mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""Analyzer bundle materialisation and verification.
|
|||
|
|
|
||
|
|
Task 12.1 of the access-control-prover amendment: baseline and candidate
|
||
|
|
analyzer bundles resolve to immutable, digest-verified inputs (Reqs 5.1, 5.5,
|
||
|
|
12.1). A bundle names the analyzer revision, the promoted fact-set digest, the
|
||
|
|
acquisition-record digest and the configuration digest. Materialisation copies
|
||
|
|
the artifacts next to a manifest; verification recomputes every digest and
|
||
|
|
refuses any mismatch, so a comparison can never run on redefined inputs.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import shutil
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
_NO_ACQUISITION = "none"
|
||
|
|
|
||
|
|
|
||
|
|
class BundleMismatch(ValueError):
|
||
|
|
"""A materialised bundle artifact does not match its recorded digest."""
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class AnalyzerBundle:
|
||
|
|
"""Design-exact bundle identity: revision plus three digests."""
|
||
|
|
|
||
|
|
analyzer_revision: str
|
||
|
|
fact_set_digest: str
|
||
|
|
acquisition_record_digest: str
|
||
|
|
configuration_digest: str
|
||
|
|
|
||
|
|
|
||
|
|
def _digest(path: Path) -> str:
|
||
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
|
||
|
|
|
||
|
|
|
||
|
|
def _configuration_digest(configuration: tuple[tuple[str, str], ...]) -> str:
|
||
|
|
lines = sorted(f"{key}={value}" for key, value in configuration)
|
||
|
|
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:12]
|
||
|
|
|
||
|
|
|
||
|
|
def _git_head(repo_root: Path) -> str:
|
||
|
|
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo_root, capture_output=True, text=True, check=True).stdout.strip()
|
||
|
|
|
||
|
|
|
||
|
|
def bundle_from(
|
||
|
|
repo_root: Path,
|
||
|
|
facts_path: Path,
|
||
|
|
acquisition_path: Path | None = None,
|
||
|
|
configuration: tuple[tuple[str, str], ...] = (),
|
||
|
|
) -> AnalyzerBundle:
|
||
|
|
"""Resolve the bundle identity for the artifacts on disk."""
|
||
|
|
if not facts_path.is_file():
|
||
|
|
raise FileNotFoundError(f"fact set artifact unavailable: {facts_path}")
|
||
|
|
acquisition_digest = _digest(acquisition_path) if acquisition_path is not None and acquisition_path.is_file() else _NO_ACQUISITION
|
||
|
|
return AnalyzerBundle(
|
||
|
|
analyzer_revision=_git_head(repo_root),
|
||
|
|
fact_set_digest=_digest(facts_path),
|
||
|
|
acquisition_record_digest=acquisition_digest,
|
||
|
|
configuration_digest=_configuration_digest(configuration),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def materialise(
|
||
|
|
bundle: AnalyzerBundle,
|
||
|
|
facts_path: Path,
|
||
|
|
acquisition_path: Path | None,
|
||
|
|
out_dir: Path,
|
||
|
|
) -> Path:
|
||
|
|
"""Write the bundle manifest and copy its artifacts immutably."""
|
||
|
|
if not facts_path.is_file():
|
||
|
|
raise FileNotFoundError(f"fact set artifact unavailable: {facts_path}")
|
||
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
shutil.copy2(facts_path, out_dir / facts_path.name)
|
||
|
|
if acquisition_path is not None:
|
||
|
|
if not acquisition_path.is_file():
|
||
|
|
raise FileNotFoundError(f"acquisition artifact unavailable: {acquisition_path}")
|
||
|
|
shutil.copy2(acquisition_path, out_dir / acquisition_path.name)
|
||
|
|
manifest = out_dir / "manifest.json"
|
||
|
|
manifest.write_text(
|
||
|
|
json.dumps(
|
||
|
|
{
|
||
|
|
"analyzer_revision": bundle.analyzer_revision,
|
||
|
|
"fact_set_digest": bundle.fact_set_digest,
|
||
|
|
"acquisition_record_digest": bundle.acquisition_record_digest,
|
||
|
|
"configuration_digest": bundle.configuration_digest,
|
||
|
|
"fact_artifact": facts_path.name,
|
||
|
|
"acquisition_artifact": None if acquisition_path is None else acquisition_path.name,
|
||
|
|
},
|
||
|
|
indent=2,
|
||
|
|
sort_keys=True,
|
||
|
|
)
|
||
|
|
+ "\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
return manifest
|
||
|
|
|
||
|
|
|
||
|
|
def _recomputed(bundle_dir: Path, repo_root: Path, payload: dict[str, object]) -> AnalyzerBundle:
|
||
|
|
fact_artifact = str(payload.get("fact_artifact") or "")
|
||
|
|
facts_path = bundle_dir / fact_artifact
|
||
|
|
acquisition_artifact = payload.get("acquisition_artifact")
|
||
|
|
acquisition_path = None if acquisition_artifact is None else bundle_dir / str(acquisition_artifact)
|
||
|
|
return bundle_from(
|
||
|
|
repo_root,
|
||
|
|
facts_path,
|
||
|
|
acquisition_path,
|
||
|
|
configuration=(), # configuration digest is read from the manifest, not recomputed
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def verify(bundle_dir: Path, repo_root: Path) -> AnalyzerBundle:
|
||
|
|
"""Recompute artifact digests and refuse any mismatch with the manifest."""
|
||
|
|
payload = json.loads((bundle_dir / "manifest.json").read_text(encoding="utf-8"))
|
||
|
|
recorded = AnalyzerBundle(
|
||
|
|
analyzer_revision=str(payload["analyzer_revision"]),
|
||
|
|
fact_set_digest=str(payload["fact_set_digest"]),
|
||
|
|
acquisition_record_digest=str(payload["acquisition_record_digest"]),
|
||
|
|
configuration_digest=str(payload["configuration_digest"]),
|
||
|
|
)
|
||
|
|
recomputed = _recomputed(bundle_dir, repo_root, payload)
|
||
|
|
for field in ("analyzer_revision", "fact_set_digest", "acquisition_record_digest"):
|
||
|
|
if getattr(recomputed, field) != getattr(recorded, field):
|
||
|
|
raise BundleMismatch(f"bundle artifact mismatch: {field}")
|
||
|
|
return recorded
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__": # pragma: no cover
|
||
|
|
sys.exit(0)
|