vetguard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,52 @@
1
+ // src/core/ignore.ts
2
+ function matches(finding, ignore) {
3
+ return finding.ruleId === ignore.rule && finding.packageName === ignore.package;
4
+ }
5
+ function applyIgnores(findings, ignores) {
6
+ if (ignores.length === 0) return { active: [...findings], suppressed: [] };
7
+ const active = [];
8
+ const suppressed = [];
9
+ for (const finding of findings) {
10
+ const ignore = ignores.find((rule) => matches(finding, rule));
11
+ if (ignore) {
12
+ suppressed.push({ ...finding, suppressedReason: ignore.reason });
13
+ } else {
14
+ active.push(finding);
15
+ }
16
+ }
17
+ return { active, suppressed };
18
+ }
19
+
20
+ // src/core/baseline.ts
21
+ var BASELINE_REASON = "in baseline (pre-existing)";
22
+ function matches2(finding, entry) {
23
+ return finding.ruleId === entry.rule && finding.packageName === entry.package && (finding.packageVersion ?? "") === (entry.version ?? "");
24
+ }
25
+ function applyBaseline(findings, baseline) {
26
+ if (baseline.length === 0) return { active: [...findings], suppressed: [] };
27
+ const active = [];
28
+ const suppressed = [];
29
+ for (const finding of findings) {
30
+ if (baseline.some((entry) => matches2(finding, entry))) {
31
+ suppressed.push({ ...finding, suppressedReason: BASELINE_REASON });
32
+ } else {
33
+ active.push(finding);
34
+ }
35
+ }
36
+ return { active, suppressed };
37
+ }
38
+ function toBaselineEntries(findings) {
39
+ return findings.map((f) => ({
40
+ rule: f.ruleId,
41
+ package: f.packageName,
42
+ ...f.packageVersion === void 0 ? {} : { version: f.packageVersion }
43
+ }));
44
+ }
45
+
46
+ // src/config.ts
47
+ import { readFile } from "fs/promises";
48
+ import path from "path";
49
+
1
50
  // src/core/model.ts
