@intentius/chant 0.124.32 → 0.124.33
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/dist/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -1
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +81 -21
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +23 -1
- package/dist/cli/registry.d.ts +18 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +235 -228
- package/dist/workspace/check.schema.json +70 -2
- package/dist/workspace/checks/security.d.ts +35 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +5 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/declaration.d.ts +17 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +3 -0
- package/dist/workspace/declaration.schema.json +23 -0
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +17 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +18 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +16 -1
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +81 -21
- package/src/cli/parse-args.ts +17 -1
- package/src/cli/registry.ts +18 -4
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +70 -2
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +5 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/declaration.schema.json +23 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +22 -1
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +18 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner-start statement (#4147): a provisioner signs that it started a
|
|
3
|
+
* runner for one CI job, and the job verifies it before it runs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
ciJobFromEnv,
|
|
10
|
+
DEFAULT_RUNNER_START_FILE,
|
|
11
|
+
readRunnerStart,
|
|
12
|
+
RUNNER_START_PREDICATE,
|
|
13
|
+
signRunnerStart,
|
|
14
|
+
verifyRunnerStart,
|
|
15
|
+
verifyRunnerStartForJob,
|
|
16
|
+
type CiJobIdentity,
|
|
17
|
+
type RunnerStartSigner,
|
|
18
|
+
} from "./runner-start";
|
|
19
|
+
import { IN_TOTO_PAYLOAD_TYPE, signEnvelope, sshFromEd25519 } from "./workspace/trust/dsse";
|
|
20
|
+
|
|
21
|
+
function signer(principal: string): RunnerStartSigner {
|
|
22
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
23
|
+
const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x: string };
|
|
24
|
+
return { principal, key: privateKey, publicKey: sshFromEd25519(Buffer.from(jwk.x, "base64url")) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROVISIONER = signer("provisioner");
|
|
28
|
+
const RUNNERS = [{ principal: "provisioner", key: PROVISIONER.publicKey }];
|
|
29
|
+
const ROLES = { runner: ["provisioner"] };
|
|
30
|
+
const SHA = "a".repeat(40);
|
|
31
|
+
const JOB: CiJobIdentity = { system: "github", repo: "acme/infra", run: "4242", attempt: "1", job: "apply", commit: SHA, runner: "jit-apply-4242-1" };
|
|
32
|
+
const GITHUB_ENV = {
|
|
33
|
+
GITHUB_ACTIONS: "true",
|
|
34
|
+
GITHUB_REPOSITORY: "acme/infra",
|
|
35
|
+
GITHUB_RUN_ID: "4242",
|
|
36
|
+
GITHUB_RUN_ATTEMPT: "1",
|
|
37
|
+
GITHUB_JOB: "apply",
|
|
38
|
+
GITHUB_SHA: SHA,
|
|
39
|
+
RUNNER_NAME: "jit-apply-4242-1",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const verify = (envelope: unknown, job: CiJobIdentity = JOB, extra: Partial<Parameters<typeof verifyRunnerStart>[0]> = {}) =>
|
|
43
|
+
verifyRunnerStart({ envelope, job, runners: RUNNERS, roles: ROLES, ...extra });
|
|
44
|
+
|
|
45
|
+
describe("signRunnerStart and verifyRunnerStart", () => {
|
|
46
|
+
test("a statement the provisioner signed for this job verifies, as an object or as JSON text", () => {
|
|
47
|
+
const envelope = signRunnerStart(JOB, PROVISIONER, new Date("2026-10-20T12:00:00Z"));
|
|
48
|
+
expect(envelope.payloadType).toBe(IN_TOTO_PAYLOAD_TYPE);
|
|
49
|
+
const statement = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
|
|
50
|
+
expect(statement.predicateType).toBe(RUNNER_START_PREDICATE);
|
|
51
|
+
expect(statement.subject).toEqual([{ name: SHA, digest: { gitCommit: SHA } }]);
|
|
52
|
+
expect(statement.predicate).toEqual({
|
|
53
|
+
signer: "provisioner",
|
|
54
|
+
ci: { system: "github", repo: "acme/infra", run: "4242", attempt: "1", job: "apply" },
|
|
55
|
+
runner: { name: "jit-apply-4242-1", singleJob: true },
|
|
56
|
+
issuedAt: "2026-10-20T12:00:00.000Z",
|
|
57
|
+
});
|
|
58
|
+
expect(verify(envelope)).toMatchObject({ status: "verified", signer: "provisioner", issuedAt: "2026-10-20T12:00:00.000Z" });
|
|
59
|
+
expect(verify(JSON.stringify(envelope)).status).toBe("verified");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test.each([
|
|
63
|
+
["another job", { job: "plan" }, "it is for job apply"],
|
|
64
|
+
["another attempt", { attempt: "2" }, "it is for attempt 1"],
|
|
65
|
+
["another run", { run: "4243" }, "it is for run 4242"],
|
|
66
|
+
["another commit", { commit: "b".repeat(40) }, "it is for commit aaaaaaaaaaaa"],
|
|
67
|
+
["another repository", { repo: "acme/app" }, "it is for repository acme/infra"],
|
|
68
|
+
["another runner of the same matrix job", { runner: "jit-apply-4242-2" }, "it is for runner jit-apply-4242-1, and this is jit-apply-4242-2"],
|
|
69
|
+
["a job whose environment names no runner", { runner: undefined }, "the job's environment names no runner"],
|
|
70
|
+
])("a statement for %s is refused as a mismatch", (_what, change, says) => {
|
|
71
|
+
const v = verify(signRunnerStart(JOB, PROVISIONER), { ...JOB, ...change } as CiJobIdentity);
|
|
72
|
+
expect(v.status).toBe("mismatch");
|
|
73
|
+
expect(v.reason).toContain(says);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a missing statement is refused, naming the job", () => {
|
|
77
|
+
for (const none of [undefined, null, ""]) {
|
|
78
|
+
const v = verify(none);
|
|
79
|
+
expect(v.status).toBe("missing");
|
|
80
|
+
expect(v.reason).toContain("the github job apply of run 4242 (attempt 1) has no runner-start statement");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a statement signed by a key outside runners is untrusted", () => {
|
|
85
|
+
const stranger = signer("provisioner");
|
|
86
|
+
const v = verify(signRunnerStart(JOB, stranger));
|
|
87
|
+
expect(v.status).toBe("untrusted");
|
|
88
|
+
expect(v.reason).toContain("no signature verifies against a runner key");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a listed key whose principal holds no runner role is untrusted", () => {
|
|
92
|
+
const v = verify(signRunnerStart(JOB, PROVISIONER), JOB, { roles: { admin: ["provisioner"] } });
|
|
93
|
+
expect(v.status).toBe("untrusted");
|
|
94
|
+
expect(v.reason).toContain("holds no runner role");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a statement whose payload was changed after signing does not verify", () => {
|
|
98
|
+
const envelope = signRunnerStart(JOB, PROVISIONER);
|
|
99
|
+
const statement = JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8"));
|
|
100
|
+
statement.predicate.ci.job = "plan";
|
|
101
|
+
const tampered = { ...envelope, payload: Buffer.from(JSON.stringify(statement)).toString("base64") };
|
|
102
|
+
expect(verify(tampered, { ...JOB, job: "plan" }).status).toBe("untrusted");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a signed payload with a field the verifier does not know, or another predicate, is invalid", () => {
|
|
106
|
+
const sign = (statement: unknown) => signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(JSON.stringify(statement)), PROVISIONER.key, PROVISIONER.publicKey);
|
|
107
|
+
const good = JSON.parse(Buffer.from(signRunnerStart(JOB, PROVISIONER).payload, "base64").toString("utf8"));
|
|
108
|
+
const extra = verify(sign({ ...good, predicate: { ...good.predicate, reused: true } }));
|
|
109
|
+
expect(extra.status).toBe("invalid");
|
|
110
|
+
expect(extra.reason).toContain("predicate has fields other than");
|
|
111
|
+
const other = verify(sign({ ...good, predicateType: "https://intentius.io/chant/op-run/v1" }));
|
|
112
|
+
expect(other.status).toBe("invalid");
|
|
113
|
+
const notSingle = verify(sign({ ...good, predicate: { ...good.predicate, runner: { name: "jit-apply-4242-1", singleJob: false } } }));
|
|
114
|
+
expect(notSingle.reason).toContain("singleJob: true");
|
|
115
|
+
const forged = verify(sign({ ...good, predicate: { ...good.predicate, signer: "someone-else" } }));
|
|
116
|
+
expect(forged.reason).toContain("names signer \"someone-else\"");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("maxAgeSeconds refuses an old statement, and nothing limits age by default", () => {
|
|
120
|
+
const envelope = signRunnerStart(JOB, PROVISIONER, new Date("2026-10-20T12:00:00Z"));
|
|
121
|
+
const later = new Date("2026-10-20T13:00:00Z");
|
|
122
|
+
expect(verify(envelope, JOB, { now: later }).status).toBe("verified");
|
|
123
|
+
expect(verify(envelope, JOB, { now: later, maxAgeSeconds: 600 }).status).toBe("stale");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a GitLab statement needs no runner name, since CI_JOB_ID is unique", () => {
|
|
127
|
+
const job: CiJobIdentity = { system: "gitlab", repo: "acme/platform/infra", run: "900", attempt: "1", job: "77001", commit: SHA };
|
|
128
|
+
expect(verify(signRunnerStart(job, PROVISIONER), job).status).toBe("verified");
|
|
129
|
+
expect(verify(signRunnerStart(job, PROVISIONER), { ...job, job: "77002" }).status).toBe("mismatch");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the provisioner cannot sign for a GitHub job without the runner's name, or for a short commit", () => {
|
|
133
|
+
expect(() => signRunnerStart({ ...JOB, runner: undefined }, PROVISIONER)).toThrow(/needs the runner's name/);
|
|
134
|
+
expect(() => signRunnerStart({ ...JOB, commit: "abc123" }, PROVISIONER)).toThrow(/not a full commit id/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("ciJobFromEnv", () => {
|
|
139
|
+
test("GitHub, Forgejo and GitLab jobs name themselves from the forge's variables", () => {
|
|
140
|
+
expect(ciJobFromEnv(GITHUB_ENV)).toEqual({ ok: true, job: JOB });
|
|
141
|
+
expect(ciJobFromEnv({ ...GITHUB_ENV, FORGEJO_ACTIONS: "true" })).toEqual({ ok: true, job: { ...JOB, system: "forgejo" } });
|
|
142
|
+
const { GITHUB_RUN_ATTEMPT: _a, ...noAttempt } = GITHUB_ENV;
|
|
143
|
+
expect(ciJobFromEnv(noAttempt)).toMatchObject({ ok: true, job: { attempt: "1" } });
|
|
144
|
+
expect(ciJobFromEnv({ GITLAB_CI: "true", CI_PROJECT_PATH: "acme/platform/infra", CI_PIPELINE_ID: "900", CI_JOB_ID: "77001", CI_COMMIT_SHA: SHA })).toEqual({
|
|
145
|
+
ok: true,
|
|
146
|
+
job: { system: "gitlab", repo: "acme/platform/infra", run: "900", attempt: "1", job: "77001", commit: SHA },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a job missing a variable, or no forge at all, names no job", () => {
|
|
151
|
+
const { GITHUB_JOB: _j, ...noJob } = GITHUB_ENV;
|
|
152
|
+
expect(ciJobFromEnv(noJob)).toEqual({ ok: false, reason: "the job's environment does not set GITHUB_JOB" });
|
|
153
|
+
expect(ciJobFromEnv({}).ok).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("readRunnerStart and verifyRunnerStartForJob", () => {
|
|
158
|
+
const envelope = signRunnerStart(JOB, PROVISIONER);
|
|
159
|
+
|
|
160
|
+
test("the statement comes from CHANT_RUNNER_START, else the file CHANT_RUNNER_START_FILE names, else the default file", () => {
|
|
161
|
+
const files: Record<string, string> = { "/tmp/rs.json": "from-file", [DEFAULT_RUNNER_START_FILE]: "from-default" };
|
|
162
|
+
const read = (p: string) => {
|
|
163
|
+
if (!(p in files)) throw new Error("ENOENT");
|
|
164
|
+
return files[p]!;
|
|
165
|
+
};
|
|
166
|
+
expect(readRunnerStart({ CHANT_RUNNER_START: "inline", CHANT_RUNNER_START_FILE: "/tmp/rs.json" }, read)).toBe("inline");
|
|
167
|
+
expect(readRunnerStart({ CHANT_RUNNER_START_FILE: "/tmp/rs.json" }, read)).toBe("from-file");
|
|
168
|
+
expect(readRunnerStart({}, read)).toBe("from-default");
|
|
169
|
+
expect(readRunnerStart({ CHANT_RUNNER_START_FILE: "/nowhere" }, read)).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("one call verifies the job's own statement, and refuses it in another job", () => {
|
|
173
|
+
const env = { ...GITHUB_ENV, CHANT_RUNNER_START: JSON.stringify(envelope) };
|
|
174
|
+
expect(verifyRunnerStartForJob({ runners: RUNNERS, roles: ROLES, env }).status).toBe("verified");
|
|
175
|
+
expect(verifyRunnerStartForJob({ runners: RUNNERS, roles: ROLES, env: { ...env, GITHUB_RUN_ATTEMPT: "2" } }).status).toBe("mismatch");
|
|
176
|
+
expect(verifyRunnerStartForJob({ runners: RUNNERS, roles: ROLES, env, commit: "c".repeat(40) }).status).toBe("mismatch");
|
|
177
|
+
expect(verifyRunnerStartForJob({ runners: RUNNERS, roles: ROLES, env: GITHUB_ENV, readFile: () => { throw new Error("ENOENT"); } }).status).toBe("missing");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner-start statement (#4147): a provisioner's signed word that it
|
|
3
|
+
* started a runner for one CI job, which the job checks before it runs.
|
|
4
|
+
*
|
|
5
|
+
* A persistent self-hosted runner keeps files and processes between jobs,
|
|
6
|
+
* so a plan job that ran a pull request's code can leave something behind
|
|
7
|
+
* for the next apply. An apply job that requires this statement refuses to
|
|
8
|
+
* run, before it trades for any credentials, on a runner that no trusted
|
|
9
|
+
* provisioner says it started for this job.
|
|
10
|
+
*
|
|
11
|
+
* It differs from the Op-run statement (#4044, ./workspace/trust/op-run-statement.ts)
|
|
12
|
+
* in three ways. It is signed before the job runs, not when a run settles.
|
|
13
|
+
* The provisioner that starts the runner signs it, so the key never enters
|
|
14
|
+
* the job. And it names the CI job, run and attempt, so a statement issued
|
|
15
|
+
* for one job does not verify in another.
|
|
16
|
+
*
|
|
17
|
+
* The statement is an in-toto Statement v1 in a DSSE envelope
|
|
18
|
+
* (./workspace/trust/dsse.ts), signed with an Ed25519 key that
|
|
19
|
+
* `.chant/trust.json` lists under `runners`, whose principal holds the
|
|
20
|
+
* `runner` role there, the same keys and role a ci tag needs (#4031):
|
|
21
|
+
*
|
|
22
|
+
* - `subject`: the commit the job runs, by `digest.gitCommit`.
|
|
23
|
+
* - `predicate`: the signer's principal; `ci` with the system (`github`,
|
|
24
|
+
* `gitlab`, `forgejo`), the repository, and the run, attempt and job ids;
|
|
25
|
+
* `runner` with the runner's name and `singleJob: true`; and `issuedAt`.
|
|
26
|
+
*
|
|
27
|
+
* The job finds it in `CHANT_RUNNER_START` (the envelope's JSON) or in the
|
|
28
|
+
* file `CHANT_RUNNER_START_FILE` names, else in
|
|
29
|
+
* {@link DEFAULT_RUNNER_START_FILE}. The job's own ids come from the forge's
|
|
30
|
+
* variables ({@link ciJobFromEnv}).
|
|
31
|
+
*
|
|
32
|
+
* This module needs only `node:crypto` and `node:fs`, so a bundled stage can
|
|
33
|
+
* import it alone:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { verifyRunnerStartForJob } from "@intentius/chant/runner-start";
|
|
37
|
+
*
|
|
38
|
+
* const trust = JSON.parse(readFileSync(".chant/trust.json", "utf8"));
|
|
39
|
+
* const v = verifyRunnerStartForJob({ runners: trust.runners ?? [], roles: trust.roles ?? {} });
|
|
40
|
+
* if (v.status !== "verified") throw new Error(v.reason);
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* `chant ci runner-start verify` and `chant ci run --require-runner-start`
|
|
44
|
+
* read the runners and roles from `.chant/trust.json` at base, which also
|
|
45
|
+
* drops a runner entry the signers file lists (./workspace/trust/policy.ts).
|
|
46
|
+
* A caller that passes trust.json's lists itself skips that check.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
import type { KeyObject } from "node:crypto";
|
|
51
|
+
import { IN_TOTO_PAYLOAD_TYPE, signEnvelope, verifyEnvelope, type DsseEnvelope } from "./workspace/trust/dsse";
|
|
52
|
+
|
|
53
|
+
export const RUNNER_START_PREDICATE = "https://intentius.io/chant/runner-start/v1";
|
|
54
|
+
|
|
55
|
+
/** The in-toto Statement v1 type, as ./workspace/trust/evidence.ts has it. */
|
|
56
|
+
const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
|
|
57
|
+
|
|
58
|
+
/** The role a runner-start statement's signer must hold in `.chant/trust.json`, as for a ci tag (#4031). */
|
|
59
|
+
export const RUNNER_START_SIGNER_ROLE = "runner";
|
|
60
|
+
|
|
61
|
+
/** The variable that holds the statement's envelope as JSON. */
|
|
62
|
+
export const RUNNER_START_ENV = "CHANT_RUNNER_START";
|
|
63
|
+
|
|
64
|
+
/** The variable that names a file holding the envelope, in place of {@link DEFAULT_RUNNER_START_FILE}. */
|
|
65
|
+
export const RUNNER_START_FILE_ENV = "CHANT_RUNNER_START_FILE";
|
|
66
|
+
|
|
67
|
+
/** Where the job looks for the envelope when neither variable is set. */
|
|
68
|
+
export const DEFAULT_RUNNER_START_FILE = "/run/chant/runner-start.json";
|
|
69
|
+
|
|
70
|
+
export type CiSystem = "github" | "gitlab" | "forgejo";
|
|
71
|
+
|
|
72
|
+
/** The CI job a runner was started for, or the job reading the statement. */
|
|
73
|
+
export interface CiJobIdentity {
|
|
74
|
+
system: CiSystem;
|
|
75
|
+
/** `owner/name` on GitHub and Forgejo, the project path on GitLab. */
|
|
76
|
+
repo: string;
|
|
77
|
+
/** The run: `GITHUB_RUN_ID`, or `CI_PIPELINE_ID` on GitLab. */
|
|
78
|
+
run: string;
|
|
79
|
+
/** The run's attempt: `GITHUB_RUN_ATTEMPT`. GitLab gives a retried job a new job id, so it is always `1` there. */
|
|
80
|
+
attempt: string;
|
|
81
|
+
/** The job: `GITHUB_JOB`, the job's key in the workflow, or `CI_JOB_ID` on GitLab. */
|
|
82
|
+
job: string;
|
|
83
|
+
/** The full commit id the job runs: `GITHUB_SHA`, or `CI_COMMIT_SHA`. */
|
|
84
|
+
commit: string;
|
|
85
|
+
/**
|
|
86
|
+
* The runner's name: `RUNNER_NAME` on GitHub and Forgejo. Required there,
|
|
87
|
+
* since `GITHUB_JOB` repeats across a matrix's jobs and the runner's name
|
|
88
|
+
* tells them apart. Not compared on GitLab, where `CI_JOB_ID` is unique.
|
|
89
|
+
*/
|
|
90
|
+
runner?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RunnerStartStatement {
|
|
94
|
+
_type: typeof STATEMENT_TYPE;
|
|
95
|
+
subject: [{ name: string; digest: { gitCommit: string } }];
|
|
96
|
+
predicateType: typeof RUNNER_START_PREDICATE;
|
|
97
|
+
predicate: {
|
|
98
|
+
signer: string;
|
|
99
|
+
ci: { system: CiSystem; repo: string; run: string; attempt: string; job: string };
|
|
100
|
+
runner: { name?: string; singleJob: true };
|
|
101
|
+
issuedAt: string;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A key that may sign the statement, as `.chant/trust.json` lists it under `runners`. */
|
|
106
|
+
export interface RunnerStartKey {
|
|
107
|
+
principal: string;
|
|
108
|
+
/** `ssh-ed25519 <base64>`. */
|
|
109
|
+
key: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whoever signs: the provisioner's key and the principal `.chant/trust.json` lists it for. */
|
|
113
|
+
export interface RunnerStartSigner {
|
|
114
|
+
principal: string;
|
|
115
|
+
key: KeyObject;
|
|
116
|
+
publicKey: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const FULL_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
120
|
+
const SYSTEMS: readonly CiSystem[] = ["github", "gitlab", "forgejo"];
|
|
121
|
+
|
|
122
|
+
/** The job the forge's variables name, or why they name none. GitHub's names are also Forgejo's; `FORGEJO_ACTIONS` or `GITEA_ACTIONS` marks Forgejo, as in ./notify.ts. */
|
|
123
|
+
export function ciJobFromEnv(env: Record<string, string | undefined> = process.env): { ok: true; job: CiJobIdentity } | { ok: false; reason: string } {
|
|
124
|
+
const need = (names: readonly string[]): string | undefined => {
|
|
125
|
+
const missing = names.filter((n) => !env[n]);
|
|
126
|
+
return missing.length > 0 ? `the job's environment does not set ${missing.join(", ")}` : undefined;
|
|
127
|
+
};
|
|
128
|
+
if (env.GITLAB_CI === "true" || env.CI_PIPELINE_ID) {
|
|
129
|
+
const gap = need(["CI_PROJECT_PATH", "CI_PIPELINE_ID", "CI_JOB_ID", "CI_COMMIT_SHA"]);
|
|
130
|
+
if (gap) return { ok: false, reason: gap };
|
|
131
|
+
return { ok: true, job: { system: "gitlab", repo: env.CI_PROJECT_PATH!, run: env.CI_PIPELINE_ID!, attempt: "1", job: env.CI_JOB_ID!, commit: env.CI_COMMIT_SHA! } };
|
|
132
|
+
}
|
|
133
|
+
if (env.GITHUB_ACTIONS === "true" || env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true") {
|
|
134
|
+
const system: CiSystem = env.FORGEJO_ACTIONS === "true" || env.GITEA_ACTIONS === "true" ? "forgejo" : "github";
|
|
135
|
+
const gap = need(["GITHUB_REPOSITORY", "GITHUB_RUN_ID", "GITHUB_JOB", "GITHUB_SHA"]);
|
|
136
|
+
if (gap) return { ok: false, reason: gap };
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
job: {
|
|
140
|
+
system,
|
|
141
|
+
repo: env.GITHUB_REPOSITORY!,
|
|
142
|
+
run: env.GITHUB_RUN_ID!,
|
|
143
|
+
attempt: env.GITHUB_RUN_ATTEMPT || "1",
|
|
144
|
+
job: env.GITHUB_JOB!,
|
|
145
|
+
commit: env.GITHUB_SHA!,
|
|
146
|
+
...(env.RUNNER_NAME ? { runner: env.RUNNER_NAME } : {}),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ok: false, reason: "this is not a GitHub, Forgejo or GitLab job: none of GITHUB_ACTIONS, FORGEJO_ACTIONS, GITEA_ACTIONS or GITLAB_CI is set" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Why `job` cannot be signed for, or undefined. */
|
|
154
|
+
function jobProblem(job: CiJobIdentity): string | undefined {
|
|
155
|
+
if (!SYSTEMS.includes(job.system)) return `the CI system is ${JSON.stringify(job.system)}, not github, gitlab or forgejo`;
|
|
156
|
+
for (const field of ["repo", "run", "attempt", "job"] as const) {
|
|
157
|
+
if (typeof job[field] !== "string" || job[field].length === 0) return `the job names no ${field}`;
|
|
158
|
+
}
|
|
159
|
+
if (!FULL_COMMIT.test(job.commit)) return `the job's commit ${JSON.stringify(job.commit)} is not a full commit id`;
|
|
160
|
+
if (job.system !== "gitlab" && !job.runner) return `a ${job.system} job needs the runner's name, since GITHUB_JOB repeats across a matrix's jobs`;
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The statement `signer` signs for a runner started for `job`. Throws for a job it cannot name. */
|
|
165
|
+
export function buildRunnerStartStatement(job: CiJobIdentity, signer: string, issuedAt: Date = new Date()): RunnerStartStatement {
|
|
166
|
+
const problem = jobProblem(job);
|
|
167
|
+
if (problem) throw new Error(`no runner-start statement: ${problem}`);
|
|
168
|
+
return {
|
|
169
|
+
_type: STATEMENT_TYPE,
|
|
170
|
+
subject: [{ name: job.commit, digest: { gitCommit: job.commit } }],
|
|
171
|
+
predicateType: RUNNER_START_PREDICATE,
|
|
172
|
+
predicate: {
|
|
173
|
+
signer,
|
|
174
|
+
ci: { system: job.system, repo: job.repo, run: job.run, attempt: job.attempt, job: job.job },
|
|
175
|
+
runner: { ...(job.runner ? { name: job.runner } : {}), singleJob: true },
|
|
176
|
+
issuedAt: issuedAt.toISOString(),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The envelope the provisioner hands the runner, signed with its key. */
|
|
182
|
+
export function signRunnerStart(job: CiJobIdentity, signer: RunnerStartSigner, issuedAt: Date = new Date()): DsseEnvelope {
|
|
183
|
+
const statement = buildRunnerStartStatement(job, signer.principal, issuedAt);
|
|
184
|
+
return signEnvelope(IN_TOTO_PAYLOAD_TYPE, Buffer.from(JSON.stringify(statement), "utf8"), signer.key, signer.publicKey);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type RunnerStartVerdict =
|
|
188
|
+
| { status: "verified"; signer: string; keyid: string; issuedAt: string; reason: string }
|
|
189
|
+
| { status: "missing" | "invalid" | "untrusted" | "mismatch" | "stale"; reason: string };
|
|
190
|
+
|
|
191
|
+
const normalise = (name: string): string => name.normalize("NFKC").trim().toLowerCase();
|
|
192
|
+
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
193
|
+
const onlyKeys = (v: Record<string, unknown>, keys: readonly string[]): boolean => Object.keys(v).every((k) => keys.includes(k));
|
|
194
|
+
const isText = (v: unknown): v is string => typeof v === "string" && v.length > 0;
|
|
195
|
+
|
|
196
|
+
/** The statement in `raw`, or why it is not one. Strict, so a verifier never acts on a field it does not know. */
|
|
197
|
+
function parseStatement(raw: unknown): RunnerStartStatement | string {
|
|
198
|
+
if (!isObject(raw) || !onlyKeys(raw, ["_type", "subject", "predicateType", "predicate"])) return "not a statement with only _type, subject, predicateType and predicate";
|
|
199
|
+
if (raw._type !== STATEMENT_TYPE) return `_type is not ${STATEMENT_TYPE}`;
|
|
200
|
+
if (raw.predicateType !== RUNNER_START_PREDICATE) return `predicateType is not ${RUNNER_START_PREDICATE}`;
|
|
201
|
+
const subject = raw.subject;
|
|
202
|
+
if (!Array.isArray(subject) || subject.length !== 1) return "subject is not one commit";
|
|
203
|
+
const s = subject[0];
|
|
204
|
+
if (!isObject(s) || !onlyKeys(s, ["name", "digest"]) || !isObject(s.digest) || !onlyKeys(s.digest, ["gitCommit"])) return "subject is not one commit";
|
|
205
|
+
if (typeof s.name !== "string" || !FULL_COMMIT.test(s.name) || s.digest.gitCommit !== s.name) return "the subject is not a full commit id named by itself";
|
|
206
|
+
const p = raw.predicate;
|
|
207
|
+
if (!isObject(p) || !onlyKeys(p, ["signer", "ci", "runner", "issuedAt"])) return "predicate has fields other than signer, ci, runner and issuedAt";
|
|
208
|
+
if (!isText(p.signer)) return "predicate.signer is missing";
|
|
209
|
+
const ci = p.ci;
|
|
210
|
+
if (!isObject(ci) || !onlyKeys(ci, ["system", "repo", "run", "attempt", "job"])) return "predicate.ci has fields other than system, repo, run, attempt and job";
|
|
211
|
+
if (!SYSTEMS.includes(ci.system as CiSystem)) return "predicate.ci.system is not github, gitlab or forgejo";
|
|
212
|
+
for (const field of ["repo", "run", "attempt", "job"] as const) if (!isText(ci[field])) return `predicate.ci.${field} is missing`;
|
|
213
|
+
const runner = p.runner;
|
|
214
|
+
if (!isObject(runner) || !onlyKeys(runner, ["name", "singleJob"]) || runner.singleJob !== true) return "predicate.runner does not say singleJob: true";
|
|
215
|
+
if (runner.name !== undefined && !isText(runner.name)) return "predicate.runner.name is not a name";
|
|
216
|
+
if (!isText(p.issuedAt) || Number.isNaN(Date.parse(p.issuedAt))) return "predicate.issuedAt is not a time";
|
|
217
|
+
return raw as unknown as RunnerStartStatement;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface VerifyRunnerStartInput {
|
|
221
|
+
/** The envelope, as JSON text or parsed. Undefined when the job found none. */
|
|
222
|
+
envelope: unknown;
|
|
223
|
+
/** The job reading it. */
|
|
224
|
+
job: CiJobIdentity;
|
|
225
|
+
/** `runners` from `.chant/trust.json` at base. */
|
|
226
|
+
runners: readonly RunnerStartKey[];
|
|
227
|
+
/** `roles` from `.chant/trust.json` at base. */
|
|
228
|
+
roles: Readonly<Record<string, readonly string[]>>;
|
|
229
|
+
/** Refuse a statement issued longer ago than this. Default: no limit, since a statement names one job attempt. */
|
|
230
|
+
maxAgeSeconds?: number;
|
|
231
|
+
now?: Date;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Verify a runner-start statement for `job`: a signature by a key listed
|
|
236
|
+
* under `runners` whose principal holds the `runner` role, over a statement
|
|
237
|
+
* for this system, repository, run, attempt, job, commit and, on GitHub and
|
|
238
|
+
* Forgejo, runner. Never throws.
|
|
239
|
+
*/
|
|
240
|
+
export function verifyRunnerStart(input: VerifyRunnerStartInput): RunnerStartVerdict {
|
|
241
|
+
const { job } = input;
|
|
242
|
+
const what = `the ${job.system} job ${job.job} of run ${job.run} (attempt ${job.attempt})`;
|
|
243
|
+
if (input.envelope === undefined || input.envelope === null || input.envelope === "") {
|
|
244
|
+
return { status: "missing", reason: `${what} has no runner-start statement: no provisioner says this runner was started for it` };
|
|
245
|
+
}
|
|
246
|
+
let envelope = input.envelope;
|
|
247
|
+
if (typeof envelope === "string") {
|
|
248
|
+
try {
|
|
249
|
+
envelope = JSON.parse(envelope);
|
|
250
|
+
} catch {
|
|
251
|
+
return { status: "invalid", reason: `${what}: the runner-start statement is not JSON` };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const v = verifyEnvelope(envelope, input.runners);
|
|
255
|
+
if (!v.ok) return { status: v.code === "envelope-untrusted" ? "untrusted" : "invalid", reason: `${what}: ${v.reason}` };
|
|
256
|
+
if (!(input.roles[RUNNER_START_SIGNER_ROLE] ?? []).some((p) => normalise(p) === normalise(v.principal))) {
|
|
257
|
+
return { status: "untrusted", reason: `${what}: the statement is signed by ${v.principal}'s key, and ${v.principal} holds no ${RUNNER_START_SIGNER_ROLE} role in .chant/trust.json at base` };
|
|
258
|
+
}
|
|
259
|
+
if (v.payloadType !== IN_TOTO_PAYLOAD_TYPE) return { status: "invalid", reason: `${what}: payload type ${v.payloadType} is not ${IN_TOTO_PAYLOAD_TYPE}` };
|
|
260
|
+
let raw: unknown;
|
|
261
|
+
try {
|
|
262
|
+
raw = JSON.parse(v.payload.toString("utf8"));
|
|
263
|
+
} catch {
|
|
264
|
+
return { status: "invalid", reason: `${what}: the statement's payload is not JSON` };
|
|
265
|
+
}
|
|
266
|
+
const parsed = parseStatement(raw);
|
|
267
|
+
if (typeof parsed === "string") return { status: "invalid", reason: `${what}: the payload is not a ${RUNNER_START_PREDICATE} statement: ${parsed}` };
|
|
268
|
+
const p = parsed.predicate;
|
|
269
|
+
if (p.signer !== v.principal) return { status: "invalid", reason: `${what}: the statement names signer ${JSON.stringify(p.signer)}, but ${v.principal}'s key signed it` };
|
|
270
|
+
const differs: string[] = [];
|
|
271
|
+
if (p.ci.system !== job.system) differs.push(`it is for a ${p.ci.system} job`);
|
|
272
|
+
if (p.ci.repo !== job.repo) differs.push(`it is for repository ${p.ci.repo}`);
|
|
273
|
+
if (p.ci.run !== job.run) differs.push(`it is for run ${p.ci.run}`);
|
|
274
|
+
if (p.ci.attempt !== job.attempt) differs.push(`it is for attempt ${p.ci.attempt}`);
|
|
275
|
+
if (p.ci.job !== job.job) differs.push(`it is for job ${p.ci.job}`);
|
|
276
|
+
const commit = parsed.subject[0].name;
|
|
277
|
+
if (commit !== job.commit) differs.push(`it is for commit ${commit.slice(0, 12)}, and the job runs ${job.commit.slice(0, 12)}`);
|
|
278
|
+
if (job.system !== "gitlab") {
|
|
279
|
+
if (!p.runner.name) differs.push("it names no runner, and a job id alone does not tell a matrix's jobs apart");
|
|
280
|
+
else if (!job.runner) differs.push(`it is for runner ${p.runner.name}, and the job's environment names no runner (RUNNER_NAME)`);
|
|
281
|
+
else if (p.runner.name !== job.runner) differs.push(`it is for runner ${p.runner.name}, and this is ${job.runner}`);
|
|
282
|
+
}
|
|
283
|
+
if (differs.length > 0) return { status: "mismatch", reason: `${what}: the runner-start statement signed by ${v.principal} is for another job: ${differs.join("; ")}` };
|
|
284
|
+
if (input.maxAgeSeconds !== undefined) {
|
|
285
|
+
const age = ((input.now ?? new Date()).getTime() - Date.parse(p.issuedAt)) / 1000;
|
|
286
|
+
if (age > input.maxAgeSeconds) return { status: "stale", reason: `${what}: the runner-start statement was issued ${Math.round(age)}s ago, more than ${input.maxAgeSeconds}s` };
|
|
287
|
+
}
|
|
288
|
+
return { status: "verified", signer: v.principal, keyid: v.keyid, issuedAt: p.issuedAt, reason: `runner started for this job, signed by ${v.principal} (${RUNNER_START_SIGNER_ROLE})` };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The envelope's JSON the job was given: `CHANT_RUNNER_START`, else the file `CHANT_RUNNER_START_FILE` names, else {@link DEFAULT_RUNNER_START_FILE}. Undefined when there is none. */
|
|
292
|
+
export function readRunnerStart(env: Record<string, string | undefined> = process.env, readFile: (path: string) => string = (p) => readFileSync(p, "utf8")): string | undefined {
|
|
293
|
+
const inline = env[RUNNER_START_ENV];
|
|
294
|
+
if (inline) return inline;
|
|
295
|
+
try {
|
|
296
|
+
return readFile(env[RUNNER_START_FILE_ENV] || DEFAULT_RUNNER_START_FILE);
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One call for a job: find its statement ({@link readRunnerStart}), name the
|
|
304
|
+
* job from the forge's variables ({@link ciJobFromEnv}) and verify one
|
|
305
|
+
* against the other ({@link verifyRunnerStart}). Never throws.
|
|
306
|
+
*/
|
|
307
|
+
export function verifyRunnerStartForJob(input: {
|
|
308
|
+
runners: readonly RunnerStartKey[];
|
|
309
|
+
roles: Readonly<Record<string, readonly string[]>>;
|
|
310
|
+
env?: Record<string, string | undefined>;
|
|
311
|
+
/** The commit the job runs, in place of the forge's variable. */
|
|
312
|
+
commit?: string;
|
|
313
|
+
readFile?: (path: string) => string;
|
|
314
|
+
maxAgeSeconds?: number;
|
|
315
|
+
now?: Date;
|
|
316
|
+
}): RunnerStartVerdict {
|
|
317
|
+
const env = input.env ?? process.env;
|
|
318
|
+
const named = ciJobFromEnv(env);
|
|
319
|
+
if (!named.ok) return { status: "missing", reason: `no job to check a runner-start statement for: ${named.reason}` };
|
|
320
|
+
const job = input.commit ? { ...named.job, commit: input.commit } : named.job;
|
|
321
|
+
return verifyRunnerStart({
|
|
322
|
+
envelope: readRunnerStart(env, input.readFile),
|
|
323
|
+
job,
|
|
324
|
+
runners: input.runners,
|
|
325
|
+
roles: input.roles,
|
|
326
|
+
...(input.maxAgeSeconds !== undefined ? { maxAgeSeconds: input.maxAgeSeconds } : {}),
|
|
327
|
+
...(input.now ? { now: input.now } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -13,6 +13,7 @@ import schema from "./check.schema.json";
|
|
|
13
13
|
import { BOX_FINDING_CODES } from "./checks/boxes";
|
|
14
14
|
import { RECORD_FINDING_CODES } from "./checks/records";
|
|
15
15
|
import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
|
|
16
|
+
import { SECURITY_FINDING_CODES } from "./checks/security";
|
|
16
17
|
import { WORKSPACE_ERROR_CODES } from "./declaration";
|
|
17
18
|
import { CHECK_CODES, CHECK_CONTRACT_VERSION, CHECK_ERROR_CODES, CHECK_OUTPUT_SCHEMA_ID, runChecks, type CheckDocument } from "./lineage-check";
|
|
18
19
|
|
|
@@ -38,8 +39,8 @@ describe("check output schema", () => {
|
|
|
38
39
|
test("lists exactly the codes the code can return", () => {
|
|
39
40
|
expect(schema.$defs.lockFinding.properties.code.enum).toEqual([...CHECK_CODES]);
|
|
40
41
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...CHECK_ERROR_CODES]);
|
|
41
|
-
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES]);
|
|
42
|
-
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES]);
|
|
42
|
+
expect(schema.$defs.diagnostic.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
|
+
expect(schema.$defs.suppressed.properties.code.enum).toEqual([...WORKSPACE_ERROR_CODES, ...BOX_FINDING_CODES, ...RECORD_FINDING_CODES, ...DIAGRAM_FINDING_CODES, ...SECURITY_FINDING_CODES]);
|
|
43
44
|
});
|
|
44
45
|
});
|
|
45
46
|
|
|
@@ -298,7 +298,41 @@
|
|
|
298
298
|
"flag-stale",
|
|
299
299
|
"diagram-source-missing",
|
|
300
300
|
"diagram-render-missing",
|
|
301
|
-
"diagram-render-drift"
|
|
301
|
+
"diagram-render-drift",
|
|
302
|
+
|
|
303
|
+
"security-signers-missing",
|
|
304
|
+
|
|
305
|
+
"security-release-gate-missing",
|
|
306
|
+
|
|
307
|
+
"security-publish-allows-unsigned",
|
|
308
|
+
|
|
309
|
+
"security-sbom-disabled",
|
|
310
|
+
|
|
311
|
+
"security-sbom-unsourced",
|
|
312
|
+
|
|
313
|
+
"security-publish-no-provenance",
|
|
314
|
+
|
|
315
|
+
"security-component-no-sbom",
|
|
316
|
+
|
|
317
|
+
"security-component-no-verify",
|
|
318
|
+
|
|
319
|
+
"security-signed-since-unset",
|
|
320
|
+
|
|
321
|
+
"security-runner-untrusted",
|
|
322
|
+
|
|
323
|
+
"security-tick-no-key",
|
|
324
|
+
|
|
325
|
+
"security-review-not-required",
|
|
326
|
+
|
|
327
|
+
"security-attribution-any",
|
|
328
|
+
|
|
329
|
+
"security-gate-unsigned",
|
|
330
|
+
|
|
331
|
+
"security-runner-start-no-key",
|
|
332
|
+
|
|
333
|
+
"security-runner-start-no-role",
|
|
334
|
+
|
|
335
|
+
"security-trust-unreadable"
|
|
302
336
|
],
|
|
303
337
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
304
338
|
}
|
|
@@ -377,7 +411,41 @@
|
|
|
377
411
|
"flag-stale",
|
|
378
412
|
"diagram-source-missing",
|
|
379
413
|
"diagram-render-missing",
|
|
380
|
-
"diagram-render-drift"
|
|
414
|
+
"diagram-render-drift",
|
|
415
|
+
|
|
416
|
+
"security-signers-missing",
|
|
417
|
+
|
|
418
|
+
"security-release-gate-missing",
|
|
419
|
+
|
|
420
|
+
"security-publish-allows-unsigned",
|
|
421
|
+
|
|
422
|
+
"security-sbom-disabled",
|
|
423
|
+
|
|
424
|
+
"security-sbom-unsourced",
|
|
425
|
+
|
|
426
|
+
"security-publish-no-provenance",
|
|
427
|
+
|
|
428
|
+
"security-component-no-sbom",
|
|
429
|
+
|
|
430
|
+
"security-component-no-verify",
|
|
431
|
+
|
|
432
|
+
"security-signed-since-unset",
|
|
433
|
+
|
|
434
|
+
"security-runner-untrusted",
|
|
435
|
+
|
|
436
|
+
"security-tick-no-key",
|
|
437
|
+
|
|
438
|
+
"security-review-not-required",
|
|
439
|
+
|
|
440
|
+
"security-attribution-any",
|
|
441
|
+
|
|
442
|
+
"security-gate-unsigned",
|
|
443
|
+
|
|
444
|
+
"security-runner-start-no-key",
|
|
445
|
+
|
|
446
|
+
"security-runner-start-no-role",
|
|
447
|
+
|
|
448
|
+
"security-trust-unreadable"
|
|
381
449
|
],
|
|
382
450
|
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP118, flag-stale (#4024). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
383
451
|
},
|