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/src/config.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  statSync,
9
9
  writeFileSync,
10
10
  } from 'fs';
11
- import { basename, dirname, join, relative, resolve, sep } from 'path';
11
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
12
12
  import fg from 'fast-glob';
13
13
  import yaml from 'js-yaml';
14
14
  import { isEnvFilename, parseContent } from './parser.js';
@@ -161,6 +161,22 @@ function isInside(candidate, root) {
161
161
  return candidate === root || candidate.startsWith(root + sep);
162
162
  }
163
163
 
164
+ /**
165
+ * Containment decided on the paths as written, with no symlink resolution and
166
+ * no canonicalization.
167
+ *
168
+ * Used for "was this named inside the project?", where following a link is
169
+ * exactly the thing that must not happen -- and where canonicalizing one side
170
+ * but not the other silently breaks on Windows.
171
+ * @param {string} candidate
172
+ * @param {string} root both already absolute, from the same base
173
+ * @returns {boolean}
174
+ */
175
+ function isLexicallyInside(candidate, root) {
176
+ const rel = relative(root, candidate);
177
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
178
+ }
179
+
164
180
  /**
165
181
  * Refuse a path that lives inside the project but reads from outside it through
166
182
  * a symlink.
@@ -211,7 +227,25 @@ export function assertTargetContained(file, cwd = process.cwd()) {
211
227
  // there.
212
228
  const nominal = join(canonical(dirname(given)), basename(given));
213
229
  // Named from outside the project: nothing was escaped, it was never inside.
214
- if (!isInside(nominal, root)) return;
230
+ //
231
+ // That judgement must also be made on the path *as given*, not only on a
232
+ // canonicalized parent -- the parent is a component an untrusted pull request
233
+ // controls. With a directory symlink (`repo/b -> /outside`),
234
+ // `canonical(dirname(given))` already points outside, so `nominal` looked
235
+ // externally-named and this returned without checking anything:
236
+ // `repo/b/baseline.json` read straight out of the project. Replacing the
237
+ // *file* with a link was refused; replacing its *directory* was the same
238
+ // effort and was not.
239
+ //
240
+ // That second judgement is deliberately **lexical** -- `resolve(cwd)` against
241
+ // `resolve(file)`, neither canonicalized. Comparing an as-given path against
242
+ // a *canonicalized* root is what made the first attempt at this fail on
243
+ // Windows and leak: `process.cwd()` reports the 8.3 short form
244
+ // (`C:\Users\RUNNER~1\...`) while `canonical()` returns the long one, so the
245
+ // two never shared a prefix, the check decided the path was externally named,
246
+ // and returned. Both sides here derive from the same `process.cwd()` spelling,
247
+ // so no normalization is needed and none can go wrong.
248
+ if (!isInside(nominal, root) && !isLexicallyInside(given, resolve(cwd))) return;
215
249
 
216
250
  const real = canonical(given);
217
251
  if (isInside(real, root)) return;
@@ -376,6 +410,178 @@ function nearestExistingDir(path) {
376
410
  return { base: current, rest };
377
411
  }
378
412
 
413
+ /**
414
+ * Has the operator explicitly opted in to alert actions declared in `.flectorc`?
415
+ * @returns {boolean}
416
+ */
417
+ function rcAlertsAllowed() {
418
+ const raw = process.env.FLECTO_ALLOW_RC_ALERTS;
419
+ return raw === '1' || String(raw).toLowerCase() === 'true';
420
+ }
421
+
422
+ /**
423
+ * Options a pull request can turn into an action or corrupt an existing one,
424
+ * once it controls `.flectorc`, together with the reason and the CLI flag that
425
+ * names each one.
426
+ *
427
+ * `onAlertFailure` and `deliveryMode` are deliberately not here: `flecto init`
428
+ * writes them into the generated `.flectorc` itself, they only ever tune how
429
+ * an alert *already chosen by the operator* responds to failure, and they draw
430
+ * the same line `failOn` does elsewhere — a setting a repository is meant to
431
+ * delegate, not an action a settings file grants itself.
432
+ */
433
+ const ALERT_ACTION_OPTIONS = [
434
+ ['command', '--command', 'it spawns a shell command on every change'],
435
+ ['webhook', '--webhook', 'it sends change data to a URL'],
436
+ ['webhookHeader', '--webhook-header', 'it rides along on every request an operator-approved --webhook makes, and can override headers such as Content-Type'],
437
+ ];
438
+
439
+ /**
440
+ * Refuse an alert action declared in `.flectorc` rather than on the command
441
+ * line.
442
+ *
443
+ * `watch --command` spawns a shell command on every change, with diff data
444
+ * injected as `FLECTO_*` environment variables; `--webhook` POSTs that same
445
+ * data to a URL; `--webhook-header` rides along on that request. All three are
446
+ * actions, not settings — the same distinction `--update-baseline` draws — and
447
+ * all three merge through `resolveEffectiveOptions` with no other gate, so a
448
+ * `.flectorc` (or profile) naming one runs it on the next `flecto watch`. On an
449
+ * untrusted pull request `.flectorc` is a file the attacker wrote, which makes
450
+ * an rc-declared `command` unconstrained shell execution, an rc-declared
451
+ * `webhook` an exfiltration destination the attacker chose, and an rc-declared
452
+ * `webhookHeader` a way to override what an *operator-approved* webhook call
453
+ * sends — reachable even when `command`/`webhook` themselves came from the CLI.
454
+ *
455
+ * Any of the three named on the CLI is operator intent and unrestricted:
456
+ * `flecto watch config.yaml --command './notify.sh'` still works exactly as
457
+ * before. `FLECTO_ALLOW_RC_ALERTS=1` opts out for a repository that genuinely
458
+ * configures one in `.flectorc`. Refusing loudly rather than skipping silently
459
+ * is deliberate, for the same reason a stripped plugin or write destination is
460
+ * loud: an alert that quietly stopped firing would look like "nothing
461
+ * happened" instead of "this was refused".
462
+ * @param {Record<string, unknown>} effective
463
+ * @param {Record<string, unknown>} cliOverrides
464
+ * @throws {Error} when an alert action came from `.flectorc`/a profile
465
+ */
466
+ export function assertAlertActionsFromCli(effective, cliOverrides) {
467
+ if (rcAlertsAllowed()) return;
468
+ for (const [option, flag, reason] of ALERT_ACTION_OPTIONS) {
469
+ if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
470
+ const declared = Array.isArray(effective[option]) ? effective[option].join(', ') : effective[option];
471
+ throw new Error(
472
+ `Refusing "${option}" declared in .flectorc: ${reason}, `
473
+ + 'and .flectorc is attacker-controlled on an untrusted pull request.\n'
474
+ + `Declared: ${declared}\n`
475
+ + `Pass ${flag} on the command line instead, or set FLECTO_ALLOW_RC_ALERTS=1 `
476
+ + 'if this config is trusted.',
477
+ );
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Has the operator explicitly opted in to a baseline selected by `.flectorc`?
483
+ * @returns {boolean}
484
+ */
485
+ function rcBaselineAllowed() {
486
+ const raw = process.env.FLECTO_ALLOW_RC_BASELINE;
487
+ return raw === '1' || String(raw).toLowerCase() === 'true';
488
+ }
489
+
490
+ /**
491
+ * Refuse a `snapshotRef` declared in `.flectorc` rather than on the command
492
+ * line.
493
+ *
494
+ * `snapshotRef` names the baseline every change is measured against, so
495
+ * whoever sets it decides what "changed" means. That makes it an action in the
496
+ * same sense `--update-baseline` is one: a pull request that controls it can
497
+ * turn its own failing gate green without touching a policy, a suppression, or
498
+ * the gate's own `--fail-on`.
499
+ *
500
+ * The cheapest exploit needs no crafted value at all. A committed `.flectorc`
501
+ * carrying `{"defaults": {"snapshotRef": "HEAD"}}` points the baseline at the
502
+ * pull request's own tip, so every file is compared against itself, every diff
503
+ * is empty, and `flecto ci` exits 0 no matter what the change did. Confirmed
504
+ * end to end: a config that disables TLS and raises a pool size 100x fails the
505
+ * gate against the operator's ref and passes against an rc-declared `HEAD`.
506
+ *
507
+ * A ref named on the command line is operator intent and unrestricted, so
508
+ * `flecto ci config.yaml --snapshot-ref origin/main` is untouched. A repository
509
+ * that genuinely wants its baseline in the rc file sets
510
+ * `FLECTO_ALLOW_RC_BASELINE=1`, which is a statement that `.flectorc` is
511
+ * trusted there. Refusing loudly rather than falling back to a default baseline
512
+ * is deliberate, for the reason every other gate here refuses loudly: a gate
513
+ * that quietly measured against something else would still print a clean run.
514
+ * @param {Record<string, unknown>} effective
515
+ * @param {Record<string, unknown>} cliOverrides
516
+ * @throws {Error} when the baseline ref came from `.flectorc`/a profile
517
+ */
518
+ export function assertSnapshotRefFromCli(effective, cliOverrides) {
519
+ if (rcBaselineAllowed()) return;
520
+ // `snapshotFile` picks the baseline just as directly, so it is gated with it
521
+ // rather than left as the way around it.
522
+ for (const option of ['snapshotRef', 'snapshotFile']) {
523
+ if (effective[option] === undefined || cliOverrides[option] !== undefined) continue;
524
+ throw new Error(
525
+ `Refusing "${option}" declared in .flectorc: it chooses the baseline every change is`
526
+ + ' measured against, so a pull request that edits it decides what counts as changed --'
527
+ + ' pointing it at "HEAD", or at a file it committed, compares every file against itself'
528
+ + ' and exits 0.\n'
529
+ + `Declared: ${JSON.stringify(effective[option])}\n`
530
+ + `Pass --${option === 'snapshotRef' ? 'snapshot-ref' : 'snapshot-file'} on the command line`
531
+ + ' instead, or set FLECTO_ALLOW_RC_BASELINE=1 if this config is trusted.',
532
+ );
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Refuse a baseline ref that git would read as an option rather than a
538
+ * revision.
539
+ *
540
+ * The ref reaches `git show <rev>:<path>` as a single argv entry. `execFile`
541
+ * keeps it out of a shell, so there is no command injection here, but argv is
542
+ * not the same boundary as a shell: a ref beginning with `-` is still parsed by
543
+ * git as one of *its* options. `--output=pwned` turns the baseline read into a
544
+ * file write, and because the read then yields nothing, the baseline parses as
545
+ * an empty document, every key looks newly `added`, and the default
546
+ * `--fail-on changed,policy,error` never fires — an arbitrary write and a
547
+ * silent gate bypass from one string.
548
+ *
549
+ * Callers also pass `--end-of-options` so git itself refuses to read any
550
+ * operand as an option. This check is the belt to that suspenders: it names the
551
+ * problem in a message a human can act on, and it holds on a git too old for
552
+ * `--end-of-options` (added in 2.24).
553
+ * @param {string} ref
554
+ * @returns {string} the ref, unchanged, when it is safe to pass to git
555
+ * @throws {Error} when git would read the ref as an option
556
+ */
557
+ export function assertSafeGitRef(ref) {
558
+ if (typeof ref !== 'string') {
559
+ throw new Error('Refusing snapshot ref: it must be a string.');
560
+ }
561
+ if (ref.includes('..')) {
562
+ // `git show A..B` is a *range*: it succeeds and prints nothing, so the
563
+ // baseline parses empty, every key reads as `added`, and the default
564
+ // `--fail-on` never fires. Callers resolve refs through
565
+ // `rev-parse --verify <ref>^{commit}`, which already refuses a range; this
566
+ // refuses it by name, because "Needed a single revision" explains less. A
567
+ // valid ref cannot contain `..` in any case (git-check-ref-format).
568
+ throw new Error(
569
+ `Refusing snapshot ref "${ref}": ".." makes it a commit range rather than a single`
570
+ + ' revision, which git reads as an empty diff.',
571
+ );
572
+ }
573
+ if (ref.startsWith('-')) {
574
+ throw new Error(
575
+ `Refusing snapshot ref "${ref}": it starts with "-", so git would read it as an option`
576
+ + ' rather than a revision.',
577
+ );
578
+ }
579
+ if (/[\0\n\r]/.test(ref)) {
580
+ throw new Error('Refusing snapshot ref: it contains a newline or NUL byte.');
581
+ }
582
+ return ref;
583
+ }
584
+
379
585
  /**
380
586
  * Split a policy list that may arrive as an array or a comma-separated string.
381
587
  * @param {unknown} raw
@@ -0,0 +1,444 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { existsSync, readFileSync, statSync } from 'fs';
3
+ import { isAbsolute, resolve } from 'path';
4
+ import { assertTargetContained } from './config.js';
5
+
6
+ /**
7
+ * Live sources `flecto drift` can read, and the rules that keep reading them
8
+ * safe (#144).
9
+ *
10
+ * Flecto's core promise is that it authenticates to nothing: it reads files,
11
+ * shells out to nothing but `git`, and touches no key material. `drift` is the
12
+ * one thing that cannot keep that promise, which is why it is a **separate
13
+ * binary** with a separate entry point, and why nothing in core imports this
14
+ * file. Installing Flecto does not enable it; running `flecto ci` cannot reach
15
+ * it.
16
+ *
17
+ * Three rules carry the security of this module.
18
+ *
19
+ * **1. Flecto never holds a credential.** It does not read a kubeconfig, an AWS
20
+ * profile, a token, or an agent socket, and it has no flag that accepts one.
21
+ * Every source delegates to a CLI the operator has already installed and
22
+ * already authenticated -- `kubectl`, `aws` -- and inherits whatever that tool
23
+ * is entitled to. The blast radius of a bug here is therefore bounded by the
24
+ * credential the operator chose to give that tool, not by anything Flecto
25
+ * decided. It also means "use a read-only role" is advice the operator can
26
+ * actually enforce, in their own IAM, rather than a promise we make about our
27
+ * own code.
28
+ *
29
+ * **2. Read-only is structural, not careful.** Every argv is built here, from a
30
+ * fixed verb allowlist (`ALLOWED_COMMANDS`). Nothing from the URI reaches argv
31
+ * as a flag: each interpolated component is validated against a conservative
32
+ * pattern and passed after `--` where the tool supports it. There is no code
33
+ * path in this file that can construct a mutating command, so "it cannot write"
34
+ * is a property of the argv table rather than of reviewer attention.
35
+ *
36
+ * **3. A value from a secret store is a secret.** Kubernetes Secrets and SSM
37
+ * SecureStrings are compared **metadata-only**: key presence, and a
38
+ * length-and-digest shape so a rotation is still visible. The plaintext never
39
+ * enters a change event, a report, or this process's memory beyond the hashing
40
+ * call. There is no flag to turn that off, because a flag to print production
41
+ * secrets is a feature request to answer with "no".
42
+ */
43
+
44
+ import { createHmac, randomBytes } from 'crypto';
45
+
46
+ /**
47
+ * The complete set of commands this binary may run. Anything not here cannot be
48
+ * spawned, whatever a URI says.
49
+ *
50
+ * Each entry names a tool, a fixed read-only verb, and how to build the rest of
51
+ * argv from validated components. Adding a mutating verb here is the one change
52
+ * that would break the read-only property, which is why they live in one table
53
+ * rather than being assembled at each call site.
54
+ * @type {Record<string, { tool: string, verbs: string[] }>}
55
+ */
56
+ const ALLOWED_COMMANDS = {
57
+ // `verbAt` is which argv position carries the verb: `kubectl get …` puts it
58
+ // first, while `aws ssm get-parameters-by-path …` names a *service* first.
59
+ // Gating position 0 for aws would have allowed `ssm put-parameter` through
60
+ // the check whose docstring calls it "the assertion that keeps it that way".
61
+ kubectl: { tool: 'kubectl', verbAt: 0, verbs: ['get'] },
62
+ aws: { tool: 'aws', verbAt: 1, verbs: ['get-parameters-by-path'], services: ['ssm'] },
63
+ };
64
+
65
+ /** Kubernetes names, per RFC 1123: the pattern the API server itself enforces. */
66
+ const K8S_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/;
67
+ /**
68
+ * SSM parameter paths: printable, slash-delimited, no shell or flag shapes.
69
+ *
70
+ * `..` is refused explicitly. AWS would reject a traversing path itself, but
71
+ * relying on a remote service to validate an argument this process constructed
72
+ * is the wrong place to draw the line -- and it costs one check to not send it.
73
+ */
74
+ const SSM_PATH_RE = /^\/(?!.*\.\.)[A-Za-z0-9_.\-/]*$/;
75
+
76
+ /**
77
+ * @typedef {{
78
+ * kind: 'configmap' | 'secret' | 'ssm' | 'tfstate',
79
+ * label: string,
80
+ * sensitive: boolean,
81
+ * shapedKeys: Set<string>
82
+ * }} SourceMeta
83
+ * @typedef {{ state: Record<string, unknown>, meta: SourceMeta }} LiveState
84
+ */
85
+
86
+ /**
87
+ * Refuse a component that could be read as a flag or escape its position.
88
+ * @param {string} value
89
+ * @param {RegExp} pattern
90
+ * @param {string} what
91
+ * @returns {string}
92
+ */
93
+ function assertComponent(value, pattern, what) {
94
+ if (typeof value !== 'string' || value === '') {
95
+ throw new Error(`drift: ${what} is required`);
96
+ }
97
+ if (value.startsWith('-')) {
98
+ throw new Error(`drift: ${what} "${value}" starts with "-" and would be read as an option`);
99
+ }
100
+ if (!pattern.test(value)) {
101
+ throw new Error(`drift: ${what} "${value}" is not a valid ${what}`);
102
+ }
103
+ return value;
104
+ }
105
+
106
+ /**
107
+ * Run an allowlisted read-only command.
108
+ * @param {keyof typeof ALLOWED_COMMANDS} name
109
+ * @param {string[]} args argv after the tool, beginning with an allowed verb
110
+ * @returns {string} stdout
111
+ */
112
+ function readOnly(name, args) {
113
+ const entry = ALLOWED_COMMANDS[name];
114
+ if (!entry) throw new Error(`drift: ${name} is not an allowed command`);
115
+ if (entry.services && !entry.services.includes(args[0])) {
116
+ throw new Error(`drift: "${args[0]}" is not an allowed ${name} service`);
117
+ }
118
+ if (!entry.verbs.includes(args[entry.verbAt])) {
119
+ // Unreachable from any URI -- the verb is a literal at each call site. This
120
+ // is the assertion that keeps it that way.
121
+ throw new Error(`drift: "${args[entry.verbAt]}" is not a read-only verb for ${name}`);
122
+ }
123
+ const run = spawnSync(entry.tool, args, {
124
+ encoding: 'utf8',
125
+ maxBuffer: 64 * 1024 * 1024,
126
+ // No shell, ever. And stdin closed: a tool that decides to prompt for
127
+ // credentials should fail, not hang a CI job forever.
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ });
130
+ if (run.error?.code === 'ENOENT') {
131
+ throw new Error(
132
+ `drift: ${entry.tool} is not installed or not on PATH. flecto drift reads live state through`
133
+ + ` the tool you have already authenticated -- it holds no credentials of its own.`,
134
+ );
135
+ }
136
+ if (run.status !== 0) {
137
+ // The tool's own first line is kept, unlike its stdout: an error here is a
138
+ // diagnostic ("forbidden", "not found", "expired token") that the operator
139
+ // needs, where stdout is response *data*. Bounded, and attributed, so it is
140
+ // clear whose words these are.
141
+ const detail = String(run.stderr ?? '').trim().split('\n')[0].slice(0, 200) || `exit ${run.status}`;
142
+ throw new Error(`drift: ${entry.tool} failed: ${detail}`);
143
+ }
144
+ return run.stdout;
145
+ }
146
+
147
+ /**
148
+ * The shape of a secret value: enough to see a rotation, not enough to be one.
149
+ *
150
+ * A digest rather than the value means a changed credential still shows up as a
151
+ * change -- which is the entire point of drift detection -- while the plaintext
152
+ * never reaches a diff, a report, or a terminal. Truncated because this is a
153
+ * comparison key, not a commitment.
154
+ * @param {string} value
155
+ * @returns {string}
156
+ */
157
+ const SHAPE_KEY = randomBytes(32);
158
+
159
+ /**
160
+ * What a shaped value looks like.
161
+ *
162
+ * Exported so the masking decision can be made on the *value* rather than on
163
+ * the key it sits under: a key name is metadata that can fall out of step with
164
+ * the value beside it, and when it did, a plaintext printed unmasked because
165
+ * its key was still marked as shaped.
166
+ */
167
+ export const SHAPE_RE = /^<\d+ bytes, digest:[0-9a-f]{12}>$/;
168
+
169
+ function shapeOf(value) {
170
+ // Keyed with a per-invocation random key, not a bare digest. Both sides of
171
+ // the comparison are hashed in the same process, so drift detection is
172
+ // unaffected -- but a bare truncated SHA-256 printed into a CI log is an
173
+ // offline oracle: 48 bits is trivially brute-forced for a low-entropy secret,
174
+ // and being unsalted it is a stable cross-run, cross-org identifier for a
175
+ // shared credential. Keyed, the printed digest is worth nothing to anyone
176
+ // holding the log.
177
+ const digest = createHmac('sha256', SHAPE_KEY).update(value, 'utf8').digest('hex').slice(0, 12);
178
+ return `<${value.length} bytes, digest:${digest}>`;
179
+ }
180
+
181
+ /**
182
+ * Replace every value with its shape.
183
+ * @param {Record<string, unknown>} data
184
+ * @returns {Record<string, unknown>}
185
+ */
186
+ function shapesOnly(data) {
187
+ return Object.fromEntries(
188
+ Object.entries(data).map(([key, value]) => [key, shapeOf(stableString(value))]),
189
+ );
190
+ }
191
+
192
+ /**
193
+ * A value as a string, without collapsing structure.
194
+ *
195
+ * `String(value)` turns every object into `"[object Object]"`, so two different
196
+ * secrets produced an identical shape and a rotation between them was
197
+ * invisible -- the one thing shape comparison exists to show.
198
+ * @param {unknown} value
199
+ * @returns {string}
200
+ */
201
+ function stableString(value) {
202
+ if (typeof value === 'string') return value;
203
+ if (value === null || value === undefined) return '';
204
+ return JSON.stringify(value);
205
+ }
206
+
207
+ /**
208
+ * Parse a tool's stdout as JSON without quoting it back on failure.
209
+ *
210
+ * `JSON.parse`'s own message quotes the bytes it choked on, and those bytes are
211
+ * a response from a live secret store. `readTerraformState` already guards this
212
+ * deliberately; the same reasoning applies to every reader.
213
+ * @param {string} raw
214
+ * @param {string} tool
215
+ * @returns {any}
216
+ */
217
+ function parseToolJson(raw, tool) {
218
+ try {
219
+ const parsed = JSON.parse(raw);
220
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
221
+ throw new Error('not an object');
222
+ }
223
+ return parsed;
224
+ } catch {
225
+ throw new Error(`drift: ${tool} did not return the JSON document expected`);
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Parse a live-source URI into its parts.
231
+ *
232
+ * Deliberately strict and hand-written rather than `new URL()`: the shapes are
233
+ * few and known, and a permissive parser is how a component ends up somewhere
234
+ * it was not meant to go.
235
+ * @param {string} uri
236
+ * @returns {{ scheme: string, rest: string }}
237
+ */
238
+ export function parseSourceUri(uri) {
239
+ const match = /^([a-z0-9+.-]{1,16}):\/\/(.*)$/i.exec(String(uri ?? ''));
240
+ if (!match) {
241
+ throw new Error(
242
+ `drift: "${uri}" is not a source URI. Expected one of:\n`
243
+ + ' k8s://<namespace>/configmap/<name>\n'
244
+ + ' k8s://<namespace>/secret/<name> (compared by shape, never by value)\n'
245
+ + ' ssm://<parameter-path-prefix> (SecureString compared by shape)\n'
246
+ + ' tfstate://<path-to-state-file>',
247
+ );
248
+ }
249
+ return { scheme: match[1].toLowerCase(), rest: match[2] };
250
+ }
251
+
252
+ /**
253
+ * Read a Kubernetes ConfigMap or Secret.
254
+ * @param {string} rest `<namespace>/<configmap|secret>/<name>`
255
+ * @returns {LiveState}
256
+ */
257
+ function readKubernetes(rest) {
258
+ const parts = rest.split('/');
259
+ if (parts.length !== 3) {
260
+ throw new Error('drift: expected k8s://<namespace>/<configmap|secret>/<name>');
261
+ }
262
+ const [namespace, rawKind, name] = parts;
263
+ const kind = rawKind.toLowerCase();
264
+ if (kind !== 'configmap' && kind !== 'secret') {
265
+ throw new Error(`drift: "${rawKind}" is not a readable kind; use configmap or secret`);
266
+ }
267
+ assertComponent(namespace, K8S_NAME_RE, 'namespace');
268
+ assertComponent(name, K8S_NAME_RE, 'name');
269
+
270
+ const raw = readOnly('kubectl', [
271
+ 'get', kind, name, '--namespace', namespace, '--output', 'json',
272
+ ]);
273
+ /** @type {{ data?: Record<string, string>, stringData?: Record<string, string> }} */
274
+ const parsed = parseToolJson(raw, 'kubectl');
275
+ const data = { ...(parsed.data ?? {}), ...(parsed.stringData ?? {}) };
276
+
277
+ if (kind === 'secret') {
278
+ // Values arrive base64-encoded; they are hashed without being decoded, so
279
+ // the plaintext is never materialized at all.
280
+ return {
281
+ state: shapesOnly(data),
282
+ meta: {
283
+ kind: 'secret',
284
+ label: `k8s secret ${namespace}/${name}`,
285
+ sensitive: true,
286
+ shapedKeys: new Set(Object.keys(data)),
287
+ },
288
+ };
289
+ }
290
+ return {
291
+ state: { ...data },
292
+ meta: {
293
+ kind: 'configmap',
294
+ label: `k8s configmap ${namespace}/${name}`,
295
+ sensitive: false,
296
+ shapedKeys: new Set(),
297
+ },
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Read AWS SSM Parameter Store under a path prefix.
303
+ *
304
+ * `--with-decryption` is deliberately **never** passed, so a SecureString comes
305
+ * back still encrypted and its plaintext never leaves AWS. The ciphertext is
306
+ * then shaped like any other secret, which still surfaces a rotation.
307
+ * @param {string} rest the parameter path prefix
308
+ * @returns {LiveState}
309
+ */
310
+ function readSsm(rest) {
311
+ // Built from real segments rather than normalized in place. A single literal
312
+ // `=== '/'` check left `//`, `/.`, `/./` and `/.//` all reaching the CLI with
313
+ // a root-equivalent path -- and leaning on AWS to normalize them is the very
314
+ // thing this module's own SSM_PATH_RE docstring calls the wrong place to draw
315
+ // the line.
316
+ const segments = rest.split('/').filter((segment) => segment !== '' && segment !== '.');
317
+ if (segments.some((segment) => segment === '..')) {
318
+ throw new Error(`drift: parameter path "${rest}" must not contain ".."`);
319
+ }
320
+ if (segments.length === 0) {
321
+ // `--path / --recursive` is every parameter in the account. One typo would
322
+ // read another team's namespace, which is the opposite of the
323
+ // one-named-source-per-invocation rule this tool is built on.
324
+ throw new Error(
325
+ 'drift: ssm:// needs a parameter path, e.g. ssm:///app/prod.'
326
+ + ' Reading from the account root would fetch every parameter there.',
327
+ );
328
+ }
329
+ const path = `/${segments.join('/')}`;
330
+ assertComponent(path, SSM_PATH_RE, 'parameter path');
331
+
332
+ const raw = readOnly('aws', [
333
+ 'ssm', 'get-parameters-by-path', '--path', path, '--recursive', '--output', 'json',
334
+ ]);
335
+ /** @type {{ Parameters?: Array<{ Name: string, Value: string, Type: string }> }} */
336
+ const parsed = parseToolJson(raw, 'aws');
337
+ /** @type {Record<string, unknown>} */
338
+ const state = Object.create(null);
339
+ /** @type {Set<string>} */
340
+ const shapedKeys = new Set();
341
+ for (const parameter of parsed.Parameters ?? []) {
342
+ const name = String(parameter.Name);
343
+ // `slice(path.length)` is a blind cut, so a Name outside the requested path
344
+ // would derive a key that collides with another. Two parameters deriving
345
+ // one key is how `state` and `shapedKeys` came to disagree -- a later plain
346
+ // value overwrote a shaped one while the key stayed marked as shaped, and
347
+ // the masking exemption then trusted the mark and printed the plaintext.
348
+ if (!name.startsWith(path)) {
349
+ throw new Error(`drift: ${name} is not under the requested path ${path}`);
350
+ }
351
+ const key = name.slice(path.length).replace(/^\//, '') || name;
352
+ if (Object.hasOwn(state, key)) {
353
+ throw new Error(`drift: two parameters resolve to the same key "${key}" under ${path}`);
354
+ }
355
+ if (parameter.Type === 'SecureString') {
356
+ shapedKeys.add(key);
357
+ state[key] = shapeOf(stableString(parameter.Value));
358
+ } else {
359
+ state[key] = parameter.Value;
360
+ }
361
+ }
362
+ return {
363
+ state,
364
+ meta: { kind: 'ssm', label: `ssm ${path}`, sensitive: shapedKeys.size > 0, shapedKeys },
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Read outputs from a Terraform state file on disk.
370
+ *
371
+ * A local file, so no credential and no network -- and deliberately only the
372
+ * `outputs` block. Terraform state carries resource attributes including
373
+ * provider credentials in plenty of real configurations, and walking all of it
374
+ * would turn a drift check into an exfiltration primitive. Outputs marked
375
+ * `sensitive` are shaped like any other secret.
376
+ * @param {string} rest path to the state file
377
+ * @returns {LiveState}
378
+ */
379
+ function readTerraformState(rest) {
380
+ const path = resolve(rest);
381
+ // Same containment as every other read: a path from outside the project is
382
+ // operator intent and allowed, but one inside it that escapes through a link
383
+ // is the shape a pull request can author.
384
+ assertTargetContained(path, process.cwd());
385
+ if (!isAbsolute(path) || !existsSync(path) || !statSync(path).isFile()) {
386
+ throw new Error(`drift: no terraform state file at "${rest}"`);
387
+ }
388
+ /** @type {{ outputs?: Record<string, { value: unknown, sensitive?: boolean }> }} */
389
+ let parsed;
390
+ try {
391
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
392
+ } catch {
393
+ // Deliberately not echoing the parser's message: it quotes the bytes it
394
+ // choked on, which for a file that is not terraform state means printing a
395
+ // slice of whatever the file actually was.
396
+ throw new Error(`drift: "${rest}" is not valid terraform state JSON`);
397
+ }
398
+ // Built with fromEntries rather than assignment, so a `__proto__` output is
399
+ // an ordinary own key: kept and visible in the diff, never reaching
400
+ // Object.prototype. The three readers now agree on this rule.
401
+ /** @type {Record<string, unknown>} */
402
+ const state = Object.create(null);
403
+ /** @type {Set<string>} */
404
+ const shapedKeys = new Set();
405
+ for (const [key, output] of Object.entries(parsed.outputs ?? {})) {
406
+ if (output?.sensitive) {
407
+ shapedKeys.add(key);
408
+ state[key] = shapeOf(stableString(output.value));
409
+ } else {
410
+ state[key] = output?.value;
411
+ }
412
+ }
413
+ return {
414
+ state,
415
+ meta: {
416
+ kind: 'tfstate',
417
+ label: `terraform state ${rest}`,
418
+ sensitive: shapedKeys.size > 0,
419
+ shapedKeys,
420
+ },
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Read the live state a URI names.
426
+ * @param {string} uri
427
+ * @returns {LiveState}
428
+ */
429
+ export function readLiveState(uri) {
430
+ const { scheme, rest } = parseSourceUri(uri);
431
+ switch (scheme) {
432
+ case 'k8s':
433
+ case 'kubernetes':
434
+ return readKubernetes(rest);
435
+ case 'ssm':
436
+ return readSsm(rest);
437
+ case 'tfstate':
438
+ return readTerraformState(rest);
439
+ default:
440
+ throw new Error(`drift: "${scheme}://" is not a supported source`);
441
+ }
442
+ }
443
+
444
+ export { shapeOf, ALLOWED_COMMANDS };