flecto 4.3.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 +82 -42
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/differ.js +35 -12
- package/src/positions.js +6 -3
- package/src/secrets.js +177 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ 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
|
+
|
|
35
|
+
## [4.4.0] - 2026-10-05
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Flag lists diff by flag name, so removing one arg is one removal**
|
|
40
|
+
([#238]). A real pull request that removed the first of eight container
|
|
41
|
+
`args` read as 7 `changed` and 1 `removed`, each pairing two unrelated flags.
|
|
42
|
+
It now reads as `removed args["--keytoolPath"]`. A list counts when every item
|
|
43
|
+
is `-x`, `--name` or `--name=value` with unique names; a repeated `--set`,
|
|
44
|
+
`["--port", "8080"]`, and `["-c", "echo hi"]` keep the position diff, and
|
|
45
|
+
`--no-array-id` turns it off. Paths change from `args[0]` to
|
|
46
|
+
`args["--name"]` for these lists. No built-in pack rule matches an `args`
|
|
47
|
+
index.
|
|
48
|
+
|
|
49
|
+
[#238]: https://github.com/myselfsiddharth/Flecto/issues/238
|
|
50
|
+
|
|
10
51
|
## [4.3.0] - 2026-10-05
|
|
11
52
|
|
|
12
53
|
### Fixed
|
|
@@ -315,6 +356,19 @@ for what was wrong.
|
|
|
315
356
|
untrusted-PR threat model. `--plugins` must be absolute paths. See
|
|
316
357
|
[docs/editor.md](docs/editor.md).
|
|
317
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
|
+
|
|
318
372
|
### Security
|
|
319
373
|
|
|
320
374
|
- **BREAKING: `snapshotRef` declared in `.flectorc` is refused** ([#121]). The
|
|
@@ -360,20 +414,6 @@ for what was wrong.
|
|
|
360
414
|
are cached and shared across every file in a run, and a `g` regex carries a
|
|
361
415
|
mutable `lastIndex` that `.test()` advances, so such a rule matched every
|
|
362
416
|
other value it saw.
|
|
363
|
-
### Added
|
|
364
|
-
|
|
365
|
-
- **`flecto-drift`: compare a declared config file against what is actually
|
|
366
|
-
running** ([#144]). A **separate binary**, deliberately: every other Flecto
|
|
367
|
-
command authenticates to nothing, and reading live state cannot keep that
|
|
368
|
-
promise, so it does not share an entry point with the tool that can. `flecto
|
|
369
|
-
ci` cannot reach it and installing Flecto does not enable it.
|
|
370
|
-
It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
|
|
371
|
-
and `aws`, which you have already authenticated, so Flecto inherits exactly
|
|
372
|
-
what those are entitled to. Read-only is structural: argv is built from a
|
|
373
|
-
fixed verb allowlist and nothing from the URI can reach it as a flag. Values
|
|
374
|
-
from a secret store are compared **by shape** (length and digest), never by
|
|
375
|
-
value, with no flag to change that; SSM is read without `--with-decryption`.
|
|
376
|
-
Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
|
|
377
417
|
|
|
378
418
|
### Fixed
|
|
379
419
|
|
|
@@ -489,6 +529,31 @@ for what was wrong.
|
|
|
489
529
|
publish recommendation are in
|
|
490
530
|
[`docs/ghsa-wq8m-fc3q-8m5x-2x.md`](docs/ghsa-wq8m-fc3q-8m5x-2x.md).
|
|
491
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
|
+
|
|
492
557
|
### Added
|
|
493
558
|
|
|
494
559
|
- **Inline suppressions in JSON** ([#158]). `.json` and `.jsonc` are parsed as
|
|
@@ -915,33 +980,6 @@ for what was wrong.
|
|
|
915
980
|
60,000 characters to fit GitHub's comment limit, which lands under one pipe
|
|
916
981
|
buffer.
|
|
917
982
|
|
|
918
|
-
### Security
|
|
919
|
-
|
|
920
|
-
- **Two denial-of-service vectors fixed, found while resuming the 3.0 security
|
|
921
|
-
review** ([#121]). (1) Secret detection (`src/secrets.js`), which runs on every
|
|
922
|
-
changed string value under the `default` pack, had two `O(n²)` regexes — the
|
|
923
|
-
PEM private-key and URL-credential patterns — so a single ~500 KB value in a
|
|
924
|
-
pull request could hang the CI job. Both are now linear; 1 MB scans in under a
|
|
925
|
-
second, and detection of real (including unterminated) keys is unchanged. (2) A
|
|
926
|
-
YAML alias bomb ("billion laughs") — a few hundred bytes of nested aliases that
|
|
927
|
-
`normalizeParsedValue` expanded into an exponentially large tree — now fails
|
|
928
|
-
fast against a node budget instead of exhausting memory. Regression tests for
|
|
929
|
-
both in `test/security.test.js`. The review's findings and its "checked, solid"
|
|
930
|
-
list are recorded in [docs/security-review.md](docs/security-review.md); a
|
|
931
|
-
residual limitation (attacker-supplied regexes in custom packs, which Node
|
|
932
|
-
cannot time out) is noted in [SECURITY.md](SECURITY.md).
|
|
933
|
-
|
|
934
|
-
- **Terraform plan JSON is refused by every command except `flecto plan`.**
|
|
935
|
-
Terraform's `before_sensitive` / `after_sensitive` redaction is applied only by
|
|
936
|
-
`flecto plan`; a plan file is ordinary JSON, so `ci`, `watch`, `compare`,
|
|
937
|
-
`report`, and snapshot writes read it as a plain config tree and printed the
|
|
938
|
-
values Terraform itself refuses to print. `--mask-secrets` was not a backstop —
|
|
939
|
-
it fires on the attribute *name*, and `user_data` does not match. Realistic
|
|
940
|
-
ways to hit it: `flecto ci "**/*.json"`, a committed `tfplan.json`, or
|
|
941
|
-
`.flectorc` `files` patterns that sweep JSON. Those commands now fail with a
|
|
942
|
-
pointer to `flecto plan`, mirroring the guard `flecto plan` already had in the
|
|
943
|
-
other direction. ([#113])
|
|
944
|
-
|
|
945
983
|
## [3.0.1] - 2026-08-07
|
|
946
984
|
|
|
947
985
|
### Security
|
|
@@ -1438,7 +1476,9 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
1438
1476
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
1439
1477
|
continuing with no policies.
|
|
1440
1478
|
|
|
1441
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.
|
|
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
|
|
1481
|
+
[4.4.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.3.0...v4.4.0
|
|
1442
1482
|
[4.3.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...v4.3.0
|
|
1443
1483
|
[4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
|
|
1444
1484
|
[4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
|
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.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.
|
|
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.
|
|
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.
|
|
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/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/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/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
|
|
120
|
-
* random tokens missed (worst at length 24, ~7%).
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
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);
|