flecto 3.0.1 → 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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "provenance": true
6
6
  },
7
- "version": "3.0.1",
7
+ "version": "3.0.2",
8
8
  "description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
9
9
  "license": "MIT",
10
10
  "keywords": [
@@ -43,7 +43,10 @@
43
43
  "scripts": {
44
44
  "test": "node --test test/*.test.js",
45
45
  "test:watch": "node --test --watch test/*.test.js",
46
+ "test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov test/*.test.js",
47
+ "coverage": "npm run test:coverage && node scripts/coverage-report.js",
46
48
  "bench": "node bench/run.js",
49
+ "fuzz": "node test/fuzz/run.js",
47
50
  "pack:check": "npm pack --dry-run"
48
51
  },
49
52
  "dependencies": {
@@ -45,6 +45,7 @@
45
45
  "beforeLooksSecret": { "const": true },
46
46
  "afterLooksSecret": { "const": true },
47
47
  "afterMatches": { "type": "string" },
48
+ "afterAnyMatches": { "type": "string" },
48
49
  "numericJump": {
49
50
  "type": "object",
50
51
  "additionalProperties": false,
@@ -95,6 +96,7 @@
95
96
  "beforeLooksSecret": { "const": true },
96
97
  "afterLooksSecret": { "const": true },
97
98
  "afterMatches": { "type": "string" },
99
+ "afterAnyMatches": { "type": "string" },
98
100
  "numericJump": { "$ref": "#/$defs/numericJump" },
99
101
  "numericDelta": { "$ref": "#/$defs/numericDelta" }
100
102
  }
@@ -0,0 +1,193 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { relative, resolve } from 'path';
3
+
4
+ /**
5
+ * Adoption baseline for `flecto ci`. Records the policy findings that already
6
+ * exist in a repository so the gate fails only on *new* ones, the way every
7
+ * durable policy tool lets a team turn on enforcement without first fixing
8
+ * years of accumulated config.
9
+ *
10
+ * The fingerprint is the crux (#118). It is `(rule id, file, path)` and
11
+ * deliberately excludes the finding's value:
12
+ *
13
+ * - **Excluding the value** keeps a baseline from churning on every edit. A
14
+ * `pool-size-jump` that was accepted at 5→20 should stay accepted when it
15
+ * becomes 5→21; re-flagging it would make the file thrash and get deleted.
16
+ * - **Including the path** — which for multi-document YAML already carries the
17
+ * document identity (`Deployment/prod/api.…`) — keeps two findings of the same
18
+ * rule in the same file distinct.
19
+ * - **The file is stored repo-relative and POSIX-slashed**, so a baseline is
20
+ * portable across machines and clean in a diff.
21
+ *
22
+ * The tradeoff, documented for the user: renaming a file or restructuring a path
23
+ * re-introduces its findings as new. That is the honest failure mode — a
24
+ * fingerprint stable across a rename would have to ignore location, which would
25
+ * make it too coarse to tell two findings apart.
26
+ */
27
+
28
+ const BASELINE_VERSION = 1;
29
+
30
+ /**
31
+ * @typedef {{
32
+ * rule: string,
33
+ * file: string,
34
+ * path: string,
35
+ * severity?: string,
36
+ * message?: string,
37
+ * pack?: string,
38
+ * acceptedAt?: string,
39
+ * reason?: string
40
+ * }} BaselineEntry
41
+ *
42
+ * @typedef {{ version: number, generatedAt?: string, findings: BaselineEntry[] }} BaselineFile
43
+ */
44
+
45
+ /**
46
+ * The stable identity of a finding: rule, file, path. NUL-joined so no two
47
+ * different triples can collide by concatenation.
48
+ * @param {{ rule: string, file: string, path: string }} entry
49
+ * @returns {string}
50
+ */
51
+ export function fingerprint(entry) {
52
+ return `${entry.rule}\u0000${entry.file}\u0000${entry.path}`;
53
+ }
54
+
55
+ /**
56
+ * Normalize a file path to the repo-relative, POSIX form stored in a baseline.
57
+ * @param {string} file
58
+ * @param {string} cwd
59
+ * @returns {string}
60
+ */
61
+ export function baselineRelativePath(file, cwd) {
62
+ const rel = relative(cwd, file);
63
+ if (!rel || rel.startsWith('..')) return file.split(/[\\/]/).join('/');
64
+ return rel.split('\\').join('/');
65
+ }
66
+
67
+ /**
68
+ * Read and validate a baseline file. A missing file is not an error — it is the
69
+ * first run, before anyone has recorded anything — so it reads as empty. A file
70
+ * that exists but is malformed *is* an error: silently treating it as empty
71
+ * would turn every recorded finding new and fail the build in a way that looks
72
+ * like a regression.
73
+ * @param {string} path
74
+ * @returns {{ entries: Map<string, BaselineEntry>, existed: boolean }}
75
+ */
76
+ export function loadBaseline(path) {
77
+ if (!existsSync(path)) return { entries: new Map(), existed: false };
78
+
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
82
+ } catch (err) {
83
+ throw new Error(`Baseline file is not valid JSON: ${path}: ${err.message}`);
84
+ }
85
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.findings)) {
86
+ throw new Error(`Baseline file is malformed (expected a "findings" array): ${path}`);
87
+ }
88
+
89
+ /** @type {Map<string, BaselineEntry>} */
90
+ const entries = new Map();
91
+ for (const [index, entry] of parsed.findings.entries()) {
92
+ for (const field of ['rule', 'file', 'path']) {
93
+ if (typeof entry?.[field] !== 'string') {
94
+ throw new Error(`Baseline findings[${index}].${field} must be a string: ${path}`);
95
+ }
96
+ }
97
+ entries.set(fingerprint(entry), entry);
98
+ }
99
+ return { entries, existed: true };
100
+ }
101
+
102
+ /**
103
+ * Partition this run's findings against a loaded baseline.
104
+ *
105
+ * `located` is the run's findings paired with their repo-relative file. Result:
106
+ * - `active` — findings not in the baseline; these still gate the build
107
+ * - `accepted` — findings the baseline already records; suppressed from the gate
108
+ * - `stale` — baseline entries that did not occur this run; reported so the
109
+ * file shrinks rather than accreting forever
110
+ * @param {Array<{ file: string, finding: import('./policy.js').PolicyFinding }>} located
111
+ * @param {Map<string, BaselineEntry>} baseline
112
+ * @returns {{
113
+ * active: Array<{ file: string, finding: import('./policy.js').PolicyFinding }>,
114
+ * accepted: Array<{ file: string, finding: import('./policy.js').PolicyFinding }>,
115
+ * stale: BaselineEntry[]
116
+ * }}
117
+ */
118
+ export function applyBaseline(located, baseline) {
119
+ const active = [];
120
+ const accepted = [];
121
+ const seen = new Set();
122
+
123
+ for (const item of located) {
124
+ const fp = fingerprint({
125
+ rule: String(item.finding.id),
126
+ file: item.file,
127
+ path: String(item.finding.path ?? ''),
128
+ });
129
+ seen.add(fp);
130
+ if (baseline.has(fp)) accepted.push(item);
131
+ else active.push(item);
132
+ }
133
+
134
+ const stale = [];
135
+ for (const [fp, entry] of baseline) {
136
+ if (!seen.has(fp)) stale.push(entry);
137
+ }
138
+
139
+ return { active, accepted, stale };
140
+ }
141
+
142
+ /**
143
+ * Build the baseline file content from this run's findings, preserving the
144
+ * `acceptedAt` and `reason` of entries that already existed so an update does
145
+ * not reset the provenance of findings that were accepted long ago. Entries are
146
+ * sorted by (file, rule, path) for a stable, review-friendly diff.
147
+ * @param {Array<{ file: string, finding: import('./policy.js').PolicyFinding }>} located
148
+ * @param {Map<string, BaselineEntry>} previous
149
+ * @param {{ now?: string }} [options]
150
+ * @returns {BaselineFile}
151
+ */
152
+ export function buildBaselineFile(located, previous, options = {}) {
153
+ const now = options.now ?? new Date().toISOString();
154
+
155
+ /** @type {Map<string, BaselineEntry>} */
156
+ const byFingerprint = new Map();
157
+ for (const { file, finding } of located) {
158
+ const entry = {
159
+ rule: String(finding.id),
160
+ file,
161
+ path: String(finding.path ?? ''),
162
+ };
163
+ const fp = fingerprint(entry);
164
+ // One entry per fingerprint even if a rule fires twice on the same path.
165
+ if (byFingerprint.has(fp)) continue;
166
+ const prior = previous.get(fp);
167
+ byFingerprint.set(fp, {
168
+ ...entry,
169
+ severity: finding.severity,
170
+ ...(finding.pack ? { pack: String(finding.pack) } : {}),
171
+ message: String(finding.message ?? ''),
172
+ acceptedAt: prior?.acceptedAt ?? now,
173
+ ...(prior?.reason ? { reason: prior.reason } : {}),
174
+ });
175
+ }
176
+
177
+ const findings = [...byFingerprint.values()].sort((a, b) =>
178
+ a.file.localeCompare(b.file)
179
+ || a.rule.localeCompare(b.rule)
180
+ || a.path.localeCompare(b.path));
181
+
182
+ return { version: BASELINE_VERSION, generatedAt: now, findings };
183
+ }
184
+
185
+ /**
186
+ * Write a baseline file with a trailing newline and stable 2-space indent, so it
187
+ * reviews and diffs cleanly.
188
+ * @param {string} path
189
+ * @param {BaselineFile} content
190
+ */
191
+ export function writeBaselineFile(path, content) {
192
+ writeFileSync(resolve(path), `${JSON.stringify(content, null, 2)}\n`, 'utf8');
193
+ }
package/src/config.js CHANGED
@@ -1,14 +1,34 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
2
- import { resolve, sep } from 'path';
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'fs';
2
+ import { basename, dirname, join, relative, resolve, sep } from 'path';
3
3
  import fg from 'fast-glob';
