Skip to content
Merged
105 changes: 105 additions & 0 deletions BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -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
```
66 changes: 65 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,60 @@ 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
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 <file>` 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. **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,
~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 <host>` — 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).
Expand Down Expand Up @@ -49,10 +101,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- PyPI metadata: trove classifiers and `[project.urls]` (homepage, docs, changelog, issues) —
populates the sidebar on the PyPI project page from the next release.

- **Fixture/example code is scoped out of the attack surface.** Test/example/fixture routes are split
out (kept in `FACTS.json` under `fixture_endpoints`, counted but not probed) so a project that
vendors a demo app isn't profiled as if that demo were the product; secrets in fixture files are
demoted to LOW and annotated rather than dropped. `--include-fixtures` treats all of it as product
code. Fixture package manifests no longer drive framework detection. Also adds raw-server detection.

### Changed

### Fixed

- **`--exclude` now reaches every scanner** (bug-205). It was honored by trivy/semgrep but dropped by
the gitleaks/checkov paths and the findings post-filter (which only excluded built-in `SKIP_DIRS`).
Added a single post-filter choke point that applies every `--exclude` glob across all scanners, with
a disclosed `user_excluded_dropped` hygiene counter.

### Removed

## [0.10.0] — 2026-07-03
Expand Down Expand Up @@ -507,7 +570,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
Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` — 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 <host>`.** 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
Expand Down Expand Up @@ -208,6 +223,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.
Expand All @@ -217,6 +246,26 @@ 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 <file>`), 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.
Expand All @@ -226,7 +275,8 @@ Register it in your MCP client:
`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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 22 additions & 1 deletion src/websec_validator/briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
Loading