opensecurity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +268 -0
- package/dist/analysis/ast.js +20 -0
- package/dist/analysis/graphs.js +295 -0
- package/dist/analysis/patterns.js +230 -0
- package/dist/analysis/rules.js +48 -0
- package/dist/analysis/taint.js +199 -0
- package/dist/cli.js +396 -0
- package/dist/config.js +71 -0
- package/dist/deps/cve.js +102 -0
- package/dist/deps/engine.js +27 -0
- package/dist/deps/patch.js +11 -0
- package/dist/deps/scanners.js +114 -0
- package/dist/deps/scoring.js +46 -0
- package/dist/deps/simulate.js +9 -0
- package/dist/deps/types.js +1 -0
- package/dist/fileWalker.js +27 -0
- package/dist/login.js +583 -0
- package/dist/oauthStore.js +48 -0
- package/dist/pr-comment.js +118 -0
- package/dist/progress.js +150 -0
- package/dist/proxy.js +93 -0
- package/dist/rules/defaultRules.js +177 -0
- package/dist/rules/loadRules.js +14 -0
- package/dist/scan.js +1129 -0
- package/dist/telemetry.js +72 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { login } from "./login.js";
|
|
6
|
+
import { startProxyServer } from "./proxy.js";
|
|
7
|
+
import { scan, renderJsonReport, renderSarifReport, renderTextReport, listMatchedFiles } from "./scan.js";
|
|
8
|
+
import { setTelemetryEnabled, trackEvent } from "./telemetry.js";
|
|
9
|
+
import { loadGlobalConfig } from "./config.js";
|
|
10
|
+
import { getOAuthProfile } from "./oauthStore.js";
|
|
11
|
+
import { Logger, Spinner, formatDuration, pluralize, bold, severityColor } from "./progress.js";
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name("opensecurity")
|
|
15
|
+
.description("openSecurity CLI")
|
|
16
|
+
.version("0.1.0");
|
|
17
|
+
program
|
|
18
|
+
.command("login")
|
|
19
|
+
.description("Store Codex Access Token in global config")
|
|
20
|
+
.option("--mode <mode>", "oauth|api_key")
|
|
21
|
+
.option("--provider <provider>", "openai|anthropic|google|mistral|xai|cohere")
|
|
22
|
+
.option("--model <model>", "set default model")
|
|
23
|
+
.action(async (opts) => {
|
|
24
|
+
try {
|
|
25
|
+
await login(process.env, opts.mode, opts.model, opts.provider);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
console.error(err?.message ?? err);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
program
|
|
33
|
+
.command("proxy")
|
|
34
|
+
.description("Run local OAuth proxy for Codex tokens")
|
|
35
|
+
.option("--port <port>", "port to listen on", (v) => Number(v), 8787)
|
|
36
|
+
.action(async (opts) => {
|
|
37
|
+
try {
|
|
38
|
+
await startProxyServer({ port: opts.port });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(err?.message ?? err);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
program
|
|
46
|
+
.command("scan")
|
|
47
|
+
.description("Run AI security scan")
|
|
48
|
+
.option("--format <format>", "text|json|sarif", "text")
|
|
49
|
+
.option("--max-chars <maxChars>", "max chars per chunk", (v) => Number(v), 4000)
|
|
50
|
+
.option("--model <model>", "override model")
|
|
51
|
+
.option("--auth <mode>", "oauth|api_key (overrides config)", (value) => {
|
|
52
|
+
if (value !== "oauth" && value !== "api_key") {
|
|
53
|
+
throw new Error("--auth must be 'oauth' or 'api_key'");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
})
|
|
57
|
+
.option("--provider <provider>", "openai|anthropic|google|mistral|xai|cohere")
|
|
58
|
+
.option("--cwd <cwd>", "override working directory")
|
|
59
|
+
.option("--path <path>", "scan a specific file or directory")
|
|
60
|
+
.option("--include <pattern...>", "include glob patterns (overrides project config)")
|
|
61
|
+
.option("--exclude <pattern...>", "exclude glob patterns (overrides project config)")
|
|
62
|
+
.option("--rules <path>", "path to rules JSON (overrides project config)")
|
|
63
|
+
.option("--cve-cache <path>", "path to CVE cache JSON (overrides project config)")
|
|
64
|
+
.option("--cve-api-url <url>", "CVE API URL (overrides project config)")
|
|
65
|
+
.option("--simulate", "include simulated payload + impact for dependency findings")
|
|
66
|
+
.option("--data-sensitivity <level>", "low|medium|high (affects risk scoring)", "medium")
|
|
67
|
+
.option("--ai-all-text", "allow AI scan on all text files (non-JS/TS)")
|
|
68
|
+
.option("--ai-js-only", "limit AI scan to JS/TS only")
|
|
69
|
+
.option("--ai-multi-agent", "split AI scan into worker batches")
|
|
70
|
+
.option("--ai-batch-size <n>", "files per AI worker batch", (v) => Number(v))
|
|
71
|
+
.option("--ai-batch-depth <n>", "path depth for AI batching", (v) => Number(v))
|
|
72
|
+
.option("--ai-cache", "enable AI per-file cache")
|
|
73
|
+
.option("--no-ai-cache", "disable AI per-file cache")
|
|
74
|
+
.option("--ai-cache-path <path>", "path to AI cache file")
|
|
75
|
+
.option("--concurrency <n>", "parallel scan workers", (v) => Number(v))
|
|
76
|
+
.option("--dependency-only", "only run dependency/CVE scanning")
|
|
77
|
+
.option("--no-ai", "skip AI model scanning")
|
|
78
|
+
.option("--diff-only", "only scan files changed in git")
|
|
79
|
+
.option("--diff-base <ref>", "git base ref for diff-only (default: HEAD)")
|
|
80
|
+
.option("--dry-run", "list matched files without calling the model")
|
|
81
|
+
.option("--fail-on <severity>", "fail if findings are >= severity (low|medium|high|critical)")
|
|
82
|
+
.option("--fail-on-high", "fail if findings are >= high")
|
|
83
|
+
.option("--sarif-output <path>", "write SARIF to file in addition to primary output")
|
|
84
|
+
.option("--verbose", "show detailed progress information")
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
await executeScan(opts);
|
|
87
|
+
});
|
|
88
|
+
async function executeScan(opts) {
|
|
89
|
+
const isJson = opts.format === "json";
|
|
90
|
+
const log = new Logger({ verbose: opts.verbose, silent: isJson });
|
|
91
|
+
try {
|
|
92
|
+
const authMode = await resolveAuthMode(opts);
|
|
93
|
+
if (authMode) {
|
|
94
|
+
opts.auth = authMode;
|
|
95
|
+
}
|
|
96
|
+
if (opts.dryRun) {
|
|
97
|
+
log.info("Dry run — listing matched files…");
|
|
98
|
+
const files = await listMatchedFiles({
|
|
99
|
+
cwd: opts.cwd,
|
|
100
|
+
include: opts.include,
|
|
101
|
+
exclude: opts.exclude
|
|
102
|
+
});
|
|
103
|
+
if (!files.length) {
|
|
104
|
+
console.log("No files matched.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const base = opts.cwd ?? process.cwd();
|
|
108
|
+
const output = files.map((file) => path.relative(base, file)).join("\n");
|
|
109
|
+
log.info(`Found ${pluralize(files.length, "file")}.`);
|
|
110
|
+
console.log(output);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const startTime = Date.now();
|
|
114
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
115
|
+
log.info(`Scanning ${bold(cwd)}…`);
|
|
116
|
+
log.verbose(`Format: ${opts.format}, Model: ${opts.model ?? "default"}`);
|
|
117
|
+
log.verbose(`AI scanning: ${opts.noAi ? "disabled" : "enabled"}`);
|
|
118
|
+
log.verbose(`Dependency-only: ${opts.dependencyOnly ? "yes" : "no"}`);
|
|
119
|
+
const liveOutput = Boolean(opts.verbose);
|
|
120
|
+
const spinner = new Spinner("Running security scan…");
|
|
121
|
+
const useSpinner = !isJson;
|
|
122
|
+
if (useSpinner)
|
|
123
|
+
spinner.start();
|
|
124
|
+
const result = await scan({
|
|
125
|
+
format: opts.format,
|
|
126
|
+
maxChars: opts.maxChars,
|
|
127
|
+
model: opts.model,
|
|
128
|
+
authMode: opts.auth,
|
|
129
|
+
provider: opts.provider,
|
|
130
|
+
liveOutput,
|
|
131
|
+
onProgress: (info) => {
|
|
132
|
+
const message = `Scanning ${info.file} (${info.fileIndex}/${info.totalFiles}) chunk ${info.chunkIndex}/${info.totalChunks}`;
|
|
133
|
+
if (useSpinner) {
|
|
134
|
+
spinner.update(message);
|
|
135
|
+
}
|
|
136
|
+
if (opts.verbose) {
|
|
137
|
+
log.verbose(message);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
onOutputChunk: liveOutput && !isJson ? (chunk) => {
|
|
141
|
+
if (useSpinner)
|
|
142
|
+
spinner.pause();
|
|
143
|
+
process.stderr.write(chunk);
|
|
144
|
+
if (useSpinner)
|
|
145
|
+
spinner.resume();
|
|
146
|
+
} : undefined,
|
|
147
|
+
cwd: opts.cwd,
|
|
148
|
+
targetPath: opts.path,
|
|
149
|
+
include: opts.include,
|
|
150
|
+
exclude: opts.exclude,
|
|
151
|
+
rulesPath: opts.rules,
|
|
152
|
+
cveCachePath: opts.cveCache,
|
|
153
|
+
cveApiUrl: opts.cveApiUrl,
|
|
154
|
+
simulate: opts.simulate,
|
|
155
|
+
dataSensitivity: opts.dataSensitivity,
|
|
156
|
+
dependencyOnly: opts.dependencyOnly,
|
|
157
|
+
noAi: opts.noAi,
|
|
158
|
+
aiAllText: opts.aiJsOnly ? false : (opts.aiAllText ?? true),
|
|
159
|
+
aiMultiAgent: opts.aiMultiAgent,
|
|
160
|
+
aiBatchSize: opts.aiBatchSize,
|
|
161
|
+
aiBatchDepth: opts.aiBatchDepth,
|
|
162
|
+
aiCache: opts.aiCache,
|
|
163
|
+
aiCachePath: opts.aiCachePath,
|
|
164
|
+
diffOnly: opts.diffOnly,
|
|
165
|
+
diffBase: opts.diffBase,
|
|
166
|
+
concurrency: opts.concurrency
|
|
167
|
+
});
|
|
168
|
+
const elapsed = Date.now() - startTime;
|
|
169
|
+
if (useSpinner)
|
|
170
|
+
spinner.stop();
|
|
171
|
+
const output = opts.format === "sarif"
|
|
172
|
+
? renderSarifReport(result)
|
|
173
|
+
: isJson
|
|
174
|
+
? renderJsonReport(result)
|
|
175
|
+
: renderTextReport(result);
|
|
176
|
+
console.log(output || "No findings.");
|
|
177
|
+
if (opts.sarifOutput && opts.format !== "sarif") {
|
|
178
|
+
const sarif = renderSarifReport(result);
|
|
179
|
+
await fs.writeFile(opts.sarifOutput, sarif, "utf8");
|
|
180
|
+
}
|
|
181
|
+
// Print summary
|
|
182
|
+
if (!isJson) {
|
|
183
|
+
const total = result.findings.length;
|
|
184
|
+
if (total === 0) {
|
|
185
|
+
log.success(`Scan complete in ${formatDuration(elapsed)} — no findings.`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const counts = countBySeverity(result.findings);
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const [sev, count] of Object.entries(counts)) {
|
|
191
|
+
if (count > 0)
|
|
192
|
+
parts.push(`${severityColor(sev)}: ${count}`);
|
|
193
|
+
}
|
|
194
|
+
log.warn(`Scan complete in ${formatDuration(elapsed)} — ${pluralize(total, "finding")} [${parts.join(", ")}]`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (opts.failOn) {
|
|
198
|
+
const threshold = severityRank(opts.failOn);
|
|
199
|
+
if (threshold === null) {
|
|
200
|
+
log.warn(`Unknown --fail-on level: ${opts.failOn}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
const worst = highestSeverity(result.findings);
|
|
204
|
+
if (worst !== null && worst >= threshold) {
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (opts.failOnHigh) {
|
|
210
|
+
const threshold = severityRank("high");
|
|
211
|
+
const worst = highestSeverity(result.findings);
|
|
212
|
+
if (worst !== null && threshold !== null && worst >= threshold) {
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Fire telemetry event (no-ops if disabled)
|
|
217
|
+
const globalCfg = await loadGlobalConfig();
|
|
218
|
+
await trackEvent("scan_completed", {
|
|
219
|
+
findings: result.findings.length,
|
|
220
|
+
format: opts.format ?? "text",
|
|
221
|
+
dependencyOnly: Boolean(opts.dependencyOnly),
|
|
222
|
+
noAi: Boolean(opts.noAi)
|
|
223
|
+
}, globalCfg);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
log.error(err?.message ?? err);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
program
|
|
231
|
+
.command("telemetry")
|
|
232
|
+
.description("Enable or disable anonymous telemetry")
|
|
233
|
+
.argument("<action>", "on | off")
|
|
234
|
+
.action(async (action) => {
|
|
235
|
+
try {
|
|
236
|
+
const enabled = action.toLowerCase() === "on";
|
|
237
|
+
await setTelemetryEnabled(enabled);
|
|
238
|
+
console.log(`Telemetry ${enabled ? "enabled" : "disabled"}.`);
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
console.error(err?.message ?? err);
|
|
242
|
+
process.exitCode = 1;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
program.parse();
|
|
246
|
+
// --- helpers ---
|
|
247
|
+
function countBySeverity(findings) {
|
|
248
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
249
|
+
for (const f of findings) {
|
|
250
|
+
counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
251
|
+
}
|
|
252
|
+
return counts;
|
|
253
|
+
}
|
|
254
|
+
function severityRank(severity) {
|
|
255
|
+
switch (severity) {
|
|
256
|
+
case "critical":
|
|
257
|
+
return 3;
|
|
258
|
+
case "high":
|
|
259
|
+
return 2;
|
|
260
|
+
case "medium":
|
|
261
|
+
return 1;
|
|
262
|
+
case "low":
|
|
263
|
+
return 0;
|
|
264
|
+
default:
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function highestSeverity(findings) {
|
|
269
|
+
let max = null;
|
|
270
|
+
for (const f of findings) {
|
|
271
|
+
const rank = severityRank(f.severity);
|
|
272
|
+
if (rank === null)
|
|
273
|
+
continue;
|
|
274
|
+
if (max === null || rank > max)
|
|
275
|
+
max = rank;
|
|
276
|
+
}
|
|
277
|
+
return max;
|
|
278
|
+
}
|
|
279
|
+
async function resolveAuthMode(opts) {
|
|
280
|
+
if (opts.auth)
|
|
281
|
+
return opts.auth;
|
|
282
|
+
const globalCfg = await loadGlobalConfig();
|
|
283
|
+
const hasApiKey = Boolean(globalCfg.apiKey);
|
|
284
|
+
const profileId = globalCfg.authProfileId ?? "codex-cli";
|
|
285
|
+
const oauthProfile = await getOAuthProfile(profileId);
|
|
286
|
+
const hasOauth = Boolean(oauthProfile);
|
|
287
|
+
if (hasApiKey && hasOauth) {
|
|
288
|
+
return await promptAuthMode();
|
|
289
|
+
}
|
|
290
|
+
if (hasOauth)
|
|
291
|
+
return "oauth";
|
|
292
|
+
if (hasApiKey)
|
|
293
|
+
return "api_key";
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
async function promptAuthMode() {
|
|
297
|
+
try {
|
|
298
|
+
return await interactiveSelectAuth();
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// fall through to text prompt
|
|
302
|
+
}
|
|
303
|
+
const answer = await askQuestion("Select auth mode for this scan (oauth/api_key): ");
|
|
304
|
+
return answer.trim() === "api_key" ? "api_key" : "oauth";
|
|
305
|
+
}
|
|
306
|
+
function askQuestion(question) {
|
|
307
|
+
const readline = require("node:readline");
|
|
308
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
rl.question(question, (answer) => {
|
|
311
|
+
rl.close();
|
|
312
|
+
resolve(answer.trim());
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async function interactiveSelectAuth() {
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
const readline = require("node:readline");
|
|
319
|
+
const { input, output, cleanup: baseCleanup } = getInteractiveStreams();
|
|
320
|
+
readline.emitKeypressEvents(input);
|
|
321
|
+
input.setRawMode(true);
|
|
322
|
+
const options = [
|
|
323
|
+
{ label: "OpenAI Codex OAuth", value: "oauth" },
|
|
324
|
+
{ label: "OpenAI API Key", value: "api_key" }
|
|
325
|
+
];
|
|
326
|
+
let index = 0;
|
|
327
|
+
const render = () => {
|
|
328
|
+
output.write("\x1b[2J\x1b[H");
|
|
329
|
+
output.write("Select auth mode for this scan\n");
|
|
330
|
+
for (let i = 0; i < options.length; i += 1) {
|
|
331
|
+
const prefix = i === index ? "◉" : "○";
|
|
332
|
+
output.write(`${prefix} ${options[i].label}\n`);
|
|
333
|
+
}
|
|
334
|
+
output.write("\nUse ↑/↓ to move, Enter to select.\n");
|
|
335
|
+
};
|
|
336
|
+
const cleanup = () => {
|
|
337
|
+
input.off("keypress", onKeypress);
|
|
338
|
+
baseCleanup();
|
|
339
|
+
};
|
|
340
|
+
const onKeypress = (_, key) => {
|
|
341
|
+
if (key.ctrl && key.name === "c") {
|
|
342
|
+
cleanup();
|
|
343
|
+
reject(new Error("Selection cancelled."));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (key.name === "down") {
|
|
347
|
+
index = (index + 1) % options.length;
|
|
348
|
+
render();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (key.name === "up") {
|
|
352
|
+
index = (index - 1 + options.length) % options.length;
|
|
353
|
+
render();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (key.name === "return") {
|
|
357
|
+
const value = options[index].value;
|
|
358
|
+
cleanup();
|
|
359
|
+
resolve(value);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
input.on("keypress", onKeypress);
|
|
363
|
+
render();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function shouldForceInteractive() {
|
|
367
|
+
return process.env.OPENSECURITY_FORCE_TTY === "1";
|
|
368
|
+
}
|
|
369
|
+
function getInteractiveStreams() {
|
|
370
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
371
|
+
const wasRaw = process.stdin.isRaw;
|
|
372
|
+
const cleanup = () => {
|
|
373
|
+
if (!wasRaw)
|
|
374
|
+
process.stdin.setRawMode(false);
|
|
375
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
376
|
+
};
|
|
377
|
+
return { input: process.stdin, output: process.stdout, cleanup };
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const tty = require("node:tty");
|
|
381
|
+
const fs = require("node:fs");
|
|
382
|
+
const fd = fs.openSync("/dev/tty", "r+");
|
|
383
|
+
const input = new tty.ReadStream(fd);
|
|
384
|
+
const output = new tty.WriteStream(fd);
|
|
385
|
+
const cleanup = () => {
|
|
386
|
+
input.setRawMode(false);
|
|
387
|
+
input.pause();
|
|
388
|
+
output.write("\x1b[2J\x1b[H");
|
|
389
|
+
fs.closeSync(fd);
|
|
390
|
+
};
|
|
391
|
+
return { input, output, cleanup };
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
throw new Error("No TTY available for interactive selection.");
|
|
395
|
+
}
|
|
396
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
export const DEFAULT_INCLUDE = ["**/*"];
|
|
5
|
+
export const DEFAULT_EXCLUDE = [
|
|
6
|
+
"**/.git/**",
|
|
7
|
+
"**/node_modules/**",
|
|
8
|
+
"**/dist/**",
|
|
9
|
+
"**/build/**",
|
|
10
|
+
"**/coverage/**",
|
|
11
|
+
"**/.opensecurity.json",
|
|
12
|
+
"**/.opensecurity-cache.json",
|
|
13
|
+
"**/.opensecurity/ai-cache.json"
|
|
14
|
+
];
|
|
15
|
+
const DEFAULT_GLOBALS = {
|
|
16
|
+
baseUrl: "https://api.openai.com/v1/responses",
|
|
17
|
+
model: "gpt-4o-mini",
|
|
18
|
+
apiType: "responses",
|
|
19
|
+
provider: "openai"
|
|
20
|
+
};
|
|
21
|
+
export function getConfigDir(env = process.env) {
|
|
22
|
+
const override = env.OPENSECURITY_CONFIG_HOME;
|
|
23
|
+
if (override && override.trim())
|
|
24
|
+
return override;
|
|
25
|
+
return path.join(os.homedir(), ".config", "opensecurity");
|
|
26
|
+
}
|
|
27
|
+
export function getGlobalConfigPath(env = process.env) {
|
|
28
|
+
return path.join(getConfigDir(env), "config.json");
|
|
29
|
+
}
|
|
30
|
+
export function getProjectConfigPath(cwd = process.cwd()) {
|
|
31
|
+
return path.join(cwd, ".opensecurity.json");
|
|
32
|
+
}
|
|
33
|
+
async function readJsonFile(filePath) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err?.code === "ENOENT")
|
|
40
|
+
return null;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function writeJsonFile(filePath, data) {
|
|
45
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
46
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
|
47
|
+
}
|
|
48
|
+
export async function loadGlobalConfig(env = process.env) {
|
|
49
|
+
const filePath = getGlobalConfigPath(env);
|
|
50
|
+
const existing = await readJsonFile(filePath);
|
|
51
|
+
return {
|
|
52
|
+
...DEFAULT_GLOBALS,
|
|
53
|
+
...(existing ?? {})
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function saveGlobalConfig(config, env = process.env) {
|
|
57
|
+
const current = await loadGlobalConfig(env);
|
|
58
|
+
const merged = { ...current, ...config };
|
|
59
|
+
await writeJsonFile(getGlobalConfigPath(env), merged);
|
|
60
|
+
}
|
|
61
|
+
export async function loadProjectConfig(cwd = process.cwd()) {
|
|
62
|
+
const filePath = getProjectConfigPath(cwd);
|
|
63
|
+
const existing = await readJsonFile(filePath);
|
|
64
|
+
return existing ?? {};
|
|
65
|
+
}
|
|
66
|
+
export function resolveProjectFilters(project) {
|
|
67
|
+
return {
|
|
68
|
+
include: project.include?.length ? project.include : [...DEFAULT_INCLUDE],
|
|
69
|
+
exclude: project.exclude?.length ? project.exclude : [...DEFAULT_EXCLUDE]
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/deps/cve.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import semver from "semver";
|
|
4
|
+
export function createCveLookup(options, cwd) {
|
|
5
|
+
if (options.cachePath) {
|
|
6
|
+
return new LocalCveLookup(options.cachePath, cwd);
|
|
7
|
+
}
|
|
8
|
+
if (options.apiUrl) {
|
|
9
|
+
return new ApiCveLookup(options.apiUrl);
|
|
10
|
+
}
|
|
11
|
+
return new EmptyCveLookup();
|
|
12
|
+
}
|
|
13
|
+
class EmptyCveLookup {
|
|
14
|
+
async lookup() {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class LocalCveLookup {
|
|
19
|
+
cache = null;
|
|
20
|
+
cachePath;
|
|
21
|
+
constructor(cachePath, cwd) {
|
|
22
|
+
this.cachePath = path.isAbsolute(cachePath) ? cachePath : path.join(cwd, cachePath);
|
|
23
|
+
}
|
|
24
|
+
async lookup(dependency) {
|
|
25
|
+
const cache = await this.load();
|
|
26
|
+
return cache.filter((record) => matchesDependency(record, dependency));
|
|
27
|
+
}
|
|
28
|
+
async load() {
|
|
29
|
+
if (this.cache)
|
|
30
|
+
return this.cache;
|
|
31
|
+
const raw = await fs.readFile(this.cachePath, "utf8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.vulnerabilities) ? parsed.vulnerabilities : [];
|
|
34
|
+
this.cache = list;
|
|
35
|
+
return this.cache;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
class ApiCveLookup {
|
|
39
|
+
apiUrl;
|
|
40
|
+
constructor(apiUrl) {
|
|
41
|
+
this.apiUrl = apiUrl;
|
|
42
|
+
}
|
|
43
|
+
async lookup(dependency) {
|
|
44
|
+
const body = {
|
|
45
|
+
package: { name: dependency.name, ecosystem: dependency.ecosystem === "pypi" ? "PyPI" : "npm" },
|
|
46
|
+
version: dependency.version ?? dependency.spec
|
|
47
|
+
};
|
|
48
|
+
const res = await fetch(this.apiUrl, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
return [];
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
const vulns = Array.isArray(data?.vulns) ? data.vulns : [];
|
|
57
|
+
return vulns.map((v) => normalizeOsvRecord(v));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalizeOsvRecord(record) {
|
|
61
|
+
const severity = record?.severity?.[0]?.type === "CVSS_V3" ? mapCvss(record?.severity?.[0]?.score) : undefined;
|
|
62
|
+
const cvssScore = record?.severity?.[0]?.score ? Number(record.severity[0].score) : undefined;
|
|
63
|
+
return {
|
|
64
|
+
id: record?.id ?? "UNKNOWN",
|
|
65
|
+
package: record?.package?.name ?? "unknown",
|
|
66
|
+
ecosystem: record?.package?.ecosystem === "PyPI" ? "pypi" : "npm",
|
|
67
|
+
affectedRange: record?.affected?.[0]?.ranges?.[0]?.events
|
|
68
|
+
?.map((e) => (e.introduced ? `>=${e.introduced}` : e.fixed ? `<${e.fixed}` : ""))
|
|
69
|
+
.join(" "),
|
|
70
|
+
fixedVersion: record?.affected?.[0]?.ranges?.[0]?.events?.find((e) => e.fixed)?.fixed,
|
|
71
|
+
severity,
|
|
72
|
+
cvssScore,
|
|
73
|
+
description: record?.summary ?? record?.details,
|
|
74
|
+
references: record?.references?.map((r) => r.url).filter(Boolean)
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function mapCvss(score) {
|
|
78
|
+
const numeric = Number(score);
|
|
79
|
+
if (Number.isNaN(numeric))
|
|
80
|
+
return undefined;
|
|
81
|
+
if (numeric >= 9)
|
|
82
|
+
return "critical";
|
|
83
|
+
if (numeric >= 7)
|
|
84
|
+
return "high";
|
|
85
|
+
if (numeric >= 4)
|
|
86
|
+
return "medium";
|
|
87
|
+
return "low";
|
|
88
|
+
}
|
|
89
|
+
function matchesDependency(record, dep) {
|
|
90
|
+
if (record.ecosystem && record.ecosystem !== dep.ecosystem)
|
|
91
|
+
return false;
|
|
92
|
+
if (record.package !== dep.name)
|
|
93
|
+
return false;
|
|
94
|
+
if (!record.affectedRange)
|
|
95
|
+
return true;
|
|
96
|
+
if (!dep.version)
|
|
97
|
+
return true;
|
|
98
|
+
if (dep.ecosystem === "npm" && semver.valid(dep.version)) {
|
|
99
|
+
return semver.satisfies(dep.version, record.affectedRange, { includePrerelease: true });
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { scanDependencies } from "./scanners.js";
|
|
2
|
+
import { createCveLookup } from "./cve.js";
|
|
3
|
+
import { scoreRisk } from "./scoring.js";
|
|
4
|
+
import { buildPatchSuggestion } from "./patch.js";
|
|
5
|
+
import { buildSimulation } from "./simulate.js";
|
|
6
|
+
export async function scanDependenciesWithCves(options) {
|
|
7
|
+
const deps = await scanDependencies(options.cwd);
|
|
8
|
+
const lookup = createCveLookup(options.cveLookup, options.cwd);
|
|
9
|
+
const findings = [];
|
|
10
|
+
for (const dep of deps) {
|
|
11
|
+
const cves = await lookup.lookup(dep);
|
|
12
|
+
for (const cve of cves) {
|
|
13
|
+
const risk = scoreRisk(cve, { dataSensitivity: options.dataSensitivity });
|
|
14
|
+
const finding = {
|
|
15
|
+
dependency: dep,
|
|
16
|
+
cve,
|
|
17
|
+
risk,
|
|
18
|
+
recommendation: buildPatchSuggestion({ dependency: dep, cve, risk })
|
|
19
|
+
};
|
|
20
|
+
if (options.simulate) {
|
|
21
|
+
finding.simulation = buildSimulation({ dependency: dep, cve, risk });
|
|
22
|
+
}
|
|
23
|
+
findings.push(finding);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return findings;
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function buildPatchSuggestion(finding) {
|
|
2
|
+
const dep = finding.dependency;
|
|
3
|
+
const cve = finding.cve;
|
|
4
|
+
if (cve.fixedVersion) {
|
|
5
|
+
return `Upgrade ${dep.name} to ${cve.fixedVersion} or later.`;
|
|
6
|
+
}
|
|
7
|
+
if (cve.affectedRange) {
|
|
8
|
+
return `Avoid versions in range ${cve.affectedRange}.`;
|
|
9
|
+
}
|
|
10
|
+
return `Review ${dep.name} usage and upgrade to a patched version.`;
|
|
11
|
+
}
|