flecto 3.0.0 → 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/sarif.js ADDED
@@ -0,0 +1,144 @@
1
+ import { relative } from 'path';
2
+
3
+ /**
4
+ * SARIF 2.1.0 output for `flecto ci --format sarif`, for upload to GitHub code
5
+ * scanning (github/codeql-action/upload-sarif) and any other SARIF consumer.
6
+ *
7
+ * What Flecto emits as SARIF *results* is its policy findings — a finding has a
8
+ * rule id, a severity, a file, and a path, which is exactly the shape SARIF
9
+ * models. Raw change events are not results: they carry no rule id, so they have
10
+ * nothing to be a `ruleId` of. A run that gates on changes alone (`--fail-on
11
+ * changed`) still exits non-zero; SARIF simply reports the policy findings.
12
+ *
13
+ * Line numbers: Flecto reports a *semantic path* (`Deployment/prod/api.spec.
14
+ * replicas`), not a source line, and resolving one to the other means a
15
+ * line-tracking parser for every format. Until that exists, results are
16
+ * file-level: the physical location is the file with `startLine: 1`, and the
17
+ * semantic path is preserved losslessly as a SARIF `logicalLocation`. GitHub
18
+ * still renders the alert, dedupes it, and tracks when it is fixed — the
19
+ * file-level tradeoff the issue (#120) calls out. The logical location means the
20
+ * path a reviewer needs is never lost, only not yet a clickable line.
21
+ */
22
+
23
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
24
+ const INFORMATION_URI = 'https://github.com/myselfsiddharth/Flecto';
25
+
26
+ /**
27
+ * Map a Flecto severity to a SARIF result level.
28
+ * @param {string} severity
29
+ * @returns {'error' | 'warning' | 'note'}
30
+ */
31
+ function sarifLevel(severity) {
32
+ if (severity === 'error') return 'error';
33
+ if (severity === 'info') return 'note';
34
+ return 'warning';
35
+ }
36
+
37
+ /**
38
+ * A repo-relative, POSIX-slashed URI for a file. SARIF consumers (GitHub in
39
+ * particular) map results onto the tree by relative URI; an absolute path does
40
+ * not resolve, so anything outside the working directory falls back to its base
41
+ * name rather than leaking an absolute path that would not map anyway.
42
+ * @param {string} file
43
+ * @param {string} cwd
44
+ * @returns {string}
45
+ */
46
+ function artifactUri(file, cwd) {
47
+ const rel = relative(cwd, file);
48
+ if (!rel || rel.startsWith('..') || rel.includes(`..${'/'}`)) {
49
+ return file.split(/[\\/]/).pop() ?? file;
50
+ }
51
+ return rel.split('\\').join('/');
52
+ }
53
+
54
+ /**
55
+ * Build a SARIF 2.1.0 log from CI results.
56
+ *
57
+ * `results` is the same array `printCiOutput` receives: each entry is
58
+ * `{ file, policies }`, where `policies` is the already-mask-processed finding
59
+ * list. Because SARIF is built from that same masked list, `--mask-secrets`
60
+ * applies to the SARIF file with no extra work — which matters, since a SARIF
61
+ * file is uploaded to GitHub and retained.
62
+ * @param {Array<{ file: string, policies: import('./policy.js').PolicyFinding[] }>} results
63
+ * @param {{ cwd?: string, toolVersion?: string }} [options]
64
+ * @returns {object}
65
+ */
66
+ export function buildSarif(results, options = {}) {
67
+ const cwd = options.cwd ?? process.cwd();
68
+ const version = options.toolVersion ?? '0.0.0';
69
+
70
+ /** @type {Map<string, { index: number, descriptor: object }>} */
71
+ const ruleIndex = new Map();
72
+ /** @type {object[]} */
73
+ const sarifResults = [];
74
+
75
+ for (const result of results) {
76
+ for (const finding of result.policies ?? []) {
77
+ const ruleId = String(finding.id);
78
+ if (!ruleIndex.has(ruleId)) {
79
+ ruleIndex.set(ruleId, {
80
+ index: ruleIndex.size,
81
+ descriptor: {
82
+ id: ruleId,
83
+ name: ruleId,
84
+ shortDescription: { text: shortDescriptionFor(finding) },
85
+ defaultConfiguration: { level: sarifLevel(finding.severity) },
86
+ ...(finding.pack ? { properties: { pack: String(finding.pack) } } : {}),
87
+ },
88
+ });
89
+ }
90
+ const path = String(finding.path ?? '');
91
+ const uri = artifactUri(result.file, cwd);
92
+ sarifResults.push({
93
+ ruleId,
94
+ ruleIndex: ruleIndex.get(ruleId).index,
95
+ level: sarifLevel(finding.severity),
96
+ message: { text: String(finding.message ?? `Policy ${ruleId} matched`) },
97
+ locations: [{
98
+ physicalLocation: {
99
+ artifactLocation: { uri },
100
+ region: { startLine: 1 },
101
+ },
102
+ ...(path
103
+ ? { logicalLocations: [{ fullyQualifiedName: path, kind: 'member' }] }
104
+ : {}),
105
+ }],
106
+ // Keeps a finding stable across runs as its line would drift, so GitHub
107
+ // dedupes and tracks fixes by (rule, file, semantic path) rather than by
108
+ // a line number Flecto does not have.
109
+ partialFingerprints: { flectoPathV1: `${ruleId}::${uri}::${path}` },
110
+ });
111
+ }
112
+ }
113
+
114
+ const rules = [...ruleIndex.values()].map((entry) => entry.descriptor);
115
+
116
+ return {
117
+ $schema: SARIF_SCHEMA,
118
+ version: '2.1.0',
119
+ runs: [{
120
+ tool: {
121
+ driver: {
122
+ name: 'Flecto',
123
+ informationUri: INFORMATION_URI,
124
+ version,
125
+ rules,
126
+ },
127
+ },
128
+ results: sarifResults,
129
+ }],
130
+ };
131
+ }
132
+
133
+ /**
134
+ * A stable short description for a rule descriptor. The finding's message is
135
+ * per-occurrence (it can interpolate values), so it is not ideal as a rule-level
136
+ * description, but it is the most specific text available and reads better than a
137
+ * generic placeholder. Trimmed to a single line.
138
+ * @param {import('./policy.js').PolicyFinding} finding
139
+ * @returns {string}
140
+ */
141
+ function shortDescriptionFor(finding) {
142
+ const message = String(finding.message ?? '').split('\n')[0].trim();
143
+ return message || `Policy rule ${finding.id}`;
144
+ }
package/src/secrets.js CHANGED
@@ -57,15 +57,22 @@ const KNOWN_FORMATS = [
57
57
  { kind: 'stripe-secret-key', re: /\b[sr]k_live_[0-9A-Za-z]{16,}\b/g },
58
58
  // JWT: base64url header starting with "eyJ" ('{"'), payload, signature.
59
59
  { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
60
- // PEM private key blocks, including PGP blocks and unterminated fragments.
61
- {
62
- kind: 'private-key-block',
63
- re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g,
64
- },
65
60
  ];
66
61
 
67
- /** Credentials embedded in a URL authority: scheme://user:PASSWORD@host. */
68
- const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
62
+ // PEM/PGP private-key block markers, matched linearly. A single regex spanning
63
+ // BEGIN…END with `[\s\S]*?…$` backtracks quadratically on a long BEGIN-prefixed
64
+ // value with no END — a denial-of-service vector, since secret detection runs
65
+ // on every changed string value. Instead we find the markers with anchored,
66
+ // non-spanning regexes and pair them by position (see findPrivateKeyBlocks).
67
+ const PRIVATE_KEY_BEGIN_RE = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
68
+ const PRIVATE_KEY_END_RE = /-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
69
+
70
+ // Credentials embedded in a URL authority: scheme://user:PASSWORD@host. The
71
+ // scheme run is length-bounded ({0,32}); an unbounded `*` before the required
72
+ // `://` backtracks quadratically on a long value that never contains `://`. No
73
+ // real URL scheme approaches 32 characters, so the bound changes nothing that
74
+ // matters and removes the ReDoS.
75
+ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]{0,32}:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
69
76
 
