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 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.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": "./bin/launchprep.mjs"
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
- let body = f.text;
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
+ }