@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.
Files changed (50) hide show
  1. package/README.md +86 -4
  2. package/dist/cli/annotations.mjs +145 -0
  3. package/dist/cli/audit.mjs +223 -0
  4. package/dist/cli/cache.mjs +488 -0
  5. package/dist/cli/compare.mjs +261 -0
  6. package/dist/cli/config.mjs +141 -0
  7. package/dist/cli/discovery.mjs +372 -0
  8. package/dist/cli/doctor.mjs +354 -0
  9. package/dist/cli/exceptions.mjs +170 -0
  10. package/dist/cli/file.mjs +271 -0
  11. package/dist/cli/files.mjs +141 -0
  12. package/dist/cli/history.mjs +232 -0
  13. package/dist/cli/hosted.mjs +251 -43
  14. package/dist/cli/info.mjs +125 -0
  15. package/dist/cli/init.mjs +57 -0
  16. package/dist/cli/install-guard.mjs +896 -0
  17. package/dist/cli/lockfile.mjs +330 -0
  18. package/dist/cli/main.mjs +97 -14
  19. package/dist/cli/preflight.mjs +388 -0
  20. package/dist/cli/sarif.mjs +100 -0
  21. package/dist/cli/shell-integration.mjs +122 -0
  22. package/dist/cli/terminal.mjs +396 -0
  23. package/dist/cli/watch.mjs +233 -0
  24. package/dist/cli/why.mjs +256 -0
  25. package/dist/core/advisories.mjs +101 -24
  26. package/dist/core/advisory-cache.mjs +295 -0
  27. package/dist/core/behavior-signals.mjs +1088 -0
  28. package/dist/core/dependency-graph.mjs +332 -0
  29. package/dist/core/evidence-exports.mjs +469 -0
  30. package/dist/core/guard-config.mjs +463 -0
  31. package/dist/core/guard-policy.mjs +308 -0
  32. package/dist/core/package-details.mjs +42 -6
  33. package/dist/core/report.mjs +7 -0
  34. package/dist/core/sarif.mjs +556 -0
  35. package/dist/core/syntax.mjs +296 -15
  36. package/dist/model/client.mjs +204 -56
  37. package/dist/model/diff-evidence.mjs +177 -0
  38. package/dist/model/summary.mjs +183 -0
  39. package/dist/server/advisory-result.mjs +137 -0
  40. package/dist/server/app.mjs +60 -2
  41. package/dist/server/dependency-screening.mjs +386 -0
  42. package/dist/server/exports.mjs +63 -0
  43. package/dist/server/guard-decision.mjs +37 -0
  44. package/dist/server/main.mjs +25 -7
  45. package/dist/server/queue.mjs +95 -53
  46. package/dist/server/store.mjs +21 -1
  47. package/dist/version.json +1 -1
  48. package/dist/viewer/render.mjs +10 -2
  49. package/dist/viewer/shell.mjs +1 -1
  50. package/package.json +1 -1
