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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 45 additions & 21 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ as a promise about dates — see [ROADMAP.md](ROADMAP.md) for that.
- Not an exploitation toolkit. No payload damages, persists, or exfiltrates.
- Not a GUI product and not an HTTP proxy.
- Not every language at once. TypeScript web frameworks first — Next.js, Nuxt,
NestJS, Express, and Fastify — and generalise from there.
NestJS, Express, Fastify, Hono, Koa, Hapi, Sails.js, Astro, Remix, and
Gatsby — and generalise from there.

## 2. The dual-engine model

Expand Down Expand Up @@ -77,6 +78,7 @@ This keeps the core pure, testable, and free of I/O.
| `transport` | `ReqwestTransport`: scope-enforced, streaming-capped HTTP. |
| `dynamic-engine` | Passive probes and correlation that raises matching findings to `Confirmed`. |
| `reporters` | `pretty` and `json` output, and the banner. |
| `plugin-host` | Sandboxed WASM plugin host (wasmtime). Ships partial in v0.2: source-only. See §6. |
| `napi` | The Node bridge. `scan` is async and runs the engine on a worker thread so a live probe cannot block the event loop. |
| `cli-native` | Standalone binary — the same engine without Node. |

Expand All @@ -86,9 +88,6 @@ This keeps the core pure, testable, and free of I/O.
| `@dointhai/owlwarden-config` | Config schema and resolution. |
| `owlwarden` | The CLI. |

`plugin-host` is planned. It does not exist yet, and an empty placeholder crate
would only be noise.

## 4. Core interfaces

These are the load-bearing abstractions. They are small on purpose, and they
Expand Down Expand Up @@ -265,24 +264,41 @@ and agent rules files all key off them. A rename requires an alias retained for
two minor versions. `RULES.md` is generated from source and checked in CI, so an
accidental rename fails the build.

## 6. Plugin system (planned)
## 6. Plugin system (ships partial: source-only, v0.2)

Two tiers, distinguished by how much they are trusted.

| Tier | Language | Runs in | For | Trust |
|---|---|---|---|---|
| **Recipe** | TS/JS | the CLI process | presets, custom reporters, glue | the user's own code |
| **Detector** | any → WASM/WASI | `plugin-host` (wasmtime) | scanning logic | untrusted, sandboxed |

- **Capability model.** A plugin manifest declares what it needs — `network`,
`active`. At load time the host wires only the granted host functions. No
declaration means no capability. `active` additionally requires the run to
pass `--allow-active` and the target to be in scope.
| **Detector** | any → WASM | `plugin-host` (wasmtime) | scanning logic | untrusted, sandboxed |

`plugin-host` exists (`crates/plugin-host`) and ships a `WasmDetector` any
`--plugin <path>` can load. What v0.2 grants is **source-only**: a plugin
reads a capped snapshot of project source and calls back exactly once,
through `emit_finding`. See [ADR 0015](docs/adr/0015-plugin-host-wasmtime.md)
for why wasmtime, and `crates/plugin-host/tests/sandbox_escape.rs` for the
containment tests every change to the host has to keep passing.

- **Capability model.** A plugin manifest declares what it needs —
`source`, `network`, `active`. At load time the host wires only the granted
host functions. No declaration means no capability, and in v0.2 a manifest
declaring `network` or `active` is refused at load time rather than
silently downgraded — there is no host function yet to grant either one
through. `active` will additionally require the run to pass
`--allow-active` and the target to be in scope once it is wired.
- **No ambient authority.** A WASM detector gets no clock, randomness,
filesystem, or network except through host functions the runner provides.
This is the whole reason a security tool can run third-party detectors.
- **Bounded.** Each invocation gets a fuel and time budget and a memory cap. A
misbehaving plugin is starved; the host is not.
filesystem, or network — there is no WASI in this host at all, ambient or
otherwise — except through the one host function the runner provides. This
is the whole reason a security tool can run third-party detectors.
- **Bounded.** Each invocation gets a fuel budget
(`limits::plugin::MAX_FUEL`), a wall-clock deadline via epoch interruption
(`limits::plugin::MAX_INVOCATION_TIME`), and a memory cap enforced by
`wasmtime::StoreLimits` (`limits::plugin::MAX_MEMORY_BYTES`) — not merely
requested of the guest. A misbehaving plugin is starved; the host is not.
- **Untrusted by default.** `--plugin` is refused under `--ci` unless
`--allow-plugins` is also passed, the same trust posture as
`--allow-baseline` and `--allow-suppressions`.

## 7. Configuration

