flecto 4.2.0 → 4.4.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 +46 -1
- package/README.md +3 -3
- package/package.json +1 -1
- package/schemas/flecto-policy-pack-2.0.json +2 -0
- package/src/differ.js +35 -12
- package/src/packs/default.json +1 -0
- package/src/packs/strict-prod.json +1 -0
- package/src/policy.js +10 -3
- package/src/positions.js +6 -3
- package/src/renderer.js +17 -7
- package/src/secrets.js +94 -0
- package/src/snapshot-store.js +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.4.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Flag lists diff by flag name, so removing one arg is one removal**
|
|
15
|
+
([#238]). A real pull request that removed the first of eight container
|
|
16
|
+
`args` read as 7 `changed` and 1 `removed`, each pairing two unrelated flags.
|
|
17
|
+
It now reads as `removed args["--keytoolPath"]`. A list counts when every item
|
|
18
|
+
is `-x`, `--name` or `--name=value` with unique names; a repeated `--set`,
|
|
19
|
+
`["--port", "8080"]`, and `["-c", "echo hi"]` keep the position diff, and
|
|
20
|
+
`--no-array-id` turns it off. Paths change from `args[0]` to
|
|
21
|
+
`args["--name"]` for these lists. No built-in pack rule matches an `args`
|
|
22
|
+
index.
|
|
23
|
+
|
|
24
|
+
[#238]: https://github.com/myselfsiddharth/Flecto/issues/238
|
|
25
|
+
|
|
26
|
+
## [4.3.0] - 2026-10-05
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **A secret-sounding key no longer hides, or raises an error over, a value
|
|
31
|
+
that cannot be a secret** ([#224]). Masking and `secret-key-changed` decided
|
|
32
|
+
on the key name alone, so on real Helm charts `secretCreatePolicy: { enabled:
|
|
33
|
+
true }` printed as `***` (hiding the one line the PR was about), and
|
|
34
|
+
`adminPasswordKey: keycloak-admin-password` (the *name* of a key inside a
|
|
35
|
+
Kubernetes Secret) failed as an error. Four of six repositories checked
|
|
36
|
+
during outreach hit it.
|
|
37
|
+
|
|
38
|
+
Under such a key a value is now judged leaf by leaf, and is not treated as a
|
|
39
|
+
secret when it is a boolean, `null`, an empty string, a reference-only
|
|
40
|
+
placeholder (`${NAME}`, `${NAME:-}`, `$(NAME)`, `{{ ... }}`, or `KEY=` one of
|
|
41
|
+
those), or a plain identifier under a key that names a Secret
|
|
42
|
+
(`existingSecret*`, `*SecretName`, `*SecretRef`, `*PasswordKey`,
|
|
43
|
+
`secretKeyRef.name`/`.key`). Numbers, `${NAME:-literal}`, and anything the
|
|
44
|
+
value detector flags are still masked and still fire. Display masking, the
|
|
45
|
+
committed snapshot store, and the rule share one predicate, so they cannot
|
|
46
|
+
disagree. It is exposed to packs as `afterSecretCandidate`.
|
|
47
|
+
|
|
48
|
+
A shared snapshot store that recorded one of these values as a digest will
|
|
49
|
+
report it as changed once, when it is first written in plain text.
|
|
50
|
+
|
|
51
|
+
[#224]: https://github.com/myselfsiddharth/Flecto/issues/224
|
|
52
|
+
|
|
10
53
|
## [4.2.0] - 2026-10-05
|
|
11
54
|
|
|
12
55
|
The first npm release since 4.1.1. The `v4.1.2` tag moved the documented Action
|
|
@@ -1411,7 +1454,9 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
1411
1454
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
1412
1455
|
continuing with no policies.
|
|
1413
1456
|
|
|
1414
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.
|
|
1457
|
+
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.4.0...HEAD
|
|
1458
|
+
[4.4.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.3.0...v4.4.0
|
|
1459
|
+
[4.3.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...v4.3.0
|
|
1415
1460
|
[4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
|
|
1416
1461
|
[4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
|
|
1417
1462
|
[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.
|
|
80
|
+
`myselfsiddharth/Flecto@v4.3.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.
|
|
94
|
+
- uses: myselfsiddharth/Flecto@v4.3.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.
|
|
112
|
+
- uses: myselfsiddharth/Flecto@v4.3.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.
|
|
7
|
+
"version": "4.4.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" },
|
package/src/differ.js
CHANGED
|
@@ -364,24 +364,43 @@ function identityMap(items, idKey) {
|
|
|
364
364
|
// `KEY=VALUE`, or a bare `KEY` (Compose's pass-through from the host).
|
|
365
365
|
export const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_.-]*)(?:=|$)/;
|
|
366
366
|
|
|
367
|
+
// `-x`, `--name`, or `--name=value`: a command-line flag, keyed by its name.
|
|
368
|
+
export const FLAG_RE = /^(-{1,2}[A-Za-z0-9][A-Za-z0-9_.-]*)(?:=|$)/;
|
|
369
|
+
|
|
367
370
|
/**
|
|
368
|
-
*
|
|
371
|
+
* String-list shapes that are really maps, and how each is keyed.
|
|
369
372
|
*
|
|
370
373
|
* Compose writes `environment`, `labels` and `build.args` either as a map or as
|
|
371
|
-
* a list of
|
|
374
|
+
* a list of `KEY=VALUE`, and means the same thing by both. Diffed by position,
|
|
372
375
|
* one inserted variable turned every later line into a `changed` pairing two
|
|
373
|
-
* unrelated variables (#226)
|
|
374
|
-
*
|
|
376
|
+
* unrelated variables (#226); one removed container arg did the same to every
|
|
377
|
+
* later flag (#238). A list counts only when every item has the shape and keys
|
|
378
|
+
* are unique, so a repeated `--set` falls back to position.
|
|
379
|
+
*
|
|
380
|
+
* Assignments also need at least one `=`, so a plain word list such as
|
|
381
|
+
* `command: [python, app.py]` keeps its order-sensitive diff. A flag list does
|
|
382
|
+
* not: `[-v, --debug]` is already unambiguous, and `["--port", "8080"]` or
|
|
383
|
+
* `["-c", "echo hi"]` fail the shape and keep position.
|
|
384
|
+
* @type {Array<{ re: RegExp, needsValue: boolean }>}
|
|
385
|
+
*/
|
|
386
|
+
const KEYED_STRING_SHAPES = [
|
|
387
|
+
{ re: ASSIGNMENT_RE, needsValue: true },
|
|
388
|
+
{ re: FLAG_RE, needsValue: false },
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Key a list of strings by the given shape, or return null when it is not one.
|
|
375
393
|
* @param {unknown[]} items
|
|
394
|
+
* @param {RegExp} re
|
|
376
395
|
* @returns {Map<string, { value: unknown, index: number }> | null}
|
|
377
396
|
*/
|
|
378
|
-
function
|
|
397
|
+
function keyedStringMap(items, re) {
|
|
379
398
|
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
380
399
|
const map = new Map();
|
|
381
400
|
for (let i = 0; i < items.length; i++) {
|
|
382
401
|
const item = items[i];
|
|
383
402
|
if (typeof item !== 'string') return null;
|
|
384
|
-
const match =
|
|
403
|
+
const match = re.exec(item);
|
|
385
404
|
if (!match || map.has(match[1])) return null;
|
|
386
405
|
map.set(match[1], { value: item, index: i });
|
|
387
406
|
}
|
|
@@ -389,18 +408,22 @@ function assignmentMap(items) {
|
|
|
389
408
|
}
|
|
390
409
|
|
|
391
410
|
/**
|
|
392
|
-
* Both sides
|
|
411
|
+
* Both sides have the same keyed shape (see KEYED_STRING_SHAPES).
|
|
393
412
|
* @param {unknown[]} before
|
|
394
413
|
* @param {unknown[]} after
|
|
395
414
|
* @returns {{ beforeMap: Map<string, { value: unknown, index: number }>, afterMap: Map<string, { value: unknown, index: number }> } | null}
|
|
396
415
|
*/
|
|
397
416
|
function assignmentMaps(before, after) {
|
|
398
|
-
const beforeMap = assignmentMap(before);
|
|
399
|
-
const afterMap = beforeMap && assignmentMap(after);
|
|
400
|
-
if (!beforeMap || !afterMap) return null;
|
|
401
417
|
const assigns = (item) => typeof item === 'string' && item.includes('=');
|
|
402
|
-
|
|
403
|
-
|
|
418
|
+
for (const { re, needsValue } of KEYED_STRING_SHAPES) {
|
|
419
|
+
const beforeMap = keyedStringMap(before, re);
|
|
420
|
+
const afterMap = beforeMap && keyedStringMap(after, re);
|
|
421
|
+
if (!beforeMap || !afterMap) continue;
|
|
422
|
+
if (beforeMap.size === 0 && afterMap.size === 0) continue;
|
|
423
|
+
if (needsValue && !before.some(assigns) && !after.some(assigns)) continue;
|
|
424
|
+
return { beforeMap, afterMap };
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
404
427
|
}
|
|
405
428
|
|
|
406
429
|
/**
|
package/src/packs/default.json
CHANGED
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/positions.js
CHANGED
|
@@ -3,7 +3,7 @@ import yaml from 'js-yaml';
|
|
|
3
3
|
|
|
4
4
|
import { isArmoredAgeFile } from './encrypted.js';
|
|
5
5
|
import { isEnvFilename, isIniFilename, stripJsonComments, yamlDocumentKeys } from './parser.js';
|
|
6
|
-
import { ASSIGNMENT_RE } from './differ.js';
|
|
6
|
+
import { ASSIGNMENT_RE, FLAG_RE } from './differ.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Where a config path lives in the source text (#142).
|
|
@@ -248,10 +248,13 @@ function itemByIdentity(items, quoted, arrayIdKey) {
|
|
|
248
248
|
if (String(id) === target) matches.add(item);
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
-
// A `KEY=VALUE` list (Compose `environment`, `labels`) is keyed by KEY
|
|
251
|
+
// A `KEY=VALUE` list (Compose `environment`, `labels`) is keyed by KEY, and
|
|
252
|
+
// a flag list (container `args`) by flag name.
|
|
252
253
|
if (matches.size === 0 && !arrayIdKey) {
|
|
253
254
|
for (const item of items) {
|
|
254
|
-
if (typeof item.value
|
|
255
|
+
if (typeof item.value !== 'string') continue;
|
|
256
|
+
const key = ASSIGNMENT_RE.exec(item.value)?.[1] ?? FLAG_RE.exec(item.value)?.[1];
|
|
257
|
+
if (key === target) matches.add(item);
|
|
255
258
|
}
|
|
256
259
|
}
|
|
257
260
|
return matches.size === 1 ? [...matches][0] : null;
|
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 }
|
|
24
|
-
//
|
|
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
|
-
|
|
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.
|
package/src/snapshot-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|