Files
Felis/internal/build/scan_test.go

384 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}