feat: add PolicyPackSelectorComponent with tests and integration
- Implemented PolicyPackSelectorComponent for selecting policy packs. - Added unit tests for component behavior, including API success and error handling. - Introduced monaco-workers type declarations for editor workers. - Created acceptance tests for guardrails with stubs for AT1–AT10. - Established SCA Failure Catalogue Fixtures for regression testing. - Developed plugin determinism harness with stubs for PL1–PL10. - Added scripts for evidence upload and verification processes.
This commit is contained in:
49
scripts/packs/__fixtures__/bad/bundle-missing-quota.json
Normal file
49
scripts/packs/__fixtures__/bad/bundle-missing-quota.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.pack.offline-bundle.v1",
|
||||
"pack": {
|
||||
"name": "demo-pack",
|
||||
"version": "1.0.0",
|
||||
"bundle": "packs/demo-pack.tgz",
|
||||
"digest": "sha256:c0ffee0000000000000000000000000000000000000000000000000000000000",
|
||||
"registry": "registry.local/demo/demo-pack:1.0.0",
|
||||
"sbom": "sbom.json"
|
||||
},
|
||||
"plan": {
|
||||
"hashAlgorithm": "sha256",
|
||||
"hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"canonicalPlanPath": "canonical-plan.json",
|
||||
"inputsLock": "inputs.lock",
|
||||
"rngSeed": "seed-1111",
|
||||
"timestampSource": "utc-iso8601"
|
||||
},
|
||||
"evidence": {
|
||||
"attestation": "attestation.dsse",
|
||||
"approvalsLedger": "approvals-ledger.dsse"
|
||||
},
|
||||
"security": {
|
||||
"sandbox": {
|
||||
"mode": "sealed",
|
||||
"egressAllowlist": [],
|
||||
"cpuLimitMillicores": 250,
|
||||
"memoryLimitMiB": 256
|
||||
},
|
||||
"revocations": "revocations.json",
|
||||
"signatures": {
|
||||
"bundleDsse": "bundle.dsse",
|
||||
"attestationDsse": "attestation.dsse.sig",
|
||||
"registryCertChain": "certs.pem"
|
||||
},
|
||||
"secretsRedactionPolicy": "redaction-policy.json"
|
||||
},
|
||||
"hashes": [],
|
||||
"slo": {
|
||||
"runP95Seconds": 300,
|
||||
"approvalP95Seconds": 900,
|
||||
"maxQueueDepth": 1000,
|
||||
"alertRules": "alerts.yaml"
|
||||
},
|
||||
"tenant": "demo-tenant",
|
||||
"environment": "dev",
|
||||
"created": "2025-12-05T00:00:00Z",
|
||||
"verifyScriptVersion": "local-fixture"
|
||||
}
|
||||
13
scripts/packs/__fixtures__/good/approvals-ledger.dsse
Normal file
13
scripts/packs/__fixtures__/good/approvals-ledger.dsse
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.pack.approval-ledger.v1",
|
||||
"runId": "run-1",
|
||||
"gateId": "security-review",
|
||||
"planHash": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
|
||||
"decision": "approved",
|
||||
"decidedAt": "2025-12-05T00:00:00Z",
|
||||
"tenantId": "demo-tenant",
|
||||
"approver": {
|
||||
"id": "approver@example.com",
|
||||
"summary": "LGTM"
|
||||
}
|
||||
}
|
||||
1
scripts/packs/__fixtures__/good/attestation.dsse
Normal file
1
scripts/packs/__fixtures__/good/attestation.dsse
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/attestation.dsse.sig
Normal file
1
scripts/packs/__fixtures__/good/attestation.dsse.sig
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/bundle.dsse
Normal file
1
scripts/packs/__fixtures__/good/bundle.dsse
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
104
scripts/packs/__fixtures__/good/bundle.json
Normal file
104
scripts/packs/__fixtures__/good/bundle.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": "stellaops.pack.offline-bundle.v1",
|
||||
"pack": {
|
||||
"name": "demo-pack",
|
||||
"version": "1.0.0",
|
||||
"bundle": "packs/demo-pack.tgz",
|
||||
"digest": "sha256:c0ffee0000000000000000000000000000000000000000000000000000000000",
|
||||
"registry": "registry.local/demo/demo-pack:1.0.0",
|
||||
"sbom": "sbom.json"
|
||||
},
|
||||
"plan": {
|
||||
"hashAlgorithm": "sha256",
|
||||
"hash": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356",
|
||||
"canonicalPlanPath": "canonical-plan.json",
|
||||
"inputsLock": "inputs.lock",
|
||||
"rngSeed": "seed-1111",
|
||||
"timestampSource": "utc-iso8601"
|
||||
},
|
||||
"evidence": {
|
||||
"attestation": "attestation.dsse",
|
||||
"approvalsLedger": "approvals-ledger.dsse",
|
||||
"timeline": "timeline.ndjson"
|
||||
},
|
||||
"security": {
|
||||
"sandbox": {
|
||||
"mode": "sealed",
|
||||
"egressAllowlist": [],
|
||||
"cpuLimitMillicores": 250,
|
||||
"memoryLimitMiB": 256,
|
||||
"quotaSeconds": 120
|
||||
},
|
||||
"revocations": "revocations.json",
|
||||
"signatures": {
|
||||
"bundleDsse": "bundle.dsse",
|
||||
"attestationDsse": "attestation.dsse.sig",
|
||||
"registryCertChain": "certs.pem"
|
||||
},
|
||||
"secretsRedactionPolicy": "redaction-policy.json"
|
||||
},
|
||||
"hashes": [
|
||||
{
|
||||
"path": "canonical-plan.json",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "inputs.lock",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "sbom.json",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "attestation.dsse",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "approvals-ledger.dsse",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:2018f79642928cedd3b3716637b075d4d8374cc8997f58e00dd4fbf5addcea56"
|
||||
},
|
||||
{
|
||||
"path": "revocations.json",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "bundle.dsse",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "attestation.dsse.sig",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "redaction-policy.json",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
},
|
||||
{
|
||||
"path": "packs/demo-pack.tgz",
|
||||
"algorithm": "sha256",
|
||||
"digest": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
}
|
||||
],
|
||||
"slo": {
|
||||
"runP95Seconds": 300,
|
||||
"approvalP95Seconds": 900,
|
||||
"maxQueueDepth": 1000,
|
||||
"alertRules": "alerts.yaml"
|
||||
},
|
||||
"tenant": "demo-tenant",
|
||||
"environment": "dev",
|
||||
"created": "2025-12-05T00:00:00Z",
|
||||
"expires": "2026-01-05T00:00:00Z",
|
||||
"verifyScriptVersion": "local-fixture",
|
||||
"hash": "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"
|
||||
}
|
||||
1
scripts/packs/__fixtures__/good/canonical-plan.json
Normal file
1
scripts/packs/__fixtures__/good/canonical-plan.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
10
scripts/packs/__fixtures__/good/files.txt
Normal file
10
scripts/packs/__fixtures__/good/files.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
canonical-plan.json
|
||||
inputs.lock
|
||||
sbom.json
|
||||
attestation.dsse
|
||||
approvals-ledger.dsse
|
||||
revocations.json
|
||||
bundle.dsse
|
||||
attestation.dsse.sig
|
||||
redaction-policy.json
|
||||
packs/demo-pack.tgz
|
||||
1
scripts/packs/__fixtures__/good/inputs.lock
Normal file
1
scripts/packs/__fixtures__/good/inputs.lock
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/packs/demo-pack.tgz
Normal file
1
scripts/packs/__fixtures__/good/packs/demo-pack.tgz
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/redaction-policy.json
Normal file
1
scripts/packs/__fixtures__/good/redaction-policy.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/revocations.json
Normal file
1
scripts/packs/__fixtures__/good/revocations.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
scripts/packs/__fixtures__/good/sbom.json
Normal file
1
scripts/packs/__fixtures__/good/sbom.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
BIN
scripts/packs/__pycache__/verify_offline_bundle.cpython-312.pyc
Normal file
BIN
scripts/packs/__pycache__/verify_offline_bundle.cpython-312.pyc
Normal file
Binary file not shown.
7
scripts/packs/run-fixtures-check.sh
Normal file
7
scripts/packs/run-fixtures-check.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root_dir=$(cd "$(dirname "$0")/.." && pwd)
|
||||
verifier="$root_dir/packs/verify_offline_bundle.py"
|
||||
python3 "$verifier" --bundle "$root_dir/packs/__fixtures__/good" --manifest bundle.json --require-dsse
|
||||
python3 "$verifier" --bundle "$root_dir/packs/__fixtures__/bad" --manifest bundle-missing-quota.json --require-dsse && exit 1 || true
|
||||
echo "fixture checks completed"
|
||||
180
scripts/packs/test_verify_offline_bundle.py
Normal file
180
scripts/packs/test_verify_offline_bundle.py
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
_VERIFIER_PATH = Path(__file__).parent / "verify_offline_bundle.py"
|
||||
_mod = runpy.run_path(_VERIFIER_PATH.as_posix(), run_name="verify_offline_bundle")
|
||||
|
||||
BundleReader = _mod["BundleReader"]
|
||||
validate_manifest = _mod["validate_manifest"]
|
||||
verify_files = _mod["verify_files"]
|
||||
verify_hashes = _mod["verify_hashes"]
|
||||
sha256_digest = _mod["sha256_digest"]
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return sha256_digest(content.encode("utf-8"))
|
||||
|
||||
|
||||
class VerifyOfflineBundleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _build_manifest(self) -> Path:
|
||||
plan_hash = _write(self.root / "canonical-plan.json", '{"steps":[]}')
|
||||
inputs_lock_hash = _write(self.root / "inputs.lock", '{"inputs":{}}')
|
||||
sbom_hash = _write(self.root / "sbom.json", '{"bom":"demo"}')
|
||||
attest_hash = _write(self.root / "attestation.dsse", "attestation")
|
||||
approvals_ledger = json.dumps(
|
||||
{
|
||||
"schemaVersion": "stellaops.pack.approval-ledger.v1",
|
||||
"runId": "run-1",
|
||||
"gateId": "security-review",
|
||||
"planHash": plan_hash,
|
||||
"decision": "approved",
|
||||
"decidedAt": "2025-12-05T00:00:00Z",
|
||||
"tenantId": "demo-tenant",
|
||||
"approver": {"id": "approver@example.com", "summary": "LGTM"},
|
||||
}
|
||||
)
|
||||
approvals_hash = _write(self.root / "approvals-ledger.dsse", approvals_ledger)
|
||||
revocations_hash = _write(self.root / "revocations.json", '{"revoked":false}')
|
||||
bundle_dsse_hash = _write(self.root / "bundle.dsse", "bundle-dsse")
|
||||
att_dsse_hash = _write(self.root / "attestation.dsse.sig", "att-dsse")
|
||||
redaction_hash = _write(self.root / "redaction-policy.json", '{"mode":"hash"}')
|
||||
pack_blob_hash = _write(self.root / "packs/my-pack.tgz", "dummy pack")
|
||||
|
||||
manifest = {
|
||||
"schemaVersion": "stellaops.pack.offline-bundle.v1",
|
||||
"pack": {
|
||||
"name": "demo-pack",
|
||||
"version": "1.0.0",
|
||||
"bundle": "packs/my-pack.tgz",
|
||||
"digest": pack_blob_hash,
|
||||
"registry": "demo.local/pack/demo:1.0.0",
|
||||
"sbom": "sbom.json",
|
||||
},
|
||||
"plan": {
|
||||
"hashAlgorithm": "sha256",
|
||||
"hash": plan_hash,
|
||||
"canonicalPlanPath": "canonical-plan.json",
|
||||
"inputsLock": "inputs.lock",
|
||||
"rngSeed": "rng-demo",
|
||||
"timestampSource": "utc-iso8601",
|
||||
},
|
||||
"evidence": {
|
||||
"attestation": "attestation.dsse",
|
||||
"approvalsLedger": "approvals-ledger.dsse",
|
||||
"timeline": "timeline.ndjson",
|
||||
},
|
||||
"security": {
|
||||
"sandbox": {
|
||||
"mode": "sealed",
|
||||
"egressAllowlist": [],
|
||||
"cpuLimitMillicores": 250,
|
||||
"memoryLimitMiB": 256,
|
||||
"quotaSeconds": 120,
|
||||
},
|
||||
"revocations": "revocations.json",
|
||||
"signatures": {
|
||||
"bundleDsse": "bundle.dsse",
|
||||
"attestationDsse": "attestation.dsse.sig",
|
||||
"registryCertChain": "certs.pem",
|
||||
},
|
||||
"secretsRedactionPolicy": "redaction-policy.json",
|
||||
},
|
||||
"hashes": [
|
||||
{"path": "canonical-plan.json", "algorithm": "sha256", "digest": plan_hash},
|
||||
{"path": "inputs.lock", "algorithm": "sha256", "digest": inputs_lock_hash},
|
||||
{"path": "sbom.json", "algorithm": "sha256", "digest": sbom_hash},
|
||||
{"path": "attestation.dsse", "algorithm": "sha256", "digest": attest_hash},
|
||||
{"path": "approvals-ledger.dsse", "algorithm": "sha256", "digest": approvals_hash},
|
||||
{"path": "revocations.json", "algorithm": "sha256", "digest": revocations_hash},
|
||||
{"path": "bundle.dsse", "algorithm": "sha256", "digest": bundle_dsse_hash},
|
||||
{"path": "attestation.dsse.sig", "algorithm": "sha256", "digest": att_dsse_hash},
|
||||
{"path": "redaction-policy.json", "algorithm": "sha256", "digest": redaction_hash},
|
||||
{"path": "packs/my-pack.tgz", "algorithm": "sha256", "digest": pack_blob_hash},
|
||||
],
|
||||
"slo": {
|
||||
"runP95Seconds": 300,
|
||||
"approvalP95Seconds": 900,
|
||||
"maxQueueDepth": 1000,
|
||||
"alertRules": "alerts.yaml",
|
||||
},
|
||||
"tenant": "demo-tenant",
|
||||
"environment": "dev",
|
||||
"created": "2025-12-05T00:00:00Z",
|
||||
"expires": "2026-01-05T00:00:00Z",
|
||||
"verifyScriptVersion": "local-test",
|
||||
}
|
||||
manifest_path = self.root / "bundle.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
return manifest_path
|
||||
|
||||
def test_good_bundle_passes(self):
|
||||
manifest_path = self._build_manifest()
|
||||
reader = BundleReader(self.root.as_posix())
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
|
||||
errors = []
|
||||
errors.extend(validate_manifest(manifest))
|
||||
errors.extend(verify_files(reader, manifest, require_dsse=True))
|
||||
errors.extend(verify_hashes(reader, manifest))
|
||||
self.assertFalse(errors, f"Expected no validation errors, got: {errors}")
|
||||
|
||||
def test_missing_hash_fails(self):
|
||||
manifest_path = self._build_manifest()
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
# Corrupt a hash to force a failure.
|
||||
manifest["hashes"][0]["digest"] = "sha256:" + "0" * 64
|
||||
reader = BundleReader(self.root.as_posix())
|
||||
errors = verify_hashes(reader, manifest)
|
||||
self.assertTrue(errors, "Expected hash verification to fail when hash entry is missing")
|
||||
|
||||
def test_missing_quota_fails(self):
|
||||
manifest_path = self._build_manifest()
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
del manifest["security"]["sandbox"]["quotaSeconds"]
|
||||
reader = BundleReader(self.root.as_posix())
|
||||
|
||||
errors = []
|
||||
errors.extend(validate_manifest(manifest))
|
||||
errors.extend(verify_files(reader, manifest, require_dsse=True))
|
||||
errors.extend(verify_hashes(reader, manifest))
|
||||
|
||||
self.assertTrue(
|
||||
any(err.path == "security.sandbox.quotaSeconds" for err in errors),
|
||||
"Expected quotaSeconds validation failure"
|
||||
)
|
||||
|
||||
def test_invalid_approval_ledger_plan_hash_fails(self):
|
||||
manifest_path = self._build_manifest()
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
ledger_path = self.root / "approvals-ledger.dsse"
|
||||
ledger = json.loads(ledger_path.read_text())
|
||||
ledger["planHash"] = "not-a-digest"
|
||||
ledger_path.write_text(json.dumps(ledger), encoding="utf-8")
|
||||
|
||||
reader = BundleReader(self.root.as_posix())
|
||||
errors = []
|
||||
errors.extend(validate_manifest(manifest))
|
||||
errors.extend(verify_files(reader, manifest, require_dsse=True))
|
||||
errors.extend(verify_hashes(reader, manifest))
|
||||
|
||||
self.assertTrue(
|
||||
any(err.path.startswith("approvalsLedger.planHash") for err in errors),
|
||||
"Expected approval ledger plan hash validation failure"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,8 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import re
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
@@ -37,7 +39,9 @@ class BundleReader:
|
||||
def __init__(self, bundle_path: str):
|
||||
self.bundle_path = bundle_path
|
||||
self._tar: Optional[tarfile.TarFile] = None
|
||||
if tarfile.is_tarfile(bundle_path):
|
||||
if os.path.isdir(bundle_path):
|
||||
self._tar = None
|
||||
elif tarfile.is_tarfile(bundle_path):
|
||||
self._tar = tarfile.open(bundle_path, mode="r:*")
|
||||
|
||||
def exists(self, path: str) -> bool:
|
||||
@@ -73,9 +77,14 @@ def parse_args() -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bundle",
|
||||
required=True,
|
||||
required=False,
|
||||
help="Path to bundle directory or tarball containing bundle manifest + artefacts.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fixture",
|
||||
choices=["good", "bad"],
|
||||
help="If set, uses built-in fixtures under scripts/packs/__fixtures__/ for quick checks.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
default="bundle.json",
|
||||
@@ -154,6 +163,7 @@ def validate_manifest(manifest: Dict) -> List[ValidationError]:
|
||||
for quota_field in [
|
||||
("security.sandbox.cpuLimitMillicores", sandbox.get("cpuLimitMillicores")),
|
||||
("security.sandbox.memoryLimitMiB", sandbox.get("memoryLimitMiB")),
|
||||
("security.sandbox.quotaSeconds", sandbox.get("quotaSeconds")),
|
||||
]:
|
||||
field, value = quota_field
|
||||
if value is None or not isinstance(value, (int, float)) or value <= 0:
|
||||
@@ -241,12 +251,40 @@ def verify_files(reader: BundleReader, manifest: Dict, require_dsse: bool) -> Li
|
||||
f"hash mismatch (expected {expected_plan_hash}, got {actual_plan_hash})",
|
||||
)
|
||||
)
|
||||
|
||||
approvals_path = manifest.get("evidence", {}).get("approvalsLedger")
|
||||
if approvals_path and reader.exists(approvals_path):
|
||||
try:
|
||||
approval_doc = json.loads(reader.read_bytes(approvals_path))
|
||||
errors.extend(validate_approval_ledger(approval_doc, manifest.get("plan", {}).get("hash")))
|
||||
except Exception as exc: # broad but scoped to ledger parse
|
||||
errors.append(ValidationError("evidence.approvalsLedger", f"failed to parse ledger JSON: {exc}"))
|
||||
return errors
|
||||
|
||||
|
||||
def validate_approval_ledger(doc: Dict, expected_plan_hash: Optional[str]) -> List[ValidationError]:
|
||||
errors: List[ValidationError] = []
|
||||
if doc.get("schemaVersion") != "stellaops.pack.approval-ledger.v1":
|
||||
errors.append(ValidationError("approvalsLedger.schemaVersion", "must be stellaops.pack.approval-ledger.v1"))
|
||||
for field in ["runId", "gateId", "tenantId", "decision", "planHash", "decidedAt"]:
|
||||
if not doc.get(field):
|
||||
errors.append(ValidationError(f"approvalsLedger.{field}", "is required"))
|
||||
plan_hash = doc.get("planHash")
|
||||
if plan_hash and not re.match(r"^sha256:[0-9a-f]{64}$", plan_hash, re.IGNORECASE):
|
||||
errors.append(ValidationError("approvalsLedger.planHash", "must be sha256:<64-hex>"))
|
||||
if expected_plan_hash and plan_hash and plan_hash.lower() != expected_plan_hash.lower():
|
||||
errors.append(ValidationError("approvalsLedger.planHash", "must match manifest.plan.hash"))
|
||||
if doc.get("decision") not in {"approved", "rejected", "expired"}:
|
||||
errors.append(ValidationError("approvalsLedger.decision", "must be approved|rejected|expired"))
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
reader = BundleReader(args.bundle)
|
||||
bundle_path = args.bundle
|
||||
if args.fixture:
|
||||
bundle_path = Path(__file__).parent / "__fixtures__" / args.fixture
|
||||
reader = BundleReader(bundle_path.as_posix() if isinstance(bundle_path, Path) else bundle_path)
|
||||
try:
|
||||
manifest = load_manifest(reader, args.manifest)
|
||||
errors: List[ValidationError] = []
|
||||
|
||||
Reference in New Issue
Block a user