sourcewatch-sec
v0.2.1
Published
CWE-aligned static scanner for high-yield vulnerability patterns (SSRF, path traversal, prototype pollution, auth bypass, and more) in JS/TS codebases.
Maintainers
Readme
sourcewatch-sec
A zero-dependency, CWE-aligned static scanner for high-yield vulnerability patterns in JS/TS codebases — SSRF, path traversal, prototype pollution, auth-scheme bypass, timing-unsafe comparisons, open redirects, permissive CORS, and path-normalization mismatches.
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 across popular auth/framework/ORM libraries. Every finding comes with a file:line, a plain-English explanation of the risk, and the relevant CWE.
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 "timingSafeEqual" that's actually just ===. 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:
npx sourcewatch-sec scan .JSON or SARIF output (for CI / tooling / GitHub code scanning):
npx sourcewatch-sec scan . --json
npx sourcewatch-sec scan . --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-sec scan . --update-baseline .sourcewatch-sec-baseline.json # snapshot current findings
npx sourcewatch-sec scan . --baseline .sourcewatch-sec-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-sec
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: '.'
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
| 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 |
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. Seetest-fixtures/safe/vstest-fixtures/vulnerable/for exactly what does and doesn't fire. - 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. Example found:
fetch(req)was flagged as SSRF-risky purely because an unrelatedreq.urlreference happened to appear nearby in the same function, not becausereqitself traced back to it. This is an inherent ceiling of a regex/proximity approach, not something a single rule tweak fully closes — treat every finding as "verify this," never as a confirmed bug. - 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)(the standard way to read your own incoming request's path in a fetch handler) 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," not a claim that ceiling is exactly the "true" bug count. If you find another false-positive class, please open an issue with the file:line — that's exactly how the three above were found and fixed.
Contributing
New rules are welcome, especially ones distilled from a real, merged security-fix diff (the fix diff is the pattern). Open a PR with:
- The rule file under
src/rules/, 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
