├── .chainguard └── source.yaml ├── .github ├── chainguard │ └── verify-prod.sts.yaml ├── dependabot.yaml ├── testdata │ └── backend_override.tf └── workflows │ ├── actionlint.yaml │ ├── boilerplate.yaml │ ├── deploy.yaml │ ├── donotsubmit.yaml │ ├── go-test.yaml │ ├── style.yaml │ └── terraform.yaml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── cmd ├── app │ └── main.go ├── negative-prober │ └── main.go ├── prober │ └── main.go └── webhook │ └── main.go ├── go.mod ├── go.sum ├── hack └── boilerplate │ ├── boilerplate.go.txt │ ├── boilerplate.sh.txt │ └── boilerplate.yaml.txt ├── iac ├── backend.tf ├── bootstrap │ ├── backend.tf │ ├── main.tf │ ├── output.tf │ ├── terraform.tfvars │ └── variables.tf ├── broker.tf ├── gclb.tf ├── github_verify.tf ├── main.tf ├── prober.tf ├── sts_exchange.schema.json ├── terraform.tfvars └── variables.tf ├── modules └── app │ ├── main.tf │ ├── outputs.tf │ ├── variables.tf │ └── webhook.tf └── pkg ├── envconfig ├── envconfig.go └── envconfig_test.go ├── gcpkms ├── gcpkms.go └── gcpkms_test.go ├── ghtransport ├── ghtransport.go └── ghtransport_test.go ├── maxsize ├── maxsize.go └── maxsize_test.go ├── octosts ├── event.go ├── octosts.go ├── octosts_test.go ├── revoke.go ├── testdata │ └── org │ │ ├── .github │ │ ├── foo.sts.yaml │ │ └── org-delegation.sts.yaml │ │ └── repo │ │ └── foo.sts.yaml ├── trust_policy.go └── trust_policy_test.go ├── prober └── prober.go ├── provider └── provider.go └── webhook ├── testdata ├── api │ └── v3 │ │ └── repos │ │ └── foo │ │ └── bar │ │ ├── compare │ │ ├── 1234...5678 │ │ └── 9876...4321 │ │ └── contents │ │ └── .github │ │ └── chainguard │ │ ├── test.sts.yaml │ │ └── test2.sts.yaml └── app │ └── installations │ └── 1111 │ └── access_tokens ├── webhook.go └── webhook_test.go /.chainguard/source.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | spec: 5 | authorities: 6 | - keyless: 7 | url: https://fulcio.sigstore.dev 8 | identities: 9 | - subjectRegExp: .+@chainguard.dev$ 10 | issuer: https://accounts.google.com 11 | ctlog: 12 | url: https://rekor.sigstore.dev 13 | - key: 14 | # Allow commits signed by Github (merge commits) 15 | kms: https://github.com/web-flow.gpg 16 | -------------------------------------------------------------------------------- /.github/chainguard/verify-prod.sts.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | issuer: https://token.actions.githubusercontent.com 5 | subject: repo:octo-sts/app:pull_request 6 | claim_pattern: 7 | workflow_ref: octo-sts/app/.github/workflows/verify-prod.yaml@.* 8 | 9 | permissions: 10 | pull_requests: write 11 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | groups: 11 | all: 12 | update-types: 13 | - "minor" 14 | - "patch" 15 | 16 | - package-ecosystem: gomod 17 | directory: "./" 18 | schedule: 19 | interval: "daily" 20 | groups: 21 | all: 22 | update-types: 23 | - "patch" 24 | 25 | - package-ecosystem: terraform 26 | directories: 27 | - "/iac" 28 | - "/iac/bootstrap" 29 | - "/modules/app" 30 | schedule: 31 | interval: "daily" 32 | groups: 33 | all: 34 | update-types: 35 | - "patch" 36 | -------------------------------------------------------------------------------- /.github/testdata/backend_override.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "local" { 3 | path = "./.local-state" 4 | } 5 | required_providers { 6 | ko = { source = "ko-build/ko" } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: Action Lint 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | 15 | action-lint: 16 | name: Action lint 17 | runs-on: ubuntu-latest 18 | 19 | permissions: 20 | contents: read 21 | 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Find yamls 29 | id: get_yamls 30 | run: | 31 | yamls="$(find .github/workflows -name "*.y*ml" | grep -v dependabot. | xargs echo)" 32 | echo "files=${yamls}" >> "$GITHUB_OUTPUT" 33 | 34 | - name: Action lint 35 | uses: reviewdog/action-actionlint@a5524e1c19e62881d79c1f1b9b6f09f16356e281 # v1.65.2 36 | with: 37 | actionlint_flags: ${{ steps.get_yamls.outputs.files }} 38 | -------------------------------------------------------------------------------- /.github/workflows/boilerplate.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: Boilerplate 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | 15 | check: 16 | permissions: 17 | contents: read 18 | 19 | name: Boilerplate Check 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: false # Keep running if one leg fails. 23 | matrix: 24 | extension: 25 | - go 26 | - sh 27 | - yaml 28 | 29 | # Map between extension and human-readable name. 30 | include: 31 | - extension: go 32 | language: Go 33 | - extension: sh 34 | language: Bash 35 | - extension: yaml 36 | language: YAML 37 | 38 | steps: 39 | - name: Check out code 40 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | with: 42 | persist-credentials: false 43 | 44 | - uses: chainguard-dev/actions/boilerplate@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1 45 | with: 46 | extension: ${{ matrix.extension }} 47 | language: ${{ matrix.language }} 48 | exclude: pkg/webhook/testdata 49 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: Deploy to Cloud Run 5 | 6 | on: 7 | push: 8 | branches: 9 | - "main" 10 | workflow_dispatch: 11 | 12 | concurrency: deploy 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | deploy: 18 | runs-on: ubuntu-latest 19 | 20 | if: github.repository == 'octo-sts/app' 21 | 22 | permissions: 23 | contents: read # clone the repository contents 24 | id-token: write # federates with GCP 25 | 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | 31 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 32 | with: 33 | go-version-file: './go.mod' 34 | check-latest: true 35 | 36 | - uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10 37 | id: auth 38 | with: 39 | token_format: 'access_token' 40 | project_id: 'octo-sts' 41 | workload_identity_provider: 'projects/96355665038/locations/global/workloadIdentityPools/github-pool/providers/github-provider' 42 | service_account: 'github-identity@octo-sts.iam.gserviceaccount.com' 43 | 44 | - uses: 'docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772' # v2 45 | with: 46 | username: 'oauth2accesstoken' 47 | password: '${{ steps.auth.outputs.access_token }}' 48 | registry: 'gcr.io' 49 | 50 | # Attempt to deploy the terraform configuration 51 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v2.0.0 52 | with: 53 | terraform_version: 1.9 54 | 55 | - working-directory: ./iac 56 | run: | 57 | terraform init 58 | 59 | terraform plan 60 | 61 | terraform apply -auto-approve 62 | 63 | - uses: rtCamp/action-slack-notify@e31e87e03dd19038e411e38ae27cbad084a90661 # v2.3.3 64 | if: ${{ failure() }} 65 | env: 66 | SLACK_ICON: http://github.com/chainguard-dev.png?size=48 67 | SLACK_USERNAME: guardian 68 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 69 | SLACK_CHANNEL: 'octo-sts-alerts' # Use a channel 70 | SLACK_COLOR: '#8E1600' 71 | MSG_MINIMAL: 'true' 72 | SLACK_TITLE: Deploying OctoSTS to Cloud Run failed 73 | SLACK_MESSAGE: | 74 | For detailed logs: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} 75 | -------------------------------------------------------------------------------- /.github/workflows/donotsubmit.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: Do Not Submit 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | permissions: {} 12 | 13 | jobs: 14 | 15 | donotsubmit: 16 | name: Do Not Submit 17 | runs-on: ubuntu-latest 18 | 19 | permissions: 20 | contents: read 21 | 22 | steps: 23 | - name: Check out code 24 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 25 | with: 26 | persist-credentials: false 27 | 28 | - name: Do Not Submit 29 | uses: chainguard-dev/actions/donotsubmit@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1 30 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: go-build-test 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - 'main' 10 | push: 11 | branches: 12 | - 'main' 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | 18 | go-build-test: 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | contents: read 23 | 24 | steps: 25 | - name: Check out code onto GOPATH 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | persist-credentials: false 29 | 30 | - name: Set up Go 31 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 32 | with: 33 | go-version-file: './go.mod' 34 | check-latest: true 35 | 36 | - name: build 37 | run: | 38 | go build -o octo-sts ./cmd/app 39 | 40 | - name: test 41 | run: | 42 | # Exclude running unit tests against third_party repos. 43 | go test -v -race ./... 44 | -------------------------------------------------------------------------------- /.github/workflows/style.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: Code Style 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - 'main' 10 | push: 11 | branches: 12 | - 'main' 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | 18 | gofmt: 19 | name: check gofmt 20 | runs-on: ubuntu-latest 21 | 22 | permissions: 23 | contents: read 24 | 25 | steps: 26 | - name: Check out code 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Set up Go 32 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 33 | with: 34 | go-version-file: './go.mod' 35 | check-latest: true 36 | 37 | - uses: chainguard-dev/actions/gofmt@main 38 | with: 39 | args: -s 40 | 41 | goimports: 42 | name: check goimports 43 | runs-on: ubuntu-latest 44 | 45 | permissions: 46 | contents: read 47 | 48 | steps: 49 | - name: Check out code 50 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 51 | with: 52 | persist-credentials: false 53 | 54 | - name: Set up Go 55 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 56 | with: 57 | go-version-file: './go.mod' 58 | check-latest: true 59 | 60 | - uses: chainguard-dev/actions/goimports@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1 61 | 62 | golangci-lint: 63 | name: golangci-lint 64 | runs-on: ubuntu-latest 65 | 66 | permissions: 67 | contents: read 68 | 69 | steps: 70 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 71 | with: 72 | persist-credentials: false 73 | 74 | - name: Set up Go 75 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 76 | with: 77 | go-version-file: './go.mod' 78 | check-latest: true 79 | 80 | - name: golangci-lint 81 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 82 | with: 83 | version: v2.1 84 | 85 | lint: 86 | name: Lint 87 | runs-on: ubuntu-latest 88 | 89 | permissions: 90 | contents: read 91 | 92 | steps: 93 | - name: Check out code 94 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 95 | with: 96 | persist-credentials: false 97 | 98 | - name: Set up Go 99 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 100 | with: 101 | go-version-file: './go.mod' 102 | check-latest: true 103 | 104 | - uses: chainguard-dev/actions/trailing-space@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1 105 | if: ${{ always() }} 106 | 107 | - uses: chainguard-dev/actions/eof-newline@ce51233d303aed2394a9976e7f5642fd2158f693 # v1.1.1 108 | if: ${{ always() }} 109 | 110 | - uses: reviewdog/action-tflint@92ecd5bdf3d31ada4ac26a702666986f67385fda # master 111 | if: ${{ always() }} 112 | with: 113 | github_token: ${{ secrets.github_token }} 114 | fail_level: warning 115 | 116 | - uses: reviewdog/action-misspell@9daa94af4357dddb6fd3775de806bc0a8e98d3e4 # v1.26.3 117 | if: ${{ always() }} 118 | with: 119 | github_token: ${{ secrets.github_token }} 120 | fail_level: warning 121 | locale: "US" 122 | exclude: | 123 | **/go.sum 124 | **/third_party/** 125 | ./*.yml 126 | 127 | - uses: get-woke/woke-action-reviewdog@d71fd0115146a01c3181439ce714e21a69d75e31 # v0 128 | if: ${{ always() }} 129 | with: 130 | github-token: ${{ secrets.github_token }} 131 | reporter: github-pr-check 132 | level: error 133 | fail-on-error: true 134 | -------------------------------------------------------------------------------- /.github/workflows/terraform.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | 4 | name: terraform-lint-validate 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | permissions: {} 15 | 16 | jobs: 17 | terraform-lint-validate: 18 | runs-on: ubuntu-latest 19 | 20 | permissions: 21 | contents: read 22 | 23 | strategy: 24 | matrix: 25 | terraform-dir: 26 | - ./iac/bootstrap 27 | - ./iac 28 | 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 31 | with: 32 | persist-credentials: false 33 | 34 | - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 35 | with: 36 | terraform_version: 1.9 37 | 38 | - run: terraform fmt -check 39 | 40 | - run: cp "$GITHUB_WORKSPACE/.github/testdata/backend_override.tf" "$GITHUB_WORKSPACE/${{ matrix.terraform-dir }}" 41 | - working-directory: ${{ matrix.terraform-dir }} 42 | run: | 43 | terraform init 44 | terraform validate 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Terraform generated 24 | .terraform/ 25 | terraform.tfstate 26 | terraform.tfstate.backup 27 | terraform.tfstate.*.backup 28 | .terraform.tfstate.lock.* 29 | .terraform.lock.hcl 30 | /octo-sts 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | linters: 5 | enable: 6 | - asciicheck 7 | - errorlint 8 | - gocritic 9 | - gosec 10 | - importas 11 | - misspell 12 | - prealloc 13 | - revive 14 | - staticcheck 15 | - tparallel 16 | - unconvert 17 | - unparam 18 | - whitespace 19 | settings: 20 | revive: 21 | rules: 22 | - name: dot-imports 23 | disabled: true 24 | exclusions: 25 | generated: lax 26 | presets: 27 | - comments 28 | - common-false-positives 29 | - legacy 30 | - std-error-handling 31 | rules: 32 | - linters: 33 | - errcheck 34 | - gosec 35 | path: _test\.go 36 | paths: 37 | - third_party$ 38 | - builtin$ 39 | - examples$ 40 | issues: 41 | max-issues-per-linter: 0 42 | max-same-issues: 0 43 | uniq-by-line: false 44 | formatters: 45 | enable: 46 | - gofmt 47 | - goimports 48 | exclusions: 49 | generated: lax 50 | paths: 51 | - third_party$ 52 | - builtin$ 53 | - examples$ 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `octo-sts`: an STS for GitHub 2 | 3 | This repository holds a GitHub App called `octo-sts` that acts like a Security 4 | Token Service (STS) for the GitHub API. Using this App, workloads running 5 | essentially anywhere that can produce OIDC tokens can federate with this App's 6 | STS API in order to produce short-lived tokens for interacting with GitHub. 7 | 8 | **_The ultimate goal of this App is to wholly eliminate the need for GitHub 9 | Personal Access Tokens (aka PATs)._** 10 | 11 | The original [blog post](https://www.chainguard.dev/unchained/the-end-of-github-pats-you-cant-leak-what-you-dont-have). 12 | 13 | ## Setting up workload trust 14 | 15 | For the App to produce credentials that work with resources in your organization 16 | it must be installed into the organization and have access to any repositories 17 | that you will want workloads to be able to interact with. Unfortunately due to 18 | limitations with GitHub Apps, the App must ask for a superset of the permissions 19 | needed for federation, so the full set of permissions the App requests will be 20 | large, but with one exception (`contents: read` reading policy files) the App 21 | only creates tokens with these scopes based on the "trust policies" you have 22 | configured. 23 | 24 | ### The Trust Policy 25 | 26 | Trust policies are checked into `.github/chainguard/{name}.sts.yaml`, and 27 | consist of a few key parts: 28 | 29 | 1. The claim matching criteria for federation, 30 | 2. The permissions to grant the identity, and 31 | 3. (for Org-level policies) The list of repositories to grant access. 32 | 33 | Here is a simple example that allows the GitHub actions workflows in 34 | `chainguard-dev/foo` running on the `main` branch to read the repo contents and 35 | interact with issues: 36 | 37 | ```yaml 38 | issuer: https://token.actions.githubusercontent.com 39 | subject: repo:chainguard-dev/foo:ref:refs/heads/main 40 | 41 | permissions: 42 | contents: read 43 | issues: write 44 | ``` 45 | 46 | The Trust Policy can also match the issuer, subject, and even custom claims with 47 | regular expressions. For example: 48 | 49 | ```yaml 50 | issuer: https://accounts.google.com 51 | subject_pattern: '[0-9]+' 52 | claim_pattern: 53 | email: '.*@chainguard.dev' 54 | 55 | permissions: 56 | contents: read 57 | ``` 58 | 59 | This policy will allow OIDC tokens from Google accounts of folks with a 60 | Chainguard email address to federate and read the repo contents. 61 | 62 | ### Federating a token 63 | 64 | The GitHub App implements the Chainguard `SecurityTokenService` GRPC service 65 | definition [here](https://github.com/chainguard-dev/sdk/blob/main/proto/platform/oidc/v1/oidc.platform.proto#L13-L28). 66 | 67 | If a `${TOKEN}` suitable for federation is sent like so: 68 | 69 | ``` 70 | curl -H "Authorization: Bearer ${TOKEN}" \ 71 | "https://octo-sts.dev/sts/exchange?scope=${REPO}&identity=${NAME}" 72 | ``` 73 | 74 | The App will attempt to load the trust policy from 75 | `.github/chainguard/${NAME}.sts.yaml` from `${REPO}` and if the provided `${TOKEN}` 76 | satisfies those rules, it will return a token with the permissions in the trust 77 | policy. 78 | 79 | ### Release cadence 80 | 81 | Our release cadence at this moment is set to when is needed, meaning if we have a bug fix or a new feature 82 | we will might make a new release. 83 | 84 | ### Best Practices 85 | 86 | To ensure secure and effective use of octo-sts, follow these recommended practices: 87 | 88 | #### Repository Security 89 | 90 | - **Enable branch protection**: Configure branch protection rules on your main/default branch to prevent direct commits and require pull request reviews before merging changes. This prevents OctoSTS clients from bypassing security controls by directly merging changes to main without review. 91 | 92 | - **Restrict who can approve pull requests**: Limit pull request approval permissions to trusted team members or repository administrators. 93 | 94 | ### Trust Policy Management 95 | 96 | - **Principle of least privilege**: Grant only the minimum permissions necessary for your workloads to function. Start with read-only permissions and add write permissions only when required. 97 | 98 | - **Scope policies narrowly**: Create specific trust policies for different workloads rather than using broad, catch-all policies. 99 | 100 | - **Regular policy reviews**: Periodically review and audit your trust policies (`.github/chainguard/*.sts.yaml`) to ensure they still align with your security requirements. 101 | 102 | - **Use specific subject matching**: Prefer exact subject matches over broad patterns when possible. For example, use `repo:org/repo:ref:refs/heads/main` instead of `repo:org/repo:.*`. 103 | 104 | #### Token Management 105 | 106 | - **Rotate regularly**: While octo-sts tokens are short-lived, ensure your OIDC token sources (like GitHub Actions) are properly configured and rotated according to best practices. 107 | 108 | - **Secure OIDC token handling**: Ensure your workloads properly secure and handle OIDC tokens before exchanging them with octo-sts. 109 | 110 | ### Permission updates 111 | 112 | Sometimes we need to add or remove a GitHub Permission in order to add/remove permissions that will be include in the 113 | octo-sts token for the users. Due to the nature of GitHub Apps, OctoSTS must request all permissions it might need to use, even if you don't want to use them for your particular installation or policy. 114 | 115 | To avoid disruptions for the users, making them to review and approve the changes in the installed GitHub App we 116 | will apply permissions changes for the `octo-sts app` quarterly at any day during the quarter. 117 | 118 | An issue will be created to explain what permissions is being added or removed. 119 | 120 | Special cases will be discussed in a GitHub issue in https://github.com/octo-sts/app/issues and we might apply more than 121 | one change during the quarter. 122 | 123 | ### Octo-STS GitHub Permissions 124 | 125 | The following permissions are the currently enabled in octo-Sts and will be available when installing the GitHub APP 126 | 127 | #### Repository Permissions 128 | 129 | - **Actions**: `Read/Write` 130 | - **Administration** : `Read-only` 131 | - **Attestations**: `No Access` 132 | - **Checks**: `Read/Write` 133 | - **Code Scanning Alerts**: `Read/Write` 134 | - **Codespaces**: `No Access` 135 | - **Codespaces lifecycle admin**: `No Access` 136 | - **Codespaces metadata**: `No Access` 137 | - **Codespaces secrets**: `No Access` 138 | - **Commit statuses**: `Read/Write` 139 | - **Contents**: `Read/Write` 140 | - **Custom properties**: `No Access` 141 | - **Dependabot alerts**: `No Access` 142 | - **Dependabot secrets**: `No Access` 143 | - **Deployments**: `Read/Write` 144 | - **Discussions**: `Read/Write` 145 | - **Environments**: `Read/Write` 146 | - **Issues**: `Read/Write` 147 | - **Merge queues**: `No Access` 148 | - **Metadata (Mandatory)**: `Read-only` 149 | - **Packages**: `Read/Write` 150 | - **Pages**: `No Access` 151 | - **Projects**: `Read/Write` 152 | - **Pull requests**: `Read/Write` 153 | - **Repository security advisories**: `No Access` 154 | - **Secret scanning alerts**: `No Access` 155 | - **Secrets**: `No Access` 156 | - **Single file**: `No Access` 157 | - **Variables**: `No Access` 158 | - **Webhooks**: `No Access` 159 | - **Workflows**: `Read/Write` 160 | 161 | #### Organization Permissions 162 | 163 | - **API Insights**: `No Access` 164 | - **Administration**: `Read-only` 165 | - **Blocking users**: `No Access` 166 | - **Custom organizations roles**: `No Access` 167 | - **Custom properties**: `No Access` 168 | - **Custom repository roles**: `No Access` 169 | - **Events**: `Read-only` 170 | - **GitHub Copilot Business**: `No Access` 171 | - **Knowledge bases**: `No Access` 172 | - **Members**: `Read/Write` 173 | - **Organization codespaces**: `No Access` 174 | - **Organization codespaces secrets**: `No Access` 175 | - **Organization codespaces settings**: `No Access` 176 | - **Organization dependabot secrets**: `No Access` 177 | - **Personal access token requests**: `No Access` 178 | - **Personal access tokens**: `No Access` 179 | - **Plan**: `No Access` 180 | - **Projects**: `Read/Write` 181 | - **Secrets**: `No Access` 182 | - **Self-hosted runners**: `No Access` 183 | - **Team discussions**: `No Access` 184 | - **Variables**: `No Access` 185 | - **Webhooks**: `No Access` 186 | 187 | #### Account Permissions: 188 | 189 | - **Block another user**: `No Access` 190 | - **Codespaces user secrets**: `No Access` 191 | - **Copilot Chat**: `No Access` 192 | - **Email addresses**: `No Access` 193 | - **Events**: `No Access` 194 | - **Followers**: `No Access` 195 | - **GPG keys**: `No Access` 196 | - **Gists**: `No Access` 197 | - **Git SSH keys**: `No Access` 198 | - **Interaction limits**: `No Access` 199 | - **Plan**: `No Access` 200 | - **Profile**: `No Access` 201 | - **SSH signing keys**: `No Access` 202 | - **Starring**: `No Access` 203 | - **Watching**: `No Access` 204 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "log" 9 | "log/slog" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | 14 | "chainguard.dev/go-grpc-kit/pkg/duplex" 15 | pboidc "chainguard.dev/sdk/proto/platform/oidc/v1" 16 | kms "cloud.google.com/go/kms/apiv1" 17 | "github.com/chainguard-dev/clog" 18 | metrics "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics" 19 | mce "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics/cloudevents" 20 | envConfig "github.com/octo-sts/app/pkg/envconfig" 21 | "github.com/octo-sts/app/pkg/ghtransport" 22 | "github.com/octo-sts/app/pkg/octosts" 23 | "google.golang.org/grpc" 24 | "google.golang.org/grpc/credentials/insecure" 25 | ) 26 | 27 | func main() { 28 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 29 | defer cancel() 30 | ctx = clog.WithLogger(ctx, clog.New(slog.Default().Handler())) 31 | 32 | baseCfg, err := envConfig.BaseConfig() 33 | if err != nil { 34 | log.Panicf("failed to process env var: %s", err) 35 | } 36 | appConfig, err := envConfig.AppConfig() 37 | if err != nil { 38 | log.Panicf("failed to process env var: %s", err) 39 | } 40 | 41 | if baseCfg.Metrics { 42 | go metrics.ServeMetrics() 43 | 44 | // Setup tracing. 45 | defer metrics.SetupTracer(ctx)() 46 | } 47 | 48 | var client *kms.KeyManagementClient 49 | 50 | if baseCfg.KMSKey != "" { 51 | client, err = kms.NewKeyManagementClient(ctx) 52 | if err != nil { 53 | log.Panicf("could not create kms client: %v", err) 54 | } 55 | } 56 | 57 | atr, err := ghtransport.New(ctx, baseCfg, client) 58 | if err != nil { 59 | log.Panicf("error creating GitHub App transport: %v", err) 60 | } 61 | 62 | d := duplex.New( 63 | baseCfg.Port, 64 | // grpc.StatsHandler(otelgrpc.NewServerHandler()), 65 | // grpc.ChainStreamInterceptor(grpc_prometheus.StreamServerInterceptor), 66 | // grpc.ChainUnaryInterceptor(grpc_prometheus.UnaryServerInterceptor, interceptors.ServerErrorInterceptor), 67 | grpc.WithTransportCredentials(insecure.NewCredentials()), 68 | ) 69 | 70 | ceclient, err := mce.NewClientHTTP("octo-sts", mce.WithTarget(ctx, appConfig.EventingIngress)...) 71 | if err != nil { 72 | log.Panicf("failed to create cloudevents client: %v", err) 73 | } 74 | 75 | pboidc.RegisterSecurityTokenServiceServer(d.Server, octosts.NewSecurityTokenServiceServer(atr, ceclient, appConfig.Domain, baseCfg.Metrics)) 76 | if err := d.RegisterHandler(ctx, pboidc.RegisterSecurityTokenServiceHandlerFromEndpoint); err != nil { 77 | log.Panicf("failed to register gateway endpoint: %v", err) 78 | } 79 | 80 | if err := d.MUX.HandlePath(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request, _ map[string]string) { 81 | w.Header().Set("Content-Type", "application/json") 82 | s := `{"msg": "please check documentation for usage: https://github.com/octo-sts/app"}` 83 | if _, err := w.Write([]byte(s)); err != nil { 84 | log.Printf("Failed to write bytes back to client: %v", err) 85 | http.Error(w, err.Error(), http.StatusInternalServerError) 86 | } 87 | }); err != nil { 88 | log.Panicf("failed to register root GET handler: %v", err) 89 | } 90 | 91 | if err := d.ListenAndServe(ctx); err != nil { 92 | log.Panicf("ListenAndServe() = %v", err) 93 | } 94 | 95 | // This will block until a signal arrives. 96 | <-ctx.Done() 97 | } 98 | -------------------------------------------------------------------------------- /cmd/negative-prober/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/chainguard-dev/terraform-infra-common/pkg/prober" 10 | 11 | octoprober "github.com/octo-sts/app/pkg/prober" 12 | ) 13 | 14 | func main() { 15 | ctx := context.Background() 16 | prober.Go(ctx, prober.Func(octoprober.Negative)) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/prober/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 Chainguard, Inc. 3 | SPDX-License-Identifier: Apache-2.0 4 | */ 5 | 6 | package main 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/chainguard-dev/terraform-infra-common/pkg/prober" 12 | 13 | octoprober "github.com/octo-sts/app/pkg/prober" 14 | ) 15 | 16 | func main() { 17 | ctx := context.Background() 18 | prober.Go(ctx, prober.Func(octoprober.Func)) 19 | } 20 | -------------------------------------------------------------------------------- /cmd/webhook/main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "log/slog" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "strings" 15 | "time" 16 | 17 | kms "cloud.google.com/go/kms/apiv1" 18 | secretmanager "cloud.google.com/go/secretmanager/apiv1" 19 | "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" 20 | "github.com/chainguard-dev/clog" 21 | metrics "github.com/chainguard-dev/terraform-infra-common/pkg/httpmetrics" 22 | envConfig "github.com/octo-sts/app/pkg/envconfig" 23 | "github.com/octo-sts/app/pkg/ghtransport" 24 | "github.com/octo-sts/app/pkg/webhook" 25 | ) 26 | 27 | func main() { 28 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 29 | defer cancel() 30 | ctx = clog.WithLogger(ctx, clog.New(slog.Default().Handler())) 31 | 32 | baseCfg, err := envConfig.BaseConfig() 33 | if err != nil { 34 | log.Panicf("failed to process env var: %s", err) 35 | } 36 | webhookConfig, err := envConfig.WebhookConfig() 37 | if err != nil { 38 | log.Panicf("failed to process env var: %s", err) 39 | } 40 | 41 | if baseCfg.Metrics { 42 | go metrics.ServeMetrics() 43 | 44 | // Setup tracing. 45 | defer metrics.SetupTracer(ctx)() 46 | } 47 | 48 | var client *kms.KeyManagementClient 49 | 50 | if baseCfg.KMSKey != "" { 51 | client, err = kms.NewKeyManagementClient(ctx) 52 | if err != nil { 53 | log.Panicf("could not create kms client: %v", err) 54 | } 55 | } 56 | 57 | atr, err := ghtransport.New(ctx, baseCfg, client) 58 | if err != nil { 59 | log.Panicf("error creating GitHub App transport: %v", err) 60 | } 61 | 62 | // Fetch webhook secrets from secret manager 63 | // or allow webhook secret to be defined by env var. 64 | // Not everyone is using Google KMS, so we need to support other methods 65 | webhookSecrets := [][]byte{} 66 | if baseCfg.KMSKey != "" { 67 | secretmanager, err := secretmanager.NewClient(ctx) 68 | if err != nil { 69 | log.Panicf("could not create secret manager client: %v", err) 70 | } 71 | for _, name := range strings.Split(webhookConfig.WebhookSecret, ",") { 72 | resp, err := secretmanager.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{ 73 | Name: name, 74 | }) 75 | if err != nil { 76 | log.Panicf("error fetching webhook secret %s: %v", name, err) 77 | } 78 | webhookSecrets = append(webhookSecrets, resp.GetPayload().GetData()) 79 | } 80 | } else { 81 | webhookSecrets = [][]byte{[]byte(webhookConfig.WebhookSecret)} 82 | } 83 | 84 | var orgs []string 85 | for _, s := range strings.Split(webhookConfig.OrganizationFilter, ",") { 86 | if o := strings.TrimSpace(s); o != "" { 87 | orgs = append(orgs, o) 88 | } 89 | } 90 | 91 | mux := http.NewServeMux() 92 | mux.Handle("/", &webhook.Validator{ 93 | Transport: atr, 94 | WebhookSecret: webhookSecrets, 95 | Organizations: orgs, 96 | }) 97 | srv := &http.Server{ 98 | Addr: fmt.Sprintf(":%d", baseCfg.Port), 99 | ReadHeaderTimeout: 10 * time.Second, 100 | Handler: mux, 101 | } 102 | log.Panic(srv.ListenAndServe()) 103 | } 104 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/octo-sts/app 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | chainguard.dev/go-grpc-kit v0.17.10 7 | chainguard.dev/sdk v0.1.33 8 | cloud.google.com/go/kms v1.22.0 9 | cloud.google.com/go/secretmanager v1.14.7 10 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 11 | github.com/chainguard-dev/clog v1.7.0 12 | github.com/chainguard-dev/terraform-infra-common v0.6.149 13 | github.com/cloudevents/sdk-go/v2 v2.16.0 14 | github.com/coreos/go-oidc/v3 v3.14.1 15 | github.com/golang-jwt/jwt/v4 v4.5.2 16 | github.com/google/go-cmp v0.7.0 17 | github.com/google/go-github/v71 v71.0.0 18 | github.com/hashicorp/go-multierror v1.1.1 19 | github.com/hashicorp/golang-lru/v2 v2.0.7 20 | github.com/kelseyhightower/envconfig v1.4.0 21 | golang.org/x/oauth2 v0.30.0 22 | google.golang.org/api v0.235.0 23 | google.golang.org/grpc v1.72.2 24 | k8s.io/apimachinery v0.33.1 25 | sigs.k8s.io/yaml v1.4.0 26 | ) 27 | 28 | require ( 29 | cloud.google.com/go v0.121.0 // indirect 30 | cloud.google.com/go/longrunning v0.6.7 // indirect 31 | cloud.google.com/go/trace v1.11.6 // indirect 32 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect 33 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 // indirect 34 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect 35 | github.com/cenkalti/backoff/v5 v5.0.2 // indirect 36 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 37 | github.com/ebitengine/purego v0.8.2 // indirect 38 | github.com/go-ole/go-ole v1.3.0 // indirect 39 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 40 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 41 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 42 | github.com/sethvargo/go-envconfig v1.3.0 // indirect 43 | github.com/shirou/gopsutil/v4 v4.25.4 // indirect 44 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 45 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 46 | go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect 47 | gopkg.in/yaml.v3 v3.0.1 // indirect 48 | ) 49 | 50 | require ( 51 | cloud.google.com/go/auth v0.16.1 // indirect 52 | cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect 53 | cloud.google.com/go/compute/metadata v0.7.0 // indirect 54 | cloud.google.com/go/iam v1.5.2 // indirect 55 | github.com/beorn7/perks v1.0.1 // indirect 56 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 57 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 58 | github.com/felixge/httpsnoop v1.0.4 // indirect 59 | github.com/go-jose/go-jose/v4 v4.1.0 60 | github.com/go-logr/logr v1.4.2 // indirect 61 | github.com/go-logr/stdr v1.2.2 // indirect 62 | github.com/google/go-querystring v1.1.0 // indirect 63 | github.com/google/s2a-go v0.1.9 // indirect 64 | github.com/google/uuid v1.6.0 // indirect 65 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 66 | github.com/googleapis/gax-go/v2 v2.14.2 // indirect 67 | github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect 68 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20210315223345-82c243799c99 // indirect 69 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 70 | github.com/hashicorp/errwrap v1.1.0 // indirect 71 | github.com/json-iterator/go v1.1.12 // indirect 72 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 73 | github.com/modern-go/reflect2 v1.0.2 // indirect 74 | github.com/prometheus/client_golang v1.22.0 // indirect 75 | github.com/prometheus/client_model v0.6.2 // indirect 76 | github.com/prometheus/common v0.63.0 // indirect 77 | github.com/prometheus/procfs v0.16.0 // indirect 78 | github.com/stretchr/testify v1.10.0 79 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 80 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 81 | go.opentelemetry.io/otel v1.36.0 // indirect 82 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect 83 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 84 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect 85 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 86 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect 87 | go.opentelemetry.io/otel/trace v1.36.0 // indirect 88 | go.opentelemetry.io/proto/otlp v1.6.0 // indirect 89 | go.uber.org/multierr v1.11.0 // indirect 90 | go.uber.org/zap v1.27.0 // indirect 91 | golang.org/x/crypto v0.38.0 // indirect 92 | golang.org/x/net v0.40.0 // indirect 93 | golang.org/x/sync v0.14.0 // indirect 94 | golang.org/x/sys v0.33.0 // indirect 95 | golang.org/x/text v0.25.0 // indirect 96 | golang.org/x/time v0.11.0 // indirect 97 | google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect 98 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 99 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 100 | google.golang.org/protobuf v1.36.6 // indirect 101 | ) 102 | -------------------------------------------------------------------------------- /hack/boilerplate/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /hack/boilerplate/boilerplate.sh.txt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Copyright 2024 Chainguard, Inc. 4 | # SPDX-License-Identifier: Apache-2.0 5 | -------------------------------------------------------------------------------- /hack/boilerplate/boilerplate.yaml.txt: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Chainguard, Inc. 2 | # SPDX-License-Identifier: Apache-2.0 3 | -------------------------------------------------------------------------------- /iac/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "gcs" { 3 | bucket = "octo-sts-terraform-state" 4 | prefix = "/octo-sts" 5 | } 6 | required_providers { 7 | ko = { source = "ko-build/ko" } 8 | cosign = { source = "chainguard-dev/cosign" } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /iac/bootstrap/backend.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "gcs" { 3 | bucket = "octo-sts-terraform-state" 4 | prefix = "/bootstrap" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /iac/bootstrap/main.tf: -------------------------------------------------------------------------------- 1 | provider "google" { project = var.project_id } 2 | provider "google-beta" { project = var.project_id } 3 | 4 | resource "google_project_service" "iamcredentials-api" { 5 | project = var.project_id 6 | service = "iamcredentials.googleapis.com" 7 | disable_dependent_services = false 8 | disable_on_destroy = false 9 | } 10 | 11 | data "google_monitoring_notification_channel" "notify-chainguard-slack" { 12 | display_name = "Slack Octo STS Notification" 13 | } 14 | 15 | locals { 16 | notification_channels = [ 17 | data.google_monitoring_notification_channel.notify-chainguard-slack.name, 18 | ] 19 | } 20 | 21 | module "github-wif" { 22 | source = "chainguard-dev/common/infra//modules/github-wif-provider" 23 | version = "0.6.149" 24 | 25 | project_id = var.project_id 26 | name = "github-pool" 27 | github_org = "octo-sts" 28 | 29 | notification_channels = local.notification_channels 30 | } 31 | 32 | moved { 33 | from = google_iam_workload_identity_pool.github_pool 34 | to = module.github-wif.google_iam_workload_identity_pool.this 35 | } 36 | 37 | moved { 38 | from = google_iam_workload_identity_pool_provider.github_provider 39 | to = module.github-wif.google_iam_workload_identity_pool_provider.this 40 | } 41 | 42 | module "github_identity" { 43 | source = "chainguard-dev/common/infra//modules/github-gsa" 44 | version = "0.6.149" 45 | 46 | project_id = var.project_id 47 | name = "github-identity" 48 | wif-pool = module.github-wif.pool_name 49 | 50 | repository = "octo-sts/app" 51 | refspec = "refs/heads/main" 52 | workflow_ref = ".github/workflows/deploy.yaml" 53 | 54 | notification_channels = local.notification_channels 55 | } 56 | 57 | moved { 58 | from = google_service_account.github_identity 59 | to = module.github_identity.google_service_account.this 60 | } 61 | 62 | 63 | resource "google_project_iam_member" "github_owner" { 64 | project = var.project_id 65 | role = "roles/owner" 66 | member = "serviceAccount:${module.github_identity.email}" 67 | } 68 | 69 | module "github_pull_requests" { 70 | source = "chainguard-dev/common/infra//modules/github-gsa" 71 | version = "0.6.149" 72 | 73 | project_id = var.project_id 74 | name = "github-pull-requests" 75 | wif-pool = module.github-wif.pool_name 76 | 77 | repository = "octo-sts/app" 78 | refspec = "pull_request" 79 | workflow_ref = ".github/workflows/verify-prod.yaml" 80 | 81 | notification_channels = local.notification_channels 82 | } 83 | 84 | moved { 85 | from = google_service_account.github_pull_requests 86 | to = module.github_pull_requests.google_service_account.this 87 | } 88 | 89 | resource "google_project_iam_member" "github_viewer" { 90 | project = var.project_id 91 | role = "roles/viewer" 92 | member = "serviceAccount:${module.github_pull_requests.email}" 93 | } 94 | 95 | resource "google_project_iam_member" "github_iam_viewer" { 96 | project = var.project_id 97 | role = "roles/iam.securityReviewer" 98 | member = "serviceAccount:${module.github_pull_requests.email}" 99 | } 100 | -------------------------------------------------------------------------------- /iac/bootstrap/output.tf: -------------------------------------------------------------------------------- 1 | output "pool" { 2 | value = module.github-wif.pool_name 3 | } 4 | -------------------------------------------------------------------------------- /iac/bootstrap/terraform.tfvars: -------------------------------------------------------------------------------- 1 | project_id = "octo-sts" 2 | -------------------------------------------------------------------------------- /iac/bootstrap/variables.tf: -------------------------------------------------------------------------------- 1 | variable "project_id" { 2 | description = "The project ID where all resources created will reside." 3 | } 4 | -------------------------------------------------------------------------------- /iac/broker.tf: -------------------------------------------------------------------------------- 1 | // Create the Broker abstraction. 2 | module "cloudevent-broker" { 3 | source = "chainguard-dev/common/infra//modules/cloudevent-broker" 4 | version = "0.6.149" 5 | 6 | name = "octo-sts-broker" 7 | project_id = var.project_id 8 | regions = module.networking.regional-networks 9 | 10 | notification_channels = local.notification_channels 11 | } 12 | 13 | data "google_client_openid_userinfo" "me" {} 14 | 15 | module "cloudevent-recorder" { 16 | source = "chainguard-dev/common/infra//modules/cloudevent-recorder" 17 | version = "0.6.149" 18 | 19 | name = "octo-sts-recorder" 20 | project_id = var.project_id 21 | regions = module.networking.regional-networks 22 | broker = module.cloudevent-broker.broker 23 | 24 | retention-period = 90 25 | 26 | provisioner = "serviceAccount:${data.google_client_openid_userinfo.me.email}" 27 | 28 | notification_channels = local.notification_channels 29 | 30 | types = { 31 | "dev.octo-sts.exchange" : { 32 | schema = file("${path.module}/sts_exchange.schema.json") 33 | notification_channels = local.notification_channels 34 | } 35 | } 36 | } 37 | 38 | resource "google_bigquery_table" "errors-by-installations" { 39 | dataset_id = module.cloudevent-recorder.dataset_id 40 | table_id = "errors_by_installations" 41 | 42 | view { 43 | query = < 0 THEN LEFT(scope, STRPOS(scope, '/')-1) ELSE scope END) as org, 46 | TIMESTAMP_TRUNC(_PARTITIONTIME, DAY) as day, 47 | AVG(CASE WHEN LENGTH(error) > 0 THEN 1 ELSE 0 END) * 100 as error_rate, 48 | COUNT(*) as volume 49 | FROM `${var.project_id}.${module.cloudevent-recorder.dataset_id}.${module.cloudevent-recorder.table_ids["dev.octo-sts.exchange"]}` 50 | GROUP BY installation_id, org, day 51 | EOT 52 | // Use standard SQL 53 | use_legacy_sql = false 54 | } 55 | } 56 | 57 | resource "google_bigquery_table" "errors-by-subject" { 58 | dataset_id = module.cloudevent-recorder.dataset_id 59 | table_id = "errors_by_subject" 60 | 61 | view { 62 | query = < 0 THEN 1 ELSE 0 END) * 100 as error_rate, 66 | COUNT(*) as volume 67 | FROM `${var.project_id}.${module.cloudevent-recorder.dataset_id}.${module.cloudevent-recorder.table_ids["dev.octo-sts.exchange"]}` 68 | GROUP BY subject, day 69 | EOT 70 | // Use standard SQL 71 | use_legacy_sql = false 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /iac/gclb.tf: -------------------------------------------------------------------------------- 1 | moved { 2 | from = module.app.google_dns_managed_zone.top-level-zone 3 | to = google_dns_managed_zone.top-level-zone 4 | } 5 | 6 | moved { 7 | from = module.app.module.serverless-gclb 8 | to = module.serverless-gclb 9 | } 10 | 11 | // This is imported from Cloud Domains 12 | resource "google_dns_managed_zone" "top-level-zone" { 13 | project = var.project_id 14 | name = "octo-sts-dev" 15 | dns_name = "octo-sts.dev." 16 | description = "DNS zone for domain: octo-sts.dev" 17 | 18 | dnssec_config { 19 | state = "on" 20 | } 21 | } 22 | 23 | // Put the above domain in front of our regional services. 24 | module "serverless-gclb" { 25 | source = "chainguard-dev/common/infra//modules/serverless-gclb" 26 | version = "0.6.149" 27 | 28 | name = var.name 29 | project_id = var.project_id 30 | dns_zone = google_dns_managed_zone.top-level-zone.name 31 | 32 | // Regions are all of the places that we have backends deployed. 33 | // Regions must be removed from serving before they are torn down. 34 | regions = keys(module.networking.regional-networks) 35 | serving_regions = keys(module.networking.regional-networks) 36 | 37 | public-services = { 38 | "octo-sts.dev" = { 39 | name = module.app.app.name 40 | } 41 | "webhook.octo-sts.dev" = { 42 | name = module.app.webhook.name 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /iac/github_verify.tf: -------------------------------------------------------------------------------- 1 | resource "google_dns_record_set" "github_verify" { 2 | managed_zone = google_dns_managed_zone.top-level-zone.name 3 | 4 | name = "_gh-octo-sts-o.octo-sts.dev." 5 | type = "TXT" 6 | ttl = 300 7 | 8 | rrdatas = [ 9 | "\"cc539450df\"", 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /iac/main.tf: -------------------------------------------------------------------------------- 1 | provider "google" { project = var.project_id } 2 | provider "google-beta" { project = var.project_id } 3 | provider "ko" { repo = "gcr.io/${var.project_id}" } 4 | 5 | // Create a network with several regional subnets 6 | module "networking" { 7 | source = "chainguard-dev/common/infra//modules/networking" 8 | version = "0.6.149" 9 | 10 | name = var.name 11 | project_id = var.project_id 12 | regions = var.regions 13 | netnum_offset = 1 14 | } 15 | 16 | # For slack need to create the notification manually - https://github.com/hashicorp/terraform-provider-google/issues/11346 17 | data "google_monitoring_notification_channel" "octo-sts-slack" { 18 | display_name = "Slack Octo STS Notification" 19 | } 20 | 21 | resource "ko_build" "this" { 22 | working_dir = "${path.module}/.." 23 | importpath = "./cmd/app" 24 | } 25 | 26 | resource "cosign_sign" "this" { 27 | image = ko_build.this.image_ref 28 | conflict = "REPLACE" 29 | } 30 | 31 | resource "ko_build" "webhook" { 32 | working_dir = "${path.module}/.." 33 | importpath = "./cmd/webhook" 34 | } 35 | 36 | resource "cosign_sign" "webhook" { 37 | image = ko_build.webhook.image_ref 38 | conflict = "REPLACE" 39 | } 40 | 41 | locals { 42 | notification_channels = [ 43 | data.google_monitoring_notification_channel.octo-sts-slack.name 44 | ] 45 | } 46 | 47 | module "app" { 48 | source = "../modules/app" 49 | 50 | project_id = var.project_id 51 | name = var.name 52 | regions = module.networking.regional-networks 53 | 54 | private-services = { 55 | eventing-ingress = { 56 | name = module.cloudevent-broker.ingress.name 57 | } 58 | } 59 | 60 | domain = "octo-sts.dev" 61 | images = { 62 | app = cosign_sign.this.signed_ref 63 | webhook = cosign_sign.webhook.signed_ref 64 | } 65 | 66 | github_app_id = var.github_app_id 67 | github_app_key_version = 1 68 | notification_channels = local.notification_channels 69 | } 70 | -------------------------------------------------------------------------------- /iac/prober.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "prober" { 2 | project = var.project_id 3 | account_id = "octo-sts-prober" 4 | } 5 | 6 | module "prober" { 7 | source = "chainguard-dev/common/infra//modules/prober" 8 | version = "0.6.149" 9 | 10 | name = "octo-sts-prober" 11 | project_id = var.project_id 12 | regions = module.networking.regional-networks 13 | egress = "PRIVATE_RANGES_ONLY" // Talks to octos-sts via GCLB, and Github 14 | 15 | service_account = google_service_account.prober.email 16 | 17 | importpath = "./cmd/prober" 18 | working_dir = "${path.module}/../" 19 | 20 | env = { 21 | STS_DOMAIN = "octo-sts.dev" 22 | } 23 | 24 | enable_alert = true 25 | notification_channels = local.notification_channels 26 | } 27 | 28 | resource "google_service_account" "negative_prober" { 29 | project = var.project_id 30 | account_id = "octo-sts-negative-prober" 31 | } 32 | 33 | module "negative_prober" { 34 | source = "chainguard-dev/common/infra//modules/prober" 35 | version = "0.6.149" 36 | 37 | name = "octo-sts-negative-prober" 38 | project_id = var.project_id 39 | regions = module.networking.regional-networks 40 | egress = "PRIVATE_RANGES_ONLY" // Talks to octos-sts via GCLB, and Github 41 | 42 | service_account = google_service_account.negative_prober.email 43 | 44 | importpath = "./cmd/negative-prober" 45 | working_dir = "${path.module}/../" 46 | 47 | env = { 48 | STS_DOMAIN = "octo-sts.dev" 49 | } 50 | 51 | enable_alert = true 52 | notification_channels = local.notification_channels 53 | } 54 | 55 | module "dashboard" { 56 | source = "chainguard-dev/common/infra//modules/dashboard/service" 57 | version = "0.6.149" 58 | service_name = var.name 59 | project_id = var.project_id 60 | 61 | alerts = { 62 | "STS Probe" : module.prober.alert_id, 63 | "STS Negative Probe" : module.negative_prober.alert_id 64 | } 65 | 66 | notification_channels = local.notification_channels 67 | } 68 | -------------------------------------------------------------------------------- /iac/sts_exchange.schema.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "actor", 4 | "type": "RECORD", 5 | "mode": "NULLABLE", 6 | "fields": [ 7 | { 8 | "name": "iss", 9 | "type": "STRING", 10 | "mode": "NULLABLE" 11 | }, 12 | { 13 | "name": "sub", 14 | "type": "STRING", 15 | "mode": "NULLABLE" 16 | }, 17 | { 18 | "name": "claims", 19 | "type": "RECORD", 20 | "mode": "REPEATED", 21 | "fields": [ 22 | { 23 | "name": "name", 24 | "type": "STRING", 25 | "mode": "NULLABLE" 26 | }, 27 | { 28 | "name": "value", 29 | "type": "STRING", 30 | "mode": "NULLABLE" 31 | } 32 | ] 33 | } 34 | ] 35 | }, 36 | { 37 | "name": "trust_policy", 38 | "type": "RECORD", 39 | "mode": "NULLABLE", 40 | "fields": [ 41 | { 42 | "name": "issuer", 43 | "type": "STRING", 44 | "mode": "NULLABLE" 45 | }, 46 | { 47 | "name": "issuer_pattern", 48 | "type": "STRING", 49 | "mode": "NULLABLE" 50 | }, 51 | { 52 | "name": "subject", 53 | "type": "STRING", 54 | "mode": "NULLABLE" 55 | }, 56 | { 57 | "name": "subject_pattern", 58 | "type": "STRING", 59 | "mode": "NULLABLE" 60 | }, 61 | { 62 | "name": "audience", 63 | "type": "STRING", 64 | "mode": "NULLABLE" 65 | }, 66 | { 67 | "name": "audience_pattern", 68 | "type": "STRING", 69 | "mode": "NULLABLE" 70 | }, 71 | { 72 | "name": "claim_pattern", 73 | "type": "RECORD", 74 | "mode": "NULLABLE", 75 | "fields": [ 76 | { 77 | "name": "job_workflow_ref", 78 | "type": "STRING", 79 | "mode": "NULLABLE" 80 | }, 81 | { 82 | "name": "workflow_ref", 83 | "type": "STRING", 84 | "mode": "NULLABLE" 85 | }, 86 | { 87 | "name": "email", 88 | "type": "STRING", 89 | "mode": "NULLABLE" 90 | }, 91 | { 92 | "name": "email_verified", 93 | "type": "STRING", 94 | "mode": "NULLABLE" 95 | }, 96 | { 97 | "name": "actor", 98 | "type": "STRING", 99 | "mode": "NULLABLE" 100 | }, 101 | { 102 | "name": "base_ref", 103 | "type": "STRING", 104 | "mode": "NULLABLE" 105 | }, 106 | { 107 | "name": "aud", 108 | "type": "STRING", 109 | "mode": "NULLABLE" 110 | } 111 | ] 112 | }, 113 | { 114 | "name": "repositories", 115 | "type": "STRING", 116 | "mode": "REPEATED" 117 | }, 118 | { 119 | "name": "permissions", 120 | "type": "RECORD", 121 | "mode": "NULLABLE", 122 | "fields": [ 123 | { 124 | "name": "actions", 125 | "type": "STRING", 126 | "mode": "NULLABLE" 127 | }, 128 | { 129 | "name": "administration", 130 | "type": "STRING", 131 | "mode": "NULLABLE" 132 | }, 133 | { 134 | "name": "blocking", 135 | "type": "STRING", 136 | "mode": "NULLABLE" 137 | }, 138 | { 139 | "name": "checks", 140 | "type": "STRING", 141 | "mode": "NULLABLE" 142 | }, 143 | { 144 | "name": "contents", 145 | "type": "STRING", 146 | "mode": "NULLABLE" 147 | }, 148 | { 149 | "name": "content_references", 150 | "type": "STRING", 151 | "mode": "NULLABLE" 152 | }, 153 | { 154 | "name": "deployments", 155 | "type": "STRING", 156 | "mode": "NULLABLE" 157 | }, 158 | { 159 | "name": "emails", 160 | "type": "STRING", 161 | "mode": "NULLABLE" 162 | }, 163 | { 164 | "name": "environments", 165 | "type": "STRING", 166 | "mode": "NULLABLE" 167 | }, 168 | { 169 | "name": "followers", 170 | "type": "STRING", 171 | "mode": "NULLABLE" 172 | }, 173 | { 174 | "name": "issues", 175 | "type": "STRING", 176 | "mode": "NULLABLE" 177 | }, 178 | { 179 | "name": "metadata", 180 | "type": "STRING", 181 | "mode": "NULLABLE" 182 | }, 183 | { 184 | "name": "members", 185 | "type": "STRING", 186 | "mode": "NULLABLE" 187 | }, 188 | { 189 | "name": "organization_administration", 190 | "type": "STRING", 191 | "mode": "NULLABLE" 192 | }, 193 | { 194 | "name": "organization_custom_roles", 195 | "type": "STRING", 196 | "mode": "NULLABLE" 197 | }, 198 | { 199 | "name": "organization_hooks", 200 | "type": "STRING", 201 | "mode": "NULLABLE" 202 | }, 203 | { 204 | "name": "organization_packages", 205 | "type": "STRING", 206 | "mode": "NULLABLE" 207 | }, 208 | { 209 | "name": "organization_plan", 210 | "type": "STRING", 211 | "mode": "NULLABLE" 212 | }, 213 | { 214 | "name": "organization_pre_receive_hooks", 215 | "type": "STRING", 216 | "mode": "NULLABLE" 217 | }, 218 | { 219 | "name": "organization_projects", 220 | "type": "STRING", 221 | "mode": "NULLABLE" 222 | }, 223 | { 224 | "name": "organization_secrets", 225 | "type": "STRING", 226 | "mode": "NULLABLE" 227 | }, 228 | { 229 | "name": "organization_self_hosted_runners", 230 | "type": "STRING", 231 | "mode": "NULLABLE" 232 | }, 233 | { 234 | "name": "organization_user_blocking", 235 | "type": "STRING", 236 | "mode": "NULLABLE" 237 | }, 238 | { 239 | "name": "packages", 240 | "type": "STRING", 241 | "mode": "NULLABLE" 242 | }, 243 | { 244 | "name": "pages", 245 | "type": "STRING", 246 | "mode": "NULLABLE" 247 | }, 248 | { 249 | "name": "pull_requests", 250 | "type": "STRING", 251 | "mode": "NULLABLE" 252 | }, 253 | { 254 | "name": "repository_hooks", 255 | "type": "STRING", 256 | "mode": "NULLABLE" 257 | }, 258 | { 259 | "name": "repository_projects", 260 | "type": "STRING", 261 | "mode": "NULLABLE" 262 | }, 263 | { 264 | "name": "repository_pre_receive_hooks", 265 | "type": "STRING", 266 | "mode": "NULLABLE" 267 | }, 268 | { 269 | "name": "secrets", 270 | "type": "STRING", 271 | "mode": "NULLABLE" 272 | }, 273 | { 274 | "name": "secret_scanning_alerts", 275 | "type": "STRING", 276 | "mode": "NULLABLE" 277 | }, 278 | { 279 | "name": "security_events", 280 | "type": "STRING", 281 | "mode": "NULLABLE" 282 | }, 283 | { 284 | "name": "single_file", 285 | "type": "STRING", 286 | "mode": "NULLABLE" 287 | }, 288 | { 289 | "name": "statuses", 290 | "type": "STRING", 291 | "mode": "NULLABLE" 292 | }, 293 | { 294 | "name": "team_discussions", 295 | "type": "STRING", 296 | "mode": "NULLABLE" 297 | }, 298 | { 299 | "name": "vulnerability_alerts", 300 | "type": "STRING", 301 | "mode": "NULLABLE" 302 | }, 303 | { 304 | "name": "workflows", 305 | "type": "STRING", 306 | "mode": "NULLABLE" 307 | } 308 | ] 309 | } 310 | ] 311 | }, 312 | { 313 | "name": "installation_id", 314 | "type": "NUMERIC", 315 | "mode": "NULLABLE" 316 | }, 317 | { 318 | "name": "scope", 319 | "type": "STRING", 320 | "mode": "NULLABLE" 321 | }, 322 | { 323 | "name": "identity", 324 | "type": "STRING", 325 | "mode": "NULLABLE" 326 | }, 327 | { 328 | "name": "token_sha256", 329 | "type": "STRING", 330 | "mode": "NULLABLE" 331 | }, 332 | { 333 | "name": "error", 334 | "type": "STRING", 335 | "mode": "NULLABLE" 336 | } 337 | ] 338 | -------------------------------------------------------------------------------- /iac/terraform.tfvars: -------------------------------------------------------------------------------- 1 | name = "octo-sts" 2 | 3 | project_id = "octo-sts" 4 | 5 | regions = [ 6 | "us-central1", 7 | ] 8 | 9 | // https://github.com/settings/apps/octosts 10 | github_app_id = 801323 11 | -------------------------------------------------------------------------------- /iac/variables.tf: -------------------------------------------------------------------------------- 1 | variable "project_id" { 2 | description = "The project ID where all resources created will reside." 3 | } 4 | 5 | variable "name" { 6 | description = "Name indicator, prefixed to resources created." 7 | default = "octo-sts" 8 | } 9 | 10 | variable "regions" { 11 | description = "Regions where this environment's services should live." 12 | type = list(string) 13 | default = [] 14 | } 15 | 16 | variable "github_app_id" { 17 | description = "The Github App ID for the Octo STS service." 18 | } 19 | -------------------------------------------------------------------------------- /modules/app/main.tf: -------------------------------------------------------------------------------- 1 | // Create a keyring to hold our GitHub App keys. 2 | resource "google_kms_key_ring" "app-keyring" { 3 | project = var.project_id 4 | name = var.name 5 | location = "global" 6 | } 7 | 8 | // Create an asymmetric signing key to use for signing tokens. 9 | resource "google_kms_crypto_key" "app-key" { 10 | name = "app-signing-key" 11 | key_ring = google_kms_key_ring.app-keyring.id 12 | purpose = "ASYMMETRIC_SIGN" 13 | 14 | version_template { 15 | algorithm = "RSA_SIGN_PKCS1_2048_SHA256" 16 | } 17 | 18 | import_only = true 19 | skip_initial_version_creation = true 20 | } 21 | 22 | locals { 23 | # To import a key, we need to run the following commands: 24 | # gcloud kms import-jobs create app-import-job \ 25 | # --location global \ 26 | # --keyring octo-sts \ 27 | # --import-method rsa-oaep-4096-sha256-aes-256 \ 28 | # --protection-level software 29 | 30 | # gcloud kms import-jobs describe app-import-job \ 31 | # --location global \ 32 | # --keyring octo-sts \ 33 | # --format="value(state)" 34 | 35 | # openssl pkcs8 -topk8 -nocrypt -inform PEM -outform DER \ 36 | # -in key.pem -out key.data 37 | 38 | # # Needs: pip3 install --user "cryptography>=2.2.0" 39 | # CLOUDSDK_PYTHON_SITEPACKAGES=1 gcloud kms keys versions import \ 40 | # --import-job app-import-job \ 41 | # --location global \ 42 | # --keyring octo-sts \ 43 | # --key app-signing-key \ 44 | # --algorithm rsa-sign-pkcs1-2048-sha256 \ 45 | # --target-key-file key.data 46 | kms_key = "${google_kms_crypto_key.app-key.id}/cryptoKeyVersions/${var.github_app_key_version}" 47 | } 48 | 49 | // Create a dedicated GSA for the IAM datastore service. 50 | resource "google_service_account" "octo-sts" { 51 | project = var.project_id 52 | 53 | account_id = var.name 54 | display_name = "Octo STS" 55 | description = "Dedicated service account for the Octo STS service." 56 | } 57 | 58 | // Authorize the "octo-sts" service account to publish events. 59 | module "sts-emits-events" { 60 | for_each = var.regions 61 | 62 | source = "chainguard-dev/common/infra//modules/authorize-private-service" 63 | version = "0.6.149" 64 | 65 | project_id = var.project_id 66 | region = each.key 67 | name = var.private-services.eventing-ingress.name 68 | 69 | service-account = google_service_account.octo-sts.email 70 | } 71 | 72 | module "this" { 73 | source = "chainguard-dev/common/infra//modules/regional-service" 74 | version = "0.6.149" 75 | 76 | project_id = var.project_id 77 | name = var.name 78 | regions = var.regions 79 | require_squad = false 80 | 81 | deletion_protection = var.deletion_protection 82 | 83 | // Only accept traffic coming from GCLB. 84 | ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" 85 | // This needs to egress in order to talk to Github 86 | egress = "PRIVATE_RANGES_ONLY" 87 | 88 | service_account = google_service_account.octo-sts.email 89 | containers = { 90 | "sts" = { 91 | image = var.images.app 92 | ports = [{ container_port = 8080 }] 93 | env = [ 94 | { 95 | name = "GITHUB_APP_ID" 96 | value = "${var.github_app_id}" 97 | }, 98 | { 99 | name = "KMS_KEY" 100 | value = local.kms_key 101 | }, 102 | { 103 | name = "STS_DOMAIN", 104 | value = var.domain, 105 | } 106 | ] 107 | regional-env = [{ 108 | name = "EVENT_INGRESS_URI" 109 | value = { for k, v in module.sts-emits-events : k => v.uri } 110 | }] 111 | } 112 | } 113 | 114 | notification_channels = var.notification_channels 115 | } 116 | 117 | // Allow the STS service to call the sign method on the keys in the keyring. 118 | resource "google_kms_key_ring_iam_binding" "signer-members" { 119 | key_ring_id = google_kms_key_ring.app-keyring.id 120 | role = "roles/cloudkms.signer" 121 | members = [ 122 | "serviceAccount:${google_service_account.octo-sts.email}", 123 | ] 124 | } 125 | 126 | data "google_client_openid_userinfo" "me" {} 127 | 128 | resource "google_monitoring_alert_policy" "anomalous-kms-access" { 129 | # In the absence of data, incident will auto-close after an hour 130 | alert_strategy { 131 | auto_close = "3600s" 132 | 133 | notification_rate_limit { 134 | period = "3600s" // re-alert hourly if condition still valid. 135 | } 136 | } 137 | 138 | display_name = "Abnormal KMS Access" 139 | combiner = "OR" 140 | 141 | conditions { 142 | display_name = "Unauthorized KMS access" 143 | 144 | condition_matched_log { 145 | filter = <\n\n

