safeey-cli 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ // src/engine/gitignore.js
2
+ // Answers one question: "would git ignore this path?" We use it to decide
3
+ // whether secrets in a .env file matter. A secret in a *properly ignored* .env
4
+ // is that file doing its job. A secret in a .env that ISN'T ignored is a leak
5
+ // waiting to be committed — that's the finding worth raising.
6
+ //
7
+ // Primary strategy: shell out to `git check-ignore`, which implements the full
8
+ // gitignore spec (nesting, negation, anchoring, core.excludesFile) correctly so
9
+ // we don't have to. Fallback: a focused matcher for trees that aren't git repos
10
+ // (where "no .gitignore at all" correctly resolves to "not ignored" -> flag it).
11
+
12
+ const { execFileSync } = require("child_process");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "out", "vendor", "coverage"]);
17
+
18
+ function isGitRepo(root) {
19
+ try {
20
+ const out = execFileSync("git", ["-C", root, "rev-parse", "--is-inside-work-tree"], {
21
+ encoding: "utf8",
22
+ stdio: ["ignore", "pipe", "ignore"],
23
+ });
24
+ return out.trim() === "true";
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ // Returns true (ignored), false (not ignored), or null (couldn't determine).
31
+ function gitCheckIgnore(root, relPath) {
32
+ try {
33
+ execFileSync("git", ["-C", root, "check-ignore", "-q", "--", relPath], { stdio: "ignore" });
34
+ return true; // exit 0 => path is ignored
35
+ } catch (e) {
36
+ if (e && e.status === 1) return false; // exit 1 => not ignored
37
+ return null; // 128 / git missing => unknown
38
+ }
39
+ }
40
+
41
+ // ---- Non-git fallback ------------------------------------------------------
42
+
43
+ // Convert a single gitignore pattern to { re, negate, dirOnly } relative to the
44
+ // directory the .gitignore lives in. Intentionally a pragmatic subset of the
45
+ // spec — enough to resolve the env-file cases the tool cares about.
46
+ function patternToMatcher(raw) {
47
+ let pat = raw.replace(/\r$/, "");
48
+ if (!pat || pat.startsWith("#")) return null;
49
+ // Trim unescaped trailing whitespace.
50
+ pat = pat.replace(/((?:[^\\ ]|\\ )*?)\s+$/, "$1");
51
+ if (!pat) return null;
52
+
53
+ let negate = false;
54
+ if (pat.startsWith("!")) { negate = true; pat = pat.slice(1); }
55
+
56
+ let dirOnly = false;
57
+ if (pat.endsWith("/")) { dirOnly = true; pat = pat.slice(0, -1); }
58
+
59
+ const anchored = pat.startsWith("/") || pat.includes("/");
60
+ if (pat.startsWith("/")) pat = pat.slice(1);
61
+
62
+ // Escape regex metachars, then re-enable glob semantics.
63
+ let re = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&");
64
+ re = re.replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
65
+
66
+ const body = anchored ? `^${re}(?:/.*)?$` : `(?:^|/)${re}(?:/.*)?$`;
67
+ return { re: new RegExp(body), negate, dirOnly };
68
+ }
69
+
70
+ function readGitignores(root) {
71
+ const found = []; // { dir, matchers: [...] }
72
+ (function walk(dir) {
73
+ let entries = [];
74
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
75
+ const gi = path.join(dir, ".gitignore");
76
+ if (fs.existsSync(gi)) {
77
+ try {
78
+ const matchers = fs.readFileSync(gi, "utf8").split(/\n/).map(patternToMatcher).filter(Boolean);
79
+ found.push({ dir, matchers });
80
+ } catch { /* unreadable — skip */ }
81
+ }
82
+ for (const e of entries) {
83
+ if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) walk(path.join(dir, e.name));
84
+ }
85
+ })(root);
86
+ return found;
87
+ }
88
+
89
+ function buildFallbackMatcher(root) {
90
+ const gitignores = readGitignores(root);
91
+ return function isIgnored(absPath) {
92
+ let decision = false;
93
+ for (const { dir, matchers } of gitignores) {
94
+ const rel = path.relative(dir, absPath);
95
+ if (!rel || rel.startsWith("..")) continue; // file not under this .gitignore
96
+ for (const m of matchers) {
97
+ if (m.re.test(rel)) decision = !m.negate;
98
+ }
99
+ }
100
+ return decision;
101
+ };
102
+ }
103
+
104
+ // ---- Public API ------------------------------------------------------------
105
+
106
+ function makeIgnoreChecker(root) {
107
+ const git = isGitRepo(root);
108
+ const fallback = git ? null : buildFallbackMatcher(root);
109
+ const cache = new Map();
110
+ return {
111
+ mode: git ? "git" : "fallback",
112
+ // true | false | null(unknown, git-mode only)
113
+ isIgnored(absPath) {
114
+ if (cache.has(absPath)) return cache.get(absPath);
115
+ let res;
116
+ if (git) {
117
+ const rel = path.relative(root, absPath) || path.basename(absPath);
118
+ res = gitCheckIgnore(root, rel);
119
+ } else {
120
+ res = fallback(absPath);
121
+ }
122
+ cache.set(absPath, res);
123
+ return res;
124
+ },
125
+ };
126
+ }
127
+
128
+ module.exports = { makeIgnoreChecker };
@@ -0,0 +1,46 @@
1
+ // src/engine/infra.js — run infra rules against a file classified by kind.
2
+ const { normalizeAll } = require("../rules/schema");
3
+ const { INFRA_RULES, classifyInfraFile } = require("../rules/infra");
4
+
5
+ // Normalize (fills category/owasp/etc.); keep the infra-specific fields around.
6
+ const RULES = normalizeAll(
7
+ INFRA_RULES.map((r) => ({ ...r, languages: ["*"] })),
8
+ "pattern"
9
+ ).map((n, i) => ({ ...n, kinds: INFRA_RULES[i].kinds, whole: INFRA_RULES[i].whole, absentRe: INFRA_RULES[i].absentRe }));
10
+
11
+ function analyzeInfra(code, file, base) {
12
+ const kind = classifyInfraFile(file, base, code);
13
+ if (!kind) return [];
14
+ const applicable = RULES.filter((r) => r.kinds.includes(kind));
15
+ if (applicable.length === 0) return [];
16
+
17
+ const findings = [];
18
+ const lines = code.split(/\r?\n/);
19
+
20
+ for (const rule of applicable) {
21
+ if (rule.whole) {
22
+ // Whole-file rule: fires when re matches somewhere AND absentRe is absent.
23
+ if (rule.re.test(code) && rule.absentRe && !rule.absentRe.test(code)) {
24
+ findings.push(mk(rule, file, 1, 1));
25
+ }
26
+ continue;
27
+ }
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const line = lines[i];
30
+ if (line.length > 2000) continue;
31
+ const m = rule.re.exec(line);
32
+ if (m) findings.push(mk(rule, file, i + 1, (m.index ?? 0) + 1));
33
+ }
34
+ }
35
+ return findings;
36
+ }
37
+
38
+ function mk(rule, file, line, column) {
39
+ return {
40
+ ruleId: rule.id, title: rule.title, severity: rule.severity, confidence: rule.confidence,
41
+ category: rule.category, cwe: rule.cwe, owasp: rule.owasp,
42
+ file, line, column, message: rule.message, fix: rule.fix,
43
+ };
44
+ }
45
+
46
+ module.exports = { analyzeInfra };
@@ -0,0 +1,173 @@
1
+ // src/engine/osv.js — check installed dependencies against known CVEs via OSV.dev.
2
+ //
3
+ // This is the only part of Safeey that makes a network request, so it is
4
+ // OPT-IN (`--osv`). It sends *only* package names and versions (never your
5
+ // source) to OSV.dev, Google's public vulnerability database, and maps any
6
+ // advisories back to findings. Reads the lockfile so it covers transitive
7
+ // dependencies, not just what's in package.json.
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+
12
+ const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
13
+ const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
14
+
15
+ // --- lockfile parsing -------------------------------------------------------
16
+
17
+ // Returns [{ name, version }] from the best lockfile we can find. npm v2/v3
18
+ // lockfiles ("packages" map) preferred; falls back to legacy "dependencies".
19
+ function readNpmLock(dir) {
20
+ const lockPath = path.join(dir, "package-lock.json");
21
+ if (!fs.existsSync(lockPath)) return null;
22
+ let data;
23
+ try { data = JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch { return null; }
24
+ const out = new Map();
25
+ if (data.packages) {
26
+ for (const [key, info] of Object.entries(data.packages)) {
27
+ if (!key || !info || !info.version) continue; // "" is the root project
28
+ const name = key.startsWith("node_modules/") ? key.slice(key.lastIndexOf("node_modules/") + 13) : info.name;
29
+ if (name) out.set(`${name}@${info.version}`, { name, version: info.version });
30
+ }
31
+ }
32
+ if (out.size === 0 && data.dependencies) {
33
+ const walk = (deps) => {
34
+ for (const [name, info] of Object.entries(deps)) {
35
+ if (info && info.version) out.set(`${name}@${info.version}`, { name, version: info.version });
36
+ if (info && info.dependencies) walk(info.dependencies);
37
+ }
38
+ };
39
+ walk(data.dependencies);
40
+ }
41
+ return [...out.values()];
42
+ }
43
+
44
+ // Fallback: pin-only reads from package.json (no transitive coverage).
45
+ function readManifest(dir) {
46
+ const p = path.join(dir, "package.json");
47
+ if (!fs.existsSync(p)) return null;
48
+ let data;
49
+ try { data = JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
50
+ const out = [];
51
+ for (const field of ["dependencies", "devDependencies"]) {
52
+ for (const [name, range] of Object.entries(data[field] || {})) {
53
+ const version = String(range).replace(/^[\^~>=<\s]+/, "");
54
+ if (/^\d/.test(version)) out.push({ name, version });
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function collectPackages(root) {
61
+ return readNpmLock(root) || readManifest(root) || [];
62
+ }
63
+
64
+ // --- OSV API ----------------------------------------------------------------
65
+
66
+ async function postJson(url, body) {
67
+ const res = await fetch(url, {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify(body),
71
+ });
72
+ if (!res.ok) throw new Error(`OSV ${res.status}`);
73
+ return res.json();
74
+ }
75
+
76
+ // Map an OSV advisory + the package it hit into a Safeey finding.
77
+ function advisoryToFinding(pkg, vuln, rel) {
78
+ const sev = severityOf(vuln);
79
+ const id = vuln.id || (vuln.aliases && vuln.aliases[0]) || "advisory";
80
+ const summary = vuln.summary || (vuln.details ? vuln.details.slice(0, 140) : "known vulnerability");
81
+ const fixed = fixedVersion(vuln);
82
+ return {
83
+ ruleId: `osv/${id}`,
84
+ title: `Vulnerable dependency: ${pkg.name}@${pkg.version}`,
85
+ severity: sev,
86
+ confidence: "high",
87
+ category: "dependencies",
88
+ cwe: (vuln.database_specific && vuln.database_specific.cwe_ids && vuln.database_specific.cwe_ids[0]) || null,
89
+ owasp: "A06:2021 Vulnerable and Outdated Components",
90
+ file: rel,
91
+ line: 0,
92
+ column: 0,
93
+ message: `${pkg.name}@${pkg.version} is affected by ${id}: ${summary}`,
94
+ fix: fixText(pkg, vuln),
95
+ // Consumed by the auto-fixer (src/fix.js):
96
+ package: { name: pkg.name, version: pkg.version },
97
+ fixedVersion: fixed,
98
+ };
99
+ }
100
+
101
+ function fixText(pkg, vuln) {
102
+ const fixed = fixedVersion(vuln);
103
+ if (fixed) return `Upgrade ${pkg.name} to ${fixed} or later.`;
104
+ return `Upgrade ${pkg.name} to a patched version (see https://osv.dev/vulnerability/${vuln.id}).`;
105
+ }
106
+
107
+ function fixedVersion(vuln) {
108
+ for (const aff of vuln.affected || []) {
109
+ for (const range of aff.ranges || []) {
110
+ for (const ev of range.events || []) {
111
+ if (ev.fixed) return ev.fixed;
112
+ }
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ // OSV severity is inconsistent across sources; derive a coarse level.
119
+ function severityOf(vuln) {
120
+ const s = (vuln.database_specific && vuln.database_specific.severity) || "";
121
+ const t = String(s).toUpperCase();
122
+ if (t.includes("CRITICAL")) return "critical";
123
+ if (t.includes("HIGH")) return "high";
124
+ if (t.includes("MODERATE") || t.includes("MEDIUM")) return "medium";
125
+ if (t.includes("LOW")) return "low";
126
+ // CVSS vector present? crude parse of the base score.
127
+ const cvss = (vuln.severity || []).find((x) => /CVSS/.test(x.type));
128
+ if (cvss && cvss.score) {
129
+ const m = /\/AV:/.test(cvss.score) ? null : parseFloat(cvss.score);
130
+ if (m != null && !Number.isNaN(m)) return m >= 9 ? "critical" : m >= 7 ? "high" : m >= 4 ? "medium" : "low";
131
+ }
132
+ return "high"; // default: don't under-report an advisory
133
+ }
134
+
135
+ // Public entry. Returns { findings, checked, error }.
136
+ async function analyzeDependenciesOSV(root, rel = "package-lock.json") {
137
+ const pkgs = collectPackages(root);
138
+ if (pkgs.length === 0) return { findings: [], checked: 0, error: null };
139
+ if (typeof fetch !== "function") {
140
+ return { findings: [], checked: 0, error: "global fetch unavailable (needs Node 18+)" };
141
+ }
142
+
143
+ try {
144
+ // Batch query: returns per-package lists of vuln IDs only.
145
+ const queries = pkgs.map((p) => ({ version: p.version, package: { name: p.name, ecosystem: "npm" } }));
146
+ const batch = await postJson(OSV_BATCH_URL, { queries });
147
+ const results = (batch && batch.results) || [];
148
+
149
+ // Collect unique vuln IDs, fetch details once each, then map back.
150
+ const idToPkgs = new Map();
151
+ results.forEach((r, i) => {
152
+ for (const v of (r && r.vulns) || []) {
153
+ if (!idToPkgs.has(v.id)) idToPkgs.set(v.id, []);
154
+ idToPkgs.get(v.id).push(pkgs[i]);
155
+ }
156
+ });
157
+
158
+ const findings = [];
159
+ for (const [id, affectedPkgs] of idToPkgs) {
160
+ let detail;
161
+ try {
162
+ const res = await fetch(OSV_VULN_URL + id);
163
+ detail = res.ok ? await res.json() : { id };
164
+ } catch { detail = { id }; }
165
+ for (const pkg of affectedPkgs) findings.push(advisoryToFinding(pkg, detail, rel));
166
+ }
167
+ return { findings, checked: pkgs.length, error: null };
168
+ } catch (e) {
169
+ return { findings: [], checked: pkgs.length, error: e.message };
170
+ }
171
+ }
172
+
173
+ module.exports = { analyzeDependenciesOSV, collectPackages, advisoryToFinding, severityOf };
@@ -4,8 +4,9 @@
4
4
 
5
5
  const { normalizeAll } = require("../rules/schema");
6
6
  const { PATTERN_RULES } = require("../rules/patterns");
7
+ const { FRAMEWORK_RULES } = require("../rules/framework");
7
8
 
8
- const RULES = normalizeAll(PATTERN_RULES, "pattern");
9
+ const RULES = normalizeAll([...PATTERN_RULES, ...FRAMEWORK_RULES], "pattern");
9
10
 
10
11
  // Map file extension -> language tag used in rule.languages.
11
12
  const EXT_LANG = {
@@ -49,6 +49,37 @@ const GENERIC_SECRET = {
49
49
  // Lines we shouldn't flag for the generic rule: placeholders and env references.
50
50
  const IGNORE = /(process\.env|import\.meta\.env|os\.environ|getenv|example|placeholder|dummy|your[_-]?(key|token|secret)|xxxx+|<[^>]+>|\$\{|\{\{)/i;
51
51
 
52
+ // Shannon entropy in bits/char — high values mark random-looking strings
53
+ // (keys/tokens) rather than natural text or identifiers.
54
+ function shannonEntropy(s) {
55
+ const freq = {};
56
+ for (const ch of s) freq[ch] = (freq[ch] || 0) + 1;
57
+ let e = 0;
58
+ for (const ch in freq) { const p = freq[ch] / s.length; e -= p * Math.log2(p); }
59
+ return e;
60
+ }
61
+
62
+ const ENTROPY_RULE = { id: "secret/high-entropy-string", title: "High-entropy string (possible secret)", severity: "medium", confidence: "low" };
63
+ const KEYLIKE = /^[A-Za-z0-9+/_\-]{20,100}={0,2}$/;
64
+ const WORDY = /^[A-Za-z][a-z]+([A-Z][a-z]+)*$/; // camelCase words — not a key
65
+
66
+ // Flag quoted string literals that look like a high-entropy secret we didn't
67
+ // already match by provider/name. Conservative and low-confidence by design.
68
+ function entropyFindings(line, file, lineNo) {
69
+ const out = [];
70
+ const re = /['"]([^'"\n]{20,100})['"]/g;
71
+ let m;
72
+ while ((m = re.exec(line))) {
73
+ const val = m[1];
74
+ if (!KEYLIKE.test(val) || WORDY.test(val)) continue;
75
+ const threshold = /^[0-9a-f]+$/i.test(val) ? 3.2 : 4.0; // hex has lower per-char entropy
76
+ if (shannonEntropy(val) >= threshold) {
77
+ out.push(makeFinding({ ...ENTROPY_RULE }, file, lineNo, { 0: val, index: m.index + 1 }));
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
52
83
  function mask(s) {
53
84
  const t = s.replace(/['"]/g, "");
54
85
  if (t.length <= 10) return t.slice(0, 3) + "****";
@@ -90,7 +121,12 @@ function analyzeSecrets(code, file) {
90
121
 
91
122
  if (!matchedProvider) {
92
123
  const m = GENERIC_SECRET.re.exec(line);
93
- if (m && !IGNORE.test(line)) findings.push(makeFinding(GENERIC_SECRET, file, i + 1, m));
124
+ if (m && !IGNORE.test(line)) {
125
+ findings.push(makeFinding(GENERIC_SECRET, file, i + 1, m));
126
+ } else if (!IGNORE.test(line)) {
127
+ // Last resort: catch key-shaped high-entropy strings with no tell-tale name.
128
+ for (const f of entropyFindings(line, file, i + 1)) findings.push(f);
129
+ }
94
130
  }
95
131
  }
96
132
  return findings;
@@ -0,0 +1,50 @@
1
+ // src/engine/suppress.js — let users silence findings they've reviewed.
2
+ // Without this, aggressive rules just get the whole tool switched off. Two
3
+ // mechanisms: inline comments in code, and a .safeeyignore file for whole paths.
4
+
5
+ // Inline:
6
+ // foo(bar) // safeey-ignore js/command-injection
7
+ // // safeey-ignore-next-line js/sql-injection
8
+ // risky()
9
+ // A bare `safeey-ignore` with no rule id suppresses all findings on that line.
10
+ function fileSuppressions(code) {
11
+ const map = new Map(); // 1-based line -> Set(ruleId) | Set("*")
12
+ const lines = code.split(/\r?\n/);
13
+ lines.forEach((line, i) => {
14
+ const m = /safeey-ignore(-next-line)?\b[ \t]*([^\n]*)$/.exec(line);
15
+ if (!m) return;
16
+ const target = m[1] ? i + 2 : i + 1; // next-line -> the following line
17
+ const ids = (m[2] || "").trim().split(/[\s,]+/).filter(Boolean);
18
+ const set = map.get(target) || new Set();
19
+ if (ids.length === 0) set.add("*");
20
+ else ids.forEach((id) => set.add(id));
21
+ map.set(target, set);
22
+ });
23
+ return map;
24
+ }
25
+
26
+ function isSuppressed(map, line, ruleId) {
27
+ const set = map.get(line);
28
+ if (!set) return false;
29
+ return set.has("*") || set.has(ruleId);
30
+ }
31
+
32
+ // .safeeyignore: one glob-ish path pattern per line (# comments allowed).
33
+ // Supports `*` and `**`; matched against the path relative to the scan root.
34
+ function loadPathIgnores(readFile, rootJoin) {
35
+ const raw = readFile(rootJoin(".safeeyignore"));
36
+ if (!raw) return () => false;
37
+ const res = raw.split(/\r?\n/)
38
+ .map((l) => l.trim())
39
+ .filter((l) => l && !l.startsWith("#"))
40
+ .map(globToRe);
41
+ return (relPath) => res.some((re) => re.test(relPath.replace(/\\/g, "/")));
42
+ }
43
+
44
+ function globToRe(glob) {
45
+ let g = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
46
+ g = g.replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*").replace(/\?/g, "[^/]");
47
+ return new RegExp(`(^|/)${g}($|/)`);
48
+ }
49
+
50
+ module.exports = { fileSuppressions, isSuppressed, loadPathIgnores };
@@ -0,0 +1,182 @@
1
+ // src/engine/taint.js — lightweight taint tracking for JS/TS.
2
+ //
3
+ // The pattern/AST rules flag a dangerous *sink* regardless of whether its input
4
+ // is attacker-controlled. Taint tracking asks the better question: does
5
+ // untrusted input actually *reach* this sink? Following a value from source
6
+ // (a request field) to sink (exec, a query, a redirect) both catches real
7
+ // vulnerabilities the flat rules miss (input arrives via a variable) and avoids
8
+ // false positives (exec of a constant is fine).
9
+ //
10
+ // Scope: intraprocedural and best-effort. We resolve variables through Babel's
11
+ // scope bindings, propagate taint across assignments, string-building and common
12
+ // pass-through calls, but we do NOT do interprocedural (cross-function) flow.
13
+ // Findings are therefore high-confidence when they fire, but absence of a
14
+ // finding is not proof of safety. Kept deliberately conservative to stay quiet.
15
+
16
+ const parser = require("@babel/parser");
17
+ const traverseModule = require("@babel/traverse");
18
+ const traverse = traverseModule.default || traverseModule;
19
+ const { memberName } = require("../rules/helpers");
20
+
21
+ const PARSE_OPTS = {
22
+ sourceType: "unmodule",
23
+ errorRecovery: true,
24
+ allowReturnOutsideFunction: true,
25
+ plugins: ["jsx", "typescript", "classProperties", "decorators-legacy", "optionalChaining", "nullishCoalescingOperator"],
26
+ };
27
+
28
+ // Roots that represent untrusted external input.
29
+ const SOURCE_ROOTS = /^(req|request|ctx)$/;
30
+ // Full member paths that are sources on their own.
31
+ const SOURCE_MEMBERS = /^(req|request)\.(query|params|body|headers|cookies|url|originalUrl|hostname|ip)\b/;
32
+ const DIRECT_SOURCES = /^(process\.argv|location\.(search|hash|href|pathname)|document\.(URL|cookie|referrer)|window\.name)$/;
33
+
34
+ // Pass-through calls: the result stays tainted if the receiver/args are tainted.
35
+ const PASSTHROUGH = /(^|\.)(trim|toString|toLowerCase|toUpperCase|replace|replaceAll|slice|substring|substr|concat|padStart|padEnd|normalize|split|join|at|charAt|repeat)$/;
36
+ const PASSTHROUGH_FUNCS = /^(String|decodeURIComponent|decodeURI|encodeURIComponent|unescape|escape|Buffer\.from|JSON\.parse)$/;
37
+
38
+ // Sinks: callee (member) name -> { type, cwe, severity, requiresStringBuild }
39
+ const CALL_SINKS = [
40
+ { re: /(^|\.)(exec|execSync|spawn|spawnSync|execFile|execFileSync)$/, type: "command-injection", cwe: "CWE-78", severity: "critical", title: "User input reaches a shell/command execution sink" },
41
+ { re: /^(eval)$/, type: "code-injection", cwe: "CWE-95", severity: "critical", title: "User input reaches eval()" },
42
+ { re: /(^|\.)(query|execute|raw)$/, type: "sql-injection", cwe: "CWE-89", severity: "high", requiresStringBuild: true, title: "User input is string-built into a SQL query" },
43
+ { re: /(^|\.)redirect$/, type: "open-redirect", cwe: "CWE-601", severity: "medium", title: "User input reaches an HTTP redirect" },
44
+ { re: /(^|\.)(readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|readdir|readdirSync)$/, type: "path-traversal", cwe: "CWE-22", severity: "high", title: "User input reaches a filesystem path" },
45
+ { re: /(^|\.)(send|write|end)$/, type: "reflected-xss", cwe: "CWE-79", severity: "medium", title: "User input reaches an HTTP response body" },
46
+ ];
47
+
48
+ function rootName(node) {
49
+ let n = node;
50
+ while (n && n.type === "MemberExpression") n = n.object;
51
+ return n && n.type === "Identifier" ? n.name : null;
52
+ }
53
+
54
+ function isStringBuilt(node) {
55
+ if (!node) return false;
56
+ if (node.type === "TemplateLiteral") return node.expressions.length > 0;
57
+ if (node.type === "BinaryExpression" && node.operator === "+") return true;
58
+ return false;
59
+ }
60
+
61
+ // Is `node` tainted within the given scope? `seen` guards against cycles.
62
+ function isTainted(node, scope, seen) {
63
+ if (!node || typeof node !== "object") return false;
64
+
65
+ switch (node.type) {
66
+ case "MemberExpression": {
67
+ const full = memberName(node);
68
+ if (full && (SOURCE_MEMBERS.test(full) || DIRECT_SOURCES.test(full))) return true;
69
+ const root = rootName(node);
70
+ if (root && SOURCE_ROOTS.test(root)) return true;
71
+ // tainted if the object chain or a computed property is tainted
72
+ if (isTainted(node.object, scope, seen)) return true;
73
+ if (node.computed && isTainted(node.property, scope, seen)) return true;
74
+ return false;
75
+ }
76
+ case "Identifier": {
77
+ const full = node.name;
78
+ if (DIRECT_SOURCES.test(full)) return true;
79
+ if (SOURCE_ROOTS.test(full)) return true;
80
+ if (!scope) return false;
81
+ const binding = scope.getBinding(node.name);
82
+ if (!binding || seen.has(binding)) return false;
83
+ seen.add(binding);
84
+ const p = binding.path;
85
+ if (p && p.node) {
86
+ if (p.node.type === "VariableDeclarator" && p.node.init) return isTainted(p.node.init, p.scope || scope, seen);
87
+ // function param named like a source (e.g. the (req,res) handler param)
88
+ if ((p.node.type === "Identifier") && SOURCE_ROOTS.test(p.node.name)) return true;
89
+ }
90
+ // reassignments
91
+ for (const ref of binding.constantViolations || []) {
92
+ if (ref.node && ref.node.type === "AssignmentExpression" && isTainted(ref.node.right, ref.scope || scope, seen)) return true;
93
+ }
94
+ return false;
95
+ }
96
+ case "TemplateLiteral":
97
+ return node.expressions.some((e) => isTainted(e, scope, seen));
98
+ case "BinaryExpression":
99
+ if (node.operator === "+") return isTainted(node.left, scope, seen) || isTainted(node.right, scope, seen);
100
+ return false;
101
+ case "LogicalExpression":
102
+ return isTainted(node.left, scope, seen) || isTainted(node.right, scope, seen);
103
+ case "ConditionalExpression":
104
+ return isTainted(node.consequent, scope, seen) || isTainted(node.alternate, scope, seen);
105
+ case "CallExpression": {
106
+ const name = memberName(node.callee) || "";
107
+ if (PASSTHROUGH_FUNCS.test(name) || PASSTHROUGH.test(name)) {
108
+ if (node.callee.type === "MemberExpression" && isTainted(node.callee.object, scope, seen)) return true;
109
+ return node.arguments.some((a) => isTainted(a, scope, seen));
110
+ }
111
+ return false;
112
+ }
113
+ default:
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function analyzeTaint(code, file) {
119
+ let ast;
120
+ try {
121
+ ast = parser.parse(code, { ...PARSE_OPTS, sourceType: "module" });
122
+ } catch {
123
+ try { ast = parser.parse(code, { ...PARSE_OPTS, sourceType: "script" }); } catch { return []; }
124
+ }
125
+
126
+ const findings = [];
127
+ const pushed = new Set();
128
+
129
+ function record(node, sink, argNode) {
130
+ const line = node.loc ? node.loc.start.line : 0;
131
+ const col = node.loc ? node.loc.start.column + 1 : 0;
132
+ const key = `${sink.type}|${line}|${col}`;
133
+ if (pushed.has(key)) return;
134
+ pushed.add(key);
135
+ findings.push({
136
+ ruleId: `taint/${sink.type}`,
137
+ title: sink.title,
138
+ severity: sink.severity,
139
+ confidence: "high",
140
+ category: sink.type === "reflected-xss" ? "xss" : sink.type === "open-redirect" ? "auth" : sink.type === "path-traversal" ? "path" : "injection",
141
+ cwe: sink.cwe,
142
+ owasp: null,
143
+ file,
144
+ line,
145
+ column: col,
146
+ message: `${sink.title.replace(/^User input/, "Untrusted input")} — the value flows from a request/external source to this call without sanitisation.`,
147
+ fix: "Validate or encode the value for its context (parameterised queries, an allow-list for paths/redirects, argument arrays instead of shell strings) before it reaches this sink.",
148
+ });
149
+ }
150
+
151
+ traverse(ast, {
152
+ CallExpression(path) {
153
+ const name = memberName(path.node.callee);
154
+ if (!name) return;
155
+ for (const sink of CALL_SINKS) {
156
+ if (!sink.re.test(name)) continue;
157
+ const arg = path.node.arguments[0];
158
+ if (!arg) continue;
159
+ if (sink.requiresStringBuild && !isStringBuilt(arg)) continue;
160
+ if (isTainted(arg, path.scope, new Set())) { record(path.node, sink, arg); break; }
161
+ }
162
+ },
163
+ NewExpression(path) {
164
+ if (memberName(path.node.callee) === "Function") {
165
+ const arg = path.node.arguments[path.node.arguments.length - 1];
166
+ if (arg && isTainted(arg, path.scope, new Set())) {
167
+ record(path.node, { type: "code-injection", cwe: "CWE-95", severity: "critical", title: "User input reaches the Function constructor" }, arg);
168
+ }
169
+ }
170
+ },
171
+ AssignmentExpression(path) {
172
+ const left = memberName(path.node.left);
173
+ if (left && /(^|\.)(innerHTML|outerHTML)$/.test(left) && isTainted(path.node.right, path.scope, new Set())) {
174
+ record(path.node, { type: "reflected-xss", cwe: "CWE-79", severity: "high", title: "User input is assigned to innerHTML" }, path.node.right);
175
+ }
176
+ },
177
+ });
178
+
179
+ return findings;
180
+ }
181
+
182
+ module.exports = { analyzeTaint };