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/CHANGELOG.md +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
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
|
+
}
|