Skip to content
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
8 changes: 8 additions & 0 deletions cmd/yardstick/internal/report/__snapshots__/shared_test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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

---
20 changes: 20 additions & 0 deletions cmd/yardstick/internal/report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 55 additions & 9 deletions cmd/yardstick/internal/report/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import (
"fmt"
"io"
"math"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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))
}
Expand All @@ -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)
}
}

Expand All @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion cmd/yardstick/internal/report/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package report

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

Expand All @@ -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} {
Expand Down Expand Up @@ -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)
}
Expand Down
18 changes: 15 additions & 3 deletions cmd/yardstick/internal/report/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions cmd/yardstick/internal/research/research_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions cmd/yardstick/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion test/cli/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading