reaproof 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +154 -2
- package/bin/reaproof.mjs +28 -0
- package/package.json +38 -4
- package/skills/reaproof/SKILL.md +49 -0
- package/src/asar.mjs +78 -0
- package/src/bytes.mjs +9 -0
- package/src/checks/content.mjs +116 -0
- package/src/checks/paths.mjs +70 -0
- package/src/checks/secrets.mjs +94 -0
- package/src/checks/sourcemaps.mjs +96 -0
- package/src/cli.mjs +220 -0
- package/src/electron.mjs +68 -0
- package/src/env.mjs +107 -0
- package/src/fix.mjs +109 -0
- package/src/macos.mjs +38 -0
- package/src/measure.mjs +97 -0
- package/src/remote.mjs +175 -0
- package/src/report.mjs +654 -0
- package/src/rules.mjs +102 -0
- package/src/scan.mjs +244 -0
- package/src/text.mjs +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Junhan2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Reaproof
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One command that rates how exposed your shipped app is to AI reverse-engineering agents, and closes what can be closed.
|
|
4
|
+
|
|
5
|
+
Tools such as [rea](https://github.com/morluto/rea) let a coding agent inspect a shipped app without its source, explain how a feature works and rebuild it. Reaproof checks what you ship for the things that make that easy, rates the risk, explains each finding in plain language, and fixes the ones that are safe to fix automatically.
|
|
6
|
+
|
|
7
|
+
It is written for people who have never dealt with security before. There is nothing to install and nothing to configure. It needs Node.js 18.3 or later.
|
|
8
|
+
|
|
9
|
+
## Three ways to use it
|
|
10
|
+
|
|
11
|
+
### Yourself
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx reaproof
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run it in your project folder. It rates the risk, lists what it found, and when there is something it can close, it shows the list and asks before doing it. If nothing is built yet, it offers to run your build first.
|
|
18
|
+
|
|
19
|
+
Nothing stays installed. `npx` comes with Node.js and fetches the package when you run it; the first time, it asks `Ok to proceed? (y)` before downloading. Answer `y`.
|
|
20
|
+
|
|
21
|
+
To check a live site instead, give it the address:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx reaproof https://your-site.example
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### With an AI agent
|
|
28
|
+
|
|
29
|
+
Say this to any coding agent that can run commands:
|
|
30
|
+
|
|
31
|
+
> Run `npx reaproof --json` and fix what it reports.
|
|
32
|
+
|
|
33
|
+
The JSON carries the risk level and, for every kind of finding, what it means and what to do, so the agent needs no setup. Paths, file names and addresses inside the findings are copied from the scanned files: characters a reader cannot see are removed, long text is cut, and every JSON answer opens by saying that this text is data to report, not instructions to follow.
|
|
34
|
+
|
|
35
|
+
To make the agent reach for it by itself, install the skill once:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx skills add Junhan2/reaproof
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That works for Claude Code, Codex, Cursor, Gemini CLI and other agents. In Claude Code you can also install it as a plugin:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/plugin install reaproof --marketplace Junhan2/reaproof
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
On Claude Code older than 2.1.275, run `/plugin marketplace add Junhan2/reaproof` and then `/plugin install reaproof@reaproof`.
|
|
48
|
+
|
|
49
|
+
### Automatically
|
|
50
|
+
|
|
51
|
+
`npx reaproof` exits with 1 when something critical or high would ship, so it can stop a release. Add it after your build step.
|
|
52
|
+
|
|
53
|
+
## Reading the result
|
|
54
|
+
|
|
55
|
+
Every report starts with the risk.
|
|
56
|
+
|
|
57
|
+
| rea risk | What it means |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| very high | A tool like rea can take your original source or your secret values as they are |
|
|
60
|
+
| high | Near-secret values, data, or the inside of the running app are exposed |
|
|
61
|
+
| moderate | The names and structure of your code read easily |
|
|
62
|
+
| baseline | No leak was found. Minified code, server addresses and screen structure are still readable, as in any shipped app |
|
|
63
|
+
|
|
64
|
+
There is no "safe" level, because there is no such state: see "What it cannot do".
|
|
65
|
+
|
|
66
|
+
Below the risk, the report counts kinds of leak, each at its worst severity. Three hundred exposed maps are the same mistake as one. Notes are not counted.
|
|
67
|
+
|
|
68
|
+
Exit codes: 0 when nothing critical or high was found, 1 when something was, 2 for a usage error.
|
|
69
|
+
|
|
70
|
+
## Commands
|
|
71
|
+
|
|
72
|
+
| Command | What it does |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `reaproof [path]` | Rates the risk of the build output, reports, and offers the fix |
|
|
75
|
+
| `reaproof <https://your-site>` | Checks whether a deployed site serves its source maps |
|
|
76
|
+
| `reaproof fix [path]` | Takes source map files out of the output, after showing the list and asking |
|
|
77
|
+
| `reaproof undo [path]` | Puts back what the last fix took out |
|
|
78
|
+
| `reaproof measure [path]` | Runs the real rea on the output and counts what it reads |
|
|
79
|
+
|
|
80
|
+
Options: `--json` for machine-readable output, `--yes` to skip the question in `fix` and `measure`, `--lang ko|en` for the report language. Questions are asked only at a terminal; with `--json`, in a pipe or in CI nothing is asked and nothing is changed.
|
|
81
|
+
|
|
82
|
+
The path can be a project folder, a build output folder, an Electron `.asar` archive or a packaged app. In a project folder Reaproof looks for `.next/static`, `out`, `dist`, `build`, `release` and `.output/public`.
|
|
83
|
+
|
|
84
|
+
## What it checks
|
|
85
|
+
|
|
86
|
+
| Finding | Severity | Automatic fix |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| Settings files (`.env`), a `.git` folder, key stores, database files and debug symbols mixed into the output | Critical or high | No, delete them and stop them being copied in |
|
|
89
|
+
| Server-only keys in shipped code: Stripe secret and restricted keys, Supabase secret and `service_role` keys, GitHub tokens, AWS access keys, private keys, AI service keys, database addresses that carry a password | Critical or high | No, revoke the key and move it to a server |
|
|
90
|
+
| A secret value from the project's own `.env` that appears word for word in shipped code | Critical or high | No, change the value and move it to a server |
|
|
91
|
+
| Source map files that contain your original source text | Critical | Yes |
|
|
92
|
+
| Source maps embedded inside code files | Critical | No, turn it off in the build settings |
|
|
93
|
+
| A macOS app signed with the development entitlement `get-task-allow` | High | No, leave it out of release signing |
|
|
94
|
+
| Source map files that list file names without the source text | Medium | Yes |
|
|
95
|
+
| A macOS app signed without the Hardened Runtime | Medium | No, turn it on when signing |
|
|
96
|
+
| A packaged Electron app with development fuses left on (`runAsNode`, `nodeOptions`, `nodeCliInspect`) | Medium | No, turn them off with `@electron/fuses` |
|
|
97
|
+
| Source and test files packed into the app or output | Medium | No, take them out of the packaging list |
|
|
98
|
+
| Long text in client code that looks like an AI prompt | Medium, a guess | No, keep prompts on a server |
|
|
99
|
+
| Code that is not minified | Medium | No, turn on minification |
|
|
100
|
+
| Server addresses named in the code | Note | Cannot be hidden; a list to review |
|
|
101
|
+
| Electron `.asar` archives, which anyone can unpack | Note | No |
|
|
102
|
+
|
|
103
|
+
Keys that are meant for browsers, such as Stripe publishable keys and Supabase `anon` and publishable keys, are not reported. Secrets are never printed in full, and values from `.env` are reported by variable name only.
|
|
104
|
+
|
|
105
|
+
For the `.env` comparison, variables with a prefix their framework documents as public (`NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, `PUBLIC_`, `EXPO_PUBLIC_`) are expected in the output, unless the name itself says `SECRET`, `PRIVATE`, `PASSWORD` or `SERVICE_ROLE`.
|
|
106
|
+
|
|
107
|
+
## What it cannot do
|
|
108
|
+
|
|
109
|
+
Reaproof does not make an app impossible to copy. Nothing can.
|
|
110
|
+
|
|
111
|
+
- Screen structure and the server addresses your app calls stay readable, because they have to reach the user's device.
|
|
112
|
+
- Minified code stays readable in structure, and its numbers and strings stay as they are.
|
|
113
|
+
- The only logic nobody can read is logic that runs on your server. Reaproof says so in every report.
|
|
114
|
+
|
|
115
|
+
## How the fix works
|
|
116
|
+
|
|
117
|
+
The fix moves standalone `.map` files out of the build output into `~/.reaproof/backups/`, outside your project, so a backup is never deployed by accident. It never edits your code files, because changing them would break integrity hashes. `reaproof undo` moves the files back.
|
|
118
|
+
|
|
119
|
+
The next build creates the maps again unless you turn them off. The report tells you which setting to change for Next.js, Vite and Create React App.
|
|
120
|
+
|
|
121
|
+
If a hosting platform builds your project from the repository, a local fix does not reach what gets deployed. Change the build setting, deploy, and check the deployed site.
|
|
122
|
+
|
|
123
|
+
## Checking a deployed site
|
|
124
|
+
|
|
125
|
+
`npx reaproof https://your-site.example` reads one page and the code files that page loads from its own address, at most 60 of them, one after another. For each code file it looks for a map where the file or the server says one is, and beside the file, where a "hidden" map is written. Nothing fetched is saved.
|
|
126
|
+
|
|
127
|
+
If the address you type redirects, to `www` or to `https` for example, the place it ends up is the site that gets checked. A code file that redirects to another site is not followed, and a file that names many maps is asked for the last one only, so what a page serves cannot send the check anywhere else.
|
|
128
|
+
|
|
129
|
+
It checks source maps only. Looking for keys and stray files is done on the build output, by someone who holds the files. Code that the page loads later or from another address is not checked, and the report says how many files were left out. Use it on sites you run.
|
|
130
|
+
|
|
131
|
+
## Measuring with the real rea
|
|
132
|
+
|
|
133
|
+
`reaproof measure` downloads the `rea-agents` package with `npx` and runs its static JavaScript analysis on your build output only, with install scripts disabled. rea is someone else's program, so the command asks before it does that; in a script or with an AI agent, pass `--yes`. It reports how many readable source maps, original file names, server addresses, request sites and functions rea recovered. Run it before and after the fix to see the difference.
|
|
134
|
+
|
|
135
|
+
It runs one fixed release of rea, 6.4.0, the one this version was tested against. A published release cannot change afterwards. Newer releases are adopted here after they have been tested.
|
|
136
|
+
|
|
137
|
+
## Not covered yet
|
|
138
|
+
|
|
139
|
+
- Android apps. There was no real APK to test a check against, so none was written.
|
|
140
|
+
- Electron fuses of Linux builds. macOS apps and Windows `.exe` files are read.
|
|
141
|
+
- iOS apps and symbol stripping in native binaries.
|
|
142
|
+
- Public-variable prefixes of frameworks other than the five listed above.
|
|
143
|
+
- Files larger than 30 MB. They are not read, and the report counts them as skipped, so a source map that large goes unrated.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm test
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
No dependencies. Node.js 18.3 or later. The macOS signing test runs only on a Mac with `clang` and `codesign`; it builds and signs throwaway bundles and never starts them.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
MIT
|
package/bin/reaproof.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { main } from '../src/cli.mjs';
|
|
5
|
+
|
|
6
|
+
// Questions are asked only when a person is at both ends of the terminal.
|
|
7
|
+
const isAttended = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8
|
+
|
|
9
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
10
|
+
print: (text) => console.log(text),
|
|
11
|
+
printError: (text) => console.error(text),
|
|
12
|
+
ask: isAttended ? askYesOrNo : null,
|
|
13
|
+
runBuild: ([program, ...args], directory) =>
|
|
14
|
+
spawnSync(program, args, { cwd: directory, stdio: 'inherit', shell: process.platform === 'win32' }).status === 0,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function askYesOrNo(question) {
|
|
18
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
try {
|
|
20
|
+
return /^y(es)?$/i.test((await readline.question(question)).trim());
|
|
21
|
+
} catch {
|
|
22
|
+
// Ctrl+D or an input that closed mid-question is a "no", not a crash.
|
|
23
|
+
console.log('');
|
|
24
|
+
return false;
|
|
25
|
+
} finally {
|
|
26
|
+
readline.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reaproof",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "One command that shows what AI reverse-engineering agents can read from your shipped app, and closes what can be closed.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"security",
|
|
7
|
+
"source-maps",
|
|
8
|
+
"secrets",
|
|
9
|
+
"reverse-engineering",
|
|
10
|
+
"rea",
|
|
11
|
+
"electron",
|
|
12
|
+
"cli"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/Junhan2/reaproof#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/Junhan2/reaproof/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Junhan2/reaproof.git"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "Junhan2",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"reaproof": "bin/reaproof.mjs"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"src",
|
|
31
|
+
"skills",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.3"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reaproof
|
|
3
|
+
description: Checks what AI reverse-engineering agents such as rea can read from a shipped web, Electron or macOS build, and closes what can be closed. Use when the user asks to protect their app from being cloned or reverse engineered, mentions rea, or asks before a release whether source maps, secret keys or stray files are exposed.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Reaproof
|
|
7
|
+
|
|
8
|
+
The user may have no security background. Explain every finding by its effect on them, in plain words, before changing anything. Use this only on software and sites the user owns.
|
|
9
|
+
|
|
10
|
+
## Steps
|
|
11
|
+
|
|
12
|
+
1. Check that a build output exists. If it does not, ask before running the project's build command.
|
|
13
|
+
2. Run `npx reaproof --json` in the project folder. In the result, `risk` is the overall level (`very-high`, `high`, `moderate` or `baseline`), `leaks` counts kinds of leak by severity, `findings` lists each one with its `rule`, and `explain` says in words what the risk and each rule mean and whether `reaproof fix --yes` closes it.
|
|
14
|
+
3. Tell the user the risk level first, then what is exposed, why it matters and what you propose to change. Start with critical and high findings.
|
|
15
|
+
4. After the user agrees, apply the fixes below.
|
|
16
|
+
5. Rebuild and run `npx reaproof` again to confirm.
|
|
17
|
+
6. After deploying, run `npx reaproof https://their-site` to confirm the deployed site no longer serves source maps. A local fix does not reach a site that a hosting platform builds from the repository.
|
|
18
|
+
|
|
19
|
+
## Fixes by finding
|
|
20
|
+
|
|
21
|
+
- `sourcemap-sources`, `sourcemap-names`: run `npx reaproof fix --yes`. It moves standalone map files into a backup outside the project; `npx reaproof undo` reverses it. Then make it last by changing the build setting:
|
|
22
|
+
- Next.js: remove `productionBrowserSourceMaps: true` from `next.config`.
|
|
23
|
+
- Vite: set `build.sourcemap` to `false`. `'hidden'` still writes the map files into the output.
|
|
24
|
+
- Create React App: build with `GENERATE_SOURCEMAP=false`.
|
|
25
|
+
- If an error tracker needs the maps, upload them there and delete them from the output afterwards.
|
|
26
|
+
- `sourcemap-inline`: only the build setting fixes it.
|
|
27
|
+
- `stray-file`: delete the file from the output and find what copies it in, usually a `public` folder or a packaging file list.
|
|
28
|
+
- `secret`, `env-value`: see "Secret values" below.
|
|
29
|
+
- `unshipped-source`, `unminified-code`: adjust the packaging file list and turn on minification.
|
|
30
|
+
- `electron-fuse`: turn the listed fuses off at package time with `@electron/fuses`.
|
|
31
|
+
- `macos-entitlement`, `macos-runtime`: sign the release without `get-task-allow` and with the Hardened Runtime.
|
|
32
|
+
- `prompt-candidate`: a guess. If it is a prompt, move it to a server.
|
|
33
|
+
- `endpoint`: a list, not a leak. Review with the user that each address checks who is calling.
|
|
34
|
+
|
|
35
|
+
## Secret values
|
|
36
|
+
|
|
37
|
+
A `secret` or `env-value` finding means a server-only value is inside code that reaches users. Removing it from the code is not enough, because the shipped copies already contain it.
|
|
38
|
+
|
|
39
|
+
1. Tell the user to revoke or change the value where it was issued.
|
|
40
|
+
2. Move the call that needs it to the user's server, for example a route handler or a serverless function, and read the value from a server environment variable without a public prefix.
|
|
41
|
+
3. Rebuild and run the check again.
|
|
42
|
+
|
|
43
|
+
## Showing the effect
|
|
44
|
+
|
|
45
|
+
`npx reaproof measure` downloads the `rea-agents` package and runs it on the build output to count what the real tool reads. Ask before running it, because it downloads and runs a third-party package. Run it before and after the fix when the user wants to see the difference.
|
|
46
|
+
|
|
47
|
+
## Limits to state plainly
|
|
48
|
+
|
|
49
|
+
Never tell the user the app can no longer be copied. The lowest risk level is `baseline`, not "safe": it means no leak was found, nothing more. Screen structure, server addresses and the logic inside minified code stay readable. Logic that must not be copied, such as pricing or permission checks, belongs on a server.
|
package/src/asar.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync } from 'node:fs';
|
|
2
|
+
import { readAt } from './bytes.mjs';
|
|
3
|
+
|
|
4
|
+
const SIZE_PICKLE_BYTES = 8;
|
|
5
|
+
const MAX_HEADER_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ path: string, name: string, isDirectory: false, size: number, offset: number }} AsarFile
|
|
9
|
+
* @typedef {{ path: string, name: string, isDirectory: true, node: object }} AsarDirectory
|
|
10
|
+
* `node` is the directory's own part of the header, kept for `list`
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Opens an Electron ASAR archive for reading without unpacking it.
|
|
15
|
+
*
|
|
16
|
+
* Layout (electron/asar README): | UInt32: header_size | String: header | Bytes: file1 | ... |
|
|
17
|
+
* Both leading fields are Chromium Pickle objects: a UInt32 payload length, then the payload.
|
|
18
|
+
* File offsets in the header are decimal strings counted from the end of the header.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} archivePath
|
|
21
|
+
* @returns {{
|
|
22
|
+
* root: AsarDirectory,
|
|
23
|
+
* fileCount: number,
|
|
24
|
+
* list(directory: AsarDirectory): (AsarFile | AsarDirectory)[],
|
|
25
|
+
* read(file: AsarFile): Buffer,
|
|
26
|
+
* close(): void,
|
|
27
|
+
* }}
|
|
28
|
+
*/
|
|
29
|
+
export function openAsar(archivePath) {
|
|
30
|
+
const fd = openSync(archivePath, 'r');
|
|
31
|
+
try {
|
|
32
|
+
const archiveBytes = fstatSync(fd).size;
|
|
33
|
+
const headerPickleBytes = readAt(fd, 0, SIZE_PICKLE_BYTES).readUInt32LE(4);
|
|
34
|
+
const isPlausibleHeader =
|
|
35
|
+
headerPickleBytes >= 8 &&
|
|
36
|
+
headerPickleBytes <= MAX_HEADER_BYTES &&
|
|
37
|
+
SIZE_PICKLE_BYTES + headerPickleBytes <= archiveBytes;
|
|
38
|
+
if (!isPlausibleHeader) throw new Error('not a readable ASAR archive');
|
|
39
|
+
|
|
40
|
+
const headerPickle = readAt(fd, SIZE_PICKLE_BYTES, headerPickleBytes);
|
|
41
|
+
const jsonBytes = headerPickle.readUInt32LE(4);
|
|
42
|
+
const header = JSON.parse(headerPickle.toString('utf8', 8, 8 + jsonBytes));
|
|
43
|
+
const dataStart = SIZE_PICKLE_BYTES + headerPickleBytes;
|
|
44
|
+
|
|
45
|
+
const root = { path: '', name: '', isDirectory: true, node: header };
|
|
46
|
+
return {
|
|
47
|
+
root,
|
|
48
|
+
// Counting goes through the whole header, so a malformed one fails here, before a caller reports anything.
|
|
49
|
+
fileCount: countFiles(root),
|
|
50
|
+
list: listEntries,
|
|
51
|
+
read: (file) => readAt(fd, dataStart + file.offset, file.size),
|
|
52
|
+
close: () => closeSync(fd),
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What sits directly inside one directory, of the things stored in the archive itself. */
|
|
61
|
+
function listEntries({ path: directoryPath, node }) {
|
|
62
|
+
const entries = [];
|
|
63
|
+
for (const [name, entry] of Object.entries(node.files ?? {})) {
|
|
64
|
+
const path = directoryPath ? `${directoryPath}/${name}` : name;
|
|
65
|
+
if (entry.files) entries.push({ path, name, isDirectory: true, node: entry });
|
|
66
|
+
// Symlinks have no bytes; unpacked entries live next to the archive, not in it.
|
|
67
|
+
else if (entry.link === undefined && !entry.unpacked) {
|
|
68
|
+
entries.push({ path, name, isDirectory: false, size: entry.size, offset: Number(entry.offset) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function countFiles(directory) {
|
|
75
|
+
let count = 0;
|
|
76
|
+
for (const entry of listEntries(directory)) count += entry.isDirectory ? countFiles(entry) : 1;
|
|
77
|
+
return count;
|
|
78
|
+
}
|
package/src/bytes.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/** Reads exactly `length` bytes at `position`; a file that ends sooner is not what the caller took it for. */
|
|
4
|
+
export function readAt(fd, position, length) {
|
|
5
|
+
const buffer = Buffer.alloc(length);
|
|
6
|
+
const bytesRead = readSync(fd, buffer, 0, length, position);
|
|
7
|
+
if (bytesRead !== length) throw new Error('unexpected end of file');
|
|
8
|
+
return buffer;
|
|
9
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Checks on what a code file says, beyond maps and keys.
|
|
2
|
+
|
|
3
|
+
// Hand-written code averages a few dozen characters a line; minified output runs to thousands.
|
|
4
|
+
// A file of a few lines says too little either way.
|
|
5
|
+
const MIN_HANDWRITTEN_LINES = 10;
|
|
6
|
+
const MAX_HANDWRITTEN_LINE_LENGTH = 200;
|
|
7
|
+
|
|
8
|
+
const ABSOLUTE_ADDRESS = /https?:\/\/[A-Za-z0-9.-]{1,253}(?::\d{1,5})?(?:\/[A-Za-z0-9_\-./%:@~+]{0,200})?/g;
|
|
9
|
+
const RELATIVE_API_PATH = /["'`](\/(?:api|graphql|rest|rpc)\b[A-Za-z0-9_\-./:[\]{}$]{0,120})/g;
|
|
10
|
+
const API_HOST = /^api[.-]|[.-]api[.-]/i;
|
|
11
|
+
const API_PATH = /(?:^|\/)(?:api|graphql|rest|rpc|v\d{1,2})(?:\/|$)/i;
|
|
12
|
+
const MAX_ADDRESSES_PER_FILE = 50;
|
|
13
|
+
|
|
14
|
+
// A prompt assigns a role and then gives orders. Library warnings ("You are using an outdated
|
|
15
|
+
// version...") do the first half only, so both halves are required.
|
|
16
|
+
const PROMPT_OPENING = /You are (?:an?|the) |Your task is |당신은 |너는 /g;
|
|
17
|
+
const PROMPT_DIRECTIVE = /\b(?:always|never|must|do not|don't|should)\b|반드시|절대|하지 마|해야/i;
|
|
18
|
+
const QUOTES = ['"', "'", '`'];
|
|
19
|
+
const MIN_PROMPT_LENGTH = 160;
|
|
20
|
+
const MAX_PROMPT_LENGTH = 6000;
|
|
21
|
+
const MIN_PROMPT_SPACES = 20;
|
|
22
|
+
const PROMPT_PREVIEW_LENGTH = 60;
|
|
23
|
+
|
|
24
|
+
/** Code that kept its names and line breaks reads as written. */
|
|
25
|
+
export function checkUnminifiedCode(path, text) {
|
|
26
|
+
const lineCount = countLines(text);
|
|
27
|
+
const averageLineLength = Math.round(text.length / lineCount);
|
|
28
|
+
if (lineCount < MIN_HANDWRITTEN_LINES || averageLineLength >= MAX_HANDWRITTEN_LINE_LENGTH) return [];
|
|
29
|
+
return [{ rule: 'unminified-code', severity: 'medium', path, detail: { averageLineLength } }];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Lists the server addresses a code file names. They cannot be hidden, so this is a note
|
|
34
|
+
* for the owner to review, not a leak.
|
|
35
|
+
*/
|
|
36
|
+
export function checkEndpoints(path, text) {
|
|
37
|
+
const addresses = new Set();
|
|
38
|
+
for (const [address] of text.matchAll(ABSOLUTE_ADDRESS)) {
|
|
39
|
+
if (looksLikeApi(address)) addresses.add(address);
|
|
40
|
+
}
|
|
41
|
+
for (const [, apiPath] of text.matchAll(RELATIVE_API_PATH)) addresses.add(apiPath);
|
|
42
|
+
if (addresses.size === 0) return [];
|
|
43
|
+
const listed = [...addresses].slice(0, MAX_ADDRESSES_PER_FILE);
|
|
44
|
+
return [{ rule: 'endpoint', severity: 'info', path, detail: { addresses: listed } }];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Finds long instruction-like strings, the shape of a prompt written into client code.
|
|
49
|
+
* It is a guess from wording, so it is reported as a candidate.
|
|
50
|
+
*/
|
|
51
|
+
export function checkPromptCandidates(path, text) {
|
|
52
|
+
const findings = [];
|
|
53
|
+
const reportedStarts = new Set();
|
|
54
|
+
for (const { index } of text.matchAll(PROMPT_OPENING)) {
|
|
55
|
+
const literal = enclosingStringLiteral(text, index);
|
|
56
|
+
if (!literal || reportedStarts.has(literal.start) || !PROMPT_DIRECTIVE.test(literal.text)) continue;
|
|
57
|
+
reportedStarts.add(literal.start);
|
|
58
|
+
const preview = literal.text.slice(0, PROMPT_PREVIEW_LENGTH).replace(/\s+/g, ' ');
|
|
59
|
+
findings.push({ rule: 'prompt-candidate', severity: 'medium', path, detail: { preview, length: literal.text.length } });
|
|
60
|
+
}
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function countLines(text) {
|
|
65
|
+
let lines = 1;
|
|
66
|
+
for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) lines += 1;
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function looksLikeApi(address) {
|
|
71
|
+
try {
|
|
72
|
+
const { hostname, pathname } = new URL(address);
|
|
73
|
+
return API_HOST.test(hostname) || API_PATH.test(pathname);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The longest prose-like string literal around a position. Each quote style is tried,
|
|
81
|
+
* because an apostrophe inside the prose would otherwise end the literal early.
|
|
82
|
+
*/
|
|
83
|
+
function enclosingStringLiteral(text, position) {
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const quote of QUOTES) {
|
|
86
|
+
const start = previousUnescaped(text, quote, position);
|
|
87
|
+
if (start === -1) continue;
|
|
88
|
+
const end = nextUnescaped(text, quote, position);
|
|
89
|
+
if (end === -1) continue;
|
|
90
|
+
const literal = text.slice(start + 1, end);
|
|
91
|
+
const isProse = literal.length >= MIN_PROMPT_LENGTH && literal.split(' ').length > MIN_PROMPT_SPACES;
|
|
92
|
+
if (isProse && (!best || literal.length > best.text.length)) best = { start, text: literal };
|
|
93
|
+
}
|
|
94
|
+
return best;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Both searches stay inside a window of MAX_PROMPT_LENGTH characters and use the built-in
|
|
98
|
+
// search, so a window without the quote costs one short search, not a step per character.
|
|
99
|
+
function previousUnescaped(text, quote, position) {
|
|
100
|
+
const earliest = Math.max(0, position - MAX_PROMPT_LENGTH);
|
|
101
|
+
const before = text.slice(earliest, position);
|
|
102
|
+
for (let index = before.lastIndexOf(quote); index !== -1; index = before.lastIndexOf(quote, index - 1)) {
|
|
103
|
+
if (text[earliest + index - 1] !== '\\') return earliest + index;
|
|
104
|
+
// `lastIndexOf` treats a start below zero as zero and would find this same quote again.
|
|
105
|
+
if (index === 0) break;
|
|
106
|
+
}
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function nextUnescaped(text, quote, position) {
|
|
111
|
+
const after = text.slice(position, position + MAX_PROMPT_LENGTH);
|
|
112
|
+
for (let index = after.indexOf(quote); index !== -1; index = after.indexOf(quote, index + 1)) {
|
|
113
|
+
if (text[position + index - 1] !== '\\') return position + index;
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Checks that need only a name, so they also cover files too large or too binary to read.
|
|
2
|
+
|
|
3
|
+
const ENV_TEMPLATE_SUFFIX = /\.(?:example|sample|template|dist|defaults?)$/i;
|
|
4
|
+
const KEY_FILE_NAMES = new Set(['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519', '.npmrc', '.netrc', '.htpasswd']);
|
|
5
|
+
const KEY_FILE_EXTENSIONS = new Set(['.p12', '.pfx', '.jks', '.keystore']);
|
|
6
|
+
const DATABASE_EXTENSIONS = new Set(['.sql', '.sqlite', '.sqlite3', '.db', '.dump', '.bak']);
|
|
7
|
+
// Windows writes this thumbnail cache next to images; it is not application data.
|
|
8
|
+
const HARMLESS_DATABASE_NAMES = new Set(['Thumbs.db']);
|
|
9
|
+
const STRAY_SEVERITY = { env: 'critical', git: 'critical', 'key-file': 'critical', database: 'high', 'debug-symbols': 'high' };
|
|
10
|
+
|
|
11
|
+
const ALWAYS_SOURCE_EXTENSIONS = new Set(['.tsx', '.vue', '.svelte']);
|
|
12
|
+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A file that should never be part of what ships: settings with secrets, key stores, data dumps.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ path: string, name: string, extension: string }} file
|
|
18
|
+
*/
|
|
19
|
+
export function checkStrayFile({ path, name, extension }) {
|
|
20
|
+
const kind = strayFileKind(name, extension);
|
|
21
|
+
return kind ? [strayFinding(path, kind)] : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A whole directory that should never ship. It is reported once and not looked into. */
|
|
25
|
+
export function checkStrayDirectory({ path, name }) {
|
|
26
|
+
const kind = strayDirectoryKind(name);
|
|
27
|
+
return kind ? [strayFinding(path, kind)] : [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @returns {'git' | 'debug-symbols' | null} null for a directory that may ship */
|
|
31
|
+
export function strayDirectoryKind(name) {
|
|
32
|
+
if (name === '.git') return 'git';
|
|
33
|
+
if (name.endsWith('.dSYM')) return 'debug-symbols';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Source and test files that a build should have left behind.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ path: string, name: string, extension: string, isInsideArchive: boolean }} file
|
|
41
|
+
*/
|
|
42
|
+
export function checkUnshippedSource({ path, name, extension, isInsideArchive }) {
|
|
43
|
+
const segments = path.split('/');
|
|
44
|
+
if (TEST_FILE.test(name) || segments.includes('__tests__')) return [sourceFinding(path, 'test')];
|
|
45
|
+
if (ALWAYS_SOURCE_EXTENSIONS.has(extension)) return [sourceFinding(path, 'source')];
|
|
46
|
+
// On a website `.ts` is usually a video segment; inside an app or under `src` it is TypeScript.
|
|
47
|
+
const isTypeScript = extension === '.ts' && !name.endsWith('.d.ts');
|
|
48
|
+
if (isTypeScript && (isInsideArchive || segments.includes('src'))) return [sourceFinding(path, 'source')];
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A dotenv file that holds real values, as opposed to a template with the values left blank. */
|
|
53
|
+
export function isEnvFileName(name) {
|
|
54
|
+
return name === '.env' || (name.startsWith('.env.') && !ENV_TEMPLATE_SUFFIX.test(name));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function strayFileKind(name, extension) {
|
|
58
|
+
if (isEnvFileName(name)) return 'env';
|
|
59
|
+
if (KEY_FILE_NAMES.has(name) || KEY_FILE_EXTENSIONS.has(extension)) return 'key-file';
|
|
60
|
+
if (DATABASE_EXTENSIONS.has(extension) && !HARMLESS_DATABASE_NAMES.has(name)) return 'database';
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function strayFinding(path, kind) {
|
|
65
|
+
return { rule: 'stray-file', severity: STRAY_SEVERITY[kind], path, detail: { kind } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourceFinding(path, kind) {
|
|
69
|
+
return { rule: 'unshipped-source', severity: 'medium', path, detail: { kind } };
|
|
70
|
+
}
|