2
0

unsafe-pr-checkout-helper.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import {
  2. jest,
  3. describe,
  4. it,
  5. expect,
  6. beforeAll,
  7. afterEach,
  8. afterAll
  9. } from '@jest/globals'
  10. const BASE_REPO_ID = 100
  11. const FORK_REPO_ID = 200
  12. const PR_HEAD_SHA = '1111111111111111111111111111111111111111'
  13. const PR_MERGE_SHA = '2222222222222222222222222222222222222222'
  14. const SAFE_BASE_SHA = '3333333333333333333333333333333333333333'
  15. const WORKFLOW_RUN_HEAD_COMMIT_SHA = '4444444444444444444444444444444444444444'
  16. const BASE_QUALIFIED_REPO = 'some-owner/some-repo'
  17. const FORK_QUALIFIED_REPO = 'another-repo/fork'
  18. // Mutable mock context
  19. const mockContext: any = {
  20. eventName: '',
  21. payload: {},
  22. repo: {owner: 'some-owner', repo: 'some-repo'},
  23. ref: '',
  24. sha: ''
  25. }
  26. jest.unstable_mockModule('@actions/github', () => ({
  27. context: mockContext
  28. }))
  29. // Dynamic imports after mocking
  30. const {assertSafePrCheckout} =
  31. await import('../src/unsafe-pr-checkout-helper.js')
  32. const originalEventName = mockContext.eventName
  33. const originalPayload = mockContext.payload
  34. function setContext(eventName: string, payload: object): void {
  35. mockContext.eventName = eventName
  36. mockContext.payload = payload
  37. }
  38. function forkPullRequestTargetPayload(): object {
  39. return {
  40. repository: {id: BASE_REPO_ID},
  41. pull_request: {
  42. head: {
  43. sha: PR_HEAD_SHA,
  44. repo: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
  45. },
  46. merge_commit_sha: PR_MERGE_SHA
  47. }
  48. }
  49. }
  50. function sameRepoPullRequestTargetPayload(): object {
  51. return {
  52. repository: {id: BASE_REPO_ID},
  53. pull_request: {
  54. head: {
  55. sha: PR_HEAD_SHA,
  56. repo: {id: BASE_REPO_ID, full_name: BASE_QUALIFIED_REPO}
  57. },
  58. merge_commit_sha: PR_MERGE_SHA
  59. }
  60. }
  61. }
  62. function forkWorkflowRunPayload(): object {
  63. return {
  64. repository: {id: BASE_REPO_ID},
  65. workflow_run: {
  66. event: 'pull_request',
  67. head_commit: {id: WORKFLOW_RUN_HEAD_COMMIT_SHA},
  68. head_repository: {id: FORK_REPO_ID, full_name: FORK_QUALIFIED_REPO}
  69. }
  70. }
  71. }
  72. describe('unsafe-pr-checkout-helper', () => {
  73. beforeAll(() => {
  74. mockContext.repo = {owner: 'some-owner', repo: 'some-repo'}
  75. })
  76. afterEach(() => {
  77. mockContext.eventName = originalEventName
  78. mockContext.payload = originalPayload
  79. })
  80. afterAll(() => {
  81. mockContext.eventName = originalEventName
  82. mockContext.payload = originalPayload
  83. })
  84. it('allows pull_request events untouched', () => {
  85. setContext('pull_request', forkPullRequestTargetPayload())
  86. expect(() =>
  87. assertSafePrCheckout({
  88. qualifiedRepository: 'attacker/fork',
  89. ref: 'refs/pull/1/merge',
  90. commit: '',
  91. allowUnsafePrCheckout: false
  92. })
  93. ).not.toThrow()
  94. })
  95. it('allows pull_request_target default checkout (base branch)', () => {
  96. setContext('pull_request_target', forkPullRequestTargetPayload())
  97. expect(() =>
  98. assertSafePrCheckout({
  99. qualifiedRepository: BASE_QUALIFIED_REPO,
  100. ref: 'refs/heads/main',
  101. commit: SAFE_BASE_SHA,
  102. allowUnsafePrCheckout: false
  103. })
  104. ).not.toThrow()
  105. })
  106. it('allows same-repo pull_request_target checkout of PR head', () => {
  107. setContext('pull_request_target', sameRepoPullRequestTargetPayload())
  108. expect(() =>
  109. assertSafePrCheckout({
  110. qualifiedRepository: BASE_QUALIFIED_REPO,
  111. ref: '',
  112. commit: PR_HEAD_SHA,
  113. allowUnsafePrCheckout: false
  114. })
  115. ).not.toThrow()
  116. })
  117. it('refuses pull_request_target fork PR head SHA checkout', () => {
  118. setContext('pull_request_target', forkPullRequestTargetPayload())
  119. expect(() =>
  120. assertSafePrCheckout({
  121. qualifiedRepository: BASE_QUALIFIED_REPO,
  122. ref: '',
  123. commit: PR_HEAD_SHA,
  124. allowUnsafePrCheckout: false
  125. })
  126. ).toThrow(/Refusing to check out fork pull request code/)
  127. })
  128. it('refuses pull_request_target fork PR merge_commit_sha checkout', () => {
  129. setContext('pull_request_target', forkPullRequestTargetPayload())
  130. expect(() =>
  131. assertSafePrCheckout({
  132. qualifiedRepository: BASE_QUALIFIED_REPO,
  133. ref: '',
  134. commit: PR_MERGE_SHA,
  135. allowUnsafePrCheckout: false
  136. })
  137. ).toThrow(/allow-unsafe-pr-checkout/)
  138. })
  139. it('refuses pull_request_target fork PR ref pattern (head)', () => {
  140. setContext('pull_request_target', forkPullRequestTargetPayload())
  141. expect(() =>
  142. assertSafePrCheckout({
  143. qualifiedRepository: BASE_QUALIFIED_REPO,
  144. ref: 'refs/pull/42/head',
  145. commit: '',
  146. allowUnsafePrCheckout: false
  147. })
  148. ).toThrow()
  149. })
  150. it('refuses pull_request_target fork PR ref pattern (merge)', () => {
  151. setContext('pull_request_target', forkPullRequestTargetPayload())
  152. expect(() =>
  153. assertSafePrCheckout({
  154. qualifiedRepository: BASE_QUALIFIED_REPO,
  155. ref: 'refs/pull/42/merge',
  156. commit: '',
  157. allowUnsafePrCheckout: false
  158. })
  159. ).toThrow()
  160. })
  161. it('refuses pull_request_target when repository points at the fork', () => {
  162. setContext('pull_request_target', forkPullRequestTargetPayload())
  163. expect(() =>
  164. assertSafePrCheckout({
  165. qualifiedRepository: FORK_QUALIFIED_REPO,
  166. ref: 'refs/heads/main',
  167. commit: '',
  168. allowUnsafePrCheckout: false
  169. })
  170. ).toThrow()
  171. })
  172. it('allows pull_request_target checkout of an unrelated third-party repo', () => {
  173. setContext('pull_request_target', forkPullRequestTargetPayload())
  174. expect(() =>
  175. assertSafePrCheckout({
  176. qualifiedRepository: 'some-other/unrelated',
  177. ref: 'refs/heads/main',
  178. commit: '',
  179. allowUnsafePrCheckout: false
  180. })
  181. ).not.toThrow()
  182. })
  183. it('refuses pull_request_target ignoring repository case differences', () => {
  184. setContext('pull_request_target', forkPullRequestTargetPayload())
  185. expect(() =>
  186. assertSafePrCheckout({
  187. qualifiedRepository: FORK_QUALIFIED_REPO.toUpperCase(),
  188. ref: '',
  189. commit: '',
  190. allowUnsafePrCheckout: false
  191. })
  192. ).toThrow()
  193. })
  194. it('refuses pull_request_target ignoring commit SHA case differences', () => {
  195. setContext('pull_request_target', forkPullRequestTargetPayload())
  196. expect(() =>
  197. assertSafePrCheckout({
  198. qualifiedRepository: BASE_QUALIFIED_REPO,
  199. ref: '',
  200. commit: PR_HEAD_SHA.toUpperCase(),
  201. allowUnsafePrCheckout: false
  202. })
  203. ).toThrow()
  204. })
  205. it('allows pull_request_target fork PR checkout when opted in', () => {
  206. setContext('pull_request_target', forkPullRequestTargetPayload())
  207. expect(() =>
  208. assertSafePrCheckout({
  209. qualifiedRepository: BASE_QUALIFIED_REPO,
  210. ref: 'refs/pull/42/merge',
  211. commit: '',
  212. allowUnsafePrCheckout: true
  213. })
  214. ).not.toThrow()
  215. })
  216. it('refuses workflow_run fork PR head_commit.id checkout', () => {
  217. setContext('workflow_run', forkWorkflowRunPayload())
  218. expect(() =>
  219. assertSafePrCheckout({
  220. qualifiedRepository: BASE_QUALIFIED_REPO,
  221. ref: '',
  222. commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
  223. allowUnsafePrCheckout: false
  224. })
  225. ).toThrow()
  226. })
  227. it('refuses workflow_run with pull_request_target underlying event', () => {
  228. const payload = forkWorkflowRunPayload() as {
  229. workflow_run: {event: string}
  230. }
  231. payload.workflow_run.event = 'pull_request_target'
  232. setContext('workflow_run', payload)
  233. expect(() =>
  234. assertSafePrCheckout({
  235. qualifiedRepository: BASE_QUALIFIED_REPO,
  236. ref: '',
  237. commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
  238. allowUnsafePrCheckout: false
  239. })
  240. ).toThrow()
  241. })
  242. it('allows workflow_run same-repo PR (head_repository.id matches base)', () => {
  243. const payload = forkWorkflowRunPayload() as {
  244. workflow_run: {head_repository: {id: number}}
  245. }
  246. payload.workflow_run.head_repository.id = BASE_REPO_ID
  247. setContext('workflow_run', payload)
  248. expect(() =>
  249. assertSafePrCheckout({
  250. qualifiedRepository: BASE_QUALIFIED_REPO,
  251. ref: '',
  252. commit: WORKFLOW_RUN_HEAD_COMMIT_SHA,
  253. allowUnsafePrCheckout: false
  254. })
  255. ).not.toThrow()
  256. })
  257. })