From ca9f02ffda6115fd80860439caaeb77f0519bc9a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:19:03 +0200 Subject: [PATCH] fix: keep workflow tokens read-only at top level --- .../workflows/cleanup-orphaned-workflows.yml | 4 +++- scripts/lib/workflow-security.mjs | 13 +++++++++++++ scripts/lib/workflow-yaml-security.mjs | 6 +++--- tests/unit/workflow-security.test.mjs | 19 ++++++++++++++++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cleanup-orphaned-workflows.yml b/.github/workflows/cleanup-orphaned-workflows.yml index bbe81e4b..6f4c2d94 100644 --- a/.github/workflows/cleanup-orphaned-workflows.yml +++ b/.github/workflows/cleanup-orphaned-workflows.yml @@ -14,7 +14,6 @@ on: - "scripts/cleanup-orphaned-workflows.mjs" permissions: - actions: write contents: read concurrency: @@ -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 diff --git a/scripts/lib/workflow-security.mjs b/scripts/lib/workflow-security.mjs index 951df680..09322607 100644 --- a/scripts/lib/workflow-security.mjs +++ b/scripts/lib/workflow-security.mjs @@ -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."), diff --git a/scripts/lib/workflow-yaml-security.mjs b/scripts/lib/workflow-yaml-security.mjs index 85bd8313..d89103c3 100644 --- a/scripts/lib/workflow-yaml-security.mjs +++ b/scripts/lib/workflow-yaml-security.mjs @@ -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(",")) { @@ -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 }); } } } diff --git a/tests/unit/workflow-security.test.mjs b/tests/unit/workflow-security.test.mjs index 5b6d285e..eb8c98ee 100644 --- a/tests/unit/workflow-security.test.mjs +++ b/tests/unit/workflow-security.test.mjs @@ -117,7 +117,7 @@ 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")); }); @@ -125,7 +125,7 @@ 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", () => { @@ -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), []);