launchprep 0.0.1 → 0.1.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 +76 -12
- package/bin/launchprep.mjs +24 -0
- package/net/client.mjs +40 -0
- package/net/commands.mjs +156 -0
- package/net/consent.mjs +57 -0
- package/net/credentials.mjs +33 -0
- package/package.json +27 -12
- package/scripts/verify-readonly.mjs +80 -0
- package/src/brand.mjs +13 -0
- package/src/checks-ai.mjs +255 -0
- package/src/checks-auth.mjs +263 -0
- package/src/checks-authz.mjs +179 -0
- package/src/checks-batch2.mjs +385 -0
- package/src/checks-batch3.mjs +327 -0
- package/src/checks-batch4.mjs +529 -0
- package/src/checks-deploy.mjs +272 -0
- package/src/checks-frameworks.mjs +337 -0
- package/src/checks.mjs +209 -0
- package/src/detect.mjs +304 -0
- package/src/digest.mjs +169 -0
- package/src/fs-scan.mjs +118 -0
- package/src/gate.mjs +103 -0
- package/src/index.mjs +71 -0
- package/src/report.mjs +101 -0
- package/src/rules.json +3651 -0
- package/src/workspace.mjs +0 -0
- package/bin/cli.js +0 -11
package/README.md
CHANGED
|
@@ -1,20 +1,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# launchprep
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reads your code and tells you what will break before your users find out.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
```bash
|
|
6
|
+
npx launchprep # the free checks — runs here, uploads nothing
|
|
7
|
+
npx launchprep login # store the licence key from your email
|
|
8
|
+
npx launchprep deep # the paid scan — asks before sending anything
|
|
9
|
+
npx launchprep whoami # scans remaining
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No signup. No dashboard. Nothing added to your repo.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
288 checks — but it works out what you built **before** running any of them, so
|
|
17
|
+
you are not told about problems you cannot have. A static site is asked 48
|
|
18
|
+
questions; a multi-tenant SaaS with payments and AI is asked 158.
|
|
19
|
+
|
|
20
|
+
Every check ends in one of four states, and you are shown all four:
|
|
21
|
+
|
|
22
|
+
| | |
|
|
23
|
+
|---|---|
|
|
24
|
+
| **pass** | applicable, and satisfied |
|
|
25
|
+
| **fail** | applicable, and violated — a finding, with a file, a line and the fix |
|
|
26
|
+
| **skipped** | does not apply to you — **listed, with the reason** |
|
|
27
|
+
| **unknown** | cannot be determined from the code — becomes a question, never a CRITICAL |
|
|
28
|
+
|
|
29
|
+
That skipped list is the point. "47 checks skipped, you are not multi-tenant" is
|
|
30
|
+
what should make you trust the 12 that did fire.
|
|
31
|
+
|
|
32
|
+
## The free scan cannot touch your code, and cannot phone home
|
|
33
|
+
|
|
34
|
+
The scanner reads. It has no ability to write files, run commands, or open a
|
|
35
|
+
network connection — and that is enforced by a build step rather than promised:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
node scripts/verify-readonly.mjs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That script ships with the package. Run it yourself. It fails if anything under
|
|
42
|
+
`src/` gains the ability to write, execute, or reach the network — **and if
|
|
43
|
+
anything under `src/` so much as imports a file from outside it.**
|
|
44
|
+
|
|
45
|
+
That last part matters. `launchprep login` writes a key to disk and
|
|
46
|
+
`launchprep deep` uploads code. Both are real. Both live in `net/`, not `src/`,
|
|
47
|
+
so the scanner cannot borrow a capability it does not have. Without that check
|
|
48
|
+
this script could print "read-only verified" while the scanner was perfectly
|
|
49
|
+
able to upload your repository — a promise that passes its own test and is
|
|
50
|
+
false, which is worse than no promise at all.
|
|
8
51
|
|
|
9
|
-
|
|
10
|
-
- Covers Next.js, Express, Django, Rails, Laravel, Supabase, Stripe, and mobile.
|
|
11
|
-
- Every finding carries a file, a line, and the fix.
|
|
52
|
+
So the difference is visible in the file tree:
|
|
12
53
|
|
|
13
54
|
```
|
|
14
|
-
|
|
55
|
+
src/ the free scanner reads. writes nothing. connects to nothing.
|
|
56
|
+
net/ the paid client writes your key. uploads a digest. asks first.
|
|
15
57
|
```
|
|
16
58
|
|
|
17
|
-
|
|
18
|
-
|
|
59
|
+
## The deep scan does send code — and tells you before it does
|
|
60
|
+
|
|
61
|
+
`launchprep deep` sends a digest of the security-relevant files to
|
|
62
|
+
launchprep.dev, and from there to Anthropic, so a model can read them. Before a
|
|
63
|
+
single byte leaves it shows you how many files, roughly how many tokens, and
|
|
64
|
+
where they are going — and offers to list every file by name.
|
|
65
|
+
|
|
66
|
+
Your code is held in memory for the scan and discarded. It is never written to
|
|
67
|
+
disk, never logged, and never stored in a database. The findings are kept, so
|
|
68
|
+
the report link keeps working, and that link expires.
|
|
69
|
+
|
|
70
|
+
If there is no terminal — CI, a script — it refuses rather than assuming yes.
|
|
71
|
+
|
|
72
|
+
## Free and paid
|
|
73
|
+
|
|
74
|
+
Everything above is free, unlimited, forever — every check a program can make on
|
|
75
|
+
its own.
|
|
76
|
+
|
|
77
|
+
The paid tier ($29, five deep scans) is the 173 checks that need a model to
|
|
78
|
+
reason about the code: whether one customer can reach another's data, whether
|
|
79
|
+
your tenants are really separated, what happens to information you promised to
|
|
80
|
+
delete. See <https://launchprep.dev>.
|
|
81
|
+
|
|
82
|
+
## Licence
|
|
19
83
|
|
|
20
|
-
MIT
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The entry point, and a deliberately thin one.
|
|
3
|
+
//
|
|
4
|
+
// `launchprep` with no subcommand is the free scanner: src/, which cannot
|
|
5
|
+
// write, execute, or open a connection, and a build step proves it. Nothing in
|
|
6
|
+
// src/ may even import from out here - the guard checks that too, because a
|
|
7
|
+
// promise that passes its own test and is false is worse than no promise.
|
|
8
|
+
//
|
|
9
|
+
// login / logout / deep are the paid half. They write a key to disk and upload
|
|
10
|
+
// a digest, and they live in net/ so that difference is visible in the file
|
|
11
|
+
// tree rather than buried in a privacy policy.
|
|
12
|
+
import { BRAND } from '../src/brand.mjs';
|
|
13
|
+
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
const cmd = argv[0];
|
|
16
|
+
const KNOWN = new Set(['login', 'logout', 'deep', 'whoami', 'help', '--help', '-h']);
|
|
17
|
+
|
|
18
|
+
if (!KNOWN.has(cmd)) {
|
|
19
|
+
// free scan - src/ only, nothing from net/ is loaded at all
|
|
20
|
+
await import('../src/index.mjs');
|
|
21
|
+
} else {
|
|
22
|
+
const { run } = await import('../net/commands.mjs');
|
|
23
|
+
await run(cmd, argv.slice(1));
|
|
24
|
+
}
|
package/net/client.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Talking to launchprep.dev. The only file in the CLI that opens a connection.
|
|
2
|
+
const BASE = process.env.LAUNCHPREP_API || 'https://api.launchprep.dev';
|
|
3
|
+
|
|
4
|
+
async function call(path, { key, body, method = 'POST' } = {}) {
|
|
5
|
+
let res;
|
|
6
|
+
try {
|
|
7
|
+
res = await fetch(BASE + path, {
|
|
8
|
+
method,
|
|
9
|
+
headers: {
|
|
10
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
11
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
12
|
+
},
|
|
13
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
14
|
+
// A deep scan is minutes of model time, not seconds. But it is not
|
|
15
|
+
// forever either - API-010, our own rule: nothing outbound should be able
|
|
16
|
+
// to hang indefinitely.
|
|
17
|
+
signal: AbortSignal.timeout(Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
|
|
18
|
+
});
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e.name === 'TimeoutError') throw new Error('launchprep.dev did not answer in time');
|
|
21
|
+
throw new Error(`could not reach ${BASE} — ${e.message}`);
|
|
22
|
+
}
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
let data = null;
|
|
25
|
+
try { data = text ? JSON.parse(text) : null; } catch {}
|
|
26
|
+
return { status: res.status, ok: res.ok, data, raw: text };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const validate = (key) => call('/v1/validate', { key });
|
|
30
|
+
|
|
31
|
+
export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKey }) =>
|
|
32
|
+
call('/v1/scan', { key, body: {
|
|
33
|
+
digest: digest.text,
|
|
34
|
+
profile,
|
|
35
|
+
rule_ids: ruleIds,
|
|
36
|
+
app_name: appName,
|
|
37
|
+
idempotency_key: idempotencyKey,
|
|
38
|
+
}});
|
|
39
|
+
|
|
40
|
+
export { BASE };
|
package/net/commands.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// login · logout · whoami · deep
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
import { basename, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { BRAND } from '../src/brand.mjs';
|
|
7
|
+
import { scanRepo } from '../src/fs-scan.mjs';
|
|
8
|
+
import { detectProfile, toGateProfile } from '../src/detect.mjs';
|
|
9
|
+
import { gate } from '../src/gate.mjs';
|
|
10
|
+
import { buildDigest } from '../src/digest.mjs';
|
|
11
|
+
import { save, load, clear, FILE } from './credentials.mjs';
|
|
12
|
+
import { confirmUpload } from './consent.mjs';
|
|
13
|
+
import { validate, deepScan, BASE } from './client.mjs';
|
|
14
|
+
|
|
15
|
+
const C = { b:'\x1b[1m', d:'\x1b[2m', g:'\x1b[90m', o:'\x1b[38;5;209m',
|
|
16
|
+
grn:'\x1b[32m', red:'\x1b[31m', yel:'\x1b[33m', off:'\x1b[0m' };
|
|
17
|
+
const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:['\x1b[34m','MEDIUM'], low:[C.g,'LOW'] };
|
|
18
|
+
const flag = (a, n) => a.includes('--' + n);
|
|
19
|
+
|
|
20
|
+
const prompt = (q) => new Promise((r) => {
|
|
21
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
22
|
+
rl.question(q, (a) => { rl.close(); r(a.trim()); });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function login(args) {
|
|
26
|
+
let key = args.find(a => a.startsWith('lp_'));
|
|
27
|
+
if (!key) {
|
|
28
|
+
if (!process.stdin.isTTY) { console.error(' pass the key as an argument, or set LAUNCHPREP_KEY'); process.exit(1); }
|
|
29
|
+
console.log(`\n${C.b}${BRAND.name}${C.off} ${C.d}— paste the key from your email${C.off}\n`);
|
|
30
|
+
key = await prompt(' key: ');
|
|
31
|
+
}
|
|
32
|
+
if (!/^lp_/.test(key)) { console.error(`\n ${C.red}That does not look like a licence key.${C.off} They start with lp_\n`); process.exit(1); }
|
|
33
|
+
|
|
34
|
+
const r = await validate(key);
|
|
35
|
+
if (r.status === 401) { console.error(`\n ${C.red}That key is not recognised.${C.off}\n`); process.exit(1); }
|
|
36
|
+
if (r.status === 403) { console.error(`\n ${C.red}That key has been revoked.${C.off}\n`); process.exit(1); }
|
|
37
|
+
if (!r.ok) { console.error(`\n ${C.red}Could not check the key${C.off} ${C.d}(${r.status})${C.off}\n`); process.exit(1); }
|
|
38
|
+
|
|
39
|
+
const path = save({ key, email: r.data.email });
|
|
40
|
+
console.log(`\n ${C.grn}Signed in${C.off} as ${r.data.email}`);
|
|
41
|
+
console.log(` ${C.d}${r.data.scansRemaining} of ${r.data.scansLimit} deep scans left${C.off}`);
|
|
42
|
+
console.log(` ${C.d}key stored in ${path}, readable only by you${C.off}\n`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logout() {
|
|
46
|
+
console.log(clear() ? `\n ${C.grn}Signed out.${C.off} ${C.d}${FILE} removed${C.off}\n`
|
|
47
|
+
: `\n ${C.d}You were not signed in.${C.off}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function whoami() {
|
|
51
|
+
const c = load();
|
|
52
|
+
if (!c) { console.log(`\n ${C.d}Not signed in. Run: ${C.off}npx ${BRAND.slug} login\n`); return; }
|
|
53
|
+
const r = await validate(c.key);
|
|
54
|
+
if (!r.ok) { console.log(`\n ${C.red}The stored key is no longer valid.${C.off}\n`); return; }
|
|
55
|
+
console.log(`\n ${r.data.email}`);
|
|
56
|
+
console.log(` ${C.d}${r.data.scansRemaining} of ${r.data.scansLimit} deep scans left · key from ${c.source}${C.off}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function deep(args) {
|
|
60
|
+
const target = resolve(args.find(a => !a.startsWith('-')) || process.cwd());
|
|
61
|
+
const c = load();
|
|
62
|
+
if (!c) {
|
|
63
|
+
console.error(`\n ${C.red}Not signed in.${C.off}`);
|
|
64
|
+
console.error(` ${C.d}Buy a key at https://${BRAND.slug}.dev, then: ${C.off}npx ${BRAND.slug} login\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`\n${C.b}${BRAND.name.toUpperCase()} DEEP${C.off} ${C.d}${target}${C.off}\n`);
|
|
69
|
+
|
|
70
|
+
const repo = scanRepo(target, { maxFiles: 12000 });
|
|
71
|
+
const profile = toGateProfile(detectProfile(repo));
|
|
72
|
+
const g = gate(profile);
|
|
73
|
+
const tier2 = g.evaluated.filter(r => r.tier === 2);
|
|
74
|
+
if (!tier2.length) {
|
|
75
|
+
console.log(` ${C.grn}No deep checks apply to this project.${C.off}`);
|
|
76
|
+
console.log(` ${C.d}Nothing was uploaded and no scan was used.${C.off}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const digest = buildDigest(repo, { maxTokens: 150_000, profile });
|
|
80
|
+
|
|
81
|
+
console.log(` ${C.g}what it is ${C.off}${profile.surface} · ${[profile.stack.framework, profile.stack.database, profile.stack.host].filter(Boolean).join(' · ') || '—'}`);
|
|
82
|
+
console.log(` ${C.g}deep checks ${C.off}${tier2.length} of ${g.evaluated.length + g.skipped.length} apply`);
|
|
83
|
+
|
|
84
|
+
const okToSend = await confirmUpload({ digest, host: new URL(BASE).host, assumeYes: flag(args, 'yes') });
|
|
85
|
+
if (!okToSend) process.exit(1);
|
|
86
|
+
|
|
87
|
+
// Same repo, same rules, same content = same request. If the connection drops
|
|
88
|
+
// and this is run again, the server recognises it and does not charge a
|
|
89
|
+
// second scan.
|
|
90
|
+
const idem = createHash('sha256')
|
|
91
|
+
.update(digest.text).update(tier2.map(r => r.id).join(',')).digest('hex').slice(0, 32);
|
|
92
|
+
|
|
93
|
+
console.log(` ${C.d}scanning — this takes a few minutes${C.off}`);
|
|
94
|
+
const r = await deepScan({ key: c.key, digest, profile,
|
|
95
|
+
ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
|
|
96
|
+
|
|
97
|
+
if (r.status === 402) {
|
|
98
|
+
console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
if (r.status === 413) {
|
|
102
|
+
console.error(`\n ${C.red}This project is too large for one scan.${C.off}`);
|
|
103
|
+
console.error(` ${C.d}${r.data?.tokens?.toLocaleString()} tokens, limit ${r.data?.limit?.toLocaleString()}. No scan was used.${C.off}\n`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
console.error(`\n ${C.red}The scan did not run${C.off} ${C.d}(${r.status})${C.off} ${r.data?.detail || r.data?.error || ''}`);
|
|
108
|
+
if (r.data?.refunded) console.error(` ${C.grn}Your scan was not used.${C.off}`);
|
|
109
|
+
console.error('');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const findings = r.data.findings || [];
|
|
114
|
+
const rank = { critical:0, high:1, medium:2, low:3 };
|
|
115
|
+
findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
|
116
|
+
|
|
117
|
+
console.log(`\n${C.b}Findings${C.off} ${C.d}${findings.length}${C.off}\n`);
|
|
118
|
+
for (const f of findings.slice(0, 25)) {
|
|
119
|
+
const [col, label] = SEV[f.severity] || SEV.low;
|
|
120
|
+
console.log(` ${col}${C.b}${label}${C.off} ${f.title}`);
|
|
121
|
+
console.log(` ${C.g}${f.file}:${f.line}${C.off}`);
|
|
122
|
+
console.log(` ${f.detail}`);
|
|
123
|
+
console.log(` ${C.grn}Fix${C.off} ${f.fix}\n`);
|
|
124
|
+
}
|
|
125
|
+
if (r.data.report?.url) {
|
|
126
|
+
console.log(`${C.b}Your report${C.off}`);
|
|
127
|
+
console.log(` ${C.o}${r.data.report.url}${C.off}`);
|
|
128
|
+
console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
|
|
129
|
+
}
|
|
130
|
+
console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function help() {
|
|
134
|
+
console.log(`
|
|
135
|
+
${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
|
|
136
|
+
|
|
137
|
+
${C.b}npx ${BRAND.slug}${C.off} ${C.d}[path]${C.off} the free checks — runs here, uploads nothing
|
|
138
|
+
${C.b}npx ${BRAND.slug} login${C.off} store the licence key from your email
|
|
139
|
+
${C.b}npx ${BRAND.slug} whoami${C.off} who you are signed in as, and scans left
|
|
140
|
+
${C.b}npx ${BRAND.slug} logout${C.off} forget the key
|
|
141
|
+
${C.b}npx ${BRAND.slug} deep${C.off} ${C.d}[path]${C.off} the paid scan — asks before sending anything
|
|
142
|
+
|
|
143
|
+
${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
|
|
144
|
+
${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
|
|
145
|
+
|
|
146
|
+
${C.d}https://${BRAND.slug}.dev${C.off}
|
|
147
|
+
`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function run(cmd, args) {
|
|
151
|
+
if (cmd === 'login') return login(args);
|
|
152
|
+
if (cmd === 'logout') return logout();
|
|
153
|
+
if (cmd === 'whoami') return whoami();
|
|
154
|
+
if (cmd === 'deep') return deep(args);
|
|
155
|
+
return help();
|
|
156
|
+
}
|
package/net/consent.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Nobody's code leaves their machine without them being told what and where.
|
|
2
|
+
//
|
|
3
|
+
// Every other AI tool uploads first and explains in a privacy policy. This is
|
|
4
|
+
// the same principle the rest of the product runs on - shown, so you can
|
|
5
|
+
// correct it - applied to the one moment it matters most.
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
|
|
8
|
+
const C = { b:'\x1b[1m', d:'\x1b[2m', o:'\x1b[38;5;209m', g:'\x1b[90m', off:'\x1b[0m' };
|
|
9
|
+
|
|
10
|
+
function ask(question) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
rl.question(question, (a) => { rl.close(); resolve(a.trim().toLowerCase()); });
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function confirmUpload({ digest, host, assumeYes = false, showFiles = false }) {
|
|
18
|
+
const files = digest.included.length;
|
|
19
|
+
const tokens = digest.tokens.toLocaleString();
|
|
20
|
+
|
|
21
|
+
console.log(`\n${C.b}Before this runs${C.off}\n`);
|
|
22
|
+
console.log(` This sends ${C.b}${files} files${C.off} of your code ${C.d}(about ${tokens} tokens)${C.off}`);
|
|
23
|
+
console.log(` to ${C.b}${host}${C.off}, and from there to Anthropic, so a model can read them.\n`);
|
|
24
|
+
console.log(` ${C.g}Your code is not stored.${C.off} It is held in memory for the scan and discarded.`);
|
|
25
|
+
console.log(` ${C.g}The findings are kept${C.off} — file names, line numbers, and what to fix —`);
|
|
26
|
+
console.log(` so the report link keeps working. That link expires.\n`);
|
|
27
|
+
console.log(` ${C.d}The free checks never do any of this. They run entirely on this machine.${C.off}\n`);
|
|
28
|
+
|
|
29
|
+
if (showFiles) {
|
|
30
|
+
console.log(`${C.b}What would be sent${C.off}`);
|
|
31
|
+
for (const f of digest.included.slice(0, 200))
|
|
32
|
+
console.log(` ${C.g}${String(f.tokens).padStart(6)}${C.off} ${f.path}`);
|
|
33
|
+
if (digest.included.length > 200)
|
|
34
|
+
console.log(` ${C.d}… and ${digest.included.length - 200} more${C.off}`);
|
|
35
|
+
console.log('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (assumeYes) { console.log(` ${C.d}--yes given, continuing.${C.off}\n`); return true; }
|
|
39
|
+
|
|
40
|
+
// No terminal (CI, a pipe) and no --yes: refuse rather than guess. Uploading
|
|
41
|
+
// someone's source because nobody was there to say no is not a default.
|
|
42
|
+
if (!process.stdin.isTTY) {
|
|
43
|
+
console.log(` ${C.o}Not a terminal, so there is nobody to ask.${C.off}`);
|
|
44
|
+
console.log(` ${C.d}Re-run with --yes if you meant this to be automatic.${C.off}\n`);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!showFiles) {
|
|
49
|
+
const list = await ask(` List the files first? ${C.d}[y/N]${C.off} `);
|
|
50
|
+
if (list === 'y' || list === 'yes')
|
|
51
|
+
return confirmUpload({ digest, host, assumeYes, showFiles: true });
|
|
52
|
+
}
|
|
53
|
+
const go = await ask(` Send them and run the deep scan? ${C.d}[y/N]${C.off} `);
|
|
54
|
+
const yes = go === 'y' || go === 'yes';
|
|
55
|
+
console.log(yes ? '' : `\n ${C.d}Nothing was sent.${C.off}\n`);
|
|
56
|
+
return yes;
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Where the licence key lives.
|
|
2
|
+
//
|
|
3
|
+
// This file WRITES, which is why it is not in src/. The scanner has no such
|
|
4
|
+
// ability and a build step proves it; these two commands do, and say so.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const DIR = join(homedir(), '.launchprep');
|
|
10
|
+
export const FILE = join(DIR, 'credentials');
|
|
11
|
+
|
|
12
|
+
export function save({ key, email }) {
|
|
13
|
+
// 0700 / 0600. The key is a bearer token: anyone who can read the file can
|
|
14
|
+
// spend the scans. Other users on a shared machine should not be able to.
|
|
15
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
16
|
+
writeFileSync(FILE, JSON.stringify({ key, email, savedAt: new Date().toISOString() }, null, 2) + '\n',
|
|
17
|
+
{ mode: 0o600 });
|
|
18
|
+
try { chmodSync(DIR, 0o700); chmodSync(FILE, 0o600); } catch {}
|
|
19
|
+
return FILE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function load() {
|
|
23
|
+
// An env var wins, so CI never needs a file on disk.
|
|
24
|
+
if (process.env.LAUNCHPREP_KEY) return { key: process.env.LAUNCHPREP_KEY.trim(), source: 'env' };
|
|
25
|
+
try {
|
|
26
|
+
const c = JSON.parse(readFileSync(FILE, 'utf8'));
|
|
27
|
+
return c.key ? { ...c, source: 'file' } : null;
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clear() {
|
|
32
|
+
try { rmSync(FILE); return true; } catch { return false; }
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Launch readiness checker. Reads your code and tells you what will break
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"bin": {
|
|
6
|
-
"launchprep": "bin/
|
|
7
|
+
"launchprep": "./bin/launchprep.mjs"
|
|
7
8
|
},
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
"
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"net",
|
|
12
|
+
"bin",
|
|
13
|
+
"scripts/verify-readonly.mjs",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"verify": "node scripts/verify-readonly.mjs",
|
|
18
|
+
"pretest": "node scripts/verify-readonly.mjs",
|
|
19
|
+
"test": "node test/run.mjs"
|
|
11
20
|
},
|
|
12
21
|
"keywords": [
|
|
13
22
|
"security",
|
|
@@ -15,12 +24,18 @@
|
|
|
15
24
|
"readiness",
|
|
16
25
|
"scanner",
|
|
17
26
|
"prelaunch",
|
|
18
|
-
"cli"
|
|
27
|
+
"cli",
|
|
28
|
+
"launch",
|
|
29
|
+
"checklist"
|
|
19
30
|
],
|
|
20
31
|
"homepage": "https://launchprep.dev",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/312labs/launchprep-app.git",
|
|
35
|
+
"directory": "cli"
|
|
36
|
+
},
|
|
21
37
|
"license": "MIT",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build if anything under src/ gains the ability to write, execute,
|
|
3
|
+
// or phone home. Launchprep reads the user's codebase; it must never touch it.
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join, extname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SRC = new URL('../src/', import.meta.url).pathname;
|
|
8
|
+
|
|
9
|
+
const FORBIDDEN = [
|
|
10
|
+
[/\bwriteFileSync\b|\bappendFileSync\b|\bwriteSync\b|\bcreateWriteStream\b/, 'file write'],
|
|
11
|
+
[/\bmkdirSync\b|\brmSync\b|\brmdirSync\b|\bunlinkSync\b|\btruncateSync\b/, 'file/dir mutation'],
|
|
12
|
+
[/\brenameSync\b|\bcopyFileSync\b|\bchmodSync\b|\bchownSync\b|\bsymlinkSync\b/, 'file metadata change'],
|
|
13
|
+
[/from\s+['"]node:child_process['"]|require\(['"]child_process['"]\)/, 'process execution'],
|
|
14
|
+
[/\bexecSync\b|\bspawnSync\b|\bexecFileSync\b/, 'process execution'],
|
|
15
|
+
[/from\s+['"]node:(net|http|https|dgram|tls)['"]/, 'network'],
|
|
16
|
+
[/\bfetch\s*\(/, 'network'],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const files = [];
|
|
20
|
+
(function walk(d) {
|
|
21
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
22
|
+
const p = join(d, e.name);
|
|
23
|
+
if (e.isDirectory()) walk(p);
|
|
24
|
+
else if (['.mjs', '.js', '.ts'].includes(extname(e.name))) files.push(p);
|
|
25
|
+
}
|
|
26
|
+
})(SRC);
|
|
27
|
+
|
|
28
|
+
let bad = 0;
|
|
29
|
+
// A forbidden name appearing INSIDE a regex or a string is a pattern to search
|
|
30
|
+
// for, not a call. Strip those before matching, or the scanner cannot contain a
|
|
31
|
+
// check that looks for dangerous code.
|
|
32
|
+
function stripLiterals(line) {
|
|
33
|
+
return line
|
|
34
|
+
.replace(/\/(?![*/])(?:\\.|\[[^\]]*\]|[^\\/\n])+\/[gimsuy]*/g, ' RE ')
|
|
35
|
+
.replace(/'(?:\\.|[^'\\])*'/g, " '' ")
|
|
36
|
+
.replace(/"(?:\\.|[^"\\])*"/g, ' "" ')
|
|
37
|
+
.replace(/`(?:\\.|[^`\\])*`/g, ' `` ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
const text = readFileSync(f, 'utf8');
|
|
42
|
+
text.split('\n').forEach((raw, i) => {
|
|
43
|
+
if (raw.trimStart().startsWith('//')) return; // comments describe, they don't run
|
|
44
|
+
const line = stripLiterals(raw);
|
|
45
|
+
for (const [re, label] of FORBIDDEN) {
|
|
46
|
+
if (re.test(line)) {
|
|
47
|
+
console.error(` ${f.replace(SRC, 'src/')}:${i + 1} ${label}\n ${raw.trim()}`);
|
|
48
|
+
bad++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A capability src/ does not have, it must not be able to BORROW either.
|
|
55
|
+
// `login` writes a key to disk and `deep` uploads a digest - both real, both
|
|
56
|
+
// necessary, both living in net/. If src/ could import them, this script would
|
|
57
|
+
// print "read-only verified" while the scanner was able to upload the user's
|
|
58
|
+
// repository. A promise that passes its own test and is false is worse than no
|
|
59
|
+
// promise. So: src/ may import only from src/.
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
const text = readFileSync(f, 'utf8');
|
|
62
|
+
text.split('\n').forEach((raw, i) => {
|
|
63
|
+
if (raw.trimStart().startsWith('//')) return;
|
|
64
|
+
const m = raw.match(/^\s*(?:import|export)[^'"]*from\s+['"]([^'"]+)['"]/)
|
|
65
|
+
|| raw.match(/\bimport\s*\(\s*['"]([^'"]+)['"]/);
|
|
66
|
+
if (!m) return;
|
|
67
|
+
const spec = m[1];
|
|
68
|
+
if (!spec.startsWith('.')) return; // node: builtins are covered above
|
|
69
|
+
if (/^\.\.\/(?!src\/)/.test(spec) || spec.startsWith('../../')) {
|
|
70
|
+
console.error(` ${f.replace(SRC, 'src/')}:${i + 1} reaches outside src/\n ${raw.trim()}`);
|
|
71
|
+
bad++;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (bad) {
|
|
77
|
+
console.error(`\nFAIL — ${bad} capability violation(s). The Launchprep scanner must stay read-only.`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
console.log(`read-only verified across ${files.length} source file(s) — no writes, no exec, no network, and nothing imported from outside src/`);
|
package/src/brand.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The product name lives here and nowhere else.
|
|
2
|
+
//
|
|
3
|
+
// Renaming is a one-line change until the day this is published. After that,
|
|
4
|
+
// the npm package name and the plugin id are claimed by users who installed
|
|
5
|
+
// them, and a rename breaks their setup - so decide the real name before the
|
|
6
|
+
// first public release, not after.
|
|
7
|
+
export const BRAND = {
|
|
8
|
+
name: 'Launchprep', // shown to humans
|
|
9
|
+
slug: 'launchprep', // npm package, plugin id, slash command
|
|
10
|
+
tagline: 'readiness scan',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const CMD = '/' + BRAND.slug;
|