kodingo-cli 1.1.0 → 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.
package/dist/cli.js CHANGED
@@ -15,6 +15,8 @@ const scan_git_1 = require("./commands/scan-git");
15
15
  const scan_claude_1 = require("./commands/scan-claude");
16
16
  const scan_repo_1 = require("./commands/scan-repo");
17
17
  const sync_graph_1 = require("./commands/sync-graph");
18
+ const sync_dependencies_1 = require("./commands/sync-dependencies");
19
+ const security_scan_1 = require("./commands/security-scan");
18
20
  const init_1 = require("./commands/init");
19
21
  const install_hook_1 = require("./commands/install-hook");
20
22
  const update_1 = require("./commands/update");
@@ -242,6 +244,26 @@ program
242
244
  payload.repo = String(options.repo);
243
245
  await (0, sync_graph_1.syncGraphCommand)(payload);
244
246
  });
247
+ program
248
+ .command("sync-dependencies")
249
+ .description("Extract and sync declared dependencies for security scanning")
250
+ .option("--repo <path>", "repo path to scan")
251
+ .action(async (options) => {
252
+ const payload = {};
253
+ if (options.repo)
254
+ payload.repo = String(options.repo);
255
+ await (0, sync_dependencies_1.syncDependenciesCommand)(payload);
256
+ });
257
+ program
258
+ .command("security-scan")
259
+ .description("Scan synced dependencies for known vulnerabilities")
260
+ .option("--repo <path>", "repo path to scan")
261
+ .action(async (options) => {
262
+ const payload = {};
263
+ if (options.repo)
264
+ payload.repo = String(options.repo);
265
+ await (0, security_scan_1.securityScanCommand)(payload);
266
+ });
245
267
  program
246
268
  .command("context")
247
269
  .description("Get affirmed memories for a file — designed for AI agents")
