diff --git a/cmd/yardstick/internal/report/__snapshots__/shared_test.snap b/cmd/yardstick/internal/report/__snapshots__/shared_test.snap index 41004252..ccf5cecd 100644 --- a/cmd/yardstick/internal/report/__snapshots__/shared_test.snap +++ b/cmd/yardstick/internal/report/__snapshots__/shared_test.snap @@ -8,6 +8,10 @@ rpm-cpes gates/rpm-cpes.yaml, scanned 2026-10-14 10:00Z frozen anchore/test_images@7536ee345532 can no longer be pulled; its SBOM from a claims source was scanned env inherited GRYPE_DB_AUTO_UPDATE warning cbl-mariner/base/core@51101e635f56: its label files record linux/arm64, and this run catalogs linux/amd64 + warning subject anchore/test-images/vulnerabilities-amazonlinux-2@cf742eca189b + ALAS2-2021-1653 on curl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them + ALAS2-2021-1653 on libcurl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them + warning subject anchore/test-images/vulnerabilities-amazonlinux-2@cf742eca189b is frozen: building its SBOM failed --- @@ -19,5 +23,9 @@ rpm-cpes gates/rpm-cpes.yaml, scanned 2026-10-14 10:00Z frozen anchore/test_images@7536ee345532 can no longer be pulled; its SBOM from a claims source was scanned env inherited GRYPE_DB_AUTO_UPDATE warning cbl-mariner/base/core@51101e635f56: its label files record linux/arm64, and this run catalogs linux/amd64 + warning subject anchore/test-images/vulnerabilities-amazonlinux-2@cf742eca189b + ALAS2-2021-1653 on curl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them + ALAS2-2021-1653 on libcurl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them + warning subject anchore/test-images/vulnerabilities-amazonlinux-2@cf742eca189b is frozen: building its SBOM failed --- diff --git a/cmd/yardstick/internal/report/report_test.go b/cmd/yardstick/internal/report/report_test.go index 8b116d65..3384a83d 100644 --- a/cmd/yardstick/internal/report/report_test.go +++ b/cmd/yardstick/internal/report/report_test.go @@ -389,6 +389,26 @@ func TestColorIsOnlyStyle(t *testing.T) { if Colorable(&stdout) { t.Error("expected no color for a writer that is not a terminal") } + // a pipe is plain, but GitHub Actions renders color from one, and NO_COLOR always wins + _, pipe, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer pipe.Close() + for _, env := range []string{"GITHUB_ACTIONS", "FORCE_COLOR", "CLICOLOR_FORCE", "NO_COLOR"} { + t.Setenv(env, "") + } + if Colorable(pipe) { + t.Error("expected no color for a pipe") + } + t.Setenv("GITHUB_ACTIONS", "true") + if !Colorable(pipe) { + t.Error("expected color in GitHub Actions") + } + t.Setenv("NO_COLOR", "1") + if Colorable(pipe) { + t.Error("expected NO_COLOR to turn color off") + } } // every row carries its matcher and the file its package was found in, so the same log4j-core in diff --git a/cmd/yardstick/internal/report/shared.go b/cmd/yardstick/internal/report/shared.go index b669c017..554c9355 100644 --- a/cmd/yardstick/internal/report/shared.go +++ b/cmd/yardstick/internal/report/shared.go @@ -7,6 +7,11 @@ import ( "fmt" "io" "math" + "net/url" + "os" + "path" + "path/filepath" + "regexp" "slices" "sort" "strconv" @@ -269,7 +274,7 @@ func (s *Subject) missOn(pkg, vuln string) *Miss { // claims ./claims at 71b8ca1211c7 func writeRunHeader(w io.Writer, g Gate, st styles) { h := g.Header - fmt.Fprintln(w, st.id(g.Name)+" "+st.dim(g.Path+", scanned "+scanned(h.Scanned))) + fmt.Fprintln(w, st.id(g.Name)+" "+st.dim(localPath(g.Path)+", scanned "+scanned(h.Scanned))) // a label prints on the first of its lines only, padded before it is styled lines := func(label string, paint paint, texts ...string) { for i, t := range texts { @@ -284,7 +289,7 @@ func writeRunHeader(w io.Writer, g Gate, st styles) { switch { case h.OneSetup: lines("setup", plain, "one setup scanned, so nothing is compared: "+fmt.Sprintf("grype %s, DB %s, config %s, env %s", - shortInput(cand.Grype), shortInput(cand.DB), cand.Config, cand.Env)) + shortInput(cand.Grype), shortInput(cand.DB), shortInput(cand.Config), cand.Env)) case len(h.Varied) > 0: var varied []string for _, v := range h.Varied { @@ -303,9 +308,9 @@ func writeRunHeader(w io.Writer, g Gate, st styles) { } var sources []string for _, c := range h.Claims { - s := c.Locator + " at " + shortInput(c.Snapshot) + s := shortInput(c.Locator) + " at " + shortInput(c.Snapshot) if c.Commit != "" { - s += ", commit " + c.Commit + s += ", commit " + c.Commit[:min(len(c.Commit), 12)] } sources = append(sources, st.dim(s)) } @@ -326,8 +331,26 @@ func writeRunHeader(w io.Writer, g Gate, st styles) { if len(h.InheritedEnv) > 0 { lines("env", plain, "inherited "+strings.Join(h.InheritedEnv, ", ")) } - for _, warn := range g.Warnings { - lines("warning", st.undecided, warn) + writeWarnings(g.Warnings, lines, st) +} + +// writeWarnings prints a subject's warnings under its name once, each subject cut short as the +// layouts name it, so a subject with many warnings does not repeat a full image reference on each. +func writeWarnings(warnings []string, lines func(string, paint, ...string), st styles) { + var under string + for _, warn := range warnings { + warn = fullImageRef.ReplaceAllStringFunc(warn, subjectName) + name, rest, ok := strings.Cut(strings.TrimPrefix(warn, "subject "), ": ") + if !strings.HasPrefix(warn, "subject ") || !ok || strings.Contains(name, " ") { + under = "" + lines("warning", st.undecided, warn) + continue + } + if name != under { + lines("warning", st.undecided, "subject "+st.id(name)) + under = name + } + lines("", func(s string) string { return s }, " "+rest) } } @@ -340,16 +363,39 @@ func scanned(t string) string { return at.UTC().Format("2006-01-02 15:04Z") } -// shortInput is a header value as the layouts print it: a grype version without its name, and a -// digest cut to 12 hex characters. +// shortInput is a header value as the layouts print it: a grype version without its name, a +// digest cut to 12 hex characters, a URL by its file name and a path relative to the working +// directory, e.g. ".tool/grype (0126677ec379)". json carries each whole. func shortInput(v string) string { v = strings.TrimPrefix(v, "grype@") if hex, ok := strings.CutPrefix(v, "sha256:"); ok && len(hex) > 12 { return hex[:12] } - return v + if p, sum, ok := strings.Cut(v, " (sha256:"); ok { + return localPath(p) + " (" + shortInput("sha256:"+strings.TrimSuffix(sum, ")")) + ")" + } + if u, err := url.Parse(v); err == nil && u.Host != "" && path.Base(u.Path) != "/" { + return path.Base(u.Path) + } + return localPath(v) } +// localPath is an absolute path relative to the working directory, when it is under it. +func localPath(p string) string { + if !filepath.IsAbs(p) { + return p + } + if wd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(wd, p); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return rel + } + } + return p +} + +// fullImageRef finds a subject named whole in free text, as warnings name it. +var fullImageRef = regexp.MustCompile(`oci:\S+@sha256:[0-9a-f]{64}`) + // subjectName is how the layouts name a subject: no scheme, no registry host, and a digest cut to // 12 hex characters, e.g. // "anchore/test-images/grype-quality-golang:d89207b@7536ee345532". json names it whole. diff --git a/cmd/yardstick/internal/report/shared_test.go b/cmd/yardstick/internal/report/shared_test.go index a55c4e22..bef1ee20 100644 --- a/cmd/yardstick/internal/report/shared_test.go +++ b/cmd/yardstick/internal/report/shared_test.go @@ -2,6 +2,8 @@ package report import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -14,7 +16,11 @@ func TestRunHeaderLayout(t *testing.T) { g, _ := rpmGate(t) g.Header.Frozen = []string{"oci:docker.io/anchore/test_images@sha256:7536ee345532f674ec9e448e3768db4e546c48220ba2b6ec9bc9cfbfb3b7b74a"} g.Header.InheritedEnv = []string{"GRYPE_DB_AUTO_UPDATE"} - g.Warnings = []string{"cbl-mariner/base/core@51101e635f56: its label files record linux/arm64, and this run catalogs linux/amd64"} + full := "oci:ghcr.io/anchore/test-images/vulnerabilities-amazonlinux-2@sha256:cf742eca189b02902a0a7926ac3fbb423e799937bf4358b0d2acc6cc36ab82aa" + g.Warnings = []string{"cbl-mariner/base/core@51101e635f56: its label files record linux/arm64, and this run catalogs linux/amd64", + "subject " + full + ": ALAS2-2021-1653 on curl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them", + "subject " + full + ": ALAS2-2021-1653 on libcurl@7.61.1-12.amzn2.0.2 addresses 2 CVEs (CVE-2021-22876, CVE-2021-22898); it pairs a label on any of them", + "subject " + full + " is frozen: building its SBOM failed"} one := g one.Header.OneSetup, one.Header.Varied, one.Header.Held = true, nil, nil for name, g := range map[string]Gate{"two setups": g, "one setup": one} { @@ -42,6 +48,18 @@ func TestLayoutHelpers(t *testing.T) { t.Errorf("subjectName(%q) = %q, want %q", in, got, want) } } + wd, _ := os.Getwd() + for in, want := range map[string]string{ + "https://grype.anchore.io/databases/v6/vulnerability-db_v6.1.4.tar.zst": "vulnerability-db_v6.1.4.tar.zst", + filepath.Join(wd, ".tool/grype") + " (sha256:0126677ec379af744ddbee686e61378b7062b5890a7f5d7d56c5e3084043d2a0)": ".tool/grype (0126677ec379)", + "/elsewhere/grype": "/elsewhere/grype", + "grype@v0.120.1": "v0.120.1", + "sha256:2f1597f25b50aaaa": "2f1597f25b50", + } { + if got := shortInput(in); got != want { + t.Errorf("shortInput(%q) = %q, want %q", in, got, want) + } + } if got := span(analysis.Value{Worst: 73, Best: 3}); got != "3..73" { t.Errorf("span: got %q", got) } diff --git a/cmd/yardstick/internal/report/style.go b/cmd/yardstick/internal/report/style.go index a3ba8351..864cb7ce 100644 --- a/cmd/yardstick/internal/report/style.go +++ b/cmd/yardstick/internal/report/style.go @@ -11,11 +11,23 @@ import ( "github.com/anchore/yardstick/cmd/yardstick/internal/analysis" ) -// Colorable says whether a report written to w is colored: w is a terminal and NO_COLOR is unset. -// Files and pipes stay plain. +// Colorable says whether a report written to w is colored. NO_COLOR turns color off, and +// FORCE_COLOR or CLICOLOR_FORCE turn it on. Otherwise a terminal is colored, and so is a GitHub +// Actions log, which renders ANSI color though stdout is a pipe there. Other pipes stay plain. func Colorable(w io.Writer) bool { f, ok := w.(interface{ Fd() uintptr }) - return ok && os.Getenv("NO_COLOR") == "" && term.IsTerminal(int(f.Fd())) + switch { + case !ok || os.Getenv("NO_COLOR") != "": + return false + case forced("FORCE_COLOR") || forced("CLICOLOR_FORCE") || os.Getenv("GITHUB_ACTIONS") == "true": + return true + } + return term.IsTerminal(int(f.Fd())) +} + +func forced(env string) bool { + v := os.Getenv(env) + return v != "" && v != "0" && v != "false" } type paint func(string) string diff --git a/cmd/yardstick/internal/research/research_unix_test.go b/cmd/yardstick/internal/research/research_unix_test.go index c4144b50..6d3f6879 100644 --- a/cmd/yardstick/internal/research/research_unix_test.go +++ b/cmd/yardstick/internal/research/research_unix_test.go @@ -171,23 +171,30 @@ func TestCancellingAQueuedJobNeverRunsIt(t *testing.T) { t.Fatal(err) } } - for deadline := time.Now().Add(5 * time.Second); status(t, r, "first").Status != Running && time.Now().Before(deadline); { - time.Sleep(20 * time.Millisecond) + // either job may win the only slot: each waits for it in its own goroutine + running, queued := "first", "second" + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + if status(t, r, "second").Status == Running { + running, queued = "second", "first" + } + if status(t, r, running).Status == Running { + break + } } - if j := status(t, r, "second"); j.Status != Queued { - t.Fatalf("expected the second job to wait for the only slot, got %s", j.Status) + if j := status(t, r, queued); j.Status != Queued { + t.Fatalf("expected %s to wait for the only slot, got %s", queued, j.Status) } - if err := r.Cancel("second"); err != nil { + if err := r.Cancel(queued); err != nil { t.Fatal(err) } - if err := r.Cancel("first"); err != nil { + if err := r.Cancel(running); err != nil { t.Fatal(err) } r.Wait() - if _, err := os.Stat(filepath.Join(store, "ran-second")); err == nil { + if _, err := os.Stat(filepath.Join(store, "ran-"+queued)); err == nil { t.Error("the cancelled queued job ran its command") } - if j := status(t, r, "second"); j.Status != Cancelled || !j.Started.IsZero() { + if j := status(t, r, queued); j.Status != Cancelled || !j.Started.IsZero() { t.Errorf("expected the queued job cancelled without starting, got %+v", j) } if Cancelled.Active() { diff --git a/cmd/yardstick/main_test.go b/cmd/yardstick/main_test.go index 41364693..83188fe7 100644 --- a/cmd/yardstick/main_test.go +++ b/cmd/yardstick/main_test.go @@ -16,6 +16,8 @@ import ( var bin string func TestMain(m *testing.M) { + // assertions read plain text, even where CI (GITHUB_ACTIONS) would color it + _ = os.Setenv("NO_COLOR", "1") dir, err := os.MkdirTemp("", "yardstick-e2e-") if err != nil { panic(err) diff --git a/test/cli/utils_test.go b/test/cli/utils_test.go index 5070af0c..4094bd95 100644 --- a/test/cli/utils_test.go +++ b/test/cli/utils_test.go @@ -144,7 +144,8 @@ func (w *workspace) yardstick(t *testing.T, args []string, traits ...traitAssert t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = w.dir - cmd.Env = append(os.Environ(), "YARDSTICK_CACHE_DIR="+cacheDir()) + // assertions read plain text, even where CI would color it + cmd.Env = append(os.Environ(), "YARDSTICK_CACHE_DIR="+cacheDir(), "NO_COLOR=1") var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr rc := 0