trawly 0.0.2 → 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/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 kleur4 from "kleur";
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 = `${pkg.name}@${pkg.version}`;
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
- const body = {
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
- for (const id of allIds) {
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 = `${pkg.name}@${pkg.version}`;
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.affected ?? []) {
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(`OSV ${res.status}: ${res.statusText}`, res.status);
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(`OSV ${res.status}: ${res.statusText}`, res.status);
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
- await new Promise((r) => setTimeout(r, 250 * 2 ** attempt));
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 as existsSync2, statSync } from "fs";
207
- import { basename, resolve as resolve4, join as join2 } from "path";
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";
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 = resolve(filePath);
214
- const raw = readFileSync(absolute, "utf8");
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,264 +817,834 @@ 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
+ }
834
+
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";
839
+
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;
863
+ }
864
+ return info;
865
+ }
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);
871
+ }
872
+ }
292
873
 
293
874
  // src/extractors/pnpm-lock.ts
294
- import { readFileSync as readFileSync2 } from "fs";
295
- import { resolve as resolve2 } from "path";
296
- import yaml from "js-yaml";
297
875
  var SUPPORTED_MAJOR_VERSIONS = /* @__PURE__ */ new Set([6, 9]);
298
876
  function parsePnpmLock(filePath) {
299
- const absolute = resolve2(filePath);
300
- const raw = readFileSync2(absolute, "utf8");
877
+ const absolute = resolve4(filePath);
878
+ const raw = readFileSync6(absolute, "utf8");
301
879
  let parsed;
302
880
  try {
303
- parsed = yaml.load(raw) ?? {};
881
+ parsed = parseYaml(raw);
304
882
  } catch (err) {
305
883
  throw new Error(
306
884
  `Failed to parse ${absolute}: ${err.message}`
307
885
  );
308
886
  }
309
- const versionRaw = parsed.lockfileVersion;
310
- const major = parseLockfileMajor(versionRaw);
887
+ const major = parseLockfileMajor(parsed.lockfileVersion);
311
888
  if (major === null || !SUPPORTED_MAJOR_VERSIONS.has(major)) {
312
889
  throw new Error(
313
- `Unsupported pnpm lockfileVersion ${String(versionRaw)} in ${absolute}. Supported: 6.x, 9.x.`
890
+ `Unsupported pnpm lockfileVersion ${String(parsed.lockfileVersion)} in ${absolute}. Supported: 6.x, 9.x.`
314
891
  );
315
892
  }
316
- const packages = parsed.packages;
317
- if (!packages || typeof packages !== "object") {
318
- throw new Error(
319
- `Lockfile ${absolute} has no "packages" map; cannot extract installed versions.`
320
- );
893
+ if (!parsed.packages || typeof parsed.packages !== "object") {
894
+ throw new Error(`Lockfile ${absolute} has no "packages" map.`);
321
895
  }
322
- const importers = parsed.importers ?? {
323
- ".": {
324
- dependencies: parsed.dependencies,
325
- devDependencies: parsed.devDependencies,
326
- optionalDependencies: parsed.optionalDependencies
327
- }
328
- };
329
- const direct = collectDirectFromImporters(importers);
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;
330
901
  const instances = [];
331
- for (const [key, entry] of Object.entries(packages)) {
332
- const parsed2 = parsePnpmPackageKey(key);
333
- if (!parsed2) continue;
334
- const name = entry.name ?? parsed2.name;
335
- const version = entry.version ?? parsed2.version;
336
- if (!name || !version) continue;
337
- const isDirect = direct.prod.has(name) || direct.dev.has(name) || direct.optional.has(name);
338
- const onlyDev = direct.dev.has(name) && !direct.prod.has(name);
339
- const onlyOptional = direct.optional.has(name) && !direct.prod.has(name) && !direct.dev.has(name);
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);
340
906
  instances.push({
341
- name,
342
- version,
907
+ name: parsedKey.name,
908
+ version: parsedKey.version,
343
909
  ecosystem: "npm",
344
910
  path: key,
345
- direct: isDirect,
346
- dev: Boolean(entry.dev) || onlyDev,
347
- optional: Boolean(entry.optional) || onlyOptional,
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",
348
918
  resolved: entry.resolution?.tarball,
349
- integrity: entry.resolution?.integrity
919
+ integrity: entry.resolution?.integrity,
920
+ registry: registryFromResolved2(entry.resolution?.tarball),
921
+ hasInstallScript: Boolean(entry.requiresBuild)
350
922
  });
351
923
  }
352
924
  return instances;
353
925
  }
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);
953
+ }
954
+ return { all, dev, optional };
955
+ }
354
956
  function parseLockfileMajor(value) {
355
957
  if (typeof value === "number") return Math.trunc(value);
356
958
  if (typeof value === "string") {
357
- const num = parseInt(value, 10);
358
- return Number.isNaN(num) ? null : num;
959
+ const major = Number.parseInt(value, 10);
960
+ return Number.isNaN(major) ? null : major;
359
961
  }
360
962
  return null;
361
963
  }
362
- function collectDirectFromImporters(importers) {
363
- const prod = /* @__PURE__ */ new Set();
364
- const dev = /* @__PURE__ */ new Set();
365
- const optional = /* @__PURE__ */ new Set();
366
- for (const importer of Object.values(importers)) {
367
- if (!importer) continue;
368
- addDepNames(importer.dependencies, prod);
369
- addDepNames(importer.devDependencies, dev);
370
- addDepNames(importer.optionalDependencies, optional);
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 { prod, dev, optional };
373
970
  }
374
- function addDepNames(block, into) {
375
- if (!block) return;
376
- for (const name of Object.keys(block)) into.add(name);
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
+ }
377
979
  }
