@tekyzinc/gsd-t 5.1.12 → 5.2.10
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/bin/gsd-t-doc-marker.cjs +65 -0
- package/bin/gsd-t-env-registry-check.cjs +449 -0
- package/bin/gsd-t-env-registry.cjs +1384 -0
- package/bin/gsd-t-logging-scaffolder.cjs +6 -18
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +10 -0
- package/commands/gsd-t-init.md +6 -0
- package/commands/gsd-t-populate.md +7 -0
- package/package.json +1 -1
- package/scripts/gsd-t-auto-route.js +1 -0
- package/templates/CLAUDE-global.md +14 -1
- package/templates/infrastructure.md +40 -0
|
@@ -0,0 +1,1384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// bin/gsd-t-env-registry.cjs
|
|
4
|
+
//
|
|
5
|
+
// M102 — Environment Registry helper. Records every environment's connection
|
|
6
|
+
// MAP (never a secret VALUE) into docs/infrastructure.md via the ONE shared
|
|
7
|
+
// marker-block writer, and reads it back read-first (No-Re-Research) so a
|
|
8
|
+
// missing entry HALTs-and-documents rather than being guessed (No-Fallback-Ever).
|
|
9
|
+
//
|
|
10
|
+
// Contract: .gsd-t/contracts/env-registry-contract.md
|
|
11
|
+
//
|
|
12
|
+
// Public surface:
|
|
13
|
+
// recordEnvironment({ projectDir, scope, kind, host, port, name, authMethod,
|
|
14
|
+
// secretVault, secretEnvVarName, fetchCommand,
|
|
15
|
+
// connectCommand, gotchas, readOnlyDefault })
|
|
16
|
+
// -> upsert row by (scope, kind) into the Environments table (idempotent).
|
|
17
|
+
// lookupEnvironment(projectDir, scope, kind) -> row object | null.
|
|
18
|
+
// detectEnvConfig(projectDir) -> { vault, fetchCommand, secretEnvVarNames, ... }
|
|
19
|
+
// addPermissionEntry(projectDir, connectCommand) -> { added, tool, entry }
|
|
20
|
+
// ensureEnvGitignored(projectDir) -> { added }
|
|
21
|
+
//
|
|
22
|
+
// Zero external npm runtime deps — fs/path only. Reuses the M100 detectStack
|
|
23
|
+
// SHAPE and the ONE shared marker-block doc-writer (gsd-t-doc-marker.cjs).
|
|
24
|
+
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const path = require("path");
|
|
27
|
+
const { upsertMarkedDocBlock } = require("./gsd-t-doc-marker.cjs");
|
|
28
|
+
|
|
29
|
+
// ─── Markers + schema ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const ENV_MARKER_START = "<!-- gsd-t-env-registry:start -->";
|
|
32
|
+
const ENV_MARKER_END = "<!-- gsd-t-env-registry:end -->";
|
|
33
|
+
|
|
34
|
+
// Map-only columns — NO secret-value column exists (a row is structurally
|
|
35
|
+
// incapable of holding a secret VALUE). `secret vault` + `secret env-var NAME`
|
|
36
|
+
// + `fetch command` record WHERE the secret lives and HOW to fetch it, never
|
|
37
|
+
// the value itself.
|
|
38
|
+
const ENV_COLUMNS = [
|
|
39
|
+
"id",
|
|
40
|
+
"scope",
|
|
41
|
+
"kind",
|
|
42
|
+
"host",
|
|
43
|
+
"port",
|
|
44
|
+
"db/name",
|
|
45
|
+
"auth method",
|
|
46
|
+
"secret vault",
|
|
47
|
+
"secret env-var NAME",
|
|
48
|
+
"fetch command",
|
|
49
|
+
"connect command",
|
|
50
|
+
"access gotchas",
|
|
51
|
+
"read-only default",
|
|
52
|
+
"recorded",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// ─── Secret-value BACKSTOP detector (known-prefix / JWT / embedded-cred) ─────
|
|
56
|
+
//
|
|
57
|
+
// THIS IS A BACKSTOP ONLY — never the primary guard. The PRIMARY guard for
|
|
58
|
+
// every free-ish column is a TIGHT POSITIVE ALLOWLIST (see the per-column
|
|
59
|
+
// validators below): a value is accepted ONLY if it matches the shape its
|
|
60
|
+
// column is supposed to hold (a real hostname / an enum member / a $VAR / a
|
|
61
|
+
// curated subcommand word). A value like `hunter2hunter2hunter` or
|
|
62
|
+
// `correcthorsebattery` is rejected because it is NOT a valid hostname / NOT
|
|
63
|
+
// the enum / NOT a $VAR — with ZERO entropy or character-class threshold.
|
|
64
|
+
//
|
|
65
|
+
// THREE Red Team passes proved the inverse ("reject if it looks like a secret",
|
|
66
|
+
// a denylist with an entropy floor) leaks: every floor eventually admits a
|
|
67
|
+
// secret that sits below it. The floor is the defect; it is GONE as a primary
|
|
68
|
+
// guard. This backstop remains only to cheaply catch the well-known credential
|
|
69
|
+
// SHAPES (GitHub / Stripe / AWS / Slack / Google prefixes, JWTs, embedded
|
|
70
|
+
// proto://user:pw@ creds) — an EXTRA layer, never the sole thing between a
|
|
71
|
+
// value and the doc.
|
|
72
|
+
|
|
73
|
+
// Known secret prefixes. A token STARTING with one of these is a live secret.
|
|
74
|
+
// Belt-and-suspenders only — the structural validators (command allowlist +
|
|
75
|
+
// enumerated gotchas + gate high-entropy scan) are the real guard for an OPEN
|
|
76
|
+
// category. This list catches the well-known SHAPES cheaply.
|
|
77
|
+
const KNOWN_SECRET_PREFIXES = [
|
|
78
|
+
"ghp_", "gho_", "ghs_", "ghu_", "ghr_", // GitHub PAT / OAuth / server / user / refresh
|
|
79
|
+
"github_pat_", // GitHub fine-grained PAT
|
|
80
|
+
"sk_live", "sk_test", "pk_live", "rk_live", // Stripe
|
|
81
|
+
"AKIA", "ASIA", // AWS access-key ids
|
|
82
|
+
"xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-", "xapp-", // Slack (incl. app-level)
|
|
83
|
+
"AIza", // Google API key
|
|
84
|
+
"glpat-", // GitLab personal access token
|
|
85
|
+
"gitlab", // GitLab-prefixed tokens (belt-and-suspenders)
|
|
86
|
+
"npm_", // npm automation token
|
|
87
|
+
"dop_v1_", // DigitalOcean PAT
|
|
88
|
+
"SG.", // SendGrid API key
|
|
89
|
+
"shpat_", "shpss_", // Shopify access / shared-secret
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// proto://user:PASSWORD@host for ANY scheme (not a hardcoded scheme allowlist).
|
|
93
|
+
// The password segment is a literal only when it is NOT a $-reference.
|
|
94
|
+
const EMBEDDED_CRED = /[a-z][a-z0-9+.\-]*:\/\/[^\s:/@]+:(?!\$)[^\s:/@]+@/i;
|
|
95
|
+
|
|
96
|
+
// JWT: three base64url segments joined by dots. A real JWT's header segment is
|
|
97
|
+
// the base64url of a JSON object beginning `{"` → it ALWAYS starts with `eyJ`.
|
|
98
|
+
// Anchoring on that avoids a false positive on a 3-part dotted hostname
|
|
99
|
+
// (`ep-neon.neon.tech`), while still catching every genuine token.
|
|
100
|
+
const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}$/;
|
|
101
|
+
|
|
102
|
+
// A contiguous base64/hex run inside a larger string (e.g. `Bearer <token>`).
|
|
103
|
+
const BASE64_RUN = /[A-Za-z0-9+/]{24,}={0,2}/;
|
|
104
|
+
const HEX_RUN = /\b[0-9a-fA-F]{32,}\b/;
|
|
105
|
+
|
|
106
|
+
// True when `s` contains (or is) a live secret value. Used BOTH as the reject
|
|
107
|
+
// test inside the per-column allowlist checks AND as the verify gate's
|
|
108
|
+
// independent detector.
|
|
109
|
+
function looksLikeSecretValue(s) {
|
|
110
|
+
if (typeof s !== "string" || !s) return false;
|
|
111
|
+
// Known prefixes — anywhere a whitespace/quote-delimited token begins.
|
|
112
|
+
for (const tok of s.split(/[\s"'`=:,()<>]+/)) {
|
|
113
|
+
if (!tok) continue;
|
|
114
|
+
for (const p of KNOWN_SECRET_PREFIXES) {
|
|
115
|
+
if (tok.startsWith(p) && tok.length > p.length) return true;
|
|
116
|
+
}
|
|
117
|
+
if (JWT_SHAPE.test(tok)) return true;
|
|
118
|
+
}
|
|
119
|
+
if (EMBEDDED_CRED.test(s)) return true;
|
|
120
|
+
// High-entropy runs — but ignore a $-reference and skip pure hostnames
|
|
121
|
+
// (dotted, no long unbroken run) which the base64 run would otherwise catch.
|
|
122
|
+
if (HEX_RUN.test(s)) return true;
|
|
123
|
+
const b64 = s.match(BASE64_RUN);
|
|
124
|
+
if (b64) {
|
|
125
|
+
const run = b64[0];
|
|
126
|
+
// A hostname segment is short and dotted; a real blob is one long run.
|
|
127
|
+
if (run.length >= 24) return true;
|
|
128
|
+
}
|
|
129
|
+
// Cycle-8 fix: a high-entropy token can DODGE the contiguous-run tests by
|
|
130
|
+
// inserting dots (`kR8mNp2qX7wZ4vT6.aB9cD1eF3gH5jK0lM2n` splits into sub-24
|
|
131
|
+
// labels). Re-test with separators (dots/hyphens/underscores/colons/slashes)
|
|
132
|
+
// stripped, so a dotted blob is measured as ONE run. A REAL dotted hostname
|
|
133
|
+
// survives this because its labels are lowercase words that don't form a long
|
|
134
|
+
// base64/hex run once joined (`api.example.com` → `apiexamplecom`, no ≥24
|
|
135
|
+
// run) AND it contains no MIXED-CASE-with-digits density. To avoid flagging a
|
|
136
|
+
// long lowercase-only real domain, only trip when the joined run also carries
|
|
137
|
+
// BOTH upper- and lower-case OR is a ≥32 hex — i.e. token-like, not word-like.
|
|
138
|
+
const joined = s.replace(/[.\-_:/]+/g, "");
|
|
139
|
+
if (/[0-9a-fA-F]{32,}/.test(joined)) return true;
|
|
140
|
+
const jb64 = joined.match(/[A-Za-z0-9+/]{24,}={0,2}/);
|
|
141
|
+
if (jb64) {
|
|
142
|
+
const run = jb64[0];
|
|
143
|
+
const hasUpper = /[A-Z]/.test(run);
|
|
144
|
+
const hasLower = /[a-z]/.test(run);
|
|
145
|
+
const hasDigit = /[0-9]/.test(run);
|
|
146
|
+
// token-like = mixed case, or (case + digits). A long all-lowercase word
|
|
147
|
+
// (a real domain joined) is NOT flagged.
|
|
148
|
+
if ((hasUpper && hasLower) || (hasDigit && (hasUpper || hasLower && run.length >= 32))) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Back-compat alias — the OLD name is kept exported so nothing that imports it
|
|
156
|
+
// breaks, but it now points at the strict entropy/prefix detector.
|
|
157
|
+
function looksLikeInlineSecret(value) {
|
|
158
|
+
return looksLikeSecretValue(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── POSITIVE-ALLOWLIST shape primitives (shared: writer + verify gate) ──────
|
|
162
|
+
//
|
|
163
|
+
// These are the PRIMARY guard for every free-ish column. NOT an entropy floor —
|
|
164
|
+
// a set of TIGHT POSITIVE shapes. A value is accepted ONLY if it IS a real
|
|
165
|
+
// hostname / IP / $VAR / enum member / curated subcommand word. Everything else
|
|
166
|
+
// is REJECTED, so `hunter2hunter2hunter` / `8675309…` / `correcthorsebattery` /
|
|
167
|
+
// `Tr0ub4dor` fail because they are none of those shapes — with ZERO entropy or
|
|
168
|
+
// character-class threshold anywhere.
|
|
169
|
+
|
|
170
|
+
// A dotted hostname/domain: labels of [A-Za-z0-9-] joined by dots, at least one
|
|
171
|
+
// dot, TLD-ish last label. Recognizes `ep-cool.us-east-2.aws.neon.tech`,
|
|
172
|
+
// `bastion.example.com`, `api.example.com`.
|
|
173
|
+
const DOTTED_HOSTNAME =
|
|
174
|
+
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
175
|
+
|
|
176
|
+
// STRICT command-context hostname (cycle-8 fix). A dotted host appearing INSIDE
|
|
177
|
+
// a connect/fetch command must look like a REAL DNS name, because the loose
|
|
178
|
+
// DOTTED_HOSTNAME let a high-entropy token launder through by inserting a dot
|
|
179
|
+
// (`kR8mNp2qX7wZ4vT6.aB9cD1eF3gH5jK0lM2n` split into sub-24 labels dodged the
|
|
180
|
+
// backstop AND passed as a "hostname"). A real DNS name is:
|
|
181
|
+
// • lowercase-led labels of [a-z0-9-] (DNS is case-insensitive; a mixed-case
|
|
182
|
+
// high-entropy label is not a real host — force lowercase here),
|
|
183
|
+
// • each label ≤ 24 chars (a 16+ char RANDOM label is a laundered token, not a
|
|
184
|
+
// real DNS label — real labels are short words / regions / ids),
|
|
185
|
+
// • a purely ALPHABETIC TLD of ≥2 chars (`.tech`, `.com`, `.io`) — a token's
|
|
186
|
+
// trailing label is not an alpha TLD.
|
|
187
|
+
// This is a SHAPE tightening (real-hostname structure), NOT an entropy floor.
|
|
188
|
+
const STRICT_CMD_HOSTNAME =
|
|
189
|
+
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
190
|
+
|
|
191
|
+
// IPv4 dotted-quad (loose — 1-3 digits per octet; not validating 0-255 range,
|
|
192
|
+
// only the SHAPE, which is enough to distinguish a real IP from a digit blob).
|
|
193
|
+
const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
194
|
+
|
|
195
|
+
// A bare single-label host: letters + hyphen ONLY (NO digits), ≤ 16 chars,
|
|
196
|
+
// lowercase-led. Accepts docker/service names `db`, `postgres`, `db-primary`,
|
|
197
|
+
// and the single-char test host `h`. REJECTS `hunter2hunter2hunter` (digits),
|
|
198
|
+
// `8675309…` (digits), and a long single-label passphrase like
|
|
199
|
+
// `correcthorsebattery` (19 > 16) — a bare blob is never a valid host here.
|
|
200
|
+
// Real docker/service hostnames are short; a genuinely long endpoint is dotted
|
|
201
|
+
// DNS or an IP, both of which have their own positive shapes.
|
|
202
|
+
const BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
|
|
203
|
+
|
|
204
|
+
// A $VAR / ${VAR} reference, optionally single/double-quoted — the ONLY
|
|
205
|
+
// sanctioned way a secret ever appears in a command.
|
|
206
|
+
const VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
|
|
207
|
+
|
|
208
|
+
// A pure UPPER_SNAKE env-var NAME (e.g. DATABASE_URL_PROD, SECRET) — a NAME,
|
|
209
|
+
// never a value.
|
|
210
|
+
const UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
|
|
211
|
+
|
|
212
|
+
// A short lowercase-led SNAKE identifier used for db / resource names. Positive
|
|
213
|
+
// shape (NOT an entropy floor): lowercase-led, [a-z0-9_], ≤ 16 chars, and NO
|
|
214
|
+
// digit-immediately-followed-by-a-letter (a `2h`-style alternation reads as a
|
|
215
|
+
// blob, never a real db name). Accepts `binvoice_prod` (13), `analytics`,
|
|
216
|
+
// `neondb_prod`, `appdb`; REJECTS `hunter2hunter2hunter` (`2h` transition),
|
|
217
|
+
// `correcthorsebattery` (19 > 16), `Xk9…` (uppercase).
|
|
218
|
+
const DB_NAME_MAX = 16;
|
|
219
|
+
function isDbNameShape(s) {
|
|
220
|
+
if (typeof s !== "string" || s.length === 0 || s.length > DB_NAME_MAX) return false;
|
|
221
|
+
if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
|
|
222
|
+
if (/[0-9][a-z]/.test(s)) return false; // digit→letter interior = blob-like
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A dotted hostname OR IPv4 OR `localhost` — any real network endpoint shape.
|
|
227
|
+
function isHostShape(s) {
|
|
228
|
+
if (s === "localhost") return true;
|
|
229
|
+
if (IPV4.test(s)) return true;
|
|
230
|
+
if (DOTTED_HOSTNAME.test(s)) return true;
|
|
231
|
+
if (BARE_HOST_LABEL.test(s)) return true;
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ─── Per-column ALLOWLIST validators ─────────────────────────────────────────
|
|
236
|
+
//
|
|
237
|
+
// Each column may contain ONLY its expected shape. Anything else THROWS — the
|
|
238
|
+
// row is REJECTED and never written. This is the inversion the Red Team
|
|
239
|
+
// prescribed: a bare secret in ANY field fails its column's shape check.
|
|
240
|
+
|
|
241
|
+
const SCOPES = new Set(["local", "staging", "prod"]);
|
|
242
|
+
const VAULTS = new Set([
|
|
243
|
+
"local", "local (.env)", ".env", "env",
|
|
244
|
+
"vercel", "neon", "gcp-secret-manager", "google secret manager",
|
|
245
|
+
"aws-secrets-manager", "aws secrets manager", "doppler", "1password",
|
|
246
|
+
"hashicorp-vault", "vault", "azure-key-vault", "infisical",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
// `auth method` is an ENUM (positive allowlist), exactly like `secret vault`
|
|
250
|
+
// and `access gotchas`. A value NOT in this set is rejected — so
|
|
251
|
+
// `hunter2hunter2hunter` fails because it is not an auth-method name, with NO
|
|
252
|
+
// entropy check. Extend as real auth methods appear (this is the curated set,
|
|
253
|
+
// not a floor).
|
|
254
|
+
const AUTH_METHODS = new Set([
|
|
255
|
+
"password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
|
|
256
|
+
"api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
|
|
257
|
+
"cert", "mtls", "kerberos", "ldap",
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
// Curated CLI / DB subcommand words a command may carry as a BARE literal arg.
|
|
261
|
+
// A positive allowlist — a bare arg is accepted only if it is one of these OR
|
|
262
|
+
// the row's own db-name shape OR a hostname / $VAR / flag. Anything else must
|
|
263
|
+
// become a $VAR (see classifyCommandToken). Extend as real tools appear.
|
|
264
|
+
const CLI_WORDS = new Set([
|
|
265
|
+
// DB / shell clients
|
|
266
|
+
"psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
|
|
267
|
+
"pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
|
|
268
|
+
// vault / platform CLIs
|
|
269
|
+
"neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
|
|
270
|
+
"flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
|
|
271
|
+
"kubectl", "helm", "terraform", "vault",
|
|
272
|
+
// network clients
|
|
273
|
+
"ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
|
|
274
|
+
// common subcommands / operands
|
|
275
|
+
"env", "pull", "push", "list", "get", "set", "secrets", "versions",
|
|
276
|
+
"access", "version", "exec", "run", "connect", "login", "logout",
|
|
277
|
+
"connection-string", "db-url", "database-url", "redis-url",
|
|
278
|
+
"admin", "default", "latest", "read", "write", "describe", "show",
|
|
279
|
+
// descriptive-fetch operands (the detectEnvConfig fallback: "read $VAR from .env")
|
|
280
|
+
"from", "cat", "source", "printenv", "dotenv",
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
// A dotfile operand a command may reference (e.g. `.env`, `.env.local`,
|
|
284
|
+
// `.gcloudignore`). REQUIRES a leading dot — that is what makes it a dotfile
|
|
285
|
+
// and NOT a bare blob (`hunter2…` has no leading dot, so it never matches).
|
|
286
|
+
// Short lowercase labels only; a leading-dot filename is a filesystem path,
|
|
287
|
+
// never a secret VALUE.
|
|
288
|
+
const DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
|
|
289
|
+
|
|
290
|
+
// The doc-block markers a smuggled cell could use to corrupt upsert indexOf.
|
|
291
|
+
const RESERVED_MARKER_SUBSTRINGS = [
|
|
292
|
+
ENV_MARKER_START, ENV_MARKER_END,
|
|
293
|
+
"gsd-t-env-registry:start", "gsd-t-env-registry:end",
|
|
294
|
+
"gsd-t-logging-scaffold:start", "gsd-t-logging-scaffold:end",
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
function rejectMarkerSmuggle(field, value) {
|
|
298
|
+
if (typeof value !== "string" || !value) return;
|
|
299
|
+
for (const m of RESERVED_MARKER_SUBSTRINGS) {
|
|
300
|
+
if (value.includes(m)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`recordEnvironment: field "${field}" contains a reserved doc-marker substring ` +
|
|
303
|
+
`("${m}") — refusing to write a value that could corrupt the marked doc block.`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function rejectSecret(field, value) {
|
|
310
|
+
if (looksLikeSecretValue(value)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`recordEnvironment: refusing to write an inline literal secret in field "${field}". ` +
|
|
313
|
+
`Record the secret env-var NAME and reference it as $VAR (e.g. psql "$DATABASE_URL_PROD"), ` +
|
|
314
|
+
`never a literal password/token/connection-string.`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Optional-string helper: undefined/null/"" pass through as empty (—).
|
|
320
|
+
function optStr(v) {
|
|
321
|
+
return v === undefined || v === null ? "" : String(v);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function validateScope(v) {
|
|
325
|
+
if (!SCOPES.has(v)) {
|
|
326
|
+
throw new Error(`recordEnvironment: scope must be one of local|staging|prod, got "${v}"`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function validateKind(v) {
|
|
331
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(v)) {
|
|
332
|
+
throw new Error(`recordEnvironment: kind must match [a-z0-9-]+ (e.g. postgres, http-api), got "${v}"`);
|
|
333
|
+
}
|
|
334
|
+
// Symmetry with the gate: a real kind is a short word (`postgres`, `http-api`),
|
|
335
|
+
// never a long hex/token blob. The backstop rejects a hex≥32 / base64≥24 kind
|
|
336
|
+
// so the writer never accepts what the gate would fail (closes the cycle-9
|
|
337
|
+
// writer/gate divergence — harmless, but no reason to keep it).
|
|
338
|
+
rejectSecret("kind", v);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function validateHost(field, v) {
|
|
342
|
+
const s = optStr(v);
|
|
343
|
+
if (s === "") return;
|
|
344
|
+
// POSITIVE shape: `localhost` | IPv4 | dotted-DNS | a bare service label
|
|
345
|
+
// (letters+hyphen, ≤24, no digits). Strip an optional trailing `:port`.
|
|
346
|
+
const hostPart = s.replace(/:\d+$/, "");
|
|
347
|
+
if (!isHostShape(hostPart)) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`recordEnvironment: ${field} "${s}" is not a valid host shape. ` +
|
|
350
|
+
`A host must be "localhost", an IPv4 address, a dotted DNS hostname ` +
|
|
351
|
+
`(e.g. ep-cool.us-east-2.aws.neon.tech), or a short lowercase service ` +
|
|
352
|
+
`name (letters+hyphen, no digits — e.g. db, postgres, db-primary). ` +
|
|
353
|
+
`A bare token like "${hostPart}" is rejected — if it is a secret, move ` +
|
|
354
|
+
`it to an env var and reference it as $VAR.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
// Backstop (extra layer, never the primary guard).
|
|
358
|
+
rejectSecret(field, s);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validatePort(v) {
|
|
362
|
+
const s = optStr(v);
|
|
363
|
+
if (s === "") return;
|
|
364
|
+
if (!/^\d+$/.test(s)) {
|
|
365
|
+
throw new Error(`recordEnvironment: port must be digits only, got "${s}"`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function validateName(v) {
|
|
370
|
+
const s = optStr(v);
|
|
371
|
+
if (s === "") return;
|
|
372
|
+
// POSITIVE shape: a short lowercase-led snake db-name (≤16, no digit→letter
|
|
373
|
+
// interior). Accepts `binvoice_prod`, `analytics`, `neondb_prod`, `appdb`;
|
|
374
|
+
// REJECTS `hunter2hunter2hunter` (`2h` transition), `correcthorsebattery`
|
|
375
|
+
// (too long), `Xk9…` (uppercase) — NO entropy check.
|
|
376
|
+
if (!isDbNameShape(s)) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`recordEnvironment: db/name "${s}" is not a valid db-name shape. ` +
|
|
379
|
+
`A db name must be a short lowercase identifier (≤${DB_NAME_MAX} chars, ` +
|
|
380
|
+
`lowercase-led [a-z][a-z0-9_]*, no digit-then-letter run — e.g. ` +
|
|
381
|
+
`binvoice_prod, analytics). A blob like "${s}" is rejected; if it is a ` +
|
|
382
|
+
`secret, move it to an env var and reference it as $VAR.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
// Backstop (extra layer).
|
|
386
|
+
rejectSecret("db/name", s);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function validateAuthMethod(v) {
|
|
390
|
+
const s = optStr(v);
|
|
391
|
+
if (s === "") return;
|
|
392
|
+
// POSITIVE ENUM (like `secret vault` / `access gotchas`). A value NOT in the
|
|
393
|
+
// curated set is rejected — `hunter2hunter2hunter` fails because it is not an
|
|
394
|
+
// auth-method name, with NO entropy check.
|
|
395
|
+
if (!AUTH_METHODS.has(s.toLowerCase())) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`recordEnvironment: auth method "${s}" is not an enumerated auth method. ` +
|
|
398
|
+
`Allowed: ${[...AUTH_METHODS].join(" | ")}. A secret is never an ` +
|
|
399
|
+
`auth-method value — record the method name only.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
// Backstop (extra layer).
|
|
403
|
+
rejectSecret("auth method", s);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function validateVault(v) {
|
|
407
|
+
const s = optStr(v);
|
|
408
|
+
if (s === "") return;
|
|
409
|
+
rejectSecret("secret vault", s);
|
|
410
|
+
if (!VAULTS.has(s.toLowerCase())) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`recordEnvironment: secret vault must be an enumerated vault name ` +
|
|
413
|
+
`(local|vercel|neon|gcp-secret-manager|aws-secrets-manager|env|…), got "${s}"`
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function validateEnvVarName(v) {
|
|
419
|
+
const s = optStr(v);
|
|
420
|
+
if (s === "") return;
|
|
421
|
+
// an env-var NAME only: UPPER_SNAKE. A real value has lowercase/entropy.
|
|
422
|
+
// Backstop first: `AKIAIOSFODNN7EXAMPLE` is valid UPPER_SNAKE but is an AWS
|
|
423
|
+
// key id — the known-prefix detector rejects it.
|
|
424
|
+
rejectSecret("secret env-var NAME", s);
|
|
425
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(s)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`recordEnvironment: secret env-var NAME must be [A-Z][A-Z0-9_]* (a NAME, not a value), got "${s}"`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ─── Command POSITIVE allowlist ──────────────────────────────────────────────
|
|
433
|
+
//
|
|
434
|
+
// A command token is accepted ONLY if it IS one of a small set of SAFE POSITIVE
|
|
435
|
+
// shapes; ANYTHING else THROWS. This is the PRIMARY guard for the connect/fetch
|
|
436
|
+
// columns. NO entropy threshold — THREE Red Team passes proved a floor always
|
|
437
|
+
// admits a secret below it. The positive rule instead admits ONLY:
|
|
438
|
+
//
|
|
439
|
+
// 1. a $VAR / ${VAR} reference (optionally quoted) — the ONLY sanctioned way
|
|
440
|
+
// a secret ever appears in a command;
|
|
441
|
+
// 2. a flag: `-x` | `--flag` (bare) | `--flag=<value>` (value re-checked
|
|
442
|
+
// by the SAME positive rule — a bare literal flag value must itself be a
|
|
443
|
+
// $VAR / hostname / curated word);
|
|
444
|
+
// 3. a dotted hostname / IPv4 / `localhost` (a network endpoint);
|
|
445
|
+
// 4. a bare literal arg ONLY if it is a curated CLI/DB subcommand word OR the
|
|
446
|
+
// row's own db-name shape (short lowercase snake, ≤16, no digit→letter).
|
|
447
|
+
//
|
|
448
|
+
// A bare literal that is NONE of these — `hunter2hunter2hunter`,
|
|
449
|
+
// `correcthorsebattery`, `Xk9#mPq2vLzR8nQw`, a UUID, a platform token — is
|
|
450
|
+
// REJECTED with "put this value in an env var and reference it as $VAR". We
|
|
451
|
+
// ACCEPT the false positive that an unusual-but-legit bare arg must become a
|
|
452
|
+
// $VAR — that is the correct trade for a secrets-adjacent file.
|
|
453
|
+
|
|
454
|
+
// True if a BARE literal command arg is on the positive allowlist: a curated
|
|
455
|
+
// subcommand word OR a db-name shape (row identifier). NO entropy check.
|
|
456
|
+
function isSafeBareArg(bare) {
|
|
457
|
+
if (CLI_WORDS.has(bare.toLowerCase())) return true;
|
|
458
|
+
if (isDbNameShape(bare)) return true;
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Command grammar — CONVERGED (cycle-6 pivot: SAFE-LABEL ALLOWLIST) ────────
|
|
463
|
+
//
|
|
464
|
+
// SIX Red Team cycles leaked a credential through the command columns. The wrong
|
|
465
|
+
// primitive each time: a hardcoded set of SECRET-bearing flag names (`--password`,
|
|
466
|
+
// `--pw`, `-a`, …) — an OPEN category that can never be listed completely. Cycle
|
|
467
|
+
// 6 walked straight past it with `psql --password swordfish` (a flag name not
|
|
468
|
+
// yet listed, value in the next token matching the host-label shape).
|
|
469
|
+
//
|
|
470
|
+
// THE FIX — invert the list. A username (`-U binvoice`) and a password
|
|
471
|
+
// (`--password swordfish`) are the SAME shape; only the LABEL in front tells them
|
|
472
|
+
// apart. So instead of an endless denylist of secret labels, use a SHORT CLOSED
|
|
473
|
+
// ALLOWLIST of the labels whose value may be a bare identifier:
|
|
474
|
+
// VALUE_FLAGS = { -U/--user/--username, -d/--dbname/--db, -h/--host/--hostname,
|
|
475
|
+
// -p-as-PORT/--port, --secret (gcloud resource NAME) }
|
|
476
|
+
// A bare identifier value is allowed ONLY right after one of these. After ANY
|
|
477
|
+
// OTHER flag — and for any standalone bare arg — the value MUST be a $VAR or a
|
|
478
|
+
// provably-safe shape (dotted-DNS / IPv4 / localhost / .env dotfile / curated CLI
|
|
479
|
+
// word). `--password`, `--pw`, `-a`, `--dsn`, `--token` are leak-proof because
|
|
480
|
+
// they are NOT on the safe list, so their value can only be a $VAR.
|
|
481
|
+
//
|
|
482
|
+
// The safe list is CLOSED and completable (there are only so many ways to name a
|
|
483
|
+
// host / user / db / port). The secret list was open and never was. That is why
|
|
484
|
+
// this converges where six denylist cycles did not.
|
|
485
|
+
//
|
|
486
|
+
// Local-scope escape hatch: a `scope=local` row MAY be exempted from the command
|
|
487
|
+
// secret-guard per project (allowLocalLiteral). Default OFF (strict everywhere).
|
|
488
|
+
// staging/prod are ALWAYS strict.
|
|
489
|
+
|
|
490
|
+
// Split a command into whitespace-delimited tokens, but keep a quoted segment
|
|
491
|
+
// (single or double quotes) as ONE token so `"$DATABASE_URL"` stays intact.
|
|
492
|
+
function tokenizeCommand(s) {
|
|
493
|
+
const tokens = [];
|
|
494
|
+
const re = /"[^"]*"|'[^']*'|\S+/g;
|
|
495
|
+
let m;
|
|
496
|
+
while ((m = re.exec(s)) !== null) tokens.push(m[0]);
|
|
497
|
+
return tokens;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A $VAR / ${VAR} reference, quoted or bare.
|
|
501
|
+
function isVarToken(tok, bare) {
|
|
502
|
+
return VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Is this token a bare flag NAME with NO attached value? A LONG flag `--word`,
|
|
506
|
+
// or a SHORT flag that is EXACTLY one letter (`-U`, `-d`). A multi-char single-
|
|
507
|
+
// dash token (`-pMyPass`, `-Wswordfish`) is a GLUED flag+value, NOT a bare flag
|
|
508
|
+
// name — it must NOT be treated as a value-less flag (that was leak: the glued
|
|
509
|
+
// password was read as a flag name and never checked). `--` long flags are bare;
|
|
510
|
+
// `-XY…` short-with-payload is glued.
|
|
511
|
+
function isBareFlag(bare) {
|
|
512
|
+
if (/^--[A-Za-z][A-Za-z0-9-]*$/.test(bare)) return true; // --long
|
|
513
|
+
if (/^-[A-Za-z]$/.test(bare)) return true; // -X (single letter only)
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// CLOSED allowlist of flag names whose VALUE may be a bare literal — but each
|
|
518
|
+
// maps to a TYPED SHAPE, never a wide "any identifier" (cycle 7 leaked because
|
|
519
|
+
// the value slot accepted any ≤23-char blob, so a strong token after `-U`/`-h`/
|
|
520
|
+
// `--port` passed, caught only by the entropy backstop — the un-winnable
|
|
521
|
+
// primitive). Each flag's value must be the SAME tight shape its COLUMN uses:
|
|
522
|
+
// port → digits only (`--port swordfish` fails)
|
|
523
|
+
// host → dotted-DNS / IPv4 / localhost / short service label (`-h swordfish`
|
|
524
|
+
// passes only as a service label; a strong
|
|
525
|
+
// token fails)
|
|
526
|
+
// user/db → tight db-name shape (≤16 lowercase snake, no digit→letter — a
|
|
527
|
+
// strong/random token `aB3xK9…` / `S3cretP4ss`
|
|
528
|
+
// FAILS → must be $VAR; a weak dictionary
|
|
529
|
+
// username like `binvoice` passes)
|
|
530
|
+
// secret → resource-NAME shape (db-name-like) OR $VAR (a real credential value
|
|
531
|
+
// fails the shape → must be $VAR)
|
|
532
|
+
// A strong/structured credential fails EVERY one of these tight shapes, so it can
|
|
533
|
+
// only be a $VAR. That is the convergence: the value shape is now as tight as the
|
|
534
|
+
// column's, not an entropy floor.
|
|
535
|
+
const VALUE_FLAG_SHAPE = {
|
|
536
|
+
u: "dbname", user: "dbname", username: "dbname",
|
|
537
|
+
d: "dbname", dbname: "dbname", db: "dbname", database: "dbname",
|
|
538
|
+
h: "host", host: "host", hostname: "host",
|
|
539
|
+
port: "port",
|
|
540
|
+
secret: "dbname", // gcloud resource NAME — same tight shape, else $VAR
|
|
541
|
+
};
|
|
542
|
+
function flagBareName(tok) {
|
|
543
|
+
return tok.replace(/^--?/, "").toLowerCase();
|
|
544
|
+
}
|
|
545
|
+
function isValueFlagAllowingBare(name) {
|
|
546
|
+
return Object.prototype.hasOwnProperty.call(VALUE_FLAG_SHAPE, name);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Does `bareVal` satisfy the TYPED shape a given value-flag requires?
|
|
550
|
+
// Reuses the SAME tight shapes the columns use (isDbNameShape / isHostShape),
|
|
551
|
+
// NOT a wide identifier regex — so a strong/random credential fails.
|
|
552
|
+
function flagValueMatchesTypedShape(name, bareVal) {
|
|
553
|
+
const shape = VALUE_FLAG_SHAPE[name];
|
|
554
|
+
if (shape === "port") return /^\d+$/.test(bareVal);
|
|
555
|
+
// host flag value uses the STRICT command hostname (or IPv4/localhost/short
|
|
556
|
+
// service label) — NOT the loose column shape — so `-h <dotted-token>` fails.
|
|
557
|
+
if (shape === "host") {
|
|
558
|
+
return bareVal === "localhost" || IPV4.test(bareVal) ||
|
|
559
|
+
STRICT_CMD_HOSTNAME.test(bareVal) || BARE_HOST_LABEL.test(bareVal);
|
|
560
|
+
}
|
|
561
|
+
if (shape === "dbname") return isDbNameShape(bareVal);
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// The SAFE non-$VAR shapes ANY token may take (standalone or after any flag):
|
|
566
|
+
// a real network endpoint (dotted-DNS / IPv4 / localhost), a .env dotfile, or a
|
|
567
|
+
// curated CLI word. NO bare host-label, NO db-name-shape — those are
|
|
568
|
+
// password-ambiguous and are ONLY allowed right after an allowlisted value-flag.
|
|
569
|
+
// A URL whose authority is a STRICT hostname/IP/localhost and which carries NO
|
|
570
|
+
// embedded credential (`user:pw@` is caught by EMBEDDED_CRED separately). The
|
|
571
|
+
// host part must pass the strict shape, so a `http://<dotted-token>` cannot
|
|
572
|
+
// launder a secret as a URL either. Path/query are allowed but the WHOLE token
|
|
573
|
+
// is still run through the secret backstop by validateCommand.
|
|
574
|
+
const SAFE_URL =
|
|
575
|
+
/^[a-z][a-z0-9+.\-]*:\/\/(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|(?:[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?\.)+[a-z]{2,})(?::\d+)?(?:[/?#]\S*)?$/;
|
|
576
|
+
|
|
577
|
+
function isSafeNonSecretToken(bare) {
|
|
578
|
+
if (bare === "localhost") return true;
|
|
579
|
+
if (IPV4.test(bare)) return true;
|
|
580
|
+
// STRICT command hostname (lowercase labels ≤24, alpha TLD) — NOT the loose
|
|
581
|
+
// DOTTED_HOSTNAME, which let a dotted high-entropy token launder through.
|
|
582
|
+
if (STRICT_CMD_HOSTNAME.test(bare)) return true;
|
|
583
|
+
if (SAFE_URL.test(bare)) return true; // scheme://strict-host/… (no user:pw@)
|
|
584
|
+
if (DOTFILE_TOKEN.test(bare)) return true;
|
|
585
|
+
if (CLI_WORDS.has(bare.toLowerCase())) return true;
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Classify ONE command token WITHOUT adjacency (attached flag values + standalone
|
|
590
|
+
// args). Returns null if safe, else a reason string.
|
|
591
|
+
function classifyCommandToken(tok) {
|
|
592
|
+
const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
|
|
593
|
+
if (bare === "") return null;
|
|
594
|
+
|
|
595
|
+
// 1. $VAR — the only way a secret appears.
|
|
596
|
+
if (isVarToken(tok, bare)) return null;
|
|
597
|
+
|
|
598
|
+
// 2. `--flag=value` — an allowlisted value-flag may carry a bare identifier;
|
|
599
|
+
// any OTHER flag's attached value must be a $VAR or a safe non-secret shape.
|
|
600
|
+
const eqMatch = bare.match(/^(--?[A-Za-z][A-Za-z0-9-]*)=(.*)$/);
|
|
601
|
+
if (eqMatch) {
|
|
602
|
+
const name = flagBareName(eqMatch[1]);
|
|
603
|
+
const value = eqMatch[2];
|
|
604
|
+
if (value === "") return null;
|
|
605
|
+
return classifyFlagValue(eqMatch[1] + "=", name, value);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 3. glued short flag `-pVALUE`. If the letter is `p` it's PORT (digits ok);
|
|
609
|
+
// an allowlisted value-flag letter may carry a bare identifier; otherwise
|
|
610
|
+
// the glued value must be a $VAR / safe shape.
|
|
611
|
+
const gluedMatch = bare.match(/^(-[A-Za-z])(.+)$/);
|
|
612
|
+
if (gluedMatch) {
|
|
613
|
+
const name = flagBareName(gluedMatch[1]);
|
|
614
|
+
return classifyFlagValue(gluedMatch[1], name, gluedMatch[2]);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// 4. a bare flag NAME with no attached value.
|
|
618
|
+
if (isBareFlag(bare)) return null;
|
|
619
|
+
|
|
620
|
+
// 5. a safe non-secret shape: endpoint / dotfile / curated word.
|
|
621
|
+
if (isSafeNonSecretToken(bare)) return null;
|
|
622
|
+
|
|
623
|
+
// 6. anything else → REJECT (a bare literal — password-ambiguous by shape).
|
|
624
|
+
return `bare argument "${bare}" is not a $VAR reference, a flag, a dotted ` +
|
|
625
|
+
`hostname/IP, a .env file, nor a curated CLI word — it is a probable inline ` +
|
|
626
|
+
`literal secret. Move it to an env var and reference it as $VAR ` +
|
|
627
|
+
`(e.g. psql "$DATABASE_URL_PROD")`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// A flag's VALUE (attached `=`, glued, or — via classifyCommand — the next
|
|
631
|
+
// token). `name` is the flag's bare name. An allowlisted value-flag may carry a
|
|
632
|
+
// bare literal ONLY if it matches that flag's TYPED shape (port=digits,
|
|
633
|
+
// host=host-shape, user/db/secret=tight db-name shape); a strong/random
|
|
634
|
+
// credential fails the shape → must be a $VAR. EVERY other flag's value must be
|
|
635
|
+
// a $VAR or a safe non-secret shape. Returns null if safe.
|
|
636
|
+
function classifyFlagValue(flagPart, name, value) {
|
|
637
|
+
const bareVal = value.replace(/^["']/, "").replace(/["']$/, "");
|
|
638
|
+
if (isVarToken(value, bareVal)) return null;
|
|
639
|
+
if (isSafeNonSecretToken(bareVal)) return null; // dotted host / IP / dotfile / curated
|
|
640
|
+
if (isValueFlagAllowingBare(name) && flagValueMatchesTypedShape(name, bareVal)) return null;
|
|
641
|
+
return `flag "${flagPart}" carries a bare literal value "${bareVal}" — a value-flag ` +
|
|
642
|
+
`may hold only its TYPED shape (--port=digits, --host=hostname/IP, ` +
|
|
643
|
+
`--user/--dbname=short lowercase identifier); a strong/random token fails that ` +
|
|
644
|
+
`shape and every other flag's value must be a $VAR reference ` +
|
|
645
|
+
`(e.g. --password "$DB_PASSWORD"). A bare literal here is a probable secret.`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Validate a full command with TOKEN-ADJACENCY. A bare flag NAME followed by a
|
|
649
|
+
// value token: the value belongs to that flag and is checked by classifyFlagValue
|
|
650
|
+
// with the flag's name — so `-U binvoice` passes (allowlisted) but
|
|
651
|
+
// `--password swordfish` is REJECTED (not allowlisted → value must be $VAR).
|
|
652
|
+
// Returns null if the whole command is safe, else a reason string.
|
|
653
|
+
function classifyCommand(s) {
|
|
654
|
+
const tokens = tokenizeCommand(s);
|
|
655
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
656
|
+
const tok = tokens[i];
|
|
657
|
+
const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
|
|
658
|
+
|
|
659
|
+
if (isBareFlag(bare) && !isVarToken(tok, bare)) {
|
|
660
|
+
const next = tokens[i + 1];
|
|
661
|
+
if (next !== undefined) {
|
|
662
|
+
const nextBare = next.replace(/^["']/, "").replace(/["']$/, "");
|
|
663
|
+
const nextIsFlag = /^-/.test(nextBare);
|
|
664
|
+
if (!nextIsFlag) {
|
|
665
|
+
// next token is THIS flag's value — classify it with this flag's name.
|
|
666
|
+
const reason = classifyFlagValue(bare, flagBareName(bare), next);
|
|
667
|
+
if (reason) return reason;
|
|
668
|
+
i++; // consume the value token
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
continue; // bare flag with no following value
|
|
673
|
+
}
|
|
674
|
+
const reason = classifyCommandToken(tok);
|
|
675
|
+
if (reason) return reason;
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function validateCommand(field, v) {
|
|
681
|
+
const s = optStr(v);
|
|
682
|
+
if (s === "") return;
|
|
683
|
+
// Backstop first (known prefixes / JWT / base64 / hex / embedded cred).
|
|
684
|
+
rejectSecret(field, s);
|
|
685
|
+
// PRIMARY structural guard: adjacency-aware command grammar (a flag's value —
|
|
686
|
+
// attached, glued, OR the next token — must be $VAR or a safe non-secret shape).
|
|
687
|
+
const reason = classifyCommand(s);
|
|
688
|
+
if (reason) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`recordEnvironment: ${field} rejected — ${reason} ` +
|
|
691
|
+
`A command may contain only a tool, flags, $VAR/\${VAR} references, dotted ` +
|
|
692
|
+
`hostnames/IPs, .env dotfiles, and curated CLI/subcommand words; a bare ` +
|
|
693
|
+
`literal value is forbidden. Put the value in an env var and reference it ` +
|
|
694
|
+
`as $VAR (e.g. psql "$DATABASE_URL_PROD").`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ─── Gotchas ENUMERATED (no free prose) ──────────────────────────────────────
|
|
700
|
+
//
|
|
701
|
+
// Free prose gave a secret somewhere to hide. `gotchas` is now an enumerated /
|
|
702
|
+
// short-token column: a comma/space-separated list drawn from a fixed enum,
|
|
703
|
+
// plus an optional `via <hostname/identifier>` qualifier. Anything else THROWS.
|
|
704
|
+
|
|
705
|
+
const GOTCHA_ENUM = new Set([
|
|
706
|
+
"vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none",
|
|
707
|
+
]);
|
|
708
|
+
|
|
709
|
+
function validateGotchas(v) {
|
|
710
|
+
const s = optStr(v);
|
|
711
|
+
if (s === "") return;
|
|
712
|
+
rejectSecret("access gotchas", s);
|
|
713
|
+
// Tokenize on commas and whitespace.
|
|
714
|
+
const tokens = s.split(/[,\s]+/).filter(Boolean);
|
|
715
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
716
|
+
const t = tokens[i].toLowerCase();
|
|
717
|
+
if (GOTCHA_ENUM.has(t)) continue;
|
|
718
|
+
if (t === "via") {
|
|
719
|
+
// the NEXT token must be a POSITIVE host shape (dotted DNS / IPv4 /
|
|
720
|
+
// localhost / short service label) — never a free literal.
|
|
721
|
+
const next = tokens[i + 1];
|
|
722
|
+
if (!next) {
|
|
723
|
+
throw new Error(`recordEnvironment: access gotchas — "via" must be followed by a hostname/identifier`);
|
|
724
|
+
}
|
|
725
|
+
if (!isHostShape(next)) {
|
|
726
|
+
throw new Error(
|
|
727
|
+
`recordEnvironment: access gotchas — "via ${next}" target must be a hostname/IP ` +
|
|
728
|
+
`(dotted DNS, IPv4, localhost, or a short lowercase service name), got "${next}"`
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
i++; // consume the hostname token
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
// A standalone POSITIVE host shape is allowed (naming a bastion directly).
|
|
735
|
+
if (isHostShape(tokens[i])) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
throw new Error(
|
|
739
|
+
`recordEnvironment: access gotchas must be an enumerated list ` +
|
|
740
|
+
`(vpn | ip-allowlist | ssh-tunnel | bastion | none), optionally with ` +
|
|
741
|
+
`"via <hostname>" — got "${tokens[i]}". Free prose is forbidden (nowhere for a secret to hide).`
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Run the full per-column allowlist + marker-smuggle + secret backstop over a
|
|
747
|
+
// proposed row. Throws on the FIRST violation — the row is never written.
|
|
748
|
+
// ─── Per-project local-literal switch ────────────────────────────────────────
|
|
749
|
+
//
|
|
750
|
+
// A project may opt IN to allowing a LITERAL secret in `scope=local` rows (the
|
|
751
|
+
// user's testing convenience — "I don't care if you have the local password").
|
|
752
|
+
// Config: `.gsd-t/env-registry-config.json` → `{ "allowLocalLiteral": true }`.
|
|
753
|
+
// Default OFF → strict everywhere (even local rows use $VAR). staging/prod are
|
|
754
|
+
// ALWAYS strict — the switch NEVER relaxes them. A relaxed local row STILL runs
|
|
755
|
+
// the marker-smuggle guard (a literal secret is fine; corrupting the doc block
|
|
756
|
+
// is not). recordEnvironment returns `localLiteralWarning:true` on a relaxed
|
|
757
|
+
// write so the caller can print the one-time "committed file → git history" note.
|
|
758
|
+
function readEnvRegistryConfig(projectDir) {
|
|
759
|
+
if (!projectDir) return { allowLocalLiteral: false };
|
|
760
|
+
const p = path.join(projectDir, ".gsd-t", "env-registry-config.json");
|
|
761
|
+
try {
|
|
762
|
+
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
763
|
+
return { allowLocalLiteral: cfg && cfg.allowLocalLiteral === true };
|
|
764
|
+
} catch (_) {
|
|
765
|
+
return { allowLocalLiteral: false }; // absent / invalid → strict (no silent relax)
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function validateRow(opts, relaxSecrets) {
|
|
770
|
+
// Marker-smuggling guard FIRST, on EVERY user-supplied field — ALWAYS run, even
|
|
771
|
+
// when secrets are relaxed for a local row (a literal secret is allowed there;
|
|
772
|
+
// corrupting the marked doc block is NEVER allowed).
|
|
773
|
+
for (const [field, value] of Object.entries({
|
|
774
|
+
scope: opts.scope,
|
|
775
|
+
kind: opts.kind,
|
|
776
|
+
host: opts.host,
|
|
777
|
+
port: opts.port,
|
|
778
|
+
name: opts.name,
|
|
779
|
+
authMethod: opts.authMethod,
|
|
780
|
+
secretVault: opts.secretVault,
|
|
781
|
+
secretEnvVarName: opts.secretEnvVarName,
|
|
782
|
+
fetchCommand: opts.fetchCommand,
|
|
783
|
+
connectCommand: opts.connectCommand,
|
|
784
|
+
gotchas: opts.gotchas,
|
|
785
|
+
})) {
|
|
786
|
+
rejectMarkerSmuggle(field, value);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// scope/kind/port/vault/env-var-NAME are STRUCTURAL (enum/digits/UPPER_SNAKE)
|
|
790
|
+
// and stay strict even when relaxed — they can't hold a free-form secret and a
|
|
791
|
+
// malformed one breaks parsing.
|
|
792
|
+
validateScope(opts.scope);
|
|
793
|
+
validateKind(opts.kind);
|
|
794
|
+
validatePort(opts.port);
|
|
795
|
+
validateVault(opts.secretVault);
|
|
796
|
+
validateEnvVarName(opts.secretEnvVarName);
|
|
797
|
+
|
|
798
|
+
// The free-ish, secret-capable columns. Relaxed ONLY for an opted-in local row.
|
|
799
|
+
if (relaxSecrets) return;
|
|
800
|
+
|
|
801
|
+
validateHost("host", opts.host);
|
|
802
|
+
validateName(opts.name);
|
|
803
|
+
validateAuthMethod(opts.authMethod);
|
|
804
|
+
validateCommand("fetch command", opts.fetchCommand);
|
|
805
|
+
validateCommand("connect command", opts.connectCommand);
|
|
806
|
+
validateGotchas(opts.gotchas);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// ─── Table serialization ─────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
function escapeCell(v) {
|
|
812
|
+
if (v === undefined || v === null || v === "") return "—";
|
|
813
|
+
return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function rowToLine(row) {
|
|
817
|
+
return "| " + ENV_COLUMNS.map((c) => escapeCell(row[c])).join(" | ") + " |";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function headerLines() {
|
|
821
|
+
return [
|
|
822
|
+
"| " + ENV_COLUMNS.join(" | ") + " |",
|
|
823
|
+
"| " + ENV_COLUMNS.map(() => "---").join(" | ") + " |",
|
|
824
|
+
];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Build the full marker-delimited block from a list of row objects.
|
|
828
|
+
function buildTableBlock(rows) {
|
|
829
|
+
const lines = [
|
|
830
|
+
ENV_MARKER_START,
|
|
831
|
+
"## Environments",
|
|
832
|
+
"",
|
|
833
|
+
"> **Map, not secrets.** This table records only WHERE an environment lives and",
|
|
834
|
+
"> HOW to reach it — host/port/name/auth-method/which-vault-holds-the-secret/the",
|
|
835
|
+
"> env-var NAME/the fetch + connect commands. It NEVER stores a secret VALUE.",
|
|
836
|
+
"> The value lives in the vault (`.env` / Vercel / Neon / Google Secret Manager)",
|
|
837
|
+
"> and is pulled at runtime via the env-var NAME. A missing row → HALT and",
|
|
838
|
+
"> document (detect → ask → record → proceed); never guess a connection string,",
|
|
839
|
+
"> never grep transcripts to rediscover.",
|
|
840
|
+
"",
|
|
841
|
+
...headerLines(),
|
|
842
|
+
...rows.map(rowToLine),
|
|
843
|
+
ENV_MARKER_END,
|
|
844
|
+
];
|
|
845
|
+
return lines.join("\n");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ─── Table parsing ───────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
function infraDocPath(projectDir) {
|
|
851
|
+
return path.join(projectDir, "docs", "infrastructure.md");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function extractBlock(content) {
|
|
855
|
+
if (!content.includes(ENV_MARKER_START) || !content.includes(ENV_MARKER_END)) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
const start = content.indexOf(ENV_MARKER_START);
|
|
859
|
+
const end = content.indexOf(ENV_MARKER_END);
|
|
860
|
+
return content.slice(start, end);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function splitRow(line) {
|
|
864
|
+
// Strip the leading/trailing pipe, split on unescaped pipes.
|
|
865
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
866
|
+
const cells = [];
|
|
867
|
+
let cur = "";
|
|
868
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
869
|
+
const ch = trimmed[i];
|
|
870
|
+
if (ch === "\\" && trimmed[i + 1] === "|") {
|
|
871
|
+
cur += "|";
|
|
872
|
+
i++;
|
|
873
|
+
} else if (ch === "|") {
|
|
874
|
+
cells.push(cur.trim());
|
|
875
|
+
cur = "";
|
|
876
|
+
} else {
|
|
877
|
+
cur += ch;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
cells.push(cur.trim());
|
|
881
|
+
return cells;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Parse the Environments table into an array of row objects keyed by column.
|
|
885
|
+
function parseRows(projectDir) {
|
|
886
|
+
const target = infraDocPath(projectDir);
|
|
887
|
+
if (!fs.existsSync(target)) return [];
|
|
888
|
+
const content = fs.readFileSync(target, "utf8");
|
|
889
|
+
const block = extractBlock(content);
|
|
890
|
+
if (!block) return [];
|
|
891
|
+
|
|
892
|
+
const rows = [];
|
|
893
|
+
const lines = block.split("\n");
|
|
894
|
+
for (const line of lines) {
|
|
895
|
+
if (!line.trim().startsWith("|")) continue;
|
|
896
|
+
const cells = splitRow(line);
|
|
897
|
+
// Skip the header row and the separator row.
|
|
898
|
+
if (cells[0] === "id") continue;
|
|
899
|
+
if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue;
|
|
900
|
+
const row = {};
|
|
901
|
+
ENV_COLUMNS.forEach((col, i) => {
|
|
902
|
+
const raw = cells[i] === undefined ? "" : cells[i];
|
|
903
|
+
row[col] = raw === "—" ? "" : raw;
|
|
904
|
+
});
|
|
905
|
+
rows.push(row);
|
|
906
|
+
}
|
|
907
|
+
return rows;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ─── recordEnvironment — upsert by (scope, kind) ─────────────────────────────
|
|
911
|
+
|
|
912
|
+
function recordEnvironment(opts = {}) {
|
|
913
|
+
const {
|
|
914
|
+
projectDir,
|
|
915
|
+
scope,
|
|
916
|
+
kind,
|
|
917
|
+
host,
|
|
918
|
+
port,
|
|
919
|
+
name,
|
|
920
|
+
authMethod,
|
|
921
|
+
secretVault,
|
|
922
|
+
secretEnvVarName,
|
|
923
|
+
fetchCommand,
|
|
924
|
+
connectCommand,
|
|
925
|
+
gotchas,
|
|
926
|
+
} = opts;
|
|
927
|
+
|
|
928
|
+
if (!projectDir) throw new Error("recordEnvironment: projectDir is required");
|
|
929
|
+
if (!scope) throw new Error("recordEnvironment: scope is required");
|
|
930
|
+
if (!kind) throw new Error("recordEnvironment: kind is required");
|
|
931
|
+
|
|
932
|
+
// Per-project local-literal switch: relax the secret checks ONLY for a
|
|
933
|
+
// scope=local row in a project that opted in. staging/prod ALWAYS strict.
|
|
934
|
+
const { allowLocalLiteral } = readEnvRegistryConfig(projectDir);
|
|
935
|
+
const relaxSecrets = allowLocalLiteral && scope === "local";
|
|
936
|
+
|
|
937
|
+
// HARD CONSTRAINT: per-column allowlist + marker-smuggle + secret backstop.
|
|
938
|
+
// Reject the ENTIRE row before any coercion or disk write. (relaxSecrets skips
|
|
939
|
+
// ONLY the free-form secret-capable columns for an opted-in local row.)
|
|
940
|
+
validateRow(opts, relaxSecrets);
|
|
941
|
+
|
|
942
|
+
// Destructive-Action Guard: prod defaults read-only=YES unless explicitly set.
|
|
943
|
+
// Coerce any non-string/boolean input to a YES/NO string (never literal
|
|
944
|
+
// "false"/0/null leaking into the doc).
|
|
945
|
+
let readOnlyDefault = opts.readOnlyDefault;
|
|
946
|
+
if (readOnlyDefault === undefined || readOnlyDefault === null || readOnlyDefault === "") {
|
|
947
|
+
readOnlyDefault = scope === "prod" ? "YES" : "NO";
|
|
948
|
+
} else if (typeof readOnlyDefault === "boolean") {
|
|
949
|
+
readOnlyDefault = readOnlyDefault ? "YES" : "NO";
|
|
950
|
+
} else {
|
|
951
|
+
const s = String(readOnlyDefault).trim().toLowerCase();
|
|
952
|
+
readOnlyDefault = ["yes", "true", "1", "y"].includes(s) ? "YES" : "NO";
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const recorded = new Date().toISOString();
|
|
956
|
+
const id = `${scope}-${kind}`;
|
|
957
|
+
|
|
958
|
+
const row = {
|
|
959
|
+
id,
|
|
960
|
+
scope,
|
|
961
|
+
kind,
|
|
962
|
+
host,
|
|
963
|
+
port,
|
|
964
|
+
"db/name": name,
|
|
965
|
+
"auth method": authMethod,
|
|
966
|
+
"secret vault": secretVault,
|
|
967
|
+
"secret env-var NAME": secretEnvVarName,
|
|
968
|
+
"fetch command": fetchCommand,
|
|
969
|
+
"connect command": connectCommand,
|
|
970
|
+
"access gotchas": gotchas,
|
|
971
|
+
"read-only default": readOnlyDefault,
|
|
972
|
+
recorded,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// (row is already fully validated above by validateRow — allowlist-per-column
|
|
976
|
+
// + marker-smuggle guard + entropy/prefix secret backstop.)
|
|
977
|
+
// Upsert by (scope, kind): replace the existing row with the same key, else
|
|
978
|
+
// append. This is what self-heals staleness (Aiven → Neon replaces the row).
|
|
979
|
+
const rows = parseRows(projectDir);
|
|
980
|
+
const key = (r) => `${r.scope} ${r.kind}`;
|
|
981
|
+
const thisKey = `${scope} ${kind}`;
|
|
982
|
+
|
|
983
|
+
// Detect a DIFFERENT host replacing the same (scope,kind) — surface it so a
|
|
984
|
+
// second real environment doesn't silently vanish into an upsert.
|
|
985
|
+
const prior = rows.find((r) => key(r) === thisKey);
|
|
986
|
+
const replaced = !!prior;
|
|
987
|
+
const oldHost = prior ? prior.host : undefined;
|
|
988
|
+
const newHost = optStr(host);
|
|
989
|
+
const warnedHostChange = replaced && !!oldHost && !!newHost && oldHost !== newHost;
|
|
990
|
+
|
|
991
|
+
const kept = rows.filter((r) => key(r) !== thisKey);
|
|
992
|
+
kept.push(row);
|
|
993
|
+
|
|
994
|
+
const block = buildTableBlock(kept);
|
|
995
|
+
const target = infraDocPath(projectDir);
|
|
996
|
+
upsertMarkedDocBlock(target, ENV_MARKER_START, ENV_MARKER_END, block);
|
|
997
|
+
|
|
998
|
+
const result = Object.assign({}, row, { replaced });
|
|
999
|
+
if (warnedHostChange) {
|
|
1000
|
+
result.warnedHostChange = true;
|
|
1001
|
+
result.oldHost = oldHost;
|
|
1002
|
+
result.newHost = newHost;
|
|
1003
|
+
}
|
|
1004
|
+
if (relaxSecrets) {
|
|
1005
|
+
result.localLiteralWarning = true;
|
|
1006
|
+
result.localLiteralNote =
|
|
1007
|
+
"docs/infrastructure.md is a COMMITTED file — a literal secret in this " +
|
|
1008
|
+
"local row enters git history. It is allowed only because this project set " +
|
|
1009
|
+
"allowLocalLiteral:true in .gsd-t/env-registry-config.json (local scope only).";
|
|
1010
|
+
}
|
|
1011
|
+
return result;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ─── lookupEnvironment — read-first (No-Re-Research) ─────────────────────────
|
|
1015
|
+
//
|
|
1016
|
+
// Returns the row for (scope, kind) or null. On null the CALLER HALTs and
|
|
1017
|
+
// documents — this module intentionally emits NO guessed connection string and
|
|
1018
|
+
// has NO transcript-grep rediscovery path. There is no fallback here by design.
|
|
1019
|
+
|
|
1020
|
+
function lookupEnvironment(projectDir, scope, kind) {
|
|
1021
|
+
const rows = parseRows(projectDir);
|
|
1022
|
+
const match = rows.find((r) => r.scope === scope && r.kind === kind);
|
|
1023
|
+
return match || null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ─── Leak detection + remediation PROPOSAL (interactive capture trigger) ─────
|
|
1027
|
+
//
|
|
1028
|
+
// The silent guard REJECTS a command carrying a literal secret. But when a HUMAN
|
|
1029
|
+
// is present (the brownfield capture trigger / a provisioning task), a bare
|
|
1030
|
+
// rejection isn't enough — the user asked to be TOLD and OFFERED a fix: move a
|
|
1031
|
+
// (possibly rotated) secret into the vault and replace the literal with a $VAR.
|
|
1032
|
+
//
|
|
1033
|
+
// This is DETECTION + a structured PROPOSAL only. The actual rotate-vs-move
|
|
1034
|
+
// decision + vault write happen in the command-file workflow WITH the user (the
|
|
1035
|
+
// module never rotates or writes a vault by itself, and never prompts).
|
|
1036
|
+
|
|
1037
|
+
// Pull the leaked secret out of a command/URL so it can be named + reported.
|
|
1038
|
+
// Returns { leaked:true, secret, kind, rewriteHint } or { leaked:false }.
|
|
1039
|
+
function detectCommandLeak(command) {
|
|
1040
|
+
const s = optStr(command);
|
|
1041
|
+
if (s === "") return { leaked: false };
|
|
1042
|
+
|
|
1043
|
+
// 1. embedded credential in a URL: proto://user:PASSWORD@host
|
|
1044
|
+
const embedded = s.match(/([a-z][a-z0-9+.\-]*):\/\/([^\s:/@]+):((?!\$)[^\s:/@]+)@/i);
|
|
1045
|
+
if (embedded) {
|
|
1046
|
+
return {
|
|
1047
|
+
leaked: true,
|
|
1048
|
+
secret: embedded[3],
|
|
1049
|
+
kind: "url-embedded-credential",
|
|
1050
|
+
rewriteHint: s.replace(embedded[3], "$SECRET"),
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// 2. a token the classifier rejects — walk tokens, find the offending literal.
|
|
1055
|
+
const reason = classifyCommand(s);
|
|
1056
|
+
if (!reason) return { leaked: false };
|
|
1057
|
+
// Extract the quoted-out bare literal the reason names, else flag the command.
|
|
1058
|
+
const m = reason.match(/value "([^"]+)"|argument "([^"]+)"/);
|
|
1059
|
+
const secret = m ? (m[1] || m[2]) : null;
|
|
1060
|
+
return {
|
|
1061
|
+
leaked: true,
|
|
1062
|
+
secret,
|
|
1063
|
+
kind: "inline-literal",
|
|
1064
|
+
rewriteHint: secret ? s.replace(secret, "$SECRET") : s,
|
|
1065
|
+
reason,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Build a remediation proposal the workflow presents to the user. It does NOT
|
|
1070
|
+
// decide rotate-vs-move (the user does) and does NOT touch a vault. `envVarName`
|
|
1071
|
+
// is a suggested UPPER_SNAKE name for the moved secret.
|
|
1072
|
+
function proposeRemediation(projectDir, command, opts = {}) {
|
|
1073
|
+
const leak = detectCommandLeak(command);
|
|
1074
|
+
if (!leak.leaked) return { needed: false };
|
|
1075
|
+
const detected = projectDir ? detectEnvConfig(projectDir) : { vault: "local (.env)", fetchCommand: "read from .env" };
|
|
1076
|
+
const envVarName = opts.envVarName ||
|
|
1077
|
+
(leak.kind === "url-embedded-credential" ? "DATABASE_URL" : "DB_SECRET");
|
|
1078
|
+
const rewritten = leak.secret
|
|
1079
|
+
? command.replace(leak.secret, "$" + envVarName)
|
|
1080
|
+
: command;
|
|
1081
|
+
return {
|
|
1082
|
+
needed: true,
|
|
1083
|
+
leakKind: leak.kind,
|
|
1084
|
+
detectedSecret: leak.secret, // shown to the user so they SEE what leaked
|
|
1085
|
+
vault: detected.vault,
|
|
1086
|
+
fetchCommand: detected.fetchCommand,
|
|
1087
|
+
suggestedEnvVarName: envVarName,
|
|
1088
|
+
rewrittenCommand: rewritten,
|
|
1089
|
+
// The two paths the workflow must ASK the user to choose between:
|
|
1090
|
+
choices: {
|
|
1091
|
+
rotate: "Generate/request a NEW secret (the old one is compromised — it " +
|
|
1092
|
+
"was about to enter a committed file / is already exposed), store the new " +
|
|
1093
|
+
"one in the vault, use $" + envVarName + " in the command.",
|
|
1094
|
+
move: "Move the EXISTING secret into the vault as-is and reference it as $" +
|
|
1095
|
+
envVarName + " (faster; does not un-expose it if already committed).",
|
|
1096
|
+
},
|
|
1097
|
+
note: "Ask the user rotate-vs-move; then store to the vault (directly where " +
|
|
1098
|
+
"possible, else instruct the user) and record the row with the $VAR command.",
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// ─── detectEnvConfig — reuse the detectStack SHAPE (map only, never a value) ──
|
|
1103
|
+
//
|
|
1104
|
+
// Reads .env.example / .env var NAMES, connection-string shapes, and
|
|
1105
|
+
// vercel/neon/gcloud presence to PROPOSE a vault + fetch command. Records the
|
|
1106
|
+
// map only — it NEVER reads or emits a secret value.
|
|
1107
|
+
|
|
1108
|
+
function readEnvVarNames(projectDir) {
|
|
1109
|
+
const names = new Set();
|
|
1110
|
+
for (const f of [".env.example", ".env.sample", ".env.template"]) {
|
|
1111
|
+
const p = path.join(projectDir, f);
|
|
1112
|
+
if (!fs.existsSync(p)) continue;
|
|
1113
|
+
let content;
|
|
1114
|
+
try {
|
|
1115
|
+
content = fs.readFileSync(p, "utf8");
|
|
1116
|
+
} catch (_) {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
for (const line of content.split("\n")) {
|
|
1120
|
+
const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/);
|
|
1121
|
+
if (m) names.add(m[1]);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return Array.from(names);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function fileExists(projectDir, rel) {
|
|
1128
|
+
return fs.existsSync(path.join(projectDir, rel));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function readPkgDeps(projectDir) {
|
|
1132
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
1133
|
+
let pkg = {};
|
|
1134
|
+
try {
|
|
1135
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
1136
|
+
} catch (_) {
|
|
1137
|
+
return [];
|
|
1138
|
+
}
|
|
1139
|
+
const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
|
|
1140
|
+
return Object.keys(deps).map((d) => d.toLowerCase());
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function detectEnvConfig(projectDir) {
|
|
1144
|
+
if (!projectDir) throw new Error("detectEnvConfig: projectDir is required");
|
|
1145
|
+
|
|
1146
|
+
const secretEnvVarNames = readEnvVarNames(projectDir);
|
|
1147
|
+
const depNames = readPkgDeps(projectDir);
|
|
1148
|
+
|
|
1149
|
+
// Vault detection — convention-based, grounded in the 3 real inspected
|
|
1150
|
+
// projects. Precedence: an explicit platform marker wins over plain .env.
|
|
1151
|
+
const hasVercel = fileExists(projectDir, "vercel.json") || fileExists(projectDir, ".vercel");
|
|
1152
|
+
const hasGoogle =
|
|
1153
|
+
fileExists(projectDir, "cloudbuild.yaml") ||
|
|
1154
|
+
fileExists(projectDir, "cloudbuild.yml") ||
|
|
1155
|
+
fileExists(projectDir, ".gcloudignore");
|
|
1156
|
+
const hasNeon =
|
|
1157
|
+
depNames.some((d) => d === "@neondatabase/serverless" || d.includes("neon")) ||
|
|
1158
|
+
fileExists(projectDir, ".neon");
|
|
1159
|
+
|
|
1160
|
+
let vault = "local (.env)";
|
|
1161
|
+
let fetchCommand = secretEnvVarNames[0]
|
|
1162
|
+
? `read $${secretEnvVarNames[0]} from .env`
|
|
1163
|
+
: "read from .env";
|
|
1164
|
+
|
|
1165
|
+
if (hasVercel) {
|
|
1166
|
+
vault = "Vercel";
|
|
1167
|
+
fetchCommand = "vercel env pull";
|
|
1168
|
+
} else if (hasGoogle) {
|
|
1169
|
+
vault = "Google Secret Manager";
|
|
1170
|
+
fetchCommand = "gcloud secrets versions access latest --secret=<name>";
|
|
1171
|
+
} else if (hasNeon) {
|
|
1172
|
+
vault = "Neon";
|
|
1173
|
+
fetchCommand = "neonctl connection-string";
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return {
|
|
1177
|
+
vault,
|
|
1178
|
+
fetchCommand,
|
|
1179
|
+
secretEnvVarNames,
|
|
1180
|
+
signals: { hasVercel, hasGoogle, hasNeon },
|
|
1181
|
+
detectedFrom: path.join(projectDir, "package.json"),
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ─── addPermissionEntry — broad-glob allow-entry per tool ────────────────────
|
|
1186
|
+
//
|
|
1187
|
+
// Reuses the guarded-write scaffold pattern from bin/gsd-t.js (invalid-JSON
|
|
1188
|
+
// guard + symlink guard + idempotent). Writes a BROAD GLOB per tool
|
|
1189
|
+
// (Bash(psql:*)), NOT the exact command string — the user-locked decision.
|
|
1190
|
+
|
|
1191
|
+
function isSymlink(filePath) {
|
|
1192
|
+
try {
|
|
1193
|
+
return fs.lstatSync(filePath).isSymbolicLink();
|
|
1194
|
+
} catch (_) {
|
|
1195
|
+
return false;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Derive the tool from the connect command's first meaningful token, skipping
|
|
1200
|
+
// leading env-var assignments (FOO=bar psql ...) and a leading `sudo`.
|
|
1201
|
+
function deriveTool(connectCommand) {
|
|
1202
|
+
if (!connectCommand || typeof connectCommand !== "string") return null;
|
|
1203
|
+
const tokens = connectCommand.trim().split(/\s+/);
|
|
1204
|
+
let i = 0;
|
|
1205
|
+
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
|
|
1206
|
+
if (tokens[i] === "sudo") i++;
|
|
1207
|
+
const tool = tokens[i];
|
|
1208
|
+
if (!tool) return null;
|
|
1209
|
+
// Base name only (strip any path).
|
|
1210
|
+
const base = tool.split("/").pop();
|
|
1211
|
+
if (!base) return null;
|
|
1212
|
+
// Reject a tool token carrying shell metacharacters — emitting Bash(psql;:*)
|
|
1213
|
+
// (or worse) into the permission allowlist would be broken junk / an injection
|
|
1214
|
+
// vector. A real tool name is plain `[A-Za-z0-9._-]`. Anything else → null
|
|
1215
|
+
// (no permission entry written).
|
|
1216
|
+
if (!/^[A-Za-z0-9._-]+$/.test(base)) return null;
|
|
1217
|
+
return base;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function addPermissionEntry(projectDir, connectCommand) {
|
|
1221
|
+
if (!projectDir) throw new Error("addPermissionEntry: projectDir is required");
|
|
1222
|
+
const tool = deriveTool(connectCommand);
|
|
1223
|
+
if (!tool) return { added: false, tool: null, entry: null, reason: "no tool derivable" };
|
|
1224
|
+
|
|
1225
|
+
const entry = `Bash(${tool}:*)`;
|
|
1226
|
+
const settingsPath = path.join(projectDir, ".claude", "settings.json");
|
|
1227
|
+
|
|
1228
|
+
let settings = {};
|
|
1229
|
+
if (fs.existsSync(settingsPath)) {
|
|
1230
|
+
try {
|
|
1231
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
1232
|
+
if (!settings || typeof settings !== "object") settings = {};
|
|
1233
|
+
} catch (_) {
|
|
1234
|
+
// invalid-JSON guard — do not clobber, report noop
|
|
1235
|
+
return { added: false, tool, entry, reason: "settings.json has invalid JSON" };
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (!settings.permissions || typeof settings.permissions !== "object") settings.permissions = {};
|
|
1240
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
1241
|
+
|
|
1242
|
+
if (settings.permissions.allow.includes(entry)) {
|
|
1243
|
+
return { added: false, tool, entry, reason: "already present" };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (isSymlink(settingsPath)) {
|
|
1247
|
+
return { added: false, tool, entry, reason: "settings.json is a symlink" };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
settings.permissions.allow.push(entry);
|
|
1251
|
+
|
|
1252
|
+
const dir = path.dirname(settingsPath);
|
|
1253
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1254
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
1255
|
+
|
|
1256
|
+
return { added: true, tool, entry };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ─── ensureEnvGitignored — auto-add + report (user-locked decision) ──────────
|
|
1260
|
+
|
|
1261
|
+
function ensureEnvGitignored(projectDir) {
|
|
1262
|
+
if (!projectDir) throw new Error("ensureEnvGitignored: projectDir is required");
|
|
1263
|
+
const gitignorePath = path.join(projectDir, ".gitignore");
|
|
1264
|
+
|
|
1265
|
+
if (isSymlink(gitignorePath)) {
|
|
1266
|
+
return { added: false, reason: ".gitignore is a symlink" };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
let content = "";
|
|
1270
|
+
if (fs.existsSync(gitignorePath)) content = fs.readFileSync(gitignorePath, "utf8");
|
|
1271
|
+
|
|
1272
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
1273
|
+
// A broad `.env*` rule (or `*.env`) already covers every variant.
|
|
1274
|
+
const hasBroadRule = lines.some((l) => l === ".env*" || l === "/.env*" || l === "*.env");
|
|
1275
|
+
|
|
1276
|
+
// Collect the env files actually present in the project — `.env`, `.env.local`,
|
|
1277
|
+
// `.env.production`, etc. Each MUST be gitignored; a bare `.env` rule does NOT
|
|
1278
|
+
// cover `.env.production`, so leaving it tracked would leak secrets.
|
|
1279
|
+
let present = [];
|
|
1280
|
+
try {
|
|
1281
|
+
present = fs
|
|
1282
|
+
.readdirSync(projectDir)
|
|
1283
|
+
.filter((f) => f === ".env" || /^\.env\.[^/\\]+$/.test(f));
|
|
1284
|
+
} catch (_) {
|
|
1285
|
+
present = [];
|
|
1286
|
+
}
|
|
1287
|
+
// Always ensure at least a bare `.env` intent even if none is present yet.
|
|
1288
|
+
const required = new Set([".env", ...present]);
|
|
1289
|
+
|
|
1290
|
+
const ignored = new Set(lines);
|
|
1291
|
+
const isCovered = (name) =>
|
|
1292
|
+
hasBroadRule || ignored.has(name) || ignored.has("/" + name);
|
|
1293
|
+
|
|
1294
|
+
const missing = [...required].filter((name) => !isCovered(name));
|
|
1295
|
+
if (missing.length === 0) return { added: false, reason: "already gitignored" };
|
|
1296
|
+
|
|
1297
|
+
// Prefer a single broad `.env*` rule when more than one variant is present or
|
|
1298
|
+
// missing — it future-proofs against new `.env.*` files.
|
|
1299
|
+
const additions =
|
|
1300
|
+
missing.length > 1 || present.some((f) => f !== ".env") ? [".env*"] : missing;
|
|
1301
|
+
|
|
1302
|
+
const next =
|
|
1303
|
+
content.replace(/\s*$/, "") +
|
|
1304
|
+
(content ? "\n" : "") +
|
|
1305
|
+
additions.join("\n") +
|
|
1306
|
+
"\n";
|
|
1307
|
+
fs.writeFileSync(gitignorePath, next, "utf8");
|
|
1308
|
+
return { added: true, rules: additions };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
module.exports = {
|
|
1312
|
+
recordEnvironment,
|
|
1313
|
+
lookupEnvironment,
|
|
1314
|
+
detectEnvConfig,
|
|
1315
|
+
detectCommandLeak,
|
|
1316
|
+
proposeRemediation,
|
|
1317
|
+
readEnvRegistryConfig,
|
|
1318
|
+
addPermissionEntry,
|
|
1319
|
+
ensureEnvGitignored,
|
|
1320
|
+
deriveTool,
|
|
1321
|
+
looksLikeInlineSecret,
|
|
1322
|
+
looksLikeSecretValue,
|
|
1323
|
+
isHostShape,
|
|
1324
|
+
isDbNameShape,
|
|
1325
|
+
isSafeBareArg,
|
|
1326
|
+
classifyCommandToken,
|
|
1327
|
+
classifyCommand,
|
|
1328
|
+
classifyFlagValue,
|
|
1329
|
+
isSafeNonSecretToken,
|
|
1330
|
+
isValueFlagAllowingBare,
|
|
1331
|
+
flagValueMatchesTypedShape,
|
|
1332
|
+
VALUE_FLAG_SHAPE,
|
|
1333
|
+
tokenizeCommand,
|
|
1334
|
+
AUTH_METHODS,
|
|
1335
|
+
CLI_WORDS,
|
|
1336
|
+
ENV_COLUMNS,
|
|
1337
|
+
ENV_MARKER_START,
|
|
1338
|
+
ENV_MARKER_END,
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// ─── CLI arm (so sandboxed workflows can call via Bash) ──────────────────────
|
|
1342
|
+
|
|
1343
|
+
if (require.main === module) {
|
|
1344
|
+
const args = process.argv.slice(2);
|
|
1345
|
+
const sub = args[0];
|
|
1346
|
+
const out = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
|
|
1347
|
+
|
|
1348
|
+
try {
|
|
1349
|
+
if (sub === "record") {
|
|
1350
|
+
// node gsd-t-env-registry.cjs record '<json-opts>'
|
|
1351
|
+
const opts = JSON.parse(args[1] || "{}");
|
|
1352
|
+
out({ ok: true, row: recordEnvironment(opts) });
|
|
1353
|
+
} else if (sub === "lookup") {
|
|
1354
|
+
// node gsd-t-env-registry.cjs lookup <projectDir> <scope> <kind>
|
|
1355
|
+
const row = lookupEnvironment(args[1], args[2], args[3]);
|
|
1356
|
+
out({ ok: true, found: !!row, row });
|
|
1357
|
+
} else if (sub === "detect") {
|
|
1358
|
+
// node gsd-t-env-registry.cjs detect <projectDir>
|
|
1359
|
+
out({ ok: true, detected: detectEnvConfig(args[1]) });
|
|
1360
|
+
} else if (sub === "add-permission") {
|
|
1361
|
+
// node gsd-t-env-registry.cjs add-permission <projectDir> '<connectCommand>'
|
|
1362
|
+
out({ ok: true, result: addPermissionEntry(args[1], args[2]) });
|
|
1363
|
+
} else if (sub === "ensure-gitignore") {
|
|
1364
|
+
// node gsd-t-env-registry.cjs ensure-gitignore <projectDir>
|
|
1365
|
+
out({ ok: true, result: ensureEnvGitignored(args[1]) });
|
|
1366
|
+
} else if (sub === "detect-leak") {
|
|
1367
|
+
// node gsd-t-env-registry.cjs detect-leak '<command>'
|
|
1368
|
+
out({ ok: true, result: detectCommandLeak(args[1]) });
|
|
1369
|
+
} else if (sub === "propose-remediation") {
|
|
1370
|
+
// node gsd-t-env-registry.cjs propose-remediation <projectDir> '<command>' [envVarName]
|
|
1371
|
+
out({ ok: true, result: proposeRemediation(args[1], args[2], args[3] ? { envVarName: args[3] } : {}) });
|
|
1372
|
+
} else {
|
|
1373
|
+
out({
|
|
1374
|
+
ok: false,
|
|
1375
|
+
error:
|
|
1376
|
+
"usage: env-registry <record|lookup|detect|detect-leak|propose-remediation|add-permission|ensure-gitignore> ...",
|
|
1377
|
+
});
|
|
1378
|
+
process.exit(2);
|
|
1379
|
+
}
|
|
1380
|
+
} catch (e) {
|
|
1381
|
+
out({ ok: false, error: e.message });
|
|
1382
|
+
process.exit(1);
|
|
1383
|
+
}
|
|
1384
|
+
}
|