kodingo-cli 1.0.38 → 1.1.1

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.
@@ -63,9 +63,85 @@ function computeFingerprint(body) {
63
63
  .trim();
64
64
  return (0, crypto_1.createHash)("sha256").update(normalised).digest("hex").slice(0, 16);
65
65
  }
66
+ // ── Lightweight call extraction for non-TS languages (heuristic, Tier 2) ─────
67
+ /** Regex-based call-site extraction: matches `identifier(` patterns. Not scope-resolved. */
68
+ function extractCallNamesRegex(body) {
69
+ const calls = new Set();
70
+ const pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
71
+ let match;
72
+ while ((match = pattern.exec(body)) !== null) {
73
+ calls.add(match[1]);
74
+ }
75
+ return Array.from(calls);
76
+ }
66
77
  // ── TypeScript / JavaScript parser ───────────────────────────────────────────
67
78
  // eslint-disable-next-line @typescript-eslint/no-var-requires
68
79
  const { parse: tsParse } = require("@typescript-eslint/typescript-estree");
80
+ // ── Import extraction ────────────────────────────────────────────────────────
81
+ function extractImports(ast) {
82
+ const imports = [];
83
+ const body = ast.body ?? [];
84
+ for (const node of body) {
85
+ if (node.type !== "ImportDeclaration" || !node.source?.value)
86
+ continue;
87
+ const source = node.source.value;
88
+ for (const spec of node.specifiers ?? []) {
89
+ if (spec.type === "ImportDefaultSpecifier" && spec.local?.name) {
90
+ imports.push({ imported: spec.local.name, source, isDefault: true });
91
+ }
92
+ else if (spec.type === "ImportSpecifier" && spec.local?.name) {
93
+ imports.push({ imported: spec.local.name, source, isDefault: false });
94
+ }
95
+ else if (spec.type === "ImportNamespaceSpecifier" && spec.local?.name) {
96
+ imports.push({ imported: spec.local.name, source, isDefault: false });
97
+ }
98
+ }
99
+ }
100
+ return imports;
101
+ }
102
+ // ── Call-site extraction ──────────────────────────────────────────────────────
103
+ /** Walks a symbol's body subtree and collects callee identifier names. Heuristic — not scope-resolved. */
104
+ function extractCallNames(node, acc = new Set()) {
105
+ function walk(n) {
106
+ if (!n || typeof n !== "object")
107
+ return;
108
+ if (n.type === "CallExpression") {
109
+ const callee = n.callee;
110
+ if (callee?.type === "Identifier" && callee.name) {
111
+ acc.add(callee.name);
112
+ }
113
+ else if (callee?.type === "MemberExpression" && callee.property?.name) {
114
+ acc.add(callee.property.name);
115
+ }
116
+ // Also capture bare identifier ARGUMENTS passed to this call — e.g.
117
+ // app.addHook("preHandler", authMiddleware) references authMiddleware
118
+ // without ever calling it directly. Without this, security checks that
119
+ // look for "does this function reference an auth check" would miss the
120
+ // single most common Fastify/Express middleware-registration pattern.
121
+ if (Array.isArray(n.arguments)) {
122
+ for (const arg of n.arguments) {
123
+ if (arg?.type === "Identifier" && arg.name) {
124
+ acc.add(arg.name);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ for (const key of Object.keys(n)) {
130
+ if (key === "parent")
131
+ continue;
132
+ const child = n[key];
133
+ if (Array.isArray(child)) {
134
+ for (const c of child)
135
+ walk(c);
136
+ }
137
+ else if (child && typeof child === "object" && child.type) {
138
+ walk(child);
139
+ }
140
+ }
141
+ }
142
+ walk(node);
143
+ return Array.from(acc);
144
+ }
69
145
  async function parseTypeScript(filePath, source) {
70
146
  try {
71
147
  const parse = tsParse;
@@ -79,6 +155,7 @@ async function parseTypeScript(filePath, source) {
79
155
  });
80
156
  const symbols = [];
81
157
  const lines = source.split("\n");
158
+ const fileImports = extractImports(ast);
82
159
  function getBody(start, end) {
83
160
  return source.slice(start, end);
84
161
  }
@@ -95,6 +172,8 @@ async function parseTypeScript(filePath, source) {
95
172
  endLine: node.loc.end.line,
96
173
  body,
97
174
  fingerprint: computeFingerprint(body),
175
+ calls: extractCallNames(node.body),
176
+ imports: fileImports,
98
177
  });
99
178
  }
100
179
  if ((node.type === "VariableDeclarator") &&
@@ -110,6 +189,8 @@ async function parseTypeScript(filePath, source) {
110
189
  endLine: node.loc.end.line,
111
190
  body,
112
191
  fingerprint: computeFingerprint(body),
192
+ calls: extractCallNames(node.init.body),
193
+ imports: fileImports,
113
194
  });
114
195
  }
115
196
  if (node.type === "ClassDeclaration" ||
@@ -125,6 +206,8 @@ async function parseTypeScript(filePath, source) {
125
206
  endLine: node.loc.end.line,
126
207
  body,
127
208
  fingerprint: computeFingerprint(body),
209
+ calls: extractCallNames(node.body),
210
+ imports: fileImports,
128
211
  });
129
212
  // Visit methods inside the class
130
213
  if (node.body?.body) {
@@ -141,6 +224,8 @@ async function parseTypeScript(filePath, source) {
141
224
  endLine: member.loc.end.line,
142
225
  body: mBody,
143
226
  fingerprint: computeFingerprint(mBody),
227
+ calls: extractCallNames(member.value?.body),
228
+ imports: fileImports,
144
229
  });
145
230
  }
146
231
  }