378
- function parsePnpmPackageKey(key) {
379
- let working = key.startsWith("/") ? key.slice(1) : key;
380
- const parenIdx = working.indexOf("(");
381
- if (parenIdx !== -1) working = working.slice(0, parenIdx);
382
- const startSearch = working.startsWith("@") ? 1 : 0;
383
- const atIdx = working.indexOf("@", startSearch);
384
- if (atIdx <= 0) return null;
385
- const name = working.slice(0, atIdx);
386
- const version = working.slice(atIdx + 1);
387
- if (!name || !version) return null;
388
- return { name, version };
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
986
  // src/extractors/yarn-lock.ts
392
- import { existsSync, readFileSync as readFileSync3 } from "fs";
393
- import { dirname, join, resolve as resolve3 } from "path";
394
- import yaml2 from "js-yaml";
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:"];
395
993
  function parseYarnLock(filePath) {
396
- const absolute = resolve3(filePath);
397
- const content = readFileSync3(absolute, "utf8");
398
- const projectDir = dirname(absolute);
399
- const directs = readDirectDepsFromPackageJson(projectDir);
400
- const isBerry = /^__metadata:/m.test(content);
401
- return isBerry ? parseBerry(absolute, content, directs) : parseClassic(absolute, content, directs);
994
+ const absolute = resolve5(filePath);
995
+ const raw = readFileSync7(absolute, "utf8");
996
+ return isBerryLock(raw) ? parseYarnBerryLock(absolute, raw) : parseYarnClassicLock(absolute, raw);
402
997
  }
403
- function readDirectDepsFromPackageJson(projectDir) {
404
- const result = {
405
- prod: /* @__PURE__ */ new Set(),
406
- dev: /* @__PURE__ */ new Set(),
407
- optional: /* @__PURE__ */ new Set(),
408
- any: /* @__PURE__ */ new Set()
409
- };
410
- const pkgPath = join(projectDir, "package.json");
411
- if (!existsSync(pkgPath)) return result;
412
- try {
413
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
414
- for (const name of Object.keys(pkg.dependencies ?? {})) result.prod.add(name);
415
- for (const name of Object.keys(pkg.devDependencies ?? {})) result.dev.add(name);
416
- for (const name of Object.keys(pkg.optionalDependencies ?? {}))
417
- result.optional.add(name);
418
- for (const set of [result.prod, result.dev, result.optional]) {
419
- for (const n of set) result.any.add(n);
420
- }
421
- for (const name of Object.keys(pkg.peerDependencies ?? {})) result.any.add(name);
422
- } catch {
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.`);
423
1002
  }
424
- return result;
425
- }
426
- function parseClassic(absolute, content, directs) {
427
- const entries = parseClassicEntries(content);
1003
+ const rootInfo = readPackageJsonInfoFrom(absolute);
428
1004
  const instances = [];
429
- for (const entry of entries) {
430
- const version = entry.fields.version;
431
- if (!version) continue;
432
- const names = uniq(entry.specs.map((s) => parseYarnSpec(s).name).filter(Boolean));
433
- const name = names[0];
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);
434
1010
  if (!name) continue;
435
- const isDirect = names.some((n) => directs.any.has(n));
436
- const inProd = names.some((n) => directs.prod.has(n));
437
- const inDev = names.some((n) => directs.dev.has(n));
438
- const inOpt = names.some((n) => directs.optional.has(n));
1011
+ const direct = rootInfo.allDirect.has(name);
439
1012
  instances.push({
440
1013
  name,
441
- version,
1014
+ version: entry.version,
442
1015
  ecosystem: "npm",
443
- path: `${name}@${version}`,
444
- direct: isDirect,
445
- dev: inDev && !inProd,
446
- optional: inOpt && !inProd && !inDev,
447
- resolved: entry.fields.resolved,
448
- integrity: entry.fields.integrity
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
449
1028
  });
450
1029
  }
451
- void absolute;
452
- return instances;
453
- }
454
- function parseClassicEntries(content) {
455
- const lines = content.split(/\r?\n/);
456
- const entries = [];
457
- let current = null;
458
- for (const rawLine of lines) {
459
- if (rawLine === "" || rawLine.trimStart().startsWith("#")) continue;
460
- if (!/^\s/.test(rawLine)) {
461
- if (current) entries.push(current);
462
- const header = rawLine.replace(/:\s*$/, "");
463
- current = { specs: splitClassicSpecs(header), fields: {} };
464
- continue;
465
- }
466
- if (!current) continue;
467
- const indent = rawLine.match(/^ +/)?.[0].length ?? 0;
468
- if (indent !== 2) continue;
469
- const trimmed = rawLine.trim();
470
- const m = trimmed.match(/^([^\s"]+)\s+"((?:[^"\\]|\\.)*)"$/) ?? trimmed.match(/^([^\s"]+)\s+(\S+)$/);
471
- if (m && m[1] !== void 0 && m[2] !== void 0) {
472
- current.fields[m[1]] = m[2];
473
- }
474
- }
475
- if (current) entries.push(current);
476
- return entries;
477
- }
478
- function splitClassicSpecs(header) {
479
- const out = [];
480
- let cur = "";
481
- let inQuote = false;
482
- for (const ch of header) {
483
- if (ch === '"') {
484
- inQuote = !inQuote;
485
- continue;
486
- }
487
- if (ch === "," && !inQuote) {
488
- const spec = cur.trim();
489
- if (spec) out.push(spec);
490
- cur = "";
491
- continue;
492
- }
493
- cur += ch;
494
- }
495
- const last = cur.trim();
496
- if (last) out.push(last);
497
- return out;
1030
+ return dedupeInstances(instances);
498
1031
  }
499
- function parseBerry(absolute, content, directs) {
1032
+ function parseYarnBerryLock(absolute, raw) {
500
1033
  let parsed;
501
1034
  try {
502
- parsed = yaml2.load(content) ?? {};
1035
+ parsed = parseYaml2(raw);
503
1036
  } catch (err) {
504
1037
  throw new Error(
505
1038
  `Failed to parse ${absolute}: ${err.message}`
506
1039
  );
507
1040
  }
1041
+ const rootInfo = readPackageJsonInfoFrom(absolute);
508
1042
  const instances = [];
509
- for (const [key, value] of Object.entries(parsed)) {
510
- if (key === "__metadata") continue;
511
- if (!value || typeof value !== "object") continue;
1043
+ for (const [descriptor, value] of Object.entries(parsed)) {
1044
+ if (descriptor === "__metadata" || !isRecord3(value)) continue;
512
1045
  const entry = value;
513
1046
  if (!entry.version) continue;
514
- const specs = splitClassicSpecs(key);
515
- const names = uniq(specs.map((s) => parseYarnSpec(s).name).filter(Boolean));
516
- const name = names[0];
1047
+ const resolution = entry.resolution ?? descriptor;
1048
+ if (hasLocalYarnProtocol(resolution) || hasLocalYarnProtocol(descriptor)) {
1049
+ continue;
1050
+ }
1051
+ const name = parseYarnDescriptorName(resolution) ?? parseYarnDescriptorName(descriptor);
517
1052
  if (!name) continue;
518
- const isDirect = names.some((n) => directs.any.has(n));
519
- const inProd = names.some((n) => directs.prod.has(n));
520
- const inDev = names.some((n) => directs.dev.has(n));
521
- const inOpt = names.some((n) => directs.optional.has(n));
1053
+ const direct = rootInfo.allDirect.has(name);
522
1054
  instances.push({
523
1055
  name,
524
1056
  version: entry.version,
525
1057
  ecosystem: "npm",
526
- path: `${name}@${entry.version}`,
527
- direct: isDirect,
528
- dev: inDev && !inProd,
529
- optional: inOpt && !inProd && !inDev,
530
- resolved: entry.resolution,
531
- integrity: entry.checksum
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
532
1068
  });
533
1069
  }
534
- return instances;
1070
+ return dedupeInstances(instances);
1071
+ }
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
+ );
535
1077
  }
536
- function parseYarnSpec(spec) {
537
- const startSearch = spec.startsWith("@") ? 1 : 0;
538
- const atIdx = spec.indexOf("@", startSearch);
539
- if (atIdx <= 0) return { name: spec, selector: "" };
540
- const name = spec.slice(0, atIdx);
541
- let selector = spec.slice(atIdx + 1);
542
- if (selector.startsWith("npm:")) selector = selector.slice(4);
543
- return { name, selector };
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);
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);
1093
+ }
1094
+ function isBerryLock(raw) {
1095
+ return raw.includes("__metadata:") || raw.includes("cacheKey:");
1096
+ }
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;
1115
+ }
544
1116
  }
545
- function uniq(values) {
546
- return Array.from(new Set(values));
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);
547
1124
  }
548
1125
 
549
- // src/types.ts
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
+ );
1137
+ }
1138
+
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
+ );
1160
+ }
1161
+ if (!isRecord4(parsed)) {
1162
+ throw new Error(`SBOM ${absolute} must contain a JSON object.`);
1163
+ }
1164
+ if (Array.isArray(parsed.components)) {
1165
+ return parseCycloneDxJson(absolute, raw, parsed.components);
1166
+ }
1167
+ if (Array.isArray(parsed.packages)) {
1168
+ return parseSpdxJson(absolute, raw, parsed.packages);
1169
+ }
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
+ );
1197
+ }
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));
1206
+ }
1207
+ return dedupe(instances);
1208
+ }
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));
1222
+ }
1223
+ }
1224
+ return dedupe(instances);
1225
+ }
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.`
1230
+ );
1231
+ }
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]));
1239
+ }
1240
+ return dedupe(instances);
1241
+ }
1242
+ function parsePurlPackage(purl) {
1243
+ let parsed;
1244
+ try {
1245
+ parsed = PackageURL.fromString(purl);
1246
+ } catch {
1247
+ return null;
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
+ };
1258
+ }
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);
1320
+ }
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);
1334
+ }
1335
+
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 });
1356
+ } else {
1357
+ active.push(finding);
1358
+ }
1359
+ }
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;
1448
+ try {
1449
+ packument = await fetchPackument(fetchImpl, name);
1450
+ } catch (err) {
1451
+ warnings.push(
1452
+ `Could not fetch npm publish metadata for ${name}: ${err.message}`
1453
+ );
1454
+ return;
1455
+ }
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
+ }
1498
+ );
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);
1544
+ try {
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));
1560
+ } catch (err) {
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);
1567
+ }
1568
+ }
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;
1589
+ }
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))}`;
1631
+ }
1632
+ }
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
550
1648
  var SEVERITY_RANK = {
551
1649
  critical: 4,
552
1650
  high: 3,
@@ -556,80 +1654,125 @@ var SEVERITY_RANK = {
556
1654
  };
557
1655
 
558
1656
  // src/scanner.ts
1657
+ var DEFAULT_ALLOWED_REGISTRIES = [
1658
+ "https://registry.npmjs.org",
1659
+ "https://registry.yarnpkg.com"
1660
+ ];
559
1661
  async function scanProject(options = {}) {
560
- const cwd = resolve4(options.cwd ?? process.cwd());
561
- const lockfilePath = options.lockfile ? resolve4(cwd, options.lockfile) : detectLockfile(cwd);
562
- if (!lockfilePath) {
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) {
563
1669
  throw new ScanInputError(
564
- `No supported lockfile found in ${cwd}. Pass --lockfile or run in a directory with package-lock.json, pnpm-lock.yaml, or yarn.lock.`
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.`
565
1671
  );
