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/scan.mjs CHANGED
@@ -1,17 +1,37 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
- import { basename, extname, join, relative, resolve, sep } from 'node:path';
1
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
3
4
  import { openAsar } from './asar.mjs';
4
- import { checkStrayDirectory, strayDirectoryKind } from './checks/paths.mjs';
5
- import { checkElectronFuses, electronBinaryIn } from './electron.mjs';
5
+ import { fileBytes, lazyBytes, readChunks } from './bytes.mjs';
6
+ import { checkStrayDirectory, isSealedContainer, isTextless, strayDirectoryKind } from './checks/paths.mjs';
7
+ import { checkSecrets, checkSecretsInBytes } from './checks/secrets.mjs';
8
+ import { checkElectronFuses } from './electron.mjs';
6
9
  import { checkEnvValues, readEnvSecrets } from './env.mjs';
7
- import { checkAppBundle, isAppBundleName } from './macos.mjs';
8
- import { archiveFinding, checkFile, checkPath, hasContentChecks, summarizeFindings } from './rules.mjs';
10
+ import { readMachO } from './macho.mjs';
11
+ import { checkMachO } from './macos.mjs';
12
+ import { isLinuxOrWindowsProgram } from './program.mjs';
13
+ import { archiveFinding, checkFile, checkPath, hasContentChecks, isScript, summarizeFindings } from './rules.mjs';
9
14
  import { toShown, toShownText } from './text.mjs';
10
15
 
11
- // Where common toolchains put what actually ships. For Next.js only `.next/static` reaches browsers.
16
+ // Where JavaScript toolchains put what actually ships. For Next.js only `.next/static` reaches browsers.
12
17
  const KNOWN_OUTPUT_DIRS = ['.next/static', 'out', 'dist', 'build', 'release', '.output/public'];
13
- // The most that is read of one file, on disk, in an archive or from a deployed site.
18
+ // What marks the root of a project in a language whose builds land wherever its developer says.
19
+ const NATIVE_PROJECT_FILE =
20
+ /^(?:Package\.swift|Cargo\.toml|go\.mod|build\.gradle(?:\.kts)?|pubspec\.yaml|CMakeLists\.txt|.+\.xcodeproj|.+\.xcworkspace)$/;
21
+ // The most that is read of one file held whole in memory: a text file, wherever it is, and
22
+ // any file inside an archive.
14
23
  export const MAX_FILE_BYTES = 30 * 1024 * 1024;
24
+ // The most that is searched of one file on disk as bytes. It is read a chunk at a time, so
25
+ // what this limits is time.
26
+ const MAX_SEARCHED_BYTES = 1024 * 1024 * 1024;
27
+ // A chunk read as text stays in V8's own heap, and is freed at once, only under about a
28
+ // megabyte: larger strings are kept outside the heap and pile up until it gets to them.
29
+ export const CHUNK_BYTES = 896 * 1024;
30
+ // Each chunk runs this far into the next, so a key across the join is whole in one of them,
31
+ // with what stands around it.
32
+ export const OVERLAP_BYTES = 64 * 1024;
33
+ // One file full of key-shaped bytes is noise or an attack, not that many keys.
34
+ const MAX_FINDINGS_IN_BYTES = 200;
15
35
  const FRAMEWORK_BY_PACKAGE = { next: 'next', vite: 'vite', 'react-scripts': 'cra', electron: 'electron' };
