@xagents_in/unmask 0.1.3 → 0.1.4
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 +86 -4
- package/dist/cli/annotations.mjs +145 -0
- package/dist/cli/audit.mjs +223 -0
- package/dist/cli/cache.mjs +488 -0
- package/dist/cli/compare.mjs +261 -0
- package/dist/cli/config.mjs +141 -0
- package/dist/cli/discovery.mjs +372 -0
- package/dist/cli/doctor.mjs +354 -0
- package/dist/cli/exceptions.mjs +170 -0
- package/dist/cli/file.mjs +271 -0
- package/dist/cli/files.mjs +141 -0
- package/dist/cli/history.mjs +232 -0
- package/dist/cli/hosted.mjs +251 -43
- package/dist/cli/info.mjs +125 -0
- package/dist/cli/init.mjs +57 -0
- package/dist/cli/install-guard.mjs +896 -0
- package/dist/cli/lockfile.mjs +330 -0
- package/dist/cli/main.mjs +97 -14
- package/dist/cli/preflight.mjs +388 -0
- package/dist/cli/sarif.mjs +100 -0
- package/dist/cli/shell-integration.mjs +122 -0
- package/dist/cli/terminal.mjs +396 -0
- package/dist/cli/watch.mjs +233 -0
- package/dist/cli/why.mjs +256 -0
- package/dist/core/advisories.mjs +101 -24
- package/dist/core/advisory-cache.mjs +295 -0
- package/dist/core/behavior-signals.mjs +1088 -0
- package/dist/core/dependency-graph.mjs +332 -0
- package/dist/core/evidence-exports.mjs +469 -0
- package/dist/core/guard-config.mjs +463 -0
- package/dist/core/guard-policy.mjs +308 -0
- package/dist/core/package-details.mjs +42 -6
- package/dist/core/report.mjs +7 -0
- package/dist/core/sarif.mjs +556 -0
- package/dist/core/syntax.mjs +296 -15
- package/dist/model/client.mjs +204 -56
- package/dist/model/diff-evidence.mjs +177 -0
- package/dist/model/summary.mjs +183 -0
- package/dist/server/advisory-result.mjs +137 -0
- package/dist/server/app.mjs +60 -2
- package/dist/server/dependency-screening.mjs +386 -0
- package/dist/server/exports.mjs +63 -0
- package/dist/server/guard-decision.mjs +37 -0
- package/dist/server/main.mjs +25 -7
- package/dist/server/queue.mjs +95 -53
- package/dist/server/store.mjs +21 -1
- package/dist/version.json +1 -1
- package/dist/viewer/render.mjs +10 -2
- package/dist/viewer/shell.mjs +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
7
|
+
import {
|
|
8
|
+
activeExceptionFor,
|
|
9
|
+
loadGuardSettings,
|
|
10
|
+
PROFILES,
|
|
11
|
+
} from "../core/guard-config.mjs";
|
|
12
|
+
import { classifyGuardAdvisory } from "../core/guard-policy.mjs";
|
|
13
|
+
import { parentKey, parseLockfile, resolveEdge } from "./lockfile.mjs";
|
|
14
|
+
import { DEFAULT_SERVER_URL, serverOrigin } from "./hosted.mjs";
|
|
15
|
+
|
|
16
|
+
export const GUARD_ACTIVE_ENV = "UNMASK_GUARD_ACTIVE";
|
|
17
|
+
export const GUARD_SOURCES = Object.freeze(["hosted", "osv"]);
|
|
18
|
+
export const GUARD_LIMITS = Object.freeze({
|
|
19
|
+
lockfileBytes: 16 * 1024 * 1024,
|
|
20
|
+
manifestBytes: 1024 * 1024,
|
|
21
|
+
packages: 10000,
|
|
22
|
+
batchSize: 1000,
|
|
23
|
+
detailFetches: 200,
|
|
24
|
+
responseBytes: 16 * 1024 * 1024,
|
|
25
|
+
timeoutMs: 120000,
|
|
26
|
+
probeTimeoutMs: 30000,
|
|
27
|
+
concurrency: 8,
|
|
28
|
+
});
|
|
29
|
+
const OSV_BATCH = "https://api.osv.dev/v1/querybatch";
|
|
30
|
+
const OSV_VULN = "https://api.osv.dev/v1/vulns/";
|
|
31
|
+
const SEVERITY = ["unknown", "low", "medium", "high", "critical"];
|
|
32
|
+
const IDENTIFIER = /^[A-Za-z0-9:_.-]{1,256}$/;
|
|
33
|
+
const SHA512 = /^sha512-[A-Za-z0-9+/]{86}==$/;
|
|
34
|
+
const CI_FLAGS = new Set([
|
|
35
|
+
"--ignore-scripts",
|
|
36
|
+
"--foreground-scripts",
|
|
37
|
+
"--no-audit",
|
|
38
|
+
"--audit=false",
|
|
39
|
+
"--no-fund",
|
|
40
|
+
"--fund=false",
|
|
41
|
+
"--no-progress",
|
|
42
|
+
"--progress=false",
|
|
43
|
+
"--no-color",
|
|
44
|
+
"--color=false",
|
|
45
|
+
"--prefer-offline",
|
|
46
|
+
"--offline",
|
|
47
|
+
"--silent",
|
|
48
|
+
"--quiet",
|
|
49
|
+
"-q",
|
|
50
|
+
"--verbose",
|
|
51
|
+
"-d",
|
|
52
|
+
]);
|
|
53
|
+
const DEP_TYPES = ["dev", "optional", "peer"];
|
|
54
|
+
const CI_VALUES = {
|
|
55
|
+
"--omit": DEP_TYPES,
|
|
56
|
+
"--include": DEP_TYPES,
|
|
57
|
+
"--loglevel": [
|
|
58
|
+
"silent",
|
|
59
|
+
"error",
|
|
60
|
+
"warn",
|
|
61
|
+
"notice",
|
|
62
|
+
"http",
|
|
63
|
+
"info",
|
|
64
|
+
"verbose",
|
|
65
|
+
"silly",
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const SAFE_NPM_ENV = new Set(
|
|
69
|
+
[
|
|
70
|
+
"loglevel",
|
|
71
|
+
"progress",
|
|
72
|
+
"fund",
|
|
73
|
+
"audit",
|
|
74
|
+
"color",
|
|
75
|
+
"update_notifier",
|
|
76
|
+
"cache",
|
|
77
|
+
"omit",
|
|
78
|
+
"include",
|
|
79
|
+
"ignore_scripts",
|
|
80
|
+
"prefer_offline",
|
|
81
|
+
"offline",
|
|
82
|
+
].map((key) => `npm_config_${key}`),
|
|
83
|
+
);
|
|
84
|
+
const DEPENDENCY_FIELDS = [
|
|
85
|
+
"dependencies",
|
|
86
|
+
"devDependencies",
|
|
87
|
+
"optionalDependencies",
|
|
88
|
+
];
|
|
89
|
+
const REFRESH =
|
|
90
|
+
"Run `npm install --package-lock-only --ignore-scripts` to refresh the lockfile without running package code, review the diff, then rerun `unmask ci`.";
|
|
91
|
+
|
|
92
|
+
export class GuardError extends Error {
|
|
93
|
+
constructor(code, message, remediation) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.code = code;
|
|
96
|
+
this.remediation = remediation;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const object = (value) =>
|
|
100
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
101
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
102
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
103
|
+
const identifiers = (list) =>
|
|
104
|
+
Array.isArray(list) &&
|
|
105
|
+
list.every((item) => typeof item === "string" && IDENTIFIER.test(item));
|
|
106
|
+
|
|
107
|
+
export function inspectCiArguments(args) {
|
|
108
|
+
for (let index = 0; index < args.length; index++) {
|
|
109
|
+
const arg = String(args[index]);
|
|
110
|
+
const split = arg.indexOf("=");
|
|
111
|
+
const flag = split === -1 ? arg : arg.slice(0, split);
|
|
112
|
+
if (CI_FLAGS.has(arg)) continue;
|
|
113
|
+
if (Object.hasOwn(CI_VALUES, flag)) {
|
|
114
|
+
const given = split === -1 ? args[++index] : arg.slice(split + 1);
|
|
115
|
+
if (CI_VALUES[flag].includes(given)) continue;
|
|
116
|
+
}
|
|
117
|
+
throw new GuardError(
|
|
118
|
+
"unsupported-option",
|
|
119
|
+
`${arg.slice(0, 64)} is not on the guard's npm ci allowlist, so the screened plan could differ from what npm installs`,
|
|
120
|
+
"Supported: --omit/--include dev|optional|peer, --loglevel LEVEL, --ignore-scripts, --foreground-scripts, --no-audit, --no-fund, --no-progress, --no-color, --prefer-offline, --offline, --silent, --quiet, --verbose.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function inspectNpmEnvironment(env) {
|
|
126
|
+
const unsafe = Object.keys(env)
|
|
127
|
+
.filter((key) => /^npm_config_/i.test(key))
|
|
128
|
+
.filter((key) => !SAFE_NPM_ENV.has(key.toLowerCase()))
|
|
129
|
+
.sort();
|
|
130
|
+
if (unsafe.length)
|
|
131
|
+
throw new GuardError(
|
|
132
|
+
"unsupported-config",
|
|
133
|
+
`npm configuration from the environment (${unsafe.slice(0, 8).join(", ")}) could retarget npm away from the screened project`,
|
|
134
|
+
"Unset those variables, or run unmask directly rather than from an npm script.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sortedFields(manifest) {
|
|
139
|
+
return JSON.stringify(
|
|
140
|
+
DEPENDENCY_FIELDS.map((field) =>
|
|
141
|
+
object(manifest?.[field])
|
|
142
|
+
? Object.entries(manifest[field]).sort(([a], [b]) =>
|
|
143
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
144
|
+
)
|
|
145
|
+
: [],
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
function linkedOutside(lock, key, name) {
|
|
150
|
+
for (let base = key; ; base = parentKey(base)) {
|
|
151
|
+
const found = lock.entries.get(
|
|
152
|
+
`${base ? base + "/" : ""}node_modules/${name}`,
|
|
153
|
+
);
|
|
154
|
+
if (found) return found.kind === "link";
|
|
155
|
+
if (!base) return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function planFromLockfile(
|
|
160
|
+
lockText,
|
|
161
|
+
manifestText,
|
|
162
|
+
limits = GUARD_LIMITS,
|
|
163
|
+
) {
|
|
164
|
+
let manifest;
|
|
165
|
+
try {
|
|
166
|
+
manifest = JSON.parse(manifestText);
|
|
167
|
+
} catch {
|
|
168
|
+
throw new GuardError(
|
|
169
|
+
"lockfile-invalid",
|
|
170
|
+
"package.json is not valid JSON",
|
|
171
|
+
REFRESH,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const lock = parseLockfile(lockText);
|
|
175
|
+
const raw = JSON.parse(lockText).packages;
|
|
176
|
+
if (!object(manifest) || sortedFields(raw[""]) !== sortedFields(manifest))
|
|
177
|
+
throw new GuardError(
|
|
178
|
+
"lockfile-out-of-sync",
|
|
179
|
+
"package.json dependencies differ from the lockfile root, so npm could resolve versions that were not screened",
|
|
180
|
+
REFRESH,
|
|
181
|
+
);
|
|
182
|
+
if (lock.entries.size > limits.packages + 1)
|
|
183
|
+
throw new GuardError(
|
|
184
|
+
"plan-limit",
|
|
185
|
+
`The lockfile has more than ${limits.packages} entries`,
|
|
186
|
+
"Screen smaller workspaces separately; the guard does not truncate plans.",
|
|
187
|
+
);
|
|
188
|
+
const items = new Map();
|
|
189
|
+
const missing = [];
|
|
190
|
+
for (const entry of lock.entries.values()) {
|
|
191
|
+
if (
|
|
192
|
+
entry.key &&
|
|
193
|
+
entry.key.split("/").some((part) => ["", ".", ".."].includes(part))
|
|
194
|
+
)
|
|
195
|
+
throw new GuardError(
|
|
196
|
+
"lockfile-invalid",
|
|
197
|
+
`Lockfile key ${entry.key.slice(0, 120)} is not a normalized project path`,
|
|
198
|
+
REFRESH,
|
|
199
|
+
);
|
|
200
|
+
const project = entry.kind === "root" || entry.kind === "workspace";
|
|
201
|
+
// Every installed dependency edge must already resolve inside the lockfile, otherwise npm would fetch unscreened packages.
|
|
202
|
+
for (const edge of entry.edges)
|
|
203
|
+
if (
|
|
204
|
+
edge.type !== "optional" &&
|
|
205
|
+
(edge.type !== "dev" || project) &&
|
|
206
|
+
raw[entry.key]?.peerDependenciesMeta?.[edge.name]?.optional !== true &&
|
|
207
|
+
!resolveEdge(lock, entry.key, edge.name) &&
|
|
208
|
+
!linkedOutside(lock, entry.key, edge.name)
|
|
209
|
+
)
|
|
210
|
+
missing.push(`${entry.key || "(root)"} -> ${edge.name}`);
|
|
211
|
+
if (entry.kind === "root") continue;
|
|
212
|
+
if (entry.kind === "workspace" || entry.kind === "link") {
|
|
213
|
+
items.set(`local:${entry.key}`, {
|
|
214
|
+
id: `local:${entry.key}`,
|
|
215
|
+
binding: entry.kind === "link" ? "local-link" : "workspace-package",
|
|
216
|
+
paths: [entry.key],
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (entry.kind === "invalid")
|
|
221
|
+
throw new GuardError(
|
|
222
|
+
"lockfile-unbound",
|
|
223
|
+
`Lockfile entry ${entry.key} has no exact registry name and version (${entry.reason})`,
|
|
224
|
+
REFRESH,
|
|
225
|
+
);
|
|
226
|
+
const bound =
|
|
227
|
+
entry.kind === "url"
|
|
228
|
+
? "non-registry-source"
|
|
229
|
+
: entry.bundled
|
|
230
|
+
? "bundled-unverified"
|
|
231
|
+
: !entry.resolved
|
|
232
|
+
? "missing-resolved"
|
|
233
|
+
: !entry.integrity
|
|
234
|
+
? "missing-integrity"
|
|
235
|
+
: !entry.bound || !SHA512.test(entry.integrity)
|
|
236
|
+
? "weak-integrity"
|
|
237
|
+
: "registry-integrity";
|
|
238
|
+
const id = `${entry.name}@${entry.version}`;
|
|
239
|
+
const existing = items.get(id);
|
|
240
|
+
if (!existing) {
|
|
241
|
+
items.set(id, {
|
|
242
|
+
id,
|
|
243
|
+
name: entry.name,
|
|
244
|
+
version: entry.version,
|
|
245
|
+
integrity: entry.integrity,
|
|
246
|
+
binding: bound,
|
|
247
|
+
paths: [entry.key],
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
existing.paths.push(entry.key);
|
|
252
|
+
if (bound !== "registry-integrity") existing.binding = bound;
|
|
253
|
+
else if (existing.integrity !== entry.integrity)
|
|
254
|
+
existing.binding = "conflicting-integrity";
|
|
255
|
+
}
|
|
256
|
+
if (missing.length)
|
|
257
|
+
throw new GuardError(
|
|
258
|
+
"lockfile-incomplete",
|
|
259
|
+
`The lockfile has no resolved entry for ${missing.length} required dependencies (${missing.slice(0, 3).join("; ")}), so npm would resolve unscreened packages`,
|
|
260
|
+
REFRESH,
|
|
261
|
+
);
|
|
262
|
+
return [...items.values()].sort(byId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function boundedRead(file, max) {
|
|
266
|
+
const info = await stat(file);
|
|
267
|
+
const bytes = info.isFile() && info.size <= max ? await readFile(file) : null;
|
|
268
|
+
if (!bytes || bytes.length > max)
|
|
269
|
+
throw new GuardError(
|
|
270
|
+
"plan-limit",
|
|
271
|
+
`${path.basename(file)} is not a regular file within the size limit`,
|
|
272
|
+
);
|
|
273
|
+
return bytes;
|
|
274
|
+
}
|
|
275
|
+
async function readInputs(cwd, limits) {
|
|
276
|
+
for (const lockfile of ["npm-shrinkwrap.json", "package-lock.json"]) {
|
|
277
|
+
let lockBytes;
|
|
278
|
+
try {
|
|
279
|
+
lockBytes = await boundedRead(
|
|
280
|
+
path.join(cwd, lockfile),
|
|
281
|
+
limits.lockfileBytes,
|
|
282
|
+
);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (error?.code === "ENOENT") continue;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
let manifestBytes;
|
|
288
|
+
try {
|
|
289
|
+
manifestBytes = await boundedRead(
|
|
290
|
+
path.join(cwd, "package.json"),
|
|
291
|
+
limits.manifestBytes,
|
|
292
|
+
);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof GuardError) throw error;
|
|
295
|
+
throw new GuardError(
|
|
296
|
+
"lockfile-invalid",
|
|
297
|
+
"package.json could not be read",
|
|
298
|
+
REFRESH,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
lockfile,
|
|
303
|
+
lockBytes,
|
|
304
|
+
digest: `${sha256(lockBytes)}:${sha256(manifestBytes)}`,
|
|
305
|
+
manifestBytes,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
throw new GuardError(
|
|
309
|
+
"lockfile-missing",
|
|
310
|
+
"No package-lock.json or npm-shrinkwrap.json in this directory, so no exact plan exists",
|
|
311
|
+
"Run `npm install --package-lock-only --ignore-scripts` in the project root, then rerun `unmask ci`.",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function readProjectPlan(cwd, limits = GUARD_LIMITS) {
|
|
316
|
+
const { manifestBytes, ...inputs } = await readInputs(cwd, limits);
|
|
317
|
+
return {
|
|
318
|
+
...inputs,
|
|
319
|
+
packages: planFromLockfile(
|
|
320
|
+
inputs.lockBytes.toString("utf8"),
|
|
321
|
+
manifestBytes.toString("utf8"),
|
|
322
|
+
limits,
|
|
323
|
+
),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function fetchJson(fetchImpl, url, options, limits, signal) {
|
|
328
|
+
const response = await fetchImpl(url, {
|
|
329
|
+
...options,
|
|
330
|
+
redirect: "manual",
|
|
331
|
+
credentials: "omit",
|
|
332
|
+
referrerPolicy: "no-referrer",
|
|
333
|
+
signal,
|
|
334
|
+
headers: { accept: "application/json", ...(options.headers ?? {}) },
|
|
335
|
+
});
|
|
336
|
+
try {
|
|
337
|
+
if (response.status !== 200)
|
|
338
|
+
throw Object.assign(new Error("http-error"), { status: response.status });
|
|
339
|
+
if (
|
|
340
|
+
!/^application\/json\b/i.test(response.headers.get("content-type") ?? "")
|
|
341
|
+
)
|
|
342
|
+
throw new Error("invalid-response");
|
|
343
|
+
const chunks = [];
|
|
344
|
+
let size = 0;
|
|
345
|
+
for await (const chunk of response.body ?? []) {
|
|
346
|
+
size += chunk.byteLength;
|
|
347
|
+
if (size > limits.responseBytes) throw new Error("response-limit");
|
|
348
|
+
chunks.push(chunk);
|
|
349
|
+
}
|
|
350
|
+
return JSON.parse(Buffer.concat(chunks, size).toString("utf8"));
|
|
351
|
+
} finally {
|
|
352
|
+
response.body?.cancel?.().catch?.(() => {});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const classify = (record, item) =>
|
|
356
|
+
classifyGuardAdvisory(record, {
|
|
357
|
+
ecosystem: "npm",
|
|
358
|
+
name: item.name,
|
|
359
|
+
version: item.version,
|
|
360
|
+
});
|
|
361
|
+
const unknownLookup = (reason, advisories = []) => ({
|
|
362
|
+
complete: false,
|
|
363
|
+
reasons: [reason],
|
|
364
|
+
advisories,
|
|
365
|
+
});
|
|
366
|
+
const activeFindings = (classified) =>
|
|
367
|
+
classified
|
|
368
|
+
.filter((record) => record.status === "active")
|
|
369
|
+
.map((record) => ({
|
|
370
|
+
id: record.id,
|
|
371
|
+
malware: record.knownMalware,
|
|
372
|
+
severity: record.severity,
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
async function osvLookups(registry, { fetchImpl, signal, limits }) {
|
|
376
|
+
const lookups = new Map();
|
|
377
|
+
for (let start = 0; start < registry.length; start += limits.batchSize) {
|
|
378
|
+
const batch = registry.slice(start, start + limits.batchSize);
|
|
379
|
+
let results;
|
|
380
|
+
try {
|
|
381
|
+
const body = await fetchJson(
|
|
382
|
+
fetchImpl,
|
|
383
|
+
OSV_BATCH,
|
|
384
|
+
{
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: { "content-type": "application/json" },
|
|
387
|
+
body: JSON.stringify({
|
|
388
|
+
queries: batch.map((item) => ({
|
|
389
|
+
package: { name: item.name, ecosystem: "npm" },
|
|
390
|
+
version: item.version,
|
|
391
|
+
})),
|
|
392
|
+
}),
|
|
393
|
+
},
|
|
394
|
+
limits,
|
|
395
|
+
signal,
|
|
396
|
+
);
|
|
397
|
+
results = body?.results;
|
|
398
|
+
if (!Array.isArray(results) || results.length !== batch.length)
|
|
399
|
+
throw new Error("invalid-response");
|
|
400
|
+
} catch {
|
|
401
|
+
for (const item of batch)
|
|
402
|
+
lookups.set(item.id, unknownLookup("advisory-lookup-unavailable"));
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
batch.forEach((item, index) => {
|
|
406
|
+
const result = results[index];
|
|
407
|
+
const vulns = object(result) ? (result.vulns ?? []) : null;
|
|
408
|
+
if (
|
|
409
|
+
!Array.isArray(vulns) ||
|
|
410
|
+
result.next_page_token ||
|
|
411
|
+
!identifiers(vulns.map((vuln) => vuln?.id))
|
|
412
|
+
)
|
|
413
|
+
lookups.set(item.id, unknownLookup("invalid-advisory-record"));
|
|
414
|
+
else
|
|
415
|
+
lookups.set(item.id, {
|
|
416
|
+
ids: [...new Set(vulns.map((vuln) => vuln.id))].sort(),
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const ids = [
|
|
421
|
+
...new Set([...lookups.values()].flatMap((item) => item.ids ?? [])),
|
|
422
|
+
].slice(0, limits.detailFetches);
|
|
423
|
+
const details = new Map();
|
|
424
|
+
let cursor = 0;
|
|
425
|
+
await Promise.all(
|
|
426
|
+
Array.from(
|
|
427
|
+
{ length: Math.min(limits.concurrency, ids.length) },
|
|
428
|
+
async () => {
|
|
429
|
+
while (cursor < ids.length) {
|
|
430
|
+
const id = ids[cursor++];
|
|
431
|
+
try {
|
|
432
|
+
const detail = await fetchJson(
|
|
433
|
+
fetchImpl,
|
|
434
|
+
OSV_VULN + encodeURIComponent(id),
|
|
435
|
+
{ method: "GET" },
|
|
436
|
+
limits,
|
|
437
|
+
signal,
|
|
438
|
+
);
|
|
439
|
+
if (detail?.id === id) details.set(id, detail);
|
|
440
|
+
} catch {}
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
const byPackage = new Map(registry.map((item) => [item.id, item]));
|
|
446
|
+
for (const [key, lookup] of lookups) {
|
|
447
|
+
if (!lookup.ids) continue;
|
|
448
|
+
const advisories = [];
|
|
449
|
+
let complete = true;
|
|
450
|
+
for (const id of lookup.ids) {
|
|
451
|
+
const detail = details.get(id);
|
|
452
|
+
if (!detail) complete = false;
|
|
453
|
+
else {
|
|
454
|
+
const record = classify(
|
|
455
|
+
{
|
|
456
|
+
id: detail.id,
|
|
457
|
+
aliases: detail.aliases ?? [],
|
|
458
|
+
withdrawn: detail.withdrawn,
|
|
459
|
+
databaseSeverity: detail.database_specific?.severity,
|
|
460
|
+
affected: (Array.isArray(detail.affected) ? detail.affected : [])
|
|
461
|
+
.slice(0, 8)
|
|
462
|
+
.map((affected) => ({
|
|
463
|
+
package: affected?.package,
|
|
464
|
+
ecosystemSeverity: affected?.ecosystem_specific?.severity,
|
|
465
|
+
})),
|
|
466
|
+
},
|
|
467
|
+
byPackage.get(key),
|
|
468
|
+
);
|
|
469
|
+
if (record.status === "invalid") complete = false;
|
|
470
|
+
else if (record.status === "active")
|
|
471
|
+
advisories.push({
|
|
472
|
+
id,
|
|
473
|
+
malware: record.knownMalware,
|
|
474
|
+
severity: record.severity,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
lookups.set(key, {
|
|
479
|
+
complete,
|
|
480
|
+
reasons: complete ? [] : ["advisory-detail-unavailable"],
|
|
481
|
+
advisories,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return lookups;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function hostedLookups(
|
|
488
|
+
identities,
|
|
489
|
+
{ fetchImpl, signal, limits, serverUrl, lockBytes },
|
|
490
|
+
) {
|
|
491
|
+
const registry = identities.filter(
|
|
492
|
+
(item) => item.binding === "registry-integrity",
|
|
493
|
+
);
|
|
494
|
+
const origin = serverOrigin(serverUrl ?? DEFAULT_SERVER_URL);
|
|
495
|
+
const lookups = new Map();
|
|
496
|
+
const failAll = (reason) => {
|
|
497
|
+
for (const item of registry) lookups.set(item.id, unknownLookup(reason));
|
|
498
|
+
return lookups;
|
|
499
|
+
};
|
|
500
|
+
let body;
|
|
501
|
+
try {
|
|
502
|
+
body = await fetchJson(
|
|
503
|
+
fetchImpl,
|
|
504
|
+
`${origin}/api/dependency-screening`,
|
|
505
|
+
{
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { "content-type": "application/json" },
|
|
508
|
+
body: lockBytes,
|
|
509
|
+
},
|
|
510
|
+
limits,
|
|
511
|
+
signal,
|
|
512
|
+
);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
return failAll(
|
|
515
|
+
error?.status === 429
|
|
516
|
+
? "hosted-rate-limited"
|
|
517
|
+
: error?.status
|
|
518
|
+
? `hosted-http-${error.status}`
|
|
519
|
+
: "hosted-unavailable",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (
|
|
523
|
+
!object(body) ||
|
|
524
|
+
body.schemaVersion !== "1" ||
|
|
525
|
+
body.coverage?.scope !== "exact-registry-identities" ||
|
|
526
|
+
!Array.isArray(body.dependencies)
|
|
527
|
+
)
|
|
528
|
+
return failAll("hosted-invalid-response");
|
|
529
|
+
const rows = new Map();
|
|
530
|
+
for (const row of body.dependencies) {
|
|
531
|
+
if (!object(row) || typeof row.spec !== "string" || rows.has(row.spec))
|
|
532
|
+
return failAll("hosted-invalid-response");
|
|
533
|
+
rows.set(row.spec, row);
|
|
534
|
+
}
|
|
535
|
+
// Rows may name any exact plan identity, including ones the guard itself downgraded to unbound.
|
|
536
|
+
const planned = new Set(identities.map((item) => item.id));
|
|
537
|
+
const extraRows = [...rows.keys()].some((spec) => !planned.has(spec));
|
|
538
|
+
for (const item of registry) {
|
|
539
|
+
const row = rows.get(item.id);
|
|
540
|
+
const records = Array.isArray(row?.advisories) ? row.advisories : null;
|
|
541
|
+
const classified = (records ?? []).map((record) => classify(record, item));
|
|
542
|
+
const settled =
|
|
543
|
+
["found", "none-reported"].includes(row?.status) && row.complete === true;
|
|
544
|
+
let reason;
|
|
545
|
+
if (!row) reason = "hosted-missing-identity";
|
|
546
|
+
else if (
|
|
547
|
+
row.name !== item.name ||
|
|
548
|
+
row.version !== item.version ||
|
|
549
|
+
row.integrityStatus !== "consistent" ||
|
|
550
|
+
row.integrity !== item.integrity
|
|
551
|
+
)
|
|
552
|
+
reason = "hosted-identity-mismatch";
|
|
553
|
+
else if (!settled)
|
|
554
|
+
reason = identifiers([row.status])
|
|
555
|
+
? `hosted-${row.status}`
|
|
556
|
+
: "hosted-invalid-response";
|
|
557
|
+
else if (
|
|
558
|
+
!records ||
|
|
559
|
+
(row.status === "found") !== records.length > 0 ||
|
|
560
|
+
classified.some((record) => record.status === "invalid")
|
|
561
|
+
)
|
|
562
|
+
reason = "hosted-invalid-advisory-record";
|
|
563
|
+
else if (extraRows) reason = "hosted-plan-mismatch";
|
|
564
|
+
// Valid advisories for the exact name@version stay as evidence even when coverage is incomplete; unknown still blocks the rest.
|
|
565
|
+
const evidence =
|
|
566
|
+
row?.name === item.name && row?.version === item.version
|
|
567
|
+
? activeFindings(classified)
|
|
568
|
+
: [];
|
|
569
|
+
if (reason) {
|
|
570
|
+
lookups.set(item.id, unknownLookup(reason, evidence));
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
lookups.set(item.id, {
|
|
574
|
+
complete: true,
|
|
575
|
+
reasons: [],
|
|
576
|
+
advisories: activeFindings(classified),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
for (const item of identities) {
|
|
580
|
+
const row = rows.get(item.id);
|
|
581
|
+
if (
|
|
582
|
+
item.binding === "registry-integrity" ||
|
|
583
|
+
row?.name !== item.name ||
|
|
584
|
+
row?.version !== item.version ||
|
|
585
|
+
!Array.isArray(row.advisories)
|
|
586
|
+
)
|
|
587
|
+
continue;
|
|
588
|
+
const classified = row.advisories.map((record) => classify(record, item));
|
|
589
|
+
lookups.set(
|
|
590
|
+
item.id,
|
|
591
|
+
unknownLookup("hosted-unbound-evidence", activeFindings(classified)),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
return lookups;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export async function screenPlan(
|
|
598
|
+
plan,
|
|
599
|
+
{
|
|
600
|
+
source = "hosted",
|
|
601
|
+
fetchImpl = globalThis.fetch,
|
|
602
|
+
signal,
|
|
603
|
+
policy = "strict",
|
|
604
|
+
limits = GUARD_LIMITS,
|
|
605
|
+
serverUrl,
|
|
606
|
+
config,
|
|
607
|
+
now = new Date(),
|
|
608
|
+
} = {},
|
|
609
|
+
) {
|
|
610
|
+
if (!Object.hasOwn(PROFILES, policy))
|
|
611
|
+
throw new GuardError(
|
|
612
|
+
"usage",
|
|
613
|
+
"UNMASK_GUARD_POLICY must be strict, balanced or audit",
|
|
614
|
+
);
|
|
615
|
+
const profile = PROFILES[policy];
|
|
616
|
+
if (!GUARD_SOURCES.includes(source))
|
|
617
|
+
throw new GuardError("usage", "UNMASK_GUARD_SOURCE must be hosted or osv");
|
|
618
|
+
const lifetime = AbortSignal.any([
|
|
619
|
+
AbortSignal.timeout(limits.timeoutMs),
|
|
620
|
+
...(signal ? [signal] : []),
|
|
621
|
+
]);
|
|
622
|
+
const registry = plan.packages.filter(
|
|
623
|
+
(item) => item.binding === "registry-integrity",
|
|
624
|
+
);
|
|
625
|
+
const identities = plan.packages.filter((item) => item.name);
|
|
626
|
+
const lookups =
|
|
627
|
+
source === "osv"
|
|
628
|
+
? registry.length
|
|
629
|
+
? await osvLookups(registry, { fetchImpl, signal: lifetime, limits })
|
|
630
|
+
: new Map()
|
|
631
|
+
: !identities.length
|
|
632
|
+
? new Map()
|
|
633
|
+
: await hostedLookups(identities, {
|
|
634
|
+
fetchImpl,
|
|
635
|
+
signal: lifetime,
|
|
636
|
+
limits,
|
|
637
|
+
serverUrl,
|
|
638
|
+
lockBytes: plan.lockBytes,
|
|
639
|
+
});
|
|
640
|
+
const threshold = SEVERITY.indexOf(profile.blockSeverity);
|
|
641
|
+
const decisions = plan.packages.map((item) => {
|
|
642
|
+
const lookup = lookups.get(item.id);
|
|
643
|
+
const advisories = lookup?.advisories ?? [];
|
|
644
|
+
const reasons = [];
|
|
645
|
+
if (item.binding !== "registry-integrity")
|
|
646
|
+
reasons.push(`unbound-identity:${item.binding}`);
|
|
647
|
+
else if (!lookup) reasons.push("advisory-lookup-missing");
|
|
648
|
+
else reasons.push(...lookup.reasons);
|
|
649
|
+
const malware = advisories.filter((advisory) => advisory.malware);
|
|
650
|
+
const severe = advisories.filter(
|
|
651
|
+
(advisory) =>
|
|
652
|
+
SEVERITY.indexOf(advisory.severity) >= threshold ||
|
|
653
|
+
(advisory.severity === "unknown" &&
|
|
654
|
+
profile.unknownDecision === "block"),
|
|
655
|
+
);
|
|
656
|
+
let decision = "allow";
|
|
657
|
+
if (malware.length) {
|
|
658
|
+
decision = "block";
|
|
659
|
+
reasons.unshift("known-malware");
|
|
660
|
+
} else if (reasons.length) decision = "unknown";
|
|
661
|
+
else if (severe.length) {
|
|
662
|
+
decision = "block";
|
|
663
|
+
reasons.push(
|
|
664
|
+
`advisory-severity:${[...new Set(severe.map((advisory) => (advisory.severity === "unknown" ? "unrated" : advisory.severity)))].join("/")}`,
|
|
665
|
+
);
|
|
666
|
+
} else if (advisories.length) {
|
|
667
|
+
decision = "warn";
|
|
668
|
+
reasons.push("advisory-reported");
|
|
669
|
+
}
|
|
670
|
+
// Exact expiring exceptions waive only a pure severity block on a registry-bound identity, never malware or unknown evidence.
|
|
671
|
+
const exception =
|
|
672
|
+
config &&
|
|
673
|
+
decision === "block" &&
|
|
674
|
+
!malware.length &&
|
|
675
|
+
reasons.length === 1 &&
|
|
676
|
+
reasons[0].startsWith("advisory-severity:") &&
|
|
677
|
+
item.binding === "registry-integrity" &&
|
|
678
|
+
activeExceptionFor(config, item, {
|
|
679
|
+
now,
|
|
680
|
+
knownMalware: malware.length > 0,
|
|
681
|
+
});
|
|
682
|
+
if (exception) {
|
|
683
|
+
decision = "warn";
|
|
684
|
+
reasons.push(`exception-until:${exception.expires}`);
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
id: item.id,
|
|
688
|
+
decision,
|
|
689
|
+
permitted:
|
|
690
|
+
["allow", "warn"].includes(decision) ||
|
|
691
|
+
(profile.enforcement === "audit" && !malware.length),
|
|
692
|
+
reasons,
|
|
693
|
+
advisories: advisories.map((advisory) => advisory.id),
|
|
694
|
+
paths: item.paths,
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
const count = (decision) =>
|
|
698
|
+
decisions.filter((item) => item.decision === decision).length;
|
|
699
|
+
const summary = {
|
|
700
|
+
allow: count("allow"),
|
|
701
|
+
warn: count("warn"),
|
|
702
|
+
block: count("block"),
|
|
703
|
+
unknown: count("unknown"),
|
|
704
|
+
};
|
|
705
|
+
return {
|
|
706
|
+
policy,
|
|
707
|
+
source:
|
|
708
|
+
source === "osv"
|
|
709
|
+
? "OSV (diagnostic direct query)"
|
|
710
|
+
: "hosted dependency screening",
|
|
711
|
+
registry: registry.length,
|
|
712
|
+
decisions,
|
|
713
|
+
summary,
|
|
714
|
+
enforcement: profile.enforcement,
|
|
715
|
+
blocked: decisions.some((item) => !item.permitted),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function report(stream, plan, result) {
|
|
720
|
+
const lines = [
|
|
721
|
+
`unmask guard: ${plan.lockfile}: ${result.registry} exact registry identities in advisory screening via ${result.source}, ${plan.packages.length - result.registry} other entries (${result.policy} policy)`,
|
|
722
|
+
`unmask guard: allow ${result.summary.allow}, warn ${result.summary.warn}, unknown ${result.summary.unknown}, block ${result.summary.block}`,
|
|
723
|
+
...(result.notices ?? []).map((notice) => `unmask guard: ${notice}`),
|
|
724
|
+
];
|
|
725
|
+
if (result.enforcement === "audit")
|
|
726
|
+
lines.push(
|
|
727
|
+
"unmask guard: audit profile is nonenforcing; only known malware stops npm.",
|
|
728
|
+
);
|
|
729
|
+
const shown = result.decisions.filter((item) => item.decision !== "allow");
|
|
730
|
+
for (const item of shown.slice(0, 25))
|
|
731
|
+
lines.push(
|
|
732
|
+
` ${item.decision.padEnd(7)} ${item.id} ${item.reasons.join(",")}${item.advisories.length ? ` [${item.advisories.slice(0, 5).join(" ")}]` : ""}`,
|
|
733
|
+
);
|
|
734
|
+
if (shown.length > 25) lines.push(` ... ${shown.length - 25} more`);
|
|
735
|
+
lines.push(
|
|
736
|
+
result.blocked
|
|
737
|
+
? "unmask guard: blocked; npm was not started."
|
|
738
|
+
: "unmask guard: no blocking finding. Advisory screening only; this is not a safety verdict.",
|
|
739
|
+
);
|
|
740
|
+
stream.write(lines.join("\n") + "\n");
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function runNpm(command, args, { cwd, env, capture = false, timeoutMs }) {
|
|
744
|
+
return new Promise((resolve) => {
|
|
745
|
+
const child = spawn(command, args, {
|
|
746
|
+
cwd,
|
|
747
|
+
env: { ...env, [GUARD_ACTIVE_ENV]: "1" },
|
|
748
|
+
stdio: capture ? ["ignore", "pipe", "ignore"] : "inherit",
|
|
749
|
+
shell: false,
|
|
750
|
+
...(timeoutMs ? { timeout: timeoutMs } : {}),
|
|
751
|
+
});
|
|
752
|
+
let output = "";
|
|
753
|
+
child.stdout?.on("data", (chunk) => {
|
|
754
|
+
if (output.length < 8192) output += chunk;
|
|
755
|
+
});
|
|
756
|
+
child.once("error", (error) => resolve({ error }));
|
|
757
|
+
child.once("close", (code, signal) =>
|
|
758
|
+
resolve({
|
|
759
|
+
code: code ?? 128 + (os.constants.signals[signal] ?? 0),
|
|
760
|
+
output: output.trim(),
|
|
761
|
+
}),
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
async function probeNpmTarget(npm, cwd, env, limits) {
|
|
766
|
+
const read = async (args) => {
|
|
767
|
+
const outcome = await runNpm(npm, args, {
|
|
768
|
+
cwd,
|
|
769
|
+
env,
|
|
770
|
+
capture: true,
|
|
771
|
+
timeoutMs: limits.probeTimeoutMs,
|
|
772
|
+
});
|
|
773
|
+
if (outcome.error || outcome.code !== 0)
|
|
774
|
+
throw new GuardError(
|
|
775
|
+
"npm-unavailable",
|
|
776
|
+
"npm could not report its effective configuration; nothing was installed",
|
|
777
|
+
);
|
|
778
|
+
return outcome.output;
|
|
779
|
+
};
|
|
780
|
+
const prefix = await read(["prefix"]);
|
|
781
|
+
const global = await read(["config", "get", "global"]);
|
|
782
|
+
const lock = await read(["config", "get", "package-lock"]);
|
|
783
|
+
const target = await realpath(prefix).catch(() => null);
|
|
784
|
+
if (target !== (await realpath(cwd)) || global !== "false" || lock !== "true")
|
|
785
|
+
throw new GuardError(
|
|
786
|
+
"npm-config-retarget",
|
|
787
|
+
"npm's effective configuration targets another prefix, enables global mode or disables the lockfile, so it would not install the screened plan",
|
|
788
|
+
"Remove global, prefix, location or package-lock settings from the .npmrc files that apply to this project.",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function projectSettings(cwd, now) {
|
|
793
|
+
try {
|
|
794
|
+
return await loadGuardSettings(cwd, { now });
|
|
795
|
+
} catch (error) {
|
|
796
|
+
throw new GuardError(
|
|
797
|
+
"guard-config-invalid",
|
|
798
|
+
`Project guard configuration could not be used (${typeof error?.code === "string" ? error.code : "unreadable"}): ${error?.message ?? "unknown error"}`,
|
|
799
|
+
"Fix or remove .unmask.json with `unmask config`; the guard does not fall back to defaults when it is unusable.",
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Only an explicit invocation may select the nonenforcing audit profile; a project file requesting it falls back to strict.
|
|
804
|
+
function selectProfile(requested, settings) {
|
|
805
|
+
const where = settings.source === "file" ? settings.file : "defaults";
|
|
806
|
+
if (requested)
|
|
807
|
+
return {
|
|
808
|
+
profile: requested,
|
|
809
|
+
notices: [`profile ${requested} selected by UNMASK_GUARD_POLICY`],
|
|
810
|
+
};
|
|
811
|
+
if (settings.config.profile === "audit")
|
|
812
|
+
return {
|
|
813
|
+
profile: "strict",
|
|
814
|
+
notices: [
|
|
815
|
+
`${where} requests the nonenforcing audit profile, which applies only with UNMASK_GUARD_POLICY=audit; strict is used`,
|
|
816
|
+
],
|
|
817
|
+
};
|
|
818
|
+
return {
|
|
819
|
+
profile: settings.config.profile,
|
|
820
|
+
notices: [`profile ${settings.config.profile} from ${where}`],
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export async function runInstallGuard(command, args, runtime = {}) {
|
|
825
|
+
const env = runtime.env ?? process.env;
|
|
826
|
+
const stderr = runtime.stderr ?? process.stderr;
|
|
827
|
+
const cwd = runtime.cwd ?? process.cwd();
|
|
828
|
+
const limits = runtime.limits ?? GUARD_LIMITS;
|
|
829
|
+
const npm = runtime.npmCommand ?? "npm";
|
|
830
|
+
try {
|
|
831
|
+
if (env[GUARD_ACTIVE_ENV])
|
|
832
|
+
throw new GuardError(
|
|
833
|
+
"recursive-guard",
|
|
834
|
+
"unmask install/ci was started from inside a guarded npm run",
|
|
835
|
+
"Lifecycle scripts inside a guarded install use plain npm; the outer plan was already screened.",
|
|
836
|
+
);
|
|
837
|
+
if (command !== "ci")
|
|
838
|
+
throw new GuardError(
|
|
839
|
+
"install-unbindable",
|
|
840
|
+
"npm install can re-resolve versions after screening, so unmask does not run it",
|
|
841
|
+
`Update the lockfile without running package code with \`npm install --package-lock-only --ignore-scripts${args.length ? " " + args.join(" ") : ""}\`, review the diff, then run \`unmask ci\`.`,
|
|
842
|
+
);
|
|
843
|
+
inspectCiArguments(args);
|
|
844
|
+
inspectNpmEnvironment(env);
|
|
845
|
+
const plan = await readProjectPlan(cwd, limits);
|
|
846
|
+
const now = runtime.now ? runtime.now() : new Date();
|
|
847
|
+
const settings = await projectSettings(cwd, now);
|
|
848
|
+
const selected = selectProfile(env.UNMASK_GUARD_POLICY, settings);
|
|
849
|
+
const result = await screenPlan(plan, {
|
|
850
|
+
source: env.UNMASK_GUARD_SOURCE || "hosted",
|
|
851
|
+
fetchImpl: runtime.fetchImpl,
|
|
852
|
+
signal: runtime.signal,
|
|
853
|
+
policy: selected.profile,
|
|
854
|
+
limits,
|
|
855
|
+
serverUrl: env.UNMASK_SERVER_URL,
|
|
856
|
+
config: settings.config,
|
|
857
|
+
now,
|
|
858
|
+
});
|
|
859
|
+
result.notices = selected.notices;
|
|
860
|
+
report(stderr, plan, result);
|
|
861
|
+
if (result.blocked) return 3;
|
|
862
|
+
await probeNpmTarget(npm, cwd, env, limits);
|
|
863
|
+
if ((await readInputs(cwd, limits)).digest !== plan.digest)
|
|
864
|
+
throw new GuardError(
|
|
865
|
+
"plan-changed",
|
|
866
|
+
`${plan.lockfile} or package.json changed after screening; npm was not started`,
|
|
867
|
+
"Rerun `unmask ci` once the project files are stable.",
|
|
868
|
+
);
|
|
869
|
+
const outcome = await runNpm(npm, ["ci", ...args], { cwd, env });
|
|
870
|
+
if (outcome.error) {
|
|
871
|
+
stderr.write(
|
|
872
|
+
"npm-unavailable: npm could not be started; nothing was installed by unmask.\n",
|
|
873
|
+
);
|
|
874
|
+
return 1;
|
|
875
|
+
}
|
|
876
|
+
const after = await readFile(path.join(cwd, plan.lockfile)).catch(
|
|
877
|
+
() => null,
|
|
878
|
+
);
|
|
879
|
+
if (!after || sha256(after) !== plan.digest.split(":")[0])
|
|
880
|
+
stderr.write(
|
|
881
|
+
`unmask guard: warning: ${plan.lockfile} changed during npm ci.\n`,
|
|
882
|
+
);
|
|
883
|
+
return outcome.code;
|
|
884
|
+
} catch (error) {
|
|
885
|
+
if (error instanceof GuardError || error instanceof LocalInputError) {
|
|
886
|
+
stderr.write(`${error.code}: ${error.message}\n`);
|
|
887
|
+
if (error.remediation)
|
|
888
|
+
stderr.write(`remediation: ${error.remediation}\n`);
|
|
889
|
+
return 2;
|
|
890
|
+
}
|
|
891
|
+
stderr.write(
|
|
892
|
+
"Internal guard error; npm was not started and no screening result is claimed.\n",
|
|
893
|
+
);
|
|
894
|
+
return 1;
|
|
895
|
+
}
|
|
896
|
+
}
|