Expand Down Expand Up @@ -326,7 +342,9 @@ is never branded with the OWASP mark.
| `explain <id>` | shipped | The full write-up for a rule, entirely offline. |
| `watch` | shipped | Re-scan on change during development. Static only — refuses `--target`. |
| `report` | planned | Re-render a saved JSON result in another format. |
| `mcp` | planned | An MCP server, so an agent can call owlwarden as a tool. |
| `mcp` | shipped | Stdio MCP server (static, read-only). |
| `init --agent-rules` | shipped | Writes `.owlwarden/agent-rules.md` from the catalogue. |
| `plugin scaffold` | shipped | Starter guest + `owlwarden.plugin.json`. |

Exit codes are a contract: `0` clean, `1` findings at or above `--fail-on`, `2`
the scan could not run.
Expand All @@ -344,14 +362,16 @@ Three adversaries, and what is done about each. The full version is in
timeouts; bounded concurrency; deeply nested source rejected before it reaches
the parser ([ADR 0008](docs/adr/0008-bound-parser-recursion.md)).
2. **A hostile plugin.** WASM sandbox, capability-gated host calls, memory and
fuel limits, no ambient authority. Planned with the plugin host.
fuel limits, no ambient authority. Shipped, partial (source-only), in
`plugin-host` — see §6.
3. **A hostile supply chain.** `cargo-deny` and `cargo-audit` in CI, committed
lockfiles, an explicit allowlist for npm install scripts, npm provenance, and
signed releases with an SBOM.

Invariants throughout: scope is deny-by-default; secrets are redacted from
output; `unsafe` will be confined to `plugin-host` and audited line by line; the
standalone binary and the Node addon share one reviewed core.
output; `unsafe` is confined to `plugin-host` (in practice, `wasmtime`'s own —
this crate adds none of its own) and audited line by line; the standalone
binary and the Node addon share one reviewed core.

## 10. Coding standards

Expand Down Expand Up @@ -416,8 +436,12 @@ Any path that turns an attacker-controlled size into an allocation clamps first.
the JSON contract cannot drift silently.
- **Cross-language contract** — golden files generated by the Rust engine and
parsed by the TypeScript schemas, including the full exit-code truth table.
- **Sandbox-escape suite** (planned) — a deliberately malicious sample plugin;
the test asserts containment. Will run on every PR touching `plugin-host`.
- **Sandbox-escape suite** — `crates/plugin-host/tests/sandbox_escape.rs`.
Five deliberately adversarial WASM modules, assembled from `.wat` at test
time: a busy-loop (fuel exhaustion), an oversized `memory.grow` (store
limiter), a finding flood (per-invocation cap), a claim for an undeclared
rule id (dropped, not trapped), and a benign positive control. Runs on
every PR touching `plugin-host`.
- **Property and fuzz testing** (planned) — `proptest` for parsers and bounds,
`cargo-fuzz` on the response-handling and AST boundaries.

Expand Down
91 changes: 89 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,93 @@ are listed here under Changed.

## [Unreleased]

Nothing yet.
## [0.2.0] — 2026-08-08

Plugins (source-only WASM), MCP for agents, and twelve Node frameworks. The
formal v0.2 bar from [ROADMAP.md](ROADMAP.md). Autofix and active checks stay
later work.

### Added

- **`owlwarden-plugin-host`** — sandboxed WASM plugin host (ROADMAP v0.2),
ships partial: source-only. A plugin is a `.wasm` module plus an
`owlwarden.plugin.json` manifest, loaded with `--plugin <path>` (repeatable)
and refused under `--ci` unless `--allow-plugins` is also passed. Every
invocation runs in a fresh `wasmtime` store bounded by fuel, a 64 MiB
`StoreLimits` memory cap, and a wall-clock deadline via epoch interruption;
the only host function wired is `emit_finding`, and every claim it receives
is re-validated against the plugin's own manifest before it becomes a
finding. A manifest declaring `network` or `active` is refused at load
time rather than silently downgraded — see
[ADR 0015](docs/adr/0015-plugin-host-wasmtime.md). `wasmtime` is a new
dependency, confined to this one crate with default features disabled
(only `cranelift`/`runtime`/`std`); every other crate keeps
`#![forbid(unsafe_code)]`. Floored at 36.0.13 — every earlier release has
an open RUSTSEC advisory, several of them sandbox escapes.
- Sandbox-escape test suite (`crates/plugin-host/tests/sandbox_escape.rs`):
fuel exhaustion, oversized `memory.grow` / `table.grow`, a finding flood, an
undeclared rule id, an oversized `why`, and a benign positive control.
- Error code **`E_PLUGIN_INVALID`** for a plugin that could not be loaded.
- **`owlwarden mcp`** — stdio MCP server with `scan_project`, `scan_file`,
`explain_rule`, and `list_rules`. Static and read-only; no `--target`, no
file writes, paths sandboxed to the workspace root.
- **`owlwarden init --agent-rules`** — writes `.owlwarden/agent-rules.md` from
the compiled catalogue.
- **`owlwarden plugin scaffold <name>`** — guest stub (`plugin.wat`) plus a
valid `owlwarden.plugin.json`.
- Plugin-authoring schemas in `@dointhai/owlwarden-sdk` (`pluginManifestSchema`).
- **Seven more Node frameworks** with first-class profiles, remediation on every
catalogue rule, and square fixture coverage: Hono, Koa, Hapi, Sails.js, Astro,
Remix, and Gatsby. Supported set is now twelve stacks (12 rules × 12
frameworks, locked in CI).
- **Richer fixture corpus** — each framework exercises two real-world shapes for
`ssrf` (fetch + axios), `open-redirect` (redirect helper + `Location`
header), and `sensitive-data-logged` (password + accessToken), plus tempting
false-positive twins on every clean project.
- **File-route mapping** for Astro (`src/pages/api`), Remix flat routes, and
Gatsby Functions (`src/api`).
- Request-origin recognition for Hono’s `c` context and Astro’s `Astro.request`.