566
1672
  }
567
1673
  return scanLockfile({
568
- lockfilePath,
569
- includeDev: options.includeDev,
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,
570
1685
  prodOnly: options.prodOnly,
571
- fetchImpl: options.fetchImpl
1686
+ fetchImpl: options.fetchImpl,
1687
+ now: options.now
572
1688
  });
573
1689
  }
574
1690
  async function scanLockfile(options) {
575
- const { lockfilePath } = options;
576
- if (!existsSync2(lockfilePath)) {
577
- throw new ScanInputError(`Lockfile does not exist: ${lockfilePath}`);
578
- }
579
- const stat = statSync(lockfilePath);
580
- if (!stat.isFile()) {
581
- throw new ScanInputError(`Lockfile path is not a file: ${lockfilePath}`);
582
- }
583
- const allInstances = parseLockfile(lockfilePath);
584
- const instances = filterInstances(allInstances, 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
+ });
585
1708
  const errors = [];
586
- let findings = [];
1709
+ const warnings = [];
1710
+ const envResult = envEnabled ? scanEnvFiles(cwd) : { findings: [], warnings: [], filesScanned: 0 };
1711
+ warnings.push(...envResult.warnings);
1712
+ let findings = [...envResult.findings];
587
1713
  try {
588
- findings = await queryOsv(instances, { fetchImpl: options.fetchImpl });
1714
+ findings.push(...await queryOsv(instances, { fetchImpl: options.fetchImpl }));
589
1715
  } catch (err) {
590
1716
  errors.push({
591
1717
  message: "Failed to query OSV advisory database",
592
1718
  cause: err.message
593
1719
  });
594
1720
  }
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;
595
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;
1743
+ }
1744
+ if (options.writeBaseline) {
1745
+ baseline = writeBaseline(findings, cwd, options.writeBaseline, baseline);
1746
+ }
596
1747
  return {
597
- scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
1748
+ scannedAt: now.toISOString(),
598
1749
  packagesScanned: instances.length,
599
1750
  findings,
1751
+ ignoredFindings: ignoreResult.ignored,
600
1752
  summary: summarize(findings),
601
- errors
1753
+ errors,
1754
+ warnings,
1755
+ baseline
602
1756
  };
603
1757
  }
1758
+ function deriveCwdFromInputs(lockfilePaths, sbomPaths, fallback) {
1759
+ const firstInput = lockfilePaths[0] ?? sbomPaths[0];
1760
+ return firstInput ? dirname3(firstInput) : fallback;
1761
+ }
604
1762
  var ScanInputError = class extends Error {
605
1763
  constructor(message) {
606
1764
  super(message);
607
1765
  this.name = "ScanInputError";
608
1766
  }
609
1767
  };
