securevibe 0.1.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,162 @@
1
+ // packages/cli/src/engine/deps/osv.ts
2
+ /**
3
+ * `securevibe db update` — the ONLY networked path. Fetches OSV.dev bulk
4
+ * per-ecosystem dumps, compacts each record to our Advisory shape, and writes
5
+ * ~/.securevibe/db/<eco>.json. The fetcher is injectable so unit tests stay
6
+ * offline; the real run is verified manually (the npm dump size is checked then).
7
+ * Nothing about the user's code or package list leaves the machine.
8
+ */
9
+ import { promises as fs } from "node:fs";
10
+ import path from "node:path";
11
+ import { userDbDir } from "./db.js";
12
+ const OSV_BASE = "https://osv-vulnerabilities.storage.googleapis.com";
13
+ const ECO_FILE = { npm: "npm/all.zip", PyPI: "PyPI/all.zip" };
14
+ export function cvssToSeverity(score) {
15
+ if (score >= 9.0)
16
+ return "critical";
17
+ if (score >= 7.0)
18
+ return "high";
19
+ if (score >= 4.0)
20
+ return "medium";
21
+ if (score > 0)
22
+ return "low";
23
+ return "info";
24
+ }
25
+ function severityOf(rec) {
26
+ const dbScore = rec?.database_specific?.cvss?.score;
27
+ if (typeof dbScore === "number")
28
+ return cvssToSeverity(dbScore);
29
+ const txt = rec?.database_specific?.severity;
30
+ if (typeof txt === "string") {
31
+ const s = txt.toLowerCase();
32
+ if (s === "critical")
33
+ return "critical";
34
+ if (s === "high")
35
+ return "high";
36
+ if (s === "moderate" || s === "medium")
37
+ return "medium";
38
+ if (s === "low")
39
+ return "low";
40
+ }
41
+ return "medium"; // unknown severity → medium, never silently "info"
42
+ }
43
+ function rangesOf(affected) {
44
+ const out = [];
45
+ for (const r of affected?.ranges ?? []) {
46
+ let introduced;
47
+ for (const ev of r.events ?? []) {
48
+ if (ev.introduced !== undefined)
49
+ introduced = ev.introduced;
50
+ if (ev.fixed !== undefined)
51
+ out.push({ introduced, fixed: ev.fixed });
52
+ }
53
+ if (introduced !== undefined && !(r.events ?? []).some((e) => e.fixed !== undefined)) {
54
+ out.push({ introduced });
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ /** Compact a flat list of OSV records into { lowercaseName: Advisory[] }. */
60
+ export function compactOsvRecords(records) {
61
+ const byPkg = {};
62
+ for (const rec of records) {
63
+ for (const aff of rec.affected ?? []) {
64
+ const name = aff?.package?.name;
65
+ if (!name)
66
+ continue;
67
+ const advisory = {
68
+ id: rec.id,
69
+ aliases: rec.aliases ?? [],
70
+ severity: severityOf(rec),
71
+ summary: rec.summary ?? rec.details ?? rec.id,
72
+ ranges: rangesOf(aff),
73
+ url: `https://osv.dev/vulnerability/${rec.id}`,
74
+ };
75
+ if (advisory.ranges.length === 0)
76
+ continue;
77
+ const key = name.toLowerCase();
78
+ (byPkg[key] ??= []).push(advisory);
79
+ }
80
+ }
81
+ return byPkg;
82
+ }
83
+ export async function updateDb(opts = {}) {
84
+ const doFetch = opts.fetchImpl ?? fetch;
85
+ const ecosystems = opts.ecosystems ?? ["npm", "PyPI"];
86
+ const destDir = opts.destDir ?? userDbDir();
87
+ await fs.mkdir(destDir, { recursive: true });
88
+ const date = new Date().toISOString().slice(0, 10);
89
+ let count = 0;
90
+ for (const eco of ecosystems) {
91
+ const url = `${OSV_BASE}/${ECO_FILE[eco]}`;
92
+ const res = await doFetch(url);
93
+ if (!res.ok)
94
+ throw new Error(`OSV fetch failed for ${eco}: ${res.status}`);
95
+ const buf = Buffer.from(await res.arrayBuffer());
96
+ // Stream the zip entry-by-entry and compact in batches so the npm dump
97
+ // (~200MB compressed, multi-GB decompressed) never materializes in full.
98
+ const byPkg = {};
99
+ await readOsvZip(buf, (records) => {
100
+ const part = compactOsvRecords(records);
101
+ for (const key in part)
102
+ (byPkg[key] ??= []).push(...part[key]);
103
+ });
104
+ count += Object.keys(byPkg).length;
105
+ const out = { date, ecosystems: { [eco]: byPkg } };
106
+ await fs.writeFile(path.join(destDir, `${eco}.json`), JSON.stringify(out), "utf8");
107
+ }
108
+ return { destDir, count, date };
109
+ }
110
+ /**
111
+ * Stream an OSV "all.zip" buffer, invoking `onRecords` with batches of parsed
112
+ * advisory JSON objects. The zip holds one JSON file per advisory and is written
113
+ * with data descriptors (local headers carry zero sizes), so we read sizes from
114
+ * the central directory via yauzl rather than scanning local headers. Batching +
115
+ * discard keeps peak memory at roughly the compressed buffer plus one batch.
116
+ */
117
+ export async function readOsvZip(buf, onRecords, batchSize = 2000) {
118
+ const yauzl = (await import("yauzl")).default;
119
+ await new Promise((resolve, reject) => {
120
+ yauzl.fromBuffer(buf, { lazyEntries: true }, (err, zip) => {
121
+ if (err || !zip)
122
+ return reject(err ?? new Error("failed to open OSV zip"));
123
+ let batch = [];
124
+ const flush = () => {
125
+ if (batch.length) {
126
+ onRecords(batch);
127
+ batch = [];
128
+ }
129
+ };
130
+ zip.on("entry", (entry) => {
131
+ if (!entry.fileName.toLowerCase().endsWith(".json")) {
132
+ zip.readEntry();
133
+ return;
134
+ }
135
+ zip.openReadStream(entry, (streamErr, stream) => {
136
+ if (streamErr || !stream)
137
+ return reject(streamErr ?? new Error("read stream failed"));
138
+ const chunks = [];
139
+ stream.on("data", (c) => chunks.push(c));
140
+ stream.on("error", reject);
141
+ stream.on("end", () => {
142
+ try {
143
+ batch.push(JSON.parse(Buffer.concat(chunks).toString("utf8")));
144
+ }
145
+ catch {
146
+ /* skip a non-JSON or undecodable entry */
147
+ }
148
+ if (batch.length >= batchSize)
149
+ flush();
150
+ zip.readEntry();
151
+ });
152
+ });
153
+ });
154
+ zip.on("error", reject);
155
+ zip.on("end", () => {
156
+ flush();
157
+ resolve();
158
+ });
159
+ zip.readEntry();
160
+ });
161
+ });
162
+ }
@@ -0,0 +1,195 @@
1
+ import { walk, isCall, calleeText, calleeLastSegment, argsText, loc, codeWithoutComments } from "../ast.js";
2
+ import { isTainted } from "../taint.js";
3
+ import { mkFinding } from "./util.js";
4
+ const LLM_SDK_RE = /\b(from\s+["']openai["']|require\(["']openai["']\)|@anthropic-ai\/sdk|["']anthropic["']|langchain|@langchain|from\s+["']ai["']|["']cohere-ai["']|google-generative-ai|@modelcontextprotocol\/sdk|llamaindex)\b/;
5
+ const LLM_CALL_RE = /(chat\.completions\.create|completions\.create|messages\.create|responses\.create|generateText|streamText|generateObject|createChatCompletion|\.invoke|\.run|\.generate)\b/;
6
+ const TOOLS_DEFINED_RE = /\btools\s*:\s*\[|\bfunctions\s*:\s*\[|new\s+(DynamicTool|StructuredTool|Tool)\b|\btool\s*\(\s*\{/;
7
+ const TOOL_DISPATCH_RE = /tool_calls|toolCalls|function_call|functionCall|\.function\.name|\.function\.arguments|\btool\.name\b/;
8
+ // Deliberately excludes JSON.parse — parsing tool args is not validating them.
9
+ const VALIDATION_RE = /allow[_ ]?list|\bzod\b|\bz\.\w+\(|schema\.(parse|safeParse)|safeParse|validateArgs|sanitiz|isAllowed|permitted|\bajv\b|\bguard\b|firewall/i;
10
+ const RAG_RE = /vector[_ ]?store|retriever|embeddings|pinecone|qdrant|chroma|weaviate|similaritySearch|as_retriever|\.query\(\s*\{[^}]*vector/i;
11
+ const MCP_RE = /@modelcontextprotocol\/sdk/;
12
+ const DANGEROUS_SINKS = new Set([
13
+ "readFile",
14
+ "readFileSync",
15
+ "writeFile",
16
+ "writeFileSync",
17
+ "unlink",
18
+ "unlinkSync",
19
+ "exec",
20
+ "execSync",
21
+ "spawn",
22
+ "spawnSync",
23
+ "system",
24
+ "query",
25
+ "fetch",
26
+ "request",
27
+ ]);
28
+ function hasLLM(code) {
29
+ return LLM_SDK_RE.test(code) || LLM_CALL_RE.test(code);
30
+ }
31
+ /** Find calls into the model (where untrusted text becomes model context). */
32
+ function llmCalls(root) {
33
+ const out = [];
34
+ walk(root, (n) => {
35
+ if (!isCall(n))
36
+ return;
37
+ const callee = calleeText(n) ?? "";
38
+ if (LLM_CALL_RE.test(callee))
39
+ out.push(n);
40
+ });
41
+ return out;
42
+ }
43
+ /** Find dangerous sink calls (the tool implementations / effects). */
44
+ function dangerousSinks(root) {
45
+ const out = [];
46
+ walk(root, (n) => {
47
+ if (!isCall(n))
48
+ return;
49
+ const last = calleeLastSegment(n);
50
+ if (last && DANGEROUS_SINKS.has(last))
51
+ out.push(n);
52
+ });
53
+ return out;
54
+ }
55
+ export const aiSecurityDetector = (ctx) => {
56
+ // Text heuristics run on comment-stripped source so prose/comments can't
57
+ // flip a signal (e.g. the word "allowlist" inside a comment).
58
+ const code = codeWithoutComments(ctx.root, ctx.file.code);
59
+ if (!hasLLM(code))
60
+ return [];
61
+ const out = [];
62
+ const calls = llmCalls(ctx.root);
63
+ const sinks = dangerousSinks(ctx.root);
64
+ const toolsDefined = TOOLS_DEFINED_RE.test(code);
65
+ const toolDispatch = TOOL_DISPATCH_RE.test(code);
66
+ const hasValidation = VALIDATION_RE.test(code);
67
+ const usedSinkLines = new Set();
68
+ // (1) Untrusted input -> LLM : direct/indirect prompt-injection surface.
69
+ for (const call of calls) {
70
+ const args = argsText(call);
71
+ if (isTainted(args, ctx.taint)) {
72
+ out.push(mkFinding(ctx, {
73
+ node: call,
74
+ detector: "ai-prompt-injection",
75
+ title: "Prompt injection surface: untrusted input flows into the LLM",
76
+ category: "ai",
77
+ severity: toolsDefined ? "high" : "medium",
78
+ confidence: 0.7,
79
+ reachable: true,
80
+ owasp: "LLM01 Prompt Injection",
81
+ mitre: "ATLAS AML.T0051",
82
+ why: toolsDefined
83
+ ? "Untrusted user input is passed to a model that can call tools. A crafted prompt can steer the model into invoking those tools (prompt injection → tool execution)."
84
+ : "Untrusted user input is passed to the model with no isolation. An attacker can override instructions, leak the system prompt, or manipulate output.",
85
+ fix: "Isolate untrusted input as data (delimit/spotlight, never as instructions), run an injection classifier, and constrain what the model is allowed to do with tools (see AI Execution Firewall, doc 04).",
86
+ taint: ["request input", "→ messages/prompt", `→ ${calleeText(call)}()`],
87
+ }));
88
+ }
89
+ }
90
+ // (2) Prompt injection -> tool execution: model output drives a dangerous sink
91
+ // with no validation/allowlist/firewall in the file. If a firewall IS present
92
+ // (allowlist + argument-schema validation), the hijack path is considered
93
+ // mitigated — this is the doc-04 semantics, and it lets a real fix clear the
94
+ // finding instead of being flagged forever.
95
+ if (toolDispatch && sinks.length > 0 && !hasValidation) {
96
+ for (const sink of sinks) {
97
+ const { line } = loc(sink);
98
+ usedSinkLines.add(line);
99
+ out.push(mkFinding(ctx, {
100
+ node: sink,
101
+ detector: "ai-tool-hijack",
102
+ title: "Prompt injection → tool execution (unvalidated tool call)",
103
+ category: "ai",
104
+ severity: "critical",
105
+ confidence: 0.85,
106
+ reachable: true,
107
+ owasp: "LLM01 / LLM08 Excessive Agency",
108
+ mitre: "ATLAS AML.T0051",
109
+ why: "The model's tool-call output is dispatched to a powerful sink (filesystem/shell/DB/network) without validation. A prompt-injection attack can make the agent perform arbitrary privileged actions — read secrets, run commands, exfiltrate data.",
110
+ fix: "Gate every tool call: validate arguments against a strict schema, enforce a tool allowlist, scope each tool to least-privilege credentials, and require human approval for high-risk actions. Insert the AI Execution Firewall middleware (doc 04).",
111
+ taint: ["untrusted input", "→ LLM", "→ tool_call args", `→ ${calleeText(sink)}() sink`],
112
+ }));
113
+ }
114
+ }
115
+ // (3) Over-privileged tool: a tool effect touching fs/shell/env without a gate.
116
+ // Suppressed when a firewall is present (same mitigation signal as above), so a
117
+ // validated fix doesn't simply trade a critical for a new high (which the fix
118
+ // loop's regression gate would otherwise reject).
119
+ if (toolsDefined && !hasValidation) {
120
+ for (const sink of sinks) {
121
+ const { line } = loc(sink);
122
+ if (usedSinkLines.has(line))
123
+ continue; // already flagged as hijack
124
+ const last = calleeLastSegment(sink) ?? "";
125
+ if (!/readFile|writeFile|unlink|exec|spawn|system|query/i.test(last))
126
+ continue;
127
+ usedSinkLines.add(line);
128
+ out.push(mkFinding(ctx, {
129
+ node: sink,
130
+ detector: "ai-overprivileged-tool",
131
+ title: `Over-privileged AI tool (${last})`,
132
+ category: "ai",
133
+ severity: "high",
134
+ confidence: 0.65,
135
+ reachable: true,
136
+ owasp: "LLM07 Insecure Plugin Design",
137
+ mitre: "ATLAS AML.T0053",
138
+ why: "A tool exposed to the model performs a high-privilege effect (filesystem/shell/DB). If the model can be influenced, this capability becomes an attacker primitive.",
139
+ fix: "Scope the tool to the minimum capability needed (e.g. a fixed directory, parameterized query, no shell). Run tool effects with least-privilege, isolated credentials.",
140
+ }));
141
+ }
142
+ }
143
+ // (4) Missing AI Execution Firewall: tools wired to a model with no guardrails.
144
+ if (toolsDefined && !hasValidation) {
145
+ const anchor = calls[0];
146
+ out.push(mkFinding(ctx, {
147
+ node: anchor,
148
+ line: anchor ? undefined : 1,
149
+ detector: "ai-missing-firewall",
150
+ title: "AI agent exposes tools with no execution firewall",
151
+ category: "ai",
152
+ severity: "high",
153
+ confidence: 0.6,
154
+ reachable: true,
155
+ owasp: "LLM01 / LLM08 Excessive Agency",
156
+ mitre: "ATLAS AML.T0051",
157
+ why: "This integration gives the model tools but has no detectable input sanitization, argument validation, allowlist, or human-in-the-loop gate — so there is no defense-in-depth against prompt injection.",
158
+ fix: "Add the AI Execution Firewall (doc 04): prompt sanitization + injection detection, context isolation, tool allowlist + argument-schema validation, per-tool capability scoping, and a human gate for high-risk actions.",
159
+ }));
160
+ }
161
+ // (5) Indirect injection via RAG / retrieval.
162
+ if (RAG_RE.test(code) && calls.length > 0) {
163
+ out.push(mkFinding(ctx, {
164
+ line: 1,
165
+ detector: "ai-rag-injection",
166
+ title: "Retrieval (RAG) content flows into the prompt without provenance",
167
+ category: "ai",
168
+ severity: "medium",
169
+ confidence: 0.55,
170
+ reachable: true,
171
+ owasp: "LLM01 Prompt Injection (indirect)",
172
+ mitre: "ATLAS AML.T0051",
173
+ why: "Retrieved documents are treated as trusted context. If an attacker can place content in the knowledge base (or a fetched page), they can inject instructions the model will follow (indirect prompt injection).",
174
+ fix: "Treat retrieved content as untrusted data: tag provenance, isolate it from instructions, and vet/authenticate ingestion sources.",
175
+ }));
176
+ }
177
+ // (6) MCP server without authentication.
178
+ if (MCP_RE.test(code) && /new\s+(Server|McpServer)\b/.test(code) && !/auth|token|api[_ ]?key|bearer/i.test(code)) {
179
+ out.push(mkFinding(ctx, {
180
+ line: 1,
181
+ detector: "ai-mcp-unauth",
182
+ title: "MCP server exposes tools without authentication",
183
+ category: "ai",
184
+ severity: "medium",
185
+ confidence: 0.55,
186
+ reachable: true,
187
+ owasp: "LLM07 Insecure Plugin Design",
188
+ mitre: "ATLAS AML.T0053",
189
+ why: "An unauthenticated MCP server lets any client invoke its tools. Combined with privileged tools, this is a direct path to the underlying system.",
190
+ fix: "Authenticate MCP transports, allowlist clients, and scope tool permissions per the MCP hardening guidance (doc 04).",
191
+ }));
192
+ }
193
+ return out;
194
+ };
195
+ export const aiDetectors = [aiSecurityDetector];
@@ -0,0 +1,284 @@
1
+ import { walk, isCall, calleeText, calleeLastSegment, argsText, codeWithoutComments } from "../ast.js";
2
+ import { isTainted, isUntrustedStringBuild } from "../taint.js";
3
+ import { mkFinding } from "./util.js";
4
+ const SQL_SINKS = ["query", "execute", "exec", "raw", "$queryRawUnsafe", "$executeRawUnsafe"];
5
+ /**
6
+ * Is this argument node a plain string literal with no interpolation? Such a
7
+ * first argument means the query string is static and any taint lives only in
8
+ * a separate params array — i.e. a *parameterized* (safe) query.
9
+ */
10
+ function isStaticStringArg(arg) {
11
+ if (!arg)
12
+ return false;
13
+ // A JS template string with no `${}` substitution is static.
14
+ if (arg.type === "template_string")
15
+ return !/\$\{/.test(arg.text);
16
+ if (arg.type === "string") {
17
+ // Python f-strings are also type "string" but carry interpolation — NOT static.
18
+ for (let i = 0; i < arg.namedChildCount; i++) {
19
+ if (arg.namedChild(i)?.type === "interpolation")
20
+ return false;
21
+ }
22
+ const prefix = arg.text.match(/^([a-zA-Z]*)['"]/);
23
+ if (prefix && /f/i.test(prefix[1]))
24
+ return false; // f"...{x}..."
25
+ return true; // plain string literal → parameterized/safe
26
+ }
27
+ return false;
28
+ }
29
+ /** SQL / NoSQL injection: untrusted data built into a query string. */
30
+ const sqlInjection = (ctx) => {
31
+ const out = [];
32
+ walk(ctx.root, (n) => {
33
+ if (!isCall(n))
34
+ return;
35
+ const last = calleeLastSegment(n);
36
+ if (!last || !SQL_SINKS.includes(last))
37
+ return;
38
+ // Only the FIRST argument is the query string. Taint in later arguments is
39
+ // a parameter binding (safe) — flagging it would punish the correct fix.
40
+ const argsNode = n.childForFieldName("arguments");
41
+ const firstArg = argsNode?.namedChild(0) ?? null;
42
+ if (isStaticStringArg(firstArg))
43
+ return; // parameterized query → safe
44
+ const queryText = firstArg ? firstArg.text : argsText(n);
45
+ const tainted = isUntrustedStringBuild(queryText, ctx.taint) || isTainted(queryText, ctx.taint);
46
+ if (!tainted)
47
+ return;
48
+ out.push(mkFinding(ctx, {
49
+ node: n,
50
+ detector: "sql-injection",
51
+ title: "SQL injection via untrusted query string",
52
+ category: "classic",
53
+ severity: "critical",
54
+ confidence: 0.9,
55
+ reachable: true,
56
+ cwe: "CWE-89",
57
+ owasp: "A03:2021 Injection",
58
+ mitre: "T1190",
59
+ why: "Untrusted input is concatenated/interpolated directly into a database query. An attacker can alter the query to read, modify, or destroy data.",
60
+ fix: "Use parameterized queries / prepared statements (e.g. db.query(sql, [params])) or a safe ORM API. Never build SQL by string concatenation.",
61
+ taint: ["request input", "→ query string", `→ ${calleeText(n)}()`],
62
+ }));
63
+ });
64
+ return out;
65
+ };
66
+ /** Command injection / unsafe code execution (RCE). */
67
+ const codeExecution = (ctx) => {
68
+ const out = [];
69
+ walk(ctx.root, (n) => {
70
+ if (!isCall(n))
71
+ return;
72
+ const callee = calleeText(n) ?? "";
73
+ const last = calleeLastSegment(n) ?? "";
74
+ const lang = ctx.file.lang;
75
+ // Decide if this is genuinely a code/command execution sink — taking care
76
+ // NOT to flag the many benign `.exec` methods (regex.exec, ORM query.exec…).
77
+ let isDangerous = false;
78
+ let evalLike = false;
79
+ // Non-shell process APIs (execFile/spawn) are the *safe* alternatives to a
80
+ // shell: a fixed binary + arg array isn't injectable. Only flag them when an
81
+ // argument is actually tainted — otherwise they're noise on correct code.
82
+ let safeUnlessTainted = false;
83
+ if (last === "eval" || last === "Function") {
84
+ isDangerous = true;
85
+ evalLike = true;
86
+ }
87
+ else if (callee === "os.system" || /^subprocess\.(call|run|Popen|check_output)$/.test(callee) || callee.endsWith(".Popen")) {
88
+ isDangerous = true;
89
+ }
90
+ else if (lang === "python" && (last === "exec" || last === "eval")) {
91
+ isDangerous = true;
92
+ evalLike = last === "eval";
93
+ }
94
+ else if (["spawn", "spawnSync", "execFile", "execFileSync", "popen"].includes(last)) {
95
+ isDangerous = true;
96
+ safeUnlessTainted = true;
97
+ }
98
+ else if (last === "exec" || last === "execSync") {
99
+ // child_process.exec is RCE; regex.exec / x.re.exec is not.
100
+ const bare = callee === last;
101
+ const childProc = /child_?process|childProcess|\bcp\b/i.test(callee);
102
+ const regexLike = /(re|regex|regexp|rx|pattern)\.exec$/i.test(callee);
103
+ isDangerous = (bare || childProc) && !regexLike;
104
+ }
105
+ if (!isDangerous)
106
+ return;
107
+ const args = argsText(n);
108
+ const tainted = isTainted(args, ctx.taint) || isUntrustedStringBuild(args, ctx.taint);
109
+ if (safeUnlessTainted && !tainted)
110
+ return; // execFile/spawn with fixed args → safe
111
+ out.push(mkFinding(ctx, {
112
+ node: n,
113
+ detector: "code-execution",
114
+ title: tainted
115
+ ? "Remote code execution: untrusted input reaches a command/eval sink"
116
+ : `Dangerous code-execution sink (${last})`,
117
+ category: "classic",
118
+ severity: tainted ? "critical" : "medium",
119
+ confidence: tainted ? 0.92 : 0.55,
120
+ reachable: tainted,
121
+ cwe: evalLike ? "CWE-95" : "CWE-78",
122
+ owasp: "A03:2021 Injection",
123
+ mitre: "T1059",
124
+ why: tainted
125
+ ? "Untrusted input flows into a command/eval execution sink, allowing arbitrary code or OS command execution."
126
+ : "Use of a dynamic code/command execution sink. If any argument is ever attacker-influenced this becomes RCE.",
127
+ fix: "Avoid eval/exec on dynamic input. Use safe APIs, allowlists of permitted commands/args, and never pass user input to a shell. Prefer execFile with a fixed binary + array args.",
128
+ taint: tainted ? ["request input", `→ ${callee}()`] : undefined,
129
+ }));
130
+ });
131
+ return out;
132
+ };
133
+ /** Cross-site scripting (XSS). */
134
+ const xss = (ctx) => {
135
+ const out = [];
136
+ walk(ctx.root, (n) => {
137
+ // React dangerouslySetInnerHTML
138
+ if ((n.type === "property_identifier" || n.type === "identifier") &&
139
+ n.text === "dangerouslySetInnerHTML") {
140
+ out.push(mkFinding(ctx, {
141
+ node: n,
142
+ detector: "xss",
143
+ title: "Potential XSS via dangerouslySetInnerHTML",
144
+ category: "classic",
145
+ severity: "high",
146
+ confidence: 0.65,
147
+ reachable: true,
148
+ cwe: "CWE-79",
149
+ owasp: "A03:2021 Injection",
150
+ mitre: "T1059.007",
151
+ why: "Rendering raw HTML bypasses React's escaping. If the HTML includes untrusted data, an attacker can inject scripts.",
152
+ fix: "Avoid dangerouslySetInnerHTML. If unavoidable, sanitize with a vetted library (e.g. DOMPurify) before rendering.",
153
+ }));
154
+ }
155
+ // res.send / res.write with tainted content
156
+ if (isCall(n)) {
157
+ const callee = calleeText(n) ?? "";
158
+ if (/\.(send|write|end)$/.test(callee)) {
159
+ const args = argsText(n);
160
+ if (isTainted(args, ctx.taint) || isUntrustedStringBuild(args, ctx.taint)) {
161
+ out.push(mkFinding(ctx, {
162
+ node: n,
163
+ detector: "xss",
164
+ title: "Reflected XSS: untrusted input written to the response",
165
+ category: "classic",
166
+ severity: "high",
167
+ confidence: 0.7,
168
+ reachable: true,
169
+ cwe: "CWE-79",
170
+ owasp: "A03:2021 Injection",
171
+ mitre: "T1059.007",
172
+ why: "Untrusted input is written into an HTTP response without encoding, allowing script injection in the victim's browser.",
173
+ fix: "Encode/escape output for the correct context, set a strict Content-Type, and apply a Content-Security-Policy.",
174
+ taint: ["request input", `→ ${callee}()`],
175
+ }));
176
+ }
177
+ }
178
+ }
179
+ });
180
+ return out;
181
+ };
182
+ /**
183
+ * Broken access control / IDOR (heuristic semantic check, doc 03). A route
184
+ * handler that looks up a resource by a request-supplied id but contains no
185
+ * authorization/ownership check.
186
+ */
187
+ const HANDLER_NAMES = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
188
+ const AUTHZ_MARKERS = /\b(session|getServerSession|auth|authorize|currentUser|current_user|userId|user_id|req\.user|verifyToken|requireAuth|getUser|ownerId|owner_id|can\(|ability|policy|rbac)\b/i;
189
+ const brokenAccessControl = (ctx) => {
190
+ const out = [];
191
+ walk(ctx.root, (n) => {
192
+ // Next.js route handlers: exported async function GET/POST/...
193
+ if (n.type === "function_declaration") {
194
+ const nameNode = n.childForFieldName("name");
195
+ const name = nameNode?.text ?? "";
196
+ if (!HANDLER_NAMES.has(name))
197
+ return;
198
+ const body = n.text;
199
+ const usesRequestId = /\bparams\.[a-zA-Z_$]|searchParams\.get|req(uest)?\.(params|query)\b/.test(body);
200
+ const doesDbLookup = /\b(find|findUnique|findFirst|findById|query|select|get|aggregate)\b/i.test(body);
201
+ const hasAuthz = AUTHZ_MARKERS.test(body);
202
+ if (usesRequestId && doesDbLookup && !hasAuthz) {
203
+ out.push(mkFinding(ctx, {
204
+ node: n,
205
+ detector: "broken-access-control",
206
+ title: `IDOR / missing authorization in ${name} handler`,
207
+ category: "classic",
208
+ severity: "high",
209
+ confidence: 0.6,
210
+ reachable: true,
211
+ cwe: "CWE-639",
212
+ owasp: "A01:2021 Broken Access Control",
213
+ mitre: "T1190",
214
+ why: "This handler fetches a resource using a client-supplied identifier but performs no ownership/authorization check. Any authenticated user can read or modify other users' objects by changing the id.",
215
+ fix: "Enforce an ownership/authorization check: load the authenticated principal and verify it owns (or has a role permitting) the requested resource before returning it.",
216
+ taint: ["request id (params/query)", "→ resource lookup", "→ returned without authz check"],
217
+ }));
218
+ }
219
+ }
220
+ });
221
+ return out;
222
+ };
223
+ /** Insecure CORS / weak JWT configuration (text-aware over the file). */
224
+ const insecureConfig = (ctx) => {
225
+ const out = [];
226
+ const code = codeWithoutComments(ctx.root, ctx.file.code);
227
+ // Permissive CORS with credentials. Handles both cors({origin:'*'}) and the
228
+ // Next.js header form: { key: "Access-Control-Allow-Origin", value: "*" }.
229
+ const corsHeaderForm = /["']Access-Control-Allow-Origin["'][\s\S]{0,40}["']\*["']/i.test(code);
230
+ const corsLibForm = /origin\s*:\s*["']\*["']/i.test(code);
231
+ const corsStar = corsHeaderForm || corsLibForm;
232
+ const credsTrue = /["']Access-Control-Allow-Credentials["'][\s\S]{0,40}["']true["']/i.test(code) ||
233
+ /credentials\s*:\s*true/.test(code);
234
+ if (corsStar) {
235
+ const line = lineNumberOf(code, /(Access-Control-Allow-Origin)|(origin\s*:\s*["']\*)/);
236
+ out.push(mkFinding(ctx, {
237
+ line,
238
+ detector: "cors-misconfig",
239
+ title: credsTrue
240
+ ? "Insecure CORS: wildcard origin with credentials"
241
+ : "Permissive CORS: wildcard origin",
242
+ category: "classic",
243
+ severity: credsTrue ? "high" : "medium",
244
+ confidence: credsTrue ? 0.85 : 0.6,
245
+ reachable: true,
246
+ cwe: "CWE-942",
247
+ owasp: "A05:2021 Security Misconfiguration",
248
+ why: credsTrue
249
+ ? "A wildcard origin combined with credentials lets any site make authenticated cross-origin requests on behalf of your users."
250
+ : "A wildcard CORS origin exposes your API to requests from any website.",
251
+ fix: "Set an explicit allowlist of trusted origins. Never combine `Access-Control-Allow-Origin: *` with credentials.",
252
+ }));
253
+ }
254
+ // Weak JWT
255
+ if (/alg(orithm)?["'\s:]+["']?none["']?/i.test(code)) {
256
+ out.push(mkFinding(ctx, {
257
+ line: lineNumberOf(code, /alg(orithm)?["'\s:]+["']?none/i),
258
+ detector: "weak-jwt",
259
+ title: "Insecure JWT: 'none' algorithm permitted",
260
+ category: "classic",
261
+ severity: "high",
262
+ confidence: 0.8,
263
+ reachable: true,
264
+ cwe: "CWE-347",
265
+ owasp: "A02:2021 Cryptographic Failures",
266
+ why: "Accepting the 'none' algorithm lets an attacker forge tokens with no signature.",
267
+ fix: "Pin a strong algorithm (e.g. RS256/ES256), pass an explicit `algorithms` allowlist to verify(), and reject 'none'.",
268
+ }));
269
+ }
270
+ return out;
271
+ };
272
+ function lineNumberOf(code, re) {
273
+ const idx = code.search(re);
274
+ if (idx < 0)
275
+ return 1;
276
+ return code.slice(0, idx).split(/\r?\n/).length;
277
+ }
278
+ export const classicDetectors = [
279
+ sqlInjection,
280
+ codeExecution,
281
+ xss,
282
+ brokenAccessControl,
283
+ insecureConfig,
284
+ ];