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 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.0...HEAD
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
488
- const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
736
- const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
830
- const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
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 effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
1040
- const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
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
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "3.0.0",
7
+ "version": "3.0.1",
8
8
  "description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
9
9
  "license": "MIT",
10
10
  "keywords": [
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 = Array.isArray(policiesRaw)
92
- ? policiesRaw.map(String)
93
- : typeof policiesRaw === 'string'
94
- ? String(policiesRaw).split(',').map((s) => s.trim()).filter(Boolean)
95
- : ['default'];
96
- const plugins = Array.isArray(pluginsRaw)
97
- ? pluginsRaw.map(String)
98
- : typeof pluginsRaw === 'string'
99
- ? String(pluginsRaw).split(',').map((s) => s.trim()).filter(Boolean)
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')