mandrel-platform 0.14.2 → 0.15.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/README.md CHANGED
@@ -367,6 +367,38 @@ operator-facing review flow.
367
367
 
368
368
  ---
369
369
 
370
+ ## pnpm supply-chain config
371
+
372
+ `config/pnpm-workspace.supply-chain.yaml` is the canonical pnpm-native
373
+ supply-chain hardening block: `blockExoticSubdeps`, `trustPolicy`, and
374
+ `minimumReleaseAge`. Unlike the JSON config bases above, `pnpm-workspace.yaml`
375
+ has no whole-file `extends`, so this ships as a copy-merge fragment rather
376
+ than an importable module — merge its three keys into your consumer's
377
+ `pnpm-workspace.yaml` alongside any existing `packages:`/catalog entries:
378
+
379
+ ```yaml
380
+ # pnpm-workspace.yaml
381
+ packages:
382
+ - "apps/*"
383
+ - "packages/*"
384
+
385
+ # Merged from mandrel-platform/pnpm-workspace.supply-chain.yaml
386
+ blockExoticSubdeps: true
387
+ trustPolicy: no-downgrade
388
+ minimumReleaseAge: 10080
389
+ ```
390
+
391
+ `minimumReleaseAge` here is **7 days (10080 minutes)** — intentionally
392
+ stricter than the platform's 3-day Renovate `minimumReleaseAge` gate (see
393
+ [Renovate preset](#renovate-preset) above). Renovate's value governs when a
394
+ bump *PR* is raised; pnpm's value governs when `pnpm install` will *resolve*
395
+ a version at all, and 7 days is also the floor the Semgrep `p/default`
396
+ ruleset enforces. See
397
+ [`docs/reusable-workflows.md`](docs/reusable-workflows.md#pnpm-supply-chain-config-vs-renovate-minimumreleaseage)
398
+ for the full reconciliation rationale.
399
+
400
+ ---
401
+
370
402
  ## Adoption CLI (`platform-sync`)
371
403
 
372
404
  `scripts/platform-sync.mjs` is the operator-facing analogue of `mandrel sync`:
@@ -433,6 +465,7 @@ pnpm run bootstrap
433
465
  | `mandrel-platform/dependency-cruiser.base.json` | `config/dependency-cruiser.base.json` |
434
466
  | `mandrel-platform/size-limit.base.json` | `config/size-limit.base.json` |
435
467
  | `mandrel-platform/lighthouse.base.json` | `config/lighthouse.base.json` |
468
+ | `mandrel-platform/pnpm-workspace.supply-chain.yaml` | `config/pnpm-workspace.supply-chain.yaml` |
436
469
  | `mandrel-platform/edge-security` | `config/edge-security/index.mjs` |
437
470
  | `mandrel-platform/edge-security/*` | `config/edge-security/*` |
438
471
  | `mandrel-platform/scripts/*` | `scripts/*` |
@@ -0,0 +1,31 @@
1
+ # Shared pnpm-native supply-chain hardening block for mandrel-platform
2
+ # consumers. `pnpm-workspace.yaml` has no whole-file `extends`, so this ships
3
+ # as a fragment: copy these three keys into your consumer's
4
+ # `pnpm-workspace.yaml` (merge alongside any existing `packages:`/catalog
5
+ # entries — do not replace the whole file). See README.md "Package exports"
6
+ # and docs/reusable-workflows.md for the policy rationale and the
7
+ # `minimumReleaseAge` reconciliation against the platform's 3-day Renovate
8
+ # gate.
9
+ #
10
+ # - blockExoticSubdeps: refuses installs that pull in dependencies via
11
+ # "exotic" specifiers (git/tarball/local-path) buried in subdependencies,
12
+ # closing a supply-chain injection vector outside the registry's audit
13
+ # trail.
14
+ # - trustPolicy: no-downgrade refuses an install that would silently
15
+ # downgrade a previously-resolved package version (protects against a
16
+ # compromised/republished lower version shadowing a trusted one).
17
+ # - minimumReleaseAge: 10080 (7 days, in minutes) holds every dependency
18
+ # bump for a cooldown window before pnpm will resolve it, giving the
19
+ # ecosystem time to catch and yank a malicious release (e.g. the
20
+ # shai-hulud npm worm class) before it reaches a consumer install. This is
21
+ # INTENTIONALLY stricter than the platform's existing Renovate
22
+ # `minimumReleaseAge` gate (3 days / 4320 min, see default.json) — Renovate
23
+ # governs when a *bump PR* is raised, while this governs when `pnpm
24
+ # install` will *resolve* a version at all. The 7-day floor is also the
25
+ # Semgrep p/default rule's enforced minimum; setting pnpm below it leaves
26
+ # the rule perpetually red. Do not lower this value to "fix" a Semgrep
27
+ # finding — raise the Renovate-side PR cadence discussion instead if the
28
+ # two ever need to converge.
29
+ blockExoticSubdeps: true
30
+ trustPolicy: no-downgrade
31
+ minimumReleaseAge: 10080
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "./dependency-cruiser.base.json": "./config/dependency-cruiser.base.json",
25
25
  "./size-limit.base.json": "./config/size-limit.base.json",
26
26
  "./lighthouse.base.json": "./config/lighthouse.base.json",
27
+ "./pnpm-workspace.supply-chain.yaml": "./config/pnpm-workspace.supply-chain.yaml",
27
28
  "./edge-security": "./config/edge-security/index.mjs",
28
29
  "./edge-security/*": "./config/edge-security/*",
29
30
  "./scripts/*": "./scripts/*"
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * update-semgrep-rules.mjs
4
+ *
5
+ * SAST ruleset vendoring (Story #132).
6
+ *
7
+ * `pr-quality.yml`'s SAST sub-step used to scan against the LIVE Semgrep
8
+ * registry alias `p/default`. The registry alias is not pinned: Semgrep can
9
+ * (and did) add new rules to it at any time, so a full-tree / non-PR scan
10
+ * could turn red with ZERO code change on our side (the 0.14.0 release
11
+ * incident — pnpm supply-chain rules + `secrets-inherit` landed in
12
+ * `p/default` mid-flight and blocked the release gate). This script is the
13
+ * deterministic replacement: it resolves `p/default` ONCE against the
14
+ * pinned Semgrep binary version, filters to the language set this platform
15
+ * actually scans, and writes the result to `.semgrep/rules.json` — a
16
+ * committed, reviewable file `pr-quality.yml` then passes directly to
17
+ * `semgrep scan --config`. No registry call happens at scan time.
18
+ *
19
+ * A ruleset bump is now a deliberate, reviewable PR (re-run this script,
20
+ * diff `.semgrep/rules.json`, commit) — the same "bump is a PR" discipline
21
+ * the action-pin ratchet (`check-action-pins.mjs`, Story #112) and the OSV
22
+ * advisory tier (`osv-scanner-version` pin, Story #114) already apply to
23
+ * their respective pinned inputs.
24
+ *
25
+ * Language scope (Story #132): mandrel-platform and its consumers are
26
+ * TypeScript/JavaScript platforms whose CI surface is GitHub Actions YAML,
27
+ * JSON config, and shell `run:` blocks — NOT Python/Java/Go/Ruby/etc. Full
28
+ * `p/default` ships 1074 rules across every language Semgrep supports;
29
+ * >70% of them (Python, Java, Go, Ruby, HCL, PHP, Solidity, Scala, C#, …)
30
+ * can never fire in this tree and only bloat the vendored file and the scan
31
+ * time. We keep the rules for the languages actually present:
32
+ *
33
+ * js, ts, typescript — application + tooling source
34
+ * yaml — GitHub Actions workflows (this is where
35
+ * `secrets-inherit` and the pnpm supply-chain
36
+ * rules live)
37
+ * json — config files (Renovate, package.json, …)
38
+ * bash — `run:` step shell scripts
39
+ * dockerfile — container build files
40
+ * generic, regex — cross-language taint/secret patterns Semgrep
41
+ * ships as language-agnostic rules
42
+ *
43
+ * A rule whose `languages` array intersects this set is KEPT in full,
44
+ * including any sibling languages outside the set (e.g. the one
45
+ * multi-language catch-all rule) — we filter by relevance, not by
46
+ * single-language purity.
47
+ *
48
+ * Usage:
49
+ * node scripts/update-semgrep-rules.mjs
50
+ * node scripts/update-semgrep-rules.mjs --semgrep-pin semgrep==1.97.0
51
+ * node scripts/update-semgrep-rules.mjs --out .semgrep/rules.json --dry-run
52
+ *
53
+ * Requires network egress to PyPI (to install the pinned `semgrep` package)
54
+ * and to the Semgrep registry (to resolve `p/default`) — this script is run
55
+ * by a human/agent deliberately bumping the ruleset, NOT by CI on every PR.
56
+ *
57
+ * Exit codes:
58
+ * 0 — rules file written (or, with --dry-run, would-write reported).
59
+ * 1 — semgrep install or rule resolution failed.
60
+ */
61
+
62
+ import { execFileSync, spawnSync } from "node:child_process";
63
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
64
+ import { tmpdir } from "node:os";
65
+ import { dirname, join, resolve } from "node:path";
66
+ import { fileURLToPath } from "node:url";
67
+
68
+ const __dirname = dirname(fileURLToPath(import.meta.url));
69
+ const REPO_ROOT = resolve(__dirname, "..");
70
+
71
+ // Must match the `SEMGREP_PIN` literal in `.github/workflows/pr-quality.yml`'s
72
+ // SAST step. Keep these two pins in lockstep: the vendored rules are
73
+ // resolved AGAINST this exact Semgrep version's registry-resolution logic,
74
+ // so scanning with a different installed version than the one used to
75
+ // generate the file is a (harmless but inconsistent) version skew.
76
+ const DEFAULT_SEMGREP_PIN = "semgrep==1.97.0";
77
+
78
+ const DEFAULT_OUT = join(REPO_ROOT, ".semgrep", "rules.json");
79
+
80
+ // Languages this platform's reusable workflows + consumer trees actually
81
+ // scan. See the module doc above for the rationale per language.
82
+ const KEPT_LANGUAGES = new Set([
83
+ "js",
84
+ "ts",
85
+ "typescript",
86
+ "yaml",
87
+ "json",
88
+ "bash",
89
+ "dockerfile",
90
+ "generic",
91
+ "regex",
92
+ ]);
93
+
94
+ // Rule IDs excluded from the vendored snapshot for a reason OTHER than
95
+ // language scope. Each entry is a deliberate, reviewable carve-out — never a
96
+ // silent drop. The one entry below is excluded because ITS OWN rule body
97
+ // embeds a credential-shaped placeholder literal (an inert `pattern-not`
98
+ // example, not a live secret) that GitHub push protection's secret scanner
99
+ // flags on `git push` regardless of context (Story #132). We do NOT rewrite
100
+ // or obfuscate that literal to evade the scanner — that would be weakening
101
+ // a security control to get past another one. Dropping the single rule is
102
+ // the transparent fix: it costs one Slack-webhook-detection rule (not named
103
+ // in Story #132's AC — the pnpm supply-chain rules and `secrets-inherit`
104
+ // are the AC-named rules, both unaffected) out of the language-filtered
105
+ // set, and the gitleaks sub-step in the same security tier already covers
106
+ // credential-shaped findings via a dedicated, purpose-built secret scanner.
107
+ // Re-adding this rule requires either (a) GitHub's push-protection "allow
108
+ // this secret" flow for this exact literal (an explicit, audited
109
+ // repo-owner action — not a code change), or (b) Semgrep relaxing the
110
+ // placeholder shape upstream.
111
+ const EXCLUDED_RULE_IDS = new Set([
112
+ "generic.secrets.security.detected-slack-webhook.detected-slack-webhook",
113
+ ]);
114
+
115
+ function parseArgs(argv) {
116
+ const opts = {
117
+ semgrepPin: DEFAULT_SEMGREP_PIN,
118
+ out: DEFAULT_OUT,
119
+ dryRun: false,
120
+ };
121
+ for (let i = 0; i < argv.length; i += 1) {
122
+ const arg = argv[i];
123
+ if (arg === "--semgrep-pin") {
124
+ opts.semgrepPin = argv[++i];
125
+ } else if (arg === "--out") {
126
+ opts.out = resolve(argv[++i]);
127
+ } else if (arg === "--dry-run") {
128
+ opts.dryRun = true;
129
+ } else {
130
+ throw new Error(`Unrecognized argument: ${arg}`);
131
+ }
132
+ }
133
+ return opts;
134
+ }
135
+
136
+ /**
137
+ * Resolve `p/default` against a pinned, ephemeral Semgrep install and return
138
+ * the full rule list as parsed JSON objects. Mirrors the hermetic-venv
139
+ * install strategy `pr-quality.yml`'s SAST step uses (Story #92): an
140
+ * ephemeral `python3 -m venv` under `mktemp -d`, never the shared user site.
141
+ */
142
+ function resolveRegistryRules(semgrepPin) {
143
+ const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
144
+ const targetDir = mkdtempSync(join(tmpdir(), "semgrep-vendor-target-"));
145
+ const semgrepHome = mkdtempSync(join(tmpdir(), "semgrep-vendor-home-"));
146
+
147
+ try {
148
+ spawnSync("python3", ["-m", "venv", venvDir], { stdio: "inherit" });
149
+ const pip = join(venvDir, "bin", "pip");
150
+ const semgrep = join(venvDir, "bin", "semgrep");
151
+
152
+ const install = spawnSync(
153
+ pip,
154
+ ["install", "--quiet", "--disable-pip-version-check", "setuptools", semgrepPin],
155
+ { stdio: "inherit" }
156
+ );
157
+ if (install.status !== 0) {
158
+ throw new Error(`pip install ${semgrepPin} failed (exit ${install.status})`);
159
+ }
160
+
161
+ // A throwaway target file gives semgrep something to "scan" so it
162
+ // resolves + dumps the full `p/default` rule set without needing real
163
+ // findings. `--dump-command-for-core` makes semgrep write the resolved
164
+ // rules to `<SEMGREP_HOME>/semgrep_rules.json` and print the core
165
+ // command line instead of actually invoking the native scanner — we
166
+ // only want the resolved rules, not a scan result.
167
+ writeFileSync(join(targetDir, "placeholder.txt"), "// vendoring placeholder\n");
168
+
169
+ const dump = spawnSync(
170
+ semgrep,
171
+ [
172
+ "scan",
173
+ "--config",
174
+ "p/default",
175
+ "--metrics=off",
176
+ "--disable-version-check",
177
+ "--dump-command-for-core",
178
+ targetDir,
179
+ ],
180
+ {
181
+ stdio: "inherit",
182
+ env: { ...process.env, SEMGREP_HOME: semgrepHome, HOME: semgrepHome },
183
+ }
184
+ );
185
+ if (dump.status !== 0) {
186
+ throw new Error(`semgrep p/default resolution failed (exit ${dump.status})`);
187
+ }
188
+
189
+ const resolvedPath = join(semgrepHome, ".semgrep", "semgrep_rules.json");
190
+ if (!existsSync(resolvedPath)) {
191
+ throw new Error(`expected resolved rules at ${resolvedPath}, found nothing`);
192
+ }
193
+ const resolved = JSON.parse(readFileSync(resolvedPath, "utf8"));
194
+ return resolved.rules ?? [];
195
+ } finally {
196
+ rmSync(venvDir, { recursive: true, force: true });
197
+ rmSync(targetDir, { recursive: true, force: true });
198
+ rmSync(semgrepHome, { recursive: true, force: true });
199
+ }
200
+ }
201
+
202
+ /** Keep a rule iff its `languages` array intersects `KEPT_LANGUAGES`. */
203
+ function filterByLanguage(rules) {
204
+ return rules.filter((rule) => {
205
+ const langs = Array.isArray(rule.languages) ? rule.languages : [];
206
+ return langs.some((lang) => KEPT_LANGUAGES.has(lang));
207
+ });
208
+ }
209
+
210
+ /** Drop any rule whose id is in the explicit, documented `EXCLUDED_RULE_IDS`. */
211
+ function filterByExclusionList(rules) {
212
+ return rules.filter((rule) => !EXCLUDED_RULE_IDS.has(rule.id));
213
+ }
214
+
215
+ /** Sort rules by `id` for a deterministic, low-diff-noise file on re-run. */
216
+ function sortRules(rules) {
217
+ return [...rules].sort((a, b) => String(a.id).localeCompare(String(b.id)));
218
+ }
219
+
220
+ export function buildVendoredRuleset({ semgrepPin = DEFAULT_SEMGREP_PIN, resolve: resolveFn = resolveRegistryRules } = {}) {
221
+ const allRules = resolveFn(semgrepPin);
222
+ const kept = sortRules(filterByExclusionList(filterByLanguage(allRules)));
223
+ return {
224
+ rules: kept,
225
+ droppedCount: allRules.length - kept.length,
226
+ totalCount: allRules.length,
227
+ };
228
+ }
229
+
230
+ export function runCli(argv, { log = console.log, err = console.error } = {}) {
231
+ let opts;
232
+ try {
233
+ opts = parseArgs(argv);
234
+ } catch (e) {
235
+ err(`[semgrep-vendor] ❌ ${e.message}`);
236
+ return 1;
237
+ }
238
+
239
+ let result;
240
+ try {
241
+ result = buildVendoredRuleset({ semgrepPin: opts.semgrepPin });
242
+ } catch (e) {
243
+ err(`[semgrep-vendor] ❌ ${e.message}`);
244
+ return 1;
245
+ }
246
+
247
+ const payload = { rules: result.rules };
248
+ const serialized = `${JSON.stringify(payload, null, 2)}\n`;
249
+
250
+ if (opts.dryRun) {
251
+ log(
252
+ `[semgrep-vendor] (dry-run) would write ${result.rules.length} rule(s) ` +
253
+ `(${result.droppedCount} of ${result.totalCount} dropped as out-of-scope-language) to ${opts.out}`
254
+ );
255
+ return 0;
256
+ }
257
+
258
+ writeFileSync(opts.out, serialized);
259
+ log(
260
+ `[semgrep-vendor] ✅ wrote ${result.rules.length} rule(s) ` +
261
+ `(${result.droppedCount} of ${result.totalCount} dropped as out-of-scope-language) to ${opts.out}`
262
+ );
263
+ log(
264
+ "[semgrep-vendor] Review the diff, then commit `.semgrep/rules.json` as a " +
265
+ "deliberate ruleset bump (mirrors the action-pin ratchet / OSV pin model)."
266
+ );
267
+ return 0;
268
+ }
269
+
270
+ // Only run when executed directly, not when imported by the test suite.
271
+ const invokedDirectly =
272
+ process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
273
+ if (invokedDirectly) {
274
+ process.exit(runCli(process.argv.slice(2)));
275
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * update-semgrep-rules.test.mjs — node:test suite for the SAST ruleset
4
+ * vendoring script that backs `pr-quality.yml`'s `semgrep-config: 'vendored'`
5
+ * default (Story #132).
6
+ *
7
+ * The pure pieces — language filtering and deterministic sort — are
8
+ * exercised offline via an injected `resolve` function, so the suite never
9
+ * shells out to `pip`/`semgrep` or calls the live registry. The actual
10
+ * network-dependent resolution path (`resolveRegistryRules`) is a deliberate
11
+ * operator action (`node scripts/update-semgrep-rules.mjs`), not something
12
+ * CI re-runs on every PR — see the module doc in update-semgrep-rules.mjs.
13
+ *
14
+ * Run: node scripts/update-semgrep-rules.test.mjs (or `node --test scripts/`)
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { test } from "node:test";
19
+
20
+ import { buildVendoredRuleset } from "./update-semgrep-rules.mjs";
21
+
22
+ function rule(id, languages) {
23
+ return { id, languages, message: "m", severity: "ERROR", metadata: {} };
24
+ }
25
+
26
+ const FIXTURE_RULES = [
27
+ rule("python.eval.exec-injection", ["python"]),
28
+ rule("yaml.github-actions.security.secrets-inherit.secrets-inherit", ["yaml"]),
29
+ rule("package_managers.pnpm.pnpm-trust-policy.pnpm-trust-policy", ["yaml"]),
30
+ rule("javascript.lang.security.audit.detect-eval-with-expression", ["js", "ts"]),
31
+ rule("java.lang.security.audit.crypto.weak-hash", ["java"]),
32
+ rule("generic.secrets.security.detected-generic-secret", ["generic"]),
33
+ rule("go.lang.security.audit.net.use-tls", ["go"]),
34
+ rule("dockerfile.security.missing-user.missing-user", ["dockerfile"]),
35
+ ];
36
+
37
+ test("buildVendoredRuleset keeps only in-scope-language rules", () => {
38
+ const result = buildVendoredRuleset({
39
+ resolve: () => FIXTURE_RULES,
40
+ });
41
+ const ids = result.rules.map((r) => r.id);
42
+ assert.ok(ids.includes("yaml.github-actions.security.secrets-inherit.secrets-inherit"));
43
+ assert.ok(ids.includes("package_managers.pnpm.pnpm-trust-policy.pnpm-trust-policy"));
44
+ assert.ok(ids.includes("javascript.lang.security.audit.detect-eval-with-expression"));
45
+ assert.ok(ids.includes("generic.secrets.security.detected-generic-secret"));
46
+ assert.ok(ids.includes("dockerfile.security.missing-user.missing-user"));
47
+ assert.ok(!ids.includes("python.eval.exec-injection"));
48
+ assert.ok(!ids.includes("java.lang.security.audit.crypto.weak-hash"));
49
+ assert.ok(!ids.includes("go.lang.security.audit.net.use-tls"));
50
+ });
51
+
52
+ test("buildVendoredRuleset reports drop/total counts", () => {
53
+ const result = buildVendoredRuleset({ resolve: () => FIXTURE_RULES });
54
+ assert.equal(result.totalCount, FIXTURE_RULES.length);
55
+ assert.equal(result.rules.length, 5);
56
+ assert.equal(result.droppedCount, FIXTURE_RULES.length - 5);
57
+ });
58
+
59
+ test("buildVendoredRuleset sorts kept rules by id (deterministic, low-diff-noise)", () => {
60
+ const result = buildVendoredRuleset({ resolve: () => FIXTURE_RULES });
61
+ const ids = result.rules.map((r) => r.id);
62
+ const sorted = [...ids].sort((a, b) => a.localeCompare(b));
63
+ assert.deepEqual(ids, sorted);
64
+ });
65
+
66
+ test("buildVendoredRuleset is empty when the resolver returns no rules", () => {
67
+ const result = buildVendoredRuleset({ resolve: () => [] });
68
+ assert.deepEqual(result.rules, []);
69
+ assert.equal(result.totalCount, 0);
70
+ assert.equal(result.droppedCount, 0);
71
+ });
72
+
73
+ test("buildVendoredRuleset keeps a multi-language rule if ANY language is in scope", () => {
74
+ const multi = rule("polyglot.security.catch-all", [
75
+ "bash",
76
+ "c",
77
+ "csharp",
78
+ "go",
79
+ "java",
80
+ "js",
81
+ "json",
82
+ "kotlin",
83
+ "lua",
84
+ "ocaml",
85
+ "php",
86
+ "python",
87
+ "ruby",
88
+ "rust",
89
+ "scala",
90
+ "ts",
91
+ "yaml",
92
+ ]);
93
+ const result = buildVendoredRuleset({ resolve: () => [multi] });
94
+ assert.equal(result.rules.length, 1);
95
+ assert.equal(result.rules[0].id, "polyglot.security.catch-all");
96
+ });
97
+
98
+ test("buildVendoredRuleset drops a rule with no languages array", () => {
99
+ const malformed = { id: "no-langs-rule", message: "m", severity: "ERROR", metadata: {} };
100
+ const result = buildVendoredRuleset({ resolve: () => [malformed] });
101
+ assert.deepEqual(result.rules, []);
102
+ assert.equal(result.droppedCount, 1);
103
+ });
104
+
105
+ test("buildVendoredRuleset drops the explicitly excluded Slack-webhook rule (push-protection carve-out)", () => {
106
+ const slackWebhookRule = rule(
107
+ "generic.secrets.security.detected-slack-webhook.detected-slack-webhook",
108
+ ["generic"]
109
+ );
110
+ const result = buildVendoredRuleset({
111
+ resolve: () => [...FIXTURE_RULES, slackWebhookRule],
112
+ });
113
+ const ids = result.rules.map((r) => r.id);
114
+ assert.ok(
115
+ !ids.includes("generic.secrets.security.detected-slack-webhook.detected-slack-webhook"),
116
+ "the Slack-webhook rule must be excluded — its body embeds a credential-shaped placeholder " +
117
+ "literal that trips GitHub push protection (Story #132); gitleaks already covers this class"
118
+ );
119
+ });
120
+
121
+ test("the committed .semgrep/rules.json vendored file is well-formed and non-empty", async () => {
122
+ const { readFileSync } = await import("node:fs");
123
+ const { fileURLToPath } = await import("node:url");
124
+ const { dirname, join } = await import("node:path");
125
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
126
+ const vendoredPath = join(repoRoot, ".semgrep", "rules.json");
127
+ const raw = readFileSync(vendoredPath, "utf8");
128
+ const parsed = JSON.parse(raw);
129
+ assert.ok(Array.isArray(parsed.rules));
130
+ assert.ok(parsed.rules.length > 0);
131
+ for (const r of parsed.rules) {
132
+ assert.equal(typeof r.id, "string");
133
+ assert.ok(Array.isArray(r.languages));
134
+ }
135
+ const ids = parsed.rules.map((r) => r.id);
136
+ assert.ok(
137
+ ids.includes("yaml.github-actions.security.secrets-inherit.secrets-inherit"),
138
+ "secrets-inherit rule must be present in the vendored set (Story #132 AC)"
139
+ );
140
+ assert.ok(
141
+ ids.includes(
142
+ "package_managers.pnpm.pnpm-block-exotic-sub-dependencies.pnpm-block-exotic-sub-dependencies"
143
+ ),
144
+ "pnpm blockExoticSubdeps rule must remain in force (Story #132 AC)"
145
+ );
146
+ assert.ok(
147
+ ids.includes("package_managers.pnpm.pnpm-trust-policy.pnpm-trust-policy"),
148
+ "pnpm trustPolicy rule must remain in force (Story #132 AC)"
149
+ );
150
+ });