4
4
  import yaml from 'js-yaml';
5
- import { isEnvFilename } from './parser.js';
5
+ import { isEnvFilename, parseContent } from './parser.js';
6
+ import { encryptionState } from './encrypted.js';
6
7
 
7
8
  const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flectorc.yml'];
8
9
  const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
9
10
  const CONFIG_DIR_PATTERN = 'config/**/*.{yaml,yml,json,toml,ini}';
10
11
  const ENV_FILE_PATTERNS = ['.env', '.env.*', '*.env'];
11
12
  const GENERIC_FILE_PATTERNS = [CONFIG_DIR_PATTERN, ...ENV_FILE_PATTERNS];
13
+ const GITHUB_ACTIONS_WORKFLOW_PATTERN = '.github/workflows/**/*.{yaml,yml}';
14
+
15
+ // Conventional places a Kubernetes/SOPS repo keeps manifests, searched in
16
+ // addition to the repo root. Detection is content-based (see sniffManifestDirs),
17
+ // so these only bound *where* Flecto looks — a manifest named deploy.yaml still
18
+ // has to actually carry `apiVersion` + `kind` to count.
19
+ const MANIFEST_DIRS = ['k8s', 'kubernetes', 'manifests', 'deploy'];
20
+
21
+ // Cost guards. `flecto init` must not read a large repo exhaustively: the issue
22
+ // (#123) is explicit that reading every YAML file is the wrong trade. Sniffing
23
+ // stops after this many files, and a single file larger than the byte cap is
24
+ // skipped rather than read — a hand-written manifest is never megabytes, and a
25
+ // generated blob that large is not what we want to gate on anyway.
26
+ const MAX_SNIFF_FILES = 50;
27
+ const MAX_SNIFF_BYTES = 256 * 1024;
28
+ const SNIFF_EXTENSIONS = ['.yaml', '.yml', '.json'];
29
+
30
+ const K8S_ROOT_PATTERN = '*.{yaml,yml}';
31
+ const K8S_DIR_PATTERN = '**/*.{yaml,yml}';
12
32
 
