flecto 3.0.1 → 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 +501 -1
- package/README.md +11 -1
- package/index.js +410 -44
- package/package.json +4 -1
- package/schemas/flecto-policy-pack-2.0.json +2 -0
- package/src/baseline.js +193 -0
- package/src/config.js +404 -6
- package/src/encrypted.js +16 -13
- package/src/packs/github-actions.json +92 -0
- package/src/parser.js +212 -22
- package/src/policy-test.js +5 -1
- package/src/policy.js +96 -23
- package/src/pr-comment.js +53 -87
- package/src/pr-providers.js +261 -0
- package/src/renderer.js +7 -7
- package/src/report.js +39 -1
- package/src/sarif.js +144 -0
- package/src/secrets.js +41 -7
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,475 @@ 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
|
+
|
|
10
479
|
## [3.0.1] - 2026-08-07
|
|
11
480
|
|
|
12
481
|
### Security
|
|
@@ -503,7 +972,8 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
503
972
|
- Misconfigured policy packs/plugins cause `watch` to exit non-zero instead of
|
|
504
973
|
continuing with no policies.
|
|
505
974
|
|
|
506
|
-
[Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.
|
|
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
|
|
507
977
|
[3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
|
|
508
978
|
[3.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.1.0...v3.0.0
|
|
509
979
|
[2.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v2.0.0...v2.1.0
|
|
@@ -561,6 +1031,36 @@ fixed — those runs were never actually gated — but the failure is new.
|
|
|
561
1031
|
[#108]: https://github.com/myselfsiddharth/Flecto/pull/108
|
|
562
1032
|
[#109]: https://github.com/myselfsiddharth/Flecto/issues/109
|
|
563
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
|
|
564
1063
|
[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
|
|
1064
|
+
[#138]: https://github.com/myselfsiddharth/Flecto/issues/138
|
|
565
1065
|
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
|
|
566
1066
|
[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
|
|
|
@@ -370,6 +373,7 @@ services doesn't read as a wall of changes.
|
|
|
370
373
|
| `node-runtime` | Dropped engine requirements, TLS verification bypasses, debug/inspector flags |
|
|
371
374
|
| `terraform` | Replaced and destroyed stateful resources, ingress opened to `0.0.0.0/0`, IAM wildcards, public S3, capacity jumps |
|
|
372
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` |
|
|
373
377
|
|
|
374
378
|
```bash
|
|
375
379
|
flecto policies list # see what resolves here, built-in and local
|
|
@@ -401,13 +405,18 @@ and runs no code from the package. →
|
|
|
401
405
|
|
|
402
406
|
| Format | Extensions |
|
|
403
407
|
|---|---|
|
|
404
|
-
| JSON | `.json` |
|
|
408
|
+
| JSON / JSONC | `.json`, `.jsonc` |
|
|
405
409
|
| YAML | `.yaml`, `.yml` |
|
|
406
410
|
| TOML | `.toml` |
|
|
407
411
|
| INI | `.ini` |
|
|
408
412
|
| dotenv | `.env`, `.env.*`, `*.env` |
|
|
409
413
|
| age (armored) | `.age`, or any file whose contents are one armored blob |
|
|
410
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
|
+
|
|
411
420
|
Terraform plan JSON (`terraform show -json`) is read by **`flecto plan`**, which
|
|
412
421
|
applies Terraform's own sensitivity marking. Point `plan` at it rather than `ci`
|
|
413
422
|
or `watch` — those treat it as ordinary JSON and will print values Terraform
|
|
@@ -511,6 +520,7 @@ Explicit CLI flags win over profiles, which win over `defaults`.
|
|
|
511
520
|
| **[Configuration](docs/configuration.md)** | `.flectorc`, profiles, ignore patterns, array identity, masking |
|
|
512
521
|
| **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
|
|
513
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 |
|
|
514
524
|
| **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
|
|
515
525
|
| **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
|
|
516
526
|
| **[Webhooks and commands](docs/webhooks.md)** | Envelope shape, delivery modes, command environment |
|