diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cdfe1a..0a35a46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,22 +65,65 @@ jobs: - name: Scan release with Grype run: | - /tmp/grype dist/blastguard_*_linux_amd64.tar.gz -o json > /tmp/release-grype.json || true + set -euo pipefail + /tmp/grype dist/blastguard_*_linux_amd64.tar.gz -o json > /tmp/release-grype.json - name: Dogfood blastguard on its own release run: | + set -euo pipefail /tmp/blastguard ingest \ --sbom-cyclonedx /tmp/release-sbom.json \ --vulns-grype /tmp/release-grype.json \ --output /tmp/release-twin.json - + echo "## BlastGuard Self-Scan Report" > /tmp/dogfood-report.md echo "" >> /tmp/dogfood-report.md echo "This release was scanned with BlastGuard itself:" >> /tmp/dogfood-report.md echo "" >> /tmp/dogfood-report.md echo '```json' >> /tmp/dogfood-report.md - cat /tmp/release-twin.json | jq '{cves: (.cves | length), packages: (.artifacts[0].packages | length)}' >> /tmp/dogfood-report.md + jq '{cves: (.cves | length), packages: (.artifacts[0].packages | length), inventory_complete}' /tmp/release-twin.json >> /tmp/dogfood-report.md echo '```' >> /tmp/dogfood-report.md + echo "" >> /tmp/dogfood-report.md + echo "### Policy decisions" >> /tmp/dogfood-report.md + + # Assert policy on every CVE: fail on BLOCK/QUARANTINE/UNKNOWN unless + # VEX (or no-blast-radius) yields ALLOW/WARN. + mapfile -t CVES < <(jq -r '.cves[].id // empty' /tmp/release-twin.json) + if [ "${#CVES[@]}" -eq 0 ]; then + echo "No CVEs in release twin — dogfood policy gate has nothing to assert" >> /tmp/dogfood-report.md + fi + for CVE in "${CVES[@]}"; do + [ -n "$CVE" ] || continue + set +e + /tmp/blastguard ingest \ + --sbom-cyclonedx /tmp/release-sbom.json \ + --vulns-grype /tmp/release-grype.json \ + --policy config/decision-policy.json \ + --trace "$CVE" \ + --output json \ + > /tmp/decision-"$CVE".json \ + 2>/tmp/decision-"$CVE".err + CODE=$? + set -e + ACTION=$(jq -r '.action // empty' /tmp/decision-"$CVE".json) + RULE=$(jq -r '.rule_id // empty' /tmp/decision-"$CVE".json) + echo "- \`$CVE\` → **$ACTION** (\`$RULE\`, exit=$CODE)" >> /tmp/dogfood-report.md + case "$ACTION" in + BLOCK|QUARANTINE|UNKNOWN) + echo "ERROR: dogfood policy gate rejected $CVE action=$ACTION rule=$RULE" >&2 + cat /tmp/decision-"$CVE".json >&2 || true + cat /tmp/decision-"$CVE".err >&2 || true + exit 1 + ;; + ALLOW|WARN) + ;; + *) + echo "ERROR: dogfood policy gate got unexpected action='$ACTION' for $CVE" >&2 + cat /tmp/decision-"$CVE".json >&2 || true + exit 1 + ;; + esac + done - name: Upload dogfood report uses: actions/upload-artifact@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e921d..73c1fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed +- Empty/incomplete twin inventory no longer ALLOW via `no-blast-radius` (UNKNOWN / `incomplete-inventory`, fail closed) +- Release dogfood: Grype scan is fail-closed; policy decision JSON is asserted (reject BLOCK/QUARANTINE/UNKNOWN) + ## [0.2.0] - 2026-10-11 OpenVEX, reachability triad, and remediate since 0.1.0: diff --git a/cmd/blastguard/main.go b/cmd/blastguard/main.go index e024bd0..10a2c91 100644 --- a/cmd/blastguard/main.go +++ b/cmd/blastguard/main.go @@ -115,7 +115,7 @@ Usage: Exit codes (decision commands / ingest --trace): 0 ALLOW (WARN also exits 0 unless --fail-on warn) - 1 WARN when --fail-on warn + 1 WARN when --fail-on warn; UNKNOWN (incomplete inventory) 2 BLOCK or QUARANTINE Commands: diff --git a/config/decision-policy.json b/config/decision-policy.json index e404c03..4dbb1b9 100644 --- a/config/decision-policy.json +++ b/config/decision-policy.json @@ -62,5 +62,5 @@ "LOW": 1, "UNKNOWN": 0 }, - "actions": ["BLOCK", "QUARANTINE", "WARN", "ALLOW"] + "actions": ["BLOCK", "QUARANTINE", "WARN", "ALLOW", "UNKNOWN"] } diff --git a/config/decision-policy.yaml b/config/decision-policy.yaml index 896197a..18f10a6 100644 --- a/config/decision-policy.yaml +++ b/config/decision-policy.yaml @@ -63,3 +63,4 @@ actions: - QUARANTINE - WARN - ALLOW + - UNKNOWN diff --git a/fixtures/demo-graph.json b/fixtures/demo-graph.json index e03df0d..e04deca 100644 --- a/fixtures/demo-graph.json +++ b/fixtures/demo-graph.json @@ -144,5 +144,6 @@ "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path" } - ] + ], + "inventory_complete": true } diff --git a/internal/decide/engine.go b/internal/decide/engine.go index 18f44b9..2c59992 100644 --- a/internal/decide/engine.go +++ b/internal/decide/engine.go @@ -64,6 +64,11 @@ type Signals struct { Provenance string `json:"provenance"` FixImage string `json:"fix_image,omitempty"` + // InventoryComplete mirrors twin.inventory_complete. ALLOW via + // no-blast-radius requires an explicit complete inventory; empty or + // incomplete twins yield UNKNOWN (fail closed). + InventoryComplete bool `json:"inventory_complete"` + // Reachability triad — TRUE|FALSE|UNKNOWN. Missing evidence is UNKNOWN. // ExecutionObserved never yields ALLOW by itself; observed != exploitable. PackagePresent string `json:"package_present"` @@ -131,6 +136,15 @@ func (p *Policy) Evaluate(s Signals) Decision { if s.AffectedArts == 0 && s.RunningWLs == 0 && !s.VEXNotAffected && !underInvest { if s.CVE != "" && s.Package != "" { + if !s.InventoryComplete { + d := Decision{ + Action: "UNKNOWN", + RuleID: "incomplete-inventory", + Reason: "twin inventory empty or incomplete; cannot prove package absence", + Signals: s, + } + return applyTriad(d, execFalse, execTrue, netTrue) + } d := Decision{Action: "ALLOW", RuleID: "no-blast-radius", Reason: "no affected artifacts remain in the twin", Signals: s} return applyTriad(d, execFalse, execTrue, netTrue) } @@ -228,6 +242,7 @@ func TraceCVEWorkloadOpts(t *graph.Twin, cveID, workload string, opts TraceOptio FixAvailable: cve.FixAvailable, SBOM: graph.StatusMissing, Provenance: graph.StatusMissing, + InventoryComplete: t.InventoryComplete, PackagePresent: runtime.Unknown, NetworkReachable: runtime.Unknown, ExecutionObserved: runtime.ExecutionObserved(opts.RuntimeEvidence, workload, cve.Package), @@ -482,7 +497,8 @@ func artifactRelevantForStatus(t *graph.Twin, a *graph.Artifact, cve *graph.CVE, } // ExitCode maps a decision to process exit status. -// 0 = ALLOW (and WARN unless failOnWarn), 1 = WARN when failOnWarn, 2 = BLOCK/QUARANTINE. +// 0 = ALLOW (and WARN unless failOnWarn), 1 = WARN when failOnWarn or UNKNOWN, +// 2 = BLOCK/QUARANTINE. func ExitCode(action string, failOnWarn bool) int { switch strings.ToUpper(strings.TrimSpace(action)) { case "ALLOW": @@ -492,9 +508,23 @@ func ExitCode(action string, failOnWarn bool) int { return 1 } return 0 + case "UNKNOWN": + return 1 case "BLOCK", "QUARANTINE": return 2 default: return 2 } } + +// DogfoodGateReject reports whether a release self-scan decision must fail the +// build. BLOCK/QUARANTINE fail the gate; VEX-covered findings decide ALLOW and +// pass. UNKNOWN (incomplete inventory) also fails closed. +func DogfoodGateReject(d Decision) bool { + switch strings.ToUpper(strings.TrimSpace(d.Action)) { + case "BLOCK", "QUARANTINE", "UNKNOWN": + return true + default: + return false + } +} diff --git a/internal/decide/engine_test.go b/internal/decide/engine_test.go index 9758ccf..9050a38 100644 --- a/internal/decide/engine_test.go +++ b/internal/decide/engine_test.go @@ -205,6 +205,9 @@ func TestExitCode(t *testing.T) { if decide.ExitCode("WARN", true) != 1 { t.Fatal() } + if decide.ExitCode("UNKNOWN", false) != 1 { + t.Fatal("UNKNOWN must be non-zero") + } if decide.ExitCode("BLOCK", false) != 2 { t.Fatal() } @@ -213,6 +216,91 @@ func TestExitCode(t *testing.T) { } } +func TestEmptyTwinCVENotAllow(t *testing.T) { + pol, err := decide.LoadPolicy(filepath.Join("..", "..", "config", "decision-policy.json")) + if err != nil { + t.Fatal(err) + } + // Negative: empty/incomplete inventory must not fail-open to ALLOW. + twin := &graph.Twin{ + CVEs: []graph.CVE{{ + ID: "CVE-EMPTY", Package: "zlib", Severity: "HIGH", + VersionRange: "<= 1.3.2", + }}, + InventoryComplete: false, + } + s, err := decide.TraceCVE(twin, "CVE-EMPTY") + if err != nil { + t.Fatal(err) + } + if s.InventoryComplete { + t.Fatal("empty twin must report inventory_complete=false") + } + d := pol.Evaluate(s) + if d.Action == "ALLOW" { + t.Fatalf("empty twin + CVE must not ALLOW, got %s (%s)", d.Action, d.RuleID) + } + if d.Action != "UNKNOWN" || d.RuleID != "incomplete-inventory" { + t.Fatalf("want UNKNOWN/incomplete-inventory, got %s/%s (%s)", d.Action, d.RuleID, d.Reason) + } + if decide.ExitCode(d.Action, false) == 0 { + t.Fatal("UNKNOWN must yield non-zero exit") + } +} + +func TestCompleteTwinPackageAbsentAllow(t *testing.T) { + pol, err := decide.LoadPolicy(filepath.Join("..", "..", "config", "decision-policy.json")) + if err != nil { + t.Fatal(err) + } + twin := &graph.Twin{ + InventoryComplete: true, + CVEs: []graph.CVE{{ + ID: "CVE-ABSENT", Package: "zlib", Severity: "HIGH", + VersionRange: "<= 1.3.2", + }}, + Artifacts: []graph.Artifact{{ + Image: "app:1", + Packages: []graph.Pkg{{Name: "openssl", Version: "3.0.0"}}, + SBOM: graph.StatusVerified, + Provenance: graph.StatusVerified, + }}, + } + s, err := decide.TraceCVE(twin, "CVE-ABSENT") + if err != nil { + t.Fatal(err) + } + if s.AffectedArts != 0 || s.RunningWLs != 0 { + t.Fatalf("want zero blast radius for absent package, got arts=%d wls=%d", s.AffectedArts, s.RunningWLs) + } + if !s.InventoryComplete { + t.Fatal("complete twin must keep inventory_complete=true") + } + d := pol.Evaluate(s) + if d.Action != "ALLOW" || d.RuleID != "no-blast-radius" { + t.Fatalf("complete twin without package: want ALLOW/no-blast-radius, got %s/%s", d.Action, d.RuleID) + } +} + +func TestDogfoodGateReject(t *testing.T) { + // Negative: BLOCK must fail the release dogfood gate (no masking). + if !decide.DogfoodGateReject(decide.Decision{Action: "BLOCK"}) { + t.Fatal("BLOCK must reject dogfood gate") + } + if !decide.DogfoodGateReject(decide.Decision{Action: "QUARANTINE"}) { + t.Fatal("QUARANTINE must reject dogfood gate") + } + if !decide.DogfoodGateReject(decide.Decision{Action: "UNKNOWN"}) { + t.Fatal("UNKNOWN must reject dogfood gate") + } + if decide.DogfoodGateReject(decide.Decision{Action: "ALLOW", RuleID: "vex-clear"}) { + t.Fatal("VEX-covered ALLOW must pass dogfood gate") + } + if decide.DogfoodGateReject(decide.Decision{Action: "WARN"}) { + t.Fatal("WARN must pass dogfood gate") + } +} + func TestSemverFalsePositiveNotAffected(t *testing.T) { twin := &graph.Twin{ CVEs: []graph.CVE{{ID: "CVE-Z", Package: "openssl", Severity: "HIGH", VersionRange: "< 3.3.2"}}, diff --git a/internal/decide/release_workflow_test.go b/internal/decide/release_workflow_test.go new file mode 100644 index 0000000..bf010c0 --- /dev/null +++ b/internal/decide/release_workflow_test.go @@ -0,0 +1,34 @@ +package decide_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestReleaseWorkflowGrypeFailClosed(t *testing.T) { + path := filepath.Join("..", "..", ".github", "workflows", "release.yml") + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + body := string(b) + + // Negative: the Grype scan line must not be masked with || true (fail-open). + masked := regexp.MustCompile(`grype\s+dist/blastguard[^\n]*\|\|\s*true`) + if masked.MatchString(body) { + t.Fatal("release Grype scan must not mask failures with || true") + } + if !strings.Contains(body, "/tmp/grype dist/blastguard_*_linux_amd64.tar.gz -o json > /tmp/release-grype.json") { + t.Fatal("expected unmasked Grype scan of the release artifact") + } + // Dogfood must assert policy decisions, not only count CVEs. + if !strings.Contains(body, "--trace") { + t.Fatal("dogfood step must trace CVE decisions") + } + if !strings.Contains(body, "BLOCK|QUARANTINE|UNKNOWN") { + t.Fatal("dogfood step must fail the gate on BLOCK/QUARANTINE/UNKNOWN") + } +} diff --git a/internal/graph/twin.go b/internal/graph/twin.go index 8b80994..6b386ed 100644 --- a/internal/graph/twin.go +++ b/internal/graph/twin.go @@ -77,6 +77,10 @@ type Twin struct { Workloads []Workload `json:"workloads"` VEX []VEX `json:"vex"` Matches []Match `json:"matches,omitempty"` + // InventoryComplete is true only when every running workload has an + // SBOM-backed artifact (package inventory present). Empty twins and + // twins with stub/MISSING SBOM images stay false (fail closed). + InventoryComplete bool `json:"inventory_complete"` } func Load(path string) (*Twin, error) { @@ -236,6 +240,77 @@ func (t *Twin) BindWorkloads(workloads []Workload) { }) seen[w.Image] = true } + t.RefreshInventoryComplete() +} + +// RefreshInventoryComplete sets InventoryComplete when package SBOMs exist for +// every running workload (or, with no running workloads, when at least one +// artifact carries package inventory). Empty / stub-only twins stay incomplete. +func (t *Twin) RefreshInventoryComplete() { + if t == nil { + return + } + t.InventoryComplete = InventoryIsComplete(t) +} + +// InventoryIsComplete reports whether the twin has enough SBOM coverage to +// treat "package absent / no blast radius" as an ALLOW rather than UNKNOWN. +func InventoryIsComplete(t *Twin) bool { + if t == nil || len(t.Artifacts) == 0 { + return false + } + hasPkgInventory := false + for i := range t.Artifacts { + if len(t.Artifacts[i].Packages) > 0 { + hasPkgInventory = true + break + } + } + if !hasPkgInventory { + return false + } + for _, w := range t.Workloads { + if !w.Running { + continue + } + a := ArtifactForWorkload(t, w) + if a == nil || len(a.Packages) == 0 || NormalizeStatus(a.SBOM) == StatusMissing { + return false + } + } + return true +} + +// ArtifactForWorkload resolves the SBOM-backed artifact for a workload by +// digest first, then exact image, then repository-name match with packages. +func ArtifactForWorkload(t *Twin, w Workload) *Artifact { + if t == nil { + return nil + } + if w.ImageID != "" { + if a := t.ArtifactByDigest(w.ImageID); a != nil { + return a + } + } + for i := range t.Artifacts { + if t.Artifacts[i].Image == w.Image { + return &t.Artifacts[i] + } + } + var fallback *Artifact + for i := range t.Artifacts { + a := &t.Artifacts[i] + if !ImagesMatch(a.Image, w.Image) { + continue + } + if len(a.Packages) > 0 { + return a + } + if fallback == nil { + fallback = a + } + } + return fallback } func hasExactImage(t *Twin, image string) bool { diff --git a/internal/graph/twin_test.go b/internal/graph/twin_test.go index 8f9488a..955a10e 100644 --- a/internal/graph/twin_test.go +++ b/internal/graph/twin_test.go @@ -26,8 +26,8 @@ func TestBindWorkloadsNoCloneAcrossTags(t *testing.T) { }}, } twin.BindWorkloads([]Workload{ - {Name: "a", Namespace: "default", Image: "nginx:1.25"}, - {Name: "b", Namespace: "default", Image: "nginx:1.27"}, + {Name: "a", Namespace: "default", Image: "nginx:1.25", Running: true}, + {Name: "b", Namespace: "default", Image: "nginx:1.27", Running: true}, }) var got125, got127 *Artifact for i := range twin.Artifacts { @@ -51,6 +51,34 @@ func TestBindWorkloadsNoCloneAcrossTags(t *testing.T) { if got127.SBOM != StatusMissing { t.Fatalf("stub SBOM want MISSING got %s", got127.SBOM) } + // Negative: stub running workload without SBOM must not mark inventory complete. + if twin.InventoryComplete { + t.Fatal("inventory must be incomplete when a running workload has only a stub SBOM") + } +} + +func TestInventoryCompleteWhenAllRunningHaveSBOM(t *testing.T) { + twin := &Twin{ + Artifacts: []Artifact{{ + Image: "nginx:alpine", + Packages: []Pkg{{Name: "zlib", Version: "1.2.0"}}, + SBOM: StatusVerified, + }}, + } + twin.BindWorkloads([]Workload{ + {Name: "payment-api", Namespace: "default", Image: "nginx:alpine", Running: true}, + }) + if !twin.InventoryComplete { + t.Fatal("want inventory_complete when every running workload has SBOM packages") + } +} + +func TestEmptyTwinInventoryIncomplete(t *testing.T) { + twin := &Twin{} + twin.RefreshInventoryComplete() + if twin.InventoryComplete { + t.Fatal("empty twin must be inventory_complete=false") + } } func TestImagesMatchAndName(t *testing.T) { diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 899f08c..deac9be 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -465,7 +465,7 @@ func (b *Builder) Build() *graph.Twin { sbomStatus = graph.StatusVerified } - return &graph.Twin{ + twin := &graph.Twin{ CVEs: cves, Artifacts: []graph.Artifact{ { @@ -481,6 +481,8 @@ func (b *Builder) Build() *graph.Twin { VEX: b.vexStatements, Matches: b.matches, } + twin.RefreshInventoryComplete() + return twin } func imageRefFromComponent(name, version string) string { diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 36bc805..ce85e4a 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -198,4 +198,15 @@ func TestBuild(t *testing.T) { if len(twin.CVEs) == 0 { t.Error("Expected CVEs") } + if !twin.InventoryComplete { + t.Error("SBOM-backed ingest with no running workloads must set inventory_complete") + } +} + +func TestBuildEmptyInventoryIncomplete(t *testing.T) { + // Negative: empty builder must not claim a complete inventory. + twin := NewBuilder().Build() + if twin.InventoryComplete { + t.Fatal("empty ingest twin must be inventory_complete=false") + } }