Align scratch runtime and releases with image hardening baseline

Move container HTTP to8080, remove capabilities/curl, preserve demo UI without mutating assets, patch vulnerable Go dependencies and document migration/runtime contracts. Gate releases on source/final-image security evidence and unresolved policy exceptions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Cédric Verstraeten
2026-11-04 22:03:04 +01:00
co-authored by Copilot App
parent 9553b1b4b9
commit e3acd64a57
34 changed files with 1585 additions and 491 deletions
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Validate BuildKit provenance and retain resolved base-image materials."""
import json
from pathlib import Path
import re
import sys
def materials(value):
if isinstance(value, dict):
if "uri" in value and value["uri"].startswith(("pkg:docker/", "docker-image://")):
digest = value.get("digest", {}).get("sha256", "")
if not re.fullmatch(r"[a-f0-9]{64}", digest):
raise ValueError(f"Missing base-image digest: {value['uri']}")
yield value
for child in value.values():
yield from materials(child)
elif isinstance(value, list):
for child in value:
yield from materials(child)
if __name__ == "__main__":
directory = Path(sys.argv[1])
provenance = json.loads((directory / "provenance.json").read_text())
bases = list(materials(provenance))
if not bases:
raise SystemExit("Missing provenance/base-image materials")
(directory / "base-image-digests.json").write_text(json.dumps(bases, indent=2) + "\n")
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Fail-closed source/application and image scans with redacted evidence."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
def check_report(report, image=False, source=False):
if report.get("SchemaVersion") != 2 or not isinstance(report.get("Results"), list):
raise ValueError("Missing or unsupported Trivy report")
failures = []
for result in report["Results"]:
if result.get("Errors"):
failures.append(f"Scan errors in {result.get('Target')}: {result['Errors']}")
if result.get("MisconfSummary", {}).get("Exceptions", 0):
failures.append(f"Unapproved misconfiguration exceptions in {result.get('Target')}")
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
for finding in result.get(kind, []):
if kind == "Misconfigurations" and finding.get("Status") == "PASS":
continue
if kind == "Misconfigurations" and finding.get("Status") != "FAIL":
failures.append(f"Unexpected/suppressed misconfiguration status: {finding.get('Status')}")
severity = finding.get("Severity", "UNKNOWN")
if severity not in ("LOW", "MEDIUM"):
failures.append(f"{result.get('Target')}: {kind}: "
f"{finding.get('VulnerabilityID', finding.get('ID', finding.get('RuleID')))} "
f"{severity}")
if image:
# A scratch image without package metadata must not yield a false clean OS scan.
if not report.get("Metadata", {}).get("OS", {}).get("Family"):
failures.append("No OS detected: preserve runtime OS/package metadata")
if not any(r.get("Class") == "os-pkgs" and r.get("Packages")
for r in report["Results"]):
failures.append("No OS package inventory detected")
if not any(r.get("Type") == "gobinary" and r.get("Packages")
for r in report["Results"]):
failures.append("No Go application package inventory detected")
if source and not any(
r.get("Type") == "yarn" and r.get("Target", "").replace("\\", "/").endswith("ui/yarn.lock")
and r.get("Packages") for r in report["Results"]
):
failures.append("Missing production UI yarn.lock package inventory")
return failures
def redact_report(value):
"""Keep findings actionable without persisting matches, excerpts or image env/history."""
if isinstance(value, list):
return [redact_report(item) for item in value]
if not isinstance(value, dict):
return value
result = {}
for key, child in value.items():
if key.lower() in {"imageconfig", "buildinfo"}:
continue
if key.lower() in {
"match", "code", "lines", "source", "sourcecode", "snippet", "context",
"content",
}:
result[key] = "[REDACTED]"
elif key == "Errors":
result[key] = ["[REDACTED scanner error]"] if child else []
elif key in ("Secrets", "Misconfigurations"):
allowed = {
"ID", "AVDID", "RuleID", "Type", "Category", "Title", "Severity",
"Status", "StartLine", "EndLine", "References",
}
result[key] = [
{field: finding[field] for field in allowed if field in finding}
| {"SourceExcerpt": "[REDACTED]"}
for finding in child
]
else:
result[key] = redact_report(child)
return result
def scan(target, output, platform=None):
output.mkdir(parents=True, exist_ok=True)
config = output / "scanner.yaml"
ignore = output / "empty.ignore"
secret_config = output / "secret.yaml"
config.write_text("{}\n")
secret_config.write_text("{}\n")
ignore.write_text("")
# Do not consume repository trivy.yaml/.trivyignore or TRIVY_* overrides.
env = {k: v for k, v in os.environ.items() if not k.startswith("TRIVY_")}
command = [
"trivy", "image" if platform else "fs", "--config", str(config),
"--cache-dir", ".ci-tools/trivy-cache",
"--ignorefile", str(ignore), "--secret-config", str(secret_config),
"--format", "json", "--exit-code", "0",
"--scanners", "vuln,secret,misconfig",
"--severity", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "--timeout", "20m",
"--list-all-pkgs", "--detection-priority", "comprehensive",
]
if platform:
command += ["--platform", platform, "--pkg-types", "os,library",
"--image-config-scanners", "secret,misconfig"]
else:
# Include development/build dependencies too: none are silently exempted.
# Trivy marks Dev packages using the adjacent package.json and yarn.lock.
command += ["--pkg-types", "library", "--include-dev-deps"]
command.append(target)
# Never write raw scanner output, even on scanner failure/interruption. stderr
# may contain source excerpts too; retain only a safe exit-status diagnostic.
completed = subprocess.run(command, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, check=False)
(output / "scanner.log").write_text(
f"Trivy exit status: {completed.returncode}\n"
"Raw scanner stdout/stderr omitted; scan.json is redacted before persistence.\n")
if completed.returncode:
raise ValueError(f"Trivy failed with exit status {completed.returncode}; raw diagnostics withheld")
try:
report = redact_report(json.loads(completed.stdout))
except ValueError:
raise ValueError("Invalid scanner JSON; raw output withheld") from None
(output / "scan.json").write_text(json.dumps(report, indent=2) + "\n")
failures = check_report(report, image=bool(platform), source=not platform)
if not platform:
inventory = [{
"target": result.get("Target"), "type": result.get("Type"),
"packages": [{"name": pkg.get("Name"), "version": pkg.get("Version"),
"scope": "development" if pkg.get("Dev") else "production-or-unclassified"}
for pkg in result.get("Packages", [])],
} for result in report["Results"] if result.get("Packages")]
(output / "application-inventory.json").write_text(json.dumps({
"policy": "All production and development/build dependencies are gated; no exclusions.",
"inventories": inventory,
}, indent=2) + "\n")
converted = subprocess.run([
"trivy", "convert", "--cache-dir", ".ci-tools/trivy-cache",
"--format", "spdx-json", "--output", str(output / "sbom.spdx.json"),
str(output / "scan.json"),
], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if converted.returncode:
raise ValueError(f"SPDX conversion failed with exit status {converted.returncode}")
sbom = json.loads((output / "sbom.spdx.json").read_text())
if not sbom.get("spdxVersion") or not sbom.get("packages"):
failures.append("Missing/empty SPDX SBOM")
return failures
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target", help="Repository root, or final image reference with --platform")
parser.add_argument("output", type=Path, help="Directory for redacted reports and SPDX SBOM")
parser.add_argument("--platform", help="Image platform, e.g. linux/amd64; omit for source/UI scanning")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
try:
failures = scan(args.target, args.output, args.platform)
except (OSError, ValueError, subprocess.CalledProcessError) as error:
failures = [f"Scanner/evidence error: {error}"]
(args.output / "policy.json").write_text(json.dumps({
"target": args.target, "platform": args.platform, "failures": failures,
"approved_exceptions": [], "passed": not failures,
}, indent=2) + "\n")
for failure in failures:
print(failure, file=sys.stderr)
return int(bool(failures))
if __name__ == "__main__":
sys.exit(main())
+13
View File
@@ -0,0 +1,13 @@
{
"required_exceptions": [
{
"id": "remote-terminal-pty",
"status": "pending",
"reason": "The product retains functional remote terminal/PTY support, contrary to the no-shell baseline.",
"owner": null,
"expires": null,
"approved_by": null,
"approval_url": null
}
]
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Release-only policy gate. Approval records require a reviewed repository change."""
from datetime import datetime, timezone
import json
from pathlib import Path
import sys
def check_policy(policy, now=None):
now = now or datetime.now(timezone.utc)
records = policy.get("required_exceptions")
if not isinstance(records, list) or not records:
return ["Required exception records are missing"]
if {record.get("id") for record in records} != {"remote-terminal-pty"}:
return ["Required remote-terminal-pty exception record is missing or unexpected"]
failures = []
for record in records:
name = record["id"]
if record.get("status") != "approved":
failures.append(f"{name}: required exception is PENDING, not approved")
continue
if not all(isinstance(record.get(key), str) and record[key].strip()
for key in ("owner", "approved_by", "reason", "approval_url", "expires")):
failures.append(f"{name}: missing owner/approver/reason/approval evidence/expiry")
continue
if not record["approval_url"].startswith("https://github.com/kerberos-io/agent/"):
failures.append(f"{name}: approval must reference this repository's reviewed record")
try:
expires = datetime.fromisoformat(record["expires"].replace("Z", "+00:00"))
if expires.tzinfo is None or expires <= now:
failures.append(f"{name}: approval expired or lacks timezone")
except ValueError:
failures.append(f"{name}: invalid approval expiry")
return failures
if __name__ == "__main__":
try:
failures = check_policy(json.loads(Path(sys.argv[1]).read_text()))
except (OSError, ValueError, TypeError, KeyError) as error:
failures = [f"Invalid release policy: {error}"]
for failure in failures:
print(failure, file=sys.stderr)
sys.exit(bool(failures))
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
# Release assets and these platform names were checked against v0.76.1.
version=0.76.1
case "$(uname -m)" in
x86_64) platform=64bit ;;
aarch64|arm64) platform=ARM64 ;;
*) echo "Unsupported scanner host architecture" >&2; exit 1 ;;
esac
mkdir -p .ci-tools
asset="trivy_${version}_Linux-${platform}.tar.gz"
gh release download "v${version}" --repo aquasecurity/trivy \
--pattern "$asset" --pattern "trivy_${version}_checksums.txt" --dir .ci-tools
(
cd .ci-tools
grep " ${asset}$" "trivy_${version}_checksums.txt" | sha256sum --check --strict -
tar -xzf "$asset" trivy
)
echo "$PWD/.ci-tools" >> "$GITHUB_PATH"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Focused release guard tests; no registry, signing, or scanner network access."""
from datetime import datetime, timezone
import importlib.util
import json
from pathlib import Path
import unittest
from unittest.mock import patch
import subprocess
ROOT = Path(__file__).parent
def load(name):
spec = importlib.util.spec_from_file_location(name, ROOT / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
scanner = load("image-policy")
release = load("image-release-policy")
evidence = load("image-evidence")
class ScanPolicyTests(unittest.TestCase):
def report(self, severity=None, kind="Vulnerabilities"):
result = {"Class": "os-pkgs", "Packages": [{"Name": "libc6"}]}
if severity:
result[kind] = [{"Severity": severity, "Status": "FAIL", "ID": "fixture"}]
return {"SchemaVersion": 2, "Metadata": {"OS": {"Family": "debian"}},
"Results": [result, {"Class": "lang-pkgs", "Type": "gobinary",
"Packages": [{"Name": "stdlib"}]}]}
def test_low_and_medium_are_not_blocking(self):
for severity in ("LOW", "MEDIUM"):
self.assertEqual(scanner.check_report(self.report(severity), image=True), [])
def test_high_critical_unknown_and_unrecognized_block(self):
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
for severity in ("HIGH", "CRITICAL", "UNKNOWN", "invalid"):
with self.subTest(kind=kind, severity=severity):
self.assertTrue(scanner.check_report(self.report(severity, kind), image=True))
def test_missing_inventory_blocks_false_clean_scratch_scan(self):
report = self.report()
report["Metadata"] = {}
report["Results"] = []
self.assertEqual(len(scanner.check_report(report, image=True)), 3)
def test_empty_os_inventory_blocks(self):
report = self.report()
report["Results"][0]["Packages"] = []
self.assertTrue(scanner.check_report(report, image=True))
def test_report_errors_and_suppression_summary_block(self):
report = self.report()
report["Results"][0]["Errors"] = ["fixture scanner error"]
report["Results"][0]["MisconfSummary"] = {"Exceptions": 1}
self.assertEqual(len(scanner.check_report(report)), 2)
def test_missing_or_unsupported_report_blocks(self):
for report in ({}, {"SchemaVersion": 1, "Results": []}):
with self.assertRaises(ValueError):
scanner.check_report(report)
def test_suppressed_misconfiguration_blocks(self):
report = self.report("LOW", "Misconfigurations")
report["Results"][0]["Misconfigurations"][0]["Status"] = "EXCEPTION"
self.assertTrue(scanner.check_report(report))
def test_scanner_errors_are_not_ignored(self):
directory = unittest.mock.MagicMock()
with patch.object(scanner.subprocess, "run",
side_effect=subprocess.CalledProcessError(1, ["trivy"])):
with self.assertRaises(subprocess.CalledProcessError):
scanner.scan("fixture", directory, "linux/amd64")
def test_source_requires_ui_lockfile_inventory(self):
report = self.report()
self.assertTrue(scanner.check_report(report, source=True))
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
"Packages": [{"Name": "react", "Version": "17.0.2"}]})
self.assertEqual(scanner.check_report(report, source=True), [])
report["Results"][-1]["Vulnerabilities"] = [{"Severity": "HIGH", "VulnerabilityID": "fixture"}]
self.assertTrue(scanner.check_report(report, source=True))
def test_secret_and_source_excerpts_are_removed_without_waiving_findings(self):
marker = "sensitive-fixture-text-not-a-real-credential"
report = self.report("HIGH", "Secrets")
report["Metadata"]["ImageConfig"] = {"config": {"Env": [marker]}}
report["Metadata"]["BuildInfo"] = {"attrs": {"secret": marker}}
report["Results"][0]["Secrets"][0].update(
Match=marker, Code={"Lines": [{"Content": marker}]}, Message=marker,
UnexpectedFutureExcerptField=marker, RuleID="private-key-fixture")
report["Results"][0]["Misconfigurations"] = [{
"ID": "fixture", "Severity": "HIGH", "Status": "FAIL",
"Message": marker, "CauseMetadata": {"Code": {"Lines": [{"Content": marker}]}}
}]
report["Results"][0]["Errors"] = [marker]
redacted = scanner.redact_report(report)
self.assertNotIn(marker, json.dumps(redacted))
self.assertNotIn("ImageConfig", redacted["Metadata"])
self.assertTrue(scanner.check_report(redacted, image=True))
self.assertEqual(redacted["Results"][0]["Secrets"][0]["RuleID"], "private-key-fixture")
def test_source_scan_captures_output_in_memory_and_generates_sbom(self):
report = self.report()
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
"Packages": [{"Name": "fixture", "Version": "1.0", "Dev": True}]})
directory = unittest.mock.MagicMock()
directory.__truediv__.return_value.read_text.return_value = json.dumps({
"spdxVersion": "SPDX-2.3", "packages": [{"name": "fixture"}]})
completed = subprocess.CompletedProcess(["trivy"], 0, stdout=json.dumps(report), stderr="")
with patch.object(scanner.subprocess, "run", return_value=completed) as run:
self.assertEqual(scanner.scan("fixture", directory), [])
command = run.call_args_list[0].args[0]
self.assertIn("--include-dev-deps", command)
self.assertIn("vuln,secret,misconfig", command)
self.assertNotIn("--output", command)
self.assertEqual(run.call_args_list[0].kwargs["stdout"], subprocess.PIPE)
self.assertEqual(run.call_args_list[1].args[0][1], "convert")
def test_raw_scanner_error_diagnostics_are_not_persisted(self):
directory = unittest.mock.MagicMock()
marker = "sensitive-fixture-text-not-a-real-credential"
completed = subprocess.CompletedProcess(["trivy"], 1, stdout=marker, stderr=marker)
with patch.object(scanner.subprocess, "run", return_value=completed):
with self.assertRaisesRegex(ValueError, "exit status 1"):
scanner.scan("fixture", directory)
self.assertNotIn(marker, str(directory.mock_calls))
class ReleasePolicyTests(unittest.TestCase):
NOW = datetime(2026, 11, 4, tzinfo=timezone.utc)
def test_checked_in_state_blocks_publication(self):
state = json.loads((ROOT / "image-release-policy.json").read_text())
self.assertTrue(release.check_policy(state, self.NOW))
def test_missing_records_block(self):
for state in ({}, {"required_exceptions": []},
{"required_exceptions": [{"id": "unrelated"}]}):
self.assertTrue(release.check_policy(state, self.NOW))
def test_expired_or_incomplete_approval_blocks(self):
state = json.loads((ROOT / "image-release-policy.json").read_text())
record = state["required_exceptions"][0]
record["status"] = "approved"
self.assertTrue(release.check_policy(state, self.NOW))
record.update(owner="test-fixture", approved_by="test-fixture",
approval_url="https://github.com/kerberos-io/agent/issues/1",
expires="2020-01-01T00:00:00Z")
self.assertTrue(release.check_policy(state, self.NOW))
record["expires"] = "invalid"
self.assertTrue(release.check_policy(state, self.NOW))
record["expires"] = "2027-01-01"
self.assertTrue(release.check_policy(state, self.NOW))
class ProvenanceTests(unittest.TestCase):
def test_base_digest_retained(self):
material = {"uri": "pkg:docker/debian@trixie", "digest": {"sha256": "a" * 64}}
self.assertEqual(list(evidence.materials({"SLSA": {"materials": [material]}})), [material])
def test_missing_digest_rejected(self):
with self.assertRaises(ValueError):
list(evidence.materials({"uri": "pkg:docker/debian@trixie"}))
if __name__ == "__main__":
unittest.main()