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/macos.mjs
CHANGED
|
@@ -1,38 +1,120 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { worstSeverity } from './rules.mjs';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
3
|
+
// What a Mach-O file, the format of macOS and iOS programs, gives away or leaves open. The
|
|
4
|
+
// facts come from `readMachO`; nothing here runs the file or asks the system about it, so
|
|
5
|
+
// the checks give the same answer on any machine.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// A stripped build keeps a marker or two. A handful of names says little about the code,
|
|
8
|
+
// so names alone are reported from a hundred up.
|
|
9
|
+
const MIN_NAMES = 100;
|
|
10
|
+
const HOME_FOLDER = /^(?:\/Users\/|\/home\/|[A-Za-z]:\\Users\\)/;
|
|
11
|
+
// A path is long, and one is enough to show whose machine it names.
|
|
12
|
+
const MAX_SAMPLE_SOURCES = 1;
|
|
13
|
+
|
|
14
|
+
const HARDENED_RUNTIME = 0x10000;
|
|
15
|
+
// The names `codesign -dv` prints for the bits of `flags=`, so the evidence can be compared with it.
|
|
16
|
+
const SIGNATURE_FLAG_NAMES = [
|
|
17
|
+
[0x1, 'host'],
|
|
18
|
+
[0x2, 'adhoc'],
|
|
19
|
+
[0x100, 'hard'],
|
|
20
|
+
[0x200, 'kill'],
|
|
21
|
+
[0x400, 'expires'],
|
|
22
|
+
[0x800, 'restrict'],
|
|
23
|
+
[0x1000, 'enforcement'],
|
|
24
|
+
[0x2000, 'library-validation'],
|
|
25
|
+
[HARDENED_RUNTIME, 'runtime'],
|
|
26
|
+
[0x20000, 'linker-signed'],
|
|
27
|
+
];
|
|
28
|
+
// Entitlements that open the running program to others, by the system the program is for.
|
|
29
|
+
// `get-task-allow` lets another process attach to it, and is spelled differently on the
|
|
30
|
+
// Mac. `disable-library-validation` lets a Mac program load plug-ins and libraries whoever
|
|
31
|
+
// signed them. Others are left alone: `allow-dyld-environment-variables` still needs that
|
|
32
|
+
// one before it can bring in code from another signer (Apple's page for it says so), and
|
|
33
|
+
// those that loosen memory protection, as a JavaScript engine needs, are a different matter.
|
|
34
|
+
const OPENING_ENTITLEMENTS = {
|
|
35
|
+
macos: [
|
|
36
|
+
['com.apple.security.get-task-allow', 'high'],
|
|
37
|
+
['com.apple.security.cs.disable-library-validation', 'medium'],
|
|
38
|
+
],
|
|
39
|
+
ios: [['get-task-allow', 'high']],
|
|
40
|
+
};
|
|
41
|
+
const isGranted = (entitlements, key) =>
|
|
42
|
+
new RegExp(`<key>${key.replaceAll('.', '\\.')}</key>\\s*<true/>`).test(entitlements);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} path how the file is shown to the user
|
|
46
|
+
* @param {import('./macho.mjs').MachOImage[]} images one per architecture
|
|
47
|
+
*/
|
|
48
|
+
export function checkMachO(path, images) {
|
|
49
|
+
// The system grants entitlements and the Hardened Runtime to a process, by its own executable.
|
|
50
|
+
const executables = images.filter(({ kind }) => kind === 'executable');
|
|
51
|
+
return [
|
|
52
|
+
...checkNames(path, images.filter(({ kind }) => kind !== 'other')),
|
|
53
|
+
...checkEntitlements(path, executables),
|
|
54
|
+
...checkRuntime(path, executables),
|
|
55
|
+
];
|
|
9
56
|
}
|
|
10
57
|
|
|
11
58
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* @param {string} path how the bundle is shown to the user
|
|
16
|
-
* @param {string} appPath absolute path of the `.app` directory
|
|
59
|
+
* Names and paths that only a developer or a debugger needs, left in what ships. Other
|
|
60
|
+
* files link against the names a library exports, so those have to stay; nothing links
|
|
61
|
+
* against a program's, so there every name it defines counts.
|
|
17
62
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
63
|
+
function checkNames(path, images) {
|
|
64
|
+
const builds = images.map((image) => ({
|
|
65
|
+
architecture: image.architecture,
|
|
66
|
+
names: image.localNames + (image.kind === 'executable' ? image.exportedNames : 0),
|
|
67
|
+
debugEntries: image.debugEntries,
|
|
68
|
+
sources: image.sourcePaths,
|
|
69
|
+
}));
|
|
70
|
+
if (builds.length === 0) return [];
|
|
71
|
+
// The builds in a universal binary are of the same code. The one with the most names
|
|
72
|
+
// speaks for the file, so that its numbers can be checked against that one build.
|
|
73
|
+
const [{ architecture, names, debugEntries, sources }] = builds.sort((one, other) => other.names - one.names);
|
|
74
|
+
if (sources.length === 0 && names < MIN_NAMES) return [];
|
|
75
|
+
const detail = {
|
|
76
|
+
architecture,
|
|
77
|
+
names,
|
|
78
|
+
debugEntries,
|
|
79
|
+
sourceCount: sources.length,
|
|
80
|
+
sampleSources: sources.slice(0, MAX_SAMPLE_SOURCES),
|
|
81
|
+
// A path under someone's home folder names the account that built the file.
|
|
82
|
+
namesBuilder: sources.some((source) => HOME_FOLDER.test(source)),
|
|
83
|
+
};
|
|
84
|
+
return [{ rule: 'native-symbols', severity: 'medium', path, detail }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkEntitlements(path, executables) {
|
|
88
|
+
const granted = new Map();
|
|
89
|
+
for (const { system, signature } of executables) {
|
|
90
|
+
for (const [key, severity] of OPENING_ENTITLEMENTS[system] ?? []) {
|
|
91
|
+
if (signature && isGranted(signature.entitlements, key)) granted.set(key, { severity });
|
|
92
|
+
}
|
|
32
93
|
}
|
|
33
|
-
return
|
|
94
|
+
if (granted.size === 0) return [];
|
|
95
|
+
const severity = worstSeverity([...granted.values()]);
|
|
96
|
+
return [{ rule: 'macos-entitlement', severity, path, detail: { entitlements: [...granted.keys()] } }];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The Hardened Runtime is a protection of the Mac, and a Mac program that is not signed
|
|
101
|
+
* has none either. Each architecture's build carries its own signature, so each is listed
|
|
102
|
+
* with what it says.
|
|
103
|
+
*/
|
|
104
|
+
function checkRuntime(path, executables) {
|
|
105
|
+
const unprotected = executables.filter(
|
|
106
|
+
({ system, signature }) => system === 'macos' && !(signature && signature.flags & HARDENED_RUNTIME),
|
|
107
|
+
);
|
|
108
|
+
if (unprotected.length === 0) return [];
|
|
109
|
+
const slices = unprotected.map(({ architecture, signature }) => ({
|
|
110
|
+
architecture,
|
|
111
|
+
flags: signature ? describeFlags(signature.flags) : null,
|
|
112
|
+
}));
|
|
113
|
+
return [{ rule: 'macos-runtime', severity: 'medium', path, detail: { slices } }];
|
|
34
114
|
}
|
|
35
115
|
|
|
36
|
-
|
|
37
|
-
|
|
116
|
+
/** @returns {string} for example '0x10002(adhoc,runtime)', the way `codesign -dv` writes it */
|
|
117
|
+
function describeFlags(flags) {
|
|
118
|
+
const names = SIGNATURE_FLAG_NAMES.filter(([bit]) => flags & bit).map(([, name]) => name);
|
|
119
|
+
return `0x${flags.toString(16)}(${names.join(',') || 'none'})`;
|
|
38
120
|
}
|
package/src/program.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Telling a Linux or Windows program from other files by how it opens. (A macOS or iOS one
|
|
2
|
+
// is known by `readMachO` reading it.)
|
|
3
|
+
|
|
4
|
+
const ELF_MAGIC = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
5
|
+
// In an ELF header: which way round its numbers are written, then what kind of file it is.
|
|
6
|
+
// A program is an executable or a shared object; an object file is a step of a build.
|
|
7
|
+
const ELF_BYTE_ORDER = 5;
|
|
8
|
+
const ELF_BIG_ENDIAN = 2;
|
|
9
|
+
const ELF_KIND = 16;
|
|
10
|
+
const ELF_PROGRAM_KINDS = [2, 3];
|
|
11
|
+
// A Windows program opens with a DOS header that says, at this offset, where the real one starts.
|
|
12
|
+
const DOS_HEADER_BYTES = 64;
|
|
13
|
+
const PE_HEADER_POINTER = 0x3c;
|
|
14
|
+
|
|
15
|
+
/** @param {import('./bytes.mjs').ByteSource} source */
|
|
16
|
+
export function isLinuxOrWindowsProgram(source) {
|
|
17
|
+
if (source.byteLength < DOS_HEADER_BYTES) return false;
|
|
18
|
+
const opening = source.read(0, DOS_HEADER_BYTES);
|
|
19
|
+
if (opening.subarray(0, ELF_MAGIC.length).equals(ELF_MAGIC)) {
|
|
20
|
+
const isBigEndian = opening[ELF_BYTE_ORDER] === ELF_BIG_ENDIAN;
|
|
21
|
+
return ELF_PROGRAM_KINDS.includes(isBigEndian ? opening.readUInt16BE(ELF_KIND) : opening.readUInt16LE(ELF_KIND));
|
|
22
|
+
}
|
|
23
|
+
if (opening.toString('latin1', 0, 2) !== 'MZ') return false;
|
|
24
|
+
const headerOffset = opening.readUInt32LE(PE_HEADER_POINTER);
|
|
25
|
+
return headerOffset + 4 <= source.byteLength && source.read(headerOffset, 4).toString('latin1') === 'PE\0\0';
|
|
26
|
+
}
|
package/src/remote.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
1
2
|
import { checkInlineMaps, checkMapFile, findMapReferences } from './checks/sourcemaps.mjs';
|
|
2
|
-
import {
|
|
3
|
-
import { MAX_FILE_BYTES, ScanError, startResult } from './scan.mjs';
|
|
4
|
-
import { toShown } from './text.mjs';
|
|
3
|
+
import { isScript } from './rules.mjs';
|
|
4
|
+
import { finishResult, MAX_FILE_BYTES, ScanError, startResult } from './scan.mjs';
|
|
5
|
+
import { toShown, toShownText } from './text.mjs';
|
|
5
6
|
|
|
6
7
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
7
8
|
// One page and its own files, read one after another: a check, not a crawl.
|
|
@@ -15,6 +16,9 @@ const USER_AGENT = 'reaproof (source map exposure check)';
|
|
|
15
16
|
const SCRIPT_SOURCE = /<script\b[^>]{0,2000}?\bsrc\s*=\s*["']([^"']{1,2000})["']/gi;
|
|
16
17
|
const LINK_TARGET = /<link\b[^>]{0,2000}?\bhref\s*=\s*["']([^"']{1,2000})["']/gi;
|
|
17
18
|
const CODE_PATH = /\.(?:m?js|css)$/i;
|
|
19
|
+
// Why a file of the site could not be used is the message of the error thrown for it: a
|
|
20
|
+
// short code such as this one or 'HTTP 404'.
|
|
21
|
+
const TOO_LARGE = 'TOO_LARGE';
|
|
18
22
|
|
|
19
23
|
export function isWebAddress(input) {
|
|
20
24
|
return /^https?:\/\//i.test(input);
|
|
@@ -31,18 +35,28 @@ export function isWebAddress(input) {
|
|
|
31
35
|
* @param {string} pageAddress an http(s) address
|
|
32
36
|
*/
|
|
33
37
|
export async function scanDeployedSite(pageAddress) {
|
|
34
|
-
const
|
|
35
|
-
if (!requested) throw new ScanError('page-unreachable', pageAddress);
|
|
36
|
-
const result = startResult({ root: requested.href, isDeployed: true, targets: [requested.href], frameworks: [] });
|
|
37
|
-
const html = await fetchText(requested, result.skippedLargeFiles, null);
|
|
38
|
-
if (!html) throw new ScanError('page-unreachable', pageAddress);
|
|
39
|
-
|
|
38
|
+
const html = await fetchPage(pageAddress);
|
|
40
39
|
// A site may answer from another address than the one typed (www, https). That one is home.
|
|
41
40
|
const page = html.address;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
const result = startResult({
|
|
42
|
+
root: page.href,
|
|
43
|
+
isDeployed: true,
|
|
44
|
+
targets: [page.href],
|
|
45
|
+
targetsPickedByName: false,
|
|
46
|
+
frameworks: [],
|
|
47
|
+
});
|
|
48
|
+
// A file that is not there, or does not answer, is no finding. One too large to read is
|
|
49
|
+
// put on the same list the local scan puts one on.
|
|
50
|
+
const read = async (address) => {
|
|
51
|
+
try {
|
|
52
|
+
return await fetchText(address, page.origin);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.message === TOO_LARGE) result.skippedLargeFiles.push(address.pathname);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
45
58
|
const { ownAssets, otherOriginCount } = listCodeAssets(html.text.slice(0, MAX_PAGE_CHARACTERS), page);
|
|
59
|
+
result.filesSeen = ownAssets.length;
|
|
46
60
|
result.skippedOtherOrigins = otherOriginCount;
|
|
47
61
|
const addFindings = (findings) => {
|
|
48
62
|
for (const finding of findings) result.findings.push({ ...toShown(finding), diskPath: null });
|
|
@@ -53,6 +67,7 @@ export async function scanDeployedSite(pageAddress) {
|
|
|
53
67
|
const code = await read(asset);
|
|
54
68
|
if (!code) continue;
|
|
55
69
|
result.filesScanned += 1;
|
|
70
|
+
if (isScript(extname(asset.pathname).toLowerCase())) result.scriptsScanned += 1;
|
|
56
71
|
addFindings(checkInlineMaps(asset.pathname, code.text));
|
|
57
72
|
|
|
58
73
|
for (const mapAddress of listMapCandidates(code)) {
|
|
@@ -63,7 +78,24 @@ export async function scanDeployedSite(pageAddress) {
|
|
|
63
78
|
if (map) addFindings(checkMapFile(mapAddress.pathname, map.text));
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
|
-
return
|
|
81
|
+
return finishResult(result);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The page itself has to be read, so a failure here ends the check and says why. */
|
|
85
|
+
async function fetchPage(pageAddress) {
|
|
86
|
+
const unreachable = (reason) => new ScanError('page-unreachable', pageAddress, { reason });
|
|
87
|
+
const requested = toAddress(pageAddress);
|
|
88
|
+
if (!requested) throw unreachable('INVALID_ADDRESS');
|
|
89
|
+
try {
|
|
90
|
+
return await fetchText(requested, null);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error.name === 'TimeoutError' || error.name === 'AbortError') throw unreachable('TIMEOUT');
|
|
93
|
+
// Node wraps a network error in "fetch failed" and keeps underneath the system's code
|
|
94
|
+
// for it, or for a request it refuses to make (a blocked port, say) a few words. An
|
|
95
|
+
// answer that could not be used carries its own short code as the message.
|
|
96
|
+
const cause = error.cause ?? error;
|
|
97
|
+
throw unreachable(toShownText(String(cause.code ?? cause.message)));
|
|
98
|
+
}
|
|
67
99
|
}
|
|
68
100
|
|
|
69
101
|
/**
|
|
@@ -108,27 +140,21 @@ function toAddress(reference, base) {
|
|
|
108
140
|
|
|
109
141
|
/**
|
|
110
142
|
* @param {URL} address
|
|
111
|
-
* @param {string[]} skippedLargeFiles gains the path of a file that was there but too large to read
|
|
112
143
|
* @param {string | null} homeOrigin the one site the file may be read from, or null for the page itself
|
|
113
|
-
* @returns {Promise<{ text: string, sourceMapHeader: string | null, address: URL }
|
|
114
|
-
*
|
|
144
|
+
* @returns {Promise<{ text: string, sourceMapHeader: string | null, address: URL }>}
|
|
145
|
+
* `address` is where the file was finally read from
|
|
146
|
+
* @throws when the server answered with something that cannot be used, with a short code
|
|
147
|
+
* as the message, and whatever `fetch` throws when it did not answer
|
|
115
148
|
*/
|
|
116
|
-
async function fetchText(address,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// ECMA-426 section 11.1.1: `sourcemap` is the header, `x-sourcemap` its deprecated name.
|
|
126
|
-
const sourceMapHeader = response.headers.get('sourcemap') ?? response.headers.get('x-sourcemap');
|
|
127
|
-
return { text, sourceMapHeader, address: new URL(response.url) };
|
|
128
|
-
} catch {
|
|
129
|
-
// Unreachable, timed out or cut off: treated the same as a file that is not there.
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
149
|
+
async function fetchText(address, homeOrigin) {
|
|
150
|
+
const response = await request(address, homeOrigin);
|
|
151
|
+
if (!response) throw new Error('LEFT_THE_SITE');
|
|
152
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
153
|
+
const text = await readCapped(response);
|
|
154
|
+
if (text === null) throw new Error(TOO_LARGE);
|
|
155
|
+
// ECMA-426 section 11.1.1: `sourcemap` is the header, `x-sourcemap` its deprecated name.
|
|
156
|
+
const sourceMapHeader = response.headers.get('sourcemap') ?? response.headers.get('x-sourcemap');
|
|
157
|
+
return { text, sourceMapHeader, address: new URL(response.url) };
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
/**
|