@xagents_in/unmask 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +128 -2
- package/dist/cli/hosted.mjs +251 -0
- package/dist/cli/main.mjs +210 -0
- package/dist/core/acquire.mjs +283 -0
- package/dist/core/archive.mjs +485 -0
- package/dist/core/compare.mjs +230 -0
- package/dist/core/manifests.mjs +548 -0
- package/dist/core/online.mjs +310 -0
- package/dist/core/pipeline.mjs +345 -0
- package/dist/core/policy.mjs +92 -0
- package/dist/core/report.mjs +506 -0
- package/dist/core/resolve.mjs +186 -0
- package/dist/core/source.mjs +106 -0
- package/dist/core/syntax.mjs +340 -0
- package/dist/model/client.mjs +1257 -0
- package/dist/model/config.mjs +90 -0
- package/dist/server/app.mjs +299 -0
- package/dist/server/main.mjs +146 -0
- package/dist/server/queue.mjs +177 -0
- package/dist/server/runner.mjs +280 -0
- package/dist/server/store.mjs +105 -0
- package/dist/server/worker.mjs +57 -0
- package/dist/version.json +1 -0
- package/dist/viewer/render.mjs +255 -0
- package/dist/viewer/shell.mjs +73 -0
- package/package.json +39 -4
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getRegistryRelease,
|
|
5
|
+
fetchBlob,
|
|
6
|
+
parsePackageSpec,
|
|
7
|
+
AcquisitionError,
|
|
8
|
+
} from "./acquire.mjs";
|
|
9
|
+
import { resolveSource } from "./resolve.mjs";
|
|
10
|
+
import {
|
|
11
|
+
analyzeLocal,
|
|
12
|
+
LocalInputError,
|
|
13
|
+
effectiveLimits,
|
|
14
|
+
preserveInputBytes,
|
|
15
|
+
} from "./pipeline.mjs";
|
|
16
|
+
import { createReport, validateReport } from "./report.mjs";
|
|
17
|
+
import { addManifestObservations } from "./manifests.mjs";
|
|
18
|
+
|
|
19
|
+
function integrity(raw, actual) {
|
|
20
|
+
const values = [];
|
|
21
|
+
let malformed = raw !== null && raw !== undefined && typeof raw !== "string";
|
|
22
|
+
const recognized = { sha1: 20, sha256: 32, sha384: 48, sha512: 64 };
|
|
23
|
+
const unsupported = new Set();
|
|
24
|
+
for (const token of typeof raw === "string"
|
|
25
|
+
? raw.trim().split(/\s+/).filter(Boolean)
|
|
26
|
+
: []) {
|
|
27
|
+
const expression = token.split("?")[0];
|
|
28
|
+
const dash = expression.indexOf("-");
|
|
29
|
+
const algorithm = dash === -1 ? expression : expression.slice(0, dash);
|
|
30
|
+
const encoded = dash === -1 ? "" : expression.slice(dash + 1);
|
|
31
|
+
if (!Object.hasOwn(recognized, algorithm)) {
|
|
32
|
+
unsupported.add(algorithm);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
|
|
36
|
+
const bytes = Buffer.from(normalized, "base64");
|
|
37
|
+
const canonical = bytes.toString("base64");
|
|
38
|
+
if (
|
|
39
|
+
!/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded) ||
|
|
40
|
+
bytes.length !== recognized[algorithm] ||
|
|
41
|
+
canonical.replace(/=+$/, "") !== normalized.replace(/=+$/, "") ||
|
|
42
|
+
(encoded.includes("=") && canonical !== normalized)
|
|
43
|
+
) {
|
|
44
|
+
malformed = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (algorithm === "sha256" || algorithm === "sha512")
|
|
48
|
+
values.push({ algorithm, digest: bytes.toString("hex") });
|
|
49
|
+
else unsupported.add(algorithm);
|
|
50
|
+
}
|
|
51
|
+
const algorithm = values.some((value) => value.algorithm === "sha512")
|
|
52
|
+
? "sha512"
|
|
53
|
+
: "sha256";
|
|
54
|
+
const candidates = values
|
|
55
|
+
.filter((value) => value.algorithm === algorithm)
|
|
56
|
+
.sort((a, b) => (a.digest < b.digest ? -1 : a.digest > b.digest ? 1 : 0));
|
|
57
|
+
const selected =
|
|
58
|
+
candidates.find((value) => value.digest === actual[algorithm]) ||
|
|
59
|
+
candidates[0];
|
|
60
|
+
const expected = selected
|
|
61
|
+
? {
|
|
62
|
+
...selected,
|
|
63
|
+
status: selected.digest === actual[algorithm] ? "match" : "mismatch",
|
|
64
|
+
...(unsupported.size
|
|
65
|
+
? { unsupportedAlgorithms: [...unsupported].sort() }
|
|
66
|
+
: {}),
|
|
67
|
+
}
|
|
68
|
+
: {
|
|
69
|
+
status: "unavailable",
|
|
70
|
+
reason: unsupported.size
|
|
71
|
+
? "unsupported-algorithm"
|
|
72
|
+
: "missing-or-invalid",
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
expected,
|
|
76
|
+
failures: malformed
|
|
77
|
+
? [
|
|
78
|
+
{
|
|
79
|
+
code: "invalid-registry-integrity",
|
|
80
|
+
message: "Recognized registry integrity metadata is malformed",
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function rebuild(report, updates) {
|
|
87
|
+
const next = createReport({
|
|
88
|
+
inputs: updates.inputs || report.inputs,
|
|
89
|
+
source: updates.source || report.source,
|
|
90
|
+
comparison: {
|
|
91
|
+
files: report.files,
|
|
92
|
+
coverage: report.coverage,
|
|
93
|
+
evidence: report.evidence,
|
|
94
|
+
},
|
|
95
|
+
manifests: updates.manifests || report.manifests,
|
|
96
|
+
failures: updates.failures || report.failures,
|
|
97
|
+
run: updates.run || report.run,
|
|
98
|
+
analyzerVersion: report.analyzerVersion,
|
|
99
|
+
limitsVersion: report.limitsVersion,
|
|
100
|
+
});
|
|
101
|
+
next.cues = report.cues;
|
|
102
|
+
next.annotations = report.annotations;
|
|
103
|
+
next.model = report.model;
|
|
104
|
+
return validateReport(next);
|
|
105
|
+
}
|
|
106
|
+
export async function addConfiguredModel(report, model) {
|
|
107
|
+
if (model === undefined) return report;
|
|
108
|
+
if (typeof model !== "function")
|
|
109
|
+
return validateReport({
|
|
110
|
+
...report,
|
|
111
|
+
model: { status: "unavailable", reason: "not-configured" },
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const section = await model(structuredClone(report));
|
|
115
|
+
return validateReport({ ...report, model: section });
|
|
116
|
+
} catch {
|
|
117
|
+
return validateReport({
|
|
118
|
+
...report,
|
|
119
|
+
model: { status: "unavailable", reason: "model-request-failed" },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function analyzeRegistry(
|
|
125
|
+
spec,
|
|
126
|
+
{
|
|
127
|
+
workDir,
|
|
128
|
+
fetchImpl,
|
|
129
|
+
signal,
|
|
130
|
+
limits: overrides,
|
|
131
|
+
model,
|
|
132
|
+
persistCache = true,
|
|
133
|
+
} = {},
|
|
134
|
+
) {
|
|
135
|
+
let identity;
|
|
136
|
+
try {
|
|
137
|
+
identity = parsePackageSpec(spec);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof AcquisitionError)
|
|
140
|
+
throw new LocalInputError(error.code, error.message);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
if (typeof workDir !== "string" || !workDir)
|
|
144
|
+
throw new LocalInputError(
|
|
145
|
+
"work-dir-required",
|
|
146
|
+
"Registry analysis requires an explicit private work directory",
|
|
147
|
+
);
|
|
148
|
+
if (typeof persistCache !== "boolean")
|
|
149
|
+
throw new LocalInputError(
|
|
150
|
+
"invalid-cache-policy",
|
|
151
|
+
"persistCache must be an explicit boolean",
|
|
152
|
+
);
|
|
153
|
+
const limits = effectiveLimits(overrides);
|
|
154
|
+
await mkdir(workDir, { recursive: true, mode: 0o700 });
|
|
155
|
+
const jobDir = await mkdtemp(path.join(workDir, "job-"));
|
|
156
|
+
const observations = [];
|
|
157
|
+
try {
|
|
158
|
+
let release, artifact;
|
|
159
|
+
try {
|
|
160
|
+
release = await getRegistryRelease(identity, { fetchImpl, signal });
|
|
161
|
+
observations.push(release.observation);
|
|
162
|
+
await writeFile(
|
|
163
|
+
path.join(jobDir, "registry.json"),
|
|
164
|
+
release.metadataBytes,
|
|
165
|
+
{ flag: "wx", mode: 0o600 },
|
|
166
|
+
);
|
|
167
|
+
if (persistCache)
|
|
168
|
+
await preserveInputBytes(
|
|
169
|
+
workDir,
|
|
170
|
+
{ digest: release.metadataDigest },
|
|
171
|
+
release.metadataBytes,
|
|
172
|
+
observations,
|
|
173
|
+
);
|
|
174
|
+
artifact = await fetchBlob(release.tarballUrl, {
|
|
175
|
+
fetchImpl,
|
|
176
|
+
signal,
|
|
177
|
+
maxBytes: limits.compressedBytes,
|
|
178
|
+
});
|
|
179
|
+
observations.push(artifact.observation);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof AcquisitionError)
|
|
182
|
+
throw new LocalInputError(error.code, error.message);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
const tarballPath = path.join(jobDir, "artifact.tgz");
|
|
186
|
+
await writeFile(tarballPath, artifact.bytes, { flag: "wx", mode: 0o600 });
|
|
187
|
+
const resolution = resolveSource({
|
|
188
|
+
claim: identity,
|
|
189
|
+
registryDoc: release.metadata,
|
|
190
|
+
});
|
|
191
|
+
const candidate = resolution.candidate;
|
|
192
|
+
const sourceOptions = {};
|
|
193
|
+
const failures = [];
|
|
194
|
+
let sourceFetchFailed = false;
|
|
195
|
+
if (candidate) {
|
|
196
|
+
try {
|
|
197
|
+
const source = await fetchBlob(candidate.archiveUrl, {
|
|
198
|
+
fetchImpl,
|
|
199
|
+
signal,
|
|
200
|
+
maxBytes: limits.compressedBytes,
|
|
201
|
+
});
|
|
202
|
+
observations.push(source.observation);
|
|
203
|
+
sourceOptions.sourceArchivePath = path.join(jobDir, "source.tgz");
|
|
204
|
+
sourceOptions.commit = candidate.commit;
|
|
205
|
+
sourceOptions.sourceRoot = candidate.sourceRoot;
|
|
206
|
+
if (
|
|
207
|
+
typeof release.metadata.repository === "object" &&
|
|
208
|
+
release.metadata.repository !== null &&
|
|
209
|
+
Object.hasOwn(release.metadata.repository, "directory")
|
|
210
|
+
)
|
|
211
|
+
sourceOptions.sourceDir = candidate.directory;
|
|
212
|
+
await writeFile(sourceOptions.sourceArchivePath, source.bytes, {
|
|
213
|
+
flag: "wx",
|
|
214
|
+
mode: 0o600,
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (!(error instanceof AcquisitionError)) throw error;
|
|
218
|
+
if (error.observation) observations.push(error.observation);
|
|
219
|
+
sourceFetchFailed = true;
|
|
220
|
+
failures.push({
|
|
221
|
+
code: "source-acquisition-failed",
|
|
222
|
+
message: "Selected publisher source archive could not be acquired",
|
|
223
|
+
reason: error.code,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const report = await analyzeLocal({
|
|
228
|
+
tarballPath,
|
|
229
|
+
...sourceOptions,
|
|
230
|
+
claim: identity,
|
|
231
|
+
limits: overrides,
|
|
232
|
+
cacheDir: persistCache ? workDir : undefined,
|
|
233
|
+
});
|
|
234
|
+
const checked = integrity(
|
|
235
|
+
release.expectedIntegrity,
|
|
236
|
+
report.inputs.artifact,
|
|
237
|
+
);
|
|
238
|
+
if (
|
|
239
|
+
release.metadata.dist?.integrity !== undefined &&
|
|
240
|
+
typeof release.metadata.dist.integrity !== "string"
|
|
241
|
+
)
|
|
242
|
+
checked.failures.push({
|
|
243
|
+
code: "invalid-registry-integrity",
|
|
244
|
+
message: "Registry integrity must be a string",
|
|
245
|
+
});
|
|
246
|
+
const source = candidate
|
|
247
|
+
? {
|
|
248
|
+
...report.source,
|
|
249
|
+
method: candidate.method,
|
|
250
|
+
repository: candidate.repository,
|
|
251
|
+
commit: candidate.commit,
|
|
252
|
+
alternatives: resolution.alternatives,
|
|
253
|
+
...(sourceFetchFailed
|
|
254
|
+
? {
|
|
255
|
+
status: "unavailable",
|
|
256
|
+
complete: false,
|
|
257
|
+
reason: "source-acquisition-failed",
|
|
258
|
+
}
|
|
259
|
+
: {}),
|
|
260
|
+
}
|
|
261
|
+
: {
|
|
262
|
+
...report.source,
|
|
263
|
+
method: resolution.method,
|
|
264
|
+
status: resolution.status,
|
|
265
|
+
alternatives: resolution.alternatives,
|
|
266
|
+
reason: resolution.reason || "no-source-candidate",
|
|
267
|
+
};
|
|
268
|
+
const inputs = {
|
|
269
|
+
...report.inputs,
|
|
270
|
+
artifact: {
|
|
271
|
+
...report.inputs.artifact,
|
|
272
|
+
kind: "registry",
|
|
273
|
+
expectedIntegrity: checked.expected,
|
|
274
|
+
provenance: { status: "not-assessed" },
|
|
275
|
+
},
|
|
276
|
+
registryDocument: { ...release.metadataDigest, kind: "registry" },
|
|
277
|
+
limits: {
|
|
278
|
+
...report.inputs.limits,
|
|
279
|
+
acquisition: {
|
|
280
|
+
metadataBytes: 1048576,
|
|
281
|
+
timeoutMs: 30000,
|
|
282
|
+
redirects: "forbidden",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
...(report.inputs.sourceArchive
|
|
286
|
+
? { sourceArchive: { ...report.inputs.sourceArchive, kind: "remote" } }
|
|
287
|
+
: {}),
|
|
288
|
+
};
|
|
289
|
+
const next = rebuild(report, {
|
|
290
|
+
inputs,
|
|
291
|
+
source,
|
|
292
|
+
manifests: {
|
|
293
|
+
...report.manifests,
|
|
294
|
+
registry: {
|
|
295
|
+
status: "usable",
|
|
296
|
+
value: release.metadata,
|
|
297
|
+
evidenceIds: [],
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
failures: [...report.failures, ...failures, ...checked.failures],
|
|
301
|
+
run: {
|
|
302
|
+
...report.run,
|
|
303
|
+
observations: [...report.run.observations, ...observations],
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
return await addConfiguredModel(addManifestObservations(next), model);
|
|
307
|
+
} finally {
|
|
308
|
+
await rm(jobDir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readArchive, DEFAULT_LIMITS } from "./archive.mjs";
|
|
5
|
+
import { selectSourcePackage } from "./source.mjs";
|
|
6
|
+
import { compareInventories } from "./compare.mjs";
|
|
7
|
+
import { createReport } from "./report.mjs";
|
|
8
|
+
import { inspectJavaScript, addSyntaxInspection } from "./syntax.mjs";
|
|
9
|
+
import { addManifestObservations } from "./manifests.mjs";
|
|
10
|
+
|
|
11
|
+
export class LocalInputError extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "LocalInputError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.exitCode = 2;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function limitsFor(overrides = {}) {
|
|
20
|
+
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides))
|
|
21
|
+
throw new LocalInputError("invalid-limits", "Limits must be an object");
|
|
22
|
+
const defaults = { ...DEFAULT_LIMITS, retentionBudgetBytes: 134217728 };
|
|
23
|
+
for (const [key, value] of Object.entries(overrides))
|
|
24
|
+
if (
|
|
25
|
+
!(key in defaults) ||
|
|
26
|
+
!Number.isSafeInteger(value) ||
|
|
27
|
+
value < 0 ||
|
|
28
|
+
value > defaults[key]
|
|
29
|
+
)
|
|
30
|
+
throw new LocalInputError(
|
|
31
|
+
"invalid-limits",
|
|
32
|
+
"Local limits may only lower finite defaults",
|
|
33
|
+
);
|
|
34
|
+
return { ...defaults, ...overrides };
|
|
35
|
+
}
|
|
36
|
+
function safeDirectory(value, allowEmpty = false) {
|
|
37
|
+
return (
|
|
38
|
+
typeof value === "string" &&
|
|
39
|
+
((allowEmpty && value === "") ||
|
|
40
|
+
(value.length > 0 &&
|
|
41
|
+
!/^[a-z]:/i.test(value) &&
|
|
42
|
+
!/[\0\\]/.test(value) &&
|
|
43
|
+
!value.startsWith("/") &&
|
|
44
|
+
value
|
|
45
|
+
.split("/")
|
|
46
|
+
.every((part) => part && part !== "." && part !== "..")))
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
function validateOptions(options) {
|
|
50
|
+
if (typeof options.tarballPath !== "string" || !options.tarballPath)
|
|
51
|
+
throw new LocalInputError(
|
|
52
|
+
"artifact-required",
|
|
53
|
+
"A local artifact path is required",
|
|
54
|
+
);
|
|
55
|
+
if (options.sourceArchivePath !== undefined) {
|
|
56
|
+
if (
|
|
57
|
+
typeof options.sourceArchivePath !== "string" ||
|
|
58
|
+
!options.sourceArchivePath ||
|
|
59
|
+
!/^[a-f0-9]{40}$/.test(options.commit || "") ||
|
|
60
|
+
!safeDirectory(options.sourceRoot)
|
|
61
|
+
)
|
|
62
|
+
throw new LocalInputError(
|
|
63
|
+
"invalid-source-claim",
|
|
64
|
+
"Selected source requires a local archive, full lowercase commit SHA and explicit safe root",
|
|
65
|
+
);
|
|
66
|
+
} else if (
|
|
67
|
+
options.commit !== undefined ||
|
|
68
|
+
options.sourceRoot !== undefined ||
|
|
69
|
+
options.sourceDir !== undefined
|
|
70
|
+
)
|
|
71
|
+
throw new LocalInputError(
|
|
72
|
+
"source-required",
|
|
73
|
+
"Source options require an explicit local source archive",
|
|
74
|
+
);
|
|
75
|
+
if (
|
|
76
|
+
options.sourceDir !== undefined &&
|
|
77
|
+
!safeDirectory(options.sourceDir, true)
|
|
78
|
+
)
|
|
79
|
+
throw new LocalInputError(
|
|
80
|
+
"invalid-source-directory",
|
|
81
|
+
"Source directory must be a safe relative path",
|
|
82
|
+
);
|
|
83
|
+
if (
|
|
84
|
+
options.claim !== undefined &&
|
|
85
|
+
(!options.claim ||
|
|
86
|
+
typeof options.claim !== "object" ||
|
|
87
|
+
typeof options.claim.name !== "string" ||
|
|
88
|
+
typeof options.claim.version !== "string")
|
|
89
|
+
)
|
|
90
|
+
throw new LocalInputError(
|
|
91
|
+
"invalid-claim",
|
|
92
|
+
"Identity claim requires an exact name and version",
|
|
93
|
+
);
|
|
94
|
+
if (
|
|
95
|
+
options.cacheDir !== undefined &&
|
|
96
|
+
(typeof options.cacheDir !== "string" || !options.cacheDir)
|
|
97
|
+
)
|
|
98
|
+
throw new LocalInputError(
|
|
99
|
+
"invalid-cache",
|
|
100
|
+
"Cache directory must be an explicit path",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
async function boundedRead(filename, cap, side) {
|
|
104
|
+
let handle;
|
|
105
|
+
try {
|
|
106
|
+
handle = await open(filename, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
107
|
+
const before = await handle.stat({ bigint: true });
|
|
108
|
+
if (!before.isFile())
|
|
109
|
+
throw new LocalInputError(
|
|
110
|
+
`${side}-read-failed`,
|
|
111
|
+
"Input must be a regular local file",
|
|
112
|
+
);
|
|
113
|
+
if (before.size > BigInt(cap))
|
|
114
|
+
throw new LocalInputError(
|
|
115
|
+
`${side}-compressed-cap`,
|
|
116
|
+
"Compressed input exceeds the configured byte cap",
|
|
117
|
+
);
|
|
118
|
+
const bytes = Buffer.allocUnsafe(Number(before.size));
|
|
119
|
+
let offset = 0;
|
|
120
|
+
while (offset < bytes.length) {
|
|
121
|
+
const { bytesRead } = await handle.read(
|
|
122
|
+
bytes,
|
|
123
|
+
offset,
|
|
124
|
+
Math.min(bytes.length - offset, 65536),
|
|
125
|
+
offset,
|
|
126
|
+
);
|
|
127
|
+
if (bytesRead === 0)
|
|
128
|
+
throw new LocalInputError(
|
|
129
|
+
`${side}-read-changed`,
|
|
130
|
+
"Local file changed during bounded read",
|
|
131
|
+
);
|
|
132
|
+
offset += bytesRead;
|
|
133
|
+
}
|
|
134
|
+
const extra = await handle.read(Buffer.alloc(1), 0, 1, offset);
|
|
135
|
+
const after = await handle.stat({ bigint: true });
|
|
136
|
+
if (
|
|
137
|
+
extra.bytesRead ||
|
|
138
|
+
before.size !== after.size ||
|
|
139
|
+
before.mtimeNs !== after.mtimeNs ||
|
|
140
|
+
before.ctimeNs !== after.ctimeNs
|
|
141
|
+
)
|
|
142
|
+
throw new LocalInputError(
|
|
143
|
+
`${side}-read-changed`,
|
|
144
|
+
"Local file changed during bounded read",
|
|
145
|
+
);
|
|
146
|
+
return bytes;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof LocalInputError) throw error;
|
|
149
|
+
if (typeof error.code === "string" && /^E[A-Z]+$/.test(error.code))
|
|
150
|
+
throw new LocalInputError(
|
|
151
|
+
`${side}-read-failed`,
|
|
152
|
+
"Unable to read the selected local input",
|
|
153
|
+
);
|
|
154
|
+
throw error;
|
|
155
|
+
} finally {
|
|
156
|
+
if (handle) await handle.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function cacheBytes(cacheDir, inventory, bytes, observations) {
|
|
160
|
+
if (cacheDir === undefined) return;
|
|
161
|
+
const directory = path.join(cacheDir, "blobs", "sha256");
|
|
162
|
+
const destination = path.join(directory, inventory.digest.sha256);
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
165
|
+
try {
|
|
166
|
+
await writeFile(destination, bytes, { flag: "wx", mode: 0o600 });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== "EEXIST") throw error;
|
|
169
|
+
const existing = await boundedRead(destination, bytes.length, "cache");
|
|
170
|
+
if (!existing.equals(bytes)) throw new Error("Cache bytes differ");
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
observations.push({
|
|
174
|
+
code: "cache-write-failed",
|
|
175
|
+
message:
|
|
176
|
+
"Original input was analyzed, but its private cache copy could not be verified",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function manifestFor(inventory, comparison, side) {
|
|
181
|
+
const entry = inventory?.entries.find(
|
|
182
|
+
(item) => item.type === "file" && item.path === "package.json",
|
|
183
|
+
);
|
|
184
|
+
const evidenceIds = comparison.evidence
|
|
185
|
+
.filter((e) => e.side === side && e.path === "package.json")
|
|
186
|
+
.map((e) => e.id);
|
|
187
|
+
if (!entry || !Buffer.isBuffer(entry.content))
|
|
188
|
+
return {
|
|
189
|
+
status: "unavailable",
|
|
190
|
+
reason: entry?.inspection || "missing-or-nonregular",
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
const value = JSON.parse(
|
|
194
|
+
new TextDecoder("utf-8", { fatal: true }).decode(entry.content),
|
|
195
|
+
);
|
|
196
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
197
|
+
return { status: "invalid", reason: "not-an-object" };
|
|
198
|
+
return { status: "usable", value, evidenceIds };
|
|
199
|
+
} catch {
|
|
200
|
+
return { status: "invalid", reason: "invalid-json-or-encoding" };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function artifactName(inventory) {
|
|
204
|
+
const entry = inventory.entries.find(
|
|
205
|
+
(item) => item.type === "file" && item.path === "package.json",
|
|
206
|
+
);
|
|
207
|
+
if (!Buffer.isBuffer(entry?.content)) return undefined;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(
|
|
210
|
+
new TextDecoder("utf-8", { fatal: true }).decode(entry.content),
|
|
211
|
+
)?.name;
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function analyzeLocal(options) {
|
|
218
|
+
validateOptions(options);
|
|
219
|
+
const limits = limitsFor(options.limits);
|
|
220
|
+
const { retentionBudgetBytes, ...archiveLimits } = limits;
|
|
221
|
+
const retentionBudget = { limitBytes: retentionBudgetBytes, usedBytes: 0 };
|
|
222
|
+
const observations = [];
|
|
223
|
+
const failures = [];
|
|
224
|
+
const artifactBytes = await boundedRead(
|
|
225
|
+
options.tarballPath,
|
|
226
|
+
limits.compressedBytes,
|
|
227
|
+
"artifact",
|
|
228
|
+
);
|
|
229
|
+
const artifact = await readArchive(artifactBytes, {
|
|
230
|
+
kind: "artifact",
|
|
231
|
+
limits: archiveLimits,
|
|
232
|
+
retentionBudget,
|
|
233
|
+
});
|
|
234
|
+
failures.push(...artifact.failures.map((f) => ({ ...f, side: "artifact" })));
|
|
235
|
+
await cacheBytes(options.cacheDir, artifact, artifactBytes, observations);
|
|
236
|
+
let source = {
|
|
237
|
+
status: "unresolved",
|
|
238
|
+
method: "unresolved",
|
|
239
|
+
complete: false,
|
|
240
|
+
alternatives: [],
|
|
241
|
+
};
|
|
242
|
+
let sourceArchive = null;
|
|
243
|
+
let selected = null;
|
|
244
|
+
if (options.sourceArchivePath !== undefined) {
|
|
245
|
+
source = {
|
|
246
|
+
status: "unavailable",
|
|
247
|
+
method: "user-full-SHA",
|
|
248
|
+
commit: options.commit,
|
|
249
|
+
complete: false,
|
|
250
|
+
alternatives: [],
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
const sourceBytes = await boundedRead(
|
|
254
|
+
options.sourceArchivePath,
|
|
255
|
+
limits.compressedBytes,
|
|
256
|
+
"source",
|
|
257
|
+
);
|
|
258
|
+
sourceArchive = await readArchive(sourceBytes, {
|
|
259
|
+
kind: "source",
|
|
260
|
+
sourceRoot: options.sourceRoot,
|
|
261
|
+
limits: archiveLimits,
|
|
262
|
+
retentionBudget,
|
|
263
|
+
});
|
|
264
|
+
await cacheBytes(
|
|
265
|
+
options.cacheDir,
|
|
266
|
+
sourceArchive,
|
|
267
|
+
sourceBytes,
|
|
268
|
+
observations,
|
|
269
|
+
);
|
|
270
|
+
failures.push(
|
|
271
|
+
...sourceArchive.failures.map((f) => ({ ...f, side: "source" })),
|
|
272
|
+
);
|
|
273
|
+
const selection = selectSourcePackage(sourceArchive, {
|
|
274
|
+
...(options.sourceDir === undefined
|
|
275
|
+
? {}
|
|
276
|
+
: { directory: options.sourceDir }),
|
|
277
|
+
packageName: artifactName(artifact),
|
|
278
|
+
});
|
|
279
|
+
source.status = selection.status;
|
|
280
|
+
source.reason =
|
|
281
|
+
selection.reason ||
|
|
282
|
+
(sourceArchive.complete
|
|
283
|
+
? "explicit-local-source"
|
|
284
|
+
: "incomplete-source-inventory");
|
|
285
|
+
if (selection.directory !== null) source.directory = selection.directory;
|
|
286
|
+
if (selection.status === "selected") {
|
|
287
|
+
selected = { ...sourceArchive, entries: selection.entries };
|
|
288
|
+
source.complete = sourceArchive.complete;
|
|
289
|
+
} else
|
|
290
|
+
failures.push({
|
|
291
|
+
code: "source-selection-unresolved",
|
|
292
|
+
message:
|
|
293
|
+
"Selected source package could not be resolved unambiguously",
|
|
294
|
+
reason: selection.reason,
|
|
295
|
+
});
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof LocalInputError)) throw error;
|
|
298
|
+
failures.push({
|
|
299
|
+
code: error.code,
|
|
300
|
+
message: error.message,
|
|
301
|
+
side: "source",
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const comparison = compareInventories(artifact, selected);
|
|
306
|
+
const manifests = {
|
|
307
|
+
artifact: manifestFor(artifact, comparison, "artifact"),
|
|
308
|
+
source: manifestFor(selected, comparison, "source"),
|
|
309
|
+
registry: { status: "unavailable", reason: "offline" },
|
|
310
|
+
};
|
|
311
|
+
for (const side of ["artifact", "source"])
|
|
312
|
+
if (manifests[side].status === "invalid")
|
|
313
|
+
failures.push({
|
|
314
|
+
code: `${side}-manifest-invalid`,
|
|
315
|
+
message: "Package manifest is not a valid UTF-8 JSON object",
|
|
316
|
+
side,
|
|
317
|
+
});
|
|
318
|
+
const inputs = {
|
|
319
|
+
artifact: {
|
|
320
|
+
...artifact.digest,
|
|
321
|
+
kind: "local",
|
|
322
|
+
expectedIntegrity: { status: "unavailable" },
|
|
323
|
+
},
|
|
324
|
+
limits,
|
|
325
|
+
...(sourceArchive
|
|
326
|
+
? { sourceArchive: { ...sourceArchive.digest, kind: "local" } }
|
|
327
|
+
: {}),
|
|
328
|
+
...(options.claim ? { claim: options.claim } : {}),
|
|
329
|
+
};
|
|
330
|
+
const report = createReport({
|
|
331
|
+
inputs,
|
|
332
|
+
source,
|
|
333
|
+
comparison,
|
|
334
|
+
manifests,
|
|
335
|
+
failures,
|
|
336
|
+
run: { surface: "cli", observations },
|
|
337
|
+
});
|
|
338
|
+
const inspected = addSyntaxInspection(
|
|
339
|
+
report,
|
|
340
|
+
inspectJavaScript(artifact, selected),
|
|
341
|
+
);
|
|
342
|
+
return addManifestObservations(inspected, { artifact, source: selected });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export { limitsFor as effectiveLimits, cacheBytes as preserveInputBytes };
|