From f5480097242023e7da0bd5eb6c4352d6038cca5c Mon Sep 17 00:00:00 2001 From: Md Shariar Shanaz Shuvon <83355567+shuvonsec@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:07:53 +0800 Subject: [PATCH 1/4] feat(security): add security diff engine --- AGENTS.md | 1 + CLAUDE.md | 1 + COMMANDS-QUICK-REF.md | 3 + README.md | 27 + cli/main.py | 231 +++++++- commands/axguard-diff.md | 22 + docs/mcp-tools.md | 5 + docs/mcp.md | 6 +- docs/preship.md | 70 +++ docs/security-diff.md | 68 +++ engines/api/routes/__init__.py | 52 +- engines/github/pipeline.py | 29 + engines/github/reviews.py | 9 + engines/mcp/policy.py | 2 + engines/mcp/server.py | 22 + engines/mcp/tools/catalog.py | 34 ++ engines/mcp/tools/handlers.py | 109 +++- engines/preship/__init__.py | 39 ++ engines/preship/cli.py | 97 ++++ engines/preship/decision.py | 148 ++++++ engines/preship/pipeline.py | 406 ++++++++++++++ engines/preship/policy.py | 346 ++++++++++++ engines/preship/report.py | 325 ++++++++++++ engines/preship/schema.py | 80 +++ engines/report.py | 25 + engines/security_diff/__init__.py | 44 ++ engines/security_diff/app_model_diff.py | 209 ++++++++ engines/security_diff/authz_diff.py | 248 +++++++++ engines/security_diff/cli.py | 74 +++ engines/security_diff/compare.py | 496 ++++++++++++++++++ engines/security_diff/compose.py | 384 ++++++++++++++ engines/security_diff/controls.py | 314 +++++++++++ engines/security_diff/controls_diff.py | 262 +++++++++ engines/security_diff/dataflow_diff.py | 203 +++++++ engines/security_diff/git_base.py | 118 +++++ engines/security_diff/git_ops.py | 153 ++++++ engines/security_diff/github_summary.py | 65 +++ engines/security_diff/impact.py | 160 ++++++ engines/security_diff/pipeline.py | 482 +++++++++++++++++ engines/security_diff/render.py | 71 +++ engines/security_diff/report.py | 193 +++++++ engines/security_diff/schema.py | 429 +++++++++++++++ engines/security_diff/state.py | 127 +++++ engines/security_diff/store.py | 84 +++ fixtures/preship/fail_authz/app.py | 23 + fixtures/preship/pass_safe/app.py | 38 ++ .../preship/regression_authz/after/app.py | 22 + .../preship/regression_authz/before/app.py | 27 + .../security_diff/authz_moved/after/app.py | 11 + .../authz_moved/after/services/policy.py | 2 + .../security_diff/authz_moved/before/app.py | 13 + .../security_diff/authz_removed/after/app.py | 10 + .../security_diff/authz_removed/before/app.py | 14 + .../security_diff/hard_negative/after/app.py | 2 + .../security_diff/hard_negative/before/app.py | 2 + fixtures/security_diff/mcp_priv/after/app.py | 7 + .../security_diff/mcp_priv/after/mcp.json | 1 + fixtures/security_diff/mcp_priv/before/app.py | 3 + .../security_diff/mcp_priv/before/mcp.json | 1 + .../security_diff/refactor_equiv/after/app.py | 11 + .../refactor_equiv/after/services/policy.py | 2 + .../refactor_equiv/before/app.py | 13 + .../security_diff/tenant_removed/after/app.py | 8 + .../tenant_removed/before/app.py | 8 + skills/axguard-preship/SKILL.md | 111 ++-- skills/axguard-security/SKILL.md | 54 ++ skills/index.yaml | 3 + tests/test_preship.py | 185 +++++++ tests/test_security_diff.py | 162 ++++++ 69 files changed, 6911 insertions(+), 95 deletions(-) create mode 100644 commands/axguard-diff.md create mode 100644 docs/preship.md create mode 100644 docs/security-diff.md create mode 100644 engines/preship/__init__.py create mode 100644 engines/preship/cli.py create mode 100644 engines/preship/decision.py create mode 100644 engines/preship/pipeline.py create mode 100644 engines/preship/policy.py create mode 100644 engines/preship/report.py create mode 100644 engines/preship/schema.py create mode 100644 engines/security_diff/__init__.py create mode 100644 engines/security_diff/app_model_diff.py create mode 100644 engines/security_diff/authz_diff.py create mode 100644 engines/security_diff/cli.py create mode 100644 engines/security_diff/compare.py create mode 100644 engines/security_diff/compose.py create mode 100644 engines/security_diff/controls.py create mode 100644 engines/security_diff/controls_diff.py create mode 100644 engines/security_diff/dataflow_diff.py create mode 100644 engines/security_diff/git_base.py create mode 100644 engines/security_diff/git_ops.py create mode 100644 engines/security_diff/github_summary.py create mode 100644 engines/security_diff/impact.py create mode 100644 engines/security_diff/pipeline.py create mode 100644 engines/security_diff/render.py create mode 100644 engines/security_diff/report.py create mode 100644 engines/security_diff/schema.py create mode 100644 engines/security_diff/state.py create mode 100644 engines/security_diff/store.py create mode 100644 fixtures/preship/fail_authz/app.py create mode 100644 fixtures/preship/pass_safe/app.py create mode 100644 fixtures/preship/regression_authz/after/app.py create mode 100644 fixtures/preship/regression_authz/before/app.py create mode 100644 fixtures/security_diff/authz_moved/after/app.py create mode 100644 fixtures/security_diff/authz_moved/after/services/policy.py create mode 100644 fixtures/security_diff/authz_moved/before/app.py create mode 100644 fixtures/security_diff/authz_removed/after/app.py create mode 100644 fixtures/security_diff/authz_removed/before/app.py create mode 100644 fixtures/security_diff/hard_negative/after/app.py create mode 100644 fixtures/security_diff/hard_negative/before/app.py create mode 100644 fixtures/security_diff/mcp_priv/after/app.py create mode 100644 fixtures/security_diff/mcp_priv/after/mcp.json create mode 100644 fixtures/security_diff/mcp_priv/before/app.py create mode 100644 fixtures/security_diff/mcp_priv/before/mcp.json create mode 100644 fixtures/security_diff/refactor_equiv/after/app.py create mode 100644 fixtures/security_diff/refactor_equiv/after/services/policy.py create mode 100644 fixtures/security_diff/refactor_equiv/before/app.py create mode 100644 fixtures/security_diff/tenant_removed/after/app.py create mode 100644 fixtures/security_diff/tenant_removed/before/app.py create mode 100644 skills/axguard-security/SKILL.md create mode 100644 tests/test_preship.py create mode 100644 tests/test_security_diff.py diff --git a/AGENTS.md b/AGENTS.md index efb8e0e..4329b55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ | Doing | Command / skill | |---|---| | Full audit | `/axguard-audit` or skill `axguard-audit` | +| Security Diff | `/axguard-diff` · `axguard diff` · MCP `axguard_security_diff` | | Security lead pass | skill `axguard-cso` | | Triage | `/axguard-triage` | | Fix | `/axguard-fix` / skill `axguard-remediate` | diff --git a/CLAUDE.md b/CLAUDE.md index fc644a7..8ba67ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ pip install -e . | What you are doing | Start here | |---|---| | About to publish | `/axguard-audit` | +| Security Diff on a change | `/axguard-diff` · `axguard diff` | | Quick check while coding | `/axguard-scan` | | New / unknown codebase | `/axguard-threat-model` → `/axguard-audit` | | Secrets | `/axguard-secrets` | diff --git a/COMMANDS-QUICK-REF.md b/COMMANDS-QUICK-REF.md index 98af347..d115d5f 100644 --- a/COMMANDS-QUICK-REF.md +++ b/COMMANDS-QUICK-REF.md @@ -4,6 +4,8 @@ | Doing this | Run | |---|---| +| **Pre-Ship gate (ship / no-ship)** | `axguard preship .` → [docs/preship.md](docs/preship.md) | +| **Security Diff (what changed?)** | `axguard diff` / `axguard security-diff` → [docs/security-diff.md](docs/security-diff.md) | | Full pre-ship audit + HTML/MD | `/axguard-audit` | | Fast scan | `/axguard-scan` | | Map app / attack surface | `/axguard-surface` | @@ -16,6 +18,7 @@ | Investigation Agent | `axguard investigate …` | | GitHub Security Bot | `axguard github setup\|validate\|test\|status` | | Predictive Security | `axguard predict …` | +| Security Diff (what changed?) | `axguard diff` · `/axguard-diff` → [docs/security-diff.md](docs/security-diff.md) | | Local Security Intelligence API | `axguard api start` → `http://127.0.0.1:8787` | | MCP (AI coding agents) | `axguard mcp` · `serve` · `doctor` · `tools` → [docs/mcp.md](docs/mcp.md) | | Training-data pipeline | `/axguard-data` | diff --git a/README.md b/README.md index a486d5f..0e3e4dd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,21 @@ Pre-ship security gate — not a full pentest platform. Scan source, triage nois --- +## Pre-Ship Security + +Find → Explain → Fix → Verify → Ship. + +```bash +axguard preship . +``` + +AXGuard analyzes security-sensitive changes, verifies findings, checks attack paths and security regressions, and tells you whether the application is ready to ship. + +- Pre-Ship: [docs/preship.md](docs/preship.md) +- Security Diff: [docs/security-diff.md](docs/security-diff.md) + +--- + ## What is AXguard? **AXguard is a pre-ship security gate.** @@ -222,6 +237,8 @@ open .findings/axguard/axguard-report.html | Security Memory | `axguard memory record .` → [docs/memory](docs/memory/README.md) | | Investigation Agent | `axguard investigate .` → [docs/investigation](docs/investigation/README.md) | | Predictive security risk | `axguard predict .` → [docs/predictive](docs/predictive/README.md) | +| Security Diff | `axguard diff` → [docs/security-diff.md](docs/security-diff.md) | +| Pre-Ship gate | `axguard preship .` → [docs/preship.md](docs/preship.md) | | Local Security Intelligence API | `axguard api start` → [docs/api](docs/api/overview.md) | | MCP for AI coding agents | `axguard mcp` → [docs/mcp.md](docs/mcp.md) | | GitHub PR bot (self-host) | `axguard github setup` → [docs/github](docs/github/README.md) | @@ -357,6 +374,16 @@ axguard predict --agent axguard predict --mcp axguard predict --what-if +# Security Diff (security-aware comparison of two versions) +axguard diff +axguard diff HEAD~1 +axguard diff main...HEAD +axguard diff --base main --head HEAD +axguard diff --json +axguard diff --verbose +axguard diff --fail-on high +axguard diff baseline save + # Training-data pipeline (no model training) axguard data discover axguard data inspect diff --git a/cli/main.py b/cli/main.py index 061fbd2..53ef13d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -684,6 +684,104 @@ def _mem_common(p: argparse.ArgumentParser) -> None: help="Skip engagement / first-run messaging", ) + diff_cmd = sub.add_parser( + "diff", + aliases=["security-diff"], + help=( + "Security Diff — compare two application states and explain " + "security-relevant changes (not another scanner)" + ), + ) + diff_sub = diff_cmd.add_subparsers(dest="diff_command") + diff_cmd.add_argument( + "range_or_path", + nargs="?", + default=None, + help="Git range (main...HEAD), ref (HEAD~1), commit, or path (default: auto)", + ) + diff_cmd.add_argument("--base", default=None, help="Base git ref or path") + diff_cmd.add_argument("--head", default=None, help="Head git ref or path (default: working tree)") + diff_cmd.add_argument( + "--fail-on", + choices=("critical", "high", "medium", "low", "none"), + default="none", + help="Exit non-zero when security impact meets this level", + ) + diff_cmd.add_argument( + "--json", + action="store_true", + dest="as_json", + help="Print Security Diff JSON", + ) + diff_cmd.add_argument( + "--verbose", + action="store_true", + help="Detailed human-readable delta", + ) + diff_cmd.add_argument( + "--html", + action="store_true", + help="Write HTML report under --out-dir", + ) + diff_cmd.add_argument( + "--out-dir", + default=".findings/axguard", + help="Artifact directory (default: .findings/axguard)", + ) + diff_cmd.add_argument( + "--no-incremental", + action="store_true", + help="Disable incremental/changed-file scoping metadata", + ) + diff_cmd.add_argument( + "--investigate", + action="store_true", + help="Soft-invoke Investigation Engine for control-removal candidates", + ) + diff_cmd.add_argument( + "--baseline-name", + default="default", + help="AXGuard snapshot baseline name (non-git)", + ) + diff_cmd.add_argument("--no-banner", action="store_true", help="Hide the ASCII banner") + diff_cmd.add_argument( + "--no-engage", + action="store_true", + help="Skip engagement / first-run messaging", + ) + diff_base = diff_sub.add_parser( + "baseline", + help="Compare against a stored AXGuard baseline (or save one)", + ) + diff_base_sub = diff_base.add_subparsers(dest="diff_baseline_command") + diff_base.add_argument( + "--name", + default="default", + help="Baseline name (default: default)", + ) + diff_base.add_argument( + "--json", + action="store_true", + dest="as_json", + help="Print JSON", + ) + diff_base.add_argument("--no-banner", action="store_true", help="Hide the ASCII banner") + diff_base_save = diff_base_sub.add_parser( + "save", + help="Save current security state as a baseline snapshot", + ) + diff_base_save.add_argument( + "--name", + default="default", + help="Baseline name (default: default)", + ) + diff_base_save.add_argument( + "path", + nargs="?", + default=".", + help="Project path (default: .)", + ) + sub.add_parser("version", help="Print version") sub.add_parser("help", help="Show Start Using workflow table") @@ -770,6 +868,13 @@ def _gh_common(p: argparse.ArgumentParser) -> None: except ImportError: pass + try: + from engines.preship.cli import add_preship_parser + + add_preship_parser(sub) + except ImportError: + pass + return parser @@ -778,7 +883,9 @@ def _gh_common(p: argparse.ArgumentParser) -> None: What you are doing Command ----------------------------- ------------------------- - About to publish / open a PR axguard audit . | /axguard-audit + About to publish / open a PR axguard preship . | axguard audit . + Pre-ship gate (ship / no-ship) axguard preship . | /axguard-preship + Security Diff (what changed?) axguard diff [BASE] | axguard security-diff Quick check while coding axguard scan . | /axguard-scan Map attack surface / app model axguard surface . | /axguard-surface Dataflow / taint paths axguard flow . | /axguard-flow @@ -806,13 +913,16 @@ def _gh_common(p: argparse.ArgumentParser) -> None: Triage → fix → report → CI /axguard-triage · /axguard-fix · /axguard-report · /axguard-ci Pipeline: + Find → Explain → Fix → Verify → Ship threat-model → audit → triage → fix → report → ci + preship (gate) · diff (change impact) Reports land in: .findings/axguard/axguard-report.{html,md,json} + .findings/axguard/preship/preship-report.{html,md,json} Cheat sheet: COMMANDS-QUICK-REF.md -Docs: docs/engagement.md · docs/contributors/README.md (local prefs, no telemetry) +Docs: docs/preship.md · docs/security-diff.md · docs/engagement.md """.strip() @@ -1333,6 +1443,14 @@ def main(argv: list[str] | None = None) -> int: if args.command == "predict": return _run_predict_command(args) + if args.command in {"diff", "security-diff"}: + return _run_diff_command(args) + + if args.command in {"preship"}: + from engines.preship.cli import run_preship_command + + return int(run_preship_command(args)) + parser.print_help() return 2 @@ -1356,6 +1474,115 @@ def _run_github_command(args: argparse.Namespace) -> int: return 2 +def _run_diff_command(args: argparse.Namespace) -> int: + """Security Diff CLI — orchestrates existing engines, does not scan alone.""" + if not getattr(args, "no_banner", False): + print_banner(compact=True) + print() + + from engines.security_diff import ( + run_security_diff, + save_baseline_from_project, + should_fail, + ) + from engines.security_diff.report import ( + render_text, + render_verbose, + to_json, + write_security_diff_report, + ) + + # axguard diff baseline save + if getattr(args, "diff_command", None) == "baseline": + name = getattr(args, "name", None) or "default" + if getattr(args, "diff_baseline_command", None) == "save": + path = Path(getattr(args, "path", ".") or ".").resolve() + out = save_baseline_from_project(path, name=name) + print(f"Saved Security Diff baseline '{name}' → {out}") + return 0 + # axguard diff baseline → compare to snapshot + result = run_security_diff( + project=".", + use_snapshot=True, + baseline_name=name, + fail_on=getattr(args, "fail_on", "none") or "none", + write_report=False, + ) + if getattr(args, "as_json", False): + print(to_json(result), end="") + else: + print(render_text(result), end="") + return 1 if should_fail(result, getattr(args, "fail_on", "none") or "none") else 0 + + range_or_path = getattr(args, "range_or_path", None) + base = getattr(args, "base", None) + head = getattr(args, "head", None) + range_spec = None + project = "." + + if range_or_path: + if range_or_path in {".", "./"} or Path(range_or_path).exists(): + project = range_or_path + # Non-git path compare against snapshot when no --base + if base is None: + result = run_security_diff( + project=project, + use_snapshot=True, + baseline_name=getattr(args, "baseline_name", "default") or "default", + fail_on=args.fail_on, + incremental=not args.no_incremental, + investigate=bool(args.investigate), + out_dir=args.out_dir, + write_report=bool(args.html), + ) + return _emit_diff_result(args, result) + elif "..." in range_or_path or ".." in range_or_path: + range_spec = range_or_path + else: + base = base or range_or_path + + result = run_security_diff( + project=project, + base=base, + head=head, + range_spec=range_spec, + baseline_name=getattr(args, "baseline_name", "default") or "default", + fail_on=args.fail_on, + incremental=not args.no_incremental, + investigate=bool(args.investigate), + out_dir=args.out_dir, + write_report=bool(args.html), + ) + return _emit_diff_result(args, result) + + +def _emit_diff_result(args: argparse.Namespace, result: dict) -> int: + from engines.security_diff import should_fail + from engines.security_diff.report import ( + render_text, + render_verbose, + to_json, + write_security_diff_report, + ) + + if getattr(args, "html", False) and not getattr(args, "as_json", False): + paths = write_security_diff_report(result, Path(args.out_dir)) + print(f"Wrote Security Diff report → {paths.get('html')}") + if getattr(args, "as_json", False): + print(to_json(result), end="") + elif getattr(args, "verbose", False): + print(render_verbose(result), end="") + else: + print(render_text(result), end="") + + if not getattr(args, "no_engage", False): + try: + _print_engagement(emit_for_paths(result)) + except Exception: # noqa: BLE001 + pass + return 1 if should_fail(result, getattr(args, "fail_on", "none") or "none") else 0 + + def _run_predict_command(args: argparse.Namespace) -> int: """Predictive Security Intelligence CLI.""" import json as _json diff --git a/commands/axguard-diff.md b/commands/axguard-diff.md new file mode 100644 index 0000000..2d8712a --- /dev/null +++ b/commands/axguard-diff.md @@ -0,0 +1,22 @@ +--- +name: axguard-diff +description: Run AXGuard Security Diff — compare two versions and explain security-relevant changes (attack surface, controls, attack paths, privileges, regressions). Use after meaningful security-sensitive changes, not trivial edits. +--- + +# /axguard-diff + +Compare BASE vs HEAD from a **security** perspective. + +```bash +axguard diff +axguard diff HEAD~1 +axguard diff main...HEAD +axguard diff --base main --head HEAD +axguard diff --json +axguard diff --verbose +axguard diff --fail-on high +``` + +MCP: `axguard_security_diff` + +See [docs/security-diff.md](../docs/security-diff.md). diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 0fbfafd..cae250d 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -20,6 +20,10 @@ Annotations are MCP **hints** (`readOnlyHint`, `destructiveHint`, `idempotentHin ## Primary +### `axguard_preship` / `axguard_security_diff` + +Pre-Ship gate and Security Diff. Prefer these when asking "can I ship?" or "what became more dangerous?". Details: [preship.md](preship.md) · [security-diff.md](security-diff.md). + ### `axguard_security_review` **Approval:** APPROVAL_REQUIRED (esp. `DEEP` / `MAX`) · **Annotations:** `readOnlyHint=true`, `destructiveHint=false`, `idempotentHint=true`, `openWorldHint=false` @@ -136,6 +140,7 @@ Never invent evidence from model speculation. |---|---|---|---| | `axguard_predict_security_risks` | AUTO | RO, idempotent | Risk **expansion** signals — never label as confirmed vulns. | | `axguard_analyze_change_risk` | AUTO | RO, idempotent | Change-focused predictive view (PR/diff). | +| `axguard_security_diff` | AUTO | RO, idempotent | Compare two app states — attack surface, controls, data flows, privileges, trust boundaries, attack paths, regressions, predictive risks. | --- diff --git a/docs/mcp.md b/docs/mcp.md index e6faed8..0a9937e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,9 +2,9 @@ AXGuard’s **AI-agent security interface** over the [Model Context Protocol](https://modelcontextprotocol.io/). -MCP does not duplicate scanners or security reasoning. It is a thin adapter over the shared AXGuard engines (application understanding, data flow, evidence, judge, adversary, attack graph, twin, memory, investigation, predictive). +MCP does not duplicate scanners or security reasoning. It is a thin adapter over the shared AXGuard engines (application understanding, data flow, evidence, judge, adversary, attack graph, twin, memory, investigation, predictive, **security diff**). -Protocol research: [mcp-research.md](mcp-research.md) · Client setup: [mcp-config.md](mcp-config.md) · Tools: [mcp-tools.md](mcp-tools.md) · Security: [mcp-security.md](mcp-security.md) +Protocol research: [mcp-research.md](mcp-research.md) · Client setup: [mcp-config.md](mcp-config.md) · Tools: [mcp-tools.md](mcp-tools.md) · Security: [mcp-security.md](mcp-security.md) · Security Diff: [security-diff.md](security-diff.md) --- @@ -49,6 +49,8 @@ No AwareXone account, API key, hosted backend, or central telemetry is required. --- ## Primary tool: `axguard_security_review` +Also: `axguard_preship` (ship gate) and `axguard_security_diff` (change impact). See [preship.md](preship.md) · [security-diff.md](security-diff.md). + Agents should prefer this high-level tool over manually chaining every internal engine. diff --git a/docs/preship.md b/docs/preship.md new file mode 100644 index 0000000..986d16b --- /dev/null +++ b/docs/preship.md @@ -0,0 +1,70 @@ +# AXGuard Pre-Ship + +Pre-Ship answers one question: **is this code safe enough to ship?** + +```bash +axguard preship . +axguard preship . --mode QUICK +axguard preship . --json +axguard preship . --base HEAD~1 +``` + +## Decisions + +| Decision | Meaning | Exit | +|---|---|---| +| `PASS` | No verified blocking issue | 0 | +| `PASS_WITH_NOTES` | No blocker; notes / predictive / unverified present | 0 | +| `REVIEW_REQUIRED` | Likely findings, important unknowns, or significant security changes | 1 | +| `FAIL` | Configured blocking condition (verified critical/high by default) | 2 | +| *(tool error)* | Analysis crashed with no usable signal | 3 | + +**Never FAIL solely on unverified suspicion.** LLM reasoning alone cannot block a release. + +## Modes + +| Mode | Behavior | +|---|---| +| `QUICK` | Scan + Security Diff (changed files / `HEAD~1` when git) | +| `STANDARD` (default) | `run_audit` + Security Diff + soft predictive + soft memory | +| `DEEP` / `MAX` | Fuller audit + soft investigation + twin via existing engines | + +## Policy defaults + +```yaml +preship: + blocking: + verified_critical: true + verified_high: true + verified_medium: false + likely: false + unverified: false + predictive_risk: false + unknowns: + fail: false + review_required: true +``` + +Override via `.axguard.yml`. Security Diff HIGH/CRITICAL control removals and memory regressions elevate to at least `REVIEW_REQUIRED` (or `FAIL` when policy + regressions warrant). + +## Reports + +Written under `/.findings/axguard/preship/` (or `--out-dir`): + +- `preship-report.json` +- `preship-report.md` +- `preship-report.html` + +## MCP / skill + +- MCP: `axguard_preship` (approval required) +- Skill: `axguard-preship` — prefer CLI/MCP before shipping; re-verify after fixes + +## CI + +```bash +axguard preship . --json +echo $? # 0 / 1 / 2 / 3 +``` + +See also: [security-diff.md](security-diff.md) · [mcp.md](mcp.md) diff --git a/docs/security-diff.md b/docs/security-diff.md new file mode 100644 index 0000000..fd6228b --- /dev/null +++ b/docs/security-diff.md @@ -0,0 +1,68 @@ +# AXGuard Security Diff + +Security Diff answers: **what became more dangerous because of this change?** + +It is **not** another scanner. It compares application models, data flows, controls, authz/tenant heuristics, attack graphs, and Security Twins when available. + +```bash +axguard diff +axguard diff HEAD~1 +axguard diff main...HEAD +axguard security-diff --base-path ./before +axguard diff --json +``` + +## Baseline rules + +| Situation | Baseline | +|---|---| +| Git ref available | `GIT` | +| Filesystem `--base` / path | `PATH` | +| Prior `.findings` / snapshot | `ARTIFACTS` / snapshot | +| Nothing available | `UNKNOWN` — **do not invent** | + +## Categories (examples) + +`NEW_ENDPOINT`, `REMOVED_ENDPOINT`, `NEW_PARAMETER`, `NEW_DATABASE_FLOW`, `NEW_EXTERNAL_REQUEST`, `REMOVED_SECURITY_CONTROL`, `WEAKENED_SECURITY_CONTROL`, `NEW_ATTACK_PATH`, `BLOCKED_ATTACK_PATH`, … + +Overall change: `LOW` | `MEDIUM` | `HIGH` | `CRITICAL` (also `NONE` / `UNKNOWN` in some paths). + +## AuthZ / tenant + +Special-cased heuristics for ownership and tenant tokens (`tenant_id`, `org_id`, `workspace_id`, `owner_id`, …). Example signal: ownership check present in base, absent in head → authorization weakened. + +## Reuse + +Composes existing engines: + +- `engines.app_model` +- `engines.dataflow` +- `engines.attack_graph.diff.compare_attack_graphs` / `get_attack_path_diff` +- `engines.twin.pipeline.run_twin_compare` +- Security Memory snapshots when present + +No network. No exploitation. No CVE claims without evidence. + +## MCP + +Tool: `axguard_security_diff` — soft-fails on import/engine errors. + +## Compact output example + +```text +SECURITY DIFF +──────────────────────── + ++ 2 new API endpoints ++ 1 new database flow +- 1 authorization/security control + +Attack paths: ++ 2 reachable +- 1 blocked + +Overall security change: +HIGH +``` + +See also: [preship.md](preship.md) diff --git a/engines/api/routes/__init__.py b/engines/api/routes/__init__.py index 143a124..e82a5df 100644 --- a/engines/api/routes/__init__.py +++ b/engines/api/routes/__init__.py @@ -518,35 +518,29 @@ async def security_diff(project_id: str, request: Request): _auth(request, "findings:read") project = _project(project_id) body = await _json(request) - base = Path(body["base_path"]) if body.get("base_path") else None - head = Path(body["head_path"]) if body.get("head_path") else Path(project["path"]) - from engines.scanner import ScanOptions, run_scan - from engines.paths import default_rules_dir - - empty = { - "new_findings": [], - "resolved_findings": [], - "regressions": [], - "security_posture_delta": {}, - } - if not base or not base.exists() or not head.exists(): - return {**empty, "note": "base_path and head_path required"} - rules = default_rules_dir() - base_scan = run_scan(ScanOptions(target=base, rules_dir=rules)) - head_scan = run_scan(ScanOptions(target=head, rules_dir=rules)) - - def key(f: dict[str, Any]) -> str: - return f"{f.get('id')}|{f.get('file')}|{f.get('line')}" - - base_map = {key(f): f for f in base_scan.get("findings") or []} - head_map = {key(f): f for f in head_scan.get("findings") or []} - return { - **empty, - "new_findings": [head_map[k] for k in head_map.keys() - base_map.keys()], - "resolved_findings": [base_map[k] for k in base_map.keys() - head_map.keys()], - "base_finding_count": len(base_map), - "head_finding_count": len(head_map), - } + base = body.get("base_path") or body.get("base") + head = body.get("head_path") or body.get("head") or project["path"] + from engines.security_diff import security_diff as run_sd + from engines.security_diff.github_summary import format_github_pr_summary + + result = run_sd( + base=base, + head=head, + project=project["path"], + options={ + "range_spec": body.get("range"), + "baseline_name": body.get("baseline_name") or "default", + "use_snapshot": bool(body.get("use_snapshot")), + "incremental": body.get("incremental", True), + "fail_on": body.get("fail_on") or "none", + }, + ) + # Backward-compatible finding-ish fields (empty unless consumers need them) + result.setdefault("new_findings", []) + result.setdefault("resolved_findings", []) + result.setdefault("security_posture_delta", result.get("security_impact") or {}) + result["github_summary"] = format_github_pr_summary(result) + return result @app.get("/v1/projects/{project_id}/security-posture") async def security_posture(project_id: str, request: Request): diff --git a/engines/github/pipeline.py b/engines/github/pipeline.py index c371605..fa6223d 100644 --- a/engines/github/pipeline.py +++ b/engines/github/pipeline.py @@ -322,6 +322,21 @@ def _run_core_analysis( except Exception as exc: # noqa: BLE001 predictive = {"available": False, "error": redact_secrets(str(exc))} + security_diff: dict[str, Any] | None = None + if base_workspace is not None and Path(base_workspace).is_dir(): + try: + from engines.security_diff import security_diff as run_sd + + security_diff = run_sd( + base=str(base_workspace), + head=str(target), + project=str(target), + options={"incremental": True, "skip_predict": True}, + ) + security_diff = redact_secrets(security_diff) + except Exception as exc: # noqa: BLE001 + security_diff = {"error": redact_secrets(str(exc))} + return { "verification": verification, "adversary": adversary, @@ -331,6 +346,7 @@ def _run_core_analysis( "investigation": investigation, "memory_summary": memory_summary, "predictive": predictive, + "security_diff": security_diff, "ai_mode": ai.get("mode"), "changed_files": files, } @@ -367,6 +383,17 @@ def _build_pipeline_result( if isinstance(twin, dict): twin_summary = redact_secrets(twin.get("regression") or twin.get("summary") or {}) + # Soft: attach Security Diff summary for future PR check text (shared engine) + security_diff_summary = None + try: + sd = core.get("security_diff") + if isinstance(sd, dict) and sd.get("security_impact"): + from engines.security_diff.github_summary import format_github_pr_summary + + security_diff_summary = format_github_pr_summary(sd) + except Exception: # noqa: BLE001 + security_diff_summary = None + analysis_failed = bool( (isinstance(core.get("adversary"), dict) and core["adversary"].get("error")) or (isinstance(mem, dict) and mem.get("fatal")) @@ -459,6 +486,8 @@ def _build_pipeline_result( "investigation_id": (core.get("investigation") or {}).get("investigation_id"), "untrusted_pr_title": None, "predictive": predictive, + "security_diff": core.get("security_diff"), + "security_diff_summary": security_diff_summary, }, ) diff --git a/engines/github/reviews.py b/engines/github/reviews.py index 91c100e..d0fdf02 100644 --- a/engines/github/reviews.py +++ b/engines/github/reviews.py @@ -71,6 +71,15 @@ def build_summary_body( lines.append("**Security regression:** None detected.") lines.append("") + sd_summary = None + if isinstance(result.meta, dict): + sd_summary = result.meta.get("security_diff_summary") + if isinstance(sd_summary, str) and sd_summary.strip(): + lines.append("```") + lines.append(sd_summary.rstrip()) + lines.append("```") + lines.append("") + # Keep Verified vs Predictive vs Improvements separated (additive) predictive = None if isinstance(result.meta, dict): diff --git a/engines/mcp/policy.py b/engines/mcp/policy.py index 45e96b1..2c4623d 100644 --- a/engines/mcp/policy.py +++ b/engines/mcp/policy.py @@ -56,6 +56,8 @@ class ApprovalTier(str, Enum): # Predictive "axguard_predict_security_risks": ApprovalTier.AUTO, "axguard_analyze_change_risk": ApprovalTier.AUTO, + "axguard_security_diff": ApprovalTier.AUTO, + "axguard_preship": ApprovalTier.APPROVAL_REQUIRED, } # Operations that must never run autonomously via MCP diff --git a/engines/mcp/server.py b/engines/mcp/server.py index 44eb356..39b48fb 100644 --- a/engines/mcp/server.py +++ b/engines/mcp/server.py @@ -312,6 +312,28 @@ def axguard_predict_security_risks(mode: str = "default", path: str | None = Non def axguard_analyze_change_risk(base: str, path: str | None = None, approved: bool = False) -> dict[str, Any]: return wrap_fn(HANDLERS["axguard_analyze_change_risk"])(base=base, path=path, approved=approved) + @mcp.tool(name="axguard_security_diff", description=by_name["axguard_security_diff"]["description"], annotations=ann_fn("axguard_security_diff")) + def axguard_security_diff( + path: str | None = None, + base: str | None = None, + head: str | None = None, + approved: bool = False, + ) -> dict[str, Any]: + return wrap_fn(HANDLERS["axguard_security_diff"])( + path=path, base=base, head=head, approved=approved + ) + + @mcp.tool(name="axguard_preship", description=by_name["axguard_preship"]["description"], annotations=ann_fn("axguard_preship")) + def axguard_preship( + path: str | None = None, + mode: str = "STANDARD", + base: str | None = None, + approved: bool = False, + ) -> dict[str, Any]: + return wrap_fn(HANDLERS["axguard_preship"])( + path=path, mode=mode, base=base, approved=approved + ) + def serve_stdio(*, project_root: str | Path | None = None) -> None: """Run the MCP server over stdio (primary local transport).""" diff --git a/engines/mcp/tools/catalog.py b/engines/mcp/tools/catalog.py index 6a5235a..f88c108 100644 --- a/engines/mcp/tools/catalog.py +++ b/engines/mcp/tools/catalog.py @@ -430,6 +430,40 @@ "openWorldHint": False, }, ), + ( + "axguard_security_diff", + ( + "Compare two application states and explain security-relevant changes, " + "including attack surface, security controls, data flows, privileges, " + "trust boundaries, attack paths, regressions, and predictive risks. " + "Use when meaningful security-sensitive code changes occur — not for " + "trivial renames/formatting. Read-only." + ), + { + "title": "Security Diff", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, + }, + ), + ( + "axguard_preship", + ( + "Pre-Ship security gate: is this code safe enough to ship? " + "Returns PASS | PASS_WITH_NOTES | REVIEW_REQUIRED | FAIL. " + "Composes audit + Security Diff + soft predictive/memory. " + "Modes: QUICK|STANDARD|DEEP|MAX (default STANDARD). " + "Never fails solely on unverified suspicion. Read-only." + ), + { + "title": "Pre-Ship Gate", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": False, + "openWorldHint": False, + }, + ), ] diff --git a/engines/mcp/tools/handlers.py b/engines/mcp/tools/handlers.py index 3284452..4c8317d 100644 --- a/engines/mcp/tools/handlers.py +++ b/engines/mcp/tools/handlers.py @@ -625,7 +625,112 @@ def axguard_analyze_change_risk( ) -@as_tool +def axguard_security_diff( + path: str | None = None, + base: str | None = None, + head: str | None = None, + base_path: str | None = None, + approved: bool = False, +) -> dict[str, Any]: + """Compare two application states — security-aware diff for agents.""" + sess = _sess() + sess.begin_tool() + enforce("axguard_security_diff", approved=approved) + try: + from engines.security_diff import security_diff + from engines.security_diff.github_summary import compact_mcp_response + from engines.security_diff.render import render_security_diff_text + + project = bridge.require_path(sess, path) if path else sess.project_root + result = security_diff( + base=base_path or base, + head=head, + project=project, + options={"incremental": True, "skip_predict": False}, + ) + try: + compact = compact_mcp_response(result) + except Exception: # noqa: BLE001 + compact = { + "baseline": result.get("baseline"), + "overall_security_change": result.get("overall_security_change"), + "summary": result.get("summary"), + "authz_changes": result.get("authz_changes"), + "tenant_changes": result.get("tenant_changes"), + "control_changes": result.get("control_changes"), + "categories": result.get("categories"), + "text": render_security_diff_text(result), + } + impact = str( + (result.get("security_impact") or {}).get("level") + or result.get("overall_security_change") + or "UNKNOWN" + ) + conf = ( + "HIGH" + if impact in {"HIGH", "CRITICAL"} + else "MEDIUM" + if impact == "MEDIUM" + else "LOW" + ) + return _ok(compact, state="OBSERVED", confidence=conf) + except Exception as exc: # noqa: BLE001 + return McpError( + "ANALYSIS_FAILED", + f"Security diff failed: {exc}", + details={"type": type(exc).__name__}, + ).as_dict() + + +def axguard_preship( + path: str | None = None, + mode: str = "STANDARD", + base: str | None = None, + approved: bool = False, +) -> dict[str, Any]: + sess = _sess() + sess.begin_tool() + enforce("axguard_preship", approved=approved) + target = bridge.require_path(sess, path) + try: + from engines.preship import run_preship + + result = run_preship( + target if target.is_dir() else sess.project_root, + mode=mode, + base_ref=base, + out_dir=sess.findings_dir() / "preship", + ) + return _ok( + { + "decision": result.get("decision"), + "blocking_reason": result.get("blocking_reason"), + "review_why": result.get("review_why"), + "exit_code": result.get("exit_code"), + "summary": { + "finding_count": len(result.get("findings") or []), + "predictive_count": len(result.get("predictive_risks") or []), + "regression_count": len(result.get("regressions") or []), + "overall_security_change": (result.get("security_diff") or {}).get( + "overall_security_change" + ), + }, + "security_diff_baseline": (result.get("security_diff") or {}).get("baseline"), + "report_paths": result.get("report_paths"), + }, + state="JUDGED", + confidence="HIGH" + if result.get("decision") in {"PASS", "FAIL"} + else "MEDIUM", + ) + except Exception as exc: # noqa: BLE001 + return McpError( + "ANALYSIS_FAILED", + f"Pre-ship failed: {exc}", + details={"type": type(exc).__name__}, + ).as_dict() + + def axguard_security_review_tool( mode: str = "BALANCED", scope: str = "project", @@ -667,4 +772,6 @@ def axguard_security_review_tool( "axguard_get_investigation": as_tool(axguard_get_investigation), "axguard_predict_security_risks": as_tool(axguard_predict_security_risks), "axguard_analyze_change_risk": as_tool(axguard_analyze_change_risk), + "axguard_security_diff": as_tool(axguard_security_diff), + "axguard_preship": as_tool(axguard_preship), } diff --git a/engines/preship/__init__.py b/engines/preship/__init__.py new file mode 100644 index 0000000..0ea075f --- /dev/null +++ b/engines/preship/__init__.py @@ -0,0 +1,39 @@ +"""AXGuard Pre-Ship — ship / no-ship security gate.""" + +from __future__ import annotations + +from engines.preship.decision import decide +from engines.preship.pipeline import run_preship +from engines.preship.policy import ( + PreshipPolicy, + default_policy, + load_preship_policy, + map_preship_verdict, +) +from engines.preship.schema import ( + DECISION_FAIL, + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED, + DEFAULT_MODE, + EXIT_BY_DECISION, + MODES, + exit_code_for, +) + +__all__ = [ + "run_preship", + "decide", + "map_preship_verdict", + "PreshipPolicy", + "default_policy", + "load_preship_policy", + "exit_code_for", + "DECISION_PASS", + "DECISION_PASS_WITH_NOTES", + "DECISION_REVIEW_REQUIRED", + "DECISION_FAIL", + "DEFAULT_MODE", + "MODES", + "EXIT_BY_DECISION", +] diff --git a/engines/preship/cli.py b/engines/preship/cli.py new file mode 100644 index 0000000..ce9cb09 --- /dev/null +++ b/engines/preship/cli.py @@ -0,0 +1,97 @@ +"""CLI for ``axguard preship``.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def add_preship_parser(subparsers: argparse._SubParsersAction) -> None: + preship = subparsers.add_parser( + "preship", + help="Pre-Ship security gate — is this code safe enough to ship?", + ) + preship.add_argument( + "path", + nargs="?", + default=".", + help="Target path (default: .)", + ) + preship.add_argument( + "--mode", + choices=("QUICK", "STANDARD", "DEEP", "MAX"), + default="STANDARD", + help="Analysis depth (default: STANDARD)", + ) + preship.add_argument( + "--base", + default=None, + help="Git base ref for Security Diff (e.g. HEAD~1, main...HEAD)", + ) + preship.add_argument( + "--base-path", + default=None, + help="Filesystem path to previous version for Security Diff", + ) + preship.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON to stdout", + ) + preship.add_argument( + "--out-dir", + default=None, + help="Report directory (default: /.findings/axguard/preship)", + ) + preship.add_argument( + "--fail-on-policy", + action=argparse.BooleanOptionalAction, + default=True, + help="Apply blocking policy to exit codes (default: on)", + ) + preship.add_argument("--no-banner", action="store_true", help="Hide the ASCII banner") + + +def run_preship_command(args: argparse.Namespace) -> int: + from engines.banner import print_banner + from engines.preship import run_preship + from engines.preship.report import render_preship_cli_text + from engines.preship.schema import EXIT_TOOL_ERROR, exit_code_for + + if not getattr(args, "no_banner", False): + print_banner(compact=True) + print() + + target = Path(getattr(args, "path", ".") or ".").resolve() + out_dir = getattr(args, "out_dir", None) + if out_dir is None: + out_dir = target / ".findings" / "axguard" / "preship" + + try: + result = run_preship( + target, + mode=getattr(args, "mode", "STANDARD"), + base_ref=getattr(args, "base", None), + out_dir=out_dir, + base_path=getattr(args, "base_path", None), + ) + except Exception as exc: # noqa: BLE001 + print(f"error: preship failed: {exc}", file=sys.stderr) + return EXIT_TOOL_ERROR + + if getattr(args, "json", False): + print(json.dumps(result, indent=2, default=str)) + else: + print(render_preship_cli_text(result), end="") + paths = result.get("report_paths") or {} + if paths: + print() + print("Reports:") + for k, v in paths.items(): + print(f" {k}: {v}") + + if not getattr(args, "fail_on_policy", True): + return 0 + return int(result.get("exit_code") or exit_code_for(str(result.get("decision") or "PASS"))) diff --git a/engines/preship/decision.py b/engines/preship/decision.py new file mode 100644 index 0000000..8047c9e --- /dev/null +++ b/engines/preship/decision.py @@ -0,0 +1,148 @@ +"""Final decision packaging — blocking_reason + review_why.""" + +from __future__ import annotations + +from typing import Any + +from engines.preship.policy import PreshipPolicy, default_policy, map_preship_verdict +from engines.preship.schema import ( + DECISION_FAIL, + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED, + exit_code_for, +) + + +def decide( + *, + findings: list[dict[str, Any]] | None = None, + security_diff: dict[str, Any] | None = None, + regressions: list[str] | None = None, + unknowns: list[str] | None = None, + predictive_risks: list[Any] | None = None, + policy: PreshipPolicy | None = None, + analysis_failed: bool = False, +) -> dict[str, Any]: + """Produce decision, blocking_reason, review_why, exit_code.""" + pol = policy or default_policy() + findings = findings or [] + regressions = regressions or [] + unknowns = list(unknowns or []) + predictive_risks = predictive_risks or [] + security_diff = security_diff or {} + + decision = map_preship_verdict( + findings, + pol, + regressions=regressions, + unknowns=unknowns, + security_diff=security_diff, + predictive_risks=predictive_risks, + analysis_failed=analysis_failed, + ) + + blocking_reason = _blocking_reason(decision, findings, regressions, security_diff) + review_why = None + if decision == DECISION_REVIEW_REQUIRED: + review_why = _review_why( + findings=findings, + unknowns=unknowns, + security_diff=security_diff, + regressions=regressions, + predictive_risks=predictive_risks, + ) + + return { + "decision": decision, + "blocking_reason": blocking_reason, + "review_why": review_why, + "exit_code": exit_code_for(decision, tool_error=False), + } + + +def _blocking_reason( + decision: str, + findings: list[dict[str, Any]], + regressions: list[str], + security_diff: dict[str, Any], +) -> str | None: + if decision != DECISION_FAIL: + return None + verified = {"VERIFIED", "CONFIRMED"} + for f in findings: + st = str(f.get("status") or "").upper() + sev = str(f.get("severity") or "").upper() + if st in verified and sev in {"CRITICAL", "HIGH"}: + title = f.get("title") or f.get("id") or "finding" + return f"{sev} {title}" + if regressions: + return f"security regression: {regressions[0]}" + overall = security_diff.get("overall_security_change") + if overall: + return f"blocking security change ({overall})" + return "policy blocking condition met" + + +def _review_why( + *, + findings: list[dict[str, Any]], + unknowns: list[str], + security_diff: dict[str, Any], + regressions: list[str], + predictive_risks: list[Any], +) -> dict[str, Any]: + why_needed: list[str] = [] + evidence_present: list[str] = [] + evidence_missing: list[str] = [] + could_change: list[str] = [] + + for f in findings: + st = str(f.get("status") or "").upper() + if st in {"LIKELY", "REQUIRES_REVIEW", "UNVERIFIED", "UNKNOWN"}: + why_needed.append( + f"{st}: {f.get('title') or f.get('id')} " + f"({f.get('severity') or 'unknown'} severity)" + ) + if f.get("evidence") or f.get("file"): + evidence_present.append( + f"{f.get('file') or 'unknown'}:{f.get('line') or '?'}" + ) + else: + evidence_missing.append(str(f.get("id") or f.get("title"))) + could_change.append("verify finding → CONFIRM or FALSE_POSITIVE") + + if unknowns: + why_needed.extend(unknowns) + evidence_missing.extend(unknowns) + could_change.append("establish missing control/tenant evidence") + + overall = str(security_diff.get("overall_security_change") or "") + if overall in {"HIGH", "CRITICAL"}: + why_needed.append(f"security diff overall change: {overall}") + could_change.append("restore removed controls or re-verify paths") + + for ch in security_diff.get("authz_changes") or []: + why_needed.append(f"authz: {ch.get('change')}") + for ch in security_diff.get("tenant_changes") or []: + why_needed.append(f"tenant: {ch.get('change')}") + + if regressions: + why_needed.append(f"regressions: {', '.join(regressions[:5])}") + + if predictive_risks: + why_needed.append(f"{len(predictive_risks)} predictive risk(s) (non-blocking)") + + if security_diff.get("baseline") == "UNKNOWN": + why_needed.append("baseline UNKNOWN — cannot fully assess regressions") + evidence_missing.append("prior attack-paths / security twin snapshot") + could_change.append("provide --base path or git history with prior artifacts") + + return { + "why_review_needed": why_needed, + "what_is_unknown": unknowns + or [w for w in why_needed if "unknown" in w.lower() or "UNKNOWN" in w], + "evidence_present": evidence_present, + "evidence_missing": evidence_missing, + "what_could_change_verdict": could_change, + } diff --git a/engines/preship/pipeline.py b/engines/preship/pipeline.py new file mode 100644 index 0000000..2038e87 --- /dev/null +++ b/engines/preship/pipeline.py @@ -0,0 +1,406 @@ +"""Pre-Ship pipeline — compose audit + security_diff + soft predictive/memory.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from engines.preship.decision import decide +from engines.preship.policy import PreshipPolicy, default_policy, load_preship_policy +from engines.preship.schema import ( + DEFAULT_MODE, + MODE_DEEP, + MODE_MAX, + MODE_QUICK, + MODE_STANDARD, + empty_preship_result, + exit_code_for, +) + + +def _extract_findings(audit: dict[str, Any] | None, scan: dict[str, Any] | None) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + if audit: + # Prefer adversary-final when present + adv = audit.get("adversary") or {} + for key in ("final_findings", "findings"): + for f in adv.get(key) or []: + if isinstance(f, dict): + findings.append(f) + if not findings: + for f in audit.get("findings") or []: + if isinstance(f, dict): + findings.append(f) + if not findings and scan: + for f in scan.get("findings") or []: + if isinstance(f, dict): + findings.append(f) + return findings + + +def _load_memory_regressions(target: Path) -> list[str]: + try: + from engines.memory.regress import detect_regressions + from engines.memory.store import list_snapshots, load_snapshot + + memory_dir = target / ".findings" / "axguard" / "memory" + ids = list_snapshots(memory_dir) + if len(ids) < 2: + return [] + before = load_snapshot(ids[-2], memory_dir) + after = load_snapshot(ids[-1], memory_dir) + if not before or not after: + return [] + reg = detect_regressions(before, after) + out: list[str] = [] + for bucket in ("regressed", "regressions", "items"): + for item in reg.get(bucket) or []: + if isinstance(item, dict): + out.append( + str( + item.get("summary") + or item.get("fingerprint") + or item.get("id") + or item + ) + ) + else: + out.append(str(item)) + return out + except Exception: # noqa: BLE001 + return [] + + +def _soft_predictive(target: Path, security_diff: dict[str, Any]) -> list[dict[str, Any]]: + risks: list[dict[str, Any]] = [] + try: + from engines.attack_graph.predictive import predictive_report + + ag_delta = security_diff.get("attack_path_delta") or {} + if ag_delta and ag_delta.get("baseline") != "UNKNOWN": + # predictive_report expects before/after graphs — soft skip if missing + pass + except Exception: # noqa: BLE001 + pass + + # Derive soft predictive labels from security_diff categories (no CVE claims) + summary = security_diff.get("summary") or {} + if int(summary.get("new_endpoints") or 0) > 0: + risks.append( + { + "label": "ATTACK_SURFACE_EXPANSION", + "detail": f"+{summary['new_endpoints']} endpoints", + "predictive": True, + } + ) + if security_diff.get("authz_changes"): + risks.append( + { + "label": "AUTHORIZATION_DRIFT", + "detail": f"{len(security_diff['authz_changes'])} authz change(s)", + "predictive": True, + } + ) + if security_diff.get("tenant_changes"): + risks.append( + { + "label": "TENANT_ISOLATION_RISK", + "detail": f"{len(security_diff['tenant_changes'])} tenant change(s)", + "predictive": True, + } + ) + if int(summary.get("new_attack_paths") or 0) > 0: + risks.append( + { + "label": "PRIVILEGE_EXPANSION", + "detail": f"+{summary['new_attack_paths']} attack paths", + "predictive": True, + } + ) + return risks + + +def _collect_unknowns( + audit: dict[str, Any] | None, + security_diff: dict[str, Any], +) -> list[str]: + """Substantive unknowns that may trigger REVIEW (not mere missing baseline).""" + unknowns: list[str] = [] + if audit: + for phase in audit.get("phases") or []: + if isinstance(phase, dict) and phase.get("status") == "error": + unknowns.append( + f"phase {phase.get('id')} error: {phase.get('error') or 'unknown'}" + ) + # Tenant/auth unknowns from harmful authz diffs only when present + for ch in (security_diff.get("tenant_changes") or []): + if "unknown" in str(ch.get("change") or "").lower(): + unknowns.append(str(ch.get("change"))) + return unknowns[:25] + + +def run_preship( + target: str | Path, + mode: str = DEFAULT_MODE, + base_ref: str | None = None, + out_dir: str | Path | None = None, + policy: PreshipPolicy | dict[str, Any] | None = None, + *, + base_path: str | Path | None = None, +) -> dict[str, Any]: + """Run the Pre-Ship security gate. + + Modes: + - QUICK: scan + security_diff (changed files / HEAD~1 if git) + - STANDARD: run_audit + security_diff + predictive soft + memory soft + - DEEP/MAX: fuller audit + investigation soft + twin (via audit/diff) + """ + root = Path(target).resolve() + mode_u = (mode or DEFAULT_MODE).upper() + if mode_u not in {MODE_QUICK, MODE_STANDARD, MODE_DEEP, MODE_MAX}: + mode_u = DEFAULT_MODE + + out = Path(out_dir) if out_dir else (root / ".findings" / "axguard" / "preship") + out.mkdir(parents=True, exist_ok=True) + + if isinstance(policy, PreshipPolicy): + pol = policy + elif isinstance(policy, dict): + pol = default_policy() + # shallow apply + b = policy.get("blocking") or {} + u = policy.get("unknowns") or {} + from engines.preship.policy import PreshipBlocking, PreshipUnknowns + + if b: + pol.blocking = PreshipBlocking(**{**pol.blocking.__dict__, **b}) + if u: + pol.unknowns = PreshipUnknowns(**{**pol.unknowns.__dict__, **u}) + else: + pol = load_preship_policy(root / ".axguard.yml") + + result = empty_preship_result(target=str(root), mode=mode_u) + notes: list[str] = [] + audit: dict[str, Any] | None = None + scan: dict[str, Any] | None = None + analysis_failed = False + + try: + if mode_u == MODE_QUICK: + from engines.paths import default_rules_dir + from engines.scanner import ScanOptions, run_scan + + scan = run_scan( + ScanOptions(target=root, rules_dir=default_rules_dir()) + ) + notes.append("QUICK mode: scan + security_diff only") + else: + from engines.audit import AuditOptions, run_audit + + audit_out = out / "audit" + audit_out.mkdir(parents=True, exist_ok=True) + phases: list[str] = [] + if mode_u == MODE_STANDARD: + # Full audit — engines already gate expensive stages + phases = [] + audit = run_audit( + AuditOptions(target=root, out_dir=audit_out, phases=phases) + ) + notes.append(f"{mode_u} mode: run_audit + security_diff") + except Exception as exc: # noqa: BLE001 + analysis_failed = True + notes.append(f"analysis error: {exc}") + + # Security diff + security_diff: dict[str, Any] = {} + try: + from engines.security_diff import run_security_diff + + # Prefer artifact-aware compose when both sides have in-memory models + if base_path and Path(base_path).exists(): + security_diff = run_security_diff( + project=root, + base=str(Path(base_path).resolve()), + head=str(root), + write_report=False, + incremental=mode_u == MODE_QUICK, + ) + elif base_ref: + security_diff = run_security_diff( + project=root, + base=base_ref, + write_report=False, + incremental=mode_u == MODE_QUICK, + ) + else: + # Try HEAD~1 when git; else snapshot / UNKNOWN via pipeline + security_diff = run_security_diff( + project=root, + base="HEAD~1" if mode_u == MODE_QUICK else None, + use_snapshot=mode_u != MODE_QUICK, + write_report=False, + incremental=True, + ) + + # Optional prior artifacts under .findings for compose enrich + base_arts: dict[str, Any] = {} + findings_dir = root / ".findings" / "axguard" + if findings_dir.is_dir() and not base_path: + for name, key in ( + ("application-model.json", "application_model"), + ("dataflow.json", "dataflow"), + ("attack-paths.json", "attack_graph"), + ("security-twin.json", "twin"), + ): + p = findings_dir / name + if p.exists(): + try: + base_arts[key] = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + + # Also enrich with compose when audit artifacts exist for both sides + if audit and base_arts.get("application_model"): + try: + from engines.security_diff.compose import ( + run_security_diff as run_compose_diff, + ) + + composed = run_compose_diff( + root, + base_target=base_path, + base_ref=base_ref, + current_artifacts={ + "application_model": audit.get("application_model"), + "dataflow": audit.get("dataflow"), + "attack_graph": audit.get("attack_graph"), + "twin": audit.get("security_twin") or audit.get("twin"), + }, + base_artifacts=base_arts, + cheap_twin=False, + ) + # Merge authz/tenant/control signals if pipeline missed them + for key in ("authz_changes", "tenant_changes", "control_changes", "categories"): + if composed.get(key) and not security_diff.get(key): + security_diff[key] = composed[key] + if composed.get("overall_security_change") and ( + not security_diff.get("overall_security_change") + or security_diff.get("overall_security_change") in {"NONE", "LOW", "UNKNOWN"} + ): + # Keep the higher of the two + order = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4, "UNKNOWN": 0} + if order.get(str(composed["overall_security_change"]), 0) > order.get( + str(security_diff.get("overall_security_change")), 0 + ): + security_diff["overall_security_change"] = composed[ + "overall_security_change" + ] + except Exception as exc: # noqa: BLE001 + notes.append(f"compose enrich soft-skip: {exc}") + + if security_diff.get("baseline") in {"UNKNOWN", "BASELINE_UNAVAILABLE"}: + notes.append("Security Diff baseline UNKNOWN — prior version not available") + except Exception as exc: # noqa: BLE001 + notes.append(f"security_diff error: {exc}") + security_diff = {"baseline": "UNKNOWN", "error": str(exc), "summary": {}} + + findings = _extract_findings(audit, scan) + regressions = _load_memory_regressions(root) + predictive = _soft_predictive(root, security_diff) + unknowns = _collect_unknowns(audit, security_diff) + + # Soft investigation for DEEP/MAX + investigation_summary: dict[str, Any] | None = None + if mode_u in {MODE_DEEP, MODE_MAX} and findings: + try: + from engines.investigation import run_investigation + + # Soft: investigate top finding only + top = findings[0] + investigation_summary = run_investigation( + root, finding_id=str(top.get("id") or ""), budget="BALANCED" + ) + notes.append("DEEP/MAX: soft investigation on top finding") + except Exception as exc: # noqa: BLE001 + notes.append(f"investigation soft-skip: {exc}") + + decision_pack = decide( + findings=findings, + security_diff=security_diff, + regressions=regressions, + unknowns=unknowns if (unknowns and pol.unknowns.review_required) else [], + predictive_risks=predictive, + policy=pol, + analysis_failed=analysis_failed, + ) + + # Attack paths summary from audit + attack_paths: list[dict[str, Any]] = [] + ag = (audit or {}).get("attack_graph") or security_diff.get("attack_path_delta") or {} + if isinstance(ag, dict): + for p in ag.get("paths") or []: + if isinstance(p, dict): + attack_paths.append( + { + "id": p.get("id"), + "status": p.get("status"), + "entry": p.get("entry"), + "target": p.get("target"), + "tags": p.get("tags") or [], + } + ) + + coverage: dict[str, Any] = {} + if audit: + try: + from engines.report_ux import analysis_coverage + + coverage = analysis_coverage(audit) + except Exception: # noqa: BLE001 + coverage = {"present": [], "missing": [], "limited": True} + + result.update( + { + "decision": decision_pack["decision"], + "blocking_reason": decision_pack["blocking_reason"], + "review_why": decision_pack["review_why"], + "exit_code": decision_pack["exit_code"], + "findings": findings, + "security_diff": security_diff, + "attack_paths": attack_paths[:50], + "regressions": regressions, + "predictive_risks": predictive, + "unknowns": unknowns, + "controls": security_diff.get("control_changes") or [], + "evidence": [], + "coverage": coverage, + "investigation": investigation_summary, + "notes": notes, + "out_dir": str(out), + "policy": { + "blocking": pol.blocking.__dict__, + "unknowns": pol.unknowns.__dict__, + }, + } + ) + + if analysis_failed and not findings and not (security_diff.get("categories") or security_diff.get("summary")): + from engines.preship.schema import EXIT_TOOL_ERROR + + result["exit_code"] = EXIT_TOOL_ERROR + else: + # Successful analysis path — keep decision exit code even if a soft stage failed + result["exit_code"] = decision_pack["exit_code"] + + # Write reports + try: + from engines.preship.report import write_preship_reports + + paths = write_preship_reports(result, out) + result["report_paths"] = paths + except Exception as exc: # noqa: BLE001 + notes.append(f"report write error: {exc}") + result["notes"] = notes + + return result diff --git a/engines/preship/policy.py b/engines/preship/policy.py new file mode 100644 index 0000000..4a1f3b6 --- /dev/null +++ b/engines/preship/policy.py @@ -0,0 +1,346 @@ +"""Pre-Ship blocking policy + FindingView adaptation for map_policy_verdict.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from engines.preship.schema import ( + DECISION_FAIL, + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED, +) + +DEFAULT_POLICY_YAML = """\ +preship: + blocking: + verified_critical: true + verified_high: true + verified_medium: false + likely: false + unverified: false + predictive_risk: false + unknowns: + fail: false + review_required: true +""" + + +@dataclass +class PreshipBlocking: + verified_critical: bool = True + verified_high: bool = True + verified_medium: bool = False + likely: bool = False + unverified: bool = False + predictive_risk: bool = False + + +@dataclass +class PreshipUnknowns: + fail: bool = False + review_required: bool = True + + +@dataclass +class PreshipPolicy: + blocking: PreshipBlocking = field(default_factory=PreshipBlocking) + unknowns: PreshipUnknowns = field(default_factory=PreshipUnknowns) + raw: dict[str, Any] = field(default_factory=dict, repr=False) + + +def default_policy() -> PreshipPolicy: + return PreshipPolicy() + + +def load_preship_policy(path: Path | None = None) -> PreshipPolicy: + """Load policy from .axguard.yml ``preship`` section or defaults.""" + policy = default_policy() + candidates: list[Path] = [] + if path is not None: + candidates.append(path) + else: + cwd = Path.cwd() + for name in (".axguard.yml", ".axguard.yaml", ".axguard.json"): + candidates.append(cwd / name) + + data: dict[str, Any] | None = None + for cand in candidates: + if not cand.exists(): + continue + try: + if cand.suffix == ".json": + import json + + data = json.loads(cand.read_text(encoding="utf-8")) + else: + from engines.github.config import _simple_yaml_load + + data = _simple_yaml_load(cand.read_text(encoding="utf-8")) + break + except Exception: # noqa: BLE001 + continue + + if not data: + return policy + + section = data.get("preship") if isinstance(data, dict) else None + if not isinstance(section, dict): + return policy + + blocking = section.get("blocking") or {} + unknowns = section.get("unknowns") or {} + if isinstance(blocking, dict): + policy.blocking = PreshipBlocking( + verified_critical=bool(blocking.get("verified_critical", True)), + verified_high=bool(blocking.get("verified_high", True)), + verified_medium=bool(blocking.get("verified_medium", False)), + likely=bool(blocking.get("likely", False)), + unverified=bool(blocking.get("unverified", False)), + predictive_risk=bool(blocking.get("predictive_risk", False)), + ) + if isinstance(unknowns, dict): + policy.unknowns = PreshipUnknowns( + fail=bool(unknowns.get("fail", False)), + review_required=bool(unknowns.get("review_required", True)), + ) + policy.raw = section + return policy + + +def _unused_legacy_yaml_fallback(text: str) -> dict[str, Any]: + """Kept as last-resort if github config import fails during load.""" + out: dict[str, Any] = {"preship": {"blocking": {}, "unknowns": {}}} + section = None + subsection = None + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + indent = len(line) - len(line.lstrip(" ")) + key, _, val = line.strip().partition(":") + key = key.strip() + val = val.strip() + if indent == 0 and key == "preship": + section = "preship" + subsection = None + continue + if section != "preship": + continue + if indent == 2 and key in {"blocking", "unknowns"} and not val: + subsection = key + continue + if subsection and indent >= 4 and val: + low = val.lower() + if low in {"true", "yes"}: + parsed: Any = True + elif low in {"false", "no"}: + parsed = False + else: + parsed = val + out["preship"][subsection][key] = parsed + return out + + +# alias for tests / callers that expected the old name +_parse_simple_yaml_preship = _unused_legacy_yaml_fallback + + +def findings_to_views(findings: list[dict[str, Any]]) -> list[Any]: + """Convert audit/adversary finding dicts → FindingView for map_policy_verdict.""" + from engines.github.models import FindingView + + views: list[Any] = [] + for raw in findings or []: + if not isinstance(raw, dict): + continue + loc = raw.get("location") if isinstance(raw.get("location"), dict) else {} + file_ = loc.get("file") or raw.get("file") + line = loc.get("line") or raw.get("line") + status = str( + raw.get("status") + or raw.get("final_status") + or raw.get("adversary_status") + or raw.get("verification_status") + or "UNVERIFIED" + ).upper() + # Map common aliases + if status in {"CONFIRMED", "TRUE_POSITIVE", "TP"}: + status = "VERIFIED" + sev = str(raw.get("severity") or "medium").lower() + title = str(raw.get("title") or raw.get("summary") or raw.get("id") or "finding") + fid = str(raw.get("id") or raw.get("finding_id") or title) + views.append( + FindingView( + finding_id=fid, + title=title, + severity=sev, + status=status, + confidence=str(raw.get("confidence") or "UNKNOWN"), + file=str(file_) if file_ else None, + line=int(line) if line else None, + message=str(raw.get("message") or "")[:800], + evidence=str(raw.get("evidence") or "")[:800] + if not isinstance(raw.get("evidence"), list) + else "", + raw=raw, + ) + ) + return views + + +def policy_to_github_config(policy: PreshipPolicy): + """Adapt PreshipPolicy → GitHub PolicyConfig for map_policy_verdict.""" + from engines.github.config import PolicyConfig + + fail_on: list[str] = [] + if policy.blocking.verified_critical: + fail_on.append("critical") + if policy.blocking.verified_high: + fail_on.append("high") + return PolicyConfig( + fail_on=fail_on, + review_on=["likely"] if not policy.blocking.likely else ["likely"], + fail_on_analysis_error=False, + fail_on_unverified=policy.blocking.unverified, + medium_fail=policy.blocking.verified_medium, + ) + + +def map_preship_verdict( + findings: list[dict[str, Any]], + policy: PreshipPolicy | None = None, + *, + regressions: list[str] | None = None, + unknowns: list[str] | None = None, + security_diff: dict[str, Any] | None = None, + predictive_risks: list[Any] | None = None, + analysis_failed: bool = False, +) -> str: + """Map findings + diff/regressions → PASS | PASS_WITH_NOTES | REVIEW_REQUIRED | FAIL. + + Never FAIL solely on unverified suspicion. Prefer wrapping map_policy_verdict. + """ + pol = policy or default_policy() + + # Convert + call shared map_policy_verdict + try: + from engines.github.models import PolicyVerdict + from engines.github.policy import map_policy_verdict + + views = findings_to_views(findings) + gh_cfg = policy_to_github_config(pol) + # Regressions of verified issues + verdict = map_policy_verdict( + views, + gh_cfg, + analysis_failed=analysis_failed, + regressions=regressions if regressions else None, + ) + decision = verdict.value if isinstance(verdict, PolicyVerdict) else str(verdict) + except Exception: # noqa: BLE001 — thin local fallback + decision = _local_verdict(findings, pol, regressions=regressions) + + # Likely findings → REVIEW when blocking.likely is false (map_policy already does) + # but when likely=true and we want FAIL: + if pol.blocking.likely: + for f in findings: + st = str(f.get("status") or "").upper() + if st == "LIKELY": + decision = DECISION_FAIL + break + + # Security diff HIGH/CRITICAL control removals → at least REVIEW_REQUIRED + if security_diff: + overall = str(security_diff.get("overall_security_change") or "").upper() + removed = int((security_diff.get("summary") or {}).get("removed_controls") or 0) + weakened = int((security_diff.get("summary") or {}).get("weakened_controls") or 0) + authz = security_diff.get("authz_changes") or [] + tenant = security_diff.get("tenant_changes") or [] + harmful_authz = any( + any( + x in str(c.get("impact") or c.get("change") or "").lower() + for x in ("removed", "weakened", "broadened", "privilege") + ) + for c in authz + tenant + ) + if overall in {"HIGH", "CRITICAL"} or removed or weakened or harmful_authz: + if decision == DECISION_PASS: + decision = DECISION_REVIEW_REQUIRED + elif decision == DECISION_PASS_WITH_NOTES: + decision = DECISION_REVIEW_REQUIRED + # Control removal with verified finding already FAIL stays FAIL + ctrl_removed = any( + c.get("state") == "REMOVED" + and str(c.get("kind") or "") in {"authorization", "tenant", "authentication"} + for c in (security_diff.get("control_changes") or []) + ) + if ctrl_removed and overall == "CRITICAL" and pol.blocking.verified_high: + # Still do not FAIL without verified finding — REVIEW is enough + # unless regressions present + if regressions: + decision = DECISION_FAIL + + # Memory regressions + if regressions: + if decision in {DECISION_PASS, DECISION_PASS_WITH_NOTES}: + decision = DECISION_FAIL if pol.blocking.verified_high else DECISION_REVIEW_REQUIRED + + # Unknowns policy + if unknowns: + if pol.unknowns.fail: + decision = DECISION_FAIL + elif pol.unknowns.review_required and decision in { + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + }: + decision = DECISION_REVIEW_REQUIRED + + # Predictive risks — soft unless policy says otherwise + if predictive_risks: + if pol.blocking.predictive_risk: + decision = DECISION_FAIL + elif decision == DECISION_PASS: + decision = DECISION_PASS_WITH_NOTES + + return decision + + +def _local_verdict( + findings: list[dict[str, Any]], + policy: PreshipPolicy, + *, + regressions: list[str] | None = None, +) -> str: + """Fallback when github.policy is unavailable.""" + verified = {"VERIFIED", "CONFIRMED"} + open_f = [ + f + for f in findings + if str(f.get("status") or "").upper() not in {"FALSE_POSITIVE", "FP"} + ] + for f in open_f: + st = str(f.get("status") or "").upper() + sev = str(f.get("severity") or "").lower() + if st in verified: + if sev == "critical" and policy.blocking.verified_critical: + return DECISION_FAIL + if sev == "high" and policy.blocking.verified_high: + return DECISION_FAIL + if sev == "medium" and policy.blocking.verified_medium: + return DECISION_FAIL + if regressions: + return DECISION_FAIL if policy.blocking.verified_high else DECISION_REVIEW_REQUIRED + for f in open_f: + st = str(f.get("status") or "").upper() + if st in {"LIKELY", "REQUIRES_REVIEW"}: + return DECISION_REVIEW_REQUIRED + if any(str(f.get("status") or "").upper() in {"UNVERIFIED", "UNKNOWN", "CANDIDATE"} for f in open_f): + if policy.blocking.unverified: + return DECISION_FAIL + return DECISION_PASS_WITH_NOTES + if any(str(f.get("status") or "").upper() in verified for f in open_f): + return DECISION_PASS_WITH_NOTES + return DECISION_PASS diff --git a/engines/preship/report.py b/engines/preship/report.py new file mode 100644 index 0000000..a962cab --- /dev/null +++ b/engines/preship/report.py @@ -0,0 +1,325 @@ +"""Pre-Ship report writers — JSON / Markdown / self-contained HTML.""" + +from __future__ import annotations + +import html +import json +from pathlib import Path +from typing import Any + + +def write_preship_reports(result: dict[str, Any], out_dir: Path) -> dict[str, str]: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + paths: dict[str, str] = {} + + jpath = out_dir / "preship-report.json" + jpath.write_text(json.dumps(result, indent=2, default=str) + "\n", encoding="utf-8") + paths["json"] = str(jpath) + + md = render_preship_markdown(result) + mpath = out_dir / "preship-report.md" + mpath.write_text(md, encoding="utf-8") + paths["md"] = str(mpath) + + hpath = out_dir / "preship-report.html" + hpath.write_text(render_preship_html(result), encoding="utf-8") + paths["html"] = str(hpath) + return paths + + +def render_preship_markdown(result: dict[str, Any]) -> str: + decision = result.get("decision") or "PASS" + lines = [ + "# AXGuard Pre-Ship Report", + "", + f"**Decision:** {decision}", + "", + ] + if result.get("blocking_reason"): + lines += [f"**Blocking reason:** {result['blocking_reason']}", ""] + + lines += ["## Executive Summary", ""] + findings = result.get("findings") or [] + verified = [ + f + for f in findings + if str(f.get("status") or "").upper() in {"VERIFIED", "CONFIRMED"} + ] + lines.append(f"- Findings: {len(findings)} (verified: {len(verified)})") + sd = result.get("security_diff") or {} + lines.append( + f"- Security diff overall: {sd.get('overall_security_change') or 'n/a'} " + f"(baseline: {sd.get('baseline') or 'UNKNOWN'})" + ) + lines.append(f"- Predictive risks: {len(result.get('predictive_risks') or [])}") + lines.append(f"- Regressions: {len(result.get('regressions') or [])}") + lines.append("") + + lines += ["## Security Diff", ""] + try: + from engines.security_diff import render_security_diff_text + + lines.append("```") + lines.append(render_security_diff_text(sd).rstrip()) + lines.append("```") + lines.append("") + except Exception: # noqa: BLE001 + lines.append("_unavailable_") + lines.append("") + + lines += ["## Verified Findings", ""] + if not verified: + lines.append("_None_") + else: + for f in verified[:20]: + lines.append( + f"- **{f.get('severity', '?').upper()}** " + f"{f.get('title') or f.get('id')} " + f"(`{f.get('file')}:{f.get('line')}`)" + ) + lines.append("") + + lines += ["## Attack Paths", ""] + ap = result.get("attack_paths") or [] + if not ap: + lines.append("_None listed_") + else: + for p in ap[:15]: + lines.append( + f"- `{p.get('id')}` status={p.get('status')} " + f"entry={p.get('entry')} → {p.get('target')}" + ) + lines.append("") + + lines += ["## Security Regressions", ""] + regs = result.get("regressions") or [] + lines.append("_None_" if not regs else "\n".join(f"- {r}" for r in regs)) + lines.append("") + + lines += ["## Security Controls", ""] + for c in (result.get("controls") or [])[:20]: + lines.append(f"- {c.get('state')}: {c.get('id')} ({c.get('kind')})") + if not result.get("controls"): + lines.append("_No control deltas_") + lines.append("") + + lines += ["## Predictive Risks", ""] + for r in result.get("predictive_risks") or []: + if isinstance(r, dict): + lines.append(f"- `{r.get('label')}`: {r.get('detail')}") + else: + lines.append(f"- {r}") + if not result.get("predictive_risks"): + lines.append("_None_") + lines.append("") + + lines += ["## Unknowns", ""] + unk = result.get("unknowns") or [] + lines.append("_None_" if not unk else "\n".join(f"- {u}" for u in unk)) + lines.append("") + + rw = result.get("review_why") + if rw: + lines += ["## Why review is needed", ""] + for k, label in ( + ("why_review_needed", "Why"), + ("what_is_unknown", "Unknown"), + ("evidence_present", "Evidence present"), + ("evidence_missing", "Evidence missing"), + ("what_could_change_verdict", "Could change verdict"), + ): + lines.append(f"### {label}") + items = rw.get(k) or [] + lines.append("_None_" if not items else "\n".join(f"- {i}" for i in items)) + lines.append("") + + lines += [ + "## Remediation", + "", + "Run `axguard fix` / skill `axguard-remediate`, then `axguard verify`.", + "", + "## Coverage", + "", + json.dumps(result.get("coverage") or {}, indent=2), + "", + ] + return "\n".join(lines) + + +def render_preship_html(result: dict[str, Any]) -> str: + """Self-contained HTML with required sections (abbreviated). Reuses report_ux CSS when possible.""" + decision = html.escape(str(result.get("decision") or "PASS")) + css = _preship_css() + # Optional: reuse any shared report stylesheet helpers if present + try: + import engines.report_ux as report_ux + + for name in ("report_css", "base_css", "CSS"): + extra = getattr(report_ux, name, None) + if callable(extra): + val = extra() + if isinstance(val, str) and val: + css = val + "\n" + css + break + elif isinstance(extra, str) and extra: + css = extra + "\n" + css + break + except Exception: # noqa: BLE001 + pass + + def section(title: str, body: str) -> str: + return f"

