Skip to content

Commit 49ec2e9

Browse files
committed
fix(loop): fail closed on workflow permissions
1 parent c7ba5bd commit 49ec2e9

2 files changed

Lines changed: 85 additions & 31 deletions

File tree

loops/issue-dev-loop/scripts/lib/validation.mjs

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -51,43 +51,83 @@ function activeYamlLines(source) {
5151
.filter((line) => line.trim() && !line.trimStart().startsWith('#'))
5252
}
5353

54+
function yamlMapping(line) {
55+
const match = line.match(
56+
/^(\s*)(?:(["'])([^"']+)\2|([A-Za-z_][A-Za-z0-9_-]*))\s*:(.*)$/,
57+
)
58+
if (!match) return null
59+
return {
60+
indent: match[1].length,
61+
key: match[3] ?? match[4],
62+
quoted: Boolean(match[2]),
63+
value: match[5].replace(/\s+#.*$/, '').trim(),
64+
}
65+
}
66+
5467
function historicalWorkflowIsLowPrivilege(source) {
5568
const lines = activeYamlLines(source)
56-
const onIndex = lines.findIndex((line) => /^on:\s*(?:#.*)?$/.test(line))
57-
if (onIndex === -1) return false
58-
const onBlock = lines.slice(onIndex + 1).findIndex((line) => /^\S/.test(line))
59-
const triggerLines =
60-
onBlock === -1 ? lines.slice(onIndex + 1) : lines.slice(onIndex + 1, onIndex + 1 + onBlock)
69+
if (lines.some((line) => line.includes('\t') || /^\s*<<\s*:/.test(line))) return false
70+
const mappings = lines.map((line, index) => ({
71+
index,
72+
mapping: yamlMapping(line),
73+
}))
6174
if (
62-
!triggerLines.some((line) => /^ pull_request:\s*(?:#.*)?$/.test(line)) ||
63-
lines.some((line) => /^\s*pull_request_target\s*:/.test(line))
75+
mappings.some(({ mapping }) => mapping?.quoted) ||
76+
mappings.some(
77+
({ mapping }) =>
78+
mapping &&
79+
(mapping.key === 'pull_request_target' ||
80+
(mapping.key === 'permissions' && mapping.indent > 0)),
81+
)
6482
) {
6583
return false
6684
}
85+
const topLevelMappings = mappings.filter(({ mapping }) => mapping?.indent === 0)
86+
const topLevelKeys = topLevelMappings.map(({ mapping }) => mapping.key)
87+
if (new Set(topLevelKeys).size !== topLevelKeys.length) return false
6788

68-
const permissionIndexes = lines.flatMap((line, index) =>
69-
/^permissions\s*:/.test(line) ? [index] : [],
89+
const blockLines = ({ index }) => {
90+
const endOffset = lines.slice(index + 1).findIndex((line) => /^\S/.test(line))
91+
return endOffset === -1
92+
? lines.slice(index + 1)
93+
: lines.slice(index + 1, index + 1 + endOffset)
94+
}
95+
96+
const onEntries = topLevelMappings.filter(({ mapping }) => mapping.key === 'on')
97+
if (onEntries.length !== 1 || onEntries[0].mapping.value) return false
98+
const triggerBlock = blockLines(onEntries[0])
99+
const triggerBoundaryLines = triggerBlock.filter(
100+
(line) => (line.match(/^\s*/)?.[0].length ?? 0) <= 2,
70101
)
102+
const triggerMappings = triggerBoundaryLines
103+
.map(yamlMapping)
104+
.filter((mapping) => mapping?.indent === 2)
71105
if (
72-
permissionIndexes.length !== 1 ||
73-
!/^permissions:\s*(?:#.*)?$/.test(lines[permissionIndexes[0]]) ||
74-
lines.some((line) => /^\s+permissions\s*:/.test(line))
106+
triggerBoundaryLines.length !== 1 ||
107+
triggerMappings.length !== 1 ||
108+
triggerMappings[0].key !== 'pull_request' ||
109+
triggerMappings[0].value
75110
) {
76111
return false
77112
}
78-
const permissionIndex = permissionIndexes[0]
79-
const permissionBlockEnd = lines
80-
.slice(permissionIndex + 1)
81-
.findIndex((line) => /^\S/.test(line))
82-
const permissionLines =
83-
permissionBlockEnd === -1
84-
? lines.slice(permissionIndex + 1)
85-
: lines.slice(permissionIndex + 1, permissionIndex + 1 + permissionBlockEnd)
113+
114+
const permissionEntries = topLevelMappings.filter(
115+
({ mapping }) => mapping.key === 'permissions',
116+
)
117+
if (permissionEntries.length !== 1 || permissionEntries[0].mapping.value) return false
118+
const permissionLines = blockLines(permissionEntries[0])
86119
const permissions = new Map()
87120
for (const line of permissionLines) {
88-
const match = line.match(/^ ([a-z-]+):\s*(read|none)\s*(?:#.*)?$/)
89-
if (!match || permissions.has(match[1])) return false
90-
permissions.set(match[1], match[2])
121+
const mapping = yamlMapping(line)
122+
if (
123+
!mapping ||
124+
mapping.indent !== 2 ||
125+
!['read', 'none'].includes(mapping.value) ||
126+
permissions.has(mapping.key)
127+
) {
128+
return false
129+
}
130+
permissions.set(mapping.key, mapping.value)
91131
}
92132
return permissions.get('contents') === 'read'
93133
}

loops/issue-dev-loop/tests/runtime.test.mjs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5068,20 +5068,34 @@ test('historical active-run targets can validate without newer trusted runtime f
50685068
assert.equal(result.valid, true)
50695069

50705070
const historicalWorkflow = await readFile(workflowPath, 'utf8')
5071-
await writeFile(
5072-
workflowPath,
5071+
const unsafeWorkflows = [
50735072
`${historicalWorkflow}
50745073
unsafe:
50755074
permissions: write-all
50765075
runs-on: ubuntu-latest
50775076
steps: []
50785077
`,
5079-
'utf8',
5080-
)
5081-
await assert.rejects(
5082-
validateHistoricalTarget({ loopRoot: historicalLoopRoot }),
5083-
/historical target evidence workflow must remain a low-privilege pull_request workflow/,
5084-
)
5078+
`${historicalWorkflow}
5079+
unsafe:
5080+
"permissions": write-all
5081+
runs-on: ubuntu-latest
5082+
steps: []
5083+
`,
5084+
historicalWorkflow.replace(' pull_request:\n', ' pull_request:\n workflow_dispatch:\n'),
5085+
historicalWorkflow.replace(' pull_request:\n', ' "pull_request_target":\n'),
5086+
historicalWorkflow.replace(
5087+
'permissions:\n contents: read\n',
5088+
'permissions:\n contents: read\npermissions:\n contents: read\n',
5089+
),
5090+
historicalWorkflow.replace('permissions:\n', 'permissions: &shared_permissions\n'),
5091+
]
5092+
for (const unsafeWorkflow of unsafeWorkflows) {
5093+
await writeFile(workflowPath, unsafeWorkflow, 'utf8')
5094+
await assert.rejects(
5095+
validateHistoricalTarget({ loopRoot: historicalLoopRoot }),
5096+
/historical target evidence workflow must remain a low-privilege pull_request workflow/,
5097+
)
5098+
}
50855099
await writeFile(workflowPath, historicalWorkflow, 'utf8')
50865100

50875101
await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl'))

0 commit comments

Comments
 (0)