flecto 4.2.0 → 4.3.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 CHANGED
@@ -7,6 +7,33 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.3.0] - 2026-10-05
11
+
12
+ ### Fixed
13
+
14
+ - **A secret-sounding key no longer hides, or raises an error over, a value
15
+ that cannot be a secret** ([#224]). Masking and `secret-key-changed` decided
16
+ on the key name alone, so on real Helm charts `secretCreatePolicy: { enabled:
17
+ true }` printed as `***` (hiding the one line the PR was about), and
18
+ `adminPasswordKey: keycloak-admin-password` (the *name* of a key inside a
19
+ Kubernetes Secret) failed as an error. Four of six repositories checked
20
+ during outreach hit it.
21
+
22
+ Under such a key a value is now judged leaf by leaf, and is not treated as a
23
+ secret when it is a boolean, `null`, an empty string, a reference-only
24
+ placeholder (`${NAME}`, `${NAME:-}`, `$(NAME)`, `{{ ... }}`, or `KEY=` one of
25
+ those), or a plain identifier under a key that names a Secret
26
+ (`existingSecret*`, `*SecretName`, `*SecretRef`, `*PasswordKey`,
27
+ `secretKeyRef.name`/`.key`). Numbers, `${NAME:-literal}`, and anything the
28
+ value detector flags are still masked and still fire. Display masking, the
29
+ committed snapshot store, and the rule share one predicate, so they cannot
30
+ disagree. It is exposed to packs as `afterSecretCandidate`.
31
+
32
+ A shared snapshot store that recorded one of these values as a digest will
33
+ report it as changed once, when it is first written in plain text.
34
+
35
+ [#224]: https://github.com/myselfsiddharth/Flecto/issues/224
36
+
10
37
  ## [4.2.0] - 2026-10-05
11
38
 
12
39
  The first npm release since 4.1.1. The `v4.1.2` tag moved the documented Action
@@ -1411,7 +1438,8 @@ fixed — those runs were never actually gated — but the failure is new.
1411
1438
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
1412
1439
  continuing with no policies.
1413
1440
 
1414
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...HEAD
1441
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.3.0...HEAD
1442
+ [4.3.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...v4.3.0
1415
1443
  [4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
1416
1444
  [4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
1417
1445
  [4.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.0.0...v4.1.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.1.2` is the whole reference.
80
+ `myselfsiddharth/Flecto@v4.2.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.1.2
94
+ - uses: myselfsiddharth/Flecto@v4.2.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.1.2
112
+ - uses: myselfsiddharth/Flecto@v4.2.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.2.0",
7
+ "version": "4.3.0",
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": [
@@ -44,6 +44,7 @@
44
44
  "afterTruthy": { "const": true },
45
45
  "beforeLooksSecret": { "const": true },
46
46
  "afterLooksSecret": { "const": true },
47
+ "afterSecretCandidate": { "const": true },
47
48
  "afterMatches": { "type": "string" },
48
49
  "afterAnyMatches": { "type": "string" },
49
50
  "numericJump": {
@@ -95,6 +96,7 @@
95
96
  "afterTruthy": { "const": true },
96
97
  "beforeLooksSecret": { "const": true },
97
98
  "afterLooksSecret": { "const": true },
99
+ "afterSecretCandidate": { "const": true },
98
100
  "afterMatches": { "type": "string" },
99
101
  "afterAnyMatches": { "type": "string" },
100
102
  "numericJump": { "$ref": "#/$defs/numericJump" },
@@ -9,6 +9,7 @@
9
9
  "path": "(secret|token|password|api[_-]?key|private[_-]?key)",
10
10
  "pathFlags": "i"
11
11
  },
12
+ "afterSecretCandidate": true,
12
13
  "message": "Sensitive-looking key added or changed. Confirm secret storage, rotation, and access controls."
13
14
  },
14
15
  {
@@ -9,6 +9,7 @@
9
9
  "path": "(secret|token|password|api[_-]?key|private[_-]?key|credential)",
10
10
  "pathFlags": "i"
11
11
  },
12
+ "afterSecretCandidate": true,
12
13
  "message": "Sensitive-looking key changed in production profile. Confirm rotation and access controls."
13
14
  },
14
15
  {
package/src/policy.js CHANGED
@@ -4,7 +4,8 @@ import { createRequire } from 'module';
4
4
  import { fileURLToPath, pathToFileURL } from 'url';
5
5
  import yaml from 'js-yaml';
6
6
  import { checkPattern, compilePattern, explainPatternFailure } from './regex-engine.js';
7
- import { containsSecret } from './secrets.js';
7
+ import { containsSecret, holdsSecretCandidate } from './secrets.js';
8
+ import { secretMatchPath } from './differ.js';
8
9
 
9
10
  /**
10
11
  * @typedef {'info' | 'warn' | 'error'} PolicySeverity
@@ -29,6 +30,7 @@ import { containsSecret } from './secrets.js';
29
30
  * afterTruthy?: true,
30
31
  * beforeLooksSecret?: true,
31
32
  * afterLooksSecret?: true,
33
+ * afterSecretCandidate?: true,
32
34
  * afterMatches?: string,
33
35
  * afterAnyMatches?: string,
34
36
  * numericJump?: { minMultiple: number },
@@ -49,6 +51,7 @@ import { containsSecret } from './secrets.js';
49
51
  * afterTruthy?: true,
50
52
  * beforeLooksSecret?: true,
51
53
  * afterLooksSecret?: true,
54
+ * afterSecretCandidate?: true,
52
55
  * afterMatches?: string,
53
56
  * afterAnyMatches?: string,
54
57
  * numericJump?: { minMultiple: number },
@@ -102,12 +105,12 @@ const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
102
105
  const RULE_FIELDS = new Set([
103
106
  'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
104
107
  'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
105
- 'beforeLooksSecret', 'afterLooksSecret',
108
+ 'beforeLooksSecret', 'afterLooksSecret', 'afterSecretCandidate',
106
109
  'afterMatches', 'afterAnyMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
107
110
  ]);
108
111
  const CLAUSE_FIELDS = new Set([
109
112
  'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
110
- 'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
113
+ 'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret', 'afterSecretCandidate',
111
114
  'afterMatches', 'afterAnyMatches', 'numericJump', 'numericDelta',
112
115
  ]);
113
116
  const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
@@ -277,6 +280,7 @@ function validateRule(candidate, location, isClause = false, trusted = false) {
277
280
  validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
278
281
  validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
279
282
  validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
283
+ validateTruthyPredicate(rule.afterSecretCandidate, 'afterSecretCandidate', location);
280
284
  validateRegexPredicate(rule.afterMatches, 'afterMatches', location, trusted);
281
285
  validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location, trusted);
282
286
  validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
@@ -878,6 +882,9 @@ function matchClause(clause, change) {
878
882
  // the redaction it triggers never disagree.
879
883
  if (clause.beforeLooksSecret && !containsSecret(change.before)) return false;
880
884
  if (clause.afterLooksSecret && !containsSecret(change.after)) return false;
885
+ // A secret-sounding key whose new value cannot be the secret: a boolean, an
886
+ // empty string, a placeholder, or a Secret reference (#224).
887
+ if (clause.afterSecretCandidate && !holdsSecretCandidate(change.after, secretMatchPath(change))) return false;
881
888
  if (clause.afterMatches && (typeof change.after !== 'string' || !afterMatchesRegexFor(clause).test(change.after))) return false;
882
889
  // The list counterpart, for the change that turns a scalar into a list in
883
890
  // one edit -- reported as a single `changed` event whose `after` is an array,
package/src/renderer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { looksLikeSecretPath, redactSecretString } from './secrets.js';
2
+ import { isSecretCandidate, looksLikeSecretPath, redactSecretString } from './secrets.js';
3
3
  import { ENCRYPTED_DISPLAY, displayEncrypted, isEncryptedSentinel } from './encrypted.js';
4
4
  import { secretMatchPath } from './differ.js';
5
5
 
@@ -16,13 +16,12 @@ function fmt(v, opts = {}) {
16
16
  if (isEncryptedSentinel(v)) return chalk.dim(ENCRYPTED_DISPLAY);
17
17
  let value = displayEncrypted(v);
18
18
  if (opts.maskSecrets) {
19
- if (opts.path && looksLikeSecretPath(opts.path)) {
20
- return chalk.dim('"***"');
21
- }
22
19
  // The changed path itself can look benign while the value carries secrets,
23
- // e.g. "database" holding { password }. Redact those the same way the
24
- // webhook/CI payloads do.
20
+ // e.g. "database" holding { password }; or look secret while holding none,
21
+ // e.g. `secretCreatePolicy: { enabled: true }`. Both are judged leaf by leaf,
22
+ // the same way the webhook/CI payloads are.
25
23
  value = maskSensitiveValue(value, opts.path ?? '');
24
+ if (value === '***') return chalk.dim('"***"');
26
25
  }
27
26
  if (typeof value === 'string') return JSON.stringify(value);
28
27
  if (typeof value === 'object' && value !== null) return JSON.stringify(value);
@@ -190,7 +189,18 @@ export function renderPolicyFindings(findings) {
190
189
  * @returns {unknown}
191
190
  */
192
191
  export function maskSensitiveValue(value, path = '') {
193
- if (looksLikeSecretPath(path)) return '***';
192
+ // A secret-sounding key masks its scalars, not its whole subtree: under
193
+ // `secretCreatePolicy`, `enabled: true` is the line a reviewer needs (#224).
194
+ // Each leaf is judged at its own path, which still carries the key.
195
+ // Plain containers only: YAML reads `password: 2024-01-01` as a Date, and a
196
+ // Date falls through to the scalar branch below, where it is masked.
197
+ const isContainer = Array.isArray(value)
198
+ || (value !== null && typeof value === 'object'
199
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
200
+ if (looksLikeSecretPath(path) && !isContainer) {
201
+ if (isSecretCandidate(value, path)) return '***';
202
+ return typeof value === 'string' ? redactSecretString(value) : value;
203
+ }
194
204
  if (Array.isArray(value)) {
195
205
  return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
196
206
  }
package/src/secrets.js CHANGED
@@ -362,6 +362,100 @@ export function looksLikeSecretPath(path) {
362
362
  return SECRET_PATH_RE.test(path);
363
363
  }
364
364
 
365
+ /**
366
+ * Keys that hold the *name* of a secret, not the secret: `existingSecret`,
367
+ * `existingSecretPasswordKey`, `tlsSecretName`, `envSecretRef`,
368
+ * `adminPasswordKey`. The Helm convention for "the credential lives in a
369
+ * Kubernetes Secret, and this is where". Deliberately not `*SecretKey`:
370
+ * `minio.secretKey` is the credential itself.
371
+ */
372
+ const REFERENCE_KEY_RE = /^(?:existingSecret\w*|\w*Secret(?:Name|Ref)|\w*PasswordKey)$/i;
373
+ const SECRET_KEY_REF_RE = /secretKeyRef\.(?:name|key)$/i;
374
+ // A Kubernetes object name or a Secret key, which is what a reference holds.
375
+ const REFERENCE_VALUE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,251}[A-Za-z0-9])?$/;
376
+ // `KEY=VALUE`, as Compose writes an environment list entry.
377
+ const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_.-]*=(.+)$/s;
378
+ /**
379
+ * Placeholders that can only be references, for values under a key that names
380
+ * a credential. Stricter than PLACEHOLDER_RE, which serves value-shape
381
+ * detection: there, `$uperSecret1` reading as `$NAME` costs a missed entropy
382
+ * hit; here it would print a password. So `${NAME}`, `${NAME:-}` and
383
+ * `${NAME:?message}` but not `${NAME:-literal}` (the literal is a default
384
+ * credential), `$(NAME)`, `{{ ... }}` with no quoted literal inside, and an
385
+ * already-masked `***`.
386
+ */
387
+ const KEY_PLACEHOLDER_RE = /^(?:\$\{[A-Za-z_][A-Za-z0-9_]*(?::?\?[^}]*|:-)?\}|\$\([A-Za-z_][A-Za-z0-9_]*\)|\{\{[^}"'`]*\}\}|\*{3})$/;
388
+
389
+ /**
390
+ * The last key of a diff path: `a.b.c` -> `c`, `env["X_TOKEN"]` -> `X_TOKEN`.
391
+ * @param {string} path
392
+ * @returns {string}
393
+ */
394
+ function lastPathSegment(path) {
395
+ const quoted = /\[("(?:[^"\\]|\\.)*")\]$/.exec(path);
396
+ if (quoted) {
397
+ try {
398
+ return JSON.parse(quoted[1]);
399
+ } catch {
400
+ return quoted[1];
401
+ }
402
+ }
403
+ return path.slice(path.lastIndexOf('.') + 1);
404
+ }
405
+
406
+ /**
407
+ * Could this scalar, sitting under a secret-sounding key, be the secret?
408
+ *
409
+ * Key-name masking used to answer yes for anything, which hid the values a
410
+ * reviewer most needed (#224): `secretCreatePolicy: { enabled: true }` printed
411
+ * as `***`, and `adminPasswordKey: keycloak-admin-password` — the name of a key
412
+ * inside a Secret — raised an error. No, then, for:
413
+ *
414
+ * - booleans, null, and an empty string: nothing to hide
415
+ * - a placeholder (`${X}`, `$(X)`, `{{ ... }}`), alone or as `KEY=<placeholder>`
416
+ * - a reference: a key that names a Secret, holding a plain identifier that the
417
+ * value detector does not flag
418
+ *
419
+ * Numbers stay yes (`pin: 12345` is the pin). So does `KEY=` with an empty
420
+ * right side: base64 padding (`c2VjcmV0=`) has exactly that shape.
421
+ *
422
+ * Shared by display masking, the committed snapshot store, and the
423
+ * `secret-key-changed` rule, so the three cannot disagree.
424
+ * @param {unknown} value
425
+ * @param {string} [path] the configuration path, document prefix stripped
426
+ * @returns {boolean}
427
+ */
428
+ export function isSecretCandidate(value, path = '') {
429
+ if (value === null || value === undefined || typeof value === 'boolean') return false;
430
+ if (typeof value !== 'string') return true;
431
+ const trimmed = value.trim();
432
+ if (!trimmed || KEY_PLACEHOLDER_RE.test(trimmed)) return false;
433
+ const assignment = ASSIGNMENT_RE.exec(trimmed);
434
+ if (assignment && KEY_PLACEHOLDER_RE.test(assignment[1].trim())) return false;
435
+ const isReference = REFERENCE_KEY_RE.test(lastPathSegment(path)) || SECRET_KEY_REF_RE.test(path);
436
+ if (isReference && REFERENCE_VALUE_RE.test(trimmed) && !looksLikeSecret(trimmed)) return false;
437
+ return true;
438
+ }
439
+
440
+ /**
441
+ * True when any scalar inside `value` could be a secret (see
442
+ * `isSecretCandidate`), each judged at its own path.
443
+ * @param {unknown} value
444
+ * @param {string} [path]
445
+ * @returns {boolean}
446
+ */
447
+ export function holdsSecretCandidate(value, path = '') {
448
+ if (Array.isArray(value)) return value.some((entry, index) => holdsSecretCandidate(entry, `${path}[${index}]`));
449
+ if (
450
+ value
451
+ && typeof value === 'object'
452
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
453
+ ) {
454
+ return Object.entries(value).some(([key, entry]) => holdsSecretCandidate(entry, path ? `${path}.${key}` : key));
455
+ }
456
+ return isSecretCandidate(value, path);
457
+ }
458
+
365
459
  /**
366
460
  * True when a value — or any string nested inside a plain object or array —
367
461
  * looks like a secret.
@@ -11,7 +11,7 @@ import { createHash } from 'crypto';
11
11
  import { execFileSync } from 'child_process';
12
12
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
13
13
 
14
- import { containsSecret, looksLikeSecretPath } from './secrets.js';
14
+ import { containsSecret, isSecretCandidate, looksLikeSecretPath } from './secrets.js';
15
15
  import { documentKeysOf, withDocumentKeys } from './documents.js';
16
16
 
17
17
  /**
@@ -666,7 +666,11 @@ export function maskState(state, path = '') {
666
666
  if (isMaskedDigest(state)) return state;
667
667
  // `String(state)` because a credential is not always a string — `password:
668
668
  // 12345` parses as a number, and it is still the password.
669
- if (looksLikeSecretPath(path)) return maskedDigest(String(state));
669
+ // The same leaf rule as the renderer (#224): a boolean, an empty string, a
670
+ // placeholder, or a Secret reference under a secret-sounding key is not the
671
+ // secret. The store must never be more permissive than the display, and this
672
+ // is the one predicate both use.
673
+ if (looksLikeSecretPath(path) && isSecretCandidate(state, path)) return maskedDigest(String(state));
670
674
  if (typeof state === 'string') return containsSecret(state) ? maskedDigest(state) : state;
671
675
  return state;
672
676
  }