trawly 0.0.1 → 0.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/README.md +124 -20
- package/dist/cli.js +3122 -583
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +202 -8
- package/dist/index.js +2123 -118
- package/dist/index.js.map +1 -1
- package/package.json +11 -2
package/dist/cli.js
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
5
|
+
import { dirname as dirname4, resolve as resolve11 } from "path";
|
|
4
6
|
import { Command, InvalidArgumentError } from "commander";
|
|
5
|
-
import
|
|
7
|
+
import kleur5 from "kleur";
|
|
6
8
|
|
|
7
9
|
// src/commands/add.ts
|
|
8
10
|
import kleur from "kleur";
|
|
9
11
|
|
|
12
|
+
// src/fingerprint.ts
|
|
13
|
+
import { createHash } from "crypto";
|
|
14
|
+
function fingerprintFinding(input) {
|
|
15
|
+
return stableHash([
|
|
16
|
+
input.source,
|
|
17
|
+
input.type,
|
|
18
|
+
input.id,
|
|
19
|
+
input.ecosystem,
|
|
20
|
+
input.packageName,
|
|
21
|
+
input.installedVersion
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
function packageKey(pkg) {
|
|
25
|
+
return pkg.purl ?? `${pkg.ecosystem}:${pkg.name}@${pkg.version}`;
|
|
26
|
+
}
|
|
27
|
+
function stableHash(parts) {
|
|
28
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
// src/sources/osv.ts
|
|
11
32
|
var OSV_QUERYBATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
12
33
|
var OSV_VULN_URL = "https://api.osv.dev/v1/vulns";
|
|
13
34
|
var QUERY_CHUNK_SIZE = 500;
|
|
14
35
|
var REQUEST_TIMEOUT_MS = 15e3;
|
|
15
36
|
var MAX_RETRIES = 2;
|
|
37
|
+
var DETAIL_CONCURRENCY = 8;
|
|
16
38
|
function dedupeForQuery(packages) {
|
|
17
39
|
const seen = /* @__PURE__ */ new Set();
|
|
18
40
|
const out = [];
|
|
19
41
|
for (const pkg of packages) {
|
|
20
|
-
const key =
|
|
42
|
+
const key = packageKey(pkg);
|
|
21
43
|
if (seen.has(key)) continue;
|
|
22
44
|
seen.add(key);
|
|
23
|
-
out.push({ name: pkg.name, version: pkg.version });
|
|
45
|
+
if (pkg.purl) out.push({ name: pkg.name, version: pkg.version, purl: pkg.purl });
|
|
46
|
+
else if (pkg.ecosystem === "npm") out.push({ name: pkg.name, version: pkg.version });
|
|
47
|
+
else {
|
|
48
|
+
out.push({
|
|
49
|
+
name: pkg.name,
|
|
50
|
+
version: pkg.version,
|
|
51
|
+
ecosystem: pkg.ecosystem
|
|
52
|
+
});
|
|
53
|
+
}
|
|
24
54
|
}
|
|
25
55
|
return out;
|
|
26
56
|
}
|
|
@@ -30,33 +60,14 @@ async function queryOsv(packages, deps = {}) {
|
|
|
30
60
|
if (unique.length === 0) return [];
|
|
31
61
|
const idsByPackage = /* @__PURE__ */ new Map();
|
|
32
62
|
for (const chunk of chunked(unique, QUERY_CHUNK_SIZE)) {
|
|
33
|
-
|
|
34
|
-
queries: chunk.map((q) => ({
|
|
35
|
-
package: { ecosystem: "npm", name: q.name },
|
|
36
|
-
version: q.version
|
|
37
|
-
}))
|
|
38
|
-
};
|
|
39
|
-
const res = await postJson(
|
|
40
|
-
fetchImpl,
|
|
41
|
-
OSV_QUERYBATCH_URL,
|
|
42
|
-
body
|
|
43
|
-
);
|
|
44
|
-
res.results.forEach((result, i) => {
|
|
45
|
-
const q = chunk[i];
|
|
46
|
-
if (!q) return;
|
|
47
|
-
const key = `${q.name}@${q.version}`;
|
|
48
|
-
if (!result.vulns || result.vulns.length === 0) return;
|
|
49
|
-
const ids = idsByPackage.get(key) ?? /* @__PURE__ */ new Set();
|
|
50
|
-
for (const v of result.vulns) ids.add(v.id);
|
|
51
|
-
idsByPackage.set(key, ids);
|
|
52
|
-
});
|
|
63
|
+
await queryBatchWithPagination(fetchImpl, chunk, idsByPackage);
|
|
53
64
|
}
|
|
54
65
|
const allIds = /* @__PURE__ */ new Set();
|
|
55
66
|
for (const ids of idsByPackage.values()) {
|
|
56
67
|
for (const id of ids) allIds.add(id);
|
|
57
68
|
}
|
|
58
69
|
const detailsById = /* @__PURE__ */ new Map();
|
|
59
|
-
|
|
70
|
+
await mapWithConcurrency([...allIds], DETAIL_CONCURRENCY, async (id) => {
|
|
60
71
|
try {
|
|
61
72
|
const detail = await getJson(
|
|
62
73
|
fetchImpl,
|
|
@@ -65,10 +76,10 @@ async function queryOsv(packages, deps = {}) {
|
|
|
65
76
|
detailsById.set(id, detail);
|
|
66
77
|
} catch {
|
|
67
78
|
}
|
|
68
|
-
}
|
|
79
|
+
});
|
|
69
80
|
const findings = [];
|
|
70
81
|
for (const pkg of packages) {
|
|
71
|
-
const key =
|
|
82
|
+
const key = packageKey(pkg);
|
|
72
83
|
const ids = idsByPackage.get(key);
|
|
73
84
|
if (!ids) continue;
|
|
74
85
|
for (const id of ids) {
|
|
@@ -78,28 +89,88 @@ async function queryOsv(packages, deps = {}) {
|
|
|
78
89
|
}
|
|
79
90
|
return findings;
|
|
80
91
|
}
|
|
92
|
+
async function queryBatchWithPagination(fetchImpl, initial, idsByPackage) {
|
|
93
|
+
let pending = initial;
|
|
94
|
+
const pageTokens = /* @__PURE__ */ new Map();
|
|
95
|
+
while (pending.length > 0) {
|
|
96
|
+
const res = await postJson(
|
|
97
|
+
fetchImpl,
|
|
98
|
+
OSV_QUERYBATCH_URL,
|
|
99
|
+
{ queries: pending.map((q) => toOsvQuery(q, pageTokens.get(queryKey(q)))) }
|
|
100
|
+
);
|
|
101
|
+
const next = [];
|
|
102
|
+
res.results.forEach((result, i) => {
|
|
103
|
+
const q = pending[i];
|
|
104
|
+
if (!q) return;
|
|
105
|
+
const key = queryKey(q);
|
|
106
|
+
if (result.vulns && result.vulns.length > 0) {
|
|
107
|
+
const ids = idsByPackage.get(key) ?? /* @__PURE__ */ new Set();
|
|
108
|
+
for (const v of result.vulns) ids.add(v.id);
|
|
109
|
+
idsByPackage.set(key, ids);
|
|
110
|
+
}
|
|
111
|
+
if (result.next_page_token) {
|
|
112
|
+
pageTokens.set(key, result.next_page_token);
|
|
113
|
+
next.push(q);
|
|
114
|
+
} else {
|
|
115
|
+
pageTokens.delete(key);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
pending = next;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function toOsvQuery(q, pageToken) {
|
|
122
|
+
const query = q.purl ? { package: { purl: q.purl } } : {
|
|
123
|
+
package: { ecosystem: q.ecosystem ?? "npm", name: q.name },
|
|
124
|
+
version: q.version
|
|
125
|
+
};
|
|
126
|
+
return pageToken ? { ...query, page_token: pageToken } : query;
|
|
127
|
+
}
|
|
128
|
+
function queryKey(q) {
|
|
129
|
+
return q.purl ?? `${q.ecosystem ?? "npm"}:${q.name}@${q.version}`;
|
|
130
|
+
}
|
|
81
131
|
function buildFinding(pkg, id, detail) {
|
|
82
|
-
const severity = detail ? parseSeverity(detail) : "unknown";
|
|
132
|
+
const severity = detail ? parseSeverity(detail, pkg.name) : "unknown";
|
|
83
133
|
const summary = detail?.summary ?? detail?.details ?? id;
|
|
134
|
+
const aliases = detail?.aliases ?? [];
|
|
135
|
+
const fingerprint = fingerprintFinding({
|
|
136
|
+
source: "osv",
|
|
137
|
+
type: "vulnerability",
|
|
138
|
+
id,
|
|
139
|
+
ecosystem: pkg.ecosystem,
|
|
140
|
+
packageName: pkg.name,
|
|
141
|
+
installedVersion: pkg.version
|
|
142
|
+
});
|
|
84
143
|
return {
|
|
85
144
|
id,
|
|
86
145
|
source: "osv",
|
|
87
146
|
type: "vulnerability",
|
|
88
147
|
severity,
|
|
148
|
+
ecosystem: pkg.ecosystem,
|
|
89
149
|
packageName: pkg.name,
|
|
90
150
|
installedVersion: pkg.version,
|
|
91
151
|
summary: truncate(summary, 240),
|
|
92
152
|
url: pickAdvisoryUrl(detail) ?? `https://osv.dev/vulnerability/${id}`,
|
|
93
153
|
fixedVersions: detail ? collectFixedVersions(detail, pkg.name) : [],
|
|
94
|
-
affectedPaths: [pkg.path]
|
|
154
|
+
affectedPaths: [pkg.path],
|
|
155
|
+
fingerprint,
|
|
156
|
+
aliases,
|
|
157
|
+
sourceFile: pkg.sourceFile,
|
|
158
|
+
line: pkg.line
|
|
95
159
|
};
|
|
96
160
|
}
|
|
97
|
-
function parseSeverity(detail) {
|
|
161
|
+
function parseSeverity(detail, packageName) {
|
|
98
162
|
const dbSpecific = detail.database_specific?.severity?.toLowerCase();
|
|
99
163
|
if (dbSpecific === "critical" || dbSpecific === "high" || dbSpecific === "moderate" || dbSpecific === "low") {
|
|
100
164
|
return dbSpecific;
|
|
101
165
|
}
|
|
102
166
|
if (dbSpecific === "medium") return "moderate";
|
|
167
|
+
for (const aff of matchingAffected(detail, packageName)) {
|
|
168
|
+
const ecosystemSeverity = aff.ecosystem_specific?.severity?.toLowerCase();
|
|
169
|
+
if (ecosystemSeverity === "critical" || ecosystemSeverity === "high" || ecosystemSeverity === "moderate" || ecosystemSeverity === "low") {
|
|
170
|
+
return ecosystemSeverity;
|
|
171
|
+
}
|
|
172
|
+
if (ecosystemSeverity === "medium") return "moderate";
|
|
173
|
+
}
|
|
103
174
|
const cvss = detail.severity?.find((s) => s.type?.startsWith("CVSS_"));
|
|
104
175
|
if (cvss) {
|
|
105
176
|
const score = parseCvssScore(cvss.score);
|
|
@@ -123,8 +194,7 @@ function pickAdvisoryUrl(detail) {
|
|
|
123
194
|
}
|
|
124
195
|
function collectFixedVersions(detail, packageName) {
|
|
125
196
|
const out = /* @__PURE__ */ new Set();
|
|
126
|
-
for (const aff of detail
|
|
127
|
-
if (aff.package?.name && aff.package.name !== packageName) continue;
|
|
197
|
+
for (const aff of matchingAffected(detail, packageName)) {
|
|
128
198
|
for (const range of aff.ranges ?? []) {
|
|
129
199
|
for (const event of range.events ?? []) {
|
|
130
200
|
if (event.fixed) out.add(event.fixed);
|
|
@@ -133,11 +203,31 @@ function collectFixedVersions(detail, packageName) {
|
|
|
133
203
|
}
|
|
134
204
|
return [...out];
|
|
135
205
|
}
|
|
206
|
+
function matchingAffected(detail, packageName) {
|
|
207
|
+
if (!packageName) return detail.affected ?? [];
|
|
208
|
+
return (detail.affected ?? []).filter((aff) => {
|
|
209
|
+
const affectedName = aff.package?.name;
|
|
210
|
+
return !affectedName || affectedName === packageName;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
136
213
|
function* chunked(items, size) {
|
|
137
214
|
for (let i = 0; i < items.length; i += size) {
|
|
138
215
|
yield items.slice(i, i + size);
|
|
139
216
|
}
|
|
140
217
|
}
|
|
218
|
+
async function mapWithConcurrency(items, concurrency, worker) {
|
|
219
|
+
let next = 0;
|
|
220
|
+
const workers = Array.from(
|
|
221
|
+
{ length: Math.min(concurrency, items.length) },
|
|
222
|
+
async () => {
|
|
223
|
+
while (next < items.length) {
|
|
224
|
+
const item = items[next++];
|
|
225
|
+
if (item !== void 0) await worker(item);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
await Promise.all(workers);
|
|
230
|
+
}
|
|
141
231
|
async function postJson(fetchImpl, url, body) {
|
|
142
232
|
return withRetry(async () => {
|
|
143
233
|
const controller = new AbortController();
|
|
@@ -150,7 +240,11 @@ async function postJson(fetchImpl, url, body) {
|
|
|
150
240
|
signal: controller.signal
|
|
151
241
|
});
|
|
152
242
|
if (!res.ok) {
|
|
153
|
-
throw new HttpError(
|
|
243
|
+
throw new HttpError(
|
|
244
|
+
`OSV ${res.status}: ${res.statusText}`,
|
|
245
|
+
res.status,
|
|
246
|
+
retryAfterMs(res.headers)
|
|
247
|
+
);
|
|
154
248
|
}
|
|
155
249
|
return await res.json();
|
|
156
250
|
} finally {
|
|
@@ -165,7 +259,11 @@ async function getJson(fetchImpl, url) {
|
|
|
165
259
|
try {
|
|
166
260
|
const res = await fetchImpl(url, { signal: controller.signal });
|
|
167
261
|
if (!res.ok) {
|
|
168
|
-
throw new HttpError(
|
|
262
|
+
throw new HttpError(
|
|
263
|
+
`OSV ${res.status}: ${res.statusText}`,
|
|
264
|
+
res.status,
|
|
265
|
+
retryAfterMs(res.headers)
|
|
266
|
+
);
|
|
169
267
|
}
|
|
170
268
|
return await res.json();
|
|
171
269
|
} finally {
|
|
@@ -174,11 +272,13 @@ async function getJson(fetchImpl, url) {
|
|
|
174
272
|
});
|
|
175
273
|
}
|
|
176
274
|
var HttpError = class extends Error {
|
|
177
|
-
constructor(message, status) {
|
|
275
|
+
constructor(message, status, retryAfterMs3) {
|
|
178
276
|
super(message);
|
|
179
277
|
this.status = status;
|
|
278
|
+
this.retryAfterMs = retryAfterMs3;
|
|
180
279
|
}
|
|
181
280
|
status;
|
|
281
|
+
retryAfterMs;
|
|
182
282
|
};
|
|
183
283
|
async function withRetry(fn) {
|
|
184
284
|
let lastErr;
|
|
@@ -188,30 +288,452 @@ async function withRetry(fn) {
|
|
|
188
288
|
} catch (err) {
|
|
189
289
|
lastErr = err;
|
|
190
290
|
if (!isRetryable(err) || attempt === MAX_RETRIES) break;
|
|
191
|
-
|
|
291
|
+
const delay = err instanceof HttpError && err.retryAfterMs !== void 0 ? err.retryAfterMs : 250 * 2 ** attempt;
|
|
292
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
192
293
|
}
|
|
193
294
|
}
|
|
194
295
|
throw lastErr;
|
|
195
296
|
}
|
|
196
297
|
function isRetryable(err) {
|
|
197
|
-
if (err instanceof HttpError) return err.status >= 500;
|
|
298
|
+
if (err instanceof HttpError) return err.status === 429 || err.status >= 500;
|
|
198
299
|
return true;
|
|
199
300
|
}
|
|
301
|
+
function retryAfterMs(headers) {
|
|
302
|
+
const value = headers.get("retry-after");
|
|
303
|
+
if (!value) return void 0;
|
|
304
|
+
const seconds = Number(value);
|
|
305
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
306
|
+
const date = Date.parse(value);
|
|
307
|
+
if (Number.isNaN(date)) return void 0;
|
|
308
|
+
return Math.max(0, date - Date.now());
|
|
309
|
+
}
|
|
200
310
|
function truncate(s, max) {
|
|
201
311
|
if (s.length <= max) return s;
|
|
202
312
|
return `${s.slice(0, max - 1)}\u2026`;
|
|
203
313
|
}
|
|
204
314
|
|
|
205
315
|
// src/scanner.ts
|
|
206
|
-
import { existsSync, statSync } from "fs";
|
|
316
|
+
import { existsSync as existsSync4, statSync as statSync2 } from "fs";
|
|
317
|
+
import { dirname as dirname3, resolve as resolve7, join as join4 } from "path";
|
|
318
|
+
|
|
319
|
+
// src/baseline.ts
|
|
320
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
321
|
+
import { dirname, resolve } from "path";
|
|
322
|
+
var BaselineError = class extends Error {
|
|
323
|
+
constructor(message) {
|
|
324
|
+
super(message);
|
|
325
|
+
this.name = "BaselineError";
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
function applyBaseline(findings, cwd, baselinePath) {
|
|
329
|
+
if (!baselinePath) return void 0;
|
|
330
|
+
const absolute = resolve(cwd, baselinePath);
|
|
331
|
+
const loaded = readBaseline(absolute);
|
|
332
|
+
const fingerprints = new Set(loaded.findings);
|
|
333
|
+
let existing = 0;
|
|
334
|
+
let fresh = 0;
|
|
335
|
+
const marked = findings.map((finding) => {
|
|
336
|
+
if (fingerprints.has(finding.fingerprint)) {
|
|
337
|
+
existing++;
|
|
338
|
+
return { ...finding, baseline: "existing" };
|
|
339
|
+
}
|
|
340
|
+
fresh++;
|
|
341
|
+
return { ...finding, baseline: "new" };
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
result: {
|
|
345
|
+
path: absolute,
|
|
346
|
+
loaded: true,
|
|
347
|
+
total: findings.length,
|
|
348
|
+
existing,
|
|
349
|
+
new: fresh
|
|
350
|
+
},
|
|
351
|
+
findings: marked
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function writeBaseline(findings, cwd, baselinePath, existing) {
|
|
355
|
+
const absolute = resolve(cwd, baselinePath);
|
|
356
|
+
const unique = [...new Set(findings.map((f) => f.fingerprint))].sort();
|
|
357
|
+
const payload = {
|
|
358
|
+
version: 1,
|
|
359
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
360
|
+
findings: unique
|
|
361
|
+
};
|
|
362
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
363
|
+
writeFileSync(absolute, `${JSON.stringify(payload, null, 2)}
|
|
364
|
+
`);
|
|
365
|
+
return {
|
|
366
|
+
path: existing?.path,
|
|
367
|
+
loaded: existing?.loaded ?? false,
|
|
368
|
+
written: absolute,
|
|
369
|
+
total: findings.length,
|
|
370
|
+
existing: existing?.existing ?? 0,
|
|
371
|
+
new: existing?.new ?? findings.length
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function readBaseline(path) {
|
|
375
|
+
if (!existsSync(path)) {
|
|
376
|
+
throw new BaselineError(`Baseline file does not exist: ${path}`);
|
|
377
|
+
}
|
|
378
|
+
let parsed;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
381
|
+
} catch (err) {
|
|
382
|
+
throw new BaselineError(
|
|
383
|
+
`Failed to parse baseline ${path}: ${err.message}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
if (!isRecord(parsed) || parsed.version !== 1) {
|
|
387
|
+
throw new BaselineError(`${path}: unsupported baseline format.`);
|
|
388
|
+
}
|
|
389
|
+
if (!Array.isArray(parsed.findings)) {
|
|
390
|
+
throw new BaselineError(`${path}: findings must be an array.`);
|
|
391
|
+
}
|
|
392
|
+
const findings = parsed.findings.filter((v) => typeof v === "string");
|
|
393
|
+
return {
|
|
394
|
+
version: 1,
|
|
395
|
+
generatedAt: typeof parsed.generatedAt === "string" ? parsed.generatedAt : "",
|
|
396
|
+
findings
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function isRecord(value) {
|
|
400
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/config.ts
|
|
404
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
207
405
|
import { resolve as resolve2, join } from "path";
|
|
406
|
+
import { parse as parseToml } from "smol-toml";
|
|
407
|
+
var CONFIG_NAME = "trawly.toml";
|
|
408
|
+
var FAIL_ON_VALUES = /* @__PURE__ */ new Set([
|
|
409
|
+
"critical",
|
|
410
|
+
"high",
|
|
411
|
+
"moderate",
|
|
412
|
+
"low",
|
|
413
|
+
"none"
|
|
414
|
+
]);
|
|
415
|
+
var POLICY_VALUES = /* @__PURE__ */ new Set([
|
|
416
|
+
"ci",
|
|
417
|
+
"strict",
|
|
418
|
+
"library",
|
|
419
|
+
"app"
|
|
420
|
+
]);
|
|
421
|
+
var ConfigError = class extends Error {
|
|
422
|
+
constructor(message) {
|
|
423
|
+
super(message);
|
|
424
|
+
this.name = "ConfigError";
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
function loadConfig(cwd, explicitPath) {
|
|
428
|
+
const configPath = explicitPath ? resolve2(cwd, explicitPath) : findConfig(cwd);
|
|
429
|
+
if (!configPath) return { config: { ignore: [] } };
|
|
430
|
+
if (!existsSync2(configPath)) {
|
|
431
|
+
throw new ConfigError(`Config file does not exist: ${configPath}`);
|
|
432
|
+
}
|
|
433
|
+
let raw;
|
|
434
|
+
try {
|
|
435
|
+
raw = parseToml(readFileSync2(configPath, "utf8"));
|
|
436
|
+
} catch (err) {
|
|
437
|
+
throw new ConfigError(
|
|
438
|
+
`Failed to parse ${configPath}: ${err.message}`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
return { path: configPath, config: normalizeConfig(raw, configPath) };
|
|
442
|
+
}
|
|
443
|
+
function findConfig(cwd) {
|
|
444
|
+
const candidate = join(cwd, CONFIG_NAME);
|
|
445
|
+
return existsSync2(candidate) ? candidate : void 0;
|
|
446
|
+
}
|
|
447
|
+
function normalizeConfig(raw, path) {
|
|
448
|
+
if (!isRecord2(raw)) throw new ConfigError(`${path} must be a TOML table.`);
|
|
449
|
+
const failOn = optionalString(raw.failOn, "failOn", path);
|
|
450
|
+
if (failOn !== void 0 && !FAIL_ON_VALUES.has(failOn)) {
|
|
451
|
+
throw new ConfigError(
|
|
452
|
+
`${path}: failOn must be one of ${[...FAIL_ON_VALUES].join(", ")}.`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
const policy = optionalString(raw.policy, "policy", path);
|
|
456
|
+
if (policy !== void 0 && !POLICY_VALUES.has(policy)) {
|
|
457
|
+
throw new ConfigError(
|
|
458
|
+
`${path}: policy must be one of ${[...POLICY_VALUES].join(", ")}.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const risk = optionalBoolean(raw.risk, "risk", path);
|
|
462
|
+
const env = optionalBoolean(raw.env, "env", path);
|
|
463
|
+
const allowedRegistries = normalizeStringArray(
|
|
464
|
+
raw.allowedRegistries,
|
|
465
|
+
"allowedRegistries",
|
|
466
|
+
path
|
|
467
|
+
);
|
|
468
|
+
if (raw.ignore !== void 0 && raw.IgnoredVulns !== void 0) {
|
|
469
|
+
console.warn(
|
|
470
|
+
`${path}: both "ignore" and legacy "IgnoredVulns" are defined; using "ignore".`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const ignore = normalizeIgnore(raw.ignore ?? raw.IgnoredVulns ?? [], path);
|
|
474
|
+
return {
|
|
475
|
+
failOn,
|
|
476
|
+
policy,
|
|
477
|
+
risk,
|
|
478
|
+
env,
|
|
479
|
+
allowedRegistries,
|
|
480
|
+
ignore
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function normalizeIgnore(raw, path) {
|
|
484
|
+
if (raw === void 0) return [];
|
|
485
|
+
if (!Array.isArray(raw)) {
|
|
486
|
+
throw new ConfigError(`${path}: ignore must be an array of tables.`);
|
|
487
|
+
}
|
|
488
|
+
return raw.map((item, idx) => {
|
|
489
|
+
if (!isRecord2(item)) {
|
|
490
|
+
throw new ConfigError(`${path}: ignore[${idx}] must be a table.`);
|
|
491
|
+
}
|
|
492
|
+
const id = requiredString(item.id, `ignore[${idx}].id`, path);
|
|
493
|
+
const expires = requiredDateString(
|
|
494
|
+
item.expires,
|
|
495
|
+
`ignore[${idx}].expires`,
|
|
496
|
+
path
|
|
497
|
+
);
|
|
498
|
+
const reason = requiredString(item.reason, `ignore[${idx}].reason`, path);
|
|
499
|
+
return {
|
|
500
|
+
id,
|
|
501
|
+
expires,
|
|
502
|
+
reason,
|
|
503
|
+
package: optionalString(item.package, `ignore[${idx}].package`, path),
|
|
504
|
+
ecosystem: optionalString(item.ecosystem, `ignore[${idx}].ecosystem`, path),
|
|
505
|
+
version: optionalString(item.version, `ignore[${idx}].version`, path)
|
|
506
|
+
};
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
function normalizeStringArray(raw, field, path) {
|
|
510
|
+
if (raw === void 0) return void 0;
|
|
511
|
+
if (!Array.isArray(raw) || raw.some((v) => typeof v !== "string")) {
|
|
512
|
+
throw new ConfigError(`${path}: ${field} must be an array of strings.`);
|
|
513
|
+
}
|
|
514
|
+
return raw;
|
|
515
|
+
}
|
|
516
|
+
function requiredDateString(raw, field, path) {
|
|
517
|
+
const value = requiredString(raw, field, path);
|
|
518
|
+
if (!isIsoDate(value)) {
|
|
519
|
+
throw new ConfigError(`${path}: ${field} must be YYYY-MM-DD.`);
|
|
520
|
+
}
|
|
521
|
+
return value;
|
|
522
|
+
}
|
|
523
|
+
function requiredString(raw, field, path) {
|
|
524
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
525
|
+
throw new ConfigError(`${path}: ${field} is required.`);
|
|
526
|
+
}
|
|
527
|
+
return raw;
|
|
528
|
+
}
|
|
529
|
+
function optionalString(raw, field, path) {
|
|
530
|
+
if (raw === void 0) return void 0;
|
|
531
|
+
if (typeof raw !== "string") {
|
|
532
|
+
throw new ConfigError(`${path}: ${field} must be a string.`);
|
|
533
|
+
}
|
|
534
|
+
return raw;
|
|
535
|
+
}
|
|
536
|
+
function optionalBoolean(raw, field, path) {
|
|
537
|
+
if (raw === void 0) return void 0;
|
|
538
|
+
if (typeof raw !== "boolean") {
|
|
539
|
+
throw new ConfigError(`${path}: ${field} must be true or false.`);
|
|
540
|
+
}
|
|
541
|
+
return raw;
|
|
542
|
+
}
|
|
543
|
+
function isIsoDate(s) {
|
|
544
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
|
545
|
+
const date = /* @__PURE__ */ new Date(`${s}T00:00:00.000Z`);
|
|
546
|
+
return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(s);
|
|
547
|
+
}
|
|
548
|
+
function isRecord2(value) {
|
|
549
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// src/env.ts
|
|
553
|
+
import {
|
|
554
|
+
lstatSync,
|
|
555
|
+
readdirSync,
|
|
556
|
+
readFileSync as readFileSync3,
|
|
557
|
+
statSync
|
|
558
|
+
} from "fs";
|
|
559
|
+
import { join as join2, relative } from "path";
|
|
560
|
+
var MAX_ENV_FILE_BYTES = 1024 * 1024;
|
|
561
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
562
|
+
".git",
|
|
563
|
+
".hg",
|
|
564
|
+
".svn",
|
|
565
|
+
"coverage",
|
|
566
|
+
"dist",
|
|
567
|
+
"node_modules",
|
|
568
|
+
"vendor"
|
|
569
|
+
]);
|
|
570
|
+
var SAFE_ENV_SUFFIXES = /* @__PURE__ */ new Set([
|
|
571
|
+
"default",
|
|
572
|
+
"defaults",
|
|
573
|
+
"dist",
|
|
574
|
+
"example",
|
|
575
|
+
"sample",
|
|
576
|
+
"template"
|
|
577
|
+
]);
|
|
578
|
+
var SECRET_KEY_RE = /(?:^|_)(?:SECRET|TOKEN|PASSWORD|PASS|PWD|PRIVATE_KEY|API_KEY|ACCESS_KEY|AUTH|CREDENTIAL|DATABASE_URL|DB_URL|REDIS_URL|MONGO_URI|CONNECTION_STRING|WEBHOOK|CLIENT_SECRET)(?:$|_)/i;
|
|
579
|
+
var PRIVATE_KEY_RE = /PRIVATE_KEY|BEGIN_[A-Z0-9_]+_PRIVATE_KEY/i;
|
|
580
|
+
var PLACEHOLDER_RE = /^(?:|changeme|change_me|change-me|example|example-value|placeholder|replace_me|replace-me|todo|test|dummy|your_.+|<.+>|\$\{.+\}|x+)$/i;
|
|
581
|
+
function scanEnvFiles(cwd) {
|
|
582
|
+
const warnings = [];
|
|
583
|
+
const findings = [];
|
|
584
|
+
let filesScanned = 0;
|
|
585
|
+
for (const file of findEnvFiles(cwd)) {
|
|
586
|
+
let raw;
|
|
587
|
+
try {
|
|
588
|
+
const stat = statSync(file);
|
|
589
|
+
if (stat.size > MAX_ENV_FILE_BYTES) {
|
|
590
|
+
warnings.push(
|
|
591
|
+
`Skipped env file ${relative(cwd, file)} because it is larger than 1 MiB.`
|
|
592
|
+
);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
raw = readFileSync3(file, "utf8");
|
|
596
|
+
} catch (err) {
|
|
597
|
+
warnings.push(
|
|
598
|
+
`Could not read env file ${relative(cwd, file)}: ${err.message}`
|
|
599
|
+
);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
filesScanned++;
|
|
603
|
+
const rel = normalizePath(relative(cwd, file));
|
|
604
|
+
findings.push(envFileFinding(file, rel));
|
|
605
|
+
for (const assignment of parseEnvAssignments(raw)) {
|
|
606
|
+
if (!isSensitiveAssignment(assignment)) continue;
|
|
607
|
+
findings.push(envSecretFinding(file, rel, assignment));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { findings, warnings, filesScanned };
|
|
611
|
+
}
|
|
612
|
+
function findEnvFiles(root) {
|
|
613
|
+
const out = [];
|
|
614
|
+
const stack = [root];
|
|
615
|
+
while (stack.length > 0) {
|
|
616
|
+
const dir = stack.pop();
|
|
617
|
+
let entries;
|
|
618
|
+
try {
|
|
619
|
+
entries = readdirSync(dir);
|
|
620
|
+
} catch {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
for (const entry of entries) {
|
|
624
|
+
const path = join2(dir, entry);
|
|
625
|
+
let stat;
|
|
626
|
+
try {
|
|
627
|
+
stat = lstatSync(path);
|
|
628
|
+
} catch {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (stat.isSymbolicLink()) continue;
|
|
632
|
+
if (stat.isDirectory()) {
|
|
633
|
+
if (!SKIP_DIRS.has(entry)) stack.push(path);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (stat.isFile() && isEnvFile(entry)) out.push(path);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return out.sort();
|
|
640
|
+
}
|
|
641
|
+
function isEnvFile(name) {
|
|
642
|
+
if (name === ".env") return true;
|
|
643
|
+
if (!name.startsWith(".env.")) return false;
|
|
644
|
+
const suffixes = name.slice(".env.".length).toLowerCase().split(".").filter(Boolean);
|
|
645
|
+
return !suffixes.some((suffix) => SAFE_ENV_SUFFIXES.has(suffix));
|
|
646
|
+
}
|
|
647
|
+
function parseEnvAssignments(raw) {
|
|
648
|
+
const out = [];
|
|
649
|
+
raw.split(/\r?\n/).forEach((line, index) => {
|
|
650
|
+
const trimmed = line.trim();
|
|
651
|
+
if (!trimmed || trimmed.startsWith("#")) return;
|
|
652
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(
|
|
653
|
+
trimmed
|
|
654
|
+
);
|
|
655
|
+
if (!match) return;
|
|
656
|
+
const key = match[1];
|
|
657
|
+
const value = unquote(match[2].trim());
|
|
658
|
+
out.push({ key, value, line: index + 1 });
|
|
659
|
+
});
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
function isSensitiveAssignment(assignment) {
|
|
663
|
+
if (!SECRET_KEY_RE.test(assignment.key)) return false;
|
|
664
|
+
return !PLACEHOLDER_RE.test(assignment.value.trim());
|
|
665
|
+
}
|
|
666
|
+
function envFileFinding(sourceFile, rel) {
|
|
667
|
+
const id = "TRAWLY-ENV-FILE";
|
|
668
|
+
return {
|
|
669
|
+
id,
|
|
670
|
+
source: "trawly",
|
|
671
|
+
type: "secret",
|
|
672
|
+
severity: "moderate",
|
|
673
|
+
ecosystem: "env",
|
|
674
|
+
packageName: ".env file",
|
|
675
|
+
installedVersion: rel,
|
|
676
|
+
summary: "Committed env file detected. Verify it does not contain secrets and prefer committing an example/template file instead.",
|
|
677
|
+
fixedVersions: [],
|
|
678
|
+
affectedPaths: [rel],
|
|
679
|
+
fingerprint: fingerprintFinding({
|
|
680
|
+
source: "trawly",
|
|
681
|
+
type: "secret",
|
|
682
|
+
id,
|
|
683
|
+
ecosystem: "env",
|
|
684
|
+
packageName: ".env file",
|
|
685
|
+
installedVersion: rel
|
|
686
|
+
}),
|
|
687
|
+
aliases: [],
|
|
688
|
+
sourceFile,
|
|
689
|
+
line: 1
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function envSecretFinding(sourceFile, rel, assignment) {
|
|
693
|
+
const id = "TRAWLY-ENV-SECRET";
|
|
694
|
+
return {
|
|
695
|
+
id,
|
|
696
|
+
source: "trawly",
|
|
697
|
+
type: "secret",
|
|
698
|
+
severity: PRIVATE_KEY_RE.test(assignment.key) ? "critical" : "high",
|
|
699
|
+
ecosystem: "env",
|
|
700
|
+
packageName: assignment.key,
|
|
701
|
+
installedVersion: rel,
|
|
702
|
+
summary: "Committed env file contains a secret-like variable. The value is intentionally omitted from this report.",
|
|
703
|
+
fixedVersions: [],
|
|
704
|
+
affectedPaths: [rel],
|
|
705
|
+
fingerprint: fingerprintFinding({
|
|
706
|
+
source: "trawly",
|
|
707
|
+
type: "secret",
|
|
708
|
+
id,
|
|
709
|
+
ecosystem: "env",
|
|
710
|
+
packageName: assignment.key,
|
|
711
|
+
installedVersion: rel
|
|
712
|
+
}),
|
|
713
|
+
aliases: [],
|
|
714
|
+
sourceFile,
|
|
715
|
+
line: assignment.line
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function unquote(value) {
|
|
719
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
720
|
+
return value.slice(1, -1);
|
|
721
|
+
}
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
function normalizePath(path) {
|
|
725
|
+
return path.split(/[\\/]/).join("/");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/extractors/lockfile.ts
|
|
729
|
+
import { basename as basename2 } from "path";
|
|
208
730
|
|
|
209
731
|
// src/extractors/npm-package-lock.ts
|
|
210
|
-
import { readFileSync } from "fs";
|
|
211
|
-
import { resolve } from "path";
|
|
732
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
733
|
+
import { resolve as resolve3 } from "path";
|
|
212
734
|
function parseNpmPackageLock(filePath) {
|
|
213
|
-
const absolute =
|
|
214
|
-
const raw =
|
|
735
|
+
const absolute = resolve3(filePath);
|
|
736
|
+
const raw = readFileSync4(absolute, "utf8");
|
|
215
737
|
let parsed;
|
|
216
738
|
try {
|
|
217
739
|
parsed = JSON.parse(raw);
|
|
@@ -249,8 +771,14 @@ function parseNpmPackageLock(filePath) {
|
|
|
249
771
|
direct: directDeps.has(name) && isTopLevelInstance(path),
|
|
250
772
|
dev: Boolean(entry.dev || entry.devOptional),
|
|
251
773
|
optional: Boolean(entry.optional || entry.devOptional),
|
|
774
|
+
inputKind: "lockfile",
|
|
775
|
+
sourceFile: absolute,
|
|
776
|
+
line: lineOf(raw, JSON.stringify(path)),
|
|
777
|
+
manager: "npm",
|
|
252
778
|
resolved: entry.resolved,
|
|
253
|
-
integrity: entry.integrity
|
|
779
|
+
integrity: entry.integrity,
|
|
780
|
+
registry: registryFromResolved(entry.resolved),
|
|
781
|
+
hasInstallScript: Boolean(entry.hasInstallScript)
|
|
254
782
|
});
|
|
255
783
|
}
|
|
256
784
|
return instances;
|
|
@@ -289,554 +817,2254 @@ function isTopLevelInstance(path) {
|
|
|
289
817
|
if (first === -1) return false;
|
|
290
818
|
return path.indexOf("node_modules/", first + 1) === -1;
|
|
291
819
|
}
|
|
820
|
+
function registryFromResolved(resolved) {
|
|
821
|
+
if (!resolved) return void 0;
|
|
822
|
+
try {
|
|
823
|
+
const url = new URL(resolved);
|
|
824
|
+
return `${url.protocol}//${url.host}`;
|
|
825
|
+
} catch {
|
|
826
|
+
return void 0;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function lineOf(raw, needle) {
|
|
830
|
+
const idx = raw.indexOf(needle);
|
|
831
|
+
if (idx === -1) return void 0;
|
|
832
|
+
return raw.slice(0, idx).split(/\r?\n/).length;
|
|
833
|
+
}
|
|
292
834
|
|
|
293
|
-
// src/
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
moderate: 2,
|
|
298
|
-
low: 1,
|
|
299
|
-
unknown: 0
|
|
300
|
-
};
|
|
835
|
+
// src/extractors/pnpm-lock.ts
|
|
836
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
837
|
+
import { resolve as resolve4 } from "path";
|
|
838
|
+
import { parse as parseYaml } from "yaml";
|
|
301
839
|
|
|
302
|
-
// src/
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
840
|
+
// src/extractors/package-json.ts
|
|
841
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
842
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
843
|
+
function readPackageJsonInfoFrom(filePath) {
|
|
844
|
+
return readPackageJsonInfo(dirname2(filePath));
|
|
845
|
+
}
|
|
846
|
+
function readPackageJsonInfo(cwd) {
|
|
847
|
+
const info = {
|
|
848
|
+
dependencies: /* @__PURE__ */ new Set(),
|
|
849
|
+
devDependencies: /* @__PURE__ */ new Set(),
|
|
850
|
+
optionalDependencies: /* @__PURE__ */ new Set(),
|
|
851
|
+
allDirect: /* @__PURE__ */ new Set()
|
|
852
|
+
};
|
|
853
|
+
const path = join3(cwd, "package.json");
|
|
854
|
+
if (!existsSync3(path)) return info;
|
|
855
|
+
try {
|
|
856
|
+
const raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
857
|
+
collect(raw.dependencies, info.dependencies, info.allDirect);
|
|
858
|
+
collect(raw.devDependencies, info.devDependencies, info.allDirect);
|
|
859
|
+
collect(raw.optionalDependencies, info.optionalDependencies, info.allDirect);
|
|
860
|
+
collect(raw.peerDependencies, info.dependencies, info.allDirect);
|
|
861
|
+
} catch {
|
|
862
|
+
return info;
|
|
310
863
|
}
|
|
311
|
-
return
|
|
312
|
-
lockfilePath,
|
|
313
|
-
includeDev: options.includeDev,
|
|
314
|
-
prodOnly: options.prodOnly,
|
|
315
|
-
fetchImpl: options.fetchImpl
|
|
316
|
-
});
|
|
864
|
+
return info;
|
|
317
865
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const stat = statSync(lockfilePath);
|
|
324
|
-
if (!stat.isFile()) {
|
|
325
|
-
throw new ScanInputError(`Lockfile path is not a file: ${lockfilePath}`);
|
|
866
|
+
function collect(value, target, allDirect) {
|
|
867
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
|
868
|
+
for (const name of Object.keys(value)) {
|
|
869
|
+
target.add(name);
|
|
870
|
+
allDirect.add(name);
|
|
326
871
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/extractors/pnpm-lock.ts
|
|
875
|
+
var SUPPORTED_MAJOR_VERSIONS = /* @__PURE__ */ new Set([6, 9]);
|
|
876
|
+
function parsePnpmLock(filePath) {
|
|
877
|
+
const absolute = resolve4(filePath);
|
|
878
|
+
const raw = readFileSync6(absolute, "utf8");
|
|
879
|
+
let parsed;
|
|
331
880
|
try {
|
|
332
|
-
|
|
881
|
+
parsed = parseYaml(raw);
|
|
333
882
|
} catch (err) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
883
|
+
throw new Error(
|
|
884
|
+
`Failed to parse ${absolute}: ${err.message}`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const major = parseLockfileMajor(parsed.lockfileVersion);
|
|
888
|
+
if (major === null || !SUPPORTED_MAJOR_VERSIONS.has(major)) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`Unsupported pnpm lockfileVersion ${String(parsed.lockfileVersion)} in ${absolute}. Supported: 6.x, 9.x.`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
if (!parsed.packages || typeof parsed.packages !== "object") {
|
|
894
|
+
throw new Error(`Lockfile ${absolute} has no "packages" map.`);
|
|
895
|
+
}
|
|
896
|
+
const rootInfo = readPackageJsonInfoFrom(absolute);
|
|
897
|
+
const importerDirect = collectImporterDirect(parsed);
|
|
898
|
+
const directDeps = importerDirect.all.size > 0 ? importerDirect.all : rootInfo.allDirect;
|
|
899
|
+
const devDeps = importerDirect.dev.size > 0 ? importerDirect.dev : rootInfo.devDependencies;
|
|
900
|
+
const optionalDeps = importerDirect.optional.size > 0 ? importerDirect.optional : rootInfo.optionalDependencies;
|
|
901
|
+
const instances = [];
|
|
902
|
+
for (const [key, entry] of Object.entries(parsed.packages)) {
|
|
903
|
+
const parsedKey = parsePnpmPackageKey(key);
|
|
904
|
+
if (!parsedKey) continue;
|
|
905
|
+
const direct = directDeps.has(parsedKey.name);
|
|
906
|
+
instances.push({
|
|
907
|
+
name: parsedKey.name,
|
|
908
|
+
version: parsedKey.version,
|
|
909
|
+
ecosystem: "npm",
|
|
910
|
+
path: key,
|
|
911
|
+
direct,
|
|
912
|
+
dev: direct ? devDeps.has(parsedKey.name) : Boolean(entry.dev),
|
|
913
|
+
optional: direct ? optionalDeps.has(parsedKey.name) : Boolean(entry.optional),
|
|
914
|
+
inputKind: "lockfile",
|
|
915
|
+
sourceFile: absolute,
|
|
916
|
+
line: lineOf2(raw, key),
|
|
917
|
+
manager: "pnpm",
|
|
918
|
+
resolved: entry.resolution?.tarball,
|
|
919
|
+
integrity: entry.resolution?.integrity,
|
|
920
|
+
registry: registryFromResolved2(entry.resolution?.tarball),
|
|
921
|
+
hasInstallScript: Boolean(entry.requiresBuild)
|
|
337
922
|
});
|
|
338
923
|
}
|
|
339
|
-
|
|
340
|
-
return {
|
|
341
|
-
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
342
|
-
packagesScanned: instances.length,
|
|
343
|
-
findings,
|
|
344
|
-
summary: summarize(findings),
|
|
345
|
-
errors
|
|
346
|
-
};
|
|
924
|
+
return instances;
|
|
347
925
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
926
|
+
function parsePnpmPackageKey(key) {
|
|
927
|
+
let normalized = key.replace(/^\/+/, "");
|
|
928
|
+
const peerStart = normalized.indexOf("(");
|
|
929
|
+
if (peerStart !== -1) normalized = normalized.slice(0, peerStart);
|
|
930
|
+
normalized = normalized.split("_")[0] ?? normalized;
|
|
931
|
+
const at = normalized.lastIndexOf("@");
|
|
932
|
+
if (at <= 0) return null;
|
|
933
|
+
const name = normalized.slice(0, at);
|
|
934
|
+
const version = normalized.slice(at + 1);
|
|
935
|
+
if (!name || !version) return null;
|
|
936
|
+
return { name, version };
|
|
937
|
+
}
|
|
938
|
+
function collectImporterDirect(lock) {
|
|
939
|
+
const all = /* @__PURE__ */ new Set();
|
|
940
|
+
const dev = /* @__PURE__ */ new Set();
|
|
941
|
+
const optional = /* @__PURE__ */ new Set();
|
|
942
|
+
const importers = lock.importers ?? {
|
|
943
|
+
".": {
|
|
944
|
+
dependencies: lock.dependencies,
|
|
945
|
+
devDependencies: lock.devDependencies,
|
|
946
|
+
optionalDependencies: lock.optionalDependencies
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
for (const importer of Object.values(importers)) {
|
|
950
|
+
addKeys(importer.dependencies, all);
|
|
951
|
+
addKeys(importer.devDependencies, all, dev);
|
|
952
|
+
addKeys(importer.optionalDependencies, all, optional);
|
|
352
953
|
}
|
|
353
|
-
};
|
|
354
|
-
function detectLockfile(cwd) {
|
|
355
|
-
const candidate = join(cwd, "package-lock.json");
|
|
356
|
-
return existsSync(candidate) ? candidate : void 0;
|
|
357
|
-
}
|
|
358
|
-
function filterInstances(instances, options) {
|
|
359
|
-
const includeDev = options.prodOnly ? false : options.includeDev !== false;
|
|
360
|
-
if (includeDev) return instances;
|
|
361
|
-
return instances.filter((p) => !p.dev);
|
|
954
|
+
return { all, dev, optional };
|
|
362
955
|
}
|
|
363
|
-
function
|
|
364
|
-
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
return
|
|
956
|
+
function parseLockfileMajor(value) {
|
|
957
|
+
if (typeof value === "number") return Math.trunc(value);
|
|
958
|
+
if (typeof value === "string") {
|
|
959
|
+
const major = Number.parseInt(value, 10);
|
|
960
|
+
return Number.isNaN(major) ? null : major;
|
|
368
961
|
}
|
|
369
|
-
|
|
370
|
-
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
function addKeys(value, all, bucket) {
|
|
965
|
+
if (!value) return;
|
|
966
|
+
for (const name of Object.keys(value)) {
|
|
967
|
+
all.add(name);
|
|
968
|
+
bucket?.add(name);
|
|
371
969
|
}
|
|
372
|
-
return a.id.localeCompare(b.id);
|
|
373
970
|
}
|
|
374
|
-
function
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
for (const f of findings) summary[f.severity]++;
|
|
383
|
-
return summary;
|
|
971
|
+
function registryFromResolved2(resolved) {
|
|
972
|
+
if (!resolved) return void 0;
|
|
973
|
+
try {
|
|
974
|
+
const url = new URL(resolved);
|
|
975
|
+
return `${url.protocol}//${url.host}`;
|
|
976
|
+
} catch {
|
|
977
|
+
return void 0;
|
|
978
|
+
}
|
|
384
979
|
}
|
|
385
|
-
function
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return
|
|
980
|
+
function lineOf2(raw, needle) {
|
|
981
|
+
const idx = raw.indexOf(needle);
|
|
982
|
+
if (idx === -1) return void 0;
|
|
983
|
+
return raw.slice(0, idx).split(/\r?\n/).length;
|
|
389
984
|
}
|
|
390
985
|
|
|
391
|
-
// src/
|
|
392
|
-
import {
|
|
393
|
-
import {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
986
|
+
// src/extractors/yarn-lock.ts
|
|
987
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
988
|
+
import { resolve as resolve5 } from "path";
|
|
989
|
+
import { parse as parseYaml2 } from "yaml";
|
|
990
|
+
import * as yarnClassicModule from "@yarnpkg/lockfile";
|
|
991
|
+
var yarnClassic = "parse" in yarnClassicModule ? yarnClassicModule : yarnClassicModule.default;
|
|
992
|
+
var LOCAL_YARN_PROTOCOLS = ["workspace:", "patch:", "portal:", "file:"];
|
|
993
|
+
function parseYarnLock(filePath) {
|
|
994
|
+
const absolute = resolve5(filePath);
|
|
995
|
+
const raw = readFileSync7(absolute, "utf8");
|
|
996
|
+
return isBerryLock(raw) ? parseYarnBerryLock(absolute, raw) : parseYarnClassicLock(absolute, raw);
|
|
997
|
+
}
|
|
998
|
+
function parseYarnClassicLock(absolute, raw) {
|
|
999
|
+
const parsed = yarnClassic.parse(raw);
|
|
1000
|
+
if (parsed.type === "conflict") {
|
|
1001
|
+
throw new Error(`Yarn lockfile ${absolute} contains merge conflicts.`);
|
|
1002
|
+
}
|
|
1003
|
+
const rootInfo = readPackageJsonInfoFrom(absolute);
|
|
1004
|
+
const instances = [];
|
|
1005
|
+
for (const [descriptor, value] of Object.entries(parsed.object)) {
|
|
1006
|
+
if (!isRecord3(value)) continue;
|
|
1007
|
+
const entry = value;
|
|
1008
|
+
if (!entry.version) continue;
|
|
1009
|
+
const name = parseYarnDescriptorName(descriptor);
|
|
1010
|
+
if (!name) continue;
|
|
1011
|
+
const direct = rootInfo.allDirect.has(name);
|
|
1012
|
+
instances.push({
|
|
1013
|
+
name,
|
|
1014
|
+
version: entry.version,
|
|
1015
|
+
ecosystem: "npm",
|
|
1016
|
+
path: descriptor,
|
|
1017
|
+
direct,
|
|
1018
|
+
dev: direct ? rootInfo.devDependencies.has(name) : false,
|
|
1019
|
+
optional: direct ? rootInfo.optionalDependencies.has(name) : false,
|
|
1020
|
+
inputKind: "lockfile",
|
|
1021
|
+
sourceFile: absolute,
|
|
1022
|
+
line: lineOf3(raw, descriptor),
|
|
1023
|
+
manager: "yarn",
|
|
1024
|
+
resolved: entry.resolved,
|
|
1025
|
+
integrity: entry.integrity,
|
|
1026
|
+
registry: registryFromResolved3(entry.resolved),
|
|
1027
|
+
hasInstallScript: false
|
|
1028
|
+
});
|
|
409
1029
|
}
|
|
410
|
-
return
|
|
1030
|
+
return dedupeInstances(instances);
|
|
411
1031
|
}
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
if (!existsSync2(pkgPath)) return void 0;
|
|
1032
|
+
function parseYarnBerryLock(absolute, raw) {
|
|
1033
|
+
let parsed;
|
|
415
1034
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
1035
|
+
parsed = parseYaml2(raw);
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
throw new Error(
|
|
1038
|
+
`Failed to parse ${absolute}: ${err.message}`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
const rootInfo = readPackageJsonInfoFrom(absolute);
|
|
1042
|
+
const instances = [];
|
|
1043
|
+
for (const [descriptor, value] of Object.entries(parsed)) {
|
|
1044
|
+
if (descriptor === "__metadata" || !isRecord3(value)) continue;
|
|
1045
|
+
const entry = value;
|
|
1046
|
+
if (!entry.version) continue;
|
|
1047
|
+
const resolution = entry.resolution ?? descriptor;
|
|
1048
|
+
if (hasLocalYarnProtocol(resolution) || hasLocalYarnProtocol(descriptor)) {
|
|
1049
|
+
continue;
|
|
421
1050
|
}
|
|
422
|
-
|
|
1051
|
+
const name = parseYarnDescriptorName(resolution) ?? parseYarnDescriptorName(descriptor);
|
|
1052
|
+
if (!name) continue;
|
|
1053
|
+
const direct = rootInfo.allDirect.has(name);
|
|
1054
|
+
instances.push({
|
|
1055
|
+
name,
|
|
1056
|
+
version: entry.version,
|
|
1057
|
+
ecosystem: "npm",
|
|
1058
|
+
path: descriptor,
|
|
1059
|
+
direct,
|
|
1060
|
+
dev: direct ? rootInfo.devDependencies.has(name) : false,
|
|
1061
|
+
optional: direct ? rootInfo.optionalDependencies.has(name) : false,
|
|
1062
|
+
inputKind: "lockfile",
|
|
1063
|
+
sourceFile: absolute,
|
|
1064
|
+
line: lineOf3(raw, descriptor),
|
|
1065
|
+
manager: "yarn",
|
|
1066
|
+
integrity: entry.checksum,
|
|
1067
|
+
hasInstallScript: false
|
|
1068
|
+
});
|
|
423
1069
|
}
|
|
424
|
-
return
|
|
1070
|
+
return dedupeInstances(instances);
|
|
425
1071
|
}
|
|
426
|
-
function
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1072
|
+
function hasLocalYarnProtocol(value) {
|
|
1073
|
+
const normalized = value.trim().replace(/^"|"$/g, "");
|
|
1074
|
+
return LOCAL_YARN_PROTOCOLS.some(
|
|
1075
|
+
(protocol) => normalized.startsWith(protocol) || normalized.includes(`@${protocol}`)
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
function parseYarnDescriptorName(descriptor) {
|
|
1079
|
+
const first = descriptor.split(",")[0]?.trim().replace(/^"|"$/g, "");
|
|
1080
|
+
if (!first) return null;
|
|
1081
|
+
for (const marker of ["@npm:", "@patch:", "@workspace:", "@portal:", "@file:"]) {
|
|
1082
|
+
const idx = first.lastIndexOf(marker);
|
|
1083
|
+
if (idx > 0) return first.slice(0, idx);
|
|
436
1084
|
}
|
|
1085
|
+
if (first.startsWith("@")) {
|
|
1086
|
+
const slash = first.indexOf("/");
|
|
1087
|
+
if (slash === -1) return null;
|
|
1088
|
+
const at2 = first.indexOf("@", slash + 1);
|
|
1089
|
+
return at2 === -1 ? first : first.slice(0, at2);
|
|
1090
|
+
}
|
|
1091
|
+
const at = first.indexOf("@");
|
|
1092
|
+
return at === -1 ? first : first.slice(0, at);
|
|
437
1093
|
}
|
|
438
|
-
function
|
|
439
|
-
return
|
|
1094
|
+
function isBerryLock(raw) {
|
|
1095
|
+
return raw.includes("__metadata:") || raw.includes("cacheKey:");
|
|
440
1096
|
}
|
|
441
|
-
function
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1097
|
+
function dedupeInstances(instances) {
|
|
1098
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1099
|
+
const out = [];
|
|
1100
|
+
for (const instance of instances) {
|
|
1101
|
+
const key = `${instance.name}@${instance.version}`;
|
|
1102
|
+
if (seen.has(key)) continue;
|
|
1103
|
+
seen.add(key);
|
|
1104
|
+
out.push(instance);
|
|
1105
|
+
}
|
|
1106
|
+
return out;
|
|
1107
|
+
}
|
|
1108
|
+
function registryFromResolved3(resolved) {
|
|
1109
|
+
if (!resolved) return void 0;
|
|
1110
|
+
try {
|
|
1111
|
+
const url = new URL(resolved);
|
|
1112
|
+
return `${url.protocol}//${url.host}`;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return void 0;
|
|
451
1115
|
}
|
|
452
1116
|
}
|
|
1117
|
+
function lineOf3(raw, needle) {
|
|
1118
|
+
const idx = raw.indexOf(needle);
|
|
1119
|
+
if (idx === -1) return void 0;
|
|
1120
|
+
return raw.slice(0, idx).split(/\r?\n/).length;
|
|
1121
|
+
}
|
|
1122
|
+
function isRecord3(value) {
|
|
1123
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1124
|
+
}
|
|
453
1125
|
|
|
454
|
-
// src/
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
});
|
|
1126
|
+
// src/extractors/lockfile.ts
|
|
1127
|
+
function parseLockfile(filePath) {
|
|
1128
|
+
const file = basename2(filePath);
|
|
1129
|
+
if (file === "package-lock.json" || file === "npm-shrinkwrap.json") {
|
|
1130
|
+
return parseNpmPackageLock(filePath);
|
|
1131
|
+
}
|
|
1132
|
+
if (file === "pnpm-lock.yaml") return parsePnpmLock(filePath);
|
|
1133
|
+
if (file === "yarn.lock") return parseYarnLock(filePath);
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
`Unsupported lockfile ${filePath}. Supported: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock.`
|
|
1136
|
+
);
|
|
466
1137
|
}
|
|
467
1138
|
|
|
468
|
-
// src/
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
1139
|
+
// src/extractors/sbom.ts
|
|
1140
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
1141
|
+
import { basename as basename3, resolve as resolve6 } from "path";
|
|
1142
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1143
|
+
import { PackageURL } from "packageurl-js";
|
|
1144
|
+
function parseSbom(filePath) {
|
|
1145
|
+
const absolute = resolve6(filePath);
|
|
1146
|
+
const raw = readFileSync8(absolute, "utf8");
|
|
1147
|
+
const trimmed = raw.trimStart();
|
|
1148
|
+
if (trimmed.startsWith("{")) return parseJsonSbom(absolute, raw);
|
|
1149
|
+
if (trimmed.startsWith("<")) return parseCycloneDxXml(absolute, raw);
|
|
1150
|
+
return parseSpdxTagValue(absolute, raw);
|
|
1151
|
+
}
|
|
1152
|
+
function parseJsonSbom(absolute, raw) {
|
|
1153
|
+
let parsed;
|
|
1154
|
+
try {
|
|
1155
|
+
parsed = JSON.parse(raw);
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`Failed to parse ${absolute}: ${err.message}`
|
|
1159
|
+
);
|
|
474
1160
|
}
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
return { raw, name: trimmed, unsupported: "workspace" };
|
|
1161
|
+
if (!isRecord4(parsed)) {
|
|
1162
|
+
throw new Error(`SBOM ${absolute} must contain a JSON object.`);
|
|
478
1163
|
}
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
return { raw, name: trimmed, unsupported: reason };
|
|
1164
|
+
if (Array.isArray(parsed.components)) {
|
|
1165
|
+
return parseCycloneDxJson(absolute, raw, parsed.components);
|
|
482
1166
|
}
|
|
483
|
-
if (
|
|
484
|
-
return
|
|
1167
|
+
if (Array.isArray(parsed.packages)) {
|
|
1168
|
+
return parseSpdxJson(absolute, raw, parsed.packages);
|
|
485
1169
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1170
|
+
throw new Error(
|
|
1171
|
+
`Could not detect SBOM format for ${absolute}; expected CycloneDX or SPDX.`
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
function parseCycloneDxJson(absolute, raw, components) {
|
|
1175
|
+
const instances = [];
|
|
1176
|
+
for (const component of components) {
|
|
1177
|
+
if (!isRecord4(component) || typeof component.purl !== "string") continue;
|
|
1178
|
+
const pkg = parsePurlPackage(component.purl);
|
|
1179
|
+
if (!pkg) continue;
|
|
1180
|
+
instances.push(sbomPackage(pkg, absolute, raw, component.purl));
|
|
1181
|
+
}
|
|
1182
|
+
return dedupe(instances);
|
|
1183
|
+
}
|
|
1184
|
+
function parseCycloneDxXml(absolute, raw) {
|
|
1185
|
+
const parser = new XMLParser({
|
|
1186
|
+
ignoreAttributes: false,
|
|
1187
|
+
attributeNamePrefix: "",
|
|
1188
|
+
textNodeName: "#text"
|
|
1189
|
+
});
|
|
1190
|
+
let parsed;
|
|
1191
|
+
try {
|
|
1192
|
+
parsed = parser.parse(raw);
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
throw new Error(
|
|
1195
|
+
`Failed to parse ${absolute}: ${err.message}`
|
|
1196
|
+
);
|
|
500
1197
|
}
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
1198
|
+
const bom = isRecord4(parsed) ? parsed.bom : void 0;
|
|
1199
|
+
const components = isRecord4(bom) && isRecord4(bom.components) ? arrayify(bom.components.component) : [];
|
|
1200
|
+
const instances = [];
|
|
1201
|
+
for (const component of components) {
|
|
1202
|
+
if (!isRecord4(component) || typeof component.purl !== "string") continue;
|
|
1203
|
+
const pkg = parsePurlPackage(component.purl);
|
|
1204
|
+
if (!pkg) continue;
|
|
1205
|
+
instances.push(sbomPackage(pkg, absolute, raw, component.purl));
|
|
507
1206
|
}
|
|
508
|
-
return
|
|
1207
|
+
return dedupe(instances);
|
|
509
1208
|
}
|
|
510
|
-
function
|
|
511
|
-
const
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
continue;
|
|
1209
|
+
function parseSpdxJson(absolute, raw, packages) {
|
|
1210
|
+
const instances = [];
|
|
1211
|
+
for (const pkgRecord of packages) {
|
|
1212
|
+
if (!isRecord4(pkgRecord)) continue;
|
|
1213
|
+
const externalRefs = Array.isArray(pkgRecord.externalRefs) ? pkgRecord.externalRefs : [];
|
|
1214
|
+
for (const ref of externalRefs) {
|
|
1215
|
+
if (!isRecord4(ref)) continue;
|
|
1216
|
+
const locator = ref.referenceLocator;
|
|
1217
|
+
const type = String(ref.referenceType ?? "").toLowerCase();
|
|
1218
|
+
if (type !== "purl" || typeof locator !== "string") continue;
|
|
1219
|
+
const pkg = parsePurlPackage(locator);
|
|
1220
|
+
if (!pkg) continue;
|
|
1221
|
+
instances.push(sbomPackage(pkg, absolute, raw, locator));
|
|
517
1222
|
}
|
|
518
|
-
specs.push(parseSpec(arg));
|
|
519
1223
|
}
|
|
520
|
-
return
|
|
1224
|
+
return dedupe(instances);
|
|
521
1225
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
var EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/;
|
|
527
|
-
var RANGE_CHARS_RE = /[\^~><=|\s*x]/i;
|
|
528
|
-
async function resolveVersion(name, requested, deps = {}) {
|
|
529
|
-
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
530
|
-
const registry = deps.registryUrl ?? REGISTRY_URL;
|
|
531
|
-
const packument = await fetchPackument(fetchImpl, registry, name);
|
|
532
|
-
const distTags = packument["dist-tags"] ?? {};
|
|
533
|
-
const latest = distTags.latest;
|
|
534
|
-
if (!requested) {
|
|
535
|
-
if (!latest) {
|
|
536
|
-
throw new VersionResolveError(
|
|
537
|
-
`Package ${name} has no "latest" dist-tag in the registry.`
|
|
538
|
-
);
|
|
539
|
-
}
|
|
540
|
-
return { version: latest, source: "dist-tag", requested: "latest" };
|
|
541
|
-
}
|
|
542
|
-
if (EXACT_VERSION_RE.test(requested)) {
|
|
543
|
-
if (packument.versions && !packument.versions[requested]) {
|
|
544
|
-
throw new VersionResolveError(
|
|
545
|
-
`Version ${requested} of ${name} is not published.`
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
return { version: requested, source: "exact" };
|
|
549
|
-
}
|
|
550
|
-
if (!RANGE_CHARS_RE.test(requested) && distTags[requested]) {
|
|
551
|
-
return {
|
|
552
|
-
version: distTags[requested],
|
|
553
|
-
source: "dist-tag",
|
|
554
|
-
requested
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
if (!latest) {
|
|
558
|
-
throw new VersionResolveError(
|
|
559
|
-
`Cannot resolve ${name}@${requested}: no "latest" dist-tag available to fall back on.`
|
|
1226
|
+
function parseSpdxTagValue(absolute, raw) {
|
|
1227
|
+
if (!raw.includes("SPDXVersion:")) {
|
|
1228
|
+
throw new Error(
|
|
1229
|
+
`Could not detect SBOM format for ${absolute}; expected CycloneDX or SPDX.`
|
|
560
1230
|
);
|
|
561
1231
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1232
|
+
const instances = [];
|
|
1233
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1234
|
+
const match = line.match(/^ExternalRef:\s+PACKAGE-MANAGER\s+purl\s+(\S+)/);
|
|
1235
|
+
if (!match?.[1]) continue;
|
|
1236
|
+
const pkg = parsePurlPackage(match[1]);
|
|
1237
|
+
if (!pkg) continue;
|
|
1238
|
+
instances.push(sbomPackage(pkg, absolute, raw, match[1]));
|
|
568
1239
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS2);
|
|
1240
|
+
return dedupe(instances);
|
|
1241
|
+
}
|
|
1242
|
+
function parsePurlPackage(purl) {
|
|
1243
|
+
let parsed;
|
|
574
1244
|
try {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
});
|
|
579
|
-
if (res.status === 404) {
|
|
580
|
-
throw new VersionResolveError(`Package ${name} not found in registry.`);
|
|
581
|
-
}
|
|
582
|
-
if (!res.ok) {
|
|
583
|
-
throw new VersionResolveError(
|
|
584
|
-
`Registry ${res.status} for ${name}: ${res.statusText}`
|
|
585
|
-
);
|
|
586
|
-
}
|
|
587
|
-
return await res.json();
|
|
588
|
-
} finally {
|
|
589
|
-
clearTimeout(timer);
|
|
1245
|
+
parsed = PackageURL.fromString(purl);
|
|
1246
|
+
} catch {
|
|
1247
|
+
return null;
|
|
590
1248
|
}
|
|
1249
|
+
if (!parsed.version) return null;
|
|
1250
|
+
const ecosystem = purlTypeToOsvEcosystem(parsed.type);
|
|
1251
|
+
if (!ecosystem) return null;
|
|
1252
|
+
return {
|
|
1253
|
+
name: purlName(parsed),
|
|
1254
|
+
version: parsed.version,
|
|
1255
|
+
ecosystem,
|
|
1256
|
+
purl
|
|
1257
|
+
};
|
|
591
1258
|
}
|
|
592
|
-
function
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
1259
|
+
function sbomPackage(pkg, absolute, raw, needle) {
|
|
1260
|
+
return {
|
|
1261
|
+
name: pkg.name,
|
|
1262
|
+
version: pkg.version,
|
|
1263
|
+
ecosystem: pkg.ecosystem,
|
|
1264
|
+
path: `${basename3(absolute)}:${pkg.purl}`,
|
|
1265
|
+
direct: false,
|
|
1266
|
+
dev: false,
|
|
1267
|
+
optional: false,
|
|
1268
|
+
inputKind: "sbom",
|
|
1269
|
+
purl: pkg.purl,
|
|
1270
|
+
sourceFile: absolute,
|
|
1271
|
+
line: lineOf4(raw, needle),
|
|
1272
|
+
manager: "sbom"
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function purlTypeToOsvEcosystem(type) {
|
|
1276
|
+
switch (type.toLowerCase()) {
|
|
1277
|
+
case "npm":
|
|
1278
|
+
return "npm";
|
|
1279
|
+
case "pypi":
|
|
1280
|
+
return "PyPI";
|
|
1281
|
+
case "maven":
|
|
1282
|
+
return "Maven";
|
|
1283
|
+
case "gem":
|
|
1284
|
+
return "RubyGems";
|
|
1285
|
+
case "nuget":
|
|
1286
|
+
return "NuGet";
|
|
1287
|
+
case "golang":
|
|
1288
|
+
case "go":
|
|
1289
|
+
return "Go";
|
|
1290
|
+
case "cargo":
|
|
1291
|
+
return "crates.io";
|
|
1292
|
+
case "composer":
|
|
1293
|
+
return "Packagist";
|
|
1294
|
+
case "deb":
|
|
1295
|
+
return "Debian";
|
|
1296
|
+
case "apk":
|
|
1297
|
+
return "Alpine";
|
|
1298
|
+
default:
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
function purlName(purl) {
|
|
1303
|
+
if (purl.type.toLowerCase() === "npm" && purl.namespace) {
|
|
1304
|
+
const scope = purl.namespace.startsWith("@") ? purl.namespace : `@${purl.namespace}`;
|
|
1305
|
+
return `${scope}/${purl.name}`;
|
|
1306
|
+
}
|
|
1307
|
+
if (purl.type.toLowerCase() === "maven" && purl.namespace) {
|
|
1308
|
+
return `${purl.namespace}:${purl.name}`;
|
|
1309
|
+
}
|
|
1310
|
+
return purl.namespace ? `${purl.namespace}/${purl.name}` : purl.name;
|
|
1311
|
+
}
|
|
1312
|
+
function dedupe(instances) {
|
|
1313
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1314
|
+
const out = [];
|
|
1315
|
+
for (const instance of instances) {
|
|
1316
|
+
const key = instance.purl ?? `${instance.ecosystem}:${instance.name}@${instance.version}`;
|
|
1317
|
+
if (seen.has(key)) continue;
|
|
1318
|
+
seen.add(key);
|
|
1319
|
+
out.push(instance);
|
|
598
1320
|
}
|
|
599
|
-
return
|
|
1321
|
+
return out;
|
|
1322
|
+
}
|
|
1323
|
+
function arrayify(value) {
|
|
1324
|
+
if (value === void 0) return [];
|
|
1325
|
+
return Array.isArray(value) ? value : [value];
|
|
1326
|
+
}
|
|
1327
|
+
function lineOf4(raw, needle) {
|
|
1328
|
+
const idx = raw.indexOf(needle);
|
|
1329
|
+
if (idx === -1) return void 0;
|
|
1330
|
+
return raw.slice(0, idx).split(/\r?\n/).length;
|
|
1331
|
+
}
|
|
1332
|
+
function isRecord4(value) {
|
|
1333
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
600
1334
|
}
|
|
601
1335
|
|
|
602
|
-
// src/
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
if (
|
|
609
|
-
|
|
1336
|
+
// src/ignore.ts
|
|
1337
|
+
function applyIgnores(findings, ignores, now) {
|
|
1338
|
+
if (ignores.length === 0) return { active: findings, ignored: [], warnings: [] };
|
|
1339
|
+
const warnings = [];
|
|
1340
|
+
const activeIgnores = ignores.filter((entry) => {
|
|
1341
|
+
const expires = /* @__PURE__ */ new Date(`${entry.expires}T23:59:59.999Z`);
|
|
1342
|
+
if (Number.isNaN(expires.getTime()) || expires < now) {
|
|
1343
|
+
warnings.push(
|
|
1344
|
+
`Ignore for ${entry.id} expired on ${entry.expires} and was not applied.`
|
|
1345
|
+
);
|
|
1346
|
+
return false;
|
|
1347
|
+
}
|
|
1348
|
+
return true;
|
|
1349
|
+
});
|
|
1350
|
+
const active = [];
|
|
1351
|
+
const ignored = [];
|
|
1352
|
+
for (const finding of findings) {
|
|
1353
|
+
const matched = activeIgnores.some((entry) => matchesIgnore(finding, entry));
|
|
1354
|
+
if (matched) {
|
|
1355
|
+
ignored.push({ ...finding, ignored: true });
|
|
610
1356
|
} else {
|
|
611
|
-
|
|
1357
|
+
active.push(finding);
|
|
612
1358
|
}
|
|
613
1359
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1360
|
+
return { active, ignored, warnings };
|
|
1361
|
+
}
|
|
1362
|
+
function matchesIgnore(finding, entry) {
|
|
1363
|
+
const ids = /* @__PURE__ */ new Set([finding.id, ...finding.aliases]);
|
|
1364
|
+
if (!ids.has(entry.id)) return false;
|
|
1365
|
+
if (entry.package && entry.package !== finding.packageName) return false;
|
|
1366
|
+
if (entry.ecosystem && entry.ecosystem !== finding.ecosystem) return false;
|
|
1367
|
+
if (entry.version && entry.version !== finding.installedVersion) return false;
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/policy.ts
|
|
1372
|
+
var POLICY_PRESETS = {
|
|
1373
|
+
ci: {
|
|
1374
|
+
failOn: "high",
|
|
1375
|
+
risk: true,
|
|
1376
|
+
env: false,
|
|
1377
|
+
includeDev: true
|
|
1378
|
+
},
|
|
1379
|
+
strict: {
|
|
1380
|
+
failOn: "moderate",
|
|
1381
|
+
risk: true,
|
|
1382
|
+
env: true,
|
|
1383
|
+
includeDev: true
|
|
1384
|
+
},
|
|
1385
|
+
library: {
|
|
1386
|
+
failOn: "moderate",
|
|
1387
|
+
risk: true,
|
|
1388
|
+
env: false,
|
|
1389
|
+
includeDev: false
|
|
1390
|
+
},
|
|
1391
|
+
app: {
|
|
1392
|
+
failOn: "high",
|
|
1393
|
+
risk: true,
|
|
1394
|
+
env: true,
|
|
1395
|
+
includeDev: true
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
function resolvePolicy(requested, configured) {
|
|
1399
|
+
const name = requested ?? configured;
|
|
1400
|
+
return name ? POLICY_PRESETS[name] : void 0;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// src/risk.ts
|
|
1404
|
+
var REGISTRY_URL = "https://registry.npmjs.org";
|
|
1405
|
+
var REGISTRY_ENV = "TRAWLY_NPM_REGISTRY_URL";
|
|
1406
|
+
var REQUEST_TIMEOUT_MS2 = 15e3;
|
|
1407
|
+
var NEW_VERSION_DAYS = 30;
|
|
1408
|
+
var NEW_PACKAGE_DAYS = 90;
|
|
1409
|
+
var PACKUMENT_CONCURRENCY = 8;
|
|
1410
|
+
var PACKUMENT_MAX_RETRIES = 3;
|
|
1411
|
+
var PACKUMENT_BACKOFF_MS = 250;
|
|
1412
|
+
async function collectRiskSignals(packages, options) {
|
|
1413
|
+
if (!options.enabled) return { findings: [], warnings: [] };
|
|
1414
|
+
const findings = [];
|
|
1415
|
+
const warnings = [];
|
|
1416
|
+
for (const pkg of packages) {
|
|
1417
|
+
if (pkg.hasInstallScript) {
|
|
1418
|
+
findings.push(riskFinding(pkg, {
|
|
1419
|
+
id: "TRAWLY-INSTALL-SCRIPT",
|
|
1420
|
+
severity: "moderate",
|
|
1421
|
+
summary: `${pkg.name}@${pkg.version} declares install-time scripts or requires a build step.`
|
|
1422
|
+
}));
|
|
1423
|
+
}
|
|
1424
|
+
const registry = normalizeRegistry(pkg.registry);
|
|
1425
|
+
if (registry && !isAllowedRegistry(registry, options.allowedRegistries)) {
|
|
1426
|
+
findings.push(riskFinding(pkg, {
|
|
1427
|
+
id: "TRAWLY-UNEXPECTED-REGISTRY",
|
|
1428
|
+
severity: "moderate",
|
|
1429
|
+
summary: `${pkg.name}@${pkg.version} was resolved from unexpected registry ${registry}.`
|
|
1430
|
+
}));
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const npmPackageGroups = groupNpmPackages(packages);
|
|
1434
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1435
|
+
const groupsByName = /* @__PURE__ */ new Map();
|
|
1436
|
+
for (const group of npmPackageGroups) {
|
|
1437
|
+
const name = group[0]?.name;
|
|
1438
|
+
if (!name) continue;
|
|
1439
|
+
const list = groupsByName.get(name) ?? [];
|
|
1440
|
+
list.push(group);
|
|
1441
|
+
groupsByName.set(name, list);
|
|
1442
|
+
}
|
|
1443
|
+
await mapWithConcurrency2(
|
|
1444
|
+
[...groupsByName.entries()],
|
|
1445
|
+
PACKUMENT_CONCURRENCY,
|
|
1446
|
+
async ([name, groups]) => {
|
|
1447
|
+
let packument;
|
|
618
1448
|
try {
|
|
619
|
-
|
|
620
|
-
fetchImpl: options.fetchImpl
|
|
621
|
-
});
|
|
622
|
-
resolved.push({ spec, resolved: r, findings: [] });
|
|
1449
|
+
packument = await fetchPackument(fetchImpl, name);
|
|
623
1450
|
} catch (err) {
|
|
624
|
-
|
|
625
|
-
|
|
1451
|
+
warnings.push(
|
|
1452
|
+
`Could not fetch npm publish metadata for ${name}: ${err.message}`
|
|
1453
|
+
);
|
|
1454
|
+
return;
|
|
626
1455
|
}
|
|
627
|
-
|
|
1456
|
+
const createdAt = parseDate(packument.time?.created);
|
|
1457
|
+
const isNewPackage = !!createdAt && daysBetween(createdAt, options.now) < NEW_PACKAGE_DAYS;
|
|
1458
|
+
for (const group of groups) {
|
|
1459
|
+
const representative = group[0];
|
|
1460
|
+
if (!representative) continue;
|
|
1461
|
+
const versionAt = parseDate(packument.time?.[representative.version]);
|
|
1462
|
+
const deprecated = packument.versions?.[representative.version]?.deprecated;
|
|
1463
|
+
if (isNewPackage) {
|
|
1464
|
+
for (const pkg of group) {
|
|
1465
|
+
findings.push(
|
|
1466
|
+
riskFinding(pkg, {
|
|
1467
|
+
id: "TRAWLY-NEW-PACKAGE",
|
|
1468
|
+
severity: "moderate",
|
|
1469
|
+
summary: `${pkg.name} was first published less than ${NEW_PACKAGE_DAYS} days ago.`
|
|
1470
|
+
})
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (deprecated) {
|
|
1475
|
+
for (const pkg of group) {
|
|
1476
|
+
findings.push(
|
|
1477
|
+
riskFinding(pkg, {
|
|
1478
|
+
id: "TRAWLY-DEPRECATED-PACKAGE",
|
|
1479
|
+
severity: "moderate",
|
|
1480
|
+
summary: `${pkg.name}@${pkg.version} is deprecated: ${deprecated}`
|
|
1481
|
+
})
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (versionAt && daysBetween(versionAt, options.now) < NEW_VERSION_DAYS) {
|
|
1486
|
+
for (const pkg of group) {
|
|
1487
|
+
findings.push(
|
|
1488
|
+
riskFinding(pkg, {
|
|
1489
|
+
id: "TRAWLY-NEW-VERSION",
|
|
1490
|
+
severity: "low",
|
|
1491
|
+
summary: `${pkg.name}@${pkg.version} was published less than ${NEW_VERSION_DAYS} days ago.`
|
|
1492
|
+
})
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
628
1498
|
);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
1499
|
+
return { findings, warnings };
|
|
1500
|
+
}
|
|
1501
|
+
function riskFinding(pkg, input) {
|
|
1502
|
+
return {
|
|
1503
|
+
id: input.id,
|
|
1504
|
+
source: "trawly",
|
|
1505
|
+
type: "risk-signal",
|
|
1506
|
+
severity: input.severity,
|
|
1507
|
+
ecosystem: pkg.ecosystem,
|
|
1508
|
+
packageName: pkg.name,
|
|
1509
|
+
installedVersion: pkg.version,
|
|
1510
|
+
summary: input.summary,
|
|
1511
|
+
fixedVersions: [],
|
|
1512
|
+
affectedPaths: [pkg.path],
|
|
1513
|
+
fingerprint: fingerprintFinding({
|
|
1514
|
+
source: "trawly",
|
|
1515
|
+
type: "risk-signal",
|
|
1516
|
+
id: input.id,
|
|
1517
|
+
ecosystem: pkg.ecosystem,
|
|
1518
|
+
packageName: pkg.name,
|
|
1519
|
+
installedVersion: pkg.version
|
|
1520
|
+
}),
|
|
1521
|
+
aliases: [],
|
|
1522
|
+
sourceFile: pkg.sourceFile,
|
|
1523
|
+
line: pkg.line
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
function groupNpmPackages(packages) {
|
|
1527
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1528
|
+
for (const pkg of packages) {
|
|
1529
|
+
if (pkg.ecosystem !== "npm") continue;
|
|
1530
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
1531
|
+
const group = groups.get(key) ?? [];
|
|
1532
|
+
group.push(pkg);
|
|
1533
|
+
groups.set(key, group);
|
|
1534
|
+
}
|
|
1535
|
+
return [...groups.values()];
|
|
1536
|
+
}
|
|
1537
|
+
async function fetchPackument(fetchImpl, name) {
|
|
1538
|
+
const registry = (process.env[REGISTRY_ENV] ?? REGISTRY_URL).replace(/\/+$/, "");
|
|
1539
|
+
const url = `${registry}/${encodePackageName(name)}`;
|
|
1540
|
+
let lastErr;
|
|
1541
|
+
for (let attempt = 0; attempt <= PACKUMENT_MAX_RETRIES; attempt++) {
|
|
1542
|
+
const controller = new AbortController();
|
|
1543
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS2);
|
|
640
1544
|
try {
|
|
641
|
-
|
|
1545
|
+
const res = await fetchImpl(url, {
|
|
1546
|
+
signal: controller.signal,
|
|
1547
|
+
headers: { accept: "application/json" }
|
|
1548
|
+
});
|
|
1549
|
+
if (res.ok) return await res.json();
|
|
1550
|
+
const err = new RegistryHttpError(
|
|
1551
|
+
`registry ${res.status}: ${res.statusText}`,
|
|
1552
|
+
res.status,
|
|
1553
|
+
retryAfterMs2(res.headers)
|
|
1554
|
+
);
|
|
1555
|
+
if (!isRetryableRegistryError(err) || attempt === PACKUMENT_MAX_RETRIES) {
|
|
1556
|
+
throw err;
|
|
1557
|
+
}
|
|
1558
|
+
lastErr = err;
|
|
1559
|
+
await sleep(retryDelayMs(err, attempt));
|
|
642
1560
|
} catch (err) {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1561
|
+
if (err instanceof RegistryHttpError) throw err;
|
|
1562
|
+
lastErr = err;
|
|
1563
|
+
if (attempt === PACKUMENT_MAX_RETRIES) break;
|
|
1564
|
+
await sleep(retryDelayMs(void 0, attempt));
|
|
1565
|
+
} finally {
|
|
1566
|
+
clearTimeout(timer);
|
|
646
1567
|
}
|
|
647
1568
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1569
|
+
throw lastErr;
|
|
1570
|
+
}
|
|
1571
|
+
async function mapWithConcurrency2(items, concurrency, worker) {
|
|
1572
|
+
let next = 0;
|
|
1573
|
+
const workers = Array.from(
|
|
1574
|
+
{ length: Math.min(concurrency, items.length) },
|
|
1575
|
+
async () => {
|
|
1576
|
+
while (next < items.length) {
|
|
1577
|
+
const item = items[next++];
|
|
1578
|
+
if (item !== void 0) await worker(item);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
);
|
|
1582
|
+
await Promise.all(workers);
|
|
1583
|
+
}
|
|
1584
|
+
var RegistryHttpError = class extends Error {
|
|
1585
|
+
constructor(message, status, retryAfterMs3) {
|
|
1586
|
+
super(message);
|
|
1587
|
+
this.status = status;
|
|
1588
|
+
this.retryAfterMs = retryAfterMs3;
|
|
653
1589
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1590
|
+
status;
|
|
1591
|
+
retryAfterMs;
|
|
1592
|
+
};
|
|
1593
|
+
function isRetryableRegistryError(err) {
|
|
1594
|
+
return err.status === 429 || err.status >= 500;
|
|
1595
|
+
}
|
|
1596
|
+
function retryDelayMs(err, attempt) {
|
|
1597
|
+
if (err?.retryAfterMs !== void 0) return err.retryAfterMs;
|
|
1598
|
+
const base = PACKUMENT_BACKOFF_MS * 2 ** attempt;
|
|
1599
|
+
return base + Math.floor(Math.random() * Math.min(base, 100));
|
|
1600
|
+
}
|
|
1601
|
+
function retryAfterMs2(headers) {
|
|
1602
|
+
const value = headers.get("retry-after");
|
|
1603
|
+
if (!value) return void 0;
|
|
1604
|
+
const seconds = Number(value);
|
|
1605
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
1606
|
+
const date = Date.parse(value);
|
|
1607
|
+
if (Number.isNaN(date)) return void 0;
|
|
1608
|
+
return Math.max(0, date - Date.now());
|
|
1609
|
+
}
|
|
1610
|
+
function sleep(ms) {
|
|
1611
|
+
return new Promise((resolve12) => setTimeout(resolve12, ms));
|
|
1612
|
+
}
|
|
1613
|
+
function isAllowedRegistry(registry, allowed) {
|
|
1614
|
+
const normalizedAllowed = allowed.map(normalizeRegistry).filter(isString);
|
|
1615
|
+
return normalizedAllowed.includes(registry);
|
|
1616
|
+
}
|
|
1617
|
+
function normalizeRegistry(value) {
|
|
1618
|
+
if (!value) return void 0;
|
|
1619
|
+
try {
|
|
1620
|
+
const url = new URL(value);
|
|
1621
|
+
return `${url.protocol}//${url.host}`;
|
|
1622
|
+
} catch {
|
|
1623
|
+
return value.replace(/\/+$/, "");
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
function encodePackageName(name) {
|
|
1627
|
+
if (name.startsWith("@")) {
|
|
1628
|
+
const slash = name.indexOf("/");
|
|
1629
|
+
if (slash !== -1) {
|
|
1630
|
+
return `${encodeURIComponent(name.slice(0, slash))}%2F${encodeURIComponent(name.slice(slash + 1))}`;
|
|
661
1631
|
}
|
|
662
1632
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1633
|
+
return encodeURIComponent(name);
|
|
1634
|
+
}
|
|
1635
|
+
function parseDate(value) {
|
|
1636
|
+
if (!value) return void 0;
|
|
1637
|
+
const date = new Date(value);
|
|
1638
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
1639
|
+
}
|
|
1640
|
+
function daysBetween(a, b) {
|
|
1641
|
+
return (b.getTime() - a.getTime()) / 864e5;
|
|
1642
|
+
}
|
|
1643
|
+
function isString(value) {
|
|
1644
|
+
return typeof value === "string";
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/types.ts
|
|
1648
|
+
var SEVERITY_RANK = {
|
|
1649
|
+
critical: 4,
|
|
1650
|
+
high: 3,
|
|
1651
|
+
moderate: 2,
|
|
1652
|
+
low: 1,
|
|
1653
|
+
unknown: 0
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
// src/scanner.ts
|
|
1657
|
+
var DEFAULT_ALLOWED_REGISTRIES = [
|
|
1658
|
+
"https://registry.npmjs.org",
|
|
1659
|
+
"https://registry.yarnpkg.com"
|
|
1660
|
+
];
|
|
1661
|
+
async function scanProject(options = {}) {
|
|
1662
|
+
const cwd = resolve7(options.cwd ?? process.cwd());
|
|
1663
|
+
const loadedConfig = loadConfig(cwd, options.config);
|
|
1664
|
+
const policy = resolvePolicy(options.policy, loadedConfig.config.policy);
|
|
1665
|
+
const lockfilePaths = options.lockfile ? normalizePaths(cwd, options.lockfile) : detectLockfiles(cwd);
|
|
1666
|
+
const sbomPaths = normalizePaths(cwd, options.sbom);
|
|
1667
|
+
const envEnabled = options.env ?? loadedConfig.config.env ?? policy?.env ?? false;
|
|
1668
|
+
if (lockfilePaths.length === 0 && sbomPaths.length === 0 && !envEnabled) {
|
|
1669
|
+
throw new ScanInputError(
|
|
1670
|
+
`No supported lockfile or SBOM found in ${cwd}. Pass --lockfile/--sbom or run in a directory with package-lock.json, pnpm-lock.yaml, or yarn.lock.`
|
|
675
1671
|
);
|
|
676
|
-
const runner = options.runner ?? runPackageManager;
|
|
677
|
-
pmExitCode = await runner(cmd, { cwd: options.cwd });
|
|
678
1672
|
}
|
|
679
|
-
return {
|
|
1673
|
+
return scanLockfile({
|
|
1674
|
+
lockfilePath: lockfilePaths,
|
|
1675
|
+
sbom: sbomPaths,
|
|
1676
|
+
cwd,
|
|
1677
|
+
config: options.config,
|
|
1678
|
+
policy: options.policy,
|
|
1679
|
+
baseline: options.baseline,
|
|
1680
|
+
writeBaseline: options.writeBaseline,
|
|
1681
|
+
risk: options.risk ?? loadedConfig.config.risk ?? policy?.risk,
|
|
1682
|
+
env: envEnabled,
|
|
1683
|
+
allowedRegistries: options.allowedRegistries ?? loadedConfig.config.allowedRegistries,
|
|
1684
|
+
includeDev: options.includeDev ?? policy?.includeDev,
|
|
1685
|
+
prodOnly: options.prodOnly,
|
|
1686
|
+
fetchImpl: options.fetchImpl,
|
|
1687
|
+
now: options.now
|
|
1688
|
+
});
|
|
680
1689
|
}
|
|
681
|
-
function
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
1690
|
+
async function scanLockfile(options) {
|
|
1691
|
+
const initialCwd = resolve7(options.cwd ?? process.cwd());
|
|
1692
|
+
const lockfilePaths = normalizePaths(initialCwd, options.lockfilePath);
|
|
1693
|
+
const sbomPaths = normalizePaths(initialCwd, options.sbom);
|
|
1694
|
+
const cwd = options.cwd ? initialCwd : deriveCwdFromInputs(lockfilePaths, sbomPaths, initialCwd);
|
|
1695
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1696
|
+
const loadedConfig = loadConfig(cwd, options.config);
|
|
1697
|
+
const policy = resolvePolicy(options.policy, loadedConfig.config.policy);
|
|
1698
|
+
const envEnabled = options.env ?? loadedConfig.config.env ?? policy?.env ?? false;
|
|
1699
|
+
for (const path of [...lockfilePaths, ...sbomPaths]) validateFile(path);
|
|
1700
|
+
const allInstances = [
|
|
1701
|
+
...lockfilePaths.flatMap((path) => parseLockfile(path)),
|
|
1702
|
+
...sbomPaths.flatMap((path) => parseSbom(path))
|
|
1703
|
+
];
|
|
1704
|
+
const instances = filterInstances(allInstances, {
|
|
1705
|
+
...options,
|
|
1706
|
+
includeDev: options.includeDev ?? policy?.includeDev
|
|
1707
|
+
});
|
|
1708
|
+
const errors = [];
|
|
1709
|
+
const warnings = [];
|
|
1710
|
+
const envResult = envEnabled ? scanEnvFiles(cwd) : { findings: [], warnings: [], filesScanned: 0 };
|
|
1711
|
+
warnings.push(...envResult.warnings);
|
|
1712
|
+
let findings = [...envResult.findings];
|
|
1713
|
+
try {
|
|
1714
|
+
findings.push(...await queryOsv(instances, { fetchImpl: options.fetchImpl }));
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
errors.push({
|
|
1717
|
+
message: "Failed to query OSV advisory database",
|
|
1718
|
+
cause: err.message
|
|
1719
|
+
});
|
|
691
1720
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
1721
|
+
const riskEnabled = options.risk ?? loadedConfig.config.risk ?? policy?.risk ?? true;
|
|
1722
|
+
const risk = await collectRiskSignals(instances, {
|
|
1723
|
+
enabled: riskEnabled,
|
|
1724
|
+
allowedRegistries: options.allowedRegistries ?? loadedConfig.config.allowedRegistries ?? DEFAULT_ALLOWED_REGISTRIES,
|
|
1725
|
+
fetchImpl: options.fetchImpl,
|
|
1726
|
+
now
|
|
1727
|
+
});
|
|
1728
|
+
findings.push(...risk.findings);
|
|
1729
|
+
warnings.push(...risk.warnings);
|
|
1730
|
+
const ignoreResult = applyIgnores(
|
|
1731
|
+
findings,
|
|
1732
|
+
loadedConfig.config.ignore,
|
|
1733
|
+
now
|
|
1734
|
+
);
|
|
1735
|
+
warnings.push(...ignoreResult.warnings);
|
|
1736
|
+
findings = ignoreResult.active;
|
|
1737
|
+
findings.sort(compareFindings);
|
|
1738
|
+
ignoreResult.ignored.sort(compareFindings);
|
|
1739
|
+
const appliedBaseline = applyBaseline(findings, cwd, options.baseline);
|
|
1740
|
+
let baseline = appliedBaseline?.result;
|
|
1741
|
+
if (appliedBaseline) {
|
|
1742
|
+
findings = appliedBaseline.findings;
|
|
696
1743
|
}
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
const sev = summarize(b.findings);
|
|
700
|
-
const counts = describeSeverityCounts(sev);
|
|
701
|
-
lines.push(
|
|
702
|
-
kleur.red(
|
|
703
|
-
`\u2717 Blocked ${b.spec.name}@${b.resolved.version}: ${counts}`
|
|
704
|
-
)
|
|
705
|
-
);
|
|
706
|
-
if (b.resolved.source === "fallback-latest") {
|
|
707
|
-
lines.push(
|
|
708
|
-
kleur.gray(
|
|
709
|
-
` (scanned latest because we don't resolve semver ranges yet; you asked for "${b.resolved.requested}")`
|
|
710
|
-
)
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
for (const f of b.findings) {
|
|
714
|
-
lines.push(
|
|
715
|
-
` - [${colorSeverity(f.severity)}] ${f.id} : ${f.summary}`
|
|
716
|
-
);
|
|
717
|
-
if (f.fixedVersions.length > 0) {
|
|
718
|
-
lines.push(kleur.gray(` fixed in: ${f.fixedVersions.join(", ")}`));
|
|
719
|
-
}
|
|
720
|
-
if (f.url) lines.push(kleur.gray(` ${f.url}`));
|
|
721
|
-
}
|
|
722
|
-
}
|
|
1744
|
+
if (options.writeBaseline) {
|
|
1745
|
+
baseline = writeBaseline(findings, cwd, options.writeBaseline, baseline);
|
|
723
1746
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1747
|
+
return {
|
|
1748
|
+
scannedAt: now.toISOString(),
|
|
1749
|
+
packagesScanned: instances.length,
|
|
1750
|
+
findings,
|
|
1751
|
+
ignoredFindings: ignoreResult.ignored,
|
|
1752
|
+
summary: summarize(findings),
|
|
1753
|
+
errors,
|
|
1754
|
+
warnings,
|
|
1755
|
+
baseline
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
function deriveCwdFromInputs(lockfilePaths, sbomPaths, fallback) {
|
|
1759
|
+
const firstInput = lockfilePaths[0] ?? sbomPaths[0];
|
|
1760
|
+
return firstInput ? dirname3(firstInput) : fallback;
|
|
1761
|
+
}
|
|
1762
|
+
var ScanInputError = class extends Error {
|
|
1763
|
+
constructor(message) {
|
|
1764
|
+
super(message);
|
|
1765
|
+
this.name = "ScanInputError";
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
function detectLockfiles(cwd) {
|
|
1769
|
+
const candidates = [
|
|
1770
|
+
"package-lock.json",
|
|
1771
|
+
"npm-shrinkwrap.json",
|
|
1772
|
+
"pnpm-lock.yaml",
|
|
1773
|
+
"yarn.lock"
|
|
1774
|
+
].map((file) => join4(cwd, file));
|
|
1775
|
+
return candidates.filter((candidate) => existsSync4(candidate));
|
|
1776
|
+
}
|
|
1777
|
+
function filterInstances(instances, options) {
|
|
1778
|
+
const includeDev = options.prodOnly ? false : options.includeDev !== false;
|
|
1779
|
+
if (includeDev) return instances;
|
|
1780
|
+
return instances.filter((p) => !p.dev);
|
|
1781
|
+
}
|
|
1782
|
+
function compareFindings(a, b) {
|
|
1783
|
+
const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
|
|
1784
|
+
if (sev !== 0) return sev;
|
|
1785
|
+
if (a.source !== b.source) return a.source.localeCompare(b.source);
|
|
1786
|
+
if (a.packageName !== b.packageName) {
|
|
1787
|
+
return a.packageName.localeCompare(b.packageName);
|
|
1788
|
+
}
|
|
1789
|
+
if (a.installedVersion !== b.installedVersion) {
|
|
1790
|
+
return a.installedVersion.localeCompare(b.installedVersion);
|
|
1791
|
+
}
|
|
1792
|
+
return a.id.localeCompare(b.id);
|
|
1793
|
+
}
|
|
1794
|
+
function summarize(findings) {
|
|
1795
|
+
const summary = {
|
|
1796
|
+
critical: 0,
|
|
1797
|
+
high: 0,
|
|
1798
|
+
moderate: 0,
|
|
1799
|
+
low: 0,
|
|
1800
|
+
unknown: 0
|
|
1801
|
+
};
|
|
1802
|
+
for (const f of findings) summary[f.severity]++;
|
|
1803
|
+
return summary;
|
|
1804
|
+
}
|
|
1805
|
+
function meetsThreshold(findings, threshold) {
|
|
1806
|
+
if (threshold === "none") return false;
|
|
1807
|
+
const min = SEVERITY_RANK[threshold];
|
|
1808
|
+
return findings.some(
|
|
1809
|
+
(f) => f.baseline !== "existing" && SEVERITY_RANK[f.severity] >= min
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
function normalizePaths(cwd, value) {
|
|
1813
|
+
if (!value) return [];
|
|
1814
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1815
|
+
return [...new Set(values.map((path) => resolve7(cwd, path)))];
|
|
1816
|
+
}
|
|
1817
|
+
function validateFile(path) {
|
|
1818
|
+
if (!existsSync4(path)) {
|
|
1819
|
+
throw new ScanInputError(`Input file does not exist: ${path}`);
|
|
1820
|
+
}
|
|
1821
|
+
const stat = statSync2(path);
|
|
1822
|
+
if (!stat.isFile()) {
|
|
1823
|
+
throw new ScanInputError(`Input path is not a file: ${path}`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// src/installer/pm-detect.ts
|
|
1828
|
+
import { existsSync as existsSync5, readFileSync as readFileSync9 } from "fs";
|
|
1829
|
+
import { join as join5 } from "path";
|
|
1830
|
+
var LOCKFILES = [
|
|
1831
|
+
{ file: "pnpm-lock.yaml", pm: "pnpm" },
|
|
1832
|
+
{ file: "yarn.lock", pm: "yarn" },
|
|
1833
|
+
{ file: "bun.lockb", pm: "bun" },
|
|
1834
|
+
{ file: "bun.lock", pm: "bun" },
|
|
1835
|
+
{ file: "package-lock.json", pm: "npm" },
|
|
1836
|
+
{ file: "npm-shrinkwrap.json", pm: "npm" }
|
|
1837
|
+
];
|
|
1838
|
+
function detectPackageManager(opts = {}) {
|
|
1839
|
+
if (opts.override) return opts.override;
|
|
1840
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1841
|
+
const fromField = readPackageManagerField(cwd);
|
|
1842
|
+
if (fromField) return fromField;
|
|
1843
|
+
for (const { file, pm } of LOCKFILES) {
|
|
1844
|
+
if (existsSync5(join5(cwd, file))) return pm;
|
|
1845
|
+
}
|
|
1846
|
+
return "npm";
|
|
1847
|
+
}
|
|
1848
|
+
function readPackageManagerField(cwd) {
|
|
1849
|
+
const pkgPath = join5(cwd, "package.json");
|
|
1850
|
+
if (!existsSync5(pkgPath)) return void 0;
|
|
1851
|
+
try {
|
|
1852
|
+
const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
|
|
1853
|
+
if (typeof pkg.packageManager !== "string") return void 0;
|
|
1854
|
+
const name = pkg.packageManager.split("@")[0];
|
|
1855
|
+
if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") {
|
|
1856
|
+
return name;
|
|
1857
|
+
}
|
|
1858
|
+
} catch {
|
|
1859
|
+
}
|
|
1860
|
+
return void 0;
|
|
1861
|
+
}
|
|
1862
|
+
function buildAddCommand(pm, packages, flags) {
|
|
1863
|
+
switch (pm) {
|
|
1864
|
+
case "npm":
|
|
1865
|
+
return { bin: "npm", args: ["install", ...flags, ...packages] };
|
|
1866
|
+
case "pnpm":
|
|
1867
|
+
return { bin: "pnpm", args: ["add", ...flags, ...packages] };
|
|
1868
|
+
case "yarn":
|
|
1869
|
+
return { bin: "yarn", args: ["add", ...flags, ...packages] };
|
|
1870
|
+
case "bun":
|
|
1871
|
+
return { bin: "bun", args: ["add", ...flags, ...packages] };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
function buildInstallCommand(pm, flags) {
|
|
1875
|
+
return { bin: pm, args: ["install", ...flags] };
|
|
1876
|
+
}
|
|
1877
|
+
function buildRemoveCommand(pm, packages, flags) {
|
|
1878
|
+
switch (pm) {
|
|
1879
|
+
case "npm":
|
|
1880
|
+
return { bin: "npm", args: ["uninstall", ...flags, ...packages] };
|
|
1881
|
+
case "pnpm":
|
|
1882
|
+
return { bin: "pnpm", args: ["remove", ...flags, ...packages] };
|
|
1883
|
+
case "yarn":
|
|
1884
|
+
return { bin: "yarn", args: ["remove", ...flags, ...packages] };
|
|
1885
|
+
case "bun":
|
|
1886
|
+
return { bin: "bun", args: ["remove", ...flags, ...packages] };
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// src/installer/runner.ts
|
|
1891
|
+
import { spawn } from "child_process";
|
|
1892
|
+
function runPackageManager(cmd, opts = {}) {
|
|
1893
|
+
return new Promise((resolve12, reject) => {
|
|
1894
|
+
const child = spawn(cmd.bin, cmd.args, {
|
|
1895
|
+
cwd: opts.cwd,
|
|
1896
|
+
stdio: "inherit",
|
|
1897
|
+
shell: process.platform === "win32"
|
|
1898
|
+
});
|
|
1899
|
+
child.on("error", reject);
|
|
1900
|
+
child.on("close", (code) => resolve12(code ?? 0));
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// src/installer/spec-parser.ts
|
|
1905
|
+
var URL_PROTOCOLS = ["http:", "https:", "git:", "git+ssh:", "git+https:", "git+http:"];
|
|
1906
|
+
function parseSpec(raw) {
|
|
1907
|
+
const trimmed = raw.trim();
|
|
1908
|
+
if (trimmed === "") {
|
|
1909
|
+
return { raw, name: "", unsupported: "invalid" };
|
|
1910
|
+
}
|
|
1911
|
+
if (trimmed.startsWith("file:")) return { raw, name: trimmed, unsupported: "file" };
|
|
1912
|
+
if (trimmed.startsWith("workspace:")) {
|
|
1913
|
+
return { raw, name: trimmed, unsupported: "workspace" };
|
|
1914
|
+
}
|
|
1915
|
+
if (URL_PROTOCOLS.some((p) => trimmed.startsWith(p))) {
|
|
1916
|
+
const reason = trimmed.includes("git") ? "git" : "url";
|
|
1917
|
+
return { raw, name: trimmed, unsupported: reason };
|
|
1918
|
+
}
|
|
1919
|
+
if (/^[^@/].*@npm:/.test(trimmed) || /^@[^/]+\/[^@]+@npm:/.test(trimmed)) {
|
|
1920
|
+
return { raw, name: trimmed, unsupported: "alias" };
|
|
1921
|
+
}
|
|
1922
|
+
if (trimmed.startsWith("@")) {
|
|
1923
|
+
const slash = trimmed.indexOf("/");
|
|
1924
|
+
if (slash === -1) return { raw, name: trimmed, unsupported: "invalid" };
|
|
1925
|
+
const rest = trimmed.slice(slash + 1);
|
|
1926
|
+
const at2 = rest.indexOf("@");
|
|
1927
|
+
if (at2 === -1) {
|
|
1928
|
+
return { raw, name: trimmed };
|
|
1929
|
+
}
|
|
1930
|
+
const subname = rest.slice(0, at2);
|
|
1931
|
+
const requested2 = rest.slice(at2 + 1);
|
|
1932
|
+
if (subname === "" || requested2 === "") {
|
|
1933
|
+
return { raw, name: trimmed, unsupported: "invalid" };
|
|
1934
|
+
}
|
|
1935
|
+
return { raw, name: `${trimmed.slice(0, slash)}/${subname}`, requested: requested2 };
|
|
1936
|
+
}
|
|
1937
|
+
const at = trimmed.indexOf("@");
|
|
1938
|
+
if (at === -1) return { raw, name: trimmed };
|
|
1939
|
+
const name = trimmed.slice(0, at);
|
|
1940
|
+
const requested = trimmed.slice(at + 1);
|
|
1941
|
+
if (name === "" || requested === "") {
|
|
1942
|
+
return { raw, name: trimmed, unsupported: "invalid" };
|
|
1943
|
+
}
|
|
1944
|
+
return { raw, name, requested };
|
|
1945
|
+
}
|
|
1946
|
+
function partitionArgs(args) {
|
|
1947
|
+
const specs = [];
|
|
1948
|
+
const flags = [];
|
|
1949
|
+
for (const arg of args) {
|
|
1950
|
+
if (arg.startsWith("-")) {
|
|
1951
|
+
flags.push(arg);
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
specs.push(parseSpec(arg));
|
|
1955
|
+
}
|
|
1956
|
+
return { specs, flags };
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/installer/version-resolver.ts
|
|
1960
|
+
var REGISTRY_URL2 = "https://registry.npmjs.org";
|
|
1961
|
+
var REQUEST_TIMEOUT_MS3 = 15e3;
|
|
1962
|
+
var EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/;
|
|
1963
|
+
var RANGE_CHARS_RE = /[\^~><=|\s*x]/i;
|
|
1964
|
+
async function resolveVersion(name, requested, deps = {}) {
|
|
1965
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
1966
|
+
const registry = deps.registryUrl ?? REGISTRY_URL2;
|
|
1967
|
+
const packument = await fetchPackument2(fetchImpl, registry, name);
|
|
1968
|
+
const distTags = packument["dist-tags"] ?? {};
|
|
1969
|
+
const latest = distTags.latest;
|
|
1970
|
+
if (!requested) {
|
|
1971
|
+
if (!latest) {
|
|
1972
|
+
throw new VersionResolveError(
|
|
1973
|
+
`Package ${name} has no "latest" dist-tag in the registry.`
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
return { version: latest, source: "dist-tag", requested: "latest" };
|
|
1977
|
+
}
|
|
1978
|
+
if (EXACT_VERSION_RE.test(requested)) {
|
|
1979
|
+
if (packument.versions && !packument.versions[requested]) {
|
|
1980
|
+
throw new VersionResolveError(
|
|
1981
|
+
`Version ${requested} of ${name} is not published.`
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
return { version: requested, source: "exact" };
|
|
1985
|
+
}
|
|
1986
|
+
if (!RANGE_CHARS_RE.test(requested) && distTags[requested]) {
|
|
1987
|
+
return {
|
|
1988
|
+
version: distTags[requested],
|
|
1989
|
+
source: "dist-tag",
|
|
1990
|
+
requested
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
if (!latest) {
|
|
1994
|
+
throw new VersionResolveError(
|
|
1995
|
+
`Cannot resolve ${name}@${requested}: no "latest" dist-tag available to fall back on.`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
return { version: latest, source: "fallback-latest", requested };
|
|
1999
|
+
}
|
|
2000
|
+
var VersionResolveError = class extends Error {
|
|
2001
|
+
constructor(message) {
|
|
2002
|
+
super(message);
|
|
2003
|
+
this.name = "VersionResolveError";
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
async function fetchPackument2(fetchImpl, registry, name) {
|
|
2007
|
+
const url = `${registry.replace(/\/$/, "")}/${encodePackageName2(name)}`;
|
|
2008
|
+
const controller = new AbortController();
|
|
2009
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS3);
|
|
2010
|
+
try {
|
|
2011
|
+
const res = await fetchImpl(url, {
|
|
2012
|
+
signal: controller.signal,
|
|
2013
|
+
headers: { accept: "application/json" }
|
|
2014
|
+
});
|
|
2015
|
+
if (res.status === 404) {
|
|
2016
|
+
throw new VersionResolveError(`Package ${name} not found in registry.`);
|
|
2017
|
+
}
|
|
2018
|
+
if (!res.ok) {
|
|
2019
|
+
throw new VersionResolveError(
|
|
2020
|
+
`Registry ${res.status} for ${name}: ${res.statusText}`
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
return await res.json();
|
|
2024
|
+
} finally {
|
|
2025
|
+
clearTimeout(timer);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
function encodePackageName2(name) {
|
|
2029
|
+
if (name.startsWith("@")) {
|
|
2030
|
+
const slash = name.indexOf("/");
|
|
2031
|
+
if (slash !== -1) {
|
|
2032
|
+
return `${encodeURIComponent(name.slice(0, slash))}%2F${encodeURIComponent(name.slice(slash + 1))}`;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return encodeURIComponent(name);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// src/commands/add.ts
|
|
2039
|
+
async function runAdd(args, options) {
|
|
2040
|
+
const { specs, flags } = partitionArgs(args);
|
|
2041
|
+
const skipped = [];
|
|
2042
|
+
const resolvable = [];
|
|
2043
|
+
for (const spec of specs) {
|
|
2044
|
+
if (spec.unsupported) {
|
|
2045
|
+
skipped.push({ spec, reason: spec.unsupported });
|
|
2046
|
+
} else {
|
|
2047
|
+
resolvable.push(spec);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
const resolved = [];
|
|
2051
|
+
const errored = [];
|
|
2052
|
+
await Promise.all(
|
|
2053
|
+
resolvable.map(async (spec) => {
|
|
2054
|
+
try {
|
|
2055
|
+
const r = await resolveVersion(spec.name, spec.requested, {
|
|
2056
|
+
fetchImpl: options.fetchImpl
|
|
2057
|
+
});
|
|
2058
|
+
resolved.push({ spec, resolved: r, findings: [] });
|
|
2059
|
+
} catch (err) {
|
|
2060
|
+
const message = err instanceof VersionResolveError ? err.message : `Registry error: ${err.message}`;
|
|
2061
|
+
errored.push({ spec, message });
|
|
2062
|
+
}
|
|
2063
|
+
})
|
|
2064
|
+
);
|
|
2065
|
+
let findings = [];
|
|
2066
|
+
if (resolved.length > 0) {
|
|
2067
|
+
const instances = resolved.map((r) => ({
|
|
2068
|
+
name: r.spec.name,
|
|
2069
|
+
version: r.resolved.version,
|
|
2070
|
+
ecosystem: "npm",
|
|
2071
|
+
path: r.spec.raw,
|
|
2072
|
+
direct: true,
|
|
2073
|
+
dev: false,
|
|
2074
|
+
optional: false
|
|
2075
|
+
}));
|
|
2076
|
+
try {
|
|
2077
|
+
findings = await queryOsv(instances, { fetchImpl: options.fetchImpl });
|
|
2078
|
+
} catch (err) {
|
|
2079
|
+
const message = `OSV query failed: ${err.message}`;
|
|
2080
|
+
for (const r of resolved) errored.push({ spec: r.spec, message });
|
|
2081
|
+
resolved.length = 0;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
for (const f of findings) {
|
|
2085
|
+
const owner = resolved.find(
|
|
2086
|
+
(r) => r.spec.name === f.packageName && r.resolved.version === f.installedVersion
|
|
2087
|
+
);
|
|
2088
|
+
if (owner) owner.findings.push(f);
|
|
2089
|
+
}
|
|
2090
|
+
const blocked = [];
|
|
2091
|
+
const installed = [];
|
|
2092
|
+
for (const r of resolved) {
|
|
2093
|
+
if (shouldBlock(r.findings, options)) {
|
|
2094
|
+
blocked.push({ ...r, reason: "vulnerable" });
|
|
2095
|
+
} else {
|
|
2096
|
+
installed.push(r);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
findings.sort(compareFindings);
|
|
2100
|
+
let pmExitCode;
|
|
2101
|
+
if (installed.length > 0) {
|
|
2102
|
+
const pm = detectPackageManager({ override: options.pm, cwd: options.cwd });
|
|
2103
|
+
const cmd = buildAddCommand(
|
|
2104
|
+
pm,
|
|
2105
|
+
installed.map((r) => r.spec.raw),
|
|
2106
|
+
flags
|
|
2107
|
+
);
|
|
2108
|
+
process.stdout.write(
|
|
2109
|
+
kleur.gray(`> ${cmd.bin} ${cmd.args.join(" ")}
|
|
2110
|
+
`)
|
|
2111
|
+
);
|
|
2112
|
+
const runner = options.runner ?? runPackageManager;
|
|
2113
|
+
pmExitCode = await runner(cmd, { cwd: options.cwd });
|
|
2114
|
+
}
|
|
2115
|
+
return { installed, blocked, skipped, errored, findings, pmExitCode };
|
|
2116
|
+
}
|
|
2117
|
+
function reportAdd(result) {
|
|
2118
|
+
const lines = [];
|
|
2119
|
+
if (result.skipped.length > 0) {
|
|
2120
|
+
for (const s of result.skipped) {
|
|
2121
|
+
lines.push(
|
|
2122
|
+
kleur.yellow(
|
|
2123
|
+
`~ Skipped ${s.spec.raw}: ${describeUnsupported(s.reason)} (cannot scan; not forwarded to install)`
|
|
2124
|
+
)
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
if (result.errored.length > 0) {
|
|
2129
|
+
for (const e of result.errored) {
|
|
2130
|
+
lines.push(kleur.red(`! ${e.spec.raw}: ${e.message}`));
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
if (result.blocked.length > 0) {
|
|
2134
|
+
for (const b of result.blocked) {
|
|
2135
|
+
const sev = summarize(b.findings);
|
|
2136
|
+
const counts = describeSeverityCounts(sev);
|
|
2137
|
+
lines.push(
|
|
2138
|
+
kleur.red(
|
|
2139
|
+
`\u2717 Blocked ${b.spec.name}@${b.resolved.version}: ${counts}`
|
|
2140
|
+
)
|
|
2141
|
+
);
|
|
2142
|
+
if (b.resolved.source === "fallback-latest") {
|
|
2143
|
+
lines.push(
|
|
2144
|
+
kleur.gray(
|
|
2145
|
+
` (scanned latest because we don't resolve semver ranges yet; you asked for "${b.resolved.requested}")`
|
|
2146
|
+
)
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2149
|
+
for (const f of b.findings) {
|
|
2150
|
+
lines.push(
|
|
2151
|
+
` - [${colorSeverity(f.severity)}] ${f.id} : ${f.summary}`
|
|
2152
|
+
);
|
|
2153
|
+
if (f.fixedVersions.length > 0) {
|
|
2154
|
+
lines.push(kleur.gray(` fixed in: ${f.fixedVersions.join(", ")}`));
|
|
2155
|
+
}
|
|
2156
|
+
if (f.url) lines.push(kleur.gray(` ${f.url}`));
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
if (result.installed.length > 0) {
|
|
2161
|
+
const names = result.installed.map((r) => `${r.spec.name}@${r.resolved.version}`).join(", ");
|
|
2162
|
+
lines.push(kleur.green(`\u2713 Installing: ${names}`));
|
|
2163
|
+
} else if (result.blocked.length > 0) {
|
|
2164
|
+
lines.push(
|
|
2165
|
+
kleur.red("Nothing installed : all requested packages were blocked.")
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
return `${lines.join("\n")}
|
|
2169
|
+
`;
|
|
2170
|
+
}
|
|
2171
|
+
function shouldBlock(findings, options) {
|
|
2172
|
+
if (options.allowVulnerable) return false;
|
|
2173
|
+
if (options.failOn === "none") return false;
|
|
2174
|
+
const threshold = SEVERITY_RANK[options.failOn];
|
|
2175
|
+
return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold);
|
|
2176
|
+
}
|
|
2177
|
+
function describeUnsupported(reason) {
|
|
2178
|
+
switch (reason) {
|
|
2179
|
+
case "git":
|
|
2180
|
+
return "git specs cannot be scanned against OSV";
|
|
2181
|
+
case "url":
|
|
2182
|
+
return "URL specs cannot be scanned against OSV";
|
|
2183
|
+
case "file":
|
|
2184
|
+
return "local file specs cannot be scanned against OSV";
|
|
2185
|
+
case "alias":
|
|
2186
|
+
return "npm aliases are not supported in v1";
|
|
2187
|
+
case "workspace":
|
|
2188
|
+
return "workspace protocol specs are not scanned";
|
|
2189
|
+
case "invalid":
|
|
2190
|
+
return "could not parse spec";
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
function describeSeverityCounts(summary) {
|
|
2194
|
+
const parts = [];
|
|
2195
|
+
for (const sev of ["critical", "high", "moderate", "low", "unknown"]) {
|
|
2196
|
+
if (summary[sev] > 0) parts.push(`${summary[sev]} ${sev}`);
|
|
2197
|
+
}
|
|
2198
|
+
return parts.length === 0 ? "no advisories" : `${parts.join(", ")} advisor${total(summary) === 1 ? "y" : "ies"}`;
|
|
2199
|
+
}
|
|
2200
|
+
function total(summary) {
|
|
2201
|
+
return Object.values(summary).reduce((a, b) => a + b, 0);
|
|
2202
|
+
}
|
|
2203
|
+
function colorSeverity(sev) {
|
|
2204
|
+
switch (sev) {
|
|
2205
|
+
case "critical":
|
|
2206
|
+
return kleur.bold().red(sev);
|
|
2207
|
+
case "high":
|
|
2208
|
+
return kleur.red(sev);
|
|
2209
|
+
case "moderate":
|
|
2210
|
+
return kleur.yellow(sev);
|
|
2211
|
+
case "low":
|
|
2212
|
+
return kleur.cyan(sev);
|
|
2213
|
+
case "unknown":
|
|
2214
|
+
return kleur.gray(sev);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// src/env-scan.ts
|
|
2219
|
+
import { spawn as spawn2 } from "child_process";
|
|
2220
|
+
import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync10 } from "fs";
|
|
2221
|
+
import { join as join6, relative as relative2, resolve as resolve8, sep } from "path";
|
|
2222
|
+
var DEFAULT_SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
2223
|
+
"node_modules",
|
|
2224
|
+
".git",
|
|
2225
|
+
"dist",
|
|
2226
|
+
"build",
|
|
2227
|
+
".next",
|
|
2228
|
+
".nuxt",
|
|
2229
|
+
".svelte-kit",
|
|
2230
|
+
".turbo",
|
|
2231
|
+
".cache",
|
|
2232
|
+
"coverage",
|
|
2233
|
+
"out",
|
|
2234
|
+
".vercel",
|
|
2235
|
+
".output"
|
|
2236
|
+
]);
|
|
2237
|
+
var EXAMPLE_NAME = /^\.env(\.[^.]+)*\.(example|sample|template|dist)$/i;
|
|
2238
|
+
var EXAMPLE_SUFFIX_NAME = /(\.|^)(example|sample|template)$/i;
|
|
2239
|
+
async function scanEnv(options = {}) {
|
|
2240
|
+
const cwd = resolve8(options.cwd ?? process.cwd());
|
|
2241
|
+
const skipDirs = options.skipDirs ? new Set(options.skipDirs) : DEFAULT_SKIP_DIRS;
|
|
2242
|
+
const maxDepth = options.maxDepth ?? 6;
|
|
2243
|
+
const errors = [];
|
|
2244
|
+
const envFiles = discoverEnvFiles(cwd, skipDirs, maxDepth);
|
|
2245
|
+
const inGit = isGitRepo(cwd);
|
|
2246
|
+
const gitignorePath = join6(cwd, ".gitignore");
|
|
2247
|
+
const hasGitignore = existsSync6(gitignorePath);
|
|
2248
|
+
const exampleFiles = envFiles.filter(isExampleFile);
|
|
2249
|
+
const realEnvFiles = envFiles.filter((f) => !isExampleFile(f));
|
|
2250
|
+
const [trackedSet, ignoredMap, publishCheck, exampleSecrets] = await Promise.all([
|
|
2251
|
+
inGit ? gitTracked(cwd, envFiles) : Promise.resolve(/* @__PURE__ */ new Set()),
|
|
2252
|
+
inGit ? gitCheckIgnore(cwd, envFiles) : Promise.resolve(fallbackIgnoreMap(cwd, envFiles)),
|
|
2253
|
+
checkPublishExposure(cwd, realEnvFiles),
|
|
2254
|
+
scanExampleFilesForSecrets(cwd, exampleFiles)
|
|
2255
|
+
]).catch((err) => {
|
|
2256
|
+
errors.push({
|
|
2257
|
+
message: "env scan: parallel checks failed",
|
|
2258
|
+
cause: err.message
|
|
2259
|
+
});
|
|
2260
|
+
return [
|
|
2261
|
+
/* @__PURE__ */ new Set(),
|
|
2262
|
+
/* @__PURE__ */ new Map(),
|
|
2263
|
+
[],
|
|
2264
|
+
[]
|
|
2265
|
+
];
|
|
2266
|
+
});
|
|
2267
|
+
const issues = [];
|
|
2268
|
+
if (envFiles.length > 0 && !hasGitignore) {
|
|
2269
|
+
issues.push({
|
|
2270
|
+
kind: "no-gitignore",
|
|
2271
|
+
severity: "moderate",
|
|
2272
|
+
file: ".gitignore",
|
|
2273
|
+
message: "Project has env files but no .gitignore.",
|
|
2274
|
+
detail: "Add a .gitignore that includes .env and .env.* before committing."
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
for (const file of realEnvFiles) {
|
|
2278
|
+
if (trackedSet.has(file)) {
|
|
2279
|
+
issues.push({
|
|
2280
|
+
kind: "tracked-by-git",
|
|
2281
|
+
severity: "critical",
|
|
2282
|
+
file,
|
|
2283
|
+
message: `${file} is tracked by git.`,
|
|
2284
|
+
detail: "This file is committed to the repo. Run `git rm --cached` and rotate any secrets that were exposed."
|
|
2285
|
+
});
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
const ignored = ignoredMap.get(file);
|
|
2289
|
+
if (hasGitignore && ignored === false) {
|
|
2290
|
+
issues.push({
|
|
2291
|
+
kind: "not-gitignored",
|
|
2292
|
+
severity: "high",
|
|
2293
|
+
file,
|
|
2294
|
+
message: `${file} exists but is not covered by .gitignore.`,
|
|
2295
|
+
detail: "Add a matching pattern (e.g. `.env*`) to .gitignore."
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
for (const f of publishCheck) {
|
|
2300
|
+
issues.push({
|
|
2301
|
+
kind: "would-be-published",
|
|
2302
|
+
severity: "critical",
|
|
2303
|
+
file: f.file,
|
|
2304
|
+
message: `${f.file} would be included in the published npm tarball.`,
|
|
2305
|
+
detail: f.reason
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
for (const f of exampleSecrets) {
|
|
2309
|
+
issues.push({
|
|
2310
|
+
kind: "secret-in-example",
|
|
2311
|
+
severity: "high",
|
|
2312
|
+
file: f.file,
|
|
2313
|
+
message: `${f.file} appears to contain a real secret.`,
|
|
2314
|
+
detail: `Matched pattern: ${f.pattern} on key \`${f.key}\`. Example files should hold placeholder values only.`
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
issues.sort(compareIssues);
|
|
2318
|
+
return {
|
|
2319
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2320
|
+
cwd,
|
|
2321
|
+
envFiles,
|
|
2322
|
+
issues,
|
|
2323
|
+
summary: summarizeIssues(issues),
|
|
2324
|
+
errors
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
function compareIssues(a, b) {
|
|
2328
|
+
const rank = {
|
|
2329
|
+
critical: 4,
|
|
2330
|
+
high: 3,
|
|
2331
|
+
moderate: 2,
|
|
2332
|
+
low: 1,
|
|
2333
|
+
unknown: 0
|
|
2334
|
+
};
|
|
2335
|
+
const sev = rank[b.severity] - rank[a.severity];
|
|
2336
|
+
if (sev !== 0) return sev;
|
|
2337
|
+
if (a.file !== b.file) return a.file.localeCompare(b.file);
|
|
2338
|
+
return a.kind.localeCompare(b.kind);
|
|
2339
|
+
}
|
|
2340
|
+
function summarizeIssues(issues) {
|
|
2341
|
+
const out = {
|
|
2342
|
+
critical: 0,
|
|
2343
|
+
high: 0,
|
|
2344
|
+
moderate: 0,
|
|
2345
|
+
low: 0,
|
|
2346
|
+
unknown: 0
|
|
2347
|
+
};
|
|
2348
|
+
for (const i of issues) out[i.severity]++;
|
|
2349
|
+
return out;
|
|
2350
|
+
}
|
|
2351
|
+
function discoverEnvFiles(cwd, skipDirs, maxDepth) {
|
|
2352
|
+
const found = [];
|
|
2353
|
+
walk(cwd, cwd, 0);
|
|
2354
|
+
found.sort();
|
|
2355
|
+
return found;
|
|
2356
|
+
function walk(dir, root, depth) {
|
|
2357
|
+
if (depth > maxDepth) return;
|
|
2358
|
+
let entries;
|
|
2359
|
+
try {
|
|
2360
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
2361
|
+
} catch {
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
for (const entry of entries) {
|
|
2365
|
+
if (entry.isDirectory()) {
|
|
2366
|
+
if (skipDirs.has(entry.name) || entry.name.startsWith(".git")) continue;
|
|
2367
|
+
walk(join6(dir, entry.name), root, depth + 1);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
2371
|
+
if (isEnvFilename(entry.name)) {
|
|
2372
|
+
found.push(toPosix(relative2(root, join6(dir, entry.name))));
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
function isEnvFilename(name) {
|
|
2378
|
+
if (name === ".env") return true;
|
|
2379
|
+
if (name.startsWith(".env.")) return true;
|
|
2380
|
+
return false;
|
|
2381
|
+
}
|
|
2382
|
+
function isExampleFile(file) {
|
|
2383
|
+
const base = file.split("/").pop() ?? file;
|
|
2384
|
+
if (EXAMPLE_NAME.test(base)) return true;
|
|
2385
|
+
const segments = base.split(".");
|
|
2386
|
+
const last = segments[segments.length - 1] ?? "";
|
|
2387
|
+
return EXAMPLE_SUFFIX_NAME.test(last);
|
|
2388
|
+
}
|
|
2389
|
+
function toPosix(p) {
|
|
2390
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
2391
|
+
}
|
|
2392
|
+
function isGitRepo(cwd) {
|
|
2393
|
+
let dir = cwd;
|
|
2394
|
+
for (let i = 0; i < 32; i++) {
|
|
2395
|
+
if (existsSync6(join6(dir, ".git"))) return true;
|
|
2396
|
+
const parent = resolve8(dir, "..");
|
|
2397
|
+
if (parent === dir) return false;
|
|
2398
|
+
dir = parent;
|
|
2399
|
+
}
|
|
2400
|
+
return false;
|
|
2401
|
+
}
|
|
2402
|
+
async function gitTracked(cwd, files) {
|
|
2403
|
+
if (files.length === 0) return /* @__PURE__ */ new Set();
|
|
2404
|
+
const { stdout, code } = await runGit(cwd, ["ls-files", "-z", "--", ...files]);
|
|
2405
|
+
if (code !== 0) return /* @__PURE__ */ new Set();
|
|
2406
|
+
const tracked = stdout.split("\0").filter(Boolean).map(toPosix);
|
|
2407
|
+
return new Set(tracked);
|
|
2408
|
+
}
|
|
2409
|
+
async function gitCheckIgnore(cwd, files) {
|
|
2410
|
+
const result = /* @__PURE__ */ new Map();
|
|
2411
|
+
if (files.length === 0) return result;
|
|
2412
|
+
const stdin = `${files.join("\0")}\0`;
|
|
2413
|
+
const { stdout, code } = await runGit(
|
|
2414
|
+
cwd,
|
|
2415
|
+
["check-ignore", "--no-index", "-v", "-n", "-z", "--stdin"],
|
|
2416
|
+
stdin
|
|
2417
|
+
);
|
|
2418
|
+
if (code !== 0 && code !== 1) {
|
|
2419
|
+
for (const f of files) result.set(f, false);
|
|
2420
|
+
return result;
|
|
2421
|
+
}
|
|
2422
|
+
const parts = stdout.split("\0");
|
|
2423
|
+
for (let i = 0; i + 3 < parts.length; i += 4) {
|
|
2424
|
+
const source = parts[i];
|
|
2425
|
+
const pathname = toPosix(parts[i + 3] ?? "");
|
|
2426
|
+
if (!pathname) continue;
|
|
2427
|
+
result.set(pathname, source !== "");
|
|
2428
|
+
}
|
|
2429
|
+
for (const f of files) {
|
|
2430
|
+
if (!result.has(f)) result.set(f, false);
|
|
2431
|
+
}
|
|
2432
|
+
return result;
|
|
2433
|
+
}
|
|
2434
|
+
function fallbackIgnoreMap(cwd, files) {
|
|
2435
|
+
const result = /* @__PURE__ */ new Map();
|
|
2436
|
+
const patterns = readIgnoreFile(join6(cwd, ".gitignore"));
|
|
2437
|
+
for (const f of files) result.set(f, matchesAny(f, patterns));
|
|
2438
|
+
return result;
|
|
2439
|
+
}
|
|
2440
|
+
function runGit(cwd, args, stdin) {
|
|
2441
|
+
return new Promise((resolveP) => {
|
|
2442
|
+
const child = spawn2("git", args, {
|
|
2443
|
+
cwd,
|
|
2444
|
+
stdio: [stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
|
|
2445
|
+
});
|
|
2446
|
+
let stdout = "";
|
|
2447
|
+
let stderr = "";
|
|
2448
|
+
child.stdout?.on("data", (d) => {
|
|
2449
|
+
stdout += d.toString("utf8");
|
|
2450
|
+
});
|
|
2451
|
+
child.stderr?.on("data", (d) => {
|
|
2452
|
+
stderr += d.toString("utf8");
|
|
2453
|
+
});
|
|
2454
|
+
child.on("error", () => resolveP({ stdout, stderr, code: -1 }));
|
|
2455
|
+
child.on(
|
|
2456
|
+
"close",
|
|
2457
|
+
(code) => resolveP({ stdout, stderr, code: code ?? -1 })
|
|
2458
|
+
);
|
|
2459
|
+
if (stdin !== void 0 && child.stdin) {
|
|
2460
|
+
child.stdin.end(stdin);
|
|
2461
|
+
}
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
async function checkPublishExposure(cwd, envFiles) {
|
|
2465
|
+
if (envFiles.length === 0) return [];
|
|
2466
|
+
const pkgPath = join6(cwd, "package.json");
|
|
2467
|
+
if (!existsSync6(pkgPath)) return [];
|
|
2468
|
+
let pkg;
|
|
2469
|
+
try {
|
|
2470
|
+
pkg = JSON.parse(readFileSync10(pkgPath, "utf8"));
|
|
2471
|
+
} catch {
|
|
2472
|
+
return [];
|
|
2473
|
+
}
|
|
2474
|
+
if (pkg.private === true) return [];
|
|
2475
|
+
const findings = [];
|
|
2476
|
+
if (Array.isArray(pkg.files)) {
|
|
2477
|
+
const allowList = pkg.files.filter((x) => typeof x === "string");
|
|
2478
|
+
for (const file of envFiles) {
|
|
2479
|
+
if (matchesAny(file, allowList)) {
|
|
2480
|
+
findings.push({
|
|
2481
|
+
file,
|
|
2482
|
+
reason: `package.json "files" allowlist matches this path. Remove the entry or move the file.`
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return findings;
|
|
2487
|
+
}
|
|
2488
|
+
const npmignorePath = join6(cwd, ".npmignore");
|
|
2489
|
+
const ignorePath = existsSync6(npmignorePath) ? npmignorePath : join6(cwd, ".gitignore");
|
|
2490
|
+
const ignoreSource = existsSync6(ignorePath) ? ignorePath === npmignorePath ? ".npmignore" : ".gitignore" : null;
|
|
2491
|
+
const patterns = ignoreSource ? readIgnoreFile(ignorePath) : [];
|
|
2492
|
+
for (const file of envFiles) {
|
|
2493
|
+
if (!matchesAny(file, patterns)) {
|
|
2494
|
+
findings.push({
|
|
2495
|
+
file,
|
|
2496
|
+
reason: ignoreSource ? `Not matched by any pattern in ${ignoreSource}. Add an entry like \`.env*\`.` : `No .npmignore or .gitignore present, so npm will include this file in the published tarball. Add an .npmignore.`
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
return findings;
|
|
2501
|
+
}
|
|
2502
|
+
function readIgnoreFile(path) {
|
|
2503
|
+
if (!existsSync6(path)) return [];
|
|
2504
|
+
try {
|
|
2505
|
+
return readFileSync10(path, "utf8").split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("#"));
|
|
2506
|
+
} catch {
|
|
2507
|
+
return [];
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
function matchesAny(file, patterns) {
|
|
2511
|
+
let matched = false;
|
|
2512
|
+
for (const raw of patterns) {
|
|
2513
|
+
let pattern = raw;
|
|
2514
|
+
let negate = false;
|
|
2515
|
+
if (pattern.startsWith("!")) {
|
|
2516
|
+
negate = true;
|
|
2517
|
+
pattern = pattern.slice(1);
|
|
2518
|
+
}
|
|
2519
|
+
if (pattern.endsWith("/")) pattern = pattern.slice(0, -1);
|
|
2520
|
+
if (matchesPattern(file, pattern)) matched = !negate;
|
|
2521
|
+
}
|
|
2522
|
+
return matched;
|
|
2523
|
+
}
|
|
2524
|
+
function matchesPattern(file, pattern) {
|
|
2525
|
+
if (!pattern) return false;
|
|
2526
|
+
const anchored = pattern.startsWith("/");
|
|
2527
|
+
const pat = anchored ? pattern.slice(1) : pattern;
|
|
2528
|
+
const hasSlash = pat.includes("/");
|
|
2529
|
+
const candidates = anchored ? [file] : hasSlash ? [file] : [file, file.split("/").pop() ?? file];
|
|
2530
|
+
const re = globToRegex(pat);
|
|
2531
|
+
return candidates.some((c) => re.test(c));
|
|
2532
|
+
}
|
|
2533
|
+
function globToRegex(pattern) {
|
|
2534
|
+
let re = "^";
|
|
2535
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
2536
|
+
const ch = pattern[i];
|
|
2537
|
+
if (ch === "*") {
|
|
2538
|
+
if (pattern[i + 1] === "*") {
|
|
2539
|
+
re += ".*";
|
|
2540
|
+
i++;
|
|
2541
|
+
if (pattern[i + 1] === "/") i++;
|
|
2542
|
+
} else {
|
|
2543
|
+
re += "[^/]*";
|
|
2544
|
+
}
|
|
2545
|
+
} else if (ch === "?") {
|
|
2546
|
+
re += "[^/]";
|
|
2547
|
+
} else if (ch === ".") {
|
|
2548
|
+
re += "\\.";
|
|
2549
|
+
} else if (/[\\^$+()=!|{}[\]]/.test(ch ?? "")) {
|
|
2550
|
+
re += `\\${ch}`;
|
|
2551
|
+
} else {
|
|
2552
|
+
re += ch;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
re += "$";
|
|
2556
|
+
return new RegExp(re);
|
|
2557
|
+
}
|
|
2558
|
+
var SECRET_PATTERNS = [
|
|
2559
|
+
{ name: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
2560
|
+
{ name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
|
|
2561
|
+
{ name: "Slack token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
2562
|
+
{ name: "Stripe live key", re: /\bsk_live_[A-Za-z0-9]{16,}\b/ },
|
|
2563
|
+
{ name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
2564
|
+
{ name: "Generic JWT", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
2565
|
+
{ name: "Private key block", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ }
|
|
2566
|
+
];
|
|
2567
|
+
var PLACEHOLDER_RE2 = /^(?:|x+|y+|<.*>|\{.*\}|change[-_ ]?me|todo|placeholder|your[-_ ].+|example|dummy|fake|test)$/i;
|
|
2568
|
+
async function scanExampleFilesForSecrets(cwd, files) {
|
|
2569
|
+
const findings = [];
|
|
2570
|
+
await Promise.all(
|
|
2571
|
+
files.map(async (file) => {
|
|
2572
|
+
let content;
|
|
2573
|
+
try {
|
|
2574
|
+
content = readFileSync10(join6(cwd, file), "utf8");
|
|
2575
|
+
} catch {
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
const lines = content.split(/\r?\n/);
|
|
2579
|
+
for (const line of lines) {
|
|
2580
|
+
const trimmed = line.trim();
|
|
2581
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
2582
|
+
const eq = trimmed.indexOf("=");
|
|
2583
|
+
if (eq <= 0) continue;
|
|
2584
|
+
const key = trimmed.slice(0, eq).trim();
|
|
2585
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
2586
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2587
|
+
value = value.slice(1, -1);
|
|
2588
|
+
} else {
|
|
2589
|
+
const hashIdx = value.indexOf(" #");
|
|
2590
|
+
if (hashIdx >= 0) value = value.slice(0, hashIdx).trim();
|
|
2591
|
+
}
|
|
2592
|
+
if (!value || PLACEHOLDER_RE2.test(value)) continue;
|
|
2593
|
+
for (const pat of SECRET_PATTERNS) {
|
|
2594
|
+
if (pat.re.test(value)) {
|
|
2595
|
+
findings.push({ file, key, pattern: pat.name });
|
|
2596
|
+
break;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
})
|
|
2601
|
+
);
|
|
2602
|
+
return findings;
|
|
2603
|
+
}
|
|
2604
|
+
function envIssuesMeetThreshold(issues, threshold) {
|
|
2605
|
+
if (threshold === "none") return false;
|
|
2606
|
+
const rank = {
|
|
2607
|
+
critical: 4,
|
|
2608
|
+
high: 3,
|
|
2609
|
+
moderate: 2,
|
|
2610
|
+
low: 1,
|
|
2611
|
+
unknown: 0
|
|
2612
|
+
};
|
|
2613
|
+
const min = rank[threshold];
|
|
2614
|
+
return issues.some((i) => rank[i.severity] >= min);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// src/init.ts
|
|
2618
|
+
import { existsSync as existsSync7, writeFileSync as writeFileSync2 } from "fs";
|
|
2619
|
+
import { resolve as resolve9 } from "path";
|
|
2620
|
+
async function initProject(options = {}) {
|
|
2621
|
+
const cwd = resolve9(options.cwd ?? process.cwd());
|
|
2622
|
+
const policy = options.policy ?? "ci";
|
|
2623
|
+
const configPath = resolve9(cwd, options.config ?? "trawly.toml");
|
|
2624
|
+
const baselinePath = options.baseline ?? "trawly-baseline.json";
|
|
2625
|
+
const warnings = [];
|
|
2626
|
+
let configWritten = false;
|
|
2627
|
+
if (options.overwrite || !existsSync7(configPath)) {
|
|
2628
|
+
writeFileSync2(configPath, renderConfig(policy, baselinePath));
|
|
2629
|
+
configWritten = true;
|
|
2630
|
+
} else {
|
|
2631
|
+
warnings.push(`${configPath} already exists; leaving it unchanged.`);
|
|
2632
|
+
}
|
|
2633
|
+
let scan;
|
|
2634
|
+
let baselineWritten = false;
|
|
2635
|
+
if (options.writeBaseline !== false) {
|
|
2636
|
+
try {
|
|
2637
|
+
scan = await scanProject({
|
|
2638
|
+
cwd,
|
|
2639
|
+
config: configPath,
|
|
2640
|
+
policy,
|
|
2641
|
+
risk: options.risk,
|
|
2642
|
+
env: options.env,
|
|
2643
|
+
writeBaseline: baselinePath,
|
|
2644
|
+
fetchImpl: options.fetchImpl
|
|
2645
|
+
});
|
|
2646
|
+
baselineWritten = scan.baseline?.written !== void 0;
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
if (err instanceof ScanInputError) {
|
|
2649
|
+
warnings.push(
|
|
2650
|
+
"No supported lockfile or SBOM was found, so no baseline was written."
|
|
2651
|
+
);
|
|
2652
|
+
} else {
|
|
2653
|
+
throw err;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
return {
|
|
2658
|
+
configPath,
|
|
2659
|
+
configWritten,
|
|
2660
|
+
baselinePath: resolve9(cwd, baselinePath),
|
|
2661
|
+
baselineWritten,
|
|
2662
|
+
scan,
|
|
2663
|
+
warnings
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
function renderConfig(policy, baselinePath) {
|
|
2667
|
+
const preset = POLICY_PRESETS[policy];
|
|
2668
|
+
return [
|
|
2669
|
+
`policy = "${policy}"`,
|
|
2670
|
+
`failOn = "${preset.failOn}"`,
|
|
2671
|
+
`risk = ${String(preset.risk)}`,
|
|
2672
|
+
`env = ${String(preset.env)}`,
|
|
2673
|
+
'allowedRegistries = ["https://registry.npmjs.org", "https://registry.yarnpkg.com"]',
|
|
2674
|
+
"",
|
|
2675
|
+
`# Existing findings are tracked in ${baselinePath}.`,
|
|
2676
|
+
"# Ignore entries must expire.",
|
|
2677
|
+
"# [[ignore]]",
|
|
2678
|
+
'# id = "GHSA-example"',
|
|
2679
|
+
'# package = "example-package"',
|
|
2680
|
+
'# expires = "2026-06-30"',
|
|
2681
|
+
'# reason = "Not reachable in this application"',
|
|
2682
|
+
""
|
|
2683
|
+
].join("\n");
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// src/reporters/env-table.ts
|
|
2687
|
+
import kleur3 from "kleur";
|
|
2688
|
+
|
|
2689
|
+
// src/reporters/banner.ts
|
|
2690
|
+
import kleur2 from "kleur";
|
|
2691
|
+
var BORDER = {
|
|
2692
|
+
tl: "\u250C",
|
|
2693
|
+
tr: "\u2510",
|
|
2694
|
+
bl: "\u2514",
|
|
2695
|
+
br: "\u2518",
|
|
2696
|
+
h: "\u2500",
|
|
2697
|
+
v: "\u2502"
|
|
2698
|
+
};
|
|
2699
|
+
var TITLE = "trawly";
|
|
2700
|
+
var SIDE_PAD = 1;
|
|
2701
|
+
var MIN_TITLE_FILLER = 4;
|
|
2702
|
+
function renderBanner(props) {
|
|
2703
|
+
const contentRows = [props.metrics, props.timestamp];
|
|
2704
|
+
const titleSegment = ` ${TITLE} `;
|
|
2705
|
+
const minTitleWidth = 1 + titleSegment.length + MIN_TITLE_FILLER;
|
|
2706
|
+
const innerWidth = Math.max(
|
|
2707
|
+
...contentRows.map((r) => r.length + SIDE_PAD * 2),
|
|
2708
|
+
minTitleWidth
|
|
2709
|
+
);
|
|
2710
|
+
const top = renderTop(innerWidth);
|
|
2711
|
+
const bottom = renderBottom(innerWidth);
|
|
2712
|
+
const metricsLine = renderRow(innerWidth, props.metrics, kleur2.bold);
|
|
2713
|
+
const timestampLine = renderRow(innerWidth, props.timestamp, kleur2.gray);
|
|
2714
|
+
return [top, metricsLine, timestampLine, bottom].join("\n");
|
|
2715
|
+
}
|
|
2716
|
+
function renderTop(innerWidth) {
|
|
2717
|
+
const titleSegment = ` ${kleur2.bold().cyan(TITLE)} `;
|
|
2718
|
+
const titleVisibleLen = TITLE.length + 2;
|
|
2719
|
+
const fillerCount = innerWidth - 1 - titleVisibleLen;
|
|
2720
|
+
return kleur2.gray(BORDER.tl) + kleur2.gray(BORDER.h) + titleSegment + kleur2.gray(BORDER.h.repeat(Math.max(MIN_TITLE_FILLER, fillerCount))) + kleur2.gray(BORDER.tr);
|
|
2721
|
+
}
|
|
2722
|
+
function renderBottom(innerWidth) {
|
|
2723
|
+
return kleur2.gray(`${BORDER.bl}${BORDER.h.repeat(innerWidth)}${BORDER.br}`);
|
|
2724
|
+
}
|
|
2725
|
+
function renderRow(innerWidth, content, colorize) {
|
|
2726
|
+
const fill = Math.max(0, innerWidth - SIDE_PAD * 2 - content.length);
|
|
2727
|
+
return kleur2.gray(BORDER.v) + " ".repeat(SIDE_PAD) + colorize(content) + " ".repeat(fill + SIDE_PAD) + kleur2.gray(BORDER.v);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// src/reporters/env-table.ts
|
|
2731
|
+
var SEVERITY_COLOR = {
|
|
2732
|
+
critical: (s) => kleur3.bold().red(s),
|
|
2733
|
+
high: (s) => kleur3.red(s),
|
|
2734
|
+
moderate: (s) => kleur3.yellow(s),
|
|
2735
|
+
low: (s) => kleur3.cyan(s),
|
|
2736
|
+
unknown: (s) => kleur3.gray(s)
|
|
2737
|
+
};
|
|
2738
|
+
var SEVERITY_ORDER = [
|
|
2739
|
+
"critical",
|
|
2740
|
+
"high",
|
|
2741
|
+
"moderate",
|
|
2742
|
+
"low",
|
|
2743
|
+
"unknown"
|
|
2744
|
+
];
|
|
2745
|
+
var KIND_LABEL = {
|
|
2746
|
+
"tracked-by-git": "tracked-by-git",
|
|
2747
|
+
"not-gitignored": "not-gitignored",
|
|
2748
|
+
"would-be-published": "would-be-published",
|
|
2749
|
+
"secret-in-example": "secret-in-example",
|
|
2750
|
+
"no-gitignore": "no-gitignore"
|
|
2751
|
+
};
|
|
2752
|
+
function reportEnvTable(result, options = {}) {
|
|
2753
|
+
const lines = [];
|
|
2754
|
+
if (options.brand) {
|
|
2755
|
+
const { metricsLine, timestamp } = headerParts(result);
|
|
2756
|
+
lines.push(renderBanner({ metrics: metricsLine, timestamp }));
|
|
2757
|
+
} else {
|
|
2758
|
+
lines.push(kleur3.bold(formatHeader(result)));
|
|
2759
|
+
}
|
|
2760
|
+
for (const err of result.errors) {
|
|
2761
|
+
lines.push(
|
|
2762
|
+
kleur3.red(`! ${err.message}${err.cause ? ` (${err.cause})` : ""}`)
|
|
2763
|
+
);
|
|
2764
|
+
}
|
|
2765
|
+
if (result.envFiles.length === 0) {
|
|
2766
|
+
lines.push("");
|
|
2767
|
+
lines.push(kleur3.gray("No .env files found in project."));
|
|
2768
|
+
return lines.join("\n");
|
|
2769
|
+
}
|
|
2770
|
+
if (result.issues.length === 0) {
|
|
2771
|
+
lines.push("");
|
|
2772
|
+
lines.push(kleur3.green("\u2713 No env-leak issues found."));
|
|
2773
|
+
lines.push(kleur3.gray(" Files checked:"));
|
|
2774
|
+
for (const f of result.envFiles) lines.push(kleur3.gray(` ${f}`));
|
|
2775
|
+
return lines.join("\n");
|
|
2776
|
+
}
|
|
2777
|
+
lines.push("");
|
|
2778
|
+
lines.push(formatSummary(result.summary));
|
|
2779
|
+
lines.push("");
|
|
2780
|
+
lines.push(formatIssueRows(result.issues));
|
|
2781
|
+
lines.push("");
|
|
2782
|
+
lines.push(
|
|
2783
|
+
kleur3.gray(
|
|
2784
|
+
"Reminder: trawly env checks ignore coverage and publish exposure. It cannot prove a secret hasn't already leaked elsewhere \u2014 rotate any value you suspect was committed."
|
|
2785
|
+
)
|
|
2786
|
+
);
|
|
2787
|
+
return lines.join("\n");
|
|
2788
|
+
}
|
|
2789
|
+
function formatIssueRows(issues) {
|
|
2790
|
+
const rows = [["SEV", "KIND", "FILE", "MESSAGE", "HINT"]];
|
|
2791
|
+
for (const issue of issues) {
|
|
2792
|
+
rows.push([
|
|
2793
|
+
issue.severity,
|
|
2794
|
+
KIND_LABEL[issue.kind],
|
|
2795
|
+
issue.file,
|
|
2796
|
+
truncate2(issue.message, 60),
|
|
2797
|
+
issue.detail ? truncate2(issue.detail, 60) : ":"
|
|
2798
|
+
]);
|
|
2799
|
+
}
|
|
2800
|
+
return renderTable(rows, (rowIdx, row, cells) => {
|
|
2801
|
+
if (rowIdx === 0) return kleur3.bold().underline(cells.join(" "));
|
|
2802
|
+
const sev = row[0];
|
|
2803
|
+
const colorize = SEVERITY_COLOR[sev] ?? ((s) => s);
|
|
2804
|
+
cells[0] = colorize(cells[0]);
|
|
2805
|
+
cells[2] = kleur3.cyan(cells[2]);
|
|
2806
|
+
cells[1] = kleur3.bold(cells[1]);
|
|
2807
|
+
return cells.join(" ");
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
function formatHeader(result) {
|
|
2811
|
+
const { metricsLine, timestamp } = headerParts(result);
|
|
2812
|
+
return `trawly env: ${metricsLine} (${timestamp})`;
|
|
2813
|
+
}
|
|
2814
|
+
function headerParts(result) {
|
|
2815
|
+
const fileCount = result.envFiles.length;
|
|
2816
|
+
const issueCount = result.issues.length;
|
|
2817
|
+
const metricsLine = [
|
|
2818
|
+
`${fileCount} env file${fileCount === 1 ? "" : "s"} scanned`,
|
|
2819
|
+
`${issueCount} issue${issueCount === 1 ? "" : "s"}`
|
|
2820
|
+
].join(" \xB7 ");
|
|
2821
|
+
return { metricsLine, timestamp: result.scannedAt };
|
|
2822
|
+
}
|
|
2823
|
+
function formatSummary(summary) {
|
|
2824
|
+
const parts = [];
|
|
2825
|
+
for (const sev of SEVERITY_ORDER) {
|
|
2826
|
+
const count = summary[sev];
|
|
2827
|
+
if (count === 0) continue;
|
|
2828
|
+
parts.push(SEVERITY_COLOR[sev](`${sev}: ${count}`));
|
|
2829
|
+
}
|
|
2830
|
+
if (parts.length === 0) return kleur3.green("No findings.");
|
|
2831
|
+
return `Issues : ${parts.join(" ")}`;
|
|
2832
|
+
}
|
|
2833
|
+
function reportEnvJson(result) {
|
|
2834
|
+
return JSON.stringify(result, null, 2);
|
|
2835
|
+
}
|
|
2836
|
+
function truncate2(s, max) {
|
|
2837
|
+
if (s.length <= max) return s;
|
|
2838
|
+
return `${s.slice(0, max - 1)}\u2026`;
|
|
2839
|
+
}
|
|
2840
|
+
function renderTable(rows, format) {
|
|
2841
|
+
const widths = rows[0].map(
|
|
2842
|
+
(_, col) => Math.max(...rows.map((r) => visibleLength(r[col])))
|
|
2843
|
+
);
|
|
2844
|
+
return rows.map((row, i) => {
|
|
2845
|
+
const cells = row.map((cell, col) => padEndVisible(cell, widths[col]));
|
|
2846
|
+
return format(i, row, cells);
|
|
2847
|
+
}).join("\n");
|
|
2848
|
+
}
|
|
2849
|
+
var ANSI_RE = /\u001B\[[0-9;]*m/g;
|
|
2850
|
+
function visibleLength(s) {
|
|
2851
|
+
return s.replace(ANSI_RE, "").length;
|
|
2852
|
+
}
|
|
2853
|
+
function padEndVisible(s, width) {
|
|
2854
|
+
const pad = Math.max(0, width - visibleLength(s));
|
|
2855
|
+
return s + " ".repeat(pad);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
// src/reporters/json.ts
|
|
2859
|
+
function reportJson(result) {
|
|
2860
|
+
return JSON.stringify(result, null, 2);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// src/reporters/markdown.ts
|
|
2864
|
+
var ORDER = ["critical", "high", "moderate", "low", "unknown"];
|
|
2865
|
+
function reportMarkdown(result) {
|
|
2866
|
+
const lines = [];
|
|
2867
|
+
lines.push("# trawly report");
|
|
2868
|
+
lines.push("");
|
|
2869
|
+
lines.push(`Scanned at: \`${result.scannedAt}\``);
|
|
2870
|
+
lines.push(`Packages scanned: **${result.packagesScanned}**`);
|
|
2871
|
+
lines.push(`Findings: **${result.findings.length}**`);
|
|
2872
|
+
if (result.ignoredFindings.length > 0) {
|
|
2873
|
+
lines.push(`Ignored findings: **${result.ignoredFindings.length}**`);
|
|
2874
|
+
}
|
|
2875
|
+
if (result.baseline) {
|
|
2876
|
+
lines.push(
|
|
2877
|
+
`Baseline: **${result.baseline.new} new**, **${result.baseline.existing} existing**`
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
lines.push("");
|
|
2881
|
+
lines.push(`Severity summary: ${formatSummary2(result.summary)}`);
|
|
2882
|
+
if (result.warnings.length > 0) {
|
|
2883
|
+
lines.push("");
|
|
2884
|
+
lines.push("## Warnings");
|
|
2885
|
+
for (const warning of result.warnings) lines.push(`- ${warning}`);
|
|
2886
|
+
}
|
|
2887
|
+
lines.push("");
|
|
2888
|
+
lines.push("## Findings");
|
|
2889
|
+
if (result.findings.length === 0) {
|
|
2890
|
+
lines.push("");
|
|
2891
|
+
lines.push("No active findings.");
|
|
2892
|
+
} else {
|
|
2893
|
+
lines.push("");
|
|
2894
|
+
lines.push("| Severity | Source | Package | Version | ID | Summary |");
|
|
2895
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
2896
|
+
for (const finding of result.findings) {
|
|
2897
|
+
lines.push(findingRow(finding));
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
if (result.ignoredFindings.length > 0) {
|
|
2901
|
+
lines.push("");
|
|
2902
|
+
lines.push("## Ignored Findings");
|
|
2903
|
+
lines.push("");
|
|
2904
|
+
lines.push("| Severity | Source | Package | Version | ID | Summary |");
|
|
2905
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
2906
|
+
for (const finding of result.ignoredFindings) {
|
|
2907
|
+
lines.push(findingRow(finding));
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
return lines.join("\n");
|
|
2911
|
+
}
|
|
2912
|
+
function formatSummary2(summary) {
|
|
2913
|
+
const parts = ORDER.filter((sev) => summary[sev] > 0).map(
|
|
2914
|
+
(sev) => `${sev}: ${summary[sev]}`
|
|
2915
|
+
);
|
|
2916
|
+
return parts.length === 0 ? "none" : parts.join(", ");
|
|
2917
|
+
}
|
|
2918
|
+
function findingRow(finding) {
|
|
2919
|
+
const id = finding.url ? `[${escapeCell(finding.id)}](${finding.url})` : escapeCell(finding.id);
|
|
2920
|
+
return [
|
|
2921
|
+
finding.severity,
|
|
2922
|
+
finding.source,
|
|
2923
|
+
escapeCell(finding.packageName),
|
|
2924
|
+
escapeCell(finding.installedVersion),
|
|
2925
|
+
id,
|
|
2926
|
+
escapeCell(finding.summary)
|
|
2927
|
+
].join(" | ").replace(/^/, "| ").replace(/$/, " |");
|
|
2928
|
+
}
|
|
2929
|
+
function escapeCell(value) {
|
|
2930
|
+
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
// src/version.ts
|
|
2934
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
2935
|
+
var FALLBACK_VERSION = "0.1.0";
|
|
2936
|
+
var TRAWLY_VERSION = readPackageVersion();
|
|
2937
|
+
function readPackageVersion() {
|
|
2938
|
+
try {
|
|
2939
|
+
const packageJson = JSON.parse(
|
|
2940
|
+
readFileSync11(new URL("../package.json", import.meta.url), "utf8")
|
|
2941
|
+
);
|
|
2942
|
+
return typeof packageJson.version === "string" ? packageJson.version : FALLBACK_VERSION;
|
|
2943
|
+
} catch {
|
|
2944
|
+
return FALLBACK_VERSION;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
// src/reporters/sarif.ts
|
|
2949
|
+
function reportSarif(result) {
|
|
2950
|
+
const allFindings = [...result.findings, ...result.ignoredFindings];
|
|
2951
|
+
const rules = buildRules(allFindings);
|
|
2952
|
+
const sarif = {
|
|
2953
|
+
version: "2.1.0",
|
|
2954
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
2955
|
+
runs: [
|
|
2956
|
+
{
|
|
2957
|
+
tool: {
|
|
2958
|
+
driver: {
|
|
2959
|
+
name: "trawly",
|
|
2960
|
+
informationUri: "https://github.com/Arindam200/trawly",
|
|
2961
|
+
semanticVersion: TRAWLY_VERSION,
|
|
2962
|
+
rules
|
|
2963
|
+
}
|
|
2964
|
+
},
|
|
2965
|
+
results: allFindings.map((finding) => findingToResult(finding))
|
|
2966
|
+
}
|
|
2967
|
+
]
|
|
2968
|
+
};
|
|
2969
|
+
return JSON.stringify(sarif, null, 2);
|
|
2970
|
+
}
|
|
2971
|
+
function buildRules(findings) {
|
|
2972
|
+
const map = /* @__PURE__ */ new Map();
|
|
2973
|
+
for (const finding of findings) {
|
|
2974
|
+
if (map.has(finding.id)) continue;
|
|
2975
|
+
map.set(finding.id, {
|
|
2976
|
+
id: finding.id,
|
|
2977
|
+
name: finding.id,
|
|
2978
|
+
shortDescription: { text: finding.summary },
|
|
2979
|
+
fullDescription: { text: finding.summary },
|
|
2980
|
+
helpUri: finding.url,
|
|
2981
|
+
properties: {
|
|
2982
|
+
tags: [finding.source, finding.type, finding.ecosystem],
|
|
2983
|
+
precision: finding.source === "osv" ? "high" : "medium",
|
|
2984
|
+
securitySeverity: securitySeverity(finding)
|
|
2985
|
+
}
|
|
2986
|
+
});
|
|
731
2987
|
}
|
|
732
|
-
return
|
|
733
|
-
|
|
2988
|
+
return [...map.values()];
|
|
2989
|
+
}
|
|
2990
|
+
function findingToResult(finding) {
|
|
2991
|
+
const result = {
|
|
2992
|
+
ruleId: finding.id,
|
|
2993
|
+
level: sarifLevel(finding),
|
|
2994
|
+
message: {
|
|
2995
|
+
text: `${finding.packageName}@${finding.installedVersion}: ${finding.summary}`
|
|
2996
|
+
},
|
|
2997
|
+
locations: [
|
|
2998
|
+
{
|
|
2999
|
+
physicalLocation: {
|
|
3000
|
+
artifactLocation: {
|
|
3001
|
+
uri: artifactUri(finding)
|
|
3002
|
+
},
|
|
3003
|
+
region: finding.line ? { startLine: finding.line } : void 0
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
],
|
|
3007
|
+
partialFingerprints: {
|
|
3008
|
+
trawlyFingerprint: finding.fingerprint
|
|
3009
|
+
},
|
|
3010
|
+
properties: {
|
|
3011
|
+
package: finding.packageName,
|
|
3012
|
+
version: finding.installedVersion,
|
|
3013
|
+
ecosystem: finding.ecosystem,
|
|
3014
|
+
source: finding.source,
|
|
3015
|
+
aliases: finding.aliases,
|
|
3016
|
+
baseline: finding.baseline
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
if (finding.ignored) {
|
|
3020
|
+
result.suppressions = [
|
|
3021
|
+
{ kind: "external", justification: "Ignored by trawly configuration" }
|
|
3022
|
+
];
|
|
3023
|
+
}
|
|
3024
|
+
return result;
|
|
734
3025
|
}
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
if (
|
|
738
|
-
const
|
|
739
|
-
|
|
3026
|
+
function artifactUri(finding) {
|
|
3027
|
+
const path = finding.sourceFile ?? finding.affectedPaths[0];
|
|
3028
|
+
if (path) return path;
|
|
3029
|
+
const ecosystem = encodeURIComponent(finding.ecosystem || "unknown");
|
|
3030
|
+
const name = encodeURIComponent(finding.packageName || "unknown");
|
|
3031
|
+
const version = encodeURIComponent(finding.installedVersion || "unknown");
|
|
3032
|
+
return `pkg:${ecosystem}/${name}@${version}`;
|
|
740
3033
|
}
|
|
741
|
-
function
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
return "git specs cannot be scanned against OSV";
|
|
745
|
-
case "url":
|
|
746
|
-
return "URL specs cannot be scanned against OSV";
|
|
747
|
-
case "file":
|
|
748
|
-
return "local file specs cannot be scanned against OSV";
|
|
749
|
-
case "alias":
|
|
750
|
-
return "npm aliases are not supported in v1";
|
|
751
|
-
case "workspace":
|
|
752
|
-
return "workspace protocol specs are not scanned";
|
|
753
|
-
case "invalid":
|
|
754
|
-
return "could not parse spec";
|
|
3034
|
+
function sarifLevel(finding) {
|
|
3035
|
+
if (finding.severity === "critical" || finding.severity === "high") {
|
|
3036
|
+
return "error";
|
|
755
3037
|
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const parts = [];
|
|
759
|
-
for (const sev of ["critical", "high", "moderate", "low", "unknown"]) {
|
|
760
|
-
if (summary[sev] > 0) parts.push(`${summary[sev]} ${sev}`);
|
|
3038
|
+
if (finding.severity === "moderate" || finding.severity === "low") {
|
|
3039
|
+
return "warning";
|
|
761
3040
|
}
|
|
762
|
-
return
|
|
763
|
-
}
|
|
764
|
-
function total(summary) {
|
|
765
|
-
return Object.values(summary).reduce((a, b) => a + b, 0);
|
|
3041
|
+
return "note";
|
|
766
3042
|
}
|
|
767
|
-
function
|
|
768
|
-
switch (
|
|
3043
|
+
function securitySeverity(finding) {
|
|
3044
|
+
switch (finding.severity) {
|
|
769
3045
|
case "critical":
|
|
770
|
-
return
|
|
3046
|
+
return "9.5";
|
|
771
3047
|
case "high":
|
|
772
|
-
return
|
|
3048
|
+
return "8.0";
|
|
773
3049
|
case "moderate":
|
|
774
|
-
return
|
|
3050
|
+
return "5.0";
|
|
775
3051
|
case "low":
|
|
776
|
-
return
|
|
3052
|
+
return "2.0";
|
|
777
3053
|
case "unknown":
|
|
778
|
-
return
|
|
3054
|
+
return "0.0";
|
|
779
3055
|
}
|
|
780
3056
|
}
|
|
781
3057
|
|
|
782
|
-
// src/reporters/json.ts
|
|
783
|
-
function reportJson(result) {
|
|
784
|
-
return JSON.stringify(result, null, 2);
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// src/reporters/table.ts
|
|
788
|
-
import kleur3 from "kleur";
|
|
789
|
-
|
|
790
|
-
// src/reporters/banner.ts
|
|
791
|
-
import kleur2 from "kleur";
|
|
792
|
-
var BORDER = {
|
|
793
|
-
tl: "\u250C",
|
|
794
|
-
tr: "\u2510",
|
|
795
|
-
bl: "\u2514",
|
|
796
|
-
br: "\u2518",
|
|
797
|
-
h: "\u2500",
|
|
798
|
-
v: "\u2502"
|
|
799
|
-
};
|
|
800
|
-
var TITLE = "trawly";
|
|
801
|
-
var SIDE_PAD = 1;
|
|
802
|
-
var MIN_TITLE_FILLER = 4;
|
|
803
|
-
function renderBanner(props) {
|
|
804
|
-
const contentRows = [props.metrics, props.timestamp];
|
|
805
|
-
const titleSegment = ` ${TITLE} `;
|
|
806
|
-
const minTitleWidth = 1 + titleSegment.length + MIN_TITLE_FILLER;
|
|
807
|
-
const innerWidth = Math.max(
|
|
808
|
-
...contentRows.map((r) => r.length + SIDE_PAD * 2),
|
|
809
|
-
minTitleWidth
|
|
810
|
-
);
|
|
811
|
-
const top = renderTop(innerWidth);
|
|
812
|
-
const bottom = renderBottom(innerWidth);
|
|
813
|
-
const metricsLine = renderRow(innerWidth, props.metrics, kleur2.bold);
|
|
814
|
-
const timestampLine = renderRow(innerWidth, props.timestamp, kleur2.gray);
|
|
815
|
-
return [top, metricsLine, timestampLine, bottom].join("\n");
|
|
816
|
-
}
|
|
817
|
-
function renderTop(innerWidth) {
|
|
818
|
-
const titleSegment = ` ${kleur2.bold().cyan(TITLE)} `;
|
|
819
|
-
const titleVisibleLen = TITLE.length + 2;
|
|
820
|
-
const fillerCount = innerWidth - 1 - titleVisibleLen;
|
|
821
|
-
return kleur2.gray(BORDER.tl) + kleur2.gray(BORDER.h) + titleSegment + kleur2.gray(BORDER.h.repeat(Math.max(MIN_TITLE_FILLER, fillerCount))) + kleur2.gray(BORDER.tr);
|
|
822
|
-
}
|
|
823
|
-
function renderBottom(innerWidth) {
|
|
824
|
-
return kleur2.gray(`${BORDER.bl}${BORDER.h.repeat(innerWidth)}${BORDER.br}`);
|
|
825
|
-
}
|
|
826
|
-
function renderRow(innerWidth, content, colorize) {
|
|
827
|
-
const fill = Math.max(0, innerWidth - SIDE_PAD * 2 - content.length);
|
|
828
|
-
return kleur2.gray(BORDER.v) + " ".repeat(SIDE_PAD) + colorize(content) + " ".repeat(fill + SIDE_PAD) + kleur2.gray(BORDER.v);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
3058
|
// src/reporters/table.ts
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
3059
|
+
import kleur4 from "kleur";
|
|
3060
|
+
var SEVERITY_COLOR2 = {
|
|
3061
|
+
critical: (s) => kleur4.bold().red(s),
|
|
3062
|
+
high: (s) => kleur4.red(s),
|
|
3063
|
+
moderate: (s) => kleur4.yellow(s),
|
|
3064
|
+
low: (s) => kleur4.cyan(s),
|
|
3065
|
+
unknown: (s) => kleur4.gray(s)
|
|
838
3066
|
};
|
|
839
|
-
var
|
|
3067
|
+
var SEVERITY_ORDER2 = [
|
|
840
3068
|
"critical",
|
|
841
3069
|
"high",
|
|
842
3070
|
"moderate",
|
|
@@ -846,35 +3074,54 @@ var SEVERITY_ORDER = [
|
|
|
846
3074
|
function reportTable(result, options = {}) {
|
|
847
3075
|
const view = options.view ?? (options.details ? "details" : "grouped");
|
|
848
3076
|
const lines = [];
|
|
3077
|
+
const warnings = result.warnings ?? [];
|
|
3078
|
+
const ignoredFindings = result.ignoredFindings ?? [];
|
|
849
3079
|
if (options.brand) {
|
|
850
|
-
const { metricsLine, timestamp } =
|
|
3080
|
+
const { metricsLine, timestamp } = headerParts2(result);
|
|
851
3081
|
lines.push(renderBanner({ metrics: metricsLine, timestamp }));
|
|
852
3082
|
} else {
|
|
853
|
-
lines.push(
|
|
3083
|
+
lines.push(kleur4.bold(formatHeader2(result)));
|
|
854
3084
|
}
|
|
855
3085
|
if (result.errors.length > 0) {
|
|
856
3086
|
for (const err of result.errors) {
|
|
857
3087
|
lines.push(
|
|
858
|
-
|
|
3088
|
+
kleur4.red(`! ${err.message}${err.cause ? ` (${err.cause})` : ""}`)
|
|
859
3089
|
);
|
|
860
3090
|
}
|
|
861
3091
|
}
|
|
3092
|
+
if (warnings.length > 0) {
|
|
3093
|
+
for (const warning of warnings) {
|
|
3094
|
+
lines.push(kleur4.yellow(`~ ${warning}`));
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
if (ignoredFindings.length > 0) {
|
|
3098
|
+
lines.push(
|
|
3099
|
+
kleur4.gray(`${ignoredFindings.length} finding(s) ignored by config.`)
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
if (result.baseline) {
|
|
3103
|
+
lines.push(
|
|
3104
|
+
kleur4.gray(
|
|
3105
|
+
`Baseline: ${result.baseline.new} new, ${result.baseline.existing} existing.`
|
|
3106
|
+
)
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
862
3109
|
if (result.findings.length === 0) {
|
|
863
|
-
lines.push(
|
|
3110
|
+
lines.push(kleur4.green("\u2713 No active findings."));
|
|
864
3111
|
lines.push(
|
|
865
|
-
|
|
866
|
-
" Note:
|
|
3112
|
+
kleur4.gray(
|
|
3113
|
+
" Note: absence of findings is not proof of safety."
|
|
867
3114
|
)
|
|
868
3115
|
);
|
|
869
3116
|
return lines.join("\n");
|
|
870
3117
|
}
|
|
871
3118
|
if (view === "summary") {
|
|
872
|
-
lines.push(
|
|
3119
|
+
lines.push(formatSummary3(result.summary));
|
|
873
3120
|
lines.push(reminder());
|
|
874
3121
|
return lines.join("\n");
|
|
875
3122
|
}
|
|
876
3123
|
lines.push("");
|
|
877
|
-
lines.push(
|
|
3124
|
+
lines.push(formatSummary3(result.summary));
|
|
878
3125
|
lines.push("");
|
|
879
3126
|
if (view === "details") {
|
|
880
3127
|
lines.push(formatDetailRows(sortFindings(result.findings)));
|
|
@@ -883,42 +3130,42 @@ function reportTable(result, options = {}) {
|
|
|
883
3130
|
lines.push(formatGroupedRows(groups));
|
|
884
3131
|
lines.push("");
|
|
885
3132
|
lines.push(
|
|
886
|
-
|
|
3133
|
+
kleur4.gray("Run `trawly scan --details` to see individual findings.")
|
|
887
3134
|
);
|
|
888
3135
|
}
|
|
889
3136
|
lines.push("");
|
|
890
3137
|
lines.push(reminder());
|
|
891
3138
|
return lines.join("\n");
|
|
892
3139
|
}
|
|
893
|
-
function
|
|
894
|
-
const { metricsLine, timestamp } =
|
|
3140
|
+
function formatHeader2(result) {
|
|
3141
|
+
const { metricsLine, timestamp } = headerParts2(result);
|
|
895
3142
|
return `trawly: ${metricsLine} (${timestamp})`;
|
|
896
3143
|
}
|
|
897
|
-
function
|
|
898
|
-
const
|
|
3144
|
+
function headerParts2(result) {
|
|
3145
|
+
const affected = new Set(
|
|
899
3146
|
result.findings.map((f) => `${f.packageName}@${f.installedVersion}`)
|
|
900
3147
|
).size;
|
|
901
|
-
const
|
|
3148
|
+
const findingCount = result.findings.length;
|
|
902
3149
|
const metricsLine = [
|
|
903
3150
|
`${result.packagesScanned} packages`,
|
|
904
|
-
`${
|
|
905
|
-
`${
|
|
3151
|
+
`${affected} affected`,
|
|
3152
|
+
`${findingCount} ${findingCount === 1 ? "finding" : "findings"}`
|
|
906
3153
|
].join(" \xB7 ");
|
|
907
3154
|
return { metricsLine, timestamp: result.scannedAt };
|
|
908
3155
|
}
|
|
909
|
-
function
|
|
3156
|
+
function formatSummary3(summary) {
|
|
910
3157
|
const parts = [];
|
|
911
|
-
for (const sev of
|
|
3158
|
+
for (const sev of SEVERITY_ORDER2) {
|
|
912
3159
|
const count = summary[sev];
|
|
913
3160
|
if (count === 0) continue;
|
|
914
|
-
parts.push(
|
|
3161
|
+
parts.push(SEVERITY_COLOR2[sev](`${sev}: ${count}`));
|
|
915
3162
|
}
|
|
916
|
-
if (parts.length === 0) return
|
|
3163
|
+
if (parts.length === 0) return kleur4.green("No findings.");
|
|
917
3164
|
return `Findings : ${parts.join(" ")}`;
|
|
918
3165
|
}
|
|
919
3166
|
function reminder() {
|
|
920
|
-
return
|
|
921
|
-
"Reminder:
|
|
3167
|
+
return kleur4.gray(
|
|
3168
|
+
"Reminder: absence of findings is not proof of safety."
|
|
922
3169
|
);
|
|
923
3170
|
}
|
|
924
3171
|
function sortFindings(findings) {
|
|
@@ -976,8 +3223,8 @@ function formatGroupedRows(groups) {
|
|
|
976
3223
|
g.recommendedFix ? `>=${g.recommendedFix}` : ":"
|
|
977
3224
|
]);
|
|
978
3225
|
}
|
|
979
|
-
return
|
|
980
|
-
if (rowIdx === 0) return
|
|
3226
|
+
return renderTable2(rows, (rowIdx, _row, cells) => {
|
|
3227
|
+
if (rowIdx === 0) return kleur4.bold().underline(cells.join(" "));
|
|
981
3228
|
return cells.join(" ");
|
|
982
3229
|
});
|
|
983
3230
|
}
|
|
@@ -992,32 +3239,32 @@ function formatDetailRows(findings) {
|
|
|
992
3239
|
f.installedVersion,
|
|
993
3240
|
f.id,
|
|
994
3241
|
f.fixedVersions.length ? f.fixedVersions.join(", ") : ":",
|
|
995
|
-
|
|
3242
|
+
truncate3(f.summary, 70)
|
|
996
3243
|
]);
|
|
997
3244
|
}
|
|
998
|
-
return
|
|
999
|
-
if (rowIdx === 0) return
|
|
3245
|
+
return renderTable2(rows, (rowIdx, row, cells) => {
|
|
3246
|
+
if (rowIdx === 0) return kleur4.bold().underline(cells.join(" "));
|
|
1000
3247
|
const sev = row[0];
|
|
1001
|
-
const colorize =
|
|
3248
|
+
const colorize = SEVERITY_COLOR2[sev] ?? ((s) => s);
|
|
1002
3249
|
cells[0] = colorize(cells[0]);
|
|
1003
3250
|
return cells.join(" ");
|
|
1004
3251
|
});
|
|
1005
3252
|
}
|
|
1006
3253
|
function formatSeverityCounts(counts) {
|
|
1007
3254
|
const parts = [];
|
|
1008
|
-
for (const sev of
|
|
3255
|
+
for (const sev of SEVERITY_ORDER2) {
|
|
1009
3256
|
const n = counts[sev];
|
|
1010
3257
|
if (n === 0) continue;
|
|
1011
|
-
parts.push(
|
|
3258
|
+
parts.push(SEVERITY_COLOR2[sev](`${n} ${sev}`));
|
|
1012
3259
|
}
|
|
1013
3260
|
return parts.join(", ");
|
|
1014
3261
|
}
|
|
1015
|
-
function
|
|
3262
|
+
function renderTable2(rows, format) {
|
|
1016
3263
|
const widths = rows[0].map(
|
|
1017
|
-
(_, col) => Math.max(...rows.map((r) =>
|
|
3264
|
+
(_, col) => Math.max(...rows.map((r) => visibleLength2(r[col])))
|
|
1018
3265
|
);
|
|
1019
3266
|
return rows.map((row, i) => {
|
|
1020
|
-
const cells = row.map((cell, col) =>
|
|
3267
|
+
const cells = row.map((cell, col) => padEndVisible2(cell, widths[col]));
|
|
1021
3268
|
return format(i, row, cells);
|
|
1022
3269
|
}).join("\n");
|
|
1023
3270
|
}
|
|
@@ -1045,28 +3292,96 @@ function parseSemverParts(v) {
|
|
|
1045
3292
|
if (!m) return [0, 0, 0];
|
|
1046
3293
|
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
1047
3294
|
}
|
|
1048
|
-
function
|
|
3295
|
+
function truncate3(s, max) {
|
|
1049
3296
|
if (s.length <= max) return s;
|
|
1050
3297
|
return `${s.slice(0, max - 1)}\u2026`;
|
|
1051
3298
|
}
|
|
1052
|
-
var
|
|
1053
|
-
function
|
|
1054
|
-
return s.replace(
|
|
3299
|
+
var ANSI_RE2 = /\u001B\[[0-9;]*m/g;
|
|
3300
|
+
function visibleLength2(s) {
|
|
3301
|
+
return s.replace(ANSI_RE2, "").length;
|
|
1055
3302
|
}
|
|
1056
|
-
function
|
|
1057
|
-
const pad = Math.max(0, width -
|
|
3303
|
+
function padEndVisible2(s, width) {
|
|
3304
|
+
const pad = Math.max(0, width - visibleLength2(s));
|
|
1058
3305
|
return s + " ".repeat(pad);
|
|
1059
3306
|
}
|
|
1060
3307
|
|
|
3308
|
+
// src/why.ts
|
|
3309
|
+
import { existsSync as existsSync8 } from "fs";
|
|
3310
|
+
import { join as join7, resolve as resolve10 } from "path";
|
|
3311
|
+
function explainWhy(packageName, options = {}) {
|
|
3312
|
+
const cwd = resolve10(options.cwd ?? process.cwd());
|
|
3313
|
+
const lockfiles = options.lockfile ? normalizePaths2(cwd, options.lockfile) : detectLockfiles2(cwd);
|
|
3314
|
+
const packages = lockfiles.flatMap((path) => parseLockfile(path));
|
|
3315
|
+
const matches = packages.filter((pkg) => pkg.name === packageName).map((pkg) => ({
|
|
3316
|
+
package: pkg,
|
|
3317
|
+
chain: inferChain(pkg),
|
|
3318
|
+
note: graphNote(pkg)
|
|
3319
|
+
})).sort((a, b) => {
|
|
3320
|
+
const source = (a.package.sourceFile ?? "").localeCompare(
|
|
3321
|
+
b.package.sourceFile ?? ""
|
|
3322
|
+
);
|
|
3323
|
+
if (source !== 0) return source;
|
|
3324
|
+
return a.package.path.localeCompare(b.package.path);
|
|
3325
|
+
});
|
|
3326
|
+
return { packageName, lockfiles, matches };
|
|
3327
|
+
}
|
|
3328
|
+
function inferChain(pkg) {
|
|
3329
|
+
if (pkg.manager === "npm") {
|
|
3330
|
+
const chain = packageNamesFromNodeModulesPath(pkg.path);
|
|
3331
|
+
if (chain.length > 0) return chain;
|
|
3332
|
+
}
|
|
3333
|
+
return [pkg.name];
|
|
3334
|
+
}
|
|
3335
|
+
function graphNote(pkg) {
|
|
3336
|
+
if (pkg.manager === "npm") return void 0;
|
|
3337
|
+
if (pkg.direct) return "direct dependency";
|
|
3338
|
+
return `${pkg.manager ?? "lockfile"} lock entry; full parent chain is not available yet`;
|
|
3339
|
+
}
|
|
3340
|
+
function packageNamesFromNodeModulesPath(path) {
|
|
3341
|
+
const parts = path.split("/");
|
|
3342
|
+
const names = [];
|
|
3343
|
+
for (let i = 0; i < parts.length; i++) {
|
|
3344
|
+
if (parts[i] !== "node_modules") continue;
|
|
3345
|
+
const first = parts[i + 1];
|
|
3346
|
+
if (!first) continue;
|
|
3347
|
+
if (first.startsWith("@")) {
|
|
3348
|
+
const second = parts[i + 2];
|
|
3349
|
+
if (!second) continue;
|
|
3350
|
+
names.push(`${first}/${second}`);
|
|
3351
|
+
i += 2;
|
|
3352
|
+
} else {
|
|
3353
|
+
names.push(first);
|
|
3354
|
+
i += 1;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return names;
|
|
3358
|
+
}
|
|
3359
|
+
function detectLockfiles2(cwd) {
|
|
3360
|
+
const candidates = [
|
|
3361
|
+
"package-lock.json",
|
|
3362
|
+
"npm-shrinkwrap.json",
|
|
3363
|
+
"pnpm-lock.yaml",
|
|
3364
|
+
"yarn.lock"
|
|
3365
|
+
].map((file) => join7(cwd, file));
|
|
3366
|
+
return candidates.filter((candidate) => existsSync8(candidate));
|
|
3367
|
+
}
|
|
3368
|
+
function normalizePaths2(cwd, value) {
|
|
3369
|
+
if (!value) return [];
|
|
3370
|
+
const values = Array.isArray(value) ? value : [value];
|
|
3371
|
+
return [...new Set(values.map((path) => resolve10(cwd, path)))];
|
|
3372
|
+
}
|
|
3373
|
+
|
|
1061
3374
|
// src/cli.ts
|
|
1062
|
-
var
|
|
3375
|
+
var FAIL_ON_VALUES2 = [
|
|
1063
3376
|
"critical",
|
|
1064
3377
|
"high",
|
|
1065
3378
|
"moderate",
|
|
1066
3379
|
"low",
|
|
1067
3380
|
"none"
|
|
1068
3381
|
];
|
|
1069
|
-
var FORMAT_VALUES = ["table", "json"];
|
|
3382
|
+
var FORMAT_VALUES = ["table", "json", "markdown", "sarif"];
|
|
3383
|
+
var ENV_FORMAT_VALUES = ["table", "json"];
|
|
3384
|
+
var POLICY_VALUES2 = ["ci", "strict", "library", "app"];
|
|
1070
3385
|
var PM_VALUES = ["npm", "pnpm", "yarn", "bun"];
|
|
1071
3386
|
var EXIT = {
|
|
1072
3387
|
ok: 0,
|
|
@@ -1075,9 +3390,9 @@ var EXIT = {
|
|
|
1075
3390
|
invalidInput: 3
|
|
1076
3391
|
};
|
|
1077
3392
|
function parseFailOn(value) {
|
|
1078
|
-
if (!
|
|
3393
|
+
if (!FAIL_ON_VALUES2.includes(value)) {
|
|
1079
3394
|
throw new InvalidArgumentError(
|
|
1080
|
-
`must be one of: ${
|
|
3395
|
+
`must be one of: ${FAIL_ON_VALUES2.join(", ")}`
|
|
1081
3396
|
);
|
|
1082
3397
|
}
|
|
1083
3398
|
return value;
|
|
@@ -1090,13 +3405,32 @@ function parseFormat(value) {
|
|
|
1090
3405
|
}
|
|
1091
3406
|
return value;
|
|
1092
3407
|
}
|
|
3408
|
+
function parseEnvFormat(value) {
|
|
3409
|
+
if (!ENV_FORMAT_VALUES.includes(value)) {
|
|
3410
|
+
throw new InvalidArgumentError(
|
|
3411
|
+
`must be one of: ${ENV_FORMAT_VALUES.join(", ")}`
|
|
3412
|
+
);
|
|
3413
|
+
}
|
|
3414
|
+
return value;
|
|
3415
|
+
}
|
|
3416
|
+
function parsePolicy(value) {
|
|
3417
|
+
if (!POLICY_VALUES2.includes(value)) {
|
|
3418
|
+
throw new InvalidArgumentError(
|
|
3419
|
+
`must be one of: ${POLICY_VALUES2.join(", ")}`
|
|
3420
|
+
);
|
|
3421
|
+
}
|
|
3422
|
+
return value;
|
|
3423
|
+
}
|
|
1093
3424
|
function parsePm(value) {
|
|
1094
3425
|
if (!PM_VALUES.includes(value)) {
|
|
1095
3426
|
throw new InvalidArgumentError(`must be one of: ${PM_VALUES.join(", ")}`);
|
|
1096
3427
|
}
|
|
1097
3428
|
return value;
|
|
1098
3429
|
}
|
|
1099
|
-
|
|
3430
|
+
function collectOption(value, previous = []) {
|
|
3431
|
+
previous.push(value);
|
|
3432
|
+
return previous;
|
|
3433
|
+
}
|
|
1100
3434
|
var program = new Command();
|
|
1101
3435
|
program.name("trawly").description(
|
|
1102
3436
|
"Dependency sanity scanner. Checks installed npm packages against the OSV advisory database."
|
|
@@ -1109,50 +3443,89 @@ program.name("trawly").description(
|
|
|
1109
3443
|
});
|
|
1110
3444
|
program.command("scan", { isDefault: true }).description(
|
|
1111
3445
|
"Scan a project and gate on findings. Exits non-zero when --fail-on is met. Use `inspect` for a log-only run."
|
|
1112
|
-
).argument("[path]", "Project directory to scan", ".").option(
|
|
3446
|
+
).argument("[path]", "Project directory to scan", ".").option(
|
|
3447
|
+
"--lockfile <path>",
|
|
3448
|
+
"Explicit lockfile path. May be repeated.",
|
|
3449
|
+
collectOption
|
|
3450
|
+
).option("--sbom <path>", "Explicit SPDX/CycloneDX SBOM path. May be repeated.", collectOption).option(
|
|
1113
3451
|
"--format <format>",
|
|
1114
|
-
"Output format: table | json",
|
|
3452
|
+
"Output format: table | json | markdown | sarif",
|
|
1115
3453
|
parseFormat,
|
|
1116
3454
|
"table"
|
|
1117
3455
|
).option(
|
|
1118
3456
|
"--fail-on <level>",
|
|
1119
|
-
`Exit non-zero when a finding meets this severity (${
|
|
1120
|
-
parseFailOn
|
|
1121
|
-
|
|
1122
|
-
|
|
3457
|
+
`Exit non-zero when a finding meets this severity (${FAIL_ON_VALUES2.join("|")})`,
|
|
3458
|
+
parseFailOn
|
|
3459
|
+
).option("--config <path>", "Path to trawly.toml").option(
|
|
3460
|
+
"--policy <name>",
|
|
3461
|
+
`Use a built-in policy preset (${POLICY_VALUES2.join("|")})`,
|
|
3462
|
+
parsePolicy
|
|
3463
|
+
).option("--baseline <path>", "Only fail on findings not present in this baseline").option("--write-baseline <path>", "Write the current active findings baseline").option("--output <path>", "Write report output to a file").option("--risk", "Enable risk signals").option("--no-risk", "Disable risk signals").option("--env", "Scan committed .env files for secret-like values").option("--no-env", "Disable committed .env file scanning").option("--prod", "Only scan production dependencies (excludes dev)").option("--include-dev", "Include dev dependencies (default)").option(
|
|
1123
3464
|
"-v, --details",
|
|
1124
3465
|
"Show one row per advisory (full table). Default groups by package."
|
|
1125
3466
|
).option(
|
|
1126
3467
|
"-q, --summary",
|
|
1127
3468
|
"Show only the one-line severity summary. Mutually exclusive with --details."
|
|
1128
|
-
).action(async (path, opts) => {
|
|
1129
|
-
await runScanCommand(path, opts, {
|
|
3469
|
+
).action(async (path, opts, command) => {
|
|
3470
|
+
await runScanCommand(path, normalizeScanOptions(opts, command), {
|
|
3471
|
+
gate: true
|
|
3472
|
+
});
|
|
1130
3473
|
});
|
|
1131
3474
|
program.command("inspect").description(
|
|
1132
3475
|
"Scan a project and print findings without gating. Always exits 0 unless an operational error occurs. Use `scan` for CI gating."
|
|
1133
|
-
).argument("[path]", "Project directory to scan", ".").option(
|
|
3476
|
+
).argument("[path]", "Project directory to scan", ".").option(
|
|
3477
|
+
"--lockfile <path>",
|
|
3478
|
+
"Explicit lockfile path. May be repeated.",
|
|
3479
|
+
collectOption
|
|
3480
|
+
).option("--sbom <path>", "Explicit SPDX/CycloneDX SBOM path. May be repeated.", collectOption).option(
|
|
1134
3481
|
"--format <format>",
|
|
1135
|
-
"Output format: table | json",
|
|
3482
|
+
"Output format: table | json | markdown | sarif",
|
|
1136
3483
|
parseFormat,
|
|
1137
3484
|
"table"
|
|
1138
|
-
).option("--
|
|
3485
|
+
).option("--config <path>", "Path to trawly.toml").option(
|
|
3486
|
+
"--policy <name>",
|
|
3487
|
+
`Use a built-in policy preset (${POLICY_VALUES2.join("|")})`,
|
|
3488
|
+
parsePolicy
|
|
3489
|
+
).option("--baseline <path>", "Mark findings already present in this baseline").option("--write-baseline <path>", "Write the current active findings baseline").option("--output <path>", "Write report output to a file").option("--risk", "Enable risk signals").option("--no-risk", "Disable risk signals").option("--env", "Scan committed .env files for secret-like values").option("--no-env", "Disable committed .env file scanning").option("--prod", "Only scan production dependencies (excludes dev)").option("--include-dev", "Include dev dependencies (default)").option(
|
|
1139
3490
|
"-v, --details",
|
|
1140
3491
|
"Show one row per advisory (full table). Default groups by package."
|
|
1141
3492
|
).option(
|
|
1142
3493
|
"-q, --summary",
|
|
1143
3494
|
"Show only the one-line severity summary. Mutually exclusive with --details."
|
|
1144
|
-
).action(async (path, opts) => {
|
|
3495
|
+
).action(async (path, opts, command) => {
|
|
1145
3496
|
await runScanCommand(
|
|
1146
3497
|
path,
|
|
1147
|
-
{
|
|
3498
|
+
{
|
|
3499
|
+
...normalizeScanOptions(opts, command),
|
|
3500
|
+
failOn: "none"
|
|
3501
|
+
},
|
|
1148
3502
|
{ gate: false }
|
|
1149
3503
|
);
|
|
1150
3504
|
});
|
|
3505
|
+
program.command("init").description(
|
|
3506
|
+
"Create trawly.toml and write an initial baseline so CI can focus on new findings."
|
|
3507
|
+
).argument("[path]", "Project directory to initialize", ".").option("--config <path>", "Config path to write", "trawly.toml").option("--baseline <path>", "Baseline path to write", "trawly-baseline.json").option(
|
|
3508
|
+
"--policy <name>",
|
|
3509
|
+
`Initial policy preset (${POLICY_VALUES2.join("|")})`,
|
|
3510
|
+
parsePolicy,
|
|
3511
|
+
"ci"
|
|
3512
|
+
).option("--overwrite", "Overwrite an existing config file").option("--skip-baseline", "Do not scan or write a baseline").action(async (path, opts) => {
|
|
3513
|
+
await runInitCommand(path, opts);
|
|
3514
|
+
});
|
|
3515
|
+
program.command("why").description(
|
|
3516
|
+
"Explain where a package appears in supported lockfiles."
|
|
3517
|
+
).argument("<package>", "Package name to explain").argument("[path]", "Project directory to inspect", ".").option(
|
|
3518
|
+
"--lockfile <path>",
|
|
3519
|
+
"Explicit lockfile path. May be repeated.",
|
|
3520
|
+
collectOption
|
|
3521
|
+
).action((packageName, path, opts) => {
|
|
3522
|
+
runWhyCommand(packageName, path, opts);
|
|
3523
|
+
});
|
|
1151
3524
|
program.command("add").description(
|
|
1152
3525
|
"Resolve, scan, and install packages. Vulnerable packages are blocked; clean ones are forwarded to your package manager."
|
|
1153
3526
|
).argument("<args...>", "Packages to add (e.g. next vitest@1) : PM flags after the first package are passed through").option(
|
|
1154
3527
|
"--fail-on <level>",
|
|
1155
|
-
`Block install when a finding meets this severity (${
|
|
3528
|
+
`Block install when a finding meets this severity (${FAIL_ON_VALUES2.join("|")})`,
|
|
1156
3529
|
parseFailOn,
|
|
1157
3530
|
"high"
|
|
1158
3531
|
).option(
|
|
@@ -1169,7 +3542,7 @@ program.command("install").alias("i").description(
|
|
|
1169
3542
|
"Run the project's package manager install. With package args, behaves like `add` (gates on vulnerabilities). With none, forwards directly."
|
|
1170
3543
|
).argument("[args...]", "Optional packages to add").option(
|
|
1171
3544
|
"--fail-on <level>",
|
|
1172
|
-
`Block install when a finding meets this severity (${
|
|
3545
|
+
`Block install when a finding meets this severity (${FAIL_ON_VALUES2.join("|")})`,
|
|
1173
3546
|
parseFailOn,
|
|
1174
3547
|
"high"
|
|
1175
3548
|
).option(
|
|
@@ -1181,7 +3554,7 @@ program.command("install").alias("i").description(
|
|
|
1181
3554
|
const pm = detectPackageManager({ override: opts.pm });
|
|
1182
3555
|
const command = buildInstallCommand(pm, []);
|
|
1183
3556
|
process.stdout.write(
|
|
1184
|
-
|
|
3557
|
+
kleur5.gray(`> ${command.bin} ${command.args.join(" ")}
|
|
1185
3558
|
`)
|
|
1186
3559
|
);
|
|
1187
3560
|
try {
|
|
@@ -1194,6 +3567,21 @@ program.command("install").alias("i").description(
|
|
|
1194
3567
|
}
|
|
1195
3568
|
await executeAdd(args, opts);
|
|
1196
3569
|
});
|
|
3570
|
+
program.command("env").description(
|
|
3571
|
+
"Scan for env-file leaks: tracked .env files, missing .gitignore coverage, npm-publish exposure, and secrets in .env.example."
|
|
3572
|
+
).argument("[path]", "Project directory to scan", ".").option(
|
|
3573
|
+
"--format <format>",
|
|
3574
|
+
"Output format: table | json",
|
|
3575
|
+
parseEnvFormat,
|
|
3576
|
+
"table"
|
|
3577
|
+
).option(
|
|
3578
|
+
"--fail-on <level>",
|
|
3579
|
+
`Exit non-zero when an issue meets this severity (${FAIL_ON_VALUES2.join("|")})`,
|
|
3580
|
+
parseFailOn,
|
|
3581
|
+
"high"
|
|
3582
|
+
).action(async (path, opts) => {
|
|
3583
|
+
await runEnvCommand(path, opts);
|
|
3584
|
+
});
|
|
1197
3585
|
program.command("remove").alias("uninstall").description(
|
|
1198
3586
|
"Remove packages by delegating to the project's package manager (no scan)."
|
|
1199
3587
|
).argument("<args...>", "Packages to remove").option(
|
|
@@ -1205,7 +3593,7 @@ program.command("remove").alias("uninstall").description(
|
|
|
1205
3593
|
const { specs, flags } = splitArgs(args);
|
|
1206
3594
|
const command = buildRemoveCommand(pm, specs, flags);
|
|
1207
3595
|
process.stdout.write(
|
|
1208
|
-
|
|
3596
|
+
kleur5.gray(`> ${command.bin} ${command.args.join(" ")}
|
|
1209
3597
|
`)
|
|
1210
3598
|
);
|
|
1211
3599
|
try {
|
|
@@ -1216,6 +3604,45 @@ program.command("remove").alias("uninstall").description(
|
|
|
1216
3604
|
process.exit(EXIT.operational);
|
|
1217
3605
|
}
|
|
1218
3606
|
});
|
|
3607
|
+
function normalizeScanOptions(opts, command) {
|
|
3608
|
+
return {
|
|
3609
|
+
...opts,
|
|
3610
|
+
risk: triStateBooleanFlag(command, "risk"),
|
|
3611
|
+
env: triStateBooleanFlag(command, "env")
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
function triStateBooleanFlag(command, name) {
|
|
3615
|
+
return command.getOptionValueSource(name) === "cli" ? command.getOptionValue(name) : void 0;
|
|
3616
|
+
}
|
|
3617
|
+
async function runEnvCommand(path, opts) {
|
|
3618
|
+
try {
|
|
3619
|
+
const result = await scanEnv({ cwd: path });
|
|
3620
|
+
if (opts.format === "json") {
|
|
3621
|
+
process.stdout.write(`${reportEnvJson(result)}
|
|
3622
|
+
`);
|
|
3623
|
+
} else {
|
|
3624
|
+
const brand = process.stdout.isTTY === true;
|
|
3625
|
+
process.stdout.write(`${reportEnvTable(result, { brand })}
|
|
3626
|
+
`);
|
|
3627
|
+
}
|
|
3628
|
+
if (result.errors.length > 0) process.exit(EXIT.operational);
|
|
3629
|
+
if (envIssuesMeetThreshold(result.issues, opts.failOn)) {
|
|
3630
|
+
if (opts.format !== "json") {
|
|
3631
|
+
process.stderr.write(
|
|
3632
|
+
`${kleur5.red(
|
|
3633
|
+
`\xD7 Failing because at least one issue meets --fail-on=${opts.failOn}.`
|
|
3634
|
+
)}
|
|
3635
|
+
`
|
|
3636
|
+
);
|
|
3637
|
+
}
|
|
3638
|
+
process.exit(EXIT.findings);
|
|
3639
|
+
}
|
|
3640
|
+
process.exit(EXIT.ok);
|
|
3641
|
+
} catch (err) {
|
|
3642
|
+
printErr(`trawly: ${err.message}`);
|
|
3643
|
+
process.exit(EXIT.operational);
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
1219
3646
|
async function runScanCommand(path, opts, { gate }) {
|
|
1220
3647
|
if (opts.prod && opts.includeDev) {
|
|
1221
3648
|
printErr("Cannot combine --prod and --include-dev. Choose one.");
|
|
@@ -1226,29 +3653,34 @@ async function runScanCommand(path, opts, { gate }) {
|
|
|
1226
3653
|
process.exit(EXIT.invalidInput);
|
|
1227
3654
|
}
|
|
1228
3655
|
try {
|
|
3656
|
+
const cwd = resolve11(path);
|
|
3657
|
+
const config = loadConfig(cwd, opts.config).config;
|
|
3658
|
+
const policy = resolvePolicy(opts.policy, config.policy);
|
|
3659
|
+
const failOn = opts.failOn ?? config.failOn ?? policy?.failOn ?? "high";
|
|
1229
3660
|
const result = await scanProject({
|
|
1230
|
-
cwd
|
|
3661
|
+
cwd,
|
|
1231
3662
|
lockfile: opts.lockfile,
|
|
3663
|
+
sbom: opts.sbom,
|
|
3664
|
+
config: opts.config,
|
|
3665
|
+
policy: opts.policy,
|
|
3666
|
+
baseline: opts.baseline,
|
|
3667
|
+
writeBaseline: opts.writeBaseline,
|
|
3668
|
+
risk: opts.risk,
|
|
3669
|
+
env: opts.env,
|
|
1232
3670
|
includeDev: opts.includeDev,
|
|
1233
|
-
prodOnly: opts.prod
|
|
1234
|
-
cache: opts.cache
|
|
3671
|
+
prodOnly: opts.prod
|
|
1235
3672
|
});
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
} else {
|
|
1240
|
-
const view = opts.summary ? "summary" : opts.details ? "details" : "grouped";
|
|
1241
|
-
const brand = process.stdout.isTTY === true;
|
|
1242
|
-
process.stdout.write(`${reportTable(result, { view, brand })}
|
|
3673
|
+
const output = renderReport(result, opts);
|
|
3674
|
+
if (opts.output) writeOutput(cwd, opts.output, output);
|
|
3675
|
+
else process.stdout.write(`${output}
|
|
1243
3676
|
`);
|
|
1244
|
-
}
|
|
1245
3677
|
if (result.errors.length > 0) {
|
|
1246
3678
|
process.exit(EXIT.operational);
|
|
1247
3679
|
}
|
|
1248
3680
|
if (!gate) {
|
|
1249
|
-
if (opts.format
|
|
3681
|
+
if (opts.format === "table" && !opts.output && result.findings.length > 0) {
|
|
1250
3682
|
process.stdout.write(
|
|
1251
|
-
`${
|
|
3683
|
+
`${kleur5.gray(
|
|
1252
3684
|
"\u2139 inspect mode: exiting 0 regardless of findings. Run `trawly scan` to gate CI."
|
|
1253
3685
|
)}
|
|
1254
3686
|
`
|
|
@@ -1256,13 +3688,13 @@ async function runScanCommand(path, opts, { gate }) {
|
|
|
1256
3688
|
}
|
|
1257
3689
|
process.exit(EXIT.ok);
|
|
1258
3690
|
}
|
|
1259
|
-
if (meetsThreshold(result.findings,
|
|
3691
|
+
if (meetsThreshold(result.findings, failOn)) {
|
|
1260
3692
|
if (opts.format !== "json") {
|
|
1261
3693
|
process.stderr.write(
|
|
1262
|
-
`${
|
|
1263
|
-
`\xD7 Failing because at least one finding meets --fail-on=${
|
|
3694
|
+
`${kleur5.red(
|
|
3695
|
+
`\xD7 Failing because at least one finding meets --fail-on=${failOn}.`
|
|
1264
3696
|
)}
|
|
1265
|
-
${
|
|
3697
|
+
${kleur5.gray(
|
|
1266
3698
|
" Run `trawly inspect` to log without exiting non-zero, or `trawly scan --fail-on=none` to disable the gate."
|
|
1267
3699
|
)}
|
|
1268
3700
|
`
|
|
@@ -1272,7 +3704,48 @@ ${kleur4.gray(
|
|
|
1272
3704
|
}
|
|
1273
3705
|
process.exit(EXIT.ok);
|
|
1274
3706
|
} catch (err) {
|
|
1275
|
-
if (err instanceof ScanInputError) {
|
|
3707
|
+
if (err instanceof ScanInputError || err instanceof ConfigError) {
|
|
3708
|
+
printErr(err.message);
|
|
3709
|
+
process.exit(EXIT.invalidInput);
|
|
3710
|
+
}
|
|
3711
|
+
printErr(`trawly: ${err.message}`);
|
|
3712
|
+
process.exit(EXIT.operational);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
async function runInitCommand(path, opts) {
|
|
3716
|
+
try {
|
|
3717
|
+
const result = await initProject({
|
|
3718
|
+
cwd: path,
|
|
3719
|
+
config: opts.config,
|
|
3720
|
+
baseline: opts.baseline,
|
|
3721
|
+
policy: opts.policy,
|
|
3722
|
+
overwrite: opts.overwrite,
|
|
3723
|
+
writeBaseline: !opts.skipBaseline
|
|
3724
|
+
});
|
|
3725
|
+
const lines = [];
|
|
3726
|
+
lines.push(
|
|
3727
|
+
result.configWritten ? kleur5.green(`\u2713 Wrote ${result.configPath}`) : kleur5.gray(`~ Kept existing ${result.configPath}`)
|
|
3728
|
+
);
|
|
3729
|
+
if (!opts.skipBaseline) {
|
|
3730
|
+
if (result.baselineWritten && result.scan?.baseline?.written) {
|
|
3731
|
+
lines.push(kleur5.green(`\u2713 Wrote ${result.scan.baseline.written}`));
|
|
3732
|
+
lines.push(
|
|
3733
|
+
kleur5.gray(
|
|
3734
|
+
` Baseline contains ${result.scan.baseline.total} active finding(s). Future scans can fail only on new findings with --baseline=${opts.baseline}.`
|
|
3735
|
+
)
|
|
3736
|
+
);
|
|
3737
|
+
} else {
|
|
3738
|
+
lines.push(kleur5.gray("~ Baseline was not written."));
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
for (const warning of result.warnings) {
|
|
3742
|
+
lines.push(kleur5.yellow(`~ ${warning}`));
|
|
3743
|
+
}
|
|
3744
|
+
process.stdout.write(`${lines.join("\n")}
|
|
3745
|
+
`);
|
|
3746
|
+
process.exit(EXIT.ok);
|
|
3747
|
+
} catch (err) {
|
|
3748
|
+
if (err instanceof ConfigError) {
|
|
1276
3749
|
printErr(err.message);
|
|
1277
3750
|
process.exit(EXIT.invalidInput);
|
|
1278
3751
|
}
|
|
@@ -1280,6 +3753,51 @@ ${kleur4.gray(
|
|
|
1280
3753
|
process.exit(EXIT.operational);
|
|
1281
3754
|
}
|
|
1282
3755
|
}
|
|
3756
|
+
function runWhyCommand(packageName, path, opts) {
|
|
3757
|
+
try {
|
|
3758
|
+
const result = explainWhy(packageName, {
|
|
3759
|
+
cwd: path,
|
|
3760
|
+
lockfile: opts.lockfile
|
|
3761
|
+
});
|
|
3762
|
+
if (result.lockfiles.length === 0) {
|
|
3763
|
+
printErr(
|
|
3764
|
+
"No supported lockfile found. Pass --lockfile or run in a project with package-lock.json, pnpm-lock.yaml, or yarn.lock."
|
|
3765
|
+
);
|
|
3766
|
+
process.exit(EXIT.invalidInput);
|
|
3767
|
+
}
|
|
3768
|
+
const lines = [];
|
|
3769
|
+
lines.push(kleur5.bold(`trawly why ${packageName}`));
|
|
3770
|
+
if (result.matches.length === 0) {
|
|
3771
|
+
lines.push(kleur5.yellow("No matching package found in scanned lockfiles."));
|
|
3772
|
+
process.stdout.write(`${lines.join("\n")}
|
|
3773
|
+
`);
|
|
3774
|
+
process.exit(EXIT.ok);
|
|
3775
|
+
}
|
|
3776
|
+
for (const match of result.matches) {
|
|
3777
|
+
const pkg = match.package;
|
|
3778
|
+
const kind = pkg.direct ? "direct" : "transitive";
|
|
3779
|
+
lines.push(
|
|
3780
|
+
`${pkg.name}@${pkg.version} (${kind}, ${pkg.manager ?? "lockfile"})`
|
|
3781
|
+
);
|
|
3782
|
+
lines.push(` path: ${pkg.path}`);
|
|
3783
|
+
lines.push(` chain: ${match.chain.join(" > ")}`);
|
|
3784
|
+
if (match.note) lines.push(kleur5.gray(` note: ${match.note}`));
|
|
3785
|
+
if (pkg.sourceFile) {
|
|
3786
|
+
lines.push(
|
|
3787
|
+
kleur5.gray(
|
|
3788
|
+
` source: ${pkg.sourceFile}${pkg.line ? `:${pkg.line}` : ""}`
|
|
3789
|
+
)
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
process.stdout.write(`${lines.join("\n")}
|
|
3794
|
+
`);
|
|
3795
|
+
process.exit(EXIT.ok);
|
|
3796
|
+
} catch (err) {
|
|
3797
|
+
printErr(`trawly: ${err.message}`);
|
|
3798
|
+
process.exit(EXIT.operational);
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
1283
3801
|
async function executeAdd(args, opts) {
|
|
1284
3802
|
try {
|
|
1285
3803
|
const result = await runAdd(args, {
|
|
@@ -1309,7 +3827,28 @@ function splitArgs(args) {
|
|
|
1309
3827
|
return { specs, flags };
|
|
1310
3828
|
}
|
|
1311
3829
|
function printErr(msg) {
|
|
1312
|
-
process.stderr.write(`${
|
|
3830
|
+
process.stderr.write(`${kleur5.red(msg)}
|
|
3831
|
+
`);
|
|
3832
|
+
}
|
|
3833
|
+
function renderReport(result, opts) {
|
|
3834
|
+
switch (opts.format) {
|
|
3835
|
+
case "json":
|
|
3836
|
+
return reportJson(result);
|
|
3837
|
+
case "markdown":
|
|
3838
|
+
return reportMarkdown(result);
|
|
3839
|
+
case "sarif":
|
|
3840
|
+
return reportSarif(result);
|
|
3841
|
+
case "table": {
|
|
3842
|
+
const view = opts.summary ? "summary" : opts.details ? "details" : "grouped";
|
|
3843
|
+
const brand = process.stdout.isTTY === true && !opts.output;
|
|
3844
|
+
return reportTable(result, { view, brand });
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
function writeOutput(cwd, path, content) {
|
|
3849
|
+
const absolute = resolve11(cwd, path);
|
|
3850
|
+
mkdirSync2(dirname4(absolute), { recursive: true });
|
|
3851
|
+
writeFileSync3(absolute, `${content}
|
|
1313
3852
|
`);
|
|
1314
3853
|
}
|
|
1315
3854
|
await program.parseAsync(process.argv);
|