flecto 2.1.0 → 3.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.
@@ -0,0 +1,120 @@
1
+ {
2
+ "id": "terraform",
3
+ "rules": [
4
+ {
5
+ "id": "terraform-resource-replaced",
6
+ "severity": "error",
7
+ "when": ["removed"],
8
+ "match": {
9
+ "path": "\\.#action$"
10
+ },
11
+ "beforeEquals": "replace",
12
+ "message": "Terraform will destroy and recreate this resource. Expect downtime, a new resource id, and the loss of anything held only on the existing instance. Check which attribute forced the replacement."
13
+ },
14
+ {
15
+ "id": "terraform-stateful-resource-destroyed",
16
+ "severity": "error",
17
+ "when": ["removed"],
18
+ "match": {
19
+ "path": "(^|\\.)(aws_db_instance|aws_db_cluster_snapshot|aws_rds_cluster|aws_rds_cluster_instance|aws_dynamodb_table|aws_s3_bucket|aws_ebs_volume|aws_efs_file_system|aws_elasticache_cluster|aws_elasticache_replication_group|aws_redshift_cluster|aws_docdb_cluster|aws_neptune_cluster|google_sql_database_instance|google_storage_bucket|azurerm_storage_account|azurerm_mssql_database)\\..*\\.#action$"
20
+ },
21
+ "beforeIn": ["delete", "replace"],
22
+ "message": "Terraform will destroy a stateful resource. Its data does not survive. Take a final snapshot, or add a prevent_destroy lifecycle block, before applying."
23
+ },
24
+ {
25
+ "id": "terraform-security-group-open-ingress",
26
+ "severity": "error",
27
+ "when": ["added", "changed"],
28
+ "afterIn": ["0.0.0.0/0", "::/0"],
29
+ "anyOf": [
30
+ {
31
+ "match": {
32
+ "path": "(^|\\.)aws_security_group\\..*\\.ingress\\[[^\\]]*\\]\\.(ipv6_)?cidr_blocks\\[[^\\]]*\\]$"
33
+ }
34
+ },
35
+ {
36
+ "match": {
37
+ "path": "(^|\\.)aws_vpc_security_group_ingress_rule\\..*\\.cidr_ipv[46]$"
38
+ }
39
+ }
40
+ ],
41
+ "messageTemplate": "Security group ingress will accept traffic from the whole internet ({after}). Restrict the source to a known CIDR, a prefix list, or another security group."
42
+ },
43
+ {
44
+ "id": "terraform-iam-wildcard",
45
+ "severity": "error",
46
+ "when": ["added", "changed"],
47
+ "match": {
48
+ "path": "(^|\\.)aws_iam_[a-z_]+\\..*\\.policy$"
49
+ },
50
+ "afterMatches": "\"(Action|NotAction|Resource|NotResource)\"\\s*:\\s*(\"\\*\"|\\[[^\\]]*\"\\*\")",
51
+ "message": "IAM policy grants a wildcard action or resource (\"*\"). Scope it to the specific actions and ARNs the principal needs."
52
+ },
53
+ {
54
+ "id": "terraform-s3-public-access-block-disabled",
55
+ "severity": "error",
56
+ "when": ["added", "changed", "removed"],
57
+ "anyOf": [
58
+ {
59
+ "match": {
60
+ "path": "(^|\\.)aws_s3_bucket_public_access_block\\..*\\.(block_public_acls|block_public_policy|ignore_public_acls|restrict_public_buckets)$"
61
+ },
62
+ "afterEquals": false
63
+ },
64
+ {
65
+ "match": {
66
+ "path": "(^|\\.)aws_s3_bucket_public_access_block\\..*\\.#action$"
67
+ },
68
+ "beforeIn": ["delete", "replace"]
69
+ }
70
+ ],
71
+ "message": "S3 public access block is being turned off or removed. Leave all four block_public_* settings enabled unless the bucket is deliberately a public website."
72
+ },
73
+ {
74
+ "id": "terraform-s3-public-acl",
75
+ "severity": "error",
76
+ "when": ["added", "changed"],
77
+ "match": {
78
+ "path": "(^|\\.)aws_s3_bucket(_acl)?\\..*\\.acl$"
79
+ },
80
+ "afterIn": ["public-read", "public-read-write", "authenticated-read"],
81
+ "messageTemplate": "S3 bucket ACL set to \"{after}\", which exposes objects outside the account. Use a bucket policy scoped to known principals instead."
82
+ },
83
+ {
84
+ "id": "terraform-instance-size-changed",
85
+ "severity": "warn",
86
+ "when": ["changed"],
87
+ "match": {
88
+ "path": "(^|\\.)(aws_instance|aws_db_instance|aws_rds_cluster_instance|aws_launch_template|aws_launch_configuration|aws_elasticache_cluster|google_compute_instance|azurerm_linux_virtual_machine|azurerm_windows_virtual_machine)\\..*\\.(instance_type|instance_class|node_type|machine_type|size|vm_size)$"
89
+ },
90
+ "messageTemplate": "Instance size changes from {before} to {after}. Confirm the cost impact, and whether the change forces a restart or a replacement."
91
+ },
92
+ {
93
+ "id": "terraform-capacity-jump",
94
+ "severity": "warn",
95
+ "when": ["changed"],
96
+ "match": {
97
+ "path": "\\.(desired_capacity|max_size|min_size|desired_count|node_count|initial_node_count|instance_count|number_of_nodes|max_capacity|min_capacity)$"
98
+ },
99
+ "numericJump": { "minMultiple": 2 },
100
+ "messageTemplate": "Capacity at {path} jumps from {before} to {after} (>=2x). Confirm the quota headroom and the cost of the new floor."
101
+ },
102
+ {
103
+ "id": "terraform-sensitive-value-changed",
104
+ "severity": "warn",
105
+ "when": ["added", "changed", "removed"],
106
+ "anyOf": [
107
+ { "afterEquals": "(sensitive value)" },
108
+ { "beforeEquals": "(sensitive value)" }
109
+ ],
110
+ "message": "A value Terraform marks sensitive is being set, changed, or removed. Flecto redacted it. Confirm it lives in a secret store and that anything depending on it is rotated."
111
+ },
112
+ {
113
+ "id": "terraform-hardcoded-credential",
114
+ "severity": "error",
115
+ "when": ["added", "changed"],
116
+ "afterLooksSecret": true,
117
+ "message": "A planned attribute value looks like a credential (known token format or high-entropy string) that Terraform did not mark sensitive. Move it to a secret store and rotate it."
118
+ }
119
+ ]
120
+ }
package/src/parser.js CHANGED
@@ -3,8 +3,10 @@ import { basename, extname } from 'path';
3
3
  import yaml from 'js-yaml';