@@ -289,7 +289,24 @@ function registerInitCommand(program) {
289
289
  catch { }
290
290
  console.log(`✔ Connected. Token saved to ${persistence_config_1.CONFIG_PATH}`);
291
291
  console.log("\nKortex is now watching this repo.");
292
- console.log("Run `kodingo install-hook` to capture memories on every commit.\n");
292
+ // Install the git hook automatically — no manual step required.
293
+ // This is what triggers scan-git and sync-dependencies on every future
294
+ // commit, silently in the background.
295
+ try {
296
+ const { installHookCommand } = await Promise.resolve().then(() => __importStar(require("./install-hook")));
297
+ await installHookCommand({ repo: repoRoot });
298
+ console.log("✔ Git hook installed — memories and dependency data will stay current automatically on every commit.\n");
299
+ }
300
+ catch (err) {
301
+ console.log(`⚠ Could not auto-install git hook (${err.message}). Run \`kodingo install-hook\` manually to enable automatic capture.\n`);
302
+ }
303
+ // Initial dependency sync — so security scanning has data immediately,
304
+ // rather than waiting for the next commit to trigger the hook.
305
+ try {
306
+ const { syncDependenciesCommand } = await Promise.resolve().then(() => __importStar(require("./sync-dependencies")));
307
+ await syncDependenciesCommand({ repo: repoRoot, silent: true });
308
+ }
309
+ catch { }
293
310
  // Auto-scan existing codebase
294
311
  try {
295
312
  const { scanRepoCommand } = await Promise.resolve().then(() => __importStar(require("./scan-repo")));
@@ -18,6 +18,8 @@ async function installHookCommand(options) {
18
18
  "# <kodingo>",
19
19
  "# Kodingo: auto-infer decision memory from git commits",
20
20
  `"${kodingoBin}" scan-git --repo "$(git rev-parse --show-toplevel)" &`,
21
+ "# Kodingo Security: keep dependency data current for vulnerability scanning",
22
+ `"${kodingoBin}" sync-dependencies --repo "$(git rev-parse --show-toplevel)" &`,
21
23
  "# </kodingo>",
22
24
  ].join("\n");
23
25
  // Pre-commit hook — runs synchronously to enforce guardrails
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ /**
3
+ * kodingo security-scan
4
+ *
5
+ * Triggers a vulnerability scan against whatever dependencies were last
6
+ * synced via 'kodingo sync-dependencies', then prints a ranked report.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.securityScanCommand = securityScanCommand;
13
+ const path_1 = __importDefault(require("path"));
14
+ const fs_1 = __importDefault(require("fs"));
15
+ const persistence_config_1 = require("../utils/persistence-config");
16
+ const repo_scope_1 = require("../utils/repo-scope");
17
+ const graph_builder_1 = require("../utils/graph-builder");
18
+ const security_code_analyzer_1 = require("../utils/security-code-analyzer");
19
+ const context_assembler_1 = require("../utils/context-assembler");
20
+ const ast_parser_1 = require("../utils/ast-parser");
21
+ const IGNORED_DIRS = new Set(["node_modules", ".git", ".kortex", "dist", "build", "vendor", "out", ".next"]);
22
+ const SUPPORTED_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".php", ".java", ".kt", ".rb", ".c", ".cpp"];
23
+ function walkFiles(dir) {
24
+ const results = [];
25
+ try {
26
+ const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
27
+ for (const entry of entries) {
28
+ if (IGNORED_DIRS.has(entry.name))
29
+ continue;
30
+ const fullPath = path_1.default.join(dir, entry.name);
31
+ if (entry.isDirectory()) {
32
+ results.push(...walkFiles(fullPath));
33
+ }
34
+ else if (entry.isFile() && SUPPORTED_EXTENSIONS.includes(path_1.default.extname(entry.name))) {
35
+ results.push(fullPath);
36
+ }
37
+ }
38
+ }
39
+ catch { }
40
+ return results;
41
+ }
42
+ const SEVERITY_ICON = {
43
+ critical: "🔴", high: "🟠", moderate: "🟡", low: "🔵", unknown: "⚪",
44
+ };
45
+ async function securityScanCommand(options = {}) {
46
+ const repoRoot = (0, repo_scope_1.resolveRepoScope)(options.repo ? { repo: options.repo } : {}) ?? process.cwd();
47
+ const config = (0, persistence_config_1.readConfig)();
48
+ const wsConfig = (0, persistence_config_1.readWorkspaceConfig)(repoRoot);
49
+ const token = wsConfig?.token ?? "";
50
+ const apiUrl = wsConfig?.apiUrl ?? config.apiUrl ?? "https://kodingo-api.onrender.com";
51
+ if (!token) {
52
+ if (!options.silent)
53
+ console.error("✖ This repo is not connected to Kodingo. Run `kodingo init` first.");
54
+ return false;
55
+ }
56
+ const repoName = path_1.default.basename(repoRoot);
57
+ if (!options.silent)
58
+ console.log(`\n🔒 Kortex Security — scanning ${repoName} for known vulnerabilities...\n`);
59
+ try {
60
+ const scanRes = await fetch(`${apiUrl}/security/scan`, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
63
+ body: JSON.stringify({ repo: repoName }),
64
+ });
65
+ if (!scanRes.ok) {
66
+ if (!options.silent)
67
+ console.error(` ✖ Scan failed: ${scanRes.status} ${await scanRes.text()}`);
68
+ return false;
69
+ }
70
+ const scanData = await scanRes.json();
71
+ if (scanData.message) {
72
+ if (!options.silent)
73
+ console.log(` ${scanData.message}`);
74
+ return true;
75
+ }
76
+ if (!options.silent)
77
+ console.log(` Checked ${scanData.scanned} dependencies against known vulnerability databases\n`);
78
+ const findingsRes = await fetch(`${apiUrl}/security/findings?repo=${encodeURIComponent(repoName)}`, {
79
+ headers: { "X-Kodingo-Token": token },
80
+ });
81
+ if (!findingsRes.ok)
82
+ return true; // scan itself succeeded even if the report fetch fails
83
+ const { findings } = await findingsRes.json();
84
+ if (findings.length === 0) {
85
+ if (!options.silent)
86
+ console.log(" ✔ No known vulnerabilities found in your dependencies.");
87
+ return true;
88
+ }
89
+ if (!options.silent) {
90
+ console.log(` ⚠ ${findings.length} vulnerabilit${findings.length === 1 ? "y" : "ies"} found:\n`);
91
+ for (const f of findings) {
92
+ const icon = SEVERITY_ICON[f.severity] ?? "⚪";
93
+ console.log(` ${icon} ${f.severity.toUpperCase()} — ${f.package_name}@${f.version}`);
94
+ console.log(` ${f.summary}`);
95
+ if (f.fixed_version)
96
+ console.log(` Fix: upgrade to ${f.fixed_version} or later`);
97
+ if (f.alsoAffectsRepoCount && f.alsoAffectsRepoCount > 0) {
98
+ console.log(` ⚠ Also present in ${f.alsoAffectsRepoCount} other repo${f.alsoAffectsRepoCount === 1 ? "" : "s"} in this project`);
99
+ }
100
+ console.log(` ${f.vulnerability_id}\n`);
101
+ }
102
+ }
103
+ // ── Code-level analysis — Layer 1 (local) + Layer 2 (server LLM judgment) ──
104
+ if (!options.silent)
105
+ console.log(`\n🔍 Analysing code for vulnerable patterns and missing auth...\n`);
106
+ try {
107
+ const graph = await (0, graph_builder_1.buildGraph)(repoRoot);
108
+ (0, graph_builder_1.saveGraph)(repoRoot, graph);
109
+ const files = walkFiles(repoRoot);
110
+ const symbols = [];
111
+ for (const file of files) {
112
+ try {
113
+ const source = fs_1.default.readFileSync(file, "utf-8");
114
+ symbols.push(...(await (0, ast_parser_1.parseFile)(file, source)));
115
+ }
116
+ catch { }
117
+ }
118
+ const candidates = (0, security_code_analyzer_1.analyzeForSecurityCandidates)(repoRoot, graph, symbols);
119
+ if (candidates.length === 0) {
120
+ if (!options.silent)
121
+ console.log(" ✔ No suspicious patterns detected.");
122
+ }
123
+ else {
124
+ const codeScanRes = await fetch(`${apiUrl}/security/scan-code`, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
127
+ body: JSON.stringify({ repo: repoName, candidates }),
128
+ });
129
+ if (codeScanRes.ok) {
130
+ const codeResult = await codeScanRes.json();
131
+ if (!options.silent) {
132
+ console.log(` Checked ${codeResult.scanned} candidates — ${codeResult.findingsCreated} confirmed, ${codeResult.dismissed} dismissed as false positives after review\n`);
133
+ }
134
+ }
135
+ else if (!options.silent) {
136
+ console.error(` ✖ Code analysis failed: ${codeScanRes.status}`);
137
+ }
138
+ }
139
+ }
140
+ catch (err) {
141
+ if (!options.silent)
142
+ console.error(` ✖ Code analysis failed: ${err.message}`);
143
+ }
144
+ // ── Decision drift — does current code still honour past decisions? ──────
145
+ if (!options.silent)
146
+ console.log(`\n📜 Checking for drift from past decisions...\n`);
147
+ try {
148
+ const graph = await (0, graph_builder_1.buildGraph)(repoRoot);
149
+ const files = walkFiles(repoRoot);
150
+ const symbols = [];
151
+ for (const file of files) {
152
+ try {
153
+ const source = fs_1.default.readFileSync(file, "utf-8");
154
+ symbols.push(...(await (0, ast_parser_1.parseFile)(file, source)));
155
+ }
156
+ catch { }
157
+ }
158
+ const symbolNames = Array.from(new Set(symbols.map(s => s.name)));
159
+ if (symbolNames.length > 0) {
160
+ const candidatesRes = await fetch(`${apiUrl}/security/decision-drift-candidates`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
163
+ body: JSON.stringify({ repo: repoName, symbols: symbolNames.slice(0, 3000) }),
164
+ });
165
+ if (candidatesRes.ok) {
166
+ const { matches } = await candidatesRes.json();
167
+ if (matches.length === 0) {
168
+ if (!options.silent)
169
+ console.log(" No symbols with affirmed decisions found yet.");
170
+ }
171
+ else {
172
+ const checks = [];
173
+ for (const match of matches) {
174
+ const sym = symbols.find(s => s.name === match.symbol);
175
+ if (!sym)
176
+ continue;
177
+ const assembled = (0, context_assembler_1.assembleContext)(repoRoot, graph, sym.fingerprint);
178
+ if (!assembled)
179
+ continue;
180
+ checks.push({
181
+ symbol: match.symbol,
182
+ file: sym.file,
183
+ fingerprint: sym.fingerprint,
184
+ currentBody: assembled.targetBody,
185
+ decisionTitle: match.decisionTitle,
186
+ decisionContent: match.decisionContent,
187
+ });
188
+ }
189
+ if (checks.length > 0) {
190
+ const driftRes = await fetch(`${apiUrl}/security/scan-decision-drift`, {
191
+ method: "POST",
192
+ headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
193
+ body: JSON.stringify({ repo: repoName, checks }),
194
+ });
195
+ if (driftRes.ok) {
196
+ const driftResult = await driftRes.json();
197
+ if (!options.silent) {
198
+ console.log(` Checked ${driftResult.scanned} symbols against past decisions — ${driftResult.findingsCreated} drifted, ${driftResult.noDrift} still honoured\n`);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+ catch (err) {
207
+ if (!options.silent)
208
+ console.error(` ✖ Decision drift check failed: ${err.message}`);
209
+ }
210
+ return true;
211
+ }
212
+ catch (err) {
213
+ if (!options.silent)
214
+ console.error(` ✖ Scan failed: ${err.message}`);
215
+ return false;
216
+ }
217
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * kodingo sync-dependencies
4
+ *
5
+ * Extracts declared dependencies from the current repo (package.json/lock,
6
+ * composer.json/lock) and syncs them to the server for security scanning.
7
+ * Run this before 'kodingo security-scan' — the scan works off whatever was
8
+ * last synced here.
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.syncDependenciesCommand = syncDependenciesCommand;
15
+ const path_1 = __importDefault(require("path"));
16
+ const persistence_config_1 = require("../utils/persistence-config");
17
+ const repo_scope_1 = require("../utils/repo-scope");
18
+ const dependency_parser_1 = require("../utils/dependency-parser");
19
+ async function syncDependenciesCommand(options = {}) {
20
+ const repoRoot = (0, repo_scope_1.resolveRepoScope)(options.repo ? { repo: options.repo } : {}) ?? process.cwd();
21
+ const config = (0, persistence_config_1.readConfig)();
22
+ const wsConfig = (0, persistence_config_1.readWorkspaceConfig)(repoRoot);
23
+ const token = wsConfig?.token ?? "";
24
+ const apiUrl = wsConfig?.apiUrl ?? config.apiUrl ?? "https://kodingo-api.onrender.com";
25
+ if (!token) {
26
+ if (!options.silent)
27
+ console.error("✖ This repo is not connected to Kodingo. Run `kodingo init` first.");
28
+ return false;
29
+ }
30
+ const repoName = path_1.default.basename(repoRoot);
31
+ if (!options.silent)
32
+ console.log(`\n🔒 Kortex Security — scanning dependencies for ${repoName}...\n`);
33
+ const deps = (0, dependency_parser_1.extractDependencies)(repoRoot);
34
+ if (deps.length === 0) {
35
+ if (!options.silent)
36
+ console.log(" No package.json or composer.json found. Nothing to sync.");
37
+ return true;
38
+ }
39
+ if (!options.silent)
40
+ console.log(` Found ${deps.length} dependencies`);
41
+ try {
42
+ const res = await fetch(`${apiUrl}/security/sync`, {
43
+ method: "POST",
44
+ headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
45
+ body: JSON.stringify({
46
+ repo: repoName,
47
+ dependencies: deps.map(d => ({
48
+ ecosystem: d.ecosystem,
49
+ packageName: d.packageName,
50
+ version: d.version,
51
+ manifestFile: d.manifestFile,
52
+ })),
53
+ }),
54
+ });
55
+ if (!res.ok) {
56
+ if (!options.silent)
57
+ console.error(` ✖ Sync failed: ${res.status} ${await res.text()}`);
58
+ return false;
59
+ }
60
+ if (!options.silent)
61
+ console.log(`\n✔ Synced ${deps.length} dependencies for ${repoName}`);
62
+ return true;
63
+ }
64
+ catch (err) {
65
+ if (!options.silent)
66
+ console.error(` ✖ Sync failed: ${err.message}`);
67
+ return false;
68
+ }
69
+ }
@@ -69,6 +69,7 @@ async function syncGraphCommand(options = {}) {
69
69
  fanIn: n.fanIn,
70
70
  fanOut: n.fanOut,
71
71
  centrality: n.centrality,
72
+ rawCalls: n.rawCalls,
72
73
  })),
73
74
  removed: i === 0 ? diff.removed : [],
74
75
  edgesAdded: edgeChunk.map(e => ({
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const graph_builder_1 = require("./utils/graph-builder");
4
+ async function main() {
5
+ const repoRoot = process.argv[2];
6
+ const graph = await (0, graph_builder_1.buildGraph)(repoRoot);
7
+ const node = Object.values(graph.nodes).find(n => n.name === "projectRoutes");
8
+ console.log(node);
9
+ }
10
+ main();
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const ast_parser_1 = require("./utils/ast-parser");
8
+ async function main() {
9
+ const filePath = process.argv[2];
10
+ const source = fs_1.default.readFileSync(filePath, "utf-8");
11
+ const symbols = await (0, ast_parser_1.parseFile)(filePath, source);
12
+ for (const s of symbols) {
13
+ console.log(`${s.name}: calls = [${s.calls.join(", ")}]`);
14
+ }
15
+ }
16
+ main();
@@ -113,6 +113,18 @@ function extractCallNames(node, acc = new Set()) {
113
113
  else if (callee?.type === "MemberExpression" && callee.property?.name) {
114
114
  acc.add(callee.property.name);
115
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
+ }
116
128
  }
117
129
  for (const key of Object.keys(n)) {
118
130
  if (key === "parent")
@@ -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,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
+ }
@@ -301,6 +301,7 @@ async function buildGraph(repoRoot) {
301
301
  fanIn: 0,
302
302
  fanOut: 0,
303
303
  centrality: 0,
304
+ rawCalls: sym.calls ?? [],
304
305
  };
305
306
  }
306
307
  const tsFiles = symbols
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * security-code-analyzer.ts
4
+ *
5
+ * Layer 1 orchestrator — runs entirely client-side, where the actual source
6
+ * code and the fully-resolved local graph both exist. Combines:
7
+ * - Deterministic code pattern scanning (SQL injection, hardcoded secrets, etc)
8
+ * - Missing-auth-on-route detection, using the RESOLVED local graph (more
9
+ * precise than the raw_calls fallback, since resolved edges already
10
+ * correctly handle object-literal patterns like { preHandler: authMiddleware })
11
+ *
12
+ * For every flagged candidate, assembles full code context (target body +
13
+ * 1-hop callers/callees) via context-assembler.ts, ready to send to the
14
+ * server for Layer 2 — an LLM judgment with full visibility, which is what
15
+ * actually adjudicates real risk vs false positive.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.findMissingAuthCandidatesLocal = findMissingAuthCandidatesLocal;
19
+ exports.analyzeForSecurityCandidates = analyzeForSecurityCandidates;
20
+ const context_assembler_1 = require("./context-assembler");
21
+ const code_pattern_scanner_1 = require("./code-pattern-scanner");
22
+ const ROUTE_REGISTRATION_METHOD_NAMES = new Set(["get", "post", "put", "delete", "patch", "addhook", "all"]);
23
+ const AUTH_FUNCTION_HINTS = [
24
+ "auth", "requireauth", "requireorg", "requireadmin", "requirelogin",
25
+ "verifytoken", "checkauth", "isauthenticated", "authenticate", "authorize",
26
+ "ensureauth", "authmiddleware", "authguard", "protect", "jwt",
27
+ ];
28
+ const PUBLIC_ENDPOINT_HINTS = ["health", "login", "signup", "register", "public", "webhook", "ping", "status"];
29
+ function includesRouteRegistration(names) {
30
+ return names.some(name => ROUTE_REGISTRATION_METHOD_NAMES.has(name.toLowerCase()));
31
+ }
32
+ function includesAuthHint(names) {
33
+ return names.some(name => {
34
+ const lower = name.toLowerCase();
35
+ return AUTH_FUNCTION_HINTS.some(hint => lower.includes(hint));
36
+ });
37
+ }
38
+ function looksLikePublicEndpoint(symbolName, file) {
39
+ const lower = (symbolName + " " + file).toLowerCase();
40
+ return PUBLIC_ENDPOINT_HINTS.some(hint => lower.includes(hint));
41
+ }
42
+ /**
43
+ * Finds route-registering functions with no resolved auth reference.
44
+ * Uses BOTH the resolved local graph edges (most precise — handles object-
45
+ * literal patterns) AND the raw call/reference list (catches library calls
46
+ * that never resolve as edges, like app.get itself).
47
+ */
48
+ function findMissingAuthCandidatesLocal(graph, symbolsByFingerprint) {
49
+ const candidates = [];
50
+ for (const node of Object.values(graph.nodes)) {
51
+ if (node.type !== "function" && node.type !== "method")
52
+ continue;
53
+ const sym = symbolsByFingerprint.get(node.fingerprint);
54
+ const rawCallNames = sym?.calls ?? [];
55
+ // Names reachable via resolved graph edges (calls + references) — this
56
+ // correctly includes object-literal-nested identifiers like
57
+ // { preHandler: authMiddleware }, which raw call extraction alone misses.
58
+ const resolvedEdgeNames = graph.edges
59
+ .filter(e => e.from === node.fingerprint)
60
+ .map(e => graph.nodes[e.to]?.name)
61
+ .filter((n) => !!n);
62
+ const allNames = [...rawCallNames, ...resolvedEdgeNames];
63
+ if (!includesRouteRegistration(allNames))
64
+ continue;
65
+ if (includesAuthHint(allNames))
66
+ continue;
67
+ if (looksLikePublicEndpoint(node.name, node.file))
68
+ continue;
69
+ candidates.push({ symbol: node.name, file: node.file, fingerprint: node.fingerprint });
70
+ }
71
+ return candidates;
72
+ }
73
+ /**
74
+ * Runs both Layer 1 detectors and assembles full code context for every
75
+ * flagged candidate — ready to send to the server for Layer 2 LLM judgment.
76
+ */
77
+ function analyzeForSecurityCandidates(repoRoot, graph, symbols) {
78
+ const symbolsByFingerprint = new Map(symbols.map(s => [s.fingerprint, s]));
79
+ const candidates = [];
80
+ // ── Code pattern findings ────────────────────────────────────────────────
81
+ const patternFindings = (0, code_pattern_scanner_1.scanAllSymbolsForPatterns)(symbols);
82
+ for (const finding of patternFindings) {
83
+ const sym = symbols.find(s => s.name === finding.symbol && s.file === finding.file);
84
+ const fingerprint = sym?.fingerprint ?? "";
85
+ const assembled = fingerprint ? (0, context_assembler_1.assembleContext)(repoRoot, graph, fingerprint) : null;
86
+ candidates.push({
87
+ kind: "code_pattern",
88
+ symbol: finding.symbol,
89
+ file: finding.file,
90
+ fingerprint,
91
+ patternType: finding.patternType,
92
+ severity: finding.severity,
93
+ snippet: finding.snippet,
94
+ description: finding.description,
95
+ context: assembled ? {
96
+ targetBody: assembled.targetBody,
97
+ callers: assembled.callers.map(c => ({ name: c.name, snippet: c.snippet })),
98
+ callees: assembled.callees.map(c => ({ name: c.name, snippet: c.snippet })),
99
+ } : null,
100
+ });
101
+ }
102
+ // ── Missing-auth candidates ───────────────────────────────────────────────
103
+ const authCandidates = findMissingAuthCandidatesLocal(graph, symbolsByFingerprint);
104
+ for (const cand of authCandidates) {
105
+ const assembled = (0, context_assembler_1.assembleContext)(repoRoot, graph, cand.fingerprint);
106
+ candidates.push({
107
+ kind: "missing_auth",
108
+ symbol: cand.symbol,
109
+ file: cand.file,
110
+ fingerprint: cand.fingerprint,
111
+ context: assembled ? {
112
+ targetBody: assembled.targetBody,
113
+ callers: assembled.callers.map(c => ({ name: c.name, snippet: c.snippet })),
114
+ callees: assembled.callees.map(c => ({ name: c.name, snippet: c.snippet })),
115
+ } : null,
116
+ });
117
+ }
118
+ return candidates;
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodingo-cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Kodingo CLI",
5
5
  "license": "MIT",
6
6
  "private": false,