@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,342 @@
|
|
|
1
|
+
import { AcquisitionError, fetchBlob, parsePackageSpec } from "./acquire.mjs";
|
|
2
|
+
|
|
3
|
+
export const REGISTRY_FACTS_LIMITS = Object.freeze({
|
|
4
|
+
maxBytes: 1024 * 1024,
|
|
5
|
+
timeoutMs: 10000,
|
|
6
|
+
maxValueBytes: 8192,
|
|
7
|
+
maxItems: 100,
|
|
8
|
+
});
|
|
9
|
+
const object = (value) =>
|
|
10
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
const text = (value) => typeof value === "string";
|
|
12
|
+
const stringMap = (value) => object(value) && Object.values(value).every(text);
|
|
13
|
+
const record = (value, keys) =>
|
|
14
|
+
Object.fromEntries(
|
|
15
|
+
keys
|
|
16
|
+
.filter((key) => Object.hasOwn(value, key))
|
|
17
|
+
.map((key) => [key, value[key]]),
|
|
18
|
+
);
|
|
19
|
+
const person = (value) => object(value) && text(value.name);
|
|
20
|
+
|
|
21
|
+
export function registryIdentity(spec) {
|
|
22
|
+
const identity = parsePackageSpec(spec);
|
|
23
|
+
if (
|
|
24
|
+
identity.version
|
|
25
|
+
.split(/[.+-]/)
|
|
26
|
+
.slice(0, 3)
|
|
27
|
+
.some((part) => !Number.isSafeInteger(Number(part)))
|
|
28
|
+
)
|
|
29
|
+
throw new AcquisitionError(
|
|
30
|
+
"invalid-package-spec",
|
|
31
|
+
"Expected exact package name@version",
|
|
32
|
+
);
|
|
33
|
+
return identity;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readRegistryDocument(name, version, options = {}) {
|
|
37
|
+
registryIdentity(`${name}@${version ?? "0.0.0"}`);
|
|
38
|
+
const maxBytes = options.maxBytes ?? REGISTRY_FACTS_LIMITS.maxBytes;
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? REGISTRY_FACTS_LIMITS.timeoutMs;
|
|
40
|
+
if (
|
|
41
|
+
!Number.isSafeInteger(maxBytes) ||
|
|
42
|
+
maxBytes < 1 ||
|
|
43
|
+
maxBytes > REGISTRY_FACTS_LIMITS.maxBytes ||
|
|
44
|
+
!Number.isSafeInteger(timeoutMs) ||
|
|
45
|
+
timeoutMs < 1 ||
|
|
46
|
+
timeoutMs > REGISTRY_FACTS_LIMITS.timeoutMs
|
|
47
|
+
)
|
|
48
|
+
throw new TypeError("Invalid registry facts limits");
|
|
49
|
+
const url = `https://registry.npmjs.org/${encodeURIComponent(name)}${version === undefined ? "" : `/${encodeURIComponent(version)}`}`;
|
|
50
|
+
const blob = await fetchBlob(url, {
|
|
51
|
+
fetchImpl: options.fetchImpl,
|
|
52
|
+
signal: options.signal,
|
|
53
|
+
maxBytes,
|
|
54
|
+
timeoutMs,
|
|
55
|
+
});
|
|
56
|
+
let document;
|
|
57
|
+
try {
|
|
58
|
+
document = JSON.parse(blob.bytes.toString("utf8"));
|
|
59
|
+
} catch {
|
|
60
|
+
throw new AcquisitionError(
|
|
61
|
+
"invalid-registry-document",
|
|
62
|
+
"Registry metadata is not JSON",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
!object(document) ||
|
|
67
|
+
document.name !== name ||
|
|
68
|
+
(version !== undefined && document.version !== version)
|
|
69
|
+
)
|
|
70
|
+
throw new AcquisitionError(
|
|
71
|
+
"registry-identity-mismatch",
|
|
72
|
+
"Registry identity differs from request",
|
|
73
|
+
);
|
|
74
|
+
return {
|
|
75
|
+
document,
|
|
76
|
+
source: {
|
|
77
|
+
url,
|
|
78
|
+
retrievedAt: new Date().toISOString(),
|
|
79
|
+
sha256: blob.digest.sha256,
|
|
80
|
+
bytes: blob.observation.bytes,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function claim(document, key, valid, project = (value) => value) {
|
|
86
|
+
const result = { pointer: `/${key}`, status: "not-recorded" };
|
|
87
|
+
if (!Object.hasOwn(document, key)) return result;
|
|
88
|
+
const value = document[key];
|
|
89
|
+
if (
|
|
90
|
+
Buffer.byteLength(JSON.stringify(value)) >
|
|
91
|
+
REGISTRY_FACTS_LIMITS.maxValueBytes ||
|
|
92
|
+
(Array.isArray(value) && value.length > REGISTRY_FACTS_LIMITS.maxItems)
|
|
93
|
+
)
|
|
94
|
+
return { ...result, status: "omitted-limit" };
|
|
95
|
+
if (!valid(value)) return { ...result, status: "invalid-type" };
|
|
96
|
+
return {
|
|
97
|
+
...result,
|
|
98
|
+
status: "registry-reported-unverified",
|
|
99
|
+
value: project(value),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function projectRegistryClaims(document) {
|
|
104
|
+
return {
|
|
105
|
+
maintainers: claim(
|
|
106
|
+
document,
|
|
107
|
+
"maintainers",
|
|
108
|
+
(value) =>
|
|
109
|
+
Array.isArray(value) &&
|
|
110
|
+
value.length <= REGISTRY_FACTS_LIMITS.maxItems &&
|
|
111
|
+
value.every(person),
|
|
112
|
+
(value) => value.map((item) => ({ name: item.name })),
|
|
113
|
+
),
|
|
114
|
+
publisher: claim(document, "_npmUser", person, (value) => ({
|
|
115
|
+
name: value.name,
|
|
116
|
+
})),
|
|
117
|
+
deprecated: claim(document, "deprecated", text),
|
|
118
|
+
license: claim(
|
|
119
|
+
document,
|
|
120
|
+
"license",
|
|
121
|
+
(value) =>
|
|
122
|
+
text(value) ||
|
|
123
|
+
(object(value) &&
|
|
124
|
+
text(value.type) &&
|
|
125
|
+
(value.url === undefined || text(value.url))),
|
|
126
|
+
(value) => (text(value) ? value : record(value, ["type", "url"])),
|
|
127
|
+
),
|
|
128
|
+
engines: claim(document, "engines", stringMap),
|
|
129
|
+
repository: claim(
|
|
130
|
+
document,
|
|
131
|
+
"repository",
|
|
132
|
+
(value) =>
|
|
133
|
+
text(value) ||
|
|
134
|
+
(object(value) &&
|
|
135
|
+
text(value.url) &&
|
|
136
|
+
["type", "directory"].every(
|
|
137
|
+
(key) => value[key] === undefined || text(value[key]),
|
|
138
|
+
)),
|
|
139
|
+
(value) =>
|
|
140
|
+
text(value) ? value : record(value, ["type", "url", "directory"]),
|
|
141
|
+
),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function getRegistryFacts(spec, options = {}) {
|
|
146
|
+
const identity = registryIdentity(spec);
|
|
147
|
+
const { document, source } = await readRegistryDocument(
|
|
148
|
+
identity.name,
|
|
149
|
+
identity.version,
|
|
150
|
+
options,
|
|
151
|
+
);
|
|
152
|
+
return {
|
|
153
|
+
schemaVersion: "1",
|
|
154
|
+
identity,
|
|
155
|
+
source,
|
|
156
|
+
verifiedObservations: {
|
|
157
|
+
documentIdentity: "matches-exact-request",
|
|
158
|
+
documentDigest: "sha256-of-retrieved-bytes",
|
|
159
|
+
},
|
|
160
|
+
unverifiedClaims: projectRegistryClaims(document),
|
|
161
|
+
scope: {
|
|
162
|
+
method: "bounded-public-npm-metadata",
|
|
163
|
+
safetyVerdict: "not-provided",
|
|
164
|
+
modelAssessment: "not-performed-metadata-only",
|
|
165
|
+
artifactInspection: "not-performed",
|
|
166
|
+
limitation:
|
|
167
|
+
"Registry declarations only. Maintainers and publisher names are recorded metadata, not verified identities or ownership history. Repository, license and engine declarations are not independently verified. Missing deprecation does not establish support or safety. No popularity-based safety inference.",
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
173
|
+
const metadataFields = ["deprecated", "license", "engines", "repository"];
|
|
174
|
+
const recorded = (claim) => claim.status === "registry-reported-unverified";
|
|
175
|
+
function stable(value) {
|
|
176
|
+
return JSON.stringify(
|
|
177
|
+
object(value)
|
|
178
|
+
? Object.fromEntries(
|
|
179
|
+
Object.keys(value)
|
|
180
|
+
.sort(order)
|
|
181
|
+
.map((key) => [key, value[key]]),
|
|
182
|
+
)
|
|
183
|
+
: value,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
function timestamp(value) {
|
|
187
|
+
if (
|
|
188
|
+
typeof value !== "string" ||
|
|
189
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
|
|
190
|
+
)
|
|
191
|
+
return null;
|
|
192
|
+
const canonical = value.includes(".") ? value : value.replace("Z", ".000Z");
|
|
193
|
+
const ms = Date.parse(value);
|
|
194
|
+
return Number.isFinite(ms) && new Date(ms).toISOString() === canonical
|
|
195
|
+
? canonical
|
|
196
|
+
: null;
|
|
197
|
+
}
|
|
198
|
+
function compareRelease(current, previous, ambiguousTime) {
|
|
199
|
+
if (ambiguousTime) return { status: "not-assessed-ambiguous-time" };
|
|
200
|
+
if (!previous) return { status: "not-assessed-no-predecessor" };
|
|
201
|
+
const left = previous.claims;
|
|
202
|
+
const right = current.claims;
|
|
203
|
+
const publisher =
|
|
204
|
+
recorded(left.publisher) && recorded(right.publisher)
|
|
205
|
+
? {
|
|
206
|
+
status:
|
|
207
|
+
left.publisher.value.name === right.publisher.value.name
|
|
208
|
+
? "unchanged"
|
|
209
|
+
: "changed",
|
|
210
|
+
before: left.publisher.value.name,
|
|
211
|
+
after: right.publisher.value.name,
|
|
212
|
+
}
|
|
213
|
+
: { status: "not-assessed" };
|
|
214
|
+
const unassessedFields = metadataFields.filter(
|
|
215
|
+
(field) => !recorded(left[field]) || !recorded(right[field]),
|
|
216
|
+
);
|
|
217
|
+
const metadataChanges = metadataFields
|
|
218
|
+
.filter(
|
|
219
|
+
(field) =>
|
|
220
|
+
recorded(left[field]) &&
|
|
221
|
+
recorded(right[field]) &&
|
|
222
|
+
stable(left[field].value) !== stable(right[field].value),
|
|
223
|
+
)
|
|
224
|
+
.map((field) => ({
|
|
225
|
+
field,
|
|
226
|
+
before: left[field].value,
|
|
227
|
+
after: right[field].value,
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
status:
|
|
231
|
+
unassessedFields.length || publisher.status === "not-assessed"
|
|
232
|
+
? "partial"
|
|
233
|
+
: "compared",
|
|
234
|
+
againstVersion: previous.version,
|
|
235
|
+
publisher,
|
|
236
|
+
metadataChanges,
|
|
237
|
+
unassessedFields,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function getRegistryHistory(name, options = {}) {
|
|
242
|
+
registryIdentity(`${name}@0.0.0`);
|
|
243
|
+
const limit = options.limit ?? 20;
|
|
244
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
245
|
+
throw new TypeError("Invalid registry history limit");
|
|
246
|
+
const { document, source } = await readRegistryDocument(
|
|
247
|
+
name,
|
|
248
|
+
undefined,
|
|
249
|
+
options,
|
|
250
|
+
);
|
|
251
|
+
const times = object(document.time) ? document.time : {};
|
|
252
|
+
const versions = object(document.versions) ? document.versions : {};
|
|
253
|
+
const keys = [
|
|
254
|
+
...new Set([
|
|
255
|
+
...Object.keys(times).filter(
|
|
256
|
+
(key) => !["created", "modified"].includes(key),
|
|
257
|
+
),
|
|
258
|
+
...Object.keys(versions),
|
|
259
|
+
]),
|
|
260
|
+
];
|
|
261
|
+
if (keys.length > 5000)
|
|
262
|
+
throw new AcquisitionError(
|
|
263
|
+
"registry-history-cap",
|
|
264
|
+
"Registry version history exceeds record limit",
|
|
265
|
+
);
|
|
266
|
+
let invalidVersionKeys = 0,
|
|
267
|
+
undatedVersions = 0;
|
|
268
|
+
const timeline = [];
|
|
269
|
+
for (const version of keys) {
|
|
270
|
+
try {
|
|
271
|
+
registryIdentity(`${name}@${version}`);
|
|
272
|
+
} catch {
|
|
273
|
+
invalidVersionKeys++;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const releasedAt = timestamp(times[version]);
|
|
277
|
+
if (!releasedAt) {
|
|
278
|
+
undatedVersions++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const entry = versions[version];
|
|
282
|
+
const metadataStatus = !Object.hasOwn(versions, version)
|
|
283
|
+
? "not-recorded"
|
|
284
|
+
: !object(entry)
|
|
285
|
+
? "invalid-type"
|
|
286
|
+
: entry.name !== name || entry.version !== version
|
|
287
|
+
? "identity-mismatch"
|
|
288
|
+
: "registry-reported-unverified";
|
|
289
|
+
const claims = projectRegistryClaims(
|
|
290
|
+
metadataStatus === "registry-reported-unverified" ? entry : {},
|
|
291
|
+
);
|
|
292
|
+
for (const claim of Object.values(claims))
|
|
293
|
+
claim.pointer = `/versions/${version}${claim.pointer}`;
|
|
294
|
+
timeline.push({
|
|
295
|
+
version,
|
|
296
|
+
releasedAt,
|
|
297
|
+
timePointer: `/time/${version}`,
|
|
298
|
+
metadataStatus,
|
|
299
|
+
claims,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
timeline.sort(
|
|
303
|
+
(a, b) => order(a.releasedAt, b.releasedAt) || order(a.version, b.version),
|
|
304
|
+
);
|
|
305
|
+
const counts = new Map();
|
|
306
|
+
for (const entry of timeline)
|
|
307
|
+
counts.set(entry.releasedAt, (counts.get(entry.releasedAt) ?? 0) + 1);
|
|
308
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
309
|
+
const entry = timeline[i];
|
|
310
|
+
entry.comparison = compareRelease(
|
|
311
|
+
entry,
|
|
312
|
+
timeline[i - 1],
|
|
313
|
+
counts.get(entry.releasedAt) > 1 ||
|
|
314
|
+
(i > 0 && counts.get(timeline[i - 1].releasedAt) > 1),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
schemaVersion: "1",
|
|
319
|
+
status: timeline.length ? "recorded-history" : "history-unavailable",
|
|
320
|
+
identity: { name },
|
|
321
|
+
source,
|
|
322
|
+
timeline: timeline.slice(-limit).reverse(),
|
|
323
|
+
coverage: {
|
|
324
|
+
recordedVersions: keys.length - invalidVersionKeys,
|
|
325
|
+
datedVersions: timeline.length,
|
|
326
|
+
undatedVersions,
|
|
327
|
+
invalidVersionKeys,
|
|
328
|
+
returned: Math.min(limit, timeline.length),
|
|
329
|
+
omitted: Math.max(0, timeline.length - limit),
|
|
330
|
+
recordLimit: 5000,
|
|
331
|
+
},
|
|
332
|
+
scope: {
|
|
333
|
+
method: "adjacent-dated-version-records-in-current-registry-snapshot",
|
|
334
|
+
historicalEdits: "not-assessed-single-snapshot",
|
|
335
|
+
ownershipTransfers: "not-assessed",
|
|
336
|
+
safetyVerdict: "not-provided",
|
|
337
|
+
modelAssessment: "not-performed-metadata-only",
|
|
338
|
+
limitation:
|
|
339
|
+
"Publish times and declarations are registry-reported and unverified. Comparisons describe differences between adjacent dated records in this snapshot, not edits over time or proven ownership transfers. Missing, malformed or omitted fields remain unassessed. Removed releases may retain dates without metadata. Equal timestamps do not establish ordering. Changes, unchanged metadata and popularity do not establish safety.",
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const states = new Map([
|
|
4
|
+
["changed", "changed"],
|
|
5
|
+
["artifact-only", "added"],
|
|
6
|
+
["byte-identical", "identical"],
|
|
7
|
+
["not-compared", "not-compared"],
|
|
8
|
+
]);
|
|
9
|
+
const digest = (value) => (/^[a-f0-9]{64}$/.test(value ?? "") ? value : null);
|
|
10
|
+
const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
11
|
+
export const EXPLORATION_BYTE_LIMIT = 131072;
|
|
12
|
+
export const explorationLimitations = [
|
|
13
|
+
"States compare artifact and selected source, not releases or version history.",
|
|
14
|
+
"Source-only paths are recorded observations; their hashes, file types and excerpts may be unavailable.",
|
|
15
|
+
"Incomplete inventories and unassessed files do not establish safety or absence.",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function safeExplorationPath(value, { prefix = false } = {}) {
|
|
19
|
+
if (
|
|
20
|
+
typeof value !== "string" ||
|
|
21
|
+
value.length > 4096 ||
|
|
22
|
+
/[\x00-\x1f\x7f\\%:]/.test(value)
|
|
23
|
+
)
|
|
24
|
+
return false;
|
|
25
|
+
if (prefix && value === "") return true;
|
|
26
|
+
const parts = (
|
|
27
|
+
prefix && value.endsWith("/") ? value.slice(0, -1) : value
|
|
28
|
+
).split("/");
|
|
29
|
+
return parts.every((part) => part && part !== "." && part !== "..");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function explorationJson(value) {
|
|
33
|
+
return JSON.stringify(value).replace(
|
|
34
|
+
/[<>&\u2028\u2029]/g,
|
|
35
|
+
(character) =>
|
|
36
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function reportFileRows(report, { reportId } = {}) {
|
|
41
|
+
if (reportId !== undefined && !/^[a-f0-9]{32}$/.test(reportId))
|
|
42
|
+
throw new TypeError("Invalid report identity");
|
|
43
|
+
const rows = [];
|
|
44
|
+
const paths = new Set();
|
|
45
|
+
let omitted = 0;
|
|
46
|
+
for (const file of report.files ?? []) {
|
|
47
|
+
if (
|
|
48
|
+
!states.has(file.state) ||
|
|
49
|
+
!safeExplorationPath(file.originalPath) ||
|
|
50
|
+
(file.path !== null && !safeExplorationPath(file.path))
|
|
51
|
+
) {
|
|
52
|
+
omitted++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (file.path !== null) paths.add(file.path);
|
|
56
|
+
rows.push({
|
|
57
|
+
path: file.path,
|
|
58
|
+
originalPath: file.originalPath,
|
|
59
|
+
state: states.get(file.state),
|
|
60
|
+
comparisonState: file.state,
|
|
61
|
+
size:
|
|
62
|
+
Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : null,
|
|
63
|
+
artifactSha256: digest(file.artifactSha256),
|
|
64
|
+
sourceSha256: digest(file.sourceSha256),
|
|
65
|
+
reason:
|
|
66
|
+
typeof file.reason === "string" ? file.reason.slice(0, 128) : null,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
for (const path of new Set(report.coverage?.sourceOnly ?? [])) {
|
|
70
|
+
if (!safeExplorationPath(path)) {
|
|
71
|
+
omitted++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (paths.has(path)) continue;
|
|
75
|
+
rows.push({
|
|
76
|
+
path,
|
|
77
|
+
originalPath: null,
|
|
78
|
+
state: "removed",
|
|
79
|
+
comparisonState: "source-only",
|
|
80
|
+
size: null,
|
|
81
|
+
artifactSha256: null,
|
|
82
|
+
sourceSha256: null,
|
|
83
|
+
reason: "recorded-source-only",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
rows.sort(
|
|
87
|
+
(a, b) =>
|
|
88
|
+
order(a.path ?? a.originalPath, b.path ?? b.originalPath) ||
|
|
89
|
+
order(a.originalPath ?? "", b.originalPath ?? ""),
|
|
90
|
+
);
|
|
91
|
+
if (reportId !== undefined)
|
|
92
|
+
for (const file of rows) {
|
|
93
|
+
file.fileId = `f-${createHash("sha256")
|
|
94
|
+
.update(
|
|
95
|
+
JSON.stringify([
|
|
96
|
+
"unmask-file-id-v1",
|
|
97
|
+
reportId,
|
|
98
|
+
file.path,
|
|
99
|
+
file.originalPath,
|
|
100
|
+
]),
|
|
101
|
+
)
|
|
102
|
+
.digest("base64url")}`;
|
|
103
|
+
}
|
|
104
|
+
return { rows, omitted };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildReportFileInventory(
|
|
108
|
+
report,
|
|
109
|
+
{ state = "all", prefix = "", offset = 0, limit = 50 } = {},
|
|
110
|
+
context = {},
|
|
111
|
+
) {
|
|
112
|
+
if (
|
|
113
|
+
!["all", "removed", ...states.values()].includes(state) ||
|
|
114
|
+
!safeExplorationPath(prefix, { prefix: true }) ||
|
|
115
|
+
!Number.isSafeInteger(offset) ||
|
|
116
|
+
offset < 0 ||
|
|
117
|
+
!Number.isSafeInteger(limit) ||
|
|
118
|
+
limit < 1 ||
|
|
119
|
+
limit > 100
|
|
120
|
+
)
|
|
121
|
+
throw new TypeError("Invalid inventory query");
|
|
122
|
+
const { rows, omitted } = reportFileRows(report, context);
|
|
123
|
+
const filtered = rows.filter(
|
|
124
|
+
(file) =>
|
|
125
|
+
(state === "all" || file.state === state) &&
|
|
126
|
+
(file.path ?? file.originalPath).startsWith(prefix),
|
|
127
|
+
);
|
|
128
|
+
const files = [];
|
|
129
|
+
let bytes = 0;
|
|
130
|
+
for (const file of filtered.slice(offset, offset + limit)) {
|
|
131
|
+
const size = Buffer.byteLength(explorationJson(file)) + 1;
|
|
132
|
+
if (
|
|
133
|
+
bytes + size >
|
|
134
|
+
EXPLORATION_BYTE_LIMIT -
|
|
135
|
+
Buffer.byteLength(explorationJson({ prefix })) -
|
|
136
|
+
8192
|
|
137
|
+
)
|
|
138
|
+
break;
|
|
139
|
+
bytes += size;
|
|
140
|
+
files.push(file);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
schemaVersion: "1",
|
|
144
|
+
scope: "artifact-source",
|
|
145
|
+
state,
|
|
146
|
+
prefix,
|
|
147
|
+
offset,
|
|
148
|
+
limit,
|
|
149
|
+
total: filtered.length,
|
|
150
|
+
files,
|
|
151
|
+
nextOffset:
|
|
152
|
+
offset + files.length < filtered.length ? offset + files.length : null,
|
|
153
|
+
omittedUnsafePaths: omitted,
|
|
154
|
+
inventoryComplete: report.coverage?.inventory?.complete === true,
|
|
155
|
+
sourceComplete: report.coverage?.sourceComplete === true,
|
|
156
|
+
limitations: explorationLimitations,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validRange(value, size) {
|
|
161
|
+
return (
|
|
162
|
+
Array.isArray(value) &&
|
|
163
|
+
value.length === 2 &&
|
|
164
|
+
value.every((number) => Number.isSafeInteger(number) && number >= 0) &&
|
|
165
|
+
value[0] <= value[1] &&
|
|
166
|
+
value[1] <= size
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function recordedExcerpt(item) {
|
|
171
|
+
const unavailable = {
|
|
172
|
+
excerpt: null,
|
|
173
|
+
excerptRange: null,
|
|
174
|
+
recordedExcerptRange: null,
|
|
175
|
+
excerptTruncated: false,
|
|
176
|
+
excerptStatus: "not-recorded",
|
|
177
|
+
};
|
|
178
|
+
if (item.excerpt === undefined) return unavailable;
|
|
179
|
+
if (
|
|
180
|
+
typeof item.excerpt !== "string" ||
|
|
181
|
+
!validRange(item.excerptRange, item.size) ||
|
|
182
|
+
item.excerptRange[0] !== item.range[0] ||
|
|
183
|
+
item.excerptRange[1] > item.range[1] ||
|
|
184
|
+
Buffer.byteLength(item.excerpt) !==
|
|
185
|
+
item.excerptRange[1] - item.excerptRange[0]
|
|
186
|
+
)
|
|
187
|
+
return { ...unavailable, excerptStatus: "invalid-recorded-range" };
|
|
188
|
+
let excerpt = "",
|
|
189
|
+
bytes = 0;
|
|
190
|
+
for (const character of item.excerpt) {
|
|
191
|
+
const size = Buffer.byteLength(character);
|
|
192
|
+
if (bytes + size > 2048) break;
|
|
193
|
+
bytes += size;
|
|
194
|
+
excerpt += character;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
excerpt,
|
|
198
|
+
excerptRange: [item.excerptRange[0], item.excerptRange[0] + bytes],
|
|
199
|
+
recordedExcerptRange: item.excerptRange,
|
|
200
|
+
excerptTruncated: bytes < Buffer.byteLength(item.excerpt),
|
|
201
|
+
excerptStatus: "recorded",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildReportFileEvidence(report, selection, context = {}) {
|
|
206
|
+
const keys = Object.keys(selection ?? {});
|
|
207
|
+
if (
|
|
208
|
+
keys.length !== 1 ||
|
|
209
|
+
!["path", "originalPath", "fileId"].includes(keys[0]) ||
|
|
210
|
+
(keys[0] === "fileId"
|
|
211
|
+
? !/^[a-f0-9]{32}$/.test(context.reportId ?? "") ||
|
|
212
|
+
!/^f-[A-Za-z0-9_-]{43}$/.test(selection.fileId)
|
|
213
|
+
: !safeExplorationPath(selection[keys[0]]))
|
|
214
|
+
)
|
|
215
|
+
throw new TypeError("Invalid file selection");
|
|
216
|
+
const { rows } = reportFileRows(report, context);
|
|
217
|
+
const matches = rows.filter((file) =>
|
|
218
|
+
keys[0] === "fileId"
|
|
219
|
+
? file.fileId === selection.fileId
|
|
220
|
+
: keys[0] === "path"
|
|
221
|
+
? file.path === selection.path
|
|
222
|
+
: file.path === null && file.originalPath === selection.originalPath,
|
|
223
|
+
);
|
|
224
|
+
if (matches.length !== 1) return null;
|
|
225
|
+
const file = matches[0];
|
|
226
|
+
const rawFile = (report.files ?? []).find(
|
|
227
|
+
(item) =>
|
|
228
|
+
item.path === file.path && item.originalPath === file.originalPath,
|
|
229
|
+
);
|
|
230
|
+
const samePathEvidence = (report.evidence ?? []).filter((item) =>
|
|
231
|
+
file.path === null
|
|
232
|
+
? item.side === "artifact" &&
|
|
233
|
+
item.path === null &&
|
|
234
|
+
item.originalPath === file.originalPath
|
|
235
|
+
: item.path === file.path &&
|
|
236
|
+
(file.state !== "removed" || item.side === "source"),
|
|
237
|
+
);
|
|
238
|
+
if (file.state === "removed") {
|
|
239
|
+
const hashes = new Set(
|
|
240
|
+
samePathEvidence
|
|
241
|
+
.filter(
|
|
242
|
+
(item) =>
|
|
243
|
+
digest(item.fileSha256) &&
|
|
244
|
+
digest(item.blobDigest) &&
|
|
245
|
+
item.blobDigest === report.inputs?.sourceArchive?.sha256,
|
|
246
|
+
)
|
|
247
|
+
.map((item) => item.fileSha256),
|
|
248
|
+
);
|
|
249
|
+
if (hashes.size === 1) file.sourceSha256 = [...hashes][0];
|
|
250
|
+
}
|
|
251
|
+
const candidateIds = new Set([
|
|
252
|
+
...(rawFile?.evidenceIds ?? []),
|
|
253
|
+
...samePathEvidence.map((item) => item.id),
|
|
254
|
+
]);
|
|
255
|
+
const result = {
|
|
256
|
+
schemaVersion: "1",
|
|
257
|
+
scope: "artifact-source",
|
|
258
|
+
file,
|
|
259
|
+
recordedOnly: true,
|
|
260
|
+
evidence: [],
|
|
261
|
+
evidenceStatus: "unavailable",
|
|
262
|
+
evidenceOmitted: 0,
|
|
263
|
+
invalidEvidenceOmitted: 0,
|
|
264
|
+
limitations: [
|
|
265
|
+
...explorationLimitations,
|
|
266
|
+
"Only previously recorded excerpts are returned; omitted text is not fetched or reconstructed. Hashes are recorded claims, not new artifact verification. Render excerpts as text, never HTML.",
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
const evidenceById = new Map();
|
|
270
|
+
for (const item of report.evidence ?? [])
|
|
271
|
+
evidenceById.set(item.id, evidenceById.has(item.id) ? null : item);
|
|
272
|
+
let bytes = Buffer.byteLength(explorationJson(result));
|
|
273
|
+
for (const id of candidateIds) {
|
|
274
|
+
const item = evidenceById.get(id);
|
|
275
|
+
const expectedHash =
|
|
276
|
+
item?.side === "artifact" ? file.artifactSha256 : file.sourceSha256;
|
|
277
|
+
const input =
|
|
278
|
+
item?.side === "artifact"
|
|
279
|
+
? report.inputs?.artifact
|
|
280
|
+
: report.inputs?.sourceArchive;
|
|
281
|
+
if (
|
|
282
|
+
!item ||
|
|
283
|
+
!/^ev-[a-f0-9]{64}$/.test(id) ||
|
|
284
|
+
!["artifact", "source"].includes(item.side) ||
|
|
285
|
+
item.path !== file.path ||
|
|
286
|
+
!safeExplorationPath(item.originalPath) ||
|
|
287
|
+
(item.side === "artifact" &&
|
|
288
|
+
(item.originalPath !== file.originalPath || item.size !== file.size)) ||
|
|
289
|
+
!expectedHash ||
|
|
290
|
+
item.fileSha256 !== expectedHash ||
|
|
291
|
+
!digest(item.blobDigest) ||
|
|
292
|
+
item.blobDigest !== input?.sha256 ||
|
|
293
|
+
!Number.isSafeInteger(item.size) ||
|
|
294
|
+
item.size < 0 ||
|
|
295
|
+
!validRange(item.range, item.size)
|
|
296
|
+
) {
|
|
297
|
+
result.invalidEvidenceOmitted++;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const evidence = {
|
|
301
|
+
id,
|
|
302
|
+
side: item.side,
|
|
303
|
+
path: item.path,
|
|
304
|
+
originalPath: item.originalPath,
|
|
305
|
+
fileSha256: item.fileSha256,
|
|
306
|
+
blobDigest: item.blobDigest,
|
|
307
|
+
size: item.size,
|
|
308
|
+
range: item.range,
|
|
309
|
+
...recordedExcerpt(item),
|
|
310
|
+
};
|
|
311
|
+
const size = Buffer.byteLength(explorationJson(evidence)) + 1;
|
|
312
|
+
if (
|
|
313
|
+
result.evidence.length >= 32 ||
|
|
314
|
+
bytes + size > EXPLORATION_BYTE_LIMIT - 2048
|
|
315
|
+
) {
|
|
316
|
+
result.evidenceOmitted++;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
bytes += size;
|
|
320
|
+
result.evidence.push(evidence);
|
|
321
|
+
}
|
|
322
|
+
if (result.evidence.length)
|
|
323
|
+
result.evidenceStatus =
|
|
324
|
+
result.evidenceOmitted || result.invalidEvidenceOmitted
|
|
325
|
+
? "partial"
|
|
326
|
+
: "recorded";
|
|
327
|
+
return result;
|
|
328
|
+
}
|
package/dist/core/report.mjs
CHANGED
|
@@ -225,6 +225,13 @@ export function validateReport(report) {
|
|
|
225
225
|
typeof report.run === "object",
|
|
226
226
|
"Invalid model or run section",
|
|
227
227
|
);
|
|
228
|
+
requireValue(
|
|
229
|
+
report.model.assessment === undefined ||
|
|
230
|
+
(report.model.status === "complete" &&
|
|
231
|
+
report.model.assessment?.source === "model" &&
|
|
232
|
+
typeof report.model.summary === "string"),
|
|
233
|
+
"Model assessment requires a completed model result",
|
|
234
|
+
);
|
|
228
235
|
const coverage = report.coverage;
|
|
229
236
|
requireValue(
|
|
230
237
|
coverage &&
|