diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c7bc3..59aac20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.22.0] + +### Added +- **Assessment-lens grouping.** Every rule and finding is now tagged with one of three + security-assessment lenses — **Harness gap**, **Guardrail gap**, or **Attack vector** — + so AutonomyProof reads as a structured assessment, not a flat rule list. `rules list` groups + by lens; `rules explain` shows the lens; JSON reports add `assessmentCounts` and a + `category` on each finding; the HTML report shows a per-lens breakdown. Central mapping in + `rules/categories.py`, enforced complete by tests. + ## [0.21.0] ### Added diff --git a/README.md b/README.md index a7bfbb2..c5a57da 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,11 @@ known-vulnerable framework dependencies (version-validated CVEs), and more. Run for details. Every finding carries **OWASP Agentic, NIST AI RMF, ISO 42001, MITRE ATLAS/ATT&CK, and CVE** mappings where a genuine one exists. +Rules and findings are grouped into three **assessment lenses** — **Harness gaps** (missing +runtime controls), **Guardrail gaps** (absent/disabled safety controls), and **Attack vectors** +(exploitable capability paths) — so a scan reads as a structured security assessment. +`autonomyproof rules list` shows the catalogue grouped this way. + ### How the analysis works (and its limits) AutonomyProof is AST-based static analysis. It resolves imports and follows source tracking — @@ -146,7 +151,7 @@ re-run `autonomyproof baseline .` and commit the updated file in the same PR. Use the action directly: ```yaml -- uses: autonomyproof/autonomyproof-cli@v0.21.0 +- uses: autonomyproof/autonomyproof-cli@v0.22.0 with: target: . fail-on: high @@ -173,7 +178,7 @@ Gate locally before a commit ever leaves your machine: # .pre-commit-config.yaml repos: - repo: https://github.com/autonomyproof/autonomyproof-cli - rev: v0.21.0 + rev: v0.22.0 hooks: - id: autonomyproof ``` diff --git a/pyproject.toml b/pyproject.toml index f9df065..37769b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "autonomyproof" -version = "0.21.0" +version = "0.22.0" description = "Open-source local scanner that finds unsafe capabilities and missing guardrails in Python AI-agent code." readme = "README.md" requires-python = ">=3.11" diff --git a/src/autonomyproof/__init__.py b/src/autonomyproof/__init__.py index cd2e516..d905d23 100644 --- a/src/autonomyproof/__init__.py +++ b/src/autonomyproof/__init__.py @@ -4,4 +4,4 @@ __all__ = ["__version__"] -__version__ = "0.21.0" +__version__ = "0.22.0" diff --git a/src/autonomyproof/cli.py b/src/autonomyproof/cli.py index 79ffb74..7f8e56f 100644 --- a/src/autonomyproof/cli.py +++ b/src/autonomyproof/cli.py @@ -32,6 +32,8 @@ ) from autonomyproof.models import Finding, ScanResult, Severity from autonomyproof.reporters import write_html, write_json, write_sarif +from autonomyproof.rules.base import Rule +from autonomyproof.rules.categories import CATEGORY_ORDER from autonomyproof.rules.registry import all_rules, get_rule from autonomyproof.scanner import Scanner from autonomyproof.scoring import SCORE_DISCLAIMER @@ -306,9 +308,15 @@ def rules() -> None: @rules.command("list") def rules_list() -> None: - """List every rule.""" + """List every rule, grouped by assessment lens.""" + rules_by_category: dict[str, list[Rule]] = {} for rule in all_rules(): - click.echo(f"{rule.id} {rule.default_severity.value:8} {rule.name}") + rules_by_category.setdefault(rule.category, []).append(rule) + for category in CATEGORY_ORDER: + group = rules_by_category.get(category, []) + click.echo(f"\n{category} ({len(group)})") + for rule in group: + click.echo(f" {rule.id} {rule.default_severity.value:8} {rule.name}") @rules.command("explain") @@ -320,6 +328,7 @@ def rules_explain(rule_id: str) -> None: except KeyError as exc: raise click.ClickException(f"Unknown rule: {rule_id}") from exc click.echo(f"{rule.id} — {rule.name}") + click.echo(f"Category: {rule.category}") click.echo(f"Severity: {rule.default_severity.value}") click.echo(f"Description: {rule.description}") click.echo(f"Risk: {rule.risk}") diff --git a/src/autonomyproof/models.py b/src/autonomyproof/models.py index fdd97ce..77597dc 100644 --- a/src/autonomyproof/models.py +++ b/src/autonomyproof/models.py @@ -64,6 +64,7 @@ class Finding: fingerprint: str framework: str | None = None toolName: str | None = None + category: str = "Attack vector" def to_dict(self) -> dict[str, object]: """Serialize to a JSON-ready dict with the severity as a string.""" @@ -127,3 +128,10 @@ def severity_counts(self) -> dict[str, int]: for finding in self.findings: counts[finding.severity.value] += 1 return counts + + def category_counts(self) -> dict[str, int]: + """Return a count of findings per assessment lens, in display order.""" + counts: dict[str, int] = {} + for finding in self.findings: + counts[finding.category] = counts.get(finding.category, 0) + 1 + return counts diff --git a/src/autonomyproof/reporters/html_reporter.py b/src/autonomyproof/reporters/html_reporter.py index a68d1c8..5391eab 100644 --- a/src/autonomyproof/reporters/html_reporter.py +++ b/src/autonomyproof/reporters/html_reporter.py @@ -7,6 +7,7 @@ from jinja2 import Environment, select_autoescape from autonomyproof.models import ScanResult +from autonomyproof.rules.categories import CATEGORY_ORDER from autonomyproof.scoring import SCORE_DISCLAIMER _TEMPLATE = """ @@ -55,6 +56,8 @@ {{ tools|length }} tool(s){% if project.branch %} · branch {{ project.branch }}{% endif %}

