flecto 2.0.0 → 3.0.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/src/secrets.js ADDED
@@ -0,0 +1,316 @@
1
+ import { Buffer } from 'buffer';
2
+
3
+ /**
4
+ * Value-based secret detection.
5
+ *
6
+ * Key-name detection (see SECRET_PATH_RE in renderer.js and the
7
+ * secret-key-changed rule in packs/default.json) misses a credential stored
8
+ * under a boring key such as `db.connstr`. This module inspects the *value*
9
+ * instead, and is shared by the masking path and the policy engine so
10
+ * detection and redaction never drift apart.
11
+ *
12
+ * Two detectors run, in order:
13
+ *
14
+ * 1. Known token formats (below). High confidence, cheap, no false
15
+ * positives in practice — these prefixes are vendor-assigned.
16
+ * 2. A high-entropy fallback for opaque strings with no recognizable
17
+ * prefix. Deliberately conservative: masking a real hostname in
18
+ * someone's terminal is worse than missing an unusual secret, so the
19
+ * gates below reject anything that could plausibly be a hostname, URL,
20
+ * path, UUID, digest, or version string.
21
+ */
22
+
23
+ /**
24
+ * @typedef {'aws-access-key-id'
25
+ * | 'github-token'
26
+ * | 'slack-token'
27
+ * | 'google-api-key'
28
+ * | 'stripe-secret-key'
29
+ * | 'jwt'
30
+ * | 'private-key-block'
31
+ * | 'url-credentials'
32
+ * | 'high-entropy'} SecretKind
33
+ *
34
+ * @typedef {{ kind: SecretKind, start: number, end: number }} SecretMatch
35
+ */
36
+
37
+ const REDACTED = '***';
38
+
39
+ /**
40
+ * Vendor token formats. Each pattern is global so a value can carry more than
41
+ * one secret (a connection string, a shell snippet), and the prefixed ones are
42
+ * anchored on a word boundary so they do not fire mid-word.
43
+ * Stripe test keys (sk_test_) are intentionally excluded — they are not
44
+ * credentials worth redacting and appear in documentation constantly.
45
+ * @type {Array<{ kind: SecretKind, re: RegExp }>}
46
+ */
47
+ const KNOWN_FORMATS = [
48
+ // AWS access key ids: AKIA (long-lived) / ASIA (STS session), then 16 chars.
49
+ { kind: 'aws-access-key-id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
50
+ // GitHub PATs, OAuth, user-to-server, server-to-server, refresh, fine-grained.
51
+ { kind: 'github-token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g },
52
+ // Slack bot/user/app/refresh/legacy tokens.
53
+ { kind: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
54
+ // Google API keys: fixed "AIza" prefix, 35 further base64url chars.
55
+ { kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
56
+ // Stripe live secret / restricted keys.
57
+ { kind: 'stripe-secret-key', re: /\b[sr]k_live_[0-9A-Za-z]{16,}\b/g },
58
+ // JWT: base64url header starting with "eyJ" ('{"'), payload, signature.
59
+ { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
60
+ // PEM private key blocks, including PGP blocks and unterminated fragments.
61
+ {
62
+ kind: 'private-key-block',
63
+ re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g,
64
+ },
65
+ ];
66
+
67
+ /** Credentials embedded in a URL authority: scheme://user:PASSWORD@host. */
68
+ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
69
+
70
+ /**
71
+ * Values that only *reference* a secret. Redacting these adds noise and, worse,
72
+ * would make the policy rule fire on configs that correctly keep secrets out of
73
+ * the file.
74
+ */
75
+ const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
76
+
77
+ /**
78
+ * High-entropy fallback gates. Every one of these must pass:
79
+ *
80
+ * length >= 24 characters. Shorter opaque strings (short ids, hashes of
81
+ * truncated length, base36 counters) are too common in configs.
82
+ * charset only [A-Za-z0-9+=_-]. Notably excludes "." "/" ":" and
83
+ * whitespace, so hostnames, URLs, filesystem paths, dotted
84
+ * version strings, ARNs, and image refs can never be candidates.
85
+ * The cost is missing standard-base64 secrets that happen to
86
+ * contain "/" — an acceptable trade for the false-positive floor.
87
+ * classes at least one lowercase, one uppercase, and one digit. This
88
+ * alone rejects git SHAs and other lowercase hex, uppercase
89
+ * base32/TOTP seeds, kebab-case names, and ISO-8601 stamps.
90
+ * case mix 25%–85% of the letters are uppercase. Random base64/base62 key
91
+ * material sits near 50%; word-shaped identifiers such as
92
+ * `CustomerSuccessDashboard2026` or `AcmeVpnGateway01Prod` sit
93
+ * near 12% and are the main residual false-positive class once
94
+ * the charset gate has removed paths and hostnames.
95
+ * word runs no run of 8 or more consecutive same-case letters. Words are
96
+ * long same-case runs; random tokens change case roughly every
97
+ * other character. This catches mixed shapes the case-mix gate
98
+ * lets through, such as `PRODUCTION_us_east_1_Config2024`.
99
+ * entropy Shannon entropy over the string's own characters >= 4.0
100
+ * bits/char. A 24-char base64 secret lands around 4.3; English-ish
101
+ * identifiers of the same length land well below 4.0.
102
+ *
103
+ * Plus explicit rejections for benign shapes that can clear all five: UUIDs,
104
+ * subresource-integrity strings (sha512-…), and base64 of ordinary ASCII text.
105
+ *
106
+ * Measured on a corpus of 3,000 random base64url/base62 tokens (lengths 24–64)
107
+ * and a hand-built corpus of benign config values: 0 false positives, ~5% of
108
+ * random tokens missed (worst at length 24, ~7%). Standard-base64 secrets that
109
+ * contain "/" are always missed by this fallback by construction — the known
110
+ * formats above are what covers those.
111
+ */
112
+ const ENTROPY_MIN_LENGTH = 24;
113
+ const ENTROPY_MIN_BITS = 4.0;
114
+ const ENTROPY_MIN_UPPER_RATIO = 0.25;
115
+ const ENTROPY_MAX_UPPER_RATIO = 0.85;
116
+ const ENTROPY_MAX_SAME_CASE_RUN = 8;
117
+ const ENTROPY_CHARSET_RE = /^[A-Za-z0-9+=_-]+$/;
118
+ const UUID_RE = /^\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?$/i;
119
+ const INTEGRITY_PREFIX_RE = /^(?:sha1|sha256|sha384|sha512|md5)-/i;
120
+ const PRINTABLE_DECODE_RATIO = 0.9;
121
+
122
+ /**
123
+ * Shannon entropy of a string in bits per character.
124
+ * @param {string} value
125
+ * @returns {number}
126
+ */
127
+ function shannonEntropy(value) {
128
+ /** @type {Map<string, number>} */
129
+ const counts = new Map();
130
+ for (const char of value) counts.set(char, (counts.get(char) ?? 0) + 1);
131
+ let entropy = 0;
132
+ for (const count of counts.values()) {
133
+ const p = count / value.length;
134
+ entropy -= p * Math.log2(p);
135
+ }
136
+ return entropy;
137
+ }
138
+
139
+ /**
140
+ * True when a base64-ish string decodes to ordinary printable text, e.g. an
141
+ * encoded config blob or a base64'd sentence. Random key material decodes to
142
+ * bytes that are printable only ~37% of the time, so this separates the two
143
+ * cleanly.
144
+ * @param {string} value
145
+ * @returns {boolean}
146
+ */
147
+ function decodesToPrintableText(value) {
148
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/');
149
+ const decoded = Buffer.from(normalized, 'base64');
150
+ if (decoded.length < 8) return false;
151
+ let printable = 0;
152
+ for (const byte of decoded) {
153
+ if (byte === 0x09 || byte === 0x0a || byte === 0x0d || (byte >= 0x20 && byte <= 0x7e)) printable += 1;
154
+ }
155
+ return printable / decoded.length >= PRINTABLE_DECODE_RATIO;
156
+ }
157
+
158
+ /**
159
+ * Fraction of the letters in a string that are uppercase. Word-shaped
160
+ * identifiers cluster low, random key material clusters near one half.
161
+ * @param {string} value
162
+ * @returns {number}
163
+ */
164
+ function uppercaseRatio(value) {
165
+ const letters = value.match(/[A-Za-z]/g)?.length ?? 0;
166
+ if (letters === 0) return 0;
167
+ return (value.match(/[A-Z]/g)?.length ?? 0) / letters;
168
+ }
169
+
170
+ /**
171
+ * Length of the longest run of consecutive same-case letters. Words produce
172
+ * long runs; random tokens flip case every couple of characters.
173
+ * @param {string} value
174
+ * @returns {number}
175
+ */
176
+ function maxSameCaseRun(value) {
177
+ let longest = 0;
178
+ let run = 0;
179
+ let previous = null;
180
+ for (const char of value) {
181
+ const kind = /[a-z]/.test(char) ? 'lower' : /[A-Z]/.test(char) ? 'upper' : null;
182
+ run = kind && kind === previous ? run + 1 : (kind ? 1 : 0);
183
+ previous = kind;
184
+ if (run > longest) longest = run;
185
+ }
186
+ return longest;
187
+ }
188
+
189
+ /**
190
+ * The high-entropy fallback. See the gate documentation above.
191
+ * @param {string} value
192
+ * @returns {boolean}
193
+ */
194
+ function isHighEntropySecret(value) {
195
+ if (value.length < ENTROPY_MIN_LENGTH) return false;
196
+ if (!ENTROPY_CHARSET_RE.test(value)) return false;
197
+ if (!/[a-z]/.test(value) || !/[A-Z]/.test(value) || !/[0-9]/.test(value)) return false;
198
+ const upper = uppercaseRatio(value);
199
+ if (upper < ENTROPY_MIN_UPPER_RATIO || upper > ENTROPY_MAX_UPPER_RATIO) return false;
200
+ if (maxSameCaseRun(value) >= ENTROPY_MAX_SAME_CASE_RUN) return false;
201
+ if (UUID_RE.test(value)) return false;
202
+ if (INTEGRITY_PREFIX_RE.test(value)) return false;
203
+ if (shannonEntropy(value) < ENTROPY_MIN_BITS) return false;
204
+ if (decodesToPrintableText(value)) return false;
205
+ return true;
206
+ }
207
+
208
+ /**
209
+ * Locate every secret-shaped span inside a string, sorted and non-overlapping.
210
+ * @param {string} value
211
+ * @returns {SecretMatch[]}
212
+ */
213
+ function findSecretMatches(value) {
214
+ /** @type {SecretMatch[]} */
215
+ const matches = [];
216
+
217
+ for (const { kind, re } of KNOWN_FORMATS) {
218
+ re.lastIndex = 0;
219
+ let match;
220
+ while ((match = re.exec(value)) !== null) {
221
+ matches.push({ kind, start: match.index, end: match.index + match[0].length });
222
+ if (match[0].length === 0) re.lastIndex += 1;
223
+ }
224
+ }
225
+
226
+ URL_CREDENTIALS_RE.lastIndex = 0;
227
+ let credentials;
228
+ while ((credentials = URL_CREDENTIALS_RE.exec(value)) !== null) {
229
+ const password = credentials[1];
230
+ if (PLACEHOLDER_RE.test(password)) continue;
231
+ const start = credentials.index + credentials[0].length - password.length - 1;
232
+ matches.push({ kind: 'url-credentials', start, end: start + password.length });
233
+ }
234
+
235
+ matches.sort((a, b) => a.start - b.start || b.end - a.end);
236
+ /** @type {SecretMatch[]} */
237
+ const merged = [];
238
+ for (const match of matches) {
239
+ const previous = merged.at(-1);
240
+ if (previous && match.start <= previous.end) {
241
+ previous.end = Math.max(previous.end, match.end);
242
+ continue;
243
+ }
244
+ merged.push({ ...match });
245
+ }
246
+ return merged;
247
+ }
248
+
249
+ /**
250
+ * Classify a value by the kind of the earliest secret found in it, or null when
251
+ * no detector recognizes it. Non-strings are never secrets.
252
+ * @param {unknown} value
253
+ * @returns {SecretKind | null}
254
+ */
255
+ export function detectSecretKind(value) {
256
+ if (typeof value !== 'string') return null;
257
+ const trimmed = value.trim();
258
+ if (!trimmed || PLACEHOLDER_RE.test(trimmed)) return null;
259
+ const [first] = findSecretMatches(value);
260
+ if (first) return first.kind;
261
+ return isHighEntropySecret(trimmed) ? 'high-entropy' : null;
262
+ }
263
+
264
+ /**
265
+ * True when a string carries a secret, by known format or by entropy.
266
+ * @param {unknown} value
267
+ * @returns {boolean}
268
+ */
269
+ export function looksLikeSecret(value) {
270
+ return detectSecretKind(value) !== null;
271
+ }
272
+
273
+ /**
274
+ * Redact the secret parts of a string. An opaque high-entropy value is
275
+ * replaced wholesale; a secret embedded in a larger value (a connection
276
+ * string, a URL with credentials) keeps its surrounding context so the diff
277
+ * stays readable.
278
+ * @param {string} value
279
+ * @returns {string}
280
+ */
281
+ export function redactSecretString(value) {
282
+ const trimmed = value.trim();
283
+ if (!trimmed || PLACEHOLDER_RE.test(trimmed)) return value;
284
+
285
+ const matches = findSecretMatches(value);
286
+ if (matches.length === 0) {
287
+ return isHighEntropySecret(trimmed) ? REDACTED : value;
288
+ }
289
+
290
+ let out = '';
291
+ let cursor = 0;
292
+ for (const { start, end } of matches) {
293
+ out += value.slice(cursor, start) + REDACTED;
294
+ cursor = end;
295
+ }
296
+ return out + value.slice(cursor);
297
+ }
298
+
299
+ /**
300
+ * True when a value — or any string nested inside a plain object or array —
301
+ * looks like a secret.
302
+ * @param {unknown} value
303
+ * @returns {boolean}
304
+ */
305
+ export function containsSecret(value) {
306
+ if (typeof value === 'string') return looksLikeSecret(value);
307
+ if (Array.isArray(value)) return value.some((entry) => containsSecret(entry));
308
+ if (
309
+ value
310
+ && typeof value === 'object'
311
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
312
+ ) {
313
+ return Object.values(value).some((entry) => containsSecret(entry));
314
+ }
315
+ return false;
316
+ }