610
- var LOCKFILE_CANDIDATES = [
611
- "pnpm-lock.yaml",
612
- "yarn.lock",
613
- "package-lock.json",
614
- "npm-shrinkwrap.json"
615
- ];
616
- function detectLockfile(cwd) {
617
- for (const file of LOCKFILE_CANDIDATES) {
618
- const candidate = join2(cwd, file);
619
- if (existsSync2(candidate)) return candidate;
620
- }
621
- return void 0;
622
- }
623
- function parseLockfile(lockfilePath) {
624
- const name = basename(lockfilePath);
625
- if (name === "pnpm-lock.yaml") return parsePnpmLock(lockfilePath);
626
- if (name === "yarn.lock") return parseYarnLock(lockfilePath);
627
- if (name === "package-lock.json" || name === "npm-shrinkwrap.json") {
628
- return parseNpmPackageLock(lockfilePath);
629
- }
630
- throw new ScanInputError(
631
- `Unsupported lockfile name: ${name}. Supported: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock.`
632
- );
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));
633
1776
  }
634
1777
  function filterInstances(instances, options) {
635
1778
  const includeDev = options.prodOnly ? false : options.includeDev !== false;
@@ -639,6 +1782,7 @@ function filterInstances(instances, options) {
639
1782
  function compareFindings(a, b) {
640
1783
  const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
641
1784
  if (sev !== 0) return sev;
1785
+ if (a.source !== b.source) return a.source.localeCompare(b.source);
642
1786
  if (a.packageName !== b.packageName) {
643
1787
  return a.packageName.localeCompare(b.packageName);
644
1788
  }
@@ -661,12 +1805,28 @@ function summarize(findings) {
661
1805
  function meetsThreshold(findings, threshold) {
662
1806
  if (threshold === "none") return false;
663
1807
  const min = SEVERITY_RANK[threshold];
664
- return findings.some((f) => SEVERITY_RANK[f.severity] >= min);
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
+ }
665
1825
  }
666
1826
 
667
1827
  // src/installer/pm-detect.ts
668
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
669
- import { join as join3 } from "path";
1828
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "fs";
1829
+ import { join as join5 } from "path";
670
1830
  var LOCKFILES = [
671
1831
  { file: "pnpm-lock.yaml", pm: "pnpm" },
672
1832
  { file: "yarn.lock", pm: "yarn" },
@@ -681,15 +1841,15 @@ function detectPackageManager(opts = {}) {
681
1841
  const fromField = readPackageManagerField(cwd);
682
1842
  if (fromField) return fromField;
683
1843
  for (const { file, pm } of LOCKFILES) {
684
- if (existsSync3(join3(cwd, file))) return pm;
1844
+ if (existsSync5(join5(cwd, file))) return pm;
685
1845
  }
686
1846
  return "npm";
687
1847
  }
688
1848
  function readPackageManagerField(cwd) {
689
- const pkgPath = join3(cwd, "package.json");
690
- if (!existsSync3(pkgPath)) return void 0;
1849
+ const pkgPath = join5(cwd, "package.json");
1850
+ if (!existsSync5(pkgPath)) return void 0;
691
1851
  try {
692
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1852
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
693
1853
  if (typeof pkg.packageManager !== "string") return void 0;
694
1854
  const name = pkg.packageManager.split("@")[0];
695
1855
  if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") {
@@ -730,14 +1890,14 @@ function buildRemoveCommand(pm, packages, flags) {
730
1890
  // src/installer/runner.ts
731
1891
  import { spawn } from "child_process";
732
1892
  function runPackageManager(cmd, opts = {}) {
733
- return new Promise((resolve5, reject) => {
1893
+ return new Promise((resolve12, reject) => {
734
1894
  const child = spawn(cmd.bin, cmd.args, {
735
1895
  cwd: opts.cwd,
736
1896
  stdio: "inherit",
737
1897
  shell: process.platform === "win32"
738
1898
  });
739
1899
  child.on("error", reject);
740
- child.on("close", (code) => resolve5(code ?? 0));
1900
+ child.on("close", (code) => resolve12(code ?? 0));
741
1901
  });
742
1902
  }
743
1903
 
@@ -797,14 +1957,14 @@ function partitionArgs(args) {
797
1957
  }
798
1958
 
799
1959
  // src/installer/version-resolver.ts
800
- var REGISTRY_URL = "https://registry.npmjs.org";
801
- var REQUEST_TIMEOUT_MS2 = 15e3;
1960
+ var REGISTRY_URL2 = "https://registry.npmjs.org";
1961
+ var REQUEST_TIMEOUT_MS3 = 15e3;
802
1962
  var EXACT_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][\w.+-]+)?$/;
803
1963
  var RANGE_CHARS_RE = /[\^~><=|\s*x]/i;
804
1964
  async function resolveVersion(name, requested, deps = {}) {
805
1965
  const fetchImpl = deps.fetchImpl ?? fetch;
806
- const registry = deps.registryUrl ?? REGISTRY_URL;
807
- const packument = await fetchPackument(fetchImpl, registry, name);
1966
+ const registry = deps.registryUrl ?? REGISTRY_URL2;
1967
+ const packument = await fetchPackument2(fetchImpl, registry, name);
808
1968
  const distTags = packument["dist-tags"] ?? {};
809
1969
  const latest = distTags.latest;
810
1970
  if (!requested) {
@@ -843,10 +2003,10 @@ var VersionResolveError = class extends Error {
843
2003
  this.name = "VersionResolveError";
844
2004
  }
845
2005
  };
846
- async function fetchPackument(fetchImpl, registry, name) {
847
- const url = `${registry.replace(/\/$/, "")}/${encodePackageName(name)}`;
2006
+ async function fetchPackument2(fetchImpl, registry, name) {
2007
+ const url = `${registry.replace(/\/$/, "")}/${encodePackageName2(name)}`;
848
2008
  const controller = new AbortController();
849
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS2);
2009
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS3);
850
2010
  try {
851
2011
  const res = await fetchImpl(url, {
852
2012
  signal: controller.signal,
@@ -865,7 +2025,7 @@ async function fetchPackument(fetchImpl, registry, name) {
865
2025
  clearTimeout(timer);
866
2026
  }
867
2027
  }
868
- function encodePackageName(name) {
2028
+ function encodePackageName2(name) {
869
2029
  if (name.startsWith("@")) {
870
2030
  const slash = name.indexOf("/");
871
2031
  if (slash !== -1) {
@@ -1014,105 +2174,897 @@ function shouldBlock(findings, options) {
1014
2174
  const threshold = SEVERITY_RANK[options.failOn];
1015
2175
  return findings.some((f) => SEVERITY_RANK[f.severity] >= threshold);
1016
2176
  }
1017
- function describeUnsupported(reason) {
1018
- switch (reason) {
1019
- case "git":
1020
- return "git specs cannot be scanned against OSV";
1021
- case "url":
1022
- return "URL specs cannot be scanned against OSV";
1023
- case "file":
1024
- return "local file specs cannot be scanned against OSV";
1025
- case "alias":
1026
- return "npm aliases are not supported in v1";
1027
- case "workspace":
1028
- return "workspace protocol specs are not scanned";
1029
- case "invalid":
1030
- return "could not parse spec";
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
+ });
1031
2987
  }
2988
+ return [...map.values()];
1032
2989
  }
1033
- function describeSeverityCounts(summary) {
1034
- const parts = [];
1035
- for (const sev of ["critical", "high", "moderate", "low", "unknown"]) {
1036
- if (summary[sev] > 0) parts.push(`${summary[sev]} ${sev}`);
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
+ ];
1037
3023
  }
1038
- return parts.length === 0 ? "no advisories" : `${parts.join(", ")} advisor${total(summary) === 1 ? "y" : "ies"}`;
3024
+ return result;
1039
3025
  }
1040
- function total(summary) {
1041
- return Object.values(summary).reduce((a, b) => a + b, 0);
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}`;
1042
3033
  }
1043
- function colorSeverity(sev) {
1044
- switch (sev) {
3034
+ function sarifLevel(finding) {
3035
+ if (finding.severity === "critical" || finding.severity === "high") {
3036
+ return "error";
3037
+ }
3038
+ if (finding.severity === "moderate" || finding.severity === "low") {
3039
+ return "warning";
3040
+ }
3041
+ return "note";
3042
+ }
3043
+ function securitySeverity(finding) {
3044
+ switch (finding.severity) {
1045
3045
  case "critical":
1046
- return kleur.bold().red(sev);
3046
+ return "9.5";
1047
3047
  case "high":
1048
- return kleur.red(sev);
3048
+ return "8.0";
1049
3049
  case "moderate":
1050
- return kleur.yellow(sev);
3050
+ return "5.0";
1051
3051
  case "low":
1052
- return kleur.cyan(sev);
3052
+ return "2.0";
1053
3053
  case "unknown":
1054
- return kleur.gray(sev);
3054
+ return "0.0";
1055
3055
  }
1056
3056
  }
1057
3057
 
1058
- // src/reporters/json.ts
1059
- function reportJson(result) {
1060
- return JSON.stringify(result, null, 2);
1061
- }
1062
-
1063
- // src/reporters/table.ts
1064
- import kleur3 from "kleur";
1065
-
1066
- // src/reporters/banner.ts
1067
- import kleur2 from "kleur";
1068
- var BORDER = {
1069
- tl: "\u250C",
1070
- tr: "\u2510",
1071
- bl: "\u2514",
1072
- br: "\u2518",
1073
- h: "\u2500",
1074
- v: "\u2502"
1075
- };
1076
- var TITLE = "trawly";
1077
- var SIDE_PAD = 1;
1078
- var MIN_TITLE_FILLER = 4;
1079
- function renderBanner(props) {
1080
- const contentRows = [props.metrics, props.timestamp];
1081
- const titleSegment = ` ${TITLE} `;
1082
- const minTitleWidth = 1 + titleSegment.length + MIN_TITLE_FILLER;
1083
- const innerWidth = Math.max(
1084
- ...contentRows.map((r) => r.length + SIDE_PAD * 2),
1085
- minTitleWidth
1086
- );
1087
- const top = renderTop(innerWidth);
1088
- const bottom = renderBottom(innerWidth);
1089
- const metricsLine = renderRow(innerWidth, props.metrics, kleur2.bold);
1090
- const timestampLine = renderRow(innerWidth, props.timestamp, kleur2.gray);
1091
- return [top, metricsLine, timestampLine, bottom].join("\n");
1092
- }
1093
- function renderTop(innerWidth) {
1094
- const titleSegment = ` ${kleur2.bold().cyan(TITLE)} `;
1095
- const titleVisibleLen = TITLE.length + 2;
1096
- const fillerCount = innerWidth - 1 - titleVisibleLen;
1097
- 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);
1098
- }
1099
- function renderBottom(innerWidth) {
1100
- return kleur2.gray(`${BORDER.bl}${BORDER.h.repeat(innerWidth)}${BORDER.br}`);
1101
- }
1102
- function renderRow(innerWidth, content, colorize) {
1103
- const fill = Math.max(0, innerWidth - SIDE_PAD * 2 - content.length);
1104
- return kleur2.gray(BORDER.v) + " ".repeat(SIDE_PAD) + colorize(content) + " ".repeat(fill + SIDE_PAD) + kleur2.gray(BORDER.v);
1105
- }
1106
-
1107
3058
  // src/reporters/table.ts
1108
- var SEVERITY_COLOR = {
1109
- critical: (s) => kleur3.bold().red(s),
1110
- high: (s) => kleur3.red(s),
1111
- moderate: (s) => kleur3.yellow(s),
1112
- low: (s) => kleur3.cyan(s),
1113
- unknown: (s) => kleur3.gray(s)
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)
1114
3066
  };
1115
- var SEVERITY_ORDER = [
3067
+ var SEVERITY_ORDER2 = [
1116
3068
  "critical",
1117
3069
  "high",
1118
3070
  "moderate",
@@ -1122,35 +3074,54 @@ var SEVERITY_ORDER = [
1122
3074
  function reportTable(result, options = {}) {
1123
3075
  const view = options.view ?? (options.details ? "details" : "grouped");
1124
3076
  const lines = [];
3077
+ const warnings = result.warnings ?? [];
3078
+ const ignoredFindings = result.ignoredFindings ?? [];
1125
3079
  if (options.brand) {
1126
- const { metricsLine, timestamp } = headerParts(result);
3080
+ const { metricsLine, timestamp } = headerParts2(result);
1127
3081
  lines.push(renderBanner({ metrics: metricsLine, timestamp }));
1128
3082
  } else {
1129
- lines.push(kleur3.bold(formatHeader(result)));
3083
+ lines.push(kleur4.bold(formatHeader2(result)));
1130
3084
  }
1131
3085
  if (result.errors.length > 0) {
1132
3086
  for (const err of result.errors) {
1133
3087
  lines.push(
1134
- kleur3.red(`! ${err.message}${err.cause ? ` (${err.cause})` : ""}`)
3088
+ kleur4.red(`! ${err.message}${err.cause ? ` (${err.cause})` : ""}`)
1135
3089
  );
1136
3090
  }
1137
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
+ }
1138
3109
  if (result.findings.length === 0) {
1139
- lines.push(kleur3.green("\u2713 No known advisories found."));
3110
+ lines.push(kleur4.green("\u2713 No active findings."));
1140
3111
  lines.push(
1141
- kleur3.gray(
1142
- " Note: this only checks known advisories. It cannot prove a package is safe."
3112
+ kleur4.gray(
3113
+ " Note: absence of findings is not proof of safety."
1143
3114
  )
1144
3115
  );
1145
3116
  return lines.join("\n");
1146
3117
  }
1147
3118
  if (view === "summary") {
1148
- lines.push(formatSummary(result.summary));
3119
+ lines.push(formatSummary3(result.summary));
1149
3120
  lines.push(reminder());
1150
3121
  return lines.join("\n");
1151
3122
  }
1152
3123
  lines.push("");
1153
- lines.push(formatSummary(result.summary));
3124
+ lines.push(formatSummary3(result.summary));
1154
3125
  lines.push("");
1155
3126
  if (view === "details") {
1156
3127
  lines.push(formatDetailRows(sortFindings(result.findings)));
@@ -1159,42 +3130,42 @@ function reportTable(result, options = {}) {
1159
3130
  lines.push(formatGroupedRows(groups));
1160
3131
  lines.push("");
1161
3132
  lines.push(
1162
- kleur3.gray("Run `trawly scan --details` to see individual advisories.")
3133
+ kleur4.gray("Run `trawly scan --details` to see individual findings.")
1163
3134
  );
1164
3135
  }
1165
3136
  lines.push("");
1166
3137
  lines.push(reminder());
1167
3138
  return lines.join("\n");
1168
3139
  }
1169
- function formatHeader(result) {
1170
- const { metricsLine, timestamp } = headerParts(result);
3140
+ function formatHeader2(result) {
3141
+ const { metricsLine, timestamp } = headerParts2(result);
1171
3142
  return `trawly: ${metricsLine} (${timestamp})`;
1172
3143
  }
1173
- function headerParts(result) {
1174
- const vulnerable = new Set(
3144
+ function headerParts2(result) {
3145
+ const affected = new Set(
1175
3146
  result.findings.map((f) => `${f.packageName}@${f.installedVersion}`)
1176
3147
  ).size;
1177
- const advisories = result.findings.length;
3148
+ const findingCount = result.findings.length;
1178
3149
  const metricsLine = [
1179
3150
  `${result.packagesScanned} packages`,
1180
- `${vulnerable} vulnerable`,
1181
- `${advisories} ${advisories === 1 ? "advisory" : "advisories"}`
3151
+ `${affected} affected`,
3152
+ `${findingCount} ${findingCount === 1 ? "finding" : "findings"}`
1182
3153
  ].join(" \xB7 ");
1183
3154
  return { metricsLine, timestamp: result.scannedAt };
1184
3155
  }
1185
- function formatSummary(summary) {
3156
+ function formatSummary3(summary) {
1186
3157
  const parts = [];
1187
- for (const sev of SEVERITY_ORDER) {
3158
+ for (const sev of SEVERITY_ORDER2) {
1188
3159
  const count = summary[sev];
1189
3160
  if (count === 0) continue;
1190
- parts.push(SEVERITY_COLOR[sev](`${sev}: ${count}`));
3161
+ parts.push(SEVERITY_COLOR2[sev](`${sev}: ${count}`));
1191
3162
  }
1192
- if (parts.length === 0) return kleur3.green("No findings.");
3163
+ if (parts.length === 0) return kleur4.green("No findings.");
1193
3164
  return `Findings : ${parts.join(" ")}`;
1194
3165
  }
1195
3166
  function reminder() {
1196
- return kleur3.gray(
1197
- "Reminder: trawly reports known advisories only. Absence of findings is not proof of safety."
3167
+ return kleur4.gray(
3168
+ "Reminder: absence of findings is not proof of safety."
1198
3169
  );
1199
3170
  }
1200
3171
  function sortFindings(findings) {
@@ -1252,8 +3223,8 @@ function formatGroupedRows(groups) {
1252
3223
  g.recommendedFix ? `>=${g.recommendedFix}` : ":"
1253
3224
  ]);
1254
3225
  }
1255
- return renderTable(rows, (rowIdx, _row, cells) => {
1256
- if (rowIdx === 0) return kleur3.bold().underline(cells.join(" "));
3226
+ return renderTable2(rows, (rowIdx, _row, cells) => {
3227
+ if (rowIdx === 0) return kleur4.bold().underline(cells.join(" "));
1257
3228
  return cells.join(" ");
1258
3229
  });
1259
3230
  }
@@ -1268,32 +3239,32 @@ function formatDetailRows(findings) {
1268
3239
  f.installedVersion,
1269
3240
  f.id,
1270
3241
  f.fixedVersions.length ? f.fixedVersions.join(", ") : ":",
1271
- truncate2(f.summary, 70)
3242
+ truncate3(f.summary, 70)
1272
3243
  ]);
1273
3244
  }
1274
- return renderTable(rows, (rowIdx, row, cells) => {
1275
- if (rowIdx === 0) return kleur3.bold().underline(cells.join(" "));
3245
+ return renderTable2(rows, (rowIdx, row, cells) => {
3246
+ if (rowIdx === 0) return kleur4.bold().underline(cells.join(" "));
1276
3247
  const sev = row[0];
1277
- const colorize = SEVERITY_COLOR[sev] ?? ((s) => s);
3248
+ const colorize = SEVERITY_COLOR2[sev] ?? ((s) => s);
1278
3249
  cells[0] = colorize(cells[0]);
1279
3250
  return cells.join(" ");
1280
3251
  });
1281
3252
  }
1282
3253
  function formatSeverityCounts(counts) {
1283
3254
  const parts = [];
1284
- for (const sev of SEVERITY_ORDER) {
3255
+ for (const sev of SEVERITY_ORDER2) {
1285
3256
  const n = counts[sev];
1286
3257
  if (n === 0) continue;
1287
- parts.push(SEVERITY_COLOR[sev](`${n} ${sev}`));
3258
+ parts.push(SEVERITY_COLOR2[sev](`${n} ${sev}`));
1288
3259
  }
1289
3260
  return parts.join(", ");
1290
3261
  }
1291
- function renderTable(rows, format) {
3262
+ function renderTable2(rows, format) {
1292
3263
  const widths = rows[0].map(
1293
- (_, col) => Math.max(...rows.map((r) => visibleLength(r[col])))
3264
+ (_, col) => Math.max(...rows.map((r) => visibleLength2(r[col])))
1294
3265
  );
1295
3266
  return rows.map((row, i) => {
1296
- const cells = row.map((cell, col) => padEndVisible(cell, widths[col]));
3267
+ const cells = row.map((cell, col) => padEndVisible2(cell, widths[col]));
1297
3268
  return format(i, row, cells);
1298
3269
  }).join("\n");
1299
3270
  }
@@ -1321,28 +3292,96 @@ function parseSemverParts(v) {
1321
3292
  if (!m) return [0, 0, 0];
1322
3293
  return [Number(m[1]), Number(m[2]), Number(m[3])];
1323
3294
  }
1324
- function truncate2(s, max) {
3295
+ function truncate3(s, max) {
1325
3296
  if (s.length <= max) return s;
1326
3297
  return `${s.slice(0, max - 1)}\u2026`;
1327
3298
  }
1328
- var ANSI_RE = /\u001B\[[0-9;]*m/g;
1329
- function visibleLength(s) {
1330
- return s.replace(ANSI_RE, "").length;
3299
+ var ANSI_RE2 = /\u001B\[[0-9;]*m/g;
3300
+ function visibleLength2(s) {
3301
+ return s.replace(ANSI_RE2, "").length;
1331
3302
  }
1332
- function padEndVisible(s, width) {
1333
- const pad = Math.max(0, width - visibleLength(s));
3303
+ function padEndVisible2(s, width) {
3304
+ const pad = Math.max(0, width - visibleLength2(s));
1334
3305
  return s + " ".repeat(pad);
1335
3306
  }
1336
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
+
1337
3374
  // src/cli.ts
1338
- var FAIL_ON_VALUES = [
3375
+ var FAIL_ON_VALUES2 = [
1339
3376
  "critical",
1340
3377
  "high",
1341
3378
  "moderate",
1342
3379
  "low",
1343
3380
  "none"
1344
3381
  ];
1345
- 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"];
1346
3385
  var PM_VALUES = ["npm", "pnpm", "yarn", "bun"];
1347
3386
  var EXIT = {
1348
3387
  ok: 0,
@@ -1351,9 +3390,9 @@ var EXIT = {
1351
3390
  invalidInput: 3
1352
3391
  };
1353
3392
  function parseFailOn(value) {
1354
- if (!FAIL_ON_VALUES.includes(value)) {
3393
+ if (!FAIL_ON_VALUES2.includes(value)) {
1355
3394
  throw new InvalidArgumentError(
1356
- `must be one of: ${FAIL_ON_VALUES.join(", ")}`
3395
+ `must be one of: ${FAIL_ON_VALUES2.join(", ")}`
1357
3396
  );
1358
3397
  }
1359
3398
  return value;
@@ -1366,13 +3405,32 @@ function parseFormat(value) {
1366
3405
  }
1367
3406
  return value;
1368
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
+ }
1369
3424
  function parsePm(value) {
1370
3425
  if (!PM_VALUES.includes(value)) {
1371
3426
  throw new InvalidArgumentError(`must be one of: ${PM_VALUES.join(", ")}`);
1372
3427
  }
1373
3428
  return value;
1374
3429
  }
1375
- var TRAWLY_VERSION = "0.1.0";
3430
+ function collectOption(value, previous = []) {
3431
+ previous.push(value);
3432
+ return previous;
3433
+ }
1376
3434
  var program = new Command();
1377
3435
  program.name("trawly").description(
1378
3436
  "Dependency sanity scanner. Checks installed npm packages against the OSV advisory database."
@@ -1387,54 +3445,87 @@ program.command("scan", { isDefault: true }).description(
1387
3445
  "Scan a project and gate on findings. Exits non-zero when --fail-on is met. Use `inspect` for a log-only run."
1388
3446
  ).argument("[path]", "Project directory to scan", ".").option(
1389
3447
  "--lockfile <path>",
1390
- "Explicit path to a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock)"
1391
- ).option(
3448
+ "Explicit lockfile path. May be repeated.",
3449
+ collectOption
3450
+ ).option("--sbom <path>", "Explicit SPDX/CycloneDX SBOM path. May be repeated.", collectOption).option(
1392
3451
  "--format <format>",
1393
- "Output format: table | json",
3452
+ "Output format: table | json | markdown | sarif",
1394
3453
  parseFormat,
1395
3454
  "table"
1396
3455
  ).option(
1397
3456
  "--fail-on <level>",
1398
- `Exit non-zero when a finding meets this severity (${FAIL_ON_VALUES.join("|")})`,
1399
- parseFailOn,
1400
- "high"
1401
- ).option("--prod", "Only scan production dependencies (excludes dev)").option("--include-dev", "Include dev dependencies (default)").option("--no-cache", "Bypass any local cache").option(
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(
1402
3464
  "-v, --details",
1403
3465
  "Show one row per advisory (full table). Default groups by package."
1404
3466
  ).option(
1405
3467
  "-q, --summary",
1406
3468
  "Show only the one-line severity summary. Mutually exclusive with --details."
1407
- ).action(async (path, opts) => {
1408
- await runScanCommand(path, opts, { gate: true });
3469
+ ).action(async (path, opts, command) => {
3470
+ await runScanCommand(path, normalizeScanOptions(opts, command), {
3471
+ gate: true
3472
+ });
1409
3473
  });
1410
3474
  program.command("inspect").description(
1411
3475
  "Scan a project and print findings without gating. Always exits 0 unless an operational error occurs. Use `scan` for CI gating."
1412
3476
  ).argument("[path]", "Project directory to scan", ".").option(
1413
3477
  "--lockfile <path>",
1414
- "Explicit path to a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock)"
1415
- ).option(
3478
+ "Explicit lockfile path. May be repeated.",
3479
+ collectOption
3480
+ ).option("--sbom <path>", "Explicit SPDX/CycloneDX SBOM path. May be repeated.", collectOption).option(
1416
3481
  "--format <format>",
1417
- "Output format: table | json",
3482
+ "Output format: table | json | markdown | sarif",
1418
3483
  parseFormat,
1419
3484
  "table"
1420
- ).option("--prod", "Only scan production dependencies (excludes dev)").option("--include-dev", "Include dev dependencies (default)").option("--no-cache", "Bypass any local cache").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(
1421
3490
  "-v, --details",
1422
3491
  "Show one row per advisory (full table). Default groups by package."
1423
3492
  ).option(
1424
3493
  "-q, --summary",
1425
3494
  "Show only the one-line severity summary. Mutually exclusive with --details."
1426
- ).action(async (path, opts) => {
3495
+ ).action(async (path, opts, command) => {
1427
3496
  await runScanCommand(
1428
3497
  path,
1429
- { ...opts, failOn: "none" },
3498
+ {
3499
+ ...normalizeScanOptions(opts, command),
3500
+ failOn: "none"
3501
+ },
1430
3502
  { gate: false }
1431
3503
  );
1432
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
+ });
1433
3524
  program.command("add").description(
1434
3525
  "Resolve, scan, and install packages. Vulnerable packages are blocked; clean ones are forwarded to your package manager."
1435
3526
  ).argument("<args...>", "Packages to add (e.g. next vitest@1) : PM flags after the first package are passed through").option(
1436
3527
  "--fail-on <level>",
1437
- `Block install when a finding meets this severity (${FAIL_ON_VALUES.join("|")})`,
3528
+ `Block install when a finding meets this severity (${FAIL_ON_VALUES2.join("|")})`,
1438
3529
  parseFailOn,
1439
3530
  "high"
1440
3531
  ).option(
@@ -1451,7 +3542,7 @@ program.command("install").alias("i").description(
1451
3542
  "Run the project's package manager install. With package args, behaves like `add` (gates on vulnerabilities). With none, forwards directly."
1452
3543
  ).argument("[args...]", "Optional packages to add").option(
1453
3544
  "--fail-on <level>",
1454
- `Block install when a finding meets this severity (${FAIL_ON_VALUES.join("|")})`,
3545
+ `Block install when a finding meets this severity (${FAIL_ON_VALUES2.join("|")})`,
1455
3546
  parseFailOn,
1456
3547
  "high"
1457
3548
  ).option(
@@ -1463,7 +3554,7 @@ program.command("install").alias("i").description(
1463
3554
  const pm = detectPackageManager({ override: opts.pm });
1464
3555
  const command = buildInstallCommand(pm, []);
1465
3556
  process.stdout.write(
1466
- kleur4.gray(`> ${command.bin} ${command.args.join(" ")}
3557
+ kleur5.gray(`> ${command.bin} ${command.args.join(" ")}
1467
3558
  `)
1468
3559
  );
1469
3560
  try {
@@ -1476,6 +3567,21 @@ program.command("install").alias("i").description(
1476
3567
  }
1477
3568
  await executeAdd(args, opts);
1478
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
+ });
1479
3585
  program.command("remove").alias("uninstall").description(
1480
3586
  "Remove packages by delegating to the project's package manager (no scan)."
1481
3587
  ).argument("<args...>", "Packages to remove").option(
@@ -1487,7 +3593,7 @@ program.command("remove").alias("uninstall").description(
1487
3593
  const { specs, flags } = splitArgs(args);
1488
3594
  const command = buildRemoveCommand(pm, specs, flags);
1489
3595
  process.stdout.write(
1490
- kleur4.gray(`> ${command.bin} ${command.args.join(" ")}
3596
+ kleur5.gray(`> ${command.bin} ${command.args.join(" ")}
1491
3597
  `)
1492
3598
  );
1493
3599
  try {
@@ -1498,6 +3604,45 @@ program.command("remove").alias("uninstall").description(
1498
3604
  process.exit(EXIT.operational);
1499
3605
  }
1500
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
+ }
1501
3646
  async function runScanCommand(path, opts, { gate }) {
1502
3647
  if (opts.prod && opts.includeDev) {
1503
3648
  printErr("Cannot combine --prod and --include-dev. Choose one.");
@@ -1508,29 +3653,34 @@ async function runScanCommand(path, opts, { gate }) {
1508
3653
  process.exit(EXIT.invalidInput);
1509
3654
  }
1510
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";
1511
3660
  const result = await scanProject({
1512
- cwd: path,
3661
+ cwd,
1513
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,
1514
3670
  includeDev: opts.includeDev,
1515
- prodOnly: opts.prod,
1516
- cache: opts.cache
3671
+ prodOnly: opts.prod
1517
3672
  });
1518
- if (opts.format === "json") {
1519
- process.stdout.write(`${reportJson(result)}
1520
- `);
1521
- } else {
1522
- const view = opts.summary ? "summary" : opts.details ? "details" : "grouped";
1523
- const brand = process.stdout.isTTY === true;
1524
- 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}
1525
3676
  `);
1526
- }
1527
3677
  if (result.errors.length > 0) {
1528
3678
  process.exit(EXIT.operational);
1529
3679
  }
1530
3680
  if (!gate) {
1531
- if (opts.format !== "json" && result.findings.length > 0) {
3681
+ if (opts.format === "table" && !opts.output && result.findings.length > 0) {
1532
3682
  process.stdout.write(
1533
- `${kleur4.gray(
3683
+ `${kleur5.gray(
1534
3684
  "\u2139 inspect mode: exiting 0 regardless of findings. Run `trawly scan` to gate CI."
1535
3685
  )}
1536
3686
  `
@@ -1538,13 +3688,13 @@ async function runScanCommand(path, opts, { gate }) {
1538
3688
  }
1539
3689
  process.exit(EXIT.ok);
1540
3690
  }
1541
- if (meetsThreshold(result.findings, opts.failOn)) {
3691
+ if (meetsThreshold(result.findings, failOn)) {
1542
3692
  if (opts.format !== "json") {
1543
3693
  process.stderr.write(
1544
- `${kleur4.red(
1545
- `\xD7 Failing because at least one finding meets --fail-on=${opts.failOn}.`
3694
+ `${kleur5.red(
3695
+ `\xD7 Failing because at least one finding meets --fail-on=${failOn}.`
1546
3696
  )}
1547
- ${kleur4.gray(
3697
+ ${kleur5.gray(
1548
3698
  " Run `trawly inspect` to log without exiting non-zero, or `trawly scan --fail-on=none` to disable the gate."
1549
3699
  )}
1550
3700
  `
@@ -1554,7 +3704,48 @@ ${kleur4.gray(
1554
3704
  }
1555
3705
  process.exit(EXIT.ok);
1556
3706
  } catch (err) {
1557
- 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) {
1558
3749
  printErr(err.message);
1559
3750
  process.exit(EXIT.invalidInput);
1560
3751
  }
@@ -1562,6 +3753,51 @@ ${kleur4.gray(
1562
3753
  process.exit(EXIT.operational);
1563
3754
  }
1564
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
+ }
1565
3801
  async function executeAdd(args, opts) {
1566
3802
  try {
1567
3803
  const result = await runAdd(args, {
@@ -1591,7 +3827,28 @@ function splitArgs(args) {
1591
3827
  return { specs, flags };
1592
3828
  }
1593
3829
  function printErr(msg) {
1594
- process.stderr.write(`${kleur4.red(msg)}
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}
1595
3852
  `);
1596
3853
  }
1597
3854
  await program.parseAsync(process.argv);