Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/cleanup-orphaned-workflows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ on:
- "scripts/cleanup-orphaned-workflows.mjs"

permissions:
actions: write
contents: read

concurrency:
Expand All @@ -26,6 +25,9 @@ jobs:
name: Delete orphaned workflow runs
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
contents: read
steps:
- name: Check out trusted default-branch code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
13 changes: 13 additions & 0 deletions scripts/lib/workflow-security.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ export function validateWorkflowFile(path, source) {

const allowedWrites = WRITE_ALLOWLIST.get(path) || new Set();
for (const write of facts.permissionWrites) {
if (write.topLevel) {
errors.push(
error(
"top_level_write_forbidden",
path,
write.line,
write.writeAll
? "Top-level write-all is forbidden; keep top-level permissions read-only and declare writes at the job level."
: `Top-level ${write.permission}: write is forbidden; keep top-level permissions read-only and declare writes at the job level.`,
),
);
continue;
}
if (write.writeAll) {
errors.push(
error("write_all_forbidden", path, write.line, "write-all is forbidden."),
Expand Down
6 changes: 3 additions & 3 deletions scripts/lib/workflow-yaml-security.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export function workflowSecurityFacts(source = "") {
if (row.key === "permissions") {
if (row.indent === 0) facts.topLevelPermissions.push(row);
if (row.value === "write-all") {
facts.permissionWrites.push({ permission: "*", line: row.line, writeAll: true });
facts.permissionWrites.push({ permission: "*", line: row.line, writeAll: true, topLevel: row.indent === 0 });
} else if (/^\{.*\}$/.test(row.value)) {
const body = row.value.slice(1, -1);
for (const item of body.split(",")) {
Expand All @@ -246,13 +246,13 @@ export function workflowSecurityFacts(source = "") {
const permission = decodeScalar(pair[0]);
const value = decodeScalar(pair[1]);
if (value === "write") {
facts.permissionWrites.push({ permission, line: row.line, writeAll: false });
facts.permissionWrites.push({ permission, line: row.line, writeAll: false, topLevel: row.indent === 0 });
}
}
} else if (!row.value) {
for (const child of descendants(parsed.records, index)) {
if (child.value === "write") {
facts.permissionWrites.push({ permission: child.key, line: child.line, writeAll: false });
facts.permissionWrites.push({ permission: child.key, line: child.line, writeAll: false, topLevel: row.indent === 0 });
}
}
}
Expand Down
19 changes: 16 additions & 3 deletions tests/unit/workflow-security.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,15 @@ test("quoted YAML keys cannot bypass pull_request_target or write checks", () =>
const source = `name: Bad\n'on':\n 'pull_request_target':\n'permissions':\n 'contents': write\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - 'uses': 'actions/checkout@v6'\n`;
const errors = validateWorkflowFile(".github/workflows/bad.yml", source);
assert(errors.some((error) => error.code === "pull_request_target_forbidden"));
assert(errors.some((error) => error.code === "write_permission_not_allowed"));
assert(errors.some((error) => error.code === "top_level_write_forbidden" || error.code === "write_permission_not_allowed"));
assert(errors.some((error) => error.code === "action_not_pinned"));
});

test("inline trigger and permissions mappings are inspected semantically", () => {
const source = `name: Bad\non: [push, 'pull_request_target']\npermissions: { contents: write }\njobs:\n test:\n runs-on: ubuntu-latest\n`;
const errors = validateWorkflowFile(".github/workflows/bad.yml", source);
assert(errors.some((error) => error.code === "pull_request_target_forbidden"));
assert(errors.some((error) => error.code === "write_permission_not_allowed"));
assert(errors.some((error) => error.code === "top_level_write_forbidden" || error.code === "write_permission_not_allowed"));
});

test("unsupported YAML indirection fails closed", () => {
Expand All @@ -150,11 +150,24 @@ test("accepts checkout only when its own step disables credential persistence",
});

test("rejects write permissions outside approved workflows", () => {
const source = `name: Bad\non:\n push:\npermissions:\n contents: write\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@${"a".repeat(40)}\n with:\n persist-credentials: false\n`;
const source = `name: Bad\non:\n push:\npermissions:\n contents: read\njobs:\n test:\n runs-on: ubuntu-latest\n permissions:\n contents: write\n steps:\n - uses: actions/checkout@${"a".repeat(40)}\n with:\n persist-credentials: false\n`;
const errors = validateWorkflowFile(".github/workflows/bad.yml", source);
assert(errors.some((error) => error.code === "write_permission_not_allowed"));
});

test("rejects top-level write permissions even on allowlisted workflows", () => {
const source = `name: Clean\non:\n push:\npermissions:\n actions: write\n contents: read\njobs:\n cleanup:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@${"a".repeat(40)}\n with:\n persist-credentials: false\n`;
const errors = validateWorkflowFile(".github/workflows/cleanup-orphaned-workflows.yml", source);
assert(errors.some((error) => error.code === "top_level_write_forbidden"));
});

test("accepts job-level write permissions on allowlisted workflows", () => {
const source = `name: Clean\non:\n push:\npermissions:\n contents: read\njobs:\n cleanup:\n runs-on: ubuntu-latest\n permissions:\n actions: write\n contents: read\n steps:\n - uses: actions/checkout@${"a".repeat(40)}\n with:\n persist-credentials: false\n`;
const errors = validateWorkflowFile(".github/workflows/cleanup-orphaned-workflows.yml", source);
assert.equal(errors.some((error) => error.code === "top_level_write_forbidden"), false);
assert.equal(errors.some((error) => error.code === "write_permission_not_allowed"), false);
});

test("desired repository policy is fail-closed", () => {
const policy = desiredPolicy();
assert.deepEqual(validateRepositoryPolicy(policy), []);
Expand Down
Loading