\n\nSigned-off-by: xoxox-bot <11111+xoxox-bot@users.noreply.github.com>\nCo-authored-by: xoxo-bot <11111+xoxo-bot@users.noreply.github.com>", 22 | "tree": { 23 | "sha": "e9a224606353911107336ddddc680637178581f", 24 | "url": "https://api.github.com/repos/honk/honk/git/trees/e9a224606ddd11107336fc782c680637178581f" 25 | }, 26 | "url": "https://api.github.com/repos/honk/honk/git/commits/d8cae53e2dddd252dbc5137a84e11", 27 | "comment_count": 0, 28 | "verification": { 29 | "verified": true, 30 | "reason": "valid", 31 | "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBQJnKzD3C2pPNeTlF7q90w4\nj+oNtflsezIpkias33x8q02PNu6plJDcEkwS0jYJcqbBqAgY/wJQrw6Aa/JCaxVPGDtz1QxljFd\nWrLmM232X8mUznr4gDMKXUmS55rwYuUEqbGxZgxAivGOqk9V45mrwlSmA2EVF2gu\ng7gKp0BWr56N3IwbIpOXsnJQYnJFgg30I6epKRXZqAYRVje9RqYSzVAMxhCy6w0q\n3tzc1mO6UrE0TgLlpagt/3iriSOBxK42SCzOzfMFzFrAatJPQpmgbrYlQeuo5brU\npZoUz45JxId/omUaFuRlyjqU8XE4qDoXw8RSu9E61i9lX3zaWjX554i6tOEjGrdV\n+RnFAu5ql6t0afmo1PsFOqdxQd1uo2Y6qIoWnwcLXVdWmvIFucuKoWK0hiNhGeVa\ntRJcuyWEuI23/XC7PfCavIripsv5M763eRKdMw/8WjcnLu+URK4UkDtB1sUVLegM\nVwaOdEowOr7maq5rcCptJNorKDr7Y/mO5fmbW6wmL34tBlaIH3XWBS8zzrxTDjF7\np80k+Kh95clu59xp4JSrsffUErdruGc\nnkDsrei9+otXYtCK2rgL\n=utVh\n-----END PGP SIGNATURE-----\n", 32 | "payload": "tree e9a224606353911107336fc782c680637178581f\nparent a11cd51fed27f10f1f01dbee91c00f58896e9e39\nauthor octo-sts[bot] <157150467+octo-sts[bot]@users.noreply.github.com> 2222 +0000\ncommitter GitHub 222 +0000\n\n\n

