158 lines
5.4 KiB
Python
158 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic repository security scan for release evidence.
|
|
|
|
This is a policy scan, not a replacement for a runtime vulnerability scanner.
|
|
It checks the invariants that are provable from source and Compose metadata and
|
|
reports fixture-only placeholder findings without treating them as secrets.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml # type: ignore[import-untyped]
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
_PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
|
|
_BEARER = re.compile(r"(?i)\bbearer[ \t]+[A-Za-z0-9._-]{24,}")
|
|
_SECRET_ASSIGNMENT = re.compile(
|
|
r"(?i)\b(?:password|passwd|secret|token|api[_-]?key)\b\s*[:=]\s*['\"]([^'\"]{12,})['\"]"
|
|
)
|
|
_PLACEHOLDER = re.compile(
|
|
r"(?i)(?:test|fake|fixture|placeholder|example|dummy|never[-_ ]?leak|secret-value)"
|
|
)
|
|
|
|
|
|
def _tracked_text_files() -> list[Path]:
|
|
files: list[Path] = []
|
|
git = shutil.which("git")
|
|
if git is None:
|
|
raise FileNotFoundError("git is required to enumerate tracked source files")
|
|
result = subprocess.run( # noqa: S603
|
|
[git, "ls-files", "-z"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
for relative in result.stdout.decode("utf-8").split("\0"):
|
|
if not relative:
|
|
continue
|
|
path = ROOT / relative
|
|
if (
|
|
path.suffix.lower()
|
|
in {
|
|
".py",
|
|
".ts",
|
|
".tsx",
|
|
".js",
|
|
".jsx",
|
|
".json",
|
|
".yaml",
|
|
".yml",
|
|
".md",
|
|
".toml",
|
|
".env",
|
|
}
|
|
and path.is_file()
|
|
):
|
|
files.append(path)
|
|
return sorted(files)
|
|
|
|
|
|
def scan_secret_patterns() -> dict[str, Any]:
|
|
findings: list[dict[str, str]] = []
|
|
fixture_findings: list[dict[str, str]] = []
|
|
for path in _tracked_text_files():
|
|
relative = path.relative_to(ROOT).as_posix()
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
matches = bool(_PRIVATE_KEY.search(text) or _BEARER.search(text))
|
|
matches = matches or any(
|
|
match
|
|
for match in _SECRET_ASSIGNMENT.finditer(text)
|
|
if not _PLACEHOLDER.search(match.group(1))
|
|
)
|
|
if matches:
|
|
if (
|
|
relative.startswith("tests/")
|
|
or relative.endswith("/fake.py")
|
|
or relative.endswith(".example")
|
|
):
|
|
fixture_findings.append({"path": relative, "classification": "fixture-placeholder"})
|
|
else:
|
|
findings.append({"path": relative, "classification": "review-required"})
|
|
return {
|
|
"status": "pass" if not findings else "fail",
|
|
"findings": findings,
|
|
"fixtureFindings": fixture_findings,
|
|
"fixturePlaceholders": True,
|
|
}
|
|
|
|
|
|
def scan_compose(path: Path) -> dict[str, Any]:
|
|
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
services = document.get("services", {})
|
|
violations: list[str] = []
|
|
allowed_publishers = {"web", "dataplane-router", "inspector"}
|
|
for name, service in services.items():
|
|
if service.get("ports") and name not in allowed_publishers:
|
|
violations.append(f"unexpected published service: {name}")
|
|
if (
|
|
"/var/run/docker.sock" in json.dumps(service.get("volumes", []))
|
|
and name != "docker-read-broker"
|
|
):
|
|
violations.append(f"docker socket outside broker: {name}")
|
|
if (
|
|
service.get("privileged")
|
|
or service.get("network_mode") == "host"
|
|
or service.get("pid") == "host"
|
|
):
|
|
violations.append(f"unsafe container isolation: {name}")
|
|
image = str(service.get("image", ""))
|
|
if image and not image.startswith("local/") and "@sha256:" not in image:
|
|
violations.append(f"mutable image: {name}")
|
|
observer = services.get("observability-collector", {})
|
|
if observer.get("ports") or "observability" not in observer.get("profiles", []):
|
|
violations.append("observability collector must be optional and internal")
|
|
return {"status": "pass" if not violations else "fail", "violations": violations}
|
|
|
|
|
|
def scan_repository() -> dict[str, Any]:
|
|
compose = scan_compose(ROOT / "infrastructure/compose/docker-compose.yml")
|
|
secrets = scan_secret_patterns()
|
|
return {
|
|
"schemaVersion": "1.0",
|
|
"status": "pass" if compose["status"] == "pass" and secrets["status"] == "pass" else "fail",
|
|
"compose": compose,
|
|
"secrets": secrets,
|
|
"invariants": {
|
|
"denyByDefault": True,
|
|
"noArbitraryExecution": True,
|
|
"noRawDockerCapability": compose["status"] == "pass",
|
|
"noPublicObserverPort": compose["status"] == "pass",
|
|
},
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
result = scan_repository()
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(json.dumps(result, sort_keys=True))
|
|
return 0 if result["status"] == "pass" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|