@@ -213,6 +298,8 @@ function parseStructural(filePath, source, patterns) {
213
298
  endLine,
214
299
  body,
215
300
  fingerprint: computeFingerprint(body),
301
+ calls: extractCallNamesRegex(body),
302
+ imports: [],
216
303
  });
217
304
  break;
218
305
  }
@@ -256,6 +343,8 @@ function parsePython(filePath, source) {
256
343
  endLine,
257
344
  body,
258
345
  fingerprint: computeFingerprint(body),
346
+ calls: extractCallNamesRegex(body),
347
+ imports: [],
259
348
  });
260
349
  }
261
350
  return symbols;
@@ -283,6 +372,7 @@ const KOTLIN_PATTERNS = [
283
372
  /^(?:fun|override\s+fun|suspend\s+fun|private\s+fun|public\s+fun)\s+(\w+)/,
284
373
  /^(?:class|data class|object|interface)\s+(\w+)/,
285
374
  ];
375
+ const TREE_SITTER_EXTENSIONS = new Set([".py", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".c", ".cpp"]);
286
376
  async function parseFile(filePath, source) {
287
377
  const ext = path.extname(filePath).toLowerCase();
288
378
  switch (ext) {
@@ -293,6 +383,21 @@ async function parseFile(filePath, source) {
293
383
  case ".mjs":
294
384
  case ".cjs":
295
385
  return parseTypeScript(filePath, source);
386
+ default:
387
+ break;
388
+ }
389
+ if (TREE_SITTER_EXTENSIONS.has(ext)) {
390
+ try {
391
+ const { parseWithTreeSitter } = require("./tree-sitter-parser");
392
+ const result = await parseWithTreeSitter(filePath, source);
393
+ if (result.length > 0)
394
+ return result;
395
+ }
396
+ catch {
397
+ // fall through to legacy heuristic parsers below
398
+ }
399
+ }
400
+ switch (ext) {
296
401
  case ".py":
297
402
  return parsePython(filePath, source);
298
403
  case ".go":
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * code-pattern-scanner.ts
4
+ *
5
+ * Layer 1 of Kortex Security's code analysis: deterministic pattern detection
6
+ * over function bodies already extracted by ast-parser.ts. Zero LLM cost,
7
+ * runs on every scan. Findings here feed Layer 2 (LLM-grounded severity
8
+ * judgment using graph context) for anything worth a closer look.
9
+ *
10
+ * These are heuristic pattern matches, not proof of a vulnerability — that's
11
+ * exactly what Layer 2 exists to adjudicate. A match here means "worth a
12
+ * human or an LLM looking at this," not "this is definitely exploitable."
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.scanSymbolForPatterns = scanSymbolForPatterns;
16
+ exports.scanAllSymbolsForPatterns = scanAllSymbolsForPatterns;
17
+ const MAX_SNIPPET_LENGTH = 200;
18
+ function extractSnippet(body, matchIndex) {
19
+ // Grab the line containing the match, plus a little context
20
+ const before = body.slice(0, matchIndex);
21
+ const lineStart = before.lastIndexOf("\n") + 1;
22
+ const lineEndRel = body.slice(matchIndex).indexOf("\n");
23
+ const lineEnd = lineEndRel === -1 ? body.length : matchIndex + lineEndRel;
24
+ const snippet = body.slice(lineStart, lineEnd).trim();
25
+ return snippet.length > MAX_SNIPPET_LENGTH ? snippet.slice(0, MAX_SNIPPET_LENGTH) + "..." : snippet;
26
+ }
27
+ const RULES = [
28
+ // ── SQL injection — string concatenation/interpolation built directly into
29
+ // a query call, instead of a parameterized placeholder ────────────────────
30
+ {
31
+ type: "sql_injection",
32
+ severity: "critical",
33
+ regex: /\b(query|execute|exec)\s*\(\s*[`'"][^`'"]*\$\{[^}]+\}[^`'"]*[`'"]/,
34
+ description: "Query string built with template literal interpolation instead of parameterized placeholders — classic SQL injection pattern.",
35
+ },
36
+ {
37
+ type: "sql_injection",
38
+ severity: "critical",
39
+ regex: /\b(query|execute|exec)\s*\(\s*[`'"][^`'"]*['"]?\s*\+\s*\w+/,
40
+ description: "Query string built with string concatenation instead of parameterized placeholders.",
41
+ },
42
+ // ── Command injection ──────────────────────────────────────────────────────
43
+ {
44
+ type: "command_injection",
45
+ severity: "critical",
46
+ regex: /\b(exec|execSync|spawn)\s*\(\s*[`'"][^`'"]*\$\{[^}]+\}/,
47
+ description: "Shell command built with interpolated variables — potential command injection if the variable is user-controlled.",
48
+ },
49
+ {
50
+ type: "command_injection",
51
+ severity: "critical",
52
+ regex: /\bos\.system\s*\(\s*.*%|\.system\s*\(\s*f['"]/, // Python os.system with % formatting or f-string
53
+ description: "Shell command built with string formatting — potential command injection.",
54
+ languages: ["python"],
55
+ },
56
+ {
57
+ type: "command_injection",
58
+ severity: "high",
59
+ regex: /\beval\s*\(/,
60
+ description: "Use of eval() — executes arbitrary code from its input. Almost always avoidable.",
61
+ },
62
+ // ── Hardcoded secrets ──────────────────────────────────────────────────────
63
+ {
64
+ type: "hardcoded_secret",
65
+ severity: "high",
66
+ regex: /\b(api[_-]?key|secret[_-]?key|password|passwd|access[_-]?token|private[_-]?key)\s*[:=]\s*['"][A-Za-z0-9_\-\/+=]{12,}['"]/i,
67
+ description: "Possible hardcoded credential or secret assigned directly in code, rather than loaded from environment/config.",
68
+ },
69
+ // ── Insecure crypto ─────────────────────────────────────────────────────────
70
+ {
71
+ type: "insecure_crypto",
72
+ severity: "moderate",
73
+ regex: /\bcreateHash\s*\(\s*['"](md5|sha1)['"]\)/i,
74
+ description: "MD5/SHA1 used for hashing — cryptographically broken for security-sensitive use (passwords, tokens, signatures).",
75
+ },
76
+ {
77
+ type: "insecure_crypto",
78
+ severity: "high",
79
+ regex: /\bMath\.random\s*\(\s*\)/,
80
+ description: "Math.random() used — not cryptographically secure. If this feeds a token, session ID, or password reset code, it's predictable.",
81
+ },
82
+ // ── Path traversal ──────────────────────────────────────────────────────────
83
+ {
84
+ type: "path_traversal",
85
+ severity: "high",
86
+ regex: /\b(readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream)\s*\(\s*[^)]*\+\s*\w+/,
87
+ description: "File path built via concatenation with a variable — potential path traversal if that variable is user-controlled.",
88
+ },
89
+ // ── SSRF ──────────────────────────────────────────────────────────────────
90
+ {
91
+ type: "ssrf",
92
+ severity: "high",
93
+ regex: /\b(fetch|axios\.get|axios\.post|request)\s*\(\s*[^)]*\+\s*\w+/,
94
+ description: "Outbound HTTP request built via concatenation with a variable — potential SSRF if that variable is user-controlled.",
95
+ },
96
+ ];
97
+ /**
98
+ * Scans a single symbol's body against all pattern rules. Returns every match —
99
+ * a symbol can trigger multiple findings if it has multiple distinct issues.
100
+ */
101
+ function scanSymbolForPatterns(symbol, language) {
102
+ const findings = [];
103
+ for (const rule of RULES) {
104
+ if (rule.languages && !rule.languages.includes(language))
105
+ continue;
106
+ const match = symbol.body.match(rule.regex);
107
+ if (match && match.index !== undefined) {
108
+ findings.push({
109
+ patternType: rule.type,
110
+ symbol: symbol.name,
111
+ file: symbol.file,
112
+ startLine: symbol.startLine,
113
+ severity: rule.severity,
114
+ snippet: extractSnippet(symbol.body, match.index),
115
+ description: rule.description,
116
+ });
117
+ }
118
+ }
119
+ return findings;
120
+ }
121
+ /** Scans every symbol in a repo (already-parsed) for code patterns. */
122
+ function scanAllSymbolsForPatterns(symbols) {
123
+ const findings = [];
124
+ for (const symbol of symbols) {
125
+ findings.push(...scanSymbolForPatterns(symbol, symbol.language ?? "unknown"));
126
+ }
127
+ return findings;
128
+ }
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * context-assembler.ts
4
+ *
5
+ * Given a target symbol's fingerprint and the repo's structural graph,
6
+ * assembles a bounded 1-hop neighborhood: what it calls, what calls it,
7
+ * and its imports. This is the context sent to /infer/contextual instead
8
+ * of reasoning about a function in total isolation.
9
+ *
10
+ * Token budget is enforced by truncating the lowest-confidence neighbors
11
+ * first — never sends unbounded context.
12
+ */
13
+ var __importDefault = (this && this.__importDefault) || function (mod) {
14
+ return (mod && mod.__esModule) ? mod : { "default": mod };
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.assembleContext = assembleContext;
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const DEFAULT_TOKEN_BUDGET = 3000;
21
+ const CHARS_PER_TOKEN_ESTIMATE = 4;
22
+ const MAX_SNIPPET_CHARS = 500; // per-neighbor cap before budget truncation
23
+ function readSnippet(repoRoot, file, startLine, endLine) {
24
+ try {
25
+ const absPath = path_1.default.isAbsolute(file) ? file : path_1.default.join(repoRoot, file);
26
+ const source = fs_1.default.readFileSync(absPath, "utf-8");
27
+ const lines = source.split("\n");
28
+ const snippet = lines.slice(Math.max(0, startLine - 1), endLine).join("\n");
29
+ return snippet.length > MAX_SNIPPET_CHARS ? snippet.slice(0, MAX_SNIPPET_CHARS) + "\n// ...truncated" : snippet;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function estimateTokens(text) {
36
+ return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
37
+ }
38
+ function assembleContext(repoRoot, graph, targetFingerprint, tokenBudget = DEFAULT_TOKEN_BUDGET) {
39
+ const target = graph.nodes[targetFingerprint];
40
+ if (!target)
41
+ return null;
42
+ const targetBody = readSnippet(repoRoot, target.file, target.startLine, target.endLine);
43
+ if (targetBody === null)
44
+ return null;
45
+ let usedTokens = estimateTokens(targetBody);
46
+ // Callees: edges where this symbol is the source
47
+ const calleeEdges = graph.edges
48
+ .filter(e => e.from === targetFingerprint)
49
+ .sort((a, b) => b.confidence - a.confidence);
50
+ // Callers: edges where this symbol is the target
51
+ const callerEdges = graph.edges
52
+ .filter(e => e.to === targetFingerprint)
53
+ .sort((a, b) => b.confidence - a.confidence);
54
+ function buildNeighbors(edges, direction) {
55
+ const neighbors = [];
56
+ for (const edge of edges) {
57
+ const neighborFingerprint = direction === "from" ? edge.to : edge.from;
58
+ const neighborNode = graph.nodes[neighborFingerprint];
59
+ if (!neighborNode)
60
+ continue;
61
+ const snippet = readSnippet(repoRoot, neighborNode.file, neighborNode.startLine, neighborNode.endLine);
62
+ if (snippet === null)
63
+ continue;
64
+ const projected = usedTokens + estimateTokens(snippet);
65
+ if (projected > tokenBudget)
66
+ break; // budget exhausted — remaining lower-confidence neighbors are dropped
67
+ usedTokens = projected;
68
+ neighbors.push({
69
+ name: neighborNode.name,
70
+ file: neighborNode.file,
71
+ snippet,
72
+ confidence: edge.confidence,
73
+ edgeType: edge.type,
74
+ });
75
+ }
76
+ return neighbors;
77
+ }
78
+ const callees = buildNeighbors(calleeEdges, "from");
79
+ const callers = buildNeighbors(callerEdges, "to");
80
+ const allConfidences = [...callees, ...callers].map(n => n.confidence);
81
+ const weakestConfidence = allConfidences.length > 0 ? Math.min(...allConfidences) : 1.0;
82
+ return {
83
+ targetName: target.name,
84
+ targetFile: target.file,
85
+ targetBody,
86
+ callers,
87
+ callees,
88
+ imports: [], // populated by caller if needed — graph doesn't currently store per-node imports
89
+ weakestConfidence,
90
+ };
91
+ }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ /**
3
+ * dependency-parser.ts
4
+ *
5
+ * Extracts declared dependencies from manifest files. Used to feed the
6
+ * security scanning pipeline — the server queries OSV.dev against whatever
7
+ * this extracts, so accuracy here directly determines scan coverage.
8
+ *
9
+ * Ecosystem names match OSV.dev's own ecosystem strings exactly
10
+ * (https://ossf.github.io/osv-schema/#affectedpackage-field) — this is not
11
+ * arbitrary; using the wrong ecosystem name means OSV silently returns zero
12
+ * results instead of erroring, so precision here matters.
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.extractDependencies = extractDependencies;
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ function stripVersionRange(raw) {
22
+ // package.json / composer.json versions are often ranges like "^4.17.21",
23
+ // "~1.2.3", ">=2.0 <3.0". OSV wants a concrete version to check, so we take
24
+ // the first concrete version number found — this is a best-effort resolution,
25
+ // not a full semver-range solver. A locked version from package-lock.json
26
+ // or composer.lock is more accurate when available (handled separately).
27
+ const match = raw.match(/\d+\.\d+(\.\d+)?(-[\w.]+)?/);
28
+ return match ? match[0] : raw;
29
+ }
30
+ function parsePackageJson(repoRoot, filePath) {
31
+ const entries = [];
32
+ try {
33
+ const raw = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
34
+ const sections = ["dependencies", "devDependencies"];
35
+ for (const section of sections) {
36
+ const deps = raw[section];
37
+ if (!deps || typeof deps !== "object")
38
+ continue;
39
+ for (const [name, versionRaw] of Object.entries(deps)) {
40
+ if (typeof versionRaw !== "string")
41
+ continue;
42
+ entries.push({
43
+ ecosystem: "npm",
44
+ packageName: name,
45
+ version: stripVersionRange(versionRaw),
46
+ manifestFile: path_1.default.relative(repoRoot, filePath),
47
+ });
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // malformed or unreadable manifest — skip, never fail the whole scan
53
+ }
54
+ return entries;
55
+ }
56
+ /** Prefers package-lock.json's exact resolved versions when available — far more
57
+ * accurate than package.json's version ranges for vulnerability matching. */
58
+ function parsePackageLockJson(repoRoot, filePath) {
59
+ const entries = [];
60
+ try {
61
+ const raw = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
62
+ const packages = raw.packages;
63
+ if (packages && typeof packages === "object") {
64
+ // npm lockfile v2/v3 format: keys are paths like "node_modules/lodash"
65
+ for (const [key, val] of Object.entries(packages)) {
66
+ if (!key.startsWith("node_modules/"))
67
+ continue;
68
+ const name = key.replace(/^node_modules\//, "");
69
+ const version = val?.version;
70
+ if (typeof version === "string") {
71
+ entries.push({
72
+ ecosystem: "npm",
73
+ packageName: name,
74
+ version,
75
+ manifestFile: path_1.default.relative(repoRoot, filePath),
76
+ });
77
+ }
78
+ }
79
+ return entries;
80
+ }
81
+ // npm lockfile v1 format: "dependencies" key, nested
82
+ const deps = raw.dependencies;
83
+ if (deps && typeof deps === "object") {
84
+ for (const [name, val] of Object.entries(deps)) {
85
+ const version = val?.version;
86
+ if (typeof version === "string") {
87
+ entries.push({
88
+ ecosystem: "npm",
89
+ packageName: name,
90
+ version,
91
+ manifestFile: path_1.default.relative(repoRoot, filePath),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // malformed or unreadable lockfile — skip
99
+ }
100
+ return entries;
101
+ }
102
+ function parseComposerJson(repoRoot, filePath) {
103
+ const entries = [];
104
+ try {
105
+ const raw = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
106
+ const sections = ["require", "require-dev"];
107
+ for (const section of sections) {
108
+ const deps = raw[section];
109
+ if (!deps || typeof deps !== "object")
110
+ continue;
111
+ for (const [name, versionRaw] of Object.entries(deps)) {
112
+ if (typeof versionRaw !== "string")
113
+ continue;
114
+ if (name === "php" || name.startsWith("ext-"))
115
+ continue; // not real packages
116
+ entries.push({
117
+ ecosystem: "Packagist",
118
+ packageName: name,
119
+ version: stripVersionRange(versionRaw),
120
+ manifestFile: path_1.default.relative(repoRoot, filePath),
121
+ });
122
+ }
123
+ }
124
+ }
125
+ catch {
126
+ // malformed or unreadable manifest — skip
127
+ }
128
+ return entries;
129
+ }
130
+ /** Prefers composer.lock's exact resolved versions when available. */
131
+ function parseComposerLock(repoRoot, filePath) {
132
+ const entries = [];
133
+ try {
134
+ const raw = JSON.parse(fs_1.default.readFileSync(filePath, "utf-8"));
135
+ const sections = ["packages", "packages-dev"];
136
+ for (const section of sections) {
137
+ const pkgs = raw[section];
138
+ if (!Array.isArray(pkgs))
139
+ continue;
140
+ for (const pkg of pkgs) {
141
+ if (!pkg?.name || !pkg?.version)
142
+ continue;
143
+ entries.push({
144
+ ecosystem: "Packagist",
145
+ packageName: pkg.name,
146
+ version: stripVersionRange(pkg.version),
147
+ manifestFile: path_1.default.relative(repoRoot, filePath),
148
+ });
149
+ }
150
+ }
151
+ }
152
+ catch {
153
+ // malformed or unreadable lockfile — skip
154
+ }
155
+ return entries;
156
+ }
157
+ const IGNORED_DIRS = new Set(["node_modules", ".git", ".kortex", "dist", "build", "vendor", "out", ".next"]);
158
+ function findManifestFiles(repoRoot) {
159
+ const result = { packageJson: [], packageLock: [], composerJson: [], composerLock: [] };
160
+ function walk(dir) {
161
+ let entries;
162
+ try {
163
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
164
+ }
165
+ catch {
166
+ return;
167
+ }
168
+ for (const entry of entries) {
169
+ if (IGNORED_DIRS.has(entry.name))
170
+ continue;
171
+ const fullPath = path_1.default.join(dir, entry.name);
172
+ if (entry.isDirectory()) {
173
+ walk(fullPath);
174
+ }
175
+ else if (entry.name === "package.json") {
176
+ result.packageJson.push(fullPath);
177
+ }
178
+ else if (entry.name === "package-lock.json") {
179
+ result.packageLock.push(fullPath);
180
+ }
181
+ else if (entry.name === "composer.json") {
182
+ result.composerJson.push(fullPath);
183
+ }
184
+ else if (entry.name === "composer.lock") {
185
+ result.composerLock.push(fullPath);
186
+ }
187
+ }
188
+ }
189
+ walk(repoRoot);
190
+ return result;
191
+ }
192
+ /**
193
+ * Extracts all dependencies from a repo. Prefers lockfiles (exact resolved
194
+ * versions) over manifest files (version ranges) when both exist for the
195
+ * same directory, since lockfile versions are what's actually installed and
196
+ * therefore what's actually exploitable.
197
+ */
198
+ function extractDependencies(repoRoot) {
199
+ const manifests = findManifestFiles(repoRoot);
200
+ const entries = [];
201
+ // npm: prefer lockfiles; fall back to package.json only for directories
202
+ // that don't have a corresponding lockfile
203
+ const lockDirs = new Set(manifests.packageLock.map(p => path_1.default.dirname(p)));
204
+ for (const lockFile of manifests.packageLock) {
205
+ entries.push(...parsePackageLockJson(repoRoot, lockFile));
206
+ }
207
+ for (const pkgFile of manifests.packageJson) {
208
+ if (!lockDirs.has(path_1.default.dirname(pkgFile))) {
209
+ entries.push(...parsePackageJson(repoRoot, pkgFile));
210
+ }
211
+ }
212
+ // Composer: same lockfile-preference pattern
213
+ const composerLockDirs = new Set(manifests.composerLock.map(p => path_1.default.dirname(p)));
214
+ for (const lockFile of manifests.composerLock) {
215
+ entries.push(...parseComposerLock(repoRoot, lockFile));
216
+ }
217
+ for (const pkgFile of manifests.composerJson) {
218
+ if (!composerLockDirs.has(path_1.default.dirname(pkgFile))) {
219
+ entries.push(...parseComposerJson(repoRoot, pkgFile));
220
+ }
221
+ }
222
+ // Deduplicate — same package/ecosystem can appear in multiple lockfiles in monorepos
223
+ const seen = new Map();
224
+ for (const e of entries) {
225
+ const key = `${e.ecosystem}::${e.packageName}`;
226
+ seen.set(key, e); // last write wins — fine since versions should be consistent within a repo
227
+ }
228
+ return Array.from(seen.values());
229
+ }