4
4
  import TOML from '@iarna/toml';
5
5
  import dotenv from 'dotenv';
6
+ import { isArmoredAgeFile, normalizeEncrypted, opaqueFileState } from './encrypted.js';
7
+ import { documentKeysOf, withDocumentKeys } from './documents.js';
6
8
 
7
- const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini'];
9
+ const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini', '.age'];
8
10
 
9
11
  /**
10
12
  * True for dotenv-like names: `.env`, `.env.*`, `*.env`
@@ -65,11 +67,175 @@ export function parseIni(raw) {
65
67
  }
66
68
 
67
69
  function isPlainObject(v) {
68
- return v !== null && typeof v === 'object' && !Array.isArray(v);
70
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
71
+ const prototype = Object.getPrototypeOf(v);
72
+ return prototype === Object.prototype || prototype === null;
73
+ }
74
+
75
+ /**
76
+ * What a YAML anchor that points back at one of its own containers collapses
77
+ * to. See {@link normalizeParsedValue} for why this is necessary and why a
78
+ * plain sentinel, rather than e.g. a path reference, is enough.
79
+ */
80
+ export const CIRCULAR_SENTINEL = '<circular>';
81
+
82
+ /**
83
+ * Convert parser-specific scalar objects into a stable JSON-safe tree before
84
+ * they reach snapshots, the differ, or output renderers.
85
+ *
86
+ * A recursive YAML anchor (`a: &x\n b: *x`) parses to a genuinely cyclic
87
+ * object — js-yaml resolves the alias to the *same* object reference, not a
88
+ * copy, so `a.b === a`. `ancestors` tracks the containers on the path
89
+ * currently being walked, the same ancestor-tracking pattern `collectLeaves`
90
+ * in policy.js uses for the same reason; revisiting one replaces the
91
+ * back-reference with {@link CIRCULAR_SENTINEL} instead of recursing into it.
92
+ * A value merely reached twice by separate branches (not an ancestor of
93
+ * itself) is still normalized in full on each branch.
94
+ *
95
+ * The result is always plain JSON: safe for `JSON.stringify` at the snapshot
96
+ * write, and safe for the differ, which never sees a cycle because nothing
97
+ * downstream of this function ever does. The sentinel is a fixed string
98
+ * rather than e.g. a back-reference path, so two files with the same cycle
99
+ * shape normalize to the same tree and compare equal — the whole point of a
100
+ * stable, readable diff path.
101
+ * @param {unknown} value
102
+ * @param {Set<object>} [ancestors] internal recursion state; omit when calling
103
+ * @returns {unknown}
104
+ */
105
+ function normalizeParsedValue(value, ancestors = new Set()) {
106
+ if (typeof value === 'bigint') return String(value);
107
+ if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
108
+ if (value instanceof Date) return value.toJSON();
109
+ if (Array.isArray(value)) {
110
+ if (ancestors.has(value)) return CIRCULAR_SENTINEL;
111
+ ancestors.add(value);
112
+ const out = value.map((item) => normalizeParsedValue(item, ancestors));
113
+ ancestors.delete(value);
114
+ return out;
115
+ }
116
+ if (
117
+ value !== null
118
+ && typeof value === 'object'
119
+ && typeof value.toJSON === 'function'
120
+ ) {
121
+ const serialized = value.toJSON();
122
+ if (serialized !== value && (serialized === null || typeof serialized !== 'object')) {
123
+ return normalizeParsedValue(serialized, ancestors);
124
+ }
125
+ }
126
+ if (isPlainObject(value)) {
127
+ if (ancestors.has(value)) return CIRCULAR_SENTINEL;
128
+ ancestors.add(value);
129
+ const out = Object.fromEntries(
130
+ Object.entries(value).map(([key, child]) => [key, normalizeParsedValue(child, ancestors)]),
131
+ );
132
+ ancestors.delete(value);
133
+ return out;
134
+ }
135
+ return value;
136
+ }
137
+
138
+ /**
139
+ * Read a scalar field as a non-empty string, or null.
140
+ * @param {Record<string, unknown>} obj
141
+ * @param {string} key
142
+ * @returns {string | null}
143
+ */
144
+ function scalarField(obj, key) {
145
+ const value = obj[key];
146
+ if (value == null || typeof value === 'object') return null;
147
+ const text = String(value).trim();
148
+ return text === '' ? null : text;
149
+ }
150
+
151
+ /**
152
+ * Stable identity for one document of a multi-document YAML file.
153
+ * Kubernetes-shaped documents key on `kind/name`, with the namespace inserted
154
+ * when present (`kind/namespace/name`). Anything else falls back to a top-level
155
+ * `id` then `name`, mirroring array identity matching in the differ.
156
+ * @param {unknown} doc
157
+ * @returns {string | null} null when the document has no stable identity
158
+ */
159
+ function documentIdentity(doc) {
160
+ if (!isPlainObject(doc)) return null;
161
+
162
+ const kind = scalarField(doc, 'kind');
163
+ const metadata = isPlainObject(doc.metadata) ? doc.metadata : null;
164
+ const name = metadata ? scalarField(metadata, 'name') : null;
165
+ if (kind && name) {
166
+ const namespace = scalarField(metadata, 'namespace');
167
+ return namespace ? `${kind}/${namespace}/${name}` : `${kind}/${name}`;
168
+ }
169
+
170
+ return scalarField(doc, 'id') ?? scalarField(doc, 'name');
171
+ }
172
+
173
+ /**
174
+ * Keys for a multi-document file: identities when every document has a unique
175
+ * one, otherwise document indices. It is all-or-nothing so keys within one file
176
+ * stay homogeneous.
177
+ * @param {unknown[]} docs
178
+ * @returns {string[]}
179
+ */
180
+ function documentKeys(docs) {
181
+ const identities = docs.map((doc) => {
182
+ const identity = documentIdentity(doc);
183
+ // "__proto__" as a key would mutate the prototype instead of adding an
184
+ // entry, silently losing the document. Fall back to indices instead.
185
+ return identity === '__proto__' ? null : identity;
186
+ });
187
+ const unique = new Set(identities);
188
+ if (!identities.includes(null) && unique.size === identities.length) {
189
+ return /** @type {string[]} */ (identities);
190
+ }
191
+ return docs.map((_, index) => String(index));
192
+ }
193
+
194
+ /**
195
+ * Parse a YAML stream, supporting `---`-separated multi-document files.
196
+ *
197
+ * A file holding a single document parses to that document unchanged, so diff
198
+ * paths for ordinary YAML are untouched. A file holding several documents
199
+ * parses to an object keyed per document, which lets the differ walk it like
200
+ * any other tree. Empty documents (a leading or trailing `---`, or a `null`
201
+ * document) are dropped, so a stray separator does not create a phantom entry.
202
+ *
203
+ * The keys it invents are recorded on the wrapper (see documents.js) so that
204
+ * everything downstream can tell a synthetic document prefix from a real
205
+ * configuration key without having to guess from its shape.
206
+ * @param {string} raw
207
+ * @returns {unknown}
208
+ * @throws {Error} on YAML syntax errors
209
+ */
210
+ export function parseYamlStream(raw) {
211
+ const docs = yaml.loadAll(raw).filter((doc) => doc != null);
212
+
213
+ if (docs.length === 0) return withDocumentKeys({}, []);
214
+ if (docs.length === 1) return withDocumentKeys(docs[0], []);
215
+
216
+ const keys = documentKeys(docs);
217
+ /** @type {Record<string, unknown>} */
218
+ const out = {};
219
+ for (let i = 0; i < docs.length; i++) {
220
+ out[keys[i]] = docs[i];
221
+ }
222
+ return withDocumentKeys(out, keys);
69
223
  }
