Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add the table format #4

Merged
merged 2 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,43 @@ $ trivy image -q -f sarif YOUR_IMAGE | trivy referrer put --subject YOUR_IMAGE
```
$ trivy image -q -f cosign-vuln YOUR_IMAGE | trivy referrer put
```

### Listing referrers
```
$ trivy referrer list localhost:5002/demo:app --type cyclonedx
Subject: localhost:5002/demo:app
Referrers:
Digest: sha256:5b0306dbbe8252a9efaa7a5177d5ae313441aa85c3d994b2183726303fe52101
Reference: localhost:5002/demo@sha256:5b0306dbbe8252a9efaa7a5177d5ae313441aa85c3d994b2183726303fe52101
MediaType: application/vnd.oci.image.manifest.v1+json
ArtifactType: application/vnd.cyclonedx+json
Annotations:
created: trivy
org.opencontainers.artifact.created: 2023-04-11T19:21:29+09:00
org.opencontainers.artifact.description: CycloneDX JSON SBOM


$ trivy referrer list localhost:5002/demo:app --format table
DIGEST TYPE ANNOTATIONS DESCRIPTION CREATED
5b0306d cyclonedx created=trivy CycloneDX JSON SBOM 22 hours ago
771989f spdx-json created=trivy SPDX JSON SBOM 22 hours ago
83542d1 sarif SARIF 18 hours ago
8b9f058 sarif SARIF 15 hours ago
```

### Getting the artifact

```
$ trivy referrer get localhost:5002/demo:app --digest 5b0306d | head
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"serialNumber": "urn:uuid:2512b7b2-ed10-4526-9b64-dc32869eb401",
"version": 1,
"metadata": {
"timestamp": "2023-04-11T10:21:29+00:00",
"tools": [
{
"vendor": "aquasecurity",

```
264 changes: 176 additions & 88 deletions list.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"text/tabwriter"
"text/template"
"time"

"github.com/fatih/color"
"github.com/google/go-containerregistry/pkg/authn"
Expand All @@ -16,109 +17,197 @@ import (
"github.com/samber/lo"
)

func fetchTargetDigest(subject string) (name.Digest, error) {
ref, err := name.ParseReference(subject)
const tableTemplate = `DIGEST TYPE ANNOTATIONS DESCRIPTION CREATED
{{- range $index, $descriptor := .Manifests }}
{{ $descriptor.Digest }} {{ $descriptor.ArtifactType }} {{ $descriptor.ShortAnnotations }} {{ $descriptor.Description }} {{ $descriptor.Created }}
{{- end }}`

const onelineTemplate = `{{- range $index, $descriptor := .Manifests }}
{{ color $descriptor.Digest "yellow" }} {{ color $descriptor.ArtifactType "cyan" }} {{ $descriptor.ShortAnnotations }}
{{- end }}`

const detailsTemplate = `Subject: {{ .Subject }}
Referrers: {{ range $index, $descriptor := .Manifests }}
Digest: {{ $descriptor.Descriptor.Digest }}
Reference: {{ $descriptor.Reference }}
MediaType: {{ $descriptor.Descriptor.MediaType }}
ArtifactType: {{ $descriptor.Descriptor.ArtifactType }}
{{- if $descriptor.Descriptor.Annotations }}
Annotations: {{ range $key, $value := $descriptor.Descriptor.Annotations }}
{{ $key }}: {{ $value }}{{ end }}
{{- end }}
{{ end }}`

type customDescriptor struct {
v1.Descriptor
registry string
repository string
}

func (d *customDescriptor) Digest() string {
s := d.Descriptor.Digest.String()
s = strings.TrimPrefix(s, "sha256:")
return s[:7]
}

func (d *customDescriptor) ArtifactType() string {
a, err := artifactTypeFromMediaType(d.Descriptor.ArtifactType)
if err != nil {
return name.Digest{}, fmt.Errorf("error parsing reference: %w", err)
return d.Descriptor.ArtifactType
}
return a.String()
}

desc, err := remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
return name.Digest{}, fmt.Errorf("error getting descriptor: %w", err)
func (d *customDescriptor) ShortAnnotations() string {
s := ""
for k, v := range d.Descriptor.Annotations {
if k != annotationKeyCreated && k != annotationKeyDescription {
s += fmt.Sprintf("%s=%s ", k, v)
}
}
return s
}

digest, err := name.NewDigest(
fmt.Sprintf("%s/%s@%s", ref.Context().RegistryStr(), ref.Context().RepositoryStr(), desc.Digest.String()),
)
func (d *customDescriptor) Description() string {
for k, v := range d.Descriptor.Annotations {
if k == annotationKeyDescription {
return v
}
}
return ""
}

func (d *customDescriptor) Created() string {
for k, v := range d.Descriptor.Annotations {
if k == annotationKeyCreated {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return ""
}
return readableDuration(time.Since(t)) + " ago"
}
}
return ""
}