{html.escape(title)}

{body}
" + + md_body = html.escape(render_preship_markdown(result)) + # Build structured abbreviated sections + findings_html = "" if result.get("findings") else "

None

") + + sd = result.get("security_diff") or {} + try: + from engines.security_diff import render_security_diff_text + + sd_text = html.escape(render_security_diff_text(sd)) + except Exception: # noqa: BLE001 + sd_text = html.escape(json.dumps(sd.get("summary") or {}, indent=2)) + + rw = result.get("review_why") or {} + review_html = "" + if rw: + review_html = section( + "Human Review", + "
" + html.escape(json.dumps(rw, indent=2)) + "
", + ) + + parts = [ + "", + f"AXGuard Pre-Ship — {decision}", + f"", + "
", + "

AXGuard Pre-Ship

", + f"

Decision: {decision}

", + "
", + section("Executive Summary", f"

Target: {html.escape(str(result.get('target') or ''))}

" + f"

Mode: {html.escape(str(result.get('mode') or ''))}

" + f"

Blocking: {html.escape(str(result.get('blocking_reason') or '—'))}

"), + section("Decision", f"

{decision}

"), + section("Security Diff", f"
{sd_text}
"), + section("Verified Findings", findings_html), + section( + "Attack Paths", + "
" + html.escape(json.dumps(result.get("attack_paths") or [], indent=2)[:4000]) + "
", + ), + section( + "Security Regressions", + "" + if result.get("regressions") + else "

