mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
3406dc4d85
Pin the remaining Cutter upload/download actions and enforce immutable GitHub/Docker references at actual action call sites. Run the same exact guard for workflow-only changes without preparing runtime artifacts. Co-authored-by: Govindu Sasanka <31107592+GovinduSasanka@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
149 lines
4.5 KiB
TypeScript
149 lines
4.5 KiB
TypeScript
import { readdir, readFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { z } from "zod";
|
|
|
|
const github = fileURLToPath(new URL("../../../.github/", import.meta.url));
|
|
const steps = z.array(z.object({ uses: z.string().optional() })).optional();
|
|
const actionReferences = z.object({
|
|
jobs: z
|
|
.record(z.string(), z.object({ uses: z.string().optional(), steps }))
|
|
.optional(),
|
|
runs: z.object({ steps }).optional(),
|
|
});
|
|
|
|
// Inspect action/workflow call sites, not arbitrary inputs named `uses` or
|
|
// shell source. YAML parsing also covers flow syntax and quoted references.
|
|
const unpinnedReferences = (source: string): readonly string[] => {
|
|
const document = actionReferences.parse(parse(source));
|
|
const references = [
|
|
...Object.values(document.jobs ?? {}).flatMap((job) => [
|
|
job.uses,
|
|
...(job.steps ?? []).map((step) => step.uses),
|
|
]),
|
|
...(document.runs?.steps ?? []).map((step) => step.uses),
|
|
];
|
|
return references.filter(
|
|
(reference): reference is string =>
|
|
reference !== undefined &&
|
|
!reference.startsWith("./") &&
|
|
(reference.startsWith("docker://")
|
|
? !/@sha256:[0-9a-f]{64}$/iu.test(reference)
|
|
: !/@[0-9a-f]{40}$/iu.test(reference)),
|
|
);
|
|
};
|
|
|
|
describe("external GitHub action pinning", () => {
|
|
const sha = "11d5960a326750d5838078e36cf38b85af677262";
|
|
|
|
it("finds mutable GitHub and Docker references in all action call sites", () => {
|
|
expect(
|
|
unpinnedReferences(`
|
|
jobs:
|
|
call:
|
|
uses: owner/repo/.github/workflows/build.yml@main
|
|
build:
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- { uses: actions/setup-node@v4 }
|
|
- uses: docker://alpine:3.20
|
|
- uses: docker://alpine@sha256:abcd
|
|
runs:
|
|
using: composite
|
|
steps:
|
|
- uses: "actions/cache@v4"
|
|
`),
|
|
).toEqual([
|
|
"owner/repo/.github/workflows/build.yml@main",
|
|
"actions/checkout@v4",
|
|
"actions/setup-node@v4",
|
|
"docker://alpine:3.20",
|
|
"docker://alpine@sha256:abcd",
|
|
"actions/cache@v4",
|
|
]);
|
|
});
|
|
|
|
it("accepts full GitHub SHAs, local calls and Docker SHA256 digests", () => {
|
|
expect(
|
|
unpinnedReferences(`
|
|
jobs:
|
|
local:
|
|
uses: ./.github/workflows/build.yml
|
|
remote:
|
|
uses: owner/repo/.github/workflows/build.yml@${sha}
|
|
build:
|
|
steps:
|
|
- uses: "actions/checkout@${sha}" # version comment
|
|
- uses: actions/cache/restore@${sha.toUpperCase()}
|
|
- uses: './.github/actions/npm-cache'
|
|
- uses: docker://alpine@sha256:${"a".repeat(64)}
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("ignores inputs, environment and shell source named uses", () => {
|
|
expect(
|
|
unpinnedReferences(`
|
|
env: { uses: ordinary-value }
|
|
jobs:
|
|
build:
|
|
steps:
|
|
- uses: actions/checkout@${sha}
|
|
with: { uses: ordinary-input }
|
|
- run: 'echo "uses: actions/example@main"'
|
|
`),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("also runs the guard for workflow-only changes without preparing runtime artifacts", async () => {
|
|
const workflow = z
|
|
.object({
|
|
jobs: z.object({
|
|
static: z.object({
|
|
steps: z.array(
|
|
z.object({
|
|
if: z.string().optional(),
|
|
run: z.string().optional(),
|
|
}),
|
|
),
|
|
}),
|
|
}),
|
|
})
|
|
.parse(parse(await readFile(join(github, "workflows/ci.yml"), "utf8")));
|
|
const guard = workflow.jobs.static.steps.find((step) =>
|
|
step.run?.includes("workflowActionPinning.test.ts"),
|
|
);
|
|
expect(guard?.if).toBe(
|
|
"needs.changes.outputs.workflows == 'true' && needs.changes.outputs.code != 'true'",
|
|
);
|
|
expect(guard?.run).toBe(
|
|
"node scripts/run-exact-tests.mjs run tests/boundary/filesystem/workflowActionPinning.test.ts",
|
|
);
|
|
});
|
|
|
|
it("pins external actions in all repository workflows and composite actions", async () => {
|
|
const workflows = join(github, "workflows");
|
|
const actions = join(github, "actions");
|
|
const files = [
|
|
...(await readdir(workflows))
|
|
.filter((name) => /\.ya?ml$/u.test(name))
|
|
.map((name) => join(workflows, name)),
|
|
...(await readdir(actions, { recursive: true }))
|
|
.filter((name) => /(?:^|[/\\])action\.ya?ml$/u.test(name))
|
|
.map((name) => join(actions, name)),
|
|
].sort();
|
|
const unpinned: string[] = [];
|
|
for (const file of files) {
|
|
for (const reference of unpinnedReferences(
|
|
await readFile(file, "utf8"),
|
|
)) {
|
|
unpinned.push(`${file}: ${reference}`);
|
|
}
|
|
}
|
|
expect(unpinned).toEqual([]);
|
|
});
|
|
});
|