mandrel-platform 0.18.0 → 0.19.1
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 +34 -20
- package/config/edge-security/rate-limit.mjs +103 -20
- package/default.json +4 -19
- package/package.json +1 -1
- package/scripts/audit-check.mjs +321 -180
- package/scripts/audit-check.test.mjs +263 -0
- package/scripts/check-action-pins.mjs +106 -173
- package/scripts/check-coverage-threshold.mjs +44 -6
- package/scripts/check-coverage-threshold.test.mjs +43 -0
- package/scripts/check-docs-staleness.mjs +130 -81
- package/scripts/check-docs-staleness.test.mjs +130 -0
- package/scripts/check-pin-drift.mjs +61 -110
- package/scripts/check-pin-drift.test.mjs +175 -3
- package/scripts/check-workflow-portability.mjs +163 -118
- package/scripts/check-workflow-portability.test.mjs +199 -0
- package/scripts/edge-security.test.mjs +81 -1
- package/scripts/lib/args.mjs +93 -0
- package/scripts/lib/args.test.mjs +152 -0
- package/scripts/lib/gh-json.mjs +119 -0
- package/scripts/lib/semver-duration.mjs +84 -0
- package/scripts/lib/uses-pins.mjs +220 -0
- package/scripts/lib/uses-pins.test.mjs +219 -0
- package/scripts/lib/walk.mjs +74 -0
- package/scripts/platform-repair.mjs +9 -3
- package/scripts/update-semgrep-rules.mjs +76 -5
- package/templates/runbooks/README.md +9 -5
- package/templates/runbooks/branch-protection-setup.md +9 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uses-pins.test.mjs — node:test suite for the shared `uses:`-line / SHA-pin
|
|
4
|
+
* primitives (`scripts/lib/uses-pins.mjs`, Story #203).
|
|
5
|
+
*
|
|
6
|
+
* Covers value stripping, line parsing, reference classification (incl. the
|
|
7
|
+
* new `subpath` field), the 40-char-SHA predicate, and the intra-repo
|
|
8
|
+
* single-pin invariant that `check-action-pins.mjs` now enforces. Pure — no
|
|
9
|
+
* temp dirs, no git, fully offline.
|
|
10
|
+
*
|
|
11
|
+
* Run: node --test scripts/lib/uses-pins.test.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_FIRST_PARTY_OWNER,
|
|
19
|
+
stripUsesValue,
|
|
20
|
+
parseUsesLine,
|
|
21
|
+
classifyUses,
|
|
22
|
+
isSha40,
|
|
23
|
+
collectFirstPartyPins,
|
|
24
|
+
findSinglePinViolations,
|
|
25
|
+
} from "./uses-pins.mjs";
|
|
26
|
+
|
|
27
|
+
const SHA = "11bd71901bbe5b1630ceea73d27597364c9af683"; // 40 hex
|
|
28
|
+
const SHA2 = "0000000000000000000000000000000000000000"; // 40 hex, distinct
|
|
29
|
+
const SHORT = "11bd719"; // 7 hex
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// isSha40
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
test("isSha40 accepts exactly 40 hex chars (any case)", () => {
|
|
36
|
+
assert.equal(isSha40(SHA), true);
|
|
37
|
+
assert.equal(isSha40(SHA.toUpperCase()), true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("isSha40 rejects tags, short SHAs, branches, and off-by-one", () => {
|
|
41
|
+
assert.equal(isSha40("v4"), false);
|
|
42
|
+
assert.equal(isSha40(SHORT), false);
|
|
43
|
+
assert.equal(isSha40("main"), false);
|
|
44
|
+
assert.equal(isSha40(SHA + "0"), false); // 41 chars
|
|
45
|
+
assert.equal(isSha40("g".repeat(40)), false); // non-hex
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// stripUsesValue
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
test("stripUsesValue drops the trailing # tag comment", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
stripUsesValue(`actions/checkout@${SHA} # v4.2.2`),
|
|
55
|
+
`actions/checkout@${SHA}`
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("stripUsesValue unwraps surrounding quotes and no-comment inputs", () => {
|
|
60
|
+
assert.equal(stripUsesValue(`actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
|
|
61
|
+
assert.equal(stripUsesValue(`"actions/checkout@${SHA}"`), `actions/checkout@${SHA}`);
|
|
62
|
+
assert.equal(stripUsesValue(`'actions/checkout@${SHA}'`), `actions/checkout@${SHA}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// parseUsesLine
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
test("parseUsesLine returns the bare ref for a mapping-key uses line", () => {
|
|
70
|
+
assert.equal(parseUsesLine(` - uses: actions/checkout@${SHA} # v4`), `actions/checkout@${SHA}`);
|
|
71
|
+
assert.equal(parseUsesLine(` uses: actions/checkout@${SHA}`), `actions/checkout@${SHA}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parseUsesLine returns null for comments and non-uses lines", () => {
|
|
75
|
+
assert.equal(parseUsesLine("# uses: actions/checkout@v4"), null);
|
|
76
|
+
assert.equal(parseUsesLine(" steps:"), null);
|
|
77
|
+
assert.equal(parseUsesLine(" run: echo uses: not-a-key"), null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// classifyUses (incl. subpath)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
test("classifyUses flags external owner/repo as third-party with subpath", () => {
|
|
85
|
+
const c = classifyUses(`github/codeql-action/analyze@${SHA}`);
|
|
86
|
+
assert.equal(c.kind, "third-party");
|
|
87
|
+
assert.equal(c.owner, "github/codeql-action");
|
|
88
|
+
assert.equal(c.subpath, "analyze");
|
|
89
|
+
assert.equal(c.ref, SHA);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("classifyUses treats the default first-party owner as exempt and exposes subpath", () => {
|
|
93
|
+
const c = classifyUses(
|
|
94
|
+
`dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`
|
|
95
|
+
);
|
|
96
|
+
assert.equal(c.kind, "first-party");
|
|
97
|
+
assert.equal(c.owner, DEFAULT_FIRST_PARTY_OWNER);
|
|
98
|
+
assert.equal(c.subpath, ".github/actions/setup-toolchain");
|
|
99
|
+
assert.equal(c.ref, SHA);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("classifyUses honours a custom first-party owner", () => {
|
|
103
|
+
const c = classifyUses(`my-org/my-repo/.github/workflows/x.yml@v1`, "my-org/my-repo");
|
|
104
|
+
assert.equal(c.kind, "first-party");
|
|
105
|
+
assert.equal(c.subpath, ".github/workflows/x.yml");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("classifyUses exempts local and docker refs", () => {
|
|
109
|
+
assert.equal(classifyUses("./.github/actions/foo").kind, "local");
|
|
110
|
+
assert.equal(classifyUses("../shared/action").kind, "local");
|
|
111
|
+
assert.equal(classifyUses("docker://alpine:3.19").kind, "docker");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("classifyUses reports empty subpath for a bare owner/repo self-ref", () => {
|
|
115
|
+
const c = classifyUses(`dsj1984/mandrel-platform@${SHA}`);
|
|
116
|
+
assert.equal(c.kind, "first-party");
|
|
117
|
+
assert.equal(c.subpath, "");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("classifyUses returns unparseable for a ref with no @ or too few segments", () => {
|
|
121
|
+
assert.equal(classifyUses("").kind, "unparseable");
|
|
122
|
+
assert.equal(classifyUses("actions/checkout").kind, "unparseable");
|
|
123
|
+
assert.equal(classifyUses(`justowner@${SHA}`).kind, "unparseable");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// collectFirstPartyPins
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test("collectFirstPartyPins keys first-party subpath refs by target", () => {
|
|
131
|
+
const content = [
|
|
132
|
+
" steps:",
|
|
133
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/setup-toolchain@${SHA}`,
|
|
134
|
+
` - uses: actions/checkout@${SHA}`, // third-party → ignored
|
|
135
|
+
` - uses: dsj1984/mandrel-platform@${SHA}`, // bare self-ref, no subpath → ignored
|
|
136
|
+
].join("\n");
|
|
137
|
+
const byTarget = collectFirstPartyPins(content, "wf.yml");
|
|
138
|
+
assert.equal(byTarget.size, 1);
|
|
139
|
+
const occs = byTarget.get("dsj1984/mandrel-platform/.github/actions/setup-toolchain");
|
|
140
|
+
assert.equal(occs.length, 1);
|
|
141
|
+
assert.equal(occs[0].ref, SHA);
|
|
142
|
+
assert.equal(occs[0].line, 2);
|
|
143
|
+
assert.equal(occs[0].file, "wf.yml");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// findSinglePinViolations (the single-pin invariant)
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
test("findSinglePinViolations is clean when a target is pinned consistently", () => {
|
|
151
|
+
const files = [
|
|
152
|
+
{
|
|
153
|
+
file: "a.yml",
|
|
154
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
file: "b.yml",
|
|
158
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
assert.deepEqual(findSinglePinViolations(files), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("findSinglePinViolations flags a target pinned to two different SHAs", () => {
|
|
165
|
+
const files = [
|
|
166
|
+
{
|
|
167
|
+
file: "a.yml",
|
|
168
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
file: "b.yml",
|
|
172
|
+
content: ` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
const v = findSinglePinViolations(files);
|
|
176
|
+
assert.equal(v.length, 1);
|
|
177
|
+
assert.equal(v[0].target, "dsj1984/mandrel-platform/.github/actions/foo");
|
|
178
|
+
assert.equal(v[0].shas.length, 2);
|
|
179
|
+
assert.ok(v[0].shas.includes(SHA));
|
|
180
|
+
assert.ok(v[0].shas.includes(SHA2));
|
|
181
|
+
assert.equal(v[0].occurrences.length, 2);
|
|
182
|
+
assert.deepEqual(
|
|
183
|
+
v[0].occurrences.map((o) => o.file).sort(),
|
|
184
|
+
["a.yml", "b.yml"]
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("findSinglePinViolations catches drift within a single file too", () => {
|
|
189
|
+
const files = [
|
|
190
|
+
{
|
|
191
|
+
file: "a.yml",
|
|
192
|
+
content: [
|
|
193
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA}`,
|
|
194
|
+
` - uses: dsj1984/mandrel-platform/.github/actions/foo@${SHA2}`,
|
|
195
|
+
].join("\n"),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
const v = findSinglePinViolations(files);
|
|
199
|
+
assert.equal(v.length, 1);
|
|
200
|
+
assert.equal(v[0].shas.length, 2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("findSinglePinViolations ignores third-party targets (cross-repo dashboard owns those)", () => {
|
|
204
|
+
const files = [
|
|
205
|
+
{ file: "a.yml", content: ` - uses: github/codeql-action/analyze@${SHA}` },
|
|
206
|
+
{ file: "b.yml", content: ` - uses: github/codeql-action/analyze@${SHA2}` },
|
|
207
|
+
];
|
|
208
|
+
assert.deepEqual(findSinglePinViolations(files), []);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("findSinglePinViolations honours a custom first-party owner", () => {
|
|
212
|
+
const files = [
|
|
213
|
+
{ file: "a.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA}` },
|
|
214
|
+
{ file: "b.yml", content: ` - uses: my-org/my-repo/actions/x@${SHA2}` },
|
|
215
|
+
];
|
|
216
|
+
const v = findSinglePinViolations(files, "my-org/my-repo");
|
|
217
|
+
assert.equal(v.length, 1);
|
|
218
|
+
assert.equal(v[0].target, "my-org/my-repo/actions/x");
|
|
219
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/walk.mjs
|
|
3
|
+
*
|
|
4
|
+
* The single directory-discovery seam for the pin-tooling scripts. Both
|
|
5
|
+
* `check-action-pins.mjs` and `check-workflow-portability.mjs` had grown their
|
|
6
|
+
* own `listWorkflowFiles` / `listActionFiles` pair — same intent, subtly
|
|
7
|
+
* different code (one sorted, one didn't; one guarded `statSync`, one didn't).
|
|
8
|
+
* Story #203 consolidates them here.
|
|
9
|
+
*
|
|
10
|
+
* All discovery is best-effort: a missing directory or an unreadable entry
|
|
11
|
+
* yields `[]` / is skipped rather than throwing, so a repo without a
|
|
12
|
+
* `.github/actions/` tree lints cleanly. Results are sorted for deterministic
|
|
13
|
+
* output.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdirSync, statSync, existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List `*.yml` / `*.yaml` files directly under a workflows dir
|
|
21
|
+
* (non-recursive — GitHub only runs top-level workflow files).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} dir
|
|
24
|
+
* @returns {string[]} Sorted absolute/relative paths (as joined from `dir`).
|
|
25
|
+
*/
|
|
26
|
+
export function listWorkflowFiles(dir) {
|
|
27
|
+
if (!existsSync(dir)) return [];
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(dir);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return entries
|
|
35
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
36
|
+
.map((f) => join(dir, f))
|
|
37
|
+
.filter((p) => {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(p).isFile();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
.sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Recursively list composite `action.yml` / `action.yaml` files under a dir.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} dir
|
|
51
|
+
* @returns {string[]} Sorted paths.
|
|
52
|
+
*/
|
|
53
|
+
export function listActionFiles(dir) {
|
|
54
|
+
const out = [];
|
|
55
|
+
if (!existsSync(dir)) return out;
|
|
56
|
+
const walk = (d) => {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
const full = join(d, e.name);
|
|
65
|
+
if (e.isDirectory()) {
|
|
66
|
+
walk(full);
|
|
67
|
+
} else if (/^action\.ya?ml$/.test(e.name)) {
|
|
68
|
+
out.push(full);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
walk(dir);
|
|
73
|
+
return out.sort();
|
|
74
|
+
}
|
|
@@ -68,7 +68,9 @@ import { tmpdir } from "node:os";
|
|
|
68
68
|
import { dirname, join, resolve } from "node:path";
|
|
69
69
|
import { fileURLToPath } from "node:url";
|
|
70
70
|
|
|
71
|
-
import { buildReport,
|
|
71
|
+
import { buildReport, isFullSha } from "./check-pin-drift.mjs";
|
|
72
|
+
import { defaultGhRunner } from "./lib/gh-json.mjs";
|
|
73
|
+
import { parseSemver } from "./lib/semver-duration.mjs";
|
|
72
74
|
|
|
73
75
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
74
76
|
|
|
@@ -188,12 +190,16 @@ export function describeDrift(result) {
|
|
|
188
190
|
const short = v.pinnedSha ? v.pinnedSha.slice(0, 7) : "?";
|
|
189
191
|
out.push(`**Release lag** — workflow \`uses:\` pins \`${short}\`, behind the latest release.`);
|
|
190
192
|
}
|
|
193
|
+
// Normalize the npm version through the shared parser (Story #198): the
|
|
194
|
+
// detector already emits a dotted triple, but re-parsing keeps the repair
|
|
195
|
+
// PR body robust to a raw spec ever reaching here (`^0.11.7` → `0.11.7`).
|
|
196
|
+
const npmVersion = parseSemver(result.npm?.version ?? null) ?? result.npm?.version ?? "?";
|
|
191
197
|
if (result.surfaceSkew === true) {
|
|
192
198
|
out.push(
|
|
193
|
-
`**Surface skew** — the npm \`mandrel-platform\` dependency (\`${
|
|
199
|
+
`**Surface skew** — the npm \`mandrel-platform\` dependency (\`${npmVersion}\`) and the workflow \`uses:\` pins are on different releases.`,
|
|
194
200
|
);
|
|
195
201
|
} else if (result.npm?.npmState === "lagging") {
|
|
196
|
-
out.push(`**npm lag** — \`mandrel-platform@${
|
|
202
|
+
out.push(`**npm lag** — \`mandrel-platform@${npmVersion}\` is behind the latest release.`);
|
|
197
203
|
}
|
|
198
204
|
return out;
|
|
199
205
|
}
|
|
@@ -50,16 +50,18 @@
|
|
|
50
50
|
* node scripts/update-semgrep-rules.mjs --semgrep-pin semgrep==1.97.0
|
|
51
51
|
* node scripts/update-semgrep-rules.mjs --out .semgrep/rules.json --dry-run
|
|
52
52
|
*
|
|
53
|
-
* Requires network egress to PyPI (to install the pinned `semgrep` package
|
|
54
|
-
*
|
|
55
|
-
*
|
|
53
|
+
* Requires network egress to PyPI (to install the pinned `semgrep` package,
|
|
54
|
+
* whose artifact is verified against the recorded SHA-256 hashes via pip's
|
|
55
|
+
* `--require-hashes`) and to the Semgrep registry (to resolve `p/default`) —
|
|
56
|
+
* this script is run by a human/agent deliberately bumping the ruleset, NOT
|
|
57
|
+
* by CI on every PR.
|
|
56
58
|
*
|
|
57
59
|
* Exit codes:
|
|
58
60
|
* 0 — rules file written (or, with --dry-run, would-write reported).
|
|
59
61
|
* 1 — semgrep install or rule resolution failed.
|
|
60
62
|
*/
|
|
61
63
|
|
|
62
|
-
import {
|
|
64
|
+
import { spawnSync } from "node:child_process";
|
|
63
65
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
64
66
|
import { tmpdir } from "node:os";
|
|
65
67
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -75,6 +77,26 @@ const REPO_ROOT = resolve(__dirname, "..");
|
|
|
75
77
|
// generate the file is a (harmless but inconsistent) version skew.
|
|
76
78
|
const DEFAULT_SEMGREP_PIN = "semgrep==1.97.0";
|
|
77
79
|
|
|
80
|
+
// SHA-256 hashes for every `DEFAULT_SEMGREP_PIN` distribution published on
|
|
81
|
+
// PyPI (the four platform wheels + the sdist). pip's `--require-hashes` mode
|
|
82
|
+
// verifies the downloaded `semgrep` artifact against this set before it is
|
|
83
|
+
// installed, so a compromised or swapped PyPI artifact for this exact version
|
|
84
|
+
// is rejected at install time — the same "pin the supply-chain input" posture
|
|
85
|
+
// the action-pin ratchet (SHA-pinned Actions) and the OSV advisory pin apply
|
|
86
|
+
// to their inputs. When bumping `DEFAULT_SEMGREP_PIN`, refresh this map from
|
|
87
|
+
// `https://pypi.org/pypi/semgrep/<version>/json` (the `urls[].digests.sha256`
|
|
88
|
+
// values) — a version with no hash entry here fails fast rather than
|
|
89
|
+
// installing unverified.
|
|
90
|
+
const SEMGREP_HASHES = {
|
|
91
|
+
"1.97.0": [
|
|
92
|
+
"sha256:0ddaa25ee45e669e1fef87e88dcef73b2aee0874b507e09f618862c42452a205",
|
|
93
|
+
"sha256:9184500bf8c49ad19d0fb2d84923abb4aa53058b0ece7008b57a3b0b5e6ce3ee",
|
|
94
|
+
"sha256:f7d21d6499d4e6fafb4c0b04b1750e9f4b704a26bd78f0aff19f1c73d44843b4",
|
|
95
|
+
"sha256:996fe0b2bfac3a4d4511e470fdf5f3bca96b1f794f398e0336c8388802c218de",
|
|
96
|
+
"sha256:c585164358e03cd7868e1f0d38fbcb422c88dbe08795b83caeb5e36cf18874aa",
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
78
100
|
const DEFAULT_OUT = join(REPO_ROOT, ".semgrep", "rules.json");
|
|
79
101
|
|
|
80
102
|
// Languages this platform's reusable workflows + consumer trees actually
|
|
@@ -143,19 +165,67 @@ function resolveRegistryRules(semgrepPin) {
|
|
|
143
165
|
const venvDir = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-")), "venv");
|
|
144
166
|
const targetDir = mkdtempSync(join(tmpdir(), "semgrep-vendor-target-"));
|
|
145
167
|
const semgrepHome = mkdtempSync(join(tmpdir(), "semgrep-vendor-home-"));
|
|
168
|
+
const reqsFile = join(mkdtempSync(join(tmpdir(), "semgrep-vendor-reqs-")), "semgrep.txt");
|
|
146
169
|
|
|
147
170
|
try {
|
|
148
171
|
spawnSync("python3", ["-m", "venv", venvDir], { stdio: "inherit" });
|
|
149
172
|
const pip = join(venvDir, "bin", "pip");
|
|
150
173
|
const semgrep = join(venvDir, "bin", "semgrep");
|
|
151
174
|
|
|
175
|
+
// Resolve the exact version from the pin (`semgrep==<version>`) so we can
|
|
176
|
+
// look up its published artifact hashes. Only the `==` form is hashable;
|
|
177
|
+
// an unpinned or range pin cannot be verified.
|
|
178
|
+
const versionMatch = /^semgrep==(.+)$/.exec(semgrepPin.trim());
|
|
179
|
+
if (!versionMatch) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`cannot hash-pin ${semgrepPin}: only an exact 'semgrep==<version>' pin is supported`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const version = versionMatch[1];
|
|
185
|
+
const hashes = SEMGREP_HASHES[version];
|
|
186
|
+
if (!hashes || hashes.length === 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`no published artifact hashes recorded for ${semgrepPin} in SEMGREP_HASHES — ` +
|
|
189
|
+
`refresh from https://pypi.org/pypi/semgrep/${version}/json before pinning`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Hash-pinned install: write a requirements file that pins `semgrep` to
|
|
194
|
+
// the exact version with every published artifact hash, then install it
|
|
195
|
+
// with `--require-hashes --no-deps`. pip verifies the downloaded semgrep
|
|
196
|
+
// artifact against this set before installing — a swapped/compromised
|
|
197
|
+
// artifact for this version is rejected. Dependencies are resolved in a
|
|
198
|
+
// separate, non-hashed step (semgrep is already satisfied), keeping the
|
|
199
|
+
// supply-chain-critical `semgrep` binary itself hash-verified.
|
|
200
|
+
const hashFlags = hashes.map((h) => ` --hash=${h}`).join(" \\\n");
|
|
201
|
+
writeFileSync(reqsFile, `semgrep==${version} \\\n${hashFlags}\n`, "utf8");
|
|
202
|
+
|
|
203
|
+
const installSemgrep = spawnSync(
|
|
204
|
+
pip,
|
|
205
|
+
[
|
|
206
|
+
"install",
|
|
207
|
+
"--quiet",
|
|
208
|
+
"--disable-pip-version-check",
|
|
209
|
+
"--require-hashes",
|
|
210
|
+
"--no-deps",
|
|
211
|
+
"-r",
|
|
212
|
+
reqsFile,
|
|
213
|
+
],
|
|
214
|
+
{ stdio: "inherit" }
|
|
215
|
+
);
|
|
216
|
+
if (installSemgrep.status !== 0) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`hash-pinned pip install ${semgrepPin} failed (exit ${installSemgrep.status})`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
152
222
|
const install = spawnSync(
|
|
153
223
|
pip,
|
|
154
224
|
["install", "--quiet", "--disable-pip-version-check", "setuptools", semgrepPin],
|
|
155
225
|
{ stdio: "inherit" }
|
|
156
226
|
);
|
|
157
227
|
if (install.status !== 0) {
|
|
158
|
-
throw new Error(`pip install ${semgrepPin} failed (exit ${install.status})`);
|
|
228
|
+
throw new Error(`pip install ${semgrepPin} (dependencies) failed (exit ${install.status})`);
|
|
159
229
|
}
|
|
160
230
|
|
|
161
231
|
// A throwaway target file gives semgrep something to "scan" so it
|
|
@@ -196,6 +266,7 @@ function resolveRegistryRules(semgrepPin) {
|
|
|
196
266
|
rmSync(venvDir, { recursive: true, force: true });
|
|
197
267
|
rmSync(targetDir, { recursive: true, force: true });
|
|
198
268
|
rmSync(semgrepHome, { recursive: true, force: true });
|
|
269
|
+
rmSync(dirname(reqsFile), { recursive: true, force: true });
|
|
199
270
|
}
|
|
200
271
|
}
|
|
201
272
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# Runbook Templates (copyable thin stubs)
|
|
2
2
|
|
|
3
|
-
These are **copyable thin-stub templates**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
These are **copyable thin-stub templates** for the eight most commonly-adopted
|
|
4
|
+
canonical mandrel-platform runbooks in
|
|
5
|
+
[`docs/runbooks/`](https://github.com/dsj1984/mandrel-platform/tree/main/docs/runbooks)
|
|
6
|
+
(listed in the table below). It is **not** a stub-per-canonical-runbook set —
|
|
7
|
+
several canonical runbooks (`rollback.md`, `slo.md`, `secret-rotation.md`,
|
|
8
|
+
`pin-drift-dashboard.md`) are platform-process docs a consumer reads directly
|
|
9
|
+
and ships no local stub for. They implement the MP-9 adoption model
|
|
10
|
+
(§7.7 / F1): *replace each duplicated process runbook with a thin local doc
|
|
11
|
+
that holds project-specific values plus a link to the canonical runbook.*
|
|
8
12
|
|
|
9
13
|
Each stub:
|
|
10
14
|
|
|
@@ -21,9 +21,15 @@
|
|
|
21
21
|
## Apply & Verify
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
# Apply — PUT the protection with the aggregator as the only required context.
|
|
25
|
+
# (There is no apply-branch-protection script; use gh api directly — see the
|
|
26
|
+
# canonical runbook § 3.)
|
|
27
|
+
gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \
|
|
28
|
+
--method PUT \
|
|
29
|
+
--raw-field required_status_checks='{"strict":false,"contexts":["<AGGREGATOR_CHECK>"]}' \
|
|
30
|
+
--field enforce_admins=false \
|
|
31
|
+
--raw-field required_pull_request_reviews=null \
|
|
32
|
+
--raw-field restrictions=null
|
|
27
33
|
|
|
28
34
|
# Verify
|
|
29
35
|
gh api repos/<OWNER>/<REPO>/branches/<PROTECTED_BRANCH>/protection \
|