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/src/macos.mjs CHANGED
@@ -1,38 +1,120 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { worstSeverity } from './rules.mjs';
2
2
 
3
- const GET_TASK_ALLOW = /<key>com\.apple\.security\.get-task-allow<\/key>\s*<true\/>/;
4
- // `codesign -dv` prints for example: CodeDirectory v=20500 size=278 flags=0x10002(adhoc,runtime)
5
- const CODE_DIRECTORY_FLAGS = /^CodeDirectory .*\bflags=0x[0-9a-f]+\(([^)]*)\)/m;
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
- export function isAppBundleName(name) {
8
- return name.endsWith('.app');
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
- * Reports release-signing settings of a macOS app that make it easy to look inside while
13
- * it runs. Reads the signature with the system's own `codesign`; the app is never started.
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
- export function checkAppBundle(path, appPath) {
19
- if (process.platform !== 'darwin') return [];
20
- const signature = runCodesign(['-dv', appPath]);
21
- // Unsigned, or no codesign on this machine: there is no release signature to judge.
22
- if (signature.status !== 0) return [];
23
-
24
- const findings = [];
25
- const entitlements = runCodesign(['-d', '--entitlements', '-', '--xml', appPath]).stdout ?? '';
26
- if (GET_TASK_ALLOW.test(entitlements)) {
27
- findings.push({ rule: 'macos-entitlement', severity: 'high', path, detail: {} });
28
- }
29
- const flags = signature.stderr.match(CODE_DIRECTORY_FLAGS)?.[1].split(',') ?? [];
30
- if (!flags.includes('runtime')) {
31
- findings.push({ rule: 'macos-runtime', severity: 'medium', path, detail: {} });
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 findings;
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
- function runCodesign(args) {
37
- return spawnSync('codesign', args, { encoding: 'utf8' });
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
  }
@@ -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 { summarizeFindings } from './rules.mjs';
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 requested = toAddress(pageAddress);
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
- Object.assign(result, { root: page.href, targets: [page.href] });
43
- // A file too large to read is put on the same list the local scan puts one on.
44
- const read = (address) => fetchText(address, result.skippedLargeFiles, page.origin);
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 Object.assign(result, summarizeFindings(result.findings));
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 } | null>}
114
- * null when there is nothing usable; `address` is where the file was finally read from
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, skippedLargeFiles, homeOrigin) {
117
- try {
118
- const response = await request(address, homeOrigin);
119
- if (!response?.ok) return null;
120
- const text = await readCapped(response);
121
- if (text === null) {
122
- skippedLargeFiles.push(address.pathname);
123
- return null;
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
  /**