fallow security
Find local security candidates for you or a coding agent to verify: server secrets that reach client code, and untrusted values that reach dangerous sinks. CLI reference for the opt-in fallow security command.
fallow security finds local security candidates for you or a coding agent to verify. Each candidate comes with a structural trace that shows the reviewer where to look. Two rule families ship:
client-server-leak(graph-structural): a"use client"file reads a non-publicprocess.envsecret, directly or through a module that it imports transitively.tainted-sink(a data-driven catalogue): syntactic candidates for dangerous sinks, across a catalogue of CWE categories.
Both families default to off. They run only under fallow security, never under bare fallow or the audit gate.
Findings are candidates, not confirmed vulnerabilities. Fallow reports a structural trace, so an agent or a human can verify if a secret can actually reach client-bundled code, or if untrusted input can actually reach the reported sink.
fallow security
Options
Output
| Flag | Description |
|---|---|
-f, --format <FORMAT> | Output format: human (default), json, sarif, github-annotations, or github-summary. Other values exit 2 |
-q, --quiet | Suppress progress output |
--explain | Include metric definitions and rule descriptions in the output |
--summary | Show a compact human summary instead of per-finding detail |
--ci | CI mode: equivalent to --format sarif --fail-on-issues --quiet |
--surface | Add the attack_surface[] inventory for agents to the JSON output |
--fail-on-issues | Exit with code 1 if fallow finds security candidates |
--sarif-file <PATH> | Write SARIF output to a file in addition to the primary output |
Scoping
| Flag | Description |
|---|---|
[PATH] | Report only candidates in this file or directory. Fallow still builds the full project graph |
-r, --root <PATH> | Project root directory (default: current working directory) |
-c, --config <PATH> | Path to config file (default: auto-detected) |
--changed-since <REF> (alias: --base) | Report only candidates whose client anchor or trace hops touch files changed since a git ref |
--file <PATH> | Report only candidates whose finding anchor or trace hop matches the selected file. Repeat to select multiple files. Fallow still analyzes the full graph |
--diff-file <PATH> | Keep only candidates on added hunks of the client anchor or the import trace. Fallow keeps secret-source hops at file level, because it does not yet store member-access spans. Use - to read from stdin. |
--diff-stdin | Read the unified diff from stdin |
-w, --workspace <PATTERNS> | Scope output to selected workspace packages |
--changed-workspaces <REF> | Scope output to workspace packages touched since the given git ref |
Performance
| Flag | Description |
|---|---|
--no-cache | Disable incremental caching |
--threads <N> | Number of parser threads |
Runtime coverage
| Flag | Description |
|---|---|
--runtime-coverage <PATH> | Add production runtime state to tainted-sink candidates and use it as an extra ranking signal. Accepts a V8 coverage directory, a single V8 JSON file, or an Istanbul coverage map JSON file. A single local capture is free. Continuous or multi-capture monitoring needs a license. See fallow license. |
--min-invocations-hot <N> | Threshold for hot-path classification when --runtime-coverage is active (default: 100) |
Regression gate
| Flag | Description |
|---|---|
--gate new | Fail (exit code 8) only when the change adds a NEW security-sink candidate on the changed lines. The existing candidate backlog does not fail the gate. Requires a diff source (--changed-since, --diff-file, or --diff-stdin). |
--gate newly-reachable | Fail (exit code 8) when an existing security candidate in the source becomes reachable from project entry points, compared with the base ref. Requires --changed-since <ref>, because this mode analyzes the base tree and not only a line diff. |
A refactor that only touches a file with an existing sink passes. A change fails when, on a changed line, it adds a new sink or connects a new untrusted source to an existing sink.
Gated findings stay unverified:
- The human output says
REVIEW REQUIRED, notFAIL. - SARIF keeps every result at
level: noteand puts the verdict inrun.properties.fallowGate. --format jsonadds agateblock (mode/verdict/new_count).
newly-reachable ignores candidates that are unreachable in both trees. In this mode, diff-only inputs (--diff-file / --diff-stdin) exit 2. When newly-reachable cannot map the analysis root into the temporary base worktree, it prints a warning on stderr and analyzes the whole worktree.
Exit codes:
- 8: the change adds a new candidate on the changed lines.
- 0: clean (or a docs-only or empty diff).
- 2: the gate could not compute the diff, for example because of an unfetched ref on a shallow clone, a bad ref, or a directory that is not a git repo. The gate fails loudly and never passes silently.
Exit 8 is dedicated and stable. A pipeline can soft-gate it without allow-listing real errors (GitLab allow_failure: exit_codes: [8]).
On a shallow clone, the merge-base can be missing. In GitHub Actions, set
fetch-depth: 0 on actions/checkout. In GitLab CI, set GIT_DEPTH: 0.
# GitHub Action / generic CI: gate the PR's committed range
fallow security --gate new --changed-since "$BASE_SHA"
# Gate existing sinks that became entry-point reachable
fallow security --gate newly-reachable --changed-since "$BASE_SHA"
# Pre-commit hook: gate the STAGED content (not committed HEAD)
git diff --cached --unified=0 | fallow security --gate new --diff-stdin
Rule: client-server-leak
This rule finds server secrets that can end up in client code. The detector starts at each file with a top-level "use client" directive and follows static imports. It reports a candidate when the client boundary can reach a module that reads a non-public process.env value.
Fallow excludes env values that are public by convention:
| Public prefix | Example |
|---|---|
NODE_ENV | process.env.NODE_ENV |
NEXT_PUBLIC_* | process.env.NEXT_PUBLIC_API_URL |
VITE_* | process.env.VITE_API_URL |
NUXT_PUBLIC_* | process.env.NUXT_PUBLIC_SITE_URL |
REACT_APP_* | process.env.REACT_APP_API_URL |
PUBLIC_* | process.env.PUBLIC_SITE_URL |
GATSBY_* | process.env.GATSBY_SITE_URL |
EXPO_PUBLIC_* | process.env.EXPO_PUBLIC_API_URL |
STORYBOOK_* | process.env.STORYBOOK_THEME |
The output counts dynamic import() edges that the graph cannot follow as unresolved edge files. An empty finding list with a non-zero unresolved count does not mean that the code is clean.
Fallow checks the same client cone (the modules that "use client" files reach) against a second sink set. It reports these findings as the server-only-import category: a "use client" file that transitively reaches a server-only module. A module counts as server-only when one of these is true:
- It has a
"use server"directive. - It imports the
server-onlypackage,next/server,node:fs/node:fs/promises, ornode:child_process(thenode:and bare forms both count). - It imports a server-only
next/headersAPI (cookies,headers,draftMode).
The sink set is narrow on purpose, to avoid false positives. Fallow excludes a module that comes in only through next/dynamic(() => import('./x'), { ssr: false }), the sanctioned client-only escape hatch. The human output gives this category its own label, and SARIF uses the rule id security/server-only-import.
The secret-leak check and the server-only check are independent. A client cone that reads a secret and also reaches server-only code produces one finding for each check.
Rule: tainted-sink (catalogue)
tainted-sink is a data-driven catalogue of syntactic sink candidates. It finds values that go into known dangerous APIs. client-server-leak is a graph-reachability rule. tainted-sink instead flags a call, a member assignment, or a tagged template that reaches a known dangerous sink.
Most catalogue rows require a non-literal argument. A few narrow rows check literals, and flag deterministic unsafe values such as wildcard postMessage origins, weak crypto algorithms, disabled TLS validation, and JWT algorithm issues.
Each catalogue finding has kind: "tainted-sink", a category (the catalogue id), and a cwe number. The catalogue ships these categories:
| Category | CWE | Sink shape |
|---|---|---|
dangerous-html | 79 | innerHTML / outerHTML / insertAdjacentHTML / dangerouslySetInnerHTML |
template-escape-bypass | 79 | template-engine SafeString(...) wrapping a non-literal value |
command-injection | 78 | child_process exec / execSync / spawn / spawnSync (import-provenance gated) |
code-injection | 94 | eval / vm.runInNewContext |
dynamic-regex | 1333 | RegExp(...) / new RegExp(...) with a non-literal pattern |
redos-regex | 1333 | vulnerable regex literals tested with source-backed input |
resource-amplification | 400 | source-backed size into Array(...) / new Array(...) / Buffer.alloc* / String.prototype.repeat / padStart / padEnd (directly Math.min-clamped sizes stay quiet) |
dynamic-module-load | 95 | dynamic require(...) |
sql-injection | 89 | query / execute with concatenation or interpolation, raw escape hatches (sql.raw, Prisma unsafe raw, Knex raw, sequelize.literal) |
ssrf | 918 | fetch / got / ky / needle / request / axios / superagent / undici / http(s).request |
path-traversal | 22 | path.join / path.resolve / node:fs path methods / route sendFile |
header-injection | 113 | response setHeader / writeHead (a writeHead headers object with only literal names and literal values is not a candidate) |
open-redirect | 601 | res.redirect / location.href / location.assign / window.open |
postmessage-wildcard-origin | 346 | postMessage(..., "*") |
tls-validation-disabled | 295 | HTTPS/TLS options with rejectUnauthorized: false, plus NODE_TLS_REJECT_UNAUTHORIZED = "0" |
cleartext-transport | 319 | cleartext http:// URLs in fetch-like calls and WebSocket constructors |
electron-unsafe-webpreferences | 1188 | Electron webPreferences with unsafe literal options |
world-writable-permission | 732 | chmod / chmodSync with world-writable modes |
insecure-temp-file | 377 | predictable temporary file paths in fs writes |
mysql-multiple-statements | 89 | MySQL connection options with multipleStatements: true |
permissive-cors | 942 | CORS wildcard origin with credentials |
insecure-cookie | 614 | cookie options missing or disabling httpOnly / secure |
mass-assignment | 915 | source-backed Object.assign(target, source) |
weak-crypto | 327 | runtime-selectable hash or cipher algorithm |
deprecated-cipher | 327 | crypto.createCipher / createDecipher (no IV, MD5-based KDF) |
insecure-randomness | 338 | crypto.pseudoRandomBytes(...) |
unsafe-buffer-alloc | 1188 | Buffer.allocUnsafe / allocUnsafeSlow (uninitialized memory) |
unsafe-deserialization | 502 | js-yaml load / node-serialize |
prototype-pollution | 1321 | __proto__ writes and recursive merge sources |
zip-slip | 22 | archive extraction destination paths |
nosql-injection | 943 | Mongo / Mongoose query object passthrough |
ssti | 1336 | template engine compile / render calls |
xxe | 611 | XML parse calls |
secret-pii-log | 532 | source-backed secrets or request PII reaching logs |
hardcoded-secret | 798 | provider-prefix credentials and high-entropy literals assigned to secret-shaped identifiers (include-required) |
secret-to-network | 201 | a non-public process.env / import.meta.env secret reaching a network call body (fetch / axios / got / ...) via same-identifier flow (include-required) |
llm-call-injection | 1427 | an untrusted source reaching the prompt/messages argument of a known LLM-call sink (taint-path gated, pinned to distinctive LLM SDK call shapes) |
xpath-injection | 643 | xpath.select / select1 with a non-literal expression |
jwt-alg-none | 347 | JWT signing with algorithm none |
jwt-verify-missing-algorithms | 347 | jsonwebtoken verify calls missing an algorithms allowlist |
webview-injection | 94 | react-native-webview injectJavaScript(...) / injectedJavaScript= (enabler-gated) |
angular-trusted-html | 79 | Angular bypassSecurityTrust* (enabler-gated) |
nextjs-open-redirect | 601 | Next.js redirect / permanentRedirect (enabler-gated) |
dom-document-write | 79 | document.write / document.writeln |
jquery-html | 79 | jQuery .html(value) (enabler-gated) |
route-send-file | 22 | Express / Fastify / Hono route sendFile (enabler-gated) |
The catalogue is deliberately conservative. A non-literal argument is a signal to verify, not proof of a vulnerability. Fallow does not prove that the value is attacker-controlled or that it reaches the sink unsanitized. The agent does the verification.
The output counts sink-shaped nodes whose callee fallow cannot resolve to a static path (dynamic dispatch, computed members, aliased bindings) as unresolved_callee_sites. When present, unresolved_callee_diagnostics adds bounded sample locations, top files, and reason counts for follow-up review. As with client-server-leak, an empty finding list with a non-zero count does not mean that the code is clean.
Sanitizer-aware suppression
Fallow does not report flows through trusted local sanitizers. The detector recognizes:
- local HTML escape helpers that the syntax proves safe
- renderer helpers whose dynamic HTML fragments all go through a sanitizer
- SQL identifier quoting helpers in identifier positions
Mixed HTML or SQL templates with unsanitized dynamic fragments still report as candidates. SQL identifier quoting does not count as value parameterization.
Enabling categories
To choose which catalogue categories run, set security.categories in the config:
{
"security": {
"categories": {
"include": ["dangerous-html", "command-injection", "hardcoded-secret"],
"exclude": []
}
}
}
When both lists are empty, the ordinary catalogue categories run. hardcoded-secret and secret-to-network are include-required on purpose. They run only when you list them in security.categories.include.
secret-to-network is opt-in because legitimate auth also sends a secret in a network call, for example a bearer token to its own provider. Each candidate has the destination in candidate.network.destination. It holds the request URL when the URL is a literal, and is absent when the URL is dynamic. Use it to tell exfiltration from intended auth. Fallow never treats public-by-convention env vars (NEXT_PUBLIC_, VITE_, REACT_APP_, ...) as secrets.
Suppression
To suppress a known false positive, add a file-level comment. Each rule has its own token:
// fallow-ignore-file security-client-server-leak
"use client";
// fallow-ignore-file security-sink
const el = document.querySelector(".out");
el.innerHTML = render(userInput);
One security-sink token covers every catalogue category. Suppress a finding only after you verify that the value cannot reach the sink unsanitized. For example, the input is a trusted constant, is server-only, or is sanitized upstream.
JSON output
--format json returns a typed root envelope with kind: "security".
{
"kind": "security",
"schema_version": 8,
"version": "3.30.0",
"elapsed_ms": 42,
"config": {
"rules": {
"security_client_server_leak": {
"configured": "off",
"effective": "warn"
},
"security_sink": {
"configured": "off",
"effective": "warn"
}
},
"categories_include": null,
"categories_exclude": null
},
"security_findings": [],
"unresolved_edge_files": 0,
"unresolved_callee_sites": 0,
"unresolved_callee_diagnostics": null
}
When a run writes the --sarif-file document, the output has a root request_outcomes object. Its sarif-file entry is applied and names the path that the run wrote. This tells a run that wrote the document apart from a run that did not request one. A failed write makes the run exit 2 with an error document, so no envelope reports that entry as unapplied.
fallow security --summary --format json --quiet returns the same kind, schema_version, version, elapsed_ms, and config metadata. It replaces the candidate arrays with aggregate counts in summary:
{
"kind": "security",
"summary": {
"security_findings": 0,
"by_severity": {
"high": 0,
"medium": 0,
"low": 0
},
"by_category": {},
"by_reachability": {
"entry_reachable": 0,
"untrusted_source_reachable": 0,
"arg_level": 0,
"module_level": 0,
"crosses_boundary": 0,
"source_backed": 0
},
"by_runtime_state": {
"runtime_hot": 0,
"runtime_cold": 0,
"never_executed": 0,
"low_traffic": 0,
"coverage_unavailable": 0,
"runtime_unknown": 0,
"not_collected": 0
},
"unresolved_edge_files": 0,
"unresolved_callee_sites": 0,
"attack_surface_entries": 0
}
}
attack_surface is present only when you pass --surface. Use it when an agent verifier needs the source-to-sink path context and the defensive-boundary prompts.
Each finding has kind, path, line, col, evidence, trace, actions, severity, and an optional reachability.
severityis a review-priority tier (high,medium, orlow). Fallow derives it from reachability, boundary, source-backed, and runtime-hot signals. It is not a verified vulnerability verdict, and it does not change the gate or the exit codes. SARIF maps high and medium candidates towarning, and low candidates tonote.tainted-sinkfindings also havecategory(the catalogue id, for example"dangerous-html") andcwe(the CWE number of the category).client-server-leakfindings have neither field.tainted-sinkfindings can also includereachability.untrusted_source_tracewhen a module with a known untrusted source imports the sink module. This trace is context for ranking and triage only. It does not prove that a specific value reaches the sink.
When reachability.reachable_from_untrusted_source is set, reachability.taint_confidence gives the strength of the association:
"arg-level"(the stronger candidate): the sink argument traces back to a source read in the same module, directly or through up to three chained local bindings. The first hop of the trace points at the line of the actual source read."module-level"(the weaker candidate): the sink is only in a module that a source reaches over the import graph. The source hops use the role"module-source"and never imply a proven value path.
Rank candidates with this field. Do not parse the evidence text.
When present, unresolved_callee_diagnostics adds bounded metadata about unresolved callees for follow-up review:
sampled[]rows withpath,line,col,reason, andexpression_kindtop_files[]countsby_reason[]counts- the sample and top-file limits that fallow applied
This is blind-spot metadata, not a finding list. It follows the same --file, --workspace, --changed-since, and --gate new scoping as the security candidates.
Agent-actionable candidate record
Each finding also has a candidate record, an optional taint_flow triple, and a stable finding_id. An agent can act on these fields without parsing the evidence string.
{
"finding_id": "9a705395d3b50465",
"kind": "tainted-sink",
"category": "command-injection",
"cwe": 78,
"path": "src/runner.ts",
"line": 4,
"col": 2,
"candidate": {
"source_kind": null,
"sink": {
"path": "src/runner.ts",
"line": 4,
"col": 2,
"category": "command-injection",
"cwe": 78,
"callee": "child_process.exec"
},
"boundary": { "client_server": false, "cross_module": true }
},
"taint_flow": {
"source": { "path": "src/route.ts", "line": 1, "col": 9 },
"sink": { "path": "src/runner.ts", "line": 4, "col": 2 },
"path": { "intra_module": false, "cross_module_hops": 1 }
}
}
The candidate record has three slots:
source_kind: the kind of untrusted input that reaches the sink, as a stable catalogue source id such as"http-request-input","process-env","process-argv","message-event-data", or"location-input". Absent when no untrusted source matched (always absent forclient-server-leak). Treat an unknown id as an untrusted source of unknown kind. Never drop a candidate because its id is unknown.sink: a complete description of the sink site (path,line,col,category,cwe, and the capturedcallee). You can act oncandidate.sinkwithout reading the rest of the finding. URL-category sinks (SSRF, open redirect) can addurl_shapewhen the shape is statically visible:"fixed-origin-dynamic-path"(a fixed origin with a dynamic path or query, the less alarming shape) or"dynamic-origin"(the origin itself is dynamic). The static prefix must complete the authority. A dynamic value directly after the host, in the port, or after userinfo makes the shape"dynamic-origin". A/,?, or#after the host completes it. A relative URL counts as fixed-origin only when a path segment comes before the dynamic value:`/path/${id}`is fixed-origin,`/${id}`is"dynamic-origin".boundary: whether the flow crosses aclient_serverboundary (a"use client"file in the trace) or across_moduleboundary (the source reaches the sink across one or more import hops). It also has anarchitecture_zone(from/to) when the anchor is part of a declared architecture-boundary violation.
There is no impact field. The verifying agent decides exploitability. Fallow flags candidates with structural evidence and a review-priority tier from severity.
taint_flow is present only when an untrusted source reaches the sink through imports. It has the { source, sink, path } shape that agent SAST tools expect. path is a compact summary (intra_module and cross_module_hops). The full ordered hop list is in reachability.untrusted_source_trace, and taint_flow does not duplicate it.
candidate.network is present only on secret-to-network candidates. Its destination field holds the URL of the network call when the URL is a static string literal. That case is usually intended auth to the credential's own provider. destination is absent when the destination is dynamic, which is the stronger exfiltration signal. Use this field to tell exfiltration from intended auth without reading the code again.
finding_id is a stable correlation id. It is identical across runs for the same rule, path, line, and column, and it matches the SARIF partialFingerprints["fallowSecurity/v2"] value of the finding. Two sinks on the same line get different IDs. Use it to track a candidate across runs (for example after a rebase) and to join JSON and SARIF output. IDs from earlier fallow versions hash only the rule, path, and line, so every ID changes when you upgrade. See Finding identity migration.
Subcommands
Two read-only subcommands support the agent verification loop. Neither one feeds analysis assumptions back into the candidate output:
survivorsjoins existing candidates with the verdicts of an external verifier.blind-spotsreshapes the unresolved-callee diagnostics that fallow already produces.
See Security agent verification for the full recipe and the fallow-security-verdict/v1 verdict schema.
survivors
Show only the candidates that a verifier kept. survivors joins the raw candidate output of fallow security --format json with the verdict file of the verifier, and renders the survivors. It does not rewrite the original candidate fields.
fallow security --format json --quiet > candidates.json
# (run your verifier, writing verdicts.json keyed by finding_id)
fallow security survivors --candidates candidates.json --verdicts verdicts.json
| Flag | Description |
|---|---|
--candidates <PATH> | Raw fallow security --format json candidate output (required) |
--verdicts <PATH> | Verifier verdict JSON file, one fallow-security-verdict/v1 entry per finding_id (required) |
--require-verdict-for-each-candidate | Fail with a structured exit 2 when a candidate has no matching verdict. Use it in CI that must review every candidate |
-f, --format <FORMAT> | Output format: human (default) or json |
-o, --output-file <PATH> | Write the report to a file instead of stdout |
The output reports summary.unverdicted, so you can tell reviewed candidates from unreviewed ones. The human output separates verifier dispositions from candidates that still need review. Without --require-verdict-for-each-candidate, an incomplete verdict file does not fail the run. The gap shows in summary.unverdicted.
survivors exits 2 when the candidate file has a duplicate finding_id, or when the verdict file has two verdicts for one finding_id. It also exits 2 when a verdict names a finding_id that is not in the candidate file. A verdict file from an earlier fallow version therefore fails against new candidates. Regenerate the candidates and the verdicts together.
blind-spots
Show a verifier where the structural trace stops. blind-spots groups the security callees that fallow could not resolve (dynamic dispatch, unmodeled framework entry points) into blind-spot output that you can act on. You can pass --file before or after the subcommand.
fallow security blind-spots --format json --quiet
| Flag | Description |
|---|---|
-f, --format <FORMAT> | Output format: human (default) or json |
--file <PATH> | Scope diagnostics to selected files (repeatable; accepted before or after the subcommand) |
--changed-since <REF> | Scope to files changed since this git ref |
--diff-file <PATH> / --diff-stdin | Unified diff for line-level scoping |
-w, --workspace <WORKSPACE> / --changed-workspaces <REF> | Scope output to selected or recently touched workspaces |
-r, --root <ROOT> / -c, --config <CONFIG> | Project root and config path |
-q, --quiet / --no-cache / --threads <N> | Standard analysis controls |
-o, --output-file <PATH> | Write the report to a file instead of stdout |
Examples
fallow security