flecto 3.0.1 → 3.0.2

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/encrypted.js CHANGED
@@ -292,14 +292,14 @@ function mapStrings(value, mapString) {
292
292
 
293
293
  if (isPlainObject(value)) {
294
294
  let changed = false;
295
- /** @type {Record<string, unknown>} */
296
- const out = {};
297
- for (const [key, item] of Object.entries(value)) {
295
+ // Rebuilt with fromEntries, not `out[key] = next`: a key literally named
296
+ // "__proto__" would run the prototype setter and drop the subtree.
297
+ const entries = Object.entries(value).map(([key, item]) => {
298
298
  const next = mapStrings(item, mapString);
299
299
  if (next !== item) changed = true;
300
- out[key] = next;
301
- }
302
- return changed ? out : value;
300
+ return [key, next];
301
+ });
302
+ return changed ? Object.fromEntries(entries) : value;
303
303
  }
304
304
 
305
305
  return value;
@@ -385,14 +385,12 @@ function normalizeRecipientGroup(group, value) {
385
385
  */
386
386
  function normalizeSopsBlock(block) {
387
387
  let changed = false;
388
- /** @type {Record<string, unknown>} */
389
- const out = {};
390
- for (const [key, value] of Object.entries(block)) {
388
+ const entries = Object.entries(block).map(([key, value]) => {
391
389
  const next = RECIPIENT_GROUPS.includes(key) ? normalizeRecipientGroup(key, value) : value;
392
390
  if (next !== value) changed = true;
393
- out[key] = next;
394
- }
395
- return changed ? out : block;
391
+ return [key, next];
392
+ });
393
+ return changed ? Object.fromEntries(entries) : block;
396
394
  }
397
395
 
398
396
  /**
@@ -436,7 +434,12 @@ export function normalizeEncrypted(tree, documentKeys = []) {
436
434
  const normalized = normalizeSopsOwner(doc);
437
435
  if (normalized === doc) continue;
438
436
  if (out === redacted) out = { ...redacted };
439
- out[key] = normalized;
437
+ Object.defineProperty(out, key, {
438
+ value: normalized,
439
+ writable: true,
440
+ enumerable: true,
441
+ configurable: true,
442
+ });
440
443
  }
441
444
  return out;
442
445
  }
@@ -0,0 +1,92 @@
1
+ {
2
+ "id": "github-actions",
3
+ "expandSubtrees": true,
4
+ "rules": [
5
+ {
6
+ "id": "github-actions-pull-request-target",
7
+ "severity": "error",
8
+ "when": ["added"],
9
+ "match": { "pathEquals": "on.pull_request_target" },
10
+ "message": "A pull_request_target trigger was added. Do not run fork-controlled code in a privileged workflow; separate metadata checks from untrusted execution."
11
+ },
12
+ {
13
+ "id": "github-actions-permissions-removed",
14
+ "severity": "error",
15
+ "when": ["removed"],
16
+ "match": { "pathEquals": "permissions" },
17
+ "message": "The workflow permissions block was removed. GitHub may restore a broader default token scope; keep an explicit least-privilege block."
18
+ },
19
+ {
20
+ "id": "github-actions-permissions-write-all",
21
+ "severity": "error",
22
+ "when": ["added", "changed"],
23
+ "match": { "pathEquals": "permissions" },
24
+ "afterEquals": "write-all",
25
+ "message": "The workflow token was widened to write-all. Scope permissions to the specific resources and operations required by the job."
26
+ },
27
+ {
28
+ "id": "github-actions-permission-write-scope",
29
+ "severity": "warn",
30
+ "when": ["added", "changed"],
31
+ "match": { "path": "^permissions\\.[^.]+$" },
32
+ "afterEquals": "write",
33
+ "messageTemplate": "Workflow permission {path} was widened to write. Confirm the job and its trigger need write access."
34
+ },
35
+ {
36
+ "id": "github-actions-unpinned-action",
37
+ "severity": "error",
38
+ "when": ["added", "changed"],
39
+ "match": { "path": "^jobs\\.[^.]+\\.steps\\[[0-9]+\\]\\.uses$" },
40
+ "afterMatches": "^.+@(?![0-9a-fA-F]{40}$).+$",
41
+ "messageTemplate": "Action {after} is not pinned to a full commit SHA at {path}. Pin third-party actions to an immutable SHA and keep the release in a comment."
42
+ },
43
+ {
44
+ "id": "github-actions-pull-request-head-checkout",
45
+ "severity": "error",
46
+ "when": ["added", "changed"],
47
+ "match": { "path": "^jobs\\.[^.]+\\.steps\\[[0-9]+\\]\\.with\\.ref$" },
48
+ "afterMatches": "^(?:github\\.event\\.pull_request\\.head\\.sha|\\$\\{\\{\\s*github\\.event\\.pull_request\\.head\\.sha\\s*\\}\\})$",
49
+ "message": "The workflow checks out the pull request head SHA. Recheck the trust boundary before combining this with pull_request_target or write permissions."
50
+ },
51
+ {
52
+ "id": "github-actions-secrets-in-run",
53
+ "severity": "error",
54
+ "when": ["added", "changed"],
55
+ "match": { "path": "^jobs\\.[^.]+\\.steps\\[[0-9]+\\]\\.run$" },
56
+ "afterMatches": "\\$\\{\\{\\s*secrets\\.[^}]+\\}\\}",
57
+ "message": "A GitHub secret is interpolated into a run step. Prefer an environment boundary and ensure untrusted pull-request code cannot reach the step."
58
+ },
59
+ {
60
+ "id": "github-actions-self-hosted-runner",
61
+ "severity": "error",
62
+ "when": ["added", "changed"],
63
+ "match": { "path": "^jobs\\.[^.]+\\.runs-on(?:\\.labels)?(?:\\[[0-9]+\\])?$" },
64
+ "anyOf": [
65
+ { "afterMatches": "(^|[ ,])self-hosted([ ,]|$)" },
66
+ { "afterAnyMatches": "(^|[ ,])self-hosted([ ,]|$)" }
67
+ ],
68
+ "message": "A self-hosted runner is reachable from this workflow. Confirm that fork-controlled changes cannot execute on a runner with persistent credentials."
69
+ },
70
+ {
71
+ "id": "github-actions-schedule-exposed",
72
+ "severity": "warn",
73
+ "when": ["added"],
74
+ "match": { "pathEquals": "on.schedule" },
75
+ "message": "A scheduled trigger was added. Review which jobs and secrets become reachable without a pull request review."
76
+ },
77
+ {
78
+ "id": "github-actions-workflow-dispatch-exposed",
79
+ "severity": "warn",
80
+ "when": ["added"],
81
+ "match": { "pathEquals": "on.workflow_dispatch" },
82
+ "message": "A manual workflow trigger was added. Document who may run it and which permissions or secrets it can reach."
83
+ },
84
+ {
85
+ "id": "github-actions-workflow-call-exposed",
86
+ "severity": "warn",
87
+ "when": ["added"],
88
+ "match": { "pathEquals": "on.workflow_call" },
89
+ "message": "A reusable workflow trigger was added. Review its caller permissions and secret inheritance before treating it as a trusted boundary."
90
+ }
91
+ ]
92
+ }
package/src/parser.js CHANGED
@@ -5,8 +5,14 @@ import TOML from '@iarna/toml';
5
5
  import dotenv from 'dotenv';
6
6
  import { isArmoredAgeFile, normalizeEncrypted, opaqueFileState } from './encrypted.js';
7
7
  import { documentKeysOf, withDocumentKeys } from './documents.js';
8
+ import { assertNotTerraformPlan } from './terraform.js';
8
9
 
9
- const SUPPORTED_EXT = ['.json', '.yaml', '.yml', '.toml', '.env', '.ini', '.age'];
10
+ const SUPPORTED_EXT = ['.json', '.jsonc', '.yaml', '.yml', '.toml', '.env', '.ini', '.age'];
11
+
12
+ // Upper bound on nodes produced when normalizing a parsed tree. Well above any
13
+ // real config (a 5,000-key file is 5,000 nodes) and below where alias expansion
14
+ // becomes a denial of service. See normalizeParsedValue.
15
+ const MAX_NORMALIZED_NODES = 5_000_000;
10
16
 
11
17
  /**
12
18
  * True for dotenv-like names: `.env`, `.env.*`, `*.env`
@@ -25,24 +31,63 @@ export function isIniFilename(filepath) {
25
31
  return extname(filepath).toLowerCase() === '.ini';
26
32
  }
27
33
 
34
+ /**
35
+ * Define an own data property, whatever the key is called.
36
+ *
37
+ * `target[key] = value` is not a property write when `key` is `"__proto__"`: it
38
+ * runs the `Object.prototype.__proto__` setter instead, which either reassigns
39
+ * the object's prototype or silently discards the value. `defineProperty` is the
40
+ * operation that was actually meant every time a parser writes a key it read out
41
+ * of a file, and it treats every key name the same.
42
+ * @param {Record<string, unknown>} target
43
+ * @param {string} key
44
+ * @param {unknown} value
45
+ */
46
+ function defineOwn(target, key, value) {
47
+ Object.defineProperty(target, key, {
48
+ value,
49
+ writable: true,
50
+ enumerable: true,
51
+ configurable: true,
52
+ });
53
+ }
54
+
28
55
  /**
29
56
  * Minimal INI parser: [section] + key=value.
30
57
  * Root keys are top-level; sectioned keys nest under the section name.
58
+ *
59
+ * Section and key names come out of the file, which in Flecto's threat model
60
+ * means they come out of a pull request. They are read and written as **own
61
+ * properties only**: `out[section]` on a section named `__proto__` resolves to
62
+ * `Object.prototype` — which passes `isPlainObject`, since its own prototype is
63
+ * `null` — and every key in that section would then be written onto the
64
+ * prototype of every object in the process. `Object.hasOwn` for the lookup and
65
+ * `defineOwn` for the write make a reserved name an ordinary key holding
66
+ * ordinary data, which is what a config file's `[__proto__]` section is.
31
67
  * @param {string} raw
32
68
  * @returns {Record<string, unknown>}
33
69
  */
34
70
  export function parseIni(raw) {
35
71
  /** @type {Record<string, unknown>} */
36
72
  const out = {};
37
- let section = null;
73
+ /** @type {Record<string, unknown>} */
74
+ let bucket = out;
38
75
 
39
76
  for (const line of String(raw).split(/\r?\n/)) {
40
77
  const trimmed = line.trim();
41
78
  if (!trimmed || trimmed.startsWith(';') || trimmed.startsWith('#')) continue;
42
79
  const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
43
80
  if (sectionMatch) {
44
- section = sectionMatch[1].trim();
45
- if (!isPlainObject(out[section])) out[section] = {};
81
+ const section = sectionMatch[1].trim();
82
+ const existing = Object.hasOwn(out, section) ? out[section] : undefined;
83
+ if (isPlainObject(existing)) {
84
+ bucket = /** @type {Record<string, unknown>} */ (existing);
85
+ } else {
86
+ // A repeated section keeps accumulating; a section colliding with a
87
+ // root scalar replaces it, exactly as before.
88
+ bucket = {};
89
+ defineOwn(out, section, bucket);
90
+ }
46
91
  continue;
47
92
  }
48
93
  const eq = trimmed.indexOf('=');
@@ -55,17 +100,106 @@ export function parseIni(raw) {
55
100
  ) {
56
101
  value = value.slice(1, -1);
57
102
  }
58
- if (section == null) {
59
- out[key] = value;
60
- } else {
61
- /** @type {Record<string, string>} */
62
- const bucket = /** @type {any} */ (out[section]);
63
- bucket[key] = value;
64
- }
103
+ defineOwn(bucket, key, value);
65
104
  }
66
105
  return out;
67
106
  }
68
107
 
108
+ /**
109
+ * Parse JSON that may carry line and block comments and trailing commas —
110
+ * the JSONC dialect `tsconfig.json`, `.vscode/settings.json`, `jsconfig.json`,
111
+ * and `devcontainer.json` are written in by convention.
112
+ *
113
+ * Comments are blanked rather than removed: every stripped character is
114
+ * replaced by a space, and newlines inside a block comment are kept as
115
+ * newlines. That keeps byte offsets and line/column numbers identical to the
116
+ * original file, so the position `JSON.parse` reports on a real syntax error
117
+ * still points at the line the author has open. Deleting the spans instead
118
+ * would silently shift every error after the first comment.
119
+ *
120
+ * The scan tracks string state, because the naive strip is wrong on exactly
121
+ * the values config files are full of: `"https://example.com"` contains `//`,
122
+ * and a `/*` may sit inside a string just as legitimately. Backslash escapes
123
+ * are consumed as a pair so a `\"` never looks like the end of a string.
124
+ *
125
+ * Note that comments are not preserved on the parsed value. Flecto only ever
126
+ * reads config, so nothing is written back — but a snapshot records the parsed
127
+ * structure, not the file, and comments are not part of it.
128
+ * @param {string} raw
129
+ * @returns {unknown}
130
+ */
131
+ export function parseJsonc(raw) {
132
+ return JSON.parse(stripJsonComments(raw));
133
+ }
134
+
135
+ /**
136
+ * Blank out JSONC comments and trailing commas, preserving every byte offset.
137
+ * Exported for tests; {@link parseJsonc} is the parsing entry point.
138
+ * @param {string} raw
139
+ * @returns {string}
140
+ */
141
+ export function stripJsonComments(raw) {
142
+ const text = String(raw);
143
+ const out = text.split('');
144
+ const blank = (from, to) => {
145
+ for (let k = from; k < to; k += 1) {
146
+ // Keep line breaks so line numbers in parse errors stay true.
147
+ if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' ';
148
+ }
149
+ };
150
+
151
+ // Index of a comma that has seen nothing but whitespace since, or -1. When a
152
+ // closing brace or bracket arrives it is a trailing comma and gets blanked.
153
+ let pendingComma = -1;
154
+ let i = 0;
155
+
156
+ while (i < text.length) {
157
+ const ch = text[i];
158
+
159
+ if (ch === '"') {
160
+ // A string is opaque: scan to its unescaped closing quote.
161
+ pendingComma = -1;
162
+ i += 1;
163
+ while (i < text.length) {
164
+ if (text[i] === '\\') { i += 2; continue; }
165
+ if (text[i] === '"') { i += 1; break; }
166
+ i += 1;
167
+ }
168
+ continue;
169
+ }
170
+
171
+ if (ch === '/' && text[i + 1] === '/') {
172
+ let end = i + 2;
173
+ while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end += 1;
174
+ blank(i, end);
175
+ i = end;
176
+ continue;
177
+ }
178
+
179
+ if (ch === '/' && text[i + 1] === '*') {
180
+ const closed = text.indexOf('*/', i + 2);
181
+ // An unterminated block comment runs to end of input. Blanking it leaves
182
+ // JSON.parse to report the truncated document, which is the real error.
183
+ const end = closed === -1 ? text.length : closed + 2;
184
+ blank(i, end);
185
+ i = end;
186
+ continue;
187
+ }
188
+
189
+ if (ch === ',') {
190
+ pendingComma = i;
191
+ } else if (ch === '}' || ch === ']') {
192
+ if (pendingComma !== -1) out[pendingComma] = ' ';
193
+ pendingComma = -1;
194
+ } else if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
195
+ pendingComma = -1;
196
+ }
197
+ i += 1;
198
+ }
199
+
200
+ return out.join('');
201
+ }
202
+
69
203
  function isPlainObject(v) {
70
204
  if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
71
205
  const prototype = Object.getPrototypeOf(v);
@@ -98,18 +232,34 @@ export const CIRCULAR_SENTINEL = '<circular>';
98
232
  * rather than e.g. a back-reference path, so two files with the same cycle
99
233
  * shape normalize to the same tree and compare equal — the whole point of a
100
234
  * stable, readable diff path.
235
+ *
236
+ * A budget bounds the total nodes produced. YAML aliases resolve to shared
237
+ * object *references*, so a tiny file — `a: &a [x,…]`, `b: [*a,*a,…]`, repeated
238
+ * a handful of levels — parses to a small DAG that this function expands into an
239
+ * exponentially large *tree* (each alias reference is normalized independently,
240
+ * on purpose, so two files with the same shape compare equal). Without a bound,
241
+ * a few hundred bytes of nested aliases hang the process — a "billion laughs"
242
+ * denial of service reachable on any parsed file. The budget makes it fail with
243
+ * a clear error instead. The limit is far above any real config.
101
244
  * @param {unknown} value
102
245
  * @param {Set<object>} [ancestors] internal recursion state; omit when calling
246
+ * @param {{ n: number }} [budget] internal node counter; omit when calling
103
247
  * @returns {unknown}
104
248
  */
105
- function normalizeParsedValue(value, ancestors = new Set()) {
249
+ function normalizeParsedValue(value, ancestors = new Set(), budget = { n: 0 }) {
250
+ if (++budget.n > MAX_NORMALIZED_NODES) {
251
+ throw new Error(
252
+ `document expands to too many nodes (limit ${MAX_NORMALIZED_NODES}); `
253
+ + 'this is usually YAML alias expansion (a "billion laughs" bomb)',
254
+ );
255
+ }
106
256
  if (typeof value === 'bigint') return String(value);
107
257
  if (typeof value === 'number' && !Number.isFinite(value)) return String(value);
108
258
  if (value instanceof Date) return value.toJSON();
109
259
  if (Array.isArray(value)) {
110
260
  if (ancestors.has(value)) return CIRCULAR_SENTINEL;
111
261
  ancestors.add(value);
112
- const out = value.map((item) => normalizeParsedValue(item, ancestors));
262
+ const out = value.map((item) => normalizeParsedValue(item, ancestors, budget));
113
263
  ancestors.delete(value);
114
264
  return out;
115
265
  }
@@ -120,14 +270,14 @@ function normalizeParsedValue(value, ancestors = new Set()) {
120
270
  ) {
121
271
  const serialized = value.toJSON();
122
272
  if (serialized !== value && (serialized === null || typeof serialized !== 'object')) {
123
- return normalizeParsedValue(serialized, ancestors);
273
+ return normalizeParsedValue(serialized, ancestors, budget);
124
274
  }
125
275
  }
126
276
  if (isPlainObject(value)) {
127
277
  if (ancestors.has(value)) return CIRCULAR_SENTINEL;
128
278
  ancestors.add(value);
129
279
  const out = Object.fromEntries(
130
- Object.entries(value).map(([key, child]) => [key, normalizeParsedValue(child, ancestors)]),
280
+ Object.entries(value).map(([key, child]) => [key, normalizeParsedValue(child, ancestors, budget)]),
131
281
  );
132
282
  ancestors.delete(value);
133
283
  return out;
@@ -170,6 +320,25 @@ function documentIdentity(doc) {
170
320
  return scalarField(doc, 'id') ?? scalarField(doc, 'name');
171
321
  }
172
322
 
323
+ /**
324
+ * True for a document that carries the Kubernetes resource markers `apiVersion`
325
+ * and `kind`. This is the signal used to decide whether a *single*-document file
326
+ * should be keyed by identity like a multi-document one (#124): a manifest that
327
+ * gains a second document beside it must keep the paths it had, and the only way
328
+ * to do that is to key it the same way whether it stands alone or not.
329
+ *
330
+ * Ordinary config that happens to have a `kind` but no `apiVersion` — a form
331
+ * field, say — is not treated as a manifest, so single-document config files are
332
+ * untouched.
333
+ * @param {unknown} doc
334
+ * @returns {boolean}
335
+ */
336
+ function isKubernetesDocument(doc) {
337
+ return isPlainObject(doc)
338
+ && scalarField(doc, 'apiVersion') !== null
339
+ && scalarField(doc, 'kind') !== null;
340
+ }
341
+
173
342
  /**
174
343
  * Keys for a multi-document file: identities when every document has a unique
175
344
  * one, otherwise document indices. It is all-or-nothing so keys within one file
@@ -194,10 +363,12 @@ function documentKeys(docs) {
194
363
  /**
195
364
  * Parse a YAML stream, supporting `---`-separated multi-document files.
196
365
  *
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`
366
+ * A file holding a single *non-manifest* document parses to that document
367
+ * unchanged, so diff paths for ordinary YAML are untouched. A file holding
368
+ * several documents or a single Kubernetes manifest parses to an object
369
+ * keyed per document, which lets the differ walk it like any other tree and,
370
+ * crucially, keeps a manifest's paths stable when a second document is added
371
+ * beside it (#124). Empty documents (a leading or trailing `---`, or a `null`
201
372
  * document) are dropped, so a stray separator does not create a phantom entry.
202
373
  *
203
374
  * The keys it invents are recorded on the wrapper (see documents.js) so that
@@ -211,7 +382,20 @@ export function parseYamlStream(raw) {
211
382
  const docs = yaml.loadAll(raw).filter((doc) => doc != null);
212
383
 
213
384
  if (docs.length === 0) return withDocumentKeys({}, []);
214
- if (docs.length === 1) return withDocumentKeys(docs[0], []);
385
+
386
+ // A lone document is normally returned bare, preserving ordinary YAML paths.
387
+ // The exception is a Kubernetes manifest with a resolvable identity: keying it
388
+ // now means adding a second document later leaves its paths unchanged, instead
389
+ // of re-pathing the whole file and reporting the untouched resource as
390
+ // removed-and-re-added. Ordinary single-document config is unaffected.
391
+ if (docs.length === 1) {
392
+ const [doc] = docs;
393
+ const identity = isKubernetesDocument(doc) ? documentIdentity(doc) : null;
394
+ if (identity == null || identity === '__proto__') {
395
+ return withDocumentKeys(doc, []);
396
+ }
397
+ return withDocumentKeys({ [identity]: doc }, [identity]);
398
+ }
215
399
 
216
400
  const keys = documentKeys(docs);
217
401
  /** @type {Record<string, unknown>} */
@@ -267,8 +451,8 @@ export function parseContent(filepath, raw) {
267
451
  parsed = dotenv.parse(raw);
268
452
  } else if (iniLike) {
269
453
  parsed = parseIni(raw);
270
- } else if (ext === '.json') {
271
- parsed = JSON.parse(raw);
454
+ } else if (ext === '.json' || ext === '.jsonc') {
455
+ parsed = parseJsonc(raw);
272
456
  } else if (ext === '.yaml' || ext === '.yml') {
273
457
  parsed = parseYamlStream(raw);
274
458
  } else if (ext === '.toml') {
@@ -282,6 +466,12 @@ export function parseContent(filepath, raw) {
282
466
  );
283
467
  }
284
468
 
469
+ // Guarded here, on the one path every generic command shares, rather than in
470
+ // each command: ci, watch, compare, report, and snapshot reads all land here,
471
+ // and so does a plan read out of git via --snapshot-ref. `flecto plan` reads
472
+ // through readTerraformPlanFile() instead and is unaffected (#113).
473
+ assertNotTerraformPlan(parsed, filepath);
474
+
285
475
  const keys = documentKeysOf(parsed) ?? [];
286
476
  return withDocumentKeys(normalizeEncrypted(normalizeParsedValue(parsed), keys), keys);
287
477
  }
@@ -90,7 +90,7 @@ export function assertExpectedFindings(actual, expected) {
90
90
  /**
91
91
  * Run a policy fixture stored in a directory.
92
92
  * @param {string} fixtureDir
93
- * @param {{ configName?: string }} [options]
93
+ * @param {{ configName?: string, cwd?: string }} [options]
94
94
  */
95
95
  export async function testPolicyFixture(fixtureDir, options = {}) {
96
96
  const dir = resolve(fixtureDir);
@@ -117,6 +117,10 @@ export async function testPolicyFixture(fixtureDir, options = {}) {
117
117
  source: config.source ?? 'ci',
118
118
  policies: config.policies,
119
119
  plugins: config.plugins,
120
+ // A fixture's own policies/ still wins, so self-contained fixtures behave
121
+ // exactly as before. The invoking project is a fallback, which is where
122
+ // `flecto policies add` installs packs (#114).
123
+ packRoots: [options.cwd ?? process.cwd()],
120
124
  });
121
125
  assertExpectedFindings(findings, config.expected);
122
126