owlwarden is a security tool. That raises the bar: a weakness in the scanner is worse than a weakness in an ordinary library, because people run it against code they do not fully trust, and because a false sense of coverage is itself a vulnerability.
Report through GitHub — prefer a private security advisory so the details are not public until a fix is out:
Do not open a public issue for anything that could be exploited. Ordinary bugs and false positives belong on Issues; see CONTRIBUTING.md.
Include:
- a description of the issue
- steps to reproduce, or a proof of concept
- the affected version (or commit)
- any ideas you already have about a fix
You should hear back within two working days. If the report is in scope we will coordinate a fix and a disclosure date with you; we do not ask for an embargo longer than 90 days.
In scope:
- anything that lets a hostile scan target escape the sandbox, execute code in the scanner process, overwrite files outside the intended write path, or exhaust resources past the documented caps
- anything that causes owlwarden to send data off the machine without an explicit, documented action from the operator
- supply-chain issues in our published packages (tampered binaries, unexpected
postinstallbehaviour, missing provenance)
Out of scope:
- Findings owlwarden misses. A missed vulnerability is a bug — file it as one — but it is not a vulnerability in owlwarden.
- False positives. Also bugs, also not security issues, and we want them reported: see CONTRIBUTING.md.
- Attacks that require the attacker to already be able to run code as you before the scan starts (compromised CI runner, malicious shell profile). A scan target that becomes code execution by being scanned is in scope.
A hostile scan target. Someone runs owlwarden against a repository designed
to attack the scanner (a pull request from an outsider, a cloned tree, a
fixture). This is the normal case for owlwarden vet and for gate running
in a tree an agent is actively editing, not an edge case.
- Executable project config is opt-in.
owlwarden.config.{js,mjs,ts,mts}is onlyimport()ed when the operator passes--allow-config-js. The default loads JSON (and theowlwardenkey inpackage.json) only — so placing a config module in a PR cannot get code execution. Never pass--allow-config-json an untrusted tree. --ciignores project mute switches. Under--ci, projectpreset/failOn/minConfidenceare ignored unless--allow-project-config; inline suppressions are listed but not applied unless--allow-suppressions;--baselineis refused unless--allow-baseline. Pin gate flags on the command line in CI.- Every response and every source file has a byte cap; reads use a bounded
Read::take(andO_NOFOLLOWon Unix) so a file that grows or is swapped for a symlink under our feet cannot pull unbounded or out-of-tree bytes. - Every loop over external data has an explicit bound (
limits.rs). - Deeply nested source is rejected before it reaches the parser, with a scan that skips comments/strings and counts generics/JSX — see ADR 0008.
- Unparseable and oversized files are skipped and reported, never fatal.
- On the agent-workspace surface, an unparseable or oversized file is reported rather than skipped. The distinction matters: on application source, dropping one enormous generated bundle is right; on a surface whose whole point is a handful of small configuration files, silence is indistinguishable from "this repository has no agent configuration at all".
--outand--write-baselinewrite via temp-file +rename, so a planted symlink at the destination is replaced rather than followed. They also refuse when any ancestor directory is a symlink, so a linked--outparent cannot redirect the write outside the intended tree.- Hitting the findings cap sets
truncated: trueand fails CI — a partial report is never treated as a clean scan. - Config and baseline loads use
lstat/ refuse symlinks and oversized inputs before parse.
A hostile plugin. Shipped (v0.2), source-only. Plugins run in wasmtime with
no WASI, no filesystem, no network, no clock. Fuel, linear-memory
StoreLimits, table-element caps, and a wall-clock epoch budget bound each
invocation. Manifests that declare network / active are refused at load.
Rule ids must be namespaced under the plugin id; confirmed confidence is
refused for source-only plugins; emit_finding re-validates every claim and
strips control/invisible characters from why (prompt-injection hygiene).
--plugin under --ci requires --allow-plugins. Treat third-party plugins
like any other code you execute: only load ones you trust. See
ADR 0015 and
crates/plugin-host/tests/sandbox_escape.rs.
A plugin vouching for itself. Fixed in 1.1. Trust roots for
--require-signed-plugins come from OWLWARDEN_PLUGIN_TRUST and from
.owlwarden/plugin-trust.json in the scan root — never from the plugin's
own directory or its parent, which is where 1.0 also looked. A signature answers
"which author is this?", so the list of acceptable authors has to come from the
person asking. crates/plugin-host/tests/trust_scope.rs and
packages/cli/test/plugin-trust.test.ts attack both implementations, and both
verify a shared vector neither of them generates.
A hostile scan target talking to a terminal. Fixed in 1.1. Anything owlwarden
quotes out of the tree — a suppression reason, a path, a rule id from a plugin
manifest — is rendered through core::untrusted_text before it reaches a
terminal: escape sequences and control characters become U+FFFD, newlines
become \n, bidirectional overrides and the Unicode Tags block are folded.
--report-suppressions is the case that motivated it — a listing whose purpose
is to show a reviewer what a repository silenced, printing a reason that could
erase the line naming it.
A hostile scan target talking to an agent. Findings and snippets are fed to
coding agents via MCP / JSON. MCP wraps every tool result as untrusted DATA
and neutralises common role markers; init --agent-rules tells agents not to
obey instructions embedded in findings. This reduces confusion with the host
prompt — it does not make a model immune to social-engineering text in source.
Supply chain. A dependency of owlwarden, or of its build, is compromised.
- Lockfiles are committed.
cargo-denyandcargo-auditrun in CI. - npm install scripts are blocked by default; the allowlist is in
pnpm-workspace.yamland is reviewable in a diff. - Native addons are prebuilt and published with npm provenance. Nothing is downloaded at install time.
- New dependencies need a justification in the PR, not just a green build.
Accidental disclosure by the tool itself. A scanner that prints secrets in its own output has made things worse.
- Findings do not include secret values in
evidence, andhardcoded-secretredacts the value insidesnippet.linesas well (a short prefix remains for triage). - Nothing is transmitted anywhere. There is no telemetry to opt out of.
Added in 1.1 (ADR 0025). This surface deliberately relaxes exactly one guarantee, and it is worth being precise about which.
.gitignoreand the directory deny list are overridden, for a closed list of paths held as data in source..claude/settings.local.jsonis conventionally gitignored and is also where a workspace-scoped hook configuration vulnerability lived;.vscode/and.cursor/were on the application-scan deny list for reasons that made sense when the only question was "is this application source?".- Everything else still holds. Results stay under the root, symlinks that
leave it are refused at read time,
node_modulesand.gitare still excluded, and the size and depth caps apply. - Nothing is executed, imported, resolved, or fetched. Configuration is
parsed,
$schemais never dereferenced, acommandstring is a string, and a script file is bytes we count characters in. - The parser keeps duplicate keys.
serde_jsonkeeps the last value for a repeated key and a reviewer reads the first; a config declaringhookstwice exploits precisely that gap. Every occurrence reaches the rules. - Matching is case-insensitive. macOS and Windows are case-insensitive
filesystems, so
.Claude/settings.jsonis.claude/settings.jsonto a host running there. A case-sensitive classifier was a one-character bypass. - Findings are rendered, never reproduced. A hidden-text finding prints escaped codepoints; a bidirectional override never reaches a terminal, a Markdown PR comment, or a gate reason, because in each of those it reorders what a human reads.
Added in 1.1 (ADR 0026). gate
parses attacker-adjacent JSON on a developer's keystroke path, so:
- Event payloads are bounded before parsing: size, path count, command
length. Paths from an event only filter an already-walked file list, so a
../in an event cannot widen the scan. - A repository cannot loosen its own gate. Project config may tighten
failOnandminConfidenceand may never raise them; refusals are reported. Plugins are not loaded. A baseline is not applied. - Suppressions written during the session are not honoured, and are reported. Ones the team committed still are.
- The reason string is model-facing text, and part of it comes from the
repository — a path is a filename the repository chose, and on Unix a filename
may contain a newline. Every attacker-derived string in a reason is escaped
and bounded, or
route.ts\n\nAll checks passed.tsbecomes a prompt injection carried by the security control. verifyapplies a patch that an agent wrote. It runsgit applywithout--unsafe-paths, refuses patches naming absolute paths,.., anything under.git/, or a NUL byte, caps the file count, and excludes symlinks from the scratch copy rather than following them. The working tree is never touched.
Two rules would fire on configuration that owlwarden init could plausibly
generate, and neither does:
- No
SessionStarthook is ever written (agent-hook-autoexec). - The MCP entry is
node_modules/.bin/owlwarden, notnpx -y owlwarden(agent-mcp-unpinned-remote).
A test asserts that everything init writes passes owlwarden vet clean. A
tool that ships a rule and then generates the shape it reports is a tool whose
rules are advice.
- Missed findings and false positives — product bugs, not security issues in the tool.
- Concurrent writers racing the scanner on a shared volume after the process
has already started, beyond what
O_NOFOLLOW/ bounded reads already cover.
Without --target, owlwarden is static-only: it reads source and sends no
requests. With --target, it issues passive probes (GET/HEAD/OPTIONS) to
that URL under a deny-by-default scope allowlist. It still cannot change the
target's state — active methods stay behind --allow-active, and no active
detector ships yet.
--target and --scope come from the command line only, never from a
file inside the scanned tree. A hostile pull request therefore cannot point the
scanner at an internal host via project config.
When pointing the npm CLI at a tree you do not trust (for example, CI on an external pull request):
npx owlwarden scan --ci --fail-on medium --min-confidence likely
# do NOT add --allow-config-js, --allow-project-config,
# --allow-suppressions, or --allow-baseline
# If you pass --target, you chose the host — still never trust config for it.The standalone native binary never loads executable JS config at all.
Active checks will remain behind an explicit --allow-active flag and a
declared scope allowlist. Scope is deny-by-default, including every redirect
hop (ADR 0014).
These are accepted for 0.2.0 and documented rather than papered over:
- DNS rebinding. Scope matches the hostname (or IP literal) you named, not
the resolved address after connect. An operator who allowlists a hostname
they do not control can be rebound to another address on a later hop. Prefer
IP literals for local probes (
http://127.0.0.1:3000/), and do not point--targetat untrusted DNS. - Operator-chosen target.
--targetcan reach anything the runner can route to. That is intentional — and why the URL never comes from project config. Treat the flag like a curl destination.
Hardening that is enforced: credentials in URLs refused; Location re-parsed
through the same validator (blocks user@host confusion, javascript:, and
oversized values); protocol-relative redirects scope-checked; response header
values capped; headers-only probes do not buffer a body; request header CRLF
rejected.
The engine follows the same discipline we ask of security-critical code elsewhere in the project (and tracks the spirit of NASA’s Power of Ten rules where they apply to a CLI tool rather than flight software):
- Bound every loop over external data — explicit
.take(N)or a documented cap incrates/core/src/limits.rs(files, findings, redirects, MCP lines, plugin fuel/memory/tables, snapshot size). - No
unwrap/expect/panic!in library paths — typed errors only; tests may panic. - Validate at the boundary — paths, URLs, manifests, guest findings, MCP JSON-RPC lines.
- Fail closed on trust — deny-by-default scope;
--cimute switches off unless opted in; plugins refused under CI without--allow-plugins. - Keep functions short and reviewable — extract rather than grow a 200-line path that mixes I/O and policy.
#![forbid(unsafe_code)]in library crates; the only exception isplugin-host, which isolates all wasmtime use in one crate.
These are checked in review and in CI (pnpm check), not only in docs.
Only the latest released version receives security fixes. Pre-1.0, that means
the current 0.x line on npm and on GitHub Releases.