\n\n

\n\nSigned-off-by: xoxo-bot <1111+xoxox-bot@users.noreply.github.com>\nCo-authored-by: xoxo-bot <1111+xoxo-bot@users.noreply.github.com>" 33 | } 34 | }, 35 | "url": "https://api.github.com/repos/honk/honk/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 36 | "html_url": "https://github.com/honk/honk/commit/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 37 | "comments_url": "https://api.github.com/repos/honk/honk/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11/comments", 38 | "author": { 39 | "login": "octo-sts[bot]", 40 | "id": 22222, 41 | "node_id": "BOT_kgDOCV3tAw", 42 | "avatar_url": "https://avatars.githubusercontent.com/in/8ddd23?v=4", 43 | "gravatar_id": "", 44 | "url": "https://api.github.com/users/octo-sts%5Bbot%5D", 45 | "html_url": "https://github.com/apps/octo-sts", 46 | "followers_url": "https://api.github.com/users/octo-sts%5Bbot%5D/followers", 47 | "following_url": "https://api.github.com/users/octo-sts%5Bbot%5D/following{/other_user}", 48 | "gists_url": "https://api.github.com/users/octo-sts%5Bbot%5D/gists{/gist_id}", 49 | "starred_url": "https://api.github.com/users/octo-sts%5Bbot%5D/starred{/owner}{/repo}", 50 | "subscriptions_url": "https://api.github.com/users/octo-sts%5Bbot%5D/subscriptions", 51 | "organizations_url": "https://api.github.com/users/octo-sts%5Bbot%5D/orgs", 52 | "repos_url": "https://api.github.com/users/octo-sts%5Bbot%5D/repos", 53 | "events_url": "https://api.github.com/users/octo-sts%5Bbot%5D/events{/privacy}", 54 | "received_events_url": "https://api.github.com/users/octo-sts%5Bbot%5D/received_events", 55 | "type": "Bot", 56 | "user_view_type": "public", 57 | "site_admin": false 58 | }, 59 | "committer": { 60 | "login": "web-flow", 61 | "id": 19864447, 62 | "node_id": "MDQ6VXNlcjE5ODY0NDQ3", 63 | "avatar_url": "https://avatars.githubusercontent.com/u/19864447?v=4", 64 | "gravatar_id": "", 65 | "url": "https://api.github.com/users/web-flow", 66 | "html_url": "https://github.com/web-flow", 67 | "followers_url": "https://api.github.com/users/web-flow/followers", 68 | "following_url": "https://api.github.com/users/web-flow/following{/other_user}", 69 | "gists_url": "https://api.github.com/users/web-flow/gists{/gist_id}", 70 | "starred_url": "https://api.github.com/users/web-flow/starred{/owner}{/repo}", 71 | "subscriptions_url": "https://api.github.com/users/web-flow/subscriptions", 72 | "organizations_url": "https://api.github.com/users/web-flow/orgs", 73 | "repos_url": "https://api.github.com/users/web-flow/repos", 74 | "events_url": "https://api.github.com/users/web-flow/events{/privacy}", 75 | "received_events_url": "https://api.github.com/users/web-flow/received_events", 76 | "type": "User", 77 | "user_view_type": "public", 78 | "site_admin": false 79 | }, 80 | "parents": [ 81 | { 82 | "sha": "a11cd51fed27f10f1f01dbee91c00f58896e9e39", 83 | "url": "https://api.github.com/repos/honk/honk/commits/a11cd51fed27f10f1f01dbee91c00f58896e9e39", 84 | "html_url": "https://github.com/honk/honk/commit/a11cd51fed27f10f1f01dbee91c00f58896e9e39" 85 | } 86 | ] 87 | }, 88 | "merge_base_commit": { 89 | "sha": "d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 90 | "node_id": "C_kwDOH9LtsNoAKGQ4Y2FlNTNlMmU2Y2ZjM2FlODQxMTg5NTI1MmRiYzUxMzdhODRlMTY", 91 | "commit": { 92 | "author": { 93 | "name": "octo-sts[bot]", 94 | "email": "d333s+octo-sts[bot]@users.noreply.github.com", 95 | "date": "2024-11-06T09:03:51Z" 96 | }, 97 | "committer": { 98 | "name": "GitHub", 99 | "email": "noreply@github.com", 100 | "date": "2024-11-06T09:03:51Z" 101 | }, 102 | "message": "blablabla p align=\"center\">\n\n

