Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 62 additions & 3 deletions src/portfolio_security_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any


Expand Down Expand Up @@ -38,19 +39,38 @@ 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:
return len(self.flagged_repos)

@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"
Expand All @@ -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],
}

Expand All @@ -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
Comment on lines +118 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject future-dated truth snapshots

When --max-age-hours is used and generated_at is later than now (for example because the truth file was produced with a bad clock or corrupted timestamp), this returns a negative source_age_hours with no freshness_error. Since SecurityGateReport.is_stale only checks source_age_hours > max_age_hours, the gate can report a clear scanned snapshot as fresh until wall time catches up, even though the repo's own seam-linter treats future generated_at values as a freshness violation. Please fail freshness validation for future timestamps as well.

Useful? React with 👍 / 👎.



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):
Expand Down Expand Up @@ -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,
)


Expand All @@ -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:
Expand Down
68 changes: 67 additions & 1 deletion tests/test_portfolio_security_gate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
from datetime import datetime, timezone
from types import SimpleNamespace

import pytest
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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:
Expand All @@ -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)]}),
Expand Down