flecto 3.0.1 → 3.1.0
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 +567 -1
- package/README.md +15 -2
- package/index.js +544 -154
- 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 +564 -6
- package/src/differ.js +76 -12
- 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 +95 -87
- package/src/pr-providers.js +268 -0
- package/src/renderer.js +10 -22
- package/src/report.js +43 -3
- package/src/sarif.js +144 -0
- package/src/secrets.js +68 -7
- package/src/snapshot-store.js +668 -0
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
package/src/config.js
CHANGED
|
@@ -1,14 +1,43 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readlinkSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'fs';
|
|
11
|
+
import { basename, dirname, join, relative, resolve, sep } from 'path';
|
|
3
12
|
import fg from 'fast-glob';
|
|
4
13
|
import yaml from 'js-yaml';
|
|
5
|
-
import { isEnvFilename } from './parser.js';
|
|
14
|
+
import { isEnvFilename, parseContent } from './parser.js';
|
|
15
|
+
import { encryptionState } from './encrypted.js';
|
|
6
16
|
|
|
7
17
|
const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flectorc.yml'];
|
|
8
18
|
const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
|
9
19
|
const CONFIG_DIR_PATTERN = 'config/**/*.{yaml,yml,json,toml,ini}';
|
|
10
20
|
const ENV_FILE_PATTERNS = ['.env', '.env.*', '*.env'];
|
|
11
21
|
const GENERIC_FILE_PATTERNS = [CONFIG_DIR_PATTERN, ...ENV_FILE_PATTERNS];
|
|
22
|
+
const GITHUB_ACTIONS_WORKFLOW_PATTERN = '.github/workflows/**/*.{yaml,yml}';
|
|
23
|
+
|
|
24
|
+
// Conventional places a Kubernetes/SOPS repo keeps manifests, searched in
|
|
25
|
+
// addition to the repo root. Detection is content-based (see sniffManifestDirs),
|
|
26
|
+
// so these only bound *where* Flecto looks — a manifest named deploy.yaml still
|
|
27
|
+
// has to actually carry `apiVersion` + `kind` to count.
|
|
28
|
+
const MANIFEST_DIRS = ['k8s', 'kubernetes', 'manifests', 'deploy'];
|
|
29
|
+
|
|
30
|
+
// Cost guards. `flecto init` must not read a large repo exhaustively: the issue
|
|
31
|
+
// (#123) is explicit that reading every YAML file is the wrong trade. Sniffing
|
|
32
|
+
// stops after this many files, and a single file larger than the byte cap is
|
|
33
|
+
// skipped rather than read — a hand-written manifest is never megabytes, and a
|
|
34
|
+
// generated blob that large is not what we want to gate on anyway.
|
|
35
|
+
const MAX_SNIFF_FILES = 50;
|
|
36
|
+
const MAX_SNIFF_BYTES = 256 * 1024;
|
|
37
|
+
const SNIFF_EXTENSIONS = ['.yaml', '.yml', '.json'];
|
|
38
|
+
|
|
39
|
+
const K8S_ROOT_PATTERN = '*.{yaml,yml}';
|
|
40
|
+
const K8S_DIR_PATTERN = '**/*.{yaml,yml}';
|
|
12
41
|
|
|
13
42
|
/**
|
|
14
43
|
* @typedef {{
|
|
@@ -89,6 +118,264 @@ function rcPluginsAllowed() {
|
|
|
89
118
|
return raw === '1' || String(raw).toLowerCase() === 'true';
|
|
90
119
|
}
|
|
91
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Has the operator explicitly opted in to targets that leave the project through
|
|
123
|
+
* a symlink?
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
function symlinkTargetsAllowed() {
|
|
127
|
+
const raw = process.env.FLECTO_ALLOW_SYMLINK_TARGETS;
|
|
128
|
+
return raw === '1' || String(raw).toLowerCase() === 'true';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve symlinks where possible, falling back to the input for a path that
|
|
133
|
+
* does not exist yet.
|
|
134
|
+
*
|
|
135
|
+
* `realpathSync.native` first because on Windows it asks the OS for the final
|
|
136
|
+
* path, resolving 8.3 short names and normalizing case — two spellings of one
|
|
137
|
+
* directory would otherwise compare as different and make a contained path look
|
|
138
|
+
* like an escape.
|
|
139
|
+
* @param {string} path
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function canonical(path) {
|
|
143
|
+
try {
|
|
144
|
+
return realpathSync.native(path);
|
|
145
|
+
} catch {
|
|
146
|
+
// Falls through for a path that does not exist yet.
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
return realpathSync(path);
|
|
150
|
+
} catch {
|
|
151
|
+
return resolve(path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} candidate
|
|
157
|
+
* @param {string} root both already canonical
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
function isInside(candidate, root) {
|
|
161
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse a path that lives inside the project but reads from outside it through
|
|
166
|
+
* a symlink.
|
|
167
|
+
*
|
|
168
|
+
* On an untrusted pull request the file *names* are attacker-controlled, and so
|
|
169
|
+
* is what they point at. A pull request adding `config/app.ini` as a symlink to
|
|
170
|
+
* `~/.aws/credentials` gets that file parsed and its contents emitted — into the
|
|
171
|
+
* job log, the JSON envelope, and, with `--format pr-comment --pr-comment-post`,
|
|
172
|
+
* into a comment on the pull request itself. The attacker never controls the
|
|
173
|
+
* linked-to file, which is exactly what makes it worth reading.
|
|
174
|
+
*
|
|
175
|
+
* The rule is deliberately narrow, and it is about *escape*, not about location:
|
|
176
|
+
*
|
|
177
|
+
* - A path given from outside the project is operator intent — `flecto compare
|
|
178
|
+
* /etc/a.yaml /etc/b.yaml` is a real thing to do — and is untouched.
|
|
179
|
+
* - A path inside the project that resolves to somewhere inside it is fine, so
|
|
180
|
+
* symlinks within a repository keep working.
|
|
181
|
+
* - A path inside the project that resolves *out* of it is refused, because that
|
|
182
|
+
* is the one shape an untrusted pull request can author to reach a file it
|
|
183
|
+
* could not otherwise commit.
|
|
184
|
+
*
|
|
185
|
+
* `FLECTO_ALLOW_SYMLINK_TARGETS=1` opts out, for a checkout that genuinely links
|
|
186
|
+
* config in from a sibling directory. Refusing loudly rather than skipping is
|
|
187
|
+
* deliberate, for the reason rc-declared plugins are: a target that stops being
|
|
188
|
+
* scanned without saying so weakens a gate the operator believes is in place.
|
|
189
|
+
* @param {string} file absolute path as Flecto was given it
|
|
190
|
+
* @param {string} [cwd]
|
|
191
|
+
* @throws {Error} when the path escapes the project through a link
|
|
192
|
+
*/
|
|
193
|
+
export function assertTargetContained(file, cwd = process.cwd()) {
|
|
194
|
+
if (symlinkTargetsAllowed()) return;
|
|
195
|
+
|
|
196
|
+
const root = canonical(resolve(cwd));
|
|
197
|
+
const given = resolve(file);
|
|
198
|
+
// A path with nothing readable behind it cannot leak a file: either it does
|
|
199
|
+
// not exist, or it is a symlink whose target does not exist (existsSync
|
|
200
|
+
// follows the link). Missing targets are reported elsewhere; refusing one here
|
|
201
|
+
// as an "escape" is a false positive -- and canonical() cannot resolve a
|
|
202
|
+
// nonexistent path, so its symlink-normalized fallback would not even match
|
|
203
|
+
// `root` on a platform where the temp root is itself a symlink (macOS /var).
|
|
204
|
+
if (!existsSync(given)) return;
|
|
205
|
+
// Whether the target is *nominally* inside the project. Canonicalize its
|
|
206
|
+
// containing directory rather than the path itself: the directory is a real
|
|
207
|
+
// directory, never the symlink under test, so this normalizes Windows drive-
|
|
208
|
+
// letter case and 8.3 short names -- which would otherwise make an in-repo
|
|
209
|
+
// path compare as external and skip the check entirely -- without following
|
|
210
|
+
// the final link. On POSIX the two forms already agree, so this is a no-op
|
|
211
|
+
// there.
|
|
212
|
+
const nominal = join(canonical(dirname(given)), basename(given));
|
|
213
|
+
// Named from outside the project: nothing was escaped, it was never inside.
|
|
214
|
+
if (!isInside(nominal, root)) return;
|
|
215
|
+
|
|
216
|
+
const real = canonical(given);
|
|
217
|
+
if (isInside(real, root)) return;
|
|
218
|
+
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Refusing to read "${relative(root, nominal).split(sep).join('/') || given}": it is a link out of the project, `
|
|
221
|
+
+ `resolving to ${real}.\n`
|
|
222
|
+
+ 'File names and links are attacker-controlled on an untrusted pull request, and '
|
|
223
|
+
+ 'reading one would put a file from outside the repository into Flecto\'s output.\n'
|
|
224
|
+
+ 'Set FLECTO_ALLOW_SYMLINK_TARGETS=1 if this link is intentional.',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Has the operator explicitly opted in to write destinations that `.flectorc`
|
|
230
|
+
* points outside the project?
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
function rcWritesAllowed() {
|
|
234
|
+
const raw = process.env.FLECTO_ALLOW_RC_WRITES;
|
|
235
|
+
return raw === '1' || String(raw).toLowerCase() === 'true';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Refuse a *write* destination that leaves the project.
|
|
240
|
+
*
|
|
241
|
+
* The read rule ({@link assertTargetContained}) is about escape, and treats a
|
|
242
|
+
* path named from outside the project as operator intent. A write cannot be read
|
|
243
|
+
* that way, because the path is not always the operator's: `output` and
|
|
244
|
+
* `baseline` can be declared in `.flectorc`, and on an untrusted pull request
|
|
245
|
+
* `.flectorc` is attacker-controlled. Both destinations carry content the
|
|
246
|
+
* attacker partly controls — the report embeds config values and file names, a
|
|
247
|
+
* baseline embeds rule ids, paths, and messages — so an unconstrained
|
|
248
|
+
* destination is an arbitrary file overwrite with partly chosen content, which
|
|
249
|
+
* on a CI runner is a shell profile, a workflow file, or an SSH config.
|
|
250
|
+
*
|
|
251
|
+
* Two rules, matching the provenance:
|
|
252
|
+
*
|
|
253
|
+
* - **Declared in `.flectorc`** — must resolve inside the project.
|
|
254
|
+
* `FLECTO_ALLOW_RC_WRITES=1` opts out, for a repository that genuinely
|
|
255
|
+
* configures a destination elsewhere.
|
|
256
|
+
* - **Any source** — must not leave the project through a symlink, on the path
|
|
257
|
+
* itself or on the directory it is written into. That is the shape a pull
|
|
258
|
+
* request can author without touching the workflow, and it is refused for the
|
|
259
|
+
* same reason a symlinked *target* is; `FLECTO_ALLOW_SYMLINK_TARGETS=1` opts
|
|
260
|
+
* out of this half, as it does for reads.
|
|
261
|
+
*
|
|
262
|
+
* Refusing loudly rather than falling back to a default path is deliberate: a
|
|
263
|
+
* report that silently went somewhere else is worse than one that was not
|
|
264
|
+
* written.
|
|
265
|
+
* @param {string} file destination path, already resolved against the cwd
|
|
266
|
+
* @param {{ option: string, fromCli?: boolean, cwd?: string }} context
|
|
267
|
+
* @throws {Error} when the destination escapes the project
|
|
268
|
+
*/
|
|
269
|
+
export function assertWriteDestinationContained(file, context) {
|
|
270
|
+
const { option, fromCli = false } = context;
|
|
271
|
+
const cwd = context.cwd ?? process.cwd();
|
|
272
|
+
const lexicalRoot = resolve(cwd);
|
|
273
|
+
const root = canonical(lexicalRoot);
|
|
274
|
+
const given = resolve(cwd, file);
|
|
275
|
+
const shown = relative(lexicalRoot, given).split(sep).join('/') || given;
|
|
276
|
+
|
|
277
|
+
// A destination need not exist yet, and neither need the directories above it,
|
|
278
|
+
// so normalize from the nearest ancestor that does: canonical() cannot resolve
|
|
279
|
+
// a path that is not there, and its fallback leaves the spelling as written —
|
|
280
|
+
// which on Windows is an 8.3 short name that compares as a different directory
|
|
281
|
+
// from the long form `root` carries, making an in-project path look external.
|
|
282
|
+
const { base, rest } = nearestExistingDir(dirname(given));
|
|
283
|
+
const nominal = join(canonical(base), ...rest, basename(given));
|
|
284
|
+
// Either spelling counts as inside: the lexical form covers a path derived
|
|
285
|
+
// from the cwd, the canonical form a path named some other way.
|
|
286
|
+
const insideProject = isInside(given, lexicalRoot) || isInside(nominal, root);
|
|
287
|
+
|
|
288
|
+
if (!fromCli && !rcWritesAllowed() && !insideProject) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Refusing to write "${option}" to ${given}: .flectorc points it outside the project.\n`
|
|
291
|
+
+ '.flectorc is attacker-controlled on an untrusted pull request, and this file carries '
|
|
292
|
+
+ 'config values and file names into whatever it overwrites.\n'
|
|
293
|
+
+ `Pass ${option} on the command line for a destination outside the project, or set `
|
|
294
|
+
+ 'FLECTO_ALLOW_RC_WRITES=1 if this repository genuinely configures one.',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (symlinkTargetsAllowed()) return;
|
|
299
|
+
|
|
300
|
+
// Two ways a write leaves through a link: the destination is itself a link, or
|
|
301
|
+
// the nearest directory that exists on the way to it is. The second covers a
|
|
302
|
+
// path several levels deep whose parents have not been created yet.
|
|
303
|
+
for (const candidate of [given, base]) {
|
|
304
|
+
const real = linkDestination(candidate);
|
|
305
|
+
// Nothing there at all: no link to follow, so nothing to refuse.
|
|
306
|
+
if (real === null) continue;
|
|
307
|
+
// Named from outside the project: nothing escaped, it was never inside.
|
|
308
|
+
if (!isInside(candidate, lexicalRoot)) continue;
|
|
309
|
+
if (isInside(real, root)) continue;
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Refusing to write "${option}" to "${shown}": it is a link out of the project, `
|
|
312
|
+
+ `resolving to ${real}.\n`
|
|
313
|
+
+ 'File names and links are attacker-controlled on an untrusted pull request, and '
|
|
314
|
+
+ 'writing through one would overwrite a file outside the repository.\n'
|
|
315
|
+
+ 'Set FLECTO_ALLOW_SYMLINK_TARGETS=1 if this link is intentional.',
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* How many links to walk before giving up. The OS gives up around 40; this only
|
|
322
|
+
* needs to outlast any chain a repository would legitimately contain.
|
|
323
|
+
*/
|
|
324
|
+
const MAX_LINK_HOPS = 32;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Where a path actually lands, following links even when the chain ends
|
|
328
|
+
* somewhere that does not exist yet.
|
|
329
|
+
*
|
|
330
|
+
* `existsSync` follows links, so it reports *nothing* for a link whose target is
|
|
331
|
+
* missing — and `realpathSync` fails on one, leaving {@link canonical} to fall
|
|
332
|
+
* back to the path as written, which is still inside the project. So a link to a
|
|
333
|
+
* file the runner does not have yet reads as contained twice over. That is not
|
|
334
|
+
* the weaker half of this attack but the stronger one: overwriting needs the
|
|
335
|
+
* file to already be there, while a link to a file the runner does *not* have
|
|
336
|
+
* yet has Flecto create it. `~/.ssh/authorized_keys` is the one that stings,
|
|
337
|
+
* because sshd skips lines it cannot parse, so HTML wrapped around one
|
|
338
|
+
* attacker-authored line is still a usable key file.
|
|
339
|
+
*
|
|
340
|
+
* So the link is resolved by hand, hop by hop, and judged wherever it ends up.
|
|
341
|
+
* @param {string} path
|
|
342
|
+
* @param {number} [depth]
|
|
343
|
+
* @returns {string | null} null when there is nothing at `path` at all
|
|
344
|
+
*/
|
|
345
|
+
function linkDestination(path, depth = 0) {
|
|
346
|
+
const stats = lstatSync(path, { throwIfNoEntry: false });
|
|
347
|
+
if (!stats) return null;
|
|
348
|
+
if (!stats.isSymbolicLink()) return canonical(path);
|
|
349
|
+
|
|
350
|
+
const target = resolve(dirname(path), readlinkSync(path));
|
|
351
|
+
// A cycle, or a chain long enough that the write would fail with ELOOP
|
|
352
|
+
// anyway: judge the hop we have rather than walking forever.
|
|
353
|
+
if (depth >= MAX_LINK_HOPS) return target;
|
|
354
|
+
// The next hop may not exist; normalize what is there and keep the rest as
|
|
355
|
+
// written, the same way an ordinary destination is normalized above.
|
|
356
|
+
const { base, rest } = nearestExistingDir(dirname(target));
|
|
357
|
+
return linkDestination(target, depth + 1) ?? join(canonical(base), ...rest, basename(target));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The nearest ancestor of a path that exists on disk, plus the segments below it
|
|
362
|
+
* that do not.
|
|
363
|
+
* @param {string} path
|
|
364
|
+
* @returns {{ base: string, rest: string[] }}
|
|
365
|
+
*/
|
|
366
|
+
function nearestExistingDir(path) {
|
|
367
|
+
let current = resolve(path);
|
|
368
|
+
/** @type {string[]} */
|
|
369
|
+
const rest = [];
|
|
370
|
+
while (!existsSync(current)) {
|
|
371
|
+
const parent = dirname(current);
|
|
372
|
+
if (parent === current) break;
|
|
373
|
+
rest.unshift(basename(current));
|
|
374
|
+
current = parent;
|
|
375
|
+
}
|
|
376
|
+
return { base: current, rest };
|
|
377
|
+
}
|
|
378
|
+
|
|
92
379
|
/**
|
|
93
380
|
* Split a policy list that may arrive as an array or a comma-separated string.
|
|
94
381
|
* @param {unknown} raw
|
|
@@ -162,29 +449,249 @@ export function resolvePolicyOptions(effective, provenance = {}) {
|
|
|
162
449
|
return { policies, plugins, severityRemap };
|
|
163
450
|
}
|
|
164
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Convert a glob pattern to the separators `fast-glob` requires.
|
|
454
|
+
*
|
|
455
|
+
* fast-glob only understands POSIX separators and treats a backslash as an
|
|
456
|
+
* escape character, so a Windows path used as a pattern matches nothing at all:
|
|
457
|
+
* `config\*.yaml` asks for a file literally named `config*.yaml`. Since
|
|
458
|
+
* PowerShell and cmd tab-completion produce backslash paths, that is the
|
|
459
|
+
* default way a Windows user would invoke Flecto, and the failure looks like
|
|
460
|
+
* "no files matched" rather than like a platform bug.
|
|
461
|
+
*
|
|
462
|
+
* The rewrite is deliberately **Windows-only**. On POSIX a backslash is a legal
|
|
463
|
+
* character in a filename *and* a meaningful glob escape, so rewriting there
|
|
464
|
+
* would break patterns that work today. On Windows a backslash can only ever be
|
|
465
|
+
* a separator — the filesystem forbids it in a name — so there is nothing to
|
|
466
|
+
* lose.
|
|
467
|
+
*
|
|
468
|
+
* Only patterns are rewritten. Resolved paths stay native, which is what every
|
|
469
|
+
* `fs` call and the snapshot key derivation expect.
|
|
470
|
+
* @param {string} pattern
|
|
471
|
+
* @param {NodeJS.Platform} [platform] Injectable so the Windows behavior is
|
|
472
|
+
* testable from any host.
|
|
473
|
+
* @returns {string}
|
|
474
|
+
*/
|
|
475
|
+
export function normalizeGlobPattern(pattern, platform = process.platform) {
|
|
476
|
+
if (platform !== 'win32') return pattern;
|
|
477
|
+
return String(pattern).replaceAll('\\', '/');
|
|
478
|
+
}
|
|
479
|
+
|
|
165
480
|
/**
|
|
166
481
|
* Expand file patterns from rc include/files and direct CLI inputs.
|
|
167
|
-
* @param {{
|
|
482
|
+
* @param {{
|
|
483
|
+
* cwd?: string,
|
|
484
|
+
* files?: string[],
|
|
485
|
+
* include?: string[],
|
|
486
|
+
* exclude?: string[],
|
|
487
|
+
* platform?: NodeJS.Platform
|
|
488
|
+
* }} input
|
|
168
489
|
* @returns {Promise<string[]>}
|
|
169
490
|
*/
|
|
170
491
|
export async function resolveFiles(input) {
|
|
171
492
|
const cwd = input.cwd ?? process.cwd();
|
|
493
|
+
const platform = input.platform ?? process.platform;
|
|
172
494
|
const files = input.files ?? [];
|
|
173
495
|
const include = input.include ?? [];
|
|
174
496
|
const exclude = input.exclude ?? [];
|
|
175
|
-
const patterns = [...files, ...include]
|
|
497
|
+
const patterns = [...files, ...include]
|
|
498
|
+
.filter(Boolean)
|
|
499
|
+
.map((pattern) => normalizeGlobPattern(pattern, platform));
|
|
176
500
|
if (patterns.length === 0) return [];
|
|
501
|
+
// `exclude` is matched against the same pattern syntax, so it needs the same
|
|
502
|
+
// rewrite -- an exclude that silently stops excluding is the worse failure.
|
|
503
|
+
const ignore = exclude.filter(Boolean).map((pattern) => normalizeGlobPattern(pattern, platform));
|
|
177
504
|
const matches = await fg(patterns, {
|
|
178
505
|
cwd,
|
|
179
506
|
absolute: true,
|
|
180
507
|
onlyFiles: true,
|
|
181
508
|
unique: true,
|
|
182
|
-
ignore
|
|
509
|
+
ignore,
|
|
183
510
|
dot: true,
|
|
184
511
|
});
|
|
512
|
+
// Back to native separators: everything downstream reads these off disk.
|
|
185
513
|
return matches.map((p) => resolve(p));
|
|
186
514
|
}
|
|
187
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Collect candidate files to sniff: YAML/JSON at the repo root plus, one level
|
|
518
|
+
* of recursion deep, the conventional manifest directories. Bounded by
|
|
519
|
+
* MAX_SNIFF_FILES so a large repo never turns `init` into a full-tree read.
|
|
520
|
+
* @param {string} cwd
|
|
521
|
+
* @param {string[]} rootFileNames
|
|
522
|
+
* @param {Set<string>} dirNames
|
|
523
|
+
* @returns {string[]} absolute paths, root files first
|
|
524
|
+
*/
|
|
525
|
+
function sniffCandidates(cwd, rootFileNames, dirNames) {
|
|
526
|
+
/** @type {string[]} */
|
|
527
|
+
const candidates = [];
|
|
528
|
+
const push = (abs) => {
|
|
529
|
+
if (candidates.length < MAX_SNIFF_FILES) candidates.push(abs);
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
for (const name of rootFileNames) {
|
|
533
|
+
if (SNIFF_EXTENSIONS.includes(extLower(name))) push(resolve(cwd, name));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const dir of MANIFEST_DIRS) {
|
|
537
|
+
if (!dirNames.has(dir)) continue;
|
|
538
|
+
for (const abs of walkYamlish(resolve(cwd, dir))) {
|
|
539
|
+
push(abs);
|
|
540
|
+
if (candidates.length >= MAX_SNIFF_FILES) break;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return candidates.slice(0, MAX_SNIFF_FILES);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Depth-first list of sniffable files under a directory, skipping node_modules
|
|
549
|
+
* and dot directories, capped by MAX_SNIFF_FILES so a deep tree cannot run away.
|
|
550
|
+
* @param {string} root
|
|
551
|
+
* @returns {string[]}
|
|
552
|
+
*/
|
|
553
|
+
function walkYamlish(root) {
|
|
554
|
+
/** @type {string[]} */
|
|
555
|
+
const out = [];
|
|
556
|
+
/** @type {string[]} */
|
|
557
|
+
const stack = [root];
|
|
558
|
+
while (stack.length > 0 && out.length < MAX_SNIFF_FILES) {
|
|
559
|
+
const dir = stack.pop();
|
|
560
|
+
let entries;
|
|
561
|
+
try {
|
|
562
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
563
|
+
} catch {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
for (const entry of entries) {
|
|
567
|
+
if (entry.isDirectory()) {
|
|
568
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
569
|
+
stack.push(join(dir, entry.name));
|
|
570
|
+
} else if (entry.isFile() && SNIFF_EXTENSIONS.includes(extLower(entry.name))) {
|
|
571
|
+
out.push(join(dir, entry.name));
|
|
572
|
+
if (out.length >= MAX_SNIFF_FILES) break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* @param {string} name
|
|
581
|
+
* @returns {string} lower-cased extension including the dot, or ''
|
|
582
|
+
*/
|
|
583
|
+
function extLower(name) {
|
|
584
|
+
const dot = name.lastIndexOf('.');
|
|
585
|
+
return dot === -1 ? '' : name.slice(dot).toLowerCase();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Parse a candidate file for detection, cheaply and defensively. Returns null
|
|
590
|
+
* for anything too large, unreadable, or unparseable — detection never fails a
|
|
591
|
+
* run, it just learns less. The byte cap is enforced before the read so a huge
|
|
592
|
+
* file is skipped rather than slurped.
|
|
593
|
+
* @param {string} abs
|
|
594
|
+
* @returns {unknown}
|
|
595
|
+
*/
|
|
596
|
+
function sniffParse(abs) {
|
|
597
|
+
try {
|
|
598
|
+
if (statSync(abs).size > MAX_SNIFF_BYTES) return null;
|
|
599
|
+
return parseContent(abs, readFileSync(abs, 'utf8'));
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* True when a parsed tree (single- or multi-document) contains a
|
|
607
|
+
* Kubernetes-shaped document: `apiVersion` + `kind`. A multi-document file is
|
|
608
|
+
* the identity-keyed wrapper, so its documents are one level down.
|
|
609
|
+
* @param {unknown} tree
|
|
610
|
+
* @returns {boolean}
|
|
611
|
+
*/
|
|
612
|
+
function hasKubernetesDocument(tree) {
|
|
613
|
+
if (!isPlainObjectLike(tree)) return false;
|
|
614
|
+
if (isKubernetesShaped(tree)) return true;
|
|
615
|
+
return Object.values(tree).some((value) => isKubernetesShaped(value));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* @param {unknown} doc
|
|
620
|
+
* @returns {boolean}
|
|
621
|
+
*/
|
|
622
|
+
function isKubernetesShaped(doc) {
|
|
623
|
+
return isPlainObjectLike(doc)
|
|
624
|
+
&& typeof doc.apiVersion === 'string' && doc.apiVersion.trim() !== ''
|
|
625
|
+
&& typeof doc.kind === 'string' && doc.kind.trim() !== '';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* @param {unknown} v
|
|
630
|
+
* @returns {v is Record<string, unknown>}
|
|
631
|
+
*/
|
|
632
|
+
function isPlainObjectLike(v) {
|
|
633
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Sniff the manifest locations for Kubernetes and SOPS signals. Content-based
|
|
638
|
+
* and cost-bounded; see MAX_SNIFF_FILES / MAX_SNIFF_BYTES. `.sops.yaml` is a
|
|
639
|
+
* plaintext creation-rules config, not an encrypted file, but its presence is
|
|
640
|
+
* still a reliable sign the repo uses SOPS, so it counts on its own.
|
|
641
|
+
* @param {string} cwd
|
|
642
|
+
* @param {string[]} rootFileNames
|
|
643
|
+
* @param {Set<string>} dirNames
|
|
644
|
+
* @returns {{
|
|
645
|
+
* kubernetes: { files: string[] } | null,
|
|
646
|
+
* sops: { files: string[], creationRules: boolean } | null
|
|
647
|
+
* }}
|
|
648
|
+
*/
|
|
649
|
+
function sniffManifestDirs(cwd, rootFileNames, dirNames) {
|
|
650
|
+
const candidates = sniffCandidates(cwd, rootFileNames, dirNames);
|
|
651
|
+
|
|
652
|
+
/** @type {Set<string>} */
|
|
653
|
+
const k8sRel = new Set();
|
|
654
|
+
/** @type {Set<string>} */
|
|
655
|
+
const sopsRel = new Set();
|
|
656
|
+
const creationRules = rootFileNames.some((name) => name === '.sops.yaml' || name === '.sops.yml');
|
|
657
|
+
|
|
658
|
+
for (const abs of candidates) {
|
|
659
|
+
const tree = sniffParse(abs);
|
|
660
|
+
if (tree == null) continue;
|
|
661
|
+
const rel = relative(cwd, abs).split(sep).join('/');
|
|
662
|
+
if (hasKubernetesDocument(tree)) k8sRel.add(rel);
|
|
663
|
+
if (encryptionState(tree) !== 'plaintext') sopsRel.add(rel);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
kubernetes: k8sRel.size > 0 ? { files: [...k8sRel].sort() } : null,
|
|
668
|
+
sops: sopsRel.size > 0 || creationRules
|
|
669
|
+
? { files: [...sopsRel].sort(), creationRules }
|
|
670
|
+
: null,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Watch patterns for the directories that held Kubernetes manifests, plus the
|
|
676
|
+
* repo root when a manifest lived there. One pattern per conventional dir keeps
|
|
677
|
+
* the generated config short instead of listing every file.
|
|
678
|
+
* @param {string[]} relFiles
|
|
679
|
+
* @returns {string[]}
|
|
680
|
+
*/
|
|
681
|
+
function manifestWatchPatterns(relFiles) {
|
|
682
|
+
/** @type {Set<string>} */
|
|
683
|
+
const patterns = new Set();
|
|
684
|
+
for (const rel of relFiles) {
|
|
685
|
+
const top = rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : null;
|
|
686
|
+
if (top && MANIFEST_DIRS.includes(top)) {
|
|
687
|
+
patterns.add(`${top}/${K8S_DIR_PATTERN}`);
|
|
688
|
+
} else {
|
|
689
|
+
patterns.add(K8S_ROOT_PATTERN);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return [...patterns].sort();
|
|
693
|
+
}
|
|
694
|
+
|
|
188
695
|
/**
|
|
189
696
|
* Detect stack signals in a directory and map them to policy packs and file
|
|
190
697
|
* patterns. Only built-in pack ids and patterns Flecto can actually parse are
|
|
@@ -235,6 +742,18 @@ export function detectStack(cwd = process.cwd()) {
|
|
|
235
742
|
});
|
|
236
743
|
}
|
|
237
744
|
|
|
745
|
+
const githubWorkflowsDir = resolve(cwd, '.github', 'workflows');
|
|
746
|
+
if (dirNames.has('.github') && existsSync(githubWorkflowsDir) && statSync(githubWorkflowsDir).isDirectory()) {
|
|
747
|
+
packs.push('github-actions');
|
|
748
|
+
files.push(GITHUB_ACTIONS_WORKFLOW_PATTERN);
|
|
749
|
+
signals.push({
|
|
750
|
+
id: 'github-actions',
|
|
751
|
+
evidence: ['.github/workflows/'],
|
|
752
|
+
pack: 'github-actions',
|
|
753
|
+
summary: 'Detected .github/workflows/ → enabled the `github-actions` policy pack and watched workflow YAML',
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
238
757
|
const terraformFiles = fileNames.filter((name) => name.toLowerCase().endsWith('.tf')).sort();
|
|
239
758
|
if (terraformFiles.length > 0) {
|
|
240
759
|
signals.push({
|
|
@@ -266,6 +785,45 @@ export function detectStack(cwd = process.cwd()) {
|
|
|
266
785
|
});
|
|
267
786
|
}
|
|
268
787
|
|
|
788
|
+
// Content-based signals for the two shapes 3.0 was built around. Sniffed, not
|
|
789
|
+
// guessed from filenames, and bounded (#123). Enabling `kubernetes` on a repo
|
|
790
|
+
// that is not Kubernetes would produce confusing findings on the first run, so
|
|
791
|
+
// a manifest must actually carry apiVersion + kind to count.
|
|
792
|
+
const manifests = sniffManifestDirs(cwd, fileNames, dirNames);
|
|
793
|
+
|
|
794
|
+
if (manifests.kubernetes) {
|
|
795
|
+
packs.push('kubernetes');
|
|
796
|
+
const watched = manifestWatchPatterns(manifests.kubernetes.files);
|
|
797
|
+
files.push(...watched);
|
|
798
|
+
const shown = manifests.kubernetes.files.slice(0, 3);
|
|
799
|
+
const more = manifests.kubernetes.files.length - shown.length;
|
|
800
|
+
const evidenceList = more > 0 ? `${shown.join(', ')}, +${more} more` : shown.join(', ');
|
|
801
|
+
signals.push({
|
|
802
|
+
id: 'kubernetes',
|
|
803
|
+
evidence: manifests.kubernetes.files,
|
|
804
|
+
pack: 'kubernetes',
|
|
805
|
+
summary: `Detected Kubernetes manifests (${evidenceList}) → enabled the \`kubernetes\` policy pack and watched ${watched.join(', ')}`,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (manifests.sops) {
|
|
810
|
+
packs.push('sops');
|
|
811
|
+
if (manifests.sops.files.length > 0) files.push(...manifests.sops.files);
|
|
812
|
+
const evidence = [
|
|
813
|
+
...(manifests.sops.creationRules ? ['.sops.yaml'] : []),
|
|
814
|
+
...manifests.sops.files,
|
|
815
|
+
];
|
|
816
|
+
const watchedNote = manifests.sops.files.length > 0
|
|
817
|
+
? ` and watched ${manifests.sops.files.join(', ')}`
|
|
818
|
+
: ' (no encrypted files found yet; the pack applies when they appear)';
|
|
819
|
+
signals.push({
|
|
820
|
+
id: 'sops',
|
|
821
|
+
evidence,
|
|
822
|
+
pack: 'sops',
|
|
823
|
+
summary: `Detected SOPS usage (${evidence.join(', ')}) → enabled the \`sops\` policy pack${watchedNote}`,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
269
827
|
return { signals, packs, files: [...new Set(files)] };
|
|
270
828
|
}
|
|
271
829
|
|