@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,388 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
3
|
+
import { checkHosted } from "./hosted.mjs";
|
|
4
|
+
import { screenPlan } from "./install-guard.mjs";
|
|
5
|
+
import { nameFromKey, parseLockfile, unresolvedEdges } from "./lockfile.mjs";
|
|
6
|
+
|
|
7
|
+
export const PREFLIGHT_SCHEMA = "unmask-preflight-1";
|
|
8
|
+
const MAX_LOCKFILE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_MAX_CHECKS = 6;
|
|
10
|
+
const LIMIT_MAX_CHECKS = 100;
|
|
11
|
+
const failure = (code, message) => new LocalInputError(code, message);
|
|
12
|
+
|
|
13
|
+
export function lockIdentities(text) {
|
|
14
|
+
const lock = parseLockfile(text);
|
|
15
|
+
const identities = new Map();
|
|
16
|
+
const unsupported = [];
|
|
17
|
+
for (const entry of lock.entries.values()) {
|
|
18
|
+
if (entry.edgesValid === false)
|
|
19
|
+
unsupported.push({
|
|
20
|
+
path: entry.key,
|
|
21
|
+
reason: "invalid-dependencies",
|
|
22
|
+
version: entry.version,
|
|
23
|
+
resolved: entry.resolved,
|
|
24
|
+
});
|
|
25
|
+
if (entry.kind === "link" && !lock.entries.has(entry.resolved ?? ""))
|
|
26
|
+
unsupported.push({
|
|
27
|
+
path: entry.key,
|
|
28
|
+
reason: "dangling-link",
|
|
29
|
+
version: entry.version,
|
|
30
|
+
resolved: entry.resolved,
|
|
31
|
+
});
|
|
32
|
+
const claimsArtifact =
|
|
33
|
+
entry.kind === "workspace" && (entry.resolved || entry.integrity);
|
|
34
|
+
if (["root", "workspace", "link"].includes(entry.kind) && !claimsArtifact)
|
|
35
|
+
continue;
|
|
36
|
+
if (entry.kind !== "registry") {
|
|
37
|
+
unsupported.push({
|
|
38
|
+
path: entry.key,
|
|
39
|
+
reason: claimsArtifact ? "workspace-artifact-claim" : entry.reason,
|
|
40
|
+
version: entry.version,
|
|
41
|
+
resolved: entry.resolved,
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const key = `${entry.name}@${entry.version}`;
|
|
46
|
+
const existing = identities.get(key) ?? {
|
|
47
|
+
name: entry.name,
|
|
48
|
+
version: entry.version,
|
|
49
|
+
paths: [],
|
|
50
|
+
sources: new Set(),
|
|
51
|
+
integrities: new Set(),
|
|
52
|
+
bound: true,
|
|
53
|
+
};
|
|
54
|
+
existing.paths.push(entry.key);
|
|
55
|
+
existing.sources.add(JSON.stringify([entry.resolved, entry.integrity]));
|
|
56
|
+
existing.integrities.add(entry.integrity);
|
|
57
|
+
existing.integrity ??= entry.integrity;
|
|
58
|
+
existing.bound &&= entry.bound === true && existing.integrities.size === 1;
|
|
59
|
+
identities.set(key, existing);
|
|
60
|
+
}
|
|
61
|
+
for (const edge of unresolvedEdges(lock))
|
|
62
|
+
unsupported.push({
|
|
63
|
+
path: `${edge.name} (required by ${edge.from || "root"})`,
|
|
64
|
+
reason: "unresolved-dependency",
|
|
65
|
+
version: edge.type,
|
|
66
|
+
resolved: null,
|
|
67
|
+
});
|
|
68
|
+
for (const item of identities.values()) delete item.integrities;
|
|
69
|
+
return { identities, unsupported };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function lockDiff(baselineText, lockfileText) {
|
|
73
|
+
const before = lockIdentities(baselineText);
|
|
74
|
+
const after = lockIdentities(lockfileText);
|
|
75
|
+
const added = [];
|
|
76
|
+
for (const [key, item] of after.identities) {
|
|
77
|
+
const prior = before.identities.get(key);
|
|
78
|
+
const { sources, ...identity } = item;
|
|
79
|
+
if (!prior) added.push({ ...identity, reason: "new-identity" });
|
|
80
|
+
else if ([...sources].some((value) => !prior.sources.has(value)))
|
|
81
|
+
added.push({ ...identity, reason: "source-changed" });
|
|
82
|
+
}
|
|
83
|
+
const removed = [...before.identities.values()]
|
|
84
|
+
.filter((item) => !after.identities.has(`${item.name}@${item.version}`))
|
|
85
|
+
.map(({ name, version }) => ({ name, version }));
|
|
86
|
+
const seen = new Set(before.unsupported.map((item) => JSON.stringify(item)));
|
|
87
|
+
const unsupported = after.unsupported
|
|
88
|
+
.filter((item) => !seen.has(JSON.stringify(item)))
|
|
89
|
+
.map(({ path, reason }) => ({ path, reason }));
|
|
90
|
+
return {
|
|
91
|
+
baselineIdentities: before.identities.size,
|
|
92
|
+
lockfileIdentities: after.identities.size,
|
|
93
|
+
added,
|
|
94
|
+
removed,
|
|
95
|
+
unsupported,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkOne(item, checkPackage, signal) {
|
|
100
|
+
const spec = `${item.name}@${item.version}`;
|
|
101
|
+
try {
|
|
102
|
+
const report = await checkPackage(spec, { signal });
|
|
103
|
+
return {
|
|
104
|
+
name: item.name,
|
|
105
|
+
version: item.version,
|
|
106
|
+
status: "completed",
|
|
107
|
+
bound: item.bound,
|
|
108
|
+
exitCode: report.policy.exitCode,
|
|
109
|
+
findings: report.policy.findings.map((finding) => finding.ruleId),
|
|
110
|
+
coverageComplete: report.coverage.complete === true,
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (signal?.aborted)
|
|
114
|
+
return {
|
|
115
|
+
name: item.name,
|
|
116
|
+
version: item.version,
|
|
117
|
+
status: "cancelled",
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
name: item.name,
|
|
121
|
+
version: item.version,
|
|
122
|
+
status: "unavailable",
|
|
123
|
+
reason:
|
|
124
|
+
error instanceof LocalInputError ? error.code : "internal-check-error",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function preflight({
|
|
130
|
+
baselineText,
|
|
131
|
+
lockfileText,
|
|
132
|
+
check = true,
|
|
133
|
+
maxChecks = DEFAULT_MAX_CHECKS,
|
|
134
|
+
checkPackage,
|
|
135
|
+
screenAdvisories,
|
|
136
|
+
signal,
|
|
137
|
+
onCheck = () => {},
|
|
138
|
+
}) {
|
|
139
|
+
const diff = lockDiff(baselineText, lockfileText);
|
|
140
|
+
const screening = check && typeof screenAdvisories === "function";
|
|
141
|
+
const checks = [];
|
|
142
|
+
for (const [index, item] of diff.added.entries()) {
|
|
143
|
+
if (!check || index >= maxChecks || signal?.aborted) {
|
|
144
|
+
checks.push({
|
|
145
|
+
name: item.name,
|
|
146
|
+
version: item.version,
|
|
147
|
+
status: signal?.aborted ? "cancelled" : "not-checked",
|
|
148
|
+
...(signal?.aborted
|
|
149
|
+
? {}
|
|
150
|
+
: { reason: check ? "max-checks" : "checks-disabled" }),
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const result = await checkOne(item, checkPackage, signal);
|
|
155
|
+
checks.push(result);
|
|
156
|
+
onCheck(result);
|
|
157
|
+
}
|
|
158
|
+
const screenable = diff.added.filter((item) => item.bound);
|
|
159
|
+
if (screening && screenable.length && !signal?.aborted) {
|
|
160
|
+
let decisions = new Map();
|
|
161
|
+
try {
|
|
162
|
+
decisions = await screenAdvisories(screenable, { signal });
|
|
163
|
+
} catch {}
|
|
164
|
+
for (const [index, item] of diff.added.entries()) {
|
|
165
|
+
if (!item.bound) continue;
|
|
166
|
+
const found = decisions.get(`${item.name}@${item.version}`);
|
|
167
|
+
checks[index].advisory = found
|
|
168
|
+
? {
|
|
169
|
+
decision: found.decision,
|
|
170
|
+
reasons: found.reasons,
|
|
171
|
+
advisories: found.advisories,
|
|
172
|
+
}
|
|
173
|
+
: {
|
|
174
|
+
decision: signal?.aborted ? "cancelled" : "unknown",
|
|
175
|
+
reasons: ["advisory-screening-unavailable"],
|
|
176
|
+
advisories: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const advisorySettled = (item) =>
|
|
181
|
+
!screening || ["allow", "warn", "block"].includes(item.advisory?.decision);
|
|
182
|
+
const advisoryScreened = checks.filter(
|
|
183
|
+
(item) => item.advisory && advisorySettled(item),
|
|
184
|
+
).length;
|
|
185
|
+
const checked = checks.filter(
|
|
186
|
+
(item) =>
|
|
187
|
+
item.status === "completed" &&
|
|
188
|
+
item.exitCode !== 2 &&
|
|
189
|
+
item.bound &&
|
|
190
|
+
advisorySettled(item),
|
|
191
|
+
).length;
|
|
192
|
+
const unbound = diff.added.filter((item) => !item.bound).length;
|
|
193
|
+
const complete =
|
|
194
|
+
diff.unsupported.length === 0 && checked === diff.added.length;
|
|
195
|
+
const cancelled = checks.some((item) => item.status === "cancelled");
|
|
196
|
+
const findings = checks.some(
|
|
197
|
+
(item) => item.exitCode === 3 || item.advisory?.decision === "block",
|
|
198
|
+
);
|
|
199
|
+
const status = findings
|
|
200
|
+
? "findings"
|
|
201
|
+
: cancelled
|
|
202
|
+
? "cancelled"
|
|
203
|
+
: complete
|
|
204
|
+
? "pass"
|
|
205
|
+
: "incomplete";
|
|
206
|
+
return {
|
|
207
|
+
schema: PREFLIGHT_SCHEMA,
|
|
208
|
+
status,
|
|
209
|
+
exitCode: findings ? 3 : complete && !cancelled ? 0 : 2,
|
|
210
|
+
cancelled,
|
|
211
|
+
baseline: { identities: diff.baselineIdentities },
|
|
212
|
+
lockfile: { identities: diff.lockfileIdentities },
|
|
213
|
+
changes: { added: diff.added, removed: diff.removed },
|
|
214
|
+
checks,
|
|
215
|
+
coverage: {
|
|
216
|
+
complete,
|
|
217
|
+
required: diff.added.length,
|
|
218
|
+
checked,
|
|
219
|
+
unbound,
|
|
220
|
+
advisoryScreening: screening ? "strict-guard-policy" : "not-requested",
|
|
221
|
+
advisoryScreened,
|
|
222
|
+
unsupported: diff.unsupported,
|
|
223
|
+
},
|
|
224
|
+
note: "pass means no default-policy finding or advisory block in checked changes; it does not mean safe.",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function readLockText(filename) {
|
|
229
|
+
let info;
|
|
230
|
+
try {
|
|
231
|
+
info = await stat(filename);
|
|
232
|
+
} catch {
|
|
233
|
+
throw failure("lockfile-unreadable", `Cannot read lockfile ${filename}.`);
|
|
234
|
+
}
|
|
235
|
+
if (!info.isFile() || info.size > MAX_LOCKFILE_BYTES)
|
|
236
|
+
throw failure(
|
|
237
|
+
"lockfile-unreadable",
|
|
238
|
+
`Lockfile ${filename} must be a regular file of at most 32 MiB.`,
|
|
239
|
+
);
|
|
240
|
+
const bytes = await readFile(filename);
|
|
241
|
+
if (bytes.length > MAX_LOCKFILE_BYTES)
|
|
242
|
+
throw failure("lockfile-unreadable", `Lockfile ${filename} is too large.`);
|
|
243
|
+
return bytes.toString("utf8");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function parseCount(value, name) {
|
|
247
|
+
if (!/^[1-9]\d{0,2}$/.test(value ?? "") || Number(value) > LIMIT_MAX_CHECKS)
|
|
248
|
+
throw failure("invalid-value", `${name} must be an integer from 1 to 100.`);
|
|
249
|
+
return Number(value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function hostedChecker(runtime) {
|
|
253
|
+
return (spec, { signal } = {}) =>
|
|
254
|
+
checkHosted(spec, {
|
|
255
|
+
serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
|
|
256
|
+
fetchImpl: runtime.fetchImpl,
|
|
257
|
+
signal: signal ?? runtime.signal,
|
|
258
|
+
limits: runtime.hostedLimits,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function hostedScreener(runtime) {
|
|
263
|
+
return async (items, { signal } = {}) => {
|
|
264
|
+
const packages = { "": { name: "unmask-preflight", version: "0.0.0" } };
|
|
265
|
+
// Every identity keeps its own lockfile locations; alias-only identities get a private nested slot.
|
|
266
|
+
for (const [index, item] of items.entries()) {
|
|
267
|
+
const entry = {
|
|
268
|
+
version: item.version,
|
|
269
|
+
resolved: `https://registry.npmjs.org/${item.name}/-/${item.name.split("/").pop()}-${item.version}.tgz`,
|
|
270
|
+
integrity: item.integrity,
|
|
271
|
+
};
|
|
272
|
+
const keys = item.paths.filter((key) => nameFromKey(key) === item.name);
|
|
273
|
+
if (!keys.length)
|
|
274
|
+
keys.push(
|
|
275
|
+
`node_modules/unmask-preflight-slot-${index}/node_modules/${item.name}`,
|
|
276
|
+
);
|
|
277
|
+
for (const key of keys) packages[key] = entry;
|
|
278
|
+
}
|
|
279
|
+
const result = await screenPlan(
|
|
280
|
+
{
|
|
281
|
+
packages: items.map((item) => ({
|
|
282
|
+
id: `${item.name}@${item.version}`,
|
|
283
|
+
name: item.name,
|
|
284
|
+
version: item.version,
|
|
285
|
+
integrity: item.integrity,
|
|
286
|
+
binding: "registry-integrity",
|
|
287
|
+
paths: item.paths,
|
|
288
|
+
})),
|
|
289
|
+
lockBytes: Buffer.from(
|
|
290
|
+
JSON.stringify({ lockfileVersion: 3, packages }),
|
|
291
|
+
),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
source: "hosted",
|
|
295
|
+
policy: "strict",
|
|
296
|
+
fetchImpl: runtime.fetchImpl,
|
|
297
|
+
signal: signal ?? runtime.signal,
|
|
298
|
+
serverUrl: (runtime.env ?? process.env).UNMASK_SERVER_URL,
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
return new Map(result.decisions.map((decision) => [decision.id, decision]));
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function formatPreflight(result) {
|
|
306
|
+
const lines = [
|
|
307
|
+
`preflight ${result.status}: ${result.changes.added.length} changed, ${result.changes.removed.length} removed, ${result.coverage.checked}/${result.coverage.required} checked`,
|
|
308
|
+
];
|
|
309
|
+
for (const item of result.checks) {
|
|
310
|
+
const detail =
|
|
311
|
+
item.status === "completed"
|
|
312
|
+
? `exit ${item.exitCode}${item.findings.length ? ` (${item.findings.join(", ")})` : ""}${item.bound ? "" : "; unbound: lockfile resolved/integrity not verified"}`
|
|
313
|
+
: item.status + (item.reason ? ` (${item.reason})` : "");
|
|
314
|
+
const advisory = item.advisory
|
|
315
|
+
? `; advisory ${item.advisory.decision}${item.advisory.reasons.length ? ` (${[...item.advisory.reasons, ...item.advisory.advisories].join(", ")})` : ""}`
|
|
316
|
+
: "";
|
|
317
|
+
lines.push(` ${item.name}@${item.version}: ${detail}${advisory}`);
|
|
318
|
+
}
|
|
319
|
+
for (const item of result.coverage.unsupported)
|
|
320
|
+
lines.push(` ${item.path}: unsupported (${item.reason})`);
|
|
321
|
+
lines.push(result.note);
|
|
322
|
+
return lines.join("\n") + "\n";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseArgs(argv) {
|
|
326
|
+
const options = { lockfile: "package-lock.json", check: true, json: false };
|
|
327
|
+
const seen = new Set();
|
|
328
|
+
for (let index = 0; index < argv.length; index++) {
|
|
329
|
+
const key = argv[index];
|
|
330
|
+
if (seen.has(key))
|
|
331
|
+
throw failure(
|
|
332
|
+
"duplicate-option",
|
|
333
|
+
"Each option may be supplied only once",
|
|
334
|
+
);
|
|
335
|
+
seen.add(key);
|
|
336
|
+
if (key === "--json") options.json = true;
|
|
337
|
+
else if (key === "--no-check") options.check = false;
|
|
338
|
+
else if (["--lockfile", "--baseline", "--max-checks"].includes(key)) {
|
|
339
|
+
const value = argv[++index];
|
|
340
|
+
if (!value || value.startsWith("--"))
|
|
341
|
+
throw failure("missing-value", "An option is missing its value");
|
|
342
|
+
options[key.slice(2)] = value;
|
|
343
|
+
} else
|
|
344
|
+
throw failure(
|
|
345
|
+
"unknown-option",
|
|
346
|
+
"Only the documented preflight options are supported",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (!options.baseline)
|
|
350
|
+
throw failure("usage", "preflight requires --baseline FILE");
|
|
351
|
+
options.maxChecks =
|
|
352
|
+
options["max-checks"] === undefined
|
|
353
|
+
? DEFAULT_MAX_CHECKS
|
|
354
|
+
: parseCount(options["max-checks"], "--max-checks");
|
|
355
|
+
return options;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function preflightMain(argv, runtime = {}) {
|
|
359
|
+
try {
|
|
360
|
+
const options = parseArgs(argv);
|
|
361
|
+
const [baselineText, lockfileText] = await Promise.all([
|
|
362
|
+
readLockText(options.baseline),
|
|
363
|
+
readLockText(options.lockfile),
|
|
364
|
+
]);
|
|
365
|
+
const result = await preflight({
|
|
366
|
+
baselineText,
|
|
367
|
+
lockfileText,
|
|
368
|
+
check: options.check,
|
|
369
|
+
maxChecks: options.maxChecks,
|
|
370
|
+
checkPackage: runtime.checkPackage ?? hostedChecker(runtime),
|
|
371
|
+
screenAdvisories: runtime.screenAdvisories ?? hostedScreener(runtime),
|
|
372
|
+
signal: runtime.signal,
|
|
373
|
+
});
|
|
374
|
+
process.stdout.write(
|
|
375
|
+
options.json
|
|
376
|
+
? JSON.stringify(result, null, 2) + "\n"
|
|
377
|
+
: formatPreflight(result),
|
|
378
|
+
);
|
|
379
|
+
return result.exitCode;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (error instanceof LocalInputError) {
|
|
382
|
+
process.stderr.write(`${error.code}: ${error.message}\n`);
|
|
383
|
+
return 2;
|
|
384
|
+
}
|
|
385
|
+
process.stderr.write("Internal preflight error; no result is claimed.\n");
|
|
386
|
+
return 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
5
|
+
import { toSarif } from "../core/sarif.mjs";
|
|
6
|
+
|
|
7
|
+
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
const failure = (code, message) => new LocalInputError(code, message);
|
|
9
|
+
|
|
10
|
+
export function parseExportOptions(argv, allowed) {
|
|
11
|
+
const options = {};
|
|
12
|
+
for (let index = 0; index < argv.length; index++) {
|
|
13
|
+
const key = argv[index];
|
|
14
|
+
if (!Object.hasOwn(allowed, key))
|
|
15
|
+
throw failure(
|
|
16
|
+
"unknown-option",
|
|
17
|
+
`Only ${Object.keys(allowed).join(", ")} are supported`,
|
|
18
|
+
);
|
|
19
|
+
if (Object.hasOwn(options, allowed[key]))
|
|
20
|
+
throw failure(
|
|
21
|
+
"duplicate-option",
|
|
22
|
+
"Each option may be supplied only once",
|
|
23
|
+
);
|
|
24
|
+
const value = argv[++index];
|
|
25
|
+
if (!value || value.startsWith("--"))
|
|
26
|
+
throw failure("missing-value", "An option is missing its value");
|
|
27
|
+
options[allowed[key]] = value;
|
|
28
|
+
}
|
|
29
|
+
if (!options.report)
|
|
30
|
+
throw failure(
|
|
31
|
+
"usage",
|
|
32
|
+
"Supply an existing UNMASK report with --report FILE",
|
|
33
|
+
);
|
|
34
|
+
return options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readJsonFile(filename, label) {
|
|
38
|
+
let handle;
|
|
39
|
+
try {
|
|
40
|
+
handle = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
41
|
+
const stat = await handle.stat();
|
|
42
|
+
if (!stat.isFile() || stat.size > MAX_INPUT_BYTES)
|
|
43
|
+
throw failure(
|
|
44
|
+
"input-read-failed",
|
|
45
|
+
`The ${label} must be a regular file of at most ${MAX_INPUT_BYTES} bytes`,
|
|
46
|
+
);
|
|
47
|
+
const bytes = await handle.readFile();
|
|
48
|
+
if (bytes.byteLength > MAX_INPUT_BYTES)
|
|
49
|
+
throw failure("input-read-failed", `The ${label} exceeds the size limit`);
|
|
50
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error instanceof LocalInputError) throw error;
|
|
53
|
+
throw failure(
|
|
54
|
+
"input-read-failed",
|
|
55
|
+
`The ${label} could not be read as UTF-8 JSON`,
|
|
56
|
+
);
|
|
57
|
+
} finally {
|
|
58
|
+
await handle?.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readExportInputs(options) {
|
|
63
|
+
const report = await readJsonFile(options.report, "report");
|
|
64
|
+
const details =
|
|
65
|
+
options.details === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: await readJsonFile(options.details, "details document");
|
|
68
|
+
return { report, details };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function writeExclusive(filename, contents) {
|
|
72
|
+
let handle;
|
|
73
|
+
try {
|
|
74
|
+
handle = await open(filename, "wx", 0o600);
|
|
75
|
+
await handle.writeFile(contents);
|
|
76
|
+
} catch {
|
|
77
|
+
throw failure(
|
|
78
|
+
"output-write-failed",
|
|
79
|
+
"Output requires a writable path that does not already exist",
|
|
80
|
+
);
|
|
81
|
+
} finally {
|
|
82
|
+
await handle?.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sarifCommand(argv, stdout = process.stdout) {
|
|
87
|
+
const options = parseExportOptions(argv, {
|
|
88
|
+
"--report": "report",
|
|
89
|
+
"--details": "details",
|
|
90
|
+
"--out": "out",
|
|
91
|
+
});
|
|
92
|
+
const { report, details } = await readExportInputs(options);
|
|
93
|
+
const json = JSON.stringify(toSarif(report, details), null, 2) + "\n";
|
|
94
|
+
if (options.out === undefined) stdout.write(json);
|
|
95
|
+
else {
|
|
96
|
+
await writeExclusive(options.out, json);
|
|
97
|
+
stdout.write(`SARIF written to ${path.resolve(options.out)}\n`);
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const SHELL_MARKER = "# unmask-shell-integration v1";
|
|
4
|
+
export const SHELL_ENV = "UNMASK_SHELL_INTEGRATION";
|
|
5
|
+
export const SHELL_ENV_VALUE = "npm-guard-v1";
|
|
6
|
+
export const SHELLS = Object.freeze(["bash", "zsh", "fish"]);
|
|
7
|
+
const INSTALL = [
|
|
8
|
+
"install",
|
|
9
|
+
"i",
|
|
10
|
+
"in",
|
|
11
|
+
"ins",
|
|
12
|
+
"inst",
|
|
13
|
+
"insta",
|
|
14
|
+
"instal",
|
|
15
|
+
"isnt",
|
|
16
|
+
"isnta",
|
|
17
|
+
"isntal",
|
|
18
|
+
"isntall",
|
|
19
|
+
"add",
|
|
20
|
+
];
|
|
21
|
+
const CI = ["ci", "clean-install", "ic", "install-clean", "isntall-clean"];
|
|
22
|
+
// Ownership is the exact definition recorded at enable time, never a text match inside some other npm function.
|
|
23
|
+
const OWNED = "__UNMASK_NPM_FUNCTION";
|
|
24
|
+
|
|
25
|
+
const posix = {
|
|
26
|
+
enable: () =>
|
|
27
|
+
[
|
|
28
|
+
SHELL_MARKER,
|
|
29
|
+
`if alias npm >/dev/null 2>&1 || { typeset -f npm >/dev/null 2>&1 && [ "$(typeset -f npm)" != "\${${OWNED}-}" ]; }; then`,
|
|
30
|
+
" echo 'unmask: an existing npm alias or function was left unchanged; guarded npm was not enabled' >&2",
|
|
31
|
+
"else",
|
|
32
|
+
"function npm {",
|
|
33
|
+
' if [ -z "${UNMASK_GUARD_ACTIVE:-}" ]; then',
|
|
34
|
+
' case "${1-}" in',
|
|
35
|
+
` ${INSTALL.join("|")}) shift; command unmask install "$@"; return $? ;;`,
|
|
36
|
+
` ${CI.join("|")}) shift; command unmask ci "$@"; return $? ;;`,
|
|
37
|
+
" esac",
|
|
38
|
+
" fi",
|
|
39
|
+
' command npm "$@"',
|
|
40
|
+
"}",
|
|
41
|
+
`${OWNED}="$(typeset -f npm)"`,
|
|
42
|
+
`export ${SHELL_ENV}=${SHELL_ENV_VALUE}`,
|
|
43
|
+
"fi",
|
|
44
|
+
].join("\n"),
|
|
45
|
+
disable: () =>
|
|
46
|
+
[
|
|
47
|
+
SHELL_MARKER,
|
|
48
|
+
`if [ -n "\${${OWNED}-}" ] && [ "$(typeset -f npm 2>/dev/null)" = "$${OWNED}" ]; then unset -f npm; fi`,
|
|
49
|
+
`unset ${OWNED} ${SHELL_ENV}`,
|
|
50
|
+
].join("\n"),
|
|
51
|
+
};
|
|
52
|
+
const fish = {
|
|
53
|
+
enable: () =>
|
|
54
|
+
[
|
|
55
|
+
SHELL_MARKER,
|
|
56
|
+
"set -l __unmask_current",
|
|
57
|
+
"functions -q npm; and set __unmask_current (functions npm | string collect)",
|
|
58
|
+
`if functions -q npm; and test "$__unmask_current" != "$${OWNED}"`,
|
|
59
|
+
" echo 'unmask: an existing npm function was left unchanged; guarded npm was not enabled' >&2",
|
|
60
|
+
"else",
|
|
61
|
+
"function npm --wraps npm --description 'unmask guarded npm install/ci'",
|
|
62
|
+
" set -l rest $argv",
|
|
63
|
+
" set -e rest[1]",
|
|
64
|
+
" if not set -q UNMASK_GUARD_ACTIVE",
|
|
65
|
+
' switch "$argv[1]"',
|
|
66
|
+
` case ${INSTALL.join(" ")}`,
|
|
67
|
+
" command unmask install $rest",
|
|
68
|
+
" return $status",
|
|
69
|
+
` case ${CI.join(" ")}`,
|
|
70
|
+
" command unmask ci $rest",
|
|
71
|
+
" return $status",
|
|
72
|
+
" end",
|
|
73
|
+
" end",
|
|
74
|
+
" command npm $argv",
|
|
75
|
+
"end",
|
|
76
|
+
`set -g ${OWNED} (functions npm | string collect)`,
|
|
77
|
+
`set -gx ${SHELL_ENV} ${SHELL_ENV_VALUE}`,
|
|
78
|
+
"end",
|
|
79
|
+
].join("\n"),
|
|
80
|
+
disable: () =>
|
|
81
|
+
[
|
|
82
|
+
SHELL_MARKER,
|
|
83
|
+
`if functions -q npm; and test (functions npm | string collect) = "$${OWNED}"`,
|
|
84
|
+
" functions -e npm",
|
|
85
|
+
"end",
|
|
86
|
+
`set -e ${OWNED}`,
|
|
87
|
+
`set -e ${SHELL_ENV}`,
|
|
88
|
+
].join("\n"),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function shellIntegrationScript(action, shell) {
|
|
92
|
+
if (!["enable", "disable"].includes(action) || !SHELLS.includes(shell))
|
|
93
|
+
throw new TypeError("Unsupported shell integration request");
|
|
94
|
+
return (shell === "fish" ? fish : posix)[action]() + "\n";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function runShellIntegration(action, args, runtime = {}) {
|
|
98
|
+
const env = runtime.env ?? process.env;
|
|
99
|
+
const stdout = runtime.stdout ?? process.stdout;
|
|
100
|
+
const stderr = runtime.stderr ?? process.stderr;
|
|
101
|
+
const detected = path.basename(env.SHELL ?? "");
|
|
102
|
+
const shell = args[0] ?? (SHELLS.includes(detected) ? detected : undefined);
|
|
103
|
+
if (args.length > 1 || !SHELLS.includes(shell)) {
|
|
104
|
+
stderr.write(
|
|
105
|
+
`usage: unmask ${action} <${SHELLS.join("|")}> prints shell code; nothing is written to shell startup files.\n`,
|
|
106
|
+
);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
stdout.write(shellIntegrationScript(action, shell));
|
|
110
|
+
if (stdout.isTTY) {
|
|
111
|
+
const load =
|
|
112
|
+
shell === "fish"
|
|
113
|
+
? `unmask ${action} fish | source`
|
|
114
|
+
: `eval "$(unmask ${action} ${shell})"`;
|
|
115
|
+
stderr.write(
|
|
116
|
+
action === "enable"
|
|
117
|
+
? `To guard npm install/ci in this shell run: ${load}\nTo persist, add that line to your shell startup file yourself. Undo with: ${load.replace("enable", "disable")}\n`
|
|
118
|
+
: `To remove the guarded npm function from this shell run: ${load}\n`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|