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/CHANGELOG.md +534 -1
- package/README.md +59 -1
- package/index.js +427 -54
- package/package.json +4 -1
- package/schemas/flecto-policy-pack-2.0.json +2 -0
- package/src/baseline.js +193 -0
- package/src/config.js +463 -19
- package/src/encrypted.js +16 -13
- package/src/packs/github-actions.json +92 -0
- package/src/parser.js +212 -22
- package/src/policy-test.js +5 -1
- package/src/policy.js +96 -23
- package/src/pr-comment.js +53 -87
- package/src/pr-providers.js +261 -0
- package/src/renderer.js +7 -7
- package/src/report.js +39 -1
- package/src/sarif.js +144 -0
- package/src/secrets.js +41 -7
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
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
|
-
|
|
68
|
-
|
|
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
|