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.
@@ -0,0 +1,431 @@
1
+ import { basename, extname } from 'path';
2
+
3
+ import { stripJsonComments } from './parser.js';
4
+
5
+ /**
6
+ * Inline suppressions: `# flecto-ignore-next-line <rule> — <reason>` on the line
7
+ * above a deliberate finding. The companion to the baseline (#118) — a baseline
8
+ * accepts findings in bulk, a suppression accepts one, in place, next to the
9
+ * thing being accepted.
10
+ *
11
+ * Two rules keep it from decaying into a wall of unexplained `# noqa`:
12
+ *
13
+ * - **A reason is mandatory.** A directive without one is refused, loudly,
14
+ * naming the file and line — never silently applied and never silently
15
+ * dropped.
16
+ * - **It is scoped to the next line and a named rule.** No bare "ignore
17
+ * everything here"; `--ignore` and `severityRemap` already do that, at the
18
+ * level where they belong.
19
+ *
20
+ * Resolving a directive to a finding is the hard part, because a finding carries
21
+ * a *semantic path*, not a line. We reconstruct the full path of the key on the
22
+ * suppressed line from the raw source — nesting for YAML, section/table for
23
+ * INI/TOML, flat for dotenv — and match a finding whose path equals it (or ends
24
+ * with it, so a multi-document identity prefix does not defeat the match). Using
25
+ * the *full* path, not just the leaf, is what stops a suppression on one
26
+ * `pool_size` from silently hiding an uncommented `pool_size` elsewhere in the
27
+ * file — over-suppression being the dangerous failure for a security tool.
28
+ *
29
+ * JSON is included, because `.json` and `.jsonc` are parsed as JSONC (#152) and
30
+ * so do carry comments. Its resolver reuses the parser's comment stripper rather
31
+ * than recognising line and block comments a second time: they are blanked in
32
+ * place, preserving every line number, and the key scan then walks a
33
+ * comment-free copy.
34
+ *
35
+ * A directive that cannot be resolved to a key — an array element in any format,
36
+ * or a file type with no comment syntax at all — produces a **warning naming the
37
+ * file and line**. That case fails closed (the finding still fires and still
38
+ * gates), so it is not a second build failure on top of the first; but a
39
+ * suppression the author believes is applied and which is quietly absent is
40
+ * exactly the failure mode this file exists to avoid, so it is never silent.
41
+ */
42
+
43
+ const DIRECTIVE = /flecto-ignore-next-line\b[ \t]*(.*)$/;
44
+ // Strip a leading reason separator: an em dash, one or more hyphens, or a colon.
45
+ const REASON_SEPARATOR = /^(?:—|-{1,2}|:)[ \t]*/;
46
+
47
+ /**
48
+ * @typedef {'yaml' | 'json' | 'toml' | 'ini' | 'dotenv' | null} SuppressionFormat
49
+ *
50
+ * @typedef {{
51
+ * rule: string,
52
+ * reason: string,
53
+ * line: number,
54
+ * path: string | null
55
+ * }} Suppression
56
+ *
57
+ * @typedef {{ line: number, message: string }} SuppressionError
58
+ *
59
+ * @typedef {{ line: number, message: string }} SuppressionWarning
60
+ */
61
+
62
+ /**
63
+ * Which comment-bearing format a file is, or null when inline suppression does
64
+ * not apply to it — an encrypted file, or an extension with no comment syntax.
65
+ * @param {string} filepath
66
+ * @returns {SuppressionFormat}
67
+ */
68
+ export function suppressionFormat(filepath) {
69
+ const base = basename(filepath);
70
+ if (base === '.env' || base.startsWith('.env.') || base.endsWith('.env')) return 'dotenv';
71
+ switch (extname(filepath).toLowerCase()) {
72
+ case '.yaml':
73
+ case '.yml':
74
+ return 'yaml';
75
+ case '.json':
76
+ case '.jsonc':
77
+ return 'json';
78
+ case '.toml':
79
+ return 'toml';
80
+ case '.ini':
81
+ return 'ini';
82
+ default:
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Leading indentation width (spaces; a tab counts as one).
89
+ * @param {string} line
90
+ * @returns {number}
91
+ */
92
+ function indentOf(line) {
93
+ const match = /^[ \t]*/.exec(line);
94
+ return match ? match[0].length : 0;
95
+ }
96
+
97
+ /**
98
+ * Whether a line carries no config. JSON is scanned on a comment-blanked copy,
99
+ * so its comments are already whitespace by the time this runs — treating a `#`
100
+ * there as a comment would misread a line whose value merely starts with one.
101
+ * @param {string} raw
102
+ * @param {SuppressionFormat} [format]
103
+ * @returns {boolean}
104
+ */
105
+ function isBlankOrComment(raw, format) {
106
+ const trimmed = raw.trim();
107
+ if (trimmed === '') return true;
108
+ if (format === 'json') return false;
109
+ return trimmed.startsWith('#') || trimmed.startsWith(';');
110
+ }
111
+
112
+ /**
113
+ * Reconstruct the dotted path of the key defined on `targetIndex`, walking the
114
+ * lines above it for context. Returns null when the line is not a plain
115
+ * `key: value` / `key = value` mapping entry (e.g. an array item), which the
116
+ * caller treats as "cannot resolve" rather than guessing.
117
+ * @param {string[]} lines
118
+ * @param {number} targetIndex
119
+ * @param {SuppressionFormat} format
120
+ * @returns {string | null}
121
+ */
122
+ function pathAtLine(lines, targetIndex, format) {
123
+ if (format === 'dotenv') return dotenvKey(lines[targetIndex]);
124
+ if (format === 'ini' || format === 'toml') return sectionedKey(lines, targetIndex, format);
125
+ if (format === 'json') return jsonPath(lines, targetIndex);
126
+ return yamlPath(lines, targetIndex);
127
+ }
128
+
129
+ /**
130
+ * @param {string} line
131
+ * @returns {string | null}
132
+ */
133
+ function dotenvKey(line) {
134
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=/.exec(line);
135
+ return match ? match[1] : null;
136
+ }
137
+
138
+ /**
139
+ * INI/TOML share a section/table header + `key = value` shape.
140
+ * @param {string[]} lines
141
+ * @param {number} targetIndex
142
+ * @param {'ini' | 'toml'} format
143
+ * @returns {string | null}
144
+ */
145
+ function sectionedKey(lines, targetIndex, format) {
146
+ const target = lines[targetIndex];
147
+ const keyMatch = /^\s*([A-Za-z0-9_.\-"']+)\s*=/.exec(target);
148
+ if (!keyMatch) return null;
149
+ const key = unquote(keyMatch[1].trim());
150
+
151
+ let section = null;
152
+ for (let i = 0; i < targetIndex; i++) {
153
+ const line = lines[i].trim();
154
+ // TOML arrays-of-tables ([[x]]) do not map onto a single dotted path.
155
+ if (format === 'toml' && /^\[\[.+\]\]$/.test(line)) { section = null; continue; }
156
+ const header = /^\[([^\]]+)\]$/.exec(line);
157
+ if (header) section = header[1].trim();
158
+ }
159
+ return section ? `${section}.${key}` : key;
160
+ }
161
+
162
+ /** Key of a `"key":` entry, capturing the raw (still-escaped) name. */
163
+ const JSON_KEY = /^\s*"((?:[^"\\]|\\.)*)"\s*:/;
164
+
165
+ /**
166
+ * Reconstruct a nested JSON object path from the enclosing key stack. `lines`
167
+ * are already comment-blanked, so what is scanned is config and nothing else.
168
+ *
169
+ * Anything inside an **array** yields null. That is the same refusal YAML makes
170
+ * for a sequence item, and for the same reason: an array element's diff path is
171
+ * either its index or its `arrayIdKey` identity depending on how the run is
172
+ * configured, so a resolver that guessed one would suppress the wrong finding
173
+ * under the other — over-suppression being the dangerous direction here. The
174
+ * caller warns rather than dropping it quietly.
175
+ * @param {string[]} lines
176
+ * @param {number} targetIndex
177
+ * @returns {string | null}
178
+ */
179
+ function jsonPath(lines, targetIndex) {
180
+ const key = jsonKeyOnLine(lines[targetIndex]);
181
+ if (key === null) return null;
182
+ const enclosing = jsonContainerKeys(lines.slice(0, targetIndex).join('\n'));
183
+ if (enclosing === null) return null;
184
+ return [...enclosing, key].join('.');
185
+ }
186
+
187
+ /**
188
+ * @param {string} line
189
+ * @returns {string | null}
190
+ */
191
+ function jsonKeyOnLine(line) {
192
+ const match = JSON_KEY.exec(line);
193
+ return match ? decodeJsonString(match[1]) : null;
194
+ }
195
+
196
+ /**
197
+ * A JSON key is an escaped string, so `\u00e9` and `\"` have to be decoded to
198
+ * the name the differ reports rather than compared raw.
199
+ * @param {string} inner
200
+ * @returns {string | null}
201
+ */
202
+ function decodeJsonString(inner) {
203
+ try {
204
+ return JSON.parse(`"${inner}"`);
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * The object keys enclosing the end of `text`, outermost first, or null when
212
+ * the position sits inside an array or the structure cannot be read. The root
213
+ * container contributes no segment, matching how the differ builds a path.
214
+ * @param {string} text
215
+ * @returns {string[] | null}
216
+ */
217
+ function jsonContainerKeys(text) {
218
+ /** @type {{ array: boolean, key: string | null }[]} */
219
+ const stack = [];
220
+ let lastKey = null;
221
+ let i = 0;
222
+
223
+ while (i < text.length) {
224
+ const ch = text[i];
225
+
226
+ if (ch === '"') {
227
+ // Strings are opaque: a brace or bracket inside one is data, not structure.
228
+ let end = i + 1;
229
+ while (end < text.length) {
230
+ if (text[end] === '\\') { end += 2; continue; }
231
+ if (text[end] === '"') break;
232
+ end += 1;
233
+ }
234
+ const inner = text.slice(i + 1, end);
235
+ i = end + 1;
236
+ let next = i;
237
+ while (next < text.length && /\s/.test(text[next])) next += 1;
238
+ // A string followed by a colon names the value that follows; otherwise it
239
+ // is itself a value, and names nothing.
240
+ if (text[next] === ':') {
241
+ lastKey = decodeJsonString(inner);
242
+ i = next + 1;
243
+ }
244
+ continue;
245
+ }
246
+
247
+ if (ch === '{' || ch === '[') {
248
+ stack.push({ array: ch === '[', key: lastKey });
249
+ lastKey = null;
250
+ } else if (ch === '}' || ch === ']') {
251
+ stack.pop();
252
+ lastKey = null;
253
+ } else if (ch === ',') {
254
+ lastKey = null;
255
+ }
256
+ i += 1;
257
+ }
258
+
259
+ if (stack.length === 0) return null;
260
+ if (stack.some((frame) => frame.array)) return null;
261
+ const keys = stack.slice(1).map((frame) => frame.key);
262
+ return keys.some((key) => key === null) ? null : /** @type {string[]} */ (keys);
263
+ }
264
+
265
+ /**
266
+ * Reconstruct a nested YAML mapping path via indentation. Array items and
267
+ * multi-document separators yield null, so those are left to the baseline rather
268
+ * than resolved by guesswork.
269
+ * @param {string[]} lines
270
+ * @param {number} targetIndex
271
+ * @returns {string | null}
272
+ */
273
+ function yamlPath(lines, targetIndex) {
274
+ const target = lines[targetIndex];
275
+ const targetKey = yamlKey(target);
276
+ if (targetKey === null) return null;
277
+
278
+ /** @type {{ indent: number, key: string }[]} */
279
+ const stack = [];
280
+ for (let i = 0; i <= targetIndex; i++) {
281
+ const line = lines[i];
282
+ if (isBlankOrComment(line, 'yaml')) continue;
283
+ if (line.trim() === '---') return null; // multi-document: identity-prefixed, skip
284
+ const trimmed = line.trim();
285
+ if (trimmed.startsWith('- ')) return null; // inside a sequence
286
+ const key = yamlKey(line);
287
+ if (key === null) continue;
288
+ const indent = indentOf(line);
289
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
290
+ stack.push({ indent, key });
291
+ }
292
+ return stack.map((entry) => entry.key).join('.');
293
+ }
294
+
295
+ /**
296
+ * The key of a `key:` or `key: value` YAML line, or null.
297
+ * @param {string} line
298
+ * @returns {string | null}
299
+ */
300
+ function yamlKey(line) {
301
+ const match = /^\s*([^\s:#][^:]*):(?:\s|$)/.exec(line);
302
+ return match ? unquote(match[1].trim()) : null;
303
+ }
304
+
305
+ /**
306
+ * @param {string} value
307
+ * @returns {string}
308
+ */
309
+ function unquote(value) {
310
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
311
+ return value.slice(1, -1);
312
+ }
313
+ return value;
314
+ }
315
+
316
+ /**
317
+ * Parse every `flecto-ignore-next-line` directive in a file's raw text.
318
+ * @param {string} raw
319
+ * @param {SuppressionFormat} format
320
+ * @returns {{
321
+ * suppressions: Suppression[],
322
+ * errors: SuppressionError[],
323
+ * warnings: SuppressionWarning[]
324
+ * }}
325
+ */
326
+ export function parseSuppressions(raw, format) {
327
+ /** @type {Suppression[]} */
328
+ const suppressions = [];
329
+ /** @type {SuppressionError[]} */
330
+ const errors = [];
331
+ /** @type {SuppressionWarning[]} */
332
+ const warnings = [];
333
+
334
+ const text = String(raw);
335
+ const lines = text.split(/\r?\n/);
336
+ // Directives are read from the raw text, and keys from a comment-blanked copy
337
+ // of it. stripJsonComments() replaces each stripped character with a space and
338
+ // keeps newlines, so the two are line-for-line identical.
339
+ const code = format === 'json' ? stripJsonComments(text).split(/\r?\n/) : lines;
340
+
341
+ for (let i = 0; i < lines.length; i++) {
342
+ const match = DIRECTIVE.exec(lines[i]);
343
+ if (!match) continue;
344
+ // Directives are scanned on the raw line so `// flecto-ignore-next-line`
345
+ // is visible. For JSON, comments have already been blanked on `code`, so a
346
+ // match still present there lived inside a string — data, not a comment.
347
+ // Treating it as a suppression would hide the next key, the over-suppression
348
+ // this resolver exists to refuse.
349
+ if (format === 'json') {
350
+ const blanked = code[i].slice(match.index, match.index + match[0].length);
351
+ if (blanked.trim() !== '') continue;
352
+ }
353
+ const lineNo = i + 1;
354
+
355
+ // A directive in a file whose format cannot carry one does nothing. Saying
356
+ // so is the whole point: the author believes the finding is accepted.
357
+ if (!format) {
358
+ warnings.push({
359
+ line: lineNo,
360
+ message: 'inline suppressions do not apply to this file type and this directive has no effect — use --baseline to accept the finding',
361
+ });
362
+ continue;
363
+ }
364
+
365
+ const rest = match[1].trim();
366
+ const ruleMatch = /^(\S+)([\s\S]*)$/.exec(rest);
367
+ if (!ruleMatch) {
368
+ errors.push({ line: lineNo, message: 'flecto-ignore-next-line needs a rule id and a reason' });
369
+ continue;
370
+ }
371
+ const rule = ruleMatch[1];
372
+ const reason = ruleMatch[2].trim().replace(REASON_SEPARATOR, '').trim();
373
+ if (!reason) {
374
+ errors.push({
375
+ line: lineNo,
376
+ message: `flecto-ignore-next-line ${rule} needs a reason (e.g. "# flecto-ignore-next-line ${rule} — why this is intended")`,
377
+ });
378
+ continue;
379
+ }
380
+
381
+ // The suppressed line is the next line that carries config, not another
382
+ // comment or a blank.
383
+ let target = -1;
384
+ for (let j = i + 1; j < code.length; j++) {
385
+ if (!isBlankOrComment(code[j], format)) { target = j; break; }
386
+ }
387
+ const path = target === -1 ? null : pathAtLine(code, target, format);
388
+ if (path === null) {
389
+ warnings.push({
390
+ line: lineNo,
391
+ message: `flecto-ignore-next-line ${rule} does not resolve to a config key, so it suppresses nothing — array elements and multi-document files are not addressable inline; use --baseline`,
392
+ });
393
+ }
394
+ suppressions.push({ rule, reason, line: lineNo, path });
395
+ }
396
+ return { suppressions, errors, warnings };
397
+ }
398
+
399
+ /**
400
+ * True when `findingPath` is the suppression's path, or ends with it on a
401
+ * dotted-segment boundary (tolerating a document-identity prefix).
402
+ * @param {string} findingPath
403
+ * @param {string} suppressionPath
404
+ * @returns {boolean}
405
+ */
406
+ function pathMatches(findingPath, suppressionPath) {
407
+ if (!suppressionPath) return false;
408
+ if (findingPath === suppressionPath) return true;
409
+ return findingPath.endsWith(`.${suppressionPath}`);
410
+ }
411
+
412
+ /**
413
+ * Partition findings against a file's suppressions.
414
+ * @param {import('./policy.js').PolicyFinding[]} findings
415
+ * @param {Suppression[]} suppressions
416
+ * @returns {{
417
+ * active: import('./policy.js').PolicyFinding[],
418
+ * suppressed: Array<{ finding: import('./policy.js').PolicyFinding, reason: string }>
419
+ * }}
420
+ */
421
+ export function applySuppressions(findings, suppressions) {
422
+ const active = [];
423
+ const suppressed = [];
424
+ for (const finding of findings) {
425
+ const hit = suppressions.find((s) =>
426
+ s.path && String(finding.id) === s.rule && pathMatches(String(finding.path ?? ''), s.path));
427
+ if (hit) suppressed.push({ finding, reason: hit.reason });
428
+ else active.push(finding);
429
+ }
430
+ return { active, suppressed };
431
+ }
package/src/terraform.js CHANGED
@@ -416,6 +416,34 @@ export function assertTerraformPlan(value, label) {
416
416
  );
417
417
  }
418
418
 
419
+ /**
420
+ * The inverse guard: refuse a Terraform plan on the *generic config* path.
421
+ *
422
+ * Terraform's `before_sensitive` / `after_sensitive` redaction is applied by
423
+ * diffTerraformPlan(), which only `flecto plan` calls. A plan file is ordinary
424
+ * JSON, so every other command would read it as a plain config tree and print
425
+ * the values Terraform itself refuses to print. `--mask-secrets` is not a
426
+ * backstop: it only fires when an attribute name matches the secret pattern,
427
+ * and `user_data` does not (#113).
428
+ *
429
+ * Failing closed rather than skipping is deliberate and matches how the rest of
430
+ * Flecto behaves — a plan file swept up by a repo-wide glob is a real
431
+ * misconfiguration, and silently omitting the file would leave an operator
432
+ * believing it had been gated.
433
+ * @param {unknown} value
434
+ * @param {string} label
435
+ */
436
+ export function assertNotTerraformPlan(value, label) {
437
+ if (!isTerraformPlan(value)) return;
438
+ throw new Error(
439
+ `"${label}" is Terraform plan JSON, which this command cannot read safely.\n`
440
+ + 'Terraform marks sensitive attributes in before_sensitive/after_sensitive, and that\n'
441
+ + 'redaction is only applied by "flecto plan". Reading the plan as a plain config file\n'
442
+ + 'would print those values.\n'
443
+ + `Use: flecto plan ${label}`,
444
+ );
445
+ }
446
+
419
447
  /**
420
448
  * Read and validate a Terraform plan JSON file.
421
449
  * @param {string} filepath