2
51
  var SEVERITY_ORDER = {
3
52
  critical: 4,
@@ -7,24 +56,148 @@ var SEVERITY_ORDER = {
7
56
  info: 0
8
57
  };
9
58
 
59
+ // src/config.ts
60
+ var CONFIG_FILENAME = "vetguard.config.json";
61
+ var ConfigError = class extends Error {
62
+ };
63
+ function isSeverity(value) {
64
+ return typeof value === "string" && value in SEVERITY_ORDER;
65
+ }
66
+ function validateIgnore(entry, index) {
67
+ if (typeof entry !== "object" || entry === null) {
68
+ throw new ConfigError(`ignore[${index}] must be an object`);
69
+ }
70
+ const { rule, package: pkg2, reason } = entry;
71
+ if (typeof rule !== "string" || rule.length === 0) {
72
+ throw new ConfigError(`ignore[${index}].rule is required (the detector id to suppress)`);
73
+ }
74
+ if (typeof pkg2 !== "string" || pkg2.length === 0) {
75
+ throw new ConfigError(`ignore[${index}].package is required (the package name to suppress)`);
76
+ }
77
+ if (typeof reason !== "string" || reason.trim().length === 0) {
78
+ throw new ConfigError(
79
+ `ignore[${index}].reason is required and must be non-empty (${rule} on ${pkg2}); a suppression must be explained`
80
+ );
81
+ }
82
+ return { rule, package: pkg2, reason };
83
+ }
84
+ function validate(raw) {
85
+ if (typeof raw !== "object" || raw === null) {
86
+ throw new ConfigError(`${CONFIG_FILENAME} must contain a JSON object`);
87
+ }
88
+ const obj = raw;
89
+ const config = {};
90
+ if (obj.failOn !== void 0) {
91
+ if (!isSeverity(obj.failOn)) {
92
+ throw new ConfigError(`failOn must be one of ${Object.keys(SEVERITY_ORDER).join(", ")}`);
93
+ }
94
+ config.failOn = obj.failOn;
95
+ }
96
+ if (obj.offline !== void 0) {
97
+ if (typeof obj.offline !== "boolean") throw new ConfigError("offline must be a boolean");
98
+ config.offline = obj.offline;
99
+ }
100
+ if (obj.ignore !== void 0) {
101
+ if (!Array.isArray(obj.ignore)) throw new ConfigError("ignore must be an array");
102
+ config.ignore = obj.ignore.map(validateIgnore);
103
+ }
104
+ return config;
105
+ }
106
+ async function loadConfig(dir) {
107
+ const configPath = path.join(dir, CONFIG_FILENAME);
108
+ let text;
109
+ try {
110
+ text = await readFile(configPath, "utf8");
111
+ } catch {
112
+ return void 0;
113
+ }
114
+ let raw;
115
+ try {
116
+ raw = JSON.parse(text);
117
+ } catch (err) {
118
+ throw new ConfigError(
119
+ `${configPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
120
+ );
121
+ }
122
+ return validate(raw);
123
+ }
124
+
125
+ // src/baseline-io.ts
126
+ import { readFile as readFile2, writeFile } from "fs/promises";
127
+ import path2 from "path";
128
+ var BASELINE_FILENAME = ".vetguard-baseline.json";
129
+ var BASELINE_SCHEMA_VERSION = 1;
130
+ var BaselineError = class extends Error {
131
+ };
132
+ function validateEntry(entry, index) {
133
+ if (typeof entry !== "object" || entry === null) {
134
+ throw new BaselineError(`${BASELINE_FILENAME} findings[${index}] must be an object`);
135
+ }
136
+ const { rule, package: pkg2, version } = entry;
137
+ if (typeof rule !== "string" || typeof pkg2 !== "string") {
138
+ throw new BaselineError(
139
+ `${BASELINE_FILENAME} findings[${index}] needs string rule and package`
140
+ );
141
+ }
142
+ if (version !== void 0 && typeof version !== "string") {
143
+ throw new BaselineError(`${BASELINE_FILENAME} findings[${index}].version must be a string`);
144
+ }
145
+ return { rule, package: pkg2, ...version === void 0 ? {} : { version } };
146
+ }
147
+ async function readBaseline(dir) {
148
+ const filePath = path2.join(dir, BASELINE_FILENAME);
149
+ let text;
150
+ try {
151
+ text = await readFile2(filePath, "utf8");
152
+ } catch {
153
+ return void 0;
154
+ }
155
+ let raw;
156
+ try {
157
+ raw = JSON.parse(text);
158
+ } catch (err) {
159
+ throw new BaselineError(
160
+ `${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
161
+ );
162
+ }
163
+ if (typeof raw !== "object" || raw === null || !Array.isArray(raw.findings)) {
164
+ throw new BaselineError(`${filePath} must be an object with a findings array`);
165
+ }
166
+ return raw.findings.map(validateEntry);
167
+ }
168
+ async function writeBaseline(dir, findings, generatedAt) {
169
+ const filePath = path2.join(dir, BASELINE_FILENAME);
170
+ const file = {
171
+ schemaVersion: BASELINE_SCHEMA_VERSION,
172
+ generatedAt,
173
+ findings
174
+ };
175
+ await writeFile(filePath, JSON.stringify(file, null, 2) + "\n", "utf8");
176
+ return filePath;
177
+ }
178
+
10
179
  // src/core/engine.ts
11
180
  function runDetectors(facts, detectors, context) {
12
- const findings = [];
181
+ const raw = [];
13
182
  for (const pkg2 of facts) {
14
183
  for (const detector of detectors) {
15
- findings.push(...detector.detect(pkg2));
184
+ raw.push(...detector.detect(pkg2));
16
185
  }
17
186
  }
18
- findings.sort(
187
+ raw.sort(
19
188
  (a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || a.packageName.localeCompare(b.packageName)
20
189
  );
21
- const verdict = decideVerdict(findings, context.unverified);
190
+ const ignored = applyIgnores(raw, context.ignore ?? []);
191
+ const baselined = applyBaseline(ignored.active, context.baseline ?? []);
192
+ const suppressed = [...ignored.suppressed, ...baselined.suppressed];
193
+ const verdict = decideVerdict(baselined.active, context.unverified);
22
194
  return {
23
195
  verdict,
24
196
  target: context.target,
25
197
  ecosystem: context.ecosystem,
26
198
  packagesScanned: facts.length,
27
- findings,
199
+ findings: baselined.active,
200
+ ...suppressed.length > 0 ? { suppressed } : {},
28
201
  unverified: context.unverified,
29
202
  generatedAt: context.generatedAt
30
203
  };
@@ -330,9 +503,25 @@ function createTyposquatDetector(corpus = defaultCorpus) {
330
503
  if (pkg2.source !== "registry") return [];
331
504
  if (pkg2.name.startsWith("@")) return [];
332
505
  if (corpus.has(pkg2.name)) return [];
333
- if (pkg2.existsOnRegistry === void 0) return [];
506
+ const offline = pkg2.existsOnRegistry === void 0 && pkg2.existenceUnverifiedReason === "offline";
507
+ if (pkg2.existsOnRegistry === void 0 && !offline) return [];
334
508
  const near = corpus.findNearMiss(pkg2.name);
335
509
  if (!near) return [];
510
+ if (offline) {
511
+ return [
512
+ {
513
+ ruleId: this.id,
514
+ severity: "low",
515
+ confidence: "low",
516
+ packageName: pkg2.name,
517
+ ...pkg2.version === void 0 ? {} : { packageVersion: pkg2.version },
518
+ title: "Name closely resembles a popular package",
519
+ detail: `This name differs from the popular package "${near.target}" by a single ${near.transform}. Attackers register look-alikes to catch typos and hallucinated names. Confirm you meant this package and not "${near.target}".`,
520
+ evidence: `resembles "${near.target}" (popularity rank ${near.rank + 1}) via ${near.transform}; existence and adoption unverified (offline scan)`,
521
+ ...pkg2.evidencePath === void 0 ? {} : { location: pkg2.evidencePath }
522
+ }
523
+ ];
524
+ }
336
525
  const downloads = pkg2.weeklyDownloads;
337
526
  const established = downloads !== void 0 && downloads >= TYPOSQUAT_POPULAR_FLOOR;
338
527
  if (pkg2.existsOnRegistry === true && established) return [];
@@ -388,9 +577,26 @@ function createHallucinationNameDetector(corpus = defaultCorpus) {
388
577
  if (pkg2.source !== "registry") return [];
389
578
  if (pkg2.name.startsWith("@")) return [];
390
579
  if (corpus.has(pkg2.name)) return [];
391
- if (pkg2.existsOnRegistry === void 0) return [];
580
+ const offline = pkg2.existsOnRegistry === void 0 && pkg2.existenceUnverifiedReason === "offline";
581
+ if (pkg2.existsOnRegistry === void 0 && !offline) return [];
392
582
  const match = corpus.findRecombination(pkg2.name);
393
583
  if (!match) return [];
584
+ const how = match.kind === "affix-drop" ? `drops the convention prefix of "${match.victim}"` : `reorders the tokens of "${match.victim}"`;
585
+ if (offline) {
586
+ return [
587
+ {
588
+ ruleId: this.id,
589
+ severity: "low",
590
+ confidence: "low",
591
+ packageName: pkg2.name,
592
+ ...pkg2.version === void 0 ? {} : { packageVersion: pkg2.version },
593
+ title: "Name looks like a recombination of a popular package",
594
+ detail: `This name ${how}, a popular package, but is not itself that package. AI assistants hallucinate plausible names like this, and attackers register them. Confirm you did not mean "${match.victim}".`,
595
+ evidence: `token match to "${match.victim}" (popularity rank ${match.rank + 1}), ${match.kind}; existence and adoption unverified (offline scan)`,
596
+ ...pkg2.evidencePath === void 0 ? {} : { location: pkg2.evidencePath }
597
+ }
598
+ ];
599
+ }
394
600
  const downloads = pkg2.weeklyDownloads;
395
601
  const established = downloads !== void 0 && downloads >= HALLUCINATION_POPULAR_FLOOR;
396
602
  if (pkg2.existsOnRegistry === true && established) return [];
@@ -413,7 +619,6 @@ function createHallucinationNameDetector(corpus = defaultCorpus) {
413
619
  confidence = "low";
414
620
  }
415
621
  }
416
- const how = match.kind === "affix-drop" ? `drops the convention prefix of "${match.victim}"` : `reorders the tokens of "${match.victim}"`;
417
622
  return [
418
623
  {
419
624
  ruleId: this.id,
@@ -442,6 +647,22 @@ var builtinDetectors = [
442
647
  hallucinationName
443
648
  ];
444
649
 
650
+ // src/output/color.ts
651
+ var CODES = { red: 31, green: 32, yellow: 33, blue: 34, gray: 90, bold: 1 };
652
+ function paint(text, color, enabled) {
653
+ return enabled ? `\x1B[${CODES[color]}m${text}\x1B[0m` : text;
654
+ }
655
+ var SEVERITY_COLOR = {
656
+ critical: "red",
657
+ high: "red",
658
+ medium: "yellow",
659
+ low: "blue",
660
+ info: "gray"
661
+ };
662
+ function severityColor(severity) {
663
+ return SEVERITY_COLOR[severity];
664
+ }
665
+
445
666
  // src/output/terminal.ts
446
667
  var SEVERITY_LABEL = {
447
668
  critical: "CRIT",
@@ -450,31 +671,56 @@ var SEVERITY_LABEL = {
450
671
  low: "LOW ",
451
672
  info: "INFO"
452
673
  };
453
- function renderTerminal(report) {
674
+ var VERDICT_COLOR = {
675
+ findings: "red",
676
+ "could-not-verify": "yellow",
677
+ clean: "green"
678
+ };
679
+ function renderTerminal(report, options = {}) {
680
+ const color = options.color ?? false;
681
+ const quiet = options.quiet ?? false;
454
682
  const lines = [];
455
- const from = report.basis === "lockfile" ? " from package-lock.json" : "";
456
- lines.push(`vetguard: scanned ${report.packagesScanned} package(s) in ${report.target}${from}`);
457
- for (const warning of report.warnings ?? []) {
458
- lines.push(`warning: ${warning}`);
459
- }
460
- if (report.findings.length === 0) {
461
- lines.push(
462
- report.verdict === "could-not-verify" ? "No findings, but some packages could not be verified (see below)." : "No findings."
463
- );
683
+ if (!quiet) {
684
+ const from = report.basis === "lockfile" ? " from package-lock.json" : "";
685
+ lines.push(`vetguard: scanned ${report.packagesScanned} package(s) in ${report.target}${from}`);
686
+ for (const warning of report.warnings ?? []) {
687
+ lines.push(paint(`warning: ${warning}`, "yellow", color));
688
+ }
689
+ if (report.findings.length === 0) {
690
+ lines.push(
691
+ report.verdict === "could-not-verify" ? "No findings, but some packages could not be verified (see below)." : "No findings."
692
+ );
693
+ }
464
694
  }
465
695
  for (const f of report.findings) {
466
696
  const version = f.packageVersion ? `@${f.packageVersion}` : "";
467
- lines.push(`[${SEVERITY_LABEL[f.severity]}] ${f.packageName}${version} (${f.ruleId})`);
697
+ const label = paint(`[${SEVERITY_LABEL[f.severity]}]`, severityColor(f.severity), color);
698
+ lines.push(`${label} ${f.packageName}${version} (${f.ruleId})`);
468
699
  lines.push(` ${f.title}`);
469
700
  lines.push(` ${f.detail}`);
470
701
  if (f.location) lines.push(` at ${f.location}`);
471
702
  }
472
- if (report.unverified.length > 0) {
473
- lines.push(`Could not verify: ${report.unverified.join(", ")}`);
703
+ if (!quiet) {
704
+ if (report.unverified.length > 0) {
705
+ lines.push(`Could not verify: ${report.unverified.join(", ")}`);
706
+ }
707
+ for (const f of report.suppressed ?? []) {
708
+ const version = f.packageVersion ? `@${f.packageVersion}` : "";
709
+ lines.push(
710
+ paint(
711
+ `[SUPPRESSED] ${f.packageName}${version} (${f.ruleId}): ${f.suppressedReason}`,
712
+ "gray",
713
+ color
714
+ )
715
+ );
716
+ }
474
717
  }
475
- lines.push(`Verdict: ${report.verdict}`);
718
+ lines.push(`Verdict: ${paint(report.verdict, verdictColor(report.verdict), color)}`);
476
719
  return lines.join("\n");
477
720
  }
721
+ function verdictColor(verdict) {
722
+ return VERDICT_COLOR[verdict];
723
+ }
478
724
 
479
725
  // src/output/json.ts
480
726
  var JSON_SCHEMA_VERSION = 1;
@@ -541,6 +787,60 @@ function renderSarif(report, toolVersion, detectors = builtinDetectors) {
541
787
  return JSON.stringify(doc, null, 2);
542
788
  }
543
789
 
790
+ // src/output/markdown.ts
791
+ var MARKDOWN_COMMENT_MARKER = "<!-- vetguard-report -->";
792
+ var SEVERITY_LABEL2 = {
793
+ critical: "critical",
794
+ high: "high",
795
+ medium: "medium",
796
+ low: "low",
797
+ info: "info"
798
+ };
799
+ function cell(value) {
800
+ return value.replace(/[\r\n]+/g, " ").replace(/`/g, "\\`").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|");
801
+ }
802
+ function renderMarkdown(report) {
803
+ const lines = [MARKDOWN_COMMENT_MARKER];
804
+ const from = report.basis === "lockfile" ? " from `package-lock.json`" : "";
805
+ lines.push(`### vetguard: ${report.verdict}`);
806
+ lines.push("");
807
+ lines.push(`Scanned ${report.packagesScanned} package(s)${from} in \`${report.target}\`.`);
808
+ for (const warning of report.warnings ?? []) {
809
+ lines.push("");
810
+ lines.push(`> warning: ${warning}`);
811
+ }
812
+ if (report.findings.length > 0) {
813
+ lines.push("");
814
+ lines.push("| Severity | Package | Rule | Finding |");
815
+ lines.push("| --- | --- | --- | --- |");
816
+ for (const f of report.findings) {
817
+ const pkg2 = f.packageVersion ? `${f.packageName}@${f.packageVersion}` : f.packageName;
818
+ lines.push(
819
+ `| ${SEVERITY_LABEL2[f.severity]} | ${cell(pkg2)} | ${cell(f.ruleId)} | ${cell(f.title)} |`
820
+ );
821
+ }
822
+ } else {
823
+ lines.push("");
824
+ lines.push(
825
+ report.verdict === "could-not-verify" ? "No findings, but some packages could not be verified." : "No findings."
826
+ );
827
+ }
828
+ if (report.unverified.length > 0) {
829
+ lines.push("");
830
+ lines.push(`Could not verify ${report.unverified.length} package(s).`);
831
+ }
832
+ const suppressed = report.suppressed ?? [];
833
+ if (suppressed.length > 0) {
834
+ lines.push("");
835
+ lines.push(`${suppressed.length} finding(s) suppressed by config:`);
836
+ for (const f of suppressed) {
837
+ const pkg2 = f.packageVersion ? `${f.packageName}@${f.packageVersion}` : f.packageName;
838
+ lines.push(`- ${cell(pkg2)} (${cell(f.ruleId)}): ${cell(f.suppressedReason)}`);
839
+ }
840
+ }
841
+ return lines.join("\n") + "\n";
842
+ }
843
+
544
844
  // src/output/exit-code.ts
545
845
  function resolveExitCode(report, failOn) {
546
846
  if (report.findings.length === 0) return 0;
@@ -549,9 +849,19 @@ function resolveExitCode(report, failOn) {
549
849
  return report.findings.some((f) => SEVERITY_ORDER[f.severity] >= threshold) ? 1 : 0;
550
850
  }
551
851
 
852
+ // src/core/diff.ts
853
+ function key(fact) {
854
+ const origin = fact.integrity ?? fact.resolvedUrl ?? fact.source ?? "";
855
+ return `${fact.name}@${fact.version ?? ""}#${origin}`;
856
+ }
857
+ function introducedFacts(base, head) {
858
+ const baseKeys = new Set(base.map(key));
859
+ return head.filter((fact) => !baseKeys.has(key(fact)));
860
+ }
861
+
552
862
  // src/ecosystems/npm/manifest.ts
553
- import { readFile } from "fs/promises";
554
- import path from "path";
863
+ import { readFile as readFile3 } from "fs/promises";
864
+ import path3 from "path";
555
865
  function classifySource(spec) {
556
866
  if (spec.startsWith("npm:")) return "alias";
557
867
  if (spec.startsWith("file:")) return "file";
@@ -564,10 +874,10 @@ function classifySource(spec) {
564
874
  return "registry";
565
875
  }
566
876
  async function readManifestFacts(dir) {
567
- const manifestPath = path.join(dir, "package.json");
877
+ const manifestPath = path3.join(dir, "package.json");
568
878
  let raw;
569
879
  try {
570
- raw = JSON.parse(await readFile(manifestPath, "utf8"));
880
+ raw = JSON.parse(await readFile3(manifestPath, "utf8"));
571
881
  } catch (err) {
572
882
  throw new Error(
573
883
  `Could not read ${manifestPath}: ${err instanceof Error ? err.message : String(err)}`
@@ -596,15 +906,15 @@ async function readManifestFacts(dir) {
596
906
  }
597
907
 
598
908
  // src/ecosystems/npm/lockfile.ts
599
- import { readFile as readFile2, access } from "fs/promises";
600
- import path2 from "path";
909
+ import { readFile as readFile4, access } from "fs/promises";
910
+ import path4 from "path";
601
911
  var REGISTRY_HOST = "registry.npmjs.org";
602
912
  var OTHER_LOCKFILES = ["yarn.lock", "pnpm-lock.yaml", "bun.lockb"];
603
913
  async function detectOtherLockfiles(dir) {
604
914
  const present = [];
605
915
  for (const file of OTHER_LOCKFILES) {
606
916
  try {
607
- await access(path2.join(dir, file));
917
+ await access(path4.join(dir, file));
608
918
  present.push(file);
609
919
  } catch {
610
920
  continue;
@@ -633,11 +943,10 @@ function kindFromEntry(entry) {
633
943
  if (entry.optional) return "optional";
634
944
  return "prod";
635
945
  }
636
- async function readLockfile(dir) {
637
- const lockPath = path2.join(dir, "package-lock.json");
946
+ async function readLockfileFile(lockPath) {
638
947
  let text;
639
948
  try {
640
- text = await readFile2(lockPath, "utf8");
949
+ text = await readFile4(lockPath, "utf8");
641
950
  } catch {
642
951
  return { status: "absent" };
643
952
  }
@@ -647,7 +956,7 @@ async function readLockfile(dir) {
647
956
  } catch (err) {
648
957
  return {
649
958
  status: "unsupported",
650
- reason: `package-lock.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
959
+ reason: `${lockPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
651
960
  };
652
961
  }
653
962
  const version = doc.lockfileVersion ?? 0;
@@ -678,6 +987,9 @@ async function readLockfile(dir) {
678
987
  }
679
988
  return { status: "ok", facts, lockfileVersion: version };
680
989
  }
990
+ async function readLockfile(dir) {
991
+ return readLockfileFile(path4.join(dir, "package-lock.json"));
992
+ }
681
993
 
682
994
  // src/ecosystems/npm/registry.ts
683
995
  var DEFAULT_REGISTRY = "https://registry.npmjs.org";
@@ -745,11 +1057,11 @@ function createRegistryClient(options = {}) {
745
1057
  }
746
1058
  return {
747
1059
  getPackument(name, version) {
748
- const key = version ? `${name}@${version}` : name;
749
- const existing = cache.get(key);
1060
+ const key2 = version ? `${name}@${version}` : name;
1061
+ const existing = cache.get(key2);
750
1062
  if (existing) return existing;
751
1063
  const pending = fetchPackument(name, version);
752
- cache.set(key, pending);
1064
+ cache.set(key2, pending);
753
1065
  return pending;
754
1066
  }
755
1067
  };
@@ -820,7 +1132,10 @@ async function enrichWithRegistry(input, client, options = {}) {
820
1132
  const now = options.now ? options.now() : /* @__PURE__ */ new Date();
821
1133
  const unverified = [];
822
1134
  const facts = await mapWithConcurrency(input, concurrency, async (fact) => {
823
- if (fact.source !== "registry") return fact;
1135
+ if (fact.source !== "registry") {
1136
+ if (fact.source === "unknown") unverified.push(fact.name);
1137
+ return fact;
1138
+ }
824
1139
  const [lookup, weeklyDownloads] = await Promise.all([
825
1140
  client.getPackument(fact.name, fact.version),
826
1141
  options.downloads?.getWeeklyDownloads(fact.name)
@@ -830,7 +1145,8 @@ async function enrichWithRegistry(input, client, options = {}) {
830
1145
  }
831
1146
  if (lookup.status === "unverified") {
832
1147
  unverified.push(fact.name);
833
- return fact;
1148
+ const existenceUnverifiedReason = lookup.reason === "offline" ? "offline" : "error";
1149
+ return { ...fact, existenceUnverifiedReason };
834
1150
  }
835
1151
  const p = lookup.packument;
836
1152
  const ageDays = ageDaysFrom(p.firstPublishAt, now);
@@ -876,6 +1192,16 @@ function enrichOptions(options) {
876
1192
  ...options.now === void 0 ? {} : { now: options.now }
877
1193
  };
878
1194
  }
1195
+ function reportContext(options, target, unverified) {
1196
+ return {
1197
+ target,
1198
+ ecosystem: "npm",
1199
+ unverified,
1200
+ generatedAt: timestamp(options),
1201
+ ...options.ignore === void 0 ? {} : { ignore: options.ignore },
1202
+ ...options.baseline === void 0 ? {} : { baseline: options.baseline }
1203
+ };
1204
+ }
879
1205
  async function scanProject(dir, options = {}) {
880
1206
  const lockfile = await readLockfile(dir);
881
1207
  const warnings = [];
@@ -899,14 +1225,38 @@ async function scanProject(dir, options = {}) {
899
1225
  registryFor(options),
900
1226
  enrichOptions(options)
901
1227
  );
902
- const report = runDetectors(facts, options.detectors ?? builtinDetectors, {
903
- target: dir,
904
- ecosystem: "npm",
905
- unverified,
906
- generatedAt: timestamp(options)
907
- });
1228
+ const report = runDetectors(
1229
+ facts,
1230
+ options.detectors ?? builtinDetectors,
1231
+ reportContext(options, dir, unverified)
1232
+ );
908
1233
  return { ...report, basis, ...warnings.length > 0 ? { warnings } : {} };
909
1234
  }
1235
+ async function diffScan(basePath, headPath, options = {}) {
1236
+ const base = await readLockfileFile(basePath);
1237
+ if (base.status !== "ok") {
1238
+ throw new Error(`base lockfile ${basePath}: ${lockfileProblem(base)}`);
1239
+ }
1240
+ const head = await readLockfileFile(headPath);
1241
+ if (head.status !== "ok") {
1242
+ throw new Error(`head lockfile ${headPath}: ${lockfileProblem(head)}`);
1243
+ }
1244
+ const introduced = introducedFacts(base.facts, head.facts);
1245
+ const { facts, unverified } = await enrichWithRegistry(
1246
+ introduced,
1247
+ registryFor(options),
1248
+ enrichOptions(options)
1249
+ );
1250
+ const report = runDetectors(
1251
+ facts,
1252
+ options.detectors ?? builtinDetectors,
1253
+ reportContext(options, `${basePath}..${headPath}`, unverified)
1254
+ );
1255
+ return { ...report, basis: "lockfile" };
1256
+ }
1257
+ function lockfileProblem(outcome) {
1258
+ return outcome.status === "unsupported" ? outcome.reason : "file not found";
1259
+ }
910
1260
  async function checkPackage(specInput, options = {}) {
911
1261
  const spec = parsePackageSpec(specInput);
912
1262
  const base = {
@@ -920,12 +1270,11 @@ async function checkPackage(specInput, options = {}) {
920
1270
  registryFor(options),
921
1271
  enrichOptions(options)
922
1272
  );
923
- return runDetectors(facts, options.detectors ?? builtinDetectors, {
924
- target: specInput,
925
- ecosystem: "npm",
926
- unverified,
927
- generatedAt: timestamp(options)
928
- });
1273
+ return runDetectors(
1274
+ facts,
1275
+ options.detectors ?? builtinDetectors,
1276
+ reportContext(options, specInput, unverified)
1277
+ );
929
1278
  }
930
1279
 
931
1280
  // src/index.ts
@@ -935,12 +1284,17 @@ var VERSION = pkg.version;
935
1284
 
936
1285
  export {
937
1286
  SEVERITY_ORDER,
1287
+ applyIgnores,
1288
+ applyBaseline,
1289
+ toBaselineEntries,
938
1290
  runDetectors,
939
1291
  builtinDetectors,
1292
+ introducedFacts,
940
1293
  classifySource,
941
1294
  readManifestFacts,
942
1295
  detectOtherLockfiles,
943
1296
  nameFromLockPath,
1297
+ readLockfileFile,
944
1298
  readLockfile,
945
1299
  enrichWithRegistry,
946
1300
  encodePackageName,
@@ -948,11 +1302,21 @@ export {
948
1302
  createDownloadsClient,
949
1303
  parsePackageSpec,
950
1304
  scanProject,
1305
+ diffScan,
951
1306
  checkPackage,
1307
+ CONFIG_FILENAME,
1308
+ ConfigError,
1309
+ loadConfig,
1310
+ BASELINE_FILENAME,
1311
+ BaselineError,
1312
+ readBaseline,
1313
+ writeBaseline,
952
1314
  renderTerminal,
953
1315
  JSON_SCHEMA_VERSION,
954
1316
  renderJson,
955
1317
  renderSarif,
1318
+ MARKDOWN_COMMENT_MARKER,
1319
+ renderMarkdown,
956
1320
  resolveExitCode,
957
1321
  VERSION
958
1322
  };