mirror of
https://github.com/kerberos-io/agent.git
synced 2026-11-05 20:20:23 +00:00
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:
co-authored by
Copilot App
parent
9553b1b4b9
commit
e3acd64a57
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user