From b2e350fbc2cc4a42724217aeb44e343b1473e10a Mon Sep 17 00:00:00 2001 From: Saagar Date: Sat, 4 Jul 2026 04:36:14 -0700 Subject: [PATCH] Add freshness threshold to security gate --- docs/security-model.md | 4 ++ src/cli.py | 11 ++++- src/portfolio_security_gate.py | 65 +++++++++++++++++++++++-- tests/test_portfolio_security_gate.py | 68 ++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/docs/security-model.md b/docs/security-model.md index 36a5578..5ac59de 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -66,6 +66,10 @@ Run `audit security-gate --output-dir output` after generating portfolio truth w `output/portfolio-truth-latest.json` and exits nonzero when any scanned repo has open high/critical Dependabot alerts. +Use `--max-age-hours N` when the caller needs freshness enforcement. For example, +`audit security-gate --output-dir output --max-age-hours 168` fails as `STALE` when +the portfolio truth snapshot is more than seven days old. + The gate is deliberately strict: a snapshot with no scanned security overlay is reported as `UNKNOWN`, not clear. This prevents a missing GHAS overlay from looking like a clean portfolio. diff --git a/src/cli.py b/src/cli.py index feec1df..1506074 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1550,6 +1550,12 @@ def _build_security_gate_subparser(subparsers: argparse._SubParsersAction) -> No action="store_true", help="Print machine-readable JSON instead of Markdown", ) + p.add_argument( + "--max-age-hours", + type=int, + default=None, + help="Fail as stale when portfolio-truth generated_at is older than this many hours", + ) def build_subcommand_parser() -> argparse.ArgumentParser: @@ -7191,7 +7197,10 @@ def _run_security_gate_mode(args) -> None: print_info(f"{truth_path} is not a portfolio-truth object.") raise SystemExit(1) - report = build_security_gate_report(portfolio_truth) + report = build_security_gate_report( + portfolio_truth, + max_age_hours=getattr(args, "max_age_hours", None), + ) if getattr(args, "json", False): print(json.dumps(report.to_dict(), indent=2)) else: diff --git a/src/portfolio_security_gate.py b/src/portfolio_security_gate.py index 23598f8..d69eed4 100644 --- a/src/portfolio_security_gate.py +++ b/src/portfolio_security_gate.py @@ -8,6 +8,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any @@ -38,6 +39,9 @@ class SecurityGateReport: total_open_critical: int total_open_high: int flagged_repos: tuple[SecurityGateItem, ...] + max_age_hours: int | None = None + source_age_hours: float | None = None + freshness_error: str | None = None @property def repos_with_open_high_critical(self) -> int: @@ -45,12 +49,28 @@ def repos_with_open_high_critical(self) -> int: @property def passed(self) -> bool: - return self.scanned_count > 0 and self.repos_with_open_high_critical == 0 + return ( + self.scanned_count > 0 + and self.repos_with_open_high_critical == 0 + and not self.is_stale + ) + + @property + def is_stale(self) -> bool: + if self.max_age_hours is None: + return False + if self.freshness_error: + return True + if self.source_age_hours is None: + return True + return self.source_age_hours > self.max_age_hours @property def status(self) -> str: if self.scanned_count <= 0: return "unknown" + if self.is_stale: + return "stale" if self.repos_with_open_high_critical > 0: return "fail" return "pass" @@ -64,6 +84,9 @@ def to_dict(self) -> dict[str, Any]: "repos_with_open_high_critical": self.repos_with_open_high_critical, "total_open_critical": self.total_open_critical, "total_open_high": self.total_open_high, + "max_age_hours": self.max_age_hours, + "source_age_hours": self.source_age_hours, + "freshness_error": self.freshness_error, "flagged_repos": [item.to_dict() for item in self.flagged_repos], } @@ -83,12 +106,37 @@ def _int(value: Any) -> int: return 0 -def build_security_gate_report(portfolio_truth: dict[str, Any]) -> SecurityGateReport: +def _source_age_hours(generated_at: str, now: datetime) -> tuple[float | None, str | None]: + if not generated_at or generated_at == "unknown": + return None, "missing generated_at" + try: + parsed = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + except ValueError: + return None, f"invalid generated_at: {generated_at}" + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + age_hours = (now.astimezone(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds() + return round(age_hours / 3600, 3), None + + +def build_security_gate_report( + portfolio_truth: dict[str, Any], + *, + max_age_hours: int | None = None, + now: datetime | None = None, +) -> SecurityGateReport: projects = portfolio_truth.get("projects") or [] scanned_count = 0 total_critical = 0 total_high = 0 flagged: list[SecurityGateItem] = [] + generated_at = _text(portfolio_truth.get("generated_at")) or "unknown" + source_age_hours = freshness_error = None + if max_age_hours is not None: + source_age_hours, freshness_error = _source_age_hours( + generated_at, + now or datetime.now(timezone.utc), + ) for project in projects: if not isinstance(project, dict): @@ -123,11 +171,14 @@ def build_security_gate_report(portfolio_truth: dict[str, Any]) -> SecurityGateR flagged.sort(key=lambda item: (-item.critical, -item.high, item.repo.lower())) return SecurityGateReport( - generated_at=_text(portfolio_truth.get("generated_at")) or "unknown", + generated_at=generated_at, scanned_count=scanned_count, total_open_critical=total_critical, total_open_high=total_high, flagged_repos=tuple(flagged), + max_age_hours=max_age_hours, + source_age_hours=source_age_hours, + freshness_error=freshness_error, ) @@ -149,6 +200,14 @@ def render_security_gate_markdown(report: SecurityGateReport) -> str: "Security overlay was not present in the snapshot. Re-run portfolio truth with " "`--portfolio-truth-include-security` before treating the portfolio as clear." ) + elif report.status == "stale": + if report.freshness_error: + lines.append(f"Portfolio truth freshness could not be verified: {report.freshness_error}.") + else: + lines.append( + f"Portfolio truth is {report.source_age_hours:.1f}h old, beyond the " + f"{report.max_age_hours}h freshness threshold." + ) elif report.passed: lines.append("All scanned repos are clear of open high/critical Dependabot alerts.") else: diff --git a/tests/test_portfolio_security_gate.py b/tests/test_portfolio_security_gate.py index 95705ff..0ee7fc0 100644 --- a/tests/test_portfolio_security_gate.py +++ b/tests/test_portfolio_security_gate.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from datetime import datetime, timezone from types import SimpleNamespace import pytest @@ -49,6 +50,54 @@ def test_security_gate_passes_when_scanned_repos_are_clear() -> None: assert "All scanned repos are clear" in render_security_gate_markdown(report) +def test_security_gate_passes_when_snapshot_is_fresh_enough() -> None: + report = build_security_gate_report( + { + "generated_at": "2026-07-04T11:00:00+00:00", + "projects": [_project("RepoA")], + }, + max_age_hours=24, + now=datetime(2026, 7, 4, 12, 30, tzinfo=timezone.utc), + ) + + assert report.passed is True + assert report.status == "pass" + assert report.source_age_hours == 1.5 + assert report.max_age_hours == 24 + + +def test_security_gate_marks_old_snapshot_stale() -> None: + report = build_security_gate_report( + { + "generated_at": "2026-07-01T11:00:00+00:00", + "projects": [_project("RepoA")], + }, + max_age_hours=24, + now=datetime(2026, 7, 4, 12, 0, tzinfo=timezone.utc), + ) + + assert report.passed is False + assert report.status == "stale" + assert report.source_age_hours == 73 + assert "beyond the 24h freshness threshold" in render_security_gate_markdown(report) + + +def test_security_gate_marks_invalid_generated_at_stale() -> None: + report = build_security_gate_report( + { + "generated_at": "not-a-date", + "projects": [_project("RepoA")], + }, + max_age_hours=24, + now=datetime(2026, 7, 4, 12, 0, tzinfo=timezone.utc), + ) + + assert report.passed is False + assert report.status == "stale" + assert report.freshness_error == "invalid generated_at: not-a-date" + assert "could not be verified" in render_security_gate_markdown(report) + + def test_security_gate_fails_and_ranks_open_high_critical_repos() -> None: report = build_security_gate_report( { @@ -89,11 +138,14 @@ def test_security_gate_treats_missing_overlay_as_unknown_not_pass() -> None: def test_security_gate_subcommand_parses() -> None: parser = build_subcommand_parser() - args = parser.parse_args(["security-gate", "--output-dir", "out", "--json"]) + args = parser.parse_args( + ["security-gate", "--output-dir", "out", "--json", "--max-age-hours", "24"] + ) assert args._subcommand == "security-gate" assert args.output_dir == "out" assert args.json is True + assert args.max_age_hours == 24 def test_security_gate_cli_json_exits_zero_on_clear_snapshot(tmp_path, capsys) -> None: @@ -109,6 +161,20 @@ def test_security_gate_cli_json_exits_zero_on_clear_snapshot(tmp_path, capsys) - assert payload["scanned_count"] == 1 +def test_security_gate_cli_exits_nonzero_on_stale_snapshot(tmp_path) -> None: + (tmp_path / "portfolio-truth-latest.json").write_text( + json.dumps({"generated_at": "2026-07-01T11:00:00+00:00", "projects": [_project("Clear")]}), + encoding="utf-8", + ) + + with pytest.raises(SystemExit) as exc: + _run_security_gate_mode( + SimpleNamespace(output_dir=str(tmp_path), json=True, max_age_hours=24) + ) + + assert exc.value.code == 1 + + def test_security_gate_cli_exits_nonzero_on_open_alerts(tmp_path) -> None: (tmp_path / "portfolio-truth-latest.json").write_text( json.dumps({"projects": [_project("Open", high=1)]}),