sourcewatch-cli
v0.6.2
Published
Static analysis scanner for security vulnerabilities, performance issues, and correctness bugs in JS/TS codebases — npx CLI + GitHub Action
Maintainers
Readme
sourcewatch
A zero-dependency static scanner for JS/TS codebases covering three modes: security vulnerabilities (--mode sec), performance anti-patterns (--mode perf), and correctness bugs (--mode bugs). Run all three at once with --mode all (the default).
It does not claim to find every bug, and it does not claim every finding is exploitable. It flags candidates worth a human's attention — the same classes of bug that keep showing up in real, published CVEs and production incidents across popular auth/framework/ORM libraries. Every finding comes with a file:line, a plain-English explanation of the risk, and the relevant CWE or category.
Why
Most of these bug classes are boringly repetitive across projects: a baseURL built from unvalidated config reaching axios/fetch, a path.join fed a raw req.url, a __proto__ key surviving a dot-notation body parser, a readFileSync in a hot request handler, a .take(0) that returns all rows. They're easy to write, easy to miss in review, and easy to grep for once you know the shape. This tool encodes that shape.
Install / usage
No install needed — run directly:
# Scan everything (sec + perf + bugs)
npx sourcewatch scan .
# Scan only security rules
npx sourcewatch scan . --mode sec
# Scan only performance rules
npx sourcewatch scan . --mode perf
# Scan only correctness/bug rules
npx sourcewatch scan . --mode bugsHunt mode — framework-first GitHub discovery
Instead of scanning repos you already have, hunt discovers quality repos from GitHub by searching for framework/library usage patterns (e.g. express jsonwebtoken, fastify cors credentials), then runs all applicable rules against those repos' source files. Two phases:
For each active rule:
- Discover — search GitHub using that rule's
stackHints(framework/library terms specific to the bug class, e.g.express jsonwebtoken authorization headerfor bearer-scheme). Filter to non-forks with 500+ stars active within 18 months — repos of consequence. - Scan — fetch those repos' source files (up to 30, skipping dist/test) and run only that rule on them.
Requires the GitHub CLI (gh auth login once).
# Hunt across all rules/modes
npx sourcewatch hunt
# Hunt only security rules
npx sourcewatch hunt --mode sec
# Repos per stack hint (default 20)
npx sourcewatch hunt --limit 30
# Hunt + LLM verify findings
npx sourcewatch hunt --llm claude
npx sourcewatch hunt --llm qwen
# JSON output
npx sourcewatch hunt --jsonOutput includes a direct GitHub link per finding:
[HIGH] ssrf-unvalidated-outbound-url (CWE-918)
someorg/somerepo — src/api/client.ts:42
https://github.com/someorg/somerepo/blob/main/src/api/client.ts#L42
axios.create({ baseURL: config.baseUrl
-> Outbound request built from "config.baseUrl" ...LLM verification (optional)
Pass --llm <provider> to run a second-pass LLM review on every pattern-matched finding. Without the flag the tool behaves exactly as before — pure pattern matching, zero network calls.
# Verify findings with a local Ollama model (free, no API key)
npx sourcewatch scan . --llm qwen
# Verify findings with Claude Sonnet via the claude CLI (OAuth)
npx sourcewatch scan . --llm claude
# Any Ollama model by tag
npx sourcewatch scan . --llm qwen2.5:14bFor each finding the LLM returns a CONFIRMED / DISMISSED verdict with confidence, one-sentence reasoning, and an optional fix suggestion:
[HIGH] perf-redundant-recompute-in-loop
src/services/outreachService.js:454
-> Expensive expression (new RegExp) called inside a loop
[LLM:qwen] CONFIRMED (high) — new RegExp is called on every iteration; the pattern is a fixed string.
[FIX] Precompute the regular expression outside the loop.A summary line is printed at the end:
LLM [qwen]: 3 confirmed, 2 dismissed, 0 errored.Providers:
claude— uses theclaudeCLI (must be installed and authenticated via OAuth — no API key needed)qwen— shorthand forqwen2.5:7bvia Ollama running locally onlocalhost:11434- Any Ollama model tag (e.g.
llama3.1:8b,qwen2.5:14b) — passed directly to Ollama
Requirements for LLM mode:
claudeprovider:npm install -g @anthropic-ai/claude-codeand runclaudeonce to authenticateqwen/ Ollama provider: install Ollama andollama pull qwen2.5:7b
JSON or SARIF output (for CI / tooling / GitHub code scanning):
npx sourcewatch scan . --json
npx sourcewatch scan . --sarif
npx sourcewatch scan . --mode sec --sarifExit code is 1 if a finding at or above --fail-on's threshold is present (default high), 0 otherwise.
Config file
Drop a sourcewatch-sec.config.json (or .sourcewatch-secrc.json) at the scan root to tune it per-repo:
{
"ignorePaths": ["test/**", "**/fixtures/**", "vendor/**"],
"ignoreRules": ["path-decode-normalization-inconsistency"],
"failOn": "high"
}ignorePaths supports */**/? globs matched against the file path relative to the scan root. ignoreRules is a list of rule IDs (see the table below). failOn is high | medium | low | none.
Baseline (only flag new findings)
npx sourcewatch scan . --update-baseline .sourcewatch-baseline.json # snapshot current findings
npx sourcewatch scan . --baseline .sourcewatch-baseline.json # only report findings not in the baselineUseful for adopting the tool on an existing codebase without being buried in pre-existing findings on day one — baseline them, then only get flagged on new introductions.
GitHub Action
name: sourcewatch
on: [pull_request, push]
permissions:
contents: read
security-events: write # to upload SARIF to the Security tab
pull-requests: write # to post inline PR review comments
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: tsushanth/sourcewatch-sec@v0
with:
path: '.'
mode: 'all' # sec | perf | bugs | all
fail-on: 'high' # high | medium | low | none
comment-pr: 'true' # inline review comments on lines changed in the PR
upload-sarif: 'true' # results show up in the repo's Security tab- SARIF upload — findings appear natively in GitHub's Security → Code scanning alerts, alongside CodeQL/Dependabot results.
- Inline PR comments — only posted for findings that land on a line actually changed in the PR (diffed against the PR's patch), so an existing repo's pre-existing findings don't spam every PR. Deduped against comments already posted on that line.
security-events: write/pull-requests: writeare required for SARIF upload and PR comments respectively — omit either input if you don't want to grant that permission.
What it checks
Security rules (--mode sec)
| Rule | CWE | Severity |
|---|---|---|
| Unvalidated outbound URL (SSRF) | CWE-918 | high |
| Path traversal via unsanitized path.join/path.resolve | CWE-22 | high |
| Prototype pollution via unguarded dynamic-key assignment | CWE-1321 | high |
| Auth-scheme-agnostic bearer parsing | CWE-287 | medium |
| Timing-unsafe compare in a function named timingSafe*/constantTime* | CWE-208 | medium |
| Open redirect via unvalidated redirect target | CWE-601 | medium |
| CORS credentials:true + wildcard/reflected origin | CWE-942 | high |
| Path decode/normalization inconsistency (decodeURI vs decodeURIComponent, unnormalized static-file serving) | CWE-436 | medium |
Performance rules (--mode perf)
| Rule | Severity |
|---|---|
| Collection grows inside a loop/handler with no size cap (unbounded memory growth) | medium |
| Expensive expression (new RegExp / JSON.parse / Object.keys / Array.from) called inside a loop | medium |
| Synchronous fs call (readFileSync/writeFileSync/existsSync) inside a request handler or exported function | high |
Bug / correctness rules (--mode bugs)
| Rule | Severity |
|---|---|
| Falsy check on a numeric/length variable — 0 is a valid value but fails the truthiness test | medium |
| Predicate named isInvalid/isError/isOutOfRange used with .every()/.filter() — likely double negation | high |
| .take(0) or .limit(0) — many ORMs treat 0 as "no limit" returning all rows instead of none | high |
| Native automation click (robotjs/nut-js) in Electron IPC handler with no subsequent window.focus() — OS keyboard focus shifts to target window, silencing the renderer's keydown listeners | medium |
More rules land as they're validated against real, confirmed findings — see CONTRIBUTING.
Design principles
- Heuristic, not authoritative. This is regex/proximity-based, not an AST-level type-flow analysis. It will miss things (false negatives) and it will occasionally flag something already handled elsewhere in a way it can't see (false positives). Read the message on each finding — it's written to tell you exactly what to go verify.
- No false-positive-by-default design. Every rule requires a taint hint AND absence of a locally-visible guard before firing; guarded code paths (e.g. a value passed through a
validate*/safe*/sanitiz*function before reaching a sink) are recognized and suppressed. - Zero runtime dependencies. Pure Node built-ins (
fs,path). Nothing to audit in a supply-chain sense beyond Node itself. - Always verify manually. This tool is a first-pass triage aid, not a vulnerability-disclosure generator. Confirm exploitability with a real reproduction before reporting anything found here to a project's security team.
Known limitations (measured, not just claimed)
The two fixture files prove each rule fires on a hand-written example — they don't prove precision on real, messy code. Before v0.2.1, scanning 5 real open-source repos (directus, novu, cloudflare/workers-sdk, elysia, sequelize) surfaced concrete false-positive classes that the fixtures alone hadn't caught:
- Text-window taint checks aren't real data-flow. A rule looks at a fixed window of characters before a sink for a "taint hint" — it doesn't actually trace whether that specific hint is the value reaching the sink.
- A file-level gate can over-associate unrelated code. The prototype-pollution rule originally gated on "does this file mention dot-notation splitting anywhere," then flagged unrelated bracket-assignments elsewhere in the same file. Fixed in v0.2.1 by requiring the flagged assignment to actually index into the split's result variable.
- A "sink" needs to actually perform I/O.
new URL(x)alone does nothing dangerous — it was removed as an SSRF sink afternew URL(req.url)turned out to be ~90% of all SSRF findings on cloudflare/workers-sdk, all noise. - Test/e2e code triggers real-code patterns without real-code risk. Excluded by default (
test/,tests/,__tests__/,e2e/,*.test.*,*.spec.*) — override with"scanTests": truein the config file if you want them included.
.github/workflows/ci.yml now clones a real external repo (directus/directus) on every CI run and asserts the finding count stays under a known-good ceiling — a regression tripwire for "a rule change just got noisy again."
Contributing
New rules are welcome, especially ones distilled from a real, merged security-fix diff or a production postmortem. Open a PR with:
- The rule file under
src/rules/sec/,src/rules/perf/, orsrc/rules/bugs/, registered insrc/rules/index.js. - A
vulnerable.js-style fixture addition showing it fires, and asafe.js-style addition showing the guarded version doesn't.
License
MIT