func (d *customDescriptor) Reference() string {
return fmt.Sprintf("%s/%s@%s", d.registry, d.repository, d.Descriptor.Digest.String())
}

type data struct {
Index *v1.IndexManifest
Manifests []customDescriptor
Subject string
}

func NewData(im *v1.IndexManifest, subject string) data {
ref, err := name.ParseReference(subject)
if err != nil {
return name.Digest{}, fmt.Errorf("error creating digest: %w", err)
return data{}
}
myManifests := make([]customDescriptor, len(im.Manifests))
for i, m := range im.Manifests {
myManifests[i] = customDescriptor{
Descriptor: m,
registry: ref.Context().RegistryStr(),
repository: ref.Context().RepositoryStr(),
}
}
return data{
Index: im,
Manifests: myManifests,
Subject: subject,
}
}

func readableDuration(d time.Duration) string {
if seconds := int(d.Seconds()); seconds < 1 {
return "Less than a second"
} else if seconds == 1 {
return "1 second"
} else if seconds < 60 {
return fmt.Sprintf("%d seconds", seconds)
} else if minutes := int(d.Minutes()); minutes == 1 {
return "About a minute"
} else if minutes < 60 {
return fmt.Sprintf("%d minutes", minutes)
} else if hours := int(d.Hours()); hours == 1 {
return "About an hour"
} else if hours < 48 {
return fmt.Sprintf("%d hours", hours)
} else if hours < 24*7*2 {
return fmt.Sprintf("%d days", hours/24)
} else if hours < 24*30*2 {
return fmt.Sprintf("%d weeks", hours/24/7)
} else if hours < 24*365*2 {
return fmt.Sprintf("%d months", hours/24/30)
}
return fmt.Sprintf("%d years", int(d.Hours())/24/365)
}

return digest, nil
type reporter interface {
Report(w io.Writer, data data) error
}

type jsonReporter struct{}

func (jr jsonReporter) Report(w io.Writer, data data) error {
marshal, err := json.Marshal(data.Index)
if err != nil {
return fmt.Errorf("error marshaling index: %w", err)
}
_, err = w.Write(marshal)
if err != nil {
return fmt.Errorf("error writing json: %w", err)
}
return nil
}

