flecto 3.1.0 → 4.0.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,191 @@ The format is based on [Keep a Changelog], and this project adheres to
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [4.0.0] - 2026-09-23
11
+
12
+ **A security release.** Every breaking change below exists because a pull
13
+ request could otherwise make `flecto ci` report a clean run on a change that was
14
+ not clean. If you run Flecto on untrusted pull requests, upgrading is not
15
+ optional.
16
+
17
+ See **[docs/migrating-to-4.md](docs/migrating-to-4.md)** for what to change, and
18
+ the [security advisories](https://github.com/myselfsiddharth/Flecto/security/advisories)
19
+ for what was wrong.
20
+
21
+ ### Breaking
22
+
23
+ - `snapshotRef` and `snapshotFile` declared in `.flectorc` are refused
24
+ (`FLECTO_ALLOW_RC_BASELINE=1` opts back in).
25
+ - `--snapshot-ref` takes a git revision. A bare snapshot filename needs
26
+ `--snapshot-file`, or a `./` prefix. The bundled `flecto-ci` Action gains a
27
+ `snapshot-file:` input.
28
+ - Policy-pack regular expressions outside `src/packs/` are compiled with RE2:
29
+ lookaround, backreferences, `\uXXXX` escapes, and `v`-flag set subtraction now
30
+ fail at load, and a few constructs match differently.
31
+ - `.flecto-queue/` is keyed by destination. A 3.x backlog is kept but not
32
+ auto-delivered.
33
+ - The `--command` spill file is deleted when the command exits, so a script must
34
+ read `FLECTO_CHANGES_FILE` while the command is still running.
35
+ - Flecto now requires git 2.24 or newer.
36
+
37
+ ### Added
38
+
39
+ - **`flecto explain` and `ci --explain`: opt-in, advisory narration of a diff by
40
+ a model you configure** ([#143]). `pool_size: 5 → 20` is mechanical; "this
41
+ quadruples connections per replica, so check `max_connections`" is judgment,
42
+ and it is what a reviewer wants at 2 AM. Bring your own key: `anthropic`
43
+ (Messages API, default model `claude-opus-5`) or `openai`, which covers any
44
+ OpenAI-compatible server, including a local one. Each is a single `fetch`, so
45
+ no vendor SDK and no new dependency.
46
+
47
+ The constraints are the feature. **Only the masked semantic diff is sent**,
48
+ masked unconditionally while the payload is built, never file contents, with
49
+ encrypted values as sentinels. `--dry-run` / `--explain-dry-run` print the exact
50
+ request and send nothing. **It never touches an exit code**: `ci` decides the
51
+ gate first, and every failure (no provider, over budget, timeout, HTTP error,
52
+ refusal) is a warning. `ci`'s stdout is byte-identical except under
53
+ `--format pr-comment`, where the narration goes in its own labeled section,
54
+ fenced so links, images, mentions, and HTML render as text. **The operator
55
+ configures it and the repository cannot**: `explain*` options in `.flectorc`
56
+ are refused, provider, endpoint, and key come only from the CLI and
57
+ `FLECTO_EXPLAIN_*` variables, redirects are refused rather than forwarding
58
+ `x-api-key`, and `FLECTO_EXPLAIN=0` is a runner-wide kill switch. **Cost is
59
+ stated before the call**, with estimated input tokens and the output cap.
60
+ Diffs over an input budget are skipped, not truncated. Identical requests are
61
+ served from a cache outside the repository, keyed with an HMAC of the API key
62
+ so a pull request cannot plant an entry. See [docs/explain.md](docs/explain.md).
63
+
64
+ - **`flecto mcp` — a read-only Model Context Protocol server over stdio**
65
+ ([#140]). An agent asked to debug a config incident no longer has to read a
66
+ two-thousand-line manifest into its context to learn that `pool_size` doubled;
67
+ Flecto already computes that small answer and now hands it over as a structured
68
+ tool result. Three read-only tools — `flecto_diff`, `flecto_check`, and
69
+ `flecto_explain` — each run the same `ci` path a pull request triggers and
70
+ return the JSON envelope.
71
+
72
+ The security posture is inherited from `ci` by construction: the tools can only
73
+ reach what `ci` reaches (no `--command`, no writes, no webhooks, no plugins —
74
+ the last enforced regardless of `FLECTO_ALLOW_RC_PLUGINS`), no tool argument is
75
+ ever parsed as a CLI option, tool-argument paths — including a `ref` that names
76
+ a snapshot file — are contained before anything spawns, and results are
77
+ bounded. **Secrets are masked by default here, inverted from the CLI**,
78
+ because the consumer is a model context that is transmitted and often logged;
79
+ `mask: false` is an explicit, documented opt-out. Adds no runtime dependency —
80
+ the stdio JSON-RPC framing is spoken directly. See [docs/mcp.md](docs/mcp.md).
81
+
82
+ - **`flecto lsp`: findings and semantic changes as editor diagnostics while a
83
+ config file is being edited** ([#142]). It's a Language Server Protocol server
84
+ over stdio, compared against `HEAD` by default (`--snapshot-ref`,
85
+ `--snapshot-store`), and it **agrees with the merge gate**: it uses the same
86
+ `.flectorc`, packs, `severityRemap`, scope (`files`/`include`/`exclude`), inline
87
+ suppressions, and `--baseline` file as `ci`. A suppression missing its reason
88
+ is the error CI fails on. The hard part the issue named, positions, is a new
89
+ module (`src/positions.js`) that maps a diff path back into the source text for
90
+ YAML (including anchors, merge keys, and multi-document manifests), JSON/JSONC,
91
+ dotenv, INI, and TOML. A position is used only where an independent scan of
92
+ the text agrees with the parsed tree. Anything else anchors at the nearest
93
+ verified ancestor, never at a guess. Across every fixture and example in the
94
+ repository, no exact position lands on the wrong key. Analyses run in a worker
95
+ thread, debounced, cancelled by a newer edit, and stopped at `--timeout`, so a
96
+ catastrophic pack regex costs one warning instead of a wedged server.
97
+ **Plugins declared in `.flectorc` are never loaded**, even with
98
+ `FLECTO_ALLOW_RC_PLUGINS=1`, since opening a repository in an editor is the
99
+ untrusted-PR threat model. `--plugins` must be absolute paths. See
100
+ [docs/editor.md](docs/editor.md).
101
+
102
+ ### Security
103
+
104
+ - **BREAKING: `snapshotRef` declared in `.flectorc` is refused** ([#121]). The
105
+ baseline decides what counts as a change, so a pull request that sets it
106
+ decides the verdict: a committed `{"defaults": {"snapshotRef": "HEAD"}}`
107
+ compared every file against the pull request's own tip and exited 0 on a
108
+ config that disabled TLS. Pass `--snapshot-ref` on the command line — the form
109
+ every example and the shipped Action already use — or set
110
+ `FLECTO_ALLOW_RC_BASELINE=1` if the rc file is trusted.
111
+ - **`--snapshot-file <path>` is added, and `--snapshot-ref` is a git revision**
112
+ ([#121]). Overloading one flag with both is what let an attacker-committed
113
+ file stand in for the operator's baseline. **This is breaking**: only a value
114
+ that is unambiguously a path — absolute, or starting `./` or `../`, shapes
115
+ git's ref format cannot produce — is still read as a file by
116
+ `--snapshot-ref`. A bare `--snapshot-ref snapshots/base.json` now fails and
117
+ says to use `--snapshot-file`. The bundled `flecto-ci` Action gains a
118
+ `snapshot-file:` input for the same reason.
119
+ When git is missing, too old, or not looking at a repository, Flecto refuses
120
+ rather than falling back to a file.
121
+ - **A baseline ref can no longer be crafted into a file write, a shadowed
122
+ baseline, or an empty diff** ([#121]). Three shapes, one property:
123
+ `--output=pwned` was read by git as an *option* and wrote a file while the
124
+ emptied read made every key look `added` so the default `--fail-on` never
125
+ fired; a committed file named after the operator's ref (`HEAD~1`, the shipped
126
+ Action's default) shadowed the baseline with one the attacker wrote; and a
127
+ commit range such as `HEAD:..` succeeded while printing nothing, for the same
128
+ silent pass. Refs now resolve through `git rev-parse --verify <ref>^{commit}`,
129
+ revision before file, and `git show` receives the resolved SHA.
130
+ - **BREAKING: pack-supplied regular expressions are compiled with RE2**
131
+ ([#121]). A policy pack is attacker input on an untrusted pull request --
132
+ `policies/*.json` is committed and `.flectorc` selects which packs run -- and
133
+ Node's engine backtracks, so `^(a+)+$` took **97 seconds** against a 44-character
134
+ value and grew exponentially. No in-process timeout could help: the
135
+ backtracking happens inside one uninterruptible call into the engine. Packs
136
+ outside `src/packs/` now use a linear-time engine (`re2js`, pure JS, no native
137
+ build), which answers the same pattern in 3 ms. The packs Flecto ships keep
138
+ the native engine. RE2 does not support lookaround, backreferences, `\uXXXX`
139
+ escapes, or `v`-flag set subtraction, so a pack using them now fails to load
140
+ with a message naming the rule; a few constructs also *match* differently, and
141
+ [docs/policy-packs.md](docs/policy-packs.md#regular-expressions-in-packs)
142
+ tables both sets.
143
+ - **A pack regex with the `g` flag no longer fires on alternate files.** Packs
144
+ are cached and shared across every file in a run, and a `g` regex carries a
145
+ mutable `lastIndex` that `.test()` advances, so such a rule matched every
146
+ other value it saw.
147
+ ### Added
148
+
149
+ - **`flecto-drift`: compare a declared config file against what is actually
150
+ running** ([#144]). A **separate binary**, deliberately: every other Flecto
151
+ command authenticates to nothing, and reading live state cannot keep that
152
+ promise, so it does not share an entry point with the tool that can. `flecto
153
+ ci` cannot reach it and installing Flecto does not enable it.
154
+ It holds **no credentials** — Kubernetes and SSM are read through `kubectl`
155
+ and `aws`, which you have already authenticated, so Flecto inherits exactly
156
+ what those are entitled to. Read-only is structural: argv is built from a
157
+ fixed verb allowlist and nothing from the URI can reach it as a flag. Values
158
+ from a secret store are compared **by shape** (length and digest), never by
159
+ value, with no flag to change that; SSM is read without `--with-decryption`.
160
+ Terraform state exposes only `outputs`. See [docs/drift.md](docs/drift.md).
161
+
162
+ ### Fixed
163
+
164
+ - **The shared snapshot store now refuses a Windows target on another drive or
165
+ a UNC share** ([#141], [#121]). The store keys a snapshot by its repo-relative
166
+ path and refuses a file outside the repository, but recognised "outside" only
167
+ as a `..`-prefixed path. On Windows, `path.relative` cannot reach another drive
168
+ or a share and returns the target absolute instead, which was accepted as a
169
+ key: a cross-drive write failed on a raw `ENOENT`, and a UNC one was written
170
+ under a meaningless `server/share/…` key. Neither left `.flecto/snapshots/`.
171
+ Both are now refused with the same message as any other outside target.
172
+
173
+ - **`watch --command`/`--webhook`/`--webhook-header` declared in `.flectorc`
174
+ are refused, not honored** ([#121]). All three merge through the ordinary
175
+ options path with no other gate, unlike
176
+ `--plugins`/`--output`/`--baseline`/`--update-baseline`, which were already
177
+ refused there. `command` spawns a shell command on every change;
178
+ `.flectorc` is attacker-controlled on an untrusted pull request, so a
179
+ `.flectorc` naming one got arbitrary shell execution on the next `flecto
180
+ watch` — no `--command` flag required. Confirmed end to end: a hostile
181
+ `.flectorc` alone, with nothing passed on the command line, ran a command that
182
+ wrote a marker file outside anything the run otherwise touched. `webhook` is
183
+ the same shape one step down — it sends the change payload to a URL the
184
+ pull request chose. `webhook-header` is reachable even when `webhook` itself
185
+ is the operator's own flag: an rc-declared header rides along on that
186
+ already-approved request and can override it. All three are refused with the
187
+ message the plugin and write guards already use, `FLECTO_ALLOW_RC_ALERTS=1`
188
+ opts out for a repository that configures one in `.flectorc` on purpose, and
189
+ any of the three named on the command line is untouched, because that is the
190
+ operator. `--delivery-mode`/`--on-alert-failure` are untouched either way —
191
+ they only tune failure handling for an alert the operator already chose, the
192
+ same "operator delegates a setting" shape `--fail-on` already has, and
193
+ `flecto init` writes both into the config it generates.
194
+
10
195
  ## [3.1.0] - 2026-09-15
11
196
 
12
197
  ### Added
@@ -1038,6 +1223,7 @@ fixed — those runs were never actually gated — but the failure is new.
1038
1223
  continuing with no policies.
1039
1224
 
1040
1225
  [Unreleased]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...HEAD
1226
+ [4.0.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.1.0...v4.0.0
1041
1227
  [3.1.0]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.2...v3.1.0
1042
1228
  [3.0.2]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.1...v3.0.2
1043
1229
  [3.0.1]: https://github.com/myselfsiddharth/Flecto/compare/v3.0.0...v3.0.1
@@ -1125,8 +1311,12 @@ fixed — those runs were never actually gated — but the failure is new.
1125
1311
  [#159]: https://github.com/myselfsiddharth/Flecto/issues/159
1126
1312
  [#150]: https://github.com/myselfsiddharth/Flecto/issues/150
1127
1313
  [#125]: https://github.com/myselfsiddharth/Flecto/issues/125
1314
+ [#143]: https://github.com/myselfsiddharth/Flecto/issues/143
1128
1315
  [#141]: https://github.com/myselfsiddharth/Flecto/issues/141
1316
+ [#140]: https://github.com/myselfsiddharth/Flecto/issues/140
1129
1317
  [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
1130
1318
  [#138]: https://github.com/myselfsiddharth/Flecto/issues/138
1131
1319
  [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
1132
1320
  [GHSA-wq8m-fc3q-8m5x]: https://github.com/myselfsiddharth/Flecto/security/advisories/GHSA-wq8m-fc3q-8m5x
1321
+ [#142]: https://github.com/myselfsiddharth/Flecto/issues/142
1322
+ [#144]: https://github.com/myselfsiddharth/Flecto/issues/144
package/README.md CHANGED
@@ -501,6 +501,7 @@ Explicit CLI flags win over profiles, which win over `defaults`.
501
501
  | `flecto watch --snapshot` | Save the current state as a baseline |
502
502
  | `flecto watch --diff` | Compare against the baseline and exit |
503
503
  | `flecto ci [files...]` | One-shot check with a gate-able exit code |
504
+ | `flecto explain [files...]` | Advisory, model-generated narration of the masked diff (opt-in, your own key) |
504
505
  | `flecto compare <fileA> <fileB>` | Diff two files against each other (`fileA` is the baseline) |
505
506
  | `flecto plan <planFiles...>` | Review `terraform show -json` output and gate on it |
506
507
  | `flecto history [files...]` | Summarize drift across local snapshots |
@@ -509,6 +510,8 @@ Explicit CLI flags win over profiles, which win over `defaults`.
509
510
  | `flecto policies list` | List available policy packs |
510
511
  | `flecto policies test <dir>` | Assert pack and plugin findings from fixtures |
511
512
  | `flecto init` | Create a `.flectorc` from detected stack signals |
513
+ | `flecto mcp` | Serve read-only diff/check/explain tools to an agent over MCP |
514
+ | `flecto lsp` | Show findings and changes as diagnostics while a config file is edited |
512
515
  | `flecto doctor` | Check setup, config, and environment |
513
516
 
514
517
  → **[Every flag, every command](docs/cli-reference.md)**
@@ -521,15 +524,20 @@ Explicit CLI flags win over profiles, which win over `defaults`.
521
524
  |---|---|
522
525
  | **[CLI reference](docs/cli-reference.md)** | Every command, flag, and exit code |
523
526
  | **[Configuration](docs/configuration.md)** | `.flectorc`, profiles, ignore patterns, array identity, masking |
527
+ | **[Editor diagnostics](docs/editor.md)** | `flecto lsp` setup for Neovim, Helix, Emacs, and where diagnostics land |
524
528
  | **[Encrypted files](docs/encrypted-files.md)** | SOPS and age: what is detected, what is reported, why nothing is decrypted |
525
529
  | **[CI](docs/ci.md)** | Baselines, fail triggers, output formats, the bundled GitHub Actions |
526
530
  | **[Performance](docs/performance.md)** | Where time goes at scale, and how much smaller a diff is than the config |
527
531
  | **[Kubernetes](docs/kubernetes.md)** | Diffing rendered Helm/Kustomize manifests before they reach a cluster |
528
532
  | **[Terraform plans](docs/terraform.md)** | Reviewing `terraform show -json` output and the `terraform` pack |
529
533
  | **[Webhooks and commands](docs/webhooks.md)** | Envelope shape, delivery modes, command environment |
534
+ | **[MCP server](docs/mcp.md)** | Read-only diff/check/explain tools for agents, and the security posture |
535
+ | **[Explain](docs/explain.md)** | Opt-in model narration of a diff: what is sent, what it can never do, cost |
530
536
  | **[Policy packs](docs/policy-packs.md)** | Writing declarative rules |
531
537
  | **[Plugins](docs/plugins.md)** · **[Cookbook](docs/plugin-cookbook.md)** | Rules that need real code |
538
+ | **[Live drift](docs/drift.md)** | `flecto-drift`: comparing a declared config against what is actually running |
532
539
  | **[Troubleshooting](docs/troubleshooting.md)** | When something doesn't behave |
540
+ | **[Migrating to 4.0](docs/migrating-to-4.md)** | The five breaking changes, and how to tell whether they affect you |
533
541
  | **[Changelog](CHANGELOG.md)** | Release history and migration notes |
534
542
 
535
543
  ---
package/drift.js ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `flecto-drift` — compare what a repository declares against what is running.
4
+ *
5
+ * Flecto answers "what changed in this file". The question underneath it is
6
+ * usually "does what we declared still match what is running" — the config
7
+ * committed six months ago, against the value somebody hotfixed into the
8
+ * cluster at 3 AM and never backported (#144).
9
+ *
10
+ * **This is a separate binary on purpose.** Core Flecto authenticates to
11
+ * nothing, reads no key material, and shells out to nothing but `git`; every
12
+ * other command in this package keeps that promise. Reading live state cannot,
13
+ * so it does not share an entry point with the tool that can. `flecto ci`
14
+ * cannot reach this file, nothing in `src/` outside `drift-sources.js` imports
15
+ * it, and installing Flecto does not enable it. The intent is for it to become
16
+ * its own package with its own security review and release cadence; it lives
17
+ * here now for the same reason the MCP server did, which is that one repository
18
+ * is easier to review than two while the shape is still settling.
19
+ *
20
+ * It holds no credentials. Every source delegates to a tool the operator has
21
+ * already installed and authenticated (`kubectl`, `aws`), so Flecto inherits
22
+ * exactly what that tool is entitled to and nothing else — which is also what
23
+ * makes "give it a read-only role" advice an operator can enforce in their own
24
+ * IAM rather than a promise this code makes about itself.
25
+ *
26
+ * Values from a secret store are compared by **shape**, never by value, and
27
+ * there is no flag to change that.
28
+ */
29
+
30
+ import { program } from 'commander';
31
+ import { readFileSync } from 'fs';
32
+ import { createRequire } from 'module';
33
+ import { resolve } from 'path';
34
+ import { diffTrees } from './src/differ.js';
35
+ import { assertTargetContained } from './src/config.js';
36
+ import { documentKeysOf } from './src/documents.js';
37
+ import { parseFile } from './src/parser.js';
38
+ import { maskChangeEvent, renderDiff, renderError, renderInfo, renderNote, renderWarn } from './src/renderer.js';
39
+ import { readLiveState, shapeOf, SHAPE_RE } from './src/drift-sources.js';
40
+
41
+ const require = createRequire(import.meta.url);
42
+ const { version } = require('./package.json');
43
+
44
+ /**
45
+ * Keys a declared file carries that a live store never does.
46
+ *
47
+ * A Kubernetes ConfigMap's `data` block is what corresponds to a config file;
48
+ * the manifest around it (`apiVersion`, `metadata`, …) has no counterpart in
49
+ * the live read, and reporting all of it as "removed" would bury the one line
50
+ * that actually drifted. Descending into `data` when it is there is the whole
51
+ * of the normalization — anything cleverer would be guessing.
52
+ * @param {unknown} declared
53
+ * @returns {unknown}
54
+ */
55
+ function declaredComparable(declared) {
56
+ if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return declared;
57
+ let record = /** @type {Record<string, unknown>} */ (declared);
58
+
59
+ // A manifest carrying apiVersion + kind + metadata.name is wrapped by the
60
+ // parser under a synthetic `Kind/ns/name` document key, so the `data` block
61
+ // sits one level down. Looking only at the top level found nothing, and the
62
+ // documented headline case -- a committed ConfigMap against an identical live
63
+ // one -- reported the whole manifest as drift and exited 1 forever.
64
+ const documents = documentKeysOf(declared) ?? [];
65
+ // Refused *before* looking for `data`. A document's identity falls back to a
66
+ // top-level `id`/`name`, so a document can be keyed literally `data` -- and
67
+ // checking `record.data` first then matched that wrapper, compared the wrong
68
+ // subtree, and silently dropped every other document in the file.
69
+ if (documents.length > 1) {
70
+ throw new Error(
71
+ `drift: ${documents.length} documents in this file, and a live source is one object.`
72
+ + ' Point drift at a file holding a single manifest.',
73
+ );
74
+ }
75
+ if (documents.length === 1 && typeof record[documents[0]] === 'object' && record[documents[0]] !== null) {
76
+ record = /** @type {Record<string, unknown>} */ (record[documents[0]]);
77
+ }
78
+
79
+ // `stringData` as well as `data`: readKubernetes merges both, and a plaintext
80
+ // Secret manifest uses `stringData`, so looking only at `data` reported the
81
+ // whole manifest as drift -- the same failure, on the other key.
82
+ const blocks = ['data', 'stringData']
83
+ .filter((key) => record[key] && typeof record[key] === 'object' && !Array.isArray(record[key]));
84
+ if (blocks.length > 0) {
85
+ const merged = Object.assign({}, ...blocks.map((key) => record[key]));
86
+ const dropped = Object.keys(record).filter((key) => !blocks.includes(key));
87
+ if (dropped.length > 0 && !documents.length) {
88
+ // For a manifest the surrounding keys have no live counterpart, which is
89
+ // the point. For an ordinary config file that happens to carry `data`,
90
+ // they are real settings -- say so rather than quietly comparing a third
91
+ // of the file.
92
+ renderWarn(
93
+ `Comparing only the ${blocks.join(' and ')} block; `
94
+ + `${dropped.length} other top-level key(s) in this file were not compared.`,
95
+ );
96
+ }
97
+ return merged;
98
+ }
99
+ return record;
100
+ }
101
+
102
+ /**
103
+ * Compare declared values against live ones, shaping the declared side for
104
+ * exactly the keys the live side shaped.
105
+ *
106
+ * Comparing a plaintext declared value against a live *shape* would report that
107
+ * key as changed on every run, which is noise that trains people to ignore the
108
+ * tool. So a shaped key is shaped on both sides, and every other key is
109
+ * compared by value.
110
+ * @param {unknown} declared
111
+ * @param {Record<string, unknown>} live
112
+ * @param {Set<string>} shapedKeys the live keys compared by shape
113
+ * @param {(v: string) => string} shape
114
+ * @returns {{ before: unknown, after: unknown }}
115
+ */
116
+ function alignForComparison(declared, live, shapedKeys, shape) {
117
+ const comparable = declaredComparable(declared);
118
+ if (shapedKeys.size === 0 || !comparable || typeof comparable !== 'object' || Array.isArray(comparable)) {
119
+ return { before: comparable, after: live };
120
+ }
121
+ // Per key, not per source. Shaping the whole declared side because *one*
122
+ // value was sensitive compared a shaped declared value against a raw live
123
+ // one, so every non-secret key drifted on every run -- the "trains people to
124
+ // ignore the tool" failure this function exists to prevent.
125
+ const shaped = Object.fromEntries(
126
+ Object.entries(/** @type {Record<string, unknown>} */ (comparable))
127
+ .map(([key, value]) => [key, shapedKeys.has(key) ? shape(stableString(value)) : value]),
128
+ );
129
+ return { before: shaped, after: live };
130
+ }
131
+
132
+ /**
133
+ * A value as a string, matching how the live side stringifies before hashing.
134
+ * @param {unknown} value
135
+ * @returns {string}
136
+ */
137
+ function stableString(value) {
138
+ if (typeof value === 'string') return value;
139
+ if (value === null || value === undefined) return '';
140
+ return JSON.stringify(value);
141
+ }
142
+
143
+ program
144
+ .name('flecto-drift')
145
+ .description(
146
+ 'Compare a declared config file against what is actually running.\n'
147
+ + 'Reads live state through a CLI you have already authenticated; holds no credentials.',
148
+ )
149
+ .version(version)
150
+ .argument('<file>', 'the declared configuration file')
151
+ .requiredOption('--against <uri>', 'live source: k8s://<ns>/configmap/<name>, k8s://<ns>/secret/<name>, ssm://<path>, tfstate://<path>')
152
+ .option('--format <type>', 'human or json', 'human')
153
+ .option('--fail-on-drift', 'exit 1 when the declared file and the live state differ', false)
154
+ .action(async (file, opts) => {
155
+ try {
156
+ const format = String(opts.format);
157
+ if (!['human', 'json'].includes(format)) {
158
+ throw new Error('--format must be human or json');
159
+ }
160
+
161
+ const filepath = resolve(file);
162
+ // The same containment every other read in Flecto has: file names and
163
+ // links are attacker-controlled on an untrusted pull request.
164
+ assertTargetContained(filepath, process.cwd());
165
+ const declared = parseFile(filepath);
166
+
167
+ const { state: live, meta } = readLiveState(opts.against);
168
+ const { before, after } = alignForComparison(declared, live, meta.shapedKeys, shapeOf);
169
+
170
+ // Declared is `before`, live is `after`, so the verbs read the way the
171
+ // question is asked: what has the running system done to what we wrote.
172
+ // Masked on both paths, not only the human one: a machine-readable report
173
+ // of live state is the likelier thing to be archived as a CI artifact, so
174
+ // leaving it raw would put those values somewhere they outlive the run.
175
+ //
176
+ // A shape is skipped, because it is already the safe form -- a keyed
177
+ // digest of a value this process never prints -- and masking it again
178
+ // would replace it with `***` on both sides, throwing away the one thing
179
+ // it exists to show: that the credential rotated.
180
+ //
181
+ // The test is on the **value**, not on the key it sits under. Keying it
182
+ // on `shapedKeys` trusted metadata that can fall out of step with the
183
+ // value beside it, and when it did, a live plaintext printed unmasked
184
+ // because its key was still marked as shaped. Every present side must be
185
+ // a shape, so a shape-to-plaintext change is masked rather than exempted.
186
+ const changes = diffTrees(before, after, {}).map((event) => {
187
+ const sides = [event.before, event.after].filter((value) => value !== undefined);
188
+ const allShaped = sides.length > 0
189
+ && sides.every((value) => typeof value === 'string' && SHAPE_RE.test(value));
190
+ return allShaped ? event : maskChangeEvent(event);
191
+ });
192
+
193
+ if (format === 'json') {
194
+ process.stdout.write(`${JSON.stringify({
195
+ file: filepath,
196
+ against: opts.against,
197
+ source: meta.label,
198
+ // Named, so a consumer never has to guess whether a value in here is
199
+ // a real value or a digest of one.
200
+ comparison: meta.sensitive ? 'shape-only' : 'values',
201
+ drifted: changes.length > 0,
202
+ changes,
203
+ }, null, 2)}\n`);
204
+ } else if (changes.length === 0) {
205
+ renderInfo(`No drift: ${filepath} matches ${meta.label}.`);
206
+ } else {
207
+ // Masked, like every other render path in Flecto. Drift prints values
208
+ // read out of a live system into a CI log, so it needs this more than
209
+ // the others, not less -- a ConfigMap value under a secret-shaped key
210
+ // is still a secret.
211
+ renderDiff(filepath, changes, { baseline: meta.label });
212
+ if (meta.sensitive) {
213
+ renderNote(
214
+ 'Values from a secret store are compared by shape (length and digest), never by value.',
215
+ );
216
+ }
217
+ }
218
+
219
+ if (changes.length > 0 && opts.failOnDrift) process.exitCode = 1;
220
+ } catch (err) {
221
+ renderError(err.message);
222
+ process.exitCode = 1;
223
+ }
224
+ });
225
+
226
+ program.parseAsync(process.argv);