70
77
  /**
71
78
  * Values that only *reference* a secret. Redacting these adds noise and, worse,
@@ -205,6 +212,31 @@ function isHighEntropySecret(value) {
205
212
  return true;
206
213
  }
207
214
 
215
+ /**
216
+ * PEM/PGP private-key spans, found linearly. Each BEGIN marker pairs with the
217
+ * next END marker after it; a BEGIN with no following END runs to end of string
218
+ * (an unterminated fragment is still a leaked key). Pairing by position avoids
219
+ * the quadratic backtracking a single BEGIN…END regex incurs.
220
+ * @param {string} value
221
+ * @returns {SecretMatch[]}
222
+ */
223
+ function findPrivateKeyBlocks(value) {
224
+ /** @type {SecretMatch[]} */
225
+ const spans = [];
226
+ PRIVATE_KEY_BEGIN_RE.lastIndex = 0;
227
+ let begin;
228
+ while ((begin = PRIVATE_KEY_BEGIN_RE.exec(value)) !== null) {
229
+ const bodyStart = begin.index + begin[0].length;
230
+ PRIVATE_KEY_END_RE.lastIndex = bodyStart;
231
+ const end = PRIVATE_KEY_END_RE.exec(value);
232
+ const spanEnd = end ? end.index + end[0].length : value.length;
233
+ spans.push({ kind: 'private-key-block', start: begin.index, end: spanEnd });
234
+ // Resume scanning past this block so overlapping BEGINs inside it are skipped.
235
+ PRIVATE_KEY_BEGIN_RE.lastIndex = spanEnd;
236
+ }
237
+ return spans;
238
+ }
239
+
208
240
  /**
209
241
  * Locate every secret-shaped span inside a string, sorted and non-overlapping.
210
242
  * @param {string} value
@@ -223,6 +255,8 @@ function findSecretMatches(value) {
223
255
  }
224
256
  }
225
257
 
258
+ matches.push(...findPrivateKeyBlocks(value));
259
+
226
260
  URL_CREDENTIALS_RE.lastIndex = 0;
227
261
  let credentials;
228
262
  while ((credentials = URL_CREDENTIALS_RE.exec(value)) !== null) {
@@ -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