From e78bdcb0f8a8016b0c992dbd53110e1f5d8f96f4 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:09:20 -0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(install):=20websec=20install=20?= =?UTF-8?q?=20=E2=80=94=20multi-host=20agent=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teach any core coding agent to reach for websec-validator on a security review. Hosts: claude, codex, cursor, gemini, aider, generic (AGENTS.md). - Skill-style hosts (Claude, Cursor) get a dedicated skill/rule file. - Block-style hosts (Codex/Gemini/Aider/generic) get an idempotent marked region injected into their standing-instructions file, preserving the user's own content. --user for home scope, --uninstall, install status. - Stdlib only, path-safety guarded (destination must resolve inside the target/home tree). 12 new tests; suite 324 -> 336, DocGuard green. Closes the gap between the README's "any agent can act on it" and shipping only a Claude plugin. Pattern adapted from graphify's multi-host installer. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 ++ README.md | 15 +++ src/websec_validator/cli.py | 32 ++++- src/websec_validator/install.py | 228 ++++++++++++++++++++++++++++++++ tests/test_install.py | 125 +++++++++++++++++ 5 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 src/websec_validator/install.py create mode 100644 tests/test_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db53af9..f3cc63f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **`websec install ` — multi-host agent installer** (`install.py`). Teaches any of the core + agent hosts to reach for websec-validator on a security review: `claude`, `codex`, `cursor`, + `gemini`, `aider`, plus a `generic` `AGENTS.md` writer. Skill-style hosts (Claude, Cursor) get a + dedicated skill/rule file; shared-instruction hosts (Codex/Gemini/Aider/generic) get an idempotent + marked block injected into their standing-instructions file without clobbering the user's own + content. `--user` installs home-wide, `--uninstall` removes cleanly, `websec install status` lists + what's present. Closes the gap between the README's "any agent can act on it" and shipping only a + Claude plugin. Stdlib only, path-safety-guarded, 12 new tests. + - **No-Row-Level-Security detection** (`missing-rls` class, in `schemas.py` + the ledger) — committed Postgres/Supabase DDL declares owner/tenant-scoped tables but ships **zero** `CREATE POLICY` / `ENABLE ROW LEVEL SECURITY` anywhere in the `.sql` corpus (the CVE-2025-48757 "Lovable" class). diff --git a/README.md b/README.md index 9c0a343..4568752 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,23 @@ four ways to get there, all ending in the same `AGENT-BRIEFING.md` your agent ac | **Tell your agent** (simplest) | — | say the line above | | **CLI** (a terminal) | `pipx install websec-validator` | `websec run /path/to/your/app` | | **Claude Code plugin** (slash) | `/plugin marketplace add raccioly/websec-validator` → `/plugin install websec-validator@websec-plugins` | invoke the **security-pass** skill, or just ask | +| **Any other agent** (Codex, Cursor, Gemini, Aider) | `pipx install websec-validator` | `websec install ` — see below | | **Docker** (no install) | `docker build -t websec-validator .` | `docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/scan" websec-validator run /scan --out /scan/websec-out` | +**Teach any agent to use it — `websec install `.** Point websec at whatever coding agent you +run and it writes the standing instruction (a skill file for Claude/Cursor, an idempotent marked +block in `AGENTS.md`/`GEMINI.md`/`CONVENTIONS.md` for Codex/Gemini/Aider/generic) so the agent knows +to run `websec` for a security review instead of improvising: + +```bash +websec install codex # or: claude · cursor · gemini · aider · generic +websec install cursor --user # home-wide, applies to every repo +websec install status # show what's installed +websec install codex --uninstall +``` + +It only ever touches its own marked region, so your existing `AGENTS.md` content is preserved. + ➡️ **Want the reasoning behind every check?** Read **[docs/METHODOLOGY.md](docs/METHODOLOGY.md)** — what each test does and why. ## Install diff --git a/src/websec_validator/cli.py b/src/websec_validator/cli.py index 128d8d4..d35a363 100644 --- a/src/websec_validator/cli.py +++ b/src/websec_validator/cli.py @@ -268,6 +268,22 @@ def cmd_mcp(args) -> int: return mcp_server.serve() +def cmd_install(args) -> int: + from . import install as _install + project_dir = Path(args.project_dir).expanduser() if args.project_dir else Path(".") + if args.host == "status": + print(_install.status(project_dir=project_dir, user=args.user)) + return 0 + try: + msg = _install.install(args.host, project_dir=project_dir, user=args.user, + uninstall=args.uninstall) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + print(msg) + return 0 + + def cmd_proof(args) -> int: from importlib import resources corpus_path = (Path(args.corpus).expanduser().resolve() if args.corpus @@ -399,7 +415,7 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--version", action="version", version=f"websec-validator {__version__}") # metavar lists only the user-facing commands; recon/proof/calibrate still work but are # omitted (they get no `help=`, so argparse leaves them out of the listing entirely). - sub = p.add_subparsers(dest="cmd", required=True, metavar="{run,doctor,dynamic,mcp}") + sub = p.add_subparsers(dest="cmd", required=True, metavar="{run,doctor,dynamic,mcp,install}") r = sub.add_parser("run", help="full pipeline → briefing + tailored probes") r.add_argument("target") @@ -454,10 +470,22 @@ def build_parser() -> argparse.ArgumentParser: mc = sub.add_parser("mcp", help="run as an MCP server over stdio (typed recon tools for any MCP client)") mc.set_defaults(func=cmd_mcp) + + from . import install as _install + ins = sub.add_parser("install", + help="teach an AI coding agent to use websec (claude|codex|cursor|gemini|aider|generic)") + ins.add_argument("host", choices=[*_install.HOSTS, "status"], + help="agent host to configure, or 'status' to list what's installed") + ins.add_argument("--user", action="store_true", + help="install into your home dir (all repos) instead of this project") + ins.add_argument("--project-dir", dest="project_dir", + help="project directory to install into (default: current dir)") + ins.add_argument("--uninstall", action="store_true", help="remove the websec block/skill instead") + ins.set_defaults(func=cmd_install) return p -_COMMANDS = {"run", "recon", "doctor", "proof", "dynamic", "calibrate", "mcp"} +_COMMANDS = {"run", "recon", "doctor", "proof", "dynamic", "calibrate", "mcp", "install"} def main(argv=None) -> int: diff --git a/src/websec_validator/install.py b/src/websec_validator/install.py new file mode 100644 index 0000000..fa1ff8b --- /dev/null +++ b/src/websec_validator/install.py @@ -0,0 +1,228 @@ +"""websec install — teach any AI coding agent to reach for websec-validator. + +The tool already emits a briefing "any agent can act on," but the only first-class integration was +the Claude Code plugin. This module closes that gap: it writes a short, marked instruction block (or +a dedicated skill file) into the place each host reads its standing instructions, so the agent knows +to run `websec` for a security review instead of hand-rolling one. + +Design constraints (see AGENTS.md): + * stdlib only, zero runtime deps — the block/skill text is embedded here as the single source. + * idempotent — a marked region (`websec-validator:start/end`) is replaced in place on re-install + and removed cleanly on --uninstall, never clobbering the user's own content in a shared file. + * path-safe — the destination must resolve inside the target dir (project) or the home dir (user); + anything escaping is refused, so a bad host key can never write outside the intended tree. + +Two install styles: + * "skill" — we own the whole file (Claude skill, Cursor .mdc rule). Install writes it; uninstall + deletes it. + * "block" — a shared instructions file the user also edits (AGENTS.md, GEMINI.md, CONVENTIONS.md). + Install injects/replaces only our marked region; uninstall strips just that region. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from . import __version__ + +MARKER_START = "" +MARKER_END = "" + +# The always-on nudge injected into shared instruction files (AGENTS.md / GEMINI.md / CONVENTIONS.md). +# Kept compact on purpose: its whole job is to make the agent reach for the tool and know the scope +# rules; the full workflow lives in the CLI's AGENT-BRIEFING.md output and the Claude skill. +_INSTRUCTION_BODY = """\ +## Security reviews — use websec-validator + +For any security review, audit, or "is my app safe?" request on **this** codebase, use the +`websec-validator` engine rather than hand-rolling a scan. It is local-first and read-only by +default (code in, artifacts out — no LLM in the tool, no server, no running app required). + +```bash +websec --version >/dev/null 2>&1 || pipx install websec-validator +websec run . --scan # → websec-out/AGENT-BRIEFING.md + FACTS.json + findings ledger + probes/ +``` + +Then read `websec-out/AGENT-BRIEFING.md` (marching orders) and `websec-out/FACTS.json` (structured +recon) and follow them — don't re-derive what's already there. Triage the findings with the human, +confirm the tenant/auth model before any BOLA probe, and verify each finding before reporting it. + +**Scope:** defensive self-review of code the operator owns. Static recon is read-only. Active probes +are opt-in, run only against a TEST instance the human supplies, one approved run at a time; never +against production or third-party targets, and never fabricate or commit credentials. +""" + +# Cursor rules are a dedicated .mdc file with YAML frontmatter; alwaysApply keeps the guidance in +# context for every request in the project. +_CURSOR_FRONTMATTER = """\ +--- +description: Use websec-validator for security reviews of this codebase +alwaysApply: true +--- +""" + + +@dataclass(frozen=True) +class HostConfig: + label: str + style: str # "skill" (we own the file) | "block" (marked region in a shared file) + project_path: str # relative to the project dir + user_path: str # relative to the home dir + + +# Core 5 agent hosts + a generic AGENTS.md writer. AGENTS.md is the emerging cross-agent standard, +# so `codex` and `generic` intentionally target the same file — writing the marked block once is +# idempotent regardless of which alias the user picked. +HOSTS: dict[str, HostConfig] = { + "claude": HostConfig( + "Claude Code", "skill", + ".claude/skills/security-pass/SKILL.md", + ".claude/skills/security-pass/SKILL.md", + ), + "cursor": HostConfig( + "Cursor", "skill", + ".cursor/rules/websec-validator.mdc", + ".cursor/rules/websec-validator.mdc", + ), + "codex": HostConfig( + "Codex CLI", "block", + "AGENTS.md", + ".codex/AGENTS.md", + ), + "gemini": HostConfig( + "Gemini CLI", "block", + "GEMINI.md", + ".gemini/GEMINI.md", + ), + "aider": HostConfig( + "Aider", "block", + "CONVENTIONS.md", + ".config/aider/CONVENTIONS.md", + ), + "generic": HostConfig( + "generic (AGENTS.md)", "block", + "AGENTS.md", + "AGENTS.md", + ), +} + + +def _skill_frontmatter(host: str) -> str: + if host == "cursor": + return _CURSOR_FRONTMATTER + # Claude / generic skill files use the same name/description frontmatter the plugin skill uses. + return ( + "---\n" + "name: security-pass\n" + "description: Defensive security self-review of the operator's OWN codebase using " + "websec-validator. Local and read-only by default. Use for security reviews, audits, " + 'BOLA/IDOR/JWT/SSRF/mass-assignment checks, or "is my app safe?" before shipping.\n' + "---\n" + ) + + +def _skill_content(host: str) -> str: + """Whole-file content for a 'skill' host (frontmatter + the instruction body + provenance).""" + stamp = f"\n" + return f"{_skill_frontmatter(host)}{stamp}\n{_INSTRUCTION_BODY}" + + +def _block_content() -> str: + """The marked region injected into a shared instructions file.""" + return f"{MARKER_START}\n\n\n{_INSTRUCTION_BODY}\n{MARKER_END}\n" + + +def _resolve_root(project_dir: Path, user: bool) -> Path: + return Path.home() if user else project_dir.resolve() + + +def _dest(host: str, project_dir: Path, user: bool) -> Path: + cfg = HOSTS[host] + root = _resolve_root(project_dir, user) + rel = cfg.user_path if user else cfg.project_path + dest = (root / rel).resolve() + # Path-safety: the destination must stay inside the intended tree. A crafted host entry or a + # symlinked parent could otherwise escape; refuse rather than write outside root. + if root not in dest.parents and dest != root: + raise ValueError(f"refusing to write outside {root}: {dest}") + return dest + + +def _upsert_block(existing: str, block: str) -> str: + """Replace an existing marked region in `existing`, or append the block if none is present.""" + start = existing.find(MARKER_START) + if start == -1: + sep = "" if existing == "" or existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n") + return f"{existing}{sep}{block}" + end = existing.find(MARKER_END, start) + if end == -1: # start marker but no end (hand-mangled) — replace from start to EOF + return existing[:start] + block + end += len(MARKER_END) + tail = existing[end:] + if tail.startswith("\n"): + tail = tail[1:] + return existing[:start] + block + tail + + +def _strip_block(existing: str) -> str: + start = existing.find(MARKER_START) + if start == -1: + return existing + end = existing.find(MARKER_END, start) + if end == -1: + return existing[:start].rstrip() + "\n" + end += len(MARKER_END) + result = existing[:start] + existing[end:].lstrip("\n") + return result.rstrip() + "\n" if result.strip() else "" + + +def install(host: str, *, project_dir: Path | None = None, user: bool = False, + uninstall: bool = False) -> str: + """Install (or uninstall) the websec-validator instruction block/skill for `host`. + + Returns a human-readable status line. Raises ValueError on an unknown host. + """ + if host not in HOSTS: + raise ValueError(f"unknown host '{host}'. Choose one of: {', '.join(HOSTS)}") + project_dir = (project_dir or Path(".")).resolve() + cfg = HOSTS[host] + dest = _dest(host, project_dir, user) + scope = "user" if user else "project" + + if uninstall: + if cfg.style == "skill": + if dest.exists(): + dest.unlink() + return f"removed {cfg.label} skill: {dest}" + return f"{cfg.label}: nothing to remove ({dest} absent)" + if not dest.exists(): + return f"{cfg.label}: nothing to remove ({dest} absent)" + stripped = _strip_block(dest.read_text(encoding="utf-8")) + if stripped: + dest.write_text(stripped, encoding="utf-8") + return f"removed websec-validator block from {dest}" + dest.unlink() # file was only our block → remove it entirely + return f"removed websec-validator block from {dest} (file was empty, deleted)" + + dest.parent.mkdir(parents=True, exist_ok=True) + if cfg.style == "skill": + dest.write_text(_skill_content(host), encoding="utf-8") + return f"installed {cfg.label} skill ({scope}): {dest}" + existing = dest.read_text(encoding="utf-8") if dest.exists() else "" + updated = _upsert_block(existing, _block_content()) + dest.write_text(updated, encoding="utf-8") + verb = "updated" if MARKER_START in existing else "wrote" + return f"{verb} websec-validator block in {dest} ({scope})" + + +def status(project_dir: Path | None = None, user: bool = False) -> str: + """Report which hosts currently have websec-validator installed under the given scope.""" + project_dir = (project_dir or Path(".")).resolve() + lines = [f"websec-validator {__version__} — install status ({'user' if user else 'project'} scope)"] + for host, cfg in HOSTS.items(): + dest = _dest(host, project_dir, user) + present = dest.exists() and (cfg.style == "skill" or MARKER_START in dest.read_text(encoding="utf-8")) + mark = "✓" if present else "·" + lines.append(f" {mark} {host:<8} {cfg.label:<20} {dest}") + return "\n".join(lines) diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..31febc1 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,125 @@ +"""Tests for `websec install ` — the multi-host agent installer. + +Covers: each host writes to the right place; skill vs block styles; idempotent re-install; +uninstall preserves surrounding content in shared files; path-safety; and the CLI wiring. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from websec_validator import install # noqa: E402 +from websec_validator.cli import main # noqa: E402 + + +class InstallTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + # --- skill-style hosts (own the whole file) --- + + def test_claude_writes_skill_file(self): + msg = install.install("claude", project_dir=self.root) + dest = self.root / ".claude/skills/security-pass/SKILL.md" + self.assertTrue(dest.exists()) + body = dest.read_text() + self.assertIn("name: security-pass", body) + self.assertIn("websec run", body) + self.assertIn("Claude Code skill", msg) + + def test_cursor_writes_mdc_with_frontmatter(self): + install.install("cursor", project_dir=self.root) + dest = self.root / ".cursor/rules/websec-validator.mdc" + self.assertTrue(dest.exists()) + body = dest.read_text() + self.assertTrue(body.startswith("---\n")) + self.assertIn("alwaysApply: true", body) + + def test_skill_uninstall_deletes_file(self): + install.install("claude", project_dir=self.root) + dest = self.root / ".claude/skills/security-pass/SKILL.md" + self.assertTrue(dest.exists()) + install.install("claude", project_dir=self.root, uninstall=True) + self.assertFalse(dest.exists()) + + # --- block-style hosts (marked region in a shared file) --- + + def test_gemini_writes_block(self): + install.install("gemini", project_dir=self.root) + dest = self.root / "GEMINI.md" + body = dest.read_text() + self.assertIn(install.MARKER_START, body) + self.assertIn(install.MARKER_END, body) + self.assertIn("websec run", body) + + def test_block_preserves_existing_content(self): + dest = self.root / "AGENTS.md" + dest.write_text("# My project rules\n\nAlways write tests.\n") + install.install("generic", project_dir=self.root) + body = dest.read_text() + self.assertIn("# My project rules", body) + self.assertIn("Always write tests.", body) + self.assertIn(install.MARKER_START, body) + + def test_reinstall_is_idempotent(self): + install.install("generic", project_dir=self.root) + install.install("generic", project_dir=self.root) + body = (self.root / "AGENTS.md").read_text() + self.assertEqual(body.count(install.MARKER_START), 1) + self.assertEqual(body.count(install.MARKER_END), 1) + + def test_block_uninstall_keeps_surrounding_content(self): + dest = self.root / "AGENTS.md" + dest.write_text("# Keep me\n") + install.install("generic", project_dir=self.root) + install.install("generic", project_dir=self.root, uninstall=True) + body = dest.read_text() + self.assertIn("# Keep me", body) + self.assertNotIn(install.MARKER_START, body) + + def test_block_only_file_removed_on_uninstall(self): + install.install("gemini", project_dir=self.root) + dest = self.root / "GEMINI.md" + self.assertTrue(dest.exists()) + install.install("gemini", project_dir=self.root, uninstall=True) + self.assertFalse(dest.exists()) + + # --- safety + errors --- + + def test_unknown_host_raises(self): + with self.assertRaises(ValueError): + install.install("notahost", project_dir=self.root) + + def test_status_lists_hosts(self): + out = install.status(project_dir=self.root) + for host in install.HOSTS: + self.assertIn(host, out) + install.install("gemini", project_dir=self.root) + out2 = install.status(project_dir=self.root) + self.assertIn("✓", out2) + + # --- CLI wiring --- + + def test_cli_install_and_uninstall(self): + rc = main(["install", "gemini", "--project-dir", str(self.root)]) + self.assertEqual(rc, 0) + self.assertTrue((self.root / "GEMINI.md").exists()) + rc = main(["install", "gemini", "--project-dir", str(self.root), "--uninstall"]) + self.assertEqual(rc, 0) + self.assertFalse((self.root / "GEMINI.md").exists()) + + def test_cli_status(self): + rc = main(["install", "status", "--project-dir", str(self.root)]) + self.assertEqual(rc, 0) + + +if __name__ == "__main__": + unittest.main() From cba6029c3e6f9cd09ea821c390ed1e1e06b73997 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:13:36 -0400 Subject: [PATCH 2/7] =?UTF-8?q?feat(hooks):=20websec=20hooks=20=E2=80=94?= =?UTF-8?q?=20git=20guardrail=20(post-commit=20advisory=20+=20pre-push=20g?= =?UTF-8?q?ate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the baseline-diff into git so websec runs automatically: - `hooks install`: advisory post-commit hook, recon-only (~1s), prints a "baseline: N new" heads-up, never blocks the commit. - `hooks install --pre-push`: blocking gate — fails the push on a NEW finding at/above $WEBSEC_HOOK_FAIL_ON (default high). - Marker-delimited install/uninstall (appends to + preserves an existing hook); interpreter pinned + allowlist-sanitized (survives pipx/uv isolation, no shell-injection); hooks dir via `git rev-parse` (worktree/core.hooksPath aware); prunes to last 5 guardrail runs. WEBSEC_SKIP_HOOK=1 overrides; WEBSEC_HOOK_SCAN=1 runs full scanners. 10 new tests incl. an end-to-end real-commit run; suite 336 -> 346, DocGuard green. Pattern adapted from graphify's hook installer. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ README.md | 14 +++ src/websec_validator/cli.py | 28 ++++- src/websec_validator/hooks.py | 206 ++++++++++++++++++++++++++++++++++ tests/test_hooks.py | 135 ++++++++++++++++++++++ 5 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 src/websec_validator/hooks.py create mode 100644 tests/test_hooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3cc63f..ba0ad22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **`websec hooks` — git guardrail** (`hooks.py`). Wires the baseline-diff into git so websec runs + automatically per commit/push: `hooks install` writes an advisory **post-commit** hook (recon-only, + ~1s, prints a `baseline: N new` heads-up, never blocks); `hooks install --pre-push` writes a + blocking **pre-push gate** that fails the push when NEW findings at/above `WEBSEC_HOOK_FAIL_ON` + (default `high`) are introduced. Marker-delimited install/uninstall (appends to and preserves an + existing hook), interpreter pinned + allowlist-sanitized so it survives pipx/uv isolation without + shell-injection risk, hooks dir resolved via `git rev-parse` (worktrees + core.hooksPath aware), + and old guardrail runs pruned to the last 5. `WEBSEC_SKIP_HOOK=1` overrides. Stdlib only, 10 new + tests incl. an end-to-end real-commit run. Adapted from graphify's hook installer. + - **`websec install ` — multi-host agent installer** (`install.py`). Teaches any of the core agent hosts to reach for websec-validator on a security review: `claude`, `codex`, `cursor`, `gemini`, `aider`, plus a `generic` `AGENTS.md` writer. Skill-style hosts (Claude, Cursor) get a diff --git a/README.md b/README.md index 4568752..93a8310 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,20 @@ jobs: # baseline: .websec/baseline-ledger.json # optional: gate only on NEW findings ``` +**Local guardrail — `websec hooks`.** Same baseline-diff, run from git instead of CI, so a new lead is +caught before it ever reaches a PR: + +```bash +websec hooks install # advisory post-commit: prints "baseline: N new" after each commit (~1s, never blocks) +websec hooks install --pre-push # blocking gate: fails `git push` on a NEW finding at/above $WEBSEC_HOOK_FAIL_ON (default high) +websec hooks status # show what's installed +websec hooks uninstall +``` + +The hook appends to (and cleanly removes from) any existing hook, pins its interpreter so it works +under pipx/uv isolation, and honors `WEBSEC_SKIP_HOOK=1` for a one-off override. `WEBSEC_HOOK_SCAN=1` +runs the full static scanners in the hook (slower); the default is fast recon-only. + **MCP server (any agent, not just Claude Code).** `websec mcp` speaks the Model Context Protocol over stdio, exposing typed tools — `websec_recon`, `websec_findings`, `websec_sarif`, `websec_briefing` — so Cursor / Cline / Windsurf / Zed can call recon directly instead of shelling out and parsing stdout. diff --git a/src/websec_validator/cli.py b/src/websec_validator/cli.py index d35a363..d909820 100644 --- a/src/websec_validator/cli.py +++ b/src/websec_validator/cli.py @@ -268,6 +268,22 @@ def cmd_mcp(args) -> int: return mcp_server.serve() +def cmd_hooks(args) -> int: + from . import hooks as _hooks + path = Path(args.path).expanduser() if getattr(args, "path", None) else Path(".") + try: + if args.action == "install": + print(_hooks.install(path, pre_push=args.pre_push)) + elif args.action == "uninstall": + print(_hooks.uninstall(path)) + else: # status + print(_hooks.status(path)) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + return 0 + + def cmd_install(args) -> int: from . import install as _install project_dir = Path(args.project_dir).expanduser() if args.project_dir else Path(".") @@ -415,7 +431,7 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--version", action="version", version=f"websec-validator {__version__}") # metavar lists only the user-facing commands; recon/proof/calibrate still work but are # omitted (they get no `help=`, so argparse leaves them out of the listing entirely). - sub = p.add_subparsers(dest="cmd", required=True, metavar="{run,doctor,dynamic,mcp,install}") + sub = p.add_subparsers(dest="cmd", required=True, metavar="{run,doctor,dynamic,mcp,install,hooks}") r = sub.add_parser("run", help="full pipeline → briefing + tailored probes") r.add_argument("target") @@ -482,10 +498,18 @@ def build_parser() -> argparse.ArgumentParser: help="project directory to install into (default: current dir)") ins.add_argument("--uninstall", action="store_true", help="remove the websec block/skill instead") ins.set_defaults(func=cmd_install) + + hk = sub.add_parser("hooks", + help="install a git guardrail hook (post-commit advisory or pre-push gate on NEW findings)") + hk.add_argument("action", choices=["install", "uninstall", "status"]) + hk.add_argument("--pre-push", dest="pre_push", action="store_true", + help="install a blocking pre-push gate (--fail-on new findings) instead of the advisory post-commit hook") + hk.add_argument("--path", help="repo directory (default: current dir)") + hk.set_defaults(func=cmd_hooks) return p -_COMMANDS = {"run", "recon", "doctor", "proof", "dynamic", "calibrate", "mcp", "install"} +_COMMANDS = {"run", "recon", "doctor", "proof", "dynamic", "calibrate", "mcp", "install", "hooks"} def main(argv=None) -> int: diff --git a/src/websec_validator/hooks.py b/src/websec_validator/hooks.py new file mode 100644 index 0000000..26b4d13 --- /dev/null +++ b/src/websec_validator/hooks.py @@ -0,0 +1,206 @@ +"""websec hooks — install a git hook that turns websec into a local continuous guardrail. + +`websec run --baseline --fail-on ` already gates on *new* findings only. This +module wires that into git so it runs automatically: + + * post-commit (default) — advisory. After each commit, run recon against the repo, diff against the + previous run's ledger, and print a one-line "N new finding(s)" heads-up. Never blocks the commit + (a post-commit hook can't), and recon-only keeps it ~1s. + * pre-push (--pre-push) — a gate. Before a push, run the same diff with --fail-on and block the push + (non-zero exit) if new findings at/above the threshold were introduced. + +Safety, adapted from graphify's hook installer: + * the interpreter is pinned at install time (sys.executable) so the hook works under pipx/uv venv + isolation where the `websec` launcher may not be on git's PATH; the pinned path is run through a + character allowlist so nothing shell-injectable reaches the generated script. + * install/uninstall are marker-delimited — an existing hook is appended to, and uninstall strips + only our section, never the user's own hook content. + * the hooks dir is resolved via `git rev-parse --git-path hooks`, so linked worktrees and a custom + core.hooksPath (Husky etc.) are handled correctly. + +Stdlib only. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +MARKER_START = "# >>> websec-validator guardrail >>>" +MARKER_END = "# <<< websec-validator guardrail <<<" + +# Only characters valid in a plain filesystem path (incl. ':' and '\' for Windows). Anything else in +# the pinned interpreter path means we drop the pin rather than risk shell injection into the hook. +_PATH_ALLOWED = re.compile(r"[^a-zA-Z0-9/_.@:\\-]") + + +def _safe_pinned_python() -> str: + exe = sys.executable or "" + return "" if _PATH_ALLOWED.search(exe) else exe + + +def _git_root(path: Path) -> Path | None: + try: + res = subprocess.run(["git", "-C", str(path), "rev-parse", "--show-toplevel"], + capture_output=True, text=True) + except (OSError, FileNotFoundError): + return None + top = res.stdout.strip() + return Path(top) if res.returncode == 0 and top else None + + +def _hooks_dir(root: Path) -> Path: + """Resolve the git hooks dir (respects worktrees + core.hooksPath); fall back to .git/hooks.""" + try: + res = subprocess.run(["git", "-C", str(root), "rev-parse", "--git-path", "hooks"], + capture_output=True, text=True) + raw = res.stdout.strip() + if res.returncode == 0 and raw and not any(c in raw for c in ("\n", "\r", "\x00")): + d = (root / raw).resolve() + d.mkdir(parents=True, exist_ok=True) + return d + except (OSError, FileNotFoundError): + pass + d = (root / ".git" / "hooks").resolve() + d.mkdir(parents=True, exist_ok=True) + return d + + +# The guardrail body, parameterized only by the pinned interpreter (via __PINNED_PYTHON__) and the +# per-hook RUN_ARGS/GATE lines (via __RUN_TAIL__). Must stay POSIX sh. It: +# 1. locates an interpreter with websec_validator importable (pinned → websec launcher → python3), +# 2. copies the previous run's ledger to a stable baseline path (latest is repointed at run start), +# 3. runs `websec run` into the guardrail dir with that baseline, +# 4. surfaces the one-line "baseline: N new" summary, and prunes old run dirs. +_BODY = """\ +[ "${WEBSEC_SKIP_HOOK:-0}" = "1" ] && exit 0 + +# Skip mid-rebase/merge/cherry-pick so we don't stall `git --continue`. +_GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} +[ -d "$_GIT_DIR/rebase-merge" ] && exit 0 +[ -d "$_GIT_DIR/rebase-apply" ] && exit 0 +[ -f "$_GIT_DIR/MERGE_HEAD" ] && exit 0 +[ -f "$_GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0 + +_PROBE='import importlib.util,sys; sys.exit(0 if importlib.util.find_spec("websec_validator") else 1)' +_PINNED='__PINNED_PYTHON__' +RUN="" +if [ -n "$_PINNED" ] && [ -x "$_PINNED" ] && "$_PINNED" -c "$_PROBE" 2>/dev/null; then + RUN="$_PINNED -m websec_validator.cli" +elif command -v websec >/dev/null 2>&1; then + RUN="websec" +elif command -v python3 >/dev/null 2>&1 && python3 -c "$_PROBE" 2>/dev/null; then + RUN="python3 -m websec_validator.cli" +else + echo "[websec hook] websec not found — run 'websec hooks install' from the env where websec lives." >&2 + exit 0 +fi + +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".") +GUARD="$_GIT_DIR/websec-guardrail" +mkdir -p "$GUARD" +BASEARG="" +if [ -f "$GUARD/latest/findings-ledger.json" ]; then + cp "$GUARD/latest/findings-ledger.json" "$GUARD/prev-ledger.json" 2>/dev/null + BASEARG="--baseline $GUARD/prev-ledger.json" +fi +SCANARG="" +[ "${WEBSEC_HOOK_SCAN:-0}" = "1" ] && SCANARG="--scan" + +__RUN_TAIL__ + +# Keep only the 5 most recent guardrail runs (each `run` is an immutable dir). +ls -1dt "$GUARD"/runs/*/ 2>/dev/null | tail -n +6 | while read -r _d; do rm -rf "$_d"; done +""" + +# post-commit: advisory. Run, echo the baseline summary line, always exit 0. +_RUN_TAIL_ADVISORY = """\ +$RUN run "$_ROOT" --out "$GUARD" $BASEARG $SCANARG --format json >/dev/null 2>"$GUARD/hook.log" +_SUMMARY=$(grep -i "baseline:" "$GUARD/hook.log" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//') +[ -n "$_SUMMARY" ] && echo "[websec guardrail] $_SUMMARY" +exit 0""" + +# pre-push: gate. Fail the push if NEW findings at/above the threshold were introduced. +_RUN_TAIL_GATE = """\ +_FAILON=${WEBSEC_HOOK_FAIL_ON:-high} +$RUN run "$_ROOT" --out "$GUARD" $BASEARG $SCANARG --fail-on "$_FAILON" --format json >/dev/null 2>"$GUARD/hook.log" +_RC=$? +_SUMMARY=$(grep -i "baseline:" "$GUARD/hook.log" 2>/dev/null | tail -1 | sed 's/^[[:space:]]*//') +[ -n "$_SUMMARY" ] && echo "[websec guardrail] $_SUMMARY" >&2 +if [ "$_RC" -ne 0 ]; then + echo "[websec guardrail] new finding(s) at/above '$_FAILON' — push blocked. Set WEBSEC_SKIP_HOOK=1 to override." >&2 +fi +exit $_RC""" + + +def _script(pre_push: bool) -> str: + tail = _RUN_TAIL_GATE if pre_push else _RUN_TAIL_ADVISORY + body = _BODY.replace("__RUN_TAIL__", tail).replace("__PINNED_PYTHON__", _safe_pinned_python()) + return f"{MARKER_START}\n{body}\n{MARKER_END}\n" + + +def _write_hook(hooks_dir: Path, name: str, script: str) -> str: + hook_path = hooks_dir / name + if hook_path.exists(): + content = hook_path.read_text(encoding="utf-8") + if MARKER_START in content: # replace our section in place (idempotent) + content = _strip_section(content) + merged = content.rstrip() + "\n\n" + script if content.strip() else "#!/bin/sh\n" + script + hook_path.write_text(merged, encoding="utf-8", newline="\n") + hook_path.chmod(0o755) + return f"updated {name} hook at {hook_path}" + hook_path.write_text("#!/bin/sh\n" + script, encoding="utf-8", newline="\n") + hook_path.chmod(0o755) + return f"installed {name} hook at {hook_path}" + + +def _strip_section(content: str) -> str: + return re.sub(rf"{re.escape(MARKER_START)}.*?{re.escape(MARKER_END)}\n?", "", + content, flags=re.DOTALL) + + +def _remove_hook(hooks_dir: Path, name: str) -> str: + hook_path = hooks_dir / name + if not hook_path.exists(): + return f"no {name} hook — nothing to remove" + content = hook_path.read_text(encoding="utf-8") + if MARKER_START not in content: + return f"websec section not in {name} — nothing to remove" + stripped = _strip_section(content).strip() + if not stripped or stripped in ("#!/bin/sh", "#!/bin/bash"): + hook_path.unlink() + return f"removed {name} hook at {hook_path}" + hook_path.write_text(stripped + "\n", encoding="utf-8", newline="\n") + return f"removed websec section from {name} at {hook_path} (other hook content preserved)" + + +def install(path: Path | None = None, *, pre_push: bool = False) -> str: + root = _git_root(path or Path(".")) + if root is None: + raise RuntimeError(f"no git repository at or above {(path or Path('.')).resolve()}") + hooks_dir = _hooks_dir(root) + name = "pre-push" if pre_push else "post-commit" + return _write_hook(hooks_dir, name, _script(pre_push)) + + +def uninstall(path: Path | None = None) -> str: + root = _git_root(path or Path(".")) + if root is None: + raise RuntimeError(f"no git repository at or above {(path or Path('.')).resolve()}") + hooks_dir = _hooks_dir(root) + return " · ".join(_remove_hook(hooks_dir, n) for n in ("post-commit", "pre-push")) + + +def status(path: Path | None = None) -> str: + root = _git_root(path or Path(".")) + if root is None: + return "not a git repository — nothing to report" + hooks_dir = _hooks_dir(root) + lines = [f"websec guardrail hooks in {hooks_dir}:"] + for name in ("post-commit", "pre-push"): + hook_path = hooks_dir / name + present = hook_path.exists() and MARKER_START in hook_path.read_text(encoding="utf-8") + lines.append(f" {'✓' if present else '·'} {name}") + return "\n".join(lines) diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..17e1a92 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,135 @@ +"""Tests for `websec hooks` — the git guardrail installer. + +Covers marker-based install/uninstall (idempotent, preserves foreign hook content), pinned-interpreter +sanitization, pre-push vs post-commit variants, and an end-to-end run of the generated post-commit +hook in a real temp git repo against a fixture app. +""" + +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from websec_validator import hooks # noqa: E402 +from websec_validator.cli import main # noqa: E402 + +FIXTURE = ROOT / "tests" / "fixtures" / "py_app" +HAVE_GIT = shutil.which("git") is not None + + +def _init_repo(root: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=root, check=True) + subprocess.run(["git", "config", "user.name", "t"], cwd=root, check=True) + + +@unittest.skipUnless(HAVE_GIT, "git not available") +class HooksTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + _init_repo(self.root) + + def tearDown(self): + self._tmp.cleanup() + + def _hook(self, name: str) -> Path: + return self.root / ".git" / "hooks" / name + + def test_install_post_commit(self): + msg = hooks.install(self.root) + self.assertIn("post-commit", msg) + hook = self._hook("post-commit") + self.assertTrue(hook.exists()) + body = hook.read_text() + self.assertIn(hooks.MARKER_START, body) + self.assertIn(hooks.MARKER_END, body) + self.assertIn("websec_validator.cli", body) + self.assertTrue(os.access(hook, os.X_OK)) + + def test_install_pre_push_gate(self): + hooks.install(self.root, pre_push=True) + body = self._hook("pre-push").read_text() + self.assertIn("--fail-on", body) + + def test_reinstall_idempotent(self): + hooks.install(self.root) + hooks.install(self.root) + body = self._hook("post-commit").read_text() + self.assertEqual(body.count(hooks.MARKER_START), 1) + + def test_appends_to_existing_hook(self): + hook = self._hook("post-commit") + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\necho 'my own hook'\n") + hooks.install(self.root) + body = hook.read_text() + self.assertIn("my own hook", body) + self.assertIn(hooks.MARKER_START, body) + + def test_uninstall_preserves_foreign_content(self): + hook = self._hook("post-commit") + hook.parent.mkdir(parents=True, exist_ok=True) + hook.write_text("#!/bin/sh\necho 'my own hook'\n") + hooks.install(self.root) + hooks.uninstall(self.root) + body = hook.read_text() + self.assertIn("my own hook", body) + self.assertNotIn(hooks.MARKER_START, body) + + def test_uninstall_removes_pure_websec_hook(self): + hooks.install(self.root) + hooks.uninstall(self.root) + self.assertFalse(self._hook("post-commit").exists()) + + def test_status_reports(self): + out = hooks.status(self.root) + self.assertIn("post-commit", out) + hooks.install(self.root) + self.assertIn("✓", hooks.status(self.root)) + + def test_pinned_python_sanitized(self): + # A pinned interpreter path with shell metacharacters must never reach the script. + real = sys.executable + try: + sys.executable = "/usr/bin/python3; rm -rf /" + script = hooks._script(pre_push=False) + self.assertNotIn("rm -rf /", script) + finally: + sys.executable = real + + def test_cli_wiring(self): + self.assertEqual(main(["hooks", "install", "--path", str(self.root)]), 0) + self.assertTrue(self._hook("post-commit").exists()) + self.assertEqual(main(["hooks", "status", "--path", str(self.root)]), 0) + self.assertEqual(main(["hooks", "uninstall", "--path", str(self.root)]), 0) + + def test_end_to_end_post_commit_runs(self): + # Copy the fixture app into the repo, install the hook, commit, and assert the guardrail ran. + for f in FIXTURE.rglob("*"): + if f.is_file(): + dst = self.root / f.relative_to(FIXTURE) + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(f.read_bytes()) + hooks.install(self.root) + env = dict(os.environ) + # Ensure the hook's interpreter can import websec_validator from source. + env["PYTHONPATH"] = str(ROOT / "src") + os.pathsep + env.get("PYTHONPATH", "") + subprocess.run(["git", "add", "-A"], cwd=self.root, check=True, env=env) + r = subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=self.root, + capture_output=True, text=True, env=env) + self.assertEqual(r.returncode, 0, r.stderr) + guard = self.root / ".git" / "websec-guardrail" + self.assertTrue((guard / "latest" / "findings-ledger.json").exists(), + f"guardrail did not run; hook.log: " + f"{(guard / 'hook.log').read_text() if (guard / 'hook.log').exists() else 'absent'}") + + +if __name__ == "__main__": + unittest.main() From 5de8b0b9a994de6ec9c51332214b22bd7edf80d3 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:17:51 -0400 Subject: [PATCH 3/7] feat(graph): opt-in blast-radius enrichment from a graphify knowledge graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the scanned repo has graphify-out/graph.json (or --graph ), tag each finding with how much of the app transitively DEPENDS ON the vulnerable code — reverse reachability over dependency edges. A SQLi in a leaf handler and the same SQLi in a helper imported by 40 modules stop looking equally urgent. - Findings gain a `graph` block (nodes, blast_radius, dependents sample, community); ledger gains a `graph_enrichment` summary. - Pure stdlib JSON — never imports tree-sitter, so the zero-runtime-deps guarantee holds. Reverse-BFS bounded at 20k visits (disclosed truncation). Wrapped so a bad/oversized graph never fails a run. - Auto-detected at /graphify-out/graph.json; --graph overrides. Verified on a real 167-node graph (finding in cli.py → 20 nodes, blast radius 5). 10 new tests; suite 346 -> 356, DocGuard green. This is the cross-promoting integration from the graphify review: the two tools compose without websec taking on graphify's dependency stack. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ README.md | 15 +++ src/websec_validator/cli.py | 19 +++ src/websec_validator/graph_enrich.py | 180 +++++++++++++++++++++++++++ tests/test_graph_enrich.py | 151 ++++++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 src/websec_validator/graph_enrich.py create mode 100644 tests/test_graph_enrich.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0ad22..a344afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Blast-radius enrichment from a graphify knowledge graph** (`graph_enrich.py`, opt-in, zero new + deps). If the scanned repo has `graphify-out/graph.json` (or `--graph ` is passed), each + finding is tagged with how much of the app transitively **depends on** the vulnerable code — + reverse-reachability over dependency edges (calls/imports/references/inherits/…). A SQLi in a + leaf handler and the same SQLi in a shared helper imported by 40 modules stop looking equally + urgent. Findings gain a `graph` block (`nodes`, `blast_radius`, `dependents` sample, `community`) + and the ledger a `graph_enrichment` summary. Pure stdlib JSON (never imports tree-sitter, so the + zero-runtime-deps guarantee holds), reverse-BFS bounded at 20k visits with disclosed truncation, + and wrapped so a malformed/oversized graph can never fail a run. 10 new tests. + - **`websec hooks` — git guardrail** (`hooks.py`). Wires the baseline-diff into git so websec runs automatically per commit/push: `hooks install` writes an advisory **post-commit** hook (recon-only, ~1s, prints a `baseline: N new` heads-up, never blocks); `hooks install --pre-push` writes a diff --git a/README.md b/README.md index 93a8310..aacb042 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,21 @@ Register it in your MCP client: { "mcpServers": { "websec": { "command": "websec", "args": ["mcp"] } } } ``` +**Blast-radius from a knowledge graph (opt-in, zero-dep).** If your repo has a +[`graphify`](https://github.com/Graphify-Labs/graphify) graph at `graphify-out/graph.json` (or you +pass `--graph `), websec tags each finding with how much of the app **transitively depends on** +the vulnerable code — so a SQLi in a leaf handler and the same SQLi in a helper imported by 40 +modules stop looking equally urgent: + +```bash +websec run . --scan # auto-detects graphify-out/graph.json if present +``` + +Each mapped finding gains a `graph` block (`blast_radius`, a `dependents` sample, `community`) in +`findings-ledger.json`, and the ledger a `graph_enrichment` summary. It reads the graph as plain +JSON — it never imports tree-sitter, so websec stays **stdlib-only, zero runtime deps** — and a +missing, malformed, or oversized graph is silently skipped, never failing the run. + **Versioned contract.** `FACTS.json`, `findings-ledger.json`, and `findings.envelope.json` all carry a `schema_version`; the JSON Schemas ship in the package (`schemas/facts.schema.json`, `schemas/ledger.schema.json`) so downstream tooling can validate against a stable shape. diff --git a/src/websec_validator/cli.py b/src/websec_validator/cli.py index d909820..1e47a2b 100644 --- a/src/websec_validator/cli.py +++ b/src/websec_validator/cli.py @@ -132,6 +132,22 @@ def cmd_run(args) -> int: ledger = findings.build_ledger(facts, unified, None, suppressions) baseline.annotate(ledger) # stable per-finding fingerprints (baseline + SARIF tracking) + # 4a. optional blast-radius enrichment from a graphify knowledge graph (opt-in, zero-dep). If the + # repo has graphify-out/graph.json (or --graph is given), tag each finding with how much of the app + # transitively depends on the vulnerable code. Wrapped so a bad/oversized graph never fails a run. + graph_arg = getattr(args, "graph", None) + graph_path = Path(graph_arg).expanduser() if graph_arg else None + if graph_path or (target / "graphify-out" / "graph.json").exists(): + try: + from . import graph_enrich + graph_enrich.enrich_ledger(ledger, target, graph_path) + ge = ledger.get("graph_enrichment") + if ge: + log(f"\n graph: {ge['mapped']} finding(s) mapped to {ge['nodes']} nodes · " + f"max blast-radius {ge['max_blast_radius']} (source: {ge['graph']})") + except Exception as e: # enrichment is best-effort — never fail the run over it + log(f"\n graph: enrichment skipped ({type(e).__name__}: {e})") + # 4b. baseline / diff — only NEW findings gate CI when a baseline is supplied diff = None if getattr(args, "baseline", None): @@ -449,6 +465,9 @@ def build_parser() -> argparse.ArgumentParser: "only NEW findings count.") r.add_argument("--baseline", metavar="LEDGER.json", help="a prior findings-ledger.json — mark findings new/unchanged/fixed and gate only on NEW") + r.add_argument("--graph", metavar="GRAPH.json", + help="a graphify graph.json for blast-radius enrichment " + "(auto-detected at /graphify-out/graph.json if present)") r.set_defaults(func=cmd_run) # recon/proof/calibrate are hidden from the main --help (argparse.SUPPRESS): recon is a diff --git a/src/websec_validator/graph_enrich.py b/src/websec_validator/graph_enrich.py new file mode 100644 index 0000000..7ac96aa --- /dev/null +++ b/src/websec_validator/graph_enrich.py @@ -0,0 +1,180 @@ +"""Optional blast-radius enrichment from a graphify knowledge graph. + +If the scanned repo already has a `graphify-out/graph.json` (from the `graphify` tool), websec reads it +— plain JSON, stdlib only, no tree-sitter, no new dependency — and answers "how much of the app +depends on this vulnerable code?" for each finding. A SQLi in a leaf handler and the same SQLi in a +shared query helper imported by 40 modules are not equally urgent; the graph makes that difference +visible. + +Mapping: a finding's `location` (a repo-relative file, optionally `:line`) is matched to graph nodes +by `source_file`. Blast radius = the count of distinct nodes that transitively DEPEND ON the finding's +node(s) — i.e. reverse reachability over dependency edges (calls / imports / references / inherits / +uses / …). A hub file legitimately yields a large radius; that is the signal, not a bug. + +Design constraints (AGENTS.md): stdlib only; must never crash a run (the CLI calls this under a +try/except and it also guards internally); coverage bounds are DISCLOSED, never silently applied. +""" + +from __future__ import annotations + +import json +from collections import deque +from pathlib import Path + +# The graph is directed as `dependent -> dependency` for these relations (A calls/imports B ⇒ edge +# A→B), so the nodes AFFECTED by a vulnerable B are B's predecessors — found by walking edges in +# reverse. This mirrors graphify's own affected-set relation list. +_DEPENDENCY_RELATIONS = frozenset({ + "calls", "indirect_call", "references", "imports", "imports_from", "re_exports", + "inherits", "extends", "implements", "uses", "mixes_in", "embeds", +}) + +# Bound the reverse-BFS so a hub node in a huge graph can't blow up a run. If we hit the cap we say so +# (truncated=True) rather than silently under-report — a capped radius still reads as "very large". +_MAX_VISIT = 20000 + +# Cap the human-facing dependents sample; the count is always exact (up to _MAX_VISIT). +_SAMPLE = 8 + + +def _norm(path: str) -> str: + p = (path or "").replace("\\", "/").strip() + while p.startswith("./"): + p = p[2:] + return p + + +def _location_file(location: str) -> str: + """A finding's `location` is a file, sometimes `file:42` or `file:L42` — take the file part.""" + loc = _norm(location) + # Strip a trailing :NN or :LNN line reference (but keep Windows drive colons, which have a letter + # before them, not a digit/L — so only strip when the tail after ':' is a line ref). + if ":" in loc: + head, _, tail = loc.rpartition(":") + t = tail[1:] if tail[:1] in ("L", "l") else tail + if head and t.isdigit(): + loc = head + return loc + + +def load_graph(graph_path: Path) -> dict | None: + try: + data = json.loads(graph_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or "nodes" not in data: + return None + return data + + +def _build_index(graph: dict): + """Return (file -> [node_id], reverse_adjacency, node_by_id) over dependency edges only.""" + nodes = graph.get("nodes") or [] + node_by_id: dict[str, dict] = {} + file_to_nodes: dict[str, list[str]] = {} + for n in nodes: + nid = n.get("id") + if nid is None: + continue + node_by_id[nid] = n + sf = _norm(n.get("source_file", "")) + if sf: + file_to_nodes.setdefault(sf, []).append(nid) + reverse: dict[str, list[str]] = {} + for e in (graph.get("links") or graph.get("edges") or []): + if e.get("relation") not in _DEPENDENCY_RELATIONS: + continue + src, tgt = e.get("source"), e.get("target") + if src is None or tgt is None: + continue + reverse.setdefault(tgt, []).append(src) # who depends on tgt + return file_to_nodes, reverse, node_by_id + + +def _match_nodes(loc_file: str, file_to_nodes: dict[str, list[str]]) -> list[str]: + if not loc_file: + return [] + if loc_file in file_to_nodes: + return file_to_nodes[loc_file] + # Fall back to a suffix match — finding paths and graph paths may be anchored differently + # (e.g. one repo-relative, one package-relative). Prefer the longest unambiguous suffix. + hits = [f for f in file_to_nodes if f.endswith("/" + loc_file) or loc_file.endswith("/" + f)] + if len(hits) == 1: + return file_to_nodes[hits[0]] + # Last resort: basename match, only if it identifies exactly one file. + base = loc_file.rsplit("/", 1)[-1] + bhits = [f for f in file_to_nodes if f.rsplit("/", 1)[-1] == base] + return file_to_nodes[bhits[0]] if len(bhits) == 1 else [] + + +def _blast_radius(seed_ids: list[str], reverse: dict[str, list[str]], node_by_id: dict[str, dict]): + """Reverse-BFS from the seed nodes; return (count, sample_labels, truncated).""" + seen: set[str] = set(seed_ids) + q: deque[str] = deque(seed_ids) + dependents: list[str] = [] + truncated = False + while q: + cur = q.popleft() + for pred in reverse.get(cur, ()): + if pred in seen: + continue + seen.add(pred) + dependents.append(pred) + q.append(pred) + if len(seen) >= _MAX_VISIT: + truncated = True + q.clear() + break + def _label(nid: str) -> str: + n = node_by_id.get(nid, {}) + return str(n.get("label") or n.get("source_file") or nid) + sample = [_label(nid) for nid in dependents[:_SAMPLE]] + return len(dependents), sample, truncated + + +def enrich_ledger(ledger: dict, target: Path, graph_path: Path | None = None) -> dict: + """Attach a `graph` block to each finding whose location maps to a graph node, plus a ledger-level + `graph_enrichment` summary. No-op (ledger returned unchanged) when no graph is present. + + Callers should still wrap this in try/except — enrichment must never fail a run. + """ + gp = graph_path or (target / "graphify-out" / "graph.json") + if not gp.exists(): + return ledger + graph = load_graph(gp) + if graph is None: + return ledger + + file_to_nodes, reverse, node_by_id = _build_index(graph) + findings = ledger.get("findings") or [] + mapped = 0 + max_radius = 0 + any_truncated = False + for f in findings: + nodes = _match_nodes(_location_file(f.get("location", "")), file_to_nodes) + if not nodes: + continue + radius, sample, truncated = _blast_radius(nodes, reverse, node_by_id) + community = node_by_id.get(nodes[0], {}).get("community") + f["graph"] = { + "nodes": nodes, + "blast_radius": radius, + "dependents": sample, + "community": community, + "truncated": truncated, + } + mapped += 1 + max_radius = max(max_radius, radius) + any_truncated = any_truncated or truncated + + ledger["graph_enrichment"] = { + "graph": str(gp), + "built_at_commit": graph.get("built_at_commit"), + "nodes": len(node_by_id), + "mapped": mapped, + "unmapped": len(findings) - mapped, + "max_blast_radius": max_radius, + "visit_cap": _MAX_VISIT, + "truncated": any_truncated, + } + return ledger diff --git a/tests/test_graph_enrich.py b/tests/test_graph_enrich.py new file mode 100644 index 0000000..8ba5f3a --- /dev/null +++ b/tests/test_graph_enrich.py @@ -0,0 +1,151 @@ +"""Tests for optional graphify blast-radius enrichment. + +Unit-tests the location→node mapping and reverse-reachability radius on synthetic graphs, then an +integration test drives `websec run` with a graphify-out/graph.json dropped next to a fixture app. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from websec_validator import graph_enrich # noqa: E402 +from websec_validator.cli import main # noqa: E402 + + +def _graph(nodes, links, commit="deadbeef"): + return {"directed": True, "multigraph": False, "graph": {}, + "nodes": nodes, "links": links, "built_at_commit": commit} + + +class LocationParseTests(unittest.TestCase): + def test_strips_line_suffix(self): + self.assertEqual(graph_enrich._location_file("src/a.js:42"), "src/a.js") + self.assertEqual(graph_enrich._location_file("src/a.js:L42"), "src/a.js") + self.assertEqual(graph_enrich._location_file("./src/a.js"), "src/a.js") + self.assertEqual(graph_enrich._location_file("src/a.js"), "src/a.js") + + def test_keeps_windows_drive(self): + # A colon after a letter (drive) is not a line ref; only a trailing : is stripped. + self.assertEqual(graph_enrich._location_file("a.js:abc"), "a.js:abc") + + +class EnrichTests(unittest.TestCase): + def _ledger(self, *locations): + return {"findings": [{"title": "t", "location": loc, "severity": "HIGH"} for loc in locations], + "total": len(locations)} + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.target = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _write_graph(self, graph): + gp = self.target / "graphify-out" / "graph.json" + gp.parent.mkdir(parents=True, exist_ok=True) + gp.write_text(json.dumps(graph)) + return gp + + def test_no_graph_is_noop(self): + ledger = self._ledger("src/a.js") + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertNotIn("graph_enrichment", out) + self.assertNotIn("graph", out["findings"][0]) + + def test_blast_radius_counts_dependents(self): + # helper.js is imported by a.js and b.js; a.js is also imported by c.js. + nodes = [ + {"id": "helper", "label": "helper.js", "source_file": "src/helper.js", "community": 1}, + {"id": "a", "label": "a.js", "source_file": "src/a.js", "community": 1}, + {"id": "b", "label": "b.js", "source_file": "src/b.js", "community": 2}, + {"id": "c", "label": "c.js", "source_file": "src/c.js", "community": 2}, + ] + links = [ + {"source": "a", "target": "helper", "relation": "imports"}, + {"source": "b", "target": "helper", "relation": "imports"}, + {"source": "c", "target": "a", "relation": "imports"}, + ] + self._write_graph(_graph(nodes, links)) + ledger = self._ledger("src/helper.js:10") + out = graph_enrich.enrich_ledger(ledger, self.target) + g = out["findings"][0]["graph"] + # a, b (direct) + c (transitive via a) all depend on helper. + self.assertEqual(g["blast_radius"], 3) + self.assertEqual(out["graph_enrichment"]["mapped"], 1) + self.assertEqual(out["graph_enrichment"]["max_blast_radius"], 3) + + def test_leaf_has_zero_radius(self): + nodes = [ + {"id": "helper", "label": "helper.js", "source_file": "src/helper.js"}, + {"id": "a", "label": "a.js", "source_file": "src/a.js"}, + ] + links = [{"source": "a", "target": "helper", "relation": "imports"}] + self._write_graph(_graph(nodes, links)) + ledger = self._ledger("src/a.js") # nobody imports a.js + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertEqual(out["findings"][0]["graph"]["blast_radius"], 0) + + def test_unmapped_finding_gets_no_graph_block(self): + nodes = [{"id": "a", "label": "a.js", "source_file": "src/a.js"}] + self._write_graph(_graph(nodes, [])) + ledger = self._ledger("src/a.js", "src/ghost.js") + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertIn("graph", out["findings"][0]) + self.assertNotIn("graph", out["findings"][1]) + self.assertEqual(out["graph_enrichment"]["unmapped"], 1) + + def test_non_dependency_edges_ignored(self): + # a `contains` edge is structural, not a dependency — must not count toward blast radius. + nodes = [ + {"id": "helper", "label": "helper.js", "source_file": "src/helper.js"}, + {"id": "a", "label": "a.js", "source_file": "src/a.js"}, + ] + links = [{"source": "a", "target": "helper", "relation": "contains"}] + self._write_graph(_graph(nodes, links)) + ledger = self._ledger("src/helper.js") + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertEqual(out["findings"][0]["graph"]["blast_radius"], 0) + + def test_malformed_graph_is_noop(self): + gp = self.target / "graphify-out" / "graph.json" + gp.parent.mkdir(parents=True, exist_ok=True) + gp.write_text("{ this is not json") + ledger = self._ledger("src/a.js") + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertNotIn("graph_enrichment", out) + + def test_suffix_match_when_paths_anchored_differently(self): + nodes = [{"id": "h", "label": "helper.js", "source_file": "pkg/src/helper.js"}, + {"id": "a", "label": "a.js", "source_file": "pkg/src/a.js"}] + links = [{"source": "a", "target": "h", "relation": "imports"}] + self._write_graph(_graph(nodes, links)) + ledger = self._ledger("src/helper.js") # finding path lacks the pkg/ prefix + out = graph_enrich.enrich_ledger(ledger, self.target) + self.assertEqual(out["findings"][0]["graph"]["blast_radius"], 1) + + +class IntegrationTests(unittest.TestCase): + def test_websec_run_enriches_when_graph_present(self): + with tempfile.TemporaryDirectory() as td: + target = Path(td) + (target / "app.py").write_text("import os\n") + gp = target / "graphify-out" / "graph.json" + gp.parent.mkdir(parents=True, exist_ok=True) + gp.write_text(json.dumps(_graph( + [{"id": "app", "label": "app.py", "source_file": "app.py"}], []))) + out = target / "out" + rc = main(["run", str(target), "--out", str(out), "--format", "json"]) + self.assertEqual(rc, 0) + ledger = json.loads((out / "latest" / "findings-ledger.json").read_text()) + self.assertIn("graph_enrichment", ledger) + self.assertEqual(ledger["graph_enrichment"]["nodes"], 1) + + +if __name__ == "__main__": + unittest.main() From 1c8eba45239a93d358f2a02ff157ebc6449d22ea Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:21:33 -0400 Subject: [PATCH 4/7] feat(mcp): HTTP transport (stdlib) + BENCHMARKS.md methodology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP over HTTP — `websec mcp --http`: - Refactor the request handler into a transport-agnostic `process()` shared by stdio and HTTP. - New HTTP JSON-RPC transport on the stdlib http.server — no starlette, no new dependency (websec stays zero-runtime-deps, which graphify's starlette-based HTTP server can't claim). GET /health + POST JSON-RPC. - Binds 127.0.0.1:8733 by default (tools read local paths → localhost unless --host on a trusted network); still read-only. - 11 new tests incl. a live HTTP round-trip. BENCHMARKS.md — open, reproducible methodology: coverage (proof 10/10), calibrated precision with Wilson 95% CIs from the labeled corpus (n=59), zero-dep/determinism guarantees, and a documented identical-conditions protocol for a future Semgrep/Bandit comparison. No head-to-head numbers claimed until that harness runs (honest-framing mandate). Suite 356 -> 367, DocGuard green. Closes the graphify-review roadmap (installer, hooks, graph enrichment, HTTP, benchmarks). Co-Authored-By: Claude Fable 5 --- BENCHMARKS.md | 105 +++++++++++++++++++++++ CHANGELOG.md | 14 ++++ README.md | 8 +- src/websec_validator/cli.py | 9 +- src/websec_validator/mcp_server.py | 128 ++++++++++++++++++++++------- tests/test_mcp.py | 119 +++++++++++++++++++++++++++ 6 files changed, 353 insertions(+), 30 deletions(-) create mode 100644 BENCHMARKS.md create mode 100644 tests/test_mcp.py diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..814004f --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,105 @@ +# Benchmarks + +How websec-validator is measured, on an open and reproducible harness. Every number here is produced +by a command in this repo against a **public** corpus — nothing is hand-tuned, and the protocol for +the one comparison that isn't yet run is documented rather than estimated. + +Last updated: 2026-07-11. + +## What "good" means for this tool + +websec is not an autonomous scanner; it's the deterministic front-half that briefs an agent. So the +metrics that matter are: + +1. **Coverage / recall** — does recon surface the app's known attack surface? (`websec proof`) +2. **Precision honesty** — for each finding, is the reported `P(real)` actually calibrated against + ground truth? (`websec calibrate`) +3. **Reproducibility** — same input ⇒ same output, no LLM, no network to the target, zero runtime + dependencies. + +A tool that scores well on (1) but lies on (2) is worse than useless — it trains the agent to trust +false positives. So we report precision as a **calibrated probability with a confidence interval**, +not a single accuracy number. + +## Corpus + +Three deliberately-vulnerable, publicly-documented apps, so anyone can reproduce: + +| App | Stack | What it exercises | +|-----|-------|-------------------| +| **VAmPI** | Python / Flask | BOLA, broken auth, mass assignment, SQLi | +| **NodeGoat** | Node / Express | OWASP Top 10 (injection, access control, SSRF) | +| **DVGA** | Python / GraphQL | GraphQL introspection, injection, DoS | + +`websec proof` clones these on first run and scores whether recon surfaces each app's documented +surface. + +## 1. Coverage (recall) — `websec proof` + +```bash +websec proof +``` + +**Result: 10/10** documented-surface checks across the corpus (a deterministic, CI-trackable proxy). +This is a *proxy* metric — the real question ("does the briefing lift an agent's bug-finding vs a +generic prompt?") is the manual A/B in [`corpus/PROOF-PROTOCOL.md`](corpus/PROOF-PROTOCOL.md), which +is human-run and reported separately. + +## 2. Precision — calibrated `P(real)`, not a vibe + +```bash +websec calibrate # fits P(real) per (attack-class, confidence) bucket vs labeled ground truth +``` + +Fits a binomial proportion + **Wilson 95% CI** per bucket against `corpus.json`'s `truth` blocks +(n = 59 labeled findings). Current shipped calibration (`calibration.json`): + +| Bucket | Real / Total | P(real) | 95% CI | +|--------|--------------|---------|--------| +| `missing-auth` · MEDIUM | 27 / 41 | 0.66 | [0.51, 0.78] | +| `graphql` · MEDIUM | 2 / 2 | 1.00 | [0.34, 1.00] | +| `command-injection` · LOW | 1 / 1 | 1.00 | [0.21, 1.00] | +| **MEDIUM (aggregate)** | 29 / 51 | 0.57 | [0.43, 0.70] | +| **LOW (aggregate)** | 1 / 8 | 0.13 | [0.02, 0.47] | + +The point is not the headline number — it's that **each finding ships with its own measured hit-rate +and interval**, and a wide CI or a `basis: prior` bucket is surfaced as "thin data, verify manually" +rather than dressed up as certainty. + +**Honest caveat (shipped in `calibration.json.meta`):** these rates are calibrated on a *deliberately +vulnerable* corpus and skew **optimistic on clean production code**. An unmatched finding is counted a +false positive (conservative). Only the five researched classes get class-specific cells; everything +else falls back to the per-label aggregate. + +## 3. Zero-dependency, deterministic + +- **Runtime dependencies: 0** (stdlib only). The tool shells out to scanners (Trivy, Gitleaks, + Semgrep, Checkov) when present; it never imports them. `pip show websec-validator` lists no deps. +- **Deterministic:** recon and the ledger are a pure function of the repo contents — re-running + produces byte-identical `FACTS.json` / `findings-ledger.json` (modulo the run timestamp). + +## Competitor comparison — protocol (not yet run) + +To compare precision honestly against general-purpose SAST (Semgrep, Bandit), the numbers must come +from **identical conditions**: same corpus, same labeled ground truth, same "unmatched = false +positive" rule. That run isn't in this repo yet — rather than estimate it, here is the exact protocol +so the comparison is reproducible and not cherry-picked: + +1. On each corpus app, run websec (`websec run --scan`), Semgrep (`--config auto`), and Bandit. +2. Map every tool's findings to the corpus `truth` labels by (file, class), applying the same + unmatched-is-FP rule to all three. +3. Report, per tool: precision (real / total), recall (real-found / real-total), and for websec the + calibration reliability (does observed hit-rate fall inside the reported CI?). +4. Publish the harness script and raw per-finding CSV alongside the summary, as this file does for + the websec-only numbers. + +This mirrors the identical-conditions discipline of good retrieval benchmarks: one shared corpus, one +grader, no per-tool tuning. Until it's run, we make **no** head-to-head precision claim. + +## Reproduce everything here + +```bash +pipx install websec-validator +websec proof # coverage: 10/10 +websec calibrate # precision: refits calibration.json from the labeled corpus +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a344afe..49c967b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **MCP over HTTP** (`websec mcp --http`). The MCP server gained an HTTP JSON-RPC transport alongside + stdio, so a team can point one URL at the recon tools instead of every client spawning its own + process. Built on the stdlib `http.server` — **no starlette, no new dependency** — with a + `GET /health` endpoint and a `POST` JSON-RPC endpoint. Binds `127.0.0.1:8733` by default (the tools + read local paths, so localhost-only unless `--host` is set on a trusted network); still read-only. + The request handler was refactored into a transport-agnostic `process()` shared by both transports. + 11 new tests (incl. a live HTTP round-trip). + +- **`BENCHMARKS.md`** — open, reproducible measurement methodology: coverage (`websec proof` 10/10), + calibrated precision with Wilson 95% CIs from the labeled corpus (n=59), the zero-dependency / + determinism guarantees, and a documented identical-conditions protocol for a future Semgrep/Bandit + comparison (no head-to-head numbers are claimed until that harness is run). Adapted from graphify's + benchmark discipline. + - **Blast-radius enrichment from a graphify knowledge graph** (`graph_enrich.py`, opt-in, zero new deps). If the scanned repo has `graphify-out/graph.json` (or `--graph ` is passed), each finding is tagged with how much of the app transitively **depends on** the vulnerable code — diff --git a/README.md b/README.md index aacb042..31f28e0 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,11 @@ Register it in your MCP client: { "mcpServers": { "websec": { "command": "websec", "args": ["mcp"] } } } ``` +Or serve it over **HTTP** so a whole team points one URL at the recon tools (stdlib only — no extra +dependency): `websec mcp --http` (binds `127.0.0.1:8733`; `GET /health` for a load balancer, `POST` +JSON-RPC for calls). It reads local paths and runs recon on them, so it binds to localhost by +default — only expose it (`--host 0.0.0.0`) on a trusted network. + **Blast-radius from a knowledge graph (opt-in, zero-dep).** If your repo has a [`graphify`](https://github.com/Graphify-Labs/graphify) graph at `graphify-out/graph.json` (or you pass `--graph `), websec tags each finding with how much of the app **transitively depends on** @@ -246,7 +251,8 @@ missing, malformed, or oversized graph is silently skipped, never failing the ru `websec proof` clones a vuln-app corpus (VAmPI, NodeGoat, DVGA) and scores whether recon surfaces each app's documented attack surface — a deterministic, CI-trackable proxy (currently **10/10**). The real kill-criterion (does the briefing lift an agent's bug-finding vs a generic prompt?) is the -manual A/B in [`corpus/PROOF-PROTOCOL.md`](corpus/PROOF-PROTOCOL.md). +manual A/B in [`corpus/PROOF-PROTOCOL.md`](corpus/PROOF-PROTOCOL.md). Full methodology, calibrated +precision numbers, and the competitor-comparison protocol: [`BENCHMARKS.md`](BENCHMARKS.md). ## Calibrated confidence diff --git a/src/websec_validator/cli.py b/src/websec_validator/cli.py index 1e47a2b..e5e0235 100644 --- a/src/websec_validator/cli.py +++ b/src/websec_validator/cli.py @@ -281,6 +281,8 @@ def cmd_dynamic(args) -> int: def cmd_mcp(args) -> int: from . import mcp_server + if getattr(args, "http", False): + return mcp_server.serve_http(args.host, args.port) return mcp_server.serve() @@ -503,7 +505,12 @@ def build_parser() -> argparse.ArgumentParser: dyn.add_argument("--out", help="output dir (default: ./websec-out)") dyn.set_defaults(func=cmd_dynamic) - mc = sub.add_parser("mcp", help="run as an MCP server over stdio (typed recon tools for any MCP client)") + mc = sub.add_parser("mcp", help="run as an MCP server (typed recon tools for any MCP client): stdio, or --http for a team-shared URL") + mc.add_argument("--http", action="store_true", + help="serve over HTTP (JSON-RPC POST) instead of stdio — one URL for a team (stdlib only)") + mc.add_argument("--host", default="127.0.0.1", + help="HTTP bind host (default 127.0.0.1; use 0.0.0.0 to expose on a TRUSTED network only)") + mc.add_argument("--port", type=int, default=8733, help="HTTP port (default 8733)") mc.set_defaults(func=cmd_mcp) from . import install as _install diff --git a/src/websec_validator/mcp_server.py b/src/websec_validator/mcp_server.py index 30df737..cb7b0c9 100644 --- a/src/websec_validator/mcp_server.py +++ b/src/websec_validator/mcp_server.py @@ -92,49 +92,56 @@ def tool_websec_briefing(a: dict) -> str: } -def _write(msg: dict) -> None: - sys.stdout.write(json.dumps(msg) + "\n") - sys.stdout.flush() - - -def _respond(rid, result=None, error=None) -> None: +def _msg(rid, result=None, error=None) -> dict: msg = {"jsonrpc": "2.0", "id": rid} if error is not None: msg["error"] = error else: msg["result"] = result - _write(msg) + return msg -def handle(req: dict) -> None: - """Handle one JSON-RPC request/notification. Notifications (no id) never get a response.""" +def process(req: dict) -> dict | None: + """Handle one JSON-RPC request/notification and RETURN the response message (or None for a + notification, which gets no reply). Transport-agnostic: shared by the stdio and HTTP servers.""" method = req.get("method") rid = req.get("id") if method == "initialize": - _respond(rid, {"protocolVersion": PROTOCOL_VERSION, - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": "websec-validator", "version": __version__}}) - elif method in ("notifications/initialized", "initialized", "notifications/cancelled"): - return # notification — no reply - elif method == "ping": - _respond(rid, {}) - elif method == "tools/list": - _respond(rid, {"tools": TOOLS}) - elif method == "tools/call": + return _msg(rid, {"protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "websec-validator", "version": __version__}}) + if method in ("notifications/initialized", "initialized", "notifications/cancelled"): + return None # notification — no reply + if method == "ping": + return _msg(rid, {}) + if method == "tools/list": + return _msg(rid, {"tools": TOOLS}) + if method == "tools/call": params = req.get("params", {}) or {} name = params.get("name") if name not in DISPATCH: - _respond(rid, {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True}) - return + return _msg(rid, {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True}) try: text = DISPATCH[name](params.get("arguments", {}) or {}) - _respond(rid, {"content": [{"type": "text", "text": text}]}) + return _msg(rid, {"content": [{"type": "text", "text": text}]}) except Exception as e: # a tool error is reported to the model, not a protocol crash - _respond(rid, {"content": [{"type": "text", "text": f"error: {type(e).__name__}: {e}"}], - "isError": True}) - elif rid is not None: - _respond(rid, error={"code": -32601, "message": f"method not found: {method}"}) - # else: unknown notification → ignore + return _msg(rid, {"content": [{"type": "text", "text": f"error: {type(e).__name__}: {e}"}], + "isError": True}) + if rid is not None: + return _msg(rid, error={"code": -32601, "message": f"method not found: {method}"}) + return None # unknown notification → ignore + + +def _write(msg: dict) -> None: + sys.stdout.write(json.dumps(msg) + "\n") + sys.stdout.flush() + + +def handle(req: dict) -> None: + """stdio convenience: process one request and write any response to stdout.""" + msg = process(req) + if msg is not None: + _write(msg) def serve(argv=None) -> int: @@ -151,7 +158,72 @@ def serve(argv=None) -> int: handle(req) except Exception as e: # never let one bad request kill the loop if isinstance(req, dict) and req.get("id") is not None: - _respond(req.get("id"), error={"code": -32603, "message": f"internal error: {e}"}) + _write(_msg(req.get("id"), error={"code": -32603, "message": f"internal error: {e}"})) + return 0 + + +def serve_http(host: str = "127.0.0.1", port: int = 8733) -> int: + """Serve MCP over HTTP (JSON-RPC POST) with only the stdlib — no starlette, no new dependency, so + a team can point one URL at the recon tools. + + Trust boundary: the tools read local filesystem paths and run recon on them, so a client can scan + any path on THIS host. It therefore binds to 127.0.0.1 by default; exposing it on a routable + interface (--host 0.0.0.0) shares that capability with anyone who can reach the port — do that only + on a trusted network. Still read-only: it never writes to or touches the target app. + """ + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _send(self, code: int, payload: dict, ctype: str = "application/json") -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): # noqa: N802 — a tiny health endpoint for load balancers / `curl` + if self.path in ("/", "/health", "/healthz"): + self._send(200, {"name": "websec-validator", "version": __version__, + "transport": "http", "protocolVersion": PROTOCOL_VERSION}) + else: + self._send(404, {"error": "not found"}) + + def do_POST(self): # noqa: N802 + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + req = json.loads(raw or b"{}") + except json.JSONDecodeError: + self._send(400, _msg(None, error={"code": -32700, "message": "parse error"})) + return + try: + msg = process(req) + except Exception as e: + rid = req.get("id") if isinstance(req, dict) else None + self._send(200, _msg(rid, error={"code": -32603, "message": f"internal error: {e}"})) + return + # A notification (no response) still needs a valid HTTP reply — 202 Accepted, empty body. + if msg is None: + self.send_response(202) + self.send_header("Content-Length", "0") + self.end_headers() + return + self._send(200, msg) + + def log_message(self, *args): # keep stdout clean; the CLI prints its own startup line + return + + httpd = ThreadingHTTPServer((host, port), Handler) + print(f"websec-mcp HTTP on http://{host}:{port} (POST JSON-RPC · GET /health)", file=sys.stderr) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() return 0 diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..b3c0538 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,119 @@ +"""Tests for the MCP server — the transport-agnostic `process()` core and the stdlib HTTP transport.""" + +import json +import sys +import threading +import time +import unittest +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from websec_validator import mcp_server # noqa: E402 + +FIXTURE = str(ROOT / "tests" / "fixtures" / "py_app") + + +class ProcessTests(unittest.TestCase): + def test_initialize(self): + msg = mcp_server.process({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + self.assertEqual(msg["id"], 1) + self.assertEqual(msg["result"]["serverInfo"]["name"], "websec-validator") + + def test_notification_returns_none(self): + self.assertIsNone(mcp_server.process( + {"jsonrpc": "2.0", "method": "notifications/initialized"})) + + def test_tools_list(self): + msg = mcp_server.process({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + names = {t["name"] for t in msg["result"]["tools"]} + self.assertIn("websec_recon", names) + self.assertIn("websec_briefing", names) + + def test_unknown_method(self): + msg = mcp_server.process({"jsonrpc": "2.0", "id": 3, "method": "no/such"}) + self.assertEqual(msg["error"]["code"], -32601) + + def test_tools_call_recon(self): + msg = mcp_server.process({"jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": {"name": "websec_recon", "arguments": {"path": FIXTURE}}}) + text = msg["result"]["content"][0]["text"] + self.assertIn("stack", json.loads(text)) + + def test_tools_call_unknown_tool_is_error(self): + msg = mcp_server.process({"jsonrpc": "2.0", "id": 5, "method": "tools/call", + "params": {"name": "nope", "arguments": {}}}) + self.assertTrue(msg["result"]["isError"]) + + +class HttpTransportTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Grab a free port, then hand it to serve_http. + probe = ThreadingHTTPServer(("127.0.0.1", 0), BaseHTTPRequestHandler) + cls.port = probe.server_address[1] + probe.server_close() + cls.thread = threading.Thread( + target=mcp_server.serve_http, kwargs={"host": "127.0.0.1", "port": cls.port}, daemon=True) + cls.thread.start() + cls._wait_ready() + + @classmethod + def _wait_ready(cls): + for _ in range(50): + try: + urllib.request.urlopen(f"http://127.0.0.1:{cls.port}/health", timeout=1).read() + return + except OSError: + time.sleep(0.05) + raise RuntimeError("HTTP server did not come up") + + def _rpc(self, payload: dict): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}/", data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, (json.loads(r.read() or b"null")) + + def test_health(self): + with urllib.request.urlopen(f"http://127.0.0.1:{self.port}/health", timeout=5) as r: + body = json.loads(r.read()) + self.assertEqual(body["name"], "websec-validator") + self.assertEqual(body["transport"], "http") + + def test_initialize_over_http(self): + status, body = self._rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize"}) + self.assertEqual(status, 200) + self.assertEqual(body["result"]["serverInfo"]["name"], "websec-validator") + + def test_tools_call_over_http(self): + status, body = self._rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "websec_recon", "arguments": {"path": FIXTURE}}}) + self.assertEqual(status, 200) + self.assertIn("stack", json.loads(body["result"]["content"][0]["text"])) + + def test_notification_gets_202(self): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}/", + data=json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=5) as r: + self.assertEqual(r.status, 202) + + def test_malformed_json_is_parse_error(self): + req = urllib.request.Request( + f"http://127.0.0.1:{self.port}/", data=b"{not json", + headers={"Content-Type": "application/json"}, method="POST") + try: + urllib.request.urlopen(req, timeout=5) + self.fail("expected HTTP 400") + except urllib.error.HTTPError as e: + self.assertEqual(e.code, 400) + self.assertEqual(json.loads(e.read())["error"]["code"], -32700) + + +if __name__ == "__main__": + unittest.main() From 375ccab9b43e8ed5680f3afc8ea3bba0f2e66fcf Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:29:49 -0400 Subject: [PATCH 5/7] feat(graph): surface blast-radius in briefing, REPORT, and SARIF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 computed blast-radius into findings-ledger.json but it was invisible in the artifacts consumers actually read. Now: - AGENT-BRIEFING.md gains a ranked "★ 3d. Blast radius" section — the agent is told to verify high-radius findings first (only rendered when a graphify graph enriched the ledger; briefing.render takes an optional ledger arg). - REPORT.md shows a per-finding "blast radius: N module(s) depend on this" line with a dependents sample. - SARIF results carry a `blastRadius` property + a message note, so GitHub Code Scanning / dashboards surface it too. A prioritization signal that doesn't reach the consumer is wasted; this makes it change what gets triaged first. Suite 367 -> 371 (+4), DocGuard green. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++- src/websec_validator/briefing.py | 23 ++++++++++++++- src/websec_validator/cli.py | 2 +- src/websec_validator/formats.py | 6 ++++ src/websec_validator/report.py | 9 +++++- tests/test_graph_enrich.py | 50 ++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c967b..e0d1961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). urgent. Findings gain a `graph` block (`nodes`, `blast_radius`, `dependents` sample, `community`) and the ledger a `graph_enrichment` summary. Pure stdlib JSON (never imports tree-sitter, so the zero-runtime-deps guarantee holds), reverse-BFS bounded at 20k visits with disclosed truncation, - and wrapped so a malformed/oversized graph can never fail a run. 10 new tests. + and wrapped so a malformed/oversized graph can never fail a run. **Surfaced in every consumer**: a + ranked "★ Blast radius" section in `AGENT-BRIEFING.md` (verify high-radius findings first), a + per-finding blast-radius line in `REPORT.md`, and a `blastRadius` property + message note in SARIF + (so GitHub Code Scanning sees it too). 14 new tests. - **`websec hooks` — git guardrail** (`hooks.py`). Wires the baseline-diff into git so websec runs automatically per commit/push: `hooks install` writes an advisory **post-commit** hook (recon-only, diff --git a/src/websec_validator/briefing.py b/src/websec_validator/briefing.py index ddf9b16..85fe9fb 100644 --- a/src/websec_validator/briefing.py +++ b/src/websec_validator/briefing.py @@ -24,7 +24,7 @@ def _section(title, items): def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list, - unified: dict | None = None) -> str: + unified: dict | None = None, ledger: dict | None = None) -> str: stack = facts.get("stack", {}) auth = facts.get("auth", {}) routes = facts.get("routes", {}) @@ -169,6 +169,26 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list "this repo. Re-run scoped to a subdirectory or with `--exclude` to cover the rest before trusting " "an absence of findings.\n" if facts.get("files_truncated") else "") + # Blast-radius prioritization (only when a graphify graph enriched the ledger). Highest-radius + # findings touch the most of the app, so verify those first. + blast_section = "" + ge = (ledger or {}).get("graph_enrichment") + if ge and ge.get("mapped"): + ranked = sorted( + ((f.get("graph", {}).get("blast_radius", 0), f) for f in (ledger.get("findings") or []) + if f.get("graph", {}).get("blast_radius")), + key=lambda x: -x[0]) + rows = "\n".join( + f"- **{r}** module(s) depend on → `{f.get('location')}` — {f.get('title')}" + for r, f in ranked[:8]) + blast_section = ( + f"\n## 3d. ★ Blast radius (graph-derived — verify high-radius findings FIRST)\n\n" + f"A graphify knowledge graph mapped {ge['mapped']} finding(s) to code nodes. The more of the " + f"app that transitively depends on a vulnerable file, the more a single bug there costs — so " + f"triage these top-down:\n\n{rows}\n\n" + f"_Source: {ge['graph']} · max radius {ge['max_blast_radius']} across {ge['nodes']} nodes._\n" + if rows else "") + return f"""# AGENT BRIEFING — security pass for `{facts.get('target','')}` > **Scope & authorization.** Defensive self-assessment of the operator's own codebase, run with their @@ -266,6 +286,7 @@ def render(facts: dict, scanners: dict, scan_results: list, probe_manifest: list **LLM / AI-agent surface (OWASP LLM Top 10 — prompt injection, insecure output, excessive agency, unbounded):** {llm_section} +{blast_section} ## 4. Static findings (no running app needed) Scanners available: {avail} diff --git a/src/websec_validator/cli.py b/src/websec_validator/cli.py index e5e0235..f028b1d 100644 --- a/src/websec_validator/cli.py +++ b/src/websec_validator/cli.py @@ -163,7 +163,7 @@ def cmd_run(args) -> int: + (f" · {ledger['suppressed']} suppressed" if ledger["suppressed"] else "")) # 5. briefing + comprehensive REPORT.md (immutable run record) + machine artifacts - (out / "AGENT-BRIEFING.md").write_text(briefing.render(facts, det, scan_results, manifest, unified)) + (out / "AGENT-BRIEFING.md").write_text(briefing.render(facts, det, scan_results, manifest, unified, ledger)) (out / "REPORT.md").write_text(report.render(facts, det, scan_results, unified, manifest, ts, ledger)) # SARIF is ALWAYS written — it's the enterprise/CI interchange artifact (GitHub Code Scanning etc.) sarif = formats.to_sarif(ledger, facts, __version__) diff --git a/src/websec_validator/formats.py b/src/websec_validator/formats.py index 1ad129b..7089692 100644 --- a/src/websec_validator/formats.py +++ b/src/websec_validator/formats.py @@ -88,6 +88,11 @@ def to_sarif(ledger: dict, facts: dict | None = None, tool_version: str = "0") - if cal.get("p") is not None: msg += f"\n\nCalibrated P(real)={cal.get('p')} (basis={cal.get('basis')}, n={cal.get('n')})." + graph = f.get("graph") or {} + radius = graph.get("blast_radius") + if radius: + msg += f"\n\nBlast radius: {radius} module(s) transitively depend on this code (graph-derived)." + result = { "ruleId": rid, "level": _SARIF_LEVEL.get(f.get("severity", "LOW"), "note"), @@ -99,6 +104,7 @@ def to_sarif(ledger: dict, facts: dict | None = None, tool_version: str = "0") - "category": f.get("category"), "calibrated": cal or None, "security-severity": _SECURITY_SEVERITY.get(f.get("severity", "LOW"), "3.0"), + "blastRadius": radius if radius else None, }, } loc = f.get("location", "") diff --git a/src/websec_validator/report.py b/src/websec_validator/report.py index 023dbb4..bb7bc2f 100644 --- a/src/websec_validator/report.py +++ b/src/websec_validator/report.py @@ -49,8 +49,15 @@ def render(facts: dict, scanners: dict, scan_results: list, unified: dict | None calstr = " · P(real): _uncalibrated — verify manually_" # don't dress n=0 as a measurement (B4) else: calstr = f" · P(real)≈**{cal.get('p')}** CI {cal.get('ci')} (n={cal.get('n')}, {cal.get('basis')})" + gr = f.get("graph") or {} + radius = gr.get("blast_radius") + graphstr = "" + if radius: + deps = ", ".join(gr.get("dependents", [])[:3]) + graphstr = (f" \n _blast radius:_ **{radius}** module(s) depend on this" + + (f" (e.g. {deps}{'…' if gr.get('truncated') else ''})" if deps else "")) _ll.append(f"- **[{f['severity']}/{f['confidence']}]** {f['title']} \n" - f" `{f['location']}` · evidence: {chain} · {cwe}{api}{calstr} \n" + f" `{f['location']}` · evidence: {chain} · {cwe}{api}{calstr}{graphstr} \n" f" _fix:_ {f['remediation']}") ledger_block = "\n".join(_ll) ledger_hdr = (f"**{ledger['total']} findings** · {ledger['by_severity']} · " diff --git a/tests/test_graph_enrich.py b/tests/test_graph_enrich.py index 8ba5f3a..b8059cf 100644 --- a/tests/test_graph_enrich.py +++ b/tests/test_graph_enrich.py @@ -130,6 +130,56 @@ def test_suffix_match_when_paths_anchored_differently(self): self.assertEqual(out["findings"][0]["graph"]["blast_radius"], 1) +class SurfacingTests(unittest.TestCase): + """Blast radius must be visible in the artifacts consumers actually read: SARIF, REPORT, briefing.""" + + def _enriched_ledger(self): + from websec_validator import baseline + ledger = { + "schema_version": "1.0", + "findings": [{ + "title": "SQLi sink", "category": "sink", "attack_class": "sqli", + "severity": "HIGH", "confidence": "MEDIUM", "location": "src/db.js", + "evidence": [{"layer": "recon"}], + "standards": {"cwe": ["CWE-89"], "asvs": [], "owasp_api": []}, + "remediation": "parameterize", "status": "open", + "graph": {"nodes": ["db"], "blast_radius": 12, + "dependents": ["a.js", "b.js", "c.js"], "community": 1, "truncated": False}, + }], + "total": 1, "by_severity": {"HIGH": 1}, "by_confidence": {"MEDIUM": 1}, + "graph_enrichment": {"graph": "graphify-out/graph.json", "nodes": 50, + "mapped": 1, "unmapped": 0, "max_blast_radius": 12}, + } + baseline.annotate(ledger) + return ledger + + def test_sarif_carries_blast_radius(self): + from websec_validator import formats + sarif = formats.to_sarif(self._enriched_ledger(), {"target": "x"}, "1.0") + result = sarif["runs"][0]["results"][0] + self.assertEqual(result["properties"]["blastRadius"], 12) + self.assertIn("Blast radius: 12", result["message"]["text"]) + + def test_report_shows_blast_radius(self): + from websec_validator import report + md = report.render({"target": "x", "stack": {}}, {"available": [], "missing": []}, [], + None, [], "ts", self._enriched_ledger()) + self.assertIn("blast radius", md.lower()) + self.assertIn("12", md) + + def test_briefing_shows_blast_radius_section(self): + from websec_validator import briefing + md = briefing.render({"target": "x", "stack": {}}, {"available": [], "missing": []}, [], [], + None, self._enriched_ledger()) + self.assertIn("Blast radius", md) + self.assertIn("src/db.js", md) + + def test_briefing_omits_section_without_enrichment(self): + from websec_validator import briefing + md = briefing.render({"target": "x", "stack": {}}, {"available": [], "missing": []}, [], [], None, None) + self.assertNotIn("3d.", md) + + class IntegrationTests(unittest.TestCase): def test_websec_run_enriches_when_graph_present(self): with tempfile.TemporaryDirectory() as td: From beb095c3b7db27728303363cedd08c1af26bdb0e Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:30:56 -0400 Subject: [PATCH 6/7] feat(mcp): apply graph blast-radius enrichment in MCP findings/sarif tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP was the one surface not getting blast-radius. The websec_findings and websec_sarif tools now run the same opt-in graphify enrichment (via a shared _ledger_for helper), best-effort so a missing/bad graph never fails a tool call. Now every channel — CLI artifacts, SARIF, and MCP — is consistent. +1 test; suite 371 -> 372. Co-Authored-By: Claude Fable 5 --- src/websec_validator/mcp_server.py | 20 ++++++++++++++++---- tests/test_mcp.py | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/websec_validator/mcp_server.py b/src/websec_validator/mcp_server.py index cb7b0c9..d9ffece 100644 --- a/src/websec_validator/mcp_server.py +++ b/src/websec_validator/mcp_server.py @@ -62,15 +62,27 @@ def tool_websec_recon(a: dict) -> str: return json.dumps(_facts(a.get("path", "")), indent=2) +def _ledger_for(path: str, suppressions) -> tuple[dict, dict]: + """Build the ledger for a repo and apply optional graphify blast-radius enrichment (best-effort).""" + root = _resolve(path) + facts = recon.build_facts(root, __version__) + ledger = findings.build_ledger(facts, None, None, suppressions) + try: + from . import graph_enrich + graph_enrich.enrich_ledger(ledger, root) + except Exception: + pass # enrichment is optional — a missing/bad graph must not fail the tool call + return facts, ledger + + def tool_websec_findings(a: dict) -> str: - facts = _facts(a.get("path", "")) - ledger = findings.build_ledger(facts, None, None, findings.load_suppressions(_resolve(a["path"]))) + path = a.get("path", "") + _, ledger = _ledger_for(path, findings.load_suppressions(_resolve(path))) return json.dumps(ledger, indent=2) def tool_websec_sarif(a: dict) -> str: - facts = _facts(a.get("path", "")) - ledger = findings.build_ledger(facts, None, None, []) + facts, ledger = _ledger_for(a.get("path", ""), []) return json.dumps(formats.to_sarif(ledger, facts, __version__), indent=2) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index b3c0538..788c267 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -48,6 +48,23 @@ def test_tools_call_unknown_tool_is_error(self): "params": {"name": "nope", "arguments": {}}}) self.assertTrue(msg["result"]["isError"]) + def test_findings_tool_enriches_when_graph_present(self): + import json as _json + import tempfile + from pathlib import Path + with tempfile.TemporaryDirectory() as td: + target = Path(td) + (target / "app.py").write_text("import os\n") + gp = target / "graphify-out" / "graph.json" + gp.parent.mkdir(parents=True, exist_ok=True) + gp.write_text(_json.dumps({"nodes": [{"id": "app", "label": "app.py", + "source_file": "app.py"}], "links": []})) + msg = mcp_server.process({"jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": {"name": "websec_findings", + "arguments": {"path": str(target)}}}) + ledger = _json.loads(msg["result"]["content"][0]["text"]) + self.assertIn("graph_enrichment", ledger) + class HttpTransportTests(unittest.TestCase): @classmethod From eefc38eac4479d85fd332af658c35ee88005e7f1 Mon Sep 17 00:00:00 2001 From: Ricardo Accioly Date: Sat, 11 Jul 2026 13:38:17 -0400 Subject: [PATCH 7/7] =?UTF-8?q?release=200.11.0=20=E2=80=94=20distribution?= =?UTF-8?q?=20&=20integration=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump 0.10.0 -> 0.11.0. Ships this session's graphify-inspired work: multi-host installer (`websec install `), git guardrail hooks (`websec hooks`), opt-in graphify blast-radius enrichment surfaced across briefing/REPORT/SARIF/MCP, and an MCP HTTP transport — all stdlib-only, zero runtime deps preserved. Suite 324 -> 372 green. Pre-flight: built the wheel in a clean venv, twine check passed, smoke verified (version match, calibration.json ships, new modules import, run produces AGENT-BRIEFING.md, install writes the host block). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 ++++++++- pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d1961..e7931c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.11.0] — 2026-07-11 + +Distribution & integration round — reach every agent host, run as a local guardrail, and compose with +a knowledge graph. Adapted from a review of [graphify](https://github.com/Graphify-Labs/graphify); +websec keeps its zero-runtime-deps guarantee throughout (stdlib-only HTTP + JSON graph parsing). + ### Added - **MCP over HTTP** (`websec mcp --http`). The MCP server gained an HTTP JSON-RPC transport alongside @@ -553,7 +559,8 @@ The initial public line. Highlights across 0.2.1–0.2.9: ### Fixed - Scanner-contamination and rate-limit fixes (agent-wallet dogfood). -[Unreleased]: https://github.com/raccioly/websec-validator/compare/v0.4.2...HEAD +[Unreleased]: https://github.com/raccioly/websec-validator/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/raccioly/websec-validator/compare/v0.10.0...v0.11.0 [0.4.2]: https://github.com/raccioly/websec-validator/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/raccioly/websec-validator/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/raccioly/websec-validator/compare/v0.3.0...v0.4.0 diff --git a/pyproject.toml b/pyproject.toml index e53d057..82962c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "websec-validator" -version = "0.10.0" +version = "0.11.0" description = "Defensive, local-first security recon that briefs your AI coding agent on your own codebase — read-only by default (code in, artifacts out): facts + tailored probe scripts, no LLM, no server, no running app." readme = "README.md" requires-python = ">=3.11"