func reportOnline(writer io.Writer, index *v1.IndexManifest, opts listOptions) error {
w := tabwriter.NewWriter(writer, 0, 0, 1, ' ', 0)
defer w.Flush()
type templateReporter struct {
template string
}

func (tr templateReporter) Report(w io.Writer, data data) error {
funcMap := template.FuncMap{
"shortDigest": func(digest v1.Hash) string {
s := digest.String()
s = strings.TrimPrefix(s, "sha256:")
return color.YellowString(s[:7])
},
"shortType": func(artifactType string) string {
a, err := artifactTypeFromMediaType(artifactType)
if err != nil {
return artifactType
}
return color.CyanString(a.String())
},
"shortAnnotation": func(annotations map[string]string) string {
s := ""
for k, v := range annotations {
if k != annotationKeyCreated && k != annotationKeyDescription {
s += fmt.Sprintf("%s=%s ", k, v)
}
"color": func(s string, c string) string {
switch c {
case "yellow":
return color.YellowString(s)
case "cyan":
return color.CyanString(s)
}
return s
},
}
t := template.Must(template.New("listTemplate").Funcs(funcMap).Parse(tr.template))
tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0)
defer tw.Flush()

tmpl := `{{- range $index, $descriptor := .Index.Manifests }}
{{ shortDigest $descriptor.Digest }} {{ shortType $descriptor.ArtifactType }} {{ shortAnnotation $descriptor.Annotations }}
{{- end }}`

t := template.Must(template.New("descriptorsTemplate").Funcs(funcMap).Parse(tmpl))
err := t.Execute(w, struct {
Index *v1.IndexManifest
Subject string
}{
Index: index,
Subject: opts.Subject,
})
if err != nil {
return fmt.Errorf("error executing template: %w", err)
if err := t.Execute(tw, data); err != nil {
return fmt.Errorf("error executing template: %w", t.Execute(w, data))
}
return nil
}

func report(writer io.Writer, index *v1.IndexManifest, targetDigest name.Digest, opts listOptions) error {
w := tabwriter.NewWriter(writer, 0, 0, 1, ' ', 0)
defer w.Flush()

tmpl := `Subject: {{ .Subject }}
{{- $registry := .Registry }}
{{- $repository := .Repository }}
Referrers: {{ range $index, $descriptor := .Index.Manifests }}
Digest: {{ $descriptor.Digest }}
Reference: {{ $registry }}/{{ $repository }}@{{ $descriptor.Digest }}
MediaType: {{ $descriptor.MediaType }}
ArtifactType: {{ $descriptor.ArtifactType }}
{{- if $descriptor.Annotations }}
Annotations: {{ range $key, $value := $descriptor.Annotations }}
{{ $key }}: {{ $value }}{{ end }}
{{- end }}
{{ end }}`
func fetchTargetDigest(subject string) (name.Digest, error) {
ref, err := name.ParseReference(subject)
if err != nil {
return name.Digest{}, fmt.Errorf("error parsing reference: %w", err)
}

t := template.Must(template.New("descriptorsTemplate").Parse(tmpl))
err := t.Execute(w, struct {
Index *v1.IndexManifest
Subject string
Registry string
Repository string
}{
Index: index,
Subject: opts.Subject,
Registry: targetDigest.RegistryStr(),
Repository: targetDigest.RepositoryStr(),
})
desc, err := remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
return name.Digest{}, fmt.Errorf("error getting descriptor: %w", err)
}

digest, err := name.NewDigest(
fmt.Sprintf("%s/%s@%s", ref.Context().RegistryStr(), ref.Context().RepositoryStr(), desc.Digest.String()),
)
if err != nil {
return fmt.Errorf("error executing template: %w", err)
return name.Digest{}, fmt.Errorf("error creating digest: %w", err)
}

return nil
return digest, nil
}

func listReferrers(writer io.Writer, opts listOptions) error {
Expand All @@ -139,20 +228,19 @@ func listReferrers(writer io.Writer, opts listOptions) error {
}
filtered.Manifests = manifests

var re reporter
if opts.Format == "json" {
marshal, err := json.Marshal(filtered)
if err != nil {
return fmt.Errorf("error marshaling index: %w", err)
}
writer.Write(marshal)
re = jsonReporter{}
} else if opts.Format == "table" {
re = templateReporter{template: tableTemplate}
} else if opts.Format == "oneline" {
if err := reportOnline(writer, filtered, opts); err != nil {
return fmt.Errorf("error reporting online: %w", err)
}
re = templateReporter{template: onelineTemplate}
} else {
if err := report(writer, filtered, targetDigest, opts); err != nil {
return fmt.Errorf("error reporting: %w", err)
}
re = templateReporter{template: detailsTemplate}
}

if err := re.Report(writer, NewData(filtered, opts.Subject)); err != nil {
return fmt.Errorf("error reporting: %w", err)
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func main() {
},
}
listCmd.Flags().StringSliceP("filter-annotation", "", nil, "filter annotations associated with the artifact (can specify multiple or separate values with commas: key1=path1,key2=path2)")
listCmd.Flags().StringP("format", "", "", "format (json, oneline)")
listCmd.Flags().StringP("format", "", "", "format (json, oneline, table)")
listCmd.Flags().StringP("type", "", "", "artifact type (cyclonedx, spdx-json, sarif, cosign-vuln)")
listCmd.Flags().StringP("output", "o", "", "output file name")

Expand Down