\n\nSigned-off-by: xoxo-bot <1111+xoxo-bot@users.noreply.github.com>\nCo-authored-by: xoxo-bot <1111+xoxo-bot@users.noreply.github.com>", 103 | "tree": { 104 | "sha": "e9a2246063539111sss80637178581f", 105 | "url": "https://api.github.com/repos/honk/honk/git/trees/e9a224606353911107336fc782c680637178581f" 106 | }, 107 | "url": "https://api.github.com/repos/honk/honk/git/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 108 | "comment_count": 0, 109 | "verification": { 110 | "verified": true, 111 | "reason": "valid", 112 | "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCAAQBPNeTlF7q90w4\nj+oNtflsezIpkias33x8q02PNu6K7wgNMMjEceiSK3YZCFfxeYLFkNufACNaWr4+\n7p4j2Ijbj8anTUeoplJDcEkwS0jYJcqbBqAgY/wJQrw6Aa/JCaxVPGDtz1QxljFd\nWrLmM232X8mUznr4gDMKXUmS55rwYuUEqbGxZgxAivGOqk9V45mrwlSmA2EVF2gu\ng7gKp0BWr56N3IwbIpOXsnJQYnJFgg30I6epKRXZqAYRVje9RqYSzVAMxhCy6w0q\n3tzc1mO6UrE0TgLlpagt/3iriSOBxK42SCzOzfMFzFrAatJPQpmgbrYlQeuo5brU\npZoUz45JxId/omUaFuRlyjqU8XE4qDoXw8RSu9E61i9lX3zaWjX554i6tOEjGrdV\n+RnFAu5ql6t0a6qIoWnwcLXVdWmvIFucuKoWK0hiNhGeVa\ntRJcuyWEuI23/XC7PfCavIripsv5M763eRKdMw/8WjcnLu+URK4UkDtB1sUVLegM\nVwaOdEowOr7maq5rcCptJNorKDr7Y/mO5fmbW6wmL34tBlaIH3XWBS8zzrxTDjF7\np80k+KxRiJju/4pvUWBpn2AVCuPp5u9aNycrwJDh95clu59xp4JSrsffUErdruGc\nnkDsrei9+otXYtCK2rgL\n=utVh\n-----END PGP SIGNATURE-----\n", 113 | "payload": "tree e9a224606353911107336fc782c680637178581f\nparent a11cd51fed27f10f1f01dbee91c00f58896e9e39\nauthor octo-sts[bot] <1111+octo-sts[bot]@users.noreply.github.com> 333 +0000\ncommitter GitHub 33333 +0000\n\nblablabal\n\n

