kodingo-cli 1.1.0 → 1.1.2
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 +22 -0
- package/dist/commands/init.js +18 -1
- package/dist/commands/install-hook.js +2 -0
- package/dist/commands/security-scan.js +232 -0
- package/dist/commands/sync-dependencies.js +69 -0
- package/dist/commands/sync-graph.js +1 -0
- package/dist/test-buildgraph-debug.js +10 -0
- package/dist/test-calls-debug.js +16 -0
- package/dist/utils/ast-parser.js +12 -0
- package/dist/utils/code-pattern-scanner.js +231 -0
- package/dist/utils/dependency-parser.js +229 -0
- package/dist/utils/graph-builder.js +1 -0
- package/dist/utils/security-code-analyzer.js +146 -0
- package/package.json +1 -1
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")
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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,232 @@
|
|
|
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
|
+
// Fetch dynamically generated rules from OSV-driven rule generation
|
|
119
|
+
let dynamicRules = [];
|
|
120
|
+
try {
|
|
121
|
+
const rulesRes = await fetch(`${apiUrl}/security/rules`, {
|
|
122
|
+
headers: { "X-Kodingo-Token": token },
|
|
123
|
+
});
|
|
124
|
+
if (rulesRes.ok) {
|
|
125
|
+
const rulesData = await rulesRes.json();
|
|
126
|
+
dynamicRules = rulesData.rules ?? [];
|
|
127
|
+
if (!options.silent && dynamicRules.length > 0) {
|
|
128
|
+
console.log(` Loaded ${dynamicRules.length} dynamically generated detection rule${dynamicRules.length === 1 ? "" : "s"} from OSV intelligence\n`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
const candidates = (0, security_code_analyzer_1.analyzeForSecurityCandidates)(repoRoot, graph, symbols, dynamicRules);
|
|
134
|
+
if (candidates.length === 0) {
|
|
135
|
+
if (!options.silent)
|
|
136
|
+
console.log(" ✔ No suspicious patterns detected.");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const codeScanRes = await fetch(`${apiUrl}/security/scan-code`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
|
|
142
|
+
body: JSON.stringify({ repo: repoName, candidates }),
|
|
143
|
+
});
|
|
144
|
+
if (codeScanRes.ok) {
|
|
145
|
+
const codeResult = await codeScanRes.json();
|
|
146
|
+
if (!options.silent) {
|
|
147
|
+
console.log(` Checked ${codeResult.scanned} candidates — ${codeResult.findingsCreated} confirmed, ${codeResult.dismissed} dismissed as false positives after review\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (!options.silent) {
|
|
151
|
+
console.error(` ✖ Code analysis failed: ${codeScanRes.status}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (!options.silent)
|
|
157
|
+
console.error(` ✖ Code analysis failed: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
// ── Decision drift — does current code still honour past decisions? ──────
|
|
160
|
+
if (!options.silent)
|
|
161
|
+
console.log(`\n📜 Checking for drift from past decisions...\n`);
|
|
162
|
+
try {
|
|
163
|
+
const graph = await (0, graph_builder_1.buildGraph)(repoRoot);
|
|
164
|
+
const files = walkFiles(repoRoot);
|
|
165
|
+
const symbols = [];
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
try {
|
|
168
|
+
const source = fs_1.default.readFileSync(file, "utf-8");
|
|
169
|
+
symbols.push(...(await (0, ast_parser_1.parseFile)(file, source)));
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
}
|
|
173
|
+
const symbolNames = Array.from(new Set(symbols.map(s => s.name)));
|
|
174
|
+
if (symbolNames.length > 0) {
|
|
175
|
+
const candidatesRes = await fetch(`${apiUrl}/security/decision-drift-candidates`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
|
|
178
|
+
body: JSON.stringify({ repo: repoName, symbols: symbolNames.slice(0, 3000) }),
|
|
179
|
+
});
|
|
180
|
+
if (candidatesRes.ok) {
|
|
181
|
+
const { matches } = await candidatesRes.json();
|
|
182
|
+
if (matches.length === 0) {
|
|
183
|
+
if (!options.silent)
|
|
184
|
+
console.log(" No symbols with affirmed decisions found yet.");
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const checks = [];
|
|
188
|
+
for (const match of matches) {
|
|
189
|
+
const sym = symbols.find(s => s.name === match.symbol);
|
|
190
|
+
if (!sym)
|
|
191
|
+
continue;
|
|
192
|
+
const assembled = (0, context_assembler_1.assembleContext)(repoRoot, graph, sym.fingerprint);
|
|
193
|
+
if (!assembled)
|
|
194
|
+
continue;
|
|
195
|
+
checks.push({
|
|
196
|
+
symbol: match.symbol,
|
|
197
|
+
file: sym.file,
|
|
198
|
+
fingerprint: sym.fingerprint,
|
|
199
|
+
currentBody: assembled.targetBody,
|
|
200
|
+
decisionTitle: match.decisionTitle,
|
|
201
|
+
decisionContent: match.decisionContent,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
if (checks.length > 0) {
|
|
205
|
+
const driftRes = await fetch(`${apiUrl}/security/scan-decision-drift`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "Content-Type": "application/json", "X-Kodingo-Token": token },
|
|
208
|
+
body: JSON.stringify({ repo: repoName, checks }),
|
|
209
|
+
});
|
|
210
|
+
if (driftRes.ok) {
|
|
211
|
+
const driftResult = await driftRes.json();
|
|
212
|
+
if (!options.silent) {
|
|
213
|
+
console.log(` Checked ${driftResult.scanned} symbols against past decisions — ${driftResult.findingsCreated} drifted, ${driftResult.noDrift} still honoured\n`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (!options.silent)
|
|
223
|
+
console.error(` ✖ Decision drift check failed: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (!options.silent)
|
|
229
|
+
console.error(` ✖ Scan failed: ${err.message}`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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();
|
package/dist/utils/ast-parser.js
CHANGED
|
@@ -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,231 @@
|
|
|
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
|
+
// ── Insecure deserialization ───────────────────────────────────────────────
|
|
97
|
+
{
|
|
98
|
+
type: "insecure_deserialization",
|
|
99
|
+
severity: "critical",
|
|
100
|
+
regex: /\b(unserialize|pickle\.loads|deserialize)\s*\(/,
|
|
101
|
+
description: "Unvalidated data passed to a deserialization function — can enable remote code execution if input is attacker-controlled.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: "insecure_deserialization",
|
|
105
|
+
severity: "critical",
|
|
106
|
+
regex: /yaml\.load\s*\([^,)]+\)(?!.*Loader)/,
|
|
107
|
+
description: "yaml.load() without a safe Loader — enables arbitrary code execution via crafted YAML input.",
|
|
108
|
+
languages: ["python"],
|
|
109
|
+
},
|
|
110
|
+
// ── XXE injection ──────────────────────────────────────────────────────────
|
|
111
|
+
{
|
|
112
|
+
type: "xxe_injection",
|
|
113
|
+
severity: "high",
|
|
114
|
+
regex: /resolve_entities\s*=\s*True|load_dtd\s*=\s*True/,
|
|
115
|
+
description: "XML parser configured with external entity resolution or DTD loading enabled — XXE injection vulnerability.",
|
|
116
|
+
languages: ["python"],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: "xxe_injection",
|
|
120
|
+
severity: "high",
|
|
121
|
+
regex: /libxml_disable_entity_loader\s*\(\s*false\s*\)/,
|
|
122
|
+
description: "PHP XML external entity loading explicitly enabled — XXE injection vulnerability.",
|
|
123
|
+
languages: ["php"],
|
|
124
|
+
},
|
|
125
|
+
// ── Open redirect ─────────────────────────────────────────────────────────
|
|
126
|
+
{
|
|
127
|
+
type: "open_redirect",
|
|
128
|
+
severity: "high",
|
|
129
|
+
regex: /\bres\.redirect\s*\([^)]*(?:req\.|params\.|query\.)/,
|
|
130
|
+
description: "Redirect target built from user-supplied request data — potential open redirect enabling phishing attacks.",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: "open_redirect",
|
|
134
|
+
severity: "high",
|
|
135
|
+
regex: /header\s*\(\s*['"]Location.*(?:\$_GET|\$_POST|\$_REQUEST)/,
|
|
136
|
+
description: "Location header set from user-supplied input — open redirect vulnerability.",
|
|
137
|
+
languages: ["php"],
|
|
138
|
+
},
|
|
139
|
+
// ── Mass assignment ───────────────────────────────────────────────────────
|
|
140
|
+
{
|
|
141
|
+
type: "mass_assignment",
|
|
142
|
+
severity: "high",
|
|
143
|
+
regex: /Object\.assign\s*\([^,)]+,\s*req\.body/,
|
|
144
|
+
description: "User-supplied request body merged directly into an object via Object.assign — potential mass assignment.",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: "mass_assignment",
|
|
148
|
+
severity: "high",
|
|
149
|
+
regex: /\$request->all\(\)|\$request->input\(\)/,
|
|
150
|
+
description: "All user input bound directly to model without allowlist — mass assignment vulnerability in Laravel/PHP.",
|
|
151
|
+
languages: ["php"],
|
|
152
|
+
},
|
|
153
|
+
// ── Prototype pollution ───────────────────────────────────────────────────
|
|
154
|
+
{
|
|
155
|
+
type: "prototype_pollution",
|
|
156
|
+
severity: "critical",
|
|
157
|
+
regex: /\[['"]__proto__['"]\]|__proto__\s*=/,
|
|
158
|
+
description: "Prototype pollution — modification of __proto__ through user-controlled input can corrupt the global object prototype.",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: "prototype_pollution",
|
|
162
|
+
severity: "critical",
|
|
163
|
+
regex: /constructor\.prototype\s*=/,
|
|
164
|
+
description: "Direct modification of constructor.prototype — potential prototype pollution vulnerability.",
|
|
165
|
+
},
|
|
166
|
+
// ── CORS misconfiguration ─────────────────────────────────────────────────
|
|
167
|
+
{
|
|
168
|
+
type: "cors_misconfiguration",
|
|
169
|
+
severity: "high",
|
|
170
|
+
regex: /['"]Access-Control-Allow-Origin['"]\s*[,:]\s*['"]\*['"]/,
|
|
171
|
+
description: "Wildcard CORS policy — allows any origin to make cross-origin requests to this endpoint.",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
type: "cors_misconfiguration",
|
|
175
|
+
severity: "moderate",
|
|
176
|
+
regex: /origin\s*[:=]\s*req\.headers(?:\.origin|\.get\s*\(\s*['"]origin)/,
|
|
177
|
+
description: "CORS origin reflected directly from request headers without validation — allows attacker-controlled origins.",
|
|
178
|
+
},
|
|
179
|
+
// ── Timing attacks ────────────────────────────────────────────────────────
|
|
180
|
+
{
|
|
181
|
+
type: "timing_attack",
|
|
182
|
+
severity: "moderate",
|
|
183
|
+
regex: /(?:token|secret|password|apiKey|api_key)\s*===\s*(?:req|request|input|provided)/,
|
|
184
|
+
description: "Non-constant-time comparison in a security-sensitive context — vulnerable to timing attacks that can leak secret values.",
|
|
185
|
+
},
|
|
186
|
+
// ── Insecure session management ───────────────────────────────────────────
|
|
187
|
+
{
|
|
188
|
+
type: "insecure_session",
|
|
189
|
+
severity: "high",
|
|
190
|
+
regex: /(?:session|sessionId|session_id)\s*[:=][^;\n]*Math\.random/,
|
|
191
|
+
description: "Session ID generated using Math.random() — not cryptographically secure, session IDs are predictable.",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
type: "insecure_session",
|
|
195
|
+
severity: "moderate",
|
|
196
|
+
regex: /res\.cookie\s*\([^)]+\)(?!.*httpOnly)(?!.*secure)/,
|
|
197
|
+
description: "Cookie set without httpOnly or Secure flags — session cookie may be accessible to JavaScript or transmitted over HTTP.",
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
/**
|
|
201
|
+
* Scans a single symbol's body against all pattern rules. Returns every match —
|
|
202
|
+
* a symbol can trigger multiple findings if it has multiple distinct issues.
|
|
203
|
+
*/
|
|
204
|
+
function scanSymbolForPatterns(symbol, language) {
|
|
205
|
+
const findings = [];
|
|
206
|
+
for (const rule of RULES) {
|
|
207
|
+
if (rule.languages && !rule.languages.includes(language))
|
|
208
|
+
continue;
|
|
209
|
+
const match = symbol.body.match(rule.regex);
|
|
210
|
+
if (match && match.index !== undefined) {
|
|
211
|
+
findings.push({
|
|
212
|
+
patternType: rule.type,
|
|
213
|
+
symbol: symbol.name,
|
|
214
|
+
file: symbol.file,
|
|
215
|
+
startLine: symbol.startLine,
|
|
216
|
+
severity: rule.severity,
|
|
217
|
+
snippet: extractSnippet(symbol.body, match.index),
|
|
218
|
+
description: rule.description,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
/** Scans every symbol in a repo (already-parsed) for code patterns. */
|
|
225
|
+
function scanAllSymbolsForPatterns(symbols) {
|
|
226
|
+
const findings = [];
|
|
227
|
+
for (const symbol of symbols) {
|
|
228
|
+
findings.push(...scanSymbolForPatterns(symbol, symbol.language ?? "unknown"));
|
|
229
|
+
}
|
|
230
|
+
return findings;
|
|
231
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
* Dynamic rules from OSV-driven generation are applied alongside static rules.
|
|
77
|
+
*/
|
|
78
|
+
function analyzeForSecurityCandidates(repoRoot, graph, symbols, dynamicRules = []) {
|
|
79
|
+
const symbolsByFingerprint = new Map(symbols.map(s => [s.fingerprint, s]));
|
|
80
|
+
const candidates = [];
|
|
81
|
+
// ── Code pattern findings — static + dynamic rules ────────────────────────
|
|
82
|
+
const patternFindings = (0, code_pattern_scanner_1.scanAllSymbolsForPatterns)(symbols);
|
|
83
|
+
// Apply dynamic rules generated from OSV vulnerability data
|
|
84
|
+
for (const sym of symbols) {
|
|
85
|
+
const language = sym.language ?? "unknown";
|
|
86
|
+
for (const rule of dynamicRules) {
|
|
87
|
+
if (rule.languages && !rule.languages.includes(language))
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
const regex = new RegExp(rule.regexPattern);
|
|
91
|
+
const match = sym.body.match(regex);
|
|
92
|
+
if (match && match.index !== undefined) {
|
|
93
|
+
patternFindings.push({
|
|
94
|
+
patternType: rule.patternType,
|
|
95
|
+
symbol: sym.name,
|
|
96
|
+
file: sym.file,
|
|
97
|
+
startLine: sym.startLine,
|
|
98
|
+
severity: rule.severity,
|
|
99
|
+
snippet: sym.body.slice(Math.max(0, match.index - 20), match.index + 100).trim(),
|
|
100
|
+
description: rule.description,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const finding of patternFindings) {
|
|
110
|
+
const sym = symbols.find(s => s.name === finding.symbol && s.file === finding.file);
|
|
111
|
+
const fingerprint = sym?.fingerprint ?? "";
|
|
112
|
+
const assembled = fingerprint ? (0, context_assembler_1.assembleContext)(repoRoot, graph, fingerprint) : null;
|
|
113
|
+
candidates.push({
|
|
114
|
+
kind: "code_pattern",
|
|
115
|
+
symbol: finding.symbol,
|
|
116
|
+
file: finding.file,
|
|
117
|
+
fingerprint,
|
|
118
|
+
patternType: finding.patternType,
|
|
119
|
+
severity: finding.severity,
|
|
120
|
+
snippet: finding.snippet,
|
|
121
|
+
description: finding.description,
|
|
122
|
+
context: assembled ? {
|
|
123
|
+
targetBody: assembled.targetBody,
|
|
124
|
+
callers: assembled.callers.map(c => ({ name: c.name, snippet: c.snippet })),
|
|
125
|
+
callees: assembled.callees.map(c => ({ name: c.name, snippet: c.snippet })),
|
|
126
|
+
} : null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// ── Missing-auth candidates ───────────────────────────────────────────────
|
|
130
|
+
const authCandidates = findMissingAuthCandidatesLocal(graph, symbolsByFingerprint);
|
|
131
|
+
for (const cand of authCandidates) {
|
|
132
|
+
const assembled = (0, context_assembler_1.assembleContext)(repoRoot, graph, cand.fingerprint);
|
|
133
|
+
candidates.push({
|
|
134
|
+
kind: "missing_auth",
|
|
135
|
+
symbol: cand.symbol,
|
|
136
|
+
file: cand.file,
|
|
137
|
+
fingerprint: cand.fingerprint,
|
|
138
|
+
context: assembled ? {
|
|
139
|
+
targetBody: assembled.targetBody,
|
|
140
|
+
callers: assembled.callers.map(c => ({ name: c.name, snippet: c.snippet })),
|
|
141
|
+
callees: assembled.callees.map(c => ({ name: c.name, snippet: c.snippet })),
|
|
142
|
+
} : null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return candidates;
|
|
146
|
+
}
|