flecto 4.1.0 → 4.2.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 +122 -1
- package/README.md +111 -505
- package/index.js +78 -8
- package/package.json +1 -1
- package/src/alerter.js +6 -1
- package/src/config.js +6 -2
- package/src/differ.js +46 -3
- package/src/parser.js +37 -0
- package/src/positions.js +7 -0
- package/src/secrets.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,125 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.2.0] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
The first npm release since 4.1.1. The `v4.1.2` tag moved the documented Action
|
|
13
|
+
pins but its npm publish failed (the version was never bumped), so the fixes
|
|
14
|
+
below reach npm for the first time here.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`--new-files added`: a pull request that adds a config file no longer has to
|
|
19
|
+
fail the gate.** `flecto ci --snapshot-ref` stopped with "Failed to resolve
|
|
20
|
+
snapshot baseline" whenever a target was not in the base commit, so any PR
|
|
21
|
+
that added a values file or a manifest matched by the gate's glob went red,
|
|
22
|
+
whatever `--fail-on` said. With `--new-files added` (Action input
|
|
23
|
+
`new-files: added`), such a file is reported as new, every key `added`, and
|
|
24
|
+
policies still run on it.
|
|
25
|
+
|
|
26
|
+
The default is unchanged and still fails closed, and the error now names the
|
|
27
|
+
flag. A renamed file looks exactly like a new one, and would turn every
|
|
28
|
+
`changed` into an `added`, which the Action's `policy,error` does not gate,
|
|
29
|
+
so this is opt-in and refused from `.flectorc`, as `snapshotRef` is.
|
|
30
|
+
|
|
31
|
+
Found by replaying Flecto over a real repository's infra history, where one
|
|
32
|
+
commit in twelve added a values file.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **`KEY=VALUE` lists diff by KEY, so one inserted variable is one addition**
|
|
37
|
+
([#226]). Compose writes `environment`, `labels` and `build.args` as a map or
|
|
38
|
+
as a list of `KEY=VALUE` strings, and means the same by both. The list form
|
|
39
|
+
diffed by position: a real pull request that inserted ten `OIDC_*` variables
|
|
40
|
+
read as 31 `changed` rows, each pairing two unrelated variables. It now reads
|
|
41
|
+
as 9 additions under `environment["OIDC_ISSUER_URL"]` and so on, and the
|
|
42
|
+
language server places diagnostics on the right line.
|
|
43
|
+
|
|
44
|
+
Paths for these lists change from `environment[3]` to `environment["KEY"]`.
|
|
45
|
+
`--no-array-id` restores index paths. A list counts only when every item is a
|
|
46
|
+
`KEY=VALUE` or bare `KEY` with unique keys and at least one `=`, so `command:
|
|
47
|
+
[python, app.py]` and `args: [--a=1]` keep their order-sensitive diff.
|
|
48
|
+
|
|
49
|
+
[#226]: https://github.com/myselfsiddharth/Flecto/issues/226
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **A connection string whose password is a reference is no longer reported as
|
|
54
|
+
a leaked credential** ([#225]). `postgres://app:$(DB_PASSWORD)@db/app`, the
|
|
55
|
+
Kubernetes form of env-var expansion, and `{{ .Values.x }}` Helm expressions
|
|
56
|
+
were flagged `url-credentials` (error), although `${DB_PASSWORD}` already
|
|
57
|
+
was not. A literal password in the same position is still caught. Found on
|
|
58
|
+
a real repository's ConfigMap during outreach.
|
|
59
|
+
|
|
60
|
+
- **The symlinked-baseline check never fired below the repository root.** It
|
|
61
|
+
asked `git ls-tree` for a root-relative path from inside the file's own
|
|
62
|
+
directory, so for `config/app.yaml` it looked up `config/config/app.yaml`,
|
|
63
|
+
found nothing, and let the baseline through as the link's target path. Only
|
|
64
|
+
files at the root were protected.
|
|
65
|
+
|
|
66
|
+
- **Pointing Flecto at a Helm chart says so, instead of reporting broken YAML**
|
|
67
|
+
([#210]). `flecto ci "helm/**/*.yaml"` is the first thing a Helm user tries,
|
|
68
|
+
and it failed with a syntax error pointing inside a chart template that is
|
|
69
|
+
perfectly valid — sending them to debug their chart rather than their command.
|
|
70
|
+
|
|
71
|
+
A YAML file that **fails to parse** and carries Go template delimiters now
|
|
72
|
+
reports what it is and the two ways to read it: render the chart, or point at
|
|
73
|
+
`values.yaml`. The check runs only after a parse failure, so valid YAML
|
|
74
|
+
holding `{{ ... }}` in a string — a Prometheus alert rule, say — is untouched.
|
|
75
|
+
|
|
76
|
+
Found by running Flecto against real repositories instead of our own fixtures.
|
|
77
|
+
|
|
78
|
+
- **`flecto ci --format human` now explains itself** ([#211], fixed by
|
|
79
|
+
[@DYNOSuprovo](https://github.com/DYNOSuprovo) in [#212]). `human` is the
|
|
80
|
+
default for `plan` and `compare` and what `watch` prints, so reaching for it on
|
|
81
|
+
`ci` is the natural mistake — especially when running `ci` locally to see what
|
|
82
|
+
the gate will say. The old error listed the valid values without saying that
|
|
83
|
+
`human` was deliberately excluded, so it read as a typo or an inconsistency. It
|
|
84
|
+
now names the reason and both ways out: `pr-comment` to read a run, `json` to
|
|
85
|
+
parse it. An actual typo still gets the list.
|
|
86
|
+
|
|
87
|
+
- **A command killed by a signal is a failed delivery, not a success**
|
|
88
|
+
([#185], fixed by [@nova-loop](https://github.com/nova-loop) in [#214]).
|
|
89
|
+
`watch --command` checked only the exit code, which is `null` when a signal
|
|
90
|
+
ends the process, so an OOM-killed or timed-out hook counted as delivered:
|
|
91
|
+
`--on-alert-failure` never fired and at-least-once deliveries were marked done.
|
|
92
|
+
The warning now names the signal.
|
|
93
|
+
|
|
94
|
+
[#210]: https://github.com/myselfsiddharth/Flecto/issues/210
|
|
95
|
+
[#185]: https://github.com/myselfsiddharth/Flecto/issues/185
|
|
96
|
+
[#211]: https://github.com/myselfsiddharth/Flecto/issues/211
|
|
97
|
+
[#212]: https://github.com/myselfsiddharth/Flecto/pull/212
|
|
98
|
+
[#214]: https://github.com/myselfsiddharth/Flecto/pull/214
|
|
99
|
+
[#225]: https://github.com/myselfsiddharth/Flecto/issues/225
|
|
100
|
+
|
|
101
|
+
## [4.1.1] - 2026-09-29
|
|
102
|
+
|
|
103
|
+
### Fixed
|
|
104
|
+
|
|
105
|
+
- **The root `action.yml` description was too long to list on the Marketplace.**
|
|
106
|
+
GitHub rejects a listing whose description is 125 characters or more; 4.1.0
|
|
107
|
+
shipped 194. It is now 119, and a test pins the limit — the constraint is not
|
|
108
|
+
in GitHub's metadata-syntax documentation and the publish form only reports it
|
|
109
|
+
at publish time, by which point the release is already cut.
|
|
110
|
+
|
|
111
|
+
The rest of the pitch — that Flecto never runs `terraform`, `helm`, or `sops`,
|
|
112
|
+
and never decrypts — lives in the README, which has room for it.
|
|
113
|
+
|
|
114
|
+
- **Documented Action pins moved from `@v4.0.0` to `@v4.1.0`.** Tags are
|
|
115
|
+
immutable, and the fix that stopped the bundled Actions installing the pre-4.0
|
|
116
|
+
CLI shipped *in* 4.1.0 — so while the examples said `@v4.0.0` they pointed at a
|
|
117
|
+
tag whose `flecto-ci` and `flecto-pr-risk` still run `flecto@3`, the line
|
|
118
|
+
exposed to the baseline-shadowing bypass 4.0 closed.
|
|
119
|
+
|
|
120
|
+
**If you pinned `@v4.0.0` following an earlier example, repin to `@v4.1.0`.**
|
|
121
|
+
The CLI at `flecto@4.0.0` is fine; it is only the bundled Action metadata at
|
|
122
|
+
that tag that is not. [docs/ci.md](docs/ci.md#pinning) now warns against it.
|
|
123
|
+
|
|
124
|
+
[RELEASE.md](RELEASE.md) gained a step that repins the documented Actions as
|
|
125
|
+
part of every release. That step is the real fix — the bug was not the pin, it
|
|
126
|
+
was that nothing tied the documented pin to the release that fixed what it
|
|
127
|
+
pointed at.
|
|
128
|
+
|
|
10
129
|
## [4.1.0] - 2026-09-29
|
|
11
130
|
|
|
12
131
|
### Added
|
|
@@ -1292,7 +1411,9 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
1292
1411
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
1293
1412
|
continuing with no policies.
|
|
1294
1413
|
|
|
1295
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.
|
|
1414
|
+
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v4.2.0...HEAD
|
|
1415
|
+
[4.2.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.1...v4.2.0
|
|
1416
|
+
[4.1.1]: https://github.com/myselfsiddharth/Flecto/compare/v4.1.0...v4.1.1
|
|
1296
1417
|
[4.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v4.0.0...v4.1.0
|
|
1297
1418
|
[4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
|
|
1298
1419
|
[3.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...v3.1.0
|
package/README.md
CHANGED
|
@@ -1,192 +1,103 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/assets/flecto-hero.png" alt="Flecto — semantic config watcher" width="920"/>
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
1
|
<h1 align="center">Flecto</h1>
|
|
6
2
|
|
|
7
3
|
<p align="center">
|
|
8
|
-
<strong>
|
|
4
|
+
<strong>Flecto reads your Terraform plan and Kubernetes changes and posts a
|
|
5
|
+
plain-English risk summary on every pull request, blocking the dangerous ones.</strong>
|
|
9
6
|
</p>
|
|
10
7
|
|
|
11
8
|
<p align="center">
|
|
9
|
+
<a href="https://github.com/marketplace/actions/flecto-pr-risk"><img alt="GitHub Marketplace" src="https://img.shields.io/badge/marketplace-Flecto%20PR%20Risk-34d399?style=flat-square&logo=github&labelColor=0b1220"/></a>
|
|
12
10
|
<a href="https://www.npmjs.com/package/flecto"><img alt="npm" src="https://img.shields.io/npm/v/flecto?style=flat-square&color=34d399&labelColor=0b1220"/></a>
|
|
13
11
|
<a href="https://github.com/myselfsiddharth/Flecto/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/myselfsiddharth/Flecto/ci.yml?branch=main&style=flat-square&label=CI&labelColor=0b1220"/></a>
|
|
14
12
|
<a href="LICENSE"><img alt="MIT" src="https://img.shields.io/badge/license-MIT-8fa3bf?style=flat-square&labelColor=0b1220"/></a>
|
|
15
13
|
<a href="#documentation"><img alt="Docs" src="https://img.shields.io/badge/docs-read-34d399?style=flat-square&labelColor=0b1220"/></a>
|
|
16
14
|
</p>
|
|
17
15
|
|
|
18
|
-
<p align="center">
|
|
19
|
-
<img src="docs/assets/demo-watch.svg" alt="Flecto reporting semantic config changes in the terminal" width="920"/>
|
|
20
|
-
</p>
|
|
21
|
-
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
Config drives the parts of a system that break loudest: connection pools, feature
|
|
25
|
-
flags, TLS, retries, secrets. But we still review it as text — so a reordered key
|
|
26
|
-
looks identical to a doubled pool size, and `debug: true` slips through in a
|
|
27
|
-
40-line formatting diff.
|
|
28
|
-
|
|
29
|
-
Flecto reads config as structure, not lines. It tells you what changed in plain
|
|
30
|
-
English, flags what looks risky, and gives you an exit code to gate on.
|
|
31
|
-
|
|
32
|
-
| Reviewing config without Flecto | With Flecto |
|
|
33
|
-
|---|---|
|
|
34
|
-
| `+ 40 lines of YAML noise` | `~ database.pool_size: 5 → 20` |
|
|
35
|
-
| Hope someone notices `debug: true` | Policy finding → build fails |
|
|
36
|
-
| "Something in `.env` changed" | The exact keys, with secrets masked |
|
|
37
|
-
|
|
38
|
-
The same engine reads whatever your change actually lives in:
|
|
39
|
-
|
|
40
|
-
| You are reviewing | Flecto reads |
|
|
41
|
-
|---|---|
|
|
42
|
-
| App config — YAML, JSON, TOML, INI, dotenv | the files directly |
|
|
43
|
-
| A Terraform change | `terraform show -json` output, via `flecto plan` |
|
|
44
|
-
| A Kubernetes change | rendered manifests from `helm`, `kustomize`, or anything else |
|
|
45
|
-
| A SOPS-encrypted file | its structure and recipients — **never its plaintext** |
|
|
46
|
-
|
|
47
|
-
It never invokes `terraform`, `helm`, `kustomize`, `sops`, or `age`, so nothing
|
|
48
|
-
extra has to exist on the CI runner.
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## Install
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
npm install -g flecto
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Requires **Node.js 20.19.0+**. Verify:
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
flecto --version
|
|
62
|
-
flecto doctor
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Prefer not to install globally? Every example below works with
|
|
66
|
-
`npx --yes flecto@4` instead of `flecto`.
|
|
67
|
-
|
|
68
16
|
---
|
|
69
17
|
|
|
70
|
-
##
|
|
71
|
-
|
|
72
|
-
A complete walkthrough, start to finish. Copy-paste it anywhere.
|
|
73
|
-
|
|
74
|
-
**1. Create a config file to track.**
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
mkdir flecto-demo && cd flecto-demo && mkdir config
|
|
78
|
-
cat > config/prod.yaml <<'EOF'
|
|
79
|
-
database:
|
|
80
|
-
host: db.internal
|
|
81
|
-
pool_size: 5
|
|
82
|
-
ssl: true
|
|
83
|
-
logging:
|
|
84
|
-
level: info
|
|
85
|
-
debug: false
|
|
86
|
-
EOF
|
|
87
|
-
```
|
|
18
|
+
## What lands on the pull request
|
|
88
19
|
|
|
89
|
-
|
|
20
|
+
<p align="center">
|
|
21
|
+
<img src="docs/assets/flecto-pr-comment.png" alt="Flecto's comment on a pull request: check failing, six policy errors naming an IAM wildcard, a disabled S3 public-access block, and security group ingress opened to 0.0.0.0/0" width="960"/>
|
|
22
|
+
</p>
|
|
90
23
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
24
|
+
That is a real comment on a
|
|
25
|
+
**[real pull request](https://github.com/myselfsiddharth/flecto-example-terraform/pull/1)**
|
|
26
|
+
you can open right now. The PR says it is about partner access and changes eight
|
|
27
|
+
lines; it opens the web tier to the internet, turns off the bucket's
|
|
28
|
+
public-access protection, and widens an IAM policy to `s3:*` on `*`.
|
|
94
29
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
```
|
|
30
|
+
One sticky comment, updated in place on every push. Exit code `1`, so the build
|
|
31
|
+
fails before the change ships.
|
|
98
32
|
|
|
99
|
-
**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
cat > config/prod.yaml <<'EOF'
|
|
103
|
-
database:
|
|
104
|
-
host: db.internal
|
|
105
|
-
pool_size: 20
|
|
106
|
-
ssl: true
|
|
107
|
-
logging:
|
|
108
|
-
level: info
|
|
109
|
-
debug: true
|
|
110
|
-
EOF
|
|
111
|
-
```
|
|
33
|
+
**[The same gate on an ordinary change](https://github.com/myselfsiddharth/flecto-example-terraform/pull/2)**
|
|
34
|
+
reports *no findings* and passes. That matters as much: a check that fires on
|
|
35
|
+
everything gets uninstalled in a week.
|
|
112
36
|
|
|
113
|
-
|
|
37
|
+
<details>
|
|
38
|
+
<summary>The same report as text, and what a plan with existing state adds</summary>
|
|
114
39
|
|
|
115
|
-
|
|
116
|
-
flecto watch config/prod.yaml --diff
|
|
117
|
-
```
|
|
40
|
+
The comment above, as `flecto plan` prints it:
|
|
118
41
|
|
|
119
42
|
```
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
~ logging.debug: false → true
|
|
123
|
-
```
|
|
43
|
+
❌ Check failing — 30 changes in 1 file — 0 changed, 30 added, 0 removed.
|
|
44
|
+
Policy: 6 errors.
|
|
124
45
|
|
|
125
|
-
|
|
46
|
+
aws_security_group.web.ingress[0].cidr_blocks[0]
|
|
47
|
+
terraform-security-group-open-ingress
|
|
48
|
+
Security group ingress will accept traffic from the whole internet
|
|
49
|
+
(0.0.0.0/0). Restrict the source to a known CIDR, a prefix list, or
|
|
50
|
+
another security group.
|
|
126
51
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
52
|
+
aws_s3_bucket_public_access_block.uploads.block_public_acls (+3 more)
|
|
53
|
+
terraform-s3-public-access-block-disabled
|
|
54
|
+
S3 public access block is being turned off or removed.
|
|
130
55
|
|
|
56
|
+
aws_iam_role_policy.app.policy
|
|
57
|
+
terraform-iam-wildcard
|
|
58
|
+
IAM policy grants a wildcard action or resource ("*").
|
|
131
59
|
```
|
|
132
|
-
::warning title=flecto changed::database.pool_size
|
|
133
|
-
::warning title=flecto changed::logging.debug
|
|
134
|
-
::warning title=flecto policy pool-size-jump [default]::database.pool_size: Pool size increased from 5 to 20 (>=2x).
|
|
135
|
-
::error title=flecto policy dangerous-toggle-enabled [default]::logging.debug: Potentially dangerous toggle enabled.
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
Exit code `1`. In CI, that's a failed build — before the change ships.
|
|
139
60
|
|
|
140
|
-
|
|
61
|
+
The example repository has no Terraform state, so every resource shows as
|
|
62
|
+
`create`. With existing state, a plan that replaces a database also reports:
|
|
141
63
|
|
|
142
|
-
```bash
|
|
143
|
-
flecto watch config/prod.yaml
|
|
144
64
|
```
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
[18:24:48] /path/to/flecto-demo/config/prod.yaml — 2 changes
|
|
151
|
-
~ database.pool_size: 5 → 20
|
|
152
|
-
~ logging.debug: false → true
|
|
153
|
-
! policy(warn) [default] database.pool_size: Pool size increased from 5 to 20 (>=2x).
|
|
154
|
-
! policy(error) [default] logging.debug: Potentially dangerous toggle enabled.
|
|
65
|
+
aws_db_instance.main.#action
|
|
66
|
+
terraform-stateful-resource-destroyed
|
|
67
|
+
Terraform will destroy a stateful resource. Its data does not survive.
|
|
68
|
+
Take a final snapshot, or add a prevent_destroy lifecycle block, before
|
|
69
|
+
applying.
|
|
155
70
|
```
|
|
156
71
|
|
|
157
|
-
|
|
72
|
+
</details>
|
|
158
73
|
|
|
159
74
|
---
|
|
160
75
|
|
|
161
|
-
##
|
|
76
|
+
## Add it in 60 seconds
|
|
162
77
|
|
|
163
|
-
|
|
78
|
+
Flecto PR Risk is on the
|
|
79
|
+
[GitHub Marketplace](https://github.com/marketplace/actions/flecto-pr-risk), so
|
|
80
|
+
`myselfsiddharth/Flecto@v4.1.2` is the whole reference.
|
|
164
81
|
|
|
165
|
-
|
|
166
|
-
the pull request:
|
|
82
|
+
**Terraform** — point it at the plan JSON:
|
|
167
83
|
|
|
168
84
|
```yaml
|
|
169
85
|
permissions:
|
|
170
86
|
contents: read
|
|
87
|
+
pull-requests: write
|
|
171
88
|
|
|
172
89
|
steps:
|
|
173
90
|
- uses: actions/checkout@v7
|
|
91
|
+
- run: |
|
|
92
|
+
terraform plan -out=tf.plan
|
|
93
|
+
terraform show -json tf.plan > plan.json
|
|
94
|
+
- uses: myselfsiddharth/Flecto@v4.1.2
|
|
174
95
|
with:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
with:
|
|
178
|
-
targets: config/**/*.{yaml,yml,json,toml,ini}
|
|
179
|
-
snapshot-ref: HEAD~1
|
|
96
|
+
terraform-plan: plan.json
|
|
97
|
+
fail-on: error
|
|
180
98
|
```
|
|
181
99
|
|
|
182
|
-
|
|
183
|
-
policy findings as markdown and, when you opt in with `--pr-comment-post` inside
|
|
184
|
-
a GitHub PR run, keeps **one** sticky comment up to date instead of adding a new
|
|
185
|
-
one per push. Without that flag it just prints the markdown, so it can't post
|
|
186
|
-
from your laptop.
|
|
187
|
-
|
|
188
|
-
The `flecto-pr-risk` Action is that, packaged — the whole adoption is one
|
|
189
|
-
`uses:`, with the baseline resolved from the pull request rather than `HEAD~1`:
|
|
100
|
+
**Kubernetes** — render your manifests, then diff them against the PR's base:
|
|
190
101
|
|
|
191
102
|
```yaml
|
|
192
103
|
permissions:
|
|
@@ -197,324 +108,64 @@ steps:
|
|
|
197
108
|
- uses: actions/checkout@v7
|
|
198
109
|
with:
|
|
199
110
|
fetch-depth: 0
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
A fork's pull request gets a read-only token, so the comment is skipped with a
|
|
207
|
-
warning there — the check itself still runs and still fails on risky changes.
|
|
208
|
-
|
|
209
|
-
Works on any CI runner — it's a plain CLI with meaningful exit codes.
|
|
210
|
-
→ **[CI guide](docs/ci.md)**
|
|
211
|
-
|
|
212
|
-
### Trigger automation on change
|
|
213
|
-
|
|
214
|
-
Restart a service, reload a process, or notify an endpoint whenever config moves:
|
|
215
|
-
|
|
216
|
-
```bash
|
|
217
|
-
flecto watch .env --command "docker-compose restart app"
|
|
218
|
-
|
|
219
|
-
flecto watch config/prod.yaml \
|
|
220
|
-
--webhook https://hooks.example.com/notify \
|
|
221
|
-
--delivery-mode at-least-once
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Changes arrive as a versioned JSON envelope, and `at-least-once` persists and
|
|
225
|
-
retries failed deliveries.
|
|
226
|
-
|
|
227
|
-
Posting straight to chat needs no receiver of your own — `--webhook-format`
|
|
228
|
-
shapes the body for Slack, Discord, or Teams, colored by the highest policy
|
|
229
|
-
severity:
|
|
230
|
-
|
|
231
|
-
```bash
|
|
232
|
-
flecto watch config/prod.yaml \
|
|
233
|
-
--webhook "https://hooks.slack.com/services/T000/B000/XXXX" \
|
|
234
|
-
--webhook-format slack
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
→ **[Webhooks and commands](docs/webhooks.md)**
|
|
238
|
-
|
|
239
|
-
### Compare two environments
|
|
240
|
-
|
|
241
|
-
"Works in staging, fails in prod" is usually one key apart:
|
|
242
|
-
|
|
243
|
-
```bash
|
|
244
|
-
flecto compare config/prod.yaml config/staging.yaml
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
```
|
|
248
|
-
"+" exists only in the compared file, "-" only in the baseline, "~" differs
|
|
249
|
-
/path/to/config/staging.yaml — 2 changes from /path/to/config/prod.yaml:
|
|
250
|
-
- only_in_prod: true
|
|
251
|
-
~ database.pool_size: 5 → 20
|
|
252
|
-
! policy(warn) [default] database.pool_size: Pool size increased from 5 to 20 (>=2x).
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
The first file is the baseline, the files don't have to share a format, and
|
|
256
|
-
`--format json` gives you the same output `flecto ci` produces.
|
|
257
|
-
→ **[CLI reference](docs/cli-reference.md#flecto-compare-filea-fileb)**
|
|
258
|
-
|
|
259
|
-
### Track drift over time
|
|
260
|
-
|
|
261
|
-
```bash
|
|
262
|
-
flecto history config/prod.yaml --limit 10
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
Snapshots stay on your machine in `.flecto-snapshots/`. Nothing is uploaded and
|
|
266
|
-
no account is required. To read the same history on a CI runner, save it to the
|
|
267
|
-
git-tracked store instead — `--snapshot-store shared` writes a committable
|
|
268
|
-
`.flecto/snapshots/`, masking secret-like values into digests as it goes.
|
|
269
|
-
→ **[CLI reference](docs/cli-reference.md#flecto-history-files)**
|
|
270
|
-
|
|
271
|
-
### Share what changed before the incident
|
|
272
|
-
|
|
273
|
-
```bash
|
|
274
|
-
flecto report --limit 20 --mask-secrets --output drift.html
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
One HTML file from that same local history: a timeline per file, every change
|
|
278
|
-
with its UTC timestamp, and policy findings grouped by severity. Fully
|
|
279
|
-
self-contained — inline styles, no fonts, no CDN scripts, no analytics — so you
|
|
280
|
-
can attach it to an incident thread and it renders offline. No server and no
|
|
281
|
-
account, same as everything else here.
|
|
282
|
-
→ **[CLI reference](docs/cli-reference.md#flecto-report-files)**
|
|
283
|
-
|
|
284
|
-
### Review a Kubernetes change before it reaches a cluster
|
|
285
|
-
|
|
286
|
-
ArgoCD, Flux, and `helm diff` compare the cluster to the repo — which needs a
|
|
287
|
-
cluster, and an apply that already happened. Flecto compares the manifests *this
|
|
288
|
-
pull request would produce* against the ones `main` produces:
|
|
289
|
-
|
|
290
|
-
```bash
|
|
291
|
-
helm template api ./charts/api -f values/prod.yaml > /tmp/head.yaml
|
|
292
|
-
flecto compare /tmp/base.yaml /tmp/head.yaml --policies kubernetes --fail-on error
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
```
|
|
296
|
-
~ Service/prod/api.spec.type: "ClusterIP" → "LoadBalancer"
|
|
297
|
-
! policy(error) [kubernetes] Service type is LoadBalancer, which exposes the
|
|
298
|
-
workload outside the cluster. Confirm the exposure is intended.
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
Multi-document YAML is keyed by `kind/namespace/name`, so findings name the
|
|
302
|
-
resource. Flecto never runs `helm` or `kustomize` — you render, it diffs, so any
|
|
303
|
-
renderer works and no binary is needed in CI.
|
|
304
|
-
→ **[Kubernetes](docs/kubernetes.md)**
|
|
305
|
-
|
|
306
|
-
### Read a Terraform plan in plain English
|
|
307
|
-
|
|
308
|
-
`terraform plan` output is precise and long. Flecto turns it into the handful of
|
|
309
|
-
lines a reviewer actually needs to argue about:
|
|
310
|
-
|
|
311
|
-
```bash
|
|
312
|
-
terraform show -json plan.tfplan > plan.json
|
|
313
|
-
flecto plan plan.json --fail-on error
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
```
|
|
317
|
-
plan.json — plan format 1.2
|
|
318
|
-
Plan: 0 to add, 1 to change, 0 to destroy, 1 to replace.
|
|
319
|
-
~ aws_security_group.web.ingress[0].cidr_blocks[0]: "10.0.0.0/8" → "0.0.0.0/0"
|
|
320
|
-
- aws_db_instance.main.#action: "replace" [terraform will destroy and recreate aws_db_instance.main]
|
|
321
|
-
~ aws_db_instance.main.password: "(sensitive value)" → "(sensitive value)" [sensitive]
|
|
322
|
-
! policy(error) [terraform] …cidr_blocks[0]: Security group ingress will accept
|
|
323
|
-
traffic from the whole internet (0.0.0.0/0). Restrict the source to a known CIDR…
|
|
324
|
-
! policy(error) [terraform] …#action: Terraform will destroy a stateful resource.
|
|
325
|
-
Its data does not survive. Take a final snapshot, or add a prevent_destroy…
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
A **replace reads as a removal**, not a benign update — a recreated database
|
|
329
|
-
should never look like a config tweak. Values Terraform marks sensitive are
|
|
330
|
-
redacted during parsing, before the policy engine or any formatter sees them, and
|
|
331
|
-
`after_unknown` renders as `(known after apply)` rather than `null`.
|
|
332
|
-
|
|
333
|
-
**Flecto never runs `terraform`** — you produce the JSON, it reads it, so nothing
|
|
334
|
-
extra has to exist on the CI runner.
|
|
335
|
-
→ **[Terraform plans](docs/terraform.md)**
|
|
336
|
-
|
|
337
|
-
### Encode your own rules
|
|
338
|
-
|
|
339
|
-
Beyond the built-in packs, write rules as declarative JSON or YAML — no code:
|
|
340
|
-
|
|
341
|
-
```json
|
|
342
|
-
{
|
|
343
|
-
"id": "risky-feature-enable",
|
|
344
|
-
"severity": "error",
|
|
345
|
-
"allOf": [
|
|
346
|
-
{ "match": { "pathPrefix": "features." } },
|
|
347
|
-
{ "afterTruthy": true }
|
|
348
|
-
]
|
|
349
|
-
}
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
For anything a predicate can't express, a local ESM plugin exporting
|
|
353
|
-
`evaluate(changes, ctx)` gets the full change set.
|
|
354
|
-
→ **[Writing policy packs](docs/policy-packs.md)** · **[Plugins](docs/plugins.md)**
|
|
355
|
-
|
|
356
|
-
### Cut the noise
|
|
357
|
-
|
|
358
|
-
```bash
|
|
359
|
-
flecto watch config/prod.yaml --ignore "updated_at,**.meta.timestamp"
|
|
111
|
+
- run: helm template ./chart > rendered.yaml
|
|
112
|
+
- uses: myselfsiddharth/Flecto@v4.1.2
|
|
113
|
+
with:
|
|
114
|
+
targets: rendered.yaml
|
|
115
|
+
policies: kubernetes
|
|
360
116
|
```
|
|
361
117
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
→ **[Configuration](docs/configuration.md)**
|
|
118
|
+
Flecto **never invokes** `terraform`, `helm`, `kustomize`, `sops`, or `age`, so
|
|
119
|
+
nothing extra has to exist on the runner. → **[CI guide](docs/ci.md)**
|
|
365
120
|
|
|
366
121
|
---
|
|
367
122
|
|
|
368
|
-
##
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
Pass files or glob patterns, quoted so your shell doesn't expand them first — a
|
|
387
|
-
bare directory is not a valid target.
|
|
388
|
-
|
|
389
|
-
A local `policies/<id>.json` overrides the built-in pack of the same id, and
|
|
390
|
-
`severityRemap` raises or silences individual rules per profile without forking
|
|
391
|
-
anything. → **[Policy packs](docs/policy-packs.md)**
|
|
392
|
-
|
|
393
|
-
Community packs ship on npm as `flecto-pack-<id>` packages — a package name and
|
|
394
|
-
one declarative JSON file, nothing else:
|
|
395
|
-
|
|
396
|
-
```bash
|
|
397
|
-
npm install --save-dev flecto-pack-deployment-safety
|
|
398
|
-
flecto policies add deployment-safety
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
`policies add` validates the pack, writes it to `policies/deployment-safety.json`,
|
|
402
|
-
and runs no code from the package. →
|
|
403
|
-
**[Installing community packs](docs/policy-packs.md#installing-a-community-pack)**
|
|
123
|
+
## What it catches
|
|
124
|
+
|
|
125
|
+
- **A database about to be destroyed** — `terraform plan` says `replace`, the
|
|
126
|
+
diff says the data does not survive
|
|
127
|
+
- **Ingress widened to the world** — `10.0.0.0/8` → `0.0.0.0/0` on a security
|
|
128
|
+
group
|
|
129
|
+
- **An IAM policy gone wildcard** — `Action: "*"` where it used to be scoped
|
|
130
|
+
- **S3 public-access protection switched off** — the block removed, not just
|
|
131
|
+
loosened
|
|
132
|
+
- **A container turned privileged**, or `runAsNonRoot` quietly weakened
|
|
133
|
+
- **Resource limits removed** from a Deployment, so one pod can starve a node
|
|
134
|
+
- **An image tag unpinned** — `:1.4.0` → `:latest`
|
|
135
|
+
- **A new recipient on a SOPS file** — someone who could not decrypt it now can
|
|
136
|
+
- **`debug: true` in a 40-line formatting diff**, or a connection pool
|
|
137
|
+
quadrupled
|
|
138
|
+
|
|
139
|
+
Rules live in [policy packs](docs/policy-packs.md) you can extend, remap, or
|
|
140
|
+
replace.
|
|
404
141
|
|
|
405
142
|
---
|
|
406
143
|
|
|
407
|
-
##
|
|
144
|
+
## Also works as
|
|
408
145
|
|
|
409
|
-
|
|
|
146
|
+
| | |
|
|
410
147
|
|---|---|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
|
|
|
414
|
-
|
|
|
415
|
-
|
|
|
416
|
-
|
|
|
417
|
-
|
|
418
|
-
`.json` accepts comments and trailing commas, so `tsconfig.json`,
|
|
419
|
-
`.vscode/settings.json`, `jsconfig.json`, and `devcontainer.json` are read as
|
|
420
|
-
written. →
|
|
421
|
-
**[JSON with comments](docs/configuration.md#json-with-comments)**
|
|
422
|
-
|
|
423
|
-
Terraform plan JSON (`terraform show -json`) is read by **`flecto plan`**, which
|
|
424
|
-
applies Terraform's own sensitivity marking. Point `plan` at it rather than `ci`
|
|
425
|
-
or `watch` — those treat it as ordinary JSON and will print values Terraform
|
|
426
|
-
marks sensitive ([#113](https://github.com/myselfsiddharth/Flecto/issues/113)).
|
|
427
|
-
|
|
428
|
-
Multi-document YAML (`---`-separated, the usual shape of a Kubernetes manifest)
|
|
429
|
-
is supported. Each document is diffed under its own key — `kind/name` for
|
|
430
|
-
Kubernetes-shaped documents, so a document inserted at the top of the file does
|
|
431
|
-
not renumber every other path. →
|
|
432
|
-
**[Multi-document YAML](docs/configuration.md#multi-document-yaml)**
|
|
433
|
-
|
|
434
|
-
---
|
|
435
|
-
|
|
436
|
-
## Encrypted files
|
|
437
|
-
|
|
438
|
-
A `sops`- or age-encrypted file is detected from its **contents**, and diffed
|
|
439
|
-
structurally:
|
|
440
|
-
|
|
441
|
-
```
|
|
442
|
-
+ cache: {"ttl_seconds":300}
|
|
443
|
-
~ database.password: <encrypted value changed>
|
|
444
|
-
+ sops.age.age1exampleexample…: {"recipient":"age1exampleexample…","enc":"<encrypted value>"}
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
You get keys added and removed, which encrypted values moved, and — the useful
|
|
448
|
-
part — who can decrypt the file. A recipient added is a genuine security event
|
|
449
|
-
and the `sops` pack raises it as one.
|
|
450
|
-
|
|
451
|
-
**Flecto never decrypts.** It never shells out to `sops` or `age`, never reads a
|
|
452
|
-
key file or agent socket, and never prints ciphertext — not even without
|
|
453
|
-
`--mask-secrets`. Ciphertext is replaced with an opaque sentinel in the parser,
|
|
454
|
-
so no diff, snapshot, webhook, or report can carry it. →
|
|
455
|
-
**[Encrypted files](docs/encrypted-files.md)**
|
|
148
|
+
| **A CLI** | `flecto ci`, `flecto plan`, `flecto compare` — plain exit codes, any runner → **[CLI reference](docs/cli-reference.md)** |
|
|
149
|
+
| **A file watcher** | `flecto watch` reports changes as you edit → **[Getting started](docs/getting-started.md)** |
|
|
150
|
+
| **A webhook / command trigger** | Restart a service or notify an endpoint on change → **[Webhooks](docs/webhooks.md)** |
|
|
151
|
+
| **An MCP server** | Read-only `diff`, `check`, `explain` tools for agents → **[MCP](docs/mcp.md)** |
|
|
152
|
+
| **An editor language server** | `flecto lsp` — diagnostics while you type → **[Editor](docs/editor.md)** |
|
|
153
|
+
| **A drift detector** | `flecto-drift` compares declared config against what is running → **[Drift](docs/drift.md)** |
|
|
456
154
|
|
|
457
155
|
---
|
|
458
156
|
|
|
459
|
-
##
|
|
460
|
-
|
|
461
|
-
Most teams commit a `.flectorc` so local runs and CI agree:
|
|
462
|
-
|
|
463
|
-
```bash
|
|
464
|
-
flecto init
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
```json
|
|
468
|
-
{
|
|
469
|
-
"defaults": {
|
|
470
|
-
"policies": ["default"],
|
|
471
|
-
"ignore": ["**.updated_at"]
|
|
472
|
-
},
|
|
473
|
-
"profiles": {
|
|
474
|
-
"dev": { "mode": "verbose" },
|
|
475
|
-
"ci": { "failOn": "policy,error" },
|
|
476
|
-
"prod": {
|
|
477
|
-
"policies": ["default", "strict-prod"],
|
|
478
|
-
"severityRemap": { "pool-size-jump": "error" },
|
|
479
|
-
"maskSecrets": true
|
|
480
|
-
}
|
|
481
|
-
},
|
|
482
|
-
"files": ["config/**/*.{yaml,yml,json,toml,ini}", ".env"]
|
|
483
|
-
}
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
```bash
|
|
487
|
-
flecto watch --profile dev
|
|
488
|
-
flecto ci --profile ci
|
|
489
|
-
```
|
|
157
|
+
## Stability
|
|
490
158
|
|
|
491
|
-
|
|
492
|
-
|
|
159
|
+
Flecto runs inside your merge path. The **JSON envelope**
|
|
160
|
+
(`schema_version: "2.0"`), **exit codes** (`0` clean, `1` failed — it fails
|
|
161
|
+
closed), **`.flectorc`** keys, and **command and flag names** follow
|
|
162
|
+
[semver](https://semver.org/), and no breaking change to them ships without a
|
|
163
|
+
minor release that warns first. The one exception is a security fix.
|
|
493
164
|
|
|
494
|
-
|
|
165
|
+
Terminal output, message wording, and anything under `src/` are deliberately
|
|
166
|
+
**not** stable — parse `--format json`.
|
|
495
167
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
| Command | What it does |
|
|
499
|
-
|---|---|
|
|
500
|
-
| `flecto watch [files...]` | Watch for changes and print them as they happen |
|
|
501
|
-
| `flecto watch --snapshot` | Save the current state as a baseline |
|
|
502
|
-
| `flecto watch --diff` | Compare against the baseline and exit |
|
|
503
|
-
| `flecto ci [files...]` | One-shot check with a gate-able exit code |
|
|
504
|
-
| `flecto explain [files...]` | Advisory, model-generated narration of the masked diff (opt-in, your own key) |
|
|
505
|
-
| `flecto compare <fileA> <fileB>` | Diff two files against each other (`fileA` is the baseline) |
|
|
506
|
-
| `flecto plan <planFiles...>` | Review `terraform show -json` output and gate on it |
|
|
507
|
-
| `flecto history [files...]` | Summarize drift across local snapshots |
|
|
508
|
-
| `flecto report [files...]` | Render that history as a self-contained HTML file |
|
|
509
|
-
| `flecto policies add <name>` | Install a pack from an `flecto-pack-*` npm package |
|
|
510
|
-
| `flecto policies list` | List available policy packs |
|
|
511
|
-
| `flecto policies test <dir>` | Assert pack and plugin findings from fixtures |
|
|
512
|
-
| `flecto init` | Create a `.flectorc` from detected stack signals |
|
|
513
|
-
| `flecto mcp` | Serve read-only diff/check/explain tools to an agent over MCP |
|
|
514
|
-
| `flecto lsp` | Show findings and changes as diagnostics while a config file is edited |
|
|
515
|
-
| `flecto doctor` | Check setup, config, and environment |
|
|
516
|
-
|
|
517
|
-
→ **[Every flag, every command](docs/cli-reference.md)**
|
|
168
|
+
→ **[Full stability policy](docs/stability.md)**
|
|
518
169
|
|
|
519
170
|
---
|
|
520
171
|
|
|
@@ -522,75 +173,30 @@ Explicit CLI flags win over profiles, which win over `defaults`.
|
|
|
522
173
|
|
|
523
174
|
| Guide | Covers |
|
|
524
175
|
|---|---|
|
|
525
|
-
| **[
|
|
526
|
-
| **[
|
|
527
|
-
| **[Editor diagnostics](docs/editor.md)** | `flecto lsp` setup for Neovim, Helix, Emacs, and where diagnostics land |
|
|
528
|
-
| **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
|
|
529
|
-
| **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled GitHub Actions |
|
|
530
|
-
| **[Performance](docs/performance.md)** | Where time goes at scale, and how much smaller a diff is than the config |
|
|
531
|
-
| **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
|
|
176
|
+
| **[Getting started](docs/getting-started.md)** | A full walkthrough, from install to a failing build |
|
|
177
|
+
| **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled Actions, pinning |
|
|
532
178
|
| **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
|
|
179
|
+
| **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
|
|
180
|
+
| **[Comparison](docs/comparison.md)** | Honest comparison with Checkov, Trivy/tfsec, conftest/OPA, tf-summarize, dyff |
|
|
181
|
+
| **[Supported formats](docs/formats.md)** | YAML, JSON, TOML, INI, dotenv, age — and what each one accepts |
|
|
182
|
+
| **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, why nothing is decrypted |
|
|
183
|
+
| **[Configuration](docs/configuration.md)** | `.flectorc`, profiles, ignore patterns, array identity, masking |
|
|
184
|
+
| **[CLI reference](docs/cli-reference.md)** | Every command, flag, and exit code |
|
|
185
|
+
| **[Policy packs](docs/policy-packs.md)** | Writing declarative rules |
|
|
186
|
+
| **[Plugins](docs/plugins.md)** · **[Cookbook](docs/plugin-cookbook.md)** | Rules that need real code |
|
|
533
187
|
| **[Webhooks and commands](docs/webhooks.md)** | Envelope shape, delivery modes, command environment |
|
|
534
188
|
| **[MCP server](docs/mcp.md)** | Read-only diff/check/explain tools for agents, and the security posture |
|
|
535
189
|
| **[Explain](docs/explain.md)** | Opt-in model narration of a diff: what is sent, what it can never do, cost |
|
|
536
|
-
| **[
|
|
537
|
-
| **[
|
|
538
|
-
| **[
|
|
190
|
+
| **[Editor diagnostics](docs/editor.md)** | `flecto lsp` setup for Neovim, Helix, Emacs |
|
|
191
|
+
| **[Live drift](docs/drift.md)** | `flecto-drift`: declared config versus what is actually running |
|
|
192
|
+
| **[Performance](docs/performance.md)** | Where time goes at scale |
|
|
193
|
+
| **[Stability](docs/stability.md)** | What you can build against, and the deprecation sequence |
|
|
539
194
|
| **[Troubleshooting](docs/troubleshooting.md)** | When something doesn't behave |
|
|
540
|
-
| **[
|
|
541
|
-
| **[Migrating to 4.0](docs/migrating-to-4.md)** | The five breaking changes, and how to tell whether they affect you |
|
|
195
|
+
| **[Migrating to 4.0](docs/migrating-to-4.md)** | The five breaking changes, and whether they affect you |
|
|
542
196
|
| **[Changelog](CHANGELOG.md)** | Release history and migration notes |
|
|
543
197
|
|
|
544
198
|
---
|
|
545
199
|
|
|
546
|
-
## How it works
|
|
547
|
-
|
|
548
|
-
1. **Parse** — format detected by extension or dotenv naming → structured values
|
|
549
|
-
2. **Watch** — [chokidar](https://github.com/paulmillr/chokidar) with debounce
|
|
550
|
-
3. **Diff** — semantic tree comparison with ignore rules and array identity
|
|
551
|
-
4. **Evaluate** — policy packs and plugins → severity-tagged findings
|
|
552
|
-
5. **Emit** — a versioned envelope (`schema_version: "2.0"`)
|
|
553
|
-
6. **Deliver** — terminal output, shell command, webhook, or CI annotations
|
|
554
|
-
|
|
555
|
-
Flecto runs entirely on your machine. Snapshots are local files, and nothing
|
|
556
|
-
leaves the process unless you configure a webhook or command.
|
|
557
|
-
|
|
558
|
-
---
|
|
559
|
-
|
|
560
|
-
## Stability
|
|
561
|
-
|
|
562
|
-
Flecto runs inside your merge path, so here is what you can build against.
|
|
563
|
-
These follow [semver](https://semver.org/) and are covered by the deprecation
|
|
564
|
-
sequence below:
|
|
565
|
-
|
|
566
|
-
- **The JSON envelope** (`schema_version: "2.0"`) — existing fields keep their
|
|
567
|
-
name, type, and meaning; new fields are additive. Schemas in [`schemas/`](schemas).
|
|
568
|
-
- **Exit codes** — `0` clean, `1` a fail trigger matched or the run could not
|
|
569
|
-
complete. That is the whole set, and Flecto fails closed.
|
|
570
|
-
- **`.flectorc`** — documented keys keep their name, meaning, and default.
|
|
571
|
-
- **Command and flag names**, and what a flag accepts.
|
|
572
|
-
|
|
573
|
-
**No breaking change to those ships without a minor release that warns first**,
|
|
574
|
-
names the replacement, and says which version removes the old form. The one
|
|
575
|
-
exception is a security fix: if a surface can make `flecto ci` report a clean run
|
|
576
|
-
on a change that is not clean, it gets closed in the next release with an
|
|
577
|
-
advisory. 4.0 was exactly that — five breaking changes, every one a bypass.
|
|
578
|
-
|
|
579
|
-
Deliberately **not** stable: terminal and `pr-comment` output (presentation —
|
|
580
|
-
parse `--format json` instead), message wording, anything under `src/`, and
|
|
581
|
-
snapshot file internals. Built-in packs gain rules in minor releases; rule IDs
|
|
582
|
-
never change meaning.
|
|
583
|
-
|
|
584
|
-
Flecto reached 4.0 in four months, which is fast. That churn was front-loaded
|
|
585
|
-
into a period with no real users, and 4.0 was forced by a
|
|
586
|
-
[security review](docs/security-review.md) finding real bypasses. The intent now
|
|
587
|
-
is minor releases only — anything needing a 5.0 waits in
|
|
588
|
-
[`docs/v5-proposals.md`](docs/v5-proposals.md).
|
|
589
|
-
|
|
590
|
-
→ **[Full stability policy](docs/stability.md)**
|
|
591
|
-
|
|
592
|
-
---
|
|
593
|
-
|
|
594
200
|
## Project
|
|
595
201
|
|
|
596
202
|
- **Questions and ideas** — [Discussions](https://github.com/myselfsiddharth/Flecto/discussions)
|
package/index.js
CHANGED
|
@@ -385,18 +385,51 @@ function gitRepoRelativePath(filePath) {
|
|
|
385
385
|
* @returns {boolean}
|
|
386
386
|
*/
|
|
387
387
|
function baselineEntryIsSymlink(commit, rel, dir) {
|
|
388
|
+
return baselineEntryMode(commit, rel, dir) === '120000';
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The git mode of the entry at this path in this commit: '' when the commit
|
|
393
|
+
* has no such path, null when git could not answer.
|
|
394
|
+
* @param {string} commit
|
|
395
|
+
* @param {string} rel
|
|
396
|
+
* @param {string} dir
|
|
397
|
+
* @returns {string | null}
|
|
398
|
+
*/
|
|
399
|
+
function baselineEntryMode(commit, rel, dir) {
|
|
388
400
|
try {
|
|
389
|
-
|
|
401
|
+
// `rel` is relative to the repository root, and without --full-tree
|
|
402
|
+
// ls-tree resolves it against `dir`: for any file below the root it looked
|
|
403
|
+
// up `sub/sub/file`, found nothing, and the symlink check never fired.
|
|
404
|
+
return execFileSync(
|
|
390
405
|
'git',
|
|
391
|
-
['-C', dir, 'ls-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
|
|
406
|
+
['-C', dir, 'ls-tree', '--full-tree', '--format=%(objectmode)', '--end-of-options', commit, '--', rel],
|
|
392
407
|
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
393
408
|
).trim();
|
|
394
|
-
return entry === '120000';
|
|
395
409
|
} catch {
|
|
396
|
-
return
|
|
410
|
+
return null;
|
|
397
411
|
}
|
|
398
412
|
}
|
|
399
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Returned in place of a baseline state when `--new-files added` is set and the
|
|
416
|
+
* baseline commit does not contain the file. A distinct value rather than an
|
|
417
|
+
* empty document, so the caller cannot mistake "this file is new" for "this
|
|
418
|
+
* file was empty" (#186), and has to say which one it is reporting.
|
|
419
|
+
*/
|
|
420
|
+
const NEW_FILE = Symbol('new file');
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* An empty baseline of the same shape as the head document, so every key
|
|
424
|
+
* diffs as `added`. Not `parseContent(path, '')`: JSON has no empty document.
|
|
425
|
+
* @param {unknown} after
|
|
426
|
+
*/
|
|
427
|
+
function emptyBaselineLike(after) {
|
|
428
|
+
if (Array.isArray(after)) return [];
|
|
429
|
+
if (after !== null && typeof after === 'object') return {};
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
400
433
|
/**
|
|
401
434
|
* Resolve symlinks where possible, falling back to the input when the path does
|
|
402
435
|
* not exist on disk.
|
|
@@ -518,7 +551,7 @@ function isExplicitPath(value) {
|
|
|
518
551
|
|| value.startsWith('..\\');
|
|
519
552
|
}
|
|
520
553
|
|
|
521
|
-
function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
|
|
554
|
+
function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile, { newFiles = 'fail' } = {}) {
|
|
522
555
|
if (snapshotRef === '') {
|
|
523
556
|
// The same unset-CI-variable case readSnapshotFile refuses. Falling back to
|
|
524
557
|
// the store here would compare against something the operator did not
|
|
@@ -576,6 +609,19 @@ function readSnapshotStateFromRef(filePath, snapshotRef, store, snapshotFile) {
|
|
|
576
609
|
}
|
|
577
610
|
|
|
578
611
|
const rel = gitRepoRelativePath(filePath);
|
|
612
|
+
// A file the baseline commit does not have is a file this change adds. The
|
|
613
|
+
// default is still to fail closed: a rename reads as a new file too, and
|
|
614
|
+
// turns every `changed` into an `added` that the Action's default --fail-on
|
|
615
|
+
// does not gate. `--new-files added` is the explicit, CLI-only opt-in. The
|
|
616
|
+
// commit is the operator's, so the change under review cannot fake absence.
|
|
617
|
+
if (baselineEntryMode(commit, rel, repoDir) === '') {
|
|
618
|
+
if (newFiles === 'added') return NEW_FILE;
|
|
619
|
+
throw new Error(
|
|
620
|
+
`"${rel}" is not in ${ref}, so there is no baseline to diff it against.`
|
|
621
|
+
+ ' If this change adds the file, pass --new-files added to report every key as added'
|
|
622
|
+
+ ' (policies still run); the default fails closed because a rename looks the same.',
|
|
623
|
+
);
|
|
624
|
+
}
|
|
579
625
|
if (baselineEntryIsSymlink(commit, rel, repoDir)) {
|
|
580
626
|
throw new Error(
|
|
581
627
|
`"${rel}" is a symbolic link in ${ref}, so the baseline there is a path, not a configuration.`
|
|
@@ -1309,6 +1355,7 @@ program
|
|
|
1309
1355
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1310
1356
|
.option('--snapshot-ref <ref>', 'Baseline git revision (a snapshot path also works if it is path-shaped)')
|
|
1311
1357
|
.option('--snapshot-file <path>', 'Baseline snapshot file, never consulted as a git revision')
|
|
1358
|
+
.option('--new-files <mode>', 'A file not in the --snapshot-ref commit: fail (default) | added (every key reported as added)')
|
|
1312
1359
|
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1313
1360
|
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1314
1361
|
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
@@ -1347,9 +1394,23 @@ program
|
|
|
1347
1394
|
|
|
1348
1395
|
const ignorePaths = parseCsv(effective.ignore);
|
|
1349
1396
|
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
1397
|
+
const newFiles = String(effective.newFiles ?? 'fail');
|
|
1398
|
+
if (newFiles !== 'fail' && newFiles !== 'added') {
|
|
1399
|
+
throw new Error(`--new-files must be fail or added (got ${newFiles})`);
|
|
1400
|
+
}
|
|
1350
1401
|
const format = String(effective.format ?? 'json');
|
|
1351
|
-
if (
|
|
1352
|
-
throw new Error(
|
|
1402
|
+
if (format === 'human') {
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
'--format human is not available for `ci`, whose output is meant to be consumed by a machine. Use pr-comment to read it yourself, or json to parse it. (human is the default for `plan` and `compare`.)',
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
// Listed once: the check and the message drift apart otherwise, and the
|
|
1408
|
+
// message is the only place a user finds out what is allowed. The wording
|
|
1409
|
+
// is unchanged -- this is a dedup, not a rewording.
|
|
1410
|
+
const CI_FORMATS = ['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'];
|
|
1411
|
+
if (!CI_FORMATS.includes(format)) {
|
|
1412
|
+
const listed = `${CI_FORMATS.slice(0, -1).join(', ')}, or ${CI_FORMATS.at(-1)}`;
|
|
1413
|
+
throw new Error(`--format must be ${listed}`);
|
|
1353
1414
|
}
|
|
1354
1415
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1355
1416
|
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
@@ -1421,13 +1482,22 @@ program
|
|
|
1421
1482
|
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
1422
1483
|
let before;
|
|
1423
1484
|
try {
|
|
1424
|
-
before = readSnapshotStateFromRef(
|
|
1485
|
+
before = readSnapshotStateFromRef(
|
|
1486
|
+
filepath, effective.snapshotRef, snapshotStore, effective.snapshotFile, { newFiles },
|
|
1487
|
+
);
|
|
1425
1488
|
} catch (err) {
|
|
1426
1489
|
throw new Error(
|
|
1427
1490
|
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
1428
1491
|
`${effective.snapshotRef ? ` (ref: ${effective.snapshotRef})` : ''}: ${err.message}`
|
|
1429
1492
|
);
|
|
1430
1493
|
}
|
|
1494
|
+
if (before === NEW_FILE) {
|
|
1495
|
+
renderWarn(
|
|
1496
|
+
`${relative(cwd, filepath).replaceAll('\\', '/')} is not in ${effective.snapshotRef}: reporting it as a new file,`
|
|
1497
|
+
+ ' every key added (--new-files added).',
|
|
1498
|
+
);
|
|
1499
|
+
before = emptyBaselineLike(after);
|
|
1500
|
+
}
|
|
1431
1501
|
const events = diffTrees(before, after, dOpts);
|
|
1432
1502
|
const rawFindings = await evaluatePolicies(events, {
|
|
1433
1503
|
cwd,
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "4.
|
|
7
|
+
"version": "4.2.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": [
|
package/src/alerter.js
CHANGED
|
@@ -204,7 +204,12 @@ export function runCommand(command, envelope) {
|
|
|
204
204
|
renderWarn(`Command failed to start: ${err.message}`);
|
|
205
205
|
settle(false);
|
|
206
206
|
});
|
|
207
|
-
child.on('close', (code) => {
|
|
207
|
+
child.on('close', (code, signal) => {
|
|
208
|
+
if (signal) {
|
|
209
|
+
renderWarn(`Command failed (signal ${signal}): ${command}`);
|
|
210
|
+
settle(false);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
208
213
|
if (code && code !== 0) {
|
|
209
214
|
renderWarn(`Command failed (exit ${code}): ${command}`);
|
|
210
215
|
settle(false);
|
package/src/config.js
CHANGED
|
@@ -519,7 +519,11 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
|
|
|
519
519
|
if (rcBaselineAllowed()) return;
|
|
520
520
|
// `snapshotFile` picks the baseline just as directly, so it is gated with it
|
|
521
521
|
// rather than left as the way around it.
|
|
522
|
-
|
|
522
|
+
// `newFiles` decides what a file missing from the baseline is compared
|
|
523
|
+
// against, and a rename is a missing file: from .flectorc it would let a pull
|
|
524
|
+
// request move prod.yaml and have every `changed` reported as `added`.
|
|
525
|
+
const flags = { snapshotRef: 'snapshot-ref', snapshotFile: 'snapshot-file', newFiles: 'new-files' };
|
|
526
|
+
for (const [option, flag] of Object.entries(flags)) {
|
|
523
527
|
if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
|
|
524
528
|
throw new Error(
|
|
525
529
|
`Refusing "${option}" declared in .flectorc: it chooses the baseline every change is`
|
|
@@ -527,7 +531,7 @@ export function assertSnapshotRefFromCli(effective, cliOverrides) {
|
|
|
527
531
|
+ ' pointing it at "HEAD", or at a file it committed, compares every file against itself'
|
|
528
532
|
+ ' and exits 0.\n'
|
|
529
533
|
+ `Declared: ${JSON.stringify(effective[option])}\n`
|
|
530
|
-
+ `Pass --${
|
|
534
|
+
+ `Pass --${flag} on the command line`
|
|
531
535
|
+ ' instead, or set FLECTO_ALLOW_RC_BASELINE=1 if this config is trusted.',
|
|
532
536
|
);
|
|
533
537
|
}
|
package/src/differ.js
CHANGED
|
@@ -361,6 +361,48 @@ function identityMap(items, idKey) {
|
|
|
361
361
|
return map;
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
// `KEY=VALUE`, or a bare `KEY` (Compose's pass-through from the host).
|
|
365
|
+
export const ASSIGNMENT_RE = /^([A-Za-z_][A-Za-z0-9_.-]*)(?:=|$)/;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Key a list of `KEY=VALUE` strings by KEY, or return null when it is not one.
|
|
369
|
+
*
|
|
370
|
+
* Compose writes `environment`, `labels` and `build.args` either as a map or as
|
|
371
|
+
* a list of assignments, and means the same thing by both. Diffed by position,
|
|
372
|
+
* one inserted variable turned every later line into a `changed` pairing two
|
|
373
|
+
* unrelated variables (#226). At least one item must carry an `=`, so a plain
|
|
374
|
+
* word list such as `command: [python, app.py]` keeps its order-sensitive diff.
|
|
375
|
+
* @param {unknown[]} items
|
|
376
|
+
* @returns {Map<string, { value: unknown, index: number }> | null}
|
|
377
|
+
*/
|
|
378
|
+
function assignmentMap(items) {
|
|
379
|
+
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
380
|
+
const map = new Map();
|
|
381
|
+
for (let i = 0; i < items.length; i++) {
|
|
382
|
+
const item = items[i];
|
|
383
|
+
if (typeof item !== 'string') return null;
|
|
384
|
+
const match = ASSIGNMENT_RE.exec(item);
|
|
385
|
+
if (!match || map.has(match[1])) return null;
|
|
386
|
+
map.set(match[1], { value: item, index: i });
|
|
387
|
+
}
|
|
388
|
+
return map;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Both sides are assignment lists and at least one item assigns a value.
|
|
393
|
+
* @param {unknown[]} before
|
|
394
|
+
* @param {unknown[]} after
|
|
395
|
+
* @returns {{ beforeMap: Map<string, { value: unknown, index: number }>, afterMap: Map<string, { value: unknown, index: number }> } | null}
|
|
396
|
+
*/
|
|
397
|
+
function assignmentMaps(before, after) {
|
|
398
|
+
const beforeMap = assignmentMap(before);
|
|
399
|
+
const afterMap = beforeMap && assignmentMap(after);
|
|
400
|
+
if (!beforeMap || !afterMap) return null;
|
|
401
|
+
const assigns = (item) => typeof item === 'string' && item.includes('=');
|
|
402
|
+
if (!before.some(assigns) && !after.some(assigns)) return null;
|
|
403
|
+
return { beforeMap, afterMap };
|
|
404
|
+
}
|
|
405
|
+
|
|
364
406
|
/**
|
|
365
407
|
* Select a configured identity key, or auto-detect id then name.
|
|
366
408
|
* @param {unknown[]} before
|
|
@@ -425,10 +467,11 @@ function arraySignature(value) {
|
|
|
425
467
|
*/
|
|
426
468
|
function diffArrays(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
|
|
427
469
|
const idKey = resolveArrayIdKey(before, after, options);
|
|
470
|
+
const assignments = !idKey && options.arrayIdentity !== false ? assignmentMaps(before, after) : null;
|
|
428
471
|
|
|
429
|
-
if (idKey) {
|
|
430
|
-
const beforeMap =
|
|
431
|
-
|
|
472
|
+
if (idKey || assignments) {
|
|
473
|
+
const { beforeMap, afterMap } = assignments
|
|
474
|
+
?? { beforeMap: identityMap(before, idKey), afterMap: identityMap(after, idKey) };
|
|
432
475
|
|
|
433
476
|
if (beforeMap && afterMap) {
|
|
434
477
|
for (const [key, afterItem] of afterMap) {
|
package/src/parser.js
CHANGED
|
@@ -435,6 +435,30 @@ export function yamlDocumentKeys(docs) {
|
|
|
435
435
|
* @returns {unknown}
|
|
436
436
|
* @throws {Error} on unsupported format or parse failure
|
|
437
437
|
*/
|
|
438
|
+
/**
|
|
439
|
+
* Whether a file that failed to parse as YAML looks like a Helm chart template.
|
|
440
|
+
*
|
|
441
|
+
* Only ever consulted **after** a parse failure, which is what keeps it from
|
|
442
|
+
* firing on valid YAML that happens to contain braces -- a Prometheus rule
|
|
443
|
+
* holding `{{ $labels.instance }}` in a string parses fine and never reaches
|
|
444
|
+
* here. Go template delimiters in a file that did not parse are close to
|
|
445
|
+
* conclusive, and `{{-`, `nindent`, `include "` and a `templates/` path are the
|
|
446
|
+
* shapes that only a chart has.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} raw
|
|
449
|
+
* @param {string} filepath
|
|
450
|
+
* @returns {boolean}
|
|
451
|
+
*/
|
|
452
|
+
function looksLikeHelmTemplate(raw, filepath) {
|
|
453
|
+
if (!/\{\{/u.test(raw) || !/\}\}/u.test(raw)) return false;
|
|
454
|
+
const path = filepath.replace(/\\/gu, '/');
|
|
455
|
+
return /\/templates\//u.test(path)
|
|
456
|
+
|| /\{\{-/u.test(raw)
|
|
457
|
+
|| /\|\s*nindent\b/u.test(raw)
|
|
458
|
+
|| /\{\{[^}]*\binclude\s+"/u.test(raw)
|
|
459
|
+
|| /\{\{[^}]*\.Values\./u.test(raw);
|
|
460
|
+
}
|
|
461
|
+
|
|
438
462
|
export function parseContent(filepath, raw) {
|
|
439
463
|
const ext = extname(filepath).toLowerCase();
|
|
440
464
|
const envLike = isEnvFilename(filepath);
|
|
@@ -469,6 +493,19 @@ export function parseContent(filepath, raw) {
|
|
|
469
493
|
parsed = TOML.parse(raw);
|
|
470
494
|
}
|
|
471
495
|
} catch (err) {
|
|
496
|
+
// A chart template is not YAML, and saying so beats a column number. Without
|
|
497
|
+
// this, `flecto ci "helm/**/*.yaml"` -- the first thing a Helm user tries --
|
|
498
|
+
// reports a syntax error inside a file that is perfectly valid, and sends
|
|
499
|
+
// them to debug their chart instead of their command.
|
|
500
|
+
if ((ext === '.yaml' || ext === '.yml') && looksLikeHelmTemplate(raw, filepath)) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`"${filepath}" looks like a Helm template, not YAML.\n` +
|
|
503
|
+
'Flecto reads rendered manifests, so render the chart first:\n' +
|
|
504
|
+
' helm template ./chart > rendered.yaml && flecto ci rendered.yaml\n' +
|
|
505
|
+
'Or point Flecto at the values file, which is plain YAML:\n' +
|
|
506
|
+
' flecto ci chart/values.yaml'
|
|
507
|
+
);
|
|
508
|
+
}
|
|
472
509
|
const lineMatch = err.message?.match(/line (\d+)/i);
|
|
473
510
|
const lineInfo = lineMatch ? ` (line ${lineMatch[1]})` : '';
|
|
474
511
|
throw new Error(
|
package/src/positions.js
CHANGED
|
@@ -3,6 +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
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Where a config path lives in the source text (#142).
|
|
@@ -247,6 +248,12 @@ function itemByIdentity(items, quoted, arrayIdKey) {
|
|
|
247
248
|
if (String(id) === target) matches.add(item);
|
|
248
249
|
}
|
|
249
250
|
}
|
|
251
|
+
// A `KEY=VALUE` list (Compose `environment`, `labels`) is keyed by KEY.
|
|
252
|
+
if (matches.size === 0 && !arrayIdKey) {
|
|
253
|
+
for (const item of items) {
|
|
254
|
+
if (typeof item.value === 'string' && ASSIGNMENT_RE.exec(item.value)?.[1] === target) matches.add(item);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
250
257
|
return matches.size === 1 ? [...matches][0] : null;
|
|
251
258
|
}
|
|
252
259
|
|
package/src/secrets.js
CHANGED
|
@@ -78,8 +78,13 @@ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]{0,32}:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
|
|
|
78
78
|
* Values that only *reference* a secret. Redacting these adds noise and, worse,
|
|
79
79
|
* would make the policy rule fire on configs that correctly keep secrets out of
|
|
80
80
|
* the file.
|
|
81
|
+
*
|
|
82
|
+
* `$(NAME)` is Kubernetes' env-var expansion in a container's env and args, and
|
|
83
|
+
* `{{ ... }}` a Helm or Jinja template expression: in a connection string such
|
|
84
|
+
* as `postgres://app:$(DB_PASSWORD)@db/app` both are the reference, not the
|
|
85
|
+
* credential.
|
|
81
86
|
*/
|
|
82
|
-
const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
|
|
87
|
+
const PLACEHOLDER_RE = /^(?:\$\{[^}]*\}|\$\([A-Za-z_][A-Za-z0-9_]*\)|\{\{[^}]*\}\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z0-9_]+%|<[^>]*>|\*+)$/;
|
|
83
88
|
|
|
84
89
|
/**
|
|
85
90
|
* High-entropy fallback gates. Every one of these must pass:
|