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
49 changes: 46 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cmd/blastguard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion config/decision-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,5 @@
"LOW": 1,
"UNKNOWN": 0
},
"actions": ["BLOCK", "QUARANTINE", "WARN", "ALLOW"]
"actions": ["BLOCK", "QUARANTINE", "WARN", "ALLOW", "UNKNOWN"]
}
1 change: 1 addition & 0 deletions config/decision-policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,4 @@ actions:
- QUARANTINE
- WARN
- ALLOW
- UNKNOWN
3 changes: 2 additions & 1 deletion fixtures/demo-graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,6 @@
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
],
"inventory_complete": true
}
32 changes: 31 additions & 1 deletion internal/decide/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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":
Expand All @@ -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
}
}
88 changes: 88 additions & 0 deletions internal/decide/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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"}},
Expand Down
34 changes: 34 additions & 0 deletions internal/decide/release_workflow_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading