From 09492d34b42f628dfdb5ca02ca66e8f7f62f9a8c Mon Sep 17 00:00:00 2001 From: Sinabina Date: Mon, 20 Jul 2026 16:22:25 -0700 Subject: [PATCH] chore(security): harden GitHub workflows --- .github/workflows/actionlint.yml | 4 +++ .github/workflows/ci-image.yml | 4 +++ .github/workflows/dependency-review.yml | 21 +++++++++++ .github/workflows/osv-scanner.yml | 26 ++++++++++++++ .github/workflows/quality-gate.yml | 4 +++ .github/workflows/scorecard.yml | 46 +++++++++++++++++++++++++ .github/workflows/skill-docs.yml | 4 +++ test/release-hardening.test.ts | 19 ++++++++++ 8 files changed, 128 insertions(+) create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/osv-scanner.yml create mode 100644 .github/workflows/scorecard.yml diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 59c615933..39eb7100c 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -4,6 +4,10 @@ on: [push, pull_request] permissions: contents: read +concurrency: + group: actionlint-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: actionlint: runs-on: ubicloud-standard-8 diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml index 3f8debe02..ad638c2c9 100644 --- a/.github/workflows/ci-image.yml +++ b/.github/workflows/ci-image.yml @@ -16,6 +16,10 @@ on: # Manual trigger workflow_dispatch: +concurrency: + group: ci-image + cancel-in-progress: false + jobs: build: runs-on: ubicloud-standard-8 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..a5f20973b --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,21 @@ +name: Dependency Review + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependency-review: + runs-on: ubuntu-24.04 + steps: + - name: Review dependency changes + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: high diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml new file mode 100644 index 000000000..22b8ae6eb --- /dev/null +++ b/.github/workflows/osv-scanner.yml @@ -0,0 +1,26 @@ +name: OSV Scanner + +on: + schedule: + - cron: '23 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: osv-scanner + cancel-in-progress: true + +jobs: + scan: + permissions: + actions: read + contents: read + security-events: write + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@3adb4b14a2b0623876d18d863a498b785fb3752d # v2.3.8 + with: + scan-args: |- + --include-git-root + --recursive + ./ diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 932a82c14..62554cca2 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: quality-gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-24.04 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..a35bc680e --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,46 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '41 7 * * 1' + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +jobs: + analysis: + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Run Scorecard analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: false + + - name: Upload Scorecard results to code scanning + uses: github/codeql-action/upload-sarif@85b88275909735f5bc23196090e03d2eb148b3de # v3.32.4 + with: + sarif_file: results.sarif + + - name: Upload Scorecard results artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml index 2757f5e3a..8d410d610 100644 --- a/.github/workflows/skill-docs.yml +++ b/.github/workflows/skill-docs.yml @@ -4,6 +4,10 @@ on: [push, pull_request] permissions: contents: read +concurrency: + group: skill-docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: check-freshness: runs-on: ubicloud-standard-8 diff --git a/test/release-hardening.test.ts b/test/release-hardening.test.ts index 529683957..7db3fad03 100644 --- a/test/release-hardening.test.ts +++ b/test/release-hardening.test.ts @@ -12,6 +12,7 @@ describe("release and CI hardening", () => { for (const name of fs.readdirSync(workflowRoot).filter((entry) => entry.endsWith(".yml"))) { const source = fs.readFileSync(path.join(workflowRoot, name), "utf8"); expect(source, `${name} must declare top-level permissions`).toMatch(/^permissions:\s*$/m); + expect(source, `${name} must declare workflow concurrency`).toMatch(/^concurrency:\s*$/m); for (const match of source.matchAll(/\buses:\s*[^\s@]+@([^\s#]+)/g)) { expect(match[1], `${name} contains a mutable action ref`).toMatch(/^[a-f0-9]{40}$/); } @@ -24,6 +25,24 @@ describe("release and CI hardening", () => { expect(source.match(new RegExp(guard.replaceAll(".", "\\."), "g"))?.length).toBeGreaterThanOrEqual(3); }); + test("public dependency and repository security workflows stay enabled and least-privileged", () => { + const dependencyReview = read(".github/workflows/dependency-review.yml"); + expect(dependencyReview).toContain("actions/dependency-review-action@"); + expect(dependencyReview).toContain("fail-on-severity: high"); + expect(dependencyReview).not.toContain("pull_request_target:"); + + const osv = read(".github/workflows/osv-scanner.yml"); + expect(osv).toContain("google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@"); + expect(osv).toContain("security-events: write"); + expect(osv).toContain("schedule:"); + + const scorecard = read(".github/workflows/scorecard.yml"); + expect(scorecard).toContain("ossf/scorecard-action@"); + expect(scorecard).toContain("github/codeql-action/upload-sarif@"); + expect(scorecard).toContain("publish_results: false"); + expect(scorecard).not.toContain("id-token: write"); + }); + test("npm package is an explicit small runtime-control surface", () => { const pkg = JSON.parse(read("package.json")); expect(pkg.version).toBe(read("VERSION").trim());