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 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
- 288 checks — but it works out what you built **before** running any of them, so
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
- MIT
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} skip the upload confirmation ${C.d}(for CI)${C.off}
149
- ${C.d}--json${C.off} machine readable ${C.d}(free scan only)${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.2.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",
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
- "repository": {
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
- 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/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 target = process.argv[2] || process.cwd();
10
- const asJson = process.argv.includes('--json');
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
+ }