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/scan.mjs
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
51
|
+
constructor(code, path, details = {}) {
|
|
30
52
|
super(`${code}: ${path}`);
|
|
31
53
|
this.code = code;
|
|
32
54
|
this.path = path;
|
|
33
|
-
this.
|
|
55
|
+
this.details = details;
|
|
34
56
|
}
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
/**
|
|
38
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
83
|
-
|
|
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)
|
|
91
|
-
else
|
|
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
|
|
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
|
-
|
|
112
|
-
};
|
|
208
|
+
});
|
|
113
209
|
}
|
|
114
210
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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;
|