rea-agents 0.3.0 → 0.4.0
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 +124 -20
- package/bridge/hopper_bridge.py +141 -13
- package/dist/application/AnalysisProvider.js +12 -1
- package/dist/application/ArtifactExtraction.js +166 -0
- package/dist/application/ArtifactGraphConstruction.js +257 -0
- package/dist/application/ArtifactInventory.js +253 -0
- package/dist/application/BinarySession.js +164 -14
- package/dist/application/CompositeProvider.js +73 -0
- package/dist/application/DirectAnalysis.js +27 -3
- package/dist/application/Doctor.js +35 -6
- package/dist/application/EnhancedTools.js +8 -7
- package/dist/application/EvidenceBundleCommands.js +29 -0
- package/dist/application/EvidenceBundleFiles.js +127 -0
- package/dist/application/EvidenceLedger.js +225 -18
- package/dist/application/FilesystemSnapshot.js +124 -0
- package/dist/application/LinuxHopper.js +186 -0
- package/dist/application/LoopbackReplay.js +195 -37
- package/dist/application/ProcessHarness.js +200 -191
- package/dist/application/ProcessNormalization.js +44 -0
- package/dist/application/ProcessOwnership.js +84 -0
- package/dist/application/ProcessSampling.js +284 -0
- package/dist/application/RealHopperAssertions.js +105 -0
- package/dist/application/ReferenceSourceImport.js +182 -0
- package/dist/application/ReferenceSourceImportEntries.js +122 -0
- package/dist/application/ReferenceSourceImportPolicy.js +73 -0
- package/dist/application/ReferenceSourceImportTypes.js +18 -0
- package/dist/application/ReferenceSourceVcsAdapter.js +34 -0
- package/dist/application/Setup.js +192 -41
- package/dist/application/Uninstall.js +130 -0
- package/dist/application/runtime.js +8 -1
- package/dist/artifacts/ArtifactPaths.js +51 -0
- package/dist/artifacts/ArtifactProvider.js +130 -0
- package/dist/artifacts/ArtifactReader.js +9 -0
- package/dist/artifacts/AsarArtifactReader.js +62 -0
- package/dist/artifacts/DirectoryArtifactReader.js +107 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +66 -0
- package/dist/artifacts/SafeOutputTree.js +199 -0
- package/dist/artifacts/StreamBytes.js +10 -0
- package/dist/artifacts/ZipArtifactReader.js +109 -0
- package/dist/cli.js +149 -2
- package/dist/config.js +35 -1
- package/dist/contracts/artifactComparisonExample.js +95 -0
- package/dist/contracts/artifactToolContracts.js +84 -0
- package/dist/contracts/enhancedInputs.js +4 -0
- package/dist/contracts/functionComparisonExample.js +57 -0
- package/dist/contracts/investigationExamples.js +118 -0
- package/dist/contracts/nativeToolContracts.js +52 -0
- package/dist/contracts/processCaptureExample.js +25 -0
- package/dist/contracts/toolContractExamples.js +69 -0
- package/dist/contracts/toolContracts.js +99 -36
- package/dist/contracts/toolOutputSchemas.js +154 -82
- package/dist/contracts/unknownContractExamples.js +33 -0
- package/dist/domain/artifactComparison.js +273 -0
- package/dist/domain/artifactGraph.js +194 -0
- package/dist/domain/artifactInventoryEvidence.js +150 -0
- package/dist/domain/binaryTarget.js +55 -0
- package/dist/domain/bundleComparison.js +266 -0
- package/dist/domain/callPath.js +346 -0
- package/dist/domain/changedBehavior.js +294 -0
- package/dist/domain/errors.js +192 -4
- package/dist/domain/evidence.js +41 -10
- package/dist/domain/evidenceBundle.js +187 -6
- package/dist/domain/functionComparison.js +201 -0
- package/dist/domain/functionComparisonNormalization.js +112 -0
- package/dist/domain/functionComparisonResults.js +54 -0
- package/dist/domain/functionComparisonSchemas.js +82 -0
- package/dist/domain/functionDossierEvidence.js +171 -0
- package/dist/domain/hopperValues.js +14 -7
- package/dist/domain/nativeInspection.js +142 -0
- package/dist/domain/processCapture.js +152 -54
- package/dist/domain/processComparison.js +106 -0
- package/dist/domain/reconstructionUnknowns.js +90 -0
- package/dist/domain/reconstructionVerification.js +285 -0
- package/dist/domain/reconstructionVerificationSchemas.js +126 -0
- package/dist/domain/referenceSourceClassification.js +496 -0
- package/dist/domain/referenceSourceGraph.js +376 -0
- package/dist/domain/referenceSourceImportParsing.js +235 -0
- package/dist/domain/referenceSourcePolicy.js +1 -0
- package/dist/domain/residualUnknown.js +239 -0
- package/dist/domain/staticRuntimeCorrelation.js +375 -0
- package/dist/hopper/BridgeLauncher.js +40 -3
- package/dist/hopper/HopperClient.js +14 -5
- package/dist/hopper/HopperProvider.js +57 -22
- package/dist/identity.js +1 -0
- package/dist/main.js +5 -1
- package/dist/native/CommandRunner.js +156 -0
- package/dist/native/NativeMacOSProvider.js +306 -0
- package/dist/native/NativeMachoInspection.js +135 -0
- package/dist/native/parsers/codesign.js +55 -0
- package/dist/native/parsers/demangle.js +26 -0
- package/dist/native/parsers/dyldInfo.js +25 -0
- package/dist/native/parsers/lipo.js +67 -0
- package/dist/native/parsers/otool.js +193 -0
- package/dist/native/parsers/plist.js +23 -0
- package/dist/reference/ReferenceSourceReader.js +73 -0
- package/dist/reference/ReferenceSourceReaderEntries.js +206 -0
- package/dist/reference/ReferenceSourceReaderErrors.js +19 -0
- package/dist/reference/ReferenceSourceReaderFile.js +119 -0
- package/dist/reference/ReferenceSourceReaderPaths.js +23 -0
- package/dist/reference/ReferenceSourceReaderTypes.js +2 -0
- package/dist/reference/ReferenceSourceReaderValidate.js +71 -0
- package/dist/server/createServer.js +31 -5
- package/dist/server/recordDerivedEvidence.js +10 -0
- package/dist/server/registerArtifactComparisonTool.js +62 -0
- package/dist/server/registerArtifactTools.js +6 -0
- package/dist/server/registerBundleComparisonTool.js +47 -0
- package/dist/server/registerEnhancedTools.js +64 -12
- package/dist/server/registerEvidenceTools.js +36 -0
- package/dist/server/registerFunctionComparisonTool.js +68 -0
- package/dist/server/registerInvestigationTools.js +224 -0
- package/dist/server/registerNativeTools.js +6 -0
- package/dist/server/registerOfficialTools.js +47 -14
- package/dist/server/registerProcessComparisonTool.js +106 -0
- package/dist/server/registerSessionTools.js +179 -70
- package/dist/server/sessionEvidence.js +28 -0
- package/dist/server/sessionToolPolicies.js +64 -0
- package/dist/server/toolRegistrationOptions.js +7 -0
- package/dist/server/toolResult.js +8 -5
- package/install.sh +198 -0
- package/package.json +18 -1
- package/scripts/rea.mjs +5 -1
- package/skills/rea-analysis/SKILL.md +77 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { createAnalysisExecution, } from "../application/AnalysisProvider.js";
|
|
2
|
+
import { inventoryArtifact } from "../application/ArtifactInventory.js";
|
|
3
|
+
import { extractArtifact } from "../application/ArtifactExtraction.js";
|
|
4
|
+
import { ARTIFACT_TOOL_CONTRACTS, artifactInventoryInputSchema, artifactExtractionInputSchema, } from "../contracts/artifactToolContracts.js";
|
|
5
|
+
import { AnalysisCapabilityUnavailableError, ArtifactOperationError, } from "../domain/errors.js";
|
|
6
|
+
import { err, ok } from "../domain/result.js";
|
|
7
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
8
|
+
const IDENTITY = Object.freeze({
|
|
9
|
+
id: "rea-artifact-graph",
|
|
10
|
+
name: "REA safe artifact graph provider",
|
|
11
|
+
version: "1",
|
|
12
|
+
});
|
|
13
|
+
/** Read-only inventory and exclusively owned extraction provider. */
|
|
14
|
+
export class ArtifactProvider {
|
|
15
|
+
#capabilities = Object.freeze(ARTIFACT_TOOL_CONTRACTS.map((contract) => Object.freeze({
|
|
16
|
+
provider: IDENTITY,
|
|
17
|
+
operation: contract.name,
|
|
18
|
+
inputContractVersion: 1,
|
|
19
|
+
outputContractVersion: 1,
|
|
20
|
+
available: true,
|
|
21
|
+
reason: null,
|
|
22
|
+
pagination: "offset",
|
|
23
|
+
exhaustive: false,
|
|
24
|
+
effects: Object.freeze({
|
|
25
|
+
mutatesArtifact: false,
|
|
26
|
+
launchesProcess: true,
|
|
27
|
+
mayShowUi: false,
|
|
28
|
+
mayAccessNetwork: false,
|
|
29
|
+
mayWriteFilesystem: contract.name === "extract_artifact",
|
|
30
|
+
changesPermissions: false,
|
|
31
|
+
requiresRoot: false,
|
|
32
|
+
}),
|
|
33
|
+
limits: Object.freeze({
|
|
34
|
+
maxResults: 500,
|
|
35
|
+
maxPayloadBytes: 4 * 1024 * 1024,
|
|
36
|
+
timeoutMs: 120_000,
|
|
37
|
+
}),
|
|
38
|
+
limitations: Object.freeze([
|
|
39
|
+
"DMG and PKG child inventory requires a future native read-only adapter.",
|
|
40
|
+
"Nested containers are recorded but not recursively expanded implicitly.",
|
|
41
|
+
]),
|
|
42
|
+
})));
|
|
43
|
+
identity() {
|
|
44
|
+
return IDENTITY;
|
|
45
|
+
}
|
|
46
|
+
capabilities() {
|
|
47
|
+
return this.#capabilities;
|
|
48
|
+
}
|
|
49
|
+
createClient(target) {
|
|
50
|
+
return new ArtifactClient(target);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
class ArtifactClient {
|
|
54
|
+
target;
|
|
55
|
+
constructor(target) {
|
|
56
|
+
this.target = target;
|
|
57
|
+
}
|
|
58
|
+
async execute(operation, parameters, options) {
|
|
59
|
+
if (operation === "health")
|
|
60
|
+
return ok(createAnalysisExecution(null, IDENTITY));
|
|
61
|
+
if (!isArtifactOperation(operation))
|
|
62
|
+
return err(new AnalysisCapabilityUnavailableError(IDENTITY.id, operation, "Operation is not implemented by artifact graph provider."));
|
|
63
|
+
try {
|
|
64
|
+
if (operation === "extract_artifact") {
|
|
65
|
+
const parsed = artifactExtractionInputSchema.parse(parameters);
|
|
66
|
+
const result = await extractArtifact({
|
|
67
|
+
inputPath: this.target.sourcePath ?? this.target.path,
|
|
68
|
+
inputFormat: this.target.format,
|
|
69
|
+
outputRoot: parsed.output_root,
|
|
70
|
+
occurrenceIds: parsed.occurrence_ids,
|
|
71
|
+
offset: parsed.offset,
|
|
72
|
+
limit: parsed.limit,
|
|
73
|
+
limits: limitsFrom(parsed),
|
|
74
|
+
}, options?.signal);
|
|
75
|
+
return ok(createAnalysisExecution(result, IDENTITY, {
|
|
76
|
+
rawResult: null,
|
|
77
|
+
limitations: result.limitations,
|
|
78
|
+
subject: subjectFor(this.target.sourcePath ?? this.target.path, result.manifest),
|
|
79
|
+
locations: result.artifacts.items.map(({ relative_path: path }) => ({
|
|
80
|
+
kind: "artifact-path",
|
|
81
|
+
path,
|
|
82
|
+
})),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
const parsed = artifactInventoryInputSchema.parse(parameters);
|
|
86
|
+
const result = await inventoryArtifact(this.target.sourcePath ?? this.target.path, limitsFrom(parsed), {
|
|
87
|
+
nodeOffset: parsed.node_offset,
|
|
88
|
+
nodeLimit: parsed.node_limit,
|
|
89
|
+
occurrenceOffset: parsed.occurrence_offset,
|
|
90
|
+
occurrenceLimit: parsed.occurrence_limit,
|
|
91
|
+
edgeOffset: parsed.edge_offset,
|
|
92
|
+
edgeLimit: parsed.edge_limit,
|
|
93
|
+
}, options?.signal);
|
|
94
|
+
return ok(createAnalysisExecution(result, IDENTITY, {
|
|
95
|
+
rawResult: null,
|
|
96
|
+
limitations: result.limitations,
|
|
97
|
+
subject: subjectFor(this.target.sourcePath ?? this.target.path, result.manifest),
|
|
98
|
+
locations: result.occurrences.items.map(({ logical_path: path }) => ({
|
|
99
|
+
kind: "artifact-path",
|
|
100
|
+
path,
|
|
101
|
+
})),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
catch (cause) {
|
|
105
|
+
return err(translateFailure(operation, cause));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
close() {
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const limitsFrom = (input) => ({
|
|
113
|
+
maxEntries: input.max_entries,
|
|
114
|
+
maxTotalBytes: input.max_total_bytes,
|
|
115
|
+
maxEntryBytes: input.max_entry_bytes,
|
|
116
|
+
maxCompressionRatio: input.max_compression_ratio,
|
|
117
|
+
maxDepth: input.max_depth,
|
|
118
|
+
maxPathBytes: input.max_path_bytes,
|
|
119
|
+
});
|
|
120
|
+
const isArtifactOperation = (operation) => ARTIFACT_TOOL_CONTRACTS.some(({ name }) => name === operation);
|
|
121
|
+
const translateFailure = (operation, cause) => {
|
|
122
|
+
if (cause instanceof ArtifactReaderFailure)
|
|
123
|
+
return new ArtifactOperationError(operation, cause.reason);
|
|
124
|
+
return new ArtifactOperationError(operation, "io");
|
|
125
|
+
};
|
|
126
|
+
const subjectFor = (path, manifest) => ({
|
|
127
|
+
path,
|
|
128
|
+
sha256: manifest.root_sha256,
|
|
129
|
+
format: manifest.root_format,
|
|
130
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Typed adapter failure translated at provider boundary. */
|
|
2
|
+
export class ArtifactReaderFailure extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
constructor(reason, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
this.name = "ArtifactReaderFailure";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { extractFile, listPackage, statFile } from "@electron/asar";
|
|
3
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
4
|
+
/** Official Electron ASAR adapter. Individual files remain caller-bounded. */
|
|
5
|
+
export class AsarArtifactReader {
|
|
6
|
+
path;
|
|
7
|
+
format = "asar";
|
|
8
|
+
constructor(path) {
|
|
9
|
+
this.path = path;
|
|
10
|
+
}
|
|
11
|
+
async *entries(signal) {
|
|
12
|
+
const paths = listPackage(this.path, { isPack: false }).sort((left, right) => left.localeCompare(right, "en"));
|
|
13
|
+
for (const listed of paths) {
|
|
14
|
+
abortIfNeeded(signal);
|
|
15
|
+
const path = listed.replace(/^\/+|\/+$/gu, "");
|
|
16
|
+
if (path.length === 0)
|
|
17
|
+
continue;
|
|
18
|
+
const metadata = statFile(this.path, path, false);
|
|
19
|
+
const kind = "files" in metadata
|
|
20
|
+
? "directory"
|
|
21
|
+
: "link" in metadata
|
|
22
|
+
? "symlink"
|
|
23
|
+
: "file";
|
|
24
|
+
yield {
|
|
25
|
+
path,
|
|
26
|
+
kind,
|
|
27
|
+
declaredSize: "size" in metadata ? metadata.size : null,
|
|
28
|
+
compressedSize: null,
|
|
29
|
+
executable: "executable" in metadata && metadata.executable,
|
|
30
|
+
encrypted: false,
|
|
31
|
+
byteOffset: null,
|
|
32
|
+
declaredSha256: "integrity" in metadata &&
|
|
33
|
+
metadata.integrity.algorithm === "SHA256" &&
|
|
34
|
+
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
|
35
|
+
? metadata.integrity.hash
|
|
36
|
+
: null,
|
|
37
|
+
limitations: kind === "symlink"
|
|
38
|
+
? ["ASAR symlink target was not followed or disclosed."]
|
|
39
|
+
: [
|
|
40
|
+
"Official ASAR extraction buffers each individually bounded file.",
|
|
41
|
+
],
|
|
42
|
+
adapterKey: path,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
open(entry, signal) {
|
|
47
|
+
abortIfNeeded(signal);
|
|
48
|
+
if (entry.kind !== "file")
|
|
49
|
+
return Promise.reject(new ArtifactReaderFailure("format", "ASAR entry is not a regular file"));
|
|
50
|
+
return Promise.resolve(Readable.from(extractFile(this.path, entry.adapterKey, false)));
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
return Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
provenance() {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const abortIfNeeded = (signal) => {
|
|
60
|
+
if (signal?.aborted === true)
|
|
61
|
+
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
|
62
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, opendir, realpath } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
5
|
+
/** Deterministic, non-symlink-following directory reader. */
|
|
6
|
+
export class DirectoryArtifactReader {
|
|
7
|
+
format = "directory";
|
|
8
|
+
#rootPromise;
|
|
9
|
+
constructor(root) {
|
|
10
|
+
this.#rootPromise = realpath(root);
|
|
11
|
+
}
|
|
12
|
+
async *entries(signal) {
|
|
13
|
+
const root = await this.#rootPromise;
|
|
14
|
+
const pending = [root];
|
|
15
|
+
while (pending.length > 0) {
|
|
16
|
+
abortIfNeeded(signal);
|
|
17
|
+
const directory = pending.pop();
|
|
18
|
+
if (directory === undefined)
|
|
19
|
+
break;
|
|
20
|
+
await assertContainedDirectory(root, directory);
|
|
21
|
+
const children = [];
|
|
22
|
+
const handle = await opendir(directory);
|
|
23
|
+
try {
|
|
24
|
+
for await (const child of handle)
|
|
25
|
+
children.push(child.name);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await handle.close().catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
await assertContainedDirectory(root, directory);
|
|
31
|
+
children.sort((left, right) => left.localeCompare(right, "en"));
|
|
32
|
+
const directories = [];
|
|
33
|
+
for (const name of children) {
|
|
34
|
+
abortIfNeeded(signal);
|
|
35
|
+
const absolute = join(directory, name);
|
|
36
|
+
const metadata = await lstat(absolute);
|
|
37
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
38
|
+
const kind = metadata.isSymbolicLink()
|
|
39
|
+
? "symlink"
|
|
40
|
+
: metadata.isDirectory()
|
|
41
|
+
? "directory"
|
|
42
|
+
: metadata.isFile()
|
|
43
|
+
? "file"
|
|
44
|
+
: undefined;
|
|
45
|
+
if (kind === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
if (kind === "directory")
|
|
48
|
+
directories.push(absolute);
|
|
49
|
+
yield {
|
|
50
|
+
path,
|
|
51
|
+
kind,
|
|
52
|
+
declaredSize: kind === "file" ? metadata.size : null,
|
|
53
|
+
compressedSize: null,
|
|
54
|
+
executable: (metadata.mode & 0o111) !== 0,
|
|
55
|
+
encrypted: false,
|
|
56
|
+
byteOffset: null,
|
|
57
|
+
declaredSha256: null,
|
|
58
|
+
limitations: kind === "symlink"
|
|
59
|
+
? ["Symlink target was not followed or disclosed."]
|
|
60
|
+
: [],
|
|
61
|
+
adapterKey: absolute,
|
|
62
|
+
...(kind === "file"
|
|
63
|
+
? { sourceIdentity: { device: metadata.dev, inode: metadata.ino } }
|
|
64
|
+
: {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
directories.reverse();
|
|
68
|
+
pending.push(...directories);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async open(entry, signal) {
|
|
72
|
+
abortIfNeeded(signal);
|
|
73
|
+
if (entry.kind !== "file")
|
|
74
|
+
throw new ArtifactReaderFailure("format", "Only regular files can be opened");
|
|
75
|
+
const handle = await open(entry.adapterKey, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
76
|
+
const observed = await handle.stat();
|
|
77
|
+
if (!observed.isFile() ||
|
|
78
|
+
entry.sourceIdentity === undefined ||
|
|
79
|
+
observed.dev !== entry.sourceIdentity.device ||
|
|
80
|
+
observed.ino !== entry.sourceIdentity.inode) {
|
|
81
|
+
await handle.close();
|
|
82
|
+
throw new ArtifactReaderFailure("integrity", `Directory entry changed before open: ${entry.path}`);
|
|
83
|
+
}
|
|
84
|
+
return handle.createReadStream({ autoClose: true });
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
provenance() {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const abortIfNeeded = (signal) => {
|
|
94
|
+
if (signal?.aborted === true)
|
|
95
|
+
throw new ArtifactReaderFailure("cancelled", "Artifact traversal cancelled");
|
|
96
|
+
};
|
|
97
|
+
const assertContainedDirectory = async (root, directory) => {
|
|
98
|
+
const canonical = await realpath(directory);
|
|
99
|
+
const fromRoot = relative(root, canonical);
|
|
100
|
+
const metadata = await lstat(directory);
|
|
101
|
+
if (metadata.isSymbolicLink() ||
|
|
102
|
+
!metadata.isDirectory() ||
|
|
103
|
+
fromRoot === ".." ||
|
|
104
|
+
fromRoot.startsWith(`..${sep}`) ||
|
|
105
|
+
isAbsolute(fromRoot))
|
|
106
|
+
throw new ArtifactReaderFailure("path", "Directory traversal escaped or changed its artifact root");
|
|
107
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { XcrunCommandRunner, } from "../native/CommandRunner.js";
|
|
3
|
+
import { parseLipoArchitectures } from "../native/parsers/lipo.js";
|
|
4
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
5
|
+
/** Read-only universal Mach-O slice reader backed by native lipo metadata. */
|
|
6
|
+
export class MachOSliceArtifactReader {
|
|
7
|
+
path;
|
|
8
|
+
runner;
|
|
9
|
+
format = "file";
|
|
10
|
+
#command;
|
|
11
|
+
constructor(path, runner = new XcrunCommandRunner()) {
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.runner = runner;
|
|
14
|
+
}
|
|
15
|
+
async *entries(signal) {
|
|
16
|
+
const captured = await this.runner.run("lipo", ["-detailed_info", this.path], {
|
|
17
|
+
...(signal === undefined ? {} : { signal }),
|
|
18
|
+
timeoutMs: 30_000,
|
|
19
|
+
maxOutputBytes: 4 * 1024 * 1024,
|
|
20
|
+
});
|
|
21
|
+
if (!captured.ok)
|
|
22
|
+
throw new ArtifactReaderFailure(captured.error.reason === "cancelled" ? "cancelled" : "unavailable", "lipo could not enumerate universal Mach-O slices", { cause: captured.error });
|
|
23
|
+
this.#command = {
|
|
24
|
+
tool: captured.value.tool,
|
|
25
|
+
arguments: ["-detailed_info", "$ARTIFACT"],
|
|
26
|
+
tool_version: captured.value.toolVersion,
|
|
27
|
+
executable_sha256: captured.value.executableSha256,
|
|
28
|
+
exit_code: captured.value.exitCode,
|
|
29
|
+
effects: ["read"],
|
|
30
|
+
};
|
|
31
|
+
for (const architecture of parseLipoArchitectures(captured.value.stdout)) {
|
|
32
|
+
if (architecture.file_offset === null || architecture.size === null)
|
|
33
|
+
throw new ArtifactReaderFailure("integrity", "lipo omitted a universal slice byte range");
|
|
34
|
+
yield {
|
|
35
|
+
path: `slices/${architecture.name}`,
|
|
36
|
+
kind: "slice",
|
|
37
|
+
declaredSize: architecture.size,
|
|
38
|
+
compressedSize: null,
|
|
39
|
+
executable: true,
|
|
40
|
+
encrypted: false,
|
|
41
|
+
byteOffset: architecture.file_offset,
|
|
42
|
+
declaredSha256: null,
|
|
43
|
+
limitations: [],
|
|
44
|
+
adapterKey: `${String(architecture.file_offset)}:${String(architecture.size)}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
open(entry, signal) {
|
|
49
|
+
if (signal?.aborted === true)
|
|
50
|
+
return Promise.reject(new ArtifactReaderFailure("cancelled", "Mach-O slice read cancelled"));
|
|
51
|
+
const [offsetText, sizeText] = entry.adapterKey.split(":");
|
|
52
|
+
const offset = Number(offsetText);
|
|
53
|
+
const size = Number(sizeText);
|
|
54
|
+
if (!Number.isSafeInteger(offset) ||
|
|
55
|
+
!Number.isSafeInteger(size) ||
|
|
56
|
+
size <= 0)
|
|
57
|
+
return Promise.reject(new ArtifactReaderFailure("integrity", "Invalid Mach-O slice byte range"));
|
|
58
|
+
return Promise.resolve(createReadStream(this.path, { start: offset, end: offset + size - 1 }));
|
|
59
|
+
}
|
|
60
|
+
provenance() {
|
|
61
|
+
return this.#command === undefined ? [] : [this.#command];
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { streamChunkToBuffer } from "./StreamBytes.js";
|
|
6
|
+
import { ArtifactPathRegistry, normalizeArtifactPath, } from "./ArtifactPaths.js";
|
|
7
|
+
import { ArtifactReaderFailure, } from "./ArtifactReader.js";
|
|
8
|
+
/** Symlink-resistant materialization in an exclusively owned, initially absent tree. */
|
|
9
|
+
export class SafeOutputTree {
|
|
10
|
+
#registry = new ArtifactPathRegistry();
|
|
11
|
+
#limits;
|
|
12
|
+
#outputRoot;
|
|
13
|
+
#stagingRoot;
|
|
14
|
+
#totalBytes = 0;
|
|
15
|
+
#published = false;
|
|
16
|
+
#cleanup = {
|
|
17
|
+
attempted: false,
|
|
18
|
+
verified: false,
|
|
19
|
+
residualPaths: [],
|
|
20
|
+
};
|
|
21
|
+
constructor(outputRoot, stagingRoot, limits) {
|
|
22
|
+
this.#outputRoot = outputRoot;
|
|
23
|
+
this.#stagingRoot = stagingRoot;
|
|
24
|
+
this.#limits = limits;
|
|
25
|
+
}
|
|
26
|
+
/** Exclusively create the absent destination as this operation's owned tree. */
|
|
27
|
+
static async create(outputRoot, limits) {
|
|
28
|
+
if (!isAbsolute(outputRoot))
|
|
29
|
+
throw new ArtifactReaderFailure("path", "Extraction output root must be absolute");
|
|
30
|
+
const requested = resolve(outputRoot);
|
|
31
|
+
const name = basename(requested);
|
|
32
|
+
if (name === "." || name === "..")
|
|
33
|
+
throw new ArtifactReaderFailure("path", "Invalid extraction output root");
|
|
34
|
+
const parent = await realpath(dirname(requested)).catch((cause) => {
|
|
35
|
+
throw new ArtifactReaderFailure("unavailable", "Extraction output parent is unavailable", { cause });
|
|
36
|
+
});
|
|
37
|
+
const canonicalOutput = join(parent, name);
|
|
38
|
+
await mkdir(canonicalOutput, { mode: 0o700 }).catch((cause) => {
|
|
39
|
+
if (isAlreadyExists(cause))
|
|
40
|
+
throw new ArtifactReaderFailure("path", "Extraction output root already exists", { cause });
|
|
41
|
+
throw cause;
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const stagingHandle = await open(canonicalOutput, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
45
|
+
try {
|
|
46
|
+
await stagingHandle.chmod(0o700);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await stagingHandle.close();
|
|
50
|
+
}
|
|
51
|
+
return new SafeOutputTree(canonicalOutput, canonicalOutput, limits);
|
|
52
|
+
}
|
|
53
|
+
catch (cause) {
|
|
54
|
+
await rm(canonicalOutput, { recursive: true, force: true });
|
|
55
|
+
throw cause;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
get outputRoot() {
|
|
59
|
+
return this.#outputRoot;
|
|
60
|
+
}
|
|
61
|
+
get cleanup() {
|
|
62
|
+
return this.#cleanup;
|
|
63
|
+
}
|
|
64
|
+
/** Stream one selected regular file with exact byte and digest verification. */
|
|
65
|
+
async write(relativePath, source, expectedSha256, signal) {
|
|
66
|
+
this.#assertWritable();
|
|
67
|
+
const path = normalizeArtifactPath(relativePath, this.#limits);
|
|
68
|
+
this.#registry.add(path, "file");
|
|
69
|
+
const destination = await this.#prepareParent(path);
|
|
70
|
+
const handle = await open(destination, constants.O_CREAT |
|
|
71
|
+
constants.O_EXCL |
|
|
72
|
+
constants.O_WRONLY |
|
|
73
|
+
constants.O_NOFOLLOW, 0o600).catch((cause) => {
|
|
74
|
+
throw new ArtifactReaderFailure("path", `Could not exclusively create extraction path: ${path}`, { cause });
|
|
75
|
+
});
|
|
76
|
+
const hash = createHash("sha256");
|
|
77
|
+
let bytes = 0;
|
|
78
|
+
try {
|
|
79
|
+
for await (const raw of source) {
|
|
80
|
+
abortIfNeeded(signal);
|
|
81
|
+
const chunk = streamChunkToBuffer(raw);
|
|
82
|
+
bytes += chunk.length;
|
|
83
|
+
if (bytes > this.#limits.maxEntryBytes)
|
|
84
|
+
throw new ArtifactReaderFailure("limit", `Observed entry bytes exceed limit: ${path}`);
|
|
85
|
+
if (this.#totalBytes + bytes > this.#limits.maxTotalBytes)
|
|
86
|
+
throw new ArtifactReaderFailure("limit", "Observed extraction bytes exceed cumulative limit");
|
|
87
|
+
hash.update(chunk);
|
|
88
|
+
await writeAll(handle, chunk);
|
|
89
|
+
}
|
|
90
|
+
const sha256 = hash.digest("hex");
|
|
91
|
+
if (sha256 !== expectedSha256)
|
|
92
|
+
throw new ArtifactReaderFailure("integrity", `Extracted content disagrees with inventory: ${path}`);
|
|
93
|
+
await handle.sync();
|
|
94
|
+
await handle.close();
|
|
95
|
+
const readback = await hashFile(destination, bytes, signal);
|
|
96
|
+
if (readback.sha256 !== sha256 || readback.bytes !== bytes)
|
|
97
|
+
throw new ArtifactReaderFailure("integrity", `Durable readback verification failed: ${path}`);
|
|
98
|
+
this.#totalBytes += bytes;
|
|
99
|
+
return { relativePath: path, sha256, bytesWritten: bytes };
|
|
100
|
+
}
|
|
101
|
+
catch (cause) {
|
|
102
|
+
await handle.close().catch(() => undefined);
|
|
103
|
+
throw cause;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Sync the owned output tree and prevent further writes through this instance. */
|
|
107
|
+
async commit() {
|
|
108
|
+
this.#assertWritable();
|
|
109
|
+
const parent = await open(dirname(this.#outputRoot), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
110
|
+
const staging = await open(this.#stagingRoot, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
111
|
+
try {
|
|
112
|
+
await staging.sync();
|
|
113
|
+
await parent.sync();
|
|
114
|
+
this.#published = true;
|
|
115
|
+
}
|
|
116
|
+
catch (cause) {
|
|
117
|
+
throw new ArtifactReaderFailure("path", "Could not durably sync extraction output", { cause });
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await Promise.allSettled([parent.close(), staging.close()]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Remove only this operation's unsealed tree and verify absence. */
|
|
124
|
+
async rollback() {
|
|
125
|
+
if (this.#published)
|
|
126
|
+
return this.#cleanup;
|
|
127
|
+
await rm(this.#stagingRoot, { recursive: true, force: true });
|
|
128
|
+
const absent = await isAbsent(this.#stagingRoot);
|
|
129
|
+
this.#cleanup = {
|
|
130
|
+
attempted: true,
|
|
131
|
+
verified: absent,
|
|
132
|
+
residualPaths: absent ? [] : [basename(this.#stagingRoot)],
|
|
133
|
+
};
|
|
134
|
+
if (!absent)
|
|
135
|
+
throw new ArtifactReaderFailure("integrity", "Extraction output cleanup could not be verified");
|
|
136
|
+
return this.#cleanup;
|
|
137
|
+
}
|
|
138
|
+
async #prepareParent(relativePath) {
|
|
139
|
+
const parts = relativePath.split("/");
|
|
140
|
+
const fileName = parts.pop();
|
|
141
|
+
if (fileName === undefined)
|
|
142
|
+
throw new ArtifactReaderFailure("path", "Invalid extraction path");
|
|
143
|
+
let current = this.#stagingRoot;
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
current = join(current, part);
|
|
146
|
+
await mkdir(current, { mode: 0o700 }).catch((cause) => {
|
|
147
|
+
if (!isAlreadyExists(cause))
|
|
148
|
+
throw cause;
|
|
149
|
+
});
|
|
150
|
+
const metadata = await lstat(current);
|
|
151
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
152
|
+
throw new ArtifactReaderFailure("path", `Unsafe extraction parent: ${relativePath}`);
|
|
153
|
+
}
|
|
154
|
+
return join(current, fileName);
|
|
155
|
+
}
|
|
156
|
+
#assertWritable() {
|
|
157
|
+
if (this.#published)
|
|
158
|
+
throw new ArtifactReaderFailure("integrity", "Extraction tree is already committed");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const hashFile = async (path, maximum, signal) => {
|
|
162
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
163
|
+
const hash = createHash("sha256");
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
try {
|
|
166
|
+
for await (const raw of handle.createReadStream({ autoClose: false })) {
|
|
167
|
+
abortIfNeeded(signal);
|
|
168
|
+
const chunk = streamChunkToBuffer(raw);
|
|
169
|
+
bytes += chunk.length;
|
|
170
|
+
if (bytes > maximum)
|
|
171
|
+
throw new ArtifactReaderFailure("integrity", "Readback exceeded the bytes written");
|
|
172
|
+
hash.update(chunk);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await handle.close();
|
|
177
|
+
}
|
|
178
|
+
return { sha256: hash.digest("hex"), bytes };
|
|
179
|
+
};
|
|
180
|
+
const writeAll = async (handle, chunk) => {
|
|
181
|
+
let offset = 0;
|
|
182
|
+
while (offset < chunk.length) {
|
|
183
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset);
|
|
184
|
+
if (bytesWritten === 0)
|
|
185
|
+
throw new ArtifactReaderFailure("unavailable", "Extraction output stopped accepting bytes");
|
|
186
|
+
offset += bytesWritten;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const isAbsent = async (path) => lstat(path).then(() => false, (cause) => {
|
|
190
|
+
if (isNotFound(cause))
|
|
191
|
+
return true;
|
|
192
|
+
throw cause;
|
|
193
|
+
});
|
|
194
|
+
const isNotFound = (cause) => cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
195
|
+
const isAlreadyExists = (cause) => cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
|
196
|
+
const abortIfNeeded = (signal) => {
|
|
197
|
+
if (signal?.aborted === true)
|
|
198
|
+
throw new ArtifactReaderFailure("cancelled", "Artifact extraction cancelled");
|
|
199
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Parse a Node readable chunk without unchecked coercion. */
|
|
2
|
+
export const streamChunkToBuffer = (value) => {
|
|
3
|
+
if (Buffer.isBuffer(value))
|
|
4
|
+
return value;
|
|
5
|
+
if (value instanceof Uint8Array)
|
|
6
|
+
return Buffer.from(value);
|
|
7
|
+
if (typeof value === "string")
|
|
8
|
+
return Buffer.from(value);
|
|
9
|
+
throw new TypeError("Readable stream emitted a non-byte chunk");
|
|
10
|
+
};
|