launchprep 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/net/consent.mjs +5 -0
- package/package.json +3 -3
- package/src/digest.mjs +13 -1
- package/src/redact.mjs +127 -0
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,10 +1,10 @@
|
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"verify": "node scripts/verify-readonly.mjs",
|
|
19
19
|
"pretest": "node scripts/verify-readonly.mjs",
|
|
20
|
-
"test": "node test/run.mjs && node test/gate.mjs",
|
|
20
|
+
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs",
|
|
21
21
|
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
22
|
"postpack": "node scripts/restore-after-publish.mjs"
|
|
23
23
|
},
|
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/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
|
+
}
|