13
33
  /**
14
34
  * @typedef {{
@@ -89,6 +109,113 @@ function rcPluginsAllowed() {
89
109
  return raw === '1' || String(raw).toLowerCase() === 'true';
90
110
  }
91
111
 
112
+ /**
113
+ * Has the operator explicitly opted in to targets that leave the project through
114
+ * a symlink?
115
+ * @returns {boolean}
116
+ */
117
+ function symlinkTargetsAllowed() {
118
+ const raw = process.env.FLECTO_ALLOW_SYMLINK_TARGETS;
119
+ return raw === '1' || String(raw).toLowerCase() === 'true';
120
+ }
121
+
122
+ /**
123
+ * Resolve symlinks where possible, falling back to the input for a path that
124
+ * does not exist yet.
125
+ *
126
+ * `realpathSync.native` first because on Windows it asks the OS for the final
127
+ * path, resolving 8.3 short names and normalizing case — two spellings of one
128
+ * directory would otherwise compare as different and make a contained path look
129
+ * like an escape.
130
+ * @param {string} path
131
+ * @returns {string}
132
+ */
133
+ function canonical(path) {
134
+ try {
135
+ return realpathSync.native(path);
136
+ } catch {
137
+ // Falls through for a path that does not exist yet.
138
+ }
139
+ try {
140
+ return realpathSync(path);
141
+ } catch {
142
+ return resolve(path);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * @param {string} candidate
148
+ * @param {string} root both already canonical
149
+ * @returns {boolean}
150
+ */
151
+ function isInside(candidate, root) {
152
+ return candidate === root || candidate.startsWith(root + sep);
153
+ }
154
+
155
+ /**
156
+ * Refuse a path that lives inside the project but reads from outside it through
157
+ * a symlink.
158
+ *
159
+ * On an untrusted pull request the file *names* are attacker-controlled, and so
160
+ * is what they point at. A pull request adding `config/app.ini` as a symlink to
161
+ * `~/.aws/credentials` gets that file parsed and its contents emitted — into the
162
+ * job log, the JSON envelope, and, with `--format pr-comment --pr-comment-post`,
163
+ * into a comment on the pull request itself. The attacker never controls the
164
+ * linked-to file, which is exactly what makes it worth reading.
165
+ *
166
+ * The rule is deliberately narrow, and it is about *escape*, not about location:
167
+ *
168
+ * - A path given from outside the project is operator intent — `flecto compare
169
+ * /etc/a.yaml /etc/b.yaml` is a real thing to do — and is untouched.
170
+ * - A path inside the project that resolves to somewhere inside it is fine, so
171
+ * symlinks within a repository keep working.
172
+ * - A path inside the project that resolves *out* of it is refused, because that
173
+ * is the one shape an untrusted pull request can author to reach a file it
174
+ * could not otherwise commit.
175
+ *
176
+ * `FLECTO_ALLOW_SYMLINK_TARGETS=1` opts out, for a checkout that genuinely links
177
+ * config in from a sibling directory. Refusing loudly rather than skipping is
178
+ * deliberate, for the reason rc-declared plugins are: a target that stops being
179
+ * scanned without saying so weakens a gate the operator believes is in place.
180
+ * @param {string} file absolute path as Flecto was given it
181
+ * @param {string} [cwd]
182
+ * @throws {Error} when the path escapes the project through a link
183
+ */
184
+ export function assertTargetContained(file, cwd = process.cwd()) {
185
+ if (symlinkTargetsAllowed()) return;
186
+
187
+ const root = canonical(resolve(cwd));
188
+ const given = resolve(file);
189
+ // A path with nothing readable behind it cannot leak a file: either it does
190
+ // not exist, or it is a symlink whose target does not exist (existsSync
191
+ // follows the link). Missing targets are reported elsewhere; refusing one here
192
+ // as an "escape" is a false positive -- and canonical() cannot resolve a
193
+ // nonexistent path, so its symlink-normalized fallback would not even match
194
+ // `root` on a platform where the temp root is itself a symlink (macOS /var).
195
+ if (!existsSync(given)) return;
196
+ // Whether the target is *nominally* inside the project. Canonicalize its
197
+ // containing directory rather than the path itself: the directory is a real
198
+ // directory, never the symlink under test, so this normalizes Windows drive-
199
+ // letter case and 8.3 short names -- which would otherwise make an in-repo
200
+ // path compare as external and skip the check entirely -- without following
201
+ // the final link. On POSIX the two forms already agree, so this is a no-op
202
+ // there.
203
+ const nominal = join(canonical(dirname(given)), basename(given));
204
+ // Named from outside the project: nothing was escaped, it was never inside.
205
+ if (!isInside(nominal, root)) return;
206
+
207
+ const real = canonical(given);
208
+ if (isInside(real, root)) return;
209
+
210
+ throw new Error(
211
+ `Refusing to read "${relative(root, nominal).split(sep).join('/') || given}": it is a link out of the project, `
212
+ + `resolving to ${real}.\n`
213
+ + 'File names and links are attacker-controlled on an untrusted pull request, and '
214
+ + 'reading one would put a file from outside the repository into Flecto\'s output.\n'
215
+ + 'Set FLECTO_ALLOW_SYMLINK_TARGETS=1 if this link is intentional.',
216
+ );
217
+ }
218
+
92
219
  /**
93
220
  * Split a policy list that may arrive as an array or a comma-separated string.
94
221
  * @param {unknown} raw
@@ -162,29 +289,249 @@ export function resolvePolicyOptions(effective, provenance = {}) {
162
289
  return { policies, plugins, severityRemap };
163
290
  }
164
291
 
292
+ /**
293
+ * Convert a glob pattern to the separators `fast-glob` requires.
294
+ *
295
+ * fast-glob only understands POSIX separators and treats a backslash as an
296
+ * escape character, so a Windows path used as a pattern matches nothing at all:
297
+ * `config\*.yaml` asks for a file literally named `config*.yaml`. Since
298
+ * PowerShell and cmd tab-completion produce backslash paths, that is the
299
+ * default way a Windows user would invoke Flecto, and the failure looks like
300
+ * "no files matched" rather than like a platform bug.
301
+ *
302
+ * The rewrite is deliberately **Windows-only**. On POSIX a backslash is a legal
303
+ * character in a filename *and* a meaningful glob escape, so rewriting there
304
+ * would break patterns that work today. On Windows a backslash can only ever be
305
+ * a separator — the filesystem forbids it in a name — so there is nothing to
306
+ * lose.
307
+ *
308
+ * Only patterns are rewritten. Resolved paths stay native, which is what every
309
+ * `fs` call and the snapshot key derivation expect.
310
+ * @param {string} pattern
311
+ * @param {NodeJS.Platform} [platform] Injectable so the Windows behavior is
312
+ * testable from any host.
313
+ * @returns {string}
314
+ */
315
+ export function normalizeGlobPattern(pattern, platform = process.platform) {
316
+ if (platform !== 'win32') return pattern;
317
+ return String(pattern).replaceAll('\\', '/');
318
+ }
319
+
165
320
  /**
166
321
  * Expand file patterns from rc include/files and direct CLI inputs.
167
- * @param {{ cwd?: string, files?: string[], include?: string[], exclude?: string[] }} input
322
+ * @param {{
323
+ * cwd?: string,
324
+ * files?: string[],
325
+ * include?: string[],
326
+ * exclude?: string[],
327
+ * platform?: NodeJS.Platform
328
+ * }} input
168
329
  * @returns {Promise<string[]>}
169
330
  */
170
331
  export async function resolveFiles(input) {
171
332
  const cwd = input.cwd ?? process.cwd();
333
+ const platform = input.platform ?? process.platform;
172
334
  const files = input.files ?? [];
173
335
  const include = input.include ?? [];
174
336
  const exclude = input.exclude ?? [];
175
- const patterns = [...files, ...include].filter(Boolean);
337
+ const patterns = [...files, ...include]
338
+ .filter(Boolean)
339
+ .map((pattern) => normalizeGlobPattern(pattern, platform));
176
340
  if (patterns.length === 0) return [];
341
+ // `exclude` is matched against the same pattern syntax, so it needs the same
342
+ // rewrite -- an exclude that silently stops excluding is the worse failure.
343
+ const ignore = exclude.filter(Boolean).map((pattern) => normalizeGlobPattern(pattern, platform));
177
344
  const matches = await fg(patterns, {
178
345
  cwd,
179
346
  absolute: true,
180
347
  onlyFiles: true,
181
348
  unique: true,
182
- ignore: exclude,
349
+ ignore,
183
350
  dot: true,
184
351
  });
352
+ // Back to native separators: everything downstream reads these off disk.
185
353
  return matches.map((p) => resolve(p));
186
354
  }
187
355
 
356
+ /**
357
+ * Collect candidate files to sniff: YAML/JSON at the repo root plus, one level
358
+ * of recursion deep, the conventional manifest directories. Bounded by
359
+ * MAX_SNIFF_FILES so a large repo never turns `init` into a full-tree read.
360
+ * @param {string} cwd
361
+ * @param {string[]} rootFileNames
362
+ * @param {Set<string>} dirNames
363
+ * @returns {string[]} absolute paths, root files first
364
+ */
365
+ function sniffCandidates(cwd, rootFileNames, dirNames) {
366
+ /** @type {string[]} */
367
+ const candidates = [];
368
+ const push = (abs) => {
369
+ if (candidates.length < MAX_SNIFF_FILES) candidates.push(abs);
370
+ };
371
+
372
+ for (const name of rootFileNames) {
373
+ if (SNIFF_EXTENSIONS.includes(extLower(name))) push(resolve(cwd, name));
374
+ }
375
+
376
+ for (const dir of MANIFEST_DIRS) {
377
+ if (!dirNames.has(dir)) continue;
378
+ for (const abs of walkYamlish(resolve(cwd, dir))) {
379
+ push(abs);
380
+ if (candidates.length >= MAX_SNIFF_FILES) break;
381
+ }
382
+ }
383
+
384
+ return candidates.slice(0, MAX_SNIFF_FILES);
385
+ }
386
+
387
+ /**
388
+ * Depth-first list of sniffable files under a directory, skipping node_modules
389
+ * and dot directories, capped by MAX_SNIFF_FILES so a deep tree cannot run away.
390
+ * @param {string} root
391
+ * @returns {string[]}
392
+ */
393
+ function walkYamlish(root) {
394
+ /** @type {string[]} */
395
+ const out = [];
396
+ /** @type {string[]} */
397
+ const stack = [root];
398
+ while (stack.length > 0 && out.length < MAX_SNIFF_FILES) {
399
+ const dir = stack.pop();
400
+ let entries;
401
+ try {
402
+ entries = readdirSync(dir, { withFileTypes: true });
403
+ } catch {
404
+ continue;
405
+ }
406
+ for (const entry of entries) {
407
+ if (entry.isDirectory()) {
408
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
409
+ stack.push(join(dir, entry.name));
410
+ } else if (entry.isFile() && SNIFF_EXTENSIONS.includes(extLower(entry.name))) {
411
+ out.push(join(dir, entry.name));
412
+ if (out.length >= MAX_SNIFF_FILES) break;
413
+ }
414
+ }
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /**
420
+ * @param {string} name
421
+ * @returns {string} lower-cased extension including the dot, or ''
422
+ */
423
+ function extLower(name) {
424
+ const dot = name.lastIndexOf('.');
425
+ return dot === -1 ? '' : name.slice(dot).toLowerCase();
426
+ }
427
+
428
+ /**
429
+ * Parse a candidate file for detection, cheaply and defensively. Returns null
430
+ * for anything too large, unreadable, or unparseable — detection never fails a
431
+ * run, it just learns less. The byte cap is enforced before the read so a huge
432
+ * file is skipped rather than slurped.
433
+ * @param {string} abs
434
+ * @returns {unknown}
435
+ */
436
+ function sniffParse(abs) {
437
+ try {
438
+ if (statSync(abs).size > MAX_SNIFF_BYTES) return null;
439
+ return parseContent(abs, readFileSync(abs, 'utf8'));
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * True when a parsed tree (single- or multi-document) contains a
447
+ * Kubernetes-shaped document: `apiVersion` + `kind`. A multi-document file is
448
+ * the identity-keyed wrapper, so its documents are one level down.
449
+ * @param {unknown} tree
450
+ * @returns {boolean}
451
+ */
452
+ function hasKubernetesDocument(tree) {
453
+ if (!isPlainObjectLike(tree)) return false;
454
+ if (isKubernetesShaped(tree)) return true;
455
+ return Object.values(tree).some((value) => isKubernetesShaped(value));
456
+ }
457
+
458
+ /**
459
+ * @param {unknown} doc
460
+ * @returns {boolean}
461
+ */
462
+ function isKubernetesShaped(doc) {
463
+ return isPlainObjectLike(doc)
464
+ && typeof doc.apiVersion === 'string' && doc.apiVersion.trim() !== ''
465
+ && typeof doc.kind === 'string' && doc.kind.trim() !== '';
466
+ }
467
+
468
+ /**
469
+ * @param {unknown} v
470
+ * @returns {v is Record<string, unknown>}
471
+ */
472
+ function isPlainObjectLike(v) {
473
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
474
+ }
475
+
476
+ /**
477
+ * Sniff the manifest locations for Kubernetes and SOPS signals. Content-based
478
+ * and cost-bounded; see MAX_SNIFF_FILES / MAX_SNIFF_BYTES. `.sops.yaml` is a
479
+ * plaintext creation-rules config, not an encrypted file, but its presence is
480
+ * still a reliable sign the repo uses SOPS, so it counts on its own.
481
+ * @param {string} cwd
482
+ * @param {string[]} rootFileNames
483
+ * @param {Set<string>} dirNames
484
+ * @returns {{
485
+ * kubernetes: { files: string[] } | null,
486
+ * sops: { files: string[], creationRules: boolean } | null
487
+ * }}
488
+ */
489
+ function sniffManifestDirs(cwd, rootFileNames, dirNames) {
490
+ const candidates = sniffCandidates(cwd, rootFileNames, dirNames);
491
+
492
+ /** @type {Set<string>} */
493
+ const k8sRel = new Set();
494
+ /** @type {Set<string>} */
495
+ const sopsRel = new Set();
496
+ const creationRules = rootFileNames.some((name) => name === '.sops.yaml' || name === '.sops.yml');
497
+
498
+ for (const abs of candidates) {
499
+ const tree = sniffParse(abs);
500
+ if (tree == null) continue;
501
+ const rel = relative(cwd, abs).split(sep).join('/');
502
+ if (hasKubernetesDocument(tree)) k8sRel.add(rel);
503
+ if (encryptionState(tree) !== 'plaintext') sopsRel.add(rel);
504
+ }
505
+
506
+ return {
507
+ kubernetes: k8sRel.size > 0 ? { files: [...k8sRel].sort() } : null,
508
+ sops: sopsRel.size > 0 || creationRules
509
+ ? { files: [...sopsRel].sort(), creationRules }
510
+ : null,
511
+ };
512
+ }
513
+
514
+ /**
515
+ * Watch patterns for the directories that held Kubernetes manifests, plus the
516
+ * repo root when a manifest lived there. One pattern per conventional dir keeps
517
+ * the generated config short instead of listing every file.
518
+ * @param {string[]} relFiles
519
+ * @returns {string[]}
520
+ */
521
+ function manifestWatchPatterns(relFiles) {
522
+ /** @type {Set<string>} */
523
+ const patterns = new Set();
524
+ for (const rel of relFiles) {
525
+ const top = rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : null;
526
+ if (top && MANIFEST_DIRS.includes(top)) {
527
+ patterns.add(`${top}/${K8S_DIR_PATTERN}`);
528
+ } else {
529
+ patterns.add(K8S_ROOT_PATTERN);
530
+ }
531
+ }
532
+ return [...patterns].sort();
533
+ }
534
+
188
535
  /**
189
536
  * Detect stack signals in a directory and map them to policy packs and file
190
537
  * patterns. Only built-in pack ids and patterns Flecto can actually parse are
@@ -235,6 +582,18 @@ export function detectStack(cwd = process.cwd()) {
235
582
  });
236
583
  }
237
584
 
585
+ const githubWorkflowsDir = resolve(cwd, '.github', 'workflows');
586
+ if (dirNames.has('.github') && existsSync(githubWorkflowsDir) && statSync(githubWorkflowsDir).isDirectory()) {
587
+ packs.push('github-actions');
588
+ files.push(GITHUB_ACTIONS_WORKFLOW_PATTERN);
589
+ signals.push({
590
+ id: 'github-actions',
591
+ evidence: ['.github/workflows/'],
592
+ pack: 'github-actions',
593
+ summary: 'Detected .github/workflows/ → enabled the `github-actions` policy pack and watched workflow YAML',
594
+ });
595
+ }
596
+
238
597
  const terraformFiles = fileNames.filter((name) => name.toLowerCase().endsWith('.tf')).sort();
239
598
  if (terraformFiles.length > 0) {
240
599
  signals.push({
@@ -266,6 +625,45 @@ export function detectStack(cwd = process.cwd()) {
266
625
  });
267
626
  }
268
627
 
628
+ // Content-based signals for the two shapes 3.0 was built around. Sniffed, not
629
+ // guessed from filenames, and bounded (#123). Enabling `kubernetes` on a repo
630
+ // that is not Kubernetes would produce confusing findings on the first run, so
631
+ // a manifest must actually carry apiVersion + kind to count.
632
+ const manifests = sniffManifestDirs(cwd, fileNames, dirNames);
633
+
634
+ if (manifests.kubernetes) {
635
+ packs.push('kubernetes');
636
+ const watched = manifestWatchPatterns(manifests.kubernetes.files);
637
+ files.push(...watched);
638
+ const shown = manifests.kubernetes.files.slice(0, 3);
639
+ const more = manifests.kubernetes.files.length - shown.length;
640
+ const evidenceList = more > 0 ? `${shown.join(', ')}, +${more} more` : shown.join(', ');
641
+ signals.push({
642
+ id: 'kubernetes',
643
+ evidence: manifests.kubernetes.files,
644
+ pack: 'kubernetes',
645
+ summary: `Detected Kubernetes manifests (${evidenceList}) → enabled the \`kubernetes\` policy pack and watched ${watched.join(', ')}`,
646
+ });
647
+ }
648
+
649
+ if (manifests.sops) {
650
+ packs.push('sops');
651
+ if (manifests.sops.files.length > 0) files.push(...manifests.sops.files);
652
+ const evidence = [
653
+ ...(manifests.sops.creationRules ? ['.sops.yaml'] : []),
654
+ ...manifests.sops.files,
655
+ ];
656
+ const watchedNote = manifests.sops.files.length > 0
657
+ ? ` and watched ${manifests.sops.files.join(', ')}`
658
+ : ' (no encrypted files found yet; the pack applies when they appear)';
659
+ signals.push({
660
+ id: 'sops',
661
+ evidence,
662
+ pack: 'sops',
663
+ summary: `Detected SOPS usage (${evidence.join(', ')}) → enabled the \`sops\` policy pack${watchedNote}`,
664
+ });
665
+ }
666
+
269
667
  return { signals, packs, files: [...new Set(files)] };
270
668
  }
271
669