reaproof 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 +51 -19
- package/package.json +1 -1
- package/skills/reaproof/SKILL.md +10 -6
- package/src/bytes.mjs +44 -2
- package/src/checks/paths.mjs +38 -2
- package/src/checks/secrets.mjs +124 -18
- package/src/cli.mjs +89 -34
- package/src/electron.mjs +16 -41
- package/src/macho.mjs +256 -0
- package/src/macos.mjs +110 -28
- package/src/program.mjs +26 -0
- package/src/remote.mjs +58 -32
- package/src/report.mjs +242 -58
- package/src/rules.mjs +25 -9
- package/src/scan.mjs +244 -78
package/src/cli.mjs
CHANGED
|
@@ -5,13 +5,26 @@ import { applyFix, planFix, undoLastFix } from './fix.mjs';
|
|
|
5
5
|
import { MeasureError, measureWithRea, REA_PACKAGE } from './measure.mjs';
|
|
6
6
|
import { isWebAddress, scanDeployedSite } from './remote.mjs';
|
|
7
7
|
import { createReporter, pickLanguage } from './report.mjs';
|
|
8
|
+
import { hasLeakAtOrAbove, LEAK_SEVERITIES } from './rules.mjs';
|
|
8
9
|
import { resolveTargets, scan, ScanError, withoutFiles } from './scan.mjs';
|
|
9
10
|
import { toPrintable, toVisibleJson } from './text.mjs';
|
|
10
11
|
|
|
11
12
|
const COMMANDS = { scan: runScan, fix: runFix, undo: runUndo, measure: runMeasure };
|
|
13
|
+
const OPTIONS = {
|
|
14
|
+
json: { type: 'boolean' },
|
|
15
|
+
yes: { type: 'boolean', short: 'y' },
|
|
16
|
+
// The mildest leak that still stops a release.
|
|
17
|
+
'fail-on': { type: 'string' },
|
|
18
|
+
lang: { type: 'string' },
|
|
19
|
+
help: { type: 'boolean', short: 'h' },
|
|
20
|
+
version: { type: 'boolean', short: 'v' },
|
|
21
|
+
};
|
|
12
22
|
const EXIT_OK = 0;
|
|
13
23
|
const EXIT_FINDINGS = 1;
|
|
14
24
|
const EXIT_USAGE = 2;
|
|
25
|
+
// Nothing could be read: an address that did not answer, or a target with nothing in it
|
|
26
|
+
// that this scanner opens. A check that read nothing must not pass for a clean one.
|
|
27
|
+
const EXIT_UNREAD = 3;
|
|
15
28
|
|
|
16
29
|
/**
|
|
17
30
|
* Everything the command needs from the outside world, so the flow can be driven by a
|
|
@@ -38,24 +51,24 @@ export async function main(argv, outside) {
|
|
|
38
51
|
};
|
|
39
52
|
let parsed;
|
|
40
53
|
try {
|
|
41
|
-
parsed = parseArgs({
|
|
42
|
-
args: argv,
|
|
43
|
-
options: {
|
|
44
|
-
json: { type: 'boolean' },
|
|
45
|
-
yes: { type: 'boolean', short: 'y' },
|
|
46
|
-
lang: { type: 'string' },
|
|
47
|
-
help: { type: 'boolean', short: 'h' },
|
|
48
|
-
version: { type: 'boolean', short: 'v' },
|
|
49
|
-
},
|
|
50
|
-
allowPositionals: true,
|
|
51
|
-
});
|
|
54
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
52
55
|
} catch (error) {
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
// What can still be read of the command says how to answer it: in which language, and as JSON or not.
|
|
57
|
+
const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
|
|
58
|
+
const report = createReporter(pickLanguage(typeof values.lang === 'string' ? values.lang : undefined));
|
|
59
|
+
const wantsJson = values.json === true;
|
|
60
|
+
const reason = report.usageError(error);
|
|
61
|
+
// A person is shown how the command is used. An agent gets the reason alone.
|
|
62
|
+
const message = wantsJson ? reason : `${reason}\n\n${report.help}`;
|
|
63
|
+
return fail(terminal, report, wantsJson, EXIT_USAGE, { code: 'usage', message });
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
const { values
|
|
66
|
+
const { values, positionals } = parsed;
|
|
67
|
+
const options = { ...values, failOn: values['fail-on'] ?? 'high' };
|
|
58
68
|
const report = createReporter(pickLanguage(options.lang));
|
|
69
|
+
if (!LEAK_SEVERITIES.includes(options.failOn)) {
|
|
70
|
+
return fail(terminal, report, options.json, EXIT_USAGE, { code: 'usage', message: report.failOnError(options.failOn) });
|
|
71
|
+
}
|
|
59
72
|
if (options.help) {
|
|
60
73
|
terminal.print(report.help);
|
|
61
74
|
return EXIT_OK;
|
|
@@ -72,17 +85,31 @@ export async function main(argv, outside) {
|
|
|
72
85
|
return await COMMANDS[command](target, options, report, terminal);
|
|
73
86
|
} catch (error) {
|
|
74
87
|
if (error instanceof ScanError) {
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
const { code, path, details } = error;
|
|
89
|
+
const exitCode = code === 'page-unreachable' ? EXIT_UNREAD : EXIT_USAGE;
|
|
90
|
+
return fail(terminal, report, options.json, exitCode, { code, message: report.scanError(error), path, ...details });
|
|
77
91
|
}
|
|
78
92
|
if (error instanceof MeasureError) {
|
|
79
|
-
|
|
80
|
-
return EXIT_USAGE;
|
|
93
|
+
const failure = { code: 'measure-failed', message: report.measureFailed(error) };
|
|
94
|
+
return fail(terminal, report, options.json, EXIT_USAGE, failure);
|
|
81
95
|
}
|
|
82
96
|
throw error;
|
|
83
97
|
}
|
|
84
98
|
}
|
|
85
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Says why the command stopped, and hands back the code it ends with. When JSON was asked
|
|
102
|
+
* for it is JSON on standard output like any other answer, so that whoever parses the
|
|
103
|
+
* output is never handed nothing.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ code: string, message: string }} failure and whatever else is known about it
|
|
106
|
+
*/
|
|
107
|
+
function fail(terminal, report, wantsJson, exitCode, failure) {
|
|
108
|
+
if (wantsJson) terminal.print(toJson({ exitCode, error: failure }, report));
|
|
109
|
+
else terminal.printError(failure.message);
|
|
110
|
+
return exitCode;
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
/**
|
|
87
114
|
* The one command most people need. For a person at a terminal it carries on from the
|
|
88
115
|
* report: it offers to build when nothing is built yet, and to take out what can be taken
|
|
@@ -93,13 +120,14 @@ async function runScan(target, options, report, terminal) {
|
|
|
93
120
|
const result = isWebAddress(target)
|
|
94
121
|
? await scanDeployedSite(target)
|
|
95
122
|
: await scanOrOfferBuild(resolve(target), ask, report, terminal);
|
|
96
|
-
|
|
123
|
+
const ending = endingOf(result, options);
|
|
124
|
+
terminal.print(options.json ? toJson(withExplanation(result, report, ending), report) : report.scan(result));
|
|
97
125
|
|
|
98
126
|
// A deployed result plans nothing: none of its findings is a file on disk.
|
|
99
127
|
const mapPaths = planFix(result);
|
|
100
|
-
if (!ask || mapPaths.length === 0) return
|
|
128
|
+
if (!ask || mapPaths.length === 0) return closeWith(ending, options, report, terminal);
|
|
101
129
|
terminal.print(`\n${report.fixPlan(result.root, mapPaths)}`);
|
|
102
|
-
if (!(await ask(report.confirmQuestion))) return
|
|
130
|
+
if (!(await ask(report.confirmQuestion))) return closeWith(ending, options, report, terminal);
|
|
103
131
|
return takeOutMaps(result, mapPaths, options, report, terminal);
|
|
104
132
|
}
|
|
105
133
|
|
|
@@ -108,7 +136,7 @@ async function scanOrOfferBuild(root, ask, report, terminal) {
|
|
|
108
136
|
return scan(root);
|
|
109
137
|
} catch (error) {
|
|
110
138
|
// Only a missing build output carries the command that would produce it.
|
|
111
|
-
const buildCommand = error instanceof ScanError ? error.buildCommand : null;
|
|
139
|
+
const buildCommand = error instanceof ScanError ? error.details.buildCommand : null;
|
|
112
140
|
if (!ask || !buildCommand || !(await ask(report.buildQuestion(buildCommand.join(' '))))) throw error;
|
|
113
141
|
if (!terminal.runBuild(buildCommand, root)) throw new ScanError('build-failed', root);
|
|
114
142
|
return scan(root);
|
|
@@ -125,9 +153,10 @@ async function runFix(target, options, report, terminal) {
|
|
|
125
153
|
const root = toLocalRoot(target);
|
|
126
154
|
const before = scan(root);
|
|
127
155
|
const mapPaths = planFix(before);
|
|
156
|
+
const ending = endingOf(before, options);
|
|
128
157
|
if (mapPaths.length === 0) {
|
|
129
|
-
terminal.print(options.json ? toJson({ movedCount: 0, before }, report) : report.nothingToFix);
|
|
130
|
-
return
|
|
158
|
+
terminal.print(options.json ? toJson({ ...ending, movedCount: 0, before }, report) : report.nothingToFix);
|
|
159
|
+
return closeWith(ending, options, report, terminal);
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
if (!options.yes) {
|
|
@@ -142,7 +171,7 @@ async function runFix(target, options, report, terminal) {
|
|
|
142
171
|
}
|
|
143
172
|
if (!(await terminal.ask(report.confirmQuestion))) {
|
|
144
173
|
terminal.print(report.cancelled);
|
|
145
|
-
return
|
|
174
|
+
return closeWith(ending, options, report, terminal);
|
|
146
175
|
}
|
|
147
176
|
}
|
|
148
177
|
return takeOutMaps(before, mapPaths, options, report, terminal);
|
|
@@ -152,8 +181,13 @@ function takeOutMaps(before, mapPaths, options, report, terminal) {
|
|
|
152
181
|
const applied = applyFix(before.root, mapPaths);
|
|
153
182
|
// The moved files are the only change, so the rest of the output is not read again.
|
|
154
183
|
const after = withoutFiles(before, mapPaths);
|
|
155
|
-
|
|
156
|
-
|
|
184
|
+
// The check has been made already, so an output with nothing left in it is not a failure.
|
|
185
|
+
const exitCode = hasLeakAtOrAbove(after.leaks, options.failOn) ? EXIT_FINDINGS : EXIT_OK;
|
|
186
|
+
const ending = { failOn: options.failOn, exitCode };
|
|
187
|
+
terminal.print(
|
|
188
|
+
options.json ? toJson({ ...ending, ...applied, before, after }, report) : report.fixDone(before, after, applied),
|
|
189
|
+
);
|
|
190
|
+
return closeWith(ending, options, report, terminal);
|
|
157
191
|
}
|
|
158
192
|
|
|
159
193
|
function runUndo(target, options, report, terminal) {
|
|
@@ -168,7 +202,7 @@ function runUndo(target, options, report, terminal) {
|
|
|
168
202
|
|
|
169
203
|
async function runMeasure(target, options, report, terminal) {
|
|
170
204
|
const root = toLocalRoot(target);
|
|
171
|
-
const targets = resolveTargets(root);
|
|
205
|
+
const { targets } = resolveTargets(root);
|
|
172
206
|
// rea is someone else's program: it is downloaded and run only after a yes.
|
|
173
207
|
if (!options.yes) {
|
|
174
208
|
if (options.json) {
|
|
@@ -196,14 +230,35 @@ async function runMeasure(target, options, report, terminal) {
|
|
|
196
230
|
return EXIT_OK;
|
|
197
231
|
}
|
|
198
232
|
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
/**
|
|
234
|
+
* The JSON a scan prints: the result, plus what it means and what to do, in words. The
|
|
235
|
+
* verdict comes first and the list of findings last, so an answer that a reader cuts short
|
|
236
|
+
* still says the most important thing.
|
|
237
|
+
*
|
|
238
|
+
* @param {{ failOn: string, exitCode: number }} ending
|
|
239
|
+
*/
|
|
240
|
+
function withExplanation(result, report, ending) {
|
|
241
|
+
const { risk, leaks, findings, ...facts } = result;
|
|
242
|
+
return { risk, leaks, ...ending, explain: report.explain(result), ...facts, findings };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* How a command ends when that follows from a result. A leak is worth failing a release
|
|
247
|
+
* check over when it is critical or high, unless `--fail-on` says otherwise: medium ones
|
|
248
|
+
* too, or critical ones alone. The answer says how it ends as well, for whoever reads a
|
|
249
|
+
* log later and for an agent that is not shown the exit code: `failOn` and `exitCode` in
|
|
250
|
+
* the JSON, and a closing line in the text (`closeWith`).
|
|
251
|
+
*/
|
|
252
|
+
function endingOf(result, options) {
|
|
253
|
+
const stops = hasLeakAtOrAbove(result.leaks, options.failOn);
|
|
254
|
+
const exitCode = stops ? EXIT_FINDINGS : result.risk === 'unrated' ? EXIT_UNREAD : EXIT_OK;
|
|
255
|
+
return { failOn: options.failOn, exitCode };
|
|
202
256
|
}
|
|
203
257
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
258
|
+
function closeWith({ failOn, exitCode }, options, report, terminal) {
|
|
259
|
+
const closing = options.json ? null : report.closing(exitCode, failOn);
|
|
260
|
+
if (closing) terminal.print(`\n${closing}`);
|
|
261
|
+
return exitCode;
|
|
207
262
|
}
|
|
208
263
|
|
|
209
264
|
function readPackageVersion() {
|
package/src/electron.mjs
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { readAt } from './bytes.mjs';
|
|
4
|
-
|
|
5
1
|
// Layout from @electron/fuses: the sentinel, one version byte, one length byte, then one
|
|
6
2
|
// byte per fuse in FuseV1Options order.
|
|
7
3
|
const SENTINEL = Buffer.from('dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX');
|
|
8
4
|
const WIRE_VERSION = 1;
|
|
9
5
|
const FUSE_ENABLED = 0x31;
|
|
10
6
|
const WIRE_HEADER_BYTES = 2;
|
|
11
|
-
const SCAN_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
12
7
|
// Fuses that let someone run or debug the shipped app as a development tool. All three are
|
|
13
8
|
// on in a stock Electron build.
|
|
14
9
|
const DEVELOPMENT_FUSES = [
|
|
@@ -16,53 +11,33 @@ const DEVELOPMENT_FUSES = [
|
|
|
16
11
|
[2, 'nodeOptions'],
|
|
17
12
|
[3, 'nodeCliInspect'],
|
|
18
13
|
];
|
|
19
|
-
const MACOS_FRAMEWORK_NAME = 'Electron Framework.framework';
|
|
20
|
-
|
|
21
|
-
/** Where the fuses of a macOS Electron app live, given a directory the scanner came across. */
|
|
22
|
-
export function electronBinaryIn(directory, name) {
|
|
23
|
-
return name === MACOS_FRAMEWORK_NAME ? join(directory, 'Electron Framework') : null;
|
|
24
|
-
}
|
|
25
14
|
|
|
26
15
|
/**
|
|
27
|
-
* Reports development fuses left on
|
|
16
|
+
* Reports development fuses left on, when a stretch of a file holds Electron's fuse list.
|
|
17
|
+
* The list sits in Electron's own binary, whatever the system and whatever the file is
|
|
18
|
+
* called, so it is looked for in the bytes and not by name.
|
|
28
19
|
*
|
|
29
|
-
* @param {string} path how the
|
|
30
|
-
* @param {
|
|
20
|
+
* @param {string} path how the file is shown to the user
|
|
21
|
+
* @param {import('./bytes.mjs').ByteSource} source the whole file
|
|
22
|
+
* @param {{ position: number, bytes: Buffer }} chunk the stretch of it being searched
|
|
31
23
|
*/
|
|
32
|
-
export function checkElectronFuses(path,
|
|
33
|
-
const
|
|
24
|
+
export function checkElectronFuses(path, source, { position, bytes }) {
|
|
25
|
+
const index = bytes.indexOf(SENTINEL);
|
|
26
|
+
if (index === -1) return [];
|
|
27
|
+
const enabled = readEnabledDevelopmentFuses(source, position + index + SENTINEL.length);
|
|
34
28
|
if (!enabled || enabled.length === 0) return [];
|
|
35
29
|
return [{ rule: 'electron-fuse', severity: 'medium', path, detail: { enabled } }];
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
/** @returns {string[] | null} null when the
|
|
39
|
-
function readEnabledDevelopmentFuses(
|
|
40
|
-
let fd;
|
|
32
|
+
/** @returns {string[] | null} null when what follows the sentinel is not a fuse list this can read */
|
|
33
|
+
function readEnabledDevelopmentFuses(source, wirePosition) {
|
|
41
34
|
try {
|
|
42
|
-
|
|
43
|
-
const sentinelPosition = findSentinel(fd, fstatSync(fd).size);
|
|
44
|
-
if (sentinelPosition === -1) return null;
|
|
45
|
-
|
|
46
|
-
const wirePosition = sentinelPosition + SENTINEL.length;
|
|
47
|
-
const [version, length] = readAt(fd, wirePosition, WIRE_HEADER_BYTES);
|
|
35
|
+
const [version, length] = source.read(wirePosition, WIRE_HEADER_BYTES);
|
|
48
36
|
if (version !== WIRE_VERSION) return null;
|
|
49
|
-
const wire =
|
|
50
|
-
return DEVELOPMENT_FUSES.filter(([
|
|
37
|
+
const wire = source.read(wirePosition + WIRE_HEADER_BYTES, length);
|
|
38
|
+
return DEVELOPMENT_FUSES.filter(([fuse]) => wire[fuse] === FUSE_ENABLED).map(([, name]) => name);
|
|
51
39
|
} catch {
|
|
52
|
-
//
|
|
40
|
+
// Cut short: nothing can be said about it.
|
|
53
41
|
return null;
|
|
54
|
-
} finally {
|
|
55
|
-
if (fd !== undefined) closeSync(fd);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Reads the file in overlapping chunks, so a sentinel across a chunk boundary is still found. */
|
|
60
|
-
function findSentinel(fd, fileBytes) {
|
|
61
|
-
const buffer = Buffer.alloc(SCAN_CHUNK_BYTES + SENTINEL.length - 1);
|
|
62
|
-
for (let position = 0; position < fileBytes; position += SCAN_CHUNK_BYTES) {
|
|
63
|
-
const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
|
|
64
|
-
const index = buffer.subarray(0, bytesRead).indexOf(SENTINEL);
|
|
65
|
-
if (index !== -1) return position + index;
|
|
66
42
|
}
|
|
67
|
-
return -1;
|
|
68
43
|
}
|
package/src/macho.mjs
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Layouts are those of Apple's <mach-o/fat.h>, <mach-o/loader.h> and <mach-o/nlist.h>, and
|
|
2
|
+
// of xnu's cs_blobs.h for the signature. A Mach-O is little-endian on every Mac built since
|
|
3
|
+
// 2006; the universal wrapper and the signature are big-endian.
|
|
4
|
+
const UNIVERSAL_MAGIC = 0xcafebabe;
|
|
5
|
+
const UNIVERSAL_MAGIC_64 = 0xcafebabf;
|
|
6
|
+
const MAGIC_32 = 0xfeedface;
|
|
7
|
+
const MAGIC_64 = 0xfeedfacf;
|
|
8
|
+
// A Java class file opens with the same four bytes as a universal binary and then states
|
|
9
|
+
// its version, 45 or more, where a universal binary counts its slices.
|
|
10
|
+
const MAX_SLICES = 16;
|
|
11
|
+
const HEADER_BYTES = { [MAGIC_32]: 28, [MAGIC_64]: 32 };
|
|
12
|
+
const SYMBOL_BYTES = { [MAGIC_32]: 12, [MAGIC_64]: 16 };
|
|
13
|
+
const MAX_COMMAND_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const FILE_KINDS = { 2: 'executable', 6: 'library', 8: 'library' };
|
|
16
|
+
const ARM64 = 0x0100000c;
|
|
17
|
+
const ARCHITECTURES = { [ARM64]: 'arm64', 0x01000007: 'x86_64', 0x0200000c: 'arm64_32', 7: 'i386', 12: 'arm' };
|
|
18
|
+
const ARM64E_SUBTYPE = 2;
|
|
19
|
+
const SUBTYPE_MASK = 0x00ffffff;
|
|
20
|
+
|
|
21
|
+
const LC_SYMTAB = 0x2;
|
|
22
|
+
const LC_CODE_SIGNATURE = 0x1d;
|
|
23
|
+
const LC_BUILD_VERSION = 0x32;
|
|
24
|
+
// Builds from before LC_BUILD_VERSION say what they are for by which "minimum version"
|
|
25
|
+
// command they carry: macOS, iOS, tvOS or watchOS.
|
|
26
|
+
const PLATFORM_BY_VERSION_COMMAND = { 0x24: 1, 0x25: 2, 0x2f: 3, 0x30: 4 };
|
|
27
|
+
// PLATFORM_* of <mach-o/loader.h>. Mac Catalyst apps run on the Mac under its rules; the
|
|
28
|
+
// phone, TV, watch and headset systems and their simulators share theirs.
|
|
29
|
+
const SYSTEM_BY_PLATFORM = { 1: 'macos', 6: 'macos', 2: 'ios', 3: 'ios', 4: 'ios', 7: 'ios', 8: 'ios', 9: 'ios', 11: 'ios', 12: 'ios' };
|
|
30
|
+
const MACOS_PLATFORM = 1;
|
|
31
|
+
|
|
32
|
+
// In a symbol's type byte: any of the top three bits marks an entry written for a debugger,
|
|
33
|
+
// the lowest bit a name that other files may link against, and the three between them say
|
|
34
|
+
// where the name is defined, or with none of them set, that it is defined somewhere else.
|
|
35
|
+
const DEBUG_ENTRY_BITS = 0xe0;
|
|
36
|
+
const EXTERNAL_BIT = 0x01;
|
|
37
|
+
const DEFINED_BITS = 0x0e;
|
|
38
|
+
const SOURCE_NAME_ENTRY = 0x64;
|
|
39
|
+
const SYMBOLS_PER_READ = 65536;
|
|
40
|
+
const MAX_NAME_BYTES = 1024;
|
|
41
|
+
// Real programs name a few thousand original files in a few hundred kilobytes. The limit
|
|
42
|
+
// keeps a file that claims far more from filling memory.
|
|
43
|
+
const MAX_SOURCE_NAME_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
const EMBEDDED_SIGNATURE_MAGIC = 0xfade0cc0;
|
|
46
|
+
const CODE_DIRECTORY_MAGIC = 0xfade0c02;
|
|
47
|
+
const ENTITLEMENTS_MAGIC = 0xfade7171;
|
|
48
|
+
const CODE_DIRECTORY_SLOT = 0;
|
|
49
|
+
const ENTITLEMENTS_SLOT = 5;
|
|
50
|
+
const MAX_SIGNATURE_BLOBS = 64;
|
|
51
|
+
const MAX_ENTITLEMENTS_BYTES = 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One architecture's build inside a Mach-O file.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {object} MachOImage
|
|
57
|
+
* @property {string} architecture for example 'arm64'
|
|
58
|
+
* @property {'executable' | 'library' | 'other'} kind
|
|
59
|
+
* @property {'macos' | 'ios' | 'other'} system which family of Apple systems it is built for;
|
|
60
|
+
* 'ios' takes in tvOS, watchOS and visionOS, whose rules are the same
|
|
61
|
+
* @property {number} debugEntries symbol table entries written for a debugger
|
|
62
|
+
* @property {number} localNames names of functions and variables that nothing outside the file needs
|
|
63
|
+
* @property {number} exportedNames names defined here that other files may link against
|
|
64
|
+
* @property {string[]} sourcePaths the original files, as the linker recorded where they were
|
|
65
|
+
* @property {{ flags: number, entitlements: string } | null} signature null when the build is not signed
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads what a Mach-O file says about itself, without running it or any other program.
|
|
70
|
+
*
|
|
71
|
+
* @param {import('./bytes.mjs').ByteSource} source
|
|
72
|
+
* @returns {MachOImage[] | null} one image per architecture; null when the bytes are not a
|
|
73
|
+
* Mach-O, or are cut short or malformed, which the system would not load either
|
|
74
|
+
*/
|
|
75
|
+
export function readMachO(source) {
|
|
76
|
+
try {
|
|
77
|
+
const images = listSlices(source).map(({ start, bytes }) => readImage(readerWithin(source.read, start, bytes)));
|
|
78
|
+
return images.length > 0 && !images.includes(null) ? images : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A reader that stays inside one stretch of the bytes, whatever offsets the file claims. */
|
|
85
|
+
function readerWithin(read, start, bytes) {
|
|
86
|
+
return Object.assign(
|
|
87
|
+
(offset, length) => {
|
|
88
|
+
if (offset + length > bytes) throw new Error('a read reaches past the end');
|
|
89
|
+
return read(start + offset, length);
|
|
90
|
+
},
|
|
91
|
+
{ bytes },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @returns {{ start: number, bytes: number }[]} where each architecture's build sits; none for another kind of file */
|
|
96
|
+
function listSlices(source) {
|
|
97
|
+
if (source.byteLength < 8) return [];
|
|
98
|
+
const opening = source.read(0, 8);
|
|
99
|
+
const magic = opening.readUInt32BE(0);
|
|
100
|
+
if (magic !== UNIVERSAL_MAGIC && magic !== UNIVERSAL_MAGIC_64) return [{ start: 0, bytes: source.byteLength }];
|
|
101
|
+
|
|
102
|
+
const sliceCount = opening.readUInt32BE(4);
|
|
103
|
+
if (sliceCount > MAX_SLICES) return [];
|
|
104
|
+
const isWide = magic === UNIVERSAL_MAGIC_64;
|
|
105
|
+
const entryBytes = isWide ? 32 : 20;
|
|
106
|
+
const table = source.read(8, sliceCount * entryBytes);
|
|
107
|
+
return Array.from({ length: sliceCount }, (_, index) => {
|
|
108
|
+
const entry = table.subarray(index * entryBytes);
|
|
109
|
+
const start = isWide ? Number(entry.readBigUInt64BE(8)) : entry.readUInt32BE(8);
|
|
110
|
+
const bytes = isWide ? Number(entry.readBigUInt64BE(16)) : entry.readUInt32BE(12);
|
|
111
|
+
if (start + bytes > source.byteLength) throw new Error('a slice reaches past the end of the file');
|
|
112
|
+
return { start, bytes };
|
|
113
|
+
}).sort((one, other) => one.start - other.start).map((slice, index, slices) => {
|
|
114
|
+
// Each build has its own stretch of the file. Builds that share bytes are not what a
|
|
115
|
+
// linker writes, and would have the same bytes read once for every one of them.
|
|
116
|
+
if (index > 0 && slice.start < slices[index - 1].start + slices[index - 1].bytes) throw new Error('slices overlap');
|
|
117
|
+
return slice;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {((offset: number, length: number) => Buffer) & { bytes: number }} read stays inside the slice
|
|
123
|
+
* @returns {MachOImage | null} null when the slice is not a Mach-O image
|
|
124
|
+
*/
|
|
125
|
+
function readImage(read) {
|
|
126
|
+
const magic = read.bytes >= 4 ? read(0, 4).readUInt32LE(0) : 0;
|
|
127
|
+
if (!(magic in HEADER_BYTES)) return null;
|
|
128
|
+
const header = read(0, HEADER_BYTES[magic]);
|
|
129
|
+
const commandBytes = header.readUInt32LE(20);
|
|
130
|
+
if (commandBytes > MAX_COMMAND_BYTES) throw new Error('the load commands are implausibly large');
|
|
131
|
+
const commands = read(HEADER_BYTES[magic], commandBytes);
|
|
132
|
+
const commandCount = header.readUInt32LE(16);
|
|
133
|
+
|
|
134
|
+
let symbolTable = null;
|
|
135
|
+
let signature = null;
|
|
136
|
+
// A build that does not say what it is for is taken as one for the Mac.
|
|
137
|
+
let platform = MACOS_PLATFORM;
|
|
138
|
+
// A read past the end of the commands throws, which also ends a count that is not true.
|
|
139
|
+
for (let index = 0, offset = 0; index < commandCount; index += 1) {
|
|
140
|
+
const command = commands.readUInt32LE(offset);
|
|
141
|
+
const size = commands.readUInt32LE(offset + 4);
|
|
142
|
+
if (size < 8) throw new Error('a load command has no size');
|
|
143
|
+
if (command === LC_SYMTAB) {
|
|
144
|
+
symbolTable = {
|
|
145
|
+
offset: commands.readUInt32LE(offset + 8),
|
|
146
|
+
count: commands.readUInt32LE(offset + 12),
|
|
147
|
+
namesOffset: commands.readUInt32LE(offset + 16),
|
|
148
|
+
namesBytes: commands.readUInt32LE(offset + 20),
|
|
149
|
+
};
|
|
150
|
+
} else if (command === LC_CODE_SIGNATURE) {
|
|
151
|
+
signature = { offset: commands.readUInt32LE(offset + 8), bytes: commands.readUInt32LE(offset + 12) };
|
|
152
|
+
} else if (command === LC_BUILD_VERSION) {
|
|
153
|
+
platform = commands.readUInt32LE(offset + 8);
|
|
154
|
+
} else if (command in PLATFORM_BY_VERSION_COMMAND) {
|
|
155
|
+
platform = PLATFORM_BY_VERSION_COMMAND[command];
|
|
156
|
+
}
|
|
157
|
+
offset += size;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
architecture: nameArchitecture(header.readUInt32LE(4), header.readUInt32LE(8)),
|
|
162
|
+
kind: FILE_KINDS[header.readUInt32LE(12)] ?? 'other',
|
|
163
|
+
system: SYSTEM_BY_PLATFORM[platform] ?? 'other',
|
|
164
|
+
...(symbolTable ? readSymbols(read, symbolTable, SYMBOL_BYTES[magic]) : NO_SYMBOLS),
|
|
165
|
+
signature: signature && readSignature(readerWithin(read, signature.offset, signature.bytes)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const NO_SYMBOLS = { debugEntries: 0, localNames: 0, exportedNames: 0, sourcePaths: [] };
|
|
170
|
+
|
|
171
|
+
function nameArchitecture(cpuType, cpuSubtype) {
|
|
172
|
+
if (cpuType === ARM64 && (cpuSubtype & SUBTYPE_MASK) === ARM64E_SUBTYPE) return 'arm64e';
|
|
173
|
+
return ARCHITECTURES[cpuType] ?? `cpu ${cpuType}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readSymbols(read, { offset, count, namesOffset, namesBytes }, symbolBytes) {
|
|
177
|
+
let debugEntries = 0;
|
|
178
|
+
let localNames = 0;
|
|
179
|
+
let exportedNames = 0;
|
|
180
|
+
let sourceNameBytes = 0;
|
|
181
|
+
const sourceNames = [];
|
|
182
|
+
const readName = (nameOffset) => {
|
|
183
|
+
if (nameOffset >= namesBytes) return '';
|
|
184
|
+
const name = read(namesOffset + nameOffset, Math.min(MAX_NAME_BYTES, namesBytes - nameOffset));
|
|
185
|
+
const end = name.indexOf(0);
|
|
186
|
+
return name.toString('utf8', 0, end === -1 ? name.length : end);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (let first = 0; first < count; first += SYMBOLS_PER_READ) {
|
|
190
|
+
const symbolsRead = Math.min(SYMBOLS_PER_READ, count - first);
|
|
191
|
+
const symbols = read(offset + first * symbolBytes, symbolsRead * symbolBytes);
|
|
192
|
+
for (let at = 0; at < symbols.length; at += symbolBytes) {
|
|
193
|
+
const type = symbols[at + 4];
|
|
194
|
+
if (type & DEBUG_ENTRY_BITS) {
|
|
195
|
+
debugEntries += 1;
|
|
196
|
+
if (type === SOURCE_NAME_ENTRY && sourceNameBytes < MAX_SOURCE_NAME_BYTES) {
|
|
197
|
+
const name = readName(symbols.readUInt32LE(at));
|
|
198
|
+
sourceNames.push(name);
|
|
199
|
+
// An empty name still costs a read, so it counts for something.
|
|
200
|
+
sourceNameBytes += name.length + 1;
|
|
201
|
+
}
|
|
202
|
+
} else if (!(type & EXTERNAL_BIT)) {
|
|
203
|
+
localNames += 1;
|
|
204
|
+
} else if (type & DEFINED_BITS) {
|
|
205
|
+
exportedNames += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { debugEntries, localNames, exportedNames, sourcePaths: joinSourcePaths(sourceNames) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The linker records each original file as two names in a row, its folder (ending in a
|
|
214
|
+
* slash) and then the file, and closes the file's entries with an empty name.
|
|
215
|
+
*/
|
|
216
|
+
function joinSourcePaths(names) {
|
|
217
|
+
const paths = new Set();
|
|
218
|
+
let folder = '';
|
|
219
|
+
for (const name of names) {
|
|
220
|
+
if (name.endsWith('/')) {
|
|
221
|
+
folder = name;
|
|
222
|
+
} else {
|
|
223
|
+
if (name) paths.add(name.startsWith('/') ? name : folder + name);
|
|
224
|
+
folder = '';
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return [...paths];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {(offset: number, length: number) => Buffer} read stays inside the signature */
|
|
231
|
+
function readSignature(read) {
|
|
232
|
+
const opening = read(0, 12);
|
|
233
|
+
if (opening.readUInt32BE(0) !== EMBEDDED_SIGNATURE_MAGIC) throw new Error('not an embedded signature');
|
|
234
|
+
const blobCount = opening.readUInt32BE(8);
|
|
235
|
+
if (blobCount > MAX_SIGNATURE_BLOBS) throw new Error('the signature lists implausibly many parts');
|
|
236
|
+
const index = read(12, blobCount * 8);
|
|
237
|
+
|
|
238
|
+
let flags = null;
|
|
239
|
+
let entitlements = '';
|
|
240
|
+
for (let at = 0; at < index.length; at += 8) {
|
|
241
|
+
const slot = index.readUInt32BE(at);
|
|
242
|
+
const blobOffset = index.readUInt32BE(at + 4);
|
|
243
|
+
if (slot === CODE_DIRECTORY_SLOT) {
|
|
244
|
+
const codeDirectory = read(blobOffset, 16);
|
|
245
|
+
if (codeDirectory.readUInt32BE(0) !== CODE_DIRECTORY_MAGIC) throw new Error('not a code directory');
|
|
246
|
+
flags = codeDirectory.readUInt32BE(12);
|
|
247
|
+
} else if (slot === ENTITLEMENTS_SLOT) {
|
|
248
|
+
const blob = read(blobOffset, 8);
|
|
249
|
+
if (blob.readUInt32BE(0) !== ENTITLEMENTS_MAGIC) throw new Error('not an entitlements list');
|
|
250
|
+
const textBytes = Math.min(blob.readUInt32BE(4) - 8, MAX_ENTITLEMENTS_BYTES);
|
|
251
|
+
entitlements = read(blobOffset + 8, textBytes).toString('utf8');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (flags === null) throw new Error('the signature has no code directory');
|
|
255
|
+
return { flags, entitlements };
|
|
256
|
+
}
|