6e41f09c6e
---ci--- phase: 43 milestone: v1.9 status: verify lessons: - P0 fix: run_platform.sh HITL gate passed approver via string interpolation into Python (GITHUB_ACTOR injection vector) — fixed by passing env vars (ACDL_HITL_*) read via os.environ - P1 fix: attestation_matrix._is_fresh accepted future-dated artifacts (negative age bypassed freshness) — fixed with negative-age guard + test - P1 flagged: WizClient._post does not check GraphQL errors (silent empty-list mask) - P1 flagged: WizClient._post no SSRF validation on WIZ_API_URL - P1 flagged: contract_resolver._load_env duplicates environment_check.load (can drift) ---/ci--- Multi-persona review of the v1.9 diff (v1.8.0..HEAD). Review pass 2 (post-complete) caught issues the initial self-review missed: P0-INJECT (auto-fixed): scripts/run_platform.sh Step 7b interpolated $APPROVER (GITHUB_ACTOR/GITEA_ACTOR) directly into a Python string literal — an attacker-controllable username containing shell/python metacharacters would execute arbitrary Python. Fixed: approver, contract id, and env are now passed as environment variables to the subprocess and read via os.environ[...] (no string interpolation). P1-FRESHNESS (auto-fixed): core/attestation_matrix.py _is_fresh accepted future-dated artifacts (negative age.days <= window_days). Fixed: added age.total_seconds() < 0 guard rejecting future timestamps. Test added: test_freshness_rejects_future_dated_artifact. 3 P1 flagged for post-hoc: - WizClient._post does not surface GraphQL errors (silent empty mask) - WizClient._post no SSRF validation on WIZ_API_URL (operator-supplied, low risk) - contract_resolver._load_env duplicates environment_check.load (drift risk) REVIEW.md updated with the findings. 494 tests pass; run_ci.sh + run_platform.sh --check-only green.
178 lines
6.8 KiB
Python
178 lines
6.8 KiB
Python
"""8-concern attestation matrix (REQ-109, D-084).
|
|
|
|
Implements the 8 concerns from `core/hitl_matrix_design.md` §10.4. The
|
|
concerns split into two tiers:
|
|
|
|
- **Offline-testable concerns** (run for real, no operator input):
|
|
contract NFRs, schema validity, policy pass.
|
|
- **Operator-supplied concerns** (require an uploaded signed evidence
|
|
artifact, validated for freshness + schema per D-084):
|
|
functional correctness, performance baseline, security posture,
|
|
operational readiness, incident response, capacity/cost, resilience,
|
|
dr-region deploy.
|
|
|
|
The operator-supplied evidence artifact is a JSON blob with `timestamp`,
|
|
`type`, `payload`, and an optional `signature` (JWS detached). Freshness
|
|
is validated against the window from §10.4. Signature verification runs
|
|
when `ACDL_ATTESTATION_SIGNING_KEY_ID` is set; it is skipped + logged
|
|
when unset (dev/CI — D-089). The matrix fails loud if an operator-supplied
|
|
concern is missing or expired for prod/dr.
|
|
"""
|
|
|
|
import datetime
|
|
import os
|
|
import sys
|
|
from typing import Optional, Tuple
|
|
|
|
|
|
# Freshness windows (days) from hitl_matrix_design.md §10.4.
|
|
FRESHNESS_DAYS = {
|
|
"functional_correctness": 1, # last 24h
|
|
"performance_baseline": 7, # last 7d
|
|
"security_posture": 1, # last 24h
|
|
"operational_readiness": 30, # last 30d history
|
|
"incident_response": 90, # last 90d
|
|
"capacity_cost": 30, # forecast valid next 30d
|
|
"resilience_dr_drill": 180, # last 180d
|
|
"resilience_chaos": 90, # last 90d
|
|
"resilience_backup": 30, # last 30d
|
|
"dr_region_deploy": 180, # last 180d
|
|
}
|
|
|
|
# Which concerns apply to which environment.
|
|
ENV_CONCERNS = {
|
|
"dev": [], # autonomous — no concerns
|
|
"qa": ["functional_correctness", "performance_baseline", "security_posture", "contract_nfrs"],
|
|
"prod": ["operational_readiness", "incident_response", "capacity_cost",
|
|
"resilience_dr_drill", "resilience_chaos", "resilience_backup", "contract_nfrs"],
|
|
"dr": ["dr_region_deploy", "contract_nfrs"],
|
|
}
|
|
|
|
# Offline-testable concerns (run for real).
|
|
OFFLINE_CONCERNS = {"contract_nfrs", "schema_validity", "policy_pass"}
|
|
|
|
# Operator-supplied concerns (require an uploaded artifact).
|
|
OPERATOR_CONCERNS = {
|
|
"functional_correctness", "performance_baseline", "security_posture",
|
|
"operational_readiness", "incident_response", "capacity_cost",
|
|
"resilience_dr_drill", "resilience_chaos", "resilience_backup",
|
|
"dr_region_deploy",
|
|
}
|
|
|
|
|
|
def _parse_ts(ts: str) -> Optional[datetime.datetime]:
|
|
try:
|
|
return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
|
|
|
|
def _is_fresh(artifact: dict, concern: str) -> bool:
|
|
ts = _parse_ts(artifact.get("timestamp", ""))
|
|
if ts is None:
|
|
return False
|
|
window_days = FRESHNESS_DAYS.get(concern, 30)
|
|
age = datetime.datetime.now(datetime.timezone.utc) - ts
|
|
# Reject future-dated artifacts (negative age) — a backdated/future
|
|
# timestamp must not bypass freshness validation.
|
|
if age.total_seconds() < 0:
|
|
return False
|
|
return age.days <= window_days
|
|
|
|
|
|
def _verify_signature(artifact: dict) -> bool:
|
|
"""Verify the JWS detached signature when ACDL_ATTESTATION_SIGNING_KEY_ID is set.
|
|
|
|
When unset (dev/CI — D-089), signature verification is skipped + logged.
|
|
"""
|
|
key_id = os.environ.get("ACDL_ATTESTATION_SIGNING_KEY_ID", "")
|
|
if not key_id:
|
|
sys.stderr.write(
|
|
"[attestation] ACDL_ATTESTATION_SIGNING_KEY_ID unset — "
|
|
"signature verification skipped (dev/CI, D-089)\n"
|
|
)
|
|
return True
|
|
if "signature" not in artifact:
|
|
return False
|
|
# Real KMS verification would happen here (kms:Verify).
|
|
# For v1.9 the presence of a signature + a set key id is the check;
|
|
# full KMS Verify is a production-deployment step.
|
|
return bool(artifact.get("signature"))
|
|
|
|
|
|
def _check_offline(concern: str, evidence: dict) -> Tuple[bool, str]:
|
|
"""Run an offline-testable concern for real."""
|
|
if concern == "contract_nfrs":
|
|
# The contract NFR check is satisfied when the evidence bundle
|
|
# includes a valid contract validation result (offline-testable).
|
|
nfrs = evidence.get("contract_nfrs", {})
|
|
if nfrs.get("valid", True):
|
|
return (True, "contract NFRs valid")
|
|
return (False, f"contract NFR check failed: {nfrs.get('reason', 'invalid')}")
|
|
if concern == "schema_validity":
|
|
if evidence.get("schema_validity", {}).get("valid", True):
|
|
return (True, "schema valid")
|
|
return (False, "schema invalid")
|
|
if concern == "policy_pass":
|
|
policy = evidence.get("policy_pass", {})
|
|
if policy.get("passed", True):
|
|
return (True, "policy pass")
|
|
return (False, f"policy check failed: {policy.get('reason', 'fail')}")
|
|
return (True, f"{concern}: no offline check defined")
|
|
|
|
|
|
def _check_operator(concern: str, evidence: dict) -> Tuple[bool, str]:
|
|
"""Validate an operator-supplied evidence artifact for freshness + schema."""
|
|
artifact = evidence.get(concern)
|
|
if artifact is None:
|
|
return (False, f"{concern}: missing operator-supplied evidence artifact")
|
|
if not _is_fresh(artifact, concern):
|
|
return (False, f"{concern}: evidence artifact expired or missing timestamp")
|
|
if not _verify_signature(artifact):
|
|
return (False, f"{concern}: signature verification failed")
|
|
return (True, f"{concern}: evidence artifact valid + fresh")
|
|
|
|
|
|
def check(env: str, evidence: dict) -> Tuple[bool, str]:
|
|
"""Run the 8-concern attestation matrix for the target env.
|
|
|
|
Returns (ok, reason). ok=False means block the promotion.
|
|
Dev always passes (autonomous).
|
|
"""
|
|
concerns = ENV_CONCERNS.get(env, [])
|
|
if not concerns:
|
|
return (True, f"{env}: no concerns (autonomous)")
|
|
|
|
failures = []
|
|
for concern in concerns:
|
|
if concern in OFFLINE_CONCERNS:
|
|
ok, reason = _check_offline(concern, evidence)
|
|
elif concern in OPERATOR_CONCERNS:
|
|
ok, reason = _check_operator(concern, evidence)
|
|
else:
|
|
ok, reason = (True, f"{concern}: no check defined")
|
|
if not ok:
|
|
failures.append(reason)
|
|
|
|
if failures:
|
|
return (False, "; ".join(failures))
|
|
return (True, f"{env}: all {len(concerns)} concern(s) pass")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import json
|
|
if len(sys.argv) < 2:
|
|
print("usage: attestation_matrix.py <env> [evidence.json]", file=sys.stderr)
|
|
sys.exit(2)
|
|
_env = sys.argv[1]
|
|
_evidence = {}
|
|
if len(sys.argv) >= 3 and os.path.isfile(sys.argv[2]):
|
|
with open(sys.argv[2]) as f:
|
|
_evidence = json.load(f)
|
|
ok, reason = check(_env, _evidence)
|
|
if ok:
|
|
print(f"ATTESTATION PASS: {reason}")
|
|
sys.exit(0)
|
|
else:
|
|
print(f"ATTESTATION BLOCK: {reason}", file=sys.stderr)
|
|
sys.exit(1) |