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

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