Skip to content

Commit

Permalink
Merge pull request #1348 from luhring/adv-ls-json
Browse files Browse the repository at this point in the history
feat(adv): add `-o json` option to `adv ls` command
  • Loading branch information
luhring authored Dec 2, 2024
2 parents fa69104 + 5cae278 commit 17c544c
Show file tree
Hide file tree
Showing 13 changed files with 122 additions and 44 deletions.
113 changes: 92 additions & 21 deletions pkg/cli/advisory_list.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cli

import (
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"time"
Expand Down Expand Up @@ -64,6 +66,12 @@ Using the --history flag, you can list advisory events instead of just
advisories' latest states. This is useful for viewing a summary of an
investigation over time for a given package/vulnerability match.'
OUTPUT FORMAT
Using the --output (-o) flag, you can select the output format used to render
the results. By default, results are rendered as a "table"; however, you can
also select "json".
COUNT
You get a count of the advisories that match the criteria by using the --count
Expand All @@ -89,12 +97,23 @@ flag. This will report just the count, not the full list of advisories.
return fmt.Errorf("invalid event type: %s", p.typ)
}

if p.outputFormat == "" {
p.outputFormat = outputFormatTable
}

if !slices.Contains(validAdvListOutputFormats, p.outputFormat) {
return fmt.Errorf(
"invalid output format %q, must be one of [%s]",
p.outputFormat,
strings.Join(validAdvListOutputFormats, ", "),
)
}

if p.advisoriesRepoDir == "" {
p.advisoriesRepoDir = "." // default to current working directory
}

advisoriesFsys := rwos.DirFS(p.advisoriesRepoDir)
advisoryCfgs, err := v2.NewIndex(cmd.Context(), advisoriesFsys)
index, err := v2.NewIndex(cmd.Context(), rwos.DirFS(p.advisoriesRepoDir))
if err != nil {
return err
}
Expand Down Expand Up @@ -141,24 +160,29 @@ flag. This will report just the count, not the full list of advisories.
updatedBefore = &ts
}

