trawly 0.0.1 → 0.1.1

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