dependencyiq 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Multi-language vulnerability scanner backed by OSV-Scanner.
3
+ * OSV-Scanner reads native manifests/lockfiles for each ecosystem
4
+ * (package.json/package-lock.json, requirements.txt/poetry.lock,
5
+ * go.mod/go.sum, pom.xml/gradle.lockfile, Gemfile.lock, Cargo.lock, ...)
6
+ * and queries the OSV.dev vulnerability database, so one scan call
7
+ * covers npm, PyPI, Go, Maven, RubyGems, crates.io, etc.
8
+ *
9
+ * Install: https://github.com/google/osv-scanner (binary `osv-scanner`)
10
+ */
11
+
12
+ const { execFileSync } = require('child_process');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { cvss3BaseScore } = require('./cvss');
16
+
17
+ const OSV_BINARY = process.env.OSV_SCANNER_PATH || 'osv-scanner';
18
+
19
+ // Last-resort fallback only: when an advisory carries neither a numeric
20
+ // score nor a parseable CVSS vector, a coarse severity-bucket midpoint
21
+ // keeps risk scoring functional. Preferred order (most → least precise):
22
+ // numeric score → computed from CVSS vector → this bucket.
23
+ const SEVERITY_TO_CVSS = {
24
+ CRITICAL: 9.5,
25
+ HIGH: 7.5,
26
+ MODERATE: 5.0,
27
+ MEDIUM: 5.0,
28
+ LOW: 2.5,
29
+ };
30
+
31
+ function isOsvScannerInstalled() {
32
+ try {
33
+ execFileSync(OSV_BINARY, ['--version'], { stdio: 'ignore' });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Run osv-scanner recursively over a repository and normalize results.
42
+ * @param {string} repoPath - Path to repository
43
+ * @returns {Promise<Array>} Normalized vulnerability objects
44
+ */
45
+ async function detectVulnerabilities(repoPath) {
46
+ if (!isOsvScannerInstalled()) {
47
+ console.warn(
48
+ ` ⚠️ ${OSV_BINARY} not found on PATH. Install it from ` +
49
+ 'https://github.com/google/osv-scanner/releases, or set OSV_SCANNER_PATH. ' +
50
+ 'Returning no results instead of guessing.'
51
+ );
52
+ return [];
53
+ }
54
+
55
+ console.log('🔍 Scanning for vulnerabilities (OSV-Scanner, multi-language)...');
56
+
57
+ let raw;
58
+ try {
59
+ // v1.x OSV-Scanner CLI syntax (the pinned OSV_SCANNER_VERSION in
60
+ // .gitlab-ci.yml): no "scan source" subcommand — that's v2.x syntax,
61
+ // and passing it here made v1.9.2 treat "source" itself as a literal
62
+ // path to scan ("Failed to walk source: lstat source: no such file
63
+ // or directory") rather than recognizing it as a mode keyword.
64
+ raw = execFileSync(
65
+ OSV_BINARY,
66
+ ['--format', 'json', '--recursive', repoPath],
67
+ { encoding: 'utf-8', maxBuffer: 1024 * 1024 * 50 }
68
+ );
69
+ } catch (error) {
70
+ // osv-scanner exits non-zero when vulnerabilities are found; the JSON is
71
+ // still on stdout in that case.
72
+ raw = error.stdout || '';
73
+ if (!raw) {
74
+ // A real scan failure (e.g. osv-scanner itself errored with no JSON
75
+ // at all) is NOT the same thing as "scanned cleanly, zero findings"
76
+ // — throw rather than returning [], so callers can't accidentally
77
+ // report a failed scan as "no vulnerabilities found".
78
+ throw new Error(`osv-scanner failed to run: ${error.message}`);
79
+ }
80
+ }
81
+
82
+ let report;
83
+ try {
84
+ report = JSON.parse(raw);
85
+ } catch (firstError) {
86
+ // Some osv-scanner versions print a status/warning line to stdout
87
+ // before the JSON despite --format json (e.g. a "Scanning dir ..."
88
+ // line, or a git-ignore-parsing warning in a shallow CI checkout).
89
+ // Tolerate that by parsing from the first '{' instead of assuming
90
+ // the whole stream is pure JSON — only throw if that also fails,
91
+ // since a genuine scan failure still must not be reported as clean.
92
+ const firstBrace = raw.indexOf('{');
93
+ if (firstBrace === -1) {
94
+ throw new Error(`osv-scanner produced unparseable output (scan likely failed, not clean): ${firstError.message}\nRaw output: ${raw.slice(0, 500)}`);
95
+ }
96
+ try {
97
+ report = JSON.parse(raw.slice(firstBrace));
98
+ } catch (secondError) {
99
+ throw new Error(`osv-scanner produced unparseable output even after stripping leading text (scan likely failed, not clean): ${secondError.message}\nRaw output: ${raw.slice(0, 500)}`);
100
+ }
101
+ }
102
+
103
+ const vulnerabilities = normalizeOsvReport(report, repoPath);
104
+ console.log(` ✓ Found ${vulnerabilities.length} vulnerabilities across ${countEcosystems(vulnerabilities)} ecosystem(s)`);
105
+ return vulnerabilities;
106
+ }
107
+
108
+ function countEcosystems(vulnerabilities) {
109
+ return new Set(vulnerabilities.map(v => v.ecosystem)).size;
110
+ }
111
+
112
+ /**
113
+ * Flatten osv-scanner's `results[].packages[].vulnerabilities[]` tree into
114
+ * one row per (package, vulnerability) pair, matching the shape the rest of
115
+ * the agent (riskCalculator, prGenerator) already expects.
116
+ */
117
+ function normalizeOsvReport(report, repoPath) {
118
+ const vulnerabilities = [];
119
+ const results = report.results || [];
120
+
121
+ for (const result of results) {
122
+ const manifestPath = result.source?.path || '';
123
+ const ecosystemHint = detectEcosystemFromManifest(manifestPath);
124
+
125
+ for (const pkg of result.packages || []) {
126
+ const pkgInfo = pkg.package || {};
127
+ const fixedVersion = findFixedVersion(pkg);
128
+
129
+ for (const vuln of pkg.vulnerabilities || []) {
130
+ const cvss = extractCvssScore(vuln);
131
+ vulnerabilities.push({
132
+ id: vuln.id || 'unknown',
133
+ package: pkgInfo.name || 'unknown',
134
+ ecosystem: pkgInfo.ecosystem || ecosystemHint,
135
+ currentVersion: pkgInfo.version || 'unknown',
136
+ vulnerability: vuln.summary || vuln.details?.slice(0, 200) || 'Unknown vulnerability',
137
+ severity: severityLabel(cvss),
138
+ cvss,
139
+ fixedVersion: fixedVersion || 'unknown',
140
+ manifestFile: path.relative(repoPath, manifestPath) || manifestPath,
141
+ references: (vuln.references || []).map(r => r.url).filter(Boolean),
142
+ cveLink: (vuln.aliases || []).find(a => a.startsWith('CVE-')) || vuln.id,
143
+ });
144
+ }
145
+ }
146
+ }
147
+
148
+ return vulnerabilities;
149
+ }
150
+
151
+ function detectEcosystemFromManifest(manifestPath = '') {
152
+ const base = path.basename(manifestPath).toLowerCase();
153
+ if (base.includes('package-lock') || base.includes('yarn.lock') || base === 'package.json') return 'npm';
154
+ if (base.includes('requirements') || base.includes('poetry.lock') || base === 'pyproject.toml') return 'PyPI';
155
+ if (base === 'go.sum' || base === 'go.mod') return 'Go';
156
+ if (base.includes('pom.xml') || base.includes('gradle')) return 'Maven';
157
+ if (base === 'gemfile.lock') return 'RubyGems';
158
+ if (base === 'cargo.lock') return 'crates.io';
159
+ if (base.includes('composer')) return 'Packagist';
160
+ return 'unknown';
161
+ }
162
+
163
+ function findFixedVersion(pkg) {
164
+ for (const vuln of pkg.vulnerabilities || []) {
165
+ for (const affected of vuln.affected || []) {
166
+ for (const range of affected.ranges || []) {
167
+ const fixedEvent = (range.events || []).find(e => e.fixed);
168
+ if (fixedEvent) return fixedEvent.fixed;
169
+ }
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function extractCvssScore(vuln) {
176
+ const severities = vuln.severity || [];
177
+ // 1. A plain numeric score, if OSV gave one directly.
178
+ for (const sev of severities) {
179
+ const numeric = parseFloat(sev.score);
180
+ if (!Number.isNaN(numeric)) return numeric;
181
+ }
182
+ // 2. Compute the real base score from a CVSS v3 vector string — exact,
183
+ // not a guess. (sev.score holds the vector when it isn't numeric.)
184
+ for (const sev of severities) {
185
+ const computed = typeof sev.score === 'string' ? cvss3BaseScore(sev.score) : null;
186
+ if (computed !== null) return computed;
187
+ }
188
+ // 3. Last resort: coarse severity-bucket midpoint.
189
+ const dbSeverity = (vuln.database_specific?.severity || '').toUpperCase();
190
+ return SEVERITY_TO_CVSS[dbSeverity] ?? 5.0;
191
+ }
192
+
193
+ function severityLabel(cvss) {
194
+ if (cvss >= 9) return 'critical';
195
+ if (cvss >= 7) return 'high';
196
+ if (cvss >= 4) return 'medium';
197
+ return 'low';
198
+ }
199
+
200
+ /**
201
+ * Detect which ecosystem manifest files are present in a repo, so the
202
+ * agent can report "this is a Go + npm monorepo" instead of assuming npm.
203
+ */
204
+ function detectEcosystems(repoPath) {
205
+ const checks = [
206
+ { file: 'package.json', ecosystem: 'npm' },
207
+ { file: 'requirements.txt', ecosystem: 'PyPI' },
208
+ { file: 'pyproject.toml', ecosystem: 'PyPI' },
209
+ { file: 'go.mod', ecosystem: 'Go' },
210
+ { file: 'pom.xml', ecosystem: 'Maven' },
211
+ { file: 'build.gradle', ecosystem: 'Maven' },
212
+ { file: 'Gemfile', ecosystem: 'RubyGems' },
213
+ { file: 'Cargo.toml', ecosystem: 'crates.io' },
214
+ { file: 'composer.json', ecosystem: 'Packagist' },
215
+ ];
216
+
217
+ return checks
218
+ .filter(c => fs.existsSync(path.join(repoPath, c.file)))
219
+ .map(c => c.ecosystem)
220
+ .filter((v, i, arr) => arr.indexOf(v) === i);
221
+ }
222
+
223
+ module.exports = {
224
+ detectVulnerabilities,
225
+ detectEcosystems,
226
+ isOsvScannerInstalled,
227
+ normalizeOsvReport,
228
+ };