@@ -0,0 +1,469 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalReport, validateReport } from "./report.mjs";
3
+
4
+ export const SBOM_LIMITS = Object.freeze({
5
+ maxComponents: 2000,
6
+ maxNameLength: 214,
7
+ maxRangeLength: 1024,
8
+ });
9
+ export const CYCLONEDX_MEDIA_TYPE = "application/vnd.cyclonedx+json";
10
+
11
+ const ROOT_REF = "unmask:artifact";
12
+ const DECLARED_FIELDS = [
13
+ ["dependencies", "required"],
14
+ ["optionalDependencies", "optional"],
15
+ ["peerDependencies", "optional"],
16
+ ["devDependencies", "excluded"],
17
+ ];
18
+ const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
19
+ const object = (value) =>
20
+ value !== null && typeof value === "object" && !Array.isArray(value);
21
+ const npmName = (value) =>
22
+ typeof value === "string" &&
23
+ value.length <= SBOM_LIMITS.maxNameLength &&
24
+ /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(value);
25
+ const property = (name, value) => ({
26
+ name: `unmask:${name}`,
27
+ value: String(value),
28
+ });
29
+
30
+ function splitName(name) {
31
+ const slash = name.startsWith("@") ? name.indexOf("/") : -1;
32
+ return slash > 0
33
+ ? { group: name.slice(0, slash), name: name.slice(slash + 1) }
34
+ : { name };
35
+ }
36
+
37
+ export function npmPurl(name, version) {
38
+ if (!npmName(name) || typeof version !== "string" || !version) return null;
39
+ const parts = splitName(name);
40
+ const namespace = parts.group ? `${encodeURIComponent(parts.group)}/` : "";
41
+ return `pkg:npm/${namespace}${encodeURIComponent(parts.name)}@${encodeURIComponent(version)}`;
42
+ }
43
+
44
+ export function reportIdentity(report) {
45
+ const manifest = report.manifests.artifact;
46
+ const declared =
47
+ manifest.status === "usable" && object(manifest.value)
48
+ ? manifest.value
49
+ : null;
50
+ const claim = report.inputs.claim ?? null;
51
+ const declaredName =
52
+ typeof declared?.name === "string" ? declared.name : null;
53
+ const declaredVersion =
54
+ typeof declared?.version === "string" ? declared.version : null;
55
+ const status =
56
+ claim && declared
57
+ ? claim.name === declaredName && claim.version === declaredVersion
58
+ ? "claim-matches-manifest"
59
+ : "claim-conflicts-with-manifest"
60
+ : claim
61
+ ? "claimed-manifest-unavailable"
62
+ : declared
63
+ ? "manifest-declared-unclaimed"
64
+ : "unknown";
65
+ // Only bind a purl when the artifact's own manifest agrees with (or stands in for) the claim.
66
+ const bound = [
67
+ "claim-matches-manifest",
68
+ "manifest-declared-unclaimed",
69
+ ].includes(status);
70
+ const name =
71
+ bound || status === "claim-conflicts-with-manifest" ? declaredName : null;
72
+ const version =
73
+ bound || status === "claim-conflicts-with-manifest"
74
+ ? declaredVersion
75
+ : null;
76
+ return {
77
+ status,
78
+ name,
79
+ version,
80
+ purl: bound ? npmPurl(name, version) : null,
81
+ requested: claim ? { name: claim.name, version: claim.version } : null,
82
+ declared: declared
83
+ ? { name: declaredName, version: declaredVersion }
84
+ : null,
85
+ manifestStatus: manifest.status,
86
+ declaredManifest: declared,
87
+ };
88
+ }
89
+
90
+ export function integrityStatus(artifact) {
91
+ const expected = artifact.expectedIntegrity;
92
+ if (!expected) return "not-recorded";
93
+ if (expected.status === "unavailable") return "unavailable";
94
+ return expected.digest === artifact[expected.algorithm]
95
+ ? "matches"
96
+ : "mismatch";
97
+ }
98
+
99
+ function bundledObservations(report) {
100
+ const found = new Map();
101
+ for (const file of report.files) {
102
+ if (typeof file.path !== "string") continue;
103
+ const segments = file.path.split("/");
104
+ for (let i = 0; i < segments.length - 1; i++) {
105
+ if (segments[i] !== "node_modules") continue;
106
+ const scoped = segments[i + 1].startsWith("@");
107
+ const end = i + (scoped ? 3 : 2);
108
+ if (end >= segments.length) continue;
109
+ const name = segments.slice(i + 1, end).join("/");
110
+ const prefix = segments.slice(0, end).join("/");
111
+ const item = found.get(prefix) ?? {
112
+ prefix,
113
+ name,
114
+ within: i === 0 ? null : segments.slice(0, i).join("/"),
115
+ files: 0,
116
+ bytes: 0,
117
+ };
118
+ item.files++;
119
+ item.bytes += file.size;
120
+ found.set(prefix, item);
121
+ }
122
+ }
123
+ return [...found.values()].sort((a, b) => order(a.prefix, b.prefix));
124
+ }
125
+
126
+ export function buildCycloneDxSbom(report, { toolVersion = null } = {}) {
127
+ validateReport(report);
128
+ const identity = reportIdentity(report);
129
+ const declared = identity.declaredManifest;
130
+ const artifact = report.inputs.artifact;
131
+ const components = [];
132
+ const dependsOn = [];
133
+ const fieldStatus = [];
134
+ let invalidEntries = 0;
135
+ let omitted = 0;
136
+ function add(component, runtime) {
137
+ if (components.length >= SBOM_LIMITS.maxComponents) {
138
+ omitted++;
139
+ return;
140
+ }
141
+ components.push(component);
142
+ if (runtime) dependsOn.push(component["bom-ref"]);
143
+ }
144
+ for (const [field, scope] of DECLARED_FIELDS) {
145
+ const value = declared?.[field];
146
+ if (declared === null) {
147
+ fieldStatus.push(property(`declared:${field}`, "manifest-unavailable"));
148
+ continue;
149
+ }
150
+ if (value === undefined) {
151
+ fieldStatus.push(property(`declared:${field}`, "not-declared"));
152
+ continue;
153
+ }
154
+ if (!object(value)) {
155
+ fieldStatus.push(property(`declared:${field}`, "invalid-type"));
156
+ continue;
157
+ }
158
+ fieldStatus.push(property(`declared:${field}`, "declared"));
159
+ for (const name of Object.keys(value).sort(order)) {
160
+ const range = value[name];
161
+ if (
162
+ !npmName(name) ||
163
+ typeof range !== "string" ||
164
+ range.length > SBOM_LIMITS.maxRangeLength
165
+ ) {
166
+ invalidEntries++;
167
+ continue;
168
+ }
169
+ add(
170
+ {
171
+ type: "library",
172
+ "bom-ref": `unmask:declared:${field}:${name}`,
173
+ ...splitName(name),
174
+ scope,
175
+ properties: [
176
+ property("resolution", "unresolved"),
177
+ property("evidence", "publisher-declared-manifest-entry"),
178
+ property("declared-field", field),
179
+ property("declared-range", range),
180
+ ],
181
+ },
182
+ scope !== "excluded",
183
+ );
184
+ }
185
+ }
186
+ const bundledDeclared = new Set(
187
+ [declared?.bundledDependencies, declared?.bundleDependencies]
188
+ .filter(Array.isArray)
189
+ .flat()
190
+ .filter((item) => typeof item === "string"),
191
+ );
192
+ for (const item of bundledObservations(report)) {
193
+ if (!npmName(item.name)) {
194
+ invalidEntries++;
195
+ continue;
196
+ }
197
+ add(
198
+ {
199
+ type: "library",
200
+ "bom-ref": `unmask:bundled:${item.prefix}`,
201
+ ...splitName(item.name),
202
+ scope: "required",
203
+ properties: [
204
+ property("resolution", "unresolved"),
205
+ property("evidence", "observed-bundled-path-in-artifact"),
206
+ property("bundled-path", item.prefix),
207
+ property(
208
+ "bundled-location",
209
+ item.within === null ? "artifact-root-node-modules" : "nested",
210
+ ),
211
+ ...(item.within === null
212
+ ? []
213
+ : [property("bundled-within", item.within)]),
214
+ property("bundled-files", item.files),
215
+ property("bundled-bytes", item.bytes),
216
+ property("bundled-declared", bundledDeclared.has(item.name)),
217
+ ],
218
+ },
219
+ item.within === null,
220
+ );
221
+ }
222
+ const root = {
223
+ type: "library",
224
+ "bom-ref": ROOT_REF,
225
+ ...(identity.name && npmName(identity.name)
226
+ ? splitName(identity.name)
227
+ : { name: "unidentified-npm-artifact" }),
228
+ ...(identity.version ? { version: identity.version } : {}),
229
+ ...(identity.purl ? { purl: identity.purl } : {}),
230
+ hashes: [
231
+ { alg: "SHA-256", content: artifact.sha256 },
232
+ { alg: "SHA-512", content: artifact.sha512 },
233
+ ],
234
+ properties: [
235
+ property("identity-status", identity.status),
236
+ property("manifest-status", identity.manifestStatus),
237
+ property("expected-integrity", integrityStatus(artifact)),
238
+ ...(identity.requested
239
+ ? [
240
+ property(
241
+ "requested-identity",
242
+ `${identity.requested.name}@${identity.requested.version}`,
243
+ ),
244
+ ]
245
+ : []),
246
+ ...(typeof identity.declared?.name === "string"
247
+ ? [
248
+ property(
249
+ "declared-identity",
250
+ `${identity.declared.name}@${identity.declared.version}`,
251
+ ),
252
+ ]
253
+ : []),
254
+ property("source-status", report.source.status),
255
+ property("source-method", report.source.method),
256
+ ...(report.source.commit
257
+ ? [property("source-commit-recorded", report.source.commit)]
258
+ : []),
259
+ ],
260
+ };
261
+ const unresolved = components.length;
262
+ return {
263
+ $schema: "http://cyclonedx.org/schema/bom-1.6.schema.json",
264
+ bomFormat: "CycloneDX",
265
+ specVersion: "1.6",
266
+ version: 1,
267
+ metadata: {
268
+ tools: {
269
+ components: [
270
+ {
271
+ type: "application",
272
+ name: "unmask",
273
+ ...(toolVersion ? { version: toolVersion } : {}),
274
+ },
275
+ ],
276
+ },
277
+ component: root,
278
+ properties: [
279
+ property("sbom-scope", "observed-artifact-and-declared-direct-entries"),
280
+ property("dependency-resolution", "not-performed"),
281
+ property("transitive-dependencies", "not-assessed"),
282
+ property("unresolved-dependencies", unresolved),
283
+ property("invalid-entries-omitted", invalidEntries),
284
+ property("components-omitted", omitted),
285
+ property("vulnerabilities", "not-included"),
286
+ property("signature", "unsigned"),
287
+ property("inventory-complete", report.coverage.inventory.complete),
288
+ property("report-schema", report.schemaVersion),
289
+ property("analyzer-version", report.analyzerVersion),
290
+ ...fieldStatus,
291
+ ],
292
+ },
293
+ components,
294
+ dependencies: [{ ref: ROOT_REF, dependsOn }],
295
+ compositions: [
296
+ {
297
+ aggregate: declared === null ? "unknown" : "incomplete",
298
+ dependencies: [ROOT_REF],
299
+ },
300
+ ...(components.length
301
+ ? [
302
+ {
303
+ aggregate: "unknown",
304
+ dependencies: components.map((item) => item["bom-ref"]),
305
+ },
306
+ ]
307
+ : []),
308
+ ],
309
+ };
310
+ }
311
+
312
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
313
+
314
+ function limitations(report, identity) {
315
+ const coverage = report.coverage;
316
+ return [
317
+ "Unsigned manifest: digests support integrity comparison only and do not prove authorship or origin.",
318
+ "Static bounded analysis; package code, install scripts and builds were not executed.",
319
+ "Dependency versions and transitive dependencies were not resolved.",
320
+ "Vulnerability advisories are not included; consult the report and details exports.",
321
+ "Absence of a current finding does not establish that the package is safe.",
322
+ ...(coverage.inventory.complete
323
+ ? []
324
+ : ["Artifact inventory is incomplete; counts are lower bounds."]),
325
+ ...(coverage.sourceAvailable
326
+ ? coverage.sourceComplete
327
+ ? []
328
+ : ["Source archive was incomplete; comparison is partial."]
329
+ : ["No source archive was compared with the published artifact."]),
330
+ ...(["provenance-verified"].includes(report.source.method)
331
+ ? []
332
+ : [
333
+ `Source selection method is ${report.source.method}; the recorded commit is not cryptographically verified by this manifest.`,
334
+ ]),
335
+ ...(report.failures.length
336
+ ? [
337
+ `Analysis recorded ${report.failures.length} deterministic failure(s).`,
338
+ ]
339
+ : []),
340
+ ...(report.model.status === "complete"
341
+ ? []
342
+ : [
343
+ `AI review status is ${report.model.status}; no AI conclusion is included.`,
344
+ ]),
345
+ ...(identity.status === "claim-conflicts-with-manifest"
346
+ ? ["Requested identity conflicts with the artifact manifest."]
347
+ : []),
348
+ ];
349
+ }
350
+
351
+ export function buildEvidenceManifest(
352
+ report,
353
+ { reportId = null, completedAt = null, versionInfo = null } = {},
354
+ ) {
355
+ validateReport(report);
356
+ const identity = reportIdentity(report);
357
+ const { declaredManifest, ...packageIdentity } = identity;
358
+ const artifact = report.inputs.artifact;
359
+ const expected = artifact.expectedIntegrity;
360
+ const inventoryLines = report.files
361
+ .map((file) => [file.originalPath, file.artifactSha256, file.size])
362
+ .sort((a, b) => order(a[0], b[0]));
363
+ const ruleCounts = new Map();
364
+ for (const finding of report.policy.findings)
365
+ ruleCounts.set(finding.ruleId, (ruleCounts.get(finding.ruleId) ?? 0) + 1);
366
+ const sbom = buildCycloneDxSbom(report, {
367
+ toolVersion: versionInfo?.version ?? null,
368
+ });
369
+ const path = reportId ? `/r/${reportId}` : null;
370
+ return {
371
+ manifestVersion: "1",
372
+ kind: "unmask-evidence-manifest",
373
+ signature: {
374
+ status: "unsigned",
375
+ note: "No cryptographic signature or attestation is attached to this manifest.",
376
+ },
377
+ report: {
378
+ id: reportId,
379
+ completedAt:
380
+ typeof completedAt === "number"
381
+ ? new Date(completedAt).toISOString()
382
+ : null,
383
+ schemaVersion: report.schemaVersion,
384
+ servedJsonSha256: sha256(JSON.stringify(report)),
385
+ canonicalSha256: sha256(canonicalReport(report)),
386
+ canonicalization:
387
+ "Sorted-key JSON of the report excluding run and model, newline terminated.",
388
+ },
389
+ analyzer: {
390
+ version: report.analyzerVersion,
391
+ policyVersion: report.policyVersion,
392
+ limitsVersion: report.limitsVersion,
393
+ limitsDigest: report.inputs.limitsDigest ?? null,
394
+ service: versionInfo
395
+ ? {
396
+ version: versionInfo.version,
397
+ commit: versionInfo.commit,
398
+ tarballSha256: versionInfo.tarballSha256,
399
+ }
400
+ : null,
401
+ },
402
+ package: packageIdentity,
403
+ artifacts: [
404
+ {
405
+ role: "npm-tarball",
406
+ sha256: artifact.sha256,
407
+ sha512: artifact.sha512,
408
+ expectedIntegrity: {
409
+ status: integrityStatus(artifact),
410
+ ...(expected?.algorithm
411
+ ? { algorithm: expected.algorithm, digest: expected.digest }
412
+ : {}),
413
+ },
414
+ provenance: artifact.provenance ?? { status: "not-recorded" },
415
+ },
416
+ ...["sourceArchive", "registryDocument"]
417
+ .filter((name) => report.inputs[name] !== undefined)
418
+ .map((name) => ({
419
+ role:
420
+ name === "sourceArchive" ? "source-archive" : "registry-document",
421
+ sha256: report.inputs[name].sha256,
422
+ kind: report.inputs[name].kind ?? null,
423
+ })),
424
+ ],
425
+ inventory: {
426
+ files: report.coverage.inventory.files,
427
+ bytes: report.coverage.inventory.bytes,
428
+ complete: report.coverage.inventory.complete,
429
+ digestSha256: sha256(JSON.stringify(inventoryLines)),
430
+ digestInput:
431
+ "JSON array of [originalPath, artifactSha256, size] sorted by originalPath.",
432
+ },
433
+ source: {
434
+ status: report.source.status,
435
+ method: report.source.method,
436
+ commit: report.source.commit ?? null,
437
+ complete: report.source.complete,
438
+ alternatives: report.source.alternatives.length,
439
+ label: "Recorded source selection; method states how it was chosen.",
440
+ },
441
+ assessment: {
442
+ policyExitCode: report.policy.exitCode,
443
+ findings: [...ruleCounts]
444
+ .sort((a, b) => order(a[0], b[0]))
445
+ .map(([ruleId, count]) => ({ ruleId, count })),
446
+ failures: report.failures.map((failure) => failure.code),
447
+ coverage: {
448
+ complete: report.coverage.complete,
449
+ sourceAvailable: report.coverage.sourceAvailable,
450
+ sourceComplete: report.coverage.sourceComplete,
451
+ states: Object.fromEntries(
452
+ Object.entries(report.coverage.states).map(([key, value]) => [
453
+ key,
454
+ { files: value.files, bytes: value.bytes },
455
+ ]),
456
+ ),
457
+ },
458
+ aiStatus: report.model.status,
459
+ safetyVerdict: "not-provided",
460
+ limitations: limitations(report, identity),
461
+ },
462
+ exports: {
463
+ report: path && `${path}.json`,
464
+ details: path && `${path}.details.json`,
465
+ sbom: path && `${path}.cdx.json`,
466
+ sbomServedSha256: sha256(JSON.stringify(sbom)),
467
+ },
468
+ };
469
+ }