if advisoryCfgs.Select().Len() == 0 {
if index.Select().Len() == 0 {
return fmt.Errorf("no advisory data found in %q; cd to an advisories directory, or use -a flag", p.advisoriesRepoDir)
}

var cfgs []v2.Document
var docs []v2.Document
if pkg := p.packageName; pkg != "" {
cfgs = advisoryCfgs.Select().WhereName(pkg).Configurations()
docs = index.Select().WhereName(pkg).Configurations()
} else {
cfgs = advisoryCfgs.Select().Configurations()
docs = index.Select().Configurations()
}

list := advisoryListRenderer{
showHistory: p.history,
showAliases: p.showAliases,
var table *advisoryListTableRenderer
if p.outputFormat == outputFormatTable {
table = &advisoryListTableRenderer{
showHistory: p.history,
showAliases: p.showAliases,
}
}

for _, cfg := range cfgs {
for _, adv := range cfg.Advisories {
var resultDocs []v2.Document
for _, doc := range docs {
var resultAdvs []v2.Advisory
for _, adv := range doc.Advisories {
sortedEvents := adv.SortedEvents()

if len(sortedEvents) == 0 {
Expand Down Expand Up @@ -206,9 +230,16 @@ flag. This will report just the count, not the full list of advisories.

if p.history {
// user wants the full history
for i, event := range sortedEvents {
isLatest := i == len(sortedEvents)-1 // last event is the latest
list.add(cfg.Package.Name, adv.ID, adv.Aliases, event, isLatest)

switch p.outputFormat {
case outputFormatTable:
for i, event := range sortedEvents {
isLatest := i == len(sortedEvents)-1 // last event is the latest
table.add(doc.Package.Name, adv.ID, adv.Aliases, event, isLatest)
}

case outputFormatJSON:
resultAdvs = append(resultAdvs, adv)
}

continue
Expand All @@ -221,17 +252,52 @@ flag. This will report just the count, not the full list of advisories.
continue
}

list.add(cfg.Package.Name, adv.ID, adv.Aliases, latest, true)
switch p.outputFormat {
case outputFormatTable:
table.add(doc.Package.Name, adv.ID, adv.Aliases, latest, true)

case outputFormatJSON:
// Since full history wasn't requested, filter the advisory's event list to just
// the latest.
prunedAdv := v2.Advisory{
ID: adv.ID,
Aliases: adv.Aliases,
Events: []v2.Event{latest},
}
resultAdvs = append(resultAdvs, prunedAdv)
}
}

if len(resultAdvs) >= 1 {
resultDoc := v2.Document{
SchemaVersion: doc.SchemaVersion,
Package: doc.Package,
Advisories: resultAdvs,
}
resultDocs = append(resultDocs, resultDoc)
}
}

if p.count {
// Just show the count and then exit.
fmt.Printf("%d\n", list.len())
fmt.Printf("%d\n", table.len())
return nil
}

fmt.Printf("%s\n", list)
switch p.outputFormat {
case outputFormatTable:
fmt.Printf("%s\n", table)

case outputFormatJSON:
if resultDocs == nil {
resultDocs = []v2.Document{}
}

if err := json.NewEncoder(os.Stdout).Encode(resultDocs); err != nil {
return fmt.Errorf("encoding JSON: %w", err)
}
}

return nil
},
}
Expand All @@ -255,8 +321,12 @@ type listParams struct {
updatedSince string
updatedBefore string
count bool

outputFormat string
}

var validAdvListOutputFormats = []string{outputFormatTable, outputFormatJSON}

func (p *listParams) addFlagsTo(cmd *cobra.Command) {
addAdvisoriesDirFlag(&p.advisoriesRepoDir, cmd)

Expand All @@ -273,6 +343,7 @@ func (p *listParams) addFlagsTo(cmd *cobra.Command) {
cmd.Flags().StringVar(&p.updatedSince, "updated-since", "", "filter advisories updated since a given date")
cmd.Flags().StringVar(&p.updatedBefore, "updated-before", "", "filter advisories updated before a given date")
cmd.Flags().BoolVar(&p.count, "count", false, "show only the count of advisories that match the criteria")
cmd.Flags().StringVarP(&p.outputFormat, "output", "o", "", fmt.Sprintf("output format (%s), defaults to %s", strings.Join(validAdvListOutputFormats, "|"), outputFormatTable))
}

func advHasDetectedComponentType(adv v2.Advisory, componentType string) bool {
Expand All @@ -299,7 +370,7 @@ type advisoryListTableRow struct {
isLatestInHistory bool
}

type advisoryListRenderer struct {
type advisoryListTableRenderer struct {
// configuration values
showHistory bool
showAliases bool
Expand All @@ -309,11 +380,11 @@ type advisoryListRenderer struct {
currentPkg, currentAdvID string
}

func (r advisoryListRenderer) len() int {
func (r advisoryListTableRenderer) len() int {
return len(r.rows)
}

func (r *advisoryListRenderer) add(pkg, advID string, aliases []string, event v2.Event, isLatest bool) {
func (r *advisoryListTableRenderer) add(pkg, advID string, aliases []string, event v2.Event, isLatest bool) {
row := advisoryListTableRow{}

// Don't show the package name again if it's the same as for the prior row
Expand Down Expand Up @@ -342,7 +413,7 @@ func (r *advisoryListRenderer) add(pkg, advID string, aliases []string, event v2
r.rows = append(r.rows, row)
}

func (r advisoryListRenderer) String() string {
func (r advisoryListTableRenderer) String() string {
var (
stylePkg = styles.Bold().Foreground(lipgloss.Color("#3ba0f7"))
styleAdvID = lipgloss.NewStyle().Foreground(lipgloss.Color("#bc85ff"))
Expand Down
9 changes: 5 additions & 4 deletions pkg/cli/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ import (

const (
outputFormatOutline = "outline"
outputFormatTable = "table"
outputFormatJSON = "json"
)

var validOutputFormats = []string{outputFormatOutline, outputFormatJSON}
var validScanOutputFormats = []string{outputFormatOutline, outputFormatJSON}

func cmdScan() *cobra.Command {
p := &scanParams{}
Expand Down Expand Up @@ -135,11 +136,11 @@ wolfictl scan package1 package2 --remote

// Validate inputs

if !slices.Contains(validOutputFormats, p.outputFormat) {
if !slices.Contains(validScanOutputFormats, p.outputFormat) {
return fmt.Errorf(
"invalid output format %q, must be one of [%s]",
p.outputFormat,
strings.Join(validOutputFormats, ", "),
strings.Join(validScanOutputFormats, ", "),
)
}

Expand Down Expand Up @@ -355,7 +356,7 @@ type scanParams struct {
func (p *scanParams) addFlagsTo(cmd *cobra.Command) {
cmd.Flags().BoolVar(&p.requireZeroFindings, "require-zero", false, "exit 1 if any vulnerabilities are found")
cmd.Flags().StringVar(&p.localDBFilePath, "local-file-grype-db", "", "import a local grype db file")
cmd.Flags().StringVarP(&p.outputFormat, "output", "o", "", fmt.Sprintf("output format (%s), defaults to %s", strings.Join(validOutputFormats, "|"), outputFormatOutline))
cmd.Flags().StringVarP(&p.outputFormat, "output", "o", "", fmt.Sprintf("output format (%s), defaults to %s", strings.Join(validScanOutputFormats, "|"), outputFormatOutline))
cmd.Flags().BoolVarP(&p.sbomInput, "sbom", "s", false, "treat input(s) as SBOM(s) of APK(s) instead of as actual APK(s)")
cmd.Flags().BoolVar(&p.packageBuildLogInput, "build-log", false, "treat input as a package build log file (or a directory that contains a packages.log file)")
cmd.Flags().StringVar(&p.distro, "distro", "wolfi", "distro to use during vulnerability matching")
Expand Down
6 changes: 3 additions & 3 deletions pkg/configs/advisory/v2/advisory.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import (
)

type Advisory struct {
ID string `yaml:"id"`
ID string `yaml:"id" json:"id"`

// Aliases lists any known IDs of this vulnerability in databases.
Aliases []string `yaml:"aliases,omitempty"`
Aliases []string `yaml:"aliases,omitempty" json:"aliases"`

// Events is a list of timestamped events that occurred during the investigation
// and resolution of the vulnerability.
Events []Event `yaml:"events"`
Events []Event `yaml:"events" json:"events"`
}

// IsZero returns true if the advisory has no data.
Expand Down
2 changes: 1 addition & 1 deletion pkg/configs/advisory/v2/analysis_not_planned.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import "errors"
// analyzed further by the distro maintainers.
type AnalysisNotPlanned struct {
// Note should explain why there is no plan to analyze the vulnerability match.
Note string `yaml:"note"`
Note string `yaml:"note" json:"note"`
}

// Validate returns an error if the AnalysisNotPlanned data is invalid.
Expand Down
4 changes: 2 additions & 2 deletions pkg/configs/advisory/v2/detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ var (
// detected for a distro package.
type Detection struct {
// Type is the type of detection used to identify the vulnerability match.
Type string `yaml:"type"`
Type string `yaml:"type" json:"type"`

// Data is the data associated with the detection type.
Data interface{} `yaml:"data,omitempty"`
Data interface{} `yaml:"data,omitempty" json:"data,omitempty"`
}

// Validate returns an error if the Detection data is invalid.
Expand Down
8 changes: 4 additions & 4 deletions pkg/configs/advisory/v2/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import (
const SchemaVersion = "2.0.2"

type Document struct {
SchemaVersion string `yaml:"schema-version"`
Package Package `yaml:"package"`
Advisories Advisories `yaml:"advisories,omitempty"`
SchemaVersion string `yaml:"schema-version" json:"schemaVersion"`
Package Package `yaml:"package" json:"package"`
Advisories Advisories `yaml:"advisories,omitempty" json:"advisories"`
}

func (doc Document) Name() string {
Expand Down Expand Up @@ -79,7 +79,7 @@ func decodeDocument(r io.Reader) (*Document, error) {
}

type Package struct {
Name string `yaml:"name"`
Name string `yaml:"name" json:"name"`
}

func (p Package) Validate() error {
Expand Down
6 changes: 3 additions & 3 deletions pkg/configs/advisory/v2/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ var (
// and resolution of a potential vulnerability match.
type Event struct {
// Timestamp is the time at which the event occurred.
Timestamp Timestamp `yaml:"timestamp"`
Timestamp Timestamp `yaml:"timestamp" json:"timestamp"`

// Type is a string that identifies the kind of event. This field is used to
// determine how to unmarshal the Data field.
Type string `yaml:"type"`
Type string `yaml:"type" json:"type"`

// Data is the event-specific data. The type of this field is determined by the
// Type field.
Data interface{} `yaml:"data,omitempty"`
Data interface{} `yaml:"data,omitempty" json:"data,omitempty"`
}

type partialEvent struct {
Expand Down
4 changes: 2 additions & 2 deletions pkg/configs/advisory/v2/false_positive_determination.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ var FPTypes = []string{
// FalsePositiveDetermination is an event that indicates that a previously
// detected vulnerability was determined to be a false positive.
type FalsePositiveDetermination struct {
Type string `yaml:"type"`
Note string `yaml:"note,omitempty"`
Type string `yaml:"type" json:"type"`
Note string `yaml:"note,omitempty" json:"note"`
}

func (fp FalsePositiveDetermination) Validate() error {
Expand Down
2 changes: 1 addition & 1 deletion pkg/configs/advisory/v2/fix_not_planned.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "errors"
// not to receive a fix for the vulnerability.
type FixNotPlanned struct {
// Note should explain why there is no plan to fix the vulnerability.
Note string `yaml:"note"`
Note string `yaml:"note" json:"note"`
}

// Validate returns an error if the FixNotPlanned data is invalid.
Expand Down
2 changes: 1 addition & 1 deletion pkg/configs/advisory/v2/fixed.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
type Fixed struct {
// FixedVersion is the version of the distribution package that contains
// the fix to the vulnerability.
FixedVersion string `yaml:"fixed-version"`
FixedVersion string `yaml:"fixed-version" json:"fixedVersion"`
}

// Validate returns an error if the Fixed data is invalid.
Expand Down
2 changes: 1 addition & 1 deletion pkg/configs/advisory/v2/pending_upstream_fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import "errors"
// changes that should be managed by the upstream maintainers.
type PendingUpstreamFix struct {
// Note should explain why an upstream fix is anticipated or necessary.
Note string `yaml:"note"`
Note string `yaml:"note" json:"note"`
}

// Validate returns an error if the PendingUpstreamFix data is invalid.
Expand Down
6 changes: 6 additions & 0 deletions pkg/configs/advisory/v2/timestamp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package v2

import (
"encoding/json"
"fmt"
"time"

Expand All @@ -17,6 +18,11 @@ func Now() Timestamp {

const yamlTagTimestamp = "!!timestamp" // see https://yaml.org/type/timestamp.html

// MarshalJSON implements json.Marshaler.
func (t Timestamp) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}

// MarshalYAML implements yaml.Marshaler.
func (t Timestamp) MarshalYAML() (interface{}, error) {
return yaml.Node{
Expand Down
2 changes: 1 addition & 1 deletion pkg/configs/advisory/v2/true_positive_determination.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ package v2
// TruePositiveDetermination is an event that indicates that a previously
// detected vulnerability was acknowledged to be a true positive.
type TruePositiveDetermination struct {
Note string `yaml:"note,omitempty"`
Note string `yaml:"note,omitempty" json:"note"`
}

0 comments on commit 17c544c

Please sign in to comment.