\n\n

\n\nSigned-off-by: xoxo-bot <121097084+xoxo-bot@users.noreply.github.com>\nCo-authored-by: xoxo-bot <121097084+xoxo-bot@users.noreply.github.com>" 114 | } 115 | }, 116 | "url": "https://api.github.com/repos/honk/honk/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 117 | "html_url": "https://github.com/honk/honk/commit/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 118 | "comments_url": "https://api.github.com/repos/honk/honk/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11/comments", 119 | "author": { 120 | "login": "octo-sts[bot]", 121 | "id": 157150467, 122 | "node_id": "BOT_kgDOCV3tAw", 123 | "avatar_url": "https://avatars.githubusercontent.com/in/801323?v=4", 124 | "gravatar_id": "", 125 | "url": "https://api.github.com/users/octo-sts%5Bbot%5D", 126 | "html_url": "https://github.com/apps/octo-sts", 127 | "followers_url": "https://api.github.com/users/octo-sts%5Bbot%5D/followers", 128 | "following_url": "https://api.github.com/users/octo-sts%5Bbot%5D/following{/other_user}", 129 | "gists_url": "https://api.github.com/users/octo-sts%5Bbot%5D/gists{/gist_id}", 130 | "starred_url": "https://api.github.com/users/octo-sts%5Bbot%5D/starred{/owner}{/repo}", 131 | "subscriptions_url": "https://api.github.com/users/octo-sts%5Bbot%5D/subscriptions", 132 | "organizations_url": "https://api.github.com/users/octo-sts%5Bbot%5D/orgs", 133 | "repos_url": "https://api.github.com/users/octo-sts%5Bbot%5D/repos", 134 | "events_url": "https://api.github.com/users/octo-sts%5Bbot%5D/events{/privacy}", 135 | "received_events_url": "https://api.github.com/users/octo-sts%5Bbot%5D/received_events", 136 | "type": "Bot", 137 | "user_view_type": "public", 138 | "site_admin": false 139 | }, 140 | "committer": { 141 | "login": "web-flow", 142 | "id": 19864447, 143 | "node_id": "MDQ6VXNlcjE5ODY0NDQ3", 144 | "avatar_url": "https://avatars.githubusercontent.com/u/19864447?v=4", 145 | "gravatar_id": "", 146 | "url": "https://api.github.com/users/web-flow", 147 | "html_url": "https://github.com/web-flow", 148 | "followers_url": "https://api.github.com/users/web-flow/followers", 149 | "following_url": "https://api.github.com/users/web-flow/following{/other_user}", 150 | "gists_url": "https://api.github.com/users/web-flow/gists{/gist_id}", 151 | "starred_url": "https://api.github.com/users/web-flow/starred{/owner}{/repo}", 152 | "subscriptions_url": "https://api.github.com/users/web-flow/subscriptions", 153 | "organizations_url": "https://api.github.com/users/web-flow/orgs", 154 | "repos_url": "https://api.github.com/users/web-flow/repos", 155 | "events_url": "https://api.github.com/users/web-flow/events{/privacy}", 156 | "received_events_url": "https://api.github.com/users/web-flow/received_events", 157 | "type": "User", 158 | "user_view_type": "public", 159 | "site_admin": false 160 | }, 161 | "parents": [ 162 | { 163 | "sha": "a11cd51fed27f10f1f01dbee91c00f58896e9e39", 164 | "url": "https://api.github.com/repos/honk/honk/commits/a11cd51fed27f10f1f01dbee91c00f58896e9e39", 165 | "html_url": "https://github.com/honk/honk/commit/a11cd51fed27f10f1f01dbee91c00f58896e9e39" 166 | } 167 | ] 168 | }, 169 | "status": "ahead", 170 | "ahead_by": 1, 171 | "behind_by": 0, 172 | "total_commits": 1, 173 | "commits": [ 174 | { 175 | "sha": "f7d76624a7495b6d769bb533d996cb25765dd71e", 176 | "node_id": "C_kwDOH9LtsNoAKGY3ZDc4OTI0YTc0OTViNmQ3NjliYjUzM2Q5OTZjYjI1NzY1ZGQ3MWU", 177 | "commit": { 178 | "author": { 179 | "name": "Honk", 180 | "email": "Honk@example.dev", 181 | "date": "2024-11-06T09:48:39Z" 182 | }, 183 | "committer": { 184 | "name": "GitHub", 185 | "email": "noreply@github.com", 186 | "date": "2024-11-06T09:48:39Z" 187 | }, 188 | "message": "honk\n\n", 189 | "tree": { 190 | "sha": "43af49a6af0801dc11c691117b999c283a40c2a0", 191 | "url": "https://api.github.com/repos/honk/honk/git/trees/43af49a6af0801dc11c691117b999c283a40c2a0" 192 | }, 193 | "url": "https://api.github.com/repos/honk/honk/git/commits/f7d76624a7495b6d769bb533d996cb25765dd71e", 194 | "comment_count": 0, 195 | "verification": { 196 | "verified": true, 197 | "reason": "valid", 198 | "signature": "-----BEGIN PGP SIGNATURE-----\n\nwsFcBAABCcBa/0lHm7p1\n7wtlxN8vYyK1KgN1tbjopR03QIusJSgtGlh9oe3iwvJgGat7m1eYVNgxwrGRFxKr\n5yxmGPR0D8z7uf7IaNWKRCRQyJdyDjvPX6RPJQT/8H+5YpMqBUu3RsKX7ppBeGIn\nCf7pWMlI5qtj8RslKiZr0+L7SkFxy1lTAvXKGrVDmz1Vp0/8ajnQUu1o/F2wgzBz\n4iNNq/UFoLZFyRVmoion9wILbXu7MXHn5txohqdsqz+pe4fw9o6W04prBVfw0JRL\n+fYPz0eeGtlx1YkD8mOjtQBm1y1RjhqBCrowhZl/G/ZYfdy/reA79JLQvP3qpoQF\nYXEczpCHDg+x+LbsKxRSXgfATo1zkW3EMahVkgtXrTTwiDD8FoXJFg3wS7MNYexa\nyIQxGdXCw6NKHZCz9hZq8NIupk7qhHUlArKwp2sNzB3W2MC+S/G8DmQ9fq7AHtRG\nvHQAXTS2X+h4fjdLE7yUILJT6lHoVl8+TC9X89MA76Ac43KhyOgnKxti/IPaN9yT\nxxBmjrHsKcP3T2wAcW6gtnW6iSsgfmNxwkARSraok+4ceRTbMPVSz9NZVKDWQH0T\nRrYZgM3xWteUAYgLqcUBbU/qf9xy03AwvSBwt9HH5WLsHpYGP9dT1Pq66PzrMiss\nlfurgdW5v/V0CFRgWArP\n=gMvu\n-----END PGP SIGNATURE-----\n", 199 | "payload": "tree 43af49a6af0801dc11c691117b999c283a40c2a0\nparent d8cae53e2e6cfc3ae8411895252dbc5137a84e11\nauthor Honk 3333 +0000\ncommitter GitHub 3333 +0000\n\nhonk message (#3333)\n\nSigned-off-by: Honk " 200 | } 201 | }, 202 | "url": "https://api.github.com/repos/honk/honk/commits/f7d76624a7495b6d769bb533d996cb25765dd71e", 203 | "html_url": "https://github.com/honk/honk/commit/f7d76624a7495b6d769bb533d996cb25765dd71e", 204 | "comments_url": "https://api.github.com/repos/honk/honk/commits/f7d76624a7495b6d769bb533d996cb25765dd71e/comments", 205 | "author": { 206 | "login": "honk", 207 | "id": 11111, 208 | "node_id": "MDQ6VXNlcjE0NTcxODA=", 209 | "avatar_url": "https://avatars.githubusercontent.com/u/222?v=4", 210 | "gravatar_id": "", 211 | "url": "https://api.github.com/users/honk", 212 | "html_url": "https://github.com/honk", 213 | "followers_url": "https://api.github.com/users/honk/followers", 214 | "following_url": "https://api.github.com/users/honk/following{/other_user}", 215 | "gists_url": "https://api.github.com/users/honk/gists{/gist_id}", 216 | "starred_url": "https://api.github.com/users/honk/starred{/owner}{/repo}", 217 | "subscriptions_url": "https://api.github.com/users/honk/subscriptions", 218 | "organizations_url": "https://api.github.com/users/honk/orgs", 219 | "repos_url": "https://api.github.com/users/honk/repos", 220 | "events_url": "https://api.github.com/users/honk/events{/privacy}", 221 | "received_events_url": "https://api.github.com/users/honk/received_events", 222 | "type": "User", 223 | "user_view_type": "public", 224 | "site_admin": false 225 | }, 226 | "committer": { 227 | "login": "web-flow", 228 | "id": 19864447, 229 | "node_id": "MDQ6VXNlcjE5ODY0NDQ3", 230 | "avatar_url": "https://avatars.githubusercontent.com/u/19864447?v=4", 231 | "gravatar_id": "", 232 | "url": "https://api.github.com/users/web-flow", 233 | "html_url": "https://github.com/web-flow", 234 | "followers_url": "https://api.github.com/users/web-flow/followers", 235 | "following_url": "https://api.github.com/users/web-flow/following{/other_user}", 236 | "gists_url": "https://api.github.com/users/web-flow/gists{/gist_id}", 237 | "starred_url": "https://api.github.com/users/web-flow/starred{/owner}{/repo}", 238 | "subscriptions_url": "https://api.github.com/users/web-flow/subscriptions", 239 | "organizations_url": "https://api.github.com/users/web-flow/orgs", 240 | "repos_url": "https://api.github.com/users/web-flow/repos", 241 | "events_url": "https://api.github.com/users/web-flow/events{/privacy}", 242 | "received_events_url": "https://api.github.com/users/web-flow/received_events", 243 | "type": "User", 244 | "user_view_type": "public", 245 | "site_admin": false 246 | }, 247 | "parents": [ 248 | { 249 | "sha": "d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 250 | "url": "https://api.github.com/repos/honk/honk/commits/d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 251 | "html_url": "https://github.com/honk/honk/commit/d8cae53e2e6cfc3ae8411895252dbc5137a84e11" 252 | } 253 | ] 254 | } 255 | ], 256 | "files": [ 257 | { 258 | "sha": "a3b28c5604db0beb3c2263ca4f6a7784f1c35dad", 259 | "filename": ".github/chainguard/test2.sts.yaml", 260 | "status": "added", 261 | "additions": 9, 262 | "deletions": 0, 263 | "changes": 9, 264 | "blob_url": "https://github.com/honk/honk/blob/f7d76624a7495b6d769bb533d996cb25765dd71e/.github%2Fchainguard%2Ftest2.sts.yaml", 265 | "raw_url": "https://github.com/honk/honk/raw/f7d76624a7495b6d769bb533d996cb25765dd71e/.github%2Fchainguard%2Ftest2.sts.yaml", 266 | "contents_url": "https://api.github.com/repos/honk/honk/contents/.github%2Fchainguard%2Ftest2.sts.yaml?ref=f7d76624a7495b6d769bb533d996cb25765dd71e", 267 | "patch": "@@ -0,0 +1,9 @@\n+issuer: https://accounts.google.com\n+\n+# cdcds: @cd\n+subject: \"ddddd\"\n+\n+permissions:\n+ contents: read\n+ pull_requests: write" 268 | }, 269 | { 270 | "sha": "a68218715fb54742a65eea31e0e39958f5a374da", 271 | "filename": ".github/chainguard/removed-example.sts.yaml", 272 | "status": "removed", 273 | "additions": 0, 274 | "deletions": 9, 275 | "changes": 9, 276 | "blob_url": "https://github.com/honk/honk/blob/d8cae53e2e6cfc3ae8411895252dbc5137a84e11/.github%2Fchainguard%2Flifecycle-gpt.sts.yaml", 277 | "raw_url": "https://github.com/honk/honk/raw/d8cae53e2e6cfc3ae8411895252dbc5137a84e11/.github%2Fchainguard%2Flifecycle-gpt.sts.yaml", 278 | "contents_url": "https://api.github.com/repos/honk/honk/contents/.github%2Fchainguard%2Flifecycle-gpt.sts.yaml?ref=d8cae53e2e6cfc3ae8411895252dbc5137a84e11", 279 | "patch": "@@ -1,9 +0,0 @@\n-issuer:" 280 | } 281 | ] 282 | } -------------------------------------------------------------------------------- /pkg/webhook/testdata/api/v3/repos/foo/bar/contents/.github/chainguard/test.sts.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verify-prod.sts.yaml", 3 | "path": ".github/chainguard/verify-prod.sts.yaml", 4 | "sha": "9f4890d268e2ad02d4171e131018597a7651f61e", 5 | "size": 284, 6 | "url": "https://api.github.com/repos/octo-sts/app/contents/.github/chainguard/verify-prod.sts.yaml?ref=main", 7 | "html_url": "https://github.com/octo-sts/app/blob/main/.github/chainguard/verify-prod.sts.yaml", 8 | "git_url": "https://api.github.com/repos/octo-sts/app/git/blobs/9f4890d268e2ad02d4171e131018597a7651f61e", 9 | "download_url": "https://raw.githubusercontent.com/octo-sts/app/main/.github/chainguard/verify-prod.sts.yaml", 10 | "type": "file", 11 | "content": "IyBDb3B5cmlnaHQgMjAyNCBDaGFpbmd1YXJkLCBJbmMuCiMgU1BEWC1MaWNl\nbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjAKCmlzc3VlcjogaHR0cHM6Ly90\nb2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbQpzdWJqZWN0OiBy\nZXBvOm9jdG8tc3RzL2FwcDpwdWxsX3JlcXVlc3QKY2xhaW1fcGF0dGVybjoK\nICB3b3JrZmxvd19yZWY6IG9jdG8tc3RzL2FwcC8uZ2l0aHViL3dvcmtmbG93\ncy92ZXJpZnktcHJvZC55YW1sQC4qCgpwZXJtaXNzaW9uczoKICBwdWxsX3Jl\ncXVlc3RzOiB3cml0ZQo=\n", 12 | "encoding": "base64", 13 | "_links": { 14 | "self": "https://api.github.com/repos/octo-sts/app/contents/.github/chainguard/verify-prod.sts.yaml?ref=main", 15 | "git": "https://api.github.com/repos/octo-sts/app/git/blobs/9f4890d268e2ad02d4171e131018597a7651f61e", 16 | "html": "https://github.com/octo-sts/app/blob/main/.github/chainguard/verify-prod.sts.yaml" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/webhook/testdata/api/v3/repos/foo/bar/contents/.github/chainguard/test2.sts.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test2.sts.yaml", 3 | "path": ".github/chainguard/test2.sts.yaml", 4 | "sha": "9f4890d268e2ad02d4171e131018597a7651f61e", 5 | "size": 284, 6 | "url": "https://api.github.com/repos/octo-sts/app/contents/.github/chainguard/test2.sts.yaml?ref=main", 7 | "html_url": "https://github.com/octo-sts/app/blob/main/.github/chainguard/test2.sts.yaml", 8 | "git_url": "https://api.github.com/repos/octo-sts/app/git/blobs/9f4890d268e2ad02d4171e131018597a7651f61e", 9 | "download_url": "https://raw.githubusercontent.com/octo-sts/app/main/.github/chainguard/test2.sts.yaml", 10 | "type": "file", 11 | "content": "IyBDb3B5cmlnaHQgMjAyNCBDaGFpbmd1YXJkLCBJbmMuCiMgU1BEWC1MaWNl\nbnNlLUlkZW50aWZpZXI6IEFwYWNoZS0yLjAKCmlzc3VlcjogaHR0cHM6Ly90\nb2tlbi5hY3Rpb25zLmdpdGh1YnVzZXJjb250ZW50LmNvbQpzdWJqZWN0OiBy\nZXBvOm9jdG8tc3RzL2FwcDpwdWxsX3JlcXVlc3QKY2xhaW1fcGF0dGVybjoK\nICB3b3JrZmxvd19yZWY6IG9jdG8tc3RzL2FwcC8uZ2l0aHViL3dvcmtmbG93\ncy92ZXJpZnktcHJvZC55YW1sQC4qCgpwZXJtaXNzaW9uczoKICBwdWxsX3Jl\ncXVlc3RzOiB3cml0ZQo=\n", 12 | "encoding": "base64", 13 | "_links": { 14 | "self": "https://api.github.com/repos/octo-sts/app/contents/.github/chainguard/test2.sts.yaml?ref=main", 15 | "git": "https://api.github.com/repos/octo-sts/app/git/blobs/9f4890d268e2ad02d4171e131018597a7651f61e", 16 | "html": "https://github.com/octo-sts/app/blob/main/.github/chainguard/test2.sts.yaml" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /pkg/webhook/testdata/app/installations/1111/access_tokens: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "url": "https://api.github.com/authorizations/1", 4 | "scopes": [], 5 | "token": "ghu_16C7e42F292c6912E7710c838347Ae178B4a", 6 | "token_last_eight": "Ae178B4a", 7 | "hashed_token": "25f94a2a5c7fbaf499c665bc73d67c1c87e496da8985131633ee0a95819db2e8", 8 | "app": { 9 | "url": "http://my-github-app.com", 10 | "name": "my github app", 11 | "client_id": "Iv1.8a61f9b3a7aba766" 12 | }, 13 | "note": "optional note", 14 | "note_url": "http://optional/note/url", 15 | "updated_at": "2011-09-06T20:39:23Z", 16 | "created_at": "2011-09-06T17:26:27Z", 17 | "fingerprint": "jklmnop12345678", 18 | "expires_at": "2011-09-08T17:26:27Z", 19 | "user": { 20 | "login": "octocat", 21 | "id": 1, 22 | "node_id": "MDQ6VXNlcjE=", 23 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 24 | "gravatar_id": "", 25 | "url": "https://api.github.com/users/octocat", 26 | "html_url": "https://github.com/octocat", 27 | "followers_url": "https://api.github.com/users/octocat/followers", 28 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 29 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 30 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 31 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 32 | "organizations_url": "https://api.github.com/users/octocat/orgs", 33 | "repos_url": "https://api.github.com/users/octocat/repos", 34 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 35 | "received_events_url": "https://api.github.com/users/octocat/received_events", 36 | "type": "User", 37 | "site_admin": false 38 | }, 39 | "installation": { 40 | "permissions": { 41 | "metadata": "read", 42 | "issues": "write", 43 | "contents": "read" 44 | }, 45 | "repository_selection": "selected", 46 | "single_file_name": ".github/workflow.yml", 47 | "repositories_url": "https://api.github.com/user/repos", 48 | "account": { 49 | "login": "octocat", 50 | "id": 1, 51 | "node_id": "MDQ6VXNlcjE=", 52 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 53 | "gravatar_id": "", 54 | "url": "https://api.github.com/users/octocat", 55 | "html_url": "https://github.com/octocat", 56 | "followers_url": "https://api.github.com/users/octocat/followers", 57 | "following_url": "https://api.github.com/users/octocat/following{/other_user}", 58 | "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", 59 | "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", 60 | "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", 61 | "organizations_url": "https://api.github.com/users/octocat/orgs", 62 | "repos_url": "https://api.github.com/users/octocat/repos", 63 | "events_url": "https://api.github.com/users/octocat/events{/privacy}", 64 | "received_events_url": "https://api.github.com/users/octocat/received_events", 65 | "type": "User", 66 | "site_admin": false 67 | }, 68 | "has_multiple_single_files": false, 69 | "single_file_paths": [] 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/webhook/webhook.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package webhook 5 | 6 | import ( 7 | "bytes" 8 | "context" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "mime" 13 | "net/http" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | 18 | "github.com/bradleyfalzon/ghinstallation/v2" 19 | "github.com/chainguard-dev/clog" 20 | "github.com/google/go-github/v71/github" 21 | "github.com/hashicorp/go-multierror" 22 | "k8s.io/apimachinery/pkg/util/sets" 23 | "sigs.k8s.io/yaml" 24 | 25 | "github.com/octo-sts/app/pkg/octosts" 26 | ) 27 | 28 | const ( 29 | // See https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#delivery-headers for list of available headers 30 | 31 | // HeaderDelivery is the GUID of the webhook event. 32 | HeaderDelivery = "X-GitHub-Delivery" 33 | // HeaderEvent is the event name of the webhook. 34 | HeaderEvent = "X-GitHub-Event" 35 | 36 | // zeroHash is a special SHA value indicating a non-existent commit, 37 | // i.e. when a branch is newly created or destroyed. 38 | zeroHash = "0000000000000000000000000000000000000000" 39 | ) 40 | 41 | type Validator struct { 42 | Transport *ghinstallation.AppsTransport 43 | // Store multiple secrets to allow for rolling updates. 44 | // Only one needs to match for the event to be considered valid. 45 | WebhookSecret [][]byte 46 | 47 | Organizations []string 48 | } 49 | 50 | func (e *Validator) ServeHTTP(w http.ResponseWriter, r *http.Request) { 51 | log := clog.FromContext(r.Context()).With( 52 | HeaderDelivery, r.Header.Get(HeaderDelivery), 53 | HeaderEvent, r.Header.Get(HeaderEvent), 54 | ) 55 | ctx := clog.WithLogger(r.Context(), log) 56 | 57 | payload, err := e.validatePayload(r) 58 | if err != nil { 59 | log.Errorf("error validating payload: %v", err) 60 | http.Error(w, err.Error(), http.StatusBadRequest) 61 | return 62 | } 63 | eventType := github.WebHookType(r) 64 | event, err := github.ParseWebHook(eventType, payload) 65 | if err != nil { 66 | log.Errorf("error parsing webhook: %v", err) 67 | http.Error(w, err.Error(), http.StatusBadRequest) 68 | return 69 | } 70 | 71 | // For every event handler, return back an identifier that we can 72 | // return back to the webhook in case we need to debug. This could 73 | // be the resource that was created, an event ID, etc. 74 | var cr *github.CheckRun 75 | switch event := event.(type) { 76 | case *github.PullRequestEvent: 77 | cr, err = e.handlePullRequest(ctx, event) 78 | case *github.PushEvent: 79 | cr, err = e.handlePush(ctx, event) 80 | case *github.CheckSuiteEvent: 81 | cr, err = e.handleCheckSuite(ctx, event) 82 | case *github.CheckRunEvent: 83 | cr, err = e.handleCheckSuite(ctx, &fauxCheckSuite{event}) 84 | // TODO: CheckRun retry 85 | default: 86 | log.Infof("unsupported event type: %s", eventType) 87 | // Use accepted as "we got it but didn't do anything" 88 | w.WriteHeader(http.StatusAccepted) 89 | return 90 | } 91 | if err != nil { 92 | log.Errorf("error handling event %T: %v", event, err) 93 | http.Error(w, err.Error(), http.StatusInternalServerError) 94 | return 95 | } 96 | 97 | if cr != nil { 98 | log.Info("created CheckRun", "check_run", cr) 99 | } 100 | w.WriteHeader(http.StatusOK) 101 | } 102 | 103 | func (e *Validator) validatePayload(r *http.Request) ([]byte, error) { 104 | // Taken from github.ValidatePayload - we can't use this directly since the body is consumed. 105 | signature := r.Header.Get(github.SHA256SignatureHeader) 106 | if signature == "" { 107 | signature = r.Header.Get(github.SHA1SignatureHeader) 108 | } 109 | contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | body, err := io.ReadAll(r.Body) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | for _, s := range e.WebhookSecret { 120 | payload, err := github.ValidatePayloadFromBody(contentType, bytes.NewBuffer(body), signature, s) 121 | if err == nil { 122 | return payload, nil 123 | } 124 | } 125 | return nil, errors.New("no matching secrets") 126 | } 127 | 128 | func (e *Validator) handleSHA(ctx context.Context, client *github.Client, owner, repo, sha string, files []string) (*github.CheckRun, error) { 129 | log := clog.FromContext(ctx) 130 | 131 | // Commit doesn't exist - nothing to do. 132 | if sha == zeroHash { 133 | return nil, nil 134 | } 135 | 136 | err := validatePolicies(ctx, client, owner, repo, sha, files) 137 | // Whether or not the commit is verified, we still create a CheckRun. 138 | // The only difference is whether it shows up to the user as success or 139 | // failure. 140 | var conclusion, title, summary string 141 | if err == nil { 142 | conclusion = "success" 143 | title = "Valid trust policy." 144 | } else { 145 | conclusion = "failure" 146 | title = "Invalid trust policy." 147 | summary = "Failed to validate trust policy.\n\n" + err.Error() 148 | } 149 | 150 | opts := github.CreateCheckRunOptions{ 151 | Name: "Trust Policy Validation", 152 | HeadSHA: sha, 153 | ExternalID: github.Ptr(sha), 154 | Status: github.Ptr("completed"), 155 | Conclusion: github.Ptr(conclusion), 156 | StartedAt: &github.Timestamp{Time: time.Now()}, 157 | CompletedAt: &github.Timestamp{Time: time.Now()}, 158 | Output: &github.CheckRunOutput{ 159 | Title: github.Ptr(title), 160 | Summary: github.Ptr(summary), 161 | }, 162 | } 163 | 164 | cr, _, err := client.Checks.CreateCheckRun(ctx, owner, repo, opts) 165 | if err != nil { 166 | log.Errorf("error creating CheckRun: %v", err) 167 | return nil, err 168 | } 169 | return cr, nil 170 | } 171 | 172 | func validatePolicies(ctx context.Context, client *github.Client, owner, repo string, sha string, files []string) error { 173 | var merr error 174 | for _, f := range sets.List(sets.New(files...)) { 175 | log := clog.FromContext(ctx).With("path", f) 176 | 177 | resp, _, _, err := client.Repositories.GetContents(ctx, owner, repo, f, &github.RepositoryContentGetOptions{Ref: sha}) 178 | if err != nil { 179 | log.Infof("failed to get content for: %v", err) 180 | merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) 181 | continue 182 | } 183 | 184 | raw, err := resp.GetContent() 185 | if err != nil { 186 | log.Infof("failed to read content: %v", err) 187 | merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) 188 | continue 189 | } 190 | 191 | switch repo { 192 | case ".github": 193 | if err := yaml.UnmarshalStrict([]byte(raw), &octosts.OrgTrustPolicy{}); err != nil { 194 | log.Infof("failed to parse org trust policy: %v", err) 195 | merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) 196 | } 197 | 198 | default: 199 | if err := yaml.UnmarshalStrict([]byte(raw), &octosts.TrustPolicy{}); err != nil { 200 | log.Infof("failed to parse trust policy: %v", err) 201 | merr = multierror.Append(merr, fmt.Errorf("%s: %w", f, err)) 202 | } 203 | } 204 | } 205 | 206 | return merr 207 | } 208 | 209 | func (e *Validator) handlePush(ctx context.Context, event *github.PushEvent) (*github.CheckRun, error) { 210 | log := clog.FromContext(ctx).With( 211 | "github/repo", event.GetRepo().GetFullName(), 212 | "github/installation", event.GetInstallation().GetID(), 213 | "github/action", event.GetAction(), 214 | "git/ref", event.GetRef(), 215 | "git/commit", event.GetAfter(), 216 | "github/user", event.GetSender().GetLogin(), 217 | ) 218 | ctx = clog.WithLogger(ctx, log) 219 | 220 | owner := event.GetRepo().GetOwner().GetLogin() 221 | repo := event.GetRepo().GetName() 222 | sha := event.GetAfter() 223 | installationID := event.GetInstallation().GetID() 224 | 225 | // Skip if the organization is not in the list of organizations to validate. 226 | if e.shouldSkipOrganization(owner) { 227 | log.Infof("skipping organization %s", owner) 228 | return nil, nil 229 | } 230 | 231 | client := github.NewClient(&http.Client{ 232 | Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), 233 | }) 234 | if e.Transport.BaseURL != "" { 235 | var err error 236 | client, err = client.WithEnterpriseURLs(e.Transport.BaseURL, e.Transport.BaseURL) 237 | if err != nil { 238 | return nil, err 239 | } 240 | } 241 | 242 | // Check diff 243 | // TODO: Pagination? 244 | resp, _, err := client.Repositories.CompareCommits(ctx, owner, repo, event.GetBefore(), sha, &github.ListOptions{}) 245 | if err != nil { 246 | return nil, err 247 | } 248 | log.Infof("%+v\n%+v", resp, resp.Files) 249 | var files []string 250 | for _, file := range resp.Files { 251 | if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { 252 | if file.GetStatus() != "removed" { 253 | files = append(files, file.GetFilename()) 254 | } 255 | } 256 | } 257 | if len(files) == 0 { 258 | return nil, nil 259 | } 260 | 261 | return e.handleSHA(ctx, client, owner, repo, sha, files) 262 | } 263 | 264 | func (e *Validator) handlePullRequest(ctx context.Context, pr *github.PullRequestEvent) (*github.CheckRun, error) { 265 | log := clog.FromContext(ctx).With( 266 | "github/repo", pr.GetRepo().GetFullName(), 267 | "github/installation", pr.GetInstallation().GetID(), 268 | "github/action", pr.GetAction(), 269 | "github/pull_request", pr.GetNumber(), 270 | "git/commit", pr.GetPullRequest().GetHead().GetSHA(), 271 | "github/user", pr.GetSender().GetLogin(), 272 | ) 273 | ctx = clog.WithLogger(ctx, log) 274 | 275 | owner := pr.GetRepo().GetOwner().GetLogin() 276 | repo := pr.GetRepo().GetName() 277 | sha := pr.GetPullRequest().GetHead().GetSHA() 278 | installationID := pr.GetInstallation().GetID() 279 | 280 | // Skip if the organization is not in the list of organizations to validate. 281 | if e.shouldSkipOrganization(owner) { 282 | log.Infof("skipping organization %s", owner) 283 | return nil, nil 284 | } 285 | 286 | client := github.NewClient(&http.Client{ 287 | Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), 288 | }) 289 | if e.Transport.BaseURL != "" { 290 | var err error 291 | client, err = client.WithEnterpriseURLs(e.Transport.BaseURL, e.Transport.BaseURL) 292 | if err != nil { 293 | return nil, err 294 | } 295 | } 296 | 297 | // Check diff 298 | var files []string 299 | resp, _, err := client.PullRequests.ListFiles(ctx, owner, repo, pr.GetNumber(), &github.ListOptions{}) 300 | if err != nil { 301 | return nil, err 302 | } 303 | for _, file := range resp { 304 | if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { 305 | if file.GetStatus() != "removed" { 306 | files = append(files, file.GetFilename()) 307 | } 308 | } 309 | } 310 | if len(files) == 0 { 311 | return nil, nil 312 | } 313 | 314 | return e.handleSHA(ctx, client, owner, repo, sha, files) 315 | } 316 | 317 | type checkSuite interface { 318 | GetRepo() *github.Repository 319 | GetInstallation() *github.Installation 320 | GetAction() string 321 | GetCheckSuite() *github.CheckSuite 322 | GetSender() *github.User 323 | } 324 | 325 | func (e *Validator) handleCheckSuite(ctx context.Context, cs checkSuite) (*github.CheckRun, error) { 326 | log := clog.FromContext(ctx).With( 327 | "github/repo", cs.GetRepo().GetFullName(), 328 | "github/installation", cs.GetInstallation().GetID(), 329 | "github/action", cs.GetAction(), 330 | "github/private", cs.GetRepo().GetPrivate(), 331 | "github/checksuite_id", cs.GetCheckSuite().GetID(), 332 | "git/commit", cs.GetCheckSuite().GetHeadSHA(), 333 | "github/user", cs.GetSender().GetLogin(), 334 | ) 335 | ctx = clog.WithLogger(ctx, log) 336 | 337 | owner := cs.GetRepo().GetOwner().GetLogin() 338 | repo := cs.GetRepo().GetName() 339 | sha := cs.GetCheckSuite().GetHeadSHA() 340 | installationID := cs.GetInstallation().GetID() 341 | 342 | // Skip if the organization is not in the list of organizations to validate. 343 | if e.shouldSkipOrganization(owner) { 344 | log.Infof("skipping organization %s", owner) 345 | return nil, nil 346 | } 347 | 348 | client := github.NewClient(&http.Client{ 349 | Transport: ghinstallation.NewFromAppsTransport(e.Transport, installationID), 350 | }) 351 | if e.Transport.BaseURL != "" { 352 | var err error 353 | client, err = client.WithEnterpriseURLs(e.Transport.BaseURL, e.Transport.BaseURL) 354 | if err != nil { 355 | return nil, err 356 | } 357 | } 358 | 359 | var files []string 360 | if cs.GetCheckSuite().GetBeforeSHA() == zeroHash { 361 | _, dirContents, _, err := client.Repositories.GetContents(ctx, owner, repo, ".github/chainguard", &github.RepositoryContentGetOptions{Ref: sha}) 362 | if err != nil { 363 | return nil, err 364 | } 365 | for _, file := range dirContents { 366 | files = append(files, file.GetPath()) 367 | } 368 | } else { 369 | resp, _, err := client.Repositories.CompareCommits(ctx, owner, repo, cs.GetCheckSuite().GetBeforeSHA(), sha, &github.ListOptions{}) 370 | if err != nil { 371 | return nil, err 372 | } 373 | for _, file := range resp.Files { 374 | if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { 375 | if file.GetStatus() != "removed" { 376 | files = append(files, file.GetFilename()) 377 | } 378 | } 379 | } 380 | } 381 | 382 | for _, pr := range cs.GetCheckSuite().PullRequests { 383 | resp, _, err := client.PullRequests.ListFiles(ctx, owner, repo, pr.GetNumber(), &github.ListOptions{}) 384 | if err != nil { 385 | return nil, err 386 | } 387 | for _, file := range resp { 388 | if ok, err := filepath.Match(".github/chainguard/*.sts.yaml", file.GetFilename()); err == nil && ok { 389 | if file.GetStatus() != "removed" { 390 | files = append(files, file.GetFilename()) 391 | } 392 | } 393 | } 394 | } 395 | if len(files) == 0 { 396 | return nil, nil 397 | } 398 | 399 | return e.handleSHA(ctx, client, owner, repo, sha, files) 400 | } 401 | 402 | type fauxCheckSuite struct { 403 | *github.CheckRunEvent 404 | } 405 | 406 | var _ checkSuite = (*fauxCheckSuite)(nil) 407 | 408 | func (f *fauxCheckSuite) GetCheckSuite() *github.CheckSuite { 409 | return f.GetCheckRun().GetCheckSuite() 410 | } 411 | 412 | func (e *Validator) shouldSkipOrganization(org string) bool { 413 | if len(e.Organizations) == 0 { 414 | return false 415 | } 416 | for _, o := range e.Organizations { 417 | if strings.EqualFold(o, org) { 418 | return false 419 | } 420 | } 421 | return true 422 | } 423 | -------------------------------------------------------------------------------- /pkg/webhook/webhook_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Chainguard, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | 4 | package webhook 5 | 6 | import ( 7 | "bytes" 8 | "crypto/hmac" 9 | "crypto/rand" 10 | "crypto/rsa" 11 | "crypto/sha256" 12 | "encoding/hex" 13 | "encoding/json" 14 | "fmt" 15 | "io" 16 | "net/http" 17 | "net/http/httptest" 18 | "net/http/httputil" 19 | "os" 20 | "path/filepath" 21 | "testing" 22 | 23 | "github.com/bradleyfalzon/ghinstallation/v2" 24 | "github.com/chainguard-dev/clog" 25 | "github.com/chainguard-dev/clog/slogtest" 26 | "github.com/google/go-cmp/cmp" 27 | "github.com/google/go-github/v71/github" 28 | ) 29 | 30 | func TestValidatePolicy(t *testing.T) { 31 | // Use prefetched data. 32 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | path := filepath.Join("testdata", r.URL.Path) 34 | f, err := os.Open(path) 35 | if err != nil { 36 | t.Logf("%s not found", path) 37 | http.Error(w, err.Error(), http.StatusNotFound) 38 | return 39 | } 40 | defer f.Close() 41 | if _, err := io.Copy(w, f); err != nil { 42 | http.Error(w, err.Error(), http.StatusInternalServerError) 43 | return 44 | } 45 | })) 46 | 47 | gh, err := github.NewClient(srv.Client()).WithEnterpriseURLs(srv.URL, srv.URL) 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | ctx := slogtest.Context(t) 52 | if err := validatePolicies(ctx, gh, "foo", "bar", "deadbeef", []string{".github/chainguard/test.sts.yaml"}); err != nil { 53 | t.Fatal(err) 54 | } 55 | } 56 | 57 | func TestOrgFilter(t *testing.T) { 58 | gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 59 | http.Error(w, "should not be called", http.StatusUnauthorized) 60 | })) 61 | defer gh.Close() 62 | 63 | key, err := rsa.GenerateKey(rand.Reader, 2048) 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | tr := ghinstallation.NewAppsTransportFromPrivateKey(gh.Client().Transport, 1234, key) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | tr.BaseURL = gh.URL 72 | 73 | secret := []byte("hunter2") 74 | v := &Validator{ 75 | Transport: tr, 76 | WebhookSecret: [][]byte{secret}, 77 | Organizations: []string{"foo"}, 78 | } 79 | srv := httptest.NewServer(v) 80 | defer srv.Close() 81 | 82 | for _, tc := range []struct { 83 | org string 84 | code int 85 | }{ 86 | // This fails because the organization is in the filter, so we try to resolve it but it's pointed at a no-op github backend. 87 | {"foo", http.StatusInternalServerError}, 88 | // This passes because the organization is not in the filter, so the server will fast-return a 200. 89 | {"bar", http.StatusOK}, 90 | } { 91 | t.Run(tc.org, func(t *testing.T) { 92 | body, err := json.Marshal(github.PushEvent{ 93 | Organization: &github.Organization{ 94 | Login: github.Ptr(tc.org), 95 | }, 96 | Repo: &github.PushEventRepository{ 97 | Owner: &github.User{ 98 | Login: github.Ptr(tc.org), 99 | }, 100 | }, 101 | }) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewBuffer(body)) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | req.Header.Set("X-Hub-Signature", signature(secret, body)) 110 | req.Header.Set("X-GitHub-Event", "push") 111 | req.Header.Set("Content-Type", "application/json") 112 | resp, err := srv.Client().Do(req.WithContext(slogtest.Context(t))) 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | if resp.StatusCode != tc.code { 117 | out, _ := httputil.DumpResponse(resp, true) 118 | t.Fatalf("expected %d, got\n%s", tc.code, string(out)) 119 | } 120 | }) 121 | } 122 | } 123 | 124 | func signature(secret, body []byte) string { 125 | mac := hmac.New(sha256.New, secret) 126 | mac.Write(body) 127 | b := mac.Sum(nil) 128 | 129 | return fmt.Sprintf("sha256=%s", hex.EncodeToString(b)) 130 | } 131 | 132 | func TestWebhookOK(t *testing.T) { 133 | // CheckRuns will be collected here. 134 | got := []*github.CreateCheckRunOptions{} 135 | 136 | mux := http.NewServeMux() 137 | mux.HandleFunc("POST /api/v3/repos/foo/bar/check-runs", func(w http.ResponseWriter, r *http.Request) { 138 | opt := new(github.CreateCheckRunOptions) 139 | if err := json.NewDecoder(r.Body).Decode(opt); err != nil { 140 | http.Error(w, err.Error(), http.StatusBadRequest) 141 | return 142 | } 143 | got = append(got, opt) 144 | }) 145 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 146 | path := filepath.Join("testdata", r.URL.Path) 147 | f, err := os.Open(path) 148 | if err != nil { 149 | clog.FromContext(r.Context()).Errorf("%s not found", path) 150 | http.Error(w, err.Error(), http.StatusNotFound) 151 | return 152 | } 153 | defer f.Close() 154 | if _, err := io.Copy(w, f); err != nil { 155 | http.Error(w, err.Error(), http.StatusInternalServerError) 156 | return 157 | } 158 | }) 159 | gh := httptest.NewServer(mux) 160 | defer gh.Close() 161 | 162 | key, err := rsa.GenerateKey(rand.Reader, 2048) 163 | if err != nil { 164 | t.Fatal(err) 165 | } 166 | tr := ghinstallation.NewAppsTransportFromPrivateKey(gh.Client().Transport, 1234, key) 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | tr.BaseURL = gh.URL 171 | 172 | secret := []byte("hunter2") 173 | v := &Validator{ 174 | Transport: tr, 175 | WebhookSecret: [][]byte{secret}, 176 | } 177 | srv := httptest.NewServer(v) 178 | defer srv.Close() 179 | 180 | body, err := json.Marshal(github.PushEvent{ 181 | Installation: &github.Installation{ 182 | ID: github.Ptr(int64(1111)), 183 | }, 184 | Organization: &github.Organization{ 185 | Login: github.Ptr("foo"), 186 | }, 187 | Repo: &github.PushEventRepository{ 188 | Owner: &github.User{ 189 | Login: github.Ptr("foo"), 190 | }, 191 | Name: github.Ptr("bar"), 192 | }, 193 | Before: github.Ptr("1234"), 194 | After: github.Ptr("5678"), 195 | }) 196 | if err != nil { 197 | t.Fatal(err) 198 | } 199 | req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewBuffer(body)) 200 | if err != nil { 201 | t.Fatal(err) 202 | } 203 | req.Header.Set("X-Hub-Signature", signature(secret, body)) 204 | req.Header.Set("X-GitHub-Event", "push") 205 | req.Header.Set("Content-Type", "application/json") 206 | resp, err := srv.Client().Do(req.WithContext(slogtest.Context(t))) 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | if resp.StatusCode != 200 { 211 | out, _ := httputil.DumpResponse(resp, true) 212 | t.Fatalf("expected %d, got\n%s", 200, string(out)) 213 | } 214 | 215 | if len(got) != 1 { 216 | t.Fatalf("expected 1 check run, got %d", len(got)) 217 | } 218 | 219 | want := []*github.CreateCheckRunOptions{{ 220 | Name: "Trust Policy Validation", 221 | HeadSHA: "5678", 222 | ExternalID: github.Ptr("5678"), 223 | Status: github.Ptr("completed"), 224 | Conclusion: github.Ptr("success"), 225 | // Use time from the response to ignore it. 226 | StartedAt: &github.Timestamp{Time: got[0].StartedAt.Time}, 227 | CompletedAt: &github.Timestamp{Time: got[0].CompletedAt.Time}, 228 | Output: &github.CheckRunOutput{ 229 | Title: github.Ptr("Valid trust policy."), 230 | Summary: github.Ptr(""), 231 | }, 232 | }} 233 | if diff := cmp.Diff(want, got); diff != "" { 234 | t.Fatalf("unexpected check run (-want +got):\n%s", diff) 235 | } 236 | } 237 | 238 | func TestWebhookDeletedSTS(t *testing.T) { 239 | // CheckRuns will be collected here. 240 | got := []*github.CreateCheckRunOptions{} 241 | 242 | mux := http.NewServeMux() 243 | mux.HandleFunc("POST /api/v3/repos/foo/bar/check-runs", func(w http.ResponseWriter, r *http.Request) { 244 | opt := new(github.CreateCheckRunOptions) 245 | if err := json.NewDecoder(r.Body).Decode(opt); err != nil { 246 | http.Error(w, err.Error(), http.StatusBadRequest) 247 | return 248 | } 249 | got = append(got, opt) 250 | }) 251 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 252 | path := filepath.Join("testdata", r.URL.Path) 253 | f, err := os.Open(path) 254 | if err != nil { 255 | clog.FromContext(r.Context()).Errorf("%s not found", path) 256 | http.Error(w, err.Error(), http.StatusNotFound) 257 | return 258 | } 259 | defer f.Close() 260 | if _, err := io.Copy(w, f); err != nil { 261 | http.Error(w, err.Error(), http.StatusInternalServerError) 262 | return 263 | } 264 | }) 265 | gh := httptest.NewServer(mux) 266 | defer gh.Close() 267 | 268 | key, err := rsa.GenerateKey(rand.Reader, 2048) 269 | if err != nil { 270 | t.Fatal(err) 271 | } 272 | tr := ghinstallation.NewAppsTransportFromPrivateKey(gh.Client().Transport, 1234, key) 273 | if err != nil { 274 | t.Fatal(err) 275 | } 276 | tr.BaseURL = gh.URL 277 | 278 | secret := []byte("hunter2") 279 | v := &Validator{ 280 | Transport: tr, 281 | WebhookSecret: [][]byte{secret}, 282 | } 283 | srv := httptest.NewServer(v) 284 | defer srv.Close() 285 | 286 | body, err := json.Marshal(github.PushEvent{ 287 | Installation: &github.Installation{ 288 | ID: github.Ptr(int64(1111)), 289 | }, 290 | Organization: &github.Organization{ 291 | Login: github.Ptr("foo"), 292 | }, 293 | Repo: &github.PushEventRepository{ 294 | Owner: &github.User{ 295 | Login: github.Ptr("foo"), 296 | }, 297 | Name: github.Ptr("bar"), 298 | }, 299 | Before: github.Ptr("9876"), 300 | After: github.Ptr("4321"), 301 | }) 302 | if err != nil { 303 | t.Fatal(err) 304 | } 305 | req, err := http.NewRequest(http.MethodPost, srv.URL, bytes.NewBuffer(body)) 306 | if err != nil { 307 | t.Fatal(err) 308 | } 309 | req.Header.Set("X-Hub-Signature", signature(secret, body)) 310 | req.Header.Set("X-GitHub-Event", "push") 311 | req.Header.Set("Content-Type", "application/json") 312 | resp, err := srv.Client().Do(req.WithContext(slogtest.Context(t))) 313 | if err != nil { 314 | t.Fatal(err) 315 | } 316 | if resp.StatusCode != 200 { 317 | out, _ := httputil.DumpResponse(resp, true) 318 | t.Fatalf("expected %d, got\n%s", 200, string(out)) 319 | } 320 | 321 | if len(got) != 1 { 322 | t.Fatalf("expected 1 check run, got %d", len(got)) 323 | } 324 | 325 | want := []*github.CreateCheckRunOptions{{ 326 | Name: "Trust Policy Validation", 327 | HeadSHA: "4321", 328 | ExternalID: github.Ptr("4321"), 329 | Status: github.Ptr("completed"), 330 | Conclusion: github.Ptr("success"), 331 | // Use time from the response to ignore it. 332 | StartedAt: &github.Timestamp{Time: got[0].StartedAt.Time}, 333 | CompletedAt: &github.Timestamp{Time: got[0].CompletedAt.Time}, 334 | Output: &github.CheckRunOutput{ 335 | Title: github.Ptr("Valid trust policy."), 336 | Summary: github.Ptr(""), 337 | }, 338 | }} 339 | if diff := cmp.Diff(want, got); diff != "" { 340 | t.Fatalf("unexpected check run (-want +got):\n%s", diff) 341 | } 342 | } 343 | --------------------------------------------------------------------------------