GraphNode
Back to all guides
SAST

Static Analysis for JavaScript: Common Vulnerability Patterns and How to Catch Them

| 10 min read |GraphNode Research

In November 2018, a maintainer named Dominic Tarr handed control of the popular npm package event-stream to a stranger who had emailed asking to help. Within weeks, the new maintainer published a release that pulled in a fresh dependency called flatmap-stream, and that dependency contained a payload designed to steal cryptocurrency wallets from a single downstream consumer: the Copay Bitcoin wallet. The malicious code was obfuscated, narrowly targeted, and shipped quietly to roughly two million weekly downloads of event-stream before researchers at the Node Security Platform unraveled it. A few months earlier, in July 2019, Snyk had disclosed CVE-2019-10744 in lodash, a prototype-pollution flaw in defaultsDeep that affected every Node.js application using a vulnerable lodash version — which, at the time, was almost all of them. Together, the two incidents framed the modern JavaScript security problem: the danger sits less in the code you write and more in the millions of lines you import, transformed into a single bundle that runs with the privileges of your application server.

Static analysis for JavaScript operates against that backdrop. The language is dynamic, the type system is bolted on through TypeScript or JSDoc, and a single line of req.body can flow through six middleware layers before it reaches a database driver. This guide walks through the JavaScript-specific patterns that SAST tools reliably catch in 2026, what vulnerable and fixed code looks like for the ones that matter most, and where the ecosystem's particular shape changes how detection has to work.

The JavaScript Vulnerability Landscape

The bug classes that dominate JavaScript and Node.js scanner findings cluster into a recognizable shape. Prototype pollution (CWE-1321) is the JavaScript-native flaw the rest of the ecosystem imitates: an attacker controls a property name in a recursive merge or deep-set helper and writes to __proto__, polluting Object.prototype for every object subsequently constructed in the process — see our prototype pollution deep dive for the full mechanics. Code injection through eval and the Function constructor (CWE-95) remains the most direct path from user input to remote code execution and shows up in template engines, expression evaluators, and homegrown rule systems. SQL injection in raw queries still appears in Express applications that bypass the parameterized API of their driver to interpolate user input into a query string. NoSQL injection in MongoDB query objects is the JavaScript-shaped variant — passing a parsed JSON object straight into find() lets an attacker substitute {"$ne": null} for a string and bypass authentication.

Cross-site scripting in innerHTML, React's dangerouslySetInnerHTML, and Vue's v-html remains the highest-volume client-side finding. Server-side request forgery in Node fetch, axios, and got happens whenever a user-supplied URL flows into an HTTP client without origin validation. Command injection through child_process.exec shows up wherever a shell-style API is reached for instead of execFile. Path traversal in fs.readFile continues to leak files when a user-supplied path component is joined without normalization. Hardcoded secrets, weak randomness from Math.random used for tokens, ReDoS from catastrophic backtracking in user-facing regexes, and typosquatting from npm packages one character off from popular ones round out the list.

Express SQL Injection: The Raw Query That Forgot About Strings

The pattern still ships in 2026 because the documentation for every database driver shows two ways to do the same thing, and the wrong one looks shorter. The vulnerable handler accepts a user identifier from the URL and concatenates it directly into a SQL string:

// VULNERABLE
const express = require('express');
const { Pool } = require('pg');

const app = express();
const pool = new Pool();

app.get('/users/:id', async (req, res) => {
  const result = await pool.query(
    `SELECT id, email, role FROM users WHERE id = ${req.params.id}`
  );
  res.json(result.rows);
});

A request to /users/1%20OR%201=1 returns every row in the table. The fix is the parameterized query that the driver has supported since the day it shipped, and the only reason the vulnerable form appeared is that the template literal looked clean enough to skip thinking about:

// FIXED
app.get('/users/:id', async (req, res) => {
  const result = await pool.query(
    'SELECT id, email, role FROM users WHERE id = $1',
    [req.params.id]
  );
  res.json(result.rows);
});

SAST catches this by tracing the flow from req.params into the first argument of pool.query and flagging it as a tainted-string sink. The detection holds across template literals, string concatenation with +, and String.prototype.replace chains that look like sanitization but are not. See our coverage of A03 Injection for the broader category.

SSRF in Node fetch: The URL the Attacker Wrote

Server-side request forgery in Node applications usually arrives through a feature that legitimately needs to fetch a URL on behalf of the user — a webhook tester, an open-graph preview, a remote-image importer. The vulnerable shape passes the user-supplied URL straight to the HTTP client:

// VULNERABLE
app.post('/preview', async (req, res) => {
  const response = await fetch(req.body.url);
  const text = await response.text();
  res.json({ preview: text.slice(0, 500) });
});

An attacker submits http://169.254.169.254/latest/meta-data/iam/security-credentials/ and reads cloud-instance metadata, or http://localhost:6379/ and probes an internal Redis. The fix is to validate the URL against an allowlist of permitted hosts and refuse private address ranges before the request goes out:

// FIXED
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');

