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/README.md +132 -19
- package/dist/{chunk-XXICU3EZ.js → chunk-2DIOTV5P.js} +414 -50
- package/dist/cli.js +100 -16
- package/dist/index.d.ts +138 -12
- package/dist/index.js +33 -3
- package/package.json +2 -2
|
@@ -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
|
|
181
|
+
const raw = [];
|
|
13
182
|
for (const pkg2 of facts) {
|
|
14
183
|
for (const detector of detectors) {
|
|
15
|
-
|
|
184
|
+
raw.push(...detector.detect(pkg2));
|
|
16
185
|
}
|
|
17
186
|
}
|
|
18
|
-
|
|
187
|
+
raw.sort(
|
|
19
188
|
(a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || a.packageName.localeCompare(b.packageName)
|
|
20
189
|
);
|
|
21
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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 (
|
|
473
|
-
|
|
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, "<").replace(/>/g, ">").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
|
|
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 =
|
|
877
|
+
const manifestPath = path3.join(dir, "package.json");
|
|
568
878
|
let raw;
|
|
569
879
|
try {
|
|
570
|
-
raw = JSON.parse(await
|
|
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
|
|
600
|
-
import
|
|
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(
|
|
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
|
|
637
|
-
const lockPath = path2.join(dir, "package-lock.json");
|
|
946
|
+
async function readLockfileFile(lockPath) {
|
|
638
947
|
let text;
|
|
639
948
|
try {
|
|
640
|
-
text = await
|
|
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:
|
|
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
|
|
749
|
-
const existing = cache.get(
|
|
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(
|
|
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")
|
|
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
|
-
|
|
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(
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
unverified
|
|
906
|
-
|
|
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(
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
unverified
|
|
927
|
-
|
|
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
|
};
|