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.
|
|
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
|
+
});
|