None

", + ), + section( + "Security Controls", + "
" + html.escape(json.dumps(result.get("controls") or [], indent=2)[:4000]) + "
", + ), + section( + "Predictive Risks", + "
" + html.escape(json.dumps(result.get("predictive_risks") or [], indent=2)) + "
", + ), + section( + "Unknowns", + "" + if result.get("unknowns") + else "

None

", + ), + review_html, + section("Evidence", "

See JSON report for evidence payloads (redacted upstream).

"), + section("Remediation", "

Fix verified issues, then run axguard verify.

"), + section("Fix Verification", "

Re-run axguard preship after patches.

"), + section( + "Coverage", + "
" + html.escape(json.dumps(result.get("coverage") or {}, indent=2)) + "
", + ), + "
Full markdown
" + md_body + "
", + "", + ] + return "\n".join(parts) + + +def _preship_css() -> str: + return """ +:root { --bg:#0f1419; --fg:#e7ecf1; --mut:#9aa7b5; --fail:#e85d5d; --pass:#3ecf8e; --rev:#e6b84d; } +body { font-family: ui-sans-serif, system-ui, sans-serif; background:var(--bg); color:var(--fg); + margin:0; padding:2rem; line-height:1.45; max-width:960px; } +.brand { font-size:1.4rem; letter-spacing:.04em; margin:0; } +.decision { font-size:1.2rem; font-weight:600; } +.decision-fail, .decision-FAIL { color:var(--fail); } +.decision-pass, .decision-PASS, .decision-pass_with_notes { color:var(--pass); } +.decision-review_required { color:var(--rev); } +section { margin:1.5rem 0; padding-top:.5rem; border-top:1px solid #243041; } +h2 { font-size:1.05rem; color:var(--mut); text-transform:uppercase; letter-spacing:.06em; } +pre { background:#1a2330; padding:1rem; overflow:auto; border-radius:6px; font-size:.85rem; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +""" + + +def render_preship_cli_text(result: dict[str, Any]) -> str: + """Compact terminal output matching the product example.""" + lines = [ + "AXGUARD PRE-SHIP", + "", + f"Decision: {result.get('decision')}", + "", + ] + findings = result.get("findings") or [] + by_sev: dict[str, int] = {} + for f in findings: + st = str(f.get("status") or "").upper() + if st not in {"VERIFIED", "CONFIRMED"}: + continue + sev = str(f.get("severity") or "info").upper() + by_sev[sev] = by_sev.get(sev, 0) + 1 + if by_sev: + lines.append("Verified:") + for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"): + if by_sev.get(sev): + lines.append(f"{by_sev[sev]} {sev}") + lines.append("") + + sd = result.get("security_diff") or {} + summary = sd.get("summary") or {} + lines.append("Security changes:") + if int(summary.get("new_endpoints") or 0): + lines.append(f"+ {summary['new_endpoints']} endpoints") + if int(summary.get("new_external_requests") or 0): + lines.append(f"+ {summary['new_external_requests']} external integration") + if int(summary.get("removed_controls") or 0): + lines.append(f"- {summary['removed_controls']} authorization control") + if not any(int(summary.get(k) or 0) for k in ("new_endpoints", "new_external_requests", "removed_controls")): + lines.append("(none significant)") + lines.append("") + lines.append("New attack paths:") + lines.append(str(int(summary.get("new_attack_paths") or 0))) + lines.append("") + lines.append("Predictive risks:") + lines.append(str(len(result.get("predictive_risks") or []))) + lines.append("") + if result.get("blocking_reason"): + lines.append("Blocking reason:") + lines.append(str(result["blocking_reason"])) + lines.append("") + rw = result.get("review_why") + if rw and result.get("decision") == "REVIEW_REQUIRED": + lines.append("Why review is needed:") + for item in (rw.get("why_review_needed") or [])[:5]: + lines.append(f"- {item}") + lines.append("") + lines += [ + "Run:", + "axguard report", + "axguard fix", + "axguard verify", + "", + ] + return "\n".join(lines) diff --git a/engines/preship/schema.py b/engines/preship/schema.py new file mode 100644 index 0000000..a52cd4c --- /dev/null +++ b/engines/preship/schema.py @@ -0,0 +1,80 @@ +"""Pre-Ship schema — modes, decisions, exit codes.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +SCHEMA_VERSION = "1.0.0" +TOOL_NAME = "axguard" + +# Modes +MODE_QUICK = "QUICK" +MODE_STANDARD = "STANDARD" +MODE_DEEP = "DEEP" +MODE_MAX = "MAX" +MODES = frozenset({MODE_QUICK, MODE_STANDARD, MODE_DEEP, MODE_MAX}) +DEFAULT_MODE = MODE_STANDARD + +# Decision vocabulary +DECISION_PASS = "PASS" +DECISION_PASS_WITH_NOTES = "PASS_WITH_NOTES" +DECISION_REVIEW_REQUIRED = "REVIEW_REQUIRED" +DECISION_FAIL = "FAIL" +DECISIONS = frozenset( + { + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED, + DECISION_FAIL, + } +) + +# Exit codes +# PASS=0, PASS_WITH_NOTES=0, REVIEW_REQUIRED=1, FAIL=2, TOOL_ERROR=3 +EXIT_PASS = 0 +EXIT_PASS_WITH_NOTES = 0 +EXIT_REVIEW_REQUIRED = 1 +EXIT_FAIL = 2 +EXIT_TOOL_ERROR = 3 + +EXIT_BY_DECISION = { + DECISION_PASS: EXIT_PASS, + DECISION_PASS_WITH_NOTES: EXIT_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED: EXIT_REVIEW_REQUIRED, + DECISION_FAIL: EXIT_FAIL, +} + + +def exit_code_for(decision: str, *, tool_error: bool = False) -> int: + if tool_error: + return EXIT_TOOL_ERROR + return EXIT_BY_DECISION.get(str(decision).upper(), EXIT_TOOL_ERROR) + + +def empty_preship_result( + *, + target: str | None = None, + mode: str = DEFAULT_MODE, +) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "tool": TOOL_NAME, + "generated_at": datetime.now(timezone.utc).isoformat(), + "target": target, + "mode": mode, + "decision": DECISION_PASS, + "blocking_reason": None, + "review_why": None, + "findings": [], + "security_diff": {}, + "attack_paths": [], + "regressions": [], + "predictive_risks": [], + "unknowns": [], + "controls": [], + "evidence": [], + "coverage": {}, + "exit_code": EXIT_PASS, + "notes": [], + } diff --git a/engines/report.py b/engines/report.py index e4bb405..3839a77 100644 --- a/engines/report.py +++ b/engines/report.py @@ -273,6 +273,7 @@ def render_html(result: dict) -> str: twin_html = _twin_html_section(result) memory_html = _memory_html_section(result) investigation_html = _investigation_html_section(result) + security_diff_html = _security_diff_html_section(result) engagement_html = _engagement_html_footer(result) if findings: @@ -548,6 +549,7 @@ def render_html(result: dict) -> str: {twin_html} {memory_html} {investigation_html} + {security_diff_html} {engagement_html}
Generated by AXguard · pre-ship security gate
@@ -705,6 +707,29 @@ def _twin_html_section(result: dict) -> str: ) +def _security_diff_html_section(result: dict) -> str: + """HTML Security Diff dashboard fragment when present on the result.""" + payload = result.get("security_diff") or result.get("security_diff_result") + if not payload and result.get("schema_version") and ( + result.get("attack_surface_delta") is not None + and result.get("security_impact") is not None + and result.get("tool") == "axguard" + and "attack_path_delta" in result + ): + # Result itself is a security diff document + payload = result + if not isinstance(payload, dict): + return "" + if not payload.get("security_impact") and not payload.get("attack_surface_delta"): + return "" + try: + from engines.security_diff.report import render_html_section + + return render_html_section(payload) + except Exception: # noqa: BLE001 + return "" + + def _text_report(result: dict) -> str: findings = result.get("findings", []) lines = [ diff --git a/engines/security_diff/__init__.py b/engines/security_diff/__init__.py new file mode 100644 index 0000000..9256a1a --- /dev/null +++ b/engines/security_diff/__init__.py @@ -0,0 +1,44 @@ +"""AXGuard Security Diff Engine — security-aware comparison of two app states.""" + +from __future__ import annotations + +from engines.security_diff.impact import should_fail +from engines.security_diff.pipeline import ( + run_security_diff, + save_baseline_from_project, + security_diff, +) +from engines.security_diff.render import render_security_diff_text +from engines.security_diff.schema import ( + BASELINE_UNKNOWN, + CHANGE_CATEGORIES, + CONTROL_STATES, + SECURITY_DIFF_VERSION, + SEVERITIES, + empty_security_diff, +) +from engines.security_diff.store import list_baselines, load_baseline, save_baseline + +# Artifact-dict compose API (MVP / tests) +from engines.security_diff.compose import run_security_diff as run_security_diff_compose +from engines.security_diff.git_base import list_changed_files, resolve_base_ref + +__all__ = [ + "SECURITY_DIFF_VERSION", + "empty_security_diff", + "security_diff", + "run_security_diff", + "run_security_diff_compose", + "render_security_diff_text", + "resolve_base_ref", + "list_changed_files", + "save_baseline", + "load_baseline", + "list_baselines", + "save_baseline_from_project", + "should_fail", + "CHANGE_CATEGORIES", + "CONTROL_STATES", + "SEVERITIES", + "BASELINE_UNKNOWN", +] diff --git a/engines/security_diff/app_model_diff.py b/engines/security_diff/app_model_diff.py new file mode 100644 index 0000000..53b7fb7 --- /dev/null +++ b/engines/security_diff/app_model_diff.py @@ -0,0 +1,209 @@ +"""Compare two application models for endpoint/route/method/param deltas.""" + +from __future__ import annotations + +from typing import Any + +from engines.security_diff.schema import ( + NEW_AI_TOOL, + NEW_AGENT, + NEW_COMMAND_EXECUTION, + NEW_DATABASE_FLOW, + NEW_ENDPOINT, + NEW_EXTERNAL_INTEGRATION, + NEW_EXTERNAL_REQUEST, + NEW_FILE_ACCESS, + NEW_IDENTITY, + NEW_MCP_TOOL, + NEW_PARAMETER, + NEW_TRUST_BOUNDARY, + NEW_UPLOAD, + NEW_WEBHOOK, + REMOVED_ENDPOINT, + category_record, +) + + +def _ep_key(ep: dict[str, Any]) -> str: + method = str(ep.get("method") or ep.get("http_method") or "*").upper() + path = str(ep.get("path") or ep.get("route") or ep.get("name") or "") + return f"{method} {path}".strip() + + +def _input_keys(ep: dict[str, Any]) -> set[str]: + keys: set[str] = set() + for inp in ep.get("inputs") or ep.get("params") or []: + if isinstance(inp, dict): + name = inp.get("name") or inp.get("param") or inp.get("id") + if name: + keys.add(str(name)) + elif isinstance(inp, str): + keys.add(inp) + return keys + + +def _id_set(items: list[Any], *keys: str) -> set[str]: + out: set[str] = set() + for it in items or []: + if not isinstance(it, dict): + continue + for k in keys: + v = it.get(k) + if v: + out.add(str(v)) + break + else: + # fallback label + label = it.get("label") or it.get("type") or it.get("kind") + if label: + out.add(str(label)) + return out + + +def _sink_keys(sinks: list[Any], kinds: set[str]) -> set[str]: + out: set[str] = set() + for s in sinks or []: + if not isinstance(s, dict): + continue + st = str(s.get("type") or s.get("kind") or "").lower() + if st in kinds or any(k in st for k in kinds): + loc = s.get("file") or (s.get("evidence") or {}).get("file") or "" + line = s.get("line") or (s.get("evidence") or {}).get("line") or "" + out.add(f"{st}:{loc}:{line}:{s.get('symbol') or s.get('label') or ''}") + return out + + +def compare_app_models( + before: dict[str, Any] | None, + after: dict[str, Any] | None, +) -> dict[str, Any]: + """Structural app-model delta. Empty/None before → baseline unknown for that side.""" + categories: list[dict[str, Any]] = [] + summary = { + "added_endpoints": [], + "removed_endpoints": [], + "new_parameters": [], + "new_uploads": [], + "new_webhooks": [], + "new_identities": [], + "new_external": [], + "new_ai": [], + "new_trust_boundaries": [], + "baseline_unknown": before is None, + } + + if before is None or after is None: + return {"categories": categories, "summary": summary, "baseline_unknown": True} + + before_eps = {_ep_key(e): e for e in (before.get("entrypoints") or []) if isinstance(e, dict)} + after_eps = {_ep_key(e): e for e in (after.get("entrypoints") or []) if isinstance(e, dict)} + + for key, ep in after_eps.items(): + if key not in before_eps: + summary["added_endpoints"].append(key) + categories.append( + category_record( + NEW_ENDPOINT, + detail=key, + evidence=_evidence_loc(ep), + ) + ) + # uploads / webhooks heuristics on new endpoints + path_l = key.lower() + if "upload" in path_l or "multipart" in path_l: + summary["new_uploads"].append(key) + categories.append(category_record(NEW_UPLOAD, detail=key)) + if "webhook" in path_l or "hook" in path_l: + summary["new_webhooks"].append(key) + categories.append(category_record(NEW_WEBHOOK, detail=key)) + else: + # new parameters on existing endpoint + before_in = _input_keys(before_eps[key]) + after_in = _input_keys(ep) + for p in sorted(after_in - before_in): + summary["new_parameters"].append(f"{key}::{p}") + categories.append( + category_record(NEW_PARAMETER, detail=f"{key} param={p}") + ) + + for key in before_eps: + if key not in after_eps: + summary["removed_endpoints"].append(key) + categories.append(category_record(REMOVED_ENDPOINT, detail=key)) + + # Identities / external / AI / trust boundaries / sinks + for label, cat, before_key, after_key, keys in ( + ("identities", NEW_IDENTITY, "identities", "identities", ("id", "name", "role")), + ( + "external", + NEW_EXTERNAL_INTEGRATION, + "external_services", + "external_services", + ("id", "name", "url", "host"), + ), + ("ai", NEW_AGENT, "ai_components", "ai_components", ("id", "name", "type")), + ( + "trust", + NEW_TRUST_BOUNDARY, + "trust_boundaries", + "trust_boundaries", + ("id", "name", "label"), + ), + ): + b = _id_set(before.get(before_key) or [], *keys) + a = _id_set(after.get(after_key) or [], *keys) + for item in sorted(a - b): + categories.append(category_record(cat, detail=item)) + if label == "identities": + summary["new_identities"].append(item) + elif label == "external": + summary["new_external"].append(item) + elif label == "ai": + summary["new_ai"].append(item) + else: + summary["new_trust_boundaries"].append(item) + + # AI / MCP tools + for comp in after.get("ai_components") or []: + if not isinstance(comp, dict): + continue + cid = str(comp.get("id") or comp.get("name") or "") + before_ids = _id_set(before.get("ai_components") or [], "id", "name") + if cid and cid not in before_ids: + kind = str(comp.get("type") or comp.get("kind") or "").lower() + if "mcp" in kind or "mcp" in cid.lower(): + categories.append(category_record(NEW_MCP_TOOL, detail=cid)) + elif "tool" in kind: + categories.append(category_record(NEW_AI_TOOL, detail=cid)) + + # Sink-based categories + before_sinks = before.get("sinks") or [] + after_sinks = after.get("sinks") or [] + for kinds, cat in ( + ({"http", "net"}, NEW_EXTERNAL_REQUEST), + ({"fs", "file"}, NEW_FILE_ACCESS), + ({"exec", "cmd", "eval"}, NEW_COMMAND_EXECUTION), + ({"sql", "db"}, NEW_DATABASE_FLOW), + ({"upload"}, NEW_UPLOAD), + ): + b = _sink_keys(before_sinks, kinds) + a = _sink_keys(after_sinks, kinds) + for sk in sorted(a - b): + categories.append(category_record(cat, detail=sk)) + + return { + "categories": categories, + "summary": summary, + "baseline_unknown": False, + } + + +def _evidence_loc(ep: dict[str, Any]) -> str | None: + ev = ep.get("evidence") + if isinstance(ev, dict): + f = ev.get("file") + ln = ev.get("line") + if f: + return f"{f}:{ln}" if ln else str(f) + f = ep.get("file") + return str(f) if f else None diff --git a/engines/security_diff/authz_diff.py b/engines/security_diff/authz_diff.py new file mode 100644 index 0000000..5f6c972 --- /dev/null +++ b/engines/security_diff/authz_diff.py @@ -0,0 +1,248 @@ +"""Authorization and tenant-isolation special-case diffs.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +TENANT_TOKENS = ( + "tenant_id", + "organization_id", + "org_id", + "workspace_id", + "account_id", + "owner_id", +) + +_OWNERSHIP_PATTERNS = ( + re.compile(r"\b\.owner\b", re.I), + re.compile(r"user[_]?id\s*==", re.I), + re.compile(r"\bcurrent_user_id\b", re.I), + re.compile(r"belongs_to", re.I), + re.compile(r"check_object_permissions", re.I), + re.compile(r"has_perm(?:ission)?\b", re.I), + re.compile(r"is_owner\b", re.I), + re.compile(r"filter\(\s*owner", re.I), + re.compile(r"\.filter\(.*user", re.I), + # Comparisons / checks involving owner_id (not mere dict keys or comments) + re.compile(r"owner[_]?id\s*[!=]=", re.I), + re.compile(r"[!=]=\s*.*owner[_]?id", re.I), + re.compile(r"\[['\"]owner_id['\"]\]\s*[!=]=", re.I), + re.compile(r"check_ownership\b", re.I), + re.compile(r"\bdef\s+check_ownership\b", re.I), +) + +_AUTHZ_DECORATORS = ( + re.compile(r"@\s*login_required\b", re.I), + re.compile(r"@\s*permission_required\b", re.I), + re.compile(r"@\s*require[_]?auth", re.I), + re.compile(r"@\s*authorize\b", re.I), + re.compile(r"@\s*roles_required\b", re.I), + re.compile(r"Depends\(\s*[^)]*auth", re.I), +) + +_TENANT_PATTERNS = tuple( + re.compile(rf"\b{re.escape(t)}\b", re.I) for t in TENANT_TOKENS +) + ( + re.compile(r"tenant[_ ]?(?:check|scope|isolation|middleware)", re.I), + re.compile(r"row[_ -]?level[_ ]?security|\brls\b", re.I), +) + + +def _scan_text(text: str) -> dict[str, Any]: + ownership_hits = sum(1 for p in _OWNERSHIP_PATTERNS if p.search(text)) + authz_hits = sum(1 for p in _AUTHZ_DECORATORS if p.search(text)) + tenant_hits = sum(1 for p in _TENANT_PATTERNS if p.search(text)) + tokens_present = [t for t in TENANT_TOKENS if re.search(rf"\b{t}\b", text, re.I)] + return { + "ownership_hits": ownership_hits, + "authz_decorator_hits": authz_hits, + "tenant_hits": tenant_hits, + "tenant_tokens": tokens_present, + "has_ownership": ownership_hits > 0, + "has_authz": authz_hits > 0 or ownership_hits > 0, + "has_tenant": tenant_hits > 0, + } + + +def scan_path_for_authz(path: Path) -> dict[str, Any]: + """Scan source files under path for ownership/authz/tenant heuristics.""" + aggregate = { + "ownership_hits": 0, + "authz_decorator_hits": 0, + "tenant_hits": 0, + "tenant_tokens": set(), + "files": [], + } + if not path.exists(): + return { + **aggregate, + "tenant_tokens": [], + "has_ownership": False, + "has_authz": False, + "has_tenant": False, + } + + files = [path] if path.is_file() else list(path.rglob("*.py")) + list( + path.rglob("*.js") + ) + list(path.rglob("*.ts")) + for f in files: + if not f.is_file(): + continue + try: + text = f.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + hit = _scan_text(text) + if hit["has_ownership"] or hit["has_authz"] or hit["has_tenant"]: + aggregate["files"].append( + {"file": str(f), **{k: v for k, v in hit.items() if k != "tenant_tokens"}} + ) + aggregate["ownership_hits"] += hit["ownership_hits"] + aggregate["authz_decorator_hits"] += hit["authz_decorator_hits"] + aggregate["tenant_hits"] += hit["tenant_hits"] + aggregate["tenant_tokens"].update(hit["tenant_tokens"]) + + tokens = sorted(aggregate["tenant_tokens"]) + return { + "ownership_hits": aggregate["ownership_hits"], + "authz_decorator_hits": aggregate["authz_decorator_hits"], + "tenant_hits": aggregate["tenant_hits"], + "tenant_tokens": tokens, + "files": aggregate["files"], + "has_ownership": aggregate["ownership_hits"] > 0, + "has_authz": aggregate["authz_decorator_hits"] > 0 + or aggregate["ownership_hits"] > 0, + "has_tenant": aggregate["tenant_hits"] > 0, + } + + +def _auth_status(ep: dict[str, Any]) -> str: + authn = ep.get("authentication") or {} + authz = ep.get("authorization") or {} + if isinstance(authn, dict): + st = str(authn.get("status") or "").lower() + if st: + return st + if isinstance(authz, dict): + st = str(authz.get("status") or "").lower() + if st: + return st + return "unknown" + + +def compare_authz( + *, + before_target: Path | None = None, + after_target: Path | None = None, + before_app: dict[str, Any] | None = None, + after_app: dict[str, Any] | None = None, + before_text_scan: dict[str, Any] | None = None, + after_text_scan: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Produce authz_changes and tenant_changes lists.""" + authz_changes: list[dict[str, Any]] = [] + tenant_changes: list[dict[str, Any]] = [] + + before_scan = before_text_scan + after_scan = after_text_scan + if before_scan is None and before_target is not None: + before_scan = scan_path_for_authz(before_target) + if after_scan is None and after_target is not None: + after_scan = scan_path_for_authz(after_target) + + if before_scan is not None and after_scan is not None: + if before_scan.get("has_ownership") and not after_scan.get("has_ownership"): + authz_changes.append( + { + "change": "ownership check removed", + "impact": "authorization weakened", + "evidence": "heuristic: ownership patterns present in base, absent in current", + } + ) + if before_scan.get("has_authz") and not after_scan.get("has_authz"): + authz_changes.append( + { + "change": "authorization removed", + "impact": "authorization weakened", + "evidence": "heuristic: authz decorators/checks removed", + } + ) + if not before_scan.get("has_authz") and after_scan.get("has_authz"): + authz_changes.append( + { + "change": "authorization added", + "impact": "authorization strengthened", + } + ) + if before_scan.get("has_tenant") and not after_scan.get("has_tenant"): + tenant_changes.append( + { + "change": "tenant boundary removed", + "impact": "tenant isolation weakened", + "tokens_before": before_scan.get("tenant_tokens") or [], + } + ) + before_tok = set(before_scan.get("tenant_tokens") or []) + after_tok = set(after_scan.get("tenant_tokens") or []) + lost = before_tok - after_tok + if lost: + tenant_changes.append( + { + "change": "tenant validation bypassed", + "impact": "tenant isolation weakened", + "tokens_removed": sorted(lost), + } + ) + + # Entrypoint auth status broadening + if before_app and after_app: + def ep_map(model: dict[str, Any]) -> dict[str, str]: + out = {} + for ep in model.get("entrypoints") or []: + if not isinstance(ep, dict): + continue + method = str(ep.get("method") or "*").upper() + path = str(ep.get("path") or "") + out[f"{method} {path}"] = _auth_status(ep) + return out + + bmap = ep_map(before_app) + amap = ep_map(after_app) + rank = {"required": 3, "optional": 2, "none": 1, "unknown": 0} + for key, after_st in amap.items(): + before_st = bmap.get(key) + if before_st is None: + continue + if rank.get(before_st, 0) > rank.get(after_st, 0) and after_st in { + "none", + "optional", + "unknown", + }: + authz_changes.append( + { + "change": "authorization broadened", + "endpoint": key, + "previous": before_st, + "current": after_st, + "impact": "privilege expansion", + } + ) + elif before_st != after_st and after_st != "unknown": + authz_changes.append( + { + "change": "authorization scope changed", + "endpoint": key, + "previous": before_st, + "current": after_st, + } + ) + + return { + "authz_changes": authz_changes, + "tenant_changes": tenant_changes, + "before_scan": before_scan, + "after_scan": after_scan, + "baseline_unknown": before_scan is None, + } diff --git a/engines/security_diff/cli.py b/engines/security_diff/cli.py new file mode 100644 index 0000000..1173870 --- /dev/null +++ b/engines/security_diff/cli.py @@ -0,0 +1,74 @@ +"""Optional CLI helpers for Security Diff (primary registration is in cli/main.py).""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +def add_diff_parser(subparsers: argparse._SubParsersAction) -> None: + """Register ``diff`` / ``security-diff`` only if not already present.""" + choices = getattr(subparsers, "choices", {}) or {} + if "diff" in choices or "security-diff" in choices: + return + diff = subparsers.add_parser( + "diff", + aliases=["security-diff"], + help="Security Diff — what became more dangerous between two versions", + ) + diff.add_argument("base_or_path", nargs="?", default=None) + diff.add_argument("path", nargs="?", default=".") + diff.add_argument("--base-path", default=None) + diff.add_argument("--base", default=None) + diff.add_argument("--json", action="store_true") + diff.add_argument("--out", default=None) + diff.add_argument("--no-banner", action="store_true") + diff.add_argument("--no-twin", action="store_true") + + +def run_diff_command(args: argparse.Namespace) -> int: + """Fallback runner when invoked via engines.security_diff.cli.""" + from engines.banner import print_banner + from engines.security_diff.pipeline import run_security_diff + from engines.security_diff.render import render_security_diff_text as render_text + + if not getattr(args, "no_banner", False): + print_banner(compact=True) + print() + + current = Path(getattr(args, "path", ".") or ".").resolve() + base_ref = getattr(args, "base", None) + base_or = getattr(args, "base_or_path", None) + base_path = getattr(args, "base_path", None) + + if base_or and base_path is None and base_ref is None: + candidate = Path(base_or) + if candidate.exists(): + base_path = str(candidate.resolve()) + else: + base_ref = base_or + + try: + result = run_security_diff( + project=current, + base=base_path or base_ref, + write_report=False, + ) + except Exception as exc: # noqa: BLE001 + print(f"error: security diff failed: {exc}", file=sys.stderr) + return 3 + + out_path = getattr(args, "out", None) + if out_path: + Path(out_path).write_text( + json.dumps(result, indent=2, default=str) + "\n", encoding="utf-8" + ) + + if getattr(args, "json", False) or getattr(args, "as_json", False): + print(json.dumps(result, indent=2, default=str)) + else: + print(render_text(result), end="") + return 0 diff --git a/engines/security_diff/compare.py b/engines/security_diff/compare.py new file mode 100644 index 0000000..9d2c243 --- /dev/null +++ b/engines/security_diff/compare.py @@ -0,0 +1,496 @@ +"""Compose category diffs from two security states.""" + +from __future__ import annotations + +from typing import Any + +from engines.security_diff.app_model_diff import compare_app_models +from engines.security_diff.controls import compare_controls +from engines.security_diff.dataflow_diff import compare_dataflows +from engines.security_diff.schema import ( + AUTH_STRONGER, + AUTH_UNCHANGED, + AUTH_UNKNOWN, + AUTH_WEAKER, + CONTROL_REMOVED, + CONTROL_WEAKENED, + IMPACT_HIGH, + IMPACT_MEDIUM, + NEW_ATTACK_PATH, + BLOCKED_ATTACK_PATH, + REMOVED_ATTACK_PATH, + REOPENED_ATTACK_PATH, + WEAKENED_ATTACK_PATH, + PRIVILEGE_EXPANSION, + TENANT_BOUNDARY_WEAKENED, + category_record, + delta_item, + regression_record, + unknown_record, +) + + +def _auth_effective(auth_changes: list[dict[str, Any]]) -> str: + if not auth_changes: + return AUTH_UNCHANGED + states = {c.get("state") for c in auth_changes} + if "REMOVED" in states or "WEAKENED" in states or "BYPASSED" in states: + return AUTH_WEAKER + if "ADDED" in states or "STRENGTHENED" in states: + return AUTH_STRONGER + if "UNKNOWN" in states: + return AUTH_UNKNOWN + return AUTH_UNCHANGED + + +def _path_record(change: dict[str, Any], kind: str) -> dict[str, Any]: + hops = change.get("hop_labels") or change.get("signature") or [] + return { + "kind": kind, + "entry": change.get("entry"), + "steps": hops, + "status": change.get("status") or change.get("status_after"), + "detail": change.get("detail"), + "confidence": change.get("confidence") or "MEDIUM", + "evidence": change.get("evidence") or [], + "impact": change.get("impact") or IMPACT_MEDIUM, + "root_cause": change.get("root_cause"), + } + + +def compare_states( + base_state: dict[str, Any] | None, + head_state: dict[str, Any] | None, + *, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Semantic comparison of two built security states.""" + opts = dict(options or {}) + base_state = base_state or {} + head_state = head_state or {} + + before_am = base_state.get("application_model") + after_am = head_state.get("application_model") + before_df = base_state.get("dataflow") + after_df = head_state.get("dataflow") + before_ag = base_state.get("attack_graph") + after_ag = head_state.get("attack_graph") + before_twin = base_state.get("twin") + after_twin = head_state.get("twin") + before_pred = base_state.get("predictive") + after_pred = head_state.get("predictive") + + app_delta = compare_app_models(before_am, after_am) + df_delta = compare_dataflows(before_df, after_df) + ctrl = compare_controls( + before_am, + after_am, + before_dataflow=before_df, + after_dataflow=after_df, + ) + + categories: list[dict[str, Any]] = [] + categories.extend(app_delta.get("categories") or []) + categories.extend(df_delta.get("categories") or []) + categories.extend(ctrl.get("categories") or []) + + unknowns: list[dict[str, Any]] = [] + unknowns.extend(base_state.get("unknowns") or []) + unknowns.extend(head_state.get("unknowns") or []) + unknowns.extend(ctrl.get("unknowns") or []) + if app_delta.get("baseline_unknown"): + unknowns.append( + unknown_record( + "attack_surface", + "BASE application model unavailable for comparison.", + ) + ) + if df_delta.get("baseline_unknown"): + unknowns.append( + unknown_record( + "data_flow", + "BASE dataflow model unavailable for comparison.", + ) + ) + + # Attack surface lists + surface_added = [] + surface_removed = [] + for cat in app_delta.get("categories") or []: + kind = str(cat.get("category") or "") + item = delta_item( + kind, + detail=str(cat.get("detail") or ""), + impact=str(cat.get("severity") or IMPACT_MEDIUM), + evidence=[cat.get("evidence")] if cat.get("evidence") else [], + ) + if kind.startswith("REMOVED_"): + surface_removed.append(item) + else: + surface_added.append(item) + + # Data flow + df_added = [] + df_changed = [] + for cat in df_delta.get("categories") or []: + item = delta_item( + str(cat.get("category")), + detail=str(cat.get("detail") or ""), + impact=str(cat.get("severity") or IMPACT_MEDIUM), + ) + if "REMOVED" in str(cat.get("category")) or "WEAKENED" in str(cat.get("category")): + df_changed.append(item) + else: + df_added.append(item) + + # Attack path delta via existing compare_attack_graphs + path_delta = { + "new_paths": [], + "removed_paths": [], + "blocked_paths": [], + "reopened_paths": [], + "weakened_paths": [], + "summary": {}, + } + if before_ag and after_ag: + try: + from engines.attack_graph.diff import ( + CHANGE_NEW_ATTACK_PATH, + CHANGE_REMOVED_ATTACK_PATH, + CHANGE_STRENGTHENED_CONTROL, + CHANGE_WEAKENED_CONTROL, + compare_attack_graphs, + ) + + ag_diff = compare_attack_graphs(before_ag, after_ag) + for ch in ag_diff.get("changes") or []: + ctype = str(ch.get("change") or "") + if ctype == CHANGE_NEW_ATTACK_PATH: + path_delta["new_paths"].append(_path_record(ch, NEW_ATTACK_PATH)) + categories.append( + category_record(NEW_ATTACK_PATH, detail=str(ch.get("detail") or "")) + ) + elif ctype == CHANGE_REMOVED_ATTACK_PATH: + # blocked vs removed — treat removed as blocked if after missing + path_delta["removed_paths"].append( + _path_record(ch, REMOVED_ATTACK_PATH) + ) + path_delta["blocked_paths"].append( + _path_record(ch, BLOCKED_ATTACK_PATH) + ) + categories.append( + category_record( + BLOCKED_ATTACK_PATH, detail=str(ch.get("detail") or "") + ) + ) + elif ctype == CHANGE_WEAKENED_CONTROL: + status_before = str(ch.get("status_before") or "") + status_after = str(ch.get("status_after") or "") + if status_before == "BLOCKED" and status_after != "BLOCKED": + path_delta["reopened_paths"].append( + _path_record(ch, REOPENED_ATTACK_PATH) + ) + else: + path_delta["weakened_paths"].append( + _path_record(ch, WEAKENED_ATTACK_PATH) + ) + categories.append( + category_record( + WEAKENED_ATTACK_PATH, detail=str(ch.get("detail") or "") + ) + ) + elif ctype == CHANGE_STRENGTHENED_CONTROL: + path_delta["blocked_paths"].append( + _path_record(ch, BLOCKED_ATTACK_PATH) + ) + path_delta["summary"] = ag_diff.get("summary") or { + "new": len(path_delta["new_paths"]), + "removed": len(path_delta["removed_paths"]), + "weakened": len(path_delta["weakened_paths"]), + } + except Exception as exc: # noqa: BLE001 + unknowns.append( + unknown_record("attack_paths", f"attack graph compare failed: {exc}") + ) + elif before_ag is None or after_ag is None: + unknowns.append( + unknown_record( + "attack_paths", + "Attack graph missing on one side; path delta incomplete.", + ) + ) + + # Twin delta + twin_delta: dict[str, Any] = {} + if before_twin and after_twin: + try: + from engines.twin.pipeline import run_twin_compare + + twin_delta = run_twin_compare(before_twin, after_twin) + except Exception as exc: # noqa: BLE001 + unknowns.append(unknown_record("security_twin", str(exc))) + elif opts.get("require_twin"): + unknowns.append( + unknown_record("security_twin", "Twin unavailable for one or both sides.") + ) + + # Predictive + predictive_risks: list[dict[str, Any]] = [] + if before_pred or after_pred: + try: + from engines.predictive.compare import compare_risk_sets + + prev = (before_pred or {}).get("risks") or [] + cur = (after_pred or {}).get("risks") or [] + cmp = compare_risk_sets(prev, cur) + predictive_risks = cmp.get("comparisons") or cur + except Exception as exc: # noqa: BLE001 + unknowns.append(unknown_record("predictive", str(exc))) + + # AI / MCP / privilege / identity from app model + twin entities + ai_changes: list[dict[str, Any]] = [] + mcp_changes: list[dict[str, Any]] = [] + identity_changes: list[dict[str, Any]] = [] + privilege_changes: list[dict[str, Any]] = [] + trust_changes: list[dict[str, Any]] = [] + dep_changes: list[dict[str, Any]] = [] + secret_changes: list[dict[str, Any]] = [] + + for cat in app_delta.get("categories") or []: + kind = str(cat.get("category") or "") + item = delta_item(kind, detail=str(cat.get("detail") or ""), impact=IMPACT_MEDIUM) + if "MCP" in kind: + mcp_changes.append(item) + elif kind in {"NEW_AGENT", "NEW_AI_TOOL", "NEW_AI_AGENT", "NEW_AGENT_TOOL"}: + ai_changes.append(item) + elif kind == "NEW_IDENTITY": + identity_changes.append(item) + elif "TRUST" in kind: + trust_changes.append(item) + elif "DEPENDENCY" in kind: + dep_changes.append(item) + elif kind == "NEW_SECRET": + secret_changes.append( + delta_item( + kind, + detail=str(cat.get("detail") or ""), + impact=IMPACT_HIGH, + value="[REDACTED]", + ) + ) + + # Privilege expansion heuristics from twin regression + for ch in (twin_delta.get("regression") or {}).get("changes") or []: + ctype = str(ch.get("change") or ch.get("type") or "") + if "PRIVILEGE" in ctype.upper() or ctype == "NEW_PRIVILEGE": + privilege_changes.append( + delta_item( + PRIVILEGE_EXPANSION, + detail=str(ch.get("detail") or ctype), + impact=IMPACT_HIGH, + ) + ) + categories.append( + category_record(PRIVILEGE_EXPANSION, detail=str(ch.get("detail") or "")) + ) + if "EXTERNAL" in ctype.upper(): + trust_changes.append( + delta_item(ctype, detail=str(ch.get("detail") or ""), impact=IMPACT_MEDIUM) + ) + + # Auth / authz / tenant from control compare + auth_changes = ctrl.get("auth_changes") or [] + authz_changes = list(ctrl.get("authz_changes") or []) + tenant_raw = list(ctrl.get("tenant_changes") or []) + tenant_changes: list[dict[str, Any]] = [] + for c in tenant_raw: + item = dict(c) + if c.get("state") in {CONTROL_REMOVED, CONTROL_WEAKENED}: + item["kind"] = TENANT_BOUNDARY_WEAKENED + item["priority"] = "high" + categories.append( + category_record( + TENANT_BOUNDARY_WEAKENED, + detail=str(c.get("detail") or ""), + severity=IMPACT_HIGH, + ) + ) + tenant_changes.append(item) + + # Regressions: removed/weakened authz/tenant + reopened paths + regressions: list[dict[str, Any]] = [] + for c in ctrl.get("changes") or []: + if c.get("state") in {CONTROL_REMOVED, CONTROL_WEAKENED} and c.get( + "control_type" + ) in {"authorization", "tenant_isolation", "authentication"}: + regressions.append( + regression_record( + title=f"{c.get('control_type')} {c.get('state')}", + before=str(c.get("before") or "Control present"), + change=str(c.get("detail") or ""), + now=str(c.get("after") or "Control absent or weaker"), + impact=str(c.get("impact") or IMPACT_HIGH), + evidence=c.get("evidence") or [], + category=str(c.get("control_type")), + ) + ) + for p in path_delta.get("reopened_paths") or []: + regressions.append( + regression_record( + title="Attack path reopened", + before="Path blocked or controlled", + change=str(p.get("detail") or ""), + now="Path reachable again", + impact=IMPACT_HIGH, + evidence=p.get("evidence") or [], + category="attack_path", + ) + ) + + # Recommended actions (smallest relevant fix) + recommended: list[str] = [] + for r in regressions[:5]: + if r.get("category") == "authorization": + recommended.append( + "Restore the existing ownership/authorization check. " + "Do not introduce a second authorization implementation." + ) + elif r.get("category") == "tenant_isolation": + recommended.append( + "Restore tenant scope enforcement on the affected query/path." + ) + elif r.get("category") == "authentication": + recommended.append("Restore authentication on the affected entrypoint.") + else: + recommended.append(f"Review regression: {r.get('title')}") + if path_delta.get("new_paths") and not recommended: + recommended.append( + "Review new attack paths; confirm intended exposure and controls." + ) + + # Root causes (simple) + root_causes: list[dict[str, Any]] = [] + for c in ctrl.get("changes") or []: + if c.get("state") == CONTROL_REMOVED: + root_causes.append( + { + "observed": "Control missing in HEAD", + "root_cause": c.get("detail"), + "location": c.get("location_before"), + } + ) + + # Summaries + app_sum = app_delta.get("summary") or {} + df_sum = df_delta.get("summary") or {} + ctrl_sum = ctrl.get("summary") or {} + + return { + "categories": categories, + "attack_surface_delta": { + "added": surface_added, + "removed": surface_removed, + "changed": [], + "summary": { + "added": len(surface_added), + "removed": len(surface_removed), + "new_endpoints": len(app_sum.get("added_endpoints") or []), + "removed_endpoints": len(app_sum.get("removed_endpoints") or []), + "new_parameters": len(app_sum.get("new_parameters") or []), + "new_webhooks": len(app_sum.get("new_webhooks") or []), + "new_uploads": len(app_sum.get("new_uploads") or []), + }, + }, + "control_delta": { + "changes": ctrl.get("changes") or [], + "summary": ctrl_sum, + }, + "auth_delta": { + "changes": auth_changes, + "effective": _auth_effective(auth_changes), + "summary": {"count": len(auth_changes)}, + }, + "authorization_delta": { + "changes": authz_changes, + "summary": {"count": len(authz_changes)}, + }, + "tenant_isolation_delta": { + "changes": tenant_changes, + "summary": {"count": len(tenant_changes)}, + "priority": "high", + }, + "data_flow_delta": { + "added": df_added, + "removed": [], + "changed": df_changed, + "summary": { + "new_paths": len(df_sum.get("new_paths") or []), + "new_sources": len(df_sum.get("new_sources") or []), + "new_sinks": len(df_sum.get("new_sinks") or []), + "removed_controls": len(df_sum.get("removed_controls") or []), + }, + }, + "trust_boundary_delta": { + "changes": trust_changes, + "summary": {"count": len(trust_changes)}, + }, + "identity_delta": { + "changes": identity_changes, + "summary": {"count": len(identity_changes)}, + }, + "privilege_delta": { + "changes": privilege_changes, + "summary": {"count": len(privilege_changes)}, + }, + "secrets_delta": { + "changes": secret_changes, + "summary": {"count": len(secret_changes)}, + }, + "dependency_delta": { + "changes": dep_changes, + "summary": {"count": len(dep_changes)}, + }, + "ai_delta": {"changes": ai_changes, "summary": {"count": len(ai_changes)}}, + "mcp_delta": {"changes": mcp_changes, "summary": {"count": len(mcp_changes)}}, + "attack_path_delta": path_delta, + "security_twin_delta": twin_delta, + "predictive_risks": predictive_risks, + "regressions": regressions, + "unknowns": unknowns, + "recommended_actions": recommended, + "root_causes": root_causes, + "app_model_delta": app_delta, + "dataflow_delta": df_delta, + "authz_changes": authz_changes, + "tenant_changes": tenant_changes, + "control_changes": ctrl.get("changes") or [], + "summary": { + "new_endpoints": len(app_sum.get("added_endpoints") or []), + "removed_endpoints": len(app_sum.get("removed_endpoints") or []), + "new_parameters": len(app_sum.get("new_parameters") or []), + "new_database_flows": sum( + 1 for c in categories if c.get("category") == "NEW_DATABASE_FLOW" + ), + "new_external_requests": sum( + 1 for c in categories if c.get("category") == "NEW_EXTERNAL_REQUEST" + ), + "new_uploads": len(app_sum.get("new_uploads") or []), + "new_webhooks": len(app_sum.get("new_webhooks") or []), + "removed_controls": int(ctrl_sum.get("removed") or 0), + "weakened_controls": int(ctrl_sum.get("weakened") or 0), + "strengthened_controls": int(ctrl_sum.get("strengthened") or 0), + "moved_controls": int(ctrl_sum.get("moved") or 0), + "added_controls": int(ctrl_sum.get("added") or 0), + "new_attack_paths": len(path_delta.get("new_paths") or []), + "blocked_attack_paths": len(path_delta.get("blocked_paths") or []), + "new_sensitive_flows": sum( + 1 for c in categories if c.get("category") == "NEW_SENSITIVE_DATA_FLOW" + ), + "authz_changes": len(authz_changes), + "tenant_changes": len(tenant_changes), + "privilege_changes": len(privilege_changes), + "regressions": len(regressions), + "predictive_risks": len(predictive_risks), + "unknowns": len(unknowns), + }, + } diff --git a/engines/security_diff/compose.py b/engines/security_diff/compose.py new file mode 100644 index 0000000..809b96d --- /dev/null +++ b/engines/security_diff/compose.py @@ -0,0 +1,384 @@ +"""Compose a unified Security Diff from app model / dataflow / AG / twin.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from engines.security_diff.app_model_diff import compare_app_models +from engines.security_diff.authz_diff import compare_authz +from engines.security_diff.controls_diff import compare_controls +from engines.security_diff.dataflow_diff import compare_dataflows +from engines.security_diff.git_base import resolve_base_ref +from engines.security_diff.schema import ( + BASELINE_ARTIFACTS, + BASELINE_GIT, + BASELINE_PATH, + BASELINE_UNKNOWN, + BLOCKED_ATTACK_PATH, + NEW_ATTACK_PATH, + SEVERITY_CRITICAL, + SEVERITY_HIGH, + SEVERITY_LOW, + SEVERITY_MEDIUM, + category_record, + empty_security_diff, +) + + +def _load_json(path: Path | None) -> dict[str, Any] | None: + if path is None or not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, json.JSONDecodeError): + return None + + +def _build_current_artifacts(target: Path, *, cheap_twin: bool = True) -> dict[str, Any]: + """Build app model + dataflow + attack graph (+ optional twin) for target.""" + arts: dict[str, Any] = { + "application_model": None, + "dataflow": None, + "attack_graph": None, + "twin": None, + } + try: + from engines.app_model import build_application_model + + arts["application_model"] = build_application_model(target) + except Exception: # noqa: BLE001 + arts["application_model"] = None + + try: + from engines.dataflow import analyze_dataflow + + arts["dataflow"] = analyze_dataflow( + target, application_model=arts.get("application_model") + ) + except Exception: # noqa: BLE001 + arts["dataflow"] = None + + try: + from engines.attack_graph import run_attack_graph + + # run_attack_graph builds evidence→adversary internally; pass-through + # of app_model/dataflow is not part of its public signature. + arts["attack_graph"] = run_attack_graph(target) + except Exception: # noqa: BLE001 + arts["attack_graph"] = None + + if cheap_twin and arts.get("attack_graph") is not None: + try: + from engines.twin import build_security_twin + + arts["twin"] = build_security_twin( + target, + application_model=arts.get("application_model"), + attack_graph=arts.get("attack_graph"), + dataflow=arts.get("dataflow"), + ) + except Exception: # noqa: BLE001 + arts["twin"] = None + return arts + + +def _load_findings_artifacts(findings_dir: Path) -> dict[str, Any]: + return { + "application_model": _load_json(findings_dir / "application-model.json"), + "dataflow": _load_json(findings_dir / "dataflow.json"), + "attack_graph": _load_json(findings_dir / "attack-paths.json"), + "twin": _load_json(findings_dir / "security-twin.json") + or _load_json(findings_dir / "twin.json"), + } + + +def _overall_severity( + categories: list[dict[str, Any]], + authz_changes: list[dict[str, Any]], + tenant_changes: list[dict[str, Any]], + control_changes: list[dict[str, Any]], +) -> str: + score = 0 + for c in categories: + cat = str(c.get("category") or "") + sev = str(c.get("severity") or "").upper() + if sev == "CRITICAL" or "CRITICAL" in cat: + score = max(score, 4) + elif sev == "HIGH" or cat in { + "REMOVED_SECURITY_CONTROL", + "WEAKENED_SECURITY_CONTROL", + "NEW_ATTACK_PATH", + }: + score = max(score, 3) + elif cat.startswith("NEW_") or cat == "CONFIGURATION_SECURITY_CHANGE": + score = max(score, 2) + else: + score = max(score, 1) + + for ch in authz_changes + tenant_changes: + impact = str(ch.get("impact") or ch.get("change") or "").lower() + if any( + x in impact + for x in ("removed", "weakened", "broadened", "privilege expansion", "bypass") + ): + score = max(score, 3) + + for ch in control_changes: + if ch.get("state") in {"REMOVED", "WEAKENED"}: + score = max(score, 3) + + if score >= 4: + return SEVERITY_CRITICAL + if score >= 3: + return SEVERITY_HIGH + if score >= 2: + return SEVERITY_MEDIUM + return SEVERITY_LOW + + +def _count_categories(categories: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for c in categories: + cat = str(c.get("category") or "UNKNOWN") + counts[cat] = counts.get(cat, 0) + 1 + return counts + + +def run_security_diff( + current_target: str | Path, + base_target: str | Path | None = None, + base_ref: str | None = None, + current_artifacts: dict[str, Any] | None = None, + base_artifacts: dict[str, Any] | None = None, + *, + cheap_twin: bool = True, +) -> dict[str, Any]: + """Run Security Diff between current and base. + + If neither base_target nor resolvable git base nor base_artifacts exist, + baseline is UNKNOWN (never invented). + """ + current = Path(current_target).resolve() + result = empty_security_diff( + current_target=str(current), + base_target=str(Path(base_target).resolve()) if base_target else None, + base_ref=base_ref, + baseline=BASELINE_UNKNOWN, + ) + + # Current artifacts + cur = dict(current_artifacts or {}) + if not cur.get("application_model") or not cur.get("attack_graph"): + built = _build_current_artifacts(current, cheap_twin=cheap_twin) + for k, v in built.items(): + cur.setdefault(k, v) + + # Base resolution + base_path: Path | None = Path(base_target).resolve() if base_target else None + base = dict(base_artifacts or {}) + git_info = resolve_base_ref(current, base_ref) + notes: list[str] = list(git_info.get("notes") or []) + + if base_path and base_path.exists(): + result["baseline"] = BASELINE_PATH + result["base_target"] = str(base_path) + if not base.get("application_model"): + built_b = _build_current_artifacts(base_path, cheap_twin=cheap_twin) + for k, v in built_b.items(): + base.setdefault(k, v) + elif base and any(base.get(k) for k in ("application_model", "attack_graph", "dataflow", "twin")): + result["baseline"] = BASELINE_ARTIFACTS + elif git_info.get("available"): + result["baseline"] = BASELINE_GIT + result["base_ref"] = git_info.get("base_ref") + result["base_sha"] = git_info.get("base_sha") + result["head_sha"] = git_info.get("head_sha") + result["changed_files"] = git_info.get("changed_files") or [] + # Try previous artifacts under .findings + findings = current / ".findings" / "axguard" + if findings.is_dir(): + prev = _load_findings_artifacts(findings) + # Only use if they look like a prior snapshot (presence of attack graph) + if prev.get("attack_graph") or prev.get("application_model"): + for k, v in prev.items(): + base.setdefault(k, v) + notes.append("base artifacts loaded from .findings/axguard (best-effort)") + if not any(base.get(k) for k in ("application_model", "attack_graph", "dataflow")): + notes.append( + "git base resolved but no base checkout/artifacts; " + "structural base UNKNOWN for missing pieces" + ) + else: + notes.append("baseline UNKNOWN — no git base, base path, or prior artifacts") + + result["notes"] = notes + + before_app = base.get("application_model") + after_app = cur.get("application_model") + before_df = base.get("dataflow") + after_df = cur.get("dataflow") + before_ag = base.get("attack_graph") + after_ag = cur.get("attack_graph") + before_twin = base.get("twin") + after_twin = cur.get("twin") + + categories: list[dict[str, Any]] = [] + + # App model + app_delta = compare_app_models(before_app, after_app) + categories.extend(app_delta.get("categories") or []) + result["app_model_delta"] = app_delta.get("summary") or {} + + # Dataflow + df_delta = compare_dataflows(before_df, after_df) + categories.extend(df_delta.get("categories") or []) + result["dataflow_delta"] = df_delta.get("summary") or {} + + # Controls + ctrl = compare_controls( + before_app=before_app, + after_app=after_app, + before_ag=before_ag, + after_ag=after_ag, + before_twin=before_twin, + after_twin=after_twin, + ) + categories.extend(ctrl.get("categories") or []) + result["control_changes"] = ctrl.get("control_changes") or [] + + # Authz / tenant + authz = compare_authz( + before_target=base_path, + after_target=current, + before_app=before_app, + after_app=after_app, + ) + result["authz_changes"] = authz.get("authz_changes") or [] + result["tenant_changes"] = authz.get("tenant_changes") or [] + + # Attack graph — reuse engines.attack_graph.diff + attack_path_delta: dict[str, Any] = {} + if before_ag and after_ag: + try: + from engines.attack_graph.diff import ( + CHANGE_NEW_ATTACK_PATH, + CHANGE_REMOVED_ATTACK_PATH, + CHANGE_WEAKENED_CONTROL, + compare_attack_graphs, + get_attack_path_diff, + ) + + attack_path_delta = compare_attack_graphs(before_ag, after_ag) + path_changes = get_attack_path_diff(before_ag, after_ag) + for ch in path_changes: + ctype = ch.get("change") + if ctype == CHANGE_NEW_ATTACK_PATH: + categories.append( + category_record( + NEW_ATTACK_PATH, + detail=str(ch.get("detail") or ch.get("hop_labels") or ""), + severity="HIGH", + ) + ) + elif ctype == CHANGE_REMOVED_ATTACK_PATH: + # removed path can mean blocked/fixed + if str(ch.get("status") or "").upper() == "BLOCKED": + categories.append( + category_record(BLOCKED_ATTACK_PATH, detail=str(ch.get("detail") or "")) + ) + elif ctype == CHANGE_WEAKENED_CONTROL: + categories.append( + category_record( + "WEAKENED_SECURITY_CONTROL", + detail=str(ch.get("detail") or ""), + severity="HIGH", + ) + ) + # status changes to reachable + if ctype and "STATUS" in str(ctype): + before_st = str(ch.get("before_status") or "").upper() + after_st = str(ch.get("after_status") or "").upper() + if before_st == "BLOCKED" and after_st in {"CONFIRMED", "LIKELY", "UNVERIFIED"}: + categories.append( + category_record( + NEW_ATTACK_PATH, + detail=f"path unblocked: {before_st}→{after_st}", + severity="HIGH", + ) + ) + if after_st == "BLOCKED" and before_st != "BLOCKED": + categories.append( + category_record( + BLOCKED_ATTACK_PATH, + detail=f"path blocked: {before_st}→{after_st}", + ) + ) + except Exception as exc: # noqa: BLE001 + notes.append(f"attack graph diff skipped: {exc}") + attack_path_delta = {"error": str(exc)} + else: + attack_path_delta = {"baseline": BASELINE_UNKNOWN} + notes.append("attack path delta baseline UNKNOWN (missing before and/or after graph)") + + result["attack_path_delta"] = attack_path_delta + + # Twin compare when both exist + twin_delta: dict[str, Any] = {} + if before_twin and after_twin: + try: + from engines.twin.pipeline import run_twin_compare + + twin_delta = run_twin_compare(before_twin, after_twin) + except Exception as exc: # noqa: BLE001 + notes.append(f"twin compare skipped: {exc}") + twin_delta = {"error": str(exc)} + else: + twin_delta = {"baseline": BASELINE_UNKNOWN} + result["twin_delta"] = twin_delta + + # Summary counts + cat_counts = _count_categories(categories) + summary = result["summary"] + summary["new_endpoints"] = cat_counts.get("NEW_ENDPOINT", 0) + summary["removed_endpoints"] = cat_counts.get("REMOVED_ENDPOINT", 0) + summary["new_parameters"] = cat_counts.get("NEW_PARAMETER", 0) + summary["new_database_flows"] = cat_counts.get("NEW_DATABASE_FLOW", 0) + summary["new_external_requests"] = cat_counts.get("NEW_EXTERNAL_REQUEST", 0) + summary["new_uploads"] = cat_counts.get("NEW_UPLOAD", 0) + summary["new_webhooks"] = cat_counts.get("NEW_WEBHOOK", 0) + summary["removed_controls"] = cat_counts.get("REMOVED_SECURITY_CONTROL", 0) + sum( + 1 for c in result["control_changes"] if c.get("state") == "REMOVED" + ) + summary["weakened_controls"] = cat_counts.get("WEAKENED_SECURITY_CONTROL", 0) + sum( + 1 for c in result["control_changes"] if c.get("state") == "WEAKENED" + ) + summary["strengthened_controls"] = cat_counts.get("STRENGTHENED_SECURITY_CONTROL", 0) + sum( + 1 for c in result["control_changes"] if c.get("state") == "STRENGTHENED" + ) + summary["added_controls"] = sum( + 1 for c in result["control_changes"] if c.get("state") == "ADDED" + ) + summary["new_attack_paths"] = cat_counts.get("NEW_ATTACK_PATH", 0) + summary["blocked_attack_paths"] = cat_counts.get("BLOCKED_ATTACK_PATH", 0) + summary["new_sensitive_flows"] = cat_counts.get("NEW_SENSITIVE_DATA_FLOW", 0) + summary["authz_changes"] = len(result["authz_changes"]) + summary["tenant_changes"] = len(result["tenant_changes"]) + + result["categories"] = categories + result["overall_security_change"] = _overall_severity( + categories, + result["authz_changes"], + result["tenant_changes"], + result["control_changes"], + ) + result["notes"] = notes + result["current_artifacts_present"] = { + k: cur.get(k) is not None for k in ("application_model", "dataflow", "attack_graph", "twin") + } + result["base_artifacts_present"] = { + k: base.get(k) is not None for k in ("application_model", "dataflow", "attack_graph", "twin") + } + return result diff --git a/engines/security_diff/controls.py b/engines/security_diff/controls.py new file mode 100644 index 0000000..106ac78 --- /dev/null +++ b/engines/security_diff/controls.py @@ -0,0 +1,314 @@ +"""Security control comparison with refactor / MOVED awareness.""" + +from __future__ import annotations + +import re +from typing import Any + +from engines.security_diff.schema import ( + CONTROL_ADDED, + CONTROL_MOVED, + CONTROL_REMOVED, + CONTROL_STRENGTHENED, + CONTROL_UNKNOWN, + CONTROL_WEAKENED, + IMPACT_HIGH, + IMPACT_LOW, + IMPACT_MEDIUM, + IMPACT_NONE, + REMOVED_SECURITY_CONTROL, + STRENGTHENED_SECURITY_CONTROL, + WEAKENED_SECURITY_CONTROL, + category_record, + control_change, +) + +_AUTHZ_HINTS = re.compile( + r"authz|authoriz|ownership|permission|rbac|policy|tenant|acl|require_admin|" + r"is_admin|can_access|check_owner", + re.I, +) +_AUTHN_HINTS = re.compile( + r"authn|authenticat|login|session|jwt|oauth|mfa|bearer|require_login|" + r"is_authenticated", + re.I, +) +_TENANT_HINTS = re.compile( + r"tenant|organization_id|org_id|workspace_id|account_id|row.?level|" + r"multi.?tenant", + re.I, +) +_VALIDATION_HINTS = re.compile( + r"validat|sanitiz|escap|encod|parameteriz|allowlist|whitelist|csrf|" + r"rate.?limit|normalize", + re.I, +) + + +def _ctrl_text(c: dict[str, Any]) -> str: + parts = [ + str(c.get(k) or "") + for k in ("type", "kind", "name", "label", "id", "mechanism", "purpose") + ] + return " ".join(parts) + + +def _ctrl_type(c: dict[str, Any]) -> str: + text = _ctrl_text(c).lower() + if _TENANT_HINTS.search(text): + return "tenant_isolation" + if _AUTHZ_HINTS.search(text): + return "authorization" + if _AUTHN_HINTS.search(text): + return "authentication" + if _VALIDATION_HINTS.search(text): + return "input_validation" + explicit = str(c.get("type") or c.get("kind") or "security_control").lower() + return explicit or "security_control" + + +def _location(c: dict[str, Any]) -> str: + ev = c.get("evidence") if isinstance(c.get("evidence"), dict) else {} + f = c.get("file") or ev.get("file") or "" + ln = c.get("line") or ev.get("line") or "" + sym = c.get("symbol") or c.get("name") or c.get("label") or "" + if f and ln: + return f"{f}:{ln}" + if f: + return str(f) + return str(sym) + + +def control_fingerprint(c: dict[str, Any]) -> str: + """Semantic fingerprint ignoring file path (refactor-aware).""" + ctype = _ctrl_type(c) + name = str(c.get("name") or c.get("label") or c.get("symbol") or "").lower() + purpose = str(c.get("purpose") or c.get("mechanism") or "").lower() + # strip path-like tokens from name + name = re.sub(r"[\\/].*", "", name) + name = re.sub(r"\W+", "_", name).strip("_") + purpose = re.sub(r"\W+", "_", purpose).strip("_") + return f"{ctype}|{name}|{purpose}" + + +def _effectiveness_rank(c: dict[str, Any]) -> int: + order = { + "ineffective": 0, + "unknown": 1, + "likely": 2, + "confirmed": 3, + "required": 3, + "optional": 1, + "none": 0, + } + raw = str( + c.get("effectiveness") or c.get("strength") or c.get("status") or "unknown" + ).lower() + return order.get(raw, 1) + + +def compare_controls( + before_model: dict[str, Any] | None, + after_model: dict[str, Any] | None, + *, + before_dataflow: dict[str, Any] | None = None, + after_dataflow: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Diff security controls with MOVED detection for refactors.""" + changes: list[dict[str, Any]] = [] + categories: list[dict[str, Any]] = [] + unknowns: list[dict[str, Any]] = [] + + if before_model is None or after_model is None: + unknowns.append( + { + "area": "security_controls", + "reason": "BASE authorization/control model could not be reconstructed." + if before_model is None + else "HEAD control model unavailable.", + } + ) + return { + "changes": changes, + "categories": categories, + "summary": {"unknown": True}, + "unknowns": unknowns, + "auth_changes": [], + "authz_changes": [], + "tenant_changes": [], + } + + before_list = list(before_model.get("security_controls") or []) + after_list = list(after_model.get("security_controls") or []) + + # Also fold dataflow-level controls when present + if before_dataflow: + before_list.extend(before_dataflow.get("controls") or []) + if after_dataflow: + after_list.extend(after_dataflow.get("controls") or []) + + before_by_fp: dict[str, list[dict[str, Any]]] = {} + after_by_fp: dict[str, list[dict[str, Any]]] = {} + for c in before_list: + if isinstance(c, dict): + before_by_fp.setdefault(control_fingerprint(c), []).append(c) + for c in after_list: + if isinstance(c, dict): + after_by_fp.setdefault(control_fingerprint(c), []).append(c) + + before_fps = set(before_by_fp) + after_fps = set(after_by_fp) + + removed_fps = before_fps - after_fps + added_fps = after_fps - before_fps + shared = before_fps & after_fps + + # MOVED: same fingerprint already handles rename/move of identical controls. + # Additional heuristic: removed+added with same control_type and similar name tokens. + moved_pairs: list[tuple[str, str]] = [] + remaining_removed = set(removed_fps) + remaining_added = set(added_fps) + for rfp in list(remaining_removed): + r_type = rfp.split("|", 1)[0] + r_name = rfp.split("|")[1] if "|" in rfp else "" + best = None + for afp in list(remaining_added): + if afp.split("|", 1)[0] != r_type: + continue + a_name = afp.split("|")[1] if "|" in afp else "" + if r_name and a_name and (r_name in a_name or a_name in r_name): + best = afp + break + if r_type in {"authorization", "authentication", "tenant_isolation"} and not best: + # same type only — weak move candidate when counts match 1:1 later + best = afp + if best and best in remaining_added: + moved_pairs.append((rfp, best)) + remaining_removed.discard(rfp) + remaining_added.discard(best) + + for rfp, afp in moved_pairs: + bc = before_by_fp[rfp][0] + ac = after_by_fp[afp][0] + ch = control_change( + control_type=_ctrl_type(bc), + state=CONTROL_MOVED, + detail=f"Control moved {_location(bc)} → {_location(ac)}", + before=_ctrl_text(bc), + after=_ctrl_text(ac), + location_before=_location(bc), + location_after=_location(ac), + impact=IMPACT_NONE, + ) + changes.append(ch) + categories.append( + category_record( + "MOVED_SECURITY_CONTROL", + detail=ch["detail"], + severity=IMPACT_NONE, + ) + ) + + for rfp in sorted(remaining_removed): + bc = before_by_fp[rfp][0] + ctype = _ctrl_type(bc) + ch = control_change( + control_type=ctype, + state=CONTROL_REMOVED, + detail=f"Removed {ctype} control", + before=_ctrl_text(bc), + after=None, + location_before=_location(bc), + impact=IMPACT_HIGH + if ctype in {"authorization", "tenant_isolation", "authentication"} + else IMPACT_MEDIUM, + ) + changes.append(ch) + categories.append( + category_record( + REMOVED_SECURITY_CONTROL, + detail=ch["detail"], + severity=ch["impact"], + evidence=_location(bc), + ) + ) + + for afp in sorted(remaining_added): + ac = after_by_fp[afp][0] + ctype = _ctrl_type(ac) + ch = control_change( + control_type=ctype, + state=CONTROL_ADDED, + detail=f"Added {ctype} control", + before=None, + after=_ctrl_text(ac), + location_after=_location(ac), + impact=IMPACT_LOW, + ) + changes.append(ch) + + for fp in sorted(shared): + bc = before_by_fp[fp][0] + ac = after_by_fp[fp][0] + ra = _effectiveness_rank(bc) + rb = _effectiveness_rank(ac) + if rb < ra: + ch = control_change( + control_type=_ctrl_type(bc), + state=CONTROL_WEAKENED, + detail=f"Weakened {_ctrl_type(bc)} control", + before=_ctrl_text(bc), + after=_ctrl_text(ac), + location_before=_location(bc), + location_after=_location(ac), + impact=IMPACT_HIGH, + ) + changes.append(ch) + categories.append( + category_record( + WEAKENED_SECURITY_CONTROL, + detail=ch["detail"], + severity=IMPACT_HIGH, + ) + ) + elif rb > ra: + ch = control_change( + control_type=_ctrl_type(bc), + state=CONTROL_STRENGTHENED, + detail=f"Strengthened {_ctrl_type(bc)} control", + before=_ctrl_text(bc), + after=_ctrl_text(ac), + location_before=_location(bc), + location_after=_location(ac), + impact=IMPACT_LOW, + ) + changes.append(ch) + categories.append( + category_record( + STRENGTHENED_SECURITY_CONTROL, + detail=ch["detail"], + severity=IMPACT_LOW, + ) + ) + + auth_changes = [c for c in changes if c.get("control_type") == "authentication"] + authz_changes = [c for c in changes if c.get("control_type") == "authorization"] + tenant_changes = [c for c in changes if c.get("control_type") == "tenant_isolation"] + + summary = { + "added": sum(1 for c in changes if c["state"] == CONTROL_ADDED), + "removed": sum(1 for c in changes if c["state"] == CONTROL_REMOVED), + "weakened": sum(1 for c in changes if c["state"] == CONTROL_WEAKENED), + "strengthened": sum(1 for c in changes if c["state"] == CONTROL_STRENGTHENED), + "moved": sum(1 for c in changes if c["state"] == CONTROL_MOVED), + "unknown": sum(1 for c in changes if c["state"] == CONTROL_UNKNOWN), + } + return { + "changes": changes, + "categories": categories, + "summary": summary, + "unknowns": unknowns, + "auth_changes": auth_changes, + "authz_changes": authz_changes, + "tenant_changes": tenant_changes, + } diff --git a/engines/security_diff/controls_diff.py b/engines/security_diff/controls_diff.py new file mode 100644 index 0000000..c984ee7 --- /dev/null +++ b/engines/security_diff/controls_diff.py @@ -0,0 +1,262 @@ +"""Security control state diffs (authn/authz/tenant/validation/…).""" + +from __future__ import annotations + +from typing import Any + +from engines.security_diff.schema import ( + CONTROL_ADDED, + CONTROL_MOVED, + CONTROL_REMOVED, + CONTROL_STRENGTHENED, + CONTROL_UNCHANGED, + CONTROL_UNKNOWN, + CONTROL_WEAKENED, + REMOVED_SECURITY_CONTROL, + STRENGTHENED_SECURITY_CONTROL, + WEAKENED_SECURITY_CONTROL, + category_record, +) + +CONTROL_KINDS = ( + "authentication", + "authorization", + "tenant", + "validation", + "sanitization", + "encoding", + "parameterization", + "csrf", + "rate_limiting", + "origin_validation", + "url_allowlist", + "signature_validation", + "security_headers", + "secret_handling", + "tool_permissions", + "agent_permissions", + "mcp_trust", +) + +_EFFECTIVENESS_RANK = { + "ineffective": 0, + "absent": 0, + "none": 0, + "unknown": 1, + "partial": 2, + "likely": 3, + "confirmed": 4, + "effective": 4, +} + + +def _norm_kind(raw: str) -> str: + s = raw.lower().replace("-", "_").replace(" ", "_") + aliases = { + "authn": "authentication", + "auth": "authentication", + "authz": "authorization", + "rbac": "authorization", + "abac": "authorization", + "ownership": "authorization", + "tenant_isolation": "tenant", + "tenant_scoping": "tenant", + "sanitize": "sanitization", + "validate": "validation", + "parameterized": "parameterization", + "allowlist": "url_allowlist", + "whitelist": "url_allowlist", + "mcp": "mcp_trust", + "agent": "agent_permissions", + "tool": "tool_permissions", + } + if s in CONTROL_KINDS: + return s + for a, k in aliases.items(): + if a in s: + return k + return s or "unknown" + + +def _control_id(c: dict[str, Any]) -> str: + kind = _norm_kind(str(c.get("kind") or c.get("type") or c.get("category") or "")) + label = str(c.get("label") or c.get("name") or c.get("id") or kind) + return f"{kind}:{label}" + + +def _collect_controls( + app_model: dict[str, Any] | None, + attack_graph: dict[str, Any] | None, + twin: dict[str, Any] | None, +) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + + def add(c: dict[str, Any], source: str) -> None: + if not isinstance(c, dict): + return + cid = _control_id(c) + eff = str( + c.get("effectiveness") or c.get("status") or c.get("strength") or "unknown" + ).lower() + kind = _norm_kind(str(c.get("kind") or c.get("type") or "")) + loc = "" + ev = c.get("evidence") + if isinstance(ev, dict): + loc = str(ev.get("file") or "") + loc = loc or str(c.get("file") or "") + prev = out.get(cid) + if prev and _EFFECTIVENESS_RANK.get(prev.get("effectiveness", "unknown"), 1) >= _EFFECTIVENESS_RANK.get( + eff, 1 + ): + return + out[cid] = { + "id": cid, + "kind": kind, + "label": str(c.get("label") or c.get("name") or cid), + "effectiveness": eff, + "location": loc, + "source": source, + "raw": c, + } + + if app_model: + for c in app_model.get("security_controls") or []: + add(c if isinstance(c, dict) else {}, "app_model") + + if attack_graph: + for n in (attack_graph.get("graph") or {}).get("nodes") or []: + if isinstance(n, dict) and n.get("type") == "control": + add( + { + "id": n.get("id"), + "kind": n.get("kind") or n.get("control_kind") or "control", + "label": n.get("label") or n.get("id"), + "effectiveness": n.get("effectiveness") or "unknown", + "file": (n.get("evidence") or {}).get("file") + if isinstance(n.get("evidence"), dict) + else n.get("file"), + }, + "attack_graph", + ) + for p in attack_graph.get("paths") or []: + if not isinstance(p, dict): + continue + for c in p.get("controls") or []: + if isinstance(c, dict): + add(c, "attack_graph_path") + + if twin: + for c in twin.get("controls") or twin.get("security_controls") or []: + add(c if isinstance(c, dict) else {}, "twin") + # Twin may nest under entities + for ent in twin.get("entities") or []: + if isinstance(ent, dict) and str(ent.get("type") or "") == "control": + add(ent, "twin") + + return out + + +def compare_controls( + *, + before_app: dict[str, Any] | None = None, + after_app: dict[str, Any] | None = None, + before_ag: dict[str, Any] | None = None, + after_ag: dict[str, Any] | None = None, + before_twin: dict[str, Any] | None = None, + after_twin: dict[str, Any] | None = None, +) -> dict[str, Any]: + before = _collect_controls(before_app, before_ag, before_twin) + after = _collect_controls(after_app, after_ag, after_twin) + + changes: list[dict[str, Any]] = [] + categories: list[dict[str, Any]] = [] + + if not before and (before_app is None and before_ag is None and before_twin is None): + return { + "control_changes": [ + { + "id": "*", + "kind": "all", + "state": CONTROL_UNKNOWN, + "detail": "baseline UNKNOWN — no prior control inventory", + } + ], + "categories": [], + "baseline_unknown": True, + } + + for cid, ctrl in after.items(): + if cid not in before: + changes.append( + { + "id": cid, + "kind": ctrl["kind"], + "state": CONTROL_ADDED, + "detail": f"control added: {ctrl['label']}", + "location": ctrl.get("location"), + } + ) + else: + b = before[cid] + br = _EFFECTIVENESS_RANK.get(b.get("effectiveness", "unknown"), 1) + ar = _EFFECTIVENESS_RANK.get(ctrl.get("effectiveness", "unknown"), 1) + bloc = (b.get("location") or "").strip() + aloc = (ctrl.get("location") or "").strip() + if br > ar: + state = CONTROL_WEAKENED + categories.append( + category_record( + WEAKENED_SECURITY_CONTROL, + detail=f"{cid}: {b.get('effectiveness')}→{ctrl.get('effectiveness')}", + severity="HIGH", + ) + ) + elif ar > br: + state = CONTROL_STRENGTHENED + categories.append( + category_record( + STRENGTHENED_SECURITY_CONTROL, + detail=f"{cid}: {b.get('effectiveness')}→{ctrl.get('effectiveness')}", + ) + ) + elif bloc and aloc and bloc != aloc: + state = CONTROL_MOVED + else: + state = CONTROL_UNCHANGED + if state != CONTROL_UNCHANGED: + changes.append( + { + "id": cid, + "kind": ctrl["kind"], + "state": state, + "detail": f"{b.get('effectiveness')}→{ctrl.get('effectiveness')}", + "before_location": bloc or None, + "location": aloc or None, + } + ) + + for cid, ctrl in before.items(): + if cid not in after: + changes.append( + { + "id": cid, + "kind": ctrl["kind"], + "state": CONTROL_REMOVED, + "detail": f"control removed: {ctrl['label']}", + "location": ctrl.get("location"), + } + ) + categories.append( + category_record( + REMOVED_SECURITY_CONTROL, + detail=f"{cid}: {ctrl['label']}", + severity="HIGH", + evidence=ctrl.get("location"), + ) + ) + + return { + "control_changes": changes, + "categories": categories, + "baseline_unknown": False, + } diff --git a/engines/security_diff/dataflow_diff.py b/engines/security_diff/dataflow_diff.py new file mode 100644 index 0000000..959e7a7 --- /dev/null +++ b/engines/security_diff/dataflow_diff.py @@ -0,0 +1,203 @@ +"""Compare dataflow results for new sources/sinks/paths and removed controls.""" + +from __future__ import annotations + +from typing import Any + +from engines.security_diff.schema import ( + NEW_DATABASE_FLOW, + NEW_SENSITIVE_DATA_FLOW, + REMOVED_SECURITY_CONTROL, + WEAKENED_SECURITY_CONTROL, + category_record, +) + +_CONTROL_LABELS = frozenset( + { + "sanitize", + "sanitization", + "validate", + "validation", + "authorize", + "authorization", + "authz", + "parameterize", + "parameterization", + "encode", + "encoding", + "escape", + "csrf", + "allowlist", + "whitelist", + } +) + + +def _path_sig(path: dict[str, Any]) -> str: + hops = path.get("hops") or path.get("steps") or [] + if hops: + parts = [] + for h in hops: + if isinstance(h, dict): + parts.append(str(h.get("id") or h.get("label") or h.get("kind") or "")) + else: + parts.append(str(h)) + return "→".join(parts) + src = path.get("source") or {} + sink = path.get("sink") or {} + if isinstance(src, dict): + src_s = str(src.get("id") or src.get("label") or src.get("kind") or "") + else: + src_s = str(src) + if isinstance(sink, dict): + sink_s = str(sink.get("id") or sink.get("label") or sink.get("kind") or sink.get("type") or "") + else: + sink_s = str(sink) + return f"{src_s}→{sink_s}" + + +def _entity_keys(items: list[Any]) -> set[str]: + out: set[str] = set() + for it in items or []: + if isinstance(it, dict): + out.add( + str( + it.get("id") + or it.get("label") + or f"{it.get('kind') or it.get('type')}:{it.get('file')}:{it.get('line')}" + ) + ) + else: + out.add(str(it)) + return out + + +def _control_labels_from_path(path: dict[str, Any]) -> set[str]: + labels: set[str] = set() + for key in ("controls", "sanitizers", "validators", "transforms", "barriers"): + for c in path.get(key) or []: + if isinstance(c, dict): + raw = " ".join( + str(c.get(k) or "") + for k in ("kind", "type", "label", "name", "effectiveness") + ).lower() + else: + raw = str(c).lower() + for token in _CONTROL_LABELS: + if token in raw: + labels.add(token) + # Also scan step annotations + for step in path.get("hops") or path.get("steps") or []: + if not isinstance(step, dict): + continue + raw = " ".join( + str(step.get(k) or "") for k in ("kind", "type", "label", "taint_state") + ).lower() + for token in _CONTROL_LABELS: + if token in raw: + labels.add(token) + taint = str(path.get("taint_state") or path.get("state") or "").upper() + if taint in {"SANITIZED", "VALIDATED", "TRUSTED"}: + labels.add(taint.lower()) + return labels + + +def _is_sensitive(path: dict[str, Any]) -> bool: + if path.get("sensitive") or path.get("is_sensitive"): + return True + tags = [str(t).lower() for t in (path.get("tags") or [])] + if any(t in tags for t in ("sensitive", "pii", "secret", "credential")): + return True + sink = path.get("sink") or {} + st = str(sink.get("type") or sink.get("kind") or "").lower() if isinstance(sink, dict) else "" + return st in {"sql", "html", "cmd", "exec", "eval", "ai_tool"} + + +def compare_dataflows( + before: dict[str, Any] | None, + after: dict[str, Any] | None, +) -> dict[str, Any]: + categories: list[dict[str, Any]] = [] + summary: dict[str, Any] = { + "new_sources": [], + "new_sinks": [], + "new_paths": [], + "removed_controls": [], + "baseline_unknown": before is None, + } + + if before is None or after is None: + return {"categories": categories, "summary": summary, "baseline_unknown": True} + + before_sources = _entity_keys(before.get("sources") or []) + after_sources = _entity_keys(after.get("sources") or []) + for s in sorted(after_sources - before_sources): + summary["new_sources"].append(s) + + before_sinks = _entity_keys(before.get("sinks") or []) + after_sinks = _entity_keys(after.get("sinks") or []) + for s in sorted(after_sinks - before_sinks): + summary["new_sinks"].append(s) + + def _paths_of(df: dict[str, Any]) -> list[dict[str, Any]]: + raw = df.get("taint_paths") or df.get("paths") or df.get("flows") or [] + return [p for p in raw if isinstance(p, dict)] + + before_paths = {_path_sig(p): p for p in _paths_of(before)} + after_paths = {_path_sig(p): p for p in _paths_of(after)} + + for sig, path in after_paths.items(): + if sig not in before_paths: + summary["new_paths"].append(sig) + if _is_sensitive(path): + categories.append( + category_record(NEW_SENSITIVE_DATA_FLOW, detail=sig) + ) + sink = path.get("sink") or {} + st = ( + str(sink.get("type") or sink.get("kind") or "").lower() + if isinstance(sink, dict) + else "" + ) + if st in {"sql", "db"}: + categories.append(category_record(NEW_DATABASE_FLOW, detail=sig)) + else: + # Control label diffs on shared paths + before_ctrl = _control_labels_from_path(before_paths[sig]) + after_ctrl = _control_labels_from_path(path) + removed = before_ctrl - after_ctrl + for label in sorted(removed): + detail = f"{sig}: removed {label}" + summary["removed_controls"].append(detail) + categories.append( + category_record( + REMOVED_SECURITY_CONTROL, + detail=detail, + severity="HIGH", + ) + ) + # Weakened: was sanitized/validated, now tainted + before_state = str( + before_paths[sig].get("taint_state") + or before_paths[sig].get("state") + or "" + ).upper() + after_state = str(path.get("taint_state") or path.get("state") or "").upper() + if before_state in {"SANITIZED", "VALIDATED", "TRUSTED"} and after_state in { + "TAINTED", + "PARTIALLY_SANITIZED", + "UNKNOWN", + }: + categories.append( + category_record( + WEAKENED_SECURITY_CONTROL, + detail=f"{sig}: {before_state}→{after_state}", + severity="HIGH", + ) + ) + + return { + "categories": categories, + "summary": summary, + "baseline_unknown": False, + } diff --git a/engines/security_diff/git_base.py b/engines/security_diff/git_base.py new file mode 100644 index 0000000..95cd1c2 --- /dev/null +++ b/engines/security_diff/git_base.py @@ -0,0 +1,118 @@ +"""Resolve git base refs for Security Diff. Never invent a baseline.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + + +def _run_git(cwd: Path, *args: str, timeout: float = 15.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip() + except (OSError, subprocess.TimeoutExpired): + return 1, "", "git unavailable" + + +def is_git_repo(path: Path) -> bool: + root = path if path.is_dir() else path.parent + code, out, _ = _run_git(root, "rev-parse", "--is-inside-work-tree") + return code == 0 and out.lower() == "true" + + +def git_root(path: Path) -> Path | None: + root = path if path.is_dir() else path.parent + code, out, _ = _run_git(root, "rev-parse", "--show-toplevel") + if code != 0 or not out: + return None + return Path(out) + + +def resolve_base_ref( + target: Path, + base_ref: str | None = None, +) -> dict[str, Any]: + """Resolve a comparable git base. + + Returns keys: available (bool), base_ref, base_sha, head_sha, range_files, + baseline ("GIT" | "UNKNOWN"), notes. + """ + result: dict[str, Any] = { + "available": False, + "base_ref": base_ref, + "base_sha": None, + "head_sha": None, + "changed_files": [], + "baseline": "UNKNOWN", + "notes": [], + } + root = git_root(target) + if root is None or not is_git_repo(root): + result["notes"].append("no git repository; baseline UNKNOWN") + return result + + code, head, _ = _run_git(root, "rev-parse", "HEAD") + if code != 0 or not head: + result["notes"].append("unable to resolve HEAD; baseline UNKNOWN") + return result + result["head_sha"] = head + + ref = (base_ref or "HEAD~1").strip() + # Support main...HEAD / origin/main...HEAD range syntax → left side is base + if "..." in ref: + left, _, right = ref.partition("...") + ref_base = left.strip() or "HEAD~1" + ref_head = right.strip() or "HEAD" + elif ".." in ref and "..." not in ref: + left, _, right = ref.partition("..") + ref_base = left.strip() or "HEAD~1" + ref_head = right.strip() or "HEAD" + else: + ref_base = ref + ref_head = "HEAD" + + code, base_sha, err = _run_git(root, "rev-parse", "--verify", ref_base) + if code != 0 or not base_sha: + # Fall back to HEAD~1 when explicit ref missing + if ref_base != "HEAD~1": + code2, base_sha2, _ = _run_git(root, "rev-parse", "--verify", "HEAD~1") + if code2 == 0 and base_sha2: + result["notes"].append( + f"base ref '{ref_base}' unavailable ({err or 'missing'}); using HEAD~1" + ) + ref_base = "HEAD~1" + base_sha = base_sha2 + else: + result["notes"].append( + f"base ref '{ref_base}' unavailable; baseline UNKNOWN" + ) + return result + else: + result["notes"].append("HEAD~1 unavailable (single commit?); baseline UNKNOWN") + return result + + result["available"] = True + result["baseline"] = "GIT" + result["base_ref"] = ref_base + result["base_sha"] = base_sha + + # Changed files between base and head + code, diff_out, _ = _run_git( + root, "diff", "--name-only", f"{base_sha}...{ref_head}" + ) + if code == 0 and diff_out: + result["changed_files"] = [ln for ln in diff_out.splitlines() if ln.strip()] + return result + + +def list_changed_files(target: Path, base_ref: str | None = None) -> list[str]: + info = resolve_base_ref(target, base_ref) + return list(info.get("changed_files") or []) diff --git a/engines/security_diff/git_ops.py b/engines/security_diff/git_ops.py new file mode 100644 index 0000000..2ae7f7b --- /dev/null +++ b/engines/security_diff/git_ops.py @@ -0,0 +1,153 @@ +"""Git helpers for Security Diff — materialize refs without mutating the worktree.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from engines.security_diff.git_base import ( + git_root, + is_git_repo, + list_changed_files, + resolve_base_ref, +) + + +def _run_git(cwd: Path, *args: str, timeout: float = 60.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + ["git", *args], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip() + except (OSError, subprocess.TimeoutExpired) as exc: + return 1, "", str(exc) + + +def parse_range_spec(spec: str | None) -> tuple[str | None, str | None]: + """Parse HEAD~1 / main...HEAD / commit into (base, head).""" + if not spec: + return None, None + text = spec.strip() + if "..." in text: + left, _, right = text.partition("...") + return (left.strip() or None), (right.strip() or "HEAD") + if ".." in text: + left, _, right = text.partition("..") + return (left.strip() or None), (right.strip() or "HEAD") + return text, "HEAD" + + +def resolve_comparison( + project: Path, + *, + base: str | None = None, + head: str | None = None, + range_spec: str | None = None, +) -> dict[str, Any]: + """Resolve git comparison endpoints. Never fabricates a base SHA.""" + out: dict[str, Any] = { + "available": False, + "repo": None, + "base_ref": base, + "head_ref": head or "HEAD", + "base_sha": None, + "head_sha": None, + "changed_files": [], + "notes": [], + } + root = git_root(project) + if root is None or not is_git_repo(root): + out["notes"].append("not a git repository") + return out + out["repo"] = str(root) + + if range_spec: + b, h = parse_range_spec(range_spec) + base = base or b + head = head or h + + info = resolve_base_ref(root, base) + out["notes"].extend(info.get("notes") or []) + if not info.get("available"): + return out + + head_ref = head or "HEAD" + code, head_sha, err = _run_git(root, "rev-parse", "--verify", head_ref) + if code != 0 or not head_sha: + # Working tree comparison: allow HEAD as conceptual head + if head_ref in {None, "HEAD", "WORKTREE", "."}: + code2, head_sha2, _ = _run_git(root, "rev-parse", "HEAD") + if code2 != 0: + out["notes"].append(f"unable to resolve head '{head_ref}': {err}") + return out + head_sha = head_sha2 + head_ref = "HEAD" + else: + out["notes"].append(f"unable to resolve head '{head_ref}': {err}") + return out + + out["available"] = True + out["base_ref"] = info.get("base_ref") + out["base_sha"] = info.get("base_sha") + out["head_ref"] = head_ref + out["head_sha"] = head_sha + out["changed_files"] = list( + info.get("changed_files") + or list_changed_files(root, str(info.get("base_ref") or base)) + ) + return out + + +def materialize_ref(repo: Path, ref: str, dest_dir: Path | None = None) -> Path | None: + """Export ``ref`` into a temp directory via ``git archive`` (no checkout).""" + root = git_root(repo) or Path(repo) + code, sha, err = _run_git(root, "rev-parse", "--verify", ref) + if code != 0 or not sha: + return None + + dest = Path(dest_dir) if dest_dir else Path(tempfile.mkdtemp(prefix="axguard-diff-")) + dest.mkdir(parents=True, exist_ok=True) + + # Prefer git archive | tar + try: + archive = subprocess.Popen( + ["git", "archive", "--format=tar", sha], + cwd=str(root), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + extract = subprocess.run( + ["tar", "-xf", "-"], + cwd=str(dest), + stdin=archive.stdout, + capture_output=True, + check=False, + ) + if archive.stdout: + archive.stdout.close() + archive.wait(timeout=120) + if archive.returncode != 0 or extract.returncode != 0: + shutil.rmtree(dest, ignore_errors=True) + return None + return dest + except (OSError, subprocess.TimeoutExpired): + shutil.rmtree(dest, ignore_errors=True) + return None + + +def cleanup_materialized(path: Path | None) -> None: + if path is None: + return + try: + if path.is_dir() and "axguard-diff-" in path.name: + shutil.rmtree(path, ignore_errors=True) + except OSError: + pass diff --git a/engines/security_diff/github_summary.py b/engines/security_diff/github_summary.py new file mode 100644 index 0000000..a81d008 --- /dev/null +++ b/engines/security_diff/github_summary.py @@ -0,0 +1,65 @@ +"""GitHub PR summary formatter for Security Diff (shared engine, not a second bot).""" + +from __future__ import annotations + +from typing import Any + + +def format_github_pr_summary(diff: dict[str, Any]) -> str: + """Compact markdown suitable for a PR check / comment.""" + impact = diff.get("security_impact") or {} + level = impact.get("level") or "UNKNOWN" + summary = diff.get("summary") or {} + paths = diff.get("attack_path_delta") or {} + regressions = diff.get("regressions") or [] + pred = diff.get("predictive_risks") or [] + ctrl = (diff.get("control_delta") or {}).get("summary") or {} + + lines = [ + "AXGUARD SECURITY DIFF", + "", + f"Risk increased: {level}", + "", + f"+ {summary.get('new_endpoints', 0)} endpoints", + f"+ {summary.get('privilege_changes', 0)} privileged operation(s)", + f"- {ctrl.get('removed', summary.get('removed_controls', 0))} authorization control(s)", + f"+ {summary.get('new_sensitive_flows', 0)} sensitive data flow(s)", + "", + "New attack paths:", + str(len(paths.get("new_paths") or summary.get("new_attack_paths") or [])), + "", + "Security regressions:", + str(len(regressions)), + "", + "Predictive risks:", + str(len(pred)), + "", + f"Decision: {impact.get('decision') or 'UNKNOWN'}", + ] + return "\n".join(lines) + "\n" + + +def compact_mcp_response(diff: dict[str, Any]) -> dict[str, Any]: + """Compact payload for AI-agent context.""" + impact = diff.get("security_impact") or {} + ctrl = (diff.get("control_delta") or {}).get("changes") or [] + paths = diff.get("attack_path_delta") or {} + return { + "summary": diff.get("summary") or {}, + "security_impact": impact, + "changed_controls": ctrl[:20], + "new_attack_paths": (paths.get("new_paths") or [])[:10], + "removed_attack_paths": (paths.get("removed_paths") or [])[:10], + "regressions": (diff.get("regressions") or [])[:10], + "privilege_changes": ((diff.get("privilege_delta") or {}).get("changes") or [])[ + :10 + ], + "sensitive_flow_changes": ( + (diff.get("data_flow_delta") or {}).get("added") or [] + )[:10], + "predictive_risks": (diff.get("predictive_risks") or [])[:10], + "unknowns": (diff.get("unknowns") or [])[:10], + "recommended_action": (diff.get("recommended_actions") or ["none"])[:5], + "baseline": diff.get("baseline"), + "decision": impact.get("decision"), + } diff --git a/engines/security_diff/impact.py b/engines/security_diff/impact.py new file mode 100644 index 0000000..4437a23 --- /dev/null +++ b/engines/security_diff/impact.py @@ -0,0 +1,160 @@ +"""Risk aggregation and security impact classification.""" + +from __future__ import annotations + +from typing import Any + +from engines.security_diff.schema import ( + CONTROL_MOVED, + CONTROL_REMOVED, + CONTROL_WEAKENED, + DECISION_FAIL, + DECISION_PASS, + DECISION_REVIEW_REQUIRED, + DECISION_UNKNOWN, + IMPACT_CRITICAL, + IMPACT_HIGH, + IMPACT_LOW, + IMPACT_MEDIUM, + IMPACT_NONE, + IMPACT_UNKNOWN, + BASELINE_UNAVAILABLE, +) + +_RANK = { + IMPACT_NONE: 0, + IMPACT_LOW: 1, + IMPACT_MEDIUM: 2, + IMPACT_HIGH: 3, + IMPACT_CRITICAL: 4, + IMPACT_UNKNOWN: 1, +} + + +def _max_impact(*levels: str) -> str: + best = IMPACT_NONE + for level in levels: + if _RANK.get(str(level), 0) > _RANK.get(best, 0): + best = str(level) + return best + + +def classify_impact(diff: dict[str, Any]) -> dict[str, Any]: + """Derive overall security_impact + decision from populated deltas.""" + if str(diff.get("baseline")) in {BASELINE_UNAVAILABLE, "UNKNOWN", "BASELINE_UNAVAILABLE"}: + return { + "level": IMPACT_UNKNOWN, + "reason": "No valid baseline available; comparison was not fabricated.", + "decision": DECISION_UNKNOWN, + } + + reasons: list[str] = [] + level = IMPACT_NONE + + control_changes = (diff.get("control_delta") or {}).get("changes") or diff.get( + "control_changes" + ) or [] + removed = [c for c in control_changes if c.get("state") == CONTROL_REMOVED] + weakened = [c for c in control_changes if c.get("state") == CONTROL_WEAKENED] + moved = [c for c in control_changes if c.get("state") == CONTROL_MOVED] + + tenant = (diff.get("tenant_isolation_delta") or {}).get("changes") or diff.get( + "tenant_changes" + ) or [] + authz = (diff.get("authorization_delta") or {}).get("changes") or diff.get( + "authz_changes" + ) or [] + path_delta = diff.get("attack_path_delta") or {} + new_paths = path_delta.get("new_paths") or [] + reopened = path_delta.get("reopened_paths") or [] + weakened_paths = path_delta.get("weakened_paths") or [] + regressions = diff.get("regressions") or [] + priv = (diff.get("privilege_delta") or {}).get("changes") or [] + surface_added = (diff.get("attack_surface_delta") or {}).get("added") or [] + sensitive = (diff.get("data_flow_delta") or {}).get("added") or [] + mcp = (diff.get("mcp_delta") or {}).get("changes") or [] + ai = (diff.get("ai_delta") or {}).get("changes") or [] + + if tenant: + level = _max_impact(level, IMPACT_CRITICAL) + reasons.append("Tenant isolation boundary changed.") + if removed: + high_types = { + c.get("control_type") + for c in removed + if c.get("control_type") + in {"authorization", "tenant_isolation", "authentication"} + } + if high_types: + level = _max_impact(level, IMPACT_HIGH) + reasons.append( + f"Security control removed ({', '.join(sorted(high_types))})." + ) + else: + level = _max_impact(level, IMPACT_MEDIUM) + reasons.append("Security control(s) removed.") + if weakened: + level = _max_impact(level, IMPACT_HIGH) + reasons.append("Security control(s) weakened.") + if authz: + level = _max_impact(level, IMPACT_HIGH) + reasons.append("Authorization posture changed.") + if new_paths or reopened or weakened_paths: + level = _max_impact(level, IMPACT_HIGH) + reasons.append( + f"Attack path delta: +{len(new_paths)} new, " + f"{len(reopened)} reopened, {len(weakened_paths)} weakened." + ) + if regressions: + level = _max_impact(level, IMPACT_HIGH) + reasons.append(f"{len(regressions)} security regression(s).") + if priv: + level = _max_impact(level, IMPACT_HIGH if len(priv) else IMPACT_MEDIUM) + reasons.append("Privilege expansion detected.") + if any( + str(c.get("kind") or c.get("category") or "").endswith("PRIVILEGE_EXPANSION") + or "PRIVILEGE" in str(c.get("kind") or "") + for c in list(mcp) + list(ai) + list(priv) + ): + level = _max_impact(level, IMPACT_HIGH) + reasons.append("AI/MCP privilege expansion.") + if sensitive: + level = _max_impact(level, IMPACT_MEDIUM) + reasons.append("Sensitive data-flow changes.") + if surface_added and level == IMPACT_NONE: + level = IMPACT_LOW + reasons.append("Attack surface expanded.") + if moved and level == IMPACT_NONE: + reasons.append("Controls moved but security meaning unchanged.") + + if not reasons: + reasons.append( + "No meaningful security controls, attack paths, privileges, " + "or sensitive flows changed." + ) + + decision = DECISION_PASS + if level in {IMPACT_HIGH, IMPACT_CRITICAL}: + decision = DECISION_REVIEW_REQUIRED + elif level == IMPACT_MEDIUM: + decision = DECISION_REVIEW_REQUIRED + elif level == IMPACT_UNKNOWN: + decision = DECISION_UNKNOWN + + # CI fail-on uses level; decision FAIL reserved for explicit policy + fail_on = str((diff.get("options") or {}).get("fail_on") or "none").lower() + if fail_on != "none" and _RANK.get(level, 0) >= _RANK.get(fail_on.upper(), 99): + decision = DECISION_FAIL + + return { + "level": level, + "reason": " ".join(reasons), + "decision": decision, + } + + +def should_fail(diff: dict[str, Any], fail_on: str) -> bool: + if not fail_on or fail_on == "none": + return False + level = str((diff.get("security_impact") or {}).get("level") or IMPACT_NONE) + return _RANK.get(level, 0) >= _RANK.get(fail_on.upper(), 99) diff --git a/engines/security_diff/pipeline.py b/engines/security_diff/pipeline.py new file mode 100644 index 0000000..92ed539 --- /dev/null +++ b/engines/security_diff/pipeline.py @@ -0,0 +1,482 @@ +"""Security Diff pipeline — reusable core API for CLI / MCP / GitHub / Pre-Ship.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from engines.security_diff.compare import compare_states +from engines.security_diff.git_ops import ( + cleanup_materialized, + materialize_ref, + resolve_comparison, +) +from engines.security_diff.impact import classify_impact, should_fail +from engines.security_diff.report import write_security_diff_report +from engines.security_diff.schema import ( + BASELINE_AXGUARD_SNAPSHOT, + BASELINE_GIT, + BASELINE_PATH, + BASELINE_UNAVAILABLE, + DECISION_UNKNOWN, + IMPACT_UNKNOWN, + empty_security_diff, + side_ref, + utc_now, +) +from engines.security_diff.state import build_security_state +from engines.security_diff.store import load_baseline, save_baseline + + +def _as_path(value: Any) -> Path | None: + if value is None: + return None + if isinstance(value, Path): + return value + text = str(value).strip() + if not text: + return None + p = Path(text) + if p.exists(): + return p.resolve() + return None + + +def _looks_like_git_ref(value: str) -> bool: + if value in {".", "./"}: + return False + p = Path(value) + if p.exists(): + return False + return True + + +def save_baseline_from_project( + project: Path | str, + name: str = "default", + *, + options: dict[str, Any] | None = None, +) -> Path: + root = Path(project).resolve() + state = build_security_state(root, options=options) + return save_baseline(root, state, name=name) + + +def security_diff( + base: Any = None, + head: Any = None, + project: Any = None, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Compare two application states and explain security-relevant changes. + + Parameters + ---------- + base: + Git ref, path, or None (auto / snapshot). + head: + Git ref, path, working tree, or None (defaults to project). + project: + Project root (default ``.``). + options: + verbose, fail_on, incremental, investigate, reuse_artifacts, + baseline_name, write_report, out_dir, skip_*, range_spec, ... + """ + opts = dict(options or {}) + project_path = _as_path(project) or Path(".").resolve() + if not project_path.is_dir(): + project_path = project_path.parent + + range_spec = opts.get("range_spec") + baseline_name = str(opts.get("baseline_name") or "default") + incremental = bool(opts.get("incremental", True)) + materialized: list[Path] = [] + + result = empty_security_diff( + current_target=str(project_path), + baseline=BASELINE_UNAVAILABLE, + ) + result["generated_at"] = utc_now() + result["options"] = { + k: v + for k, v in opts.items() + if k + in { + "fail_on", + "incremental", + "investigate", + "reuse_artifacts", + "baseline_name", + "skip_predict", + "skip_memory", + "skip_investigation", + } + } + + base_path: Path | None = _as_path(base) + head_path: Path | None = _as_path(head) or project_path + base_ref: str | None = None + head_ref: str | None = None + baseline_source = BASELINE_UNAVAILABLE + changed_files: list[str] = [] + notes: list[str] = [] + + # --- resolve baseline ------------------------------------------------- + if base_path is not None and base_path.is_dir(): + baseline_source = BASELINE_PATH + base_ref = str(base_path) + elif base is not None and _looks_like_git_ref(str(base)): + base_ref = str(base) + elif range_spec: + base_ref = None # resolved below + elif opts.get("use_snapshot") or ( + base is None and opts.get("prefer_snapshot") + ): + snap = load_baseline(project_path, baseline_name) + if snap and isinstance(snap.get("state"), dict): + baseline_source = BASELINE_AXGUARD_SNAPSHOT + base_state = snap["state"] + head_state = build_security_state( + head_path, + changed_files=None, + incremental=incremental, + options=opts, + ) + result = _finalize( + result, + base_state=base_state, + head_state=head_state, + baseline_source=baseline_source, + base_side=side_ref( + source=BASELINE_AXGUARD_SNAPSHOT, + ref=baseline_name, + path=str(project_path), + label=f"snapshot:{baseline_name}", + ), + head_side=side_ref( + source=BASELINE_PATH, + path=str(head_path), + label=str(head_path), + ), + changed_files=[], + notes=["BASELINE_SOURCE: AXGUARD_SNAPSHOT"], + opts=opts, + project_path=project_path, + ) + return result + notes.append("AXGuard snapshot baseline not found") + result["baseline"] = BASELINE_UNAVAILABLE + result["notes"] = notes + result["security_impact"] = { + "level": IMPACT_UNKNOWN, + "reason": "BASELINE_UNAVAILABLE — no valid baseline for comparison.", + "decision": DECISION_UNKNOWN, + } + result["unknowns"].append( + { + "area": "baseline", + "reason": "No git base and no stored AXGuard snapshot.", + "status": "UNKNOWN", + } + ) + return result + else: + # Auto: try git, else snapshot + base_ref = None + + cmp = resolve_comparison( + project_path, + base=base_ref or (str(base) if base and base_path is None else None), + head=str(head) if head and head_path == project_path and head is not None and _looks_like_git_ref(str(head)) else None, + range_spec=range_spec or (str(base) if isinstance(base, str) and "..." in str(base) else None), + ) + notes.extend(cmp.get("notes") or []) + + if baseline_source == BASELINE_PATH and base_path is not None: + pass + elif cmp.get("available"): + baseline_source = BASELINE_GIT + base_ref = str(cmp.get("base_ref") or base_ref) + head_ref = str(cmp.get("head_ref") or "HEAD") + changed_files = list(cmp.get("changed_files") or []) + # Materialize base tree + mat = materialize_ref(Path(cmp["repo"]), str(cmp.get("base_sha") or base_ref)) + if mat is None: + notes.append("failed to materialize git base tree") + result["baseline"] = BASELINE_UNAVAILABLE + result["notes"] = notes + result["security_impact"] = { + "level": IMPACT_UNKNOWN, + "reason": "BASELINE_UNAVAILABLE — git base could not be materialized.", + "decision": DECISION_UNKNOWN, + } + return result + materialized.append(mat) + base_path = mat + # Head: working tree unless head ref != HEAD + if head_ref not in {"HEAD", "WORKTREE", "."} and cmp.get("head_sha"): + code_head = materialize_ref(Path(cmp["repo"]), str(cmp["head_sha"])) + if code_head is not None: + materialized.append(code_head) + head_path = code_head + else: + # Fall back to snapshot + snap = load_baseline(project_path, baseline_name) + if snap and isinstance(snap.get("state"), dict): + baseline_source = BASELINE_AXGUARD_SNAPSHOT + try: + result = _finalize( + result, + base_state=snap["state"], + head_state=build_security_state( + head_path, + incremental=incremental, + options=opts, + ), + baseline_source=baseline_source, + base_side=side_ref( + source=BASELINE_AXGUARD_SNAPSHOT, + ref=baseline_name, + path=str(project_path), + label=f"snapshot:{baseline_name}", + ), + head_side=side_ref( + source=BASELINE_PATH, + path=str(head_path), + label=str(head_path), + ), + changed_files=[], + notes=notes + ["BASELINE_SOURCE: AXGUARD_SNAPSHOT"], + opts=opts, + project_path=project_path, + ) + finally: + for m in materialized: + cleanup_materialized(m) + return result + + result["baseline"] = BASELINE_UNAVAILABLE + result["notes"] = notes + [ + "BASELINE_UNAVAILABLE — no git base and no AXGuard snapshot." + ] + result["security_impact"] = { + "level": IMPACT_UNKNOWN, + "reason": "BASELINE_UNAVAILABLE — no valid baseline for comparison.", + "decision": DECISION_UNKNOWN, + } + result["unknowns"].append( + { + "area": "baseline", + "reason": "No valid baseline exists.", + "status": "UNKNOWN", + } + ) + return result + + try: + assert base_path is not None and head_path is not None + base_state = build_security_state( + base_path, + changed_files=changed_files if incremental else None, + incremental=incremental, + options=opts, + ) + head_state = build_security_state( + head_path, + changed_files=changed_files if incremental else None, + incremental=incremental, + options=opts, + ) + result = _finalize( + result, + base_state=base_state, + head_state=head_state, + baseline_source=baseline_source, + base_side=side_ref( + source=baseline_source, + ref=base_ref, + path=str(base_path) if baseline_source == BASELINE_PATH else None, + label=base_ref or str(base_path), + sha=cmp.get("base_sha") if baseline_source == BASELINE_GIT else None, + ), + head_side=side_ref( + source=BASELINE_PATH + if head_path == project_path + else baseline_source, + ref=head_ref, + path=str(head_path), + label=head_ref or str(head_path), + sha=cmp.get("head_sha") if baseline_source == BASELINE_GIT else None, + ), + changed_files=changed_files, + notes=notes, + opts=opts, + project_path=project_path, + ) + finally: + for m in materialized: + cleanup_materialized(m) + + return result + + +def _finalize( + result: dict[str, Any], + *, + base_state: dict[str, Any], + head_state: dict[str, Any], + baseline_source: str, + base_side: dict[str, Any], + head_side: dict[str, Any], + changed_files: list[str], + notes: list[str], + opts: dict[str, Any], + project_path: Path, +) -> dict[str, Any]: + partial = compare_states(base_state, head_state, options=opts) + + result.update( + { + "base": base_side, + "head": head_side, + "baseline": baseline_source, + "current_target": head_side.get("path"), + "base_target": base_side.get("path") or base_side.get("ref"), + "base_ref": base_side.get("ref"), + "changed_files": changed_files, + "notes": notes, + } + ) + for key, value in partial.items(): + result[key] = value + + # Soft: Security Memory regressions + if not opts.get("skip_memory"): + try: + from engines.memory.changes import compare_snapshots + from engines.memory.store import load_index, load_snapshot, resolve_memory_dir + + mem_dir = resolve_memory_dir(project_path / ".findings/axguard/memory") + index = load_index(mem_dir) + snaps = index.get("snapshots") or index.get("history") or [] + if len(snaps) >= 2: + before_id = ( + snaps[-2].get("id") + if isinstance(snaps[-2], dict) + else snaps[-2] + ) + after_id = ( + snaps[-1].get("id") + if isinstance(snaps[-1], dict) + else snaps[-1] + ) + before = load_snapshot(str(before_id), mem_dir) + after = load_snapshot(str(after_id), mem_dir) + if before and after: + mem_diff = compare_snapshots(before, after) + result["memory_delta"] = { + "outcomes": mem_diff.get("outcomes") or {}, + "regressions": (mem_diff.get("outcomes") or {}).get("REGRESSED") + or [], + "summary": mem_diff.get("summary") or {}, + } + for reg in result["memory_delta"]["regressions"]: + if reg not in result["regressions"]: + result["regressions"].append(reg) + except Exception as exc: # noqa: BLE001 + result["unknowns"].append( + { + "area": "memory", + "reason": f"memory compare soft-failed: {exc}", + "status": "UNKNOWN", + } + ) + + # Soft: investigation for control-removal candidates + if opts.get("investigate"): + candidates = [ + c + for c in (result.get("control_delta") or {}).get("changes") or [] + if c.get("state") in {"REMOVED", "WEAKENED"} + ] + if candidates: + try: + from engines.investigation import investigate_candidate + + for cand in candidates[:3]: + inv = investigate_candidate( + project_path, + candidate={ + "title": cand.get("detail"), + "control": cand, + }, + ) + result.setdefault("evidence", []).append( + {"kind": "investigation", "result": inv} + ) + except Exception as exc: # noqa: BLE001 + result["unknowns"].append( + { + "area": "investigation", + "reason": str(exc), + "status": "UNKNOWN", + } + ) + + result["security_impact"] = classify_impact(result) + result["overall_security_change"] = result["security_impact"].get("level") + + # Sync summary unknowns count + summary = result.get("summary") or {} + summary["unknowns"] = len(result.get("unknowns") or []) + summary["regressions"] = len(result.get("regressions") or []) + summary["predictive_risks"] = len(result.get("predictive_risks") or []) + result["summary"] = summary + + if opts.get("write_report"): + out_dir = Path(opts.get("out_dir") or (project_path / ".findings/axguard")) + write_security_diff_report(result, out_dir) + + return result + + +def run_security_diff( + *, + project: Path | str = ".", + base: str | None = None, + head: str | None = None, + range_spec: str | None = None, + baseline_name: str = "default", + use_snapshot: bool = False, + verbose: bool = False, + fail_on: str = "none", + incremental: bool = True, + investigate: bool = False, + out_dir: Path | str | None = None, + write_report: bool = False, + **kwargs: Any, +) -> dict[str, Any]: + """CLI-friendly wrapper around :func:`security_diff`.""" + options = { + "range_spec": range_spec, + "baseline_name": baseline_name, + "use_snapshot": use_snapshot, + "prefer_snapshot": use_snapshot, + "verbose": verbose, + "fail_on": fail_on, + "incremental": incremental, + "investigate": investigate, + "out_dir": out_dir, + "write_report": write_report, + **kwargs, + } + return security_diff(base=base, head=head, project=project, options=options) + + +# Re-export for CI helpers +__all__ = [ + "security_diff", + "run_security_diff", + "save_baseline_from_project", + "should_fail", +] diff --git a/engines/security_diff/render.py b/engines/security_diff/render.py new file mode 100644 index 0000000..8508d02 --- /dev/null +++ b/engines/security_diff/render.py @@ -0,0 +1,71 @@ +"""Compact CLI rendering for Security Diff.""" + +from __future__ import annotations + +from typing import Any + + +def render_security_diff_text(result: dict[str, Any]) -> str: + """Render compact SECURITY DIFF text (product-style).""" + lines: list[str] = [] + lines.append("SECURITY DIFF") + lines.append("────────────────────────") + lines.append("") + + baseline = result.get("baseline") or "UNKNOWN" + if baseline == "UNKNOWN": + lines.append("BASELINE: UNKNOWN") + lines.append("") + + summary = result.get("summary") or {} + + def _plus(n: int, label: str) -> None: + if n: + lines.append(f"+ {n} {label}") + + def _minus(n: int, label: str) -> None: + if n: + lines.append(f"- {n} {label}") + + _plus(int(summary.get("new_endpoints") or 0), "new API endpoints") + _minus(int(summary.get("removed_endpoints") or 0), "removed endpoints") + _plus(int(summary.get("new_parameters") or 0), "new parameters") + _plus(int(summary.get("new_database_flows") or 0), "new database flow") + _plus(int(summary.get("new_external_requests") or 0), "new external HTTP request") + _plus(int(summary.get("new_uploads") or 0), "new upload") + _plus(int(summary.get("new_webhooks") or 0), "new webhook") + _plus(int(summary.get("new_sensitive_flows") or 0), "new sensitive data flow") + _minus(int(summary.get("removed_controls") or 0), "authorization/security control") + _plus(int(summary.get("added_controls") or 0), "security control added") + + # Authz / tenant highlights + for ch in result.get("authz_changes") or []: + lines.append(f"! authz: {ch.get('change')}") + for ch in result.get("tenant_changes") or []: + lines.append(f"! tenant: {ch.get('change')}") + + if len(lines) <= 4: + lines.append("(no structural security changes detected)") + + lines.append("") + lines.append("Attack paths:") + lines.append(f"+ {int(summary.get('new_attack_paths') or 0)} reachable") + lines.append(f"- {int(summary.get('blocked_attack_paths') or 0)} blocked") + lines.append("") + lines.append("Security controls:") + lines.append(f"{int(summary.get('weakened_controls') or 0)} weakened") + lines.append(f"{int(summary.get('added_controls') or 0)} added") + lines.append(f"{int(summary.get('removed_controls') or 0)} removed") + lines.append(f"{int(summary.get('strengthened_controls') or 0)} strengthened") + lines.append("") + lines.append("Overall security change:") + lines.append(str(result.get("overall_security_change") or "LOW")) + + notes = result.get("notes") or [] + if notes: + lines.append("") + lines.append("Notes:") + for n in notes[:8]: + lines.append(f"- {n}") + + return "\n".join(lines).rstrip() + "\n" diff --git a/engines/security_diff/report.py b/engines/security_diff/report.py new file mode 100644 index 0000000..e8f4257 --- /dev/null +++ b/engines/security_diff/report.py @@ -0,0 +1,193 @@ +"""Security Diff text / JSON / HTML rendering.""" + +from __future__ import annotations + +import html +import json +from pathlib import Path +from typing import Any + + +def render_text(diff: dict[str, Any]) -> str: + """Concise CLI output (default).""" + impact = diff.get("security_impact") or {} + level = impact.get("level") or "UNKNOWN" + decision = impact.get("decision") or "UNKNOWN" + summary = diff.get("summary") or {} + surface = diff.get("attack_surface_delta") or {} + controls = diff.get("control_delta") or {} + ctrl_sum = controls.get("summary") or {} + paths = diff.get("attack_path_delta") or {} + pred = diff.get("predictive_risks") or [] + regressions = diff.get("regressions") or [] + + lines = [ + "AXGUARD SECURITY DIFF", + "", + f"Impact: {level}", + "", + "Attack Surface", + f"+ {summary.get('new_endpoints', len(surface.get('added') or []))} endpoints", + f"+ {summary.get('new_webhooks', 0)} webhook(s)", + "", + "Security Controls", + f"- {ctrl_sum.get('removed', summary.get('removed_controls', 0))} authorization/control(s)", + f"+ {ctrl_sum.get('added', summary.get('added_controls', 0))} control(s)", + f"~ {ctrl_sum.get('moved', summary.get('moved_controls', 0))} moved (refactor)", + "", + "Data Flow", + f"+ {summary.get('new_sensitive_flows', 0)} sensitive path(s)", + "", + "Privileges", + f"+ {summary.get('privilege_changes', 0)} privileged operation change(s)", + "", + "Attack Paths", + f"+ {summary.get('new_attack_paths', len(paths.get('new_paths') or []))} new reachable path(s)", + "", + "Regressions", + str(len(regressions)), + "", + "Predictive Risk", + ] + if pred: + cats = sorted( + { + str(p.get("category") or p.get("change_status") or "RISK") + for p in pred[:5] + } + ) + lines.append(", ".join(cats) if cats else "none") + else: + lines.append("none") + lines.extend(["", "Decision:", str(decision)]) + + if str(diff.get("baseline")) == "BASELINE_UNAVAILABLE": + lines.extend( + [ + "", + "BASELINE_SOURCE: BASELINE_UNAVAILABLE", + "No comparison fabricated.", + ] + ) + else: + base = diff.get("base") or {} + lines.extend( + [ + "", + f"BASELINE_SOURCE: {diff.get('baseline') or base.get('source')}", + ] + ) + + notes = diff.get("notes") or [] + if notes: + lines.extend(["", "Notes:"] + [f"- {n}" for n in notes[:5]]) + + return "\n".join(lines) + "\n" + + +def render_verbose(diff: dict[str, Any]) -> str: + """Detailed human-readable dump of key deltas.""" + chunks = [render_text(diff), "", "--- VERBOSE ---", ""] + + def _section(title: str, items: list[Any]) -> None: + chunks.append(title) + if not items: + chunks.append(" (none)") + for it in items[:40]: + if isinstance(it, dict): + kind = it.get("kind") or it.get("category") or it.get("state") or it.get("title") + detail = it.get("detail") or it.get("change") or it.get("reason") or "" + chunks.append(f" - {kind}: {detail}") + else: + chunks.append(f" - {it}") + chunks.append("") + + _section("Attack surface added", (diff.get("attack_surface_delta") or {}).get("added") or []) + _section("Attack surface removed", (diff.get("attack_surface_delta") or {}).get("removed") or []) + _section("Control changes", (diff.get("control_delta") or {}).get("changes") or []) + _section("Authorization", (diff.get("authorization_delta") or {}).get("changes") or []) + _section("Tenant isolation", (diff.get("tenant_isolation_delta") or {}).get("changes") or []) + _section("Data flow added", (diff.get("data_flow_delta") or {}).get("added") or []) + _section("New attack paths", (diff.get("attack_path_delta") or {}).get("new_paths") or []) + _section("Regressions", diff.get("regressions") or []) + _section("Predictive risks", diff.get("predictive_risks") or []) + _section("Unknowns", diff.get("unknowns") or []) + _section("Recommended actions", diff.get("recommended_actions") or []) + return "\n".join(chunks) + "\n" + + +def to_json(diff: dict[str, Any]) -> str: + return json.dumps(diff, indent=2, sort_keys=True) + "\n" + + +def render_html_section(diff: dict[str, Any]) -> str: + """Fragment for embedding into AXGuard HTML reports.""" + impact = diff.get("security_impact") or {} + level = html.escape(str(impact.get("level") or "UNKNOWN")) + reason = html.escape(str(impact.get("reason") or "")) + decision = html.escape(str(impact.get("decision") or "")) + + def _lis(items: list[Any], limit: int = 25) -> str: + if not items: + return "
  • none
  • " + out = [] + for it in items[:limit]: + if isinstance(it, dict): + text = it.get("detail") or it.get("title") or it.get("kind") or json.dumps(it)[:200] + else: + text = str(it) + out.append(f"
  • {html.escape(str(text))}
  • ") + return "\n".join(out) + + return f""" +
    +

    Security Diff

    +

    Impact: {level} — {reason}

    +

    Decision: {decision}

    +

    Attack Surface Delta

    +
      {_lis((diff.get("attack_surface_delta") or {}).get("added") or [])}
    +

    Security Control Delta

    +
      {_lis((diff.get("control_delta") or {}).get("changes") or [])}
    +

    Authorization Delta

    +
      {_lis((diff.get("authorization_delta") or {}).get("changes") or [])}
    +

    Tenant Delta

    +
      {_lis((diff.get("tenant_isolation_delta") or {}).get("changes") or [])}
    +

    Data Flow Delta

    +
      {_lis((diff.get("data_flow_delta") or {}).get("added") or [])}
    +

    Attack Path Delta

    +
      {_lis((diff.get("attack_path_delta") or {}).get("new_paths") or [])}
    +

    Security Regressions

    +
      {_lis(diff.get("regressions") or [])}
    +

    Predictive Risk Delta

    +
      {_lis(diff.get("predictive_risks") or [])}
    +

    Recommended Actions

    +
      {_lis(diff.get("recommended_actions") or [])}
    +
    +""" + + +def write_security_diff_report( + diff: dict[str, Any], + out_dir: Path | str, + *, + stem: str = "security-diff", +) -> dict[str, Path]: + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + paths = { + "json": out / f"{stem}.json", + "md": out / f"{stem}.md", + "html": out / f"{stem}.html", + "txt": out / f"{stem}.txt", + } + paths["json"].write_text(to_json(diff), encoding="utf-8") + paths["txt"].write_text(render_text(diff), encoding="utf-8") + paths["md"].write_text(render_verbose(diff), encoding="utf-8") + body = render_html_section(diff) + paths["html"].write_text( + "" + "AXGuard Security Diff" + f"{body}\n", + encoding="utf-8", + ) + return paths diff --git a/engines/security_diff/schema.py b/engines/security_diff/schema.py new file mode 100644 index 0000000..48c54f0 --- /dev/null +++ b/engines/security_diff/schema.py @@ -0,0 +1,429 @@ +"""Security Diff schema — versioned result shape and vocabularies.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +SECURITY_DIFF_VERSION = "1" +SCHEMA_VERSION = SECURITY_DIFF_VERSION +TOOL_NAME = "axguard" + +# --------------------------------------------------------------------------- +# Impact / decision +# --------------------------------------------------------------------------- +IMPACT_NONE = "NONE" +IMPACT_LOW = "LOW" +IMPACT_MEDIUM = "MEDIUM" +IMPACT_HIGH = "HIGH" +IMPACT_CRITICAL = "CRITICAL" +IMPACT_UNKNOWN = "UNKNOWN" +IMPACTS = frozenset( + { + IMPACT_NONE, + IMPACT_LOW, + IMPACT_MEDIUM, + IMPACT_HIGH, + IMPACT_CRITICAL, + IMPACT_UNKNOWN, + } +) + +DECISION_PASS = "PASS" +DECISION_REVIEW_REQUIRED = "REVIEW_REQUIRED" +DECISION_FAIL = "FAIL" +DECISION_UNKNOWN = "UNKNOWN" +DECISIONS = frozenset( + {DECISION_PASS, DECISION_REVIEW_REQUIRED, DECISION_FAIL, DECISION_UNKNOWN} +) + +# --------------------------------------------------------------------------- +# Baseline sources +# --------------------------------------------------------------------------- +BASELINE_GIT = "GIT" +BASELINE_AXGUARD_SNAPSHOT = "AXGUARD_SNAPSHOT" +BASELINE_ARTIFACTS = "ARTIFACTS" +BASELINE_PATH = "PATH" +BASELINE_UNAVAILABLE = "BASELINE_UNAVAILABLE" +# Legacy / compose / preship vocabulary +BASELINE_UNKNOWN = "UNKNOWN" +BASELINE_SOURCES = frozenset( + { + BASELINE_GIT, + BASELINE_AXGUARD_SNAPSHOT, + BASELINE_ARTIFACTS, + BASELINE_PATH, + BASELINE_UNAVAILABLE, + BASELINE_UNKNOWN, + } +) + +# --------------------------------------------------------------------------- +# Control states +# --------------------------------------------------------------------------- +CONTROL_ADDED = "ADDED" +CONTROL_REMOVED = "REMOVED" +CONTROL_WEAKENED = "WEAKENED" +CONTROL_STRENGTHENED = "STRENGTHENED" +CONTROL_MOVED = "MOVED" +CONTROL_BYPASSED = "BYPASSED" +CONTROL_UNKNOWN = "UNKNOWN" +CONTROL_UNCHANGED = "UNCHANGED" +CONTROL_STATES = frozenset( + { + CONTROL_ADDED, + CONTROL_REMOVED, + CONTROL_WEAKENED, + CONTROL_STRENGTHENED, + CONTROL_MOVED, + CONTROL_BYPASSED, + CONTROL_UNKNOWN, + CONTROL_UNCHANGED, + } +) + +# --------------------------------------------------------------------------- +# Auth effective protection +# --------------------------------------------------------------------------- +AUTH_STRONGER = "STRONGER" +AUTH_WEAKER = "WEAKER" +AUTH_UNCHANGED = "UNCHANGED" +AUTH_UNKNOWN = "UNKNOWN" + +# --------------------------------------------------------------------------- +# Memory / predictive vocabularies +# --------------------------------------------------------------------------- +MEMORY_NEW = "NEW" +MEMORY_RESOLVED = "RESOLVED" +MEMORY_REGRESSED = "REGRESSED" +MEMORY_RECONFIRMED = "RECONFIRMED" +MEMORY_SUPERSEDED = "SUPERSEDED" +MEMORY_UNKNOWN = "UNKNOWN" + +PRED_NEW_RISK = "NEW_RISK" +PRED_REMOVED_RISK = "REMOVED_RISK" +PRED_INCREASED_RISK = "INCREASED_RISK" +PRED_DECREASED_RISK = "DECREASED_RISK" +PRED_UNCHANGED = "UNCHANGED" + +# --------------------------------------------------------------------------- +# Attack surface / category change types +# --------------------------------------------------------------------------- +NEW_ENDPOINT = "NEW_ENDPOINT" +REMOVED_ENDPOINT = "REMOVED_ENDPOINT" +NEW_HTTP_METHOD = "NEW_HTTP_METHOD" +REMOVED_HTTP_METHOD = "REMOVED_HTTP_METHOD" +NEW_PARAMETER = "NEW_PARAMETER" +REMOVED_PARAMETER = "REMOVED_PARAMETER" +NEW_WEBHOOK = "NEW_WEBHOOK" +REMOVED_WEBHOOK = "REMOVED_WEBHOOK" +NEW_UPLOAD = "NEW_UPLOAD" +NEW_GRAPHQL_RESOLVER = "NEW_GRAPHQL_RESOLVER" +NEW_WEBSOCKET = "NEW_WEBSOCKET" +NEW_PUBLIC_RESOURCE = "NEW_PUBLIC_RESOURCE" +NEW_EXTERNAL_INTEGRATION = "NEW_EXTERNAL_INTEGRATION" +NEW_NETWORK_INTERFACE = "NEW_NETWORK_INTERFACE" +NEW_SERVICE = "NEW_SERVICE" +NEW_WORKER = "NEW_WORKER" +NEW_QUEUE = "NEW_QUEUE" +NEW_AI_AGENT = "NEW_AI_AGENT" +NEW_AGENT_TOOL = "NEW_AGENT_TOOL" +NEW_MCP_SERVER = "NEW_MCP_SERVER" +NEW_MCP_TOOL = "NEW_MCP_TOOL" +NEW_EXTERNAL_REQUEST = "NEW_EXTERNAL_REQUEST" +NEW_DATABASE_FLOW = "NEW_DATABASE_FLOW" +NEW_FILE_ACCESS = "NEW_FILE_ACCESS" +NEW_COMMAND_EXECUTION = "NEW_COMMAND_EXECUTION" +NEW_SECRET = "NEW_SECRET" +NEW_IDENTITY = "NEW_IDENTITY" +NEW_PERMISSION = "NEW_PERMISSION" +NEW_PRIVILEGE = "NEW_PRIVILEGE" +NEW_AGENT = "NEW_AGENT" +NEW_AI_TOOL = "NEW_AI_TOOL" +NEW_TRUST_BOUNDARY = "NEW_TRUST_BOUNDARY" +REMOVED_TRUST_BOUNDARY = "REMOVED_TRUST_BOUNDARY" +REMOVED_SECURITY_CONTROL = "REMOVED_SECURITY_CONTROL" +WEAKENED_SECURITY_CONTROL = "WEAKENED_SECURITY_CONTROL" +STRENGTHENED_SECURITY_CONTROL = "STRENGTHENED_SECURITY_CONTROL" +MOVED_SECURITY_CONTROL = "MOVED_SECURITY_CONTROL" +NEW_SENSITIVE_DATA_FLOW = "NEW_SENSITIVE_DATA_FLOW" +NEW_ATTACK_PATH = "NEW_ATTACK_PATH" +BLOCKED_ATTACK_PATH = "BLOCKED_ATTACK_PATH" +REMOVED_ATTACK_PATH = "REMOVED_ATTACK_PATH" +REOPENED_ATTACK_PATH = "REOPENED_ATTACK_PATH" +WEAKENED_ATTACK_PATH = "WEAKENED_ATTACK_PATH" +NEW_DEPENDENCY = "NEW_DEPENDENCY" +REMOVED_DEPENDENCY = "REMOVED_DEPENDENCY" +DEPENDENCY_RISK_CHANGE = "DEPENDENCY_RISK_CHANGE" +CONFIGURATION_SECURITY_CHANGE = "CONFIGURATION_SECURITY_CHANGE" +PRIVILEGE_EXPANSION = "PRIVILEGE_EXPANSION" +TENANT_BOUNDARY_WEAKENED = "TENANT_BOUNDARY_WEAKENED" +AI_AGENT_PRIVILEGE_EXPANSION = "AI_AGENT_PRIVILEGE_EXPANSION" +MCP_PRIVILEGE_EXPANSION = "MCP_PRIVILEGE_EXPANSION" + +# Back-compat aliases used by early modules +SEVERITY_LOW = IMPACT_LOW +SEVERITY_MEDIUM = IMPACT_MEDIUM +SEVERITY_HIGH = IMPACT_HIGH +SEVERITY_CRITICAL = IMPACT_CRITICAL +SEVERITIES = IMPACTS +BASELINE_UNKNOWN = "UNKNOWN" + +CHANGE_CATEGORIES = frozenset( + { + NEW_ENDPOINT, + REMOVED_ENDPOINT, + NEW_PARAMETER, + NEW_EXTERNAL_REQUEST, + NEW_DATABASE_FLOW, + NEW_FILE_ACCESS, + NEW_COMMAND_EXECUTION, + NEW_UPLOAD, + NEW_WEBHOOK, + NEW_SECRET, + NEW_IDENTITY, + NEW_PERMISSION, + NEW_PRIVILEGE, + NEW_AGENT, + NEW_AI_TOOL, + NEW_MCP_TOOL, + NEW_EXTERNAL_INTEGRATION, + NEW_TRUST_BOUNDARY, + REMOVED_SECURITY_CONTROL, + WEAKENED_SECURITY_CONTROL, + STRENGTHENED_SECURITY_CONTROL, + NEW_SENSITIVE_DATA_FLOW, + NEW_ATTACK_PATH, + BLOCKED_ATTACK_PATH, + NEW_DEPENDENCY, + DEPENDENCY_RISK_CHANGE, + CONFIGURATION_SECURITY_CHANGE, + } +) + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def side_ref( + *, + source: str = BASELINE_UNAVAILABLE, + ref: str | None = None, + path: str | None = None, + label: str | None = None, + sha: str | None = None, +) -> dict[str, Any]: + return { + "source": source, + "ref": ref, + "path": path, + "label": label or ref or path, + "sha": sha, + } + + +def empty_security_diff( + *, + base: dict[str, Any] | None = None, + head: dict[str, Any] | None = None, + current_target: str | None = None, + base_target: str | None = None, + base_ref: str | None = None, + baseline: str = BASELINE_UNAVAILABLE, +) -> dict[str, Any]: + """Stable empty result for consumers and tests.""" + base_side = base or side_ref( + source=baseline, + ref=base_ref, + path=base_target, + ) + head_side = head or side_ref( + source=BASELINE_PATH if current_target else BASELINE_UNAVAILABLE, + path=current_target, + label=current_target, + ) + return { + "schema_version": SCHEMA_VERSION, + "tool": TOOL_NAME, + "generated_at": utc_now(), + "base": base_side, + "head": head_side, + # legacy mirrors + "current_target": current_target or head_side.get("path"), + "base_target": base_target or base_side.get("path"), + "base_ref": base_ref or base_side.get("ref"), + "baseline": baseline, + "security_impact": { + "level": IMPACT_NONE, + "reason": "No meaningful security changes detected.", + "decision": DECISION_PASS, + }, + "attack_surface_delta": { + "added": [], + "removed": [], + "changed": [], + "summary": {}, + }, + "control_delta": {"changes": [], "summary": {}}, + "auth_delta": { + "changes": [], + "effective": AUTH_UNCHANGED, + "summary": {}, + }, + "authorization_delta": {"changes": [], "summary": {}}, + "tenant_isolation_delta": { + "changes": [], + "summary": {}, + "priority": "high", + }, + "data_flow_delta": { + "added": [], + "removed": [], + "changed": [], + "summary": {}, + }, + "trust_boundary_delta": {"changes": [], "summary": {}}, + "identity_delta": {"changes": [], "summary": {}}, + "privilege_delta": {"changes": [], "summary": {}}, + "secrets_delta": {"changes": [], "summary": {}}, + "dependency_delta": {"changes": [], "summary": {}}, + "ai_delta": {"changes": [], "summary": {}}, + "mcp_delta": {"changes": [], "summary": {}}, + "attack_path_delta": { + "new_paths": [], + "removed_paths": [], + "blocked_paths": [], + "reopened_paths": [], + "weakened_paths": [], + "summary": {}, + }, + "security_twin_delta": {}, + "memory_delta": {"outcomes": {}, "regressions": [], "summary": {}}, + "predictive_risks": [], + "regressions": [], + "unknowns": [], + "evidence": [], + "recommended_actions": [], + "root_causes": [], + "categories": [], + "summary": { + "new_endpoints": 0, + "removed_endpoints": 0, + "new_parameters": 0, + "new_database_flows": 0, + "new_external_requests": 0, + "new_uploads": 0, + "new_webhooks": 0, + "removed_controls": 0, + "weakened_controls": 0, + "strengthened_controls": 0, + "moved_controls": 0, + "added_controls": 0, + "new_attack_paths": 0, + "blocked_attack_paths": 0, + "new_sensitive_flows": 0, + "authz_changes": 0, + "tenant_changes": 0, + "privilege_changes": 0, + "regressions": 0, + "predictive_risks": 0, + "unknowns": 0, + }, + "overall_security_change": IMPACT_NONE, + "authz_changes": [], + "tenant_changes": [], + "control_changes": [], + "app_model_delta": {}, + "dataflow_delta": {}, + "twin_delta": {}, + "options": {}, + "notes": [], + "changed_files": [], + } + + +def category_record( + category: str, + *, + detail: str = "", + evidence: str | None = None, + severity: str | None = None, + **extra: Any, +) -> dict[str, Any]: + rec: dict[str, Any] = {"category": category, "detail": detail} + if evidence: + rec["evidence"] = evidence + if severity: + rec["severity"] = severity + rec.update(extra) + return rec + + +def delta_item( + kind: str, + *, + detail: str = "", + impact: str = IMPACT_UNKNOWN, + evidence: list[Any] | None = None, + **extra: Any, +) -> dict[str, Any]: + rec: dict[str, Any] = { + "kind": kind, + "detail": detail, + "impact": impact, + "evidence": evidence or [], + } + rec.update(extra) + return rec + + +def control_change( + *, + control_type: str, + state: str, + detail: str = "", + before: str | None = None, + after: str | None = None, + location_before: str | None = None, + location_after: str | None = None, + impact: str = IMPACT_UNKNOWN, + evidence: list[Any] | None = None, +) -> dict[str, Any]: + return { + "control_type": control_type, + "state": state, + "detail": detail, + "before": before, + "after": after, + "location_before": location_before, + "location_after": location_after, + "impact": impact, + "evidence": evidence or [], + } + + +def regression_record( + *, + title: str, + before: str, + change: str, + now: str, + impact: str, + evidence: list[Any] | None = None, + category: str | None = None, +) -> dict[str, Any]: + return { + "title": title, + "before": before, + "change": change, + "now": now, + "impact": impact, + "evidence": evidence or [], + "category": category, + "status": MEMORY_REGRESSED, + } + + +def unknown_record(area: str, reason: str) -> dict[str, Any]: + return {"area": area, "reason": reason, "status": "UNKNOWN"} diff --git a/engines/security_diff/state.py b/engines/security_diff/state.py new file mode 100644 index 0000000..bb529ed --- /dev/null +++ b/engines/security_diff/state.py @@ -0,0 +1,127 @@ +"""Build comparable security states by orchestrating existing engines.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def _load_json(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _artifacts_dir(target: Path) -> Path: + return target / ".findings" / "axguard" + + +def build_security_state( + target: Path | str, + *, + changed_files: list[str] | None = None, + incremental: bool = True, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a comparable Application Security State for one tree. + + Soft-imports existing engines. Never invents missing analysis. + """ + opts = dict(options or {}) + root = Path(target).resolve() + unknowns: list[dict[str, Any]] = [] + state: dict[str, Any] = { + "target": str(root), + "application_model": None, + "dataflow": None, + "attack_graph": None, + "twin": None, + "predictive": None, + "changed_files": list(changed_files or []), + "incremental": bool(incremental), + "unknowns": unknowns, + "scope": { + "changed_files": list(changed_files or []), + "incremental": bool(incremental), + }, + } + + reuse = bool(opts.get("reuse_artifacts", False)) + art = _artifacts_dir(root) + + app_model = None + if reuse: + app_model = _load_json(art / "application-model.json") + if app_model is None: + try: + from engines.app_model import build_application_model + + app_model = build_application_model(root) + except Exception as exc: # noqa: BLE001 + unknowns.append( + {"area": "application_model", "reason": f"build failed: {exc}"} + ) + state["application_model"] = app_model + + dataflow = None + if reuse: + dataflow = _load_json(art / "dataflow.json") + if dataflow is None and not opts.get("skip_dataflow"): + try: + from engines.dataflow import analyze_dataflow + + dataflow = analyze_dataflow(root, application_model=app_model) + except Exception as exc: # noqa: BLE001 + unknowns.append({"area": "dataflow", "reason": f"analyze failed: {exc}"}) + state["dataflow"] = dataflow + + attack_graph = None + if reuse: + attack_graph = _load_json(art / "attack-paths.json") + if attack_graph is None and not opts.get("skip_attack_graph"): + try: + from engines.attack_graph import run_attack_graph + + attack_graph = run_attack_graph(root) + except Exception as exc: # noqa: BLE001 + unknowns.append( + {"area": "attack_graph", "reason": f"run failed: {exc}"} + ) + state["attack_graph"] = attack_graph + + twin = None + if reuse: + twin = _load_json(art / "security-twin.json") or _load_json(art / "twin.json") + if twin is None and not opts.get("skip_twin"): + try: + from engines.twin.build import build_security_twin + + twin = build_security_twin( + root, + attack_graph=attack_graph, + application_model=app_model, + ) + except Exception as exc: # noqa: BLE001 + unknowns.append({"area": "security_twin", "reason": f"build failed: {exc}"}) + state["twin"] = twin + + if not opts.get("skip_predict"): + try: + from engines.predictive import run_predict + + state["predictive"] = run_predict( + root, + attack_graph=attack_graph, + twin=twin, + write_report=False, + with_memory=False, + ) + except Exception as exc: # noqa: BLE001 + unknowns.append({"area": "predictive", "reason": f"run failed: {exc}"}) + + return state diff --git a/engines/security_diff/store.py b/engines/security_diff/store.py new file mode 100644 index 0000000..b0676ee --- /dev/null +++ b/engines/security_diff/store.py @@ -0,0 +1,84 @@ +"""Local Security Diff baseline snapshots (no cloud).""" + +from __future__ import annotations + +import json +import re +from copy import deepcopy +from pathlib import Path +from typing import Any + +DEFAULT_REL = Path(".findings/axguard/security_diff/baselines") +_SAFE = re.compile(r"[^A-Za-z0-9._-]+") + + +def resolve_baseline_dir(project_root: Path | str) -> Path: + root = Path(project_root).resolve() + return root / DEFAULT_REL + + +def _safe_name(name: str) -> str: + cleaned = _SAFE.sub("_", str(name or "default")).strip("._") or "default" + return cleaned + + +def _atomic_write(path: Path, payload: dict[str, Any]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + data = deepcopy(payload) + try: + from engines.dataflow.schema import ensure_no_secret_values + + ensure_no_secret_values(data) + except Exception: # noqa: BLE001 + pass + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + return path + + +def save_baseline( + project_root: Path | str, + state: dict[str, Any], + name: str = "default", +) -> Path: + """Persist a security state snapshot for later non-git diffs.""" + root = Path(project_root).resolve() + out = resolve_baseline_dir(root) / f"{_safe_name(name)}.json" + payload = { + "kind": "axguard_security_diff_baseline", + "name": _safe_name(name), + "project": str(root), + "state": state, + } + return _atomic_write(out, payload) + + +def load_baseline( + project_root: Path | str, + name: str = "default", +) -> dict[str, Any] | None: + path = resolve_baseline_dir(project_root) / f"{_safe_name(name)}.json" + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def list_baselines(project_root: Path | str) -> list[dict[str, Any]]: + base = resolve_baseline_dir(project_root) + if not base.is_dir(): + return [] + items: list[dict[str, Any]] = [] + for path in sorted(base.glob("*.json")): + items.append( + { + "name": path.stem, + "path": str(path), + "mtime": path.stat().st_mtime, + } + ) + return items diff --git a/fixtures/preship/fail_authz/app.py b/fixtures/preship/fail_authz/app.py new file mode 100644 index 0000000..8d0b934 --- /dev/null +++ b/fixtures/preship/fail_authz/app.py @@ -0,0 +1,23 @@ +"""Fail/authz fixture: get-by-id without ownership — expect FAIL or REVIEW_REQUIRED.""" + +from __future__ import annotations + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +DOCUMENTS = { + "d1": {"id": "d1", "owner_id": "u1", "title": "alpha"}, + "d2": {"id": "d2", "owner_id": "u2", "title": "beta"}, +} + + +@app.route("/documents/", methods=["GET"]) +def get_document(doc_id: str): + """Broken object-level authz — lookup by id alone.""" + # Authenticated? (header present) but no ownership check + _ = request.headers.get("X-User-Id", "u1") + doc = DOCUMENTS.get(doc_id) + if not doc: + return jsonify({"error": "not_found"}), 404 + return jsonify({"document": doc}) diff --git a/fixtures/preship/pass_safe/app.py b/fixtures/preship/pass_safe/app.py new file mode 100644 index 0000000..68fe628 --- /dev/null +++ b/fixtures/preship/pass_safe/app.py @@ -0,0 +1,38 @@ +"""Safe fixture: parameterized SQL + ownership check — expect PASS / PASS_WITH_NOTES.""" + +from __future__ import annotations + +from flask import Flask, g, jsonify, request + +app = Flask(__name__) + +DOCUMENTS = { + "d1": {"id": "d1", "owner_id": "u1", "title": "alpha"}, + "d2": {"id": "d2", "owner_id": "u2", "title": "beta"}, +} + + +def current_user_id() -> str: + return request.headers.get("X-User-Id", "u1") + + +@app.route("/documents/", methods=["GET"]) +def get_document(doc_id: str): + """Ownership-checked document fetch.""" + user_id = current_user_id() + doc = DOCUMENTS.get(doc_id) + if not doc: + return jsonify({"error": "not_found"}), 404 + # ownership check present + if doc["owner_id"] != user_id: + return jsonify({"error": "forbidden"}), 403 + return jsonify({"document": doc}) + + +@app.route("/users", methods=["GET"]) +def users(): + user_id = request.args.get("id") + # parameterized query — safe + sql = "SELECT id, email FROM users WHERE id = ?" + _ = (sql, (user_id,)) + return jsonify({"ok": True}) diff --git a/fixtures/preship/regression_authz/after/app.py b/fixtures/preship/regression_authz/after/app.py new file mode 100644 index 0000000..80989e6 --- /dev/null +++ b/fixtures/preship/regression_authz/after/app.py @@ -0,0 +1,22 @@ +"""Regression after: ownership check removed.""" + +from __future__ import annotations + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +DOCUMENTS = { + "d1": {"id": "d1", "owner_id": "u1", "title": "alpha"}, + "d2": {"id": "d2", "owner_id": "u2", "title": "beta"}, +} + + +@app.route("/documents/", methods=["GET"]) +def get_document(doc_id: str): + # ownership removed — IDOR + _ = request.headers.get("X-User-Id", "u1") + doc = DOCUMENTS.get(doc_id) + if not doc: + return jsonify({"error": "not_found"}), 404 + return jsonify({"document": doc}) diff --git a/fixtures/preship/regression_authz/before/app.py b/fixtures/preship/regression_authz/before/app.py new file mode 100644 index 0000000..88ff544 --- /dev/null +++ b/fixtures/preship/regression_authz/before/app.py @@ -0,0 +1,27 @@ +"""Regression before: ownership check present.""" + +from __future__ import annotations + +from flask import Flask, jsonify, request + +app = Flask(__name__) + +DOCUMENTS = { + "d1": {"id": "d1", "owner_id": "u1", "title": "alpha"}, + "d2": {"id": "d2", "owner_id": "u2", "title": "beta"}, +} + + +def current_user_id() -> str: + return request.headers.get("X-User-Id", "u1") + + +@app.route("/documents/", methods=["GET"]) +def get_document(doc_id: str): + user_id = current_user_id() + doc = DOCUMENTS.get(doc_id) + if not doc: + return jsonify({"error": "not_found"}), 404 + if doc["owner_id"] != user_id: + return jsonify({"error": "forbidden"}), 403 + return jsonify({"document": doc}) diff --git a/fixtures/security_diff/authz_moved/after/app.py b/fixtures/security_diff/authz_moved/after/app.py new file mode 100644 index 0000000..709d5c6 --- /dev/null +++ b/fixtures/security_diff/authz_moved/after/app.py @@ -0,0 +1,11 @@ +from flask import Flask, request, jsonify +from services.policy import check_ownership +app = Flask(__name__) + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = {"id": item_id, "owner_id": 1} + if not check_ownership(user_id, item): + return jsonify({"error": "forbidden"}), 403 + return jsonify(item) diff --git a/fixtures/security_diff/authz_moved/after/services/policy.py b/fixtures/security_diff/authz_moved/after/services/policy.py new file mode 100644 index 0000000..f6b7478 --- /dev/null +++ b/fixtures/security_diff/authz_moved/after/services/policy.py @@ -0,0 +1,2 @@ +def check_ownership(user_id, resource): + return resource.get("owner_id") == user_id diff --git a/fixtures/security_diff/authz_moved/before/app.py b/fixtures/security_diff/authz_moved/before/app.py new file mode 100644 index 0000000..70579fe --- /dev/null +++ b/fixtures/security_diff/authz_moved/before/app.py @@ -0,0 +1,13 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) + +def check_ownership(user_id, resource): + return resource.get("owner_id") == user_id + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = {"id": item_id, "owner_id": 1} + if not check_ownership(user_id, item): + return jsonify({"error": "forbidden"}), 403 + return jsonify(item) diff --git a/fixtures/security_diff/authz_removed/after/app.py b/fixtures/security_diff/authz_removed/after/app.py new file mode 100644 index 0000000..77c7384 --- /dev/null +++ b/fixtures/security_diff/authz_removed/after/app.py @@ -0,0 +1,10 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) +USERS = {1: {"id": 1, "owner_id": 1, "data": "secret"}} + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = USERS.get(item_id) or {"id": item_id, "owner_id": item_id, "data": "x"} + # ownership check removed + return jsonify(item) diff --git a/fixtures/security_diff/authz_removed/before/app.py b/fixtures/security_diff/authz_removed/before/app.py new file mode 100644 index 0000000..610a9c6 --- /dev/null +++ b/fixtures/security_diff/authz_removed/before/app.py @@ -0,0 +1,14 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) +USERS = {1: {"id": 1, "owner_id": 1, "data": "secret"}} + +def check_ownership(user_id, resource): + return resource.get("owner_id") == user_id + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = USERS.get(item_id) or {"id": item_id, "owner_id": item_id, "data": "x"} + if not check_ownership(user_id, item): + return jsonify({"error": "forbidden"}), 403 + return jsonify(item) diff --git a/fixtures/security_diff/hard_negative/after/app.py b/fixtures/security_diff/hard_negative/after/app.py new file mode 100644 index 0000000..3c6fda1 --- /dev/null +++ b/fixtures/security_diff/hard_negative/after/app.py @@ -0,0 +1,2 @@ +def greet_user(name): + return f"hello {name}" diff --git a/fixtures/security_diff/hard_negative/before/app.py b/fixtures/security_diff/hard_negative/before/app.py new file mode 100644 index 0000000..61985d7 --- /dev/null +++ b/fixtures/security_diff/hard_negative/before/app.py @@ -0,0 +1,2 @@ +def greet(name): + return f"hello {name}" diff --git a/fixtures/security_diff/mcp_priv/after/app.py b/fixtures/security_diff/mcp_priv/after/app.py new file mode 100644 index 0000000..2bb15a2 --- /dev/null +++ b/fixtures/security_diff/mcp_priv/after/app.py @@ -0,0 +1,7 @@ +# MCP tool: read_project + write_project + execute_deployment +def read_project(): + return open("README").read() +def write_project(data): + open("README", "w").write(data) +def execute_deployment(): + import os; os.system("deploy.sh") diff --git a/fixtures/security_diff/mcp_priv/after/mcp.json b/fixtures/security_diff/mcp_priv/after/mcp.json new file mode 100644 index 0000000..7164126 --- /dev/null +++ b/fixtures/security_diff/mcp_priv/after/mcp.json @@ -0,0 +1 @@ +{"tools": [{"name": "read_project", "permissions": ["read", "write", "execute_deployment"]}]} diff --git a/fixtures/security_diff/mcp_priv/before/app.py b/fixtures/security_diff/mcp_priv/before/app.py new file mode 100644 index 0000000..e932fe1 --- /dev/null +++ b/fixtures/security_diff/mcp_priv/before/app.py @@ -0,0 +1,3 @@ +# MCP tool: read_project (read-only) +def read_project(): + return open("README").read() diff --git a/fixtures/security_diff/mcp_priv/before/mcp.json b/fixtures/security_diff/mcp_priv/before/mcp.json new file mode 100644 index 0000000..bd52fa5 --- /dev/null +++ b/fixtures/security_diff/mcp_priv/before/mcp.json @@ -0,0 +1 @@ +{"tools": [{"name": "read_project", "permissions": ["read"]}]} diff --git a/fixtures/security_diff/refactor_equiv/after/app.py b/fixtures/security_diff/refactor_equiv/after/app.py new file mode 100644 index 0000000..709d5c6 --- /dev/null +++ b/fixtures/security_diff/refactor_equiv/after/app.py @@ -0,0 +1,11 @@ +from flask import Flask, request, jsonify +from services.policy import check_ownership +app = Flask(__name__) + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = {"id": item_id, "owner_id": 1} + if not check_ownership(user_id, item): + return jsonify({"error": "forbidden"}), 403 + return jsonify(item) diff --git a/fixtures/security_diff/refactor_equiv/after/services/policy.py b/fixtures/security_diff/refactor_equiv/after/services/policy.py new file mode 100644 index 0000000..f6b7478 --- /dev/null +++ b/fixtures/security_diff/refactor_equiv/after/services/policy.py @@ -0,0 +1,2 @@ +def check_ownership(user_id, resource): + return resource.get("owner_id") == user_id diff --git a/fixtures/security_diff/refactor_equiv/before/app.py b/fixtures/security_diff/refactor_equiv/before/app.py new file mode 100644 index 0000000..70579fe --- /dev/null +++ b/fixtures/security_diff/refactor_equiv/before/app.py @@ -0,0 +1,13 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) + +def check_ownership(user_id, resource): + return resource.get("owner_id") == user_id + +@app.get("/api/items/") +def get_item(item_id): + user_id = int(request.args.get("user_id", 0)) + item = {"id": item_id, "owner_id": 1} + if not check_ownership(user_id, item): + return jsonify({"error": "forbidden"}), 403 + return jsonify(item) diff --git a/fixtures/security_diff/tenant_removed/after/app.py b/fixtures/security_diff/tenant_removed/after/app.py new file mode 100644 index 0000000..566973a --- /dev/null +++ b/fixtures/security_diff/tenant_removed/after/app.py @@ -0,0 +1,8 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) +DB = [] + +@app.get("/api/records") +def list_records(): + # tenant filter removed + return jsonify(DB) diff --git a/fixtures/security_diff/tenant_removed/before/app.py b/fixtures/security_diff/tenant_removed/before/app.py new file mode 100644 index 0000000..f043f02 --- /dev/null +++ b/fixtures/security_diff/tenant_removed/before/app.py @@ -0,0 +1,8 @@ +from flask import Flask, request, jsonify +app = Flask(__name__) +DB = [] + +@app.get("/api/records") +def list_records(): + tenant_id = request.headers.get("X-Tenant-Id") + return jsonify([r for r in DB if r.get("tenant_id") == tenant_id]) diff --git a/skills/axguard-preship/SKILL.md b/skills/axguard-preship/SKILL.md index 2f700ef..59b3eb6 100644 --- a/skills/axguard-preship/SKILL.md +++ b/skills/axguard-preship/SKILL.md @@ -1,77 +1,66 @@ --- name: axguard-preship -description: Pre-ship security review checklist for the current repository. Use for focused release reviews covering auth, SQL/injection/SSTI, path, SSRF, XSS, uploads, GraphQL, crypto, secrets, cloud, supply chain, debug, and agent risks when a full /axguard-audit is not requested. +description: Pre-ship security gate for the current repository. Use before publishing or opening a release PR. Prefers axguard_preship / axguard_security_diff MCP and axguard preship CLI; verifies after fixes. --- -# AXguard — Pre-ship Review +# AXguard — Pre-ship Gate -Ship-blocking review. Prefer real bugs over volume. Defensive static review only. +Ship / no-ship review. Prefer real bugs over volume. Defensive static review only. -Load `axguard-knowledge` when a class needs root-cause / fix depth. +## Prefer engines -## Workflow +| Intent | MCP tool | CLI | +|---|---|---| +| Before shipping | `axguard_preship` | `axguard preship .` | +| After security-sensitive changes | `axguard_security_diff` | `axguard diff` / `axguard security-diff` | +| After fixing a finding | verify tools | verify, then re-run Pre-Ship | -1. Map surface: routes, APIs, auth boundaries, outbound HTTP, file/exec sinks, HTML sinks, upload handlers, GraphQL, CI/cloud, agent tools, debug flags. -2. Run when CLI available: +Do **not** force Pre-Ship after every tiny edit. ```bash -axguard scan . -# shareable reports: -axguard audit . --out-dir .findings/axguard +axguard preship . +axguard preship . --mode QUICK --json +axguard diff HEAD~1 ``` -3. Verify each high/critical lead (source → sink → missing control). -4. Report only issues you can defend with file:line. +## Decision vocabulary -## Priority (ship blockers first) +`PASS` · `PASS_WITH_NOTES` · `REVIEW_REQUIRED` · `FAIL` -| # | Class | CWE / OWASP | Static sniff | -|---|--------|-------------|--------------| -| 1 | AuthZ / IDOR | CWE-639, A01 | get-by-id without ownership/tenant | -| 2 | RCE / cmd / deser / eval | CWE-78/94/502, A03 | shell=True, pickle, eval, unserialize | -| 3 | SQLi / SSTI | CWE-89/1336 | string-built SQL; render_template_string | -| 4 | SSRF (esp. metadata) | CWE-918, A10 | variable URL → fetch/requests; 169.254.169.254 | -| 5 | Secrets in tree/artifacts | CWE-798 | AWS keys, PEM, PATs, hardcoded API secrets | -| 6 | Path traversal / LFI | CWE-22/98 | open(join), sendFile(user), include($var) | -| 7 | XSS on high-value pages | CWE-79 | innerHTML / dangerouslySetInnerHTML + user data | -| 8 | JWT / CSRF footguns | CWE-347/352 | alg=none, decode w/o verify, csrf=False | -| 9 | Upload RCE path | CWE-434 | original filename; multer.any; webroot writes | -| 10 | Crypto / TLS misuse | CWE-321/328/295 | hard-coded keys, MD5 passwords, verify=False | -| 11 | CORS / cloud ACL | CWE-942/284 | `*` + credentials; public-read buckets | -| 12 | GraphQL exposure | CWE-200/352 | prod introspection; CSRF off | -| 13 | Supply / CI | CWE-494/506 | curl\|sh; extra-index; shady postinstall | -| 14 | Debug in prod path | CWE-489/209 | DEBUG=True; stack traces; open actuators | -| 15 | Agent tool abuse | LLM01/LLM06 | exec model output; unrestricted shell tool | - -## Fast pass (15–30 min) - -- [ ] Auth on every object-id route; admin gated -- [ ] No string-built SQL / shell / pickle on request data -- [ ] Outbound URL allowlisted if user-influenced -- [ ] No secrets in source, images, or CI logs -- [ ] Client sinks not fed raw user HTML -- [ ] Uploads renamed + type-checked + non-executable storage -- [ ] Prod: DEBUG off, introspection off, TLS verify on -- [ ] Agent tools allowlisted; no blind shell-from-LLM -- [ ] Lockfiles present; no curl|sh install in ship path - -## False-positive discipline - -Drop: tests/fixtures (unless those ship), constant-only sinks, verified mitigations on the same path, docs examples that never build. - -Keep: missing control on a reachable ship path, even if “exploit steps” are incomplete — state impact + fix. - -## Output format +Never fail solely on unverified suspicion. LLM alone cannot block. -``` -Title: -Severity: -CWE / OWASP: -Location: -Why it matters: -Evidence: -Fix: -Ship blocker? (Y/N) -``` +## Workflow + +1. Map surface: routes, auth boundaries, sinks, uploads, agent tools. +2. Run Pre-Ship (MCP `axguard_preship` or CLI `axguard preship`). +3. If Security Diff shows control removal / authz weakening, investigate before shipping. +4. Verify each high/critical lead (source → sink → missing control). +5. After patches: verify (`axguard verify`), then re-run Pre-Ship. + +Load `axguard-knowledge` when a class needs root-cause / fix depth. + +## Priority (ship blockers first) -End with: blocker count, non-blocker count, recommended next (`/axguard-triage`, `/axguard-fix`, or full `/axguard-audit`). +| # | Class | Static sniff | +|---|--------|--------------| +| 1 | AuthZ / IDOR | get-by-id without ownership/tenant | +| 2 | RCE / cmd / deser / eval | shell=True, pickle, eval | +| 3 | SQLi / SSTI | string-built SQL; render_template_string | +| 4 | SSRF | variable URL → fetch | +| 5 | Secrets | keys/PATs in tree | +| 6 | Path / LFI | open(join), sendFile(user) | +| 7 | XSS | innerHTML + user data | +| 8 | JWT / CSRF | alg=none; csrf=False | +| 9 | Upload RCE | original filename; webroot writes | +| 10 | Crypto / TLS | hard-coded keys; verify=False | +| 11 | CORS / cloud | `*` + credentials | +| 12 | GraphQL | prod introspection | +| 13 | Supply / CI | curl\|sh | +| 14 | Debug | DEBUG=True in ship path | +| 15 | Agent tools | unrestricted shell from LLM | + +## Output + +Report decision, blocking reason (if FAIL), review_why (if REVIEW_REQUIRED), Security Diff summary, verified findings, and next step (fix / verify / full audit). + +Docs: [docs/preship.md](../../docs/preship.md) · [docs/security-diff.md](../../docs/security-diff.md) diff --git a/skills/axguard-security/SKILL.md b/skills/axguard-security/SKILL.md new file mode 100644 index 0000000..f2b46b9 --- /dev/null +++ b/skills/axguard-security/SKILL.md @@ -0,0 +1,54 @@ +--- +name: axguard-security +description: MCP-first AXGuard security interface for coding agents. Use for security review, Security Diff on meaningful changes, verification, and pre-ship gates. Prefer axguard_security_diff when comparing what became more dangerous across a change; prefer axguard_security_review for full review. +version: "1.0.0" +author: AwareXone +license: MIT +domain: operations +tags: [mcp, security-diff, review, preship] +related_commands: [axguard-diff, axguard-preship, axguard-audit] +last_reviewed: "2026-09-17" +--- + +# AXguard — Agent Security Skill (MCP) + +## When to use Security Diff + +Call **`axguard_security_diff`** (or `axguard diff`) when a **meaningful** security-sensitive change occurs: + +- authentication / authorization / tenant isolation changes +- new endpoints, webhooks, uploads, GraphQL, websockets +- external integrations / network exposure +- AI agent / MCP tool permission changes +- deployment / dependency / secret-handling changes +- before shipping + +Do **not** run Security Diff after every trivial edit (rename, formatting, docs-only, test-only noise). + +## Flow + +```text +Meaningful change + → axguard_security_diff + → axguard_security_review (if impact HIGH/CRITICAL or REVIEW_REQUIRED) + → fix + → axguard_verify / axguard diff (confirm path blocked) +``` + +## Primary MCP tools + +| Tool | Use | +|---|---| +| `axguard_security_diff` | What became more dangerous between two states | +| `axguard_security_review` | Full orchestrated review | +| `axguard_verify_finding` | Judge a candidate | +| `axguard_find_attack_paths` | Attack path detail | +| `axguard_predict_security_risks` | Predictive (not verified) | + +## Pre-Ship + +```text +Security Diff → Security Review → Pre-Ship Gate +``` + +See [docs/security-diff.md](../../docs/security-diff.md) and [docs/preship.md](../../docs/preship.md). diff --git a/skills/index.yaml b/skills/index.yaml index b04c1b8..7aca0ac 100644 --- a/skills/index.yaml +++ b/skills/index.yaml @@ -25,6 +25,9 @@ skills: - name: axguard-knowledge domain: operations path: skills/axguard-knowledge/SKILL.md + - name: axguard-security + domain: operations + path: skills/axguard-security/SKILL.md # Domain skills — discovery (3) - name: threat-modeling diff --git a/tests/test_preship.py b/tests/test_preship.py new file mode 100644 index 0000000..d4093a7 --- /dev/null +++ b/tests/test_preship.py @@ -0,0 +1,185 @@ +"""Tests for engines.preship — decisions, exit codes, CLI smoke.""" + +from __future__ import annotations + +import socket +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from engines.preship.decision import decide +from engines.preship.policy import ( + PreshipBlocking, + PreshipPolicy, + PreshipUnknowns, + map_preship_verdict, +) +from engines.preship.schema import ( + DECISION_FAIL, + DECISION_PASS, + DECISION_PASS_WITH_NOTES, + DECISION_REVIEW_REQUIRED, + EXIT_FAIL, + EXIT_PASS, + EXIT_REVIEW_REQUIRED, + EXIT_TOOL_ERROR, + exit_code_for, +) + +ROOT = Path(__file__).resolve().parents[1] +PASS_SAFE = ROOT / "fixtures" / "preship" / "pass_safe" +FAIL_AUTHZ = ROOT / "fixtures" / "preship" / "fail_authz" + + +def test_exit_code_mapping(): + assert exit_code_for(DECISION_PASS) == EXIT_PASS + assert exit_code_for(DECISION_PASS_WITH_NOTES) == EXIT_PASS + assert exit_code_for(DECISION_REVIEW_REQUIRED) == EXIT_REVIEW_REQUIRED + assert exit_code_for(DECISION_FAIL) == EXIT_FAIL + assert exit_code_for("PASS", tool_error=True) == EXIT_TOOL_ERROR + + +def test_verified_high_fails(): + findings = [ + { + "id": "auth.1", + "title": "IDOR", + "severity": "high", + "status": "VERIFIED", + } + ] + assert map_preship_verdict(findings) == DECISION_FAIL + pack = decide(findings=findings) + assert pack["decision"] == DECISION_FAIL + assert pack["exit_code"] == EXIT_FAIL + assert pack["blocking_reason"] + + +def test_unverified_never_fails_by_default(): + findings = [ + { + "id": "x.1", + "title": "maybe", + "severity": "critical", + "status": "UNVERIFIED", + } + ] + assert map_preship_verdict(findings) == DECISION_PASS_WITH_NOTES + + +def test_likely_is_review(): + findings = [ + { + "id": "x.1", + "title": "likely idor", + "severity": "high", + "status": "LIKELY", + } + ] + assert map_preship_verdict(findings) == DECISION_REVIEW_REQUIRED + + +def test_false_positive_is_pass(): + findings = [ + { + "id": "x.1", + "title": "fp", + "severity": "high", + "status": "FALSE_POSITIVE", + } + ] + assert map_preship_verdict(findings) == DECISION_PASS + + +def test_security_diff_control_removal_review(): + findings: list = [] + sd = { + "overall_security_change": "HIGH", + "summary": {"removed_controls": 1, "weakened_controls": 0}, + "authz_changes": [{"change": "ownership check removed", "impact": "authorization weakened"}], + "tenant_changes": [], + "control_changes": [{"state": "REMOVED", "kind": "authorization"}], + } + decision = map_preship_verdict(findings, security_diff=sd) + assert decision == DECISION_REVIEW_REQUIRED + + +def test_unknowns_policy_review(): + pol = PreshipPolicy( + blocking=PreshipBlocking(), + unknowns=PreshipUnknowns(fail=False, review_required=True), + ) + decision = map_preship_verdict( + [], + pol, + unknowns=["Tenant isolation implementation could not be established."], + ) + assert decision == DECISION_REVIEW_REQUIRED + + +def test_predictive_soft_notes(): + decision = map_preship_verdict( + [], + predictive_risks=[{"label": "ATTACK_SURFACE_EXPANSION"}], + ) + assert decision == DECISION_PASS_WITH_NOTES + + +def test_cli_preship_pass_safe_exit(monkeypatch, tmp_path): + """Smoke: axguard preship fixtures/preship/pass_safe --json exits 0/1/2 not 3.""" + # Block sockets + real_socket = socket.socket + + def no_net(*a, **k): + raise OSError("network disabled in test") + + monkeypatch.setattr(socket, "socket", no_net) + + from cli.main import main + + out = tmp_path / "preship-out" + code = main( + [ + "preship", + str(PASS_SAFE), + "--mode", + "QUICK", + "--json", + "--no-banner", + "--out-dir", + str(out), + ] + ) + assert code in {0, 1, 2} + assert code != 3 + # restore for safety + monkeypatch.setattr(socket, "socket", real_socket) + + +def test_fail_authz_decision_bucket(monkeypatch, tmp_path): + """fail_authz tends FAIL/REVIEW, or security_diff detects authz weakening vs pass_safe.""" + monkeypatch.setattr(socket, "socket", lambda *a, **k: (_ for _ in ()).throw(OSError("no net"))) + + from engines.preship import run_preship + from engines.security_diff.compose import run_security_diff as compose_diff + + result = run_preship( + FAIL_AUTHZ, + mode="QUICK", + out_dir=tmp_path / "fail", + ) + if result["decision"] in {DECISION_FAIL, DECISION_REVIEW_REQUIRED}: + return + + # Fallback: compare to pass_safe via compose + diff = compose_diff( + FAIL_AUTHZ, + base_target=PASS_SAFE, + cheap_twin=False, + ) + authz = diff.get("authz_changes") or [] + cats = [c.get("category") for c in (diff.get("categories") or [])] + assert authz or "REMOVED_SECURITY_CONTROL" in cats or diff.get( + "overall_security_change" + ) in {"HIGH", "CRITICAL", "MEDIUM"} diff --git a/tests/test_security_diff.py b/tests/test_security_diff.py new file mode 100644 index 0000000..9827083 --- /dev/null +++ b/tests/test_security_diff.py @@ -0,0 +1,162 @@ +"""Tests for engines.security_diff — authz regression + schema.""" + +from __future__ import annotations + +import socket +from pathlib import Path + +import pytest + +from engines.security_diff.authz_diff import compare_authz, scan_path_for_authz +from engines.security_diff.compose import run_security_diff as compose_diff +from engines.security_diff.schema import ( + CHANGE_CATEGORIES, + NEW_ENDPOINT, + REMOVED_SECURITY_CONTROL, + empty_security_diff, +) + +ROOT = Path(__file__).resolve().parents[1] +BEFORE = ROOT / "fixtures" / "preship" / "regression_authz" / "before" +AFTER = ROOT / "fixtures" / "preship" / "regression_authz" / "after" + + +def test_empty_security_diff_shape(): + empty = empty_security_diff() + assert "categories" in empty + assert "summary" in empty + assert "overall_security_change" in empty + assert "authz_changes" in empty + assert NEW_ENDPOINT in CHANGE_CATEGORIES + assert REMOVED_SECURITY_CONTROL in CHANGE_CATEGORIES + + +def test_authz_scan_detects_ownership(): + before = scan_path_for_authz(BEFORE) + after = scan_path_for_authz(AFTER) + assert before["has_ownership"] is True + assert after["has_ownership"] is False + + +def test_regression_authz_ownership_removed(): + result = compare_authz(before_target=BEFORE, after_target=AFTER) + changes = result.get("authz_changes") or [] + assert any( + "ownership" in str(c.get("change") or "").lower() + or "removed" in str(c.get("change") or "").lower() + for c in changes + ), changes + + +def test_compose_before_after_flags_control_or_authz(monkeypatch): + monkeypatch.setattr( + socket, + "socket", + lambda *a, **k: (_ for _ in ()).throw(OSError("no net")), + ) + # Prefer lightweight compose with text scans even if AG build is heavy + diff = compose_diff(AFTER, base_target=BEFORE, cheap_twin=False) + assert diff.get("baseline") in {"PATH", "GIT", "ARTIFACTS", "UNKNOWN", "BASELINE_UNAVAILABLE"} + authz = diff.get("authz_changes") or [] + cats = [c.get("category") for c in (diff.get("categories") or [])] + controls = diff.get("control_changes") or [] + assert ( + authz + or REMOVED_SECURITY_CONTROL in cats + or any(c.get("state") in {"REMOVED", "WEAKENED"} for c in controls) + or str(diff.get("overall_security_change") or "") in {"HIGH", "CRITICAL", "MEDIUM"} + ), { + "authz": authz, + "cats": cats, + "controls": controls, + "overall": diff.get("overall_security_change"), + } + + +def test_pipeline_run_security_diff_no_socket(monkeypatch, tmp_path): + monkeypatch.setattr( + socket, + "socket", + lambda *a, **k: (_ for _ in ()).throw(OSError("no net")), + ) + from engines.security_diff import run_security_diff + + result = run_security_diff( + project=AFTER, + base=str(BEFORE), + write_report=False, + ) + assert isinstance(result, dict) + assert "summary" in result + assert result.get("overall_security_change") is not None + + +def test_compare_states_endpoint_and_control(): + from engines.security_diff.compare import compare_states + from engines.security_diff.schema import NEW_ENDPOINT + + before = { + "application_model": { + "entrypoints": [{"method": "GET", "path": "/a"}], + "security_controls": [ + {"type": "authorization", "name": "own", "file": "a.py"} + ], + }, + "dataflow": {"paths": []}, + "attack_graph": {"paths": [], "graph": {"nodes": [], "edges": []}}, + "twin": None, + "predictive": None, + "unknowns": [], + } + after = { + "application_model": { + "entrypoints": [ + {"method": "GET", "path": "/a"}, + {"method": "POST", "path": "/api/export"}, + ], + "security_controls": [], + }, + "dataflow": {"paths": []}, + "attack_graph": {"paths": [], "graph": {"nodes": [], "edges": []}}, + "twin": None, + "predictive": None, + "unknowns": [], + } + partial = compare_states(before, after) + assert partial["summary"]["new_endpoints"] == 1 + assert partial["summary"]["removed_controls"] == 1 + assert NEW_ENDPOINT in {c["category"] for c in partial["categories"]} + + +def test_control_fingerprint_ignores_path(): + from engines.security_diff.controls import control_fingerprint + + a = {"type": "authorization", "name": "check_ownership", "file": "a.py"} + b = {"type": "authorization", "name": "check_ownership", "file": "b.py"} + assert control_fingerprint(a) == control_fingerprint(b) + + +def test_baseline_unavailable_no_fabricate(tmp_path): + from engines.security_diff import security_diff + + result = security_diff(project=tmp_path, options={"use_snapshot": True}) + assert result["baseline"] in { + "BASELINE_UNAVAILABLE", + "UNKNOWN", + } + assert result["security_impact"]["decision"] in {"UNKNOWN", "PASS"} + + +def test_mcp_compact_and_github_summary(): + from engines.security_diff.github_summary import ( + compact_mcp_response, + format_github_pr_summary, + ) + from engines.security_diff.schema import empty_security_diff + + d = empty_security_diff() + d["security_impact"] = {"level": "HIGH", "decision": "REVIEW_REQUIRED", "reason": "x"} + compact = compact_mcp_response(d) + assert "new_attack_paths" in compact + text = format_github_pr_summary(d) + assert "AXGUARD SECURITY DIFF" in text From a4616705f482563574b8d4e67b8bf3337a74fc14 Mon Sep 17 00:00:00 2001 From: Md Shariar Shanaz Shuvon <83355567+shuvonsec@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:09:26 +0800 Subject: [PATCH 2/4] fix(preship): avoid HEAD~1 baseline for fixture subdirs --- engines/preship/pipeline.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/engines/preship/pipeline.py b/engines/preship/pipeline.py index 2038e87..55b9fea 100644 --- a/engines/preship/pipeline.py +++ b/engines/preship/pipeline.py @@ -234,10 +234,21 @@ def run_preship( incremental=mode_u == MODE_QUICK, ) else: - # Try HEAD~1 when git; else snapshot / UNKNOWN via pipeline + # Try HEAD~1 only when target is the git worktree root; otherwise + # subdirectory targets (fixtures) would diff the whole monorepo. + quick_base = None + if mode_u == MODE_QUICK: + try: + from engines.security_diff.git_base import git_root + + gr = git_root(root) + if gr is not None and gr.resolve() == root.resolve(): + quick_base = "HEAD~1" + except Exception: # noqa: BLE001 + quick_base = None security_diff = run_security_diff( project=root, - base="HEAD~1" if mode_u == MODE_QUICK else None, + base=quick_base, use_snapshot=mode_u != MODE_QUICK, write_report=False, incremental=True, From d366dce21300ab7da889282bb40cd699137218e1 Mon Sep 17 00:00:00 2001 From: Md Shariar Shanaz Shuvon <83355567+shuvonsec@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:25:34 +0800 Subject: [PATCH 3/4] fix(security-diff): repair CLI parsing and baseline consistency --- cli/main.py | 82 ++++++++++++------------------ engines/preship/pipeline.py | 2 + engines/security_diff/__init__.py | 66 ++++++++++++++++++++++-- engines/security_diff/cli.py | 69 +++---------------------- engines/security_diff/impact.py | 4 +- engines/security_diff/pipeline.py | 83 ++++++++++++++++--------------- engines/security_diff/render.py | 39 ++++++--------- engines/security_diff/report.py | 6 ++- engines/security_diff/schema.py | 40 +++++++++++++-- tests/test_security_diff.py | 32 ++++++++++++ 10 files changed, 238 insertions(+), 185 deletions(-) diff --git a/cli/main.py b/cli/main.py index 53ef13d..2be9fdd 100644 --- a/cli/main.py +++ b/cli/main.py @@ -692,12 +692,16 @@ def _mem_common(p: argparse.ArgumentParser) -> None: "security-relevant changes (not another scanner)" ), ) - diff_sub = diff_cmd.add_subparsers(dest="diff_command") + # Use nargs='*' tokens (not nested subparsers) so `axguard diff HEAD~1` works. + # Tokens: [range|ref|path] OR `baseline` [save] [path] diff_cmd.add_argument( - "range_or_path", - nargs="?", - default=None, - help="Git range (main...HEAD), ref (HEAD~1), commit, or path (default: auto)", + "tokens", + nargs="*", + default=[], + help=( + "Git range (main...HEAD), ref (HEAD~1), path, or " + "'baseline' / 'baseline save' [--name]" + ), ) diff_cmd.add_argument("--base", default=None, help="Base git ref or path") diff_cmd.add_argument("--head", default=None, help="Head git ref or path (default: working tree)") @@ -741,46 +745,20 @@ def _mem_common(p: argparse.ArgumentParser) -> None: diff_cmd.add_argument( "--baseline-name", default="default", + dest="baseline_name", help="AXGuard snapshot baseline name (non-git)", ) + diff_cmd.add_argument( + "--name", + default=None, + help="Alias for --baseline-name (baseline save/compare)", + ) diff_cmd.add_argument("--no-banner", action="store_true", help="Hide the ASCII banner") diff_cmd.add_argument( "--no-engage", action="store_true", help="Skip engagement / first-run messaging", ) - diff_base = diff_sub.add_parser( - "baseline", - help="Compare against a stored AXGuard baseline (or save one)", - ) - diff_base_sub = diff_base.add_subparsers(dest="diff_baseline_command") - diff_base.add_argument( - "--name", - default="default", - help="Baseline name (default: default)", - ) - diff_base.add_argument( - "--json", - action="store_true", - dest="as_json", - help="Print JSON", - ) - diff_base.add_argument("--no-banner", action="store_true", help="Hide the ASCII banner") - diff_base_save = diff_base_sub.add_parser( - "save", - help="Save current security state as a baseline snapshot", - ) - diff_base_save.add_argument( - "--name", - default="default", - help="Baseline name (default: default)", - ) - diff_base_save.add_argument( - "path", - nargs="?", - default=".", - help="Project path (default: .)", - ) sub.add_parser("version", help="Print version") sub.add_parser("help", help="Show Start Using workflow table") @@ -1487,22 +1465,28 @@ def _run_diff_command(args: argparse.Namespace) -> int: ) from engines.security_diff.report import ( render_text, - render_verbose, to_json, - write_security_diff_report, ) - # axguard diff baseline save - if getattr(args, "diff_command", None) == "baseline": - name = getattr(args, "name", None) or "default" - if getattr(args, "diff_baseline_command", None) == "save": - path = Path(getattr(args, "path", ".") or ".").resolve() + tokens = list(getattr(args, "tokens", None) or []) + name = ( + getattr(args, "name", None) + or getattr(args, "baseline_name", None) + or "default" + ) + + # axguard diff baseline [save] [path] + if tokens and tokens[0] == "baseline": + rest = tokens[1:] + if rest and rest[0] == "save": + path = Path((rest[1] if len(rest) > 1 else ".") or ".").resolve() out = save_baseline_from_project(path, name=name) print(f"Saved Security Diff baseline '{name}' → {out}") return 0 - # axguard diff baseline → compare to snapshot + # axguard diff baseline → compare to snapshot + project = rest[0] if rest else "." result = run_security_diff( - project=".", + project=project, use_snapshot=True, baseline_name=name, fail_on=getattr(args, "fail_on", "none") or "none", @@ -1514,7 +1498,7 @@ def _run_diff_command(args: argparse.Namespace) -> int: print(render_text(result), end="") return 1 if should_fail(result, getattr(args, "fail_on", "none") or "none") else 0 - range_or_path = getattr(args, "range_or_path", None) + range_or_path = tokens[0] if tokens else None base = getattr(args, "base", None) head = getattr(args, "head", None) range_spec = None @@ -1528,7 +1512,7 @@ def _run_diff_command(args: argparse.Namespace) -> int: result = run_security_diff( project=project, use_snapshot=True, - baseline_name=getattr(args, "baseline_name", "default") or "default", + baseline_name=name, fail_on=args.fail_on, incremental=not args.no_incremental, investigate=bool(args.investigate), @@ -1546,7 +1530,7 @@ def _run_diff_command(args: argparse.Namespace) -> int: base=base, head=head, range_spec=range_spec, - baseline_name=getattr(args, "baseline_name", "default") or "default", + baseline_name=name, fail_on=args.fail_on, incremental=not args.no_incremental, investigate=bool(args.investigate), diff --git a/engines/preship/pipeline.py b/engines/preship/pipeline.py index 55b9fea..a817bfc 100644 --- a/engines/preship/pipeline.py +++ b/engines/preship/pipeline.py @@ -255,6 +255,8 @@ def run_preship( ) # Optional prior artifacts under .findings for compose enrich + # (compose path keeps legacy baseline token "UNKNOWN" for compatibility; + # pipeline path uses BASELINE_UNAVAILABLE — both mean the same thing.) base_arts: dict[str, Any] = {} findings_dir = root / ".findings" / "axguard" if findings_dir.is_dir() and not base_path: diff --git a/engines/security_diff/__init__.py b/engines/security_diff/__init__.py index 9256a1a..c986331 100644 --- a/engines/security_diff/__init__.py +++ b/engines/security_diff/__init__.py @@ -2,27 +2,83 @@ from __future__ import annotations +from typing import Any + from engines.security_diff.impact import should_fail from engines.security_diff.pipeline import ( - run_security_diff, + run_security_diff as _pipeline_run, save_baseline_from_project, - security_diff, + security_diff as _security_diff, ) from engines.security_diff.render import render_security_diff_text from engines.security_diff.schema import ( + BASELINE_UNAVAILABLE, BASELINE_UNKNOWN, CHANGE_CATEGORIES, CONTROL_STATES, SECURITY_DIFF_VERSION, SEVERITIES, empty_security_diff, + is_baseline_unavailable, ) from engines.security_diff.store import list_baselines, load_baseline, save_baseline - -# Artifact-dict compose API (MVP / tests) from engines.security_diff.compose import run_security_diff as run_security_diff_compose from engines.security_diff.git_base import list_changed_files, resolve_base_ref + +def security_diff( + base: Any = None, + head: Any = None, + project: Any = None, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Primary reusable API (CLI / MCP / GitHub / Pre-Ship / CI).""" + return _security_diff(base=base, head=head, project=project, options=options) + + +def run_security_diff(*args: Any, **kwargs: Any) -> dict[str, Any]: + """Compatibility entry point for product CLI and legacy compose callers. + + - Product/CLI: ``run_security_diff(project=..., base=..., head=..., ...)`` + - Legacy compose: ``run_security_diff(path, base_target=..., base_ref=..., ...)`` + → routed to ``run_security_diff_compose`` when artifact kwargs are present. + """ + if args or any( + k in kwargs + for k in ( + "current_target", + "base_target", + "base_ref", + "current_artifacts", + "base_artifacts", + "cheap_twin", + ) + ): + if kwargs.get("current_artifacts") or kwargs.get("base_artifacts"): + return run_security_diff_compose(*args, **kwargs) + current = ( + args[0] + if args + else kwargs.get("current_target") or kwargs.get("project") or "." + ) + base_target = kwargs.get("base_target") + base_ref = kwargs.get("base_ref") + return _pipeline_run( + project=current, + base=base_target or base_ref, + head=kwargs.get("head"), + fail_on=kwargs.get("fail_on", "none"), + incremental=kwargs.get("incremental", True), + investigate=kwargs.get("investigate", False), + write_report=kwargs.get("write_report", False), + use_snapshot=kwargs.get("use_snapshot", False), + baseline_name=kwargs.get("baseline_name", "default"), + out_dir=kwargs.get("out_dir"), + skip_twin=not kwargs.get("cheap_twin", True), + ) + return _pipeline_run(**kwargs) + + __all__ = [ "SECURITY_DIFF_VERSION", "empty_security_diff", @@ -41,4 +97,6 @@ "CONTROL_STATES", "SEVERITIES", "BASELINE_UNKNOWN", + "BASELINE_UNAVAILABLE", + "is_baseline_unavailable", ] diff --git a/engines/security_diff/cli.py b/engines/security_diff/cli.py index 1173870..6e10023 100644 --- a/engines/security_diff/cli.py +++ b/engines/security_diff/cli.py @@ -3,72 +3,19 @@ from __future__ import annotations import argparse -import json -import sys -from pathlib import Path from typing import Any def add_diff_parser(subparsers: argparse._SubParsersAction) -> None: - """Register ``diff`` / ``security-diff`` only if not already present.""" - choices = getattr(subparsers, "choices", {}) or {} - if "diff" in choices or "security-diff" in choices: - return - diff = subparsers.add_parser( - "diff", - aliases=["security-diff"], - help="Security Diff — what became more dangerous between two versions", - ) - diff.add_argument("base_or_path", nargs="?", default=None) - diff.add_argument("path", nargs="?", default=".") - diff.add_argument("--base-path", default=None) - diff.add_argument("--base", default=None) - diff.add_argument("--json", action="store_true") - diff.add_argument("--out", default=None) - diff.add_argument("--no-banner", action="store_true") - diff.add_argument("--no-twin", action="store_true") - - -def run_diff_command(args: argparse.Namespace) -> int: - """Fallback runner when invoked via engines.security_diff.cli.""" - from engines.banner import print_banner - from engines.security_diff.pipeline import run_security_diff - from engines.security_diff.render import render_security_diff_text as render_text + """No-op when ``diff`` is already registered in ``cli.main``. - if not getattr(args, "no_banner", False): - print_banner(compact=True) - print() - - current = Path(getattr(args, "path", ".") or ".").resolve() - base_ref = getattr(args, "base", None) - base_or = getattr(args, "base_or_path", None) - base_path = getattr(args, "base_path", None) - - if base_or and base_path is None and base_ref is None: - candidate = Path(base_or) - if candidate.exists(): - base_path = str(candidate.resolve()) - else: - base_ref = base_or + Kept for backward compatibility with soft-import call sites. + """ + if "diff" in getattr(subparsers, "choices", {}): + return - try: - result = run_security_diff( - project=current, - base=base_path or base_ref, - write_report=False, - ) - except Exception as exc: # noqa: BLE001 - print(f"error: security diff failed: {exc}", file=sys.stderr) - return 3 - out_path = getattr(args, "out", None) - if out_path: - Path(out_path).write_text( - json.dumps(result, indent=2, default=str) + "\n", encoding="utf-8" - ) +def render_security_diff_text(result: dict[str, Any]) -> str: + from engines.security_diff.report import render_text - if getattr(args, "json", False) or getattr(args, "as_json", False): - print(json.dumps(result, indent=2, default=str)) - else: - print(render_text(result), end="") - return 0 + return render_text(result) diff --git a/engines/security_diff/impact.py b/engines/security_diff/impact.py index 4437a23..ba4d576 100644 --- a/engines/security_diff/impact.py +++ b/engines/security_diff/impact.py @@ -18,7 +18,7 @@ IMPACT_MEDIUM, IMPACT_NONE, IMPACT_UNKNOWN, - BASELINE_UNAVAILABLE, + is_baseline_unavailable, ) _RANK = { @@ -41,7 +41,7 @@ def _max_impact(*levels: str) -> str: def classify_impact(diff: dict[str, Any]) -> dict[str, Any]: """Derive overall security_impact + decision from populated deltas.""" - if str(diff.get("baseline")) in {BASELINE_UNAVAILABLE, "UNKNOWN", "BASELINE_UNAVAILABLE"}: + if is_baseline_unavailable(str(diff.get("baseline"))): return { "level": IMPACT_UNKNOWN, "reason": "No valid baseline available; comparison was not fabricated.", diff --git a/engines/security_diff/pipeline.py b/engines/security_diff/pipeline.py index 92ed539..016d1a9 100644 --- a/engines/security_diff/pipeline.py +++ b/engines/security_diff/pipeline.py @@ -62,6 +62,32 @@ def save_baseline_from_project( return save_baseline(root, state, name=name) +def _mark_baseline_unavailable( + result: dict[str, Any], + *, + notes: list[str], + reason: str, + unknown_reason: str, +) -> dict[str, Any]: + """Stamp an unavailable-baseline result without fabricating a comparison.""" + result["baseline"] = BASELINE_UNAVAILABLE + result["notes"] = list(notes) + result["security_impact"] = { + "level": IMPACT_UNKNOWN, + "reason": reason, + "decision": DECISION_UNKNOWN, + } + result["overall_security_change"] = IMPACT_UNKNOWN + result.setdefault("unknowns", []).append( + { + "area": "baseline", + "reason": unknown_reason, + "status": "UNKNOWN", + } + ) + return result + + def security_diff( base: Any = None, head: Any = None, @@ -165,21 +191,12 @@ def security_diff( ) return result notes.append("AXGuard snapshot baseline not found") - result["baseline"] = BASELINE_UNAVAILABLE - result["notes"] = notes - result["security_impact"] = { - "level": IMPACT_UNKNOWN, - "reason": "BASELINE_UNAVAILABLE — no valid baseline for comparison.", - "decision": DECISION_UNKNOWN, - } - result["unknowns"].append( - { - "area": "baseline", - "reason": "No git base and no stored AXGuard snapshot.", - "status": "UNKNOWN", - } + return _mark_baseline_unavailable( + result, + notes=notes, + reason="BASELINE_UNAVAILABLE — no valid baseline for comparison.", + unknown_reason="No git base and no stored AXGuard snapshot.", ) - return result else: # Auto: try git, else snapshot base_ref = None @@ -203,14 +220,12 @@ def security_diff( mat = materialize_ref(Path(cmp["repo"]), str(cmp.get("base_sha") or base_ref)) if mat is None: notes.append("failed to materialize git base tree") - result["baseline"] = BASELINE_UNAVAILABLE - result["notes"] = notes - result["security_impact"] = { - "level": IMPACT_UNKNOWN, - "reason": "BASELINE_UNAVAILABLE — git base could not be materialized.", - "decision": DECISION_UNKNOWN, - } - return result + return _mark_baseline_unavailable( + result, + notes=notes, + reason="BASELINE_UNAVAILABLE — git base could not be materialized.", + unknown_reason="Git base materialization failed.", + ) materialized.append(mat) base_path = mat # Head: working tree unless head ref != HEAD @@ -255,24 +270,14 @@ def security_diff( cleanup_materialized(m) return result - result["baseline"] = BASELINE_UNAVAILABLE - result["notes"] = notes + [ - "BASELINE_UNAVAILABLE — no git base and no AXGuard snapshot." - ] - result["security_impact"] = { - "level": IMPACT_UNKNOWN, - "reason": "BASELINE_UNAVAILABLE — no valid baseline for comparison.", - "decision": DECISION_UNKNOWN, - } - result["unknowns"].append( - { - "area": "baseline", - "reason": "No valid baseline exists.", - "status": "UNKNOWN", - } + return _mark_baseline_unavailable( + result, + notes=notes + [ + "BASELINE_UNAVAILABLE — no git base and no AXGuard snapshot." + ], + reason="BASELINE_UNAVAILABLE — no valid baseline for comparison.", + unknown_reason="No valid baseline exists.", ) - return result - try: assert base_path is not None and head_path is not None base_state = build_security_state( diff --git a/engines/security_diff/render.py b/engines/security_diff/render.py index 8508d02..5f81f53 100644 --- a/engines/security_diff/render.py +++ b/engines/security_diff/render.py @@ -4,6 +4,8 @@ from typing import Any +from engines.security_diff.schema import is_baseline_unavailable + def render_security_diff_text(result: dict[str, Any]) -> str: """Render compact SECURITY DIFF text (product-style).""" @@ -13,8 +15,8 @@ def render_security_diff_text(result: dict[str, Any]) -> str: lines.append("") baseline = result.get("baseline") or "UNKNOWN" - if baseline == "UNKNOWN": - lines.append("BASELINE: UNKNOWN") + if is_baseline_unavailable(str(baseline)): + lines.append(f"BASELINE: {baseline}") lines.append("") summary = result.get("summary") or {} @@ -38,7 +40,6 @@ def _minus(n: int, label: str) -> None: _minus(int(summary.get("removed_controls") or 0), "authorization/security control") _plus(int(summary.get("added_controls") or 0), "security control added") - # Authz / tenant highlights for ch in result.get("authz_changes") or []: lines.append(f"! authz: {ch.get('change')}") for ch in result.get("tenant_changes") or []: @@ -48,24 +49,14 @@ def _minus(n: int, label: str) -> None: lines.append("(no structural security changes detected)") lines.append("") - lines.append("Attack paths:") - lines.append(f"+ {int(summary.get('new_attack_paths') or 0)} reachable") - lines.append(f"- {int(summary.get('blocked_attack_paths') or 0)} blocked") - lines.append("") - lines.append("Security controls:") - lines.append(f"{int(summary.get('weakened_controls') or 0)} weakened") - lines.append(f"{int(summary.get('added_controls') or 0)} added") - lines.append(f"{int(summary.get('removed_controls') or 0)} removed") - lines.append(f"{int(summary.get('strengthened_controls') or 0)} strengthened") - lines.append("") - lines.append("Overall security change:") - lines.append(str(result.get("overall_security_change") or "LOW")) - - notes = result.get("notes") or [] - if notes: - lines.append("") - lines.append("Notes:") - for n in notes[:8]: - lines.append(f"- {n}") - - return "\n".join(lines).rstrip() + "\n" + overall = result.get("overall_security_change") + if not overall: + impact = result.get("security_impact") or {} + overall = impact.get("level") or ( + "UNKNOWN" if is_baseline_unavailable(str(baseline)) else "NONE" + ) + lines.append(f"Overall security change: {overall}") + decision = (result.get("security_impact") or {}).get("decision") + if decision: + lines.append(f"Decision: {decision}") + return "\n".join(lines) + "\n" diff --git a/engines/security_diff/report.py b/engines/security_diff/report.py index e8f4257..0437f5d 100644 --- a/engines/security_diff/report.py +++ b/engines/security_diff/report.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Any +from engines.security_diff.schema import is_baseline_unavailable + def render_text(diff: dict[str, Any]) -> str: """Concise CLI output (default).""" @@ -61,11 +63,11 @@ def render_text(diff: dict[str, Any]) -> str: lines.append("none") lines.extend(["", "Decision:", str(decision)]) - if str(diff.get("baseline")) == "BASELINE_UNAVAILABLE": + if is_baseline_unavailable(str(diff.get("baseline"))): lines.extend( [ "", - "BASELINE_SOURCE: BASELINE_UNAVAILABLE", + f"BASELINE_SOURCE: {diff.get('baseline') or 'BASELINE_UNAVAILABLE'}", "No comparison fabricated.", ] ) diff --git a/engines/security_diff/schema.py b/engines/security_diff/schema.py index 48c54f0..379363c 100644 --- a/engines/security_diff/schema.py +++ b/engines/security_diff/schema.py @@ -173,35 +173,67 @@ { NEW_ENDPOINT, REMOVED_ENDPOINT, + NEW_HTTP_METHOD, + REMOVED_HTTP_METHOD, NEW_PARAMETER, + REMOVED_PARAMETER, + NEW_WEBHOOK, + REMOVED_WEBHOOK, + NEW_UPLOAD, + NEW_GRAPHQL_RESOLVER, + NEW_WEBSOCKET, + NEW_PUBLIC_RESOURCE, + NEW_EXTERNAL_INTEGRATION, + NEW_NETWORK_INTERFACE, + NEW_SERVICE, + NEW_WORKER, + NEW_QUEUE, + NEW_AI_AGENT, + NEW_AGENT_TOOL, + NEW_MCP_SERVER, + NEW_MCP_TOOL, NEW_EXTERNAL_REQUEST, NEW_DATABASE_FLOW, NEW_FILE_ACCESS, NEW_COMMAND_EXECUTION, - NEW_UPLOAD, - NEW_WEBHOOK, NEW_SECRET, NEW_IDENTITY, NEW_PERMISSION, NEW_PRIVILEGE, NEW_AGENT, NEW_AI_TOOL, - NEW_MCP_TOOL, - NEW_EXTERNAL_INTEGRATION, NEW_TRUST_BOUNDARY, + REMOVED_TRUST_BOUNDARY, REMOVED_SECURITY_CONTROL, WEAKENED_SECURITY_CONTROL, STRENGTHENED_SECURITY_CONTROL, + MOVED_SECURITY_CONTROL, NEW_SENSITIVE_DATA_FLOW, NEW_ATTACK_PATH, BLOCKED_ATTACK_PATH, + REMOVED_ATTACK_PATH, + REOPENED_ATTACK_PATH, + WEAKENED_ATTACK_PATH, NEW_DEPENDENCY, + REMOVED_DEPENDENCY, DEPENDENCY_RISK_CHANGE, CONFIGURATION_SECURITY_CHANGE, + PRIVILEGE_EXPANSION, + TENANT_BOUNDARY_WEAKENED, + AI_AGENT_PRIVILEGE_EXPANSION, + MCP_PRIVILEGE_EXPANSION, } ) +def is_baseline_unavailable(baseline: str | None) -> bool: + return str(baseline or "") in { + BASELINE_UNAVAILABLE, + BASELINE_UNKNOWN, + "", + } + + def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat() diff --git a/tests/test_security_diff.py b/tests/test_security_diff.py index 9827083..042b300 100644 --- a/tests/test_security_diff.py +++ b/tests/test_security_diff.py @@ -160,3 +160,35 @@ def test_mcp_compact_and_github_summary(): assert "new_attack_paths" in compact text = format_github_pr_summary(d) assert "AXGUARD SECURITY DIFF" in text + + +def test_cli_parses_diff_head_tilde(): + """Documented `axguard diff HEAD~1` must parse (no subparser collision).""" + from cli.main import build_parser + + parser = build_parser() + args = parser.parse_args(["diff", "HEAD~1", "--no-banner"]) + assert args.command == "diff" + assert args.tokens == ["HEAD~1"] + + +def test_cli_parses_diff_range_and_baseline_save(): + from cli.main import build_parser + + parser = build_parser() + args = parser.parse_args(["diff", "main...HEAD", "--json", "--no-banner"]) + assert args.tokens == ["main...HEAD"] + assert args.as_json is True + + args2 = parser.parse_args(["diff", "baseline", "save", ".", "--name", "ci"]) + assert args2.tokens == ["baseline", "save", "."] + assert args2.name == "ci" + + +def test_unavailable_baseline_sets_overall_unknown(tmp_path): + from engines.security_diff import security_diff + + result = security_diff(project=tmp_path, options={"use_snapshot": True}) + assert result["baseline"] in {"BASELINE_UNAVAILABLE", "UNKNOWN"} + assert result["overall_security_change"] in {"UNKNOWN", "BASELINE_UNAVAILABLE"} + assert (result.get("security_impact") or {}).get("level") == "UNKNOWN" From dc2a03c2d232eb686f96d0127471d767501ffa6a Mon Sep 17 00:00:00 2001 From: Md Shariar Shanaz Shuvon <83355567+shuvonsec@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:35:23 +0800 Subject: [PATCH 4/4] fix(security-diff): harden paths and API exception exposure --- engines/api/routes/__init__.py | 54 +++++++++++++---- engines/security_diff/paths.py | 98 +++++++++++++++++++++++++++++++ engines/security_diff/pipeline.py | 61 ++++++++++++------- engines/security_diff/state.py | 69 +++++++++++++++++++--- engines/security_diff/store.py | 37 +++++++++--- 5 files changed, 270 insertions(+), 49 deletions(-) create mode 100644 engines/security_diff/paths.py diff --git a/engines/api/routes/__init__.py b/engines/api/routes/__init__.py index e82a5df..6470131 100644 --- a/engines/api/routes/__init__.py +++ b/engines/api/routes/__init__.py @@ -522,19 +522,49 @@ async def security_diff(project_id: str, request: Request): head = body.get("head_path") or body.get("head") or project["path"] from engines.security_diff import security_diff as run_sd from engines.security_diff.github_summary import format_github_pr_summary + from engines.security_diff.paths import UnsafePathError + + try: + result = run_sd( + base=base, + head=head, + project=project["path"], + options={ + "range_spec": body.get("range"), + "baseline_name": body.get("baseline_name") or "default", + "use_snapshot": bool(body.get("use_snapshot")), + "incremental": body.get("incremental", True), + "fail_on": body.get("fail_on") or "none", + }, + ) + except UnsafePathError: + raise ApiError( + "VALIDATION_ERROR", + "base_path/head_path/project path is unsafe or invalid", + status_code=422, + ) + except Exception: # noqa: BLE001 + # Never return exception text / traceback to API clients + raise ApiError( + "ANALYSIS_FAILED", + "Security Diff analysis failed", + status_code=500, + ) + + # Strip any residual exception-like detail from soft-fail unknowns + cleaned_unknowns = [] + for item in result.get("unknowns") or []: + if not isinstance(item, dict): + continue + cleaned_unknowns.append( + { + "area": item.get("area"), + "reason": str(item.get("reason") or "unknown")[:120], + "status": item.get("status") or "UNKNOWN", + } + ) + result["unknowns"] = cleaned_unknowns - result = run_sd( - base=base, - head=head, - project=project["path"], - options={ - "range_spec": body.get("range"), - "baseline_name": body.get("baseline_name") or "default", - "use_snapshot": bool(body.get("use_snapshot")), - "incremental": body.get("incremental", True), - "fail_on": body.get("fail_on") or "none", - }, - ) # Backward-compatible finding-ish fields (empty unless consumers need them) result.setdefault("new_findings", []) result.setdefault("resolved_findings", []) diff --git a/engines/security_diff/paths.py b/engines/security_diff/paths.py new file mode 100644 index 0000000..a8dcc93 --- /dev/null +++ b/engines/security_diff/paths.py @@ -0,0 +1,98 @@ +"""Path guards for Security Diff — block traversal / null-byte / system escapes.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +_FORBIDDEN_PREFIXES = ( + "/etc", + "/proc", + "/sys", + "/dev", + "/var/run", +) + + +class UnsafePathError(ValueError): + """Raised when a path fails Security Diff safety checks.""" + + +def _normalize_raw(value: str | Path) -> str: + text = str(value) + if "\x00" in text: + raise UnsafePathError("null byte in path is not allowed") + return text.strip() + + +def _reject_system_paths(resolved: Path) -> None: + posix = resolved.as_posix() + for prefix in _FORBIDDEN_PREFIXES: + if posix == prefix or posix.startswith(prefix + "/"): + raise UnsafePathError(f"access to system path blocked: {prefix}") + + +def resolve_local_path( + value: str | Path | None, + *, + must_exist: bool = False, + expect_dir: bool = False, +) -> Path | None: + """Resolve a local path with basic injection defenses. + + Intended for CLI/API local analysis roots (not remote untrusted uploads). + Rejects null bytes and sensitive system prefixes. Returns ``None`` when + ``value`` is empty. + """ + if value is None: + return None + raw = _normalize_raw(value) + if not raw: + return None + candidate = Path(raw).expanduser() + # Realpath-style resolve so symlink escapes are visible to later checks. + resolved = Path(os.path.realpath(candidate)) if candidate.exists() else candidate.resolve() + _reject_system_paths(resolved) + if must_exist and not resolved.exists(): + raise UnsafePathError(f"path does not exist: {resolved}") + if expect_dir and resolved.exists() and not resolved.is_dir(): + raise UnsafePathError(f"path is not a directory: {resolved}") + return resolved + + +def resolve_under_root(root: Path | str, *parts: str) -> Path: + """Join ``parts`` under ``root`` and reject escapes outside the root.""" + base = resolve_local_path(root, expect_dir=False) + if base is None: + raise UnsafePathError("project root is required") + # Normalize each part — reject absolute segments / empty traversal + clean: list[str] = [] + for part in parts: + text = _normalize_raw(part) + if not text or text in {".", "./"}: + continue + p = Path(text) + if p.is_absolute() or ".." in p.parts: + raise UnsafePathError("path segment escapes project root") + clean.append(text) + candidate = base.joinpath(*clean).resolve() + try: + candidate.relative_to(base.resolve()) + except ValueError as exc: + raise UnsafePathError("path escapes project root") from exc + _reject_system_paths(candidate) + return candidate + + +def as_existing_path(value: Any) -> Path | None: + """Return a resolved path only when it exists (dir or file); else ``None``.""" + try: + resolved = resolve_local_path(value) + except UnsafePathError: + return None + if resolved is None: + return None + if resolved.exists(): + return resolved + return None diff --git a/engines/security_diff/pipeline.py b/engines/security_diff/pipeline.py index 016d1a9..a486236 100644 --- a/engines/security_diff/pipeline.py +++ b/engines/security_diff/pipeline.py @@ -12,6 +12,12 @@ resolve_comparison, ) from engines.security_diff.impact import classify_impact, should_fail +from engines.security_diff.paths import ( + UnsafePathError, + as_existing_path, + resolve_local_path, + resolve_under_root, +) from engines.security_diff.report import write_security_diff_report from engines.security_diff.schema import ( BASELINE_AXGUARD_SNAPSHOT, @@ -29,26 +35,14 @@ def _as_path(value: Any) -> Path | None: - if value is None: - return None - if isinstance(value, Path): - return value - text = str(value).strip() - if not text: - return None - p = Path(text) - if p.exists(): - return p.resolve() - return None + """Resolve a filesystem path when it exists; never follow unsafe inputs.""" + return as_existing_path(value) def _looks_like_git_ref(value: str) -> bool: if value in {".", "./"}: return False - p = Path(value) - if p.exists(): - return False - return True + return as_existing_path(value) is None def save_baseline_from_project( @@ -57,7 +51,11 @@ def save_baseline_from_project( *, options: dict[str, Any] | None = None, ) -> Path: - root = Path(project).resolve() + try: + root = resolve_local_path(project, must_exist=True, expect_dir=True) + except UnsafePathError as exc: + raise ValueError(str(exc)) from exc + assert root is not None state = build_security_state(root, options=options) return save_baseline(root, state, name=name) @@ -109,7 +107,14 @@ def security_diff( baseline_name, write_report, out_dir, skip_*, range_spec, ... """ opts = dict(options or {}) - project_path = _as_path(project) or Path(".").resolve() + try: + project_path = _as_path(project) or resolve_local_path( + Path("."), must_exist=True, expect_dir=True + ) + except UnsafePathError: + project_path = Path(".").resolve() + if project_path is None: + project_path = Path(".").resolve() if not project_path.is_dir(): project_path = project_path.parent @@ -388,11 +393,11 @@ def _finalize( for reg in result["memory_delta"]["regressions"]: if reg not in result["regressions"]: result["regressions"].append(reg) - except Exception as exc: # noqa: BLE001 + except Exception: # noqa: BLE001 result["unknowns"].append( { "area": "memory", - "reason": f"memory compare soft-failed: {exc}", + "reason": "memory compare soft-failed", "status": "UNKNOWN", } ) @@ -419,11 +424,11 @@ def _finalize( result.setdefault("evidence", []).append( {"kind": "investigation", "result": inv} ) - except Exception as exc: # noqa: BLE001 + except Exception: # noqa: BLE001 result["unknowns"].append( { "area": "investigation", - "reason": str(exc), + "reason": "investigation soft-failed", "status": "UNKNOWN", } ) @@ -439,7 +444,19 @@ def _finalize( result["summary"] = summary if opts.get("write_report"): - out_dir = Path(opts.get("out_dir") or (project_path / ".findings/axguard")) + raw_out = opts.get("out_dir") + if raw_out: + try: + out_dir = resolve_under_root(project_path, str(raw_out)) if not Path(str(raw_out)).is_absolute() else resolve_local_path(raw_out) + except UnsafePathError: + out_dir = project_path / ".findings" / "axguard" + if out_dir is None: + out_dir = project_path / ".findings" / "axguard" + else: + try: + out_dir = resolve_under_root(project_path, ".findings", "axguard") + except UnsafePathError: + out_dir = project_path / ".findings" / "axguard" write_security_diff_report(result, out_dir) return result diff --git a/engines/security_diff/state.py b/engines/security_diff/state.py index bb529ed..b1667e7 100644 --- a/engines/security_diff/state.py +++ b/engines/security_diff/state.py @@ -6,6 +6,17 @@ from pathlib import Path from typing import Any +from engines.security_diff.paths import ( + UnsafePathError, + resolve_local_path, + resolve_under_root, +) + + +def _safe_exc(exc: BaseException) -> str: + """Return a short exception class name — never a stack trace or message dump.""" + return type(exc).__name__ + def _load_json(path: Path) -> dict[str, Any] | None: if not path.is_file(): @@ -18,7 +29,7 @@ def _load_json(path: Path) -> dict[str, Any] | None: def _artifacts_dir(target: Path) -> Path: - return target / ".findings" / "axguard" + return resolve_under_root(target, ".findings", "axguard") def build_security_state( @@ -33,7 +44,34 @@ def build_security_state( Soft-imports existing engines. Never invents missing analysis. """ opts = dict(options or {}) - root = Path(target).resolve() + try: + root = resolve_local_path(target, must_exist=True, expect_dir=True) + except UnsafePathError: + root = None + if root is None: + # Fall back for freshly materialized temp trees mid-creation + try: + root = resolve_local_path(target, must_exist=False, expect_dir=False) + except UnsafePathError as exc: + return { + "target": str(target), + "application_model": None, + "dataflow": None, + "attack_graph": None, + "twin": None, + "predictive": None, + "changed_files": list(changed_files or []), + "incremental": bool(incremental), + "unknowns": [ + {"area": "target", "reason": f"unsafe path ({_safe_exc(exc)})"} + ], + "scope": { + "changed_files": list(changed_files or []), + "incremental": bool(incremental), + }, + } + assert root is not None + unknowns: list[dict[str, Any]] = [] state: dict[str, Any] = { "target": str(root), @@ -52,7 +90,10 @@ def build_security_state( } reuse = bool(opts.get("reuse_artifacts", False)) - art = _artifacts_dir(root) + try: + art = _artifacts_dir(root) + except UnsafePathError: + art = root / ".findings" / "axguard" app_model = None if reuse: @@ -64,7 +105,10 @@ def build_security_state( app_model = build_application_model(root) except Exception as exc: # noqa: BLE001 unknowns.append( - {"area": "application_model", "reason": f"build failed: {exc}"} + { + "area": "application_model", + "reason": f"build failed ({_safe_exc(exc)})", + } ) state["application_model"] = app_model @@ -77,7 +121,9 @@ def build_security_state( dataflow = analyze_dataflow(root, application_model=app_model) except Exception as exc: # noqa: BLE001 - unknowns.append({"area": "dataflow", "reason": f"analyze failed: {exc}"}) + unknowns.append( + {"area": "dataflow", "reason": f"analyze failed ({_safe_exc(exc)})"} + ) state["dataflow"] = dataflow attack_graph = None @@ -90,7 +136,7 @@ def build_security_state( attack_graph = run_attack_graph(root) except Exception as exc: # noqa: BLE001 unknowns.append( - {"area": "attack_graph", "reason": f"run failed: {exc}"} + {"area": "attack_graph", "reason": f"run failed ({_safe_exc(exc)})"} ) state["attack_graph"] = attack_graph @@ -107,7 +153,12 @@ def build_security_state( application_model=app_model, ) except Exception as exc: # noqa: BLE001 - unknowns.append({"area": "security_twin", "reason": f"build failed: {exc}"}) + unknowns.append( + { + "area": "security_twin", + "reason": f"build failed ({_safe_exc(exc)})", + } + ) state["twin"] = twin if not opts.get("skip_predict"): @@ -122,6 +173,8 @@ def build_security_state( with_memory=False, ) except Exception as exc: # noqa: BLE001 - unknowns.append({"area": "predictive", "reason": f"run failed: {exc}"}) + unknowns.append( + {"area": "predictive", "reason": f"run failed ({_safe_exc(exc)})"} + ) return state diff --git a/engines/security_diff/store.py b/engines/security_diff/store.py index b0676ee..25c8175 100644 --- a/engines/security_diff/store.py +++ b/engines/security_diff/store.py @@ -8,13 +8,21 @@ from pathlib import Path from typing import Any -DEFAULT_REL = Path(".findings/axguard/security_diff/baselines") +from engines.security_diff.paths import ( + UnsafePathError, + resolve_local_path, + resolve_under_root, +) + +DEFAULT_REL_PARTS = (".findings", "axguard", "security_diff", "baselines") _SAFE = re.compile(r"[^A-Za-z0-9._-]+") def resolve_baseline_dir(project_root: Path | str) -> Path: - root = Path(project_root).resolve() - return root / DEFAULT_REL + root = resolve_local_path(project_root) + if root is None: + raise UnsafePathError("project root is required") + return resolve_under_root(root, *DEFAULT_REL_PARTS) def _safe_name(name: str) -> str: @@ -43,8 +51,10 @@ def save_baseline( name: str = "default", ) -> Path: """Persist a security state snapshot for later non-git diffs.""" - root = Path(project_root).resolve() - out = resolve_baseline_dir(root) / f"{_safe_name(name)}.json" + root = resolve_local_path(project_root) + if root is None: + raise UnsafePathError("project root is required") + out = resolve_under_root(root, *DEFAULT_REL_PARTS, f"{_safe_name(name)}.json") payload = { "kind": "axguard_security_diff_baseline", "name": _safe_name(name), @@ -58,7 +68,12 @@ def load_baseline( project_root: Path | str, name: str = "default", ) -> dict[str, Any] | None: - path = resolve_baseline_dir(project_root) / f"{_safe_name(name)}.json" + try: + path = resolve_under_root( + project_root, *DEFAULT_REL_PARTS, f"{_safe_name(name)}.json" + ) + except UnsafePathError: + return None if not path.is_file(): return None try: @@ -69,11 +84,19 @@ def load_baseline( def list_baselines(project_root: Path | str) -> list[dict[str, Any]]: - base = resolve_baseline_dir(project_root) + try: + base = resolve_baseline_dir(project_root) + except UnsafePathError: + return [] if not base.is_dir(): return [] items: list[dict[str, Any]] = [] for path in sorted(base.glob("*.json")): + # Defense: only list files that remain under the baseline dir + try: + path.resolve().relative_to(base.resolve()) + except ValueError: + continue items.append( { "name": path.stem,