eval-quality 2.0.0 → 3.1.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 +4 -1
- package/dist/application/index.d.ts +4 -0
- package/dist/application/index.js +2 -0
- package/dist/core/compile/compile.js +6 -1
- package/dist/core/compile/schema-version.d.ts +15 -2
- package/dist/core/compile/schema-version.js +11 -3
- package/dist/core/emit/emit.js +4 -2
- package/dist/core/preflight/plan.js +15 -0
- package/dist/core/preflight/reduce.d.ts +1 -1
- package/dist/core/preflight/reduce.js +5 -2
- package/dist/core/schemas/evaluator-configuration.d.ts +9 -0
- package/dist/core/schemas/evaluator-configuration.js +9 -0
- package/dist/core/schemas/evidence-artifact.d.ts +9 -0
- package/dist/core/schemas/evidence-artifact.js +9 -0
- package/dist/core/schemas/isolation-manifest.d.ts +18 -0
- package/dist/core/schemas/isolation-manifest.js +18 -0
- package/dist/core/schemas/preflight-verdict.d.ts +9 -0
- package/dist/core/schemas/preflight-verdict.js +9 -0
- package/dist/core/schemas/private-artifact-manifest.d.ts +10 -0
- package/dist/core/schemas/private-artifact-manifest.js +10 -0
- package/dist/core/schemas/probe.d.ts +41 -0
- package/dist/core/schemas/probe.js +43 -0
- package/dist/core/schemas/scoring-policy.d.ts +11 -0
- package/dist/core/schemas/scoring-policy.js +11 -0
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +12 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +12 -0
- package/dist/core/schemas/sealed-run-record.d.ts +11 -0
- package/dist/core/schemas/sealed-run-record.js +11 -0
- package/dist/core/score/score.d.ts +1 -1
- package/dist/core/score/score.js +23 -0
- package/dist/core/seal/seal.js +4 -5
- package/dist/gates/audit-lockfile-age.mjs +295 -0
- package/dist/gates/check-dependency-direction.js +303 -0
- package/dist/gates/check-licenses.mjs +305 -0
- package/dist/gates/dependency-direction.js +555 -0
- package/dist/gates/discover-source-files.js +44 -0
- package/dist/gates/gate-config.js +251 -0
- package/dist/gates/gates-cli.js +410 -0
- package/dist/gates/lineage-ownership.js +364 -0
- package/dist/gates/package-boundary.js +388 -0
- package/dist/gates/token-scan.js +203 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +20 -1
- package/dist/testing/probe-conformance.d.ts +23 -18
- package/package.json +20 -8
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Holds every entry in a resolved lockfile against an allowlist of SPDX
|
|
2
|
+
// identifiers, and fails closed on an entry whose `resolved` is not the registry
|
|
3
|
+
// tarball for that entry's own name and version.
|
|
4
|
+
//
|
|
5
|
+
// Reads `package-lock.json` directly rather than walking `node_modules`: the
|
|
6
|
+
// lockfile records a `license` field for every entry, including optional
|
|
7
|
+
// platform binaries never installed on this runner's OS/CPU (Biome and friends
|
|
8
|
+
// ship one lock entry per platform). Walking node_modules would silently miss
|
|
9
|
+
// every foreign-platform entry. Needs no install.
|
|
10
|
+
//
|
|
11
|
+
// A pure scanner over a parsed lockfile and the data its caller supplies. It
|
|
12
|
+
// carries no allowlist, no policy and no exception of its own, so this
|
|
13
|
+
// repository's licence policy is data in `eval-quality.config.json` exactly as a
|
|
14
|
+
// consumer's is. `scripts/gates-cli.ts` is the entry point that reads that file,
|
|
15
|
+
// resolves the policy for each lockfile, and decides which exceptions still
|
|
16
|
+
// hold.
|
|
17
|
+
const REGISTRY_PREFIX = 'https://registry.npmjs.org/';
|
|
18
|
+
/**
|
|
19
|
+
* `error.code` on the refusal a caller repairs by pointing the gate at a real
|
|
20
|
+
* lockfile. `audit-lockfile-age.mjs` exports the same string and
|
|
21
|
+
* `tests/architecture/published-gates.test.ts` holds the two equal: neither gate
|
|
22
|
+
* may import from `node_modules` and there is no module between them, so the
|
|
23
|
+
* constant is declared twice for the reason `WINDOW_DAYS_DEFAULT` is.
|
|
24
|
+
*/
|
|
25
|
+
export const LOCKFILE_SHAPE_ERROR = 'EVAL_QUALITY_LOCKFILE_SHAPE';
|
|
26
|
+
function refuseLockfileShape(message) {
|
|
27
|
+
const error = new Error(message);
|
|
28
|
+
error.code = LOCKFILE_SHAPE_ERROR;
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
// The one URL the public registry serves `name@version` from. A scoped name's
|
|
32
|
+
// tarball basename is the segment after the slash, so `@scope/pkg` at 1.0.0 is
|
|
33
|
+
// `https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz`.
|
|
34
|
+
function registryTarballUrl(name, version) {
|
|
35
|
+
const basename = name.startsWith('@')
|
|
36
|
+
? name.slice(name.indexOf('/') + 1)
|
|
37
|
+
: name;
|
|
38
|
+
return `${REGISTRY_PREFIX}${name}/-/${basename}-${version}.tgz`;
|
|
39
|
+
}
|
|
40
|
+
// Strips a single layer of balanced outer parentheses at a time, e.g. "(MIT OR Apache-2.0)" ->
|
|
41
|
+
// "MIT OR Apache-2.0". Only strips when the opening paren's match is the expression's final
|
|
42
|
+
// character (a true outer wrap), not when parens merely appear inside, e.g. "(MIT) OR (ISC)".
|
|
43
|
+
function stripOuterParens(expr) {
|
|
44
|
+
let s = expr.trim();
|
|
45
|
+
while (s.startsWith('(') && s.endsWith(')')) {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let wrapsWhole = true;
|
|
48
|
+
for (let i = 0; i < s.length; i++) {
|
|
49
|
+
if (s[i] === '(')
|
|
50
|
+
depth++;
|
|
51
|
+
else if (s[i] === ')') {
|
|
52
|
+
depth--;
|
|
53
|
+
if (depth === 0 && i !== s.length - 1) {
|
|
54
|
+
wrapsWhole = false;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!wrapsWhole)
|
|
60
|
+
break;
|
|
61
|
+
s = s.slice(1, -1).trim();
|
|
62
|
+
}
|
|
63
|
+
return s;
|
|
64
|
+
}
|
|
65
|
+
// Splits `expr` on top-level occurrences of `token` (e.g. " OR ", " AND "), ignoring occurrences
|
|
66
|
+
// nested inside parentheses.
|
|
67
|
+
function splitTopLevel(expr, token) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
let depth = 0;
|
|
70
|
+
let start = 0;
|
|
71
|
+
for (let i = 0; i < expr.length; i++) {
|
|
72
|
+
const ch = expr[i];
|
|
73
|
+
if (ch === '(')
|
|
74
|
+
depth++;
|
|
75
|
+
else if (ch === ')')
|
|
76
|
+
depth--;
|
|
77
|
+
else if (depth === 0 && expr.slice(i, i + token.length) === token) {
|
|
78
|
+
parts.push(expr.slice(start, i));
|
|
79
|
+
i += token.length - 1;
|
|
80
|
+
start = i + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
parts.push(expr.slice(start));
|
|
84
|
+
return parts;
|
|
85
|
+
}
|
|
86
|
+
// Recursively evaluates an SPDX-ish licence expression: OR passes if any operand is allowlisted,
|
|
87
|
+
// AND passes only if every operand is. A bare identifier (including a "X WITH exception" compound,
|
|
88
|
+
// UNLICENSED, or anything unparseable) passes only via exact allowlist membership, so those all fail
|
|
89
|
+
// closed without special-casing.
|
|
90
|
+
function isAllowed(licenseExpr, allowlist) {
|
|
91
|
+
if (!licenseExpr || typeof licenseExpr !== 'string')
|
|
92
|
+
return false;
|
|
93
|
+
const stripped = stripOuterParens(licenseExpr.trim());
|
|
94
|
+
if (stripped === '')
|
|
95
|
+
return false;
|
|
96
|
+
const orParts = splitTopLevel(stripped, ' OR ');
|
|
97
|
+
if (orParts.length > 1)
|
|
98
|
+
return orParts.some((part) => isAllowed(part, allowlist));
|
|
99
|
+
const andParts = splitTopLevel(stripped, ' AND ');
|
|
100
|
+
if (andParts.length > 1)
|
|
101
|
+
return andParts.every((part) => isAllowed(part, allowlist));
|
|
102
|
+
return allowlist.has(stripped);
|
|
103
|
+
}
|
|
104
|
+
function licenseStringOf(meta) {
|
|
105
|
+
const license = meta.license;
|
|
106
|
+
if (typeof license === 'string')
|
|
107
|
+
return license;
|
|
108
|
+
if (license &&
|
|
109
|
+
typeof license === 'object' &&
|
|
110
|
+
typeof license.type === 'string')
|
|
111
|
+
return license.type;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// One scoped exception, applied to one entry. `prefix` names a family of
|
|
115
|
+
// packages and `license` is the single identifier the exception adds to the
|
|
116
|
+
// allowlist for that family; the expression is then read by the rule every other
|
|
117
|
+
// entry is read by. So an AND still needs every operand covered and a WITH
|
|
118
|
+
// compound still has to sit in the list exactly: `Apache-2.0 AND
|
|
119
|
+
// LGPL-3.0-or-later` is tolerated where the exception names the LGPL term and
|
|
120
|
+
// the allowlist carries Apache-2.0, and `LGPL-3.0-or-later AND AGPL-3.0-only` is
|
|
121
|
+
// not. A substring test here kept tolerating a family the day its expression
|
|
122
|
+
// widened to carry a term nobody agreed to. The caller has already decided this
|
|
123
|
+
// exception applies to this lockfile and that its marker still holds.
|
|
124
|
+
function isTolerated(tolerance, meta, name, license, allowlist) {
|
|
125
|
+
if (!name.startsWith(tolerance.prefix))
|
|
126
|
+
return false;
|
|
127
|
+
if (tolerance.optional !== false && meta.optional !== true)
|
|
128
|
+
return false;
|
|
129
|
+
return isAllowed(license, new Set([...allowlist, tolerance.license]));
|
|
130
|
+
}
|
|
131
|
+
// Resolves how npm's hoisting algorithm would look up dependency `name` starting from the package
|
|
132
|
+
// at `fromPath`: its own node_modules first, then each ancestor's, ending at the root.
|
|
133
|
+
//
|
|
134
|
+
// Must split on "/node_modules/" boundaries, not bare "/": a scoped package's path
|
|
135
|
+
// (node_modules/@scope/name) has three segments per nesting level, not two, so striding by two
|
|
136
|
+
// undershoots every scoped ancestor and never reaches the root scope "". That silently drops the
|
|
137
|
+
// dependency edge for anything nested under a scoped package - including every @biomejs/cli-*
|
|
138
|
+
// platform binary - and findDependencyPath falls back to the raw lockfile key instead of the real
|
|
139
|
+
// require-chain.
|
|
140
|
+
function ancestorScopesOf(pkgPath) {
|
|
141
|
+
if (pkgPath === '')
|
|
142
|
+
return [''];
|
|
143
|
+
const scopes = [];
|
|
144
|
+
let scope = pkgPath;
|
|
145
|
+
for (;;) {
|
|
146
|
+
scopes.push(scope);
|
|
147
|
+
const boundary = scope.lastIndexOf('/node_modules/');
|
|
148
|
+
if (boundary === -1)
|
|
149
|
+
break;
|
|
150
|
+
scope = scope.slice(0, boundary);
|
|
151
|
+
}
|
|
152
|
+
scopes.push('');
|
|
153
|
+
return scopes;
|
|
154
|
+
}
|
|
155
|
+
function resolveDependency(packages, fromPath, name) {
|
|
156
|
+
for (const scope of ancestorScopesOf(fromPath)) {
|
|
157
|
+
const candidate = scope
|
|
158
|
+
? `${scope}/node_modules/${name}`
|
|
159
|
+
: `node_modules/${name}`;
|
|
160
|
+
if (packages[candidate])
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
function buildEdges(packages) {
|
|
166
|
+
const edges = new Map();
|
|
167
|
+
for (const [path, meta] of Object.entries(packages)) {
|
|
168
|
+
const wantedNames = new Set([
|
|
169
|
+
...Object.keys(meta.dependencies ?? {}),
|
|
170
|
+
...Object.keys(meta.optionalDependencies ?? {}),
|
|
171
|
+
...Object.keys(meta.peerDependencies ?? {}),
|
|
172
|
+
...(path === '' ? Object.keys(meta.devDependencies ?? {}) : []),
|
|
173
|
+
]);
|
|
174
|
+
const list = [];
|
|
175
|
+
for (const name of wantedNames) {
|
|
176
|
+
const resolved = resolveDependency(packages, path, name);
|
|
177
|
+
if (resolved)
|
|
178
|
+
list.push({ name, childPath: resolved });
|
|
179
|
+
}
|
|
180
|
+
edges.set(path, list);
|
|
181
|
+
}
|
|
182
|
+
return edges;
|
|
183
|
+
}
|
|
184
|
+
// Breadth-first search from the root over the lockfile's dependency edges (walking edges, not
|
|
185
|
+
// `npm ls`, since this script needs no install) to find one shortest chain of require-names that
|
|
186
|
+
// reaches `targetPath`.
|
|
187
|
+
function findDependencyPath(packages, edges, targetPath) {
|
|
188
|
+
const rootName = packages['']?.name ?? '(root)';
|
|
189
|
+
if (targetPath === '')
|
|
190
|
+
return rootName;
|
|
191
|
+
const visited = new Set(['']);
|
|
192
|
+
const queue = [''];
|
|
193
|
+
const parent = new Map(); // childPath -> { parentPath, name }
|
|
194
|
+
while (queue.length > 0) {
|
|
195
|
+
const current = queue.shift();
|
|
196
|
+
for (const { name, childPath } of edges.get(current) ?? []) {
|
|
197
|
+
if (visited.has(childPath))
|
|
198
|
+
continue;
|
|
199
|
+
visited.add(childPath);
|
|
200
|
+
parent.set(childPath, { parentPath: current, name });
|
|
201
|
+
if (childPath === targetPath) {
|
|
202
|
+
const chain = [];
|
|
203
|
+
let cursor = targetPath;
|
|
204
|
+
while (cursor !== '') {
|
|
205
|
+
const step = parent.get(cursor);
|
|
206
|
+
chain.unshift(step.name);
|
|
207
|
+
cursor = step.parentPath;
|
|
208
|
+
}
|
|
209
|
+
return [rootName, ...chain].join(' > ');
|
|
210
|
+
}
|
|
211
|
+
queue.push(childPath);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return targetPath; // unreachable via declared edges; fall back to the raw lockfile key
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* `options.allowlist` is required and has no default. An absent allowlist either
|
|
218
|
+
* fails every entry or silently permits every entry, and a gate that picks one
|
|
219
|
+
* of those on the caller's behalf is the fallback this package does not have.
|
|
220
|
+
*
|
|
221
|
+
* `options.source` is the path this lockfile was read from, named in the refusal
|
|
222
|
+
* a document without a `packages` object earns.
|
|
223
|
+
*/
|
|
224
|
+
export function checkLicenses(lockfile, options = {}) {
|
|
225
|
+
const allowed = options.allowlist;
|
|
226
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
227
|
+
throw new Error('check-licenses: no allowlist was supplied; the licences gate carries none of its own');
|
|
228
|
+
}
|
|
229
|
+
const allowlist = new Set(allowed);
|
|
230
|
+
const label = typeof options.label === 'string' ? options.label : 'allowlist';
|
|
231
|
+
const tolerances = options.tolerances ?? [];
|
|
232
|
+
const source = typeof options.source === 'string' ? options.source : 'the lockfile';
|
|
233
|
+
// Every entry is read from `packages`, which npm writes from lockfileVersion 2
|
|
234
|
+
// onward. Defaulting it to {} turned an npm 6 lockfile, or a path naming
|
|
235
|
+
// something that is not a lockfile at all, into a run that reported success
|
|
236
|
+
// over zero entries: a gate that scanned nothing, in the words of a gate that
|
|
237
|
+
// passed.
|
|
238
|
+
const packages = lockfile?.packages;
|
|
239
|
+
if (packages === null ||
|
|
240
|
+
typeof packages !== 'object' ||
|
|
241
|
+
Array.isArray(packages)) {
|
|
242
|
+
throw refuseLockfileShape(`check-licenses: ${source} carries no "packages" object (lockfileVersion ${JSON.stringify(lockfile?.lockfileVersion ?? null)}); every entry is read from there, so this run would have passed over nothing. npm writes "packages" from lockfileVersion 2 onward.`);
|
|
243
|
+
}
|
|
244
|
+
// A `link: true` entry is a workspace symlink, not an installed artifact with its own licence -
|
|
245
|
+
// audit-lockfile-age.mjs already excludes these; this script should agree instead of flagging a
|
|
246
|
+
// symlink for a `license` field it was never going to have.
|
|
247
|
+
const entries = Object.entries(packages).filter(([pkgPath, meta]) => pkgPath !== '' && !meta.link);
|
|
248
|
+
const violations = [];
|
|
249
|
+
const tolerated = [];
|
|
250
|
+
const reasons = new Set();
|
|
251
|
+
for (const [pkgPath, meta] of entries) {
|
|
252
|
+
const name = meta.name ?? pkgPath.split('node_modules/').pop();
|
|
253
|
+
const version = meta.version ?? '(unknown)';
|
|
254
|
+
// `resolved` is the URL `npm ci` fetches, and `integrity` is checked against
|
|
255
|
+
// whatever that URL returns, so an entry can declare one package and install
|
|
256
|
+
// another. Pinning the host alone left that open: an entry naming
|
|
257
|
+
// `lodash@4.17.21` and resolving to `attacker-pkg-9.9.9.tgz` on
|
|
258
|
+
// registry.npmjs.org passed, and the licence being read was never the licence
|
|
259
|
+
// of the artifact being installed. Requiring `resolved` to be the one tarball
|
|
260
|
+
// URL the registry has for this entry's own name and version is what ties the
|
|
261
|
+
// two together.
|
|
262
|
+
const expected = registryTarballUrl(name, version);
|
|
263
|
+
if (meta.resolved !== expected) {
|
|
264
|
+
violations.push({
|
|
265
|
+
path: pkgPath,
|
|
266
|
+
name,
|
|
267
|
+
version,
|
|
268
|
+
license: meta.license ?? null,
|
|
269
|
+
reason: `resolved=${JSON.stringify(meta.resolved ?? null)} is not ${name}@${version}'s registry tarball ${expected}`,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const license = licenseStringOf(meta);
|
|
274
|
+
if (isAllowed(license, allowlist))
|
|
275
|
+
continue;
|
|
276
|
+
const tolerance = tolerances.find((candidate) => isTolerated(candidate, meta, name, license, allowlist));
|
|
277
|
+
if (tolerance !== undefined) {
|
|
278
|
+
tolerated.push(`${name}@${version}`);
|
|
279
|
+
reasons.add(tolerance.reason);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
violations.push({ path: pkgPath, name, version, license });
|
|
283
|
+
}
|
|
284
|
+
tolerated.sort();
|
|
285
|
+
const toleranceReasons = [...reasons].sort();
|
|
286
|
+
if (violations.length === 0)
|
|
287
|
+
return {
|
|
288
|
+
violations: [],
|
|
289
|
+
entryCount: entries.length,
|
|
290
|
+
tolerated,
|
|
291
|
+
toleranceReasons,
|
|
292
|
+
policy: label,
|
|
293
|
+
};
|
|
294
|
+
const edges = buildEdges(packages);
|
|
295
|
+
for (const violation of violations) {
|
|
296
|
+
violation.dependencyPath = findDependencyPath(packages, edges, violation.path);
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
violations,
|
|
300
|
+
entryCount: entries.length,
|
|
301
|
+
tolerated,
|
|
302
|
+
toleranceReasons,
|
|
303
|
+
policy: label,
|
|
304
|
+
};
|
|
305
|
+
}
|