Critical: {{ counts.critical }} · High: {{ counts.high }} · Medium: {{ counts.medium }} · Low: {{ counts.low }}

+ {% if assessment %}

By assessment lens — + {% for category, count in assessment %}{{ category }}: {{ count }}{% if not loop.last %} · {% endif %}{% endfor %}

{% endif %} @@ -156,6 +159,11 @@ def render_html(result: ScanResult) -> str: capabilities=result.capabilities, findings=[f.to_dict() for f in result.findings], counts=result.severity_counts(), + assessment=[ + (category, count) + for category in CATEGORY_ORDER + if (count := result.category_counts().get(category, 0)) + ], errors=result.errors, disclaimer=SCORE_DISCLAIMER, ) diff --git a/src/autonomyproof/reporters/json_reporter.py b/src/autonomyproof/reporters/json_reporter.py index b96270f..68225ad 100644 --- a/src/autonomyproof/reporters/json_reporter.py +++ b/src/autonomyproof/reporters/json_reporter.py @@ -27,6 +27,7 @@ def build_report_dict(result: ScanResult) -> dict[str, object]: "score": result.score, "riskLevel": result.risk_level, "severityCounts": result.severity_counts(), + "assessmentCounts": result.category_counts(), "filesScanned": result.files_scanned, "rulesExecuted": result.rules_executed, "durationMs": result.duration_ms, diff --git a/src/autonomyproof/rules/base.py b/src/autonomyproof/rules/base.py index 6fe9b61..a975afa 100644 --- a/src/autonomyproof/rules/base.py +++ b/src/autonomyproof/rules/base.py @@ -12,6 +12,7 @@ from autonomyproof.frameworks import primary_framework from autonomyproof.models import Finding, Mappings, Severity from autonomyproof.redaction import redact +from autonomyproof.rules.categories import category_for def _code_tokens(analysis: FileAnalysis) -> str: @@ -108,6 +109,11 @@ class Rule: mappings: Mappings = Mappings() project_level: bool = False + @property + def category(self) -> str: + """The assessment lens (Harness gap / Guardrail gap / Attack vector) for this rule.""" + return category_for(self.id) + def check(self, ctx: RuleContext) -> Iterable[Finding]: """Yield findings for one file. Overridden by per-file rules.""" return [] @@ -144,6 +150,7 @@ def make_project_finding( fingerprint=compute_fingerprint(self.id, pctx.anchor_file, "", pattern), framework=pctx.framework, toolName=None, + category=self.category, ) def make_finding( @@ -182,4 +189,5 @@ def make_finding( ), framework=ctx.framework, toolName=resolved_tool, + category=self.category, ) diff --git a/src/autonomyproof/rules/categories.py b/src/autonomyproof/rules/categories.py new file mode 100644 index 0000000..499fe2b --- /dev/null +++ b/src/autonomyproof/rules/categories.py @@ -0,0 +1,73 @@ +"""Assessment lens each rule belongs to. + +AutonomyProof is a security *assessment* across three lenses, not a flat rule list: + +- **Harness gaps** — missing or weak runtime controls in the agent framework/config + (no limits, no timeout, no sandbox, no tracing). +- **Guardrail gaps** — safety controls that are absent, disabled, or bypassable + (no approval, disabled safety filter, agent can edit its own guardrails). +- **Attack vectors** — concrete exploitable capability or data-flow paths + (shell/RCE, SSRF, injection, destructive/escalation authority, supply-chain). +""" + +from __future__ import annotations + +HARNESS = "Harness gap" +GUARDRAIL = "Guardrail gap" +ATTACK_VECTOR = "Attack vector" + +# Fixed display order for reports and the CLI. +CATEGORY_ORDER = (HARNESS, GUARDRAIL, ATTACK_VECTOR) + +# Every registered rule id -> its assessment lens. Kept central so the whole assessment +# structure is reviewable in one place. `tests/` asserts this covers every rule exactly. +RULE_CATEGORY: dict[str, str] = { + # --- Harness gaps: missing/weak controls --- + "AG008": HARNESS, # missing execution limits + "AG009": HARNESS, # excessive/unbounded limits + "AG010": HARNESS, # missing action tracing + "AG016": HARNESS, # unrestricted sub-agent creation + "AG018": HARNESS, # external operation without a timeout + "AG020": HARNESS, # missing accountable-agent metadata + "AG027": HARNESS, # code-execution sandbox disabled + # --- Guardrail gaps: safety controls absent/disabled/bypassable --- + "AG007": GUARDRAIL, # dangerous operation without human approval + "AG011": GUARDRAIL, # persistent memory without tenant isolation + "AG013": GUARDRAIL, # MCP tool accepts unvalidated arguments + "AG015": GUARDRAIL, # agent can modify its own guardrails + "AG017": GUARDRAIL, # secret interpolated into model context + "AG032": GUARDRAIL, # model safety filter disabled + # --- Attack vectors: exploitable capability / data-flow paths --- + "AG001": ATTACK_VECTOR, # unrestricted shell execution + "AG002": ATTACK_VECTOR, # dynamic code execution + "AG003": ATTACK_VECTOR, # arbitrary filesystem access + "AG004": ATTACK_VECTOR, # sensitive credential-path access + "AG005": ATTACK_VECTOR, # unrestricted outbound HTTP + "AG006": ATTACK_VECTOR, # server-side request forgery + "AG012": ATTACK_VECTOR, # model-controlled SQL + "AG014": ATTACK_VECTOR, # bearer token forwarded without audience separation + "AG019": ATTACK_VECTOR, # destructive command exposure + "AG021": ATTACK_VECTOR, # insecure deserialization + "AG022": ATTACK_VECTOR, # disabled TLS verification + "AG023": ATTACK_VECTOR, # server-side template injection + "AG024": ATTACK_VECTOR, # dangerous framework capability flag + "AG025": ATTACK_VECTOR, # code/shell interpreter tool exposed + "AG026": ATTACK_VECTOR, # known-vulnerable dependency (supply chain) + "AG028": ATTACK_VECTOR, # code-executing agent or chain + "AG029": ATTACK_VECTOR, # unrestricted HTTP request tool + "AG030": ATTACK_VECTOR, # agent UI exposed via public tunnel + "AG031": ATTACK_VECTOR, # CORS wildcard origin with credentials + "AG033": ATTACK_VECTOR, # irreversible data destruction + "AG034": ATTACK_VECTOR, # cloud/infrastructure destruction + "AG035": ATTACK_VECTOR, # money movement without approval + "AG036": ATTACK_VECTOR, # persistence-sensitive file write + "AG037": ATTACK_VECTOR, # runtime package installation + "AG038": ATTACK_VECTOR, # IAM/privilege escalation + "AG039": ATTACK_VECTOR, # world-writable permission grant + "AG040": ATTACK_VECTOR, # insecure model-output handling +} + + +def category_for(rule_id: str) -> str: + """Return the assessment lens for ``rule_id`` (defaults to Attack vector).""" + return RULE_CATEGORY.get(rule_id, ATTACK_VECTOR) diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 0000000..8b7c60a --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,62 @@ +"""Tests for assessment-category grouping (Harness / Guardrail / Attack vector).""" + +from __future__ import annotations + +from pathlib import Path + +from autonomyproof.config import Config +from autonomyproof.reporters.html_reporter import render_html +from autonomyproof.reporters.json_reporter import build_report_dict +from autonomyproof.rules.categories import ( + ATTACK_VECTOR, + CATEGORY_ORDER, + RULE_CATEGORY, + category_for, +) +from autonomyproof.rules.registry import all_rules +from autonomyproof.scanner import Scanner + +_VULN = "import subprocess\ndef run(cmd):\n return subprocess.run(cmd, shell=True)\n" + + +def test_every_registered_rule_has_a_valid_category() -> None: + ids = {rule.id for rule in all_rules()} + # The mapping covers exactly the registered rules — no missing, no stale entries. + assert set(RULE_CATEGORY) == ids + assert all(value in CATEGORY_ORDER for value in RULE_CATEGORY.values()) + + +def test_rule_category_property_matches_mapping() -> None: + for rule in all_rules(): + assert rule.category == RULE_CATEGORY[rule.id] + + +def test_category_for_unknown_defaults_to_attack_vector() -> None: + assert category_for("AG999") == ATTACK_VECTOR + + +def test_all_three_lenses_are_represented() -> None: + present = {rule.category for rule in all_rules()} + assert present == set(CATEGORY_ORDER) + + +def test_finding_carries_category(tmp_path: Path) -> None: + (tmp_path / "agent.py").write_text(_VULN, encoding="utf-8") + result = Scanner(Config()).scan(tmp_path) + shell = next(f for f in result.findings if f.ruleId == "AG001") + assert shell.category == ATTACK_VECTOR + + +def test_category_counts_and_reports(tmp_path: Path) -> None: + (tmp_path / "agent.py").write_text(_VULN, encoding="utf-8") + result = Scanner(Config()).scan(tmp_path) + counts = result.category_counts() + assert counts.get(ATTACK_VECTOR, 0) >= 1 + # JSON report exposes the per-lens breakdown and per-finding category. + report = build_report_dict(result) + assert report["assessmentCounts"] == counts + assert all("category" in f for f in report["findings"]) # type: ignore[union-attr] + # HTML report shows the assessment breakdown. + html = render_html(result) + assert "By assessment lens" in html + assert ATTACK_VECTOR in html diff --git a/tests/test_cli.py b/tests/test_cli.py index fb5a7a0..a750a38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,7 +33,7 @@ def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_version(runner: CliRunner) -> None: result = runner.invoke(cli.main, ["--version"]) assert result.exit_code == 0 - assert "0.21.0" in result.output + assert "0.22.0" in result.output def test_init_creates_and_is_idempotent(runner: CliRunner) -> None: @@ -49,6 +49,15 @@ def test_rules_list(runner: CliRunner) -> None: result = runner.invoke(cli.main, ["rules", "list"]) assert "AG001" in result.output assert result.output.count("AG0") >= 20 + # Grouped by assessment lens. + assert "Harness gap" in result.output + assert "Guardrail gap" in result.output + assert "Attack vector" in result.output + + +def test_rules_explain_shows_category(runner: CliRunner) -> None: + result = runner.invoke(cli.main, ["rules", "explain", "AG001"]) + assert "Category: Attack vector" in result.output def test_rules_explain(runner: CliRunner) -> None: