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,99 @@
1
+ // packages/cli/src/engine/readiness/checks.ts
2
+ /**
3
+ * The two new launch-readiness checks. Both emit findings with category
4
+ * "readiness" so they are isolated from the score and the code-findings count.
5
+ * Pattern matching runs on comment-stripped text so a comment cannot trip it.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ const WEB_FRAMEWORKS = new Set([
9
+ "Next.js", "Express", "Fastify", "NestJS", "Hono", "Koa", "FastAPI", "Django", "Flask",
10
+ ]);
11
+ const HEADER_NAMES = /Content-Security-Policy|Strict-Transport-Security|X-Frame-Options|X-Content-Type-Options|Referrer-Policy|Permissions-Policy/i;
12
+ const JS_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"]);
13
+ export function isWebApp(inv) {
14
+ return inv.frameworks.some((f) => WEB_FRAMEWORKS.has(f));
15
+ }
16
+ /** True if a file references a real security response header (not a bare CORS header). */
17
+ export function fileHasSecurityHeader(code) {
18
+ return HEADER_NAMES.test(code);
19
+ }
20
+ function rid(relPath, detector, line) {
21
+ return createHash("sha1").update(`${detector}:${relPath}:${line}`).digest("hex").slice(0, 12);
22
+ }
23
+ function stripComments(code) {
24
+ return code
25
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
26
+ .replace(/\/\/[^\n]*/g, " ")
27
+ .replace(/#[^\n]*/g, " ");
28
+ }
29
+ const DEBUG_PY = /\bdebug\s*=\s*True\b/;
30
+ const STACK_REF = /\b(?:err|error|e)\.stack\b/;
31
+ const SEND_CALL = /\.(?:send|json|end|write)\s*\(/;
32
+ export function checkDebugExposure(relPath, code) {
33
+ const dot = relPath.lastIndexOf(".");
34
+ const ext = dot >= 0 ? relPath.slice(dot) : "";
35
+ const isPy = ext === ".py";
36
+ const isJs = JS_EXT.has(ext);
37
+ if (!isPy && !isJs)
38
+ return [];
39
+ const out = [];
40
+ const lines = stripComments(code).split(/\r?\n/);
41
+ lines.forEach((ln, i) => {
42
+ let title = "";
43
+ let why = "";
44
+ let fix = "";
45
+ if (isPy && DEBUG_PY.test(ln)) {
46
+ title = "Debug mode enabled (stack traces exposed)";
47
+ why =
48
+ "Debug mode is enabled (debug=True). It exposes an interactive traceback and console to anyone who can trigger an error.";
49
+ fix = "Disable debug in production (debug=False / FLASK_DEBUG=0) and serve a generic error page.";
50
+ }
51
+ else if (isJs && STACK_REF.test(ln) && SEND_CALL.test(ln)) {
52
+ title = "Stack trace exposed in HTTP response";
53
+ why =
54
+ "A stack trace is written into an HTTP response. It leaks file paths, dependency versions, and internal logic to attackers.";
55
+ fix = "Never send a stack trace to the client. Log it server-side and return a generic error message.";
56
+ }
57
+ if (title) {
58
+ out.push({
59
+ id: rid(relPath, "debug-exposure", i + 1),
60
+ detector: "debug-exposure",
61
+ title,
62
+ category: "readiness",
63
+ severity: "high",
64
+ confidence: 0.8,
65
+ reachable: false,
66
+ cwe: "CWE-489",
67
+ owasp: "A05:2021 Security Misconfiguration",
68
+ file: relPath,
69
+ line: i + 1,
70
+ column: 1,
71
+ evidence: ln.trim().slice(0, 120),
72
+ why,
73
+ fix,
74
+ });
75
+ }
76
+ });
77
+ return out;
78
+ }
79
+ export function checkSecurityHeaders(anchorPath) {
80
+ return [
81
+ {
82
+ id: rid(anchorPath, "missing-security-headers", 1),
83
+ detector: "missing-security-headers",
84
+ title: "No security headers / Content-Security-Policy configured",
85
+ category: "readiness",
86
+ severity: "medium",
87
+ confidence: 0.7,
88
+ reachable: false,
89
+ cwe: "CWE-693",
90
+ owasp: "A05:2021 Security Misconfiguration",
91
+ file: anchorPath,
92
+ line: 1,
93
+ column: 1,
94
+ evidence: "no CSP / HSTS / X-Frame-Options / X-Content-Type-Options found",
95
+ why: "The app sets no security response headers, leaving it open to clickjacking, MIME sniffing, and protocol downgrade that a Content-Security-Policy and friends prevent.",
96
+ fix: "Add security headers (Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options): helmet for Express, a headers() block in next.config, or middleware for FastAPI/Django.",
97
+ },
98
+ ];
99
+ }
@@ -0,0 +1,59 @@
1
+ const GATES = [
2
+ { id: "secrets", label: "Secrets managed", detectors: ["hardcoded-secret"], crit: "blocker" },
3
+ { id: "deps", label: "Dependencies current", detectors: ["vulnerable-dependency"], crit: "critical-only" },
4
+ { id: "injection", label: "Injection safe (SQLi/RCE)", detectors: ["sql-injection", "code-execution"], crit: "blocker" },
5
+ { id: "authz", label: "Access control", detectors: ["broken-access-control"], crit: "blocker" },
6
+ { id: "ai", label: "AI agent safety", detectors: ["ai-tool-hijack", "ai-missing-firewall", "ai-prompt-injection", "ai-overprivileged-tool"], crit: "high-blocks" },
7
+ { id: "transport", label: "Transport config (CORS/JWT)", detectors: ["cors-misconfig", "weak-jwt"], crit: "warning" },
8
+ { id: "headers", label: "Security headers / CSP", detectors: ["missing-security-headers"], crit: "blocker" },
9
+ { id: "debug", label: "Debug / stack traces", detectors: ["debug-exposure"], crit: "blocker" },
10
+ ];
11
+ const NOT_ASSESSED = [
12
+ { id: "ratelimit", label: "Rate limiting" },
13
+ { id: "https", label: "HTTPS enforced" },
14
+ ];
15
+ function worstOf(hits) {
16
+ if (hits.some((f) => f.severity === "critical"))
17
+ return "critical";
18
+ if (hits.some((f) => f.severity === "high"))
19
+ return "high";
20
+ return "medium";
21
+ }
22
+ function detailFor(id, hits) {
23
+ if (id === "headers")
24
+ return "no CSP / security headers configured";
25
+ const n = hits.length;
26
+ return `${n} ${n === 1 ? "issue" : "issues"} (${worstOf(hits)})`;
27
+ }
28
+ export function evaluateLaunchReadiness(findings, _inventory) {
29
+ const gates = [];
30
+ let blockers = 0;
31
+ let warnings = 0;
32
+ for (const spec of GATES) {
33
+ const hits = findings.filter((f) => spec.detectors.includes(f.detector));
34
+ if (hits.length === 0) {
35
+ gates.push({ id: spec.id, label: spec.label, status: "pass", detail: "", findingIds: [] });
36
+ continue;
37
+ }
38
+ const worst = worstOf(hits);
39
+ let status;
40
+ if (spec.crit === "blocker")
41
+ status = "blocker";
42
+ else if (spec.crit === "warning")
43
+ status = "warning";
44
+ else if (spec.crit === "critical-only")
45
+ status = worst === "critical" ? "blocker" : "warning";
46
+ else
47
+ status = worst === "critical" || worst === "high" ? "blocker" : "warning"; // high-blocks
48
+ if (status === "blocker")
49
+ blockers++;
50
+ else
51
+ warnings++;
52
+ gates.push({ id: spec.id, label: spec.label, status, detail: detailFor(spec.id, hits), findingIds: hits.map((f) => f.id) });
53
+ }
54
+ for (const na of NOT_ASSESSED) {
55
+ gates.push({ id: na.id, label: na.label, status: "not-assessed", detail: "not checked yet", findingIds: [] });
56
+ }
57
+ const verdict = blockers > 0 ? "not-ready" : warnings > 0 ? "warnings" : "ready";
58
+ return { verdict, blockers, warnings, gates };
59
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Repository scan orchestrator (docs 01/02). Walks the tree, fingerprints the
3
+ * stack, parses supported files, runs classic + AI detectors with taint, then
4
+ * scores. Stateless and local-first — no upload required.
5
+ */
6
+ import { promises as fs } from "node:fs";
7
+ import path from "node:path";
8
+ import { SEVERITY_ORDER } from "./types.js";
9
+ import { parse, langForExtension } from "./parser.js";
10
+ import { analyzeTaint } from "./taint.js";
11
+ import { classicDetectors } from "./detectors/classic.js";
12
+ import { aiDetectors } from "./detectors/aisecurity.js";
13
+ import { detectSecrets } from "./detectors/secrets.js";
14
+ import { computeScore } from "./score.js";
15
+ import { detectDependencies, buildDependencyReport } from "./deps/detect.js";
16
+ import { loadDepDb } from "./deps/db.js";
17
+ import { checkDebugExposure, checkSecurityHeaders, fileHasSecurityHeader, isWebApp } from "./readiness/checks.js";
18
+ import { evaluateLaunchReadiness } from "./readiness/scorecard.js";
19
+ const IGNORE_DIRS = new Set([
20
+ "node_modules", ".git", "dist", "build", ".next", ".turbo", ".cache",
21
+ "coverage", "vendor", ".venv", "venv", "__pycache__", ".idea", ".vscode",
22
+ "out", ".pnpm", "tmp", ".securevibe-backup",
23
+ ]);
24
+ const MAX_FILE_BYTES = 1_500_000;
25
+ const TEXT_EXT_FOR_SECRETS = new Set([
26
+ ".env", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".py",
27
+ ".json", ".yaml", ".yml", ".toml", ".txt", ".sh", ".pem", ".config",
28
+ ]);
29
+ /** Is this file eligible for text-level secret detection? */
30
+ function secretEligible(relPath) {
31
+ const ext = path.extname(relPath);
32
+ const key = ext === "" ? path.basename(relPath) : ext;
33
+ return TEXT_EXT_FOR_SECRETS.has(key) || /\.env/.test(relPath);
34
+ }
35
+ /**
36
+ * Run the full detector pipeline on a single file's content (in memory). This
37
+ * is the shared core of `scan` and is reused by the fix loop's verification
38
+ * step, so "re-scan after a fix" runs exactly the same detectors as the scan.
39
+ */
40
+ export async function detectFile(relPath, code, options = {}) {
41
+ const findings = [];
42
+ if (!options.aiOnly && secretEligible(relPath)) {
43
+ findings.push(...detectSecrets(relPath, code));
44
+ }
45
+ if (!options.aiOnly && options.depDb) {
46
+ findings.push(...detectDependencies(relPath, code, options.depDb));
47
+ }
48
+ const lang = langForExtension(path.extname(relPath));
49
+ if (!lang)
50
+ return findings;
51
+ let rootNode;
52
+ try {
53
+ ({ root: rootNode } = await parse(code, lang));
54
+ }
55
+ catch {
56
+ return findings; // tree-sitter is error-tolerant; skip the rare hard failure
57
+ }
58
+ const file = { absPath: relPath, relPath, lang, code };
59
+ const taint = analyzeTaint(rootNode);
60
+ const ctx = { file, root: rootNode, taint };
61
+ const detectors = options.aiOnly ? aiDetectors : [...classicDetectors, ...aiDetectors];
62
+ for (const detect of detectors) {
63
+ try {
64
+ findings.push(...detect(ctx));
65
+ }
66
+ catch {
67
+ // a single detector failing must never abort the scan
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+ export async function scan(root, options = {}) {
73
+ const start = Date.now();
74
+ const progress = options.onProgress ?? (() => { });
75
+ const absRoot = path.resolve(root);
76
+ // Fail loudly on a bad path — silently scoring an empty/missing dir as "A"
77
+ // would mask typos in CI.
78
+ try {
79
+ const st = await fs.stat(absRoot);
80
+ if (!st.isDirectory())
81
+ throw new Error(`not a directory: ${absRoot}`);
82
+ }
83
+ catch (err) {
84
+ if (err?.code === "ENOENT")
85
+ throw new Error(`path not found: ${absRoot}`);
86
+ throw err;
87
+ }
88
+ progress(options.files ? "Analyzing staged files…" : "Walking repository…");
89
+ const files = options.files ?? (await collectFiles(absRoot));
90
+ progress("Fingerprinting architecture…");
91
+ const inventory = await detectInventory(absRoot, files);
92
+ // Dependency (SCA) scanning runs on full scans only — not on --staged (so the
93
+ // commit guard never walls off pre-existing dependency debt) and not ai-audit.
94
+ const depDb = !options.aiOnly && !options.files ? await loadDepDb() : null;
95
+ const findings = [];
96
+ let parsedCount = 0;
97
+ const fullScan = !options.aiOnly && !options.files;
98
+ const readinessFindings = [];
99
+ let headersSeen = false;
100
+ let headerAnchor;
101
+ progress(`Analyzing ${files.length} files…`);
102
+ for (const abs of files) {
103
+ const rel = path.relative(absRoot, abs).split(path.sep).join("/");
104
+ let code;
105
+ try {
106
+ const stat = await fs.stat(abs);
107
+ if (stat.size > MAX_FILE_BYTES)
108
+ continue;
109
+ code = await fs.readFile(abs, "utf8");
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ if (langForExtension(path.extname(abs)))
115
+ parsedCount++;
116
+ findings.push(...(await detectFile(rel, code, { aiOnly: options.aiOnly, depDb })));
117
+ if (fullScan) {
118
+ readinessFindings.push(...checkDebugExposure(rel, code));
119
+ if (!headersSeen && fileHasSecurityHeader(code))
120
+ headersSeen = true;
121
+ if (!headerAnchor && /(?:^|\/)(next\.config\.(?:js|mjs|ts)|package\.json)$/.test(rel))
122
+ headerAnchor = rel;
123
+ }
124
+ }
125
+ progress("Scoring…");
126
+ if (fullScan && isWebApp(inventory) && !(headersSeen || inventory.dependencies.includes("helmet"))) {
127
+ readinessFindings.push(...checkSecurityHeaders(headerAnchor ?? "package.json"));
128
+ }
129
+ findings.push(...readinessFindings);
130
+ const deduped = dedupe(findings);
131
+ const sorted = prioritize(deduped);
132
+ const score = computeScore(sorted);
133
+ const dependencies = depDb ? buildDependencyReport(sorted, depDb.date) : undefined;
134
+ const launchReadiness = fullScan ? evaluateLaunchReadiness(sorted, inventory) : undefined;
135
+ return {
136
+ root: absRoot,
137
+ filesScanned: parsedCount,
138
+ inventory,
139
+ findings: sorted,
140
+ score,
141
+ durationMs: Date.now() - start,
142
+ dependencies,
143
+ launchReadiness,
144
+ };
145
+ }
146
+ async function collectFiles(root) {
147
+ const out = [];
148
+ async function recur(dir) {
149
+ let entries;
150
+ try {
151
+ entries = await fs.readdir(dir, { withFileTypes: true });
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ for (const entry of entries) {
157
+ if (entry.isDirectory()) {
158
+ if (IGNORE_DIRS.has(entry.name))
159
+ continue;
160
+ await recur(path.join(dir, entry.name));
161
+ }
162
+ else if (entry.isFile()) {
163
+ out.push(path.join(dir, entry.name));
164
+ }
165
+ }
166
+ }
167
+ await recur(root);
168
+ return out;
169
+ }
170
+ async function readJsonSafe(file) {
171
+ try {
172
+ return JSON.parse(await fs.readFile(file, "utf8"));
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ const FRAMEWORK_DEPS = {
179
+ next: "Next.js", react: "React", express: "Express", hono: "Hono",
180
+ "@nestjs/core": "NestJS", fastify: "Fastify", svelte: "Svelte", vue: "Vue",
181
+ };
182
+ const AI_DEPS = {
183
+ openai: "OpenAI SDK", "@anthropic-ai/sdk": "Anthropic SDK", anthropic: "Anthropic SDK",
184
+ langchain: "LangChain", "@langchain/core": "LangChain", "@langchain/openai": "LangChain",
185
+ langgraph: "LangGraph", ai: "Vercel AI SDK", "cohere-ai": "Cohere",
186
+ "@modelcontextprotocol/sdk": "MCP", llamaindex: "LlamaIndex", crewai: "CrewAI",
187
+ };
188
+ async function detectInventory(root, files) {
189
+ const languages = new Set();
190
+ const frameworks = new Set();
191
+ const aiIntegrations = new Set();
192
+ const deployTargets = new Set();
193
+ const dependencies = new Set();
194
+ for (const abs of files) {
195
+ const ext = path.extname(abs);
196
+ const lang = langForExtension(ext);
197
+ if (lang)
198
+ languages.add(lang);
199
+ const base = path.basename(abs).toLowerCase();
200
+ if (base === "dockerfile")
201
+ deployTargets.add("Docker");
202
+ if (base === "vercel.json")
203
+ deployTargets.add("Vercel");
204
+ if (base === "wrangler.toml")
205
+ deployTargets.add("Cloudflare Workers");
206
+ if (/\.ya?ml$/.test(base) && /k8s|deployment|kustom/.test(abs.toLowerCase()))
207
+ deployTargets.add("Kubernetes");
208
+ if (/next\.config\.(js|mjs|ts)/.test(base))
209
+ frameworks.add("Next.js");
210
+ }
211
+ const pkg = await readJsonSafe(path.join(root, "package.json"));
212
+ if (pkg) {
213
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
214
+ for (const dep of Object.keys(deps)) {
215
+ dependencies.add(dep);
216
+ if (FRAMEWORK_DEPS[dep])
217
+ frameworks.add(FRAMEWORK_DEPS[dep]);
218
+ if (AI_DEPS[dep])
219
+ aiIntegrations.add(AI_DEPS[dep]);
220
+ }
221
+ }
222
+ // Python framework hints
223
+ for (const fname of ["requirements.txt", "pyproject.toml"]) {
224
+ try {
225
+ const txt = (await fs.readFile(path.join(root, fname), "utf8")).toLowerCase();
226
+ if (txt.includes("fastapi"))
227
+ frameworks.add("FastAPI");
228
+ if (txt.includes("django"))
229
+ frameworks.add("Django");
230
+ if (txt.includes("flask"))
231
+ frameworks.add("Flask");
232
+ if (txt.includes("openai"))
233
+ aiIntegrations.add("OpenAI SDK");
234
+ if (txt.includes("anthropic"))
235
+ aiIntegrations.add("Anthropic SDK");
236
+ if (txt.includes("langchain"))
237
+ aiIntegrations.add("LangChain");
238
+ }
239
+ catch {
240
+ /* not a python project */
241
+ }
242
+ }
243
+ return {
244
+ languages: [...languages],
245
+ frameworks: [...frameworks],
246
+ aiIntegrations: [...aiIntegrations],
247
+ deployTargets: [...deployTargets],
248
+ dependencies: [...dependencies],
249
+ };
250
+ }
251
+ function dedupe(findings) {
252
+ const byId = new Map();
253
+ for (const f of findings) {
254
+ const existing = byId.get(f.id);
255
+ if (!existing || f.confidence > existing.confidence)
256
+ byId.set(f.id, f);
257
+ }
258
+ return [...byId.values()];
259
+ }
260
+ function prioritize(findings) {
261
+ return findings.sort((a, b) => {
262
+ const sev = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
263
+ if (sev !== 0)
264
+ return sev;
265
+ if (a.reachable !== b.reachable)
266
+ return a.reachable ? -1 : 1;
267
+ return b.confidence - a.confidence;
268
+ });
269
+ }
@@ -0,0 +1,66 @@
1
+ const SEVERITY_WEIGHT = {
2
+ critical: 40,
3
+ high: 18,
4
+ medium: 7,
5
+ low: 2,
6
+ info: 0,
7
+ };
8
+ function penalty(f) {
9
+ const base = SEVERITY_WEIGHT[f.severity];
10
+ const reach = f.reachable ? 1 : 0.4; // unreachable issues hurt less
11
+ return base * reach * f.confidence;
12
+ }
13
+ function subScore(findings) {
14
+ const total = findings.reduce((acc, f) => acc + penalty(f), 0);
15
+ return clamp(Math.round(100 - total), 0, 100);
16
+ }
17
+ function clamp(n, lo, hi) {
18
+ return Math.max(lo, Math.min(hi, n));
19
+ }
20
+ function gradeFor(n) {
21
+ if (n >= 90)
22
+ return "A";
23
+ if (n >= 80)
24
+ return "B";
25
+ if (n >= 70)
26
+ return "C";
27
+ if (n >= 55)
28
+ return "D";
29
+ if (n >= 40)
30
+ return "E";
31
+ return "F";
32
+ }
33
+ export function computeScore(findings) {
34
+ const ai = findings.filter((f) => f.category === "ai");
35
+ const secrets = findings.filter((f) => f.detector === "hardcoded-secret");
36
+ const deployment = findings.filter((f) => ["cors-misconfig", "weak-jwt"].includes(f.detector));
37
+ const app = findings.filter((f) => f.category === "classic" && !secrets.includes(f) && !deployment.includes(f));
38
+ const subScores = {
39
+ appSecurity: subScore(app),
40
+ aiSecurity: subScore(ai),
41
+ dependencies: subScore(secrets),
42
+ deployment: subScore(deployment),
43
+ };
44
+ // Weighted composite — app + AI carry the most signal for vibe-coded apps.
45
+ const composite = clamp(Math.round(subScores.appSecurity * 0.4 +
46
+ subScores.aiSecurity * 0.3 +
47
+ subScores.dependencies * 0.15 +
48
+ subScores.deployment * 0.15), 0, 100);
49
+ const readiness = computeReadiness(findings, composite);
50
+ return { composite, grade: gradeFor(composite), subScores, readiness };
51
+ }
52
+ function computeReadiness(allFindings, composite) {
53
+ // The launch-readiness checks (category "readiness") are a separate lens; they
54
+ // must never move the core ship/warn/block verdict.
55
+ const findings = allFindings.filter((f) => f.category !== "readiness");
56
+ const reachableCritical = findings.some((f) => f.severity === "critical" && f.reachable);
57
+ const liveSecret = findings.some((f) => f.detector === "hardcoded-secret" && (f.severity === "critical" || f.severity === "high"));
58
+ const aiToolNoFirewall = findings.some((f) => f.detector === "ai-tool-hijack" || f.detector === "ai-missing-firewall");
59
+ // A known-critical CVE in a dependency blocks even though it is not taint-reachable.
60
+ const criticalDependency = findings.some((f) => f.detector === "vulnerable-dependency" && f.severity === "critical");
61
+ if (reachableCritical || liveSecret || aiToolNoFirewall || criticalDependency)
62
+ return "block";
63
+ if (composite < 80 || findings.some((f) => f.severity === "high"))
64
+ return "warn";
65
+ return "ship";
66
+ }
@@ -0,0 +1,123 @@
1
+ import { walk } from "./ast.js";
2
+ /** Untrusted-input sources. Matching text is considered tainted at its origin. */
3
+ const SOURCE_RE = /\b(req|request)\.(query|body|params|headers|cookies)\b|\bsearchParams\.get\b|\bnextUrl\.searchParams\b|\bparams\.[a-zA-Z_$]|\breq(uest)?\.json\(\)|\bawait\s+\w+\.json\(\)|\bprocess\.argv\b|\bflask\.request\.|\brequest\.args\b|\brequest\.form\b|\brequest\.json\b/;
4
+ function collectAssignments(root) {
5
+ const out = [];
6
+ walk(root, (n) => {
7
+ // JS: const x = expr / let x = expr
8
+ if (n.type === "variable_declarator") {
9
+ const name = n.childForFieldName("name");
10
+ const value = n.childForFieldName("value");
11
+ if (name && value)
12
+ out.push({ name: name.text, valueText: value.text });
13
+ }
14
+ // JS: x = expr
15
+ if (n.type === "assignment_expression") {
16
+ const left = n.childForFieldName("left");
17
+ const right = n.childForFieldName("right");
18
+ if (left && right)
19
+ out.push({ name: left.text, valueText: right.text });
20
+ }
21
+ // Python: x = expr
22
+ if (n.type === "assignment") {
23
+ const left = n.childForFieldName("left");
24
+ const right = n.childForFieldName("right");
25
+ if (left && right)
26
+ out.push({ name: left.text, valueText: right.text });
27
+ }
28
+ });
29
+ return out;
30
+ }
31
+ function referencesTainted(text, tainted) {
32
+ if (SOURCE_RE.test(text))
33
+ return true;
34
+ for (const v of tainted) {
35
+ // word-boundary match so `id` doesn't match `idx`
36
+ const re = new RegExp(`\\b${escapeRe(v)}\\b`);
37
+ if (re.test(text))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ function escapeRe(s) {
43
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
45
+ const ROUTE_DECORATOR_RE = /@[\w.]*\.(get|post|put|patch|delete|route|websocket|head|options)\b/;
46
+ /**
47
+ * Seed taint from route-handler parameters. In FastAPI/Flask, path and query
48
+ * parameters arrive as plain function arguments (e.g. `def order(id: str)`),
49
+ * so they're untrusted input even though no `req.`/`params.` appears.
50
+ */
51
+ function seedRouteParamTaint(root, tainted) {
52
+ walk(root, (n) => {
53
+ if (n.type !== "decorated_definition")
54
+ return;
55
+ let isRoute = false;
56
+ let fn = null;
57
+ for (let i = 0; i < n.namedChildCount; i++) {
58
+ const c = n.namedChild(i);
59
+ if (!c)
60
+ continue;
61
+ if (c.type === "decorator" && ROUTE_DECORATOR_RE.test(c.text))
62
+ isRoute = true;
63
+ if (c.type === "function_definition")
64
+ fn = c;
65
+ }
66
+ if (!isRoute || !fn)
67
+ return;
68
+ const params = fn.childForFieldName("parameters");
69
+ if (!params)
70
+ return;
71
+ for (let i = 0; i < params.namedChildCount; i++) {
72
+ const p = params.namedChild(i);
73
+ if (!p)
74
+ continue;
75
+ const name = paramName(p);
76
+ if (name && name !== "self")
77
+ tainted.add(name);
78
+ }
79
+ });
80
+ }
81
+ function paramName(p) {
82
+ if (p.type === "identifier")
83
+ return p.text;
84
+ const named = p.childForFieldName("name");
85
+ if (named)
86
+ return named.text;
87
+ for (let i = 0; i < p.namedChildCount; i++) {
88
+ const c = p.namedChild(i);
89
+ if (c && c.type === "identifier")
90
+ return c.text;
91
+ }
92
+ return null;
93
+ }
94
+ /** Build the taint state for a parsed file. */
95
+ export function analyzeTaint(root) {
96
+ const assignments = collectAssignments(root);
97
+ const tainted = new Set();
98
+ seedRouteParamTaint(root, tainted);
99
+ // Fixpoint: keep propagating until no new variable becomes tainted.
100
+ let changed = true;
101
+ let guard = 0;
102
+ while (changed && guard++ < 50) {
103
+ changed = false;
104
+ for (const { name, valueText } of assignments) {
105
+ if (tainted.has(name))
106
+ continue;
107
+ if (referencesTainted(valueText, tainted)) {
108
+ tainted.add(name);
109
+ changed = true;
110
+ }
111
+ }
112
+ }
113
+ return { tainted };
114
+ }
115
+ /** Does the given expression text carry untrusted data under this taint state? */
116
+ export function isTainted(text, state) {
117
+ return referencesTainted(text, state.tainted);
118
+ }
119
+ /** Heuristic: does the expression build a string from untrusted data (concat / template / f-string)? */
120
+ export function isUntrustedStringBuild(text, state) {
121
+ const hasInterpolation = /\$\{[^}]*\}/.test(text) || /["'][^"']*["']\s*\+/.test(text) || /\bf["'][^"']*\{/.test(text);
122
+ return hasInterpolation && isTainted(text, state);
123
+ }
@@ -0,0 +1,8 @@
1
+ /** Core types shared across the SecureVibe analysis engine. */
2
+ export const SEVERITY_ORDER = {
3
+ critical: 4,
4
+ high: 3,
5
+ medium: 2,
6
+ low: 1,
7
+ info: 0,
8
+ };