16
36
  const PACKAGE_MANAGER_BY_LOCKFILE = [
17
37
  ['pnpm-lock.yaml', 'pnpm'],
@@ -23,34 +43,43 @@ const PACKAGE_MANAGER_BY_LOCKFILE = [
23
43
  /** A problem with what the user pointed at, as opposed to a bug in the scan. */
24
44
  export class ScanError extends Error {
25
45
  /**
26
- * @param {'path-not-found' | 'no-build-output' | 'build-failed' | 'page-unreachable' | 'local-only'} code
27
- * @param {string[] | null} [buildCommand] the command that would produce the missing output, when known
46
+ * @param {'path-not-found' | 'no-build-output' | 'artifact-needed' | 'build-failed' | 'page-unreachable' | 'local-only'} code
47
+ * @param {{ buildCommand?: string[] | null, projectFile?: string, reason?: string }} [details]
48
+ * the command that would produce a missing output, the file that marks a project root,
49
+ * or why an address did not answer
28
50
  */
29
- constructor(code, path, buildCommand) {
51
+ constructor(code, path, details = {}) {
30
52
  super(`${code}: ${path}`);
31
53
  this.code = code;
32
54
  this.path = path;
33
- this.buildCommand = buildCommand;
55
+ this.details = details;
34
56
  }
35
57
  }
36
58
 
37
59
  /**
38
- * Finds the shipped output under a project, or accepts the path itself when it is the output.
60
+ * Decides what to scan for the path someone gave. A folder with a package.json is a
61
+ * JavaScript project, and what ships from one sits where its toolchain puts it. Anything
62
+ * else is taken as the thing that ships. The exception is the root of a project in another
63
+ * language: its builds land wherever its developer says, so the answer is asked for and
64
+ * not guessed.
39
65
  *
40
66
  * @param {string} root absolute path
41
- * @returns {string[]} absolute paths of directories or archives to scan
67
+ * @returns {{ targets: string[], targetsPickedByName: boolean }} absolute paths of what to
68
+ * scan, and whether they were picked by folder name and not pointed at
42
69
  */
43
70
  export function resolveTargets(root) {
44
71
  if (!existsSync(root)) throw new ScanError('path-not-found', root);
45
- if (statSync(root).isFile()) return [root];
72
+ if (statSync(root).isFile()) return { targets: [root], targetsPickedByName: false };
46
73
 
47
- const outputDirs = KNOWN_OUTPUT_DIRS.map((dir) => join(root, dir)).filter(isDirectory);
48
- if (outputDirs.length > 0) return outputDirs;
49
- // A project root without output would only "find" the developer's own source tree.
50
74
  if (existsSync(join(root, 'package.json'))) {
51
- throw new ScanError('no-build-output', root, findBuildCommand(root));
75
+ const outputDirs = KNOWN_OUTPUT_DIRS.map((dir) => join(root, dir)).filter(isDirectory);
76
+ // A project root without output would only "find" the developer's own source tree.
77
+ if (outputDirs.length === 0) throw new ScanError('no-build-output', root, { buildCommand: findBuildCommand(root) });
78
+ return { targets: outputDirs, targetsPickedByName: true };
52
79
  }
53
- return [root];
80
+ const projectFile = readdirSync(root).find((name) => NATIVE_PROJECT_FILE.test(name));
81
+ if (projectFile) throw new ScanError('artifact-needed', root, { projectFile });
82
+ return { targets: [root], targetsPickedByName: false };
54
83
  }
55
84
 
56
85
  /**
@@ -66,32 +95,99 @@ export function toDisplayPath(root, path) {
66
95
  /**
67
96
  * The result every scan starts from, so the reporter reads one shape wherever the files were.
68
97
  *
69
- * @param {{ root: string, targets: string[], frameworks: string[] }} subject what was looked at
98
+ * `filesSeen` counts every file met and `filesScanned` those read for their content, so a
99
+ * file that was passed over shows in the difference whatever the reason. `scriptsScanned`
100
+ * and `programsScanned` say how many of the files read were JavaScript and native programs.
101
+ * The three lists name what could not be read for a reason worth telling.
102
+ *
103
+ * @param {{ root: string, targets: string[], targetsPickedByName: boolean, frameworks: string[] }} subject what was looked at
70
104
  */
71
105
  export function startResult(subject) {
72
- return { ...subject, findings: [], filesScanned: 0, skippedLargeFiles: [], unreadableArchives: [] };
106
+ return {
107
+ ...subject,
108
+ findings: [],
109
+ filesSeen: 0,
110
+ filesScanned: 0,
111
+ scriptsScanned: 0,
112
+ programsScanned: 0,
113
+ skippedLargeFiles: [],
114
+ unreadableArchives: [],
115
+ unopenedContainers: [],
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Closes a result with its risk and leak counts, which follow from what was found and from
121
+ * whether what ships was read at all. It was not when nothing was read. Nor was it when a
122
+ * container stayed unopened and no program or script was read beside it: what ships is
123
+ * then most likely inside the container, whatever small files lie around it.
124
+ */
125
+ export function finishResult(result) {
126
+ const readCode = result.programsScanned + result.scriptsScanned > 0;
127
+ const wasRead = result.filesScanned > 0 && (readCode || result.unopenedContainers.length === 0);
128
+ return Object.assign(result, summarizeFindings(result.findings, wasRead));
73
129
  }
74
130
 
75
131
  /**
76
132
  * Reads shipped files the way an analysis tool would and reports what they give away.
77
133
  *
78
- * @param {string} inputPath project root, output directory, packaged app or `.asar` archive
134
+ * @param {string} inputPath project root, output directory, packaged app, program or `.asar` archive
79
135
  */
80
136
  export function scan(inputPath) {
81
137
  const root = resolve(inputPath);
82
- const result = startResult({ root, targets: resolveTargets(root), frameworks: detectFrameworks(root) });
83
- const state = { result, envSecrets: readEnvSecrets(root), outermostApp: null };
138
+ const targets = resolveTargets(root);
139
+ // What must not ship, and what built it, is said by the project the path belongs to, also
140
+ // when the path given is the build output inside it.
141
+ const project = findProjectFolder(root) ?? root;
142
+ const result = startResult({ root, ...targets, frameworks: detectFrameworks(project) });
143
+ const envSecrets = readEnvSecrets(project).map((secret) => ({
144
+ ...secret,
145
+ envFile: toDisplayPath(root, join(project, secret.envFile)),
146
+ }));
147
+ const state = { result, envSecrets };
84
148
 
85
149
  for (const target of result.targets) {
86
150
  // What was pointed at is an entry like any other, so it gets the checks its kind gets.
87
151
  const pointedAt = { path: target, name: basename(target), isDirectory: statSync(target).isDirectory() };
88
- for (const { path, isDirectory } of walk(pointedAt, listDisk)) {
152
+ for (const { path, name, isDirectory } of walk(pointedAt, listDisk)) {
89
153
  const displayPath = toDisplayPath(root, path);
90
- if (isDirectory) scanDirectory(path, displayPath, state);
91
- else scanDiskFile(path, displayPath, state);
154
+ if (isDirectory) addFindings(state, checkStrayDirectory({ path: displayPath, name }), path);
155
+ else scanFile({ displayPath, name, diskPath: path, open: (use) => openOnDisk(path, use) }, state);
92
156
  }
93
157
  }
94
- return Object.assign(result, summarizeFindings(result.findings));
158
+ return finishResult(result);
159
+ }
160
+
161
+ /**
162
+ * The nearest folder, the path itself or one above it, that holds a project file. The
163
+ * search stops short of the home folder: a package.json left there would make everything
164
+ * under it one project, and the `.env` beside it a file to read.
165
+ */
166
+ function findProjectFolder(path) {
167
+ const home = toRealPath(homedir());
168
+ for (let folder = path; toRealPath(folder) !== home; folder = dirname(folder)) {
169
+ if (listNames(folder).some((name) => name === 'package.json' || NATIVE_PROJECT_FILE.test(name))) return folder;
170
+ if (dirname(folder) === folder) break;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** A path as the system itself spells it, so that two spellings of one folder compare equal. */
176
+ function toRealPath(path) {
177
+ try {
178
+ return realpathSync(path);
179
+ } catch {
180
+ return path;
181
+ }
182
+ }
183
+
184
+ /** @returns {string[]} what a folder holds; nothing for a file, or a folder that cannot be listed */
185
+ function listNames(folder) {
186
+ try {
187
+ return readdirSync(folder);
188
+ } catch {
189
+ return [];
190
+ }
95
191
  }
96
192
 
97
193
  /**
@@ -104,41 +200,61 @@ export function scan(inputPath) {
104
200
  export function withoutFiles(scanResult, removedPaths) {
105
201
  const removed = new Set(removedPaths);
106
202
  const findings = scanResult.findings.filter(({ diskPath }) => !removed.has(diskPath));
107
- return {
203
+ return finishResult({
108
204
  ...scanResult,
109
205
  findings,
206
+ filesSeen: scanResult.filesSeen - removed.size,
110
207
  filesScanned: scanResult.filesScanned - removed.size,
111
- ...summarizeFindings(findings),
112
- };
208
+ });
113
209
  }
114
210
 
115
- function scanDirectory(directory, displayPath, state) {
116
- const name = basename(directory);
117
- addFindings(state, checkStrayDirectory({ path: displayPath, name }), directory);
118
- const electronBinary = electronBinaryIn(directory, name);
119
- if (electronBinary) addFindings(state, checkElectronFuses(displayPath, electronBinary), electronBinary);
120
- // Helper apps nested inside a bundle are signed along with it; the outer one speaks for them.
121
- // A directory arrives before what is inside it, so a helper sits inside the last outer app seen.
122
- const isNestedApp = state.outermostApp !== null && directory.startsWith(state.outermostApp + sep);
123
- if (isAppBundleName(name) && !isNestedApp) {
124
- state.outermostApp = directory;
125
- addFindings(state, checkAppBundle(displayPath, directory), directory);
211
+ /**
212
+ * One file, wherever it sits. Its name decides how it is read, by the same rules on disk
213
+ * and inside an archive: text gets every check, and whatever else can hold text is searched
214
+ * as bytes.
215
+ *
216
+ * @param {object} file
217
+ * @param {string | null} file.diskPath null for a file inside an archive
218
+ * @param {(use: (source: import('./bytes.mjs').ByteSource) => void) => void} file.open hands
219
+ * the file's bytes to `use`, or does nothing when they cannot be had
220
+ */
221
+ function scanFile({ displayPath, name, diskPath, open }, state) {
222
+ const { result } = state;
223
+ const extension = extname(name).toLowerCase();
224
+ const isInsideArchive = diskPath === null;
225
+ result.filesSeen += 1;
226
+ addFindings(state, checkPath({ path: displayPath, name, extension, isInsideArchive }), diskPath);
227
+
228
+ if (isSealedContainer(name)) {
229
+ result.unopenedContainers.push(displayPath);
230
+ } else if (extension === '.asar' && !isInsideArchive) {
231
+ scanArchive(diskPath, displayPath, state);
232
+ } else if (!isTextless(extension)) {
233
+ const isText = hasContentChecks(extension);
234
+ open((source) => {
235
+ if (source.byteLength > (isText || isInsideArchive ? MAX_FILE_BYTES : MAX_SEARCHED_BYTES)) {
236
+ result.skippedLargeFiles.push(displayPath);
237
+ return;
238
+ }
239
+ result.filesScanned += 1;
240
+ if (isText) scanText({ displayPath, extension, diskPath, source }, state);
241
+ else scanBytes({ displayPath, diskPath, source }, state);
242
+ });
126
243
  }
127
244
  }
128
245
 
129
- function scanDiskFile(file, displayPath, state) {
130
- const name = basename(file);
131
- const extension = extname(name).toLowerCase();
132
- addFindings(state, checkPath({ path: displayPath, name, extension, isInsideArchive: false }), file);
133
-
134
- if (extension === '.asar') {
135
- scanArchive(file, displayPath, state);
136
- } else if (extension === '.exe') {
137
- // On Windows the fuses sit in the app's own executable.
138
- addFindings(state, checkElectronFuses(displayPath, file), file);
139
- } else if (hasContentChecks(extension)) {
140
- const readText = () => readFileSync(file, 'utf8');
141
- scanContent({ displayPath, extension, diskPath: file, bytes: statSync(file).size, readText }, state);
246
+ function openOnDisk(file, use) {
247
+ let fd;
248
+ try {
249
+ fd = openSync(file, 'r');
250
+ } catch {
251
+ // A file that cannot be opened stays counted as met and not as read.
252
+ return;
253
+ }
254
+ try {
255
+ use(fileBytes(fd));
256
+ } finally {
257
+ closeSync(fd);
142
258
  }
143
259
  }
144
260
 
@@ -146,19 +262,16 @@ function scanArchive(archivePath, displayPath, state) {
146
262
  let archive;
147
263
  try {
148
264
  archive = openAsar(archivePath);
265
+ state.result.filesScanned += 1;
149
266
  addFindings(state, [archiveFinding(displayPath, archive.fileCount)], null);
150
267
  for (const inner of walk(archive.root, archive.list)) {
151
268
  const innerDisplayPath = toShownText(`${displayPath}:/${inner.path}`);
152
269
  if (inner.isDirectory) {
153
270
  addFindings(state, checkStrayDirectory({ path: innerDisplayPath, name: inner.name }), null);
154
- continue;
271
+ } else {
272
+ const open = (use) => use(lazyBytes(inner.size, () => archive.read(inner)));
273
+ scanFile({ displayPath: innerDisplayPath, name: inner.name, diskPath: null, open }, state);
155
274
  }
156
- const extension = extname(inner.name).toLowerCase();
157
- const file = { path: innerDisplayPath, name: inner.name, extension, isInsideArchive: true };
158
- addFindings(state, checkPath(file), null);
159
- if (!hasContentChecks(extension)) continue;
160
- const readText = () => archive.read(inner).toString('utf8');
161
- scanContent({ displayPath: innerDisplayPath, extension, diskPath: null, bytes: inner.size, readText }, state);
162
275
  }
163
276
  } catch {
164
277
  state.result.unreadableArchives.push(displayPath);
@@ -167,19 +280,76 @@ function scanArchive(archivePath, displayPath, state) {
167
280
  }
168
281
  }
169
282
 
170
- /** The size limit, the count and the checks are the same wherever the bytes come from. */
171
- function scanContent({ displayPath, extension, diskPath, bytes, readText }, state) {
172
- if (bytes > MAX_FILE_BYTES) {
173
- state.result.skippedLargeFiles.push(displayPath);
174
- return;
175
- }
176
- // Counted before reading, so a file that cannot be read still shows as attempted.
177
- state.result.filesScanned += 1;
178
- const text = readText();
283
+ function scanText({ displayPath, extension, diskPath, source }, state) {
284
+ if (isScript(extension)) state.result.scriptsScanned += 1;
285
+ const text = source.read(0, source.byteLength).toString('utf8');
179
286
  addFindings(state, checkFile({ path: displayPath, extension, text }), diskPath);
180
287
  addFindings(state, checkEnvValues(displayPath, text, state.envSecrets), diskPath);
181
288
  }
182
289
 
290
+ /**
291
+ * A file whose name does not say it is code is searched for keys a chunk at a time: a
292
+ * program, bytecode, a data file, a script, a note. A native program has no file name to go
293
+ * by and is known by how it opens. A Mach-O is also read for what it says about its own
294
+ * names and signature, and Electron's binary for its fuses.
295
+ */
296
+ function scanBytes({ displayPath, diskPath, source }, state) {
297
+ const images = readMachO(source);
298
+ // An object file is a step of a build, not a program anyone runs.
299
+ if (images?.some(({ kind }) => kind !== 'other') || isLinuxOrWindowsProgram(source)) {
300
+ state.result.programsScanned += 1;
301
+ }
302
+
303
+ if (images) addFindings(state, checkMachO(displayPath, images), diskPath);
304
+ // The same thing sits in each architecture's build and where two chunks overlap, and is
305
+ // one finding. Keys are told apart by the search itself; the rest by what they say.
306
+ const seenKeys = new Set();
307
+ const keys = [];
308
+ const others = new Map();
309
+ // A file with no zero byte in it is text, whatever it is called. In text a key stands
310
+ // apart from what is around it; in other bytes it may sit between its neighbours or be
311
+ // stored two bytes to a character. This is said of the file and not of a chunk of it: a
312
+ // program can go a whole chunk without a zero byte, and is no more text there.
313
+ const isText = !holdsZeroByte(source);
314
+ const findKeys = isText ? checkSecrets : checkSecretsInBytes;
315
+ for (const chunk of readChunks(source, CHUNK_BYTES, OVERLAP_BYTES)) {
316
+ const place = {
317
+ hasMoreBefore: chunk.position > 0,
318
+ hasMoreAfter: chunk.position + chunk.bytes.length < source.byteLength,
319
+ };
320
+ const inThisChunk = [checkElectronFuses(displayPath, source, chunk)];
321
+ for (const text of readAsText(chunk.bytes, isText)) {
322
+ // What a finding quotes is a view into the text, and kept as it is would keep the
323
+ // whole chunk in memory until the file is done. Findings leave here as copies.
324
+ const found = findKeys(displayPath, text, place, seenKeys);
325
+ if (found.length > 0) keys.push(...structuredClone(found));
326
+ inThisChunk.push(checkEnvValues(displayPath, text, state.envSecrets));
327
+ }
328
+ for (const finding of inThisChunk.flat()) others.set(JSON.stringify(finding), finding);
329
+ if (keys.length >= MAX_FINDINGS_IN_BYTES) break;
330
+ }
331
+ addFindings(state, [...keys.slice(0, MAX_FINDINGS_IN_BYTES), ...others.values()], diskPath);
332
+ }
333
+
334
+ function holdsZeroByte(source) {
335
+ for (const { bytes } of readChunks(source, CHUNK_BYTES, 0)) {
336
+ if (bytes.includes(0)) return true;
337
+ }
338
+ return false;
339
+ }
340
+
341
+ /**
342
+ * Bytes as the text they may hold: one byte to a character and, unless they are text to
343
+ * begin with, two bytes to a character starting on either byte, which is how Windows
344
+ * programs, .NET and Hermes keep some of theirs.
345
+ */
346
+ function* readAsText(bytes, isText) {
347
+ yield bytes.toString('latin1');
348
+ if (isText) return;
349
+ yield bytes.toString('utf16le');
350
+ yield bytes.subarray(1).toString('utf16le');
351
+ }
352
+
183
353
  /** Everything a finding quotes from a file is cleaned here; `diskPath` stays exact, for acting on the file. */
184
354
  function addFindings(state, findings, diskPath) {
185
355
  for (const finding of findings) state.result.findings.push({ ...toShown(finding), diskPath });
@@ -195,7 +365,8 @@ function addFindings(state, findings, diskPath) {
195
365
  */
196
366
  function* walk(entry, list) {
197
367
  yield entry;
198
- if (!entry.isDirectory || isOpaqueDirectory(entry.name)) return;
368
+ // A directory that should never ship is reported whole and not looked into.
369
+ if (!entry.isDirectory || strayDirectoryKind(entry.name) !== null) return;
199
370
  for (const child of list(entry)) {
200
371
  // Dependencies are public code, and their docs and tests carry example keys.
201
372
  if (child.isDirectory && child.name === 'node_modules') continue;
@@ -209,11 +380,6 @@ function listDisk({ path }) {
209
380
  .map((entry) => ({ path: join(path, entry.name), name: entry.name, isDirectory: entry.isDirectory() }));
210
381
  }
211
382
 
212
- /** A stray directory is reported whole; a framework is read only for its fuses. */
213
- function isOpaqueDirectory(name) {
214
- return strayDirectoryKind(name) !== null || name.endsWith('.framework');
215
- }
216
-
217
383
  /** @returns {string[] | null} the command that builds this project, for example ['pnpm', 'run', 'build'] */
218
384
  function findBuildCommand(root) {
219
385
  if (!readManifest(root)?.scripts?.build) return null;