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/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "3.0.
|
|
7
|
+
"version": "3.0.2",
|
|
8
8
|
"description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test test/*.test.js",
|
|
45
45
|
"test:watch": "node --test --watch test/*.test.js",
|
|
46
|
+
"test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov test/*.test.js",
|
|
47
|
+
"coverage": "npm run test:coverage && node scripts/coverage-report.js",
|
|
46
48
|
"bench": "node bench/run.js",
|
|
49
|
+
"fuzz": "node test/fuzz/run.js",
|
|
47
50
|
"pack:check": "npm pack --dry-run"
|
|
48
51
|
},
|
|
49
52
|
"dependencies": {
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"beforeLooksSecret": { "const": true },
|
|
46
46
|
"afterLooksSecret": { "const": true },
|
|
47
47
|
"afterMatches": { "type": "string" },
|
|
48
|
+
"afterAnyMatches": { "type": "string" },
|
|
48
49
|
"numericJump": {
|
|
49
50
|
"type": "object",
|
|
50
51
|
"additionalProperties": false,
|
|
@@ -95,6 +96,7 @@
|
|
|
95
96
|
"beforeLooksSecret": { "const": true },
|
|
96
97
|
"afterLooksSecret": { "const": true },
|
|
97
98
|
"afterMatches": { "type": "string" },
|
|
99
|
+
"afterAnyMatches": { "type": "string" },
|
|
98
100
|
"numericJump": { "$ref": "#/$defs/numericJump" },
|
|
99
101
|
"numericDelta": { "$ref": "#/$defs/numericDelta" }
|
|
100
102
|
}
|
package/src/baseline.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { relative, resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adoption baseline for `flecto ci`. Records the policy findings that already
|
|
6
|
+
* exist in a repository so the gate fails only on *new* ones, the way every
|
|
7
|
+
* durable policy tool lets a team turn on enforcement without first fixing
|
|
8
|
+
* years of accumulated config.
|
|
9
|
+
*
|
|
10
|
+
* The fingerprint is the crux (#118). It is `(rule id, file, path)` and
|
|
11
|
+
* deliberately excludes the finding's value:
|
|
12
|
+
*
|
|
13
|
+
* - **Excluding the value** keeps a baseline from churning on every edit. A
|
|
14
|
+
* `pool-size-jump` that was accepted at 5→20 should stay accepted when it
|
|
15
|
+
* becomes 5→21; re-flagging it would make the file thrash and get deleted.
|
|
16
|
+
* - **Including the path** — which for multi-document YAML already carries the
|
|
17
|
+
* document identity (`Deployment/prod/api.…`) — keeps two findings of the same
|
|
18
|
+
* rule in the same file distinct.
|
|
19
|
+
* - **The file is stored repo-relative and POSIX-slashed**, so a baseline is
|
|
20
|
+
* portable across machines and clean in a diff.
|
|
21
|
+
*
|
|
22
|
+
* The tradeoff, documented for the user: renaming a file or restructuring a path
|
|
23
|
+
* re-introduces its findings as new. That is the honest failure mode — a
|
|
24
|
+
* fingerprint stable across a rename would have to ignore location, which would
|
|
25
|
+
* make it too coarse to tell two findings apart.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const BASELINE_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{
|
|
32
|
+
* rule: string,
|
|
33
|
+
* file: string,
|
|
34
|
+
* path: string,
|
|
35
|
+
* severity?: string,
|
|
36
|
+
* message?: string,
|
|
37
|
+
* pack?: string,
|
|
38
|
+
* acceptedAt?: string,
|
|
39
|
+
* reason?: string
|
|
40
|
+
* }} BaselineEntry
|
|
41
|
+
*
|
|
42
|
+
* @typedef {{ version: number, generatedAt?: string, findings: BaselineEntry[] }} BaselineFile
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The stable identity of a finding: rule, file, path. NUL-joined so no two
|
|
47
|
+
* different triples can collide by concatenation.
|
|
48
|
+
* @param {{ rule: string, file: string, path: string }} entry
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function fingerprint(entry) {
|
|
52
|
+
return `${entry.rule}\u0000${entry.file}\u0000${entry.path}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a file path to the repo-relative, POSIX form stored in a baseline.
|
|
57
|
+
* @param {string} file
|
|
58
|
+
* @param {string} cwd
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function baselineRelativePath(file, cwd) {
|
|
62
|
+
const rel = relative(cwd, file);
|
|
63
|
+
if (!rel || rel.startsWith('..')) return file.split(/[\\/]/).join('/');
|
|
64
|
+
return rel.split('\\').join('/');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read and validate a baseline file. A missing file is not an error — it is the
|
|
69
|
+
* first run, before anyone has recorded anything — so it reads as empty. A file
|
|
70
|
+
* that exists but is malformed *is* an error: silently treating it as empty
|
|
71
|
+
* would turn every recorded finding new and fail the build in a way that looks
|
|
72
|
+
* like a regression.
|
|
73
|
+
* @param {string} path
|
|
74
|
+
* @returns {{ entries: Map<string, BaselineEntry>, existed: boolean }}
|
|
75
|
+
*/
|
|
76
|
+
export function loadBaseline(path) {
|
|
77
|
+
if (!existsSync(path)) return { entries: new Map(), existed: false };
|
|
78
|
+
|
|
79
|
+
let parsed;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`Baseline file is not valid JSON: ${path}: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.findings)) {
|
|
86
|
+
throw new Error(`Baseline file is malformed (expected a "findings" array): ${path}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @type {Map<string, BaselineEntry>} */
|
|
90
|
+
const entries = new Map();
|
|
91
|
+
for (const [index, entry] of parsed.findings.entries()) {
|
|
92
|
+
for (const field of ['rule', 'file', 'path']) {
|
|
93
|
+
if (typeof entry?.[field] !== 'string') {
|
|
94
|
+
throw new Error(`Baseline findings[${index}].${field} must be a string: ${path}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
entries.set(fingerprint(entry), entry);
|
|
98
|
+
}
|
|
99
|
+
return { entries, existed: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Partition this run's findings against a loaded baseline.
|
|
104
|
+
*
|
|
105
|
+
* `located` is the run's findings paired with their repo-relative file. Result:
|
|
106
|
+
* - `active` — findings not in the baseline; these still gate the build
|
|
107
|
+
* - `accepted` — findings the baseline already records; suppressed from the gate
|
|
108
|
+
* - `stale` — baseline entries that did not occur this run; reported so the
|
|
109
|
+
* file shrinks rather than accreting forever
|
|
110
|
+
* @param {Array<{ file: string, finding: import('./policy.js').PolicyFinding }>} located
|
|
111
|
+
* @param {Map<string, BaselineEntry>} baseline
|
|
112
|
+
* @returns {{
|
|
113
|
+
* active: Array<{ file: string, finding: import('./policy.js').PolicyFinding }>,
|
|
114
|
+
* accepted: Array<{ file: string, finding: import('./policy.js').PolicyFinding }>,
|
|
115
|
+
* stale: BaselineEntry[]
|
|
116
|
+
* }}
|
|
117
|
+
*/
|
|
118
|
+
export function applyBaseline(located, baseline) {
|
|
119
|
+
const active = [];
|
|
120
|
+
const accepted = [];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
|
|
123
|
+
for (const item of located) {
|
|
124
|
+
const fp = fingerprint({
|
|
125
|
+
rule: String(item.finding.id),
|
|
126
|
+
file: item.file,
|
|
127
|
+
path: String(item.finding.path ?? ''),
|
|
128
|
+
});
|
|
129
|
+
seen.add(fp);
|
|
130
|
+
if (baseline.has(fp)) accepted.push(item);
|
|
131
|
+
else active.push(item);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stale = [];
|
|
135
|
+
for (const [fp, entry] of baseline) {
|
|
136
|
+
if (!seen.has(fp)) stale.push(entry);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { active, accepted, stale };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Build the baseline file content from this run's findings, preserving the
|
|
144
|
+
* `acceptedAt` and `reason` of entries that already existed so an update does
|
|
145
|
+
* not reset the provenance of findings that were accepted long ago. Entries are
|
|
146
|
+
* sorted by (file, rule, path) for a stable, review-friendly diff.
|
|
147
|
+
* @param {Array<{ file: string, finding: import('./policy.js').PolicyFinding }>} located
|
|
148
|
+
* @param {Map<string, BaselineEntry>} previous
|
|
149
|
+
* @param {{ now?: string }} [options]
|
|
150
|
+
* @returns {BaselineFile}
|
|
151
|
+
*/
|
|
152
|
+
export function buildBaselineFile(located, previous, options = {}) {
|
|
153
|
+
const now = options.now ?? new Date().toISOString();
|
|
154
|
+
|
|
155
|
+
/** @type {Map<string, BaselineEntry>} */
|
|
156
|
+
const byFingerprint = new Map();
|
|
157
|
+
for (const { file, finding } of located) {
|
|
158
|
+
const entry = {
|
|
159
|
+
rule: String(finding.id),
|
|
160
|
+
file,
|
|
161
|
+
path: String(finding.path ?? ''),
|
|
162
|
+
};
|
|
163
|
+
const fp = fingerprint(entry);
|
|
164
|
+
// One entry per fingerprint even if a rule fires twice on the same path.
|
|
165
|
+
if (byFingerprint.has(fp)) continue;
|
|
166
|
+
const prior = previous.get(fp);
|
|
167
|
+
byFingerprint.set(fp, {
|
|
168
|
+
...entry,
|
|
169
|
+
severity: finding.severity,
|
|
170
|
+
...(finding.pack ? { pack: String(finding.pack) } : {}),
|
|
171
|
+
message: String(finding.message ?? ''),
|
|
172
|
+
acceptedAt: prior?.acceptedAt ?? now,
|
|
173
|
+
...(prior?.reason ? { reason: prior.reason } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const findings = [...byFingerprint.values()].sort((a, b) =>
|
|
178
|
+
a.file.localeCompare(b.file)
|
|
179
|
+
|| a.rule.localeCompare(b.rule)
|
|
180
|
+
|| a.path.localeCompare(b.path));
|
|
181
|
+
|
|
182
|
+
return { version: BASELINE_VERSION, generatedAt: now, findings };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Write a baseline file with a trailing newline and stable 2-space indent, so it
|
|
187
|
+
* reviews and diffs cleanly.
|
|
188
|
+
* @param {string} path
|
|
189
|
+
* @param {BaselineFile} content
|
|
190
|
+
*/
|
|
191
|
+
export function writeBaselineFile(path, content) {
|
|
192
|
+
writeFileSync(resolve(path), `${JSON.stringify(content, null, 2)}\n`, 'utf8');
|
|
193
|
+
}
|