flecto 3.0.0 → 3.0.2

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,506 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.2] - 2026-09-06
11
+
12
+ ### Security
13
+
14
+ - **Assessed 2.x against GHSA-wq8m-fc3q-8m5x and corrected the advisory range**
15
+ ([#125]). The advisory's own proof-of-concept was run against a clean install
16
+ of every released version: 2.0.0, 2.1.0, and 3.0.0 execute an rc-declared
17
+ plugin; 1.0.x predate the `plugins` option, and 3.0.1 is fixed. So the true
18
+ affected range is `>= 2.0.0, <= 3.0.0`, not the `<= 3.0.0` the draft advisory
19
+ recorded — which wrongly swept in 1.x. The 2.x backport is merged on
20
+ `release/2.x` (2.1.1) and blocks both the exploit and its path-traversal
21
+ variant, but **2.1.1 was never published**, so the highest installable 2.x is
22
+ the still-vulnerable 2.1.0. `SECURITY.md` now says so, and the full matrix and
23
+ publish recommendation are in
24
+ [`docs/ghsa-wq8m-fc3q-8m5x-2x.md`](docs/ghsa-wq8m-fc3q-8m5x-2x.md).
25
+
26
+ ### Added
27
+
28
+ - **Inline suppressions in JSON** ([#158]). `.json` and `.jsonc` are parsed as
29
+ JSONC, so they carry comments — but `flecto-ignore-next-line` was still skipped
30
+ there, and skipped silently: the directive parsed as an ordinary comment, the
31
+ finding fired anyway, and nothing told the author their suppression had been
32
+ ignored. That is the failure mode inline suppressions exist to avoid, pointed
33
+ the wrong way.
34
+
35
+ The JSON resolver reuses the parser's comment stripper rather than recognising
36
+ `//` and block comments a second time — comments are blanked in place, so line
37
+ numbers still line up — and then walks the brace/bracket depth and enclosing
38
+ key stack to the same dotted path the differ reports.
39
+
40
+ Anything inside an **array** is refused, as it already is in YAML: an array
41
+ element's diff path is its index or its `--array-id-key` identity depending on
42
+ how the run is configured, so resolving one would suppress the wrong finding
43
+ under the other. Over-suppression is the dangerous direction for a security
44
+ tool, and the refusal is covered per array mode rather than by one happy path.
45
+
46
+ - **Coverage measurement in CI, focused on the modules where a gap is a security
47
+ question** ([#149]). CI ran `npm test` and `npm run pack:check` and nothing
48
+ else, so "is the plugin-loading path from GHSA-wq8m-fc3q-8m5x covered, and is
49
+ every branch of it covered?" was answered by reading `test/security.test.js`
50
+ and hoping.
51
+
52
+ `npm run coverage` runs the suite under `node --test
53
+ --experimental-test-coverage` — a flag, not a dependency — and prints a report
54
+ for `config.js` (plugin resolution), `policy.js` (pack loading), `secrets.js`,
55
+ `encrypted.js`, `pr-comment.js`, and `pr-providers.js` (token handling), worst
56
+ branch coverage first, with the count of branches that never executed.
57
+ Reporting those separately is the point: one repo-wide average is the number
58
+ that hides them.
59
+
60
+ **No threshold gates the job.** A number chosen before anyone has read the
61
+ report is arbitrary, and the usual outcome is tests written to satisfy the gate
62
+ rather than to find defects. The report prints in the job log, so reading it
63
+ needs no artifact download. The one thing that *does* fail the job is a focused
64
+ module missing from the report — a renamed module would otherwise drop out of
65
+ the table silently, leaving a report that covers less than it claims to.
66
+
67
+ No linter. The style argument is the weak one, the project is consistent
68
+ without it, and a rule set worth having is a separate decision from this one.
69
+ See [security review](docs/security-review.md#knowing-what-has-been-exercised).
70
+
71
+ - `afterAnyMatches`, a policy matcher that applies a regular expression to the
72
+ elements of an array value. `afterMatches` requires a string, so the edit that
73
+ widens a scalar into a list — `runs-on: ubuntu-latest` →
74
+ `runs-on: [self-hosted, linux]` — was invisible to every value predicate: the
75
+ differ reports it as one `changed` event whose `after` is an array and does not
76
+ descend into a type change, so no per-element leaf exists to match either. The
77
+ scan is flat and array-only: a non-array value never matches, non-string
78
+ elements are skipped, and it does not recurse into nested arrays or objects, so
79
+ what a rule matches stays readable from the rule text. `afterMatches` keeps its
80
+ exact meaning, so no existing pack changes behavior. The `github-actions` pack's
81
+ `github-actions-self-hosted-runner` rule now pairs the two in an `anyOf` and
82
+ covers the fourth `runs-on` shape it previously documented as a limitation.
83
+ ([#159])
84
+
85
+ - **Merge request comments on GitLab and Bitbucket.** The sticky review comment
86
+ was GitHub-only: `src/pr-comment.js` and both composite actions spoke
87
+ `GITHUB_TOKEN`, `GITHUB_EVENT_PATH`, and the issue-comments API directly, so
88
+ the flagship review experience was unavailable to every team not on GitHub.
89
+
90
+ Delivery is now an adapter (`src/pr-providers.js`); everything upstream of it —
91
+ the differ, the policy engine, the envelope, and the rendered markdown body —
92
+ was already provider-agnostic. The host is detected from CI variables and
93
+ `--pr-provider github|gitlab|bitbucket` forces one. All three upsert a single
94
+ sticky comment by marker, skip the write when the body is unchanged, redact
95
+ the token from error text, and leave the exit code to the diff and policy
96
+ result.
97
+
98
+ **GitLab's `CI_JOB_TOKEN` cannot post merge request notes.** Flecto does not
99
+ attempt it, because the resulting 401 reads like a broken setup rather than a
100
+ missing permission; it names `FLECTO_GITLAB_TOKEN` and the `api` scope
101
+ instead. See [CI](docs/ci.md#providers). ([#138])
102
+
103
+ - Inline suppressions: `# flecto-ignore-next-line <rule> — <reason>` on the line
104
+ above a deliberate finding accepts that one finding in place, the companion to
105
+ the baseline's bulk acceptance. **A reason is mandatory** — a directive without
106
+ one is refused with a pointer to the file and line, never silently applied or
107
+ dropped — so a repo does not accumulate unexplained suppressions. It is scoped
108
+ to the next line and the named rule, and resolves to that line's full key path
109
+ (nesting for YAML, section/table for INI/TOML, flat for dotenv), so a
110
+ suppression on one `pool_size` cannot hide an uncommented `pool_size` elsewhere
111
+ in the file. Works in every commented format Flecto parses — YAML, TOML, INI,
112
+ dotenv, and (since [#158]) JSON. Suppressed findings
113
+ are still surfaced — a count by default, the full list with `--show-suppressed`
114
+ — so the gate stays legible. ([#119])
115
+
116
+ - Adoption baseline for `flecto ci`: `--baseline <file>` gates only on findings
117
+ not already recorded, and `--update-baseline` rewrites the file from the
118
+ current findings. This is how a repo with years of pre-existing config turns on
119
+ enforcement without first fixing everything or silencing rules it still wants
120
+ on new config. A finding is keyed on `(rule id, file, path)` — not its value —
121
+ so an accepted `pool-size-jump` stays accepted as the number drifts, and the
122
+ file does not churn. Recorded findings are suppressed from the gate and the
123
+ output; new ones still fail. The file is diff-friendly (one sorted entry per
124
+ finding, with severity, message, `acceptedAt`, and an optional hand-written
125
+ `reason` that updates preserve). Stale entries — recorded findings that no
126
+ longer occur — are reported so the file shrinks; updating is always explicit,
127
+ never automatic, so a run cannot launder new risk into the accepted set.
128
+ Change-based `--fail-on` triggers still fire, since the baseline accepts policy
129
+ findings, not the diff. ([#118])
130
+
131
+ - `flecto ci --format sarif` emits SARIF 2.1.0 for upload to GitHub code
132
+ scanning (`github/codeql-action/upload-sarif`). Policy findings render on the
133
+ pull request diff and in the Security tab, with GitHub handling dedup,
134
+ new-vs-existing, and fixed-finding tracking. Each pack rule maps to a
135
+ `reportingDescriptor` (id, short description, pack, level); `severity` maps to
136
+ SARIF `level` (`error`/`warning`/`note`). `--mask-secrets` applies, since a
137
+ SARIF file is uploaded and retained. Results are **file-level** for now —
138
+ Flecto reports a semantic path, not a source line, so each result anchors at
139
+ the top of the file and preserves the full path as a SARIF logical location;
140
+ GitHub still renders and tracks the alert. Recipe and required
141
+ `security-events: write` permission are in [docs/ci.md](docs/ci.md). ([#120])
142
+
143
+ - `flecto init` now detects Kubernetes manifests and SOPS usage — the two file
144
+ shapes 3.0 was built around — and enables the `kubernetes` and `sops` packs
145
+ accordingly. Detection is content-based: a YAML document must actually carry
146
+ `apiVersion` + `kind` to count as a manifest (a config with a bare `kind:`
147
+ field does not), and SOPS is recognized from a top-level metadata block or a
148
+ `.sops.yaml` creation-rules file. Sniffing is bounded — the repo root plus the
149
+ conventional `k8s/` / `kubernetes/` / `manifests/` / `deploy/` directories, a
150
+ cap on files read, and files over 256 KB skipped — so `init` never turns into
151
+ a full-tree scan. The "detected nothing" generic fallback is unchanged. ([#123])
152
+
153
+ - **JSON with comments and trailing commas is parsed** ([#152]). `.json` was
154
+ read with bare `JSON.parse`, so a single `//` failed the whole file — and a
155
+ config watcher installed into a JavaScript repository could not read the
156
+ `tsconfig.json`, `.vscode/settings.json`, `jsconfig.json`, or
157
+ `devcontainer.json` sitting next to it. Worse, it failed with a *parse error*
158
+ rather than an unsupported-format skip, so it looked broken rather than out of
159
+ scope.
160
+
161
+ Both comment styles and trailing commas are now accepted, and `.jsonc` is a
162
+ recognised extension. No new dependency: comments are blanked in place, one
163
+ space per stripped character, with newlines kept — so byte offsets and line
164
+ numbers in a genuine syntax error still point at the line in your file.
165
+
166
+ The strip tracks string state, because the naive version corrupts exactly the
167
+ values config files carry: `{"url": "https://example.com"}` is a URL, not a
168
+ comment. Comments are not preserved on the parsed value; Flecto never rewrites
169
+ config, and a comment-only edit is not a semantic change. See
170
+ [JSON with comments](docs/configuration.md#json-with-comments).
171
+
172
+ Inline suppressions followed, in [#158] — a directive written in a file that
173
+ visibly supports comments no longer does nothing.
174
+ - **`ci --changed-only`** ([#151]). `ci --format json` emitted an envelope for
175
+ every **scanned** file, not every **changed** one, so the output grew with the
176
+ size of the repository rather than the size of the change. Each envelope
177
+ carries `schema_version`, two UUIDs, an ISO timestamp, and an absolute path —
178
+ on 250 service configs with one file edited, roughly 88% of the output
179
+ described files that did not change.
180
+
181
+ For a human that is invisible, since the terminal renderer already prints only
182
+ what changed. It is the machine consumers that pay: webhook sinks, NDJSON
183
+ readers, and any agent handed the JSON.
184
+
185
+ | change (250 configs) | default | `--changed-only` | reduction |
186
+ |---|---|---|---|
187
+ | nothing changed | 112.8 KB | 13.3 KB | 88% |
188
+ | one file changed | 113.4 KB | 14.3 KB | 87% |
189
+ | every 10th file changed | 126.8 KB | 37.3 KB | 71% |
190
+
191
+ **The evidence that Flecto looked is kept.** An envelope for a scanned but
192
+ unchanged file tells a consumer diffing two runs that a file was *checked and
193
+ clean* rather than *not checked at all*, and dropping it would quietly weaken
194
+ a gate someone relies on. Those files collapse into a single `lifecycle`
195
+ envelope carrying the list of paths, so what is removed is the per-file
196
+ overhead rather than the signal.
197
+
198
+ **Off by default**, so `schema_version` stays `2.0` and existing consumers see
199
+ byte-for-byte identical output. A file with policy findings but no changes is
200
+ never collapsed. Settable as `changedOnly` in `.flectorc`. See
201
+ [CI usage](docs/ci.md#--changed-only).
202
+
203
+ - **`github-actions` policy pack** ([#139]). Workflow YAML is the one config
204
+ file in most repositories where a bad change is a security incident rather
205
+ than an outage, and Flecto already parses it. Eleven declarative rules over
206
+ the CI-takeover shapes: `pull_request_target` added, a new scheduled, manual,
207
+ or reusable-workflow trigger, the `permissions` block removed or widened to
208
+ `write-all` or to `write` on one scope, an action referenced by mutable tag
209
+ instead of a commit SHA, a checkout of the pull-request head, `secrets.*`
210
+ interpolated into `run:`, and a job moved to a self-hosted runner. Enabled by
211
+ `flecto init` when `.github/workflows/` exists. No engine change — the pack
212
+ auto-registers from `src/packs/`.
213
+
214
+ It reports **what the pull request changed**, not what the workflow already
215
+ contained; `actionlint` and `zizmor` already lint the state well. Two limits
216
+ are documented rather than papered over: severity cannot depend on the
217
+ trigger, because a rule sees one change event and cannot consult the rest of
218
+ the document, and `runs-on` changing from a string to a list produces one
219
+ event whose value is an array, which no matcher inspects. Every rule carries
220
+ its reasoning in [policy packs](docs/policy-packs.md#github-actions-workflows),
221
+ and four fixtures pin the boundary — including one asserting **zero** findings
222
+ for changes that only look risky.
223
+
224
+ - **Context-savings measurement in the benchmark harness.** Section 5 of
225
+ `npm run bench` reports the size of the semantic diff against the size of the
226
+ config it describes, in bytes, at three mutation rates plus a single-file
227
+ crossover table. Published in [performance](docs/performance.md#context-savings).
228
+
229
+ The result is more qualified than the claim it was written to check. A sparse
230
+ change in a large file is 50x to 1270x cheaper to read as a diff than as the
231
+ file, and the advantage compounds because a change event plus its envelope
232
+ costs a fixed ~600 bytes while the file grows. But a *dense* change is not
233
+ cheaper at all — at roughly a quarter of a file's keys the payload runs about
234
+ 3x the size of the files it covers — and `ci --format json` currently emits an
235
+ envelope for every **scanned** file rather than every changed one, so with one
236
+ file changed out of 250 roughly 98% of the output is boilerplate for files that
237
+ did not change. ([#137])
238
+
239
+ ### Changed
240
+ - The 3.0 integrations were verified against the real tools they integrate with,
241
+ not only fixtures ([#122]). The HTML report was opened in a real browser — both
242
+ themes render with no JS errors, and the filter, expand/collapse, and
243
+ disclosure triangle work. The encrypted-file path is now tested against output
244
+ from the real `age` binary (`test/fixtures/encrypted-real/`), confirming a real
245
+ age file is detected and never leaks ciphertext through a diff. The
246
+ `flecto-pr-risk` Action was statically reviewed (no runner here to execute a
247
+ live PR) and its flagged mechanics are correct. What was verified, and what
248
+ still needs a real runner / `terraform` / `sops`, is recorded in
249
+ [docs/integration-verification.md](docs/integration-verification.md) — which
250
+ also notes that `flecto report` has no `--mask-secrets` yet, so it renders
251
+ secret values in the clear (a follow-up). ([#122])
252
+
253
+ - **CI runs on Windows and macOS** ([#148]). The matrix varied the Node version
254
+ and nothing else, so every job ran on `ubuntu-latest` — for a tool whose
255
+ primary local mode is watching files by glob, the two platforms where that
256
+ behavior differs had never been tested. Linux keeps the full Node matrix;
257
+ Windows and macOS run one version each, since what they add is the operating
258
+ system rather than the runtime.
259
+
260
+ - **Fuzzing for the boundary an untrusted pull request controls** ([#150]).
261
+ `flecto ci` runs on a pull request, and everything it reads there is
262
+ attacker-supplied: the config files, their names, `.flectorc`, and the regexes
263
+ inside a policy pack the same pull request can add. GHSA-wq8m-fc3q-8m5x came
264
+ out of that surface, and the two DoS vectors fixed after it were found by hand
265
+ — which finds what someone thought to look for.
266
+
267
+ `npm run fuzz` runs eleven structure-aware targets over it: `parseContent` per
268
+ format, `diffTrees`, `expandChangeSubtrees`, Flecto's own regexes in
269
+ `secrets.js` and `encrypted.js`, and pack loading and evaluation. The shared
270
+ invariant is that each either succeeds or throws a clean `Error` — never hangs,
271
+ never exhausts memory, never returns a prototype-polluted object.
272
+
273
+ **No fuzzing dependency.** The inputs are config text, trees, and regex sources
274
+ rather than binary protocols, so the generators are hand-written over a seeded
275
+ PRNG in `test/fuzz/`. That is also what makes a case `(target, seed, index)`
276
+ and nothing else, so `--case N` replays one case without walking the N-1 before
277
+ it.
278
+
279
+ **The time budget is enforced from outside the process.** A hang cannot be
280
+ observed from inside the process that hung, so cases run in a child that writes
281
+ its case index before running the case, and the driver kills the child when the
282
+ heartbeat stops. A failing input is then shrunk — each candidate in its own
283
+ child, so a candidate that hangs shrinks like any other failure.
284
+
285
+ **A finding becomes a regression test by moving one file.** The minimized input
286
+ lands in `test/fuzz/findings/`; moving it to `test/fixtures/fuzz/` is the whole
287
+ procedure, because `test/fuzz-regressions.test.js` replays everything there as
288
+ part of `npm test`. The corpus ships seeded with the already-fixed vectors from
289
+ the security review record.
290
+
291
+ Scheduled nightly, never on a pull request — a fuzz run is a wall-clock budget
292
+ against a random seed, and gating a merge on one is a flaky merge gate — and it
293
+ files nothing automatically, because a finding on this boundary may be
294
+ exploitable rather than merely a hang and those go private per `SECURITY.md`.
295
+
296
+ ### Fixed
297
+
298
+ - **"No snapshot history" no longer renders as "no drift"** ([#141]).
299
+ `.flecto-snapshots/` lives in the working directory and is not committed, so on
300
+ an ephemeral CI runner it is empty on every run — and the drift commands read
301
+ that emptiness as an all-clear. For a tool whose job is making risk visible,
302
+ rendering a clean result from a missing input is the worst failure available.
303
+
304
+ - `flecto watch --diff` exited **0** when no target had a snapshot: nothing
305
+ was compared, and the caller was told the files match their baseline. It now
306
+ errors, and a run where only *some* targets lack a snapshot reports how many
307
+ were skipped instead of quietly diffing the rest.
308
+ - `flecto history` printed `0 changes` for the first snapshot of a file — a
309
+ result that was never computed. First snapshots now read as
310
+ `baseline (no earlier snapshot to compare against)`, and a listing with no
311
+ comparisons in it says so.
312
+ - `flecto report` said "No semantic changes from the previous snapshot" on
313
+ cards that had no previous snapshot. Those now name themselves as first
314
+ snapshots, the summary gains a **Comparisons** tile beside **Changes**, and a
315
+ report in which nothing was compared carries a banner saying so above the
316
+ fold.
317
+ - `flecto ci` already failed closed on a missing baseline, but did it with a
318
+ raw `ENOENT` on a hashed filename. The error now names both ways out —
319
+ save a snapshot, or pass `--snapshot-ref <git-ref>`.
320
+
321
+ The shared snapshot store the issue also asks for is not part of this change;
322
+ what is fixed here is every consumer's answer when the history is empty.
323
+
324
+ - **Symlinked targets could read files from outside the repository** ([#121]).
325
+ A pull request adding a config file that is a symlink out of the checkout had
326
+ that file parsed and its **values** emitted — into the job log, the JSON
327
+ envelope, and the `--format pr-comment` markdown that `--pr-comment-post`
328
+ writes to a comment on the pull request. The attacker never controls the
329
+ linked-to file, which is what makes it worth reading: on a CI runner that
330
+ includes `~/.npmrc`, `~/.docker/config.json`, and `~/.aws/credentials` — which
331
+ is INI, and parses perfectly. Opening a pull request is the whole attack.
332
+
333
+ Every resolved target, and `.flecto-snapshots/` before a snapshot is written,
334
+ is now checked for escape rather than for location, so the legitimate cases are
335
+ untouched: a link that stays inside the project still resolves, and a path
336
+ *named* from outside the project (`flecto compare /a.yaml /b.yaml`) is operator
337
+ intent. Only a path inside the project that resolves out of it is refused —
338
+ loudly, naming `FLECTO_ALLOW_SYMLINK_TARGETS=1` for a checkout that links
339
+ config in from a sibling directory on purpose.
340
+
341
+ - **Prototype pollution in the INI parser** ([#121]). A `.ini` file containing a
342
+ `[__proto__]` section wrote every key in that section onto `Object.prototype`
343
+ for the rest of the process: `parseIni` looked the section up as
344
+ `out[section]`, which resolves to `Object.prototype` for that name — and
345
+ `Object.prototype` passes `isPlainObject`, because its own prototype is
346
+ `null`, so the existing guard did not catch it.
347
+
348
+ The blast radius went past the attacker's own file. `severityRemap[rule.id]`
349
+ is a plain-object lookup, so `dangerous-toggle-enabled=off` under
350
+ `[__proto__]` silenced that rule for **every file in the same run**, turning a
351
+ failing `flecto ci --fail-on error` green. Config file contents are
352
+ attacker-controlled on a pull request, which is the case `flecto ci` exists
353
+ to run in.
354
+
355
+ Sections are now read with `Object.hasOwn` and every key written with
356
+ `Object.defineProperty`, so a reserved name is an ordinary own key holding
357
+ ordinary data — and stays *visible* in the diff, rather than being dropped.
358
+ Two same-class sites were hardened alongside it, neither exploitable: the
359
+ masking walk in `src/renderer.js` and the copy loops in `src/encrypted.js`
360
+ moved a `__proto__` subtree onto the result's prototype, dropping the key from
361
+ the output instead of rendering it. Both now rebuild with
362
+ `Object.fromEntries`.
363
+
364
+ Found by the fuzz harness added in [#150] on its first full-length run.
365
+
366
+ - **A `flecto-ignore-next-line` that resolves to nothing now says so** ([#158]).
367
+ A directive on an array element, in a multi-document YAML file, or in a file
368
+ type with no comment syntax at all was accepted, resolved to no path, matched
369
+ nothing, and produced no output — the operator believed a finding was accepted
370
+ and had no way to learn otherwise. Every such directive now warns on stderr,
371
+ naming the file, the line, and `--baseline` as the way to accept the finding.
372
+
373
+ A warning rather than an error, deliberately: the case already fails closed,
374
+ because the finding the directive meant to accept still fires and still gates
375
+ the build. Failing it a second time adds nothing the first failure did not
376
+ already say. What was missing was the signal, not the gate. (The
377
+ mandatory-reason check stays a hard error — there, a suppression *would* have
378
+ hidden a finding, with no justification recorded.)
379
+
380
+ - Adding a second YAML document beside an existing one no longer re-paths the
381
+ whole file. A lone Kubernetes-shaped document (`apiVersion` + `kind` +
382
+ `metadata.name`) is now keyed by identity — `kind/namespace/name` — exactly as
383
+ it is inside a multi-document file, so a `Service` added next to a `Deployment`
384
+ reads as one addition instead of reporting the untouched Deployment as removed
385
+ and re-added. Ordinary single-document YAML (anything without both
386
+ `apiVersion` and `kind`) is unchanged. ([#124])
387
+
388
+ **Migration:** paths for a *single*-document manifest change from bare
389
+ (`spec.replicas`) to identity-prefixed (`Deployment/prod/api.spec.replicas`).
390
+ Snapshots and CI baselines taken of a single manifest before this release will
391
+ show one-time churn on the next diff; `--ignore` entries and custom pack path
392
+ regexes written against the bare paths need the prefix. Multi-document files
393
+ and non-manifest config are unaffected.
394
+
395
+ - `flecto policies test` now resolves packs installed by `flecto policies add`.
396
+ The harness searched only the fixture directory's `policies/`, while
397
+ `policies add` writes to the invoking project's — so the two commands added in
398
+ the same release did not compose. A fixture's own `policies/` still wins, so
399
+ self-contained fixtures are unaffected; the project is a fallback. The
400
+ "unknown pack" error now names every directory it searched instead of
401
+ suggesting a path that already existed. ([#114])
402
+
403
+ - **`--snapshot-ref <git-ref>` no longer fails on Windows** ([#148]). The
404
+ repository-relative path is derived by comparing `git rev-parse
405
+ --show-toplevel` against the file's own path, and Windows spells one directory
406
+ two ways: git reports the long form, while `os.tmpdir()` and many shells hand
407
+ Flecto the 8.3 short form (`C:\Users\RUNNER~1\...`). Node's JS `realpathSync`
408
+ reconciles neither, so the two compared as different directories and the
409
+ computed relative path climbed out of the repository — `git show` then failed
410
+ on a file that was plainly tracked. Canonicalization now prefers
411
+ `realpathSync.native`, which asks the OS for the final path and so resolves
412
+ short names and normalizes case. Linux and macOS are unaffected: the two calls
413
+ agree for any path that exists. Found by the new Windows runner.
414
+
415
+ - **Glob patterns written with Windows separators now match** ([#148]).
416
+ `resolveFiles` passed user patterns straight to `fast-glob`, which requires
417
+ POSIX separators and reads `\\` as an escape character — so on Windows
418
+ `config\\*.yaml` asked for a file literally named `config*.yaml`, matched
419
+ nothing, and reported `No files matched`, blaming the user for a platform bug.
420
+ Since PowerShell and cmd tab-completion produce backslash paths, that was the
421
+ default way a Windows user would invoke Flecto.
422
+
423
+ Patterns are now rewritten to POSIX separators **on Windows only** — on Linux
424
+ and macOS a backslash is a legal filename character and a meaningful glob
425
+ escape, so rewriting there would break patterns that work today. `exclude`
426
+ patterns get the same rewrite, since an exclude that silently stops excluding
427
+ widens what Flecto reports on. Resolved paths stay native.
428
+ - **`ci --format json` no longer truncates at 64 KB through a pipe** ([#155]).
429
+ Output was printed with `console.log` and followed immediately by
430
+ `process.exit()`, which does not flush a pending write — and Node writes to a
431
+ pipe asynchronously. Everything past the 64 KB pipe buffer was dropped, and
432
+ the command still exited with its normal status.
433
+
434
+ Redirecting to a file hid it, because Node writes to a file descriptor
435
+ synchronously. It appeared only through a pipe — which is how every consumer
436
+ that matters reads it: `| jq`, `$(...)` capture, and any CI harness collecting
437
+ stdout.
438
+
439
+ A truncated envelope stream that exits normally is the worst shape for a
440
+ consumer: it reads as a clean run over fewer files rather than as a failure.
441
+ With `ndjson` it is quieter still, since every line before the cut is valid
442
+ JSON, so a line-by-line reader consumes a clean prefix and never learns the
443
+ rest existed.
444
+
445
+ Affected `ci`, `plan`, and `diff`/`compare` on `--format json`, `ndjson`,
446
+ `sarif`, and `github-annotations`. A truncated SARIF document is rejected
447
+ outright by `upload-sarif`, but only after the gate has already reported
448
+ success. `--format pr-comment` was never affected — its body is capped at
449
+ 60,000 characters to fit GitHub's comment limit, which lands under one pipe
450
+ buffer.
451
+
452
+ ### Security
453
+
454
+ - **Two denial-of-service vectors fixed, found while resuming the 3.0 security
455
+ review** ([#121]). (1) Secret detection (`src/secrets.js`), which runs on every
456
+ changed string value under the `default` pack, had two `O(n²)` regexes — the
457
+ PEM private-key and URL-credential patterns — so a single ~500 KB value in a
458
+ pull request could hang the CI job. Both are now linear; 1 MB scans in under a
459
+ second, and detection of real (including unterminated) keys is unchanged. (2) A
460
+ YAML alias bomb ("billion laughs") — a few hundred bytes of nested aliases that
461
+ `normalizeParsedValue` expanded into an exponentially large tree — now fails
462
+ fast against a node budget instead of exhausting memory. Regression tests for
463
+ both in `test/security.test.js`. The review's findings and its "checked, solid"
464
+ list are recorded in [docs/security-review.md](docs/security-review.md); a
465
+ residual limitation (attacker-supplied regexes in custom packs, which Node
466
+ cannot time out) is noted in [SECURITY.md](SECURITY.md).
467
+
468
+ - **Terraform plan JSON is refused by every command except `flecto plan`.**
469
+ Terraform's `before_sensitive` / `after_sensitive` redaction is applied only by
470
+ `flecto plan`; a plan file is ordinary JSON, so `ci`, `watch`, `compare`,
471
+ `report`, and snapshot writes read it as a plain config tree and printed the
472
+ values Terraform itself refuses to print. `--mask-secrets` was not a backstop —
473
+ it fires on the attribute *name*, and `user_data` does not match. Realistic
474
+ ways to hit it: `flecto ci "**/*.json"`, a committed `tfplan.json`, or
475
+ `.flectorc` `files` patterns that sweep JSON. Those commands now fail with a
476
+ pointer to `flecto plan`, mirroring the guard `flecto plan` already had in the
477
+ other direction. ([#113])
478
+
479
+ ## [3.0.1] - 2026-08-07
480
+
481
+ ### Security
482
+
483
+ - **Policy plugins declared in `.flectorc` are no longer loaded**
484
+ ([GHSA-wq8m-fc3q-8m5x], critical). A pull request that added a `.flectorc`
485
+ with a `plugins` entry achieved **arbitrary code execution on the CI runner** —
486
+ `flecto ci` is what teams run on pull requests, and it honoured the attacker's
487
+ config with no opt-in, no allowlist, and no path containment. The attacker's
488
+ code ran with whatever the workflow exposed, including `GITHUB_TOKEN`, and the
489
+ path was not contained, so `../../../../tmp/x.mjs` loaded a module from
490
+ anywhere on disk.
491
+
492
+ Plugins now load only from an explicit `--plugins` flag. If a config file is
493
+ genuinely trusted, set `FLECTO_ALLOW_RC_PLUGINS=1`; even then an rc-declared
494
+ plugin must live inside the working directory. Flecto **fails loudly** rather
495
+ than skipping the plugin silently, because a policy plugin that stopped running
496
+ without saying so would quietly weaken a gate the operator believes is
497
+ enforced.
498
+
499
+ Policy *packs* are declarative and were never affected. `--plugins` is
500
+ unchanged, including paths outside the project, since the flag is operator
501
+ intent rather than attacker input.
502
+
503
+ **If you run Flecto on untrusted pull requests, upgrade.** If you rely on
504
+ `plugins` in `.flectorc`, move it to `--plugins` or set the opt-in.
505
+
506
+ The trust boundary is now documented in [plugin authoring](docs/plugins.md);
507
+ it previously was not stated anywhere.
508
+
509
+
10
510
  ## [3.0.0] - 2026-08-06
11
511
 
12
512
  ### Migration notes
@@ -472,7 +972,9 @@ fixed — those runs were never actually gated — but the failure is new.
472
972
  - Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
473
973
  continuing with no policies.
474
974
 
475
- [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...HEAD
975
+ [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...HEAD
976
+ [3.0.2]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...v3.0.2
977
+ [3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
476
978
  [3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
477
979
  [2.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.0.0...v2.1.0
478
980
  [#6]: https://github.com/myselfsiddharth/Flecto/issues/6
@@ -529,5 +1031,36 @@ fixed — those runs were never actually gated — but the failure is new.
529
1031
  [#108]: https://github.com/myselfsiddharth/Flecto/pull/108
530
1032
  [#109]: https://github.com/myselfsiddharth/Flecto/issues/109
531
1033
  [#110]: https://github.com/myselfsiddharth/Flecto/issues/110
1034
+ [#122]: https://github.com/myselfsiddharth/Flecto/issues/122
1035
+
1036
+ [#121]: https://github.com/myselfsiddharth/Flecto/issues/121
1037
+
1038
+ [#119]: https://github.com/myselfsiddharth/Flecto/issues/119
1039
+
1040
+ [#118]: https://github.com/myselfsiddharth/Flecto/issues/118
1041
+
1042
+ [#120]: https://github.com/myselfsiddharth/Flecto/issues/120
1043
+
1044
+ [#123]: https://github.com/myselfsiddharth/Flecto/issues/123
1045
+
1046
+ [#124]: https://github.com/myselfsiddharth/Flecto/issues/124
1047
+
1048
+ [#113]: https://github.com/myselfsiddharth/Flecto/issues/113
1049
+
1050
+ [#114]: https://github.com/myselfsiddharth/Flecto/issues/114
1051
+ [#148]: https://github.com/myselfsiddharth/Flecto/issues/148
1052
+ [#152]: https://github.com/myselfsiddharth/Flecto/issues/152
1053
+ [#151]: https://github.com/myselfsiddharth/Flecto/issues/151
1054
+ [#155]: https://github.com/myselfsiddharth/Flecto/issues/155
1055
+ [#139]: https://github.com/myselfsiddharth/Flecto/issues/139
1056
+ [#137]: https://github.com/myselfsiddharth/Flecto/issues/137
1057
+ [#149]: https://github.com/myselfsiddharth/Flecto/issues/149
1058
+ [#158]: https://github.com/myselfsiddharth/Flecto/issues/158
1059
+ [#159]: https://github.com/myselfsiddharth/Flecto/issues/159
1060
+ [#150]: https://github.com/myselfsiddharth/Flecto/issues/150
1061
+ [#125]: https://github.com/myselfsiddharth/Flecto/issues/125
1062
+ [#141]: https://github.com/myselfsiddharth/Flecto/issues/141
532
1063
  [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
1064
+ [#138]: https://github.com/myselfsiddharth/Flecto/issues/138
533
1065
  [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
1066
+ [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
@@ -188,6 +200,9 @@ steps:
188
200
  - uses: myselfsiddharth/Flecto/.github/actions/flecto-pr-risk@main
189
201
  ```
190
202
 
203
+ GitLab and Bitbucket work the same way — Flecto detects the host from CI
204
+ variables, or `--pr-provider` forces one. See [CI](docs/ci.md#providers).
205
+
191
206
  A fork's pull request gets a read-only token, so the comment is skipped with a
192
207
  warning there — the check itself still runs and still fails on risky changes.
193
208
 
@@ -285,6 +300,37 @@ resource. Flecto never runs `helm` or `kustomize` — you render, it diffs, so a
285
300
  renderer works and no binary is needed in CI.
286
301
  → **[Kubernetes](docs/kubernetes.md)**
287
302
 
303
+ ### Read a Terraform plan in plain English
304
+
305
+ `terraform plan` output is precise and long. Flecto turns it into the handful of
306
+ lines a reviewer actually needs to argue about:
307
+
308
+ ```bash
309
+ terraform show -json plan.tfplan > plan.json
310
+ flecto plan plan.json --fail-on error
311
+ ```
312
+
313
+ ```
314
+ plan.json — plan format 1.2
315
+ Plan: 0 to add, 1 to change, 0 to destroy, 1 to replace.
316
+ ~ aws_security_group.web.ingress[0].cidr_blocks[0]: "10.0.0.0/8" → "0.0.0.0/0"
317
+ - aws_db_instance.main.#action: "replace" [terraform will destroy and recreate aws_db_instance.main]
318
+ ~ aws_db_instance.main.password: "(sensitive value)" → "(sensitive value)" [sensitive]
319
+ ! policy(error) [terraform] …cidr_blocks[0]: Security group ingress will accept
320
+ traffic from the whole internet (0.0.0.0/0). Restrict the source to a known CIDR…
321
+ ! policy(error) [terraform] …#action: Terraform will destroy a stateful resource.
322
+ Its data does not survive. Take a final snapshot, or add a prevent_destroy…
323
+ ```
324
+
325
+ A **replace reads as a removal**, not a benign update — a recreated database
326
+ should never look like a config tweak. Values Terraform marks sensitive are
327
+ redacted during parsing, before the policy engine or any formatter sees them, and
328
+ `after_unknown` renders as `(known after apply)` rather than `null`.
329
+
330
+ **Flecto never runs `terraform`** — you produce the JSON, it reads it, so nothing
331
+ extra has to exist on the CI runner.
332
+ → **[Terraform plans](docs/terraform.md)**
333
+
288
334
  ### Encode your own rules
289
335
 
290
336
  Beyond the built-in packs, write rules as declarative JSON or YAML — no code:
@@ -327,6 +373,7 @@ services doesn't read as a wall of changes.
327
373
  | `node-runtime` | Dropped engine requirements, TLS verification bypasses, debug/inspector flags |
328
374
  | `terraform` | Replaced and destroyed stateful resources, ingress opened to `0.0.0.0/0`, IAM wildcards, public S3, capacity jumps |
329
375
  | `sops` | Decryption recipients added or removed, a MAC that moved on its own, a file that stopped being encrypted |
376
+ | `github-actions` | Changed workflow triggers, widened permissions, self-hosted runners, unpinned actions, pull-request head checkout, and secrets interpolated into `run` |
330
377
 
331
378
  ```bash
332
379
  flecto policies list # see what resolves here, built-in and local
@@ -358,13 +405,23 @@ and runs no code from the package. →
358
405
 
359
406
  | Format | Extensions |
360
407
  |---|---|
361
- | JSON | `.json` |
408
+ | JSON / JSONC | `.json`, `.jsonc` |
362
409
  | YAML | `.yaml`, `.yml` |
363
410
  | TOML | `.toml` |
364
411
  | INI | `.ini` |
365
412
  | dotenv | `.env`, `.env.*`, `*.env` |
366
413
  | age (armored) | `.age`, or any file whose contents are one armored blob |
367
414
 
415
+ `.json` accepts comments and trailing commas, so `tsconfig.json`,
416
+ `.vscode/settings.json`, `jsconfig.json`, and `devcontainer.json` are read as
417
+ written. →
418
+ **[JSON with comments](docs/configuration.md#json-with-comments)**
419
+
420
+ Terraform plan JSON (`terraform show -json`) is read by **`flecto plan`**, which
421
+ applies Terraform's own sensitivity marking. Point `plan` at it rather than `ci`
422
+ or `watch` — those treat it as ordinary JSON and will print values Terraform
423
+ marks sensitive ([#113](https://github.com/myselfsiddharth/Flecto/issues/113)).
424
+
368
425
  Multi-document YAML (`---`-separated, the usual shape of a Kubernetes manifest)
369
426
  is supported. Each document is diffed under its own key — `kind/name` for
370
427
  Kubernetes-shaped documents, so a document inserted at the top of the file does
@@ -463,6 +520,7 @@ Explicit CLI flags win over profiles, which win over `defaults`.
463
520
  | **[Configuration](docs/configuration.md)** | `.flectorc`, profiles, ignore patterns, array identity, masking |
464
521
  | **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
465
522
  | **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled GitHub Actions |
523
+ | **[Performance](docs/performance.md)** | Where time goes at scale, and how much smaller a diff is than the config |
466
524
  | **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
467
525
  | **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
468
526
  | **[Webhooks and commands](docs/webhooks.md)** | Envelope shape, delivery modes, command environment |