### Changed

- `DetectorMeta.title` / `.category` / `.description` are now
`Cow<'static, str>` (were `&'static str`), so a `WasmDetector` built from a
parsed plugin manifest can own its strings. No change to the JSON wire
shape or to first-party rules, which still write string literals.
- README and npm package text rewritten in plain language: what it does, that
it stays local, which frameworks it knows, and what v0.2 actually ships
(plugins source-only, MCP read-only). States that local scans cover baseline
checks without burning LLM tokens, and that deeper AI security review still
belongs on high-impact work.
- Plugin hardening after whitebox review: `O_NOFOLLOW` + bounded reads for
manifest/WASM load; `StoreLimits` on tables; plugin rule ids must be
namespaced under the plugin id; source-only plugins cannot declare
`confirmed`; `why` capped; MCP JSON-RPC lines capped; `init` /
`plugin scaffold` use symlink-safe writes under the working directory; napi
re-checks `--ci` + `--allow-plugins`.
- Fixture matrix tightened: every clean twin ships `*tempting*` and
`*safe-redirect*` files; multi-fire rules are locked to named source shapes
(fetch/axios, redirect/Location, …); the TypeScript e2e path asserts
`SHARED_FIRES` counts on all twelve frameworks, not only Next.js.
- Cookie detection: nested setters (`ctx.cookies.set`), Hapi `isHttpOnly` /
`isSecure` / `isSameSite`, and dropped false cookie matches on
`c.header` / `res.setHeader` / bare `serialize`.
- Stack-trace rule recognises Koa-style `ctx.body = …` assignments.
- `secureHeaders` counts as header middleware for Hono.

### Fixed

- `cargo deny` CI gate: allow `CDLA-Permissive-2.0` for `webpki-roots` (Mozilla
CA data via rustls/reqwest), and give the dynamic-engine dev-dep on
`owlwarden-transport` a workspace version so it is not a path-only wildcard.

### Security

- **Prompt-injection hardening for MCP / agents / plugins.** MCP tool results
are wrapped in an `OWLWARDEN_TOOL_RESULT` trust-boundary envelope; free text
is stripped of control/invisible characters and common chat role markers.
Plugin `why` is sanitised at emit time; `init --agent-rules` tells agents to
treat findings as evidence, not instructions.

## [0.1.0]

Expand Down Expand Up @@ -153,7 +239,8 @@ does and does not reach.
- Bounded file count, file size, total bytes, and parser recursion depth, so a
hostile repository cannot exhaust memory or the stack.

[Unreleased]: https://github.com/suthat/owlwarden/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/suthat/owlwarden/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/suthat/owlwarden/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/suthat/owlwarden/compare/v0.0.2...v0.1.0
[0.0.2]: https://github.com/suthat/owlwarden/compare/v0.0.1...v0.0.2
[0.0.1]: https://github.com/suthat/owlwarden/releases/tag/v0.0.1
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ how to add a framework rather than a rule. The short version:
3. Fixtures on **every** supported framework: a vulnerable project that must
fire, and a clean twin that must stay silent — ideally the tempting case a
naive implementation would flag. Counts live in `SHARED_FIRES` /
`crates/detectors/tests/fixtures.rs` (12 × 5 cells today). CI fails if a
`crates/detectors/tests/fixtures.rs` (12 × 12 cells today). CI fails if a
catalogue rule is missing from any framework row.

Then the rule. Then run it against the whole corpus, and regenerate the
Expand Down
Loading