flecto 3.0.2 → 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 +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- 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 +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
|
@@ -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 };
|