Skip to content
Fallow home
All docs pages

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-public process.env secret, 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

FlagDescription
-f, --format <FORMAT>Output format: human (default), json, sarif, github-annotations, or github-summary. Other values exit 2
-q, --quietSuppress progress output
--explainInclude metric definitions and rule descriptions in the output
--summaryShow a compact human summary instead of per-finding detail
--ciCI mode: equivalent to --format sarif --fail-on-issues --quiet
--surfaceAdd the attack_surface[] inventory for agents to the JSON output
--fail-on-issuesExit with code 1 if fallow finds security candidates
--sarif-file <PATH>Write SARIF output to a file in addition to the primary output

Scoping

FlagDescription
[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-stdinRead 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

FlagDescription
--no-cacheDisable incremental caching
--threads <N>Number of parser threads

Runtime coverage

FlagDescription
--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

FlagDescription
--gate newFail (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-reachableFail (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, not FAIL.
  • SARIF keeps every result at level: note and puts the verdict in run.properties.fallowGate.
  • --format json adds a gate block (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 prefixExample
NODE_ENVprocess.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-only package, next/server, node:fs / node:fs/promises, or node:child_process (the node: and bare forms both count).
  • It imports a server-only next/headers API (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:

CategoryCWESink shape
dangerous-html79innerHTML / outerHTML / insertAdjacentHTML / dangerouslySetInnerHTML
template-escape-bypass79template-engine SafeString(...) wrapping a non-literal value
command-injection78child_process exec / execSync / spawn / spawnSync (import-provenance gated)
code-injection94eval / vm.runInNewContext
dynamic-regex1333RegExp(...) / new RegExp(...) with a non-literal pattern
redos-regex1333vulnerable regex literals tested with source-backed input
resource-amplification400source-backed size into Array(...) / new Array(...) / Buffer.alloc* / String.prototype.repeat / padStart / padEnd (directly Math.min-clamped sizes stay quiet)
dynamic-module-load95dynamic require(...)
sql-injection89query / execute with concatenation or interpolation, raw escape hatches (sql.raw, Prisma unsafe raw, Knex raw, sequelize.literal)
ssrf918fetch / got / ky / needle / request / axios / superagent / undici / http(s).request
path-traversal22path.join / path.resolve / node:fs path methods / route sendFile
header-injection113response setHeader / writeHead (a writeHead headers object with only literal names and literal values is not a candidate)
open-redirect601res.redirect / location.href / location.assign / window.open
postmessage-wildcard-origin346postMessage(..., "*")
tls-validation-disabled295HTTPS/TLS options with rejectUnauthorized: false, plus NODE_TLS_REJECT_UNAUTHORIZED = "0"
cleartext-transport319cleartext http:// URLs in fetch-like calls and WebSocket constructors
electron-unsafe-webpreferences1188Electron webPreferences with unsafe literal options
world-writable-permission732chmod / chmodSync with world-writable modes
insecure-temp-file377predictable temporary file paths in fs writes
mysql-multiple-statements89MySQL connection options with multipleStatements: true
permissive-cors942CORS wildcard origin with credentials
insecure-cookie614cookie options missing or disabling httpOnly / secure
mass-assignment915source-backed Object.assign(target, source)
weak-crypto327runtime-selectable hash or cipher algorithm
deprecated-cipher327crypto.createCipher / createDecipher (no IV, MD5-based KDF)
insecure-randomness338crypto.pseudoRandomBytes(...)
unsafe-buffer-alloc1188Buffer.allocUnsafe / allocUnsafeSlow (uninitialized memory)
unsafe-deserialization502js-yaml load / node-serialize
prototype-pollution1321__proto__ writes and recursive merge sources
zip-slip22archive extraction destination paths
nosql-injection943Mongo / Mongoose query object passthrough
ssti1336template engine compile / render calls
xxe611XML parse calls
secret-pii-log532source-backed secrets or request PII reaching logs
hardcoded-secret798provider-prefix credentials and high-entropy literals assigned to secret-shaped identifiers (include-required)
secret-to-network201a non-public process.env / import.meta.env secret reaching a network call body (fetch / axios / got / ...) via same-identifier flow (include-required)
llm-call-injection1427an untrusted source reaching the prompt/messages argument of a known LLM-call sink (taint-path gated, pinned to distinctive LLM SDK call shapes)
xpath-injection643xpath.select / select1 with a non-literal expression
jwt-alg-none347JWT signing with algorithm none
jwt-verify-missing-algorithms347jsonwebtoken verify calls missing an algorithms allowlist
webview-injection94react-native-webview injectJavaScript(...) / injectedJavaScript= (enabler-gated)
angular-trusted-html79Angular bypassSecurityTrust* (enabler-gated)
nextjs-open-redirect601Next.js redirect / permanentRedirect (enabler-gated)
dom-document-write79document.write / document.writeln
jquery-html79jQuery .html(value) (enabler-gated)
route-send-file22Express / 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.

  • severity is a review-priority tier (high, medium, or low). 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 to warning, and low candidates to note.
  • tainted-sink findings also have category (the catalogue id, for example "dangerous-html") and cwe (the CWE number of the category). client-server-leak findings have neither field.
  • tainted-sink findings can also include reachability.untrusted_source_trace when 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 with path, line, col, reason, and expression_kind
  • top_files[] counts
  • by_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 for client-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 captured callee). You can act on candidate.sink without reading the rest of the finding. URL-category sinks (SSRF, open redirect) can add url_shape when 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 a client_server boundary (a "use client" file in the trace) or a cross_module boundary (the source reaches the sink across one or more import hops). It also has an architecture_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:

  • survivors joins existing candidates with the verdicts of an external verifier.
  • blind-spots reshapes 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
FlagDescription
--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-candidateFail 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
FlagDescription
-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-stdinUnified 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

See also