Skip to content

Security: suthat/owlwarden

Security

SECURITY.md

Security

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.

Reporting a vulnerability

Report through GitHub — prefer a private security advisory so the details are not public until a fix is out:

Report a vulnerability

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.

Scope

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 postinstall behaviour, 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.

Threat model

Adversaries and what we do about them

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 only import()ed when the operator passes --allow-config-js. The default loads JSON (and the owlwarden key in package.json) only — so placing a config module in a PR cannot get code execution. Never pass --allow-config-js on an untrusted tree.
  • --ci ignores project mute switches. Under --ci, project preset / failOn / minConfidence are ignored unless --allow-project-config; inline suppressions are listed but not applied unless --allow-suppressions; --baseline is 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 (and O_NOFOLLOW on 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".
  • --out and --write-baseline write 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 --out parent cannot redirect the write outside the intended tree.
  • Hitting the findings cap sets truncated: true and 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-deny and cargo-audit run in CI.
  • npm install scripts are blocked by default; the allowlist is in pnpm-workspace.yaml and 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, and hardcoded-secret redacts the value inside snippet.lines as well (a short prefix remains for triage).
  • Nothing is transmitted anywhere. There is no telemetry to opt out of.

The agent workspace

Added in 1.1 (ADR 0025). This surface deliberately relaxes exactly one guarantee, and it is worth being precise about which.

  • .gitignore and the directory deny list are overridden, for a closed list of paths held as data in source. .claude/settings.local.json is 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_modules and .git are still excluded, and the size and depth caps apply.
  • Nothing is executed, imported, resolved, or fetched. Configuration is parsed, $schema is never dereferenced, a command string is a string, and a script file is bytes we count characters in.
  • The parser keeps duplicate keys. serde_json keeps the last value for a repeated key and a reviewer reads the first; a config declaring hooks twice exploits precisely that gap. Every occurrence reaches the rules.
  • Matching is case-insensitive. macOS and Windows are case-insensitive filesystems, so .Claude/settings.json is .claude/settings.json to 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.

The gate

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 failOn and minConfidence and 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.ts becomes a prompt injection carried by the security control.
  • verify applies a patch that an agent wrote. It runs git apply without --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.

Our own output

Two rules would fire on configuration that owlwarden init could plausibly generate, and neither does:

  • No SessionStart hook is ever written (agent-hook-autoexec).
  • The MCP entry is node_modules/.bin/owlwarden, not npx -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.

Out of scope (repeated for scanners of this document)

  • 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.

Scanning safely

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).

Residual risks (dynamic)

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 --target at untrusted DNS.
  • Operator-chosen target. --target can 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.

Coding standards that bind the scanner

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):

  1. Bound every loop over external data — explicit .take(N) or a documented cap in crates/core/src/limits.rs (files, findings, redirects, MCP lines, plugin fuel/memory/tables, snapshot size).
  2. No unwrap / expect / panic! in library paths — typed errors only; tests may panic.
  3. Validate at the boundary — paths, URLs, manifests, guest findings, MCP JSON-RPC lines.
  4. Fail closed on trust — deny-by-default scope; --ci mute switches off unless opted in; plugins refused under CI without --allow-plugins.
  5. Keep functions short and reviewable — extract rather than grow a 200-line path that mixes I/O and policy.
  6. #![forbid(unsafe_code)] in library crates; the only exception is plugin-host, which isolates all wasmtime use in one crate.

These are checked in review and in CI (pnpm check), not only in docs.

Supported versions

Only the latest released version receives security fixes. Pre-1.0, that means the current 0.x line on npm and on GitHub Releases.

There aren't any published security advisories