launchprep 0.2.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/LICENSE +31 -0
- package/README.md +17 -2
- package/net/commands.mjs +45 -2
- package/net/consent.mjs +5 -0
- package/package.json +11 -10
- package/src/digest.mjs +13 -1
- package/src/index.mjs +37 -2
- package/src/redact.mjs +127 -0
- package/src/rules.json +1 -3661
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Launchprep Licence
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bc. Bektur Aibekov, IČO 29522471, Prague, Czech Republic.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
You may:
|
|
7
|
+
|
|
8
|
+
1. Install and run this software to scan source code that you own or are
|
|
9
|
+
authorised to scan.
|
|
10
|
+
2. Make copies as reasonably necessary for that use (for example, inside a
|
|
11
|
+
CI pipeline or a container image you operate).
|
|
12
|
+
|
|
13
|
+
You may not:
|
|
14
|
+
|
|
15
|
+
3. Copy, redistribute, sublicense, sell, or publish this software or any
|
|
16
|
+
part of it, including the rule and check definitions it contains.
|
|
17
|
+
4. Extract, reproduce, or adapt the rule and check definitions for use in
|
|
18
|
+
any other product or service, commercial or not.
|
|
19
|
+
5. Remove or alter this notice.
|
|
20
|
+
|
|
21
|
+
Versions 0.1.0 and 0.2.0 of the "launchprep" npm package were published under
|
|
22
|
+
the MIT licence; that grant stands for those versions. This licence applies to
|
|
23
|
+
this version and all later ones.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
27
|
+
OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE. Findings are produced
|
|
28
|
+
by automated analysis and may be incomplete or wrong; they are information,
|
|
29
|
+
not a guarantee of security or fitness for launch.
|
|
30
|
+
|
|
31
|
+
Questions and permissions: hello@launchprep.dev
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ No signup. No dashboard. Nothing added to your repo.
|
|
|
13
13
|
|
|
14
14
|
## What it does
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
289 checks — but it works out what you built **before** running any of them, so
|
|
17
17
|
you are not told about problems you cannot have. A static site is asked 48
|
|
18
18
|
questions; a multi-tenant SaaS with payments and AI is asked 158.
|
|
19
19
|
|
|
@@ -69,6 +69,19 @@ the report link keeps working, and that link expires.
|
|
|
69
69
|
|
|
70
70
|
If there is no terminal — CI, a script — it refuses rather than assuming yes.
|
|
71
71
|
|
|
72
|
+
## In CI
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx launchprep . --fail-on critical # exit 1 if a critical is found
|
|
76
|
+
npx launchprep deep . --yes --fail-on high # the paid scan as a gate
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Without `--fail-on` the exit code stays 0 — a report, not a verdict. With it,
|
|
80
|
+
findings at or above the threshold (`critical`, `high`, `medium`, `low`, `any`)
|
|
81
|
+
fail the build. On a deep scan, checks that did not complete fail it too — a
|
|
82
|
+
check that could not run has not passed. A path that does not exist always
|
|
83
|
+
exits 2: a scan of nothing must never look like a clean scan of something.
|
|
84
|
+
|
|
72
85
|
## Free and paid
|
|
73
86
|
|
|
74
87
|
Everything above is free, unlimited, forever — every check a program can make on
|
|
@@ -81,4 +94,6 @@ delete. See <https://launchprep.dev>.
|
|
|
81
94
|
|
|
82
95
|
## Licence
|
|
83
96
|
|
|
84
|
-
|
|
97
|
+
Proprietary — see the LICENSE file. Run it on anything you are authorised to
|
|
98
|
+
scan; the rules and checks may not be copied into other products. Versions
|
|
99
|
+
0.1.0 and 0.2.0 were published under MIT and remain so.
|
package/net/commands.mjs
CHANGED
|
@@ -99,6 +99,12 @@ async function deep(args) {
|
|
|
99
99
|
console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}. No scan was used.${C.off}\n`);
|
|
100
100
|
process.exit(1);
|
|
101
101
|
}
|
|
102
|
+
if (r.status === 402 && (r.data?.error === 'attempt_ceiling' || r.data?.error === 'spend_ceiling')) {
|
|
103
|
+
console.error(`\n ${C.red}This key has run too many scans that did not finish.${C.off}`);
|
|
104
|
+
console.error(` ${C.d}${r.data.attempts} attempts against a ${r.data.limit}-scan licence. Something is going`);
|
|
105
|
+
console.error(` wrong on our side rather than yours — mail ${BRAND.email} and we will sort it out.${C.off}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
102
108
|
if (r.status === 402) {
|
|
103
109
|
console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
|
|
104
110
|
process.exit(1);
|
|
@@ -127,12 +133,47 @@ async function deep(args) {
|
|
|
127
133
|
console.log(` ${f.detail}`);
|
|
128
134
|
console.log(` ${C.grn}Fix${C.off} ${f.fix}\n`);
|
|
129
135
|
}
|
|
136
|
+
// Printed BEFORE the report link, and before the scans-left line, because a
|
|
137
|
+
// reader who stops at the findings must still have seen this. A check that
|
|
138
|
+
// did not run is a hole in the answer, not a footnote to it.
|
|
139
|
+
const missed = r.data.incomplete || [];
|
|
140
|
+
if (missed.length) {
|
|
141
|
+
console.log(`${C.red}${C.b}${missed.length} check${missed.length === 1 ? '' : 's'} did not complete${C.off}`);
|
|
142
|
+
console.log(` ${C.d}These are not a pass. Nothing below was ruled out.${C.off}`);
|
|
143
|
+
for (const m of missed.slice(0, 8)) {
|
|
144
|
+
console.log(` ${C.red}~${C.off} ${C.g}${m.id.padEnd(12)}${C.off}${C.d}${(m.title || '').slice(0, 58)}${C.off}`);
|
|
145
|
+
}
|
|
146
|
+
if (missed.length > 8) console.log(` ${C.d}and ${missed.length - 8} more${C.off}`);
|
|
147
|
+
if (r.data.coverage !== undefined) {
|
|
148
|
+
console.log(` ${C.d}${Math.round(r.data.coverage * 100)}% of the ${r.data.rulesAsked} checks in this scan answered.${C.off}`);
|
|
149
|
+
}
|
|
150
|
+
if (r.data.refunded) {
|
|
151
|
+
console.log(` ${C.grn}This scan did not finish, so it does not count against your five.${C.off}`);
|
|
152
|
+
}
|
|
153
|
+
console.log(` ${C.d}Run it again and they usually complete.${C.off}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
130
156
|
if (r.data.report?.url) {
|
|
131
157
|
console.log(`${C.b}Your report${C.off}`);
|
|
132
158
|
console.log(` ${C.o}${r.data.report.url}${C.off}`);
|
|
133
159
|
console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
|
|
134
160
|
}
|
|
135
161
|
console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
|
|
162
|
+
|
|
163
|
+
// --fail-on <severity>: the CI gate, same contract as the free scan. The
|
|
164
|
+
// human report above has fully printed; the pipeline now gets its verdict.
|
|
165
|
+
// A scan with holes trips the gate too — a check that did not run has not
|
|
166
|
+
// passed, and a gate that shrugs at that is a gate that waves through the
|
|
167
|
+
// exact case it exists for.
|
|
168
|
+
const failOn = args[args.indexOf('--fail-on') + 1];
|
|
169
|
+
if (args.includes('--fail-on')) {
|
|
170
|
+
if (!(failOn in rank) && failOn !== 'any') {
|
|
171
|
+
console.error(' --fail-on must be one of: critical, high, medium, low, any');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const limit = failOn === 'any' ? 3 : rank[failOn];
|
|
175
|
+
if (findings.some(f => rank[f.severity] <= limit) || missed.length) process.exit(1);
|
|
176
|
+
}
|
|
136
177
|
}
|
|
137
178
|
|
|
138
179
|
function help() {
|
|
@@ -145,8 +186,10 @@ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
|
|
|
145
186
|
${C.b}npx ${BRAND.slug} logout${C.off} forget the key
|
|
146
187
|
${C.b}npx ${BRAND.slug} deep${C.off} ${C.d}[path]${C.off} the paid scan — asks before sending anything
|
|
147
188
|
|
|
148
|
-
${C.d}--yes${C.off}
|
|
149
|
-
${C.d}--json${C.off}
|
|
189
|
+
${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
|
|
190
|
+
${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
|
|
191
|
+
${C.d}--fail-on${C.off} ${C.d}<sev>${C.off} exit 1 if findings reach this severity — fails a CI build
|
|
192
|
+
${C.d}critical | high | medium | low | any${C.off}
|
|
150
193
|
|
|
151
194
|
${C.d}https://${BRAND.slug}.dev${C.off}
|
|
152
195
|
`);
|
package/net/consent.mjs
CHANGED
|
@@ -21,6 +21,11 @@ export async function confirmUpload({ digest, host, assumeYes = false, showFiles
|
|
|
21
21
|
console.log(`\n${C.b}Before this runs${C.off}\n`);
|
|
22
22
|
console.log(` This sends ${C.b}${files} files${C.off} of your code ${C.d}(about ${tokens} tokens)${C.off}`);
|
|
23
23
|
console.log(` to ${C.b}${host}${C.off}, and from there to Anthropic, so a model can read them.\n`);
|
|
24
|
+
if (digest.secretsRedacted > 0) {
|
|
25
|
+
console.log(` ${C.g}${digest.secretsRedacted} secret${digest.secretsRedacted === 1 ? '' : 's'} masked before sending${C.off} — keys, passwords, tokens and`);
|
|
26
|
+
console.log(` the rows of any database dump are replaced on this machine, so their`);
|
|
27
|
+
console.log(` values never leave it. Only that they existed, and where, is sent.\n`);
|
|
28
|
+
}
|
|
24
29
|
console.log(` ${C.g}Your code is not stored.${C.off} It is held in memory for the scan and discarded.`);
|
|
25
30
|
console.log(` ${C.g}The findings are kept${C.off} — file names, line numbers, and what to fix —`);
|
|
26
31
|
console.log(` so the report link keeps working. That link expires.\n`);
|
package/package.json
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"launchprep": "
|
|
7
|
+
"launchprep": "bin/launchprep.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
11
|
"net",
|
|
12
12
|
"bin",
|
|
13
13
|
"scripts/verify-readonly.mjs",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"verify": "node scripts/verify-readonly.mjs",
|
|
18
19
|
"pretest": "node scripts/verify-readonly.mjs",
|
|
19
|
-
"test": "node test/run.mjs"
|
|
20
|
+
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs",
|
|
21
|
+
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
|
+
"postpack": "node scripts/restore-after-publish.mjs"
|
|
20
23
|
},
|
|
21
24
|
"keywords": [
|
|
22
25
|
"security",
|
|
@@ -29,13 +32,11 @@
|
|
|
29
32
|
"checklist"
|
|
30
33
|
],
|
|
31
34
|
"homepage": "https://launchprep.dev",
|
|
32
|
-
"
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/312labs/launchprep-app.git",
|
|
35
|
-
"directory": "cli"
|
|
36
|
-
},
|
|
37
|
-
"license": "MIT",
|
|
35
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
38
36
|
"engines": {
|
|
39
37
|
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"email": "hello@launchprep.dev"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/digest.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redact } from './redact.mjs';
|
|
2
|
+
|
|
1
3
|
// Build the block of code the model reads.
|
|
2
4
|
//
|
|
3
5
|
// This is the single most expensive decision in the product. Every token here
|
|
@@ -128,9 +130,15 @@ export function buildDigest(repo, { maxTokens = 150_000, profile = {} } = {}) {
|
|
|
128
130
|
const omitted = [];
|
|
129
131
|
const spentByKind = {};
|
|
130
132
|
let tokens = 0;
|
|
133
|
+
let secretsRedacted = 0;
|
|
131
134
|
|
|
132
135
|
const take = (f, respectShare) => {
|
|
133
|
-
|
|
136
|
+
// Scrub secret values on this machine, before the file can enter the
|
|
137
|
+
// payload. A masked secret never leaves the user's disk. Done here rather
|
|
138
|
+
// than at read time so the free scan — which reads the same files but sends
|
|
139
|
+
// nothing — pays nothing for it.
|
|
140
|
+
const scrubbed = redact(f.text, f.path);
|
|
141
|
+
let body = scrubbed.text;
|
|
134
142
|
let truncated = false;
|
|
135
143
|
if (body.length > MAX_FILE_CHARS) { body = body.slice(0, MAX_FILE_CHARS); truncated = true; }
|
|
136
144
|
const block = `\n──── ${f.path}${truncated ? ' [truncated]' : ''}\n${body}\n`;
|
|
@@ -144,6 +152,7 @@ export function buildDigest(repo, { maxTokens = 150_000, profile = {} } = {}) {
|
|
|
144
152
|
included.push({ path: f.path, kind: f.kind, tokens: cost });
|
|
145
153
|
spentByKind[f.kind] = (spentByKind[f.kind] || 0) + cost;
|
|
146
154
|
tokens += cost;
|
|
155
|
+
secretsRedacted += scrubbed.count;
|
|
147
156
|
return true;
|
|
148
157
|
};
|
|
149
158
|
|
|
@@ -157,6 +166,9 @@ export function buildDigest(repo, { maxTokens = 150_000, profile = {} } = {}) {
|
|
|
157
166
|
tokens,
|
|
158
167
|
included,
|
|
159
168
|
omitted,
|
|
169
|
+
// How many secret values were masked before anything left the machine.
|
|
170
|
+
// Surfaced on the consent screen so the promise is visible, not just made.
|
|
171
|
+
secretsRedacted,
|
|
160
172
|
// what the model must be told it cannot see, or it will reason as though
|
|
161
173
|
// the absence of an ownership check is proof there isn't one
|
|
162
174
|
coverage: {
|
package/src/index.mjs
CHANGED
|
@@ -5,9 +5,38 @@ import { detectProfile, toGateProfile } from './detect.mjs';
|
|
|
5
5
|
import { gate, missingFacts } from './gate.mjs';
|
|
6
6
|
import { runChecks, runRootChecks } from './checks.mjs';
|
|
7
7
|
import { render } from './report.mjs';
|
|
8
|
+
import { existsSync, statSync } from 'node:fs';
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const target = args.find(a => !a.startsWith('-')) || process.cwd();
|
|
12
|
+
const asJson = args.includes('--json');
|
|
13
|
+
|
|
14
|
+
// --fail-on <severity>: the CI gate. Findings at or above the threshold turn
|
|
15
|
+
// the exit code to 1 so a pipeline can stop the deploy. Opt-in on purpose —
|
|
16
|
+
// a person reading the report in a terminal gets information, a robot gets a
|
|
17
|
+
// verdict only when asked for one.
|
|
18
|
+
const RANK_GATE = { critical: 0, high: 1, medium: 2, low: 3, any: 3 };
|
|
19
|
+
let failOn = null;
|
|
20
|
+
{
|
|
21
|
+
const i = args.indexOf('--fail-on');
|
|
22
|
+
if (i !== -1) {
|
|
23
|
+
failOn = args[i + 1];
|
|
24
|
+
if (!(failOn in RANK_GATE)) {
|
|
25
|
+
process.stderr.write(`\n --fail-on must be one of: critical, high, medium, low, any\n\n`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A path that does not exist must be a loud error, never a clean report. A
|
|
32
|
+
// scan of nothing looks exactly like a scan that found nothing, and telling
|
|
33
|
+
// someone they are safe because they mistyped a folder name is the one thing
|
|
34
|
+
// this tool must never do.
|
|
35
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
36
|
+
process.stderr.write(`\n \x1b[31mThat path does not exist: ${target}\x1b[0m\n`);
|
|
37
|
+
process.stderr.write(` \x1b[2mNothing was scanned. Check the folder name and run it again.\x1b[0m\n\n`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
const repo = scanRepo(target, { maxFiles: 12000 });
|
|
13
42
|
const packages = splitWorkspaces(repo);
|
|
@@ -69,3 +98,9 @@ if (asJson) {
|
|
|
69
98
|
} else {
|
|
70
99
|
process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
|
|
71
100
|
}
|
|
101
|
+
|
|
102
|
+
// The gate, after the full report has printed: the human still sees
|
|
103
|
+
// everything, and the pipeline sees the verdict it asked for.
|
|
104
|
+
if (failOn && findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Strip secret VALUES out of a file before it is ever put in the upload digest.
|
|
2
|
+
//
|
|
3
|
+
// The deep scan sends a digest of the user's code to our server and on to
|
|
4
|
+
// Anthropic. The privacy page promises credentials are never transmitted, and
|
|
5
|
+
// until this module existed that promise was false: a backup.sql full of card
|
|
6
|
+
// numbers, a docker-compose.yml with a plaintext password, or a key hardcoded
|
|
7
|
+
// in source went up verbatim. Only .env was excluded.
|
|
8
|
+
//
|
|
9
|
+
// This runs in the CLI, on the user's machine, BEFORE anything leaves it — so a
|
|
10
|
+
// masked secret never travels at all, not even to us. That is the only correct
|
|
11
|
+
// place for it; redacting server-side would mean the secret already left.
|
|
12
|
+
//
|
|
13
|
+
// The rule of thumb is the inverse of the scanner's. The scanner must not cry
|
|
14
|
+
// wolf, so it needs evidence before it fires. Redaction is the opposite: a
|
|
15
|
+
// masked non-secret costs a little context, a leaked real secret is the whole
|
|
16
|
+
// failure. So this leans aggressive on things SHAPED like secrets, and only
|
|
17
|
+
// holds back where masking would destroy code the scan genuinely needs — a
|
|
18
|
+
// reference like `process.env.STRIPE_KEY` is good code, not a leak, and stays.
|
|
19
|
+
//
|
|
20
|
+
// What the tier-2 rules ask about — authorization, tenancy, deletion, spend —
|
|
21
|
+
// is never answered by the literal value of a key or a row of customer data, so
|
|
22
|
+
// nothing here reduces what the scan can find.
|
|
23
|
+
|
|
24
|
+
const TAG = (kind) => `«redacted:${kind}»`;
|
|
25
|
+
|
|
26
|
+
// 1. PEM private-key blocks — the whole block, header to footer.
|
|
27
|
+
const PEM = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g;
|
|
28
|
+
|
|
29
|
+
// 2. Credentials embedded in a connection URL: scheme://user:pass@host.
|
|
30
|
+
// Keep the scheme and host (useful shape), mask only user:pass.
|
|
31
|
+
const CONN = /\b((?:postgres|postgresql|mysql|mysql2|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|mariadb):\/\/)([^\s:@/]+):([^\s@/]+)@/gi;
|
|
32
|
+
|
|
33
|
+
// 3. Provider keys by their published shape. pk_ (Stripe publishable) is left
|
|
34
|
+
// alone on purpose — it is designed to be public, and masking it was one of
|
|
35
|
+
// the original false positives this whole product learned from.
|
|
36
|
+
const SHAPES = [
|
|
37
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, 'stripe-key'],
|
|
38
|
+
[/\bwhsec_[A-Za-z0-9]{20,}/g, 'webhook-secret'],
|
|
39
|
+
[/\bsk-ant-[A-Za-z0-9_-]{20,}/g, 'anthropic-key'],
|
|
40
|
+
[/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, 'openai-key'],
|
|
41
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, 'aws-access-key'],
|
|
42
|
+
[/\bASIA[0-9A-Z]{16}\b/g, 'aws-temp-key'],
|
|
43
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/g, 'google-key'],
|
|
44
|
+
[/\bya29\.[0-9A-Za-z_-]{20,}/g, 'google-oauth'],
|
|
45
|
+
[/\bgh[pousr]_[A-Za-z0-9]{36,}/g, 'github-token'],
|
|
46
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}/g, 'gitlab-token'],
|
|
47
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, 'slack-token'],
|
|
48
|
+
[/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, 'sendgrid-key'],
|
|
49
|
+
[/\bre_[A-Za-z0-9_-]{20,}/g, 'resend-key'],
|
|
50
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, 'jwt'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// 4. A value assigned to a secret-NAMED identifier. This catches the passwords
|
|
54
|
+
// and tokens that have no distinctive shape. It fires only on string
|
|
55
|
+
// literals, and holds back where the value is plainly not a secret: an env
|
|
56
|
+
// reference, a template placeholder, an interpolation, an empty string.
|
|
57
|
+
const SECRET_NAME = /(?:pass(?:word|wd)?|pwd|secret|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|token|credentials?|passphrase|dsn|encryption[_-]?key|signing[_-]?key|session[_-]?secret|db[_-]?pass(?:word)?)/i;
|
|
58
|
+
const ASSIGN = new RegExp(
|
|
59
|
+
'(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)(["\'`])([^"\'`\\n]{6,}?)\\2',
|
|
60
|
+
'gi',
|
|
61
|
+
);
|
|
62
|
+
// values that are NOT secrets even though the key name matched
|
|
63
|
+
const NOT_A_SECRET = /^(?:process\.env|import\.meta|os\.environ|ENV\[|Deno\.env|\$\{|\$[A-Z(]|<[A-Za-z%{]|\{\{|%[A-Za-z]|your[_-]|change[_-]?me|placeholder|example|dummy|test[_-]?key|xxx+|\*{3,}|\.{3,}|null|undefined|true|false|none|n\/?a)/i;
|
|
64
|
+
|
|
65
|
+
// 4b. The same, unquoted: `POSTGRES_PASSWORD: hunter2` / `DB_PASS=hunter2` /
|
|
66
|
+
// Dockerfile `ENV SECRET hunter2`. This is how .env, YAML, Dockerfiles,
|
|
67
|
+
// Terraform and .ini files carry secrets, and none of them quote. Applied
|
|
68
|
+
// ONLY to those config files — in real code a bare `password: foo` is a
|
|
69
|
+
// variable reference (foo), and masking it would delete a name the scan
|
|
70
|
+
// wants. The value runs to end of line.
|
|
71
|
+
// colon or equals, spaced or not: `PASSWORD: hunter2` (yaml), `SECRET=hunter2`
|
|
72
|
+
// (.env), `token = "..."` handled by the quoted rule above.
|
|
73
|
+
const ASSIGN_BARE = new RegExp(
|
|
74
|
+
'(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)([^\\s"\'`#][^\\n#]*?)(\\s*(?:#.*)?)$',
|
|
75
|
+
'gim',
|
|
76
|
+
);
|
|
77
|
+
// Dockerfile / shell: `ENV DB_PASSWORD hunter2`, `ARG API_TOKEN=hunter2`. The
|
|
78
|
+
// secret word is a substring of the identifier, and the value is one token.
|
|
79
|
+
const DOCKER_ENV = new RegExp(
|
|
80
|
+
'\\b(ENV|ARG|export)\\s+([A-Za-z0-9_]*' + SECRET_NAME.source + '[A-Za-z0-9_]*[\\s=]+)(["\']?)([^\\s"\']+)\\3',
|
|
81
|
+
'gi',
|
|
82
|
+
);
|
|
83
|
+
const CONFIGISH = /(^|\/)(\.env|.*\.env|.*\.ya?ml|.*\.tf|.*\.tfvars|.*\.toml|.*\.ini|.*\.conf|.*\.properties|Dockerfile[^/]*|.*\.dockerfile)$/i;
|
|
84
|
+
|
|
85
|
+
// 5. Bulk data in a SQL dump. The schema the scan needs comes from CREATE TABLE
|
|
86
|
+
// and ALTER; the rows do not, and the rows are where the customer PII and any
|
|
87
|
+
// secret literals in a dump actually live.
|
|
88
|
+
const SQL_INSERT = /(\bINSERT\s+INTO\s+[^\n(]+?)(\([^)]*\)\s*)?\bVALUES\b[\s\S]*?;/gi;
|
|
89
|
+
const SQL_COPY = /(\bCOPY\s+[^\n]+?FROM\s+stdin\s*;)[\s\S]*?\n\\\.$/gim;
|
|
90
|
+
|
|
91
|
+
export function redact(text, path = '') {
|
|
92
|
+
if (!text) return { text, count: 0 };
|
|
93
|
+
let count = 0;
|
|
94
|
+
const bump = (n = 1) => { count += n; };
|
|
95
|
+
|
|
96
|
+
let out = text.replace(PEM, () => (bump(), TAG('private-key')));
|
|
97
|
+
|
|
98
|
+
out = out.replace(CONN, (_, scheme) => (bump(), `${scheme}${TAG('credentials')}@`));
|
|
99
|
+
|
|
100
|
+
for (const [re, kind] of SHAPES) {
|
|
101
|
+
out = out.replace(re, () => (bump(), TAG(kind)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
out = out.replace(ASSIGN, (m, head, q, val) => {
|
|
105
|
+
if (NOT_A_SECRET.test(val)) return m;
|
|
106
|
+
bump();
|
|
107
|
+
return `${head}${q}${TAG('secret')}${q}`;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (CONFIGISH.test(path)) {
|
|
111
|
+
out = out.replace(ASSIGN_BARE, (m, head, val, trail) => {
|
|
112
|
+
if (NOT_A_SECRET.test(val)) return m;
|
|
113
|
+
bump();
|
|
114
|
+
return `${head}${TAG('secret')}${trail}`;
|
|
115
|
+
});
|
|
116
|
+
out = out.replace(DOCKER_ENV, (m, kw, name, q, val) => {
|
|
117
|
+
if (NOT_A_SECRET.test(val)) return m;
|
|
118
|
+
bump();
|
|
119
|
+
return `${kw} ${name}${q}${TAG('secret')}${q}`;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
out = out.replace(SQL_INSERT, (m, head) => (bump(), `${head}VALUES ${TAG('rows')};`));
|
|
124
|
+
out = out.replace(SQL_COPY, (_, head) => (bump(), `${head} ${TAG('rows')}\n\\.`));
|
|
125
|
+
|
|
126
|
+
return { text: out, count };
|
|
127
|
+
}
|