flecto 4.4.0 → 4.4.1

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 CHANGED
@@ -7,6 +7,31 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.4.1] - 2026-10-11
11
+
12
+ ### Fixed
13
+
14
+ - **A secret word anywhere above a value no longer makes it a "secret"**
15
+ ([#248]). Key-name matching tested the whole path, inside longer words too,
16
+ so it masked and flagged `capabilities.drop[0]` under `redisSecretInit`,
17
+ `matchLabels.app` under `apiKey`, `tokenizer.flavor`, `server-tokens:
18
+ "false"`, and the `type`/`description` of every CRD schema property named
19
+ `clientSecret`. Measured over every YAML leaf in 24 real repositories, 1,102
20
+ leaves were flagged; now 222, and none that were not flagged before.
21
+
22
+ A key now names a credential when the word is whole (after camelCase and
23
+ punctuation splitting, or ending a compound like `DBPASSWORD`) and its last
24
+ word is not metadata (`secretName`, `createSecretJob`, `tokenTTL`). A leaf
25
+ is a secret when its own key names one, or an ancestor does with no
26
+ structure or metadata key in between (`secrets.prod.db` yes;
27
+ `apiKey.selector.matchLabels.app` no), or it sits in a credential's
28
+ `data`/`stringData` under any name. `key` and `id` stay credential words
29
+ (`apiKey`, Vault `secret_id`). Display masking, the snapshot store, and
30
+ `secret-key-changed` all use this rule; the value detector still runs on
31
+ every value regardless.
32
+
33
+ [#248]: https://github.com/myselfsiddharth/Flecto/issues/248
34
+
10
35
  ## [4.4.0] - 2026-10-05
11
36
 
12
37
  ### Changed
@@ -331,6 +356,19 @@ for what was wrong.
331
356
  untrusted-PR threat model. `--plugins` must be absolute paths. See
332
357
  [docs/editor.md](docs/editor.md).
333
358
 
359
+ - **`flecto-drift`: compare a declared config file against what is actually
360
+ running** ([#144]). A **separate binary**, deliberately: every other Flecto
361
+ command authenticates to nothing, and reading live state cannot keep that
362
+ promise, so it does not share an entry point with the tool that can. `flecto
363
+ ci` cannot reach it and installing Flecto does not enable it.
364
+ It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
365
+ and `aws`, which you have already authenticated, so Flecto inherits exactly
366
+ what those are entitled to. Read-only is structural: argv is built from a
367
+ fixed verb allowlist and nothing from the URI can reach it as a flag. Values
368
+ from a secret store are compared **by shape** (length and digest), never by
369
+ value, with no flag to change that; SSM is read without `--with-decryption`.
370
+ Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
371
+
334
372
  ### Security
335
373
 
336
374
  - **BREAKING: `snapshotRef` declared in `.flectorc` is refused** ([#121]). The
@@ -376,20 +414,6 @@ for what was wrong.
376
414
  are cached and shared across every file in a run, and a `g` regex carries a
377
415
  mutable `lastIndex` that `.test()` advances, so such a rule matched every
378
416
  other value it saw.
379
- ### Added
380
-
381
- - **`flecto-drift`: compare a declared config file against what is actually
382
- running** ([#144]). A **separate binary**, deliberately: every other Flecto
383
- command authenticates to nothing, and reading live state cannot keep that
384
- promise, so it does not share an entry point with the tool that can. `flecto
385
- ci` cannot reach it and installing Flecto does not enable it.
386
- It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
387
- and `aws`, which you have already authenticated, so Flecto inherits exactly
388
- what those are entitled to. Read-only is structural: argv is built from a
389
- fixed verb allowlist and nothing from the URI can reach it as a flag. Values
390
- from a secret store are compared **by shape** (length and digest), never by
391
- value, with no flag to change that; SSM is read without `--with-decryption`.
392
- Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
393
417
 
394
418
  ### Fixed
395
419
 
@@ -505,6 +529,31 @@ for what was wrong.
505
529
  publish recommendation are in
506
530
  [`docs/ghsa-wq8m-fc3q-8m5x-2x.md`](docs/ghsa-wq8m-fc3q-8m5x-2x.md).
507
531
 
532
+ - **Two denial-of-service vectors fixed, found while resuming the 3.0 security
533
+ review** ([#121]). (1) Secret detection (`src/secrets.js`), which runs on every
534
+ changed string value under the `default` pack, had two `O(n²)` regexes — the
535
+ PEM private-key and URL-credential patterns — so a single ~500 KB value in a
536
+ pull request could hang the CI job. Both are now linear; 1 MB scans in under a
537
+ second, and detection of real (including unterminated) keys is unchanged. (2) A
538
+ YAML alias bomb ("billion laughs") — a few hundred bytes of nested aliases that
539
+ `normalizeParsedValue` expanded into an exponentially large tree — now fails
540
+ fast against a node budget instead of exhausting memory. Regression tests for
541
+ both in `test/security.test.js`. The review's findings and its "checked, solid"
542
+ list are recorded in [docs/security-review.md](docs/security-review.md); a
543
+ residual limitation (attacker-supplied regexes in custom packs, which Node
544
+ cannot time out) is noted in [SECURITY.md](SECURITY.md).
545
+
546
+ - **Terraform plan JSON is refused by every command except `flecto plan`.**
547
+ Terraform's `before_sensitive` / `after_sensitive` redaction is applied only by
548
+ `flecto plan`; a plan file is ordinary JSON, so `ci`, `watch`, `compare`,
549
+ `report`, and snapshot writes read it as a plain config tree and printed the
550
+ values Terraform itself refuses to print. `--mask-secrets` was not a backstop —
551
+ it fires on the attribute *name*, and `user_data` does not match. Realistic
552
+ ways to hit it: `flecto ci "**/*.json"`, a committed `tfplan.json`, or
553
+ `.flectorc` `files` patterns that sweep JSON. Those commands now fail with a
554
+ pointer to `flecto plan`, mirroring the guard `flecto plan` already had in the
555
+ other direction. ([#113])
556
+
508
557
  ### Added
509
558
 
510
559
  - **Inline suppressions in JSON** ([#158]). `.json` and `.jsonc` are parsed as
@@ -931,33 +980,6 @@ for what was wrong.
931
980
  60,000 characters to fit GitHub's comment limit, which lands under one pipe
932
981
  buffer.
933
982
 
934
- ### Security
935
-
936
- - **Two denial-of-service vectors fixed, found while resuming the 3.0 security
937
- review** ([#121]). (1) Secret detection (`src/secrets.js`), which runs on every
938
- changed string value under the `default` pack, had two `O(n²)` regexes — the
939
- PEM private-key and URL-credential patterns — so a single ~500 KB value in a
940
- pull request could hang the CI job. Both are now linear; 1 MB scans in under a
941
- second, and detection of real (including unterminated) keys is unchanged. (2) A
942
- YAML alias bomb ("billion laughs") — a few hundred bytes of nested aliases that
943
- `normalizeParsedValue` expanded into an exponentially large tree — now fails
944
- fast against a node budget instead of exhausting memory. Regression tests for
945
- both in `test/security.test.js`. The review's findings and its "checked, solid"
946
- list are recorded in [docs/security-review.md](docs/security-review.md); a
947
- residual limitation (attacker-supplied regexes in custom packs, which Node
948
- cannot time out) is noted in [SECURITY.md](SECURITY.md).
949
-
950
- - **Terraform plan JSON is refused by every command except `flecto plan`.**
951
- Terraform's `before_sensitive` / `after_sensitive` redaction is applied only by
952
- `flecto plan`; a plan file is ordinary JSON, so `ci`, `watch`, `compare`,
953
- `report`, and snapshot writes read it as a plain config tree and printed the
954
- values Terraform itself refuses to print. `--mask-secrets` was not a backstop —
955
- it fires on the attribute *name*, and `user_data` does not match. Realistic
956
- ways to hit it: `flecto ci "**/*.json"`, a committed `tfplan.json`, or
957
- `.flectorc` `files` patterns that sweep JSON. Those commands now fail with a
958
- pointer to `flecto plan`, mirroring the guard `flecto plan` already had in the
959
- other direction. ([#113])
960
-
961
983
  ## [3.0.1] - 2026-08-07
962
984
 
963
985
  ### Security
@@ -1454,7 +1476,8 @@ fixed — those runs were never actually gated — but the failure is new.
1454
1476
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
1455
1477
  continuing with no policies.
1456
1478
 
1457
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.4.0...HEAD
1479
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.4.1...HEAD
1480
+ [4.4.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.4.0...v4.4.1
1458
1481
  [4.4.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.3.0...v4.4.0
1459
1482
  [4.3.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...v4.3.0
1460
1483
  [4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
package/README.md CHANGED
@@ -77,7 +77,7 @@ aws_db_instance.main.#action
77
77
 
78
78
  Flecto PR Risk is on the
79
79
  [GitHub Marketplace](https://github.com/marketplace/actions/flecto-pr-risk), so
80
- `myselfsiddharth/Flecto@v4.3.0` is the whole reference.
80
+ `myselfsiddharth/Flecto@v4.4.0` is the whole reference.
81
81
 
82
82
  **Terraform** — point it at the plan JSON:
83
83
 
@@ -91,7 +91,7 @@ steps:
91
91
  - run: |
92
92
  terraform plan -out=tf.plan
93
93
  terraform show -json tf.plan > plan.json
94
- - uses: myselfsiddharth/Flecto@v4.3.0
94
+ - uses: myselfsiddharth/Flecto@v4.4.0
95
95
  with:
96
96
  terraform-plan: plan.json
97
97
  fail-on: error
@@ -109,7 +109,7 @@ steps:
109
109
  with:
110
110
  fetch-depth: 0
111
111
  - run: helm template ./chart > rendered.yaml
112
- - uses: myselfsiddharth/Flecto@v4.3.0
112
+ - uses: myselfsiddharth/Flecto@v4.4.0
113
113
  with:
114
114
  targets: rendered.yaml
115
115
  policies: kubernetes
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "4.4.0",
7
+ "version": "4.4.1",
8
8
  "description": "Reads your Terraform plan and Kubernetes changes and posts a plain-English risk summary on every pull request, blocking the dangerous ones",
9
9
  "license": "MIT",
10
10
  "keywords": [
package/src/secrets.js CHANGED
@@ -116,10 +116,14 @@ const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$\([A-Za-z_][A-Za-z0-9_]*\)|\{\{[^}]*\}
116
116
  * subresource-integrity strings (sha512-…), and base64 of ordinary ASCII text.
117
117
  *
118
118
  * Measured on a corpus of 3,000 random base64url/base62 tokens (lengths 24–64)
119
- * and a hand-built corpus of benign config values: 0 false positives, ~5% of
120
- * random tokens missed (worst at length 24, ~7%). Standard-base64 secrets that
121
- * contain "/" are always missed by this fallback by construction — the known
122
- * formats above are what covers those.
119
+ * and a hand-built corpus of benign config values: 0 false positives on that
120
+ * corpus; ~5% of random tokens missed (worst at length 24, ~7%). Other
121
+ * corpora can still yield false positives when a value is an opaque public
122
+ * identifier structurally indistinguishable from secret material — e.g. Google
123
+ * Place IDs (ChIJN1t_tDeuEmsRUsoyG83frY4) and IPFS CIDv0 hashes
124
+ * (QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG). Standard-base64 secrets
125
+ * that contain "/" are always missed by this fallback by construction — the
126
+ * known formats above are what covers those.
123
127
  */
124
128
  const ENTROPY_MIN_LENGTH = 24;
125
129
  const ENTROPY_MIN_BITS = 4.0;
@@ -347,9 +351,152 @@ export function redactSecretString(value) {
347
351
  */
348
352
  export const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
349
353
 
354
+ // Whole words that name a credential, after splitting a key on camelCase and
355
+ // punctuation. A word that *ends* in one also counts, for keys written with no
356
+ // separator (`DBPASSWORD`, `accesstoken`), but one that only *starts* with one
357
+ // does not: `tokenizer` is not a token, `passwordless` is not a password.
358
+ const SECRET_WORDS = ['secrets', 'secret', 'tokens', 'token', 'passwords', 'password', 'passwd', 'credentials', 'credential', 'apikey', 'privatekey'];
359
+ const SECRET_PAIRS = [['api', 'key'], ['private', 'key']];
360
+ /**
361
+ * A last word that makes a secret-named key *about* the secret rather than the
362
+ * secret: `secretName`, `passwordPolicy`, `tokenTTL`, `secretUser`. Not `key`
363
+ * (`apiKey`, `secretKey` are credentials) and not `id` (Vault's `secret_id`).
364
+ */
365
+ const METADATA_WORDS = new Set([
366
+ 'name', 'names', 'ref', 'refs', 'user', 'username', 'policy', 'policies', 'ttl', 'expiry', 'expiration',
367
+ 'lifetime', 'timeout', 'length', 'path', 'file', 'url', 'uri', 'endpoint', 'header', 'type', 'enabled',
368
+ 'mode', 'format', 'rotation', 'version', 'count', 'size', 'prefix', 'suffix', 'store', 'provider',
369
+ 'manager', 'namespace', 'selector', 'annotations', 'labels', 'mount', 'volume', 'dir', 'location',
370
+ 'interval', 'issuer', 'threshold', 'duration', 'seconds', 'retry', 'config', 'configuration', 'settings',
371
+ 'options', 'job', 'jobs', 'generator', 'source', 'review', 'init', 'controller', 'operator', 'webhook',
372
+ 'sync', 'cache',
373
+ ]);
374
+ /**
375
+ * Keys that are structure, not content, when their parent names a secret:
376
+ * `password: { secret: db, key: password }` (a reference), `clientSecret: {
377
+ * type: string }` (a schema), a projected `serviceAccountToken: { audience,
378
+ * expirationSeconds, path }`.
379
+ */
380
+ const STRUCTURAL_KEYS = new Set([
381
+ 'name', 'key', 'namespace', 'type', 'description', 'format', 'enabled', 'enable', 'optional', 'required',
382
+ 'mode', 'defaultmode', 'items', 'path', 'mountpath', 'subpath', 'readonly', 'audience', 'aud',
383
+ 'expirationseconds', 'issuer', 'provider', 'kind', 'apiversion', 'version', 'labels', 'annotations',
384
+ 'selector', 'prefix', 'suffix', 'header', 'ttl', 'rotationpolicy', 'algorithm', 'encoding', 'size',
385
+ 'length', 'image', 'repository', 'registry', 'tag', 'pullpolicy', 'digest', 'create', 'secretname',
386
+ // JSON-schema keywords: a CRD's `clientSecret: { type: string, maxLength: 64 }`.
387
+ 'properties', 'additionalproperties', 'maxproperties', 'minproperties', 'maxlength', 'minlength',
388
+ 'pattern', 'enum', 'default', 'minimum', 'maximum', 'nullable', 'xkubernetesmaptype',
389
+ 'xkuberneteslisttype', 'xkubernetespreserveunknownfields', 'xkubernetesvalidations',
390
+ ]);
391
+
392
+ // Last words of keys that group structure: `containerSecurityContext`,
393
+ // `matchLabels`, `matchExpressions`, `podSpec`.
394
+ const STRUCTURE_WORDS = new Set(['context', 'labels', 'expressions', 'spec', 'metadata', 'status', 'template', 'resources', 'probe', 'affinity', 'tolerations', 'ports', 'service', 'ingress', 'rules', 'schema']);
395
+
396
+ // Containers that carry a secret's contents under arbitrary keys.
397
+ const TRANSPARENT_KEYS = new Set(['data', 'stringdata', 'values', 'value', 'env', 'environment', 'vars', 'variables', 'entries', 'keys']);
398
+
399
+ /**
400
+ * The named keys of a diff path, indices dropped: `a.b[0]["C"]` -> a, b, C.
401
+ * @param {string} path
402
+ * @returns {string[]}
403
+ */
404
+ function pathKeys(path) {
405
+ /** @type {string[]} */
406
+ const keys = [];
407
+ for (const match of path.matchAll(/\["((?:[^"\\]|\\.)*)"\]|\[[^\]]*\]|([^.[\]]+)/g)) {
408
+ if (match[1] !== undefined) {
409
+ try {
410
+ keys.push(JSON.parse(`"${match[1]}"`));
411
+ } catch {
412
+ keys.push(match[1]);
413
+ }
414
+ } else if (match[2] !== undefined) {
415
+ keys.push(match[2]);
416
+ }
417
+ }
418
+ return keys;
419
+ }
420
+
421
+ /**
422
+ * Lowercased words of a key: `adminPasswordKey` -> admin, password, key.
423
+ * @param {string} key
424
+ * @returns {string[]}
425
+ */
426
+ function keyWords(key) {
427
+ return key
428
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
429
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
430
+ .toLowerCase()
431
+ .split(/[^a-z0-9]+/)
432
+ .filter(Boolean);
433
+ }
434
+
435
+ /**
436
+ * Index of the word that names a credential, or -1.
437
+ * @param {string[]} words
438
+ * @returns {number}
439
+ */
440
+ function secretWordIndex(words) {
441
+ for (let i = 0; i < words.length; i++) {
442
+ if (SECRET_WORDS.some((word) => words[i] === word || words[i].endsWith(word))) return i;
443
+ if (SECRET_PAIRS.some(([a, b]) => words[i] === a && words[i + 1] === b)) return i + 1;
444
+ }
445
+ return -1;
446
+ }
447
+
448
+ /**
449
+ * Lowercase alphanumerics only: `x-kubernetes-map-type` -> `xkubernetesmaptype`.
450
+ * @param {string} key
451
+ * @returns {string}
452
+ */
453
+ function normalizeKey(key) {
454
+ return key.toLowerCase().replace(/[^a-z0-9]/g, '');
455
+ }
456
+
457
+ /**
458
+ * A key that groups structure rather than content, so a credential name above
459
+ * it does not reach below it: `selector`, `matchLabels`, `securityContext`,
460
+ * `authorizationHeader`, `tokenReviewConfig`.
461
+ * @param {string} key
462
+ * @returns {boolean}
463
+ */
464
+ function isStructureOrMetadata(key) {
465
+ if (STRUCTURAL_KEYS.has(normalizeKey(key))) return true;
466
+ const last = keyWords(key).at(-1);
467
+ return last !== undefined && (METADATA_WORDS.has(last) || STRUCTURE_WORDS.has(last));
468
+ }
469
+
470
+ /**
471
+ * A key that names a credential: a secret word whose key does not end in a
472
+ * metadata word. `password`, `apiKey`, `DB_PASSWORD` yes; `secretName`,
473
+ * `createSecretJob`, `tokenRefreshInterval` no.
474
+ * @param {string} key
475
+ * @returns {boolean}
476
+ */
477
+ function namesCredential(key) {
478
+ const words = keyWords(key);
479
+ const at = secretWordIndex(words);
480
+ return at >= 0 && !(at < words.length - 1 && METADATA_WORDS.has(words.at(-1)));
481
+ }
482
+
350
483
  /**
351
484
  * True when a configuration path names a credential.
352
485
  *
486
+ * Decided on the leaf's own key and its parent, not on any key anywhere above
487
+ * it. Matching the whole path (as SECRET_PATH_RE did) masked and flagged
488
+ * `capabilities.drop[0]` under `redisSecretInit`, `matchLabels.app` under
489
+ * `apiKey`, `tokenizer.flavor`, and the `type`/`description` of every CRD
490
+ * schema property called `clientSecret` (#248). So:
491
+ *
492
+ * - own key names a credential (whole word, or ending in one), unless its last
493
+ * word is metadata (`secretName`, `passwordPolicy`, `tokenTTL`)
494
+ * - or the parent does (`sharedSecret.value`, `secrets.db`), unless the leaf is
495
+ * structural (`name`, `key`, `type`, `audience`, ...)
496
+ *
497
+ * A credential stored deeper, or under an innocuous name, is the value
498
+ * detector's job; it runs on every value regardless.
499
+ *
353
500
  * Lives here rather than beside either caller because there are two of them —
354
501
  * the renderer masking a diff for display, and the snapshot store masking a
355
502
  * state for a commit — and a store that recognized fewer key names than the
@@ -359,7 +506,29 @@ export const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?ke
359
506
  * @returns {boolean}
360
507
  */
361
508
  export function looksLikeSecretPath(path) {
362
- return SECRET_PATH_RE.test(path);
509
+ const keys = pathKeys(path);
510
+ const own = keys.at(-1);
511
+ if (own === undefined) return false;
512
+ if (secretWordIndex(keyWords(own)) >= 0) return namesCredential(own);
513
+ // Walk up to the nearest key that names a credential. A Secret's `data` /
514
+ // `stringData` (and the like) are looked through, and make the leaf a secret
515
+ // whatever it is called: `encryptedSecret.stringData.key1`. Otherwise the leaf
516
+ // must not be structural (`password.key` is a reference), and the walk stops
517
+ // at a key that is structure or metadata (`apiKey.selector.matchLabels.app`,
518
+ // `credentials.authorizationHeader.prefix`), which is where matching any
519
+ // ancestor went wrong. `secrets.prod.db` still counts.
520
+ const leafIsStructural = STRUCTURAL_KEYS.has(normalizeKey(own));
521
+ let throughData = false;
522
+ for (let i = keys.length - 2; i >= 0; i--) {
523
+ const key = keys[i];
524
+ if (TRANSPARENT_KEYS.has(key.toLowerCase())) {
525
+ throughData = true;
526
+ continue;
527
+ }
528
+ if (namesCredential(key)) return throughData || !leafIsStructural;
529
+ if (isStructureOrMetadata(key)) return false;
530
+ }
531
+ return false;
363
532
  }
364
533
 
365
534
  /**
@@ -426,10 +595,13 @@ function lastPathSegment(path) {
426
595
  * @returns {boolean}
427
596
  */
428
597
  export function isSecretCandidate(value, path = '') {
598
+ if (!looksLikeSecretPath(path)) return false;
429
599
  if (value === null || value === undefined || typeof value === 'boolean') return false;
430
600
  if (typeof value !== 'string') return true;
431
601
  const trimmed = value.trim();
432
602
  if (!trimmed || KEY_PLACEHOLDER_RE.test(trimmed)) return false;
603
+ // YAML 1.1 booleans that arrive as strings (`server-tokens: "false"`).
604
+ if (/^(?:true|false|yes|no|on|off)$/i.test(trimmed)) return false;
433
605
  const assignment = ASSIGNMENT_RE.exec(trimmed);
434
606
  if (assignment && KEY_PLACEHOLDER_RE.test(assignment[1].trim())) return false;
435
607
  const isReference = REFERENCE_KEY_RE.test(lastPathSegment(path)) || SECRET_KEY_REF_RE.test(path);