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/src/config.js CHANGED
@@ -1,14 +1,43 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
2
- import { resolve, sep } from 'path';
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 {{ cwd?: string, files?: string[], include?: string[], exclude?: string[] }} input
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].filter(Boolean);
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: exclude,
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