async function isSafeUrl(rawUrl) {
  let parsed;
  try { parsed = new URL(rawUrl); } catch { return false; }
  if (!['http:', 'https:'].includes(parsed.protocol)) return false;
  const addresses = await dns.resolve(parsed.hostname);
  return addresses.every((addr) => {
    const ip = ipaddr.parse(addr);
    return ip.range() === 'unicast';
  });
}

app.post('/preview', async (req, res) => {
  if (!(await isSafeUrl(req.body.url))) {
    return res.status(400).json({ error: 'invalid url' });
  }
  const response = await fetch(req.body.url, { redirect: 'manual' });
  const text = await response.text();
  res.json({ preview: text.slice(0, 500) });
});

The DNS resolution and address check close the obvious bypasses; redirect: 'manual' prevents an attacker-controlled server from returning a 302 to http://169.254.169.254 and walking past the validation. SAST flags the unvalidated req.body.url reaching fetch; only careful flow analysis recognizes the validated form as safe.

Command Injection: exec vs execFile

child_process.exec spawns a shell to interpret its first argument, which means any character the shell treats as special — semicolons, backticks, dollar-parentheses, ampersands — becomes an injection vector when user input flows in. The vulnerable handler converts an uploaded file by interpolating a filename into a shell command:

// VULNERABLE
const { exec } = require('child_process');

app.post('/convert', (req, res) => {
  const { filename } = req.body;
  exec(`convert /uploads/${filename} /out/${filename}.png`, (err) => {
    if (err) return res.status(500).json({ error: 'convert failed' });
    res.json({ ok: true });
  });
});

A filename of x; rm -rf /tmp; # runs three commands. The fix is to switch to execFile, which spawns the binary directly without a shell and treats the second argument as a literal argv array:

// FIXED
const { execFile } = require('child_process');
const path = require('path');

app.post('/convert', (req, res) => {
  const { filename } = req.body;
  if (!/^[\w.-]+$/.test(filename)) {
    return res.status(400).json({ error: 'invalid filename' });
  }
  const input = path.join('/uploads', filename);
  const output = path.join('/out', `${filename}.png`);
  execFile('convert', [input, output], (err) => {
    if (err) return res.status(500).json({ error: 'convert failed' });
    res.json({ ok: true });
  });
});

The allowlist regex closes the path traversal that the path.join alone does not, and execFile closes the shell-injection class entirely. SAST flags the original because req.body reaches a known-dangerous sink; the fix moves the call to a safer API and adds a normalization layer that the analyzer recognizes as input validation.

Detection: What SAST Catches Cleanly in JavaScript

The free baseline starts with ESLint plus the eslint-plugin-security and eslint-plugin-no-unsanitized plugins, which catch a meaningful fraction of eval, Function, innerHTML, and child_process.exec findings on a syntactic basis. ESLint runs on every save in editors that have it configured, which is the cheapest possible feedback loop. The next layer is Semgrep, which ships a JavaScript and TypeScript ruleset with both pattern-based and limited taint-based rules and runs in CI fast enough to gate pull requests. Semgrep's strength is its readable rule format; teams add custom rules for their internal libraries within an afternoon. Snyk Code covers the commercial end with broader inter-procedural taint analysis and IDE plugins for VS Code, JetBrains, and the major editors.

The patterns that any modern JavaScript SAST should catch on the first pass are: tainted input flowing to eval, Function, setTimeout with a string argument, vm.runInNewContext family, child_process.exec and execSync, fs APIs without path normalization, raw-string queries to common database drivers, innerHTML and framework-specific HTML sinks, and unvalidated URLs reaching outbound HTTP clients. The patterns that need real data-flow analysis to catch reliably are prototype pollution in custom merge helpers, gadget chains across multiple files, and SSRF where the URL is composed from several pieces. See our SAST tools comparison for how the major scanners rank on JavaScript-specific benchmarks.

Where GraphNode SAST Fits

GraphNode SAST covers JavaScript and TypeScript as first-class targets in its 13+ language matrix, with inter-procedural data flow that traces user input from Express, Fastify, Koa, and Next.js entry points across middleware and ORM layers to the sinks where the bug class fires. The engine handles framework-specific shapes — React props, Vue refs, Next.js server actions, tRPC inputs — that generic analyzers miss, and surfaces findings on the pull request that introduced the unsafe flow. Scans run in CI in the same time budget as a typical test suite.

Closing

JavaScript security is shaped less by exotic vulnerabilities than by familiar ones moving through unfamiliar plumbing. SQL injection still exists; it just travels through a Promise chain now. Command injection still exists; it just goes through child_process instead of system(). The dynamic nature of the language makes static analysis harder than for typed compiled languages, and the npm ecosystem makes the question of "what is in your code" a moving target every time someone runs npm install. The teams that catch these patterns reliably wire ESLint and Semgrep into the editor and CI, treat their lockfile as a security artifact rather than a build detail, and pick a SAST scanner that understands the framework they actually use. The cost of catching a flow on the diff is one review comment; the cost of catching it after a credential leaks from cloud metadata is everything else.

GraphNode SAST analyzes JavaScript and TypeScript with full inter-procedural data flow across 13+ languages — request a demo.

Request Demo