diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ad5ad1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Test + run: go test ./... + - name: Race tests + run: go test -race ./... + - name: Vet + run: go vet ./... + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build container + run: docker build --tag wolfee:ci . diff --git a/Dockerfile b/Dockerfile index 1ccaab9..dcf152a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # - cdxgen : cataloguing for project-path / --bom / --reachable. # - atom / govulncheck : --reachable call-graph (degrade gracefully). -FROM golang:1.22-bookworm AS go-builder +FROM golang:1.25-bookworm AS go-builder WORKDIR /src COPY . . ARG VERSION=dev @@ -19,8 +19,8 @@ ARG COMMIT=unknown RUN CGO_ENABLED=0 go build \ -trimpath \ -ldflags "-s -w \ - -X sca-go/cli/internal/cli.Version=${VERSION} \ - -X sca-go/cli/internal/cli.Commit=${COMMIT}" \ + -X github.com/shinigamikiko/wolfee-cli/internal/cli.Version=${VERSION} \ + -X github.com/shinigamikiko/wolfee-cli/internal/cli.Commit=${COMMIT}" \ -o /out/wolfee \ ./cmd/wolfee diff --git a/Makefile b/Makefile index d474f3d..d75dd3f 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,9 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILT ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -s -w \ - -X sca-go/cli/internal/cli.Version=$(VERSION) \ - -X sca-go/cli/internal/cli.Commit=$(COMMIT) \ - -X sca-go/cli/internal/cli.Built=$(BUILT) + -X github.com/shinigamikiko/wolfee-cli/internal/cli.Version=$(VERSION) \ + -X github.com/shinigamikiko/wolfee-cli/internal/cli.Commit=$(COMMIT) \ + -X github.com/shinigamikiko/wolfee-cli/internal/cli.Built=$(BUILT) .PHONY: build test test-race vet lint fmt tidy clean docker install tools help diff --git a/README.md b/README.md index a8c2c7d..c4c83c7 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,14 @@ Feeds are cached to disk (TTL + conditional GET). Zero pre-warming needed. ### Install ```bash -# From source (requires Go 1.21+) +# Install the latest tagged CLI (requires Go 1.25+) go install github.com/shinigamikiko/wolfee-cli/cmd/wolfee@latest -# Or build locally +# Or build from source git clone https://github.com/shinigamikiko/wolfee-cli cd wolfee-cli make build # → ./bin/wolfee +export PATH="$PWD/bin:$PATH" make tools # → ./bin/trivy + ./bin/govulncheck ``` @@ -127,10 +128,49 @@ docker run --rm wolfee --image nginx:latest | `--reachable` (Go) | `govulncheck` on PATH (or `--govulncheck-bin `) | | `--reachable` (JS / Python / Java / PHP) | `atom` + `atom-parsetools` on PATH (or `--atom-bin `) | +`--image` needs `trivy`. Project and reachability scans need `cdxgen`. +Use `make docker` when you want all runtime tools bundled in one image. +Upload is best-effort by default; add `--upload-required` in CI when a +successful server upload is part of the job contract. + --- ## Usage +### Fix plan + +The `fix-plan` format first lists all findings, then groups actionable updates. +Each vulnerable package and advisory stays visible on its own compact line, +followed by the dependency paths that brought it in: + +```text +VULNERABILITIES + + PACKAGE CVE SEV FIX + qs@6.11.0 CVE-2022-24999 HIGH 6.11.2 + path-to-regexp@0.1.7 CVE-2024-45296 HIGH 0.1.8 + +REMEDIATION PLAN + +1. Upgrade express 4.18.2 -> 4.21.2 + Fixes: 3 vulnerabilities + PACKAGE CVE SEV FIX + qs@6.11.0 CVE-2022-24999 HIGH 6.11.2 + path-to-regexp@0.1.7 CVE-2024-45296 HIGH 0.1.8 + + Dependency paths: + path: app -> api -> express -> qs + path: app -> api -> express -> path-to-regexp + ... more paths; use --format json for full list +``` + +Use `--format json` to keep the normal report and receive the same grouped +data under the top-level `fixPlan` field. + +The dependency graph is always taken from cdxgen. If an exact parent upgrade +cannot be resolved by the optional remediation stage, the plan falls back to +the fixed version reported by OSV instead of parsing Composer or lockfiles. + ### Scan a container image ```bash @@ -138,6 +178,11 @@ wolfee --image nginx:1.27 wolfee --image my-app:latest --platform linux/arm64 wolfee --image my-app:latest --save-sbom my-app.cdx.json +# Group transitive vulnerabilities by the direct dependency to upgrade +wolfee scan ./my-app --format fix-plan +# JSON reports include the same grouped data under "fixPlan" +wolfee scan ./my-app --format json --output report.json + # Tell base-image packages apart from your own (see below) wolfee --image my-app:latest --scout diff --git a/cmd/listplatforms/main.go b/cmd/listplatforms/main.go index 9e3aff4..26612ec 100644 --- a/cmd/listplatforms/main.go +++ b/cmd/listplatforms/main.go @@ -3,9 +3,9 @@ package main import ( "context" "fmt" + "github.com/shinigamikiko/wolfee-cli/internal/trivydb" "net/http" "os" - "sca-go/cli/internal/trivydb" ) func main() { diff --git a/cmd/wolfee/main.go b/cmd/wolfee/main.go index a132144..23ecb6d 100644 --- a/cmd/wolfee/main.go +++ b/cmd/wolfee/main.go @@ -8,7 +8,7 @@ import ( "os/signal" "syscall" - "sca-go/cli/internal/cli" + "github.com/shinigamikiko/wolfee-cli/internal/cli" ) type exitCoder interface{ ExitCode() int } diff --git a/go.mod b/go.mod index b2b9eb0..347284a 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module sca-go/cli +module github.com/shinigamikiko/wolfee-cli go 1.25.0 diff --git a/internal/cdxgen/cdxgen.go b/internal/cdxgen/cdxgen.go index bb31788..f591c1b 100644 --- a/internal/cdxgen/cdxgen.go +++ b/internal/cdxgen/cdxgen.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type Options struct { @@ -167,9 +167,8 @@ func GenerateImageSBOM(ctx context.Context, o Options) ([]byte, error) { } if o.SaveTo != "" { - - if err := saveCopy(o.SaveTo, bom); err != nil && o.Logger != nil { - o.Logger.Warn("could not save SBOM to %s: %v", o.SaveTo, err) + if err := saveCopy(o.SaveTo, bom); err != nil { + return nil, fmt.Errorf("cdxgen: save SBOM: %w", err) } } return bom, nil @@ -177,7 +176,9 @@ func GenerateImageSBOM(ctx context.Context, o Options) ([]byte, error) { func saveCopy(dst string, data []byte) error { if dir := filepath.Dir(dst); dir != "" { - _ = os.MkdirAll(dir, 0o755) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } } - return os.WriteFile(dst, data, 0o644) + return os.WriteFile(dst, data, 0o600) } diff --git a/internal/cli/failon.go b/internal/cli/failon.go index c13c5b6..0363195 100644 --- a/internal/cli/failon.go +++ b/internal/cli/failon.go @@ -1,7 +1,7 @@ package cli import ( - "sca-go/cli/internal/sbomscan" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan" "strings" ) diff --git a/internal/cli/scan.go b/internal/cli/scan.go index c5ebec0..188271c 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -9,11 +9,11 @@ import ( "path/filepath" "strings" - "sca-go/cli/internal/cdxgen" - "sca-go/cli/internal/output" - "sca-go/cli/internal/reachability" - "sca-go/cli/internal/sbomscan" - "sca-go/cli/internal/upload" + "github.com/shinigamikiko/wolfee-cli/internal/cdxgen" + "github.com/shinigamikiko/wolfee-cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan" + "github.com/shinigamikiko/wolfee-cli/internal/upload" ) type scanOpts struct { @@ -42,9 +42,10 @@ type scanOpts struct { quiet bool debug bool - server string - token string - project string + server string + token string + project string + uploadRequired bool reachable string govulncheckBin string @@ -213,6 +214,9 @@ func runScan(ctx context.Context, args []string) error { if len(payload) == 0 { logger.Warn("server upload skipped: nothing to upload for this input mode") + if o.uploadRequired { + return errors.New("upload required: nothing to upload for this input mode") + } } else { logger.Step(fmt.Sprintf("Uploading %s to %s (project=%s)", uploading, o.server, o.project)) if err := upload.SendBOM(ctx, upload.Params{ @@ -223,6 +227,9 @@ func runScan(ctx context.Context, args []string) error { Logger: logger, }); err != nil { logger.Warn("upload failed: %v", err) + if o.uploadRequired { + return fmt.Errorf("upload required: %w", err) + } } } } @@ -288,11 +295,13 @@ func writeReport(o *scanOpts, report *sbomscan.Report) error { renderer = output.JSON{} case "sarif": renderer = output.SARIF{} + case "fix-plan": + renderer = output.FixPlan{NoColor: o.outFile != ""} default: renderer = output.Table{NoColor: o.outFile != ""} } if o.outFile != "" { - f, err := os.Create(o.outFile) + f, err := os.OpenFile(o.outFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return err } diff --git a/internal/cli/scan_flags.go b/internal/cli/scan_flags.go index b3fac60..8729bc1 100644 --- a/internal/cli/scan_flags.go +++ b/internal/cli/scan_flags.go @@ -70,7 +70,7 @@ SBOM SAVE (filesystem + image modes): a second "trivy image --format cyclonedx" run) OUTPUT: - --format FMT table | json | sarif (default: table) + --format FMT table | json | sarif | fix-plan (default: table) --output PATH Write report to file instead of stdout --fail-on LEVEL Exit non-zero if a finding >= LEVEL exists: none|low|medium|high|critical (default: none - never fail on findings) @@ -80,6 +80,7 @@ SERVER UPLOAD (optional - fire-and-forget alongside local output): --server URL Wolfee server URL (e.g. https://wolfee.example.com) --token TOKEN API token (or pass via WOLFEE_TOKEN env var) --project NAME Project name or UUID (auto-create if missing) + --upload-required Fail the scan when server upload fails SCAN: --concurrency N Parallel OSV queries (default: 16) @@ -95,6 +96,8 @@ EXAMPLES: wolfee scan --image my-app:latest --compare ./src wolfee scan --image my-app:latest --fail-on high wolfee scan --image my-app:latest --format sarif --output report.sarif + wolfee scan ./my-app --format fix-plan + wolfee scan ./my-app --format json wolfee scan --bom existing.cdx.json --format sarif --output report.sarif wolfee scan --purl pkg:npm/ngx-bootstrap@20.0.4 wolfee scan --reachable ./my-go-service --fail-on high @@ -135,6 +138,7 @@ func parseScanFlags(args []string) (*scanOpts, error) { fs.StringVar(&o.server, "server", "", "Wolfee server URL") fs.StringVar(&o.token, "token", "", "API token (or WOLFEE_TOKEN env)") fs.StringVar(&o.project, "project", "", "project name/UUID") + fs.BoolVar(&o.uploadRequired, "upload-required", false, "fail when server upload fails") fs.IntVar(&o.concurrency, "concurrency", 0, "parallel scans (0 = auto)") fs.BoolVar(&o.trivyDBSkip, "trivy-db-skip", false, "disable Trivy DB stage") fs.StringVar(&o.trivyDBMirror, "trivy-db-mirror", "", "custom OCI registry host for trivy-db (default: ghcr.io)") @@ -183,9 +187,9 @@ func (o *scanOpts) validate() error { return errors.New("--image, --bom, --purl and a project path are mutually exclusive") } switch strings.ToLower(o.format) { - case "", "table", "json", "sarif": + case "", "table", "json", "sarif", "fix-plan": default: - return fmt.Errorf("unsupported --format %q (table|json|sarif)", o.format) + return fmt.Errorf("unsupported --format %q (table|json|sarif|fix-plan)", o.format) } switch strings.ToLower(o.failOn) { case "", "none", "low", "medium", "high", "critical": @@ -195,6 +199,9 @@ func (o *scanOpts) validate() error { if o.server != "" && o.project == "" { return errors.New("--server requires --project") } + if o.uploadRequired && o.server == "" { + return errors.New("--upload-required requires --server") + } if o.scout && o.image == "" { return errors.New("--scout only applies to --image mode (it attributes image layers base-vs-app)") } diff --git a/internal/cli/scan_test.go b/internal/cli/scan_test.go index 66b8844..49ed491 100644 --- a/internal/cli/scan_test.go +++ b/internal/cli/scan_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/sbomscan" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan" ) func TestScanOpts_ValidateRequiresOneInput(t *testing.T) { diff --git a/internal/cli/version.go b/internal/cli/version.go index 6d8715b..6a64e13 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -5,7 +5,7 @@ import ( "fmt" "runtime" - "sca-go/cli/internal/sbomscan" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan" ) var ( diff --git a/internal/onlinescan/dla.go b/internal/onlinescan/dla.go index 2e9d476..8318da8 100644 --- a/internal/onlinescan/dla.go +++ b/internal/onlinescan/dla.go @@ -10,7 +10,7 @@ import ( "sync" "time" - "sca-go/cli/internal/onlinescan/feedcache" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan/feedcache" ) const ( diff --git a/internal/onlinescan/enrich.go b/internal/onlinescan/enrich.go index ad6f0c6..4ad2b66 100644 --- a/internal/onlinescan/enrich.go +++ b/internal/onlinescan/enrich.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "sca-go/cli/internal/onlinescan/feedcache" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan/feedcache" ) func Enrich(ctx context.Context, results []*ComponentResult, o Options) error { diff --git a/internal/onlinescan/kev.go b/internal/onlinescan/kev.go index 03131ba..7579c8d 100644 --- a/internal/onlinescan/kev.go +++ b/internal/onlinescan/kev.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "sca-go/cli/internal/onlinescan/feedcache" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan/feedcache" ) const kevURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" diff --git a/internal/onlinescan/scan.go b/internal/onlinescan/scan.go index 9952749..9a5365b 100644 --- a/internal/onlinescan/scan.go +++ b/internal/onlinescan/scan.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "sca-go/cli/internal/onlinescan/feedcache" - "sca-go/cli/internal/trivydb" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan/feedcache" + "github.com/shinigamikiko/wolfee-cli/internal/trivydb" ) type Component struct { diff --git a/internal/onlinescan/trivydb_stage.go b/internal/onlinescan/trivydb_stage.go index 4df1ad3..f6d3588 100644 --- a/internal/onlinescan/trivydb_stage.go +++ b/internal/onlinescan/trivydb_stage.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "sca-go/cli/internal/trivydb" + "github.com/shinigamikiko/wolfee-cli/internal/trivydb" ) func stageTrivyDB(results []*ComponentResult, tdb *trivydb.Reader, platform, imageFamily, imageRelease, imageCodename, imageArch string, log ProgressLogger) (processed, added, withHits int) { diff --git a/internal/output/fixplan.go b/internal/output/fixplan.go new file mode 100644 index 0000000..1159b0b --- /dev/null +++ b/internal/output/fixplan.go @@ -0,0 +1,334 @@ +package output + +import ( + "fmt" + "io" + "os" + "reflect" + "sort" + "strings" +) + +// FixPlan renders the remediation groups attached to an sbomscan.Report. +// Reflection keeps the output package independent from the scanner package. +type FixPlan struct { + NoColor bool +} + +func (f FixPlan) Render(w io.Writer, report any) error { + v := reflect.Indirect(reflect.ValueOf(report)) + if v.Kind() != reflect.Struct { + return fmt.Errorf("fix-plan: unexpected report type %T", report) + } + plan := indirectField(v, "FixPlan") + if !plan.IsValid() { + _, err := fmt.Fprintln(w, "No remediation plan available.") + return err + } + + c := newColors(!f.NoColor && os.Getenv("NO_COLOR") == "") + fmt.Fprintln(w, c.bold("FIX PLAN")) + if source := stringField(v, "Source"); source != "" { + fmt.Fprintf(w, "%s %s\n", c.bold("Source:"), source) + } + fmt.Fprintln(w) + + renderFixPlanFindings(w, c, v) + + fmt.Fprintln(w, c.bold("REMEDIATION PLAN")) + fmt.Fprintln(w) + + groups := fieldSlice(plan, "Groups") + for i := 0; i < groups.Len(); i++ { + group := groups.Index(i) + verb := "Upgrade" + if stringField(group, "Via") == "osv-fixed" { + verb = "Update" + } + fmt.Fprintf(w, "%d. %s %s", i+1, verb, c.high(stringField(group, "Direct"))) + if current := stringField(group, "CurrentVersion"); current != "" { + fmt.Fprintf(w, " %s -> %s", c.high(current), c.green(stringField(group, "FixVersion"))) + } + fmt.Fprintln(w) + + packages := fieldSlice(group, "Packages") + fixCount := 0 + for j := 0; j < packages.Len(); j++ { + fixCount += fieldSlice(packages.Index(j), "Vulnerabilities").Len() + } + label := "vulnerabilities" + if fixCount == 1 { + label = "vulnerability" + } + fmt.Fprintf(w, " %s %d %s\n", c.bold("Fixes:"), fixCount, label) + fg := &grid{} + fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS") + for j := 0; j < packages.Len(); j++ { + pkg := packages.Index(j) + name := stringField(pkg, "Package") + vulns := fieldSlice(pkg, "Vulnerabilities") + for k := 0; k < vulns.Len(); k++ { + vuln := vulns.Index(k) + fg.add(c.high(name), coloredAdvisory(c, vuln), c.sev(stringField(vuln, "Severity")), + stringFieldValue(vuln, "EPSS"), c.green(stringField(vuln, "FixVersion")), vulnFlags(c, vuln)) + } + } + fg.render(w) + fmt.Fprintln(w) + if hasAnyPaths(packages) { + fmt.Fprintln(w, c.bold("Dependency paths")) + seenPaths := map[string]bool{} + shownPaths := 0 + totalPaths := 0 + for j := 0; j < packages.Len(); j++ { + pkg := packages.Index(j) + for _, path := range stringMatrixField(pkg, "DependencyPaths") { + totalPaths++ + key := strings.Join(path, "\x00") + if seenPaths[key] || shownPaths >= maxTerminalFixPlanPaths { + continue + } + seenPaths[key] = true + shownPaths++ + fmt.Fprintf(w, " path: %s\n", c.low(strings.Join(path, " -> "))) + } + } + if totalPaths > shownPaths || anyPathTruncated(packages) { + fmt.Fprintf(w, " %s\n", c.low(fmt.Sprintf("... %d more paths; use --format json for full list", totalPaths-shownPaths))) + } + } + if note := stringField(group, "Note"); note != "" { + fmt.Fprintf(w, " Note: %s\n", note) + } + fmt.Fprintln(w) + } + + unresolved := fieldSlice(plan, "Unresolved") + if unresolved.Len() > 0 { + fmt.Fprintln(w, c.bold("UNRESOLVED REMEDIATIONS")) + fg := &grid{} + fg.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS") + for i := 0; i < unresolved.Len(); i++ { + pkg := unresolved.Index(i) + vulns := fieldSlice(pkg, "Vulnerabilities") + for j := 0; j < vulns.Len(); j++ { + vuln := vulns.Index(j) + fg.add(c.high(stringField(pkg, "Package")), coloredAdvisory(c, vuln), c.sev(stringField(vuln, "Severity")), + stringFieldValue(vuln, "EPSS"), "", vulnFlags(c, vuln)) + } + } + fg.render(w) + fmt.Fprintln(w) + if hasAnyPaths(unresolved) { + fmt.Fprintln(w, c.bold("Dependency paths")) + seenPaths := map[string]bool{} + shownPaths := 0 + totalPaths := 0 + for i := 0; i < unresolved.Len(); i++ { + pkg := unresolved.Index(i) + for _, path := range stringMatrixField(pkg, "DependencyPaths") { + totalPaths++ + key := strings.Join(path, "\x00") + if seenPaths[key] || shownPaths >= maxTerminalFixPlanPaths { + continue + } + seenPaths[key] = true + shownPaths++ + fmt.Fprintf(w, " path: %s\n", c.low(strings.Join(path, " -> "))) + } + } + if totalPaths > shownPaths || anyPathTruncated(unresolved) { + fmt.Fprintf(w, " %s\n", c.low(fmt.Sprintf("... %d more paths; use --format json for full list", totalPaths-shownPaths))) + } + } + return nil + } + return nil +} + +const maxTerminalFixPlanPaths = 10 + +type fixPlanFindingRow struct { + pkg string + id string + severity string + epss string + fix string + flags string + rank int +} + +func renderFixPlanFindings(w io.Writer, c colors, report reflect.Value) { + components := fieldSlice(report, "Components") + rows := make([]fixPlanFindingRow, 0) + for i := 0; i < components.Len(); i++ { + component := components.Index(i) + pkg := fmt.Sprintf("%s@%s", stringField(component, "Name"), stringField(component, "Version")) + vulns := fieldSlice(component, "Vulnerabilities") + for j := 0; j < vulns.Len(); j++ { + vuln := vulns.Index(j) + severity := strings.ToUpper(stringField(vuln, "Severity")) + id := stringField(vuln, "CVE") + if id == "" { + id = stringField(vuln, "ID") + } + rows = append(rows, fixPlanFindingRow{ + pkg: pkg, id: id, severity: severity, + epss: stringFieldValue(vuln, "EPSS"), + fix: firstStringField(vuln, "Fixed"), flags: vulnFlags(c, vuln), + rank: severityRank(severity), + }) + } + } + if len(rows) == 0 { + return + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].rank != rows[j].rank { + return rows[i].rank > rows[j].rank + } + if rows[i].pkg != rows[j].pkg { + return rows[i].pkg < rows[j].pkg + } + return rows[i].id < rows[j].id + }) + + fmt.Fprintf(w, "%s (%d)\n", c.bold("VULNERABILITIES"), len(rows)) + grid := &grid{} + grid.add("PACKAGE", "CVE", "SEV", "EPSS", "FIX", "FLAGS") + for _, row := range rows { + grid.add(c.high(row.pkg), colorAdvisory(c, row.id, row.severity), c.sev(row.severity), row.epss, c.green(row.fix), row.flags) + } + grid.render(w) + fmt.Fprintln(w) +} + +func severityRank(severity string) int { + switch severity { + case "CRITICAL": + return 4 + case "HIGH": + return 3 + case "MEDIUM": + return 2 + case "LOW": + return 1 + default: + return 0 + } +} + +func colorAdvisory(c colors, id, severity string) string { + switch severity { + case "CRITICAL": + return c.crit(id) + case "HIGH": + return c.high(id) + case "MEDIUM": + return c.med(id) + case "LOW": + return c.low(id) + default: + return id + } +} + +func firstStringField(v reflect.Value, name string) string { + f := v.FieldByName(name) + if !f.IsValid() || f.Kind() != reflect.Slice || f.Len() == 0 { + return "" + } + if first := f.Index(0); first.Kind() == reflect.String { + return first.String() + } + return "" +} + +func anyPathTruncated(values reflect.Value) bool { + for i := 0; i < values.Len(); i++ { + if boolField(values.Index(i), "DependencyPathsTruncated") { + return true + } + } + return false +} + +func hasAnyPaths(values reflect.Value) bool { + for i := 0; i < values.Len(); i++ { + if len(stringMatrixField(values.Index(i), "DependencyPaths")) > 0 || boolField(values.Index(i), "DependencyPathsTruncated") { + return true + } + } + return false +} + +func indirectField(v reflect.Value, name string) reflect.Value { + f := v.FieldByName(name) + if !f.IsValid() { + return reflect.Value{} + } + for f.Kind() == reflect.Pointer { + if f.IsNil() { + return reflect.Value{} + } + f = f.Elem() + } + return f +} + +func fieldSlice(v reflect.Value, name string) reflect.Value { + f := v.FieldByName(name) + if !f.IsValid() || f.Kind() != reflect.Slice { + return reflect.ValueOf([]struct{}{}) + } + return f +} + +func boolField(v reflect.Value, name string) bool { + f := v.FieldByName(name) + return f.IsValid() && f.Kind() == reflect.Bool && f.Bool() +} + +func stringFieldValue(v reflect.Value, name string) string { + f := v.FieldByName(name) + if !f.IsValid() { + return "" + } + if f.Kind() == reflect.Float32 || f.Kind() == reflect.Float64 { + if f.Float() == 0 { + return "" + } + return fmt.Sprintf("%.2f", f.Float()) + } + return stringField(v, name) +} + +func coloredAdvisory(c colors, v reflect.Value) string { + id := stringField(v, "CVE") + if id == "" { + id = stringField(v, "ID") + } + switch strings.ToUpper(stringField(v, "Severity")) { + case "CRITICAL": + return c.crit(id) + case "HIGH": + return c.high(id) + case "MEDIUM": + return c.med(id) + case "LOW": + return c.low(id) + default: + return id + } +} + +func vulnFlags(c colors, v reflect.Value) string { + var flags []string + if boolField(v, "InKEV") { + flags = append(flags, c.high("KEV")) + } + if f := v.FieldByName("PoCs"); f.IsValid() && f.Kind() == reflect.Slice && f.Len() > 0 { + flags = append(flags, c.med("PoC")) + } + return strings.Join(flags, " ") +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index ffec03e..8de05f8 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -13,6 +13,33 @@ type tReport struct { Totals tTotals Components []tComponent } +type tFixPlanReport struct { + Source string + FixPlan *tFixPlan +} +type tFixPlan struct { + Groups []tFixGroup + Unresolved []tFixPackage +} +type tFixGroup struct { + Direct string + CurrentVersion string + FixVersion string + ChildFixed string + Note string + Packages []tFixPackage +} +type tFixPackage struct { + Package string + Vulnerabilities []tFixVulnerability + DependencyPaths [][]string +} +type tFixVulnerability struct { + ID string + CVE string + Severity string + FixVersion string +} type tTotals struct { Components, Scanned, Skipped, WithVulns, Malware, Toxic int CRITICAL, HIGH, MEDIUM, LOW int @@ -91,6 +118,42 @@ func TestJSON_Render_RoundTrips(t *testing.T) { } } +func TestFixPlan_Render_OneLinePerVulnerability(t *testing.T) { + var buf bytes.Buffer + report := tFixPlanReport{Source: "fs:./app", FixPlan: &tFixPlan{Groups: []tFixGroup{{ + Direct: "express", CurrentVersion: "4.18.2", FixVersion: "4.21.2", ChildFixed: "0.1.8", + Packages: []tFixPackage{{Package: "qs@6.11.0", Vulnerabilities: []tFixVulnerability{{CVE: "CVE-2022-24999", Severity: "HIGH", FixVersion: "0.1.8"}}, DependencyPaths: [][]string{{"app", "express", "qs"}}}}, + }}}} + if err := (FixPlan{NoColor: true}).Render(&buf, report); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, "qs@6.11.0 CVE-2022-24999 HIGH") { + t.Errorf("fix-plan output missing compact vulnerability row:\n%s", got) + } + if !strings.Contains(got, "0.1.8") { + t.Errorf("fix-plan output missing child fix version:\n%s", got) + } + if !strings.Contains(got, "path: app -> express -> qs") { + t.Errorf("fix-plan output missing dependency path:\n%s", got) + } +} + +func TestFixPlan_Render_UsesColors(t *testing.T) { + t.Setenv("NO_COLOR", "") + var buf bytes.Buffer + report := tFixPlanReport{FixPlan: &tFixPlan{Groups: []tFixGroup{{ + Direct: "express", CurrentVersion: "4.18.2", FixVersion: "4.21.2", + Packages: []tFixPackage{{Package: "qs@6.11.0", Vulnerabilities: []tFixVulnerability{{CVE: "CVE-1", Severity: "HIGH"}}}}, + }}}} + if err := (FixPlan{}).Render(&buf, report); err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "\x1b[31m") || !strings.Contains(buf.String(), "\x1b[32m") { + t.Errorf("fix-plan output is missing expected colors:\n%q", buf.String()) + } +} + func TestTable_Render_HighlightsAffectedFirst(t *testing.T) { var buf bytes.Buffer tbl := Table{NoColor: true} diff --git a/internal/reachability/atom.go b/internal/reachability/atom.go index cf87399..e26bc03 100644 --- a/internal/reachability/atom.go +++ b/internal/reachability/atom.go @@ -13,7 +13,7 @@ import ( "path/filepath" "strings" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type atomLang struct { diff --git a/internal/reachability/gomodusage.go b/internal/reachability/gomodusage.go index c85c82a..e435ed4 100644 --- a/internal/reachability/gomodusage.go +++ b/internal/reachability/gomodusage.go @@ -14,7 +14,7 @@ import ( "path/filepath" "strings" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type goListPkg struct { diff --git a/internal/reachability/govulncheck.go b/internal/reachability/govulncheck.go index 0f22046..d1ceecf 100644 --- a/internal/reachability/govulncheck.go +++ b/internal/reachability/govulncheck.go @@ -12,8 +12,8 @@ import ( "path/filepath" "strings" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type gvkMessage struct { diff --git a/internal/reachability/reachability.go b/internal/reachability/reachability.go index 84e7774..b4d5900 100644 --- a/internal/reachability/reachability.go +++ b/internal/reachability/reachability.go @@ -3,7 +3,7 @@ package reachability import ( "context" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type Options struct { diff --git a/internal/sbomscan/baseimage.go b/internal/sbomscan/baseimage.go index 667efc1..5953be9 100644 --- a/internal/sbomscan/baseimage.go +++ b/internal/sbomscan/baseimage.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "sca-go/cli/internal/trivy" + "github.com/shinigamikiko/wolfee-cli/internal/trivy" ) const ( diff --git a/internal/sbomscan/baseimage_test.go b/internal/sbomscan/baseimage_test.go index daffaf6..a56a90d 100644 --- a/internal/sbomscan/baseimage_test.go +++ b/internal/sbomscan/baseimage_test.go @@ -3,7 +3,7 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/trivy" + "github.com/shinigamikiko/wolfee-cli/internal/trivy" ) func TestPinnedRef(t *testing.T) { diff --git a/internal/sbomscan/compare.go b/internal/sbomscan/compare.go index a57d816..f50efc3 100644 --- a/internal/sbomscan/compare.go +++ b/internal/sbomscan/compare.go @@ -4,8 +4,8 @@ import ( "encoding/json" "strings" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" ) func SourceLibSet(bom []byte) map[string]bool { diff --git a/internal/sbomscan/compare_test.go b/internal/sbomscan/compare_test.go index fbf9eda..e1eb1fe 100644 --- a/internal/sbomscan/compare_test.go +++ b/internal/sbomscan/compare_test.go @@ -3,7 +3,7 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" ) func comp(purl, name, ver, scope string, vulns ...onlinescan.Vulnerability) ComponentReport { diff --git a/internal/sbomscan/depgraph.go b/internal/sbomscan/depgraph.go index 870ef72..ee5c990 100644 --- a/internal/sbomscan/depgraph.go +++ b/internal/sbomscan/depgraph.go @@ -6,7 +6,7 @@ import ( ) const ( - maxDepPaths = 25 + maxDepPaths = 100 maxDepPathLen = 32 @@ -67,7 +67,7 @@ func annotateDependencyPaths(r *Report) { if c.BOMRef == "" || roots[c.BOMRef] { continue } - refPaths := resolveDepPaths(c.BOMRef, parents, roots) + refPaths, truncated := resolveDepPaths(c.BOMRef, parents, roots) if len(refPaths) == 0 { continue } @@ -84,17 +84,18 @@ func annotateDependencyPaths(r *Report) { out = append(out, lp) } c.DependencyPaths = out + c.DependencyPathsTruncated = truncated } } -func resolveDepPaths(target string, parents map[string][]string, roots map[string]bool) [][]string { +func resolveDepPaths(target string, parents map[string][]string, roots map[string]bool) ([][]string, bool) { onStack := map[string]bool{target: true} budget := maxDepVisits - var walk func(node string, depth int) [][]string - walk = func(node string, depth int) [][]string { + var walk func(node string, depth int) ([][]string, bool) + walk = func(node string, depth int) ([][]string, bool) { if budget <= 0 || depth > maxDepPathLen { - return nil + return nil, true } budget-- @@ -110,19 +111,23 @@ func resolveDepPaths(target string, parents map[string][]string, roots map[strin } var paths [][]string + truncated := false if len(nonRoot) == 0 { paths = append(paths, []string{node}) } for _, p := range nonRoot { onStack[p] = true - for _, sub := range walk(p, depth+1) { + subPaths, subTruncated := walk(p, depth+1) + truncated = truncated || subTruncated + for _, sub := range subPaths { np := make([]string, len(sub)+1) copy(np, sub) np[len(sub)] = node paths = append(paths, np) if len(paths) >= maxDepPaths { + truncated = true break } } @@ -131,12 +136,15 @@ func resolveDepPaths(target string, parents map[string][]string, roots map[strin break } } - return paths + return paths, truncated } var out [][]string + truncated := false seen := map[string]bool{} - for _, p := range walk(target, 0) { + paths, walkTruncated := walk(target, 0) + truncated = truncated || walkTruncated + for _, p := range paths { if len(p) < 2 { continue } @@ -147,6 +155,7 @@ func resolveDepPaths(target string, parents map[string][]string, roots map[strin seen[k] = true out = append(out, p) if len(out) >= maxDepPaths { + truncated = true break } } @@ -157,7 +166,7 @@ func resolveDepPaths(target string, parents map[string][]string, roots map[strin } return strings.Join(out[i], ">") < strings.Join(out[j], ">") }) - return out + return out, truncated } func pkgLabel(name, version string) string { diff --git a/internal/sbomscan/depgraph_test.go b/internal/sbomscan/depgraph_test.go index 8edfae7..ba4013c 100644 --- a/internal/sbomscan/depgraph_test.go +++ b/internal/sbomscan/depgraph_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "sca-go/cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" ) func gnode(ref, name, ver string) ComponentReport { @@ -121,6 +121,9 @@ func TestDependencyPathsCapped(t *testing.T) { if got := pathsOf(r, "t"); len(got) != maxDepPaths { t.Errorf("t routes: got %d want capped at %d", len(got), maxDepPaths) } + if !r.Components[0].DependencyPathsTruncated { + t.Error("t routes should report truncation") + } } func TestDependencyPathsStripsOwnModule(t *testing.T) { diff --git a/internal/sbomscan/fixplan.go b/internal/sbomscan/fixplan.go new file mode 100644 index 0000000..31f2544 --- /dev/null +++ b/internal/sbomscan/fixplan.go @@ -0,0 +1,218 @@ +package sbomscan + +import ( + "sort" + "strings" + + "deps.dev/util/semver" + + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" +) + +// FixPlan is the actionable view of a report. Groups contain vulnerabilities +// fixed by the same direct dependency upgrade; unresolved keeps findings for +// which no remediation was computed. +type FixPlan struct { + Groups []FixPlanGroup `json:"groups,omitempty"` + Unresolved []FixPlanPackage `json:"unresolved,omitempty"` +} + +type FixPlanGroup struct { + Direct string `json:"direct"` + CurrentVersion string `json:"currentVersion,omitempty"` + FixVersion string `json:"fixVersion,omitempty"` + ChildFixed string `json:"childFixed,omitempty"` + Via string `json:"via,omitempty"` + Note string `json:"note,omitempty"` + Packages []FixPlanPackage `json:"packages"` +} + +type FixPlanPackage struct { + Package string `json:"package"` + PURL string `json:"purl,omitempty"` + Vulnerabilities []FixPlanVulnerability `json:"vulnerabilities"` + DependencyPaths [][]string `json:"dependencyPaths,omitempty"` + DependencyPathsTruncated bool `json:"dependencyPathsTruncated,omitempty"` +} + +type FixPlanVulnerability struct { + ID string `json:"id"` + CVE string `json:"cve,omitempty"` + Severity string `json:"severity"` + FixVersion string `json:"fixVersion,omitempty"` + InKEV bool `json:"inKev,omitempty"` + EPSS float64 `json:"epss,omitempty"` + PoCs []string `json:"pocs,omitempty"` +} + +func BuildFixPlan(r *Report) *FixPlan { + if r == nil { + return nil + } + + plan := &FixPlan{} + groups := make(map[string]*FixPlanGroup) + groupPackages := make(map[string]map[string]*FixPlanPackage) + unresolved := make(map[string]*FixPlanPackage) + seen := make(map[string]bool) + + for _, c := range r.Components { + for _, v := range c.Vulnerabilities { + id := v.ID + if id == "" { + id = v.CVE + } + if id == "" { + continue + } + vulnKey := c.PURL + "\x00" + id + rem := v.Remediation + if rem == nil && len(v.Fixed) > 0 { + // cdxgen supplies the dependency graph and OSV supplies the known + // fixed version. Do not parse a package-manager lockfile here. + rem = &onlinescan.Remediation{ + Direct: c.Name, CurrentVersion: c.Version, + FixVersion: v.Fixed[0], ChildFixed: v.Fixed[0], + Via: "osv-fixed", Note: "fixed version from OSV; parent upgrade was not resolved", + } + } + if rem != nil { + groupKey := strings.Join([]string{rem.Direct, rem.CurrentVersion, rem.Via}, "\x00") + group := groups[groupKey] + if group == nil { + group = &FixPlanGroup{ + Direct: rem.Direct, CurrentVersion: rem.CurrentVersion, + FixVersion: rem.FixVersion, ChildFixed: rem.ChildFixed, + Via: rem.Via, Note: rem.Note, + } + groups[groupKey] = group + groupPackages[groupKey] = make(map[string]*FixPlanPackage) + } else if group.FixVersion != rem.FixVersion { + group.FixVersion = maxFixVersion(group.FixVersion, rem.FixVersion) + group.ChildFixed = group.FixVersion + } + pkgKey := c.PURL + if pkgKey == "" { + pkgKey = c.Name + "\x00" + c.Version + } + pkg := groupPackages[groupKey][pkgKey] + if pkg == nil { + pkg = &FixPlanPackage{Package: pkgLabel(c.Name, c.Version), PURL: c.PURL, + DependencyPaths: c.DependencyPaths, DependencyPathsTruncated: c.DependencyPathsTruncated} + groupPackages[groupKey][pkgKey] = pkg + group.Packages = append(group.Packages, *pkg) + } + mergeFixPlanPaths(pkg, c) + if !seen[vulnKey+"\x00"+groupKey] { + seen[vulnKey+"\x00"+groupKey] = true + pkg.Vulnerabilities = append(pkg.Vulnerabilities, fixPlanVulnerability(v, rem.FixVersion)) + } + continue + } + + if seen[vulnKey] { + continue + } + seen[vulnKey] = true + pkgKey := c.PURL + if pkgKey == "" { + pkgKey = c.Name + "\x00" + c.Version + } + pkg := unresolved[pkgKey] + if pkg == nil { + pkg = &FixPlanPackage{Package: pkgLabel(c.Name, c.Version), PURL: c.PURL, + DependencyPaths: c.DependencyPaths, DependencyPathsTruncated: c.DependencyPathsTruncated} + unresolved[pkgKey] = pkg + } + mergeFixPlanPaths(pkg, c) + pkg.Vulnerabilities = append(pkg.Vulnerabilities, fixPlanVulnerability(v, "")) + } + } + + for _, group := range groups { + for i := range group.Packages { + key := group.Packages[i].PURL + if key == "" { + key = group.Packages[i].Package + } + if pkg := groupPackages[fixPlanGroupKey(*group)][key]; pkg != nil { + group.Packages[i] = *pkg + } + } + sortFixPlanPackages(group.Packages) + plan.Groups = append(plan.Groups, *group) + } + for _, pkg := range unresolved { + if len(pkg.Vulnerabilities) > 0 { + sortFixPlanVulnerabilities(pkg.Vulnerabilities) + plan.Unresolved = append(plan.Unresolved, *pkg) + } + } + + sort.Slice(plan.Groups, func(i, j int) bool { return plan.Groups[i].Direct < plan.Groups[j].Direct }) + sortFixPlanPackages(plan.Unresolved) + if len(plan.Groups) == 0 && len(plan.Unresolved) == 0 { + return nil + } + return plan +} + +func fixPlanGroupKey(g FixPlanGroup) string { + return strings.Join([]string{g.Direct, g.CurrentVersion, g.Via}, "\x00") +} + +func fixPlanVulnerability(v onlinescan.Vulnerability, fixVersion string) FixPlanVulnerability { + return FixPlanVulnerability{ID: v.ID, CVE: v.CVE, Severity: v.Severity, FixVersion: fixVersion, InKEV: v.InKEV, EPSS: v.EPSS, PoCs: v.PoCs} +} + +func maxFixVersion(left, right string) string { + if left == "" { + return right + } + if right == "" { + return left + } + leftVersion := strings.TrimPrefix(left, "v") + rightVersion := strings.TrimPrefix(right, "v") + if _, leftErr := semver.NPM.Parse(leftVersion); leftErr == nil { + if _, rightErr := semver.NPM.Parse(rightVersion); rightErr == nil && semver.NPM.Compare(rightVersion, leftVersion) > 0 { + return right + } + } + return left +} + +func mergeFixPlanPaths(pkg *FixPlanPackage, c ComponentReport) { + pkg.DependencyPathsTruncated = pkg.DependencyPathsTruncated || c.DependencyPathsTruncated + seen := make(map[string]bool, len(pkg.DependencyPaths)) + for _, path := range pkg.DependencyPaths { + seen[strings.Join(path, "\x00")] = true + } + for _, path := range c.DependencyPaths { + key := strings.Join(path, "\x00") + if !seen[key] { + pkg.DependencyPaths = append(pkg.DependencyPaths, path) + seen[key] = true + } + } +} + +func sortFixPlanPackages(packages []FixPlanPackage) { + sort.Slice(packages, func(i, j int) bool { return packages[i].Package < packages[j].Package }) + for i := range packages { + sortFixPlanVulnerabilities(packages[i].Vulnerabilities) + } +} + +func sortFixPlanVulnerabilities(vulns []FixPlanVulnerability) { + sort.Slice(vulns, func(i, j int) bool { + left, right := vulns[i].CVE, vulns[j].CVE + if left == "" { + left = vulns[i].ID + } + if right == "" { + right = vulns[j].ID + } + return left < right + }) +} diff --git a/internal/sbomscan/fixplan_test.go b/internal/sbomscan/fixplan_test.go new file mode 100644 index 0000000..f76b185 --- /dev/null +++ b/internal/sbomscan/fixplan_test.go @@ -0,0 +1,64 @@ +package sbomscan + +import ( + "testing" + + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" +) + +func TestBuildFixPlanGroupsPackagesAndKeepsUnresolved(t *testing.T) { + rem := &onlinescan.Remediation{Direct: "express", CurrentVersion: "4.18.2", FixVersion: "4.21.2", Via: "parent-bump"} + r := &Report{Components: []ComponentReport{ + { + PURL: "pkg:npm/qs@6.11.0", Name: "qs", Version: "6.11.0", + DependencyPaths: [][]string{{"app", "express", "qs"}}, + Vulnerabilities: []onlinescan.Vulnerability{{ID: "GHSA-qs", CVE: "CVE-2022-24999", Severity: onlinescan.SevHigh, Remediation: rem}}, + }, + { + PURL: "pkg:npm/path-to-regexp@0.1.7", Name: "path-to-regexp", Version: "0.1.7", + DependencyPaths: [][]string{{"app", "express", "path-to-regexp"}}, + Vulnerabilities: []onlinescan.Vulnerability{{ID: "CVE-2024-45296", Severity: onlinescan.SevHigh, Remediation: rem}}, + }, + { + PURL: "pkg:npm/left-pad@1.3.0", Name: "left-pad", Version: "1.3.0", + Vulnerabilities: []onlinescan.Vulnerability{{ID: "CVE-UNRESOLVED", Severity: onlinescan.SevMedium}}, + }, + { + PURL: "pkg:composer/symfony/cache@v5.4.3", Name: "cache", Version: "v5.4.3", + DependencyPaths: [][]string{{"app", "cache"}}, + Vulnerabilities: []onlinescan.Vulnerability{{ID: "CVE-OSV-FIXED", Severity: onlinescan.SevHigh, Fixed: []string{"v5.4.45"}}}, + }, + }} + + plan := BuildFixPlan(r) + if plan == nil || len(plan.Groups) != 2 { + t.Fatalf("plan groups = %#v, want two groups", plan) + } + var expressGroup *FixPlanGroup + for i := range plan.Groups { + if plan.Groups[i].Direct == "express" { + expressGroup = &plan.Groups[i] + } + } + if expressGroup == nil { + t.Fatalf("express group missing: %#v", plan.Groups) + } + if got := len(expressGroup.Packages); got != 2 { + t.Fatalf("group packages = %d, want 2", got) + } + if got := len(expressGroup.Packages[0].Vulnerabilities); got != 1 { + t.Errorf("first package vulnerabilities = %d, want 1", got) + } + if len(plan.Unresolved) != 1 || plan.Unresolved[0].Package != "left-pad@1.3.0" { + t.Errorf("unresolved = %#v", plan.Unresolved) + } + var foundFallback bool + for _, group := range plan.Groups { + if group.Direct == "cache" && group.FixVersion == "v5.4.45" && group.Via == "osv-fixed" { + foundFallback = true + } + } + if !foundFallback { + t.Errorf("missing OSV fallback group: %#v", plan.Groups) + } +} diff --git a/internal/sbomscan/image.go b/internal/sbomscan/image.go index dd35ef2..4440323 100644 --- a/internal/sbomscan/image.go +++ b/internal/sbomscan/image.go @@ -6,10 +6,10 @@ import ( "strings" "time" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/output" - "sca-go/cli/internal/reachability" - "sca-go/cli/internal/trivy" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/trivy" ) type ImageOptions struct { diff --git a/internal/sbomscan/image_test.go b/internal/sbomscan/image_test.go index 80c72be..5d0dfcc 100644 --- a/internal/sbomscan/image_test.go +++ b/internal/sbomscan/image_test.go @@ -3,8 +3,8 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/reachability" - "sca-go/cli/internal/trivy" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/trivy" ) func sampleTrivyReport() *trivy.Report { diff --git a/internal/sbomscan/language_relevance.go b/internal/sbomscan/language_relevance.go index 6656afa..726dda7 100644 --- a/internal/sbomscan/language_relevance.go +++ b/internal/sbomscan/language_relevance.go @@ -3,7 +3,7 @@ package sbomscan import ( "strings" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" ) const ( diff --git a/internal/sbomscan/language_relevance_test.go b/internal/sbomscan/language_relevance_test.go index 07e394f..4a2a2bd 100644 --- a/internal/sbomscan/language_relevance_test.go +++ b/internal/sbomscan/language_relevance_test.go @@ -3,7 +3,7 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" ) func TestApplyLanguageRelevance_UsedGoModule(t *testing.T) { diff --git a/internal/sbomscan/reachability_join.go b/internal/sbomscan/reachability_join.go index 97518fc..8cc35bc 100644 --- a/internal/sbomscan/reachability_join.go +++ b/internal/sbomscan/reachability_join.go @@ -3,8 +3,8 @@ package sbomscan import ( "context" "fmt" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" "strings" ) diff --git a/internal/sbomscan/reachability_join_test.go b/internal/sbomscan/reachability_join_test.go index 2e15af2..cb6ae6b 100644 --- a/internal/sbomscan/reachability_join_test.go +++ b/internal/sbomscan/reachability_join_test.go @@ -3,8 +3,8 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" ) func TestGoModulePath(t *testing.T) { diff --git a/internal/sbomscan/remediate.go b/internal/sbomscan/remediate.go index eff6004..cbc8522 100644 --- a/internal/sbomscan/remediate.go +++ b/internal/sbomscan/remediate.go @@ -11,9 +11,9 @@ import ( "deps.dev/util/semver" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/output" - "sca-go/cli/internal/sbomscan/internal/purl" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan/internal/purl" ) const ( diff --git a/internal/sbomscan/remediate_test.go b/internal/sbomscan/remediate_test.go index 10a3a9a..ec4a285 100644 --- a/internal/sbomscan/remediate_test.go +++ b/internal/sbomscan/remediate_test.go @@ -5,7 +5,7 @@ import ( "deps.dev/util/semver" - "sca-go/cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" ) func TestDepsDevTarget(t *testing.T) { diff --git a/internal/sbomscan/report_types.go b/internal/sbomscan/report_types.go index d61a12f..0a55708 100644 --- a/internal/sbomscan/report_types.go +++ b/internal/sbomscan/report_types.go @@ -1,6 +1,6 @@ package sbomscan -import "sca-go/cli/internal/onlinescan" +import "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" type Report struct { Generator string `json:"generator,omitempty"` @@ -9,6 +9,7 @@ type Report struct { OS *ReportOS `json:"os,omitempty"` Totals Totals `json:"totals"` Document *Document `json:"document,omitempty"` + FixPlan *FixPlan `json:"fixPlan,omitempty"` Dependencies []Dependency `json:"dependencies,omitempty"` Annotations []Annotation `json:"annotations,omitempty"` Components []ComponentReport `json:"components"` @@ -191,7 +192,8 @@ type ComponentReport struct { IntroducedBy string `json:"introducedBy,omitempty"` - DependencyPaths [][]string `json:"dependencyPaths,omitempty"` + DependencyPaths [][]string `json:"dependencyPaths,omitempty"` + DependencyPathsTruncated bool `json:"dependencyPathsTruncated,omitempty"` Origin string `json:"origin,omitempty"` diff --git a/internal/sbomscan/sbomscan.go b/internal/sbomscan/sbomscan.go index 3bbe44b..1d79657 100644 --- a/internal/sbomscan/sbomscan.go +++ b/internal/sbomscan/sbomscan.go @@ -8,10 +8,10 @@ import ( "strings" "time" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/output" - "sca-go/cli/internal/reachability" - "sca-go/cli/internal/sbomscan/internal/purl" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/sbomscan/internal/purl" ) type Options struct { @@ -292,6 +292,7 @@ func ScanBOM(ctx context.Context, o Options) (*Report, error) { annotateDependencyPaths(r) annotateRemediations(ctx, r, onlinescan.DefaultHTTPClient(), o.Logger) + r.FixPlan = BuildFixPlan(r) seenCVE := map[string]struct{}{} for _, c := range r.Components { diff --git a/internal/sbomscan/version_filter_test.go b/internal/sbomscan/version_filter_test.go index 17e812b..ab2992f 100644 --- a/internal/sbomscan/version_filter_test.go +++ b/internal/sbomscan/version_filter_test.go @@ -3,7 +3,7 @@ package sbomscan import ( "testing" - "sca-go/cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" ) func TestFilterVulnsByVersion(t *testing.T) { diff --git a/internal/sbomscan/vulns.go b/internal/sbomscan/vulns.go index 450ea40..de14b92 100644 --- a/internal/sbomscan/vulns.go +++ b/internal/sbomscan/vulns.go @@ -2,8 +2,8 @@ package sbomscan import ( "fmt" - "sca-go/cli/internal/onlinescan" - "sca-go/cli/internal/reachability" + "github.com/shinigamikiko/wolfee-cli/internal/onlinescan" + "github.com/shinigamikiko/wolfee-cli/internal/reachability" "strings" ) diff --git a/internal/trivy/trivy.go b/internal/trivy/trivy.go index 2f3253b..f1c066a 100644 --- a/internal/trivy/trivy.go +++ b/internal/trivy/trivy.go @@ -9,7 +9,7 @@ import ( "os/exec" "strings" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type Options struct { @@ -207,9 +207,7 @@ func Scan(ctx context.Context, o Options) (*Report, error) { } sbomArgs = append(sbomArgs, o.commonArgs()...) if err := o.run(ctx, bin, sbomArgs); err != nil { - if o.Logger != nil { - o.Logger.Warn("could not save SBOM to %s: %v", o.SaveSBOM, err) - } + return nil, fmt.Errorf("trivy: save SBOM to %s: %w", o.SaveSBOM, err) } else if o.Logger != nil { o.Logger.Step(fmt.Sprintf("Saved CycloneDX SBOM to %s", o.SaveSBOM)) } diff --git a/internal/upload/upload.go b/internal/upload/upload.go index bf05ec6..b8a3bd9 100644 --- a/internal/upload/upload.go +++ b/internal/upload/upload.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "sca-go/cli/internal/output" + "github.com/shinigamikiko/wolfee-cli/internal/output" ) type Params struct { @@ -83,7 +83,8 @@ func SendBOM(ctx context.Context, p Params) error { var out uploadResponse if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && p.Logger != nil && out.Token != "" { - p.Logger.Step(fmt.Sprintf("Server queued job token=%s", out.Token)) + // Never expose server job tokens in CI logs or terminal output. + p.Logger.Step("Server queued the scan job") } return nil }