flecto 3.0.0 → 3.0.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 +34 -1
- package/README.md +48 -0
- package/index.js +17 -10
- package/package.json +1 -1
- package/src/config.js +60 -14
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ The format is based on [Keep a Changelog], and this project adheres to
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.1] - 2026-08-07
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **Policy plugins declared in `.flectorc` are no longer loaded**
|
|
15
|
+
([GHSA-wq8m-fc3q-8m5x], critical). A pull request that added a `.flectorc`
|
|
16
|
+
with a `plugins` entry achieved **arbitrary code execution on the CI runner** —
|
|
17
|
+
`flecto ci` is what teams run on pull requests, and it honoured the attacker's
|
|
18
|
+
config with no opt-in, no allowlist, and no path containment. The attacker's
|
|
19
|
+
code ran with whatever the workflow exposed, including `GITHUB_TOKEN`, and the
|
|
20
|
+
path was not contained, so `../../../../tmp/x.mjs` loaded a module from
|
|
21
|
+
anywhere on disk.
|
|
22
|
+
|
|
23
|
+
Plugins now load only from an explicit `--plugins` flag. If a config file is
|
|
24
|
+
genuinely trusted, set `FLECTO_ALLOW_RC_PLUGINS=1`; even then an rc-declared
|
|
25
|
+
plugin must live inside the working directory. Flecto **fails loudly** rather
|
|
26
|
+
than skipping the plugin silently, because a policy plugin that stopped running
|
|
27
|
+
without saying so would quietly weaken a gate the operator believes is
|
|
28
|
+
enforced.
|
|
29
|
+
|
|
30
|
+
Policy *packs* are declarative and were never affected. `--plugins` is
|
|
31
|
+
unchanged, including paths outside the project, since the flag is operator
|
|
32
|
+
intent rather than attacker input.
|
|
33
|
+
|
|
34
|
+
**If you run Flecto on untrusted pull requests, upgrade.** If you rely on
|
|
35
|
+
`plugins` in `.flectorc`, move it to `--plugins` or set the opt-in.
|
|
36
|
+
|
|
37
|
+
The trust boundary is now documented in [plugin authoring](docs/plugins.md);
|
|
38
|
+
it previously was not stated anywhere.
|
|
39
|
+
|
|
40
|
+
|
|
10
41
|
## [3.0.0] - 2026-08-06
|
|
11
42
|
|
|
12
43
|
### Migration notes
|
|
@@ -472,7 +503,8 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
472
503
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
473
504
|
continuing with no policies.
|
|
474
505
|
|
|
475
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.
|
|
506
|
+
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...HEAD
|
|
507
|
+
[3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
|
|
476
508
|
[3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
|
|
477
509
|
[2.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.0.0...v2.1.0
|
|
478
510
|
[#6]: https://github.com/myselfsiddharth/Flecto/issues/6
|
|
@@ -531,3 +563,4 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
531
563
|
[#110]: https://github.com/myselfsiddharth/Flecto/issues/110
|
|
532
564
|
[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
|
|
533
565
|
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
|
|
566
|
+
[GHSA-wq8m-fc3q-8m5x]: https://github.com/myselfsiddharth/Flecto/security/advisories/GHSA-wq8m-fc3q-8m5x
|
package/README.md
CHANGED
|
@@ -35,6 +35,18 @@ English, flags what looks risky, and gives you an exit code to gate on.
|
|
|
35
35
|
| Hope someone notices `debug: true` | Policy finding → build fails |
|
|
36
36
|
| "Something in `.env` changed" | The exact keys, with secrets masked |
|
|
37
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
|
+
|
|
38
50
|
---
|
|
39
51
|
|
|
40
52
|
## Install
|
|
@@ -285,6 +297,37 @@ resource. Flecto never runs `helm` or `kustomize` — you render, it diffs, so a
|
|
|
285
297
|
renderer works and no binary is needed in CI.
|
|
286
298
|
→ **[Kubernetes](docs/kubernetes.md)**
|
|
287
299
|
|
|
300
|
+
### Read a Terraform plan in plain English
|
|
301
|
+
|
|
302
|
+
`terraform plan` output is precise and long. Flecto turns it into the handful of
|
|
303
|
+
lines a reviewer actually needs to argue about:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
terraform show -json plan.tfplan > plan.json
|
|
307
|
+
flecto plan plan.json --fail-on error
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
plan.json — plan format 1.2
|
|
312
|
+
Plan: 0 to add, 1 to change, 0 to destroy, 1 to replace.
|
|
313
|
+
~ aws_security_group.web.ingress[0].cidr_blocks[0]: "10.0.0.0/8" → "0.0.0.0/0"
|
|
314
|
+
- aws_db_instance.main.#action: "replace" [terraform will destroy and recreate aws_db_instance.main]
|
|
315
|
+
~ aws_db_instance.main.password: "(sensitive value)" → "(sensitive value)" [sensitive]
|
|
316
|
+
! policy(error) [terraform] …cidr_blocks[0]: Security group ingress will accept
|
|
317
|
+
traffic from the whole internet (0.0.0.0/0). Restrict the source to a known CIDR…
|
|
318
|
+
! policy(error) [terraform] …#action: Terraform will destroy a stateful resource.
|
|
319
|
+
Its data does not survive. Take a final snapshot, or add a prevent_destroy…
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
A **replace reads as a removal**, not a benign update — a recreated database
|
|
323
|
+
should never look like a config tweak. Values Terraform marks sensitive are
|
|
324
|
+
redacted during parsing, before the policy engine or any formatter sees them, and
|
|
325
|
+
`after_unknown` renders as `(known after apply)` rather than `null`.
|
|
326
|
+
|
|
327
|
+
**Flecto never runs `terraform`** — you produce the JSON, it reads it, so nothing
|
|
328
|
+
extra has to exist on the CI runner.
|
|
329
|
+
→ **[Terraform plans](docs/terraform.md)**
|
|
330
|
+
|
|
288
331
|
### Encode your own rules
|
|
289
332
|
|
|
290
333
|
Beyond the built-in packs, write rules as declarative JSON or YAML — no code:
|
|
@@ -365,6 +408,11 @@ and runs no code from the package. →
|
|
|
365
408
|
| dotenv | `.env`, `.env.*`, `*.env` |
|
|
366
409
|
| age (armored) | `.age`, or any file whose contents are one armored blob |
|
|
367
410
|
|
|
411
|
+
Terraform plan JSON (`terraform show -json`) is read by **`flecto plan`**, which
|
|
412
|
+
applies Terraform's own sensitivity marking. Point `plan` at it rather than `ci`
|
|
413
|
+
or `watch` — those treat it as ordinary JSON and will print values Terraform
|
|
414
|
+
marks sensitive ([#113](https://github.com/myselfsiddharth/Flecto/issues/113)).
|
|
415
|
+
|
|
368
416
|
Multi-document YAML (`---`-separated, the usual shape of a Kubernetes manifest)
|
|
369
417
|
is supported. Each document is diffed under its own key — `kind/name` for
|
|
370
418
|
Kubernetes-shaped documents, so a document inserted at the top of the file does
|
package/index.js
CHANGED
|
@@ -484,8 +484,9 @@ program
|
|
|
484
484
|
try {
|
|
485
485
|
const { config } = loadRcConfig(process.cwd());
|
|
486
486
|
const profile = resolveProfileName(opts.profile);
|
|
487
|
-
const
|
|
488
|
-
const
|
|
487
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
488
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
489
|
+
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
489
490
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
490
491
|
if (targets.length === 0) {
|
|
491
492
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -682,7 +683,8 @@ program
|
|
|
682
683
|
|
|
683
684
|
const { config } = loadRcConfig(process.cwd());
|
|
684
685
|
const profile = resolveProfileName(opts.profile);
|
|
685
|
-
const
|
|
686
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
687
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
686
688
|
const ignorePaths = parseCsv(effective.ignore);
|
|
687
689
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
688
690
|
|
|
@@ -732,8 +734,9 @@ program
|
|
|
732
734
|
try {
|
|
733
735
|
const { config } = loadRcConfig(process.cwd());
|
|
734
736
|
const profile = resolveProfileName(opts.profile);
|
|
735
|
-
const
|
|
736
|
-
const
|
|
737
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
738
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
739
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
737
740
|
|
|
738
741
|
const limit = Number.parseInt(String(effective.limit ?? '10'), 10);
|
|
739
742
|
if (!Number.isInteger(limit) || limit < 1) {
|
|
@@ -826,8 +829,9 @@ program
|
|
|
826
829
|
try {
|
|
827
830
|
const { config } = loadRcConfig(process.cwd());
|
|
828
831
|
const profile = resolveProfileName(opts.profile);
|
|
829
|
-
const
|
|
830
|
-
const
|
|
832
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
833
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
834
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
831
835
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
832
836
|
if (targets.length === 0) {
|
|
833
837
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -932,7 +936,8 @@ program
|
|
|
932
936
|
try {
|
|
933
937
|
const { config } = loadRcConfig(process.cwd());
|
|
934
938
|
const profile = resolveProfileName(opts.profile);
|
|
935
|
-
const
|
|
939
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
940
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
936
941
|
// A plan carries Terraform-shaped paths, so the config-file packs are not
|
|
937
942
|
// the useful default here; `terraform` is. An explicit --policies or a
|
|
938
943
|
// .flectorc entry still wins.
|
|
@@ -940,6 +945,7 @@ program
|
|
|
940
945
|
effective.policies === undefined
|
|
941
946
|
? { ...effective, policies: PLAN_DEFAULT_POLICIES }
|
|
942
947
|
: effective,
|
|
948
|
+
{ pluginsFromCli: cliOverrides.plugins !== undefined },
|
|
943
949
|
);
|
|
944
950
|
|
|
945
951
|
const ignorePaths = parseCsv(effective.ignore);
|
|
@@ -1036,8 +1042,9 @@ program
|
|
|
1036
1042
|
try {
|
|
1037
1043
|
const { config } = loadRcConfig(process.cwd());
|
|
1038
1044
|
const profile = resolveProfileName(opts.profile);
|
|
1039
|
-
const
|
|
1040
|
-
const
|
|
1045
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
1046
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
1047
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
1041
1048
|
|
|
1042
1049
|
const ignorePaths = parseCsv(effective.ignore);
|
|
1043
1050
|
const failOn = parseFailOn(effective.failOn ?? 'changed,added,removed,policy,error');
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
2
|
+
import { resolve, sep } from 'path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import { isEnvFilename } from './parser.js';
|
|
@@ -80,24 +80,70 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
|
|
|
80
80
|
return { ...defaults, ...profileOptions, ...cliOverrides };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Has the operator explicitly opted in to plugins declared in `.flectorc`?
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function rcPluginsAllowed() {
|
|
88
|
+
const raw = process.env.FLECTO_ALLOW_RC_PLUGINS;
|
|
89
|
+
return raw === '1' || String(raw).toLowerCase() === 'true';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a policy list that may arrive as an array or a comma-separated string.
|
|
94
|
+
* @param {unknown} raw
|
|
95
|
+
* @param {string[]} fallback
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
function toList(raw, fallback) {
|
|
99
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
100
|
+
if (typeof raw === 'string') return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
101
|
+
return fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
/**
|
|
84
105
|
* Normalize policy-related effective options.
|
|
106
|
+
*
|
|
107
|
+
* Plugins execute code, and `.flectorc` is attacker-controlled on an untrusted
|
|
108
|
+
* pull request, so a plugin that came from the rc file rather than an explicit
|
|
109
|
+
* `--plugins` flag is refused unless the operator opts in with
|
|
110
|
+
* `FLECTO_ALLOW_RC_PLUGINS=1`. Refusing loudly rather than skipping silently is
|
|
111
|
+
* deliberate: a plugin that stops running without saying so would weaken a
|
|
112
|
+
* policy gate the operator believes is in place.
|
|
85
113
|
* @param {Record<string, unknown>} effective
|
|
114
|
+
* @param {{ pluginsFromCli?: boolean, cwd?: string }} [provenance]
|
|
86
115
|
*/
|
|
87
|
-
export function resolvePolicyOptions(effective) {
|
|
88
|
-
const policiesRaw = effective.policies;
|
|
89
|
-
const pluginsRaw = effective.plugins;
|
|
116
|
+
export function resolvePolicyOptions(effective, provenance = {}) {
|
|
90
117
|
const severityRemapRaw = effective.severityRemap;
|
|
91
|
-
const policies =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
118
|
+
const policies = toList(effective.policies, ['default']);
|
|
119
|
+
const plugins = toList(effective.plugins, []);
|
|
120
|
+
|
|
121
|
+
if (plugins.length > 0 && !provenance.pluginsFromCli) {
|
|
122
|
+
if (!rcPluginsAllowed()) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Refusing to load policy plugins declared in .flectorc: plugins execute code, '
|
|
125
|
+
+ 'and a config file can come from an untrusted pull request.\n'
|
|
126
|
+
+ `Declared: ${plugins.join(', ')}\n`
|
|
127
|
+
+ 'Pass them on the command line with --plugins instead, or set '
|
|
128
|
+
+ 'FLECTO_ALLOW_RC_PLUGINS=1 if this config is trusted.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// Opted in, but the rc file may still be attacker-authored. Keep rc-declared
|
|
132
|
+
// plugins inside the project so `../../../../tmp/x.mjs` cannot reach a module
|
|
133
|
+
// planted elsewhere on the runner. An explicit --plugins is operator intent
|
|
134
|
+
// and stays unrestricted: shared policies outside the cwd are a real setup.
|
|
135
|
+
const root = resolve(provenance.cwd ?? process.cwd());
|
|
136
|
+
for (const pluginPath of plugins) {
|
|
137
|
+
const abs = resolve(root, pluginPath);
|
|
138
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Policy plugin declared in .flectorc is outside the project: ${pluginPath}\n`
|
|
141
|
+
+ 'Plugins execute code, so an rc-declared plugin must live inside the '
|
|
142
|
+
+ 'directory Flecto is running in.',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
101
147
|
if (
|
|
102
148
|
severityRemapRaw !== undefined
|
|
103
149
|
&& (severityRemapRaw === null || Array.isArray(severityRemapRaw) || typeof severityRemapRaw !== 'object')
|