vetguard 0.0.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.
package/dist/cli.js CHANGED
@@ -1,13 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ BaselineError,
4
+ ConfigError,
3
5
  VERSION,
4
6
  checkPackage,
7
+ diffScan,
8
+ loadConfig,
9
+ readBaseline,
5
10
  renderJson,
11
+ renderMarkdown,
6
12
  renderSarif,
7
13
  renderTerminal,
8
14
  resolveExitCode,
9
- scanProject
10
- } from "./chunk-XXICU3EZ.js";
15
+ scanProject,
16
+ toBaselineEntries,
17
+ writeBaseline
18
+ } from "./chunk-2DIOTV5P.js";
11
19
 
12
20
  // src/cli.ts
13
21
  import { parseArgs } from "util";
@@ -18,14 +26,23 @@ Usage:
18
26
  vetguard scan [dir] Scan a project's dependencies (defaults to cwd)
19
27
  vetguard check <pkg> Vet a single package before installing
20
28
  (e.g. vetguard check react-codeshift, foo@1.2.3)
29
+ vetguard diff --base <lockfile> [--head <lockfile>]
30
+ Scan only the dependencies a change introduces
31
+ (head defaults to ./package-lock.json)
32
+ vetguard baseline [dir] Record current findings to .vetguard-baseline.json;
33
+ later scans report those as baselined and fail only
34
+ on new findings (adopt on a messy repo, ratchet down)
21
35
  vetguard --help Show this help
22
36
  vetguard --version Show version
23
37
 
24
38
  Options:
25
39
  --offline Do not contact the registry; unverifiable facts
26
40
  are reported as "could not verify", never "safe".
27
- --json Print the report as JSON instead of text.
41
+ --json Print the report as JSON.
28
42
  --sarif Print SARIF 2.1.0 for GitHub code scanning.
43
+ --markdown Print compact markdown for a PR comment or summary.
44
+ --quiet Print only findings and the verdict.
45
+ --no-color Disable ANSI colors (also respects NO_COLOR).
29
46
  --fail-on <severity> Exit non-zero only when a finding at or above this
30
47
  severity exists (critical|high|medium|low|info).
31
48
  Default: any finding exits non-zero.
@@ -43,7 +60,12 @@ async function main(argv) {
43
60
  offline: { type: "boolean" },
44
61
  json: { type: "boolean" },
45
62
  sarif: { type: "boolean" },
46
- "fail-on": { type: "string" }
63
+ markdown: { type: "boolean" },
64
+ quiet: { type: "boolean" },
65
+ "no-color": { type: "boolean" },
66
+ "fail-on": { type: "string" },
67
+ base: { type: "string" },
68
+ head: { type: "string" }
47
69
  }
48
70
  });
49
71
  if (values.version) {
@@ -60,14 +82,22 @@ async function main(argv) {
60
82
  console.error(`Invalid --fail-on value: ${failOnRaw} (expected ${SEVERITIES.join(", ")})`);
61
83
  return 2;
62
84
  }
85
+ const color = process.stdout.isTTY === true && !process.env.NO_COLOR && values["no-color"] !== true && values.json !== true && values.sarif !== true && values.markdown !== true;
63
86
  const options = {
64
87
  offline: values.offline === true,
65
88
  json: values.json === true,
66
89
  sarif: values.sarif === true,
90
+ markdown: values.markdown === true,
91
+ quiet: values.quiet === true,
92
+ color,
67
93
  failOn: failOnRaw
68
94
  };
69
95
  if (command === "scan") {
70
- return runScan(positionals[1] ?? process.cwd(), options);
96
+ const dir = positionals[1] ?? process.cwd();
97
+ return runReport(dir, options, (opts) => scanProject(dir, opts), dir);
98
+ }
99
+ if (command === "baseline") {
100
+ return runBaseline(positionals[1] ?? process.cwd(), options);
71
101
  }
72
102
  if (command === "check") {
73
103
  const spec = positionals[1];
@@ -76,31 +106,85 @@ async function main(argv) {
76
106
  console.error(HELP);
77
107
  return 2;
78
108
  }
79
- return runCheck(spec, options);
109
+ return runReport(process.cwd(), options, (opts) => checkPackage(spec, opts));
110
+ }
111
+ if (command === "diff") {
112
+ if (values.base === void 0) {
113
+ console.error("diff requires --base <lockfile> (the base package-lock.json)\n");
114
+ console.error(HELP);
115
+ return 2;
116
+ }
117
+ const head = values.head ?? "package-lock.json";
118
+ return runReport(process.cwd(), options, (opts) => diffScan(values.base, head, opts));
80
119
  }
81
120
  console.error(`Unknown command: ${command}
82
121
  `);
83
122
  console.error(HELP);
84
123
  return 2;
85
124
  }
86
- async function runScan(dir, options) {
125
+ async function runReport(configDir, cli, produce, baselineDir) {
126
+ let scanOptions;
127
+ let failOn;
128
+ try {
129
+ const config = await loadConfig(configDir);
130
+ const baseline = baselineDir ? await readBaseline(baselineDir) : void 0;
131
+ failOn = cli.failOn ?? config?.failOn;
132
+ scanOptions = {
133
+ offline: cli.offline || config?.offline === true,
134
+ ...config?.ignore ? { ignore: config.ignore } : {},
135
+ ...baseline ? { baseline } : {}
136
+ };
137
+ } catch (err) {
138
+ if (err instanceof ConfigError || err instanceof BaselineError) {
139
+ console.error(err.message);
140
+ return 2;
141
+ }
142
+ throw err;
143
+ }
87
144
  let report;
88
145
  try {
89
- report = await scanProject(dir, { offline: options.offline });
146
+ report = await produce(scanOptions);
90
147
  } catch (err) {
91
148
  console.error(err instanceof Error ? err.message : String(err));
92
149
  return 2;
93
150
  }
94
- return emit(report, options);
151
+ console.log(render(report, cli));
152
+ return resolveExitCode(report, failOn);
95
153
  }
96
- async function runCheck(specInput, options) {
97
- const report = await checkPackage(specInput, { offline: options.offline });
98
- return emit(report, options);
154
+ async function runBaseline(dir, cli) {
155
+ let scanOptions;
156
+ try {
157
+ const config = await loadConfig(dir);
158
+ scanOptions = {
159
+ offline: cli.offline || config?.offline === true,
160
+ ...config?.ignore ? { ignore: config.ignore } : {}
161
+ };
162
+ } catch (err) {
163
+ if (err instanceof ConfigError) {
164
+ console.error(err.message);
165
+ return 2;
166
+ }
167
+ throw err;
168
+ }
169
+ let report;
170
+ try {
171
+ report = await scanProject(dir, scanOptions);
172
+ } catch (err) {
173
+ console.error(err instanceof Error ? err.message : String(err));
174
+ return 2;
175
+ }
176
+ const entries = toBaselineEntries(report.findings);
177
+ const filePath = await writeBaseline(dir, entries, (/* @__PURE__ */ new Date()).toISOString());
178
+ console.log(
179
+ `Recorded ${entries.length} finding(s) to ${filePath}. Future scans report these as baselined and fail only on new findings.`
180
+ );
181
+ return 0;
99
182
  }
100
- function emit(report, options) {
101
- const output = options.sarif ? renderSarif(report, VERSION) : options.json ? renderJson(report, VERSION) : renderTerminal(report);
102
- console.log(output);
103
- return resolveExitCode(report, options.failOn);
183
+ function render(report, options) {
184
+ if (options.sarif) return renderSarif(report, VERSION);
185
+ if (options.markdown) return renderMarkdown(report);
186
+ if (options.json) return renderJson(report, VERSION);
187
+ return renderTerminal(report, { color: options.color, quiet: options.quiet });
104
188
  }
105
189
  main(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
106
190
  console.error(err instanceof Error ? err.stack : String(err));
package/dist/index.d.ts CHANGED
@@ -24,6 +24,12 @@ interface PackageFacts {
24
24
  source: DependencySource;
25
25
  /** Whether the registry has any record of this name at all. */
26
26
  existsOnRegistry?: boolean;
27
+ /**
28
+ * When existence is unknown, why: the registry was deliberately not consulted
29
+ * (`offline`) or a lookup failed (`error`). Lets a detector offer an
30
+ * offline-only heuristic without firing on a transient online failure.
31
+ */
32
+ existenceUnverifiedReason?: "offline" | "error";
27
33
  /** Whether this exact version is still published. */
28
34
  versionPublished?: boolean;
29
35
  firstPublishAt?: string;
@@ -60,6 +66,32 @@ interface Detector {
60
66
  description: string;
61
67
  detect(pkg: PackageFacts): Finding[];
62
68
  }
69
+ /**
70
+ * A configured suppression. A finding matching `rule` and `package` is not
71
+ * counted toward the verdict, but is still reported as suppressed. The reason
72
+ * is mandatory: a suppression the team cannot explain is a suppression that
73
+ * should not exist.
74
+ */
75
+ interface IgnoreRule {
76
+ rule: string;
77
+ package: string;
78
+ reason: string;
79
+ }
80
+ /** A finding that matched an ignore rule; shown as suppressed, never hidden. */
81
+ type SuppressedFinding = Finding & {
82
+ suppressedReason: string;
83
+ };
84
+ /**
85
+ * One entry in a recorded baseline. A finding matching a baseline entry is
86
+ * pre-existing (it was present when the baseline was taken) and is reported as
87
+ * suppressed rather than failing the build; only findings absent from the
88
+ * baseline are new. Matched by rule, package, and exact version.
89
+ */
90
+ interface BaselineEntry {
91
+ rule: string;
92
+ package: string;
93
+ version?: string;
94
+ }
63
95
  type ScanVerdict = "clean" | "findings" | "could-not-verify";
64
96
  interface Report {
65
97
  verdict: ScanVerdict;
@@ -67,6 +99,8 @@ interface Report {
67
99
  ecosystem: string;
68
100
  packagesScanned: number;
69
101
  findings: Finding[];
102
+ /** Findings suppressed by a configured ignore rule; do not affect the verdict. */
103
+ suppressed?: SuppressedFinding[];
70
104
  /** Packages we could not fully verify (offline, unsupported source, etc.). */
71
105
  unverified: string[];
72
106
  generatedAt: string;
@@ -78,22 +112,86 @@ interface Report {
78
112
  declare const SEVERITY_ORDER: Record<Severity, number>;
79
113
 
80
114
  /**
81
- * Runs every detector over every package's facts and aggregates a report.
82
- * This is deliberately pure: given the same facts and detectors it produces
83
- * the same report, which is what makes CI runs deterministic and testable.
115
+ * Partitions findings into those that remain active and those a configured
116
+ * ignore rule suppresses. A suppressed finding keeps its data and gains the
117
+ * ignore's reason, so it is reported as suppressed rather than silently
118
+ * dropped. Active findings alone drive the verdict and exit code.
119
+ */
120
+ declare function applyIgnores(findings: readonly Finding[], ignores: readonly IgnoreRule[]): {
121
+ active: Finding[];
122
+ suppressed: SuppressedFinding[];
123
+ };
124
+
125
+ /**
126
+ * Partitions findings into new (absent from the baseline) and pre-existing
127
+ * (present in it). A baselined finding is reported as suppressed, so the
128
+ * pre-existing state is visible but only genuinely new findings drive the
129
+ * verdict. This is what lets a project adopt vetguard on a messy tree today and
130
+ * ratchet the baseline down over time.
84
131
  */
85
- declare function runDetectors(facts: PackageFacts[], detectors: Detector[], context: {
132
+ declare function applyBaseline(findings: readonly Finding[], baseline: readonly BaselineEntry[]): {
133
+ active: Finding[];
134
+ suppressed: SuppressedFinding[];
135
+ };
136
+ /** Reduces findings to baseline entries, the snapshot a `baseline` run records. */
137
+ declare function toBaselineEntries(findings: readonly Finding[]): BaselineEntry[];
138
+
139
+ declare const CONFIG_FILENAME = "vetguard.config.json";
140
+ interface Config {
141
+ failOn?: Severity;
142
+ offline?: boolean;
143
+ ignore?: IgnoreRule[];
144
+ }
145
+ /** Thrown for an invalid config so the CLI can exit with a clear message. */
146
+ declare class ConfigError extends Error {
147
+ }
148
+ /**
149
+ * Loads `vetguard.config.json` from a directory. Returns undefined when there
150
+ * is no config file; throws `ConfigError` when the file exists but is invalid,
151
+ * so a malformed config (or an ignore missing its reason) fails loudly.
152
+ */
153
+ declare function loadConfig(dir: string): Promise<Config | undefined>;
154
+
155
+ declare const BASELINE_FILENAME = ".vetguard-baseline.json";
156
+ /** Thrown for an unreadable/invalid baseline so the CLI can exit with a clear message. */
157
+ declare class BaselineError extends Error {
158
+ }
159
+ /**
160
+ * Reads baseline entries from a directory. Returns undefined when there is no
161
+ * baseline file; throws `BaselineError` when the file exists but is invalid, so
162
+ * a corrupt baseline is never silently treated as empty (which would fail every
163
+ * pre-existing finding).
164
+ */
165
+ declare function readBaseline(dir: string): Promise<BaselineEntry[] | undefined>;
166
+ /** Writes the baseline snapshot for a directory. */
167
+ declare function writeBaseline(dir: string, findings: BaselineEntry[], generatedAt: string): Promise<string>;
168
+
169
+ interface DetectContext {
86
170
  target: string;
87
171
  ecosystem: string;
88
172
  unverified: string[];
89
173
  generatedAt: string;
90
- }): Report;
174
+ ignore?: readonly IgnoreRule[];
175
+ baseline?: readonly BaselineEntry[];
176
+ }
177
+ /**
178
+ * Runs every detector over every package's facts and aggregates a report.
179
+ * This is deliberately pure: given the same facts and detectors it produces
180
+ * the same report, which is what makes CI runs deterministic and testable.
181
+ */
182
+ declare function runDetectors(facts: PackageFacts[], detectors: Detector[], context: DetectContext): Report;
91
183
 
92
184
  /** All built-in detectors. New detectors are registered here. */
93
185
  declare const builtinDetectors: Detector[];
94
186
 
95
- /** Renders a report as plain text. No color codes yet; kept dependency-free. */
96
- declare function renderTerminal(report: Report): string;
187
+ interface TerminalOptions {
188
+ /** ANSI colors; the CLI enables this only on a TTY without NO_COLOR. */
189
+ color?: boolean;
190
+ /** Print only findings and the verdict, no header or informational lines. */
191
+ quiet?: boolean;
192
+ }
193
+ /** Renders a report as text. Dependency-free; colors are opt-in and TTY-gated by the caller. */
194
+ declare function renderTerminal(report: Report, options?: TerminalOptions): string;
97
195
 
98
196
  /** Bumped when the JSON shape changes in a way consumers must adapt to. */
99
197
  declare const JSON_SCHEMA_VERSION = 1;
@@ -107,6 +205,11 @@ declare function renderJson(report: Report, toolVersion: string): string;
107
205
  */
108
206
  declare function renderSarif(report: Report, toolVersion: string, detectors?: Detector[]): string;
109
207
 
208
+ /** Marker so an automated PR comment can find and update its previous post in place. */
209
+ declare const MARKDOWN_COMMENT_MARKER = "<!-- vetguard-report -->";
210
+ /** Renders a report as compact markdown for a PR comment or CI job summary. */
211
+ declare function renderMarkdown(report: Report): string;
212
+
110
213
  /**
111
214
  * The process exit code for a report. No findings is always 0. With no
112
215
  * threshold any finding is 1; with a threshold only a finding at or above that
@@ -114,6 +217,15 @@ declare function renderSarif(report: Report, toolVersion: string, detectors?: De
114
217
  */
115
218
  declare function resolveExitCode(report: Report, failOn: Severity | undefined): number;
116
219
 
220
+ /**
221
+ * The facts present in `head` but not in `base`, keyed by resolved identity.
222
+ * This is every dependency a change introduces: a brand-new name, a new
223
+ * version, a downgrade, and a same-version repoint (changed integrity or
224
+ * resolved URL). An identical entry in both is dropped, so a diff scan judges
225
+ * only what the change actually adds.
226
+ */
227
+ declare function introducedFacts(base: readonly PackageFacts[], head: readonly PackageFacts[]): PackageFacts[];
228
+
117
229
  /**
118
230
  * Classifies a version specifier into how we can verify it. A registry range
119
231
  * is checkable against the registry; git/file/link/alias/workspace specifiers
@@ -143,11 +255,13 @@ type LockfileOutcome = {
143
255
  /** Extracts the package name from a lockfile path key, handling nesting and scopes. */
144
256
  declare function nameFromLockPath(lockPath: string): string | undefined;
145
257
  /**
146
- * Reads resolved dependency facts from a package-lock.json (v2 or v3). Returns
147
- * `absent` when there is no lockfile and `unsupported` for a shape we do not
148
- * parse (v1, or a missing `packages` map), so the caller can fall back to the
149
- * manifest and say so rather than silently skipping.
258
+ * Reads resolved dependency facts from a specific package-lock.json file (v2 or
259
+ * v3). Returns `absent` when the file is missing and `unsupported` for a shape
260
+ * we do not parse (v1, or a missing `packages` map), so the caller can fall
261
+ * back or report rather than silently skipping.
150
262
  */
263
+ declare function readLockfileFile(lockPath: string): Promise<LockfileOutcome>;
264
+ /** Reads the `package-lock.json` in a project directory. */
151
265
  declare function readLockfile(dir: string): Promise<LockfileOutcome>;
152
266
 
153
267
  /**
@@ -244,6 +358,10 @@ interface ScanOptions {
244
358
  client?: RegistryClient;
245
359
  downloads?: DownloadsClient;
246
360
  detectors?: Detector[];
361
+ /** Suppressions from config; matched findings are reported but do not affect the verdict. */
362
+ ignore?: readonly IgnoreRule[];
363
+ /** Recorded baseline; matched (pre-existing) findings are reported but do not affect the verdict. */
364
+ baseline?: readonly BaselineEntry[];
247
365
  /** Injected for a deterministic `generatedAt` and age in tests. */
248
366
  now?: () => Date;
249
367
  }
@@ -253,6 +371,14 @@ interface ScanOptions {
253
371
  * an unsupported shape, recording a warning so the fallback is never silent.
254
372
  */
255
373
  declare function scanProject(dir: string, options?: ScanOptions): Promise<Report>;
374
+ /**
375
+ * Scans only the dependencies a change introduces: those present in the head
376
+ * lockfile but not the base (a new name, a new version, or a downgrade). Both
377
+ * must be package-lock v2/v3; an absent or unsupported lockfile on either side
378
+ * throws, because a partial diff would silently under-report. This is the
379
+ * highest-signal moment, a dependency entering a pull request.
380
+ */
381
+ declare function diffScan(basePath: string, headPath: string, options?: ScanOptions): Promise<Report>;
256
382
  /** Vets a single package spec (`name`, `name@version`, `@scope/name@version`). */
257
383
  declare function checkPackage(specInput: string, options?: ScanOptions): Promise<Report>;
258
384
 
@@ -260,4 +386,4 @@ declare function checkPackage(specInput: string, options?: ScanOptions): Promise
260
386
 
261
387
  declare const VERSION: string;
262
388
 
263
- export { type Confidence, type DependencyKind, type DependencySource, type Detector, type DownloadsClient, type DownloadsClientOptions, type EnrichOptions, type EnrichmentResult, type Finding, JSON_SCHEMA_VERSION, type LockfileOutcome, type PackageFacts, type PackageSpec, type Packument, type RegistryClient, type RegistryClientOptions, type RegistryLookup, type Report, SEVERITY_ORDER, type ScanOptions, type ScanVerdict, type Severity, VERSION, builtinDetectors, checkPackage, classifySource, createDownloadsClient, createRegistryClient, detectOtherLockfiles, encodePackageName, enrichWithRegistry, nameFromLockPath, parsePackageSpec, readLockfile, readManifestFacts, renderJson, renderSarif, renderTerminal, resolveExitCode, runDetectors, scanProject };
389
+ export { BASELINE_FILENAME, type BaselineEntry, BaselineError, CONFIG_FILENAME, type Confidence, type Config, ConfigError, type DependencyKind, type DependencySource, type Detector, type DownloadsClient, type DownloadsClientOptions, type EnrichOptions, type EnrichmentResult, type Finding, type IgnoreRule, JSON_SCHEMA_VERSION, type LockfileOutcome, MARKDOWN_COMMENT_MARKER, type PackageFacts, type PackageSpec, type Packument, type RegistryClient, type RegistryClientOptions, type RegistryLookup, type Report, SEVERITY_ORDER, type ScanOptions, type ScanVerdict, type Severity, type SuppressedFinding, VERSION, applyBaseline, applyIgnores, builtinDetectors, checkPackage, classifySource, createDownloadsClient, createRegistryClient, detectOtherLockfiles, diffScan, encodePackageName, enrichWithRegistry, introducedFacts, loadConfig, nameFromLockPath, parsePackageSpec, readBaseline, readLockfile, readLockfileFile, readManifestFacts, renderJson, renderMarkdown, renderSarif, renderTerminal, resolveExitCode, runDetectors, scanProject, toBaselineEntries, writeBaseline };
package/dist/index.js CHANGED
@@ -1,46 +1,76 @@
1
1
  import {
2
+ BASELINE_FILENAME,
3
+ BaselineError,
4
+ CONFIG_FILENAME,
5
+ ConfigError,
2
6
  JSON_SCHEMA_VERSION,
7
+ MARKDOWN_COMMENT_MARKER,
3
8
  SEVERITY_ORDER,
4
9
  VERSION,
10
+ applyBaseline,
11
+ applyIgnores,
5
12
  builtinDetectors,
6
13
  checkPackage,
7
14
  classifySource,
8
15
  createDownloadsClient,
9
16
  createRegistryClient,
10
17
  detectOtherLockfiles,
18
+ diffScan,
11
19
  encodePackageName,
12
20
  enrichWithRegistry,
21
+ introducedFacts,
22
+ loadConfig,
13
23
  nameFromLockPath,
14
24
  parsePackageSpec,
25
+ readBaseline,
15
26
  readLockfile,
27
+ readLockfileFile,
16
28
  readManifestFacts,
17
29
  renderJson,
30
+ renderMarkdown,
18
31
  renderSarif,
19
32
  renderTerminal,
20
33
  resolveExitCode,
21
34
  runDetectors,
22
- scanProject
23
- } from "./chunk-XXICU3EZ.js";
35
+ scanProject,
36
+ toBaselineEntries,
37
+ writeBaseline
38
+ } from "./chunk-2DIOTV5P.js";
24
39
  export {
40
+ BASELINE_FILENAME,
41
+ BaselineError,
42
+ CONFIG_FILENAME,
43
+ ConfigError,
25
44
  JSON_SCHEMA_VERSION,
45
+ MARKDOWN_COMMENT_MARKER,
26
46
  SEVERITY_ORDER,
27
47
  VERSION,
48
+ applyBaseline,
49
+ applyIgnores,
28
50
  builtinDetectors,
29
51
  checkPackage,
30
52
  classifySource,
31
53
  createDownloadsClient,
32
54
  createRegistryClient,
33
55
  detectOtherLockfiles,
56
+ diffScan,
34
57
  encodePackageName,
35
58
  enrichWithRegistry,
59
+ introducedFacts,
60
+ loadConfig,
36
61
  nameFromLockPath,
37
62
  parsePackageSpec,
63
+ readBaseline,
38
64
  readLockfile,
65
+ readLockfileFile,
39
66
  readManifestFacts,
40
67
  renderJson,
68
+ renderMarkdown,
41
69
  renderSarif,
42
70
  renderTerminal,
43
71
  resolveExitCode,
44
72
  runDetectors,
45
- scanProject
73
+ scanProject,
74
+ toBaselineEntries,
75
+ writeBaseline
46
76
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vetguard",
3
- "version": "0.0.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first scanner for AI-era npm supply-chain threats: hallucinated (slopsquatted) dependencies, typosquats, malicious young packages, and prompt injection aimed at coding agents.",
5
5
  "keywords": [
6
6
  "npm",
@@ -67,6 +67,6 @@
67
67
  "tsup": "^8.3.5",
68
68
  "tsx": "^4.19.2",
69
69
  "typescript": "^5.7.2",
70
- "vitest": "^2.1.8"
70
+ "vitest": "^4.1.10"
71
71
  }
72
72
  }