net-kingdom/tools/posture-feedback/posture_feedback.py
tegwick cfc9e7d0cb
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
feat(posture): add deterministic feedback proposals
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02929-244b-7391-b933-c04010e8eedb
2026-08-23 13:16:34 +02:00

440 lines
14 KiB
Python

#!/usr/bin/env python3
# /// script
# dependencies = ["jsonschema>=4.23,<5", "PyYAML>=6,<7"]
# ///
"""Emit deterministic, proposal-only feedback for tenancy posture declarations."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import importlib.util
import json
import pathlib
import sys
from collections import Counter
from typing import Any
import jsonschema
import yaml
ROOT = pathlib.Path(__file__).resolve().parents[2]
TENANCY_SCHEMA = ROOT / "canon/schemas/tenancy-posture_v0.1.schema.json"
REPORT_SCHEMA = ROOT / "canon/schemas/posture-feedback-report_v0.1.schema.json"
TENANCY_VALIDATOR_PATH = ROOT / "tools/tenancy-posture/validate.py"
API_VERSION = "netkingdom.io/posture-feedback/v0.1"
KIND = "PostureFeedbackReport"
ADVERSARIAL_LEVELS = {"E2", "R4", "V2", "V3", "V4"}
SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3}
SPEC = importlib.util.spec_from_file_location(
"tenancy_posture_validator_for_feedback", TENANCY_VALIDATOR_PATH
)
assert SPEC and SPEC.loader
TENANCY_VALIDATOR = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(TENANCY_VALIDATOR)
def parse_timestamp(value: str) -> dt.datetime:
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError("timestamp must include an explicit UTC offset")
return parsed.astimezone(dt.timezone.utc)
def format_timestamp(value: dt.datetime) -> str:
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
def load_document(path: pathlib.Path) -> dict[str, Any]:
document = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(document, dict):
raise ValueError("document must be a YAML object")
return document
def service_entries(document: dict[str, Any]) -> list[dict[str, Any]]:
if "services" in document:
return document["services"]
return [document]
def source_identifier(path: pathlib.Path) -> str:
"""Return a portable declaration id without treating the path as ownership."""
resolved = path.resolve()
try:
return resolved.relative_to(ROOT.parent).as_posix()
except ValueError:
return resolved.as_posix()
def finding_id(
source: str,
service: str,
finding_class: str,
control: str,
due: str | None,
) -> str:
identity = "\x1f".join((source, service, finding_class, control, due or ""))
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
return f"NKFB-{digest}"
def finding(
*,
source: str,
service: str,
finding_class: str,
severity: str,
control: str,
owner: str,
evidence_state: str,
reason: str,
recommended_action: str,
due: str | None = None,
) -> dict[str, Any]:
result = {
"id": finding_id(source, service, finding_class, control, due),
"class": finding_class,
"severity": severity,
"source": source,
"service": service,
"control": control,
"owner": owner,
"evidence_state": evidence_state,
"reason": reason,
"recommended_action": recommended_action,
}
if due is not None:
result["due"] = due
return result
def review_finding(
*,
source: str,
service: str,
owner: str,
control: str,
due: dt.date,
as_of: dt.datetime,
horizon_days: int,
prefix: str,
) -> dict[str, Any] | None:
due_text = due.isoformat()
if as_of.date() > due:
return finding(
source=source,
service=service,
finding_class=f"{prefix}-review-overdue",
severity="high",
control=control,
owner=owner,
evidence_state="overdue",
due=due_text,
reason=f"{control} review was due on {due_text} and is overdue at {format_timestamp(as_of)}.",
recommended_action=f"Review {control}, update its declaration and evidence, and retain the review record.",
)
days = (due - as_of.date()).days
if days <= horizon_days:
return finding(
source=source,
service=service,
finding_class=f"{prefix}-review-due-soon",
severity="medium",
control=control,
owner=owner,
evidence_state="due-soon",
due=due_text,
reason=f"{control} review is due in {days} day(s), within the {horizon_days}-day horizon.",
recommended_action=f"Schedule and evidence the {control} review before {due_text}.",
)
return None
def current_evidence_keys(entry: dict[str, Any]) -> set[str]:
current = entry["tenancy"]["current"]
return {
f"{axis}{level}"
for axis, level in current.items()
if isinstance(level, int)
}
def evidence_findings(
*,
source: str,
entry: dict[str, Any],
as_of: dt.datetime,
horizon_days: int,
) -> list[dict[str, Any]]:
service = entry["service"]
freshness = entry.get("evidence_freshness", {})
current_keys = current_evidence_keys(entry)
results: list[dict[str, Any]] = []
for key in sorted(ADVERSARIAL_LEVELS & current_keys):
if key not in freshness:
results.append(
finding(
source=source,
service=service,
finding_class="evidence-freshness-unknown",
severity="high",
control=key,
owner="unknown",
evidence_state="unknown",
reason=f"Current adversarial claim {key} has no authoritative evidence_freshness entry.",
recommended_action=(
f"Declare the {key} observation, expiry, bounded scope, responsible repository, "
"and replacement action; do not infer freshness from prose."
),
)
)
for key in sorted(current_keys & set(freshness)):
metadata = freshness[key]
valid_until_raw = metadata.get("valid_until")
if not valid_until_raw:
continue
valid_until = parse_timestamp(valid_until_raw)
owner = metadata["responsible_repo"]
if as_of > valid_until:
results.append(
finding(
source=source,
service=service,
finding_class="evidence-expired",
severity="high",
control=key,
owner=owner,
evidence_state="expired",
due=format_timestamp(valid_until),
reason=f"Evidence for {key} expired before {format_timestamp(as_of)}.",
recommended_action=metadata["remediation"],
)
)
continue
remaining = valid_until - as_of
if remaining <= dt.timedelta(days=horizon_days):
results.append(
finding(
source=source,
service=service,
finding_class="evidence-due-soon",
severity="medium",
control=key,
owner=owner,
evidence_state="due-soon",
due=format_timestamp(valid_until),
reason=(
f"Evidence for {key} expires within the {horizon_days}-day horizon "
f"at {format_timestamp(valid_until)}."
),
recommended_action=metadata["remediation"],
)
)
return results
def evaluate_entry(
source: str,
entry: dict[str, Any],
as_of: dt.datetime,
horizon_days: int,
) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
service = entry["service"]
posture = entry["tenancy"]
posture_owner = entry.get("responsible_repo", "unknown")
posture_review = review_finding(
source=source,
service=service,
owner=posture_owner,
control="tenancy-posture",
due=dt.date.fromisoformat(posture["review_due"]),
as_of=as_of,
horizon_days=horizon_days,
prefix="posture",
)
if posture_review:
results.append(posture_review)
zones = entry.get("zones")
if zones:
zone_review = review_finding(
source=source,
service=service,
owner=zones["responsible_party"],
control="security-zones",
due=dt.date.fromisoformat(zones["review_due"]),
as_of=as_of,
horizon_days=horizon_days,
prefix="zone",
)
if zone_review:
results.append(zone_review)
results.extend(
evidence_findings(
source=source,
entry=entry,
as_of=as_of,
horizon_days=horizon_days,
)
)
current = posture["current"]
for axis, level in sorted(posture.get("implemented", {}).items()):
current_level = current[axis]
results.append(
finding(
source=source,
service=service,
finding_class="implemented-not-evidenced",
severity="medium",
control=f"{axis}{level}",
owner=posture_owner,
evidence_state="implemented",
reason=f"{axis}{level} is implemented while the current evidenced level is {axis}{current_level}.",
recommended_action=f"Produce the canon-required evidence for {axis}{level} or remove the implemented claim.",
)
)
for axis, description in sorted(posture.get("gap", {}).items()):
results.append(
finding(
source=source,
service=service,
finding_class="declared-gap",
severity="low",
control=axis,
owner=posture_owner,
evidence_state="gap",
reason=description,
recommended_action=f"Review the declared {axis}-axis gap and retain, schedule, or resolve it explicitly.",
)
)
return results
def report_digest(report_without_digest: dict[str, Any]) -> str:
encoded = json.dumps(
report_without_digest,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return f"sha256:{hashlib.sha256(encoded).hexdigest()}"
def build_report(
paths: list[pathlib.Path],
*,
as_of: dt.datetime,
horizon_days: int,
fail_on: str,
) -> dict[str, Any]:
tenancy_schema = json.loads(TENANCY_SCHEMA.read_text(encoding="utf-8"))
validation_errors: list[str] = []
findings: list[dict[str, Any]] = []
for path in paths:
source = source_identifier(path)
try:
document = load_document(path)
except Exception as exc:
validation_errors.append(f"{path}: {exc}")
continue
errors = TENANCY_VALIDATOR.validate(path, tenancy_schema)
if errors:
validation_errors.extend(errors)
continue
for entry in service_entries(document):
findings.extend(evaluate_entry(source, entry, as_of, horizon_days))
findings.sort(
key=lambda item: (
item["source"],
item["service"],
item["class"],
item["control"],
item.get("due", ""),
)
)
counts = Counter(item["severity"] for item in findings)
threshold_failed = False
if fail_on != "none":
threshold = SEVERITY_RANK[fail_on]
threshold_failed = any(
SEVERITY_RANK[item["severity"]] >= threshold for item in findings
)
report: dict[str, Any] = {
"apiVersion": API_VERSION,
"kind": KIND,
"ok": not validation_errors and not threshold_failed,
"as_of": format_timestamp(as_of),
"horizon_days": horizon_days,
"fail_on": fail_on,
"automation": {
"mode": "proposal-only",
"external_write_permitted": False,
"policy_mutation_permitted": False,
"declaration_mutation_permitted": False,
},
"summary": {
"total": len(findings),
"high": counts["high"],
"medium": counts["medium"],
"low": counts["low"],
"unknown_owner": sum(item["owner"] == "unknown" for item in findings),
},
"validation_errors": sorted(validation_errors),
"findings": findings,
}
report["report_digest"] = report_digest(report)
report_schema = json.loads(REPORT_SCHEMA.read_text(encoding="utf-8"))
jsonschema.Draft202012Validator(
report_schema,
format_checker=jsonschema.FormatChecker(),
).validate(report)
return report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Emit deterministic, proposal-only NetKingdom posture feedback."
)
parser.add_argument("declarations", nargs="+", type=pathlib.Path)
parser.add_argument("--as-of", required=True, help="RFC 3339 timestamp with explicit offset")
parser.add_argument("--horizon-days", type=int, default=30)
parser.add_argument("--fail-on", choices=("none", "low", "medium", "high"), default="high")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.horizon_days < 0:
raise SystemExit("--horizon-days must be non-negative")
try:
as_of = parse_timestamp(args.as_of)
except ValueError as exc:
raise SystemExit(f"invalid --as-of: {exc}") from exc
report = build_report(
args.declarations,
as_of=as_of,
horizon_days=args.horizon_days,
fail_on=args.fail_on,
)
print(json.dumps(report, indent=2, sort_keys=True))
if report["validation_errors"]:
return 2
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())