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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
* paths for ordinary YAML are untouched. A file holding
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/policy-test.js
CHANGED
|
@@ -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
|
|
package/src/policy.js
CHANGED
|
@@ -29,6 +29,7 @@ import { containsSecret } from './secrets.js';
|
|
|
29
29
|
* beforeLooksSecret?: true,
|
|
30
30
|
* afterLooksSecret?: true,
|
|
31
31
|
* afterMatches?: string,
|
|
32
|
+
* afterAnyMatches?: string,
|
|
32
33
|
* numericJump?: { minMultiple: number },
|
|
33
34
|
* numericDelta?: { min: number },
|
|
34
35
|
* allOf?: PolicyMatchClause[],
|
|
@@ -48,6 +49,7 @@ import { containsSecret } from './secrets.js';
|
|
|
48
49
|
* beforeLooksSecret?: true,
|
|
49
50
|
* afterLooksSecret?: true,
|
|
50
51
|
* afterMatches?: string,
|
|
52
|
+
* afterAnyMatches?: string,
|
|
51
53
|
* numericJump?: { minMultiple: number },
|
|
52
54
|
* numericDelta?: { min: number }
|
|
53
55
|
* }} PolicyMatchClause
|
|
@@ -74,6 +76,7 @@ import { containsSecret } from './secrets.js';
|
|
|
74
76
|
* source?: 'watch' | 'ci' | 'diff',
|
|
75
77
|
* policies?: string[],
|
|
76
78
|
* plugins?: string[],
|
|
79
|
+
* packRoots?: string[],
|
|
77
80
|
* severityRemap?: Record<string, PolicySeverity | 'off'>
|
|
78
81
|
* }} PolicyEvalOptions
|
|
79
82
|
*/
|
|
@@ -85,12 +88,12 @@ const RULE_FIELDS = new Set([
|
|
|
85
88
|
'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
|
|
86
89
|
'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
|
|
87
90
|
'beforeLooksSecret', 'afterLooksSecret',
|
|
88
|
-
'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
|
|
91
|
+
'afterMatches', 'afterAnyMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
|
|
89
92
|
]);
|
|
90
93
|
const CLAUSE_FIELDS = new Set([
|
|
91
94
|
'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
|
|
92
95
|
'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
|
|
93
|
-
'afterMatches', 'numericJump', 'numericDelta',
|
|
96
|
+
'afterMatches', 'afterAnyMatches', 'numericJump', 'numericDelta',
|
|
94
97
|
]);
|
|
95
98
|
const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
|
|
96
99
|
// Community distribution convention: an npm package named flecto-pack-<id>
|
|
@@ -166,17 +169,34 @@ function validatePack(pack, path) {
|
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
/**
|
|
169
|
-
*
|
|
172
|
+
* Every directory whose `policies/` is searched for a local pack, in order.
|
|
173
|
+
* The first entry is the primary cwd; later entries are fallbacks (the policy
|
|
174
|
+
* fixture harness runs from the fixture directory but packs are installed into
|
|
175
|
+
* the invoking project — see #114).
|
|
176
|
+
* @param {string | string[]} cwd
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
function packRoots(cwd) {
|
|
180
|
+
const roots = (Array.isArray(cwd) ? cwd : [cwd])
|
|
181
|
+
.filter((root) => typeof root === 'string' && root)
|
|
182
|
+
.map((root) => resolve(root));
|
|
183
|
+
// Distinct roots only: a fixture run from the project root would otherwise
|
|
184
|
+
// search the same policies/ directory twice and report it twice on failure.
|
|
185
|
+
return [...new Set(roots)];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string | string[]} cwd
|
|
170
190
|
* @param {string} packId
|
|
171
191
|
* @returns {string | null}
|
|
172
192
|
*/
|
|
173
193
|
function resolvePackPath(cwd, packId) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
194
|
+
for (const root of packRoots(cwd)) {
|
|
195
|
+
for (const ext of ['.json', '.yaml', '.yml']) {
|
|
196
|
+
const localPath = resolve(root, 'policies', `${packId}${ext}`);
|
|
197
|
+
if (existsSync(localPath)) return localPath;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
180
200
|
|
|
181
201
|
const builtinJson = join(PACKS_DIR, `${packId}.json`);
|
|
182
202
|
if (existsSync(builtinJson)) return builtinJson;
|
|
@@ -240,6 +260,7 @@ function validateRule(candidate, location, isClause = false) {
|
|
|
240
260
|
validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
|
|
241
261
|
validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
|
|
242
262
|
validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
|
|
263
|
+
validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location);
|
|
243
264
|
validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
|
|
244
265
|
validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
|
|
245
266
|
for (const name of ['message', 'messageTemplate']) {
|
|
@@ -359,13 +380,16 @@ function validateComposition(clauses, name, location) {
|
|
|
359
380
|
const packCache = new Map();
|
|
360
381
|
|
|
361
382
|
/**
|
|
362
|
-
* @param {string}
|
|
383
|
+
* @param {string[]} roots already-resolved search roots, in order
|
|
363
384
|
* @param {string} path
|
|
364
385
|
* @param {number} mtimeMs
|
|
365
386
|
* @returns {string}
|
|
366
387
|
*/
|
|
367
|
-
function packCacheKey(
|
|
368
|
-
|
|
388
|
+
function packCacheKey(roots, path, mtimeMs) {
|
|
389
|
+
// Roots are NUL-joined for the same reason the other fields are
|
|
390
|
+
// NUL-separated: two different root lists must never concatenate into the
|
|
391
|
+
// same key.
|
|
392
|
+
return `${roots.join('\u0000')}\u0000${path}\u0000${mtimeMs}`;
|
|
369
393
|
}
|
|
370
394
|
|
|
371
395
|
/**
|
|
@@ -385,11 +409,21 @@ export function loadPack(packId, cwd = process.cwd()) {
|
|
|
385
409
|
if (!id) throw new Error('Policy pack id is required');
|
|
386
410
|
const path = resolvePackPath(cwd, id);
|
|
387
411
|
if (!path) {
|
|
388
|
-
|
|
412
|
+
// Name the directories actually searched. The old message always said
|
|
413
|
+
// "Add policies/<id>.json", which was actively misleading when the pack
|
|
414
|
+
// existed but in a directory this lookup never consulted (#114).
|
|
415
|
+
const searched = packRoots(cwd)
|
|
416
|
+
.map((root) => join(root, 'policies'))
|
|
417
|
+
.concat(PACKS_DIR)
|
|
418
|
+
.join(', ');
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Unknown policy pack "${id}". Searched: ${searched}.`
|
|
421
|
+
+ ` Add policies/${id}.json in one of these, or use a built-in pack.`,
|
|
422
|
+
);
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
const mtimeMs = statSync(path).mtimeMs;
|
|
392
|
-
const cacheKey = packCacheKey(cwd, path, mtimeMs);
|
|
426
|
+
const cacheKey = packCacheKey(packRoots(cwd), path, mtimeMs);
|
|
393
427
|
const cached = packCache.get(cacheKey);
|
|
394
428
|
if (cached) return cached;
|
|
395
429
|
|
|
@@ -700,15 +734,16 @@ export function addPolicyPackFromPackage(name, options = {}) {
|
|
|
700
734
|
}
|
|
701
735
|
|
|
702
736
|
/**
|
|
703
|
-
* Compile each rule's `match.path` and `
|
|
704
|
-
* once, at pack-load time, storing them as non-enumerable
|
|
705
|
-
* loaded rule/clause objects. matchClause() runs per change
|
|
706
|
-
* rule with a path match, once per event per rule — so compiling
|
|
707
|
-
* of inside that loop turns a `new RegExp(...)` call into a
|
|
737
|
+
* Compile each rule's `match.path`, `afterMatches`, and `afterAnyMatches`
|
|
738
|
+
* regular expressions once, at pack-load time, storing them as non-enumerable
|
|
739
|
+
* properties on the loaded rule/clause objects. matchClause() runs per change
|
|
740
|
+
* event — for a rule with a path match, once per event per rule — so compiling
|
|
741
|
+
* here instead of inside that loop turns a `new RegExp(...)` call into a
|
|
742
|
+
* property read.
|
|
708
743
|
*
|
|
709
|
-
* validatePack() has already proven, by this point, that every `match.path
|
|
710
|
-
* and `
|
|
711
|
-
* cannot throw a new error the caller hasn't already seen.
|
|
744
|
+
* validatePack() has already proven, by this point, that every `match.path`,
|
|
745
|
+
* `afterMatches`, and `afterAnyMatches` string constructs a valid RegExp, so
|
|
746
|
+
* construction here cannot throw a new error the caller hasn't already seen.
|
|
712
747
|
* @param {PolicyPack} pack
|
|
713
748
|
*/
|
|
714
749
|
function compilePackRegexes(pack) {
|
|
@@ -733,6 +768,12 @@ function compileClauseRegexes(clause) {
|
|
|
733
768
|
enumerable: false,
|
|
734
769
|
});
|
|
735
770
|
}
|
|
771
|
+
if (clause.afterAnyMatches !== undefined) {
|
|
772
|
+
Object.defineProperty(clause, '_afterAnyMatchesRegex', {
|
|
773
|
+
value: new RegExp(clause.afterAnyMatches),
|
|
774
|
+
enumerable: false,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
736
777
|
}
|
|
737
778
|
|
|
738
779
|
/**
|
|
@@ -754,6 +795,29 @@ function afterMatchesRegexFor(clause) {
|
|
|
754
795
|
return clause._afterMatchesRegex ?? new RegExp(clause.afterMatches);
|
|
755
796
|
}
|
|
756
797
|
|
|
798
|
+
/**
|
|
799
|
+
* @param {{ afterAnyMatches?: string, _afterAnyMatchesRegex?: RegExp }} clause
|
|
800
|
+
* @returns {RegExp}
|
|
801
|
+
*/
|
|
802
|
+
function afterAnyMatchesRegexFor(clause) {
|
|
803
|
+
return clause._afterAnyMatchesRegex ?? new RegExp(clause.afterAnyMatches);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* True when `value` is an array carrying at least one string element that
|
|
808
|
+
* matches. Deliberately a flat scan: it does not descend into nested arrays or
|
|
809
|
+
* objects, because a rule's text should say what it matches without the reader
|
|
810
|
+
* having to reason about how deep the walk goes. Non-array values never match,
|
|
811
|
+
* which is what keeps `afterMatches` and this predicate from overlapping.
|
|
812
|
+
* @param {unknown} value
|
|
813
|
+
* @param {RegExp} regex
|
|
814
|
+
* @returns {boolean}
|
|
815
|
+
*/
|
|
816
|
+
function anyElementMatches(value, regex) {
|
|
817
|
+
return Array.isArray(value)
|
|
818
|
+
&& value.some((element) => typeof element === 'string' && regex.test(element));
|
|
819
|
+
}
|
|
820
|
+
|
|
757
821
|
/**
|
|
758
822
|
* @param {PolicyRule} rule
|
|
759
823
|
* @param {import('./differ.js').ChangeEvent} change
|
|
@@ -791,6 +855,10 @@ function matchClause(clause, change) {
|
|
|
791
855
|
if (clause.beforeLooksSecret && !containsSecret(change.before)) return false;
|
|
792
856
|
if (clause.afterLooksSecret && !containsSecret(change.after)) return false;
|
|
793
857
|
if (clause.afterMatches && (typeof change.after !== 'string' || !afterMatchesRegexFor(clause).test(change.after))) return false;
|
|
858
|
+
// The list counterpart, for the change that turns a scalar into a list in
|
|
859
|
+
// one edit -- reported as a single `changed` event whose `after` is an array,
|
|
860
|
+
// which no scalar predicate can see into.
|
|
861
|
+
if (clause.afterAnyMatches && !anyElementMatches(change.after, afterAnyMatchesRegexFor(clause))) return false;
|
|
794
862
|
|
|
795
863
|
if (clause.numericJump) {
|
|
796
864
|
const before = change.before;
|
|
@@ -1020,10 +1088,15 @@ export async function evaluatePolicies(changes, options = {}) {
|
|
|
1020
1088
|
const packIds = options.policies?.length ? options.policies : ['default'];
|
|
1021
1089
|
const plugins = options.plugins ?? [];
|
|
1022
1090
|
const severityRemap = options.severityRemap ?? {};
|
|
1091
|
+
// Packs may resolve from additional roots (the fixture harness adds the
|
|
1092
|
+
// invoking project, #114). Plugin resolution deliberately stays anchored to
|
|
1093
|
+
// the single `cwd` below: widening where executable code loads from is not
|
|
1094
|
+
// the same decision as widening where declarative packs load from.
|
|
1095
|
+
const packSearchRoots = options.packRoots?.length ? [cwd, ...options.packRoots] : cwd;
|
|
1023
1096
|
|
|
1024
1097
|
/** @type {PolicyFinding[]} */
|
|
1025
1098
|
const findings = [];
|
|
1026
|
-
const packs = packIds.map((packId) => loadPack(packId,
|
|
1099
|
+
const packs = packIds.map((packId) => loadPack(packId, packSearchRoots));
|
|
1027
1100
|
const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
|
|
1028
1101
|
for (const ruleId of Object.keys(severityRemap)) {
|
|
1029
1102
|
if (!knownRuleIds.has(ruleId)) {
|