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