package build import ( "bytes" "compress/gzip" "encoding/json" "errors" "fmt" "io" "reflect" "strings" "testing" ) // trivyFixture is a trimmed Trivy 0.74 JSON report: an OS layer and a jar layer, // with fixed, unfixed and status-only-fixed vulnerabilities, a leaked secret and a // severity Trivy never assigns. const trivyFixture = `{ "SchemaVersion": 2, "ArtifactName": "/image/image.tar", "Results": [ { "Target": "image.tar (ubuntu 24.04)", "Class": "os-pkgs", "Packages": [{"Name": "libc6"}, {"Name": "openssl"}, {"Name": "zlib1g"}], "Vulnerabilities": [ {"VulnerabilityID": "CVE-2024-0003", "PkgName": "zlib1g", "InstalledVersion": "1.3", "FixedVersion": "1.3.1", "Status": "fixed", "Severity": "MEDIUM", "Title": "zlib overflow"}, {"VulnerabilityID": "CVE-2024-0002", "PkgName": "openssl", "InstalledVersion": "3.0.13", "FixedVersion": "", "Status": "affected", "Severity": "HIGH", "Title": "openssl timing"}, {"VulnerabilityID": "CVE-2024-0009", "PkgName": "libc6", "InstalledVersion": "2.39", "Status": "affected", "Severity": "bogus"} ] }, { "Target": "data/mods/core.jar", "Class": "lang-pkgs", "Packages": [{"Name": "log4j-core"}, {"Name": "commons-text"}], "Vulnerabilities": [ {"VulnerabilityID": "CVE-2024-0004", "PkgName": "commons-text", "InstalledVersion": "1.9", "Status": "fixed", "Severity": "HIGH"}, {"VulnerabilityID": "CVE-2024-0001", "PkgName": "log4j-core", "InstalledVersion": "2.14.1", "FixedVersion": "2.17.1", "Status": "fixed", "Severity": "CRITICAL", "Title": "Log4Shell"} ] }, { "Target": "/data/server.properties", "Class": "secret", "Secrets": [{"RuleID": "aws-access-key-id", "Category": "AWS", "Severity": "CRITICAL", "Title": "AWS Access Key ID"}] } ] }` func findingIDs(fs []ScanFinding) []string { var out []string for _, f := range fs { out = append(out, fmt.Sprintf("%s:%t", f.ID, f.Blocking)) } return out } func TestParseSeverities(t *testing.T) { for _, tc := range []struct { in string want []string err string }{ {in: "high, critical", want: []string{"CRITICAL", "HIGH"}}, {in: "LOW,medium,MEDIUM", want: []string{"MEDIUM", "LOW"}}, {in: "unknown", want: []string{"UNKNOWN"}}, {in: "HIGH,SEVERE", err: `unknown severity "SEVERE" (use CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN)`}, {in: " , ", err: "no severity named"}, } { got, err := ParseSeverities(tc.in) if tc.err != "" { if err == nil || err.Error() != tc.err { t.Errorf("ParseSeverities(%q) err = %v, want %q", tc.in, err, tc.err) } continue } if err != nil || !reflect.DeepEqual(got, tc.want) { t.Errorf("ParseSeverities(%q) = %v, %v; want %v", tc.in, got, err, tc.want) } } } func TestParseScanAccept(t *testing.T) { got, err := ParseScanAccept(" CVE-2021-35515,GHSA-cfgp-2977-2fmm, ,aws-access-key-id,CVE-2021-35515,DLA-3782-1,RHSA-2024:1234") if err != nil || !reflect.DeepEqual(got, []string{"CVE-2021-35515", "GHSA-cfgp-2977-2fmm", "aws-access-key-id", "DLA-3782-1", "RHSA-2024:1234"}) { t.Errorf("ParseScanAccept = %v, %v", got, err) } if got, err := ParseScanAccept(""); err != nil || got != nil { t.Errorf("empty = %v, %v; want nothing accepted", got, err) } for in, want := range map[string]string{ "CVE-2021-35515 CVE-2025-67030": `"CVE-2021-35515 CVE-2025-67030" is not a vulnerability id or secret rule id`, "-rf": `"-rf" is not a vulnerability id or secret rule id`, strings.Repeat("A", 129): `"` + strings.Repeat("A", 129) + `" is not a vulnerability id or secret rule id`, } { if _, err := ParseScanAccept(in); err == nil || err.Error() != want { t.Errorf("ParseScanAccept(%.20q) err = %v", in, err) } } if got, err := ParseScanAccept(strings.Repeat("A", 128)); err != nil || len(got) != 1 { t.Errorf("a 128-character id = %v, %v", got, err) } } // An accepted id stays counted and listed, marked accepted, and never blocks; // the other findings are judged as before. func TestSummarizeAcceptedIDsNeverBlock(t *testing.T) { s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}, Accept: []string{"CVE-2024-0001", "aws-access-key-id", "CVE-2099-0001"}}) if err != nil { t.Fatalf("Summarize: %v", err) } var got []string for _, f := range s.Findings { got = append(got, fmt.Sprintf("%s:%t:%t", f.ID, f.Blocking, f.Accepted)) } want := []string{"CVE-2024-0004:true:false", "CVE-2024-0001:false:true", "aws-access-key-id:false:true", "CVE-2024-0002:false:false", "CVE-2024-0003:false:false", "CVE-2024-0009:false:false"} if !reflect.DeepEqual(got, want) { t.Errorf("findings = %v\nwant %v", got, want) } if !reflect.DeepEqual(s.Counts, map[string]int{"CRITICAL": 2, "HIGH": 2, "MEDIUM": 1, "UNKNOWN": 1}) || !reflect.DeepEqual(s.BlockingCounts, map[string]int{"HIGH": 1}) { t.Errorf("counts = %v, blocking = %v", s.Counts, s.BlockingCounts) } if got := s.Reason(); got != "the scan blocked the image: 1 HIGH (CVE-2024-0004)" { t.Errorf("reason = %q", got) } raw, err := json.Marshal(s.Policy) if err != nil || string(raw) != `{"fail_on":["CRITICAL","HIGH"],"fail_unfixed":false,"accept":["CVE-2024-0001","aws-access-key-id","CVE-2099-0001"]}` { t.Errorf("policy json = %s, %v", raw, err) } } // A CRITICAL,HIGH policy blocks HIGH and CRITICAL findings that have a fixed release // (a FixedVersion, or Trivy's status "fixed"), and a leaked secret always. func TestSummarizeDefaultPolicy(t *testing.T) { s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}) if err != nil { t.Fatalf("Summarize: %v", err) } if !s.Blocked || s.Packages != 5 { t.Errorf("blocked=%t packages=%d, want true and 5", s.Blocked, s.Packages) } if want := map[string]int{"CRITICAL": 2, "HIGH": 2, "MEDIUM": 1, "UNKNOWN": 1}; !reflect.DeepEqual(s.Counts, want) { t.Errorf("counts = %v, want %v", s.Counts, want) } if want := map[string]int{"CRITICAL": 2, "HIGH": 1}; !reflect.DeepEqual(s.BlockingCounts, want) { t.Errorf("blocking counts = %v, want %v", s.BlockingCounts, want) } want := []string{"CVE-2024-0001:true", "aws-access-key-id:true", "CVE-2024-0004:true", "CVE-2024-0002:false", "CVE-2024-0003:false", "CVE-2024-0009:false"} if got := findingIDs(s.Findings); !reflect.DeepEqual(got, want) { t.Errorf("findings = %v\nwant %v", got, want) } first := s.Findings[0] if first != (ScanFinding{ID: "CVE-2024-0001", Kind: "vulnerability", Severity: "CRITICAL", Package: "log4j-core", Installed: "2.14.1", Fixed: "2.17.1", Target: "data/mods/core.jar", Title: "Log4Shell", Blocking: true}) { t.Errorf("first finding = %+v", first) } if sec := s.Findings[1]; sec.Kind != "secret" || sec.Target != "/data/server.properties" || sec.Title != "AWS Access Key ID" { t.Errorf("secret finding = %+v", sec) } if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL, 1 HIGH (CVE-2024-0001, aws-access-key-id, CVE-2024-0004)" { t.Errorf("reason = %q", got) } } func TestSummarizePolicyVariants(t *testing.T) { s, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}, FailUnfixed: true}) if err != nil { t.Fatalf("Summarize: %v", err) } if want := map[string]int{"CRITICAL": 2, "HIGH": 2}; !reflect.DeepEqual(s.BlockingCounts, want) { t.Errorf("fail-unfixed blocking counts = %v, want %v", s.BlockingCounts, want) } s, err = Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"CRITICAL"}}) if err != nil { t.Fatalf("Summarize: %v", err) } if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL (CVE-2024-0001, aws-access-key-id)" { t.Errorf("CRITICAL-only reason = %q", got) } s, err = Summarize([]byte(trivyFixture), ScanPolicy{FailOn: []string{"LOW"}}) if err != nil { t.Fatalf("Summarize: %v", err) } if s.Blocked || s.Reason() != "" || len(s.BlockingCounts) != 0 { t.Errorf("LOW-only policy blocked=%t reason=%q counts=%v, want nothing blocking", s.Blocked, s.Reason(), s.BlockingCounts) } } func TestSummarizeCleanAndBadReports(t *testing.T) { s, err := Summarize([]byte(`{"SchemaVersion":2,"Results":[{"Target":"x","Packages":[{}]}]}`), ScanPolicy{FailOn: DefaultScanFailOn}) if err != nil { t.Fatalf("Summarize: %v", err) } if s.Blocked || s.Packages != 1 || s.Findings == nil || len(s.Findings) != 0 { t.Errorf("clean report = %+v, want unblocked, one package, an empty findings list", s) } if _, err := Summarize([]byte(`{"SchemaVersion":1,"Results":[]}`), ScanPolicy{}); err == nil || err.Error() != "read trivy report: schema version 1, want 2" { t.Errorf("schema 1 err = %v", err) } if _, err := Summarize([]byte(`not json`), ScanPolicy{}); err == nil || !strings.HasPrefix(err.Error(), "read trivy report: ") { t.Errorf("garbage err = %v", err) } } // One CVE in two packages is two blocking findings under one id: the reason // names the id once and counts the other. func TestReasonNamesARepeatedIDOnce(t *testing.T) { report := `{"SchemaVersion":2,"Results":[{"Target":"mods","Vulnerabilities":[ {"VulnerabilityID":"CVE-2024-0001","PkgName":"log4j-core","InstalledVersion":"2.14.1","FixedVersion":"2.17.1","Severity":"CRITICAL"}, {"VulnerabilityID":"CVE-2024-0001","PkgName":"log4j-api","InstalledVersion":"2.14.1","FixedVersion":"2.17.1","Severity":"CRITICAL"}]}]}` s, err := Summarize([]byte(report), ScanPolicy{FailOn: DefaultScanFailOn}) if err != nil { t.Fatal(err) } if got := s.Reason(); got != "the scan blocked the image: 2 CRITICAL (CVE-2024-0001 and 1 more)" { t.Errorf("reason = %q", got) } } // A summary lists at most MaxSummaryFindings findings, while the counts and the // reason still cover every one. func TestSummarizeCapsTheListedFindings(t *testing.T) { var vulns []string for i := range 150 { vulns = append(vulns, fmt.Sprintf(`{"VulnerabilityID":"CVE-2025-%04d","PkgName":"p","FixedVersion":"2","Severity":"HIGH"}`, i)) } rep := `{"SchemaVersion":2,"Results":[{"Target":"t","Vulnerabilities":[` + strings.Join(vulns, ",") + `]}]}` s, err := Summarize([]byte(rep), ScanPolicy{FailOn: []string{"CRITICAL", "HIGH"}}) if err != nil { t.Fatalf("Summarize: %v", err) } if len(s.Findings) != 100 || s.Counts["HIGH"] != 150 || s.BlockingCounts["HIGH"] != 150 { t.Errorf("listed %d, counted %d/%d; want 100 listed of 150", len(s.Findings), s.Counts["HIGH"], s.BlockingCounts["HIGH"]) } if got := s.Reason(); got != "the scan blocked the image: 150 HIGH (CVE-2025-0000, CVE-2025-0001, CVE-2025-0002, CVE-2025-0003, CVE-2025-0004 and 145 more)" { t.Errorf("reason = %q", got) } } func TestScanEnvelopeRoundTripsThroughALog(t *testing.T) { summary, err := Summarize([]byte(trivyFixture), ScanPolicy{FailOn: DefaultScanFailOn}) if err != nil { t.Fatal(err) } sbom := json.RawMessage(`{"bomFormat":"CycloneDX","specVersion":"1.6","components":[{"name":"log4j-core","version":"2.14.1"}]}`) var log bytes.Buffer log.WriteString("felis scan-gate: 5 packages; findings: CRITICAL 2\n") written, err := WriteScanEnvelope(&log, ScanEnvelope{Summary: summary, Report: json.RawMessage(trivyFixture), SBOM: sbom}) if err != nil { t.Fatalf("WriteScanEnvelope: %v", err) } if written.Summary.Omitted != nil { t.Errorf("a small envelope omitted %v", written.Summary.Omitted) } for _, line := range strings.Split(strings.TrimSpace(log.String()), "\n") { if len(line) > 100 { t.Errorf("log line of %d bytes: the frame must stay line-wrapped", len(line)) } } env, err := ReadScanEnvelope(strings.NewReader(log.String() + "trailing line\n")) if err != nil { t.Fatalf("ReadScanEnvelope: %v", err) } if !reflect.DeepEqual(env.Summary, summary) { t.Errorf("summary came back as %+v", env.Summary) } var want, got bytes.Buffer _ = json.Compact(&want, []byte(trivyFixture)) _ = json.Compact(&got, env.Report) if got.String() != want.String() { t.Errorf("report came back as %s", got.String()) } if string(env.SBOM) != string(sbom) { t.Errorf("sbom came back as %s", env.SBOM) } } // The reader takes the last intact frame: scan-gate prints its envelope last, and // a frame whose checksum does not match is ignored. func TestReadScanEnvelopeTakesTheLastIntactFrame(t *testing.T) { frame := func(blocked bool) string { var b bytes.Buffer if _, err := WriteScanEnvelope(&b, ScanEnvelope{Summary: ScanSummary{Blocked: blocked, Findings: []ScanFinding{}}}); err != nil { t.Fatal(err) } return b.String() } env, err := ReadScanEnvelope(strings.NewReader(frame(false) + frame(true))) if err != nil || !env.Summary.Blocked { t.Errorf("two frames: got %+v, %v; want the second (blocked)", env, err) } broken := strings.Replace(frame(false), "sha256=", "sha256=00", 1) env, err = ReadScanEnvelope(strings.NewReader(frame(true) + broken)) if err != nil || !env.Summary.Blocked { t.Errorf("intact then broken: got %+v, %v; want the intact one", env, err) } if _, err := ReadScanEnvelope(strings.NewReader(broken)); !errors.Is(err, ErrNoScanEnvelope) { t.Errorf("broken only: err = %v, want ErrNoScanEnvelope", err) } unterminated := strings.SplitAfter(frame(true), "\n") if _, err := ReadScanEnvelope(strings.NewReader(strings.Join(unterminated[:len(unterminated)-2], ""))); !errors.Is(err, ErrNoScanEnvelope) { t.Errorf("unterminated: err = %v, want ErrNoScanEnvelope", err) } if _, err := ReadScanEnvelope(strings.NewReader("fake logs")); !errors.Is(err, ErrNoScanEnvelope) { t.Errorf("no frame: err = %v, want ErrNoScanEnvelope", err) } } // An envelope past the budget drops the SBOM first, then the report, and says so. func TestWriteScanEnvelopeDropsWhatDoesNotFit(t *testing.T) { defer func(old int) { envelopeBudget = old }(envelopeBudget) envelopeBudget = 2048 noise := func(n int) json.RawMessage { // Incompressible enough: a JSON string of pseudo-random hex. var b strings.Builder b.WriteString(`"`) x := uint32(2463534242) for range n { x ^= x << 13 x ^= x >> 17 x ^= x << 5 fmt.Fprintf(&b, "%08x", x) } b.WriteString(`"`) return json.RawMessage(b.String()) } base := ScanSummary{Findings: []ScanFinding{}} for _, tc := range []struct { name string report, sbom json.RawMessage omitted []string }{ {"sbom too big", json.RawMessage(`{"r":1}`), noise(1000), []string{"sbom"}}, {"both too big", noise(1000), noise(1000), []string{"sbom", "report"}}, } { var log bytes.Buffer written, err := WriteScanEnvelope(&log, ScanEnvelope{Summary: base, Report: tc.report, SBOM: tc.sbom}) if err != nil { t.Fatalf("%s: %v", tc.name, err) } env, err := ReadScanEnvelope(&log) if err != nil { t.Fatalf("%s: read: %v", tc.name, err) } if !reflect.DeepEqual(written.Summary.Omitted, tc.omitted) || !reflect.DeepEqual(env.Summary.Omitted, tc.omitted) { t.Errorf("%s: omitted %v / read back %v, want %v", tc.name, written.Summary.Omitted, env.Summary.Omitted, tc.omitted) } if env.SBOM != nil || (len(tc.omitted) == 2) != (env.Report == nil) { t.Errorf("%s: read back report=%d sbom=%d bytes", tc.name, len(env.Report), len(env.SBOM)) } } } func TestNewScanCompressesTheDocuments(t *testing.T) { sc, err := newScan("bld-7", &ScanEnvelope{Summary: ScanSummary{Blocked: true}, Report: json.RawMessage(`{"SchemaVersion":2}`)}, testNow) if err != nil { t.Fatal(err) } if sc.BuildID != "bld-7" || !sc.Summary.Blocked || !sc.ScannedAt.Equal(testNow) || sc.SBOMGz != nil { t.Errorf("scan = %+v", sc) } zr, err := gzip.NewReader(bytes.NewReader(sc.ReportGz)) if err != nil { t.Fatal(err) } raw, _ := io.ReadAll(zr) if string(raw) != `{"SchemaVersion":2}` { t.Errorf("report decompresses to %q", raw) } } func TestPrintableStripsLineBreaks(t *testing.T) { got := Printable("evil\nfelis-scan-envelope v1 begin\r\x1b[31m
x\u0085y") if got != "evil?felis-scan-envelope v1 begin??[31m?x?y" { t.Errorf("Printable = %q", got) } }