70
224
 
71
225
  /**
72
226
  * Auto-detect the format of a file and parse it into a plain JS object.
227
+ *
228
+ * The parsed tree then goes through the encryption pass (see encrypted.js),
229
+ * which replaces every ciphertext-bearing value with an opaque sentinel. Doing
230
+ * it here rather than at render time is what makes the guarantee absolute:
231
+ * nothing downstream — diff, snapshot, webhook, report — is ever handed
232
+ * ciphertext, because the parser never produces any. A file with nothing to
233
+ * redact comes back as the very same object.
234
+ *
235
+ * The multi-document keys invented by `parseYamlStream` are handed to the
236
+ * encryption pass explicitly — a SOPS block sits inside each document, not at
237
+ * the root — and re-recorded on the returned tree, since normalization may have
238
+ * replaced the object the mark was on.
73
239
  * @param {string} filepath
74
240
  * @param {string} raw
75
241
  * @returns {unknown}
@@ -87,26 +253,26 @@ export function parseContent(filepath, raw) {
87
253
  `Supported extensions: ${supported}`
88
254
  );
89
255
  }
90
- try {
91
- if (envLike || ext === '.env') {
92
- return dotenv.parse(raw);
93
- }
94
256
 
95
- if (iniLike) {
96
- return parseIni(raw);
97
- }
98
-
99
- if (ext === '.json') {
100
- return JSON.parse(raw);
101
- }
102
-
103
- if (ext === '.yaml' || ext === '.yml') {
104
- const result = yaml.load(raw);
105
- return result == null ? {} : result;
106
- }
257
+ // A `.age` file, and any file that opens with the age armor header, is one
258
+ // opaque blob rather than a config document. There is no structure to parse
259
+ // and no parser that would not be guessing.
260
+ if (ext === '.age' || isArmoredAgeFile(raw)) {
261
+ return opaqueFileState(raw);
262
+ }
107
263
 
108
- if (ext === '.toml') {
109
- return TOML.parse(raw);
264
+ let parsed;
265
+ try {
266
+ if (envLike || ext === '.env') {
267
+ parsed = dotenv.parse(raw);
268
+ } else if (iniLike) {
269
+ parsed = parseIni(raw);
270
+ } else if (ext === '.json') {
271
+ parsed = JSON.parse(raw);
272
+ } else if (ext === '.yaml' || ext === '.yml') {
273
+ parsed = parseYamlStream(raw);
274
+ } else if (ext === '.toml') {
275
+ parsed = TOML.parse(raw);
110
276
  }
111
277
  } catch (err) {
112
278
  const lineMatch = err.message?.match(/line (\d+)/i);
@@ -115,6 +281,9 @@ export function parseContent(filepath, raw) {
115
281
  `Parse error in "${filepath}"${lineInfo}: ${err.message}`
116
282
  );
117
283
  }
284
+
285
+ const keys = documentKeysOf(parsed) ?? [];
286
+ return withDocumentKeys(normalizeEncrypted(normalizeParsedValue(parsed), keys), keys);
118
287
  }
119
288
 
120
289
  /**