flecto 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
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
|
-
|
|
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 };
|