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,135 @@
|
|
|
1
|
+
import { inspectMachoSchema, } from "../domain/nativeInspection.js";
|
|
2
|
+
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
3
|
+
import { err, ok } from "../domain/result.js";
|
|
4
|
+
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
|
|
5
|
+
import { parseLipoArchitectures } from "./parsers/lipo.js";
|
|
6
|
+
import { parseOtoolLoadCommands } from "./parsers/otool.js";
|
|
7
|
+
const COMMANDS = [
|
|
8
|
+
["file", ["-b"]],
|
|
9
|
+
["lipo", ["-detailed_info"]],
|
|
10
|
+
["otool", ["-l"]],
|
|
11
|
+
["nm", ["-gjU"]],
|
|
12
|
+
["dyld_info", ["-imports"]],
|
|
13
|
+
["dyld_info", ["-exports"]],
|
|
14
|
+
["dwarfdump", ["--uuid"]],
|
|
15
|
+
["vtool", ["-show-build"]],
|
|
16
|
+
];
|
|
17
|
+
/** Inspect one Mach-O with bounded native commands and normalized output. */
|
|
18
|
+
export const inspectNativeMacho = async (context) => {
|
|
19
|
+
const captures = [];
|
|
20
|
+
for (const [tool, prefix] of COMMANDS) {
|
|
21
|
+
const captured = await context.run(tool, [...prefix, context.target.path], context.signal);
|
|
22
|
+
if (!captured.ok)
|
|
23
|
+
return err(captured.error);
|
|
24
|
+
captures.push(captured.value);
|
|
25
|
+
}
|
|
26
|
+
const result = normalizeMacho(captures, context.invocation);
|
|
27
|
+
return ok({
|
|
28
|
+
result: jsonValueSchema.parse(result),
|
|
29
|
+
provenance: result.provenance,
|
|
30
|
+
limitations: result.limitations,
|
|
31
|
+
locations: fileOffsetLocations(result),
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const normalizeMacho = (captures, toInvocation) => {
|
|
35
|
+
const byTool = captureLookup(captures);
|
|
36
|
+
const architectures = parseLipoArchitectures(byTool("lipo").stdout);
|
|
37
|
+
const load = parseOtoolLoadCommands(byTool("otool").stdout);
|
|
38
|
+
const imports = parseDyldSymbols(byTool("dyld_info", 0).stdout, "imports");
|
|
39
|
+
const dyldExports = parseDyldSymbols(byTool("dyld_info", 1).stdout, "exports");
|
|
40
|
+
const exports = uniqueSymbols([
|
|
41
|
+
...dyldExports,
|
|
42
|
+
...parseNmExports(byTool("nm").stdout),
|
|
43
|
+
]);
|
|
44
|
+
const uuid = /UUID:\s*([A-Fa-f0-9-]+)/u.exec(byTool("dwarfdump").stdout)?.[1] ??
|
|
45
|
+
load.uuid;
|
|
46
|
+
const provenance = captures.map(toInvocation);
|
|
47
|
+
const limitations = [
|
|
48
|
+
"Imports and exports combine dyld_info and nm; stripped or toolchain-hidden symbols may be absent.",
|
|
49
|
+
"vtool output is retained as provenance but only otool build metadata is normalized.",
|
|
50
|
+
];
|
|
51
|
+
return inspectMachoSchema.parse({
|
|
52
|
+
format: "mach-o",
|
|
53
|
+
endian: parseEndian(byTool("file").stdout),
|
|
54
|
+
word_size: parseWordSize(byTool("file").stdout),
|
|
55
|
+
file_type: load.fileType,
|
|
56
|
+
flags: load.flags,
|
|
57
|
+
uuid: uuid ?? null,
|
|
58
|
+
entrypoints: covered(load.entrypoints, true),
|
|
59
|
+
architectures: covered(architectures, true),
|
|
60
|
+
build_metadata: covered(load.builds, true),
|
|
61
|
+
load_commands: covered(load.commands, true),
|
|
62
|
+
dependencies: covered(load.dependencies, true),
|
|
63
|
+
imports: covered(imports, false, [
|
|
64
|
+
"dyld_info textual imports may omit chained or toolchain-unsupported metadata.",
|
|
65
|
+
]),
|
|
66
|
+
exports: covered(exports, false, [
|
|
67
|
+
"Merged nm/dyld_info results may be incomplete for stripped binaries.",
|
|
68
|
+
]),
|
|
69
|
+
segments: covered(load.segments.map((segment) => ({
|
|
70
|
+
...segment,
|
|
71
|
+
sections: covered(segment.sections, true),
|
|
72
|
+
})), true),
|
|
73
|
+
provenance,
|
|
74
|
+
limitations,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const captureLookup = (captures) => (tool, occurrence = 0) => {
|
|
78
|
+
const capture = captures.filter((item) => item.tool === tool)[occurrence];
|
|
79
|
+
if (capture === undefined)
|
|
80
|
+
throw new TypeError(`Missing ${tool} capture`);
|
|
81
|
+
return capture;
|
|
82
|
+
};
|
|
83
|
+
const parseNmExports = (output) => output
|
|
84
|
+
.split(/\r?\n/u)
|
|
85
|
+
.filter((name) => name.length > 0)
|
|
86
|
+
.map((name) => ({
|
|
87
|
+
name,
|
|
88
|
+
address: null,
|
|
89
|
+
weak: null,
|
|
90
|
+
reexport: null,
|
|
91
|
+
source: "nm",
|
|
92
|
+
}));
|
|
93
|
+
const parseEndian = (output) => /little-endian/iu.test(output)
|
|
94
|
+
? "little"
|
|
95
|
+
: /big-endian/iu.test(output)
|
|
96
|
+
? "big"
|
|
97
|
+
: null;
|
|
98
|
+
const parseWordSize = (output) => /64-bit/iu.test(output) ? 64 : /32-bit/iu.test(output) ? 32 : null;
|
|
99
|
+
const covered = (items, exhaustive, limitations = []) => ({
|
|
100
|
+
items: [...items],
|
|
101
|
+
total: exhaustive ? items.length : null,
|
|
102
|
+
exhaustive,
|
|
103
|
+
limitations: [...limitations],
|
|
104
|
+
});
|
|
105
|
+
const uniqueSymbols = (items) => {
|
|
106
|
+
const unique = new Map();
|
|
107
|
+
for (const item of items)
|
|
108
|
+
unique.set(item.name, item);
|
|
109
|
+
return [...unique.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
110
|
+
};
|
|
111
|
+
const fileOffsetLocations = (macho) => {
|
|
112
|
+
const locations = architectureLocations(macho.architectures.items);
|
|
113
|
+
for (const segment of macho.segments.items) {
|
|
114
|
+
if (segment.file_offset === null || segment.file_size === null)
|
|
115
|
+
continue;
|
|
116
|
+
locations.push({
|
|
117
|
+
kind: "file-offset-range",
|
|
118
|
+
start: segment.file_offset,
|
|
119
|
+
end: segment.file_offset + segment.file_size,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return locations;
|
|
123
|
+
};
|
|
124
|
+
/** Project architecture slices into evidence file-offset locations. */
|
|
125
|
+
export const architectureLocations = (architectures) => {
|
|
126
|
+
const locations = [];
|
|
127
|
+
for (const { file_offset: offset, size } of architectures) {
|
|
128
|
+
if (offset === null)
|
|
129
|
+
continue;
|
|
130
|
+
locations.push(size === null
|
|
131
|
+
? { kind: "file-offset", offset }
|
|
132
|
+
: { kind: "file-offset-range", start: offset, end: offset + size });
|
|
133
|
+
}
|
|
134
|
+
return locations;
|
|
135
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { inspectSignatureSchema, } from "../../domain/nativeInspection.js";
|
|
2
|
+
/** Parse bounded `codesign -d` diagnostics, which Apple emits on stderr. */
|
|
3
|
+
export const parseCodeSignature = (output, unsigned) => {
|
|
4
|
+
const values = new Map();
|
|
5
|
+
const authorities = [];
|
|
6
|
+
const cdhashes = [];
|
|
7
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
8
|
+
if (line.startsWith("CodeDirectory ")) {
|
|
9
|
+
values.set("CodeDirectory", line.slice("CodeDirectory ".length));
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const separator = line.indexOf("=");
|
|
13
|
+
if (separator < 1)
|
|
14
|
+
continue;
|
|
15
|
+
const key = line.slice(0, separator).trim();
|
|
16
|
+
const value = line.slice(separator + 1).trim();
|
|
17
|
+
if (key === "Authority")
|
|
18
|
+
authorities.push(value);
|
|
19
|
+
else if (key === "CDHash")
|
|
20
|
+
cdhashes.push(value);
|
|
21
|
+
else
|
|
22
|
+
values.set(key, value);
|
|
23
|
+
}
|
|
24
|
+
const parsed = inspectSignatureSchema.omit({ provenance: true }).parse({
|
|
25
|
+
signed: !unsigned,
|
|
26
|
+
identifier: values.get("Identifier") ?? null,
|
|
27
|
+
team_identifier: nullableCodeSignValue(values.get("TeamIdentifier")),
|
|
28
|
+
format: values.get("Format") ?? null,
|
|
29
|
+
cdhashes,
|
|
30
|
+
hash_algorithms: splitAlgorithms(values.get("Hash choices")),
|
|
31
|
+
authorities,
|
|
32
|
+
designated_requirement: values.get("designated") ?? null,
|
|
33
|
+
entitlements: null,
|
|
34
|
+
timestamp: values.get("Timestamp") ?? null,
|
|
35
|
+
hardened_runtime: parseRuntime(values.get("CodeDirectory")),
|
|
36
|
+
limitations: unsigned
|
|
37
|
+
? ["Artifact is not signed."]
|
|
38
|
+
: [
|
|
39
|
+
"Entitlements and designated requirements require separate bounded commands.",
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
return parsed;
|
|
43
|
+
};
|
|
44
|
+
const nullableCodeSignValue = (value) => value === undefined || value === "not set" ? null : value;
|
|
45
|
+
const splitAlgorithms = (value) => value === undefined
|
|
46
|
+
? []
|
|
47
|
+
: value
|
|
48
|
+
.split(/[,+\s]+/u)
|
|
49
|
+
.map((part) => part.trim())
|
|
50
|
+
.filter((part) => part.length > 0);
|
|
51
|
+
const parseRuntime = (value) => {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return null;
|
|
54
|
+
return /\bruntime\b/u.test(value);
|
|
55
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const resultSchema = z.object({
|
|
3
|
+
input: z.string(),
|
|
4
|
+
output: z.string(),
|
|
5
|
+
status: z.enum(["demangled", "unchanged", "invalid"]),
|
|
6
|
+
});
|
|
7
|
+
/** Preserve input ordering while parsing `swift-demangle --compact` lines. */
|
|
8
|
+
export const parseDemangledSymbols = (inputs, output) => {
|
|
9
|
+
const lines = output.trimEnd().split(/\r?\n/u);
|
|
10
|
+
if (lines.length !== inputs.length)
|
|
11
|
+
throw new TypeError("swift-demangle output count does not match input");
|
|
12
|
+
return inputs.map((input, index) => {
|
|
13
|
+
const value = lines[index];
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
throw new TypeError("swift-demangle omitted an output line");
|
|
16
|
+
return resultSchema.parse({
|
|
17
|
+
input,
|
|
18
|
+
output: value,
|
|
19
|
+
status: value === input
|
|
20
|
+
? input.startsWith("$s") || input.startsWith("_$s")
|
|
21
|
+
? "invalid"
|
|
22
|
+
: "unchanged"
|
|
23
|
+
: "demangled",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Parse bounded imports/exports from dyld_info's line-oriented tables. */
|
|
2
|
+
export const parseDyldSymbols = (output, mode) => output.split(/\r?\n/u).flatMap((rawLine) => {
|
|
3
|
+
const line = rawLine.trim();
|
|
4
|
+
if (line.length === 0 ||
|
|
5
|
+
/^(?:imports|exports|binding|address|segment|ordinal)\b/iu.test(line))
|
|
6
|
+
return [];
|
|
7
|
+
const tokens = line.split(/\s+/u);
|
|
8
|
+
const name = tokens.at(-1);
|
|
9
|
+
if (name === undefined || !/^(?:_|\$s|objc_|swift_)/u.test(name))
|
|
10
|
+
return [];
|
|
11
|
+
const addressToken = tokens.find((token) => /^0x[a-fA-F0-9]+$/u.test(token));
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
name,
|
|
15
|
+
address: addressToken ?? null,
|
|
16
|
+
weak: /\bweak\b/iu.test(line) ? true : null,
|
|
17
|
+
reexport: /\bre-?export\b/iu.test(line)
|
|
18
|
+
? true
|
|
19
|
+
: mode === "exports"
|
|
20
|
+
? false
|
|
21
|
+
: null,
|
|
22
|
+
source: mode === "imports" ? (tokens.at(-2) ?? null) : null,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const architectureSchema = z.object({
|
|
3
|
+
name: z.string().min(1),
|
|
4
|
+
cpu_type: z.string().nullable(),
|
|
5
|
+
cpu_subtype: z.string().nullable(),
|
|
6
|
+
file_offset: z.number().int().min(0).nullable(),
|
|
7
|
+
size: z.number().int().min(0).nullable(),
|
|
8
|
+
alignment: z.number().int().min(0).nullable(),
|
|
9
|
+
});
|
|
10
|
+
/** Parse `lipo -detailed_info` into deterministic slice metadata. */
|
|
11
|
+
export const parseLipoArchitectures = (output) => {
|
|
12
|
+
const architectures = [];
|
|
13
|
+
let current;
|
|
14
|
+
const flush = () => {
|
|
15
|
+
if (current === undefined)
|
|
16
|
+
return;
|
|
17
|
+
const name = current.architecture ?? current["Non-fat file"];
|
|
18
|
+
if (name !== undefined)
|
|
19
|
+
architectures.push(architectureSchema.parse({
|
|
20
|
+
name: name.includes(" is architecture: ")
|
|
21
|
+
? (name.split(" is architecture: ").at(-1) ?? name)
|
|
22
|
+
: name,
|
|
23
|
+
cpu_type: current.cputype ?? null,
|
|
24
|
+
cpu_subtype: current.cpusubtype ?? null,
|
|
25
|
+
file_offset: integer(current.offset),
|
|
26
|
+
size: integer(current.size),
|
|
27
|
+
alignment: alignment(current.align),
|
|
28
|
+
}));
|
|
29
|
+
current = undefined;
|
|
30
|
+
};
|
|
31
|
+
for (const rawLine of output.split(/\r?\n/u)) {
|
|
32
|
+
const line = rawLine.trim();
|
|
33
|
+
if (line.startsWith("Non-fat file:")) {
|
|
34
|
+
flush();
|
|
35
|
+
current = { "Non-fat file": line };
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (line.startsWith("architecture ")) {
|
|
39
|
+
flush();
|
|
40
|
+
current = { architecture: line.slice("architecture ".length).trim() };
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const match = /^(cputype|cpusubtype|offset|size|align)\s+(.+)$/u.exec(line);
|
|
44
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
45
|
+
current ??= {};
|
|
46
|
+
current[match[1]] = match[2].trim();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
flush();
|
|
50
|
+
if (architectures.length === 0)
|
|
51
|
+
throw new TypeError("lipo output contained no architectures");
|
|
52
|
+
return architectures;
|
|
53
|
+
};
|
|
54
|
+
const integer = (value) => {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
const parsed = Number.parseInt(value, 10);
|
|
58
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
59
|
+
};
|
|
60
|
+
const alignment = (value) => {
|
|
61
|
+
if (value === undefined)
|
|
62
|
+
return null;
|
|
63
|
+
const exponent = /2\^(\d+)/u.exec(value)?.[1];
|
|
64
|
+
if (exponent !== undefined)
|
|
65
|
+
return 2 ** Number.parseInt(exponent, 10);
|
|
66
|
+
return integer(value);
|
|
67
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Parse stable fields from `otool -l`, preserving unknown command fields. */
|
|
2
|
+
export const parseOtoolLoadCommands = (output) => {
|
|
3
|
+
const headerTokens = parseHeaderTokens(output);
|
|
4
|
+
const state = createLoadCommandState(headerTokens);
|
|
5
|
+
for (const block of output.split(/(?=Load command \d+)/u))
|
|
6
|
+
parseLoadCommand(block, state);
|
|
7
|
+
return {
|
|
8
|
+
fileType: headerTokens?.[4] ?? null,
|
|
9
|
+
flags: [...state.flags].sort(),
|
|
10
|
+
uuid: state.uuid,
|
|
11
|
+
commands: state.commands,
|
|
12
|
+
segments: state.segments,
|
|
13
|
+
dependencies: state.dependencies,
|
|
14
|
+
entrypoints: state.entrypoints,
|
|
15
|
+
builds: state.builds,
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const parseHeaderTokens = (output) => output
|
|
19
|
+
.split(/\r?\n/u)
|
|
20
|
+
.map((line) => line.trim())
|
|
21
|
+
.find((line) => /^0x[a-fA-F0-9]+\s/u.test(line))
|
|
22
|
+
?.split(/\s+/u);
|
|
23
|
+
const createLoadCommandState = (headerTokens) => ({
|
|
24
|
+
commands: [],
|
|
25
|
+
segments: [],
|
|
26
|
+
dependencies: [],
|
|
27
|
+
entrypoints: [],
|
|
28
|
+
builds: [],
|
|
29
|
+
uuid: null,
|
|
30
|
+
flags: new Set(headerTokens?.slice(7) ?? []),
|
|
31
|
+
});
|
|
32
|
+
const parseLoadCommand = (block, state) => {
|
|
33
|
+
const indexText = /^Load command (\d+)/u.exec(block)?.[1];
|
|
34
|
+
if (indexText === undefined)
|
|
35
|
+
return;
|
|
36
|
+
const header = block.split(/\n\s*Section\n/u)[0] ?? block;
|
|
37
|
+
const fields = parseFields(header);
|
|
38
|
+
const kind = stringField(fields, "cmd") ?? "unknown";
|
|
39
|
+
state.commands.push({
|
|
40
|
+
index: Number.parseInt(indexText, 10),
|
|
41
|
+
kind,
|
|
42
|
+
file_offset: null,
|
|
43
|
+
fields,
|
|
44
|
+
});
|
|
45
|
+
collectUuid(kind, fields, state);
|
|
46
|
+
collectEntrypoint(kind, fields, state);
|
|
47
|
+
collectBuild(kind, block, fields, state);
|
|
48
|
+
collectDependency(kind, fields, state);
|
|
49
|
+
collectSegment(kind, block, fields, state);
|
|
50
|
+
collectFlags(fields, state.flags);
|
|
51
|
+
};
|
|
52
|
+
const collectUuid = (kind, fields, state) => {
|
|
53
|
+
if (kind === "LC_UUID")
|
|
54
|
+
state.uuid = stringField(fields, "uuid") ?? state.uuid;
|
|
55
|
+
};
|
|
56
|
+
const collectEntrypoint = (kind, fields, state) => {
|
|
57
|
+
if (kind !== "LC_MAIN")
|
|
58
|
+
return;
|
|
59
|
+
const entry = numberField(fields, "entryoff");
|
|
60
|
+
if (entry !== null)
|
|
61
|
+
state.entrypoints.push({ file_offset: entry });
|
|
62
|
+
};
|
|
63
|
+
const collectBuild = (kind, block, fields, state) => {
|
|
64
|
+
if (kind !== "LC_BUILD_VERSION")
|
|
65
|
+
return;
|
|
66
|
+
state.builds.push({
|
|
67
|
+
platform: stringField(fields, "platform"),
|
|
68
|
+
minimum_os: stringField(fields, "minos"),
|
|
69
|
+
sdk: stringField(fields, "sdk"),
|
|
70
|
+
tools: parseBuildTools(block),
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const collectDependency = (kind, fields, state) => {
|
|
74
|
+
if (!kind.startsWith("LC_LOAD_") && kind !== "LC_ID_DYLIB")
|
|
75
|
+
return;
|
|
76
|
+
state.dependencies.push({
|
|
77
|
+
path: stripOffsetSuffix(stringField(fields, "name")),
|
|
78
|
+
kind,
|
|
79
|
+
current_version: stringField(fields, "current version"),
|
|
80
|
+
compatibility_version: stringField(fields, "compatibility version"),
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
const collectSegment = (kind, block, fields, state) => {
|
|
84
|
+
if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64")
|
|
85
|
+
state.segments.push(parseSegment(block, fields));
|
|
86
|
+
};
|
|
87
|
+
const collectFlags = (fields, flags) => {
|
|
88
|
+
const rawFlags = stringField(fields, "flags");
|
|
89
|
+
if (rawFlags === null)
|
|
90
|
+
return;
|
|
91
|
+
for (const flag of rawFlags.split(/\s+/u))
|
|
92
|
+
if (flag.length > 0)
|
|
93
|
+
flags.add(flag);
|
|
94
|
+
};
|
|
95
|
+
const parseFields = (block) => {
|
|
96
|
+
const fields = {};
|
|
97
|
+
for (const rawLine of block.split(/\r?\n/u).slice(1)) {
|
|
98
|
+
const line = rawLine.trim();
|
|
99
|
+
const match = /^(\S+(?:\s+version)?)\s+(.+)$/u.exec(line);
|
|
100
|
+
if (match?.[1] === undefined || match[2] === undefined)
|
|
101
|
+
continue;
|
|
102
|
+
const value = match[2].trim();
|
|
103
|
+
fields[match[1]] = numeric(value) ?? value;
|
|
104
|
+
}
|
|
105
|
+
return fields;
|
|
106
|
+
};
|
|
107
|
+
const parseSegment = (block, fields) => ({
|
|
108
|
+
name: stringField(fields, "segname") ?? "unknown",
|
|
109
|
+
vm_address: hexField(fields, "vmaddr"),
|
|
110
|
+
vm_size: numberField(fields, "vmsize"),
|
|
111
|
+
file_offset: numberField(fields, "fileoff"),
|
|
112
|
+
file_size: numberField(fields, "filesize"),
|
|
113
|
+
maximum_permissions: permissions(stringField(fields, "maxprot")),
|
|
114
|
+
initial_permissions: permissions(stringField(fields, "initprot")),
|
|
115
|
+
sections: parseSections(block),
|
|
116
|
+
});
|
|
117
|
+
const parseSections = (block) => block
|
|
118
|
+
.split(/(?=\n\s*Section\n)/u)
|
|
119
|
+
.slice(1)
|
|
120
|
+
.map((sectionBlock) => {
|
|
121
|
+
const fields = parseFields(`Section${sectionBlock}`);
|
|
122
|
+
return {
|
|
123
|
+
segment: stringField(fields, "segname") ?? "unknown",
|
|
124
|
+
name: stringField(fields, "sectname") ?? "unknown",
|
|
125
|
+
address: hexField(fields, "addr"),
|
|
126
|
+
size: numberField(fields, "size"),
|
|
127
|
+
file_offset: numberField(fields, "offset"),
|
|
128
|
+
alignment: sectionAlignment(numberField(fields, "align")),
|
|
129
|
+
flags: (stringField(fields, "flags") ?? "")
|
|
130
|
+
.split(/\s+/u)
|
|
131
|
+
.filter((flag) => flag.length > 0),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
const parseBuildTools = (block) => {
|
|
135
|
+
const tools = [];
|
|
136
|
+
const lines = block.split(/\r?\n/u).map((line) => line.trim());
|
|
137
|
+
for (let index = 0; index < lines.length - 1; index += 1) {
|
|
138
|
+
const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1];
|
|
139
|
+
const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1];
|
|
140
|
+
if (tool !== undefined && version !== undefined)
|
|
141
|
+
tools.push({ name: tool, version });
|
|
142
|
+
}
|
|
143
|
+
return tools;
|
|
144
|
+
};
|
|
145
|
+
const permissions = (raw) => {
|
|
146
|
+
if (raw === null)
|
|
147
|
+
return { read: null, write: null, execute: null, raw: null };
|
|
148
|
+
const numericValue = numeric(raw);
|
|
149
|
+
if (numericValue !== null)
|
|
150
|
+
return {
|
|
151
|
+
read: (numericValue & 4) !== 0,
|
|
152
|
+
write: (numericValue & 2) !== 0,
|
|
153
|
+
execute: (numericValue & 1) !== 0,
|
|
154
|
+
raw,
|
|
155
|
+
};
|
|
156
|
+
if (/^[r-][w-][x-]$/u.test(raw))
|
|
157
|
+
return {
|
|
158
|
+
read: raw[0] === "r",
|
|
159
|
+
write: raw[1] === "w",
|
|
160
|
+
execute: raw[2] === "x",
|
|
161
|
+
raw,
|
|
162
|
+
};
|
|
163
|
+
return { read: null, write: null, execute: null, raw };
|
|
164
|
+
};
|
|
165
|
+
const numeric = (value) => {
|
|
166
|
+
const token = value.split(/\s+/u)[0];
|
|
167
|
+
if (token === undefined || !/^(?:0x[a-fA-F0-9]+|\d+)$/u.test(token))
|
|
168
|
+
return null;
|
|
169
|
+
const parsed = Number.parseInt(token, token.startsWith("0x") ? 16 : 10);
|
|
170
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
171
|
+
};
|
|
172
|
+
const stringField = (fields, name) => {
|
|
173
|
+
const value = fields[name];
|
|
174
|
+
return typeof value === "string"
|
|
175
|
+
? value
|
|
176
|
+
: value === undefined || value === null
|
|
177
|
+
? null
|
|
178
|
+
: String(value);
|
|
179
|
+
};
|
|
180
|
+
const numberField = (fields, name) => {
|
|
181
|
+
const value = fields[name];
|
|
182
|
+
return typeof value === "number"
|
|
183
|
+
? value
|
|
184
|
+
: typeof value === "string"
|
|
185
|
+
? numeric(value)
|
|
186
|
+
: null;
|
|
187
|
+
};
|
|
188
|
+
const hexField = (fields, name) => {
|
|
189
|
+
const value = numberField(fields, name);
|
|
190
|
+
return value === null ? null : `0x${value.toString(16)}`;
|
|
191
|
+
};
|
|
192
|
+
const stripOffsetSuffix = (value) => value?.replace(/\s+\(offset\s+\d+\)$/u, "") ?? "unknown";
|
|
193
|
+
const sectionAlignment = (exponent) => exponent === null || exponent > 52 ? null : 2 ** exponent;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const plistObject = z.record(z.string(), z.unknown());
|
|
3
|
+
/** Parse plutil JSON output and project stable bundle metadata. */
|
|
4
|
+
export const parsePlistJson = (output) => {
|
|
5
|
+
const value = JSON.parse(output);
|
|
6
|
+
const object = plistObject.safeParse(value);
|
|
7
|
+
const field = (name) => {
|
|
8
|
+
if (!object.success)
|
|
9
|
+
return null;
|
|
10
|
+
const candidate = object.data[name];
|
|
11
|
+
return typeof candidate === "string" ? candidate : null;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
value,
|
|
15
|
+
bundle: {
|
|
16
|
+
identifier: field("CFBundleIdentifier"),
|
|
17
|
+
executable: field("CFBundleExecutable"),
|
|
18
|
+
name: field("CFBundleName"),
|
|
19
|
+
version: field("CFBundleVersion"),
|
|
20
|
+
short_version: field("CFBundleShortVersionString"),
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { err, ok } from "../domain/result.js";
|
|
2
|
+
import { traverseDirectory } from "./ReferenceSourceReaderEntries.js";
|
|
3
|
+
import { compareNames } from "./ReferenceSourceReaderPaths.js";
|
|
4
|
+
import {} from "./ReferenceSourceReaderTypes.js";
|
|
5
|
+
import { isAborted, noFollowOpenSupported, prepareRoot, } from "./ReferenceSourceReaderValidate.js";
|
|
6
|
+
const DEFAULT_LIMITS = {
|
|
7
|
+
maxBytes: 16 * 1024 * 1024,
|
|
8
|
+
maxEntries: 10_000,
|
|
9
|
+
maxDepth: 32,
|
|
10
|
+
maxPathBytes: 4_096,
|
|
11
|
+
};
|
|
12
|
+
const PATH_RACE_LIMITATION = "Path identity is revalidated around operations; Node lacks portable descriptor-relative openat traversal, so a syscall-boundary pathname race remains.";
|
|
13
|
+
/** Read a bounded source tree without intentionally following symbolic links. */
|
|
14
|
+
export const readReferenceSource = async (root, limits = DEFAULT_LIMITS, options = {}) => {
|
|
15
|
+
if (!noFollowOpenSupported())
|
|
16
|
+
return err({
|
|
17
|
+
tag: "reference-source-reader",
|
|
18
|
+
code: "unsupported",
|
|
19
|
+
message: "Safe no-follow file opens are unavailable",
|
|
20
|
+
});
|
|
21
|
+
const prepared = await prepareRoot(root, limits, options.signal);
|
|
22
|
+
if (!prepared.ok)
|
|
23
|
+
return prepared;
|
|
24
|
+
const { canonicalRoot, rootIdentity } = prepared.value;
|
|
25
|
+
const state = {
|
|
26
|
+
root: canonicalRoot,
|
|
27
|
+
rootIdentity,
|
|
28
|
+
limits,
|
|
29
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
30
|
+
...(options.shouldExclude === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { shouldExclude: options.shouldExclude }),
|
|
33
|
+
entries: [],
|
|
34
|
+
pending: [{ path: canonicalRoot, depth: 0 }],
|
|
35
|
+
bytesRead: 0,
|
|
36
|
+
filesSeen: 0,
|
|
37
|
+
truncated: false,
|
|
38
|
+
stopped: false,
|
|
39
|
+
};
|
|
40
|
+
const traversal = await traverse(state);
|
|
41
|
+
if (!traversal.ok)
|
|
42
|
+
return traversal;
|
|
43
|
+
state.entries.sort((left, right) => compareNames(left.path, right.path));
|
|
44
|
+
return ok({
|
|
45
|
+
root: canonicalRoot,
|
|
46
|
+
entries: state.entries,
|
|
47
|
+
bytesRead: state.bytesRead,
|
|
48
|
+
truncated: state.truncated,
|
|
49
|
+
limitations: [
|
|
50
|
+
PATH_RACE_LIMITATION,
|
|
51
|
+
...(state.stopped
|
|
52
|
+
? ["Traversal stopped because the entry limit was reached."]
|
|
53
|
+
: []),
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
const traverse = async (state) => {
|
|
58
|
+
while (state.pending.length > 0 && !state.stopped) {
|
|
59
|
+
if (isAborted(state.signal))
|
|
60
|
+
return err({
|
|
61
|
+
tag: "reference-source-reader",
|
|
62
|
+
code: "cancelled",
|
|
63
|
+
message: "Reference source traversal cancelled",
|
|
64
|
+
});
|
|
65
|
+
const current = state.pending.pop();
|
|
66
|
+
if (current === undefined)
|
|
67
|
+
break;
|
|
68
|
+
const result = await traverseDirectory(state, current);
|
|
69
|
+
if (!result.ok)
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
return ok(undefined);
|
|
73
|
+
};
|