@xagents_in/unmask 0.1.2 → 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 +301 -85
- package/dist/cli/annotations.mjs +145 -0
- package/dist/cli/audit.mjs +223 -0
- package/dist/cli/batch.mjs +218 -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 +101 -14
- package/dist/cli/preflight.mjs +388 -0
- package/dist/cli/sarif.mjs +100 -0
- package/dist/cli/scan.mjs +128 -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/registry-facts.mjs +342 -0
- package/dist/core/report-exploration.mjs +328 -0
- 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 +66 -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/registry-facts.mjs +118 -0
- package/dist/server/report-exploration.mjs +87 -0
- 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,271 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
3
|
+
import { getHostedDetailsForReport } from "./hosted.mjs";
|
|
4
|
+
import { exactPackage, hostedOptions, terminalText } from "./info.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_BYTES = 131072;
|
|
7
|
+
const fail = (code, message) => new LocalInputError(code, message);
|
|
8
|
+
const invalid = () =>
|
|
9
|
+
fail(
|
|
10
|
+
"file-evidence-invalid",
|
|
11
|
+
"Recorded file evidence failed schema, report, file or evidence validation.",
|
|
12
|
+
);
|
|
13
|
+
function parse(args) {
|
|
14
|
+
const usage = () =>
|
|
15
|
+
fail(
|
|
16
|
+
"usage",
|
|
17
|
+
"Use file name@exact-version --report REPORT_ID --file-id FILE_ID",
|
|
18
|
+
);
|
|
19
|
+
if (args.length !== 5) throw usage();
|
|
20
|
+
exactPackage(args[0]);
|
|
21
|
+
const options = { spec: args[0] };
|
|
22
|
+
for (let index = 1; index < args.length; index += 2) {
|
|
23
|
+
const key =
|
|
24
|
+
args[index] === "--report"
|
|
25
|
+
? "reportId"
|
|
26
|
+
: args[index] === "--file-id"
|
|
27
|
+
? "fileId"
|
|
28
|
+
: null;
|
|
29
|
+
if (!key || Object.hasOwn(options, key)) throw usage();
|
|
30
|
+
options[key] = args[index + 1];
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
!/^[a-f0-9]{32}$/.test(options.reportId ?? "") ||
|
|
34
|
+
!/^f-[A-Za-z0-9_-]{43}$/.test(options.fileId ?? "")
|
|
35
|
+
)
|
|
36
|
+
throw usage();
|
|
37
|
+
return options;
|
|
38
|
+
}
|
|
39
|
+
const integer = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
40
|
+
const range = (value, size) =>
|
|
41
|
+
Array.isArray(value) &&
|
|
42
|
+
value.length === 2 &&
|
|
43
|
+
value.every(integer) &&
|
|
44
|
+
value[0] <= value[1] &&
|
|
45
|
+
value[1] <= size;
|
|
46
|
+
|
|
47
|
+
function validateEvidence(value, report, options) {
|
|
48
|
+
const file = value?.file;
|
|
49
|
+
if (
|
|
50
|
+
value?.schemaVersion !== "1" ||
|
|
51
|
+
value.reportId !== options.reportId ||
|
|
52
|
+
value.scope !== "artifact-source" ||
|
|
53
|
+
value.recordedOnly !== true ||
|
|
54
|
+
file?.fileId !== options.fileId ||
|
|
55
|
+
!Array.isArray(value.evidence) ||
|
|
56
|
+
value.evidence.length > 32 ||
|
|
57
|
+
!["recorded", "partial", "unavailable"].includes(value.evidenceStatus) ||
|
|
58
|
+
!integer(value.evidenceOmitted) ||
|
|
59
|
+
!integer(value.invalidEvidenceOmitted) ||
|
|
60
|
+
!Array.isArray(value.limitations) ||
|
|
61
|
+
value.limitations.length > 16 ||
|
|
62
|
+
value.limitations.some(
|
|
63
|
+
(item) => typeof item !== "string" || item.length > 4096,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
throw invalid();
|
|
67
|
+
if (file.state === "removed") {
|
|
68
|
+
if (
|
|
69
|
+
file.comparisonState !== "source-only" ||
|
|
70
|
+
!report.coverage.sourceOnly.includes(file.path) ||
|
|
71
|
+
file.originalPath !== null ||
|
|
72
|
+
file.artifactSha256 !== null ||
|
|
73
|
+
file.size !== null ||
|
|
74
|
+
(file.sourceSha256 !== null &&
|
|
75
|
+
!report.evidence.some(
|
|
76
|
+
(item) =>
|
|
77
|
+
item.side === "source" &&
|
|
78
|
+
item.path === file.path &&
|
|
79
|
+
item.fileSha256 === file.sourceSha256 &&
|
|
80
|
+
item.blobDigest === report.inputs.sourceArchive?.sha256,
|
|
81
|
+
))
|
|
82
|
+
)
|
|
83
|
+
throw invalid();
|
|
84
|
+
} else {
|
|
85
|
+
const raw = report.files.find(
|
|
86
|
+
(item) =>
|
|
87
|
+
item.path === file.path && item.originalPath === file.originalPath,
|
|
88
|
+
);
|
|
89
|
+
const states = {
|
|
90
|
+
"byte-identical": "identical",
|
|
91
|
+
changed: "changed",
|
|
92
|
+
"artifact-only": "added",
|
|
93
|
+
"not-compared": "not-compared",
|
|
94
|
+
};
|
|
95
|
+
if (
|
|
96
|
+
!raw ||
|
|
97
|
+
file.comparisonState !== raw.state ||
|
|
98
|
+
file.state !== states[raw.state] ||
|
|
99
|
+
file.size !== raw.size ||
|
|
100
|
+
file.artifactSha256 !== raw.artifactSha256 ||
|
|
101
|
+
file.sourceSha256 !== (raw.sourceSha256 ?? null)
|
|
102
|
+
)
|
|
103
|
+
throw invalid();
|
|
104
|
+
}
|
|
105
|
+
const status =
|
|
106
|
+
value.evidence.length === 0
|
|
107
|
+
? "unavailable"
|
|
108
|
+
: value.evidenceOmitted || value.invalidEvidenceOmitted
|
|
109
|
+
? "partial"
|
|
110
|
+
: "recorded";
|
|
111
|
+
if (value.evidenceStatus !== status) throw invalid();
|
|
112
|
+
const known = new Map(report.evidence.map((item) => [item.id, item]));
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (const item of value.evidence) {
|
|
115
|
+
const raw = known.get(item?.id);
|
|
116
|
+
if (
|
|
117
|
+
!raw ||
|
|
118
|
+
seen.has(item.id) ||
|
|
119
|
+
item.path !== file.path ||
|
|
120
|
+
![
|
|
121
|
+
"side",
|
|
122
|
+
"path",
|
|
123
|
+
"originalPath",
|
|
124
|
+
"size",
|
|
125
|
+
"fileSha256",
|
|
126
|
+
"blobDigest",
|
|
127
|
+
"range",
|
|
128
|
+
].every((key) => isDeepStrictEqual(item[key], raw[key])) ||
|
|
129
|
+
item.fileSha256 !==
|
|
130
|
+
(item.side === "artifact" ? file.artifactSha256 : file.sourceSha256) ||
|
|
131
|
+
(item.side === "artifact" && item.originalPath !== file.originalPath)
|
|
132
|
+
)
|
|
133
|
+
throw invalid();
|
|
134
|
+
seen.add(item.id);
|
|
135
|
+
if (typeof item.excerptTruncated !== "boolean") throw invalid();
|
|
136
|
+
if (item.excerpt === null) {
|
|
137
|
+
if (
|
|
138
|
+
!["not-recorded", "invalid-recorded-range"].includes(
|
|
139
|
+
item.excerptStatus,
|
|
140
|
+
) ||
|
|
141
|
+
item.excerptRange !== null
|
|
142
|
+
)
|
|
143
|
+
throw invalid();
|
|
144
|
+
} else if (
|
|
145
|
+
item.excerptStatus !== "recorded" ||
|
|
146
|
+
typeof item.excerpt !== "string" ||
|
|
147
|
+
typeof raw.excerpt !== "string" ||
|
|
148
|
+
!raw.excerpt.startsWith(item.excerpt) ||
|
|
149
|
+
Buffer.byteLength(item.excerpt) > 2048 ||
|
|
150
|
+
!range(item.excerptRange, item.size) ||
|
|
151
|
+
!isDeepStrictEqual(item.recordedExcerptRange, raw.excerptRange) ||
|
|
152
|
+
item.excerptRange[0] !== raw.excerptRange?.[0] ||
|
|
153
|
+
item.excerptRange[1] > raw.excerptRange?.[1] ||
|
|
154
|
+
Buffer.byteLength(item.excerpt) !==
|
|
155
|
+
item.excerptRange[1] - item.excerptRange[0]
|
|
156
|
+
)
|
|
157
|
+
throw invalid();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function fetchEvidence(url, runtime) {
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timeout = setTimeout(
|
|
164
|
+
() => controller.abort(),
|
|
165
|
+
Math.min(runtime.hostedLimits?.requestTimeoutMs ?? 15000, 15000),
|
|
166
|
+
);
|
|
167
|
+
const signal = runtime.signal
|
|
168
|
+
? AbortSignal.any([runtime.signal, controller.signal])
|
|
169
|
+
: controller.signal;
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await (runtime.fetchImpl ?? globalThis.fetch)(url.href, {
|
|
173
|
+
method: "GET",
|
|
174
|
+
headers: { accept: "application/json" },
|
|
175
|
+
redirect: "manual",
|
|
176
|
+
credentials: "omit",
|
|
177
|
+
referrerPolicy: "no-referrer",
|
|
178
|
+
signal,
|
|
179
|
+
});
|
|
180
|
+
if (
|
|
181
|
+
response.status !== 200 ||
|
|
182
|
+
response.redirected ||
|
|
183
|
+
(response.url && new URL(response.url).origin !== url.origin)
|
|
184
|
+
)
|
|
185
|
+
throw fail(
|
|
186
|
+
"file-evidence-unavailable",
|
|
187
|
+
`Stored file evidence is unavailable (HTTP ${response.status}); no new check was submitted.`,
|
|
188
|
+
);
|
|
189
|
+
const declared = response.headers.get("content-length");
|
|
190
|
+
if (
|
|
191
|
+
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
|
|
192
|
+
response.headers.get("content-type") ?? "",
|
|
193
|
+
) ||
|
|
194
|
+
(declared !== null &&
|
|
195
|
+
(!/^\d+$/.test(declared) || Number(declared) > MAX_BYTES))
|
|
196
|
+
)
|
|
197
|
+
throw invalid();
|
|
198
|
+
let bytes = 0;
|
|
199
|
+
const chunks = [];
|
|
200
|
+
for await (const chunk of response.body ?? []) {
|
|
201
|
+
bytes += chunk.byteLength;
|
|
202
|
+
if (bytes > MAX_BYTES) throw invalid();
|
|
203
|
+
chunks.push(chunk);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(
|
|
207
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
208
|
+
Buffer.concat(chunks, bytes),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
} catch {
|
|
212
|
+
throw invalid();
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error instanceof LocalInputError) throw error;
|
|
216
|
+
throw fail(
|
|
217
|
+
"file-evidence-unavailable",
|
|
218
|
+
signal.aborted
|
|
219
|
+
? "Stored file evidence timed out or was cancelled; no new check was submitted."
|
|
220
|
+
: "Stored file evidence could not be read; no new check was submitted.",
|
|
221
|
+
);
|
|
222
|
+
} finally {
|
|
223
|
+
clearTimeout(timeout);
|
|
224
|
+
try {
|
|
225
|
+
await response?.body?.cancel();
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function render(value, options) {
|
|
231
|
+
const file = value.file,
|
|
232
|
+
text = terminalText;
|
|
233
|
+
const lines = [
|
|
234
|
+
`UNMASK file ${text(options.spec)}`,
|
|
235
|
+
`${text(file.path ?? file.originalPath)} [${text(file.state)}] ${file.size === null ? "size unavailable" : file.size + " bytes"}`,
|
|
236
|
+
`Report ID: ${options.reportId}; File ID: ${options.fileId}`,
|
|
237
|
+
`artifact-sha256=${text(file.artifactSha256 ?? "unavailable")} source-sha256=${text(file.sourceSha256 ?? "unavailable")}`,
|
|
238
|
+
`Previously recorded evidence only: ${text(value.evidenceStatus)}; omitted=${value.evidenceOmitted}; invalid omitted=${value.invalidEvidenceOmitted}.`,
|
|
239
|
+
];
|
|
240
|
+
for (const item of value.evidence) {
|
|
241
|
+
lines.push(
|
|
242
|
+
` ${text(item.side)} ${text(item.id)} bytes [${item.range.join(", ")}]`,
|
|
243
|
+
item.excerpt === null
|
|
244
|
+
? ` Excerpt: ${text(item.excerptStatus)}`
|
|
245
|
+
: ` Excerpt [${item.excerptRange.join(", ")}]: ${text(item.excerpt)}`,
|
|
246
|
+
);
|
|
247
|
+
if (item.excerptTruncated)
|
|
248
|
+
lines.push(" Recorded excerpt truncated by the API limit.");
|
|
249
|
+
}
|
|
250
|
+
for (const limitation of value.limitations)
|
|
251
|
+
lines.push(` ${text(limitation)}`);
|
|
252
|
+
lines.push(
|
|
253
|
+
"This is not a safety verdict. No file was opened, reconstructed or executed locally.",
|
|
254
|
+
);
|
|
255
|
+
return lines.join("\n") + "\n";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function fileCommand(args, runtime = {}) {
|
|
259
|
+
const options = parse(args);
|
|
260
|
+
const { report, reportUrl } = await getHostedDetailsForReport(
|
|
261
|
+
options.spec,
|
|
262
|
+
options.reportId,
|
|
263
|
+
hostedOptions(runtime),
|
|
264
|
+
);
|
|
265
|
+
const url = new URL(`/r/${options.reportId}/file.json`, reportUrl);
|
|
266
|
+
url.searchParams.set("fileId", options.fileId);
|
|
267
|
+
const evidence = await fetchEvidence(url, runtime);
|
|
268
|
+
validateEvidence(evidence, report, options);
|
|
269
|
+
(runtime.stdout ?? process.stdout).write(render(evidence, options));
|
|
270
|
+
return 0;
|
|
271
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
2
|
+
import { getHostedDetails, getHostedDetailsForReport } from "./hosted.mjs";
|
|
3
|
+
import { exactPackage, hostedOptions, terminalText, aiLines } from "./info.mjs";
|
|
4
|
+
|
|
5
|
+
const states = ["byte-identical", "changed", "artifact-only", "not-compared"];
|
|
6
|
+
const usage = () =>
|
|
7
|
+
new LocalInputError(
|
|
8
|
+
"usage",
|
|
9
|
+
"Use files name@exact-version [--report ID] [--path TEXT] [--state STATE] [--limit 1..200] [--offset N]",
|
|
10
|
+
);
|
|
11
|
+
function parse(args) {
|
|
12
|
+
if (!args[0]) throw usage();
|
|
13
|
+
exactPackage(args[0]);
|
|
14
|
+
const options = {
|
|
15
|
+
spec: args[0],
|
|
16
|
+
path: "",
|
|
17
|
+
state: "all",
|
|
18
|
+
limit: 50,
|
|
19
|
+
offset: 0,
|
|
20
|
+
};
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
for (let index = 1; index < args.length; index += 2) {
|
|
23
|
+
const flag = args[index];
|
|
24
|
+
const value = args[index + 1];
|
|
25
|
+
if (
|
|
26
|
+
!["--path", "--state", "--limit", "--offset", "--report"].includes(
|
|
27
|
+
flag,
|
|
28
|
+
) ||
|
|
29
|
+
seen.has(flag) ||
|
|
30
|
+
value === undefined
|
|
31
|
+
)
|
|
32
|
+
throw usage();
|
|
33
|
+
seen.add(flag);
|
|
34
|
+
if (flag === "--report") {
|
|
35
|
+
if (!/^[a-f0-9]{32}$/.test(value)) throw usage();
|
|
36
|
+
options.reportId = value;
|
|
37
|
+
} else if (flag === "--path") {
|
|
38
|
+
if (value.length > 1024) throw usage();
|
|
39
|
+
options.path = value;
|
|
40
|
+
} else if (flag === "--state") {
|
|
41
|
+
if (!["all", ...states].includes(value)) throw usage();
|
|
42
|
+
options.state = value;
|
|
43
|
+
} else {
|
|
44
|
+
if (
|
|
45
|
+
!/^(0|[1-9][0-9]*)$/.test(value) ||
|
|
46
|
+
!Number.isSafeInteger(Number(value))
|
|
47
|
+
)
|
|
48
|
+
throw usage();
|
|
49
|
+
const number = Number(value);
|
|
50
|
+
if (flag === "--limit" && (number < 1 || number > 200)) throw usage();
|
|
51
|
+
options[flag.slice(2)] = number;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return options;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateFiles(list) {
|
|
58
|
+
const digest = (value) =>
|
|
59
|
+
typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
60
|
+
if (
|
|
61
|
+
list.items.some(
|
|
62
|
+
(file) =>
|
|
63
|
+
!(file.path === null || typeof file.path === "string") ||
|
|
64
|
+
typeof file.originalPath !== "string" ||
|
|
65
|
+
!states.includes(file.state) ||
|
|
66
|
+
!Number.isSafeInteger(file.size) ||
|
|
67
|
+
file.size < 0 ||
|
|
68
|
+
!digest(file.artifactSha256) ||
|
|
69
|
+
(file.sourceSha256 !== undefined && !digest(file.sourceSha256)),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
throw new LocalInputError(
|
|
73
|
+
"hosted-details-invalid",
|
|
74
|
+
"Hosted file inventory contains invalid paths, states, sizes or hashes.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderFiles({ details, report, reportUrl }, options) {
|
|
79
|
+
const reportId = new URL(reportUrl).pathname
|
|
80
|
+
.split("/")[2]
|
|
81
|
+
.replace(/\.json$/, "");
|
|
82
|
+
const inventory = details.inventory;
|
|
83
|
+
validateFiles(inventory.files);
|
|
84
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
85
|
+
const files = inventory.files.items
|
|
86
|
+
.filter(
|
|
87
|
+
(file) =>
|
|
88
|
+
(options.state === "all" || file.state === options.state) &&
|
|
89
|
+
(file.path ?? file.originalPath).includes(options.path),
|
|
90
|
+
)
|
|
91
|
+
.sort(
|
|
92
|
+
(a, b) =>
|
|
93
|
+
order(a.path ?? "", b.path ?? "") ||
|
|
94
|
+
order(a.originalPath, b.originalPath),
|
|
95
|
+
);
|
|
96
|
+
const page = files.slice(options.offset, options.offset + options.limit);
|
|
97
|
+
const start = page.length ? options.offset + 1 : 0;
|
|
98
|
+
const end = page.length ? options.offset + page.length : 0;
|
|
99
|
+
const text = terminalText;
|
|
100
|
+
const lines = [
|
|
101
|
+
`UNMASK files ${text(options.spec)}`,
|
|
102
|
+
"Scope: accepted artifact regular files; source-only paths and inert links are outside this list.",
|
|
103
|
+
`Filters: path contains ${text(JSON.stringify(options.path))}; state=${text(options.state)}`,
|
|
104
|
+
`Rows ${start}-${end} of ${files.length} matching returned records; hosted projected total=${inventory.files.total}.`,
|
|
105
|
+
`Inventory complete: ${inventory.complete === true}; ${inventory.files.omitted} records omitted by hosted projection.`,
|
|
106
|
+
];
|
|
107
|
+
if (inventory.files.omitted)
|
|
108
|
+
lines.push(
|
|
109
|
+
"Filtering and pagination cover returned records only; omitted records may also match.",
|
|
110
|
+
);
|
|
111
|
+
for (const file of page)
|
|
112
|
+
lines.push(
|
|
113
|
+
`${text(file.path ?? file.originalPath)} [${text(file.state)}] ${file.size} bytes`,
|
|
114
|
+
` artifact-sha256=${file.artifactSha256} source-sha256=${file.sourceSha256 ?? "unavailable"}`,
|
|
115
|
+
);
|
|
116
|
+
if (options.offset + page.length < files.length)
|
|
117
|
+
lines.push(
|
|
118
|
+
`Next offset: ${options.offset + page.length} (use the same filters and limit).`,
|
|
119
|
+
);
|
|
120
|
+
lines.push(...aiLines(report.model));
|
|
121
|
+
lines.push(
|
|
122
|
+
"This is not a safety verdict; byte-identical means only recorded byte correspondence.",
|
|
123
|
+
`Report: ${text(reportUrl)}`,
|
|
124
|
+
`Report ID: ${reportId}`,
|
|
125
|
+
`Reuse this report for paging: --report ${reportId} (with the same exact package spec and filters; no new check).`,
|
|
126
|
+
);
|
|
127
|
+
return lines.join("\n") + "\n";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function filesCommand(args, runtime = {}) {
|
|
131
|
+
const options = parse(args);
|
|
132
|
+
const result = options.reportId
|
|
133
|
+
? await getHostedDetailsForReport(
|
|
134
|
+
options.spec,
|
|
135
|
+
options.reportId,
|
|
136
|
+
hostedOptions(runtime),
|
|
137
|
+
)
|
|
138
|
+
: await getHostedDetails(options.spec, hostedOptions(runtime));
|
|
139
|
+
(runtime.stdout ?? process.stdout).write(renderFiles(result, options));
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AcquisitionError,
|
|
3
|
+
fetchBlob,
|
|
4
|
+
parsePackageSpec,
|
|
5
|
+
} from "../core/acquire.mjs";
|
|
6
|
+
import { LocalInputError } from "../core/pipeline.mjs";
|
|
7
|
+
|
|
8
|
+
const MAX_PACKUMENT_BYTES = 40 * 1024 * 1024;
|
|
9
|
+
const TIMEOUT_MS = 20000;
|
|
10
|
+
const DEFAULT_LIMIT = 15;
|
|
11
|
+
const MAX_LIMIT = 100;
|
|
12
|
+
const RECENT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"];
|
|
14
|
+
const usage = () =>
|
|
15
|
+
new LocalInputError("usage", "Use history NAME [--limit 1-100] [--json]");
|
|
16
|
+
export function parseHistoryArgs(argv) {
|
|
17
|
+
const options = { limit: DEFAULT_LIMIT, json: false };
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
let name;
|
|
20
|
+
for (let index = 0; index < argv.length; index++) {
|
|
21
|
+
const value = argv[index];
|
|
22
|
+
if (seen.has(value)) throw usage();
|
|
23
|
+
if (value === "--json") options.json = true;
|
|
24
|
+
else if (value === "--limit") {
|
|
25
|
+
const raw = argv[++index];
|
|
26
|
+
if (!/^[1-9][0-9]{0,2}$/.test(raw ?? "") || Number(raw) > MAX_LIMIT)
|
|
27
|
+
throw new LocalInputError(
|
|
28
|
+
"invalid-limit",
|
|
29
|
+
`--limit must be an integer from 1 to ${MAX_LIMIT}`,
|
|
30
|
+
);
|
|
31
|
+
options.limit = Number(raw);
|
|
32
|
+
} else if (value.startsWith("--") || name !== undefined) throw usage();
|
|
33
|
+
else name = value;
|
|
34
|
+
if (value.startsWith("--")) seen.add(value);
|
|
35
|
+
}
|
|
36
|
+
if (name === undefined) throw usage();
|
|
37
|
+
try {
|
|
38
|
+
parsePackageSpec(`${name}@0.0.0`);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof AcquisitionError)
|
|
41
|
+
throw new LocalInputError(
|
|
42
|
+
"invalid-package-name",
|
|
43
|
+
"History requires a bare npm package name without a version",
|
|
44
|
+
);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return { ...options, name };
|
|
48
|
+
}
|
|
49
|
+
const UNSAFE =
|
|
50
|
+
/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
51
|
+
const JSON_UNSAFE = /[\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
52
|
+
const escapeUnsafe = (text, pattern = UNSAFE) =>
|
|
53
|
+
text.replace(
|
|
54
|
+
pattern,
|
|
55
|
+
(char) => "\\u" + char.charCodeAt(0).toString(16).padStart(4, "0"),
|
|
56
|
+
);
|
|
57
|
+
const abbreviate = (value, max) =>
|
|
58
|
+
value.length > max ? value.slice(0, max) + "..." : value;
|
|
59
|
+
const validDate = (value) =>
|
|
60
|
+
typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
61
|
+
function exactPin(name, version) {
|
|
62
|
+
try {
|
|
63
|
+
parsePackageSpec(`${name}@${version}`);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function summarizeHistory(packument, { limit, now = Date.now() }) {
|
|
70
|
+
if (!packument || typeof packument !== "object" || Array.isArray(packument))
|
|
71
|
+
throw new LocalInputError(
|
|
72
|
+
"registry-invalid",
|
|
73
|
+
"Registry metadata is not a JSON object",
|
|
74
|
+
);
|
|
75
|
+
const versions =
|
|
76
|
+
packument.versions && typeof packument.versions === "object"
|
|
77
|
+
? packument.versions
|
|
78
|
+
: {};
|
|
79
|
+
const time =
|
|
80
|
+
packument.time && typeof packument.time === "object" ? packument.time : {};
|
|
81
|
+
const tags = new Map();
|
|
82
|
+
for (const [tag, version] of Object.entries(packument["dist-tags"] ?? {}))
|
|
83
|
+
if (typeof version === "string")
|
|
84
|
+
tags.set(version, [...(tags.get(version) ?? []), tag]);
|
|
85
|
+
const known = new Set([
|
|
86
|
+
...Object.keys(versions),
|
|
87
|
+
...Object.keys(time).filter(
|
|
88
|
+
(key) => !["created", "modified"].includes(key) && validDate(time[key]),
|
|
89
|
+
),
|
|
90
|
+
]);
|
|
91
|
+
const releases = [];
|
|
92
|
+
for (const version of known) {
|
|
93
|
+
const manifest = Object.hasOwn(versions, version)
|
|
94
|
+
? versions[version]
|
|
95
|
+
: undefined;
|
|
96
|
+
const available = Boolean(manifest && typeof manifest === "object");
|
|
97
|
+
releases.push({
|
|
98
|
+
version,
|
|
99
|
+
exactPin: exactPin(packument.name, version),
|
|
100
|
+
published: validDate(time[version]) ? time[version] : null,
|
|
101
|
+
available,
|
|
102
|
+
tags: tags.get(version) ?? [],
|
|
103
|
+
deprecated:
|
|
104
|
+
available && typeof manifest.deprecated === "string"
|
|
105
|
+
? manifest.deprecated
|
|
106
|
+
: null,
|
|
107
|
+
installScripts: available
|
|
108
|
+
? INSTALL_SCRIPTS.filter(
|
|
109
|
+
(name) => typeof manifest.scripts?.[name] === "string",
|
|
110
|
+
)
|
|
111
|
+
: [],
|
|
112
|
+
recent: validDate(time[version])
|
|
113
|
+
? now - Date.parse(time[version]) < RECENT_MS
|
|
114
|
+
: false,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const stamp = (release) =>
|
|
118
|
+
release.published ? Date.parse(release.published) : 0;
|
|
119
|
+
releases.sort(
|
|
120
|
+
(a, b) => stamp(b) - stamp(a) || (a.version < b.version ? 1 : -1),
|
|
121
|
+
);
|
|
122
|
+
const selectable = (release) =>
|
|
123
|
+
release.available && !release.deprecated && release.exactPin;
|
|
124
|
+
const latest = releases.find(
|
|
125
|
+
(release) => release.version === packument["dist-tags"]?.latest,
|
|
126
|
+
);
|
|
127
|
+
const candidate =
|
|
128
|
+
latest && selectable(latest)
|
|
129
|
+
? latest
|
|
130
|
+
: releases.find(
|
|
131
|
+
(release) => selectable(release) && !release.version.includes("-"),
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
name: packument.name,
|
|
135
|
+
totalVersions: releases.length,
|
|
136
|
+
removedVersions: releases.filter((release) => !release.available).length,
|
|
137
|
+
deprecatedVersions: releases.filter((release) => release.deprecated).length,
|
|
138
|
+
distTags: Object.fromEntries(
|
|
139
|
+
[...tags].flatMap(([version, names]) =>
|
|
140
|
+
names.map((tag) => [tag, version]),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
unpublished: Boolean(time.unpublished),
|
|
144
|
+
shown: releases.slice(0, limit),
|
|
145
|
+
candidate: candidate?.version ?? null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function renderHistory(summary) {
|
|
149
|
+
const lines = [
|
|
150
|
+
`History ${summary.name}: ${summary.totalVersions} versions, ${summary.deprecatedVersions} deprecated, ${summary.removedVersions} removed from registry`,
|
|
151
|
+
`Dist-tags: ${
|
|
152
|
+
Object.entries(summary.distTags)
|
|
153
|
+
.map(
|
|
154
|
+
([tag, version]) =>
|
|
155
|
+
`${abbreviate(tag, 40)}=${abbreviate(version, 64)}`,
|
|
156
|
+
)
|
|
157
|
+
.join(", ") || "none"
|
|
158
|
+
}`,
|
|
159
|
+
];
|
|
160
|
+
if (summary.unpublished)
|
|
161
|
+
lines.push("! The package is marked unpublished in the registry.");
|
|
162
|
+
lines.push(`Newest ${summary.shown.length} releases:`);
|
|
163
|
+
for (const release of summary.shown) {
|
|
164
|
+
const marks = [
|
|
165
|
+
...release.tags.map((tag) => `[${abbreviate(tag, 40)}]`),
|
|
166
|
+
...(release.available ? [] : ["[removed]"]),
|
|
167
|
+
...(release.recent ? ["[recent]"] : []),
|
|
168
|
+
...(release.installScripts.length
|
|
169
|
+
? [`[install scripts: ${release.installScripts.join(",")}]`]
|
|
170
|
+
: []),
|
|
171
|
+
];
|
|
172
|
+
lines.push(
|
|
173
|
+
` ${(release.published ?? "unknown date").slice(0, 10)} ${abbreviate(release.version, 64)}${marks.length ? " " + marks.join(" ") : ""}`,
|
|
174
|
+
);
|
|
175
|
+
if (release.deprecated)
|
|
176
|
+
lines.push(` deprecated: ${abbreviate(release.deprecated, 160)}`);
|
|
177
|
+
}
|
|
178
|
+
lines.push("Selection guidance:");
|
|
179
|
+
if (summary.candidate)
|
|
180
|
+
lines.push(
|
|
181
|
+
` Pin an exact version, e.g. ${summary.name}@${summary.candidate}, and check it first: unmask check ${summary.name}@${summary.candidate} --out DIR`,
|
|
182
|
+
);
|
|
183
|
+
else
|
|
184
|
+
lines.push(
|
|
185
|
+
" No non-deprecated stable release is available; do not select a version without review.",
|
|
186
|
+
);
|
|
187
|
+
lines.push(
|
|
188
|
+
" Avoid ranges and dist-tags in installs; removed versions cannot be verified from the registry.",
|
|
189
|
+
" Release age, popularity or a lack of findings is not proof of safety.",
|
|
190
|
+
);
|
|
191
|
+
return lines.map((line) => escapeUnsafe(line)).join("\n") + "\n";
|
|
192
|
+
}
|
|
193
|
+
export async function runHistory(argv, runtime = {}) {
|
|
194
|
+
const { name, limit, json } = parseHistoryArgs(argv);
|
|
195
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
|
|
196
|
+
let packument;
|
|
197
|
+
try {
|
|
198
|
+
const blob = await fetchBlob(url, {
|
|
199
|
+
fetchImpl: runtime.fetchImpl,
|
|
200
|
+
signal: runtime.signal,
|
|
201
|
+
maxBytes: MAX_PACKUMENT_BYTES,
|
|
202
|
+
timeoutMs: TIMEOUT_MS,
|
|
203
|
+
});
|
|
204
|
+
packument = JSON.parse(blob.bytes.toString("utf8"));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error instanceof AcquisitionError)
|
|
207
|
+
throw new LocalInputError(
|
|
208
|
+
error.observation?.status === 404
|
|
209
|
+
? "registry-not-found"
|
|
210
|
+
: "registry-unavailable",
|
|
211
|
+
error.observation?.status === 404
|
|
212
|
+
? `The registry has no package named ${name}.`
|
|
213
|
+
: `Registry metadata could not be read (${error.code}).`,
|
|
214
|
+
);
|
|
215
|
+
throw new LocalInputError(
|
|
216
|
+
"registry-invalid",
|
|
217
|
+
"Registry metadata is not valid JSON",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (packument?.name !== name)
|
|
221
|
+
throw new LocalInputError(
|
|
222
|
+
"registry-identity-mismatch",
|
|
223
|
+
"Registry metadata names a different package",
|
|
224
|
+
);
|
|
225
|
+
const summary = summarizeHistory(packument, { limit, now: runtime.now });
|
|
226
|
+
(runtime.stdout ?? process.stdout).write(
|
|
227
|
+
json
|
|
228
|
+
? escapeUnsafe(JSON.stringify(summary, null, 2), JSON_UNSAFE) + "\n"
|
|
229
|
+
: renderHistory(summary),
|
|
230
|
+
);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|