From bfbbfee4c556ceeafe1b94394d36aa069b3cc8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:27:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?security(ci):=20=EC=99=B8=EB=B6=80=20Action?= =?UTF-8?q?s=20=EB=A5=BC=20=EC=A0=84=EB=B6=80=20commit=20SHA=20=EB=A1=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4=20=E2=80=94=20=EB=B6=80?= =?UTF-8?q?=EB=8F=99=20=EC=B0=B8=EC=A1=B0=200=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 워크플로가 태그·브랜치로 액션을 부르면 그 참조가 가리키는 코드는 사후에 바뀔 수 있다. v7 태그는 옮겨 달 수 있고 @master 는 매 run 이 다른 코드다. 우리 러너에서 저장소 토큰을 들고 도는 코드이므로 리비전이 불변이어야 한다. - 부동 참조 8종을 40자리 commit SHA 로 고정하고 옆에 원래 release tag 를 남긴다. actions/checkout v7.0.1 · actions/setup-java v5.7.0 · actions/upload-artifact v4.6.2/v7.0.1 · actions/github-script v9.0.0 · docker/setup-buildx-action v4.3.0 · docker/build-push-action v7.3.0 · dvdandroid/action-android-lint v1.3 - supply-chain-policy.test.mjs 에 정책 4건 추가. 외부 uses 는 전부 SHA 여야 하고, 고정된 참조는 어느 release 에서 온 것인지 주석으로 남겨야 하며, 이 저장소의 composite action 도 같은 규칙을 따른다. 같은 저장소를 가리키는 ./ 참조는 대상이 아니다(리비전이 이 저장소의 것이다). @master 였던 dvdandroid/action-android-lint 는 v1.3 태그가 master HEAD 와 같은 커밋이라 동작 변화 없이 고정된다. 직접 Gradle command 로 바꾸는 선택지는 모듈별 lint 위반 인라인 코멘트(#279)를 잃으므로 택하지 않았다. 실측: - 고정 후 미고정 외부 uses 0건 (로컬 ./ 참조 제외) - 정책이 실제로 막는지 fixture 로 확인 — checkout 을 @v7 로 되돌리면 테스트가 'mock-cleanup-check.yml: actions/checkout@v7' 로 실패하고, 원복하면 통과한다 - actionlint · repository-quality.sh (17 workflows, 4 shell scripts) 통과 - node --test .github/scripts/*.test.mjs → 85/85 Dependabot 은 이미 github-actions ecosystem 을 주간 추적하고 auto-merge 설정이 없어(supply-chain-policy 가 고정) 고정 SHA 의 새 release 를 PR 로만 올린다. 저장소 설정(SHA pinning required · allowed actions 정책 · secret scanning · push protection)은 이 PR 이 develop 에 들어간 뒤 적용한다. 워크플로가 전부 호환되기 전에 켜면 CI 가 통째로 막힌다. closes #847 --- .github/scripts/supply-chain-policy.test.mjs | 73 ++++++++++++++++++++ .github/workflows/android-managed-device.yml | 6 +- .github/workflows/auto-close-issue.yml | 2 +- .github/workflows/dependency-audit.yml | 6 +- .github/workflows/deployment-decision.yml | 4 +- .github/workflows/firebase-wif-canary.yml | 4 +- .github/workflows/lint.yml | 10 +-- .github/workflows/mock-cleanup-check.yml | 2 +- .github/workflows/release-distribution.yml | 4 +- .github/workflows/repository-quality.yml | 2 +- .github/workflows/screenshot.yml | 10 +-- .github/workflows/unit-test.yml | 8 +-- 12 files changed, 102 insertions(+), 29 deletions(-) diff --git a/.github/scripts/supply-chain-policy.test.mjs b/.github/scripts/supply-chain-policy.test.mjs index 50b55b16a2..238899c785 100644 --- a/.github/scripts/supply-chain-policy.test.mjs +++ b/.github/scripts/supply-chain-policy.test.mjs @@ -12,6 +12,20 @@ async function workflows() { ); } +// 같은 저장소의 로컬 액션·reusable workflow 는 uses 대상이 이 저장소의 리비전이라 +// 고정 대상이 아니다. 그 밖은 전부 40자리 commit SHA 여야 한다. +function isLocalReference(reference) { + return reference.startsWith("./"); +} + +function isPinnedToCommit(reference) { + return /@[0-9a-f]{40}$/.test(reference); +} + +function actionReferences(source) { + return [...source.matchAll(/^\s*(?:-\s+)?uses:\s*(\S+)/gm)].map((match) => match[1]); +} + function requiresGradleSetup(source) { return source.includes('./gradlew') || /uses:\s*gradle\/actions\/setup-gradle@/.test(source); } @@ -28,6 +42,65 @@ test('recognizes direct and script-delegated Gradle workflows', () => { ); }); +test('recognizes floating action references that the pinning policy must reject', () => { + // 정책이 실제로 무엇을 막는지 fixture 로 고정한다 — 태그·브랜치·짧은 SHA 는 전부 + // 사후에 다른 코드로 바뀔 수 있다. + assert.equal(isPinnedToCommit('actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'), true); + assert.equal(isPinnedToCommit('actions/checkout@v7'), false); + assert.equal(isPinnedToCommit('ScaCap/action-ktlint@master'), false); + assert.equal(isPinnedToCommit('actions/checkout@3d3c42e'), false); + assert.equal(isLocalReference('./.github/actions/setup-ci-config'), true); + assert.equal(isLocalReference('actions/checkout@v7'), false); +}); + +test('every third-party action is pinned to an immutable commit SHA', async () => { + const unpinned = []; + for (const [name, source] of await workflows()) { + for (const reference of actionReferences(source)) { + if (isLocalReference(reference) || isPinnedToCommit(reference)) { + continue; + } + unpinned.push(`${name}: ${reference}`); + } + } + + assert.deepEqual(unpinned, []); +}); + +test('every pinned action records the release it was pinned from', async () => { + // SHA 만 있으면 무엇을 쓰고 있는지 사람이 읽을 수 없고 Dependabot 갱신도 대조가 안 된다. + const undocumented = []; + for (const [name, source] of await workflows()) { + for (const line of source.split('\n')) { + const match = /^\s*(?:-\s+)?uses:\s*(\S+@[0-9a-f]{40})(.*)$/.exec(line); + if (match && !/^\s*#\s*v?\d/.test(match[2])) { + undocumented.push(`${name}: ${match[1]}`); + } + } + } + + assert.deepEqual(undocumented, []); +}); + +test('composite actions in this repository pin their own dependencies', async () => { + const directory = new URL('../actions/', import.meta.url); + const names = await readdir(directory); + for (const name of names) { + let source; + try { + source = await readFile(new URL(`${name}/action.yml`, directory), 'utf8'); + } catch { + continue; + } + for (const reference of actionReferences(source)) { + assert.ok( + isLocalReference(reference) || isPinnedToCommit(reference), + `${name} uses an unpinned action: ${reference}`, + ); + } + } +}); + test('every workflow that runs the Gradle wrapper uses the pinned setup-gradle action', async () => { const gradleWorkflows = (await workflows()).filter(([, source]) => requiresGradleSetup(source)); assert.ok(gradleWorkflows.length > 0); diff --git a/.github/workflows/android-managed-device.yml b/.github/workflows/android-managed-device.yml index b37e93c7ac..a0211ec97f 100644 --- a/.github/workflows/android-managed-device.yml +++ b/.github/workflows/android-managed-device.yml @@ -21,10 +21,10 @@ jobs: timeout-minutes: 45 steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: "21" @@ -54,7 +54,7 @@ jobs: - name: Upload androidTest reports and failure evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: android-managed-device-results-${{ github.run_attempt }} if-no-files-found: warn diff --git a/.github/workflows/auto-close-issue.yml b/.github/workflows/auto-close-issue.yml index c4757f728e..5fbc8ea33a 100644 --- a/.github/workflows/auto-close-issue.yml +++ b/.github/workflows/auto-close-issue.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close issues referenced by close keywords - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml index ec30568ed5..d984415e66 100644 --- a/.github/workflows/dependency-audit.yml +++ b/.github/workflows/dependency-audit.yml @@ -29,12 +29,12 @@ jobs: REPORT_DIR: build/reports/dependency-audit steps: - name: Clone default branch - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' @@ -124,7 +124,7 @@ jobs: - name: Upload machine-readable audit evidence if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: android-dependency-audit path: ${{ env.REPORT_DIR }} diff --git a/.github/workflows/deployment-decision.yml b/.github/workflows/deployment-decision.yml index 126bbacd8f..f4225c30f8 100644 --- a/.github/workflows/deployment-decision.yml +++ b/.github/workflows/deployment-decision.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone merged revision - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.merge_commit_sha || 'develop' }} fetch-depth: 0 @@ -50,7 +50,7 @@ jobs: "$DEPLOYMENT_DECISION_PATH" - name: Publish decision on the merged PR - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: DEPLOYMENT_DECISION_PATH: ${{ runner.temp }}/afternote-deployment-decision/decision.json TARGET_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pull_request_number }} diff --git a/.github/workflows/firebase-wif-canary.yml b/.github/workflows/firebase-wif-canary.yml index 1768b59854..a951ad8bc8 100644 --- a/.github/workflows/firebase-wif-canary.yml +++ b/.github/workflows/firebase-wif-canary.yml @@ -66,7 +66,7 @@ jobs: steps: # google-github-actions/auth가 ADC 파일을 안전하게 만들려면 checkout이 먼저 실행돼야 한다. - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare Firebase release notes env: @@ -81,7 +81,7 @@ jobs: cat "$RELEASE_NOTES_FILE" >> "$GITHUB_STEP_SUMMARY" - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: '21' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ba40e3f439..1fb60a0ed7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' @@ -58,10 +58,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' @@ -102,7 +102,7 @@ jobs: - name: Report Android Lint (Reviewdog) if: always() - uses: dvdandroid/action-android-lint@master + uses: dvdandroid/action-android-lint@21c9fa6500c10caa12d8c581d9e280925f39510d # v1.3 with: github_token: ${{ secrets.GITHUB_TOKEN }} reporter: github-pr-review diff --git a/.github/workflows/mock-cleanup-check.yml b/.github/workflows/mock-cleanup-check.yml index e3ab394249..f805c700d9 100644 --- a/.github/workflows/mock-cleanup-check.yml +++ b/.github/workflows/mock-cleanup-check.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for MOCK_CLEANUP markers run: | diff --git a/.github/workflows/release-distribution.yml b/.github/workflows/release-distribution.yml index 463f602126..8edf7dac8f 100644 --- a/.github/workflows/release-distribution.yml +++ b/.github/workflows/release-distribution.yml @@ -53,7 +53,7 @@ jobs: esac - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Load release metadata from main PR if: github.event_name == 'push' @@ -81,7 +81,7 @@ jobs: cat "$RELEASE_NOTES_FILE" >> "$GITHUB_STEP_SUMMARY" - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' diff --git a/.github/workflows/repository-quality.yml b/.github/workflows/repository-quality.yml index 527fd4fa3f..bc5689b92a 100644 --- a/.github/workflows/repository-quality.yml +++ b/.github/workflows/repository-quality.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 3 steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install actionlint env: diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml index d8ff1506f6..c5282623d3 100644 --- a/.github/workflows/screenshot.yml +++ b/.github/workflows/screenshot.yml @@ -18,16 +18,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # docker buildx + gha cache 로 Dockerfile.screenshot 빌드. # 동일 이미지에서 baseline 생성·검증을 수행해 anti-aliasing/font/scale 등 host 환경 차이 root fix. # Dockerfile 변경 없으면 cache 재사용 — 분당 단위 추가 비용. - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build screenshot image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: Dockerfile.screenshot @@ -74,7 +74,7 @@ jobs: # 실패 시 diff/rendered 이미지를 artifact 로 업로드 — 본인이 다운로드해 시각 차이 확인 가능 - name: Upload screenshot diff on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: screenshot-test-results path: | @@ -89,7 +89,7 @@ jobs: # 같은 PR 의 재실행에서는 기존 코멘트를 update (marker 로 중복 누적 방지). - name: Comment artifact URL on PR (failure) if: failure() && github.event_name == 'pull_request' - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const marker = ''; diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 1118f525d1..5cd29d944e 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Fetch pull request history for changed-module coverage env: @@ -42,7 +42,7 @@ jobs: run: node --test .github/scripts/*.test.mjs - name: Set up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' @@ -62,7 +62,7 @@ jobs: - name: Upload Kover coverage reports id: kover-artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: kover-jvm-coverage path: build/reports/kover/ @@ -79,7 +79,7 @@ jobs: # 실패한 테스트의 HTML/XML 리포트를 PR artifact 로 보존 — CI 로그에서 stack trace 추적이 빠름. - name: Upload test reports on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unit-test-reports path: | From 20ae4765ff0d1d42c91047e2d34bcc6f83211cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 19:57:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?security(ci):=20conflict-label=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9D=98=20checkout=20=EB=8F=84=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1036 이 이 PR 보다 먼저 develop 에 들어오며 미고정 actions/checkout@v7 을 남겼다. PR #1036 에 미리 알려 두었고 순서가 그렇게 났으므로 이쪽에서 맞춘다. 다른 워크플로와 같은 리비전(v7.0.1)이다. --- .github/workflows/conflict-label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/conflict-label.yml b/.github/workflows/conflict-label.yml index 710b5802e7..92913d669c 100644 --- a/.github/workflows/conflict-label.yml +++ b/.github/workflows/conflict-label.yml @@ -41,7 +41,7 @@ jobs: timeout-minutes: 10 steps: - name: Clone repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false