├── .github ├── dependabot.yml └── workflows │ ├── check.yml │ ├── codeql_analysis.yml │ ├── ct_reusable_monitoring.yml │ ├── dependency_review.yml │ ├── main.yml │ ├── reusable_monitoring.yml │ ├── scorecard.yml │ ├── scripts │ ├── report_failure.sh │ └── report_success.sh │ └── verify.yml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── COPYRIGHT.txt ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── ct_monitor │ └── main.go └── rekor_monitor │ └── main.go ├── codecov.yml ├── docker-compose.yaml ├── go.mod ├── go.sum └── pkg ├── ct ├── consistency.go ├── consistency_test.go ├── monitor.go ├── monitor_test.go └── test_utils.go ├── fulcio └── extensions │ ├── extensions.go │ └── extensions_test.go ├── identity ├── identity.go └── identity_test.go ├── notifications ├── email.go ├── email_test.go ├── github_issues.go ├── github_issues_test.go ├── mailgun.go ├── mailgun_test.go ├── notifications.go ├── notifications_test.go ├── sendgrid.go └── sendgrid_test.go ├── rekor ├── client.go ├── client_test.go ├── identity.go ├── identity_test.go ├── mock │ └── mock_rekor_client.go ├── verifier.go └── verifier_test.go ├── test ├── cert_utils.go └── rekor_e2e │ ├── rekor_monitor_e2e_test.go │ └── rekor_monitor_e2e_test.sh └── util ├── file ├── file.go └── file_test.go ├── retry.go └── retry_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | version: 2 16 | updates: 17 | - package-ecosystem: "gomod" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | interval: "weekly" 25 | - package-ecosystem: "docker" 26 | directory: "/" 27 | schedule: 28 | interval: "weekly" 29 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Consistency proof 16 | 17 | on: 18 | schedule: 19 | - cron: '0 * * * *' # every hour 20 | workflow_dispatch: 21 | 22 | permissions: read-all 23 | 24 | jobs: 25 | run_consistency_proof: 26 | permissions: 27 | contents: read 28 | issues: write 29 | id-token: write # Needed to detect the current reusable repository and ref 30 | uses: sigstore/rekor-monitor/.github/workflows/reusable_monitoring.yml@main 31 | with: 32 | file_issue: true 33 | -------------------------------------------------------------------------------- /.github/workflows/codeql_analysis.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#changing-the-languages-that-are-analyzed 16 | name: CodeQL 17 | on: 18 | push: 19 | branches: [ main ] 20 | pull_request: 21 | # The branches below must be a subset of the branches above 22 | branches: [ main ] 23 | schedule: 24 | - cron: '45 10 * * 1' 25 | 26 | permissions: 27 | contents: read 28 | security-events: write 29 | 30 | jobs: 31 | analyze: 32 | name: Analyze 33 | runs-on: ubuntu-latest 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: [ 'go' ] 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 46 | with: 47 | languages: ${{ matrix.language }} 48 | 49 | - name: Autobuild 50 | uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 54 | -------------------------------------------------------------------------------- /.github/workflows/ct_reusable_monitoring.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Certificate Transparency Monitoring Template 16 | 17 | on: 18 | workflow_call: 19 | inputs: 20 | once: 21 | description: 'whether to run the identity monitor once or periodically' 22 | default: true 23 | required: false 24 | type: boolean 25 | config: 26 | description: 'multiline yaml of configuration settings for identity monitor run' 27 | required: false 28 | type: string 29 | url: 30 | description: 'Optional URL to pass to the monitor' 31 | required: false 32 | type: string 33 | 34 | permissions: 35 | contents: read 36 | 37 | env: 38 | UPLOADED_LOG_NAME: ct_checkpoint 39 | LOG_FILE: ct_checkpoint_log.txt 40 | 41 | jobs: 42 | detect-workflow: 43 | runs-on: ubuntu-latest 44 | permissions: 45 | id-token: write # Needed to detect the current reusable repository and ref. 46 | outputs: 47 | repository: ${{ steps.detect.outputs.repository }} 48 | ref: ${{ steps.detect.outputs.ref }} 49 | timeout-minutes: 60 50 | steps: 51 | - name: Detect the repository and ref 52 | id: detect 53 | uses: slsa-framework/slsa-github-generator/.github/actions/detect-workflow-js@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 54 | # NOTE: This GHA should not be run concurrently. 55 | concurrency: 56 | group: certificate-transparency-monitor 57 | cancel-in-progress: true 58 | 59 | monitor: 60 | runs-on: ubuntu-latest 61 | needs: [detect-workflow] 62 | timeout-minutes: 60 63 | steps: 64 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 65 | with: 66 | repository: ${{ needs.detect-workflow.outputs.repository }} 67 | ref: "${{ needs.detect-workflow.outputs.ref }}" 68 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 69 | with: 70 | go-version: '1.23' 71 | - name: Download artifact 72 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9 73 | with: 74 | name: ${{ env.UPLOADED_LOG_NAME }} 75 | # Skip on first run since there will be no checkpoint 76 | continue-on-error: true 77 | - name: Log current checkpoints 78 | run: cat ${{ env.LOG_FILE }} 79 | # Skip on first run 80 | continue-on-error: true 81 | - run: | 82 | go run ./cmd/ct_monitor \ 83 | --config ${{ inputs.config }} \ 84 | --file ${{ env.LOG_FILE }} \ 85 | --once=${{ inputs.once }} \ 86 | ${{ inputs.url && format('--url {0}', inputs.url) || '' }} 87 | 88 | - name: Upload checkpoint 89 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 90 | with: 91 | name: ${{ env.UPLOADED_LOG_NAME }} 92 | path: ${{ env.LOG_FILE }} 93 | retention-days: ${{ inputs.artifact_retention_days }} 94 | - name: Log new checkpoints 95 | run: cat ${{ env.LOG_FILE }} -------------------------------------------------------------------------------- /.github/workflows/dependency_review.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: 'Dependency Review' 16 | on: [pull_request] 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | dependency-review: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: 'Checkout Repository' 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | - name: 'Dependency Review' 28 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 The Sigstore Authors. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | name: CI 17 | 18 | on: 19 | push: 20 | branches: [ main ] 21 | pull_request: 22 | branches: [ main ] 23 | 24 | permissions: 25 | contents: read 26 | 27 | jobs: 28 | unit-tests: 29 | name: Run unit tests 30 | permissions: 31 | contents: read 32 | runs-on: ubuntu-latest 33 | 34 | env: 35 | OS: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 39 | # https://github.com/mvdan/github-actions-golang#how-do-i-set-up-caching-between-builds 40 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 41 | with: 42 | # In order: 43 | # * Module download cache 44 | # * Build cache (Linux) 45 | path: | 46 | ~/go/pkg/mod 47 | ~/.cache/go-build 48 | ~/Library/Caches/go-build 49 | %LocalAppData%\go-build 50 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 51 | restore-keys: | 52 | ${{ runner.os }}-go- 53 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 54 | with: 55 | go-version-file: './go.mod' 56 | check-latest: true 57 | - name: Run Go tests 58 | run: go test -covermode atomic -coverprofile coverage.txt $(go list ./... | grep -v third_party/) 59 | - name: Upload Coverage Report 60 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 61 | with: 62 | env_vars: OS 63 | - name: Run Go tests w/ `-race` 64 | if: ${{ runner.os == 'Linux' }} 65 | run: go test -race $(go list ./... | grep -v third_party/) 66 | 67 | e2e-tests: 68 | name: Run end-to-end tests 69 | runs-on: ubuntu-latest 70 | 71 | steps: 72 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 73 | # https://github.com/mvdan/github-actions-golang#how-do-i-set-up-caching-between-builds 74 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 75 | with: 76 | # In order: 77 | # * Module download cache 78 | # * Build cache (Linux) 79 | path: | 80 | ~/go/pkg/mod 81 | ~/.cache/go-build 82 | ~/Library/Caches/go-build 83 | %LocalAppData%\go-build 84 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 85 | restore-keys: | 86 | ${{ runner.os }}-go- 87 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 88 | with: 89 | go-version-file: './go.mod' 90 | check-latest: true 91 | - name: run Rekor end-to-end test 92 | run: ./pkg/test/rekor_e2e/rekor_monitor_e2e_test.sh 93 | 94 | -------------------------------------------------------------------------------- /.github/workflows/reusable_monitoring.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Rekor Monitoring Template 16 | 17 | on: 18 | workflow_call: 19 | inputs: 20 | file_issue: 21 | description: 'True to file an issue on monitoring failure' 22 | required: true 23 | type: boolean 24 | artifact_retention_days: 25 | description: 'The number of days to retain an artifact (default: 14). If this workflow runs as a cron job, it must be greater than the frequency of the job' 26 | required: false 27 | type: number 28 | default: 14 29 | once: 30 | description: 'whether to run the identity monitor once or periodically' 31 | default: true 32 | required: false 33 | type: boolean 34 | config: 35 | description: 'multiline yaml of configuration settings for identity monitor run' 36 | required: false 37 | type: string 38 | url: 39 | description: 'Optional URL to pass to the monitor' 40 | required: false 41 | type: string 42 | 43 | permissions: 44 | contents: read 45 | 46 | env: 47 | UPLOADED_LOG_NAME: checkpoint 48 | LOG_FILE: checkpoint_log.txt 49 | 50 | jobs: 51 | detect-workflow: 52 | runs-on: ubuntu-latest 53 | timeout-minutes: 60 54 | permissions: 55 | id-token: write # Needed to detect the current reusable repository and ref. 56 | outputs: 57 | repository: ${{ steps.detect.outputs.repository }} 58 | ref: ${{ steps.detect.outputs.ref }} 59 | steps: 60 | - name: Detect the repository and ref 61 | id: detect 62 | uses: slsa-framework/slsa-github-generator/.github/actions/detect-workflow-js@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 63 | # NOTE: This GHA should not be run concurrently. 64 | concurrency: 65 | group: rekor-consistency-check 66 | cancel-in-progress: true 67 | 68 | monitor: 69 | runs-on: ubuntu-latest 70 | needs: [detect-workflow] 71 | timeout-minutes: 60 72 | steps: 73 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 74 | with: 75 | repository: ${{ needs.detect-workflow.outputs.repository }} 76 | ref: "${{ needs.detect-workflow.outputs.ref }}" 77 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 78 | with: 79 | go-version: '1.23' 80 | - name: Download artifact 81 | uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295 # v9 82 | with: 83 | name: ${{ env.UPLOADED_LOG_NAME }} 84 | # Skip on first run since there will be no checkpoint 85 | continue-on-error: true 86 | - name: Log current checkpoints 87 | run: cat ${{ env.LOG_FILE }} 88 | # Skip on first run 89 | continue-on-error: true 90 | - run: | 91 | go run ./cmd/rekor_monitor \ 92 | --file ${{ env.LOG_FILE }} \ 93 | --once=${{ inputs.once }} \ 94 | --user-agent "${{ format('{0}/{1}/{2}', needs.detect-workflow.outputs.repository, needs.detect-workflow.outputs.ref, github.run_id) }}" \ 95 | --config "${{ inputs.config }}" \ 96 | ${{ inputs.url && format('--url {0}', inputs.url) || '' }} 97 | - name: Upload checkpoint 98 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 99 | with: 100 | name: ${{ env.UPLOADED_LOG_NAME }} 101 | path: ${{ env.LOG_FILE }} 102 | retention-days: ${{ inputs.artifact_retention_days }} 103 | - name: Log new checkpoints 104 | run: cat ${{ env.LOG_FILE }} 105 | 106 | if-succeeded: 107 | runs-on: ubuntu-latest 108 | needs: [monitor, detect-workflow] 109 | timeout-minutes: 60 110 | permissions: 111 | issues: 'write' 112 | env: 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | ISSUE_REPOSITORY: ${{ github.repository }} 115 | if: ${{ needs.monitor.result == 'success' && inputs.file_issue }} 116 | steps: 117 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 118 | with: 119 | repository: ${{ needs.detect-workflow.outputs.repository }} 120 | ref: "${{ needs.detect-workflow.outputs.ref }}" 121 | - run: | 122 | set -euo pipefail 123 | ./.github/workflows/scripts/report_success.sh 124 | 125 | if-failed: 126 | runs-on: ubuntu-latest 127 | needs: [monitor, detect-workflow] 128 | timeout-minutes: 60 129 | permissions: 130 | issues: 'write' 131 | env: 132 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 133 | ISSUE_REPOSITORY: ${{ github.repository }} 134 | if: ${{ always() && needs.monitor.result == 'failure' && inputs.file_issue }} 135 | steps: 136 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 137 | with: 138 | repository: ${{ needs.detect-workflow.outputs.repository }} 139 | ref: "${{ needs.detect-workflow.outputs.ref }}" 140 | - run: | 141 | set -euo pipefail 142 | ./.github/workflows/scripts/report_failure.sh 143 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Scorecards supply-chain security 16 | on: 17 | # (Optional) For Branch-Protection check. Only the default branch is supported. See 18 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 19 | # branch_protection_rule: 20 | # To guarantee Maintained check is occasionally updated. See 21 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 22 | schedule: 23 | # Weekly on Saturdays. 24 | - cron: '30 1 * * 6' 25 | push: 26 | branches: [ main ] 27 | 28 | # Declare default permissions as read only. 29 | permissions: read-all 30 | 31 | jobs: 32 | analysis: 33 | name: Scorecards analysis 34 | permissions: 35 | # Needed to upload the results to code-scanning dashboard. 36 | security-events: write 37 | # Needed to publish results and get a badge (see publish_results below). 38 | id-token: write 39 | uses: sigstore/community/.github/workflows/reusable-scorecard.yml@main 40 | # (Optional) Disable publish results: 41 | # with: 42 | # publish_results: false 43 | 44 | # (Optional) Enable Branch-Protection check: 45 | # secrets: 46 | # scorecard_token: ${{ secrets.SCORECARD_TOKEN }} 47 | -------------------------------------------------------------------------------- /.github/workflows/scripts/report_failure.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2022 The Sigstore Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -euo pipefail 18 | 19 | # Gets the name of the currently running workflow file. 20 | # Note: this requires GITHUB_TOKEN to be set in the workflows. 21 | this_file() { 22 | gh api -H "Accept: application/vnd.github.v3+json" "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | jq -r '.path' | cut -d '/' -f3 23 | } 24 | 25 | # File is BODY in current directory. 26 | create_issue_body() { 27 | RUN_DATE=$(date --utc) 28 | 29 | # see https://docs.github.com/en/actions/learn-github-actions/environment-variables 30 | # https://docs.github.com/en/actions/learn-github-actions/contexts. 31 | cat <BODY 32 | Repo: https://github.com/$GITHUB_REPOSITORY/tree/$GITHUB_REF_NAME 33 | Run: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID 34 | Workflow file: https://github.com/$GITHUB_REPOSITORY/tree/main/.github/workflows/$THIS_FILE 35 | Workflow runs: https://github.com/$GITHUB_REPOSITORY/actions/workflows/$THIS_FILE 36 | Trigger: $GITHUB_EVENT_NAME 37 | Branch: $GITHUB_REF_NAME 38 | Date: $RUN_DATE 39 | EOF 40 | } 41 | 42 | THIS_FILE=$(this_file) 43 | create_issue_body 44 | 45 | ISSUE_ID=$(gh -R "$ISSUE_REPOSITORY" issue list --label "bug" --state open -S "$THIS_FILE" --json number | jq '.[0]' | jq -r '.number' | jq 'select (.!=null)') 46 | 47 | if [[ -z "$ISSUE_ID" ]]; then 48 | TITLE="Rekor monitoring failure" 49 | GH_TOKEN=$GITHUB_TOKEN gh -R "$ISSUE_REPOSITORY" issue create -t "[bug]: $TITLE" -F ./BODY --label "bug" 50 | else 51 | GH_TOKEN=$GITHUB_TOKEN gh -R "$ISSUE_REPOSITORY" issue comment "$ISSUE_ID" -F ./BODY 52 | fi -------------------------------------------------------------------------------- /.github/workflows/scripts/report_success.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2022 The Sigstore Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -euo pipefail 18 | 19 | # Gets the name of the currently running workflow file. 20 | # Note: this requires GITHUB_TOKEN to be set in the workflows. 21 | this_file() { 22 | gh api -H "Accept: application/vnd.github.v3+json" "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" | jq -r '.path' | cut -d '/' -f3 23 | } 24 | 25 | # File is BODY in current directory. 26 | create_issue_body() { 27 | RUN_DATE=$(date --utc) 28 | 29 | # see https://docs.github.com/en/actions/learn-github-actions/environment-variables 30 | # https://docs.github.com/en/actions/learn-github-actions/contexts. 31 | cat <BODY 32 | Repo: https://github.com/$GITHUB_REPOSITORY/tree/$GITHUB_REF_NAME 33 | Run: https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID 34 | Workflow file: https://github.com/$GITHUB_REPOSITORY/tree/main/.github/workflows/$THIS_FILE 35 | Workflow runs: https://github.com/$GITHUB_REPOSITORY/actions/workflows/$THIS_FILE 36 | Trigger: $GITHUB_EVENT_NAME 37 | Branch: $GITHUB_REF_NAME 38 | Date: $RUN_DATE 39 | EOF 40 | } 41 | 42 | create_issue_success_body() { 43 | create_issue_body 44 | 45 | echo "" >>./BODY 46 | echo "**Tests are passing now. Closing this issue.**" >>./BODY 47 | } 48 | 49 | THIS_FILE=$(this_file) 50 | create_issue_success_body 51 | 52 | ISSUE_ID=$(gh -R "$ISSUE_REPOSITORY" issue list --label "bug" --state open -S "$THIS_FILE" --json number | jq '.[0]' | jq -r '.number' | jq 'select (.!=null)') 53 | 54 | if [[ -n "$ISSUE_ID" ]]; then 55 | echo gh -R "$ISSUE_REPOSITORY" issue close "$ISSUE_ID" -c "$(cat ./BODY)" 56 | GH_TOKEN=$GITHUB_TOKEN gh -R "$ISSUE_REPOSITORY" issue close "$ISSUE_ID" -c "$(cat ./BODY)" 57 | fi 58 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 The Sigstore Authors. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | name: Verify 17 | 18 | on: [push, pull_request] 19 | 20 | permissions: 21 | contents: read 22 | 23 | jobs: 24 | license-check: 25 | name: license boilerplate check 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 30 | with: 31 | go-version-file: './go.mod' 32 | check-latest: true 33 | - name: Install addlicense 34 | run: go install github.com/google/addlicense@v1.0.0 35 | - name: Check license headers 36 | run: | 37 | set -e 38 | addlicense -check -l apache -c 'The Sigstore Authors' -ignore "third_party/**" -v * 39 | 40 | golangci: 41 | name: lint 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 45 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 46 | with: 47 | go-version-file: './go.mod' 48 | check-latest: true 49 | 50 | - name: golangci-lint 51 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 52 | with: 53 | version: v2.1.0 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled source # 2 | ################### 3 | *.com 4 | *.class 5 | *.dll 6 | *.exe 7 | *.o 8 | *.so 9 | *.pyc 10 | 11 | # Packages # 12 | ############ 13 | # it's better to unpack these files and commit the raw source 14 | # git has its own built in compression methods 15 | *.7z 16 | *.dmg 17 | *.gz 18 | *.iso 19 | *.jar 20 | *.rar 21 | *.tar 22 | *.zip 23 | 24 | # Logs and databases # 25 | ###################### 26 | *.log 27 | *.sqlite 28 | 29 | # OS generated files # 30 | ###################### 31 | .DS_Store? 32 | ehthumbs.db 33 | Icon? 34 | Thumbs.db 35 | 36 | # IDE generated files # 37 | ####################### 38 | .idea/ 39 | atlassian-ide-plugin.xml 40 | 41 | # Test Files # 42 | ############## 43 | test/log 44 | 45 | # Package Managed Files # 46 | ######################### 47 | bower_components/ 48 | components/ 49 | vendor/ 50 | composer.lock 51 | node_modules/ 52 | .npm/ 53 | venv/ 54 | .venv/ 55 | .venv3/ 56 | 57 | # temporary files # 58 | ################### 59 | *.*swp 60 | nohup.out 61 | *.tmp 62 | 63 | # Virtual machines # 64 | #################### 65 | .vagrant/ 66 | 67 | # Pythonics # 68 | ############# 69 | *.py[cod] 70 | 71 | # Packages 72 | *.egg 73 | *.egg-info 74 | dist 75 | build 76 | eggs 77 | parts 78 | bin/ 79 | var 80 | sdist 81 | develop-eggs 82 | .installed.cfg 83 | lib 84 | lib64 85 | 86 | # Translations 87 | *.mo 88 | 89 | # Mr Developer 90 | .mr.developer.cfg 91 | .project 92 | .pydevproject 93 | 94 | # Complexity 95 | output/*.html 96 | output/*/index.html 97 | 98 | # Monitor Specific # 99 | #################### 100 | **/rekor-monitor 101 | 102 | *.metadata 103 | *.tree 104 | *.vscode 105 | 106 | bin/ 107 | /logInfo.txt 108 | /identities.txt 109 | /mirroring 110 | /verifier 111 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | version: "2" 16 | run: 17 | issues-exit-code: 1 18 | linters: 19 | enable: 20 | - gocritic 21 | - gosec 22 | - misspell 23 | - revive 24 | - unused 25 | exclusions: 26 | generated: lax 27 | presets: 28 | - comments 29 | - common-false-positives 30 | - legacy 31 | - std-error-handling 32 | rules: 33 | - linters: 34 | - errcheck 35 | - gosec 36 | path: _test\.go 37 | paths: 38 | - third_party$ 39 | - builtin$ 40 | - examples$ 41 | issues: 42 | max-issues-per-linter: 0 43 | max-same-issues: 0 44 | formatters: 45 | enable: 46 | - gofmt 47 | - goimports 48 | exclusions: 49 | generated: lax 50 | paths: 51 | - third_party$ 52 | - builtin$ 53 | - examples$ 54 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sigstore/rekor-monitor-codeowners 2 | -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 The Sigstore Authors. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 The Sigstore Authors. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | FROM golang:1.24.3@sha256:4c0a1814a7c6c65ece28b3bfea14ee3cf83b5e80b81418453f0e9d5255a5d7b8 as builder 17 | ENV APP_ROOT=/opt/app-root 18 | ENV GOPATH=$APP_ROOT 19 | 20 | WORKDIR $APP_ROOT/src/ 21 | ADD go.mod go.sum $APP_ROOT/src/ 22 | RUN go mod download 23 | 24 | # Add source code 25 | ADD ./cmd/ $APP_ROOT/src/cmd/ 26 | ADD ./pkg/ $APP_ROOT/src/pkg/ 27 | 28 | RUN go build ./cmd/verifier 29 | RUN CGO_ENABLED=0 go build -gcflags "all=-N -l" -o verifier_debug ./cmd/verifier 30 | 31 | # Multi-Stage build 32 | FROM golang:1.24.3@sha256:4c0a1814a7c6c65ece28b3bfea14ee3cf83b5e80b81418453f0e9d5255a5d7b8 as deploy 33 | 34 | # Retrieve the binary from the previous stage 35 | COPY --from=builder /opt/app-root/src/verifier /usr/local/bin/verifier 36 | 37 | # Set the binary as the entrypoint of the container 38 | CMD ["verifier"] 39 | 40 | # debug compile options & debugger 41 | FROM deploy as debug 42 | RUN go install github.com/go-delve/delve/cmd/dlv@v1.23.1 43 | 44 | # overwrite utility and include debugger 45 | COPY --from=builder /opt/app-root/src/verifier_debug /usr/local/bin/verifier 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2021 The Sigstore Authors. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | build: 17 | go build ./cmd/verifier 18 | 19 | test: 20 | go test ./... 21 | 22 | clean: 23 | rm -f ./verifier 24 | 25 | .PHONY: build test clean 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rekor Log Monitor 2 | 3 | Rekor Log Monitor provides an easy-to-use monitor to verify log consistency, 4 | that the log is immutable and append-only. Monitoring is critical to 5 | the transparency log ecosystem, as logs are tamper-evident but not tamper-proof. 6 | Rekor Log Monitor also provides a monitor to search for identities within a log, 7 | and send a list of found identities via various notification platforms. 8 | 9 | ## Consistency check 10 | 11 | To run, create a GitHub Actions workflow that uses the 12 | [consistency check workflow](https://github.com/sigstore/rekor-monitor/blob/main/.github/workflows/consistency_check.yml). 13 | It is recommended to run the log monitor every hour for optimal performance. 14 | 15 | Example workflow: 16 | 17 | ``` 18 | name: Rekor log monitor 19 | on: 20 | schedule: 21 | - cron: '0 * * * *' # every hour 22 | 23 | permissions: read-all 24 | 25 | jobs: 26 | run_consistency_proof: 27 | permissions: 28 | contents: read # Needed to checkout repositories 29 | issues: write # Needed if you set "file_issue: true" 30 | id-token: write # Needed to detect the current reusable repository and ref 31 | uses: sigstore/rekor-monitor/.github/workflows/reusable_monitoring.yml@main 32 | with: 33 | file_issue: true # Strongly recommended: Files an issue on monitoring failure 34 | artifact_retention_days: 14 # Optional, default is 14: Must be longer than the cron job frequency 35 | ``` 36 | 37 | Caveats: 38 | 39 | * The log monitoring job should not be run concurrently with other log monitoring jobs in the same repository 40 | * If running as a cron job, `artifact_retention_days` must be longer than the cron job frequency 41 | 42 | ## Identity monitoring 43 | 44 | You can also specify a list of identities to monitor. Currently, only identities from the certificate's 45 | Subject Alternative Name (SAN) field will be matched, and only for the hashedrekord Rekor entry type. 46 | 47 | Note: `certIdentities.certSubject`, `certIdentities.issuers` and `subjects` are expecting regular expression. 48 | Please read [this](https://github.com/google/re2/wiki/Syntax) for syntax reference. 49 | 50 | Note: The log monitor only starts monitoring from the latest checkpoint. If you want to search previous 51 | entries, you will need to query the log. 52 | 53 | To run, create a GitHub Actions workflow that uses the 54 | [identity monitoring workflow](https://github.com/sigstore/rekor-monitor/blob/main/.github/workflows/identity_monitor.yml). 55 | It is recommended to run the log monitor every hour for optimal performance. 56 | 57 | Example workflow below: 58 | 59 | ``` 60 | name: Rekor log and identity monitor 61 | on: 62 | schedule: 63 | - cron: '0 * * * *' # every hour 64 | 65 | permissions: read-all 66 | 67 | jobs: 68 | run_consistency_proof: 69 | permissions: 70 | contents: read # Needed to checkout repositories 71 | issues: write # Needed if you set "file_issue: true" 72 | id-token: write # Needed to detect the current reusable repository and ref 73 | uses: sigstore/rekor-monitor/.github/workflows/reusable_monitoring.yaml@main 74 | with: 75 | file_issue: true # Strongly recommended: Files an issue on monitoring failure 76 | artifact_retention_days: 14 # Optional, default is 14: Must be longer than the cron job frequency 77 | config: | 78 | monitoredValues: 79 | certIdentities: 80 | - certSubject: user@domain\.com 81 | - certSubject: otheruser@domain\.com 82 | issuers: 83 | - https://accounts\.google\.com 84 | - https://github\.com/login 85 | - certSubject: https://github\.com/actions/starter-workflows/blob/main/\.github/workflows/lint\.yaml@.* 86 | issuers: 87 | - https://token\.actions\.githubusercontent\.com 88 | subjects: 89 | - subject@domain\.com 90 | fingerprints: 91 | - A0B1C2D3E4F5 92 | fulcioExtensions: 93 | build-config-uri: 94 | - https://example.com/owner/repository/build-config.yml 95 | customExtensions: 96 | - objectIdentifier: 1.3.6.1.4.1.57264.1.9 97 | extensionValues: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.4.0 98 | ``` 99 | 100 | In this example, the monitor will log: 101 | 102 | * Entries that contain a certificate whose SAN is `user@domain.com` 103 | * Entries whose SAN is `otheruser@domain.com` and the OIDC provider specified in a [custom extension](https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md#1361415726418--issuer-v2) matches one of the specified issuers (Google or GitHub in this example) 104 | * Entries whose SAN start by `https://github.com/actions/starter-workflows/blob/main/.github/workflows/lint.yaml@` and the OIDC provider matches `https://token.actions.githubusercontent.com` 105 | * Non-certificate entries, such as PGP or SSH keys, whose subject matches `subject@domain.com` 106 | * Entries whose key or certificate fingerprint matches `A0B1C2D3E4F5` 107 | * Entries that contain a certificate with a Build Config URI Extension matching `https://example.com/owner/repository/build-config.yml` 108 | * Entries that contain a certificate with OID extension `1.3.6.1.4.1.57264.1.9` (Fulcio OID for Build Signer URI) and an extension value matching `https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.4.0` 109 | 110 | Fingerprint values are as follows: 111 | 112 | * For keys, certificates, and minisign, hex-encoded SHA-256 digest of the DER-encoded PKIX public key or certificate 113 | * For SSH and PGP, the standard for each ecosystem: 114 | * For SSH, unpadded base-64 encoded SHA-256 digest of the key 115 | * For PGP, hex-encoded SHA-1 digest of a key, which can be either a primary key or subkey 116 | 117 | Upcoming features: 118 | 119 | * Creating issues when identities are found 120 | * Support for other identities 121 | * CI identity values in Fulcio certificates 122 | 123 | ## Certificate transparency log monitoring 124 | 125 | Certificate transparency log instances can also be monitored. To run, create a GitHub Actions workflow that uses the 126 | [reusable certificate transparency log monitoring workflow](https://github.com/sigstore/rekor-monitor/blob/main/.github/workflows/ct_reusable_monitoring.yml). 127 | It is recommended to run the log monitor every hour for optimal performance. 128 | 129 | Example workflow below: 130 | 131 | ``` 132 | name: Fulcio log and identity monitor 133 | on: 134 | schedule: 135 | - cron: '0 * * * *' # every hour 136 | 137 | permissions: read-all 138 | 139 | jobs: 140 | run_consistency_proof: 141 | permissions: 142 | contents: read # Needed to checkout repositories 143 | issues: write # Needed if you set "file_issue: true" 144 | id-token: write # Needed to detect the current reusable repository and ref 145 | uses: sigstore/rekor-monitor/.github/workflows/reusable_monitoring.yaml@main 146 | with: 147 | file_issue: true # Strongly recommended: Files an issue on monitoring failure 148 | artifact_retention_days: 14 # Optional, default is 14: Must be longer than the cron job frequency 149 | identities: | 150 | certIdentities: 151 | - certSubject: user@domain\.com 152 | - certSubject: otheruser@domain\.com 153 | issuers: 154 | - https://accounts\.google\.com 155 | - https://github\.com/login 156 | - certSubject: https://github\.com/actions/starter-workflows/blob/main/\.github/workflows/lint\.yaml@.* 157 | issuers: 158 | - https://token\.actions\.githubusercontent\.com 159 | fulcioExtensions: 160 | build-config-uri: 161 | - https://example.com/owner/repository/build-config.yml 162 | customExtensions: 163 | - objectIdentifier: 1.3.6.1.4.1.57264.1.9 164 | extensionValues: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.4.0 165 | ``` 166 | 167 | ## Security 168 | 169 | Please report any vulnerabilities following Sigstore's [security process](https://github.com/sigstore/.github/blob/main/SECURITY.md). 170 | -------------------------------------------------------------------------------- /cmd/ct_monitor/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2024 The Sigstore Authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package main 17 | 18 | import ( 19 | "flag" 20 | "fmt" 21 | "log" 22 | "net/http" 23 | "os" 24 | "os/signal" 25 | "strings" 26 | "syscall" 27 | "time" 28 | 29 | ctgo "github.com/google/certificate-transparency-go" 30 | ctclient "github.com/google/certificate-transparency-go/client" 31 | "github.com/google/certificate-transparency-go/jsonclient" 32 | "github.com/sigstore/rekor-monitor/pkg/ct" 33 | "github.com/sigstore/rekor-monitor/pkg/identity" 34 | "github.com/sigstore/rekor-monitor/pkg/notifications" 35 | "gopkg.in/yaml.v2" 36 | ) 37 | 38 | // Default values for monitoring job parameters 39 | const ( 40 | publicCTServerURL = "https://ctfe.sigstore.dev/2022" 41 | logInfoFileName = "ctLogInfo.txt" 42 | outputIdentitiesFileName = "ctIdentities.txt" 43 | ) 44 | 45 | // This main function performs a periodic identity search. 46 | // Upon starting, any existing latest snapshot data is loaded and the function runs 47 | // indefinitely to perform identity search for every time interval that was specified. 48 | func main() { 49 | configFilePath := flag.String("config-file", "", "path to yaml configuration file containing identity monitor settings") 50 | configYamlInput := flag.String("config", "", "path to yaml configuration file containing identity monitor settings") 51 | once := flag.Bool("once", true, "whether to run the monitor on a repeated interval or once") 52 | logInfoFile := flag.String("file", logInfoFileName, "path to the initial log info checkpoint file to be read from") 53 | serverURL := flag.String("url", publicCTServerURL, "URL to the public CT server that is to be monitored") 54 | interval := flag.Duration("interval", 5*time.Minute, "Length of interval between each periodical consistency check") 55 | flag.Parse() 56 | 57 | var config notifications.IdentityMonitorConfiguration 58 | 59 | if *configFilePath != "" { 60 | readConfig, err := os.ReadFile(*configFilePath) 61 | if err != nil { 62 | log.Fatalf("error reading from identity monitor configuration file: %v", err) 63 | } 64 | 65 | configString := string(readConfig) 66 | if err := yaml.Unmarshal([]byte(configString), &config); err != nil { 67 | log.Fatalf("error parsing identities: %v", err) 68 | } 69 | } 70 | 71 | if *configYamlInput != "" { 72 | if err := yaml.Unmarshal([]byte(*configYamlInput), &config); err != nil { 73 | log.Fatalf("error parsing identities: %v", err) 74 | } 75 | } 76 | 77 | var fulcioClient *ctclient.LogClient 78 | fulcioClient, err := ctclient.New(*serverURL, http.DefaultClient, jsonclient.Options{}) 79 | if err != nil { 80 | log.Fatalf("getting Fulcio client: %v", err) 81 | } 82 | 83 | allOIDMatchers, err := config.MonitoredValues.OIDMatchers.RenderOIDMatchers() 84 | if err != nil { 85 | fmt.Printf("error parsing OID matchers: %v", err) 86 | } 87 | 88 | monitoredValues := identity.MonitoredValues{ 89 | CertificateIdentities: config.MonitoredValues.CertificateIdentities, 90 | OIDMatchers: allOIDMatchers, 91 | } 92 | 93 | for _, certID := range monitoredValues.CertificateIdentities { 94 | if len(certID.Issuers) == 0 { 95 | fmt.Printf("Monitoring certificate subject %s\n", certID.CertSubject) 96 | } else { 97 | fmt.Printf("Monitoring certificate subject %s for issuer(s) %s\n", certID.CertSubject, strings.Join(certID.Issuers, ",")) 98 | } 99 | } 100 | for _, fp := range monitoredValues.Fingerprints { 101 | fmt.Printf("Monitoring fingerprint %s\n", fp) 102 | } 103 | for _, sub := range monitoredValues.Subjects { 104 | fmt.Printf("Monitoring subject %s\n", sub) 105 | } 106 | 107 | ticker := time.NewTicker(*interval) 108 | defer ticker.Stop() 109 | 110 | signalChan := make(chan os.Signal, 1) 111 | signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) 112 | 113 | // To get an immediate first tick, for-select is at the end of the loop 114 | for { 115 | inputEndIndex := config.EndIndex 116 | 117 | // TODO: Handle Rekor sharding 118 | // https://github.com/sigstore/rekor-monitor/issues/57 119 | var prevSTH *ctgo.SignedTreeHead 120 | prevSTH, currentSTH, err := ct.RunConsistencyCheck(fulcioClient, *logInfoFile) 121 | if err != nil { 122 | fmt.Fprintf(os.Stderr, "failed to successfully complete consistency check: %v", err) 123 | return 124 | } 125 | 126 | if config.StartIndex == nil { 127 | if prevSTH != nil { 128 | checkpointStartIndex := int(prevSTH.TreeSize) //nolint: gosec // G115, log will never be large enough to overflow 129 | config.StartIndex = &checkpointStartIndex 130 | } else { 131 | defaultStartIndex := 0 132 | config.StartIndex = &defaultStartIndex 133 | } 134 | } 135 | 136 | if config.EndIndex == nil { 137 | checkpointEndIndex := int(currentSTH.TreeSize) //nolint: gosec // G115 138 | config.EndIndex = &checkpointEndIndex 139 | } 140 | 141 | if *config.StartIndex >= *config.EndIndex { 142 | fmt.Fprintf(os.Stderr, "start index %d must be strictly less than end index %d", *config.StartIndex, *config.EndIndex) 143 | } 144 | 145 | if identity.MonitoredValuesExist(monitoredValues) { 146 | foundEntries, err := ct.IdentitySearch(fulcioClient, *config.StartIndex, *config.EndIndex, monitoredValues) 147 | if err != nil { 148 | fmt.Fprintf(os.Stderr, "failed to successfully complete identity search: %v", err) 149 | return 150 | } 151 | 152 | notificationPool := notifications.CreateNotificationPool(config) 153 | 154 | err = notifications.TriggerNotifications(notificationPool, foundEntries) 155 | if err != nil { 156 | // continue running consistency check if notifications fail to trigger 157 | fmt.Fprintf(os.Stderr, "failed to trigger notifications: %v", err) 158 | } 159 | } 160 | 161 | if *once || inputEndIndex != nil { 162 | return 163 | } 164 | 165 | config.StartIndex = config.EndIndex 166 | config.EndIndex = nil 167 | 168 | select { 169 | case <-ticker.C: 170 | continue 171 | case <-signalChan: 172 | fmt.Fprintf(os.Stderr, "received signal, exiting") 173 | return 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /cmd/rekor_monitor/main.go: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright 2021 The Sigstore Authors. 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | package main 17 | 18 | import ( 19 | "context" 20 | "flag" 21 | "fmt" 22 | "log" 23 | "os" 24 | "os/signal" 25 | "runtime" 26 | "strings" 27 | "syscall" 28 | "time" 29 | 30 | "github.com/sigstore/rekor-monitor/pkg/identity" 31 | "github.com/sigstore/rekor-monitor/pkg/notifications" 32 | "github.com/sigstore/rekor-monitor/pkg/rekor" 33 | "github.com/sigstore/rekor/pkg/client" 34 | "github.com/sigstore/rekor/pkg/generated/models" 35 | "github.com/sigstore/rekor/pkg/util" 36 | "gopkg.in/yaml.v2" 37 | "sigs.k8s.io/release-utils/version" 38 | ) 39 | 40 | // Default values for monitoring job parameters 41 | const ( 42 | publicRekorServerURL = "https://rekor.sigstore.dev" 43 | outputIdentitiesFileName = "identities.txt" 44 | logInfoFileName = "logInfo.txt" 45 | ) 46 | 47 | // This main function performs a periodic identity search. 48 | // Upon starting, any existing latest snapshot data is loaded and the function runs 49 | // indefinitely to perform identity search for every time interval that was specified. 50 | func main() { 51 | // Command-line flags that are parameters to the verifier job 52 | configFilePath := flag.String("config-file", "", "path to yaml configuration file containing identity monitor settings") 53 | configYamlInput := flag.String("config", "", "path to yaml configuration file containing identity monitor settings") 54 | once := flag.Bool("once", true, "whether to run the monitor on a repeated interval or once") 55 | serverURL := flag.String("url", publicRekorServerURL, "URL to the rekor server that is to be monitored") 56 | logInfoFile := flag.String("file", logInfoFileName, "path to the initial log info checkpoint file to be read from") 57 | interval := flag.Duration("interval", 5*time.Minute, "Length of interval between each periodical consistency check") 58 | userAgentString := flag.String("user-agent", "", "details to include in the user agent string") 59 | flag.Parse() 60 | 61 | var config notifications.IdentityMonitorConfiguration 62 | 63 | if *configFilePath != "" { 64 | readConfig, err := os.ReadFile(*configFilePath) 65 | if err != nil { 66 | log.Fatalf("error reading from identity monitor configuration file: %v", err) 67 | } 68 | 69 | configString := string(readConfig) 70 | if err := yaml.Unmarshal([]byte(configString), &config); err != nil { 71 | log.Fatalf("error parsing identities: %v", err) 72 | } 73 | } 74 | 75 | if *configYamlInput != "" { 76 | if err := yaml.Unmarshal([]byte(*configYamlInput), &config); err != nil { 77 | log.Fatalf("error parsing identities: %v", err) 78 | } 79 | } 80 | 81 | if config.OutputIdentitiesFile == "" { 82 | config.OutputIdentitiesFile = outputIdentitiesFileName 83 | } 84 | 85 | rekorClient, err := client.GetRekorClient(*serverURL, client.WithUserAgent(strings.TrimSpace(fmt.Sprintf("rekor-monitor/%s (%s; %s) %s", version.GetVersionInfo().GitVersion, runtime.GOOS, runtime.GOARCH, *userAgentString)))) 86 | if err != nil { 87 | log.Fatalf("getting Rekor client: %v", err) 88 | } 89 | 90 | verifier, err := rekor.GetLogVerifier(context.Background(), rekorClient) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | 95 | allOIDMatchers, err := config.MonitoredValues.OIDMatchers.RenderOIDMatchers() 96 | if err != nil { 97 | fmt.Printf("error parsing OID matchers: %v", err) 98 | } 99 | 100 | monitoredValues := identity.MonitoredValues{ 101 | CertificateIdentities: config.MonitoredValues.CertificateIdentities, 102 | Subjects: config.MonitoredValues.Subjects, 103 | Fingerprints: config.MonitoredValues.Fingerprints, 104 | OIDMatchers: allOIDMatchers, 105 | } 106 | 107 | for _, certID := range monitoredValues.CertificateIdentities { 108 | if len(certID.Issuers) == 0 { 109 | fmt.Printf("Monitoring certificate subject %s\n", certID.CertSubject) 110 | } else { 111 | fmt.Printf("Monitoring certificate subject %s for issuer(s) %s\n", certID.CertSubject, strings.Join(certID.Issuers, ",")) 112 | } 113 | } 114 | for _, fp := range monitoredValues.Fingerprints { 115 | fmt.Printf("Monitoring fingerprint %s\n", fp) 116 | } 117 | for _, sub := range monitoredValues.Subjects { 118 | fmt.Printf("Monitoring subject %s\n", sub) 119 | } 120 | 121 | ticker := time.NewTicker(*interval) 122 | defer ticker.Stop() 123 | 124 | signalChan := make(chan os.Signal, 1) 125 | signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) 126 | 127 | // To get an immediate first tick, for-select is at the end of the loop 128 | for { 129 | inputEndIndex := config.EndIndex 130 | 131 | // TODO: Handle Rekor sharding 132 | // https://github.com/sigstore/rekor-monitor/issues/57 133 | var logInfo *models.LogInfo 134 | var prevCheckpoint *util.SignedCheckpoint 135 | prevCheckpoint, logInfo, err = rekor.RunConsistencyCheck(rekorClient, verifier, *logInfoFile) 136 | if err != nil { 137 | fmt.Fprintf(os.Stderr, "error running consistency check: %v", err) 138 | return 139 | } 140 | 141 | if config.StartIndex == nil { 142 | if prevCheckpoint != nil { 143 | checkpointStartIndex := rekor.GetCheckpointIndex(logInfo, prevCheckpoint) 144 | config.StartIndex = &checkpointStartIndex 145 | } else { 146 | fmt.Fprintf(os.Stderr, "no start index set and no log checkpoint") 147 | return 148 | } 149 | } 150 | 151 | if config.EndIndex == nil { 152 | checkpoint, err := rekor.ReadLatestCheckpoint(logInfo) 153 | if err != nil { 154 | fmt.Fprintf(os.Stderr, "error reading checkpoint: %v", err) 155 | return 156 | } 157 | 158 | checkpointEndIndex := rekor.GetCheckpointIndex(logInfo, checkpoint) 159 | config.EndIndex = &checkpointEndIndex 160 | } 161 | 162 | if *config.StartIndex >= *config.EndIndex { 163 | fmt.Fprintf(os.Stderr, "start index %d must be strictly less than end index %d", *config.StartIndex, *config.EndIndex) 164 | return 165 | } 166 | 167 | if identity.MonitoredValuesExist(monitoredValues) { 168 | _, err = rekor.IdentitySearch(*config.StartIndex, *config.EndIndex, rekorClient, monitoredValues, config.OutputIdentitiesFile, config.IdentityMetadataFile) 169 | if err != nil { 170 | fmt.Fprintf(os.Stderr, "failed to successfully complete identity search: %v", err) 171 | return 172 | } 173 | } 174 | 175 | if *once || inputEndIndex != nil { 176 | return 177 | } 178 | 179 | config.StartIndex = config.EndIndex 180 | config.EndIndex = nil 181 | 182 | select { 183 | case <-ticker.C: 184 | continue 185 | case <-signalChan: 186 | fmt.Fprintf(os.Stderr, "received signal, exiting") 187 | return 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 The Sigstore Authors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | coverage: 16 | status: 17 | project: off 18 | patch: off 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2022 The Sigstore Authors. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | version: '3.8' 16 | services: 17 | rekor-monitor: 18 | build: 19 | context: . 20 | target: "deploy" 21 | command: [ 22 | "verifier", 23 | "--file=/etc/rekor-monitor/logInfo.txt" 24 | # uncomment for more frequent polling 25 | # "--interval=1m", 26 | ] 27 | restart: always # keep the server running 28 | volumes: 29 | - ./logInfo.txt:/etc/rekor-monitor/logInfo.txt:z -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sigstore/rekor-monitor 2 | 3 | go 1.23.2 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/go-openapi/runtime v0.28.0 9 | github.com/go-openapi/swag v0.23.1 10 | github.com/google/certificate-transparency-go v1.3.1 11 | github.com/google/go-github/v65 v65.0.0 12 | github.com/mailgun/mailgun-go/v4 v4.23.0 13 | github.com/migueleliasweb/go-github-mock v1.3.0 14 | github.com/mocktools/go-smtp-mock/v2 v2.4.0 15 | github.com/sendgrid/sendgrid-go v3.16.0+incompatible 16 | github.com/sigstore/rekor v1.3.10 17 | github.com/sigstore/sigstore v1.9.4 18 | github.com/transparency-dev/merkle v0.0.2 19 | github.com/wneessen/go-mail v0.6.2 20 | golang.org/x/mod v0.24.0 21 | gopkg.in/yaml.v2 v2.4.0 22 | sigs.k8s.io/release-utils v0.11.1 23 | ) 24 | 25 | require ( 26 | github.com/alessio/shellescape v1.4.1 // indirect 27 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 28 | github.com/blang/semver v3.5.1+incompatible // indirect 29 | github.com/cavaliercoder/go-rpm v0.0.0-20200122174316-8cb9fd9c31a8 // indirect 30 | github.com/cavaliergopher/cpio v1.0.1 // indirect 31 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect 32 | github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 // indirect 33 | github.com/danieljoos/wincred v1.2.0 // indirect 34 | github.com/fsnotify/fsnotify v1.7.0 // indirect 35 | github.com/fxamacker/cbor/v2 v2.5.0 // indirect 36 | github.com/go-chi/chi v4.1.2+incompatible // indirect 37 | github.com/go-chi/chi/v5 v5.2.1 // indirect 38 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 39 | github.com/go-logr/logr v1.4.2 // indirect 40 | github.com/go-logr/stdr v1.2.2 // indirect 41 | github.com/go-openapi/analysis v0.23.0 // indirect 42 | github.com/go-openapi/errors v0.22.1 // indirect 43 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 44 | github.com/go-openapi/jsonreference v0.21.0 // indirect 45 | github.com/go-openapi/loads v0.22.0 // indirect 46 | github.com/go-openapi/spec v0.21.0 // indirect 47 | github.com/go-openapi/strfmt v0.23.0 // indirect 48 | github.com/go-openapi/validate v0.24.0 // indirect 49 | github.com/godbus/dbus/v5 v5.1.0 // indirect 50 | github.com/google/go-containerregistry v0.20.3 // indirect 51 | github.com/google/go-github/v71 v71.0.0 // indirect 52 | github.com/google/go-querystring v1.1.0 // indirect 53 | github.com/google/rpmpack v0.6.0 // indirect 54 | github.com/google/uuid v1.6.0 // indirect 55 | github.com/gorilla/mux v1.8.1 // indirect 56 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 57 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 58 | github.com/hashicorp/hcl v1.0.0 // indirect 59 | github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef // indirect 60 | github.com/in-toto/in-toto-golang v0.9.0 // indirect 61 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 62 | github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect 63 | github.com/josharian/intern v1.0.0 // indirect 64 | github.com/json-iterator/go v1.1.12 // indirect 65 | github.com/klauspost/compress v1.17.11 // indirect 66 | github.com/klauspost/pgzip v1.2.6 // indirect 67 | github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect 68 | github.com/magiconair/properties v1.8.7 // indirect 69 | github.com/mailgun/errors v0.4.0 // indirect 70 | github.com/mailru/easyjson v0.9.0 // indirect 71 | github.com/mitchellh/mapstructure v1.5.0 // 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/oklog/ulid v1.3.1 // indirect 75 | github.com/opencontainers/go-digest v1.0.0 // indirect 76 | github.com/opentracing/opentracing-go v1.2.0 // indirect 77 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 78 | github.com/pkg/errors v0.9.1 // indirect 79 | github.com/sagikazarmark/locafero v0.4.0 // indirect 80 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 81 | github.com/sassoftware/relic v7.2.1+incompatible // indirect 82 | github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect 83 | github.com/sendgrid/rest v2.6.9+incompatible // indirect 84 | github.com/shibumi/go-pathspec v1.3.0 // indirect 85 | github.com/sigstore/protobuf-specs v0.4.1 // indirect 86 | github.com/sourcegraph/conc v0.3.0 // indirect 87 | github.com/spf13/afero v1.11.0 // indirect 88 | github.com/spf13/cast v1.7.0 // indirect 89 | github.com/spf13/cobra v1.9.1 // indirect 90 | github.com/spf13/pflag v1.0.6 // indirect 91 | github.com/spf13/viper v1.19.0 // indirect 92 | github.com/subosito/gotenv v1.6.0 // indirect 93 | github.com/theupdateframework/go-tuf v0.7.0 // indirect 94 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect 95 | github.com/ulikunitz/xz v0.5.12 // indirect 96 | github.com/veraison/go-cose v1.3.0 // indirect 97 | github.com/x448/float16 v0.8.4 // indirect 98 | github.com/zalando/go-keyring v0.2.3 // indirect 99 | go.mongodb.org/mongo-driver v1.14.0 // indirect 100 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 101 | go.opentelemetry.io/otel v1.34.0 // indirect 102 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 103 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 104 | go.uber.org/multierr v1.11.0 // indirect 105 | go.uber.org/zap v1.27.0 // indirect 106 | golang.org/x/crypto v0.37.0 // indirect 107 | golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect 108 | golang.org/x/net v0.38.0 // indirect 109 | golang.org/x/sync v0.13.0 // indirect 110 | golang.org/x/sys v0.32.0 // indirect 111 | golang.org/x/term v0.31.0 // indirect 112 | golang.org/x/text v0.24.0 // indirect 113 | golang.org/x/time v0.11.0 // indirect 114 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 115 | google.golang.org/protobuf v1.36.6 // indirect 116 | gopkg.in/ini.v1 v1.67.0 // indirect 117 | gopkg.in/yaml.v3 v3.0.1 // indirect 118 | k8s.io/klog/v2 v2.130.1 // indirect 119 | sigs.k8s.io/yaml v1.4.0 // indirect 120 | ) 121 | -------------------------------------------------------------------------------- /pkg/ct/consistency.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ct 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "os" 21 | 22 | ct "github.com/google/certificate-transparency-go" 23 | ctclient "github.com/google/certificate-transparency-go/client" 24 | "github.com/sigstore/rekor-monitor/pkg/util/file" 25 | "github.com/sigstore/sigstore/pkg/cryptoutils" 26 | "github.com/transparency-dev/merkle/proof" 27 | "github.com/transparency-dev/merkle/rfc6962" 28 | ) 29 | 30 | const ( 31 | ctfe2022PubKey = `-----BEGIN PUBLIC KEY----- 32 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNK 33 | AaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw== 34 | -----END PUBLIC KEY-----` 35 | ) 36 | 37 | func verifyCertificateTransparencyConsistency(logInfoFile string, logClient *ctclient.LogClient, signedTreeHead *ct.SignedTreeHead) (*ct.SignedTreeHead, error) { 38 | prevSTH, err := file.ReadLatestCTSignedTreeHead(logInfoFile) 39 | if err != nil { 40 | return nil, fmt.Errorf("error reading checkpoint: %v", err) 41 | } 42 | 43 | if logClient.Verifier == nil { 44 | // TODO: this public key is currently hardcoded- should be fetched from TUF repository instead 45 | pubKey, err := cryptoutils.UnmarshalPEMToPublicKey([]byte(ctfe2022PubKey)) 46 | 47 | if err != nil { 48 | return nil, fmt.Errorf("error loading public key: %v", err) 49 | } 50 | logClient.Verifier = &ct.SignatureVerifier{ 51 | PubKey: pubKey, 52 | } 53 | } 54 | 55 | err = logClient.VerifySTHSignature(*prevSTH) 56 | if err != nil { 57 | return nil, fmt.Errorf("error verifying previous STH signature: %v", err) 58 | } 59 | err = logClient.VerifySTHSignature(*signedTreeHead) 60 | if err != nil { 61 | return nil, fmt.Errorf("error verifying current STH signature: %v", err) 62 | } 63 | 64 | first := prevSTH.TreeSize 65 | second := signedTreeHead.TreeSize 66 | pf, err := logClient.GetSTHConsistency(context.Background(), first, second) 67 | if err != nil { 68 | return nil, fmt.Errorf("error getting consistency proof: %v", err) 69 | } 70 | 71 | if err := proof.VerifyConsistency(rfc6962.DefaultHasher, first, second, pf, prevSTH.SHA256RootHash[:], signedTreeHead.SHA256RootHash[:]); err != nil { 72 | return nil, fmt.Errorf("error verifying consistency: %v", err) 73 | } 74 | 75 | return prevSTH, nil 76 | } 77 | 78 | // RunConsistencyCheck periodically verifies the root hash consistency of a certificate transparency log. 79 | func RunConsistencyCheck(logClient *ctclient.LogClient, logInfoFile string) (*ct.SignedTreeHead, *ct.SignedTreeHead, error) { 80 | currentSTH, err := logClient.GetSTH(context.Background()) 81 | if err != nil { 82 | return nil, nil, fmt.Errorf("error fetching latest STH: %v", err) 83 | } 84 | 85 | fi, err := os.Stat(logInfoFile) 86 | // File containing previous checkpoints exists 87 | var prevSTH *ct.SignedTreeHead 88 | if err == nil && fi.Size() != 0 { 89 | prevSTH, err = verifyCertificateTransparencyConsistency(logInfoFile, logClient, currentSTH) 90 | if err != nil { 91 | return nil, nil, fmt.Errorf("error verifying consistency between previous and current STHs: %v", err) 92 | } 93 | } 94 | 95 | if prevSTH == nil || prevSTH.TreeSize != currentSTH.TreeSize { 96 | if err := file.WriteCTSignedTreeHead(currentSTH, logInfoFile); err != nil { 97 | return nil, nil, fmt.Errorf("failed to write checkpoint: %v", err) 98 | } 99 | } 100 | 101 | return prevSTH, currentSTH, nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/ct/consistency_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ct 16 | 17 | import ( 18 | "encoding/base64" 19 | "net/http" 20 | "os" 21 | "testing" 22 | 23 | "github.com/google/certificate-transparency-go/jsonclient" 24 | "github.com/google/certificate-transparency-go/tls" 25 | "github.com/sigstore/rekor-monitor/pkg/util/file" 26 | 27 | ct "github.com/google/certificate-transparency-go" 28 | ctclient "github.com/google/certificate-transparency-go/client" 29 | ) 30 | 31 | // Test VerifyCertificateTransparencyConsistency 32 | func TestVerifyCertificateTransparencyConsistency(t *testing.T) { 33 | // TODO: placeholder test, fill this out with mock CT Log client 34 | hs := serveRspAt(t, "/ct/v1/get-sth-consistency", GetSTHConsistencyEmptyResp) 35 | defer hs.Close() 36 | 37 | var rootHash ct.SHA256Hash 38 | err := rootHash.FromBase64String(ValidSTHResponseSHA256RootHash) 39 | if err != nil { 40 | t.Errorf("error parsing root hash from string: %v", err) 41 | } 42 | 43 | wantRawSignature, err := base64.StdEncoding.DecodeString(ValidSTHResponseTreeHeadSignature) 44 | if err != nil { 45 | t.Fatalf("Couldn't b64 decode 'correct' STH signature: %v", err) 46 | } 47 | var wantDS ct.DigitallySigned 48 | if _, err := tls.Unmarshal(wantRawSignature, &wantDS); err != nil { 49 | t.Fatalf("Couldn't unmarshal DigitallySigned: %v", err) 50 | } 51 | 52 | sth := &ct.SignedTreeHead{ 53 | TreeSize: ValidSTHResponseTreeSize, 54 | Timestamp: ValidSTHResponseTimestamp, 55 | SHA256RootHash: rootHash, 56 | TreeHeadSignature: wantDS, 57 | } 58 | 59 | tempDir := t.TempDir() 60 | tempLogInfoFile, err := os.CreateTemp(tempDir, "") 61 | if err != nil { 62 | t.Errorf("failed to create temp log file: %v", err) 63 | } 64 | tempLogInfoFileName := tempLogInfoFile.Name() 65 | defer os.Remove(tempLogInfoFileName) 66 | 67 | err = file.WriteCTSignedTreeHead(sth, tempLogInfoFileName) 68 | if err != nil { 69 | t.Errorf("error writing sth to log info file: %v", err) 70 | } 71 | 72 | logClient, err := ctclient.New(hs.URL, http.DefaultClient, jsonclient.Options{}) 73 | if err != nil { 74 | t.Errorf("error creating log client: %v", err) 75 | } 76 | 77 | prevSTH, err := verifyCertificateTransparencyConsistency(tempLogInfoFileName, logClient, sth) 78 | if err == nil { 79 | t.Errorf("expected error verifying ct consistency, received nil") 80 | } 81 | if prevSTH != nil { 82 | t.Errorf("expected nil, received %v", prevSTH) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /pkg/ct/monitor.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ct 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | 21 | ct "github.com/google/certificate-transparency-go" 22 | ctclient "github.com/google/certificate-transparency-go/client" 23 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 24 | "github.com/sigstore/rekor-monitor/pkg/identity" 25 | ) 26 | 27 | func GetCTLogEntries(logClient *ctclient.LogClient, startIndex int, endIndex int) ([]ct.LogEntry, error) { 28 | entries, err := logClient.GetEntries(context.Background(), int64(startIndex), int64(endIndex)) 29 | if err != nil { 30 | return nil, fmt.Errorf("error retrieving certificate transparency log entries: %v", err) 31 | } 32 | return entries, nil 33 | } 34 | 35 | func ScanEntryCertSubject(logEntry ct.LogEntry, monitoredCertIDs []identity.CertificateIdentity) ([]identity.LogEntry, error) { 36 | matchedEntries := []identity.LogEntry{} 37 | for _, monitoredCertID := range monitoredCertIDs { 38 | match, sub, iss, err := identity.CertMatchesPolicy(logEntry.X509Cert, monitoredCertID.CertSubject, monitoredCertID.Issuers) 39 | if err != nil { 40 | return nil, fmt.Errorf("error with policy matching at index %d: %w", logEntry.Index, err) 41 | } else if match { 42 | matchedEntries = append(matchedEntries, identity.LogEntry{ 43 | CertSubject: sub, 44 | Issuer: iss, 45 | Index: logEntry.Index, 46 | }) 47 | } 48 | } 49 | return matchedEntries, nil 50 | } 51 | 52 | func ScanEntryOIDExtensions(logEntry ct.LogEntry, monitoredOIDMatchers []extensions.OIDExtension) ([]identity.LogEntry, error) { 53 | matchedEntries := []identity.LogEntry{} 54 | cert := logEntry.X509Cert 55 | for _, monitoredOID := range monitoredOIDMatchers { 56 | match, _, extValue, err := identity.OIDMatchesPolicy(cert, monitoredOID.ObjectIdentifier, monitoredOID.ExtensionValues) 57 | if err != nil { 58 | return nil, fmt.Errorf("error with policy matching at index %d: %w", logEntry.Index, err) 59 | } 60 | if match { 61 | matchedEntries = append(matchedEntries, identity.LogEntry{ 62 | Index: logEntry.Index, 63 | OIDExtension: monitoredOID.ObjectIdentifier, 64 | ExtensionValue: extValue, 65 | }) 66 | } 67 | } 68 | return matchedEntries, nil 69 | } 70 | 71 | func MatchedIndices(logEntries []ct.LogEntry, mvs identity.MonitoredValues) ([]identity.LogEntry, error) { 72 | matchedEntries := []identity.LogEntry{} 73 | for _, entry := range logEntries { 74 | matchedCertSubjectEntries, err := ScanEntryCertSubject(entry, mvs.CertificateIdentities) 75 | if err != nil { 76 | return nil, err 77 | } 78 | matchedEntries = append(matchedEntries, matchedCertSubjectEntries...) 79 | 80 | matchedOIDEntries, err := ScanEntryOIDExtensions(entry, mvs.OIDMatchers) 81 | if err != nil { 82 | return nil, err 83 | } 84 | matchedEntries = append(matchedEntries, matchedOIDEntries...) 85 | } 86 | 87 | return matchedEntries, nil 88 | } 89 | 90 | func IdentitySearch(client *ctclient.LogClient, startIndex int, endIndex int, mvs identity.MonitoredValues) ([]identity.MonitoredIdentity, error) { 91 | retrievedEntries, err := GetCTLogEntries(client, startIndex, endIndex) 92 | if err != nil { 93 | return nil, err 94 | } 95 | matchedEntries, err := MatchedIndices(retrievedEntries, mvs) 96 | if err != nil { 97 | return nil, err 98 | } 99 | identities := identity.CreateIdentitiesList(mvs) 100 | monitoredIdentities := identity.CreateMonitoredIdentities(matchedEntries, identities) 101 | return monitoredIdentities, nil 102 | } 103 | -------------------------------------------------------------------------------- /pkg/ct/monitor_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ct 16 | 17 | import ( 18 | "encoding/asn1" 19 | "reflect" 20 | "testing" 21 | 22 | google_asn1 "github.com/google/certificate-transparency-go/asn1" 23 | 24 | ct "github.com/google/certificate-transparency-go" 25 | "github.com/google/certificate-transparency-go/x509" 26 | "github.com/google/certificate-transparency-go/x509/pkix" 27 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 28 | "github.com/sigstore/rekor-monitor/pkg/identity" 29 | ) 30 | 31 | const ( 32 | subjectName = "test-subject" 33 | issuerName = "test-issuer" 34 | organizationName = "test-org" 35 | ) 36 | 37 | func TestScanEntryCertSubject(t *testing.T) { 38 | testCases := map[string]struct { 39 | inputEntry ct.LogEntry 40 | inputSubjects []identity.CertificateIdentity 41 | expected []identity.LogEntry 42 | }{ 43 | "no matching subject": { 44 | inputEntry: ct.LogEntry{ 45 | Index: 1, 46 | X509Cert: &x509.Certificate{ 47 | Subject: pkix.Name{ 48 | CommonName: subjectName, 49 | }, 50 | }, 51 | }, 52 | inputSubjects: []identity.CertificateIdentity{}, 53 | expected: []identity.LogEntry{}, 54 | }, 55 | "matching subject": { 56 | inputEntry: ct.LogEntry{ 57 | Index: 1, 58 | X509Cert: &x509.Certificate{ 59 | DNSNames: []string{subjectName}, 60 | EmailAddresses: []string{organizationName}, 61 | Extensions: []pkix.Extension{ 62 | { 63 | Id: google_asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, 64 | Value: []byte(issuerName), 65 | }, 66 | }, 67 | Issuer: pkix.Name{ 68 | CommonName: issuerName, 69 | }, 70 | }, 71 | }, 72 | inputSubjects: []identity.CertificateIdentity{ 73 | { 74 | CertSubject: subjectName, 75 | Issuers: []string{issuerName}, 76 | }, 77 | { 78 | CertSubject: organizationName, 79 | Issuers: []string{}, 80 | }, 81 | }, 82 | expected: []identity.LogEntry{ 83 | {Index: 1, 84 | CertSubject: subjectName, 85 | Issuer: issuerName}, 86 | {Index: 1, 87 | CertSubject: organizationName, 88 | Issuer: issuerName}, 89 | }, 90 | }, 91 | } 92 | 93 | for _, tc := range testCases { 94 | logEntries, err := ScanEntryCertSubject(tc.inputEntry, tc.inputSubjects) 95 | if err != nil { 96 | t.Errorf("received error scanning entry for subjects: %v", err) 97 | } 98 | expected := tc.expected 99 | if logEntries == nil { 100 | if expected != nil { 101 | t.Errorf("received nil, expected log entry") 102 | } 103 | } else { 104 | if !reflect.DeepEqual(logEntries, expected) { 105 | t.Errorf("expected %v, received %v", expected, logEntries) 106 | } 107 | } 108 | } 109 | } 110 | 111 | func TestScanEntryOIDExtensions(t *testing.T) { 112 | cert, err := mockCertificateWithExtension(google_asn1.ObjectIdentifier{2, 5, 29, 17}, "test cert value") 113 | if err != nil { 114 | t.Errorf("Expected nil got %v", err) 115 | } 116 | unmatchedAsn1OID := asn1.ObjectIdentifier{2} 117 | matchedAsn1OID := asn1.ObjectIdentifier{2, 5, 29, 17} 118 | extValueString := "test cert value" 119 | testCases := map[string]struct { 120 | inputEntry ct.LogEntry 121 | inputOIDExtensions []extensions.OIDExtension 122 | expected []identity.LogEntry 123 | }{ 124 | "no matching subject": { 125 | inputEntry: ct.LogEntry{ 126 | Index: 1, 127 | X509Cert: cert, 128 | }, 129 | inputOIDExtensions: []extensions.OIDExtension{ 130 | { 131 | ObjectIdentifier: unmatchedAsn1OID, 132 | ExtensionValues: []string{extValueString}, 133 | }, 134 | }, 135 | expected: []identity.LogEntry{}, 136 | }, 137 | "matching subject": { 138 | inputEntry: ct.LogEntry{ 139 | Index: 1, 140 | X509Cert: cert, 141 | }, 142 | inputOIDExtensions: []extensions.OIDExtension{ 143 | { 144 | ObjectIdentifier: matchedAsn1OID, 145 | ExtensionValues: []string{extValueString}, 146 | }, 147 | }, 148 | expected: []identity.LogEntry{ 149 | { 150 | Index: 1, 151 | OIDExtension: matchedAsn1OID, 152 | ExtensionValue: extValueString, 153 | }, 154 | }, 155 | }, 156 | } 157 | 158 | for _, tc := range testCases { 159 | logEntries, err := ScanEntryOIDExtensions(tc.inputEntry, tc.inputOIDExtensions) 160 | if err != nil { 161 | t.Errorf("received error scanning entry for oid extensions: %v", err) 162 | } 163 | expected := tc.expected 164 | if logEntries == nil { 165 | if expected != nil { 166 | t.Errorf("received nil, expected log entry") 167 | } 168 | } else { 169 | if !reflect.DeepEqual(logEntries, expected) { 170 | t.Errorf("expected %v, received %v", expected, logEntries) 171 | } 172 | } 173 | } 174 | } 175 | 176 | func TestMatchedIndices(t *testing.T) { 177 | extCert, err := mockCertificateWithExtension(google_asn1.ObjectIdentifier{2, 5, 29, 17}, "test cert value") 178 | if err != nil { 179 | t.Errorf("Expected nil got %v", err) 180 | } 181 | unmatchedAsn1OID := asn1.ObjectIdentifier{2} 182 | matchedAsn1OID := asn1.ObjectIdentifier{2, 5, 29, 17} 183 | extValueString := "test cert value" 184 | extCert.Extensions = append(extCert.Extensions, pkix.Extension{ 185 | Id: google_asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, 186 | Value: []byte(issuerName), 187 | }) 188 | inputEntries := []ct.LogEntry{ 189 | {Index: 1, 190 | X509Cert: &x509.Certificate{ 191 | DNSNames: []string{subjectName}, 192 | EmailAddresses: []string{organizationName}, 193 | Extensions: extCert.Extensions, 194 | Issuer: pkix.Name{ 195 | CommonName: issuerName, 196 | }, 197 | }, 198 | }, 199 | } 200 | testCases := map[string]struct { 201 | inputEntries []ct.LogEntry 202 | inputMonitoredValues identity.MonitoredValues 203 | expected []identity.LogEntry 204 | }{ 205 | "empty case": { 206 | inputEntries: []ct.LogEntry{}, 207 | inputMonitoredValues: identity.MonitoredValues{}, 208 | expected: []identity.LogEntry{}, 209 | }, 210 | "no matching entries": { 211 | inputEntries: inputEntries, 212 | inputMonitoredValues: identity.MonitoredValues{ 213 | CertificateIdentities: []identity.CertificateIdentity{ 214 | { 215 | CertSubject: "non-matched-subject", 216 | }, 217 | }, 218 | OIDMatchers: []extensions.OIDExtension{ 219 | { 220 | ObjectIdentifier: unmatchedAsn1OID, 221 | ExtensionValues: []string{"unmatched extension value"}, 222 | }, 223 | }, 224 | }, 225 | expected: []identity.LogEntry{}, 226 | }, 227 | "matching certificate identity and issuer": { 228 | inputEntries: inputEntries, 229 | inputMonitoredValues: identity.MonitoredValues{ 230 | CertificateIdentities: []identity.CertificateIdentity{ 231 | { 232 | CertSubject: subjectName, 233 | Issuers: []string{issuerName}, 234 | }, 235 | }, 236 | }, 237 | expected: []identity.LogEntry{ 238 | { 239 | Index: 1, 240 | CertSubject: subjectName, 241 | Issuer: issuerName, 242 | }, 243 | }, 244 | }, 245 | "matching OID extension": { 246 | inputEntries: inputEntries, 247 | inputMonitoredValues: identity.MonitoredValues{ 248 | OIDMatchers: []extensions.OIDExtension{ 249 | { 250 | ObjectIdentifier: matchedAsn1OID, 251 | ExtensionValues: []string{extValueString}, 252 | }, 253 | }, 254 | }, 255 | expected: []identity.LogEntry{ 256 | { 257 | Index: 1, 258 | OIDExtension: matchedAsn1OID, 259 | ExtensionValue: extValueString, 260 | }, 261 | }, 262 | }, 263 | "matching certificate subject and issuer and OID extension": { 264 | inputEntries: inputEntries, 265 | inputMonitoredValues: identity.MonitoredValues{ 266 | CertificateIdentities: []identity.CertificateIdentity{ 267 | { 268 | CertSubject: subjectName, 269 | Issuers: []string{issuerName}, 270 | }, 271 | }, 272 | OIDMatchers: []extensions.OIDExtension{ 273 | { 274 | ObjectIdentifier: matchedAsn1OID, 275 | ExtensionValues: []string{extValueString}, 276 | }, 277 | }, 278 | }, 279 | expected: []identity.LogEntry{ 280 | { 281 | Index: 1, 282 | CertSubject: subjectName, 283 | Issuer: issuerName, 284 | }, 285 | { 286 | Index: 1, 287 | OIDExtension: matchedAsn1OID, 288 | ExtensionValue: extValueString, 289 | }, 290 | }, 291 | }, 292 | } 293 | 294 | for _, tc := range testCases { 295 | matchedEntries, err := MatchedIndices(tc.inputEntries, tc.inputMonitoredValues) 296 | if err != nil { 297 | t.Errorf("error matching indices: %v", err) 298 | } 299 | expected := tc.expected 300 | if !reflect.DeepEqual(matchedEntries, expected) { 301 | t.Errorf("received %v, expected %v", matchedEntries, expected) 302 | } 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /pkg/ct/test_utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ct 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | "net/http/httptest" 21 | "testing" 22 | 23 | google_asn1 "github.com/google/certificate-transparency-go/asn1" 24 | google_x509 "github.com/google/certificate-transparency-go/x509" 25 | google_pkix "github.com/google/certificate-transparency-go/x509/pkix" 26 | ) 27 | 28 | const ( 29 | ValidSTHResponseTreeSize = 3721782 30 | ValidSTHResponseTimestamp uint64 = 1396609800587 31 | ValidSTHResponseSHA256RootHash = "SxKOxksguvHPyUaKYKXoZHzXl91Q257+JQ0AUMlFfeo=" 32 | ValidSTHResponseTreeHeadSignature = "BAMARjBEAiBUYO2tODlUUw4oWGiVPUHqZadRRyXs9T2rSXchA79VsQIgLASkQv3cu4XdPFCZbgFkIUefniNPCpO3LzzHX53l+wg=" 33 | GetSTHConsistencyEmptyResp = `{ "consistency": [ ] }` 34 | ) 35 | 36 | // serverHandlerAt returns a test HTTP server that only expects requests at the given path, and invokes 37 | // the provided handler for that path. 38 | func serverHandlerAt(t *testing.T, path string, handler func(http.ResponseWriter, *http.Request)) *httptest.Server { 39 | t.Helper() 40 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | if r.URL.Path == path { 42 | handler(w, r) 43 | } else { 44 | t.Fatalf("Incorrect URL path: %s", r.URL.Path) 45 | } 46 | })) 47 | } 48 | 49 | // serveRspAt returns a test HTTP server that returns a canned response body rsp for a given path. 50 | func serveRspAt(t *testing.T, path, rsp string) *httptest.Server { 51 | t.Helper() 52 | return serverHandlerAt(t, path, func(w http.ResponseWriter, _ *http.Request) { 53 | if _, err := fmt.Fprint(w, rsp); err != nil { 54 | t.Fatal(err) 55 | } 56 | }) 57 | } 58 | 59 | func mockCertificateWithExtension(oid google_asn1.ObjectIdentifier, value string) (*google_x509.Certificate, error) { 60 | extValue, err := google_asn1.Marshal(value) 61 | if err != nil { 62 | return nil, err 63 | } 64 | cert := &google_x509.Certificate{ 65 | Extensions: []google_pkix.Extension{ 66 | { 67 | Id: oid, 68 | Critical: false, 69 | Value: extValue, 70 | }, 71 | }, 72 | } 73 | return cert, nil 74 | } 75 | -------------------------------------------------------------------------------- /pkg/fulcio/extensions/extensions_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package extensions 16 | 17 | import ( 18 | "encoding/asn1" 19 | "errors" 20 | "testing" 21 | ) 22 | 23 | // Test RenderOIDMatchers 24 | func TestRenderOIDMatchers(t *testing.T) { 25 | testCases := map[string]struct { 26 | inputOIDMatchers OIDMatchers 27 | expectedLen int 28 | }{ 29 | "one OIDExtension": { 30 | inputOIDMatchers: OIDMatchers{ 31 | OIDExtensions: []OIDExtension{{ 32 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 17}, 33 | ExtensionValues: []string{}, 34 | }}, 35 | }, 36 | expectedLen: 1, 37 | }, 38 | "one OIDExtension with extValues": { 39 | inputOIDMatchers: OIDMatchers{ 40 | OIDExtensions: []OIDExtension{{ 41 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 17}, 42 | ExtensionValues: []string{"test", "test2"}, 43 | }}, 44 | }, 45 | expectedLen: 1, 46 | }, 47 | "two OIDExtensions with same OID field": { 48 | inputOIDMatchers: OIDMatchers{ 49 | OIDExtensions: []OIDExtension{{ 50 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 17}, 51 | ExtensionValues: []string{"test1"}, 52 | }, { 53 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 17}, 54 | ExtensionValues: []string{"test"}, 55 | }}, 56 | }, 57 | expectedLen: 1, 58 | }, 59 | "all OID extension types supported": { 60 | inputOIDMatchers: OIDMatchers{ 61 | OIDExtensions: []OIDExtension{{ 62 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 17}, 63 | ExtensionValues: []string{"test1"}, 64 | }, { 65 | ObjectIdentifier: asn1.ObjectIdentifier{2, 5, 29, 18}, 66 | ExtensionValues: []string{"test"}, 67 | }}, 68 | FulcioExtensions: FulcioExtensions{ 69 | BuildConfigDigest: []string{"test"}, 70 | }, 71 | CustomExtensions: []CustomExtension{{ 72 | ObjectIdentifier: "2.5.29.16", 73 | ExtensionValues: []string{"test"}, 74 | }}, 75 | }, 76 | expectedLen: 4, 77 | }, 78 | } 79 | 80 | for _, tc := range testCases { 81 | oidMatchers, err := tc.inputOIDMatchers.RenderOIDMatchers() 82 | if err != nil { 83 | t.Errorf("expected nil, received %v", err) 84 | } 85 | expectedLen := tc.expectedLen 86 | resultLen := len(oidMatchers) 87 | if expectedLen != resultLen { 88 | t.Errorf("expected %d OID matchers, received %d", expectedLen, resultLen) 89 | } 90 | } 91 | } 92 | 93 | // test ParseObjectIdentifier 94 | func TestParseObjectIdentifier(t *testing.T) { 95 | // success cases 96 | objectIdentifierTests := map[string]struct { 97 | oid string 98 | expectedErr error 99 | }{ 100 | "empty string": { 101 | oid: "", 102 | expectedErr: errors.New("could not parse object identifier: empty input"), 103 | }, 104 | "one dot": { 105 | oid: ".", 106 | expectedErr: errors.New("could not parse object identifier: no characters between two dots"), 107 | }, 108 | "four dots": { 109 | oid: "....", 110 | expectedErr: errors.New("could not parse object identifier: no characters between two dots"), 111 | }, 112 | "letters": { 113 | oid: "a.a", 114 | expectedErr: errors.New("strconv.Atoi: parsing \"a\": invalid syntax"), 115 | }, 116 | "ending dot": { 117 | oid: "1.", 118 | expectedErr: errors.New("could not parse object identifier: no characters between two dots"), 119 | }, 120 | "ending dots": { 121 | oid: "1.1.5.6.7.8..", 122 | expectedErr: errors.New("could not parse object identifier: no characters between two dots"), 123 | }, 124 | "leading dot": { 125 | oid: ".1.1.5.67.8", 126 | expectedErr: errors.New("could not parse object identifier: no characters between two dots"), 127 | }, 128 | "one number": { 129 | oid: "1", 130 | expectedErr: nil, 131 | }, 132 | "4 numbers, correctly spaced": { 133 | oid: "1.4.1.5", 134 | expectedErr: nil, 135 | }, 136 | "long numbers": { 137 | oid: "11254215212.4.123.54.1.622", 138 | expectedErr: nil, 139 | }, 140 | } 141 | for name, testCase := range objectIdentifierTests { 142 | t.Run(name, func(t *testing.T) { 143 | oid, err := ParseObjectIdentifier(testCase.oid) 144 | if err != nil && (testCase.expectedErr == nil || err.Error() != testCase.expectedErr.Error()) { 145 | t.Errorf("for oid %s, expected error %v, received %v", oid, testCase.expectedErr, err) 146 | } 147 | }) 148 | } 149 | } 150 | 151 | // test renderFulcioOIDMatchers 152 | func TestRenderFulcioOIDMatchers(t *testing.T) { 153 | extValueString := "test cert value" 154 | fulcioExtensions := FulcioExtensions{ 155 | BuildSignerURI: []string{extValueString}, 156 | BuildConfigURI: []string{"1", "2", "3", "4", "5", "6"}, 157 | } 158 | 159 | renderedFulcioOIDMatchers := fulcioExtensions.RenderFulcioOIDMatchers() 160 | 161 | if len(renderedFulcioOIDMatchers) != 2 { 162 | t.Errorf("expected OIDMatchers to have length 2, received length %d", len(renderedFulcioOIDMatchers)) 163 | } 164 | 165 | buildSignerURIMatcher := renderedFulcioOIDMatchers[0] 166 | buildSignerURIMatcherOID := buildSignerURIMatcher.ObjectIdentifier 167 | buildSignerURIMatcherExtValues := buildSignerURIMatcher.ExtensionValues 168 | if !buildSignerURIMatcherOID.Equal(OIDBuildSignerURI) { 169 | t.Errorf("expected OIDExtension to be BuildSignerURI 1.3.6.1.4.1.57264.1.9, received %s", buildSignerURIMatcherOID) 170 | } 171 | if len(buildSignerURIMatcherExtValues) != 1 { 172 | t.Errorf("expected BuildSignerURI extension values to have length 1, received %d", len(buildSignerURIMatcherExtValues)) 173 | } 174 | buildSignerURIMatcherExtValue := buildSignerURIMatcherExtValues[0] 175 | if buildSignerURIMatcherExtValue != extValueString { 176 | t.Errorf("expected BuildSignerURI extension value to be 'test cert value', received %s", buildSignerURIMatcherExtValue) 177 | } 178 | 179 | buildConfigURIMatcher := renderedFulcioOIDMatchers[1] 180 | buildConfigURIMatcherOID := buildConfigURIMatcher.ObjectIdentifier 181 | buildConfigURIMatcherExtValues := buildConfigURIMatcher.ExtensionValues 182 | if !buildConfigURIMatcherOID.Equal(OIDBuildConfigURI) { 183 | t.Errorf("expected OIDExtension to be BuildConfigURI 1.3.6.1.4.1.57264.1.18, received %s", buildConfigURIMatcherOID) 184 | } 185 | 186 | if len(buildConfigURIMatcherExtValues) != 6 { 187 | t.Errorf("expected BuildConfigURI extension values to have length 6, received %d", len(buildConfigURIMatcherExtValues)) 188 | } 189 | } 190 | 191 | func TestRenderFulcioOIDMatchersAllFields(t *testing.T) { 192 | testValueString := "test" 193 | fulcioExtensions := FulcioExtensions{ 194 | Issuer: []string{testValueString}, 195 | GithubWorkflowTrigger: []string{testValueString}, 196 | GithubWorkflowSHA: []string{testValueString}, 197 | GithubWorkflowName: []string{testValueString}, 198 | GithubWorkflowRepository: []string{testValueString}, 199 | GithubWorkflowRef: []string{testValueString}, 200 | BuildSignerURI: []string{testValueString}, 201 | BuildConfigURI: []string{testValueString}, 202 | BuildSignerDigest: []string{testValueString}, 203 | RunnerEnvironment: []string{testValueString}, 204 | SourceRepositoryURI: []string{testValueString}, 205 | SourceRepositoryDigest: []string{testValueString}, 206 | SourceRepositoryIdentifier: []string{testValueString}, 207 | SourceRepositoryRef: []string{testValueString}, 208 | SourceRepositoryOwnerURI: []string{testValueString}, 209 | SourceRepositoryOwnerIdentifier: []string{testValueString}, 210 | SourceRepositoryVisibilityAtSigning: []string{testValueString}, 211 | BuildConfigDigest: []string{testValueString}, 212 | BuildTrigger: []string{testValueString}, 213 | RunInvocationURI: []string{testValueString}, 214 | } 215 | 216 | renderedFulcioOIDMatchers := fulcioExtensions.RenderFulcioOIDMatchers() 217 | 218 | if len(renderedFulcioOIDMatchers) != 21 { 219 | t.Errorf("expected OIDMatchers to have length 21, received length %d", len(renderedFulcioOIDMatchers)) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /pkg/identity/identity.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package identity 16 | 17 | import ( 18 | "crypto/x509" 19 | "crypto/x509/pkix" 20 | "encoding/asn1" 21 | "encoding/json" 22 | "errors" 23 | "fmt" 24 | "regexp" 25 | "strconv" 26 | "strings" 27 | 28 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 29 | "github.com/sigstore/sigstore/pkg/cryptoutils" 30 | 31 | google_asn1 "github.com/google/certificate-transparency-go/asn1" 32 | google_x509 "github.com/google/certificate-transparency-go/x509" 33 | ) 34 | 35 | var ( 36 | certExtensionOIDCIssuer = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1} 37 | certExtensionOIDCIssuerV2 = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8} 38 | ) 39 | 40 | // CertificateIdentity holds a certificate subject and an optional list of identity issuers 41 | type CertificateIdentity struct { 42 | CertSubject string `yaml:"certSubject"` 43 | Issuers []string `yaml:"issuers"` 44 | } 45 | 46 | // MonitoredValues holds a set of values to compare against a given entry 47 | type MonitoredValues struct { 48 | // CertificateIdentities contains a list of subjects and issuers 49 | CertificateIdentities []CertificateIdentity `yaml:"certIdentities"` 50 | // Fingerprints contains a list of key fingerprints. Values are as follows: 51 | // For keys, certificates, and minisign, hex-encoded SHA-256 digest 52 | // of the DER-encoded PKIX public key or certificate 53 | // For SSH and PGP, the standard for each ecosystem: 54 | // For SSH, unpadded base-64 encoded SHA-256 digest of the key 55 | // For PGP, hex-encoded SHA-1 digest of a key, which can be either 56 | // a primary key or subkey 57 | Fingerprints []string `yaml:"fingerprints"` 58 | // Subjects contains a list of subjects that are not specified in a 59 | // certificate, such as a SSH key or PGP key email address 60 | Subjects []string `yaml:"subjects"` 61 | // OIDMatchers represents a list of OID extension fields and associated values, 62 | // which includes those constructed directly, those supported by Fulcio, and any constructed via dot notation. 63 | OIDMatchers []extensions.OIDExtension `yaml:"oidMatchers"` 64 | } 65 | 66 | // LogEntry holds a certificate subject, issuer, OID extension and associated value, and log entry metadata 67 | type LogEntry struct { 68 | CertSubject string 69 | Issuer string 70 | Fingerprint string 71 | Subject string 72 | Index int64 73 | UUID string 74 | OIDExtension asn1.ObjectIdentifier 75 | ExtensionValue string 76 | } 77 | 78 | func (e *LogEntry) String() string { 79 | var parts []string 80 | for _, s := range []string{e.CertSubject, e.Issuer, e.Fingerprint, e.Subject, strconv.Itoa(int(e.Index)), e.UUID, e.OIDExtension.String(), e.ExtensionValue} { 81 | if strings.TrimSpace(s) != "" { 82 | parts = append(parts, s) 83 | } 84 | } 85 | return strings.Join(parts, " ") 86 | } 87 | 88 | // MonitoredIdentity holds an identity and associated log entries matching the identity being monitored. 89 | type MonitoredIdentity struct { 90 | Identity string `json:"identity"` 91 | FoundIdentityEntries []LogEntry `json:"foundIdentityEntries"` 92 | } 93 | 94 | // PrintMonitoredIdentities formats a list of monitored identities and corresponding log entries 95 | // using JSON tagging into JSON formatting. 96 | func PrintMonitoredIdentities(monitoredIdentities []MonitoredIdentity) ([]byte, error) { 97 | jsonBody, err := json.MarshalIndent(monitoredIdentities, "", "\t") 98 | if err != nil { 99 | return nil, err 100 | } 101 | return jsonBody, nil 102 | } 103 | 104 | // CreateIdentitiesList takes in a MonitoredValues input and returns a list of all currently monitored identities. 105 | // It returns a list of strings. 106 | func CreateIdentitiesList(mvs MonitoredValues) []string { 107 | identities := []string{} 108 | 109 | for _, certID := range mvs.CertificateIdentities { 110 | identities = append(identities, certID.CertSubject) 111 | identities = append(identities, certID.Issuers...) 112 | } 113 | 114 | identities = append(identities, mvs.Fingerprints...) 115 | identities = append(identities, mvs.Subjects...) 116 | 117 | for _, oidMatcher := range mvs.OIDMatchers { 118 | identities = append(identities, oidMatcher.ExtensionValues...) 119 | } 120 | 121 | return identities 122 | } 123 | 124 | // CreateMonitoredIdentities takes in a list of IdentityEntries and groups them by 125 | // associated identity based on an input list of identities to monitor. 126 | // It returns a list of MonitoredIdentities. 127 | func CreateMonitoredIdentities(inputIdentityEntries []LogEntry, monitoredIdentities []string) []MonitoredIdentity { 128 | identityMap := make(map[string]bool) 129 | for _, id := range monitoredIdentities { 130 | identityMap[id] = true 131 | } 132 | 133 | monitoredIdentityMap := make(map[string][]LogEntry) 134 | for _, idEntry := range inputIdentityEntries { 135 | switch { 136 | case identityMap[idEntry.CertSubject]: 137 | idCertSubject := idEntry.CertSubject 138 | _, ok := monitoredIdentityMap[idCertSubject] 139 | if ok { 140 | monitoredIdentityMap[idCertSubject] = append(monitoredIdentityMap[idCertSubject], idEntry) 141 | } else { 142 | monitoredIdentityMap[idCertSubject] = []LogEntry{idEntry} 143 | } 144 | case identityMap[idEntry.ExtensionValue]: 145 | idExtValue := idEntry.ExtensionValue 146 | _, ok := monitoredIdentityMap[idExtValue] 147 | if ok { 148 | monitoredIdentityMap[idExtValue] = append(monitoredIdentityMap[idExtValue], idEntry) 149 | } else { 150 | monitoredIdentityMap[idExtValue] = []LogEntry{idEntry} 151 | } 152 | case identityMap[idEntry.Fingerprint]: 153 | idFingerprint := idEntry.Fingerprint 154 | _, ok := monitoredIdentityMap[idFingerprint] 155 | if ok { 156 | monitoredIdentityMap[idFingerprint] = append(monitoredIdentityMap[idFingerprint], idEntry) 157 | } else { 158 | monitoredIdentityMap[idFingerprint] = []LogEntry{idEntry} 159 | } 160 | case identityMap[idEntry.Subject]: 161 | idSubject := idEntry.Subject 162 | _, ok := monitoredIdentityMap[idSubject] 163 | if ok { 164 | monitoredIdentityMap[idSubject] = append(monitoredIdentityMap[idSubject], idEntry) 165 | } else { 166 | monitoredIdentityMap[idSubject] = []LogEntry{idEntry} 167 | } 168 | } 169 | } 170 | 171 | parsedMonitoredIdentities := []MonitoredIdentity{} 172 | for id, idEntries := range monitoredIdentityMap { 173 | parsedMonitoredIdentities = append(parsedMonitoredIdentities, MonitoredIdentity{ 174 | Identity: id, 175 | FoundIdentityEntries: idEntries, 176 | }) 177 | } 178 | 179 | return parsedMonitoredIdentities 180 | } 181 | 182 | // MonitoredValuesExist checks if there are monitored values in an input and returns accordingly. 183 | func MonitoredValuesExist(mvs MonitoredValues) bool { 184 | if len(mvs.CertificateIdentities) > 0 { 185 | return true 186 | } 187 | if len(mvs.Fingerprints) > 0 { 188 | return true 189 | } 190 | if len(mvs.OIDMatchers) > 0 { 191 | return true 192 | } 193 | if len(mvs.Subjects) > 0 { 194 | return true 195 | } 196 | return false 197 | } 198 | 199 | // getExtension gets a certificate extension by OID where the extension value is an 200 | // ASN.1-encoded string 201 | func getExtension[Certificate *x509.Certificate | *google_x509.Certificate](certificate Certificate, oid asn1.ObjectIdentifier) (string, error) { 202 | switch cert := any(certificate).(type) { 203 | case *x509.Certificate: 204 | for _, ext := range cert.Extensions { 205 | if !ext.Id.Equal(oid) { 206 | continue 207 | } 208 | var extValue string 209 | rest, err := asn1.Unmarshal(ext.Value, &extValue) 210 | if err != nil { 211 | return "", fmt.Errorf("%w", err) 212 | } 213 | if len(rest) != 0 { 214 | return "", fmt.Errorf("unmarshalling extension had rest for oid %v", oid) 215 | } 216 | return extValue, nil 217 | } 218 | return "", nil 219 | case *google_x509.Certificate: 220 | for _, ext := range cert.Extensions { 221 | if !ext.Id.Equal((google_asn1.ObjectIdentifier)(oid)) { 222 | continue 223 | } 224 | var extValue string 225 | rest, err := asn1.Unmarshal(ext.Value, &extValue) 226 | if err != nil { 227 | return "", fmt.Errorf("%w", err) 228 | } 229 | if len(rest) != 0 { 230 | return "", fmt.Errorf("unmarshalling extension had rest for oid %v", oid) 231 | } 232 | return extValue, nil 233 | } 234 | return "", nil 235 | } 236 | return "", errors.New("certificate was neither x509 nor google_x509") 237 | } 238 | 239 | // getDeprecatedExtension gets a certificate extension by OID where the extension value is a raw string 240 | func getDeprecatedExtension[Certificate *x509.Certificate | *google_x509.Certificate](certificate Certificate, oid asn1.ObjectIdentifier) (string, error) { 241 | switch cert := any(certificate).(type) { 242 | case *x509.Certificate: 243 | for _, ext := range cert.Extensions { 244 | if ext.Id.Equal(oid) { 245 | return string(ext.Value), nil 246 | } 247 | } 248 | return "", nil 249 | case *google_x509.Certificate: 250 | for _, ext := range cert.Extensions { 251 | if ext.Id.Equal((google_asn1.ObjectIdentifier)(oid)) { 252 | return string(ext.Value), nil 253 | } 254 | } 255 | return "", nil 256 | } 257 | return "", errors.New("certificate was neither x509 nor google_x509") 258 | } 259 | 260 | // OIDMatchesPolicy returns if a certificate contains both a given OID field and a matching value associated with that field 261 | // if true, it returns the OID extension and extension value that were matched on 262 | func OIDMatchesPolicy[Certificate *x509.Certificate | *google_x509.Certificate](cert Certificate, oid asn1.ObjectIdentifier, extensionValues []string) (bool, asn1.ObjectIdentifier, string, error) { 263 | extValue, err := getExtension(cert, oid) 264 | if err != nil { 265 | return false, nil, "", fmt.Errorf("error getting extension value: %w", err) 266 | } 267 | if extValue == "" { 268 | return false, nil, "", nil 269 | } 270 | for _, extensionValue := range extensionValues { 271 | if extValue == extensionValue { 272 | return true, oid, extValue, nil 273 | } 274 | } 275 | return false, nil, "", nil 276 | } 277 | 278 | // getSubjectAlternateNames extracts all subject alternative names from 279 | // the certificate, including email addresses, DNS, IP addresses, URIs, and OtherName SANs 280 | // duplicate of cryptoutils function GetSubjectAlternateNames to match in case of google_x509 fork certificate 281 | func getSubjectAlternateNames[Certificate *x509.Certificate | *google_x509.Certificate](certificate Certificate) []string { 282 | sans := []string{} 283 | switch cert := any(certificate).(type) { 284 | case *x509.Certificate: 285 | sans = append(sans, cert.DNSNames...) 286 | sans = append(sans, cert.EmailAddresses...) 287 | for _, ip := range cert.IPAddresses { 288 | sans = append(sans, ip.String()) 289 | } 290 | for _, uri := range cert.URIs { 291 | sans = append(sans, uri.String()) 292 | } 293 | // ignore error if there's no OtherName SAN 294 | otherName, _ := cryptoutils.UnmarshalOtherNameSAN(cert.Extensions) 295 | if len(otherName) > 0 { 296 | sans = append(sans, otherName) 297 | } 298 | return sans 299 | case *google_x509.Certificate: 300 | sans = append(sans, cert.DNSNames...) 301 | sans = append(sans, cert.EmailAddresses...) 302 | for _, ip := range cert.IPAddresses { 303 | sans = append(sans, ip.String()) 304 | } 305 | for _, uri := range cert.URIs { 306 | sans = append(sans, uri.String()) 307 | } 308 | // ignore error if there's no OtherName SAN 309 | pkixExts := []pkix.Extension{} 310 | for _, googleExt := range cert.Extensions { 311 | pkixExt := pkix.Extension{ 312 | Id: (asn1.ObjectIdentifier)(googleExt.Id), 313 | Critical: googleExt.Critical, 314 | Value: googleExt.Value, 315 | } 316 | pkixExts = append(pkixExts, pkixExt) 317 | } 318 | otherName, _ := cryptoutils.UnmarshalOtherNameSAN(pkixExts) 319 | if len(otherName) > 0 { 320 | sans = append(sans, otherName) 321 | } 322 | return sans 323 | } 324 | return sans 325 | } 326 | 327 | // CertMatchesPolicy returns true if a certificate contains a given subject and optionally a given issuer 328 | // expectedSub and expectedIssuers can be regular expressions 329 | // CertMatchesPolicy also returns the matched subject and issuer on success 330 | func CertMatchesPolicy[Certificate *x509.Certificate | *google_x509.Certificate](cert Certificate, expectedSub string, expectedIssuers []string) (bool, string, string, error) { 331 | sans := getSubjectAlternateNames(cert) 332 | var issuer string 333 | var err error 334 | issuer, err = getExtension(cert, certExtensionOIDCIssuerV2) 335 | if err != nil || issuer == "" { 336 | // fallback to deprecated issuer extension 337 | issuer, err = getDeprecatedExtension(cert, certExtensionOIDCIssuer) 338 | if err != nil || issuer == "" { 339 | return false, "", "", err 340 | } 341 | } 342 | subjectMatches := false 343 | regex, err := regexp.Compile(expectedSub) 344 | if err != nil { 345 | return false, "", "", fmt.Errorf("malformed subject regex: %w", err) 346 | } 347 | matchedSub := "" 348 | for _, sub := range sans { 349 | if regex.MatchString(sub) { 350 | subjectMatches = true 351 | matchedSub = sub 352 | } 353 | } 354 | // allow any issuer 355 | if len(expectedIssuers) == 0 { 356 | return subjectMatches, matchedSub, issuer, nil 357 | } 358 | 359 | issuerMatches := false 360 | for _, expectedIss := range expectedIssuers { 361 | regex, err := regexp.Compile(expectedIss) 362 | if err != nil { 363 | return false, "", "", fmt.Errorf("malformed issuer regex: %w", err) 364 | } 365 | if regex.MatchString(issuer) { 366 | issuerMatches = true 367 | } 368 | } 369 | return subjectMatches && issuerMatches, matchedSub, issuer, nil 370 | } 371 | -------------------------------------------------------------------------------- /pkg/notifications/email.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/sigstore/rekor-monitor/pkg/identity" 21 | "github.com/wneessen/go-mail" 22 | ) 23 | 24 | // EmailNotificationInput extends the NotificationPlatform interface to support 25 | // found identity notification by sending emails to a specified user. 26 | type EmailNotificationInput struct { 27 | RecipientEmailAddress string `yaml:"recipientEmailAddress"` 28 | SenderEmailAddress string `yaml:"senderEmailAddress"` 29 | SenderSMTPUsername string `yaml:"senderSMTPUsername"` 30 | SenderSMTPPassword string `yaml:"senderSMTPPassword"` 31 | SMTPHostURL string `yaml:"SMTPHostURL"` 32 | SMTPCustomOptions []mail.Option `yaml:"SMTPCustomOptions"` 33 | } 34 | 35 | func GenerateEmailBody(monitoredIdentities []identity.MonitoredIdentity) (string, error) { 36 | body, err := identity.PrintMonitoredIdentities(monitoredIdentities) 37 | if err != nil { 38 | return "", err 39 | } 40 | return "
" + string(body) + "
", nil 41 | } 42 | 43 | // Send takes in an EmailNotification input and attempts to send the 44 | // following list of found identities to the given email address. 45 | // It returns an error in the case of failure. 46 | func (emailNotificationInput EmailNotificationInput) Send(ctx context.Context, monitoredIdentities []identity.MonitoredIdentity) error { 47 | email := mail.NewMsg() 48 | if err := email.From(emailNotificationInput.SenderEmailAddress); err != nil { 49 | return err 50 | } 51 | if err := email.To(emailNotificationInput.RecipientEmailAddress); err != nil { 52 | return err 53 | } 54 | emailSubject := NotificationSubject 55 | email.Subject(emailSubject) 56 | emailBody, err := GenerateEmailBody(monitoredIdentities) 57 | if err != nil { 58 | return err 59 | } 60 | email.SetBodyString(mail.TypeTextHTML, emailBody) 61 | var client *mail.Client 62 | defaultOpts := []mail.Option{ 63 | mail.WithUsername(emailNotificationInput.SenderSMTPUsername), 64 | mail.WithPassword(emailNotificationInput.SenderSMTPPassword), 65 | } 66 | defaultOpts = append(defaultOpts, emailNotificationInput.SMTPCustomOptions...) 67 | client, err = mail.NewClient(emailNotificationInput.SMTPHostURL, defaultOpts...) 68 | if err != nil { 69 | return err 70 | } 71 | err = client.DialAndSendWithContext(ctx, email) 72 | client.Close() 73 | return err 74 | } 75 | -------------------------------------------------------------------------------- /pkg/notifications/email_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | "testing" 21 | 22 | smtpmock "github.com/mocktools/go-smtp-mock/v2" 23 | "github.com/sigstore/rekor-monitor/pkg/identity" 24 | "github.com/wneessen/go-mail" 25 | ) 26 | 27 | func TestEmailSendFailureCases(t *testing.T) { 28 | emailNotificationInputs := []EmailNotificationInput{ 29 | { 30 | RecipientEmailAddress: "test-recipient@example.com", 31 | SenderEmailAddress: "test-sender@example.com", 32 | SenderSMTPUsername: "example-username", 33 | SenderSMTPPassword: "example-password", 34 | SMTPHostURL: "smtp.gmail.com", 35 | }, 36 | { 37 | RecipientEmailAddress: "", 38 | SenderEmailAddress: "test-sender@example.com", 39 | SenderSMTPUsername: "example-username", 40 | SenderSMTPPassword: "example-password", 41 | SMTPHostURL: "smtp.gmail.com", 42 | }, 43 | { 44 | RecipientEmailAddress: "test-recipient", 45 | SenderEmailAddress: "", 46 | SenderSMTPUsername: "example-username", 47 | SenderSMTPPassword: "example-password", 48 | SMTPHostURL: "smtp.gmail.com", 49 | }, 50 | { 51 | RecipientEmailAddress: "test-recipient", 52 | SenderEmailAddress: "example@mail.com", 53 | SenderSMTPUsername: "example-username", 54 | SenderSMTPPassword: "example-password", 55 | SMTPHostURL: "smtp.mail.com", 56 | }, 57 | } 58 | monitoredIdentity := identity.MonitoredIdentity{ 59 | Identity: "test-identity", 60 | FoundIdentityEntries: []identity.LogEntry{ 61 | { 62 | CertSubject: "test-cert-subject", 63 | UUID: "test-uuid", 64 | Index: 0, 65 | }, 66 | }, 67 | } 68 | 69 | for _, emailNotificationInput := range emailNotificationInputs { 70 | err := emailNotificationInput.Send(context.Background(), []identity.MonitoredIdentity{monitoredIdentity}) 71 | if err == nil { 72 | t.Errorf("expected error, received nil") 73 | } 74 | } 75 | } 76 | 77 | func TestEmailSendMockSMTPServerSuccess(t *testing.T) { 78 | server := smtpmock.New(smtpmock.ConfigurationAttr{ 79 | HostAddress: "127.0.0.1", 80 | }) 81 | if err := server.Start(); err != nil { 82 | t.Errorf("error starting server: %v", err) 83 | } 84 | monitoredIdentity := identity.MonitoredIdentity{ 85 | Identity: "test-identity", 86 | FoundIdentityEntries: []identity.LogEntry{ 87 | { 88 | CertSubject: "test-cert-subject", 89 | UUID: "test-uuid", 90 | Index: 0, 91 | }, 92 | }, 93 | } 94 | emailNotificationInput := EmailNotificationInput{ 95 | RecipientEmailAddress: "test-recipient@mail.com", 96 | SenderEmailAddress: "example-sender@mail.com", 97 | SMTPHostURL: "127.0.0.1", 98 | SMTPCustomOptions: []mail.Option{mail.WithPort(server.PortNumber()), mail.WithTLSPolicy(mail.NoTLS), mail.WithHELO("example.com")}, 99 | } 100 | 101 | err := emailNotificationInput.Send(context.Background(), []identity.MonitoredIdentity{monitoredIdentity}) 102 | if err != nil { 103 | t.Errorf("expected nil, received error %v", err) 104 | } 105 | } 106 | 107 | func TestEmailSendMockSMTPServerFailure(t *testing.T) { 108 | server := smtpmock.New(smtpmock.ConfigurationAttr{ 109 | HostAddress: "127.0.0.1", 110 | BlacklistedMailfromEmails: []string{"example-sender@mail.com"}, 111 | }) 112 | if err := server.Start(); err != nil { 113 | t.Errorf("error starting server: %v", err) 114 | } 115 | monitoredIdentity := identity.MonitoredIdentity{ 116 | Identity: "test-identity", 117 | FoundIdentityEntries: []identity.LogEntry{ 118 | { 119 | CertSubject: "test-cert-subject", 120 | UUID: "test-uuid", 121 | Index: 0, 122 | }, 123 | }, 124 | } 125 | emailNotificationInput := EmailNotificationInput{ 126 | RecipientEmailAddress: "test-recipient@mail.com", 127 | SenderEmailAddress: "example-sender@mail.com", 128 | SMTPHostURL: "127.0.0.1", 129 | SMTPCustomOptions: []mail.Option{mail.WithPort(server.PortNumber()), mail.WithTLSPolicy(mail.NoTLS), mail.WithHELO("example.com")}, 130 | } 131 | 132 | err := emailNotificationInput.Send(context.Background(), []identity.MonitoredIdentity{monitoredIdentity}) 133 | if err == nil || !strings.Contains(err.Error(), "421 Service not available") { 134 | t.Errorf("expected 421 Service not available, received error %v", err) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /pkg/notifications/github_issues.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | 21 | "github.com/google/go-github/v65/github" 22 | "github.com/sigstore/rekor-monitor/pkg/identity" 23 | ) 24 | 25 | var ( 26 | notificationPlatformGitHubIssueBodyHeaderText = "Rekor-monitor found the following pairs of monitored identities and matching log entries: " 27 | notificationPlatformGitHubIssueLabels = []string{"rekor-monitor", "automatically generated"} 28 | ) 29 | 30 | // GitHubIssueInput extends the NotificationPlatform interface to support found identity 31 | // notification via creating new GitHub issues in a given repo. 32 | type GitHubIssueInput struct { 33 | AssigneeUsername string `yaml:"assigneeUsername"` 34 | RepositoryOwner string `yaml:"repositoryOwner"` 35 | RepositoryName string `yaml:"repositoryName"` 36 | // The PAT or other access token to authenticate creating an issue. 37 | // The authentication token requires repo write and push access. 38 | AuthenticationToken string `yaml:"authenticationToken"` 39 | // For users who want to pass in a custom client. 40 | // If nil, a default client with the given authentication token will be instantiated. 41 | GitHubClient *github.Client `yaml:"githubClient"` 42 | } 43 | 44 | func generateGitHubIssueBody(monitoredIdentities []identity.MonitoredIdentity) (string, error) { 45 | header := notificationPlatformGitHubIssueBodyHeaderText 46 | body, err := identity.PrintMonitoredIdentities(monitoredIdentities) 47 | if err != nil { 48 | return "", err 49 | } 50 | return strings.Join([]string{header, "```\n" + string(body) + "\n```"}, "\n"), nil 51 | } 52 | 53 | // Send takes in a GitHubIssueInput and attempts to create the specified issue 54 | // denoting the following found identities. 55 | // It returns an error in the case of failure. 56 | func (gitHubIssueInput GitHubIssueInput) Send(ctx context.Context, monitoredIdentities []identity.MonitoredIdentity) error { 57 | issueTitle := NotificationSubject 58 | issueBody, err := generateGitHubIssueBody(monitoredIdentities) 59 | if err != nil { 60 | return err 61 | } 62 | var client *github.Client 63 | if gitHubIssueInput.GitHubClient == nil { 64 | client = github.NewClient(nil).WithAuthToken(gitHubIssueInput.AuthenticationToken) 65 | } else { 66 | client = gitHubIssueInput.GitHubClient 67 | } 68 | labels := notificationPlatformGitHubIssueLabels 69 | 70 | issueRequest := &github.IssueRequest{ 71 | Title: &issueTitle, 72 | Body: &issueBody, 73 | Labels: &labels, 74 | Assignee: &gitHubIssueInput.AssigneeUsername, 75 | } 76 | _, _, err = client.Issues.Create(ctx, gitHubIssueInput.RepositoryOwner, gitHubIssueInput.RepositoryName, issueRequest) 77 | return err 78 | } 79 | -------------------------------------------------------------------------------- /pkg/notifications/github_issues_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/google/go-github/v65/github" 23 | "github.com/migueleliasweb/go-github-mock/src/mock" 24 | "github.com/sigstore/rekor-monitor/pkg/identity" 25 | 26 | "net/http" 27 | ) 28 | 29 | func TestGitHubIssueInputSend401BadCredentialsFailure(t *testing.T) { 30 | gitHubIssuesInput := GitHubIssueInput{ 31 | AssigneeUsername: "test-assignee", 32 | RepositoryOwner: "test-owner", 33 | RepositoryName: "test-repo", 34 | AuthenticationToken: "", 35 | } 36 | ctx := context.Background() 37 | err := gitHubIssuesInput.Send(ctx, []identity.MonitoredIdentity{}) 38 | if err == nil { 39 | t.Errorf("expected 401 Bad Credentials, received error %v", err) 40 | } 41 | } 42 | 43 | func TestGitHubIssueInputMockSendSuccess(t *testing.T) { 44 | testIssueTitle := "test-issue" 45 | mockedHTTPClient := mock.NewMockedHTTPClient( 46 | mock.WithRequestMatch( 47 | mock.PostReposIssuesByOwnerByRepo, 48 | &github.Issue{ 49 | ID: github.Int64(1), 50 | Number: github.Int(1), 51 | Title: &testIssueTitle, 52 | }, 53 | &github.Response{ 54 | Response: &http.Response{ 55 | StatusCode: http.StatusAccepted, 56 | }, 57 | }, 58 | nil, 59 | ), 60 | ) 61 | mockGitHubClient := github.NewClient(mockedHTTPClient) 62 | gitHubIssuesInput := GitHubIssueInput{ 63 | AssigneeUsername: "test-assignee", 64 | RepositoryOwner: "test-owner", 65 | RepositoryName: "test-repo", 66 | AuthenticationToken: "", 67 | GitHubClient: mockGitHubClient, 68 | } 69 | ctx := context.Background() 70 | err := gitHubIssuesInput.Send(ctx, []identity.MonitoredIdentity{}) 71 | if err != nil { 72 | t.Errorf("expected nil, received error %v", err) 73 | } 74 | } 75 | 76 | func TestGitHubIssueInputMockSendFailure(t *testing.T) { 77 | mockedHTTPClient := mock.NewMockedHTTPClient( 78 | mock.WithRequestMatchHandler( 79 | mock.PostReposIssuesByOwnerByRepo, 80 | http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 81 | mock.WriteError( 82 | w, 83 | http.StatusInternalServerError, 84 | "400 Bad Request", 85 | ) 86 | }), 87 | ), 88 | ) 89 | mockGitHubClient := github.NewClient(mockedHTTPClient) 90 | gitHubIssuesInput := GitHubIssueInput{ 91 | AssigneeUsername: "test-assignee", 92 | RepositoryOwner: "test-owner", 93 | RepositoryName: "test-repo", 94 | AuthenticationToken: "", 95 | GitHubClient: mockGitHubClient, 96 | } 97 | ctx := context.Background() 98 | err := gitHubIssuesInput.Send(ctx, []identity.MonitoredIdentity{}) 99 | if err == nil || !strings.Contains(err.Error(), "400 Bad Request") { 100 | t.Errorf("expected 400 Bad Request, received %v", err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /pkg/notifications/mailgun.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/mailgun/mailgun-go/v4" 21 | "github.com/sigstore/rekor-monitor/pkg/identity" 22 | ) 23 | 24 | // MailgunNotificationInput extends the NotificationPlatform interface to support 25 | // found identity notification by sending emails to a specified user via Mailgun. 26 | type MailgunNotificationInput struct { 27 | RecipientEmailAddress string `yaml:"recipientEmailAddress"` 28 | SenderEmailAddress string `yaml:"senderEmailAddress"` 29 | MailgunAPIKey string `yaml:"mailgunAPIKey"` 30 | MailgunDomainName string `yaml:"mailgunDomainName"` 31 | } 32 | 33 | // Send takes in an MailgunNotificationInput and attempts to send the 34 | // following list of found identities to the given email address. 35 | // It returns an error in the case of failure. 36 | func (mailgunNotificationInput MailgunNotificationInput) Send(ctx context.Context, monitoredIdentities []identity.MonitoredIdentity) error { 37 | subject := NotificationSubject 38 | emailHTMLBody, err := GenerateEmailBody(monitoredIdentities) 39 | if err != nil { 40 | return err 41 | } 42 | mg := mailgun.NewMailgun(mailgunNotificationInput.MailgunDomainName, mailgunNotificationInput.MailgunAPIKey) 43 | email := mailgun.NewMessage(mailgunNotificationInput.SenderEmailAddress, subject, "", mailgunNotificationInput.RecipientEmailAddress) 44 | email.SetHTML(emailHTMLBody) 45 | _, _, err = mg.Send(ctx, email) 46 | return err 47 | } 48 | -------------------------------------------------------------------------------- /pkg/notifications/mailgun_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | 21 | "github.com/sigstore/rekor-monitor/pkg/identity" 22 | ) 23 | 24 | func TestMailgunSendFailure(t *testing.T) { 25 | mailgunNotificationInput := MailgunNotificationInput{ 26 | RecipientEmailAddress: "test-recipient@example.com", 27 | SenderEmailAddress: "test-sender@example.com", 28 | MailgunAPIKey: "", 29 | MailgunDomainName: "", 30 | } 31 | monitoredIdentity := identity.MonitoredIdentity{ 32 | Identity: "test-identity", 33 | FoundIdentityEntries: []identity.LogEntry{ 34 | { 35 | CertSubject: "test-cert-subject", 36 | UUID: "test-uuid", 37 | Index: 0, 38 | }, 39 | }, 40 | } 41 | 42 | err := mailgunNotificationInput.Send(context.Background(), []identity.MonitoredIdentity{monitoredIdentity}) 43 | if err == nil { 44 | t.Errorf("expected error, received nil") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkg/notifications/notifications.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // This file details the named fields of OID extensions supported by Fulcio. 16 | // A list of OID extensions supported by Fulcio can be found here: 17 | // https://github.com/sigstore/fulcio/blob/main/docs/oid-info.md 18 | // Named fields in this file have been imported from this file in the Fulcio repository: 19 | // https://github.com/sigstore/fulcio/blob/main/pkg/certificate/extensions.go 20 | // Updates to the Fulcio repository extensions file should be matched here accordingly and vice-versa. 21 | 22 | package notifications 23 | 24 | import ( 25 | "context" 26 | "fmt" 27 | "time" 28 | 29 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 30 | "github.com/sigstore/rekor-monitor/pkg/identity" 31 | ) 32 | 33 | var ( 34 | NotificationSubject = fmt.Sprintf("rekor-monitor workflow results for %s", time.Now().Format(time.RFC822)) 35 | ) 36 | 37 | // NotificationPlatform provides the Send() method to handle alerting logic 38 | // for the respective notification platform extending the interface. 39 | type NotificationPlatform interface { 40 | Send(context.Context, []identity.MonitoredIdentity) error 41 | } 42 | 43 | // ConfigMonitoredValues holds a set of values to compare against a given entry. 44 | // ConfigMonitoredValues holds Object Identifier extensions and associated values 45 | // that can be constructed either directly from asn1.ObjectIdentifier, 46 | // via OID extensions supported by Fulcio, or via dot notation. 47 | type ConfigMonitoredValues struct { 48 | // CertificateIdentities contains a list of subjects and issuers 49 | CertificateIdentities []identity.CertificateIdentity `yaml:"certIdentities"` 50 | // Fingerprints contains a list of key fingerprints. Values are as follows: 51 | // For keys, certificates, and minisign, hex-encoded SHA-256 digest 52 | // of the DER-encoded PKIX public key or certificate 53 | // For SSH and PGP, the standard for each ecosystem: 54 | // For SSH, unpadded base-64 encoded SHA-256 digest of the key 55 | // For PGP, hex-encoded SHA-1 digest of a key, which can be either 56 | // a primary key or subkey 57 | Fingerprints []string `yaml:"fingerprints"` 58 | // Subjects contains a list of subjects that are not specified in a 59 | // certificate, such as a SSH key or PGP key email address 60 | Subjects []string `yaml:"subjects"` 61 | // OIDMatchers represents a list of OID extension fields and associated values, 62 | // which includes those constructed directly, those supported by Fulcio, and any constructed via dot notation. 63 | // These OIDMatchers are parsed into one list of OID extensions and matching values before being passed into MatchedIndices. 64 | OIDMatchers extensions.OIDMatchers `yaml:"oidMatchers"` 65 | } 66 | 67 | // IdentityMonitorConfiguration holds the configuration settings for an identity monitor workflow run. 68 | type IdentityMonitorConfiguration struct { 69 | StartIndex *int `yaml:"startIndex"` 70 | EndIndex *int `yaml:"endIndex"` 71 | MonitoredValues ConfigMonitoredValues `yaml:"monitoredValues"` 72 | OutputIdentitiesFile string `yaml:"outputIdentities"` 73 | LogInfoFile string `yaml:"logInfoFile"` 74 | IdentityMetadataFile *string `yaml:"identityMetadataFile"` 75 | GitHubIssue *GitHubIssueInput `yaml:"githubIssue"` 76 | EmailNotificationSMTP *EmailNotificationInput `yaml:"emailNotificationSMTP"` 77 | EmailNotificationMailgun *MailgunNotificationInput `yaml:"emailNotificationMailgun"` 78 | EmailNotificationSendGrid *SendGridNotificationInput `yaml:"emailNotificationSendGrid"` 79 | } 80 | 81 | func CreateNotificationPool(config IdentityMonitorConfiguration) []NotificationPlatform { 82 | // update this as new notification platforms are implemented within rekor-monitor 83 | notificationPlatforms := []NotificationPlatform{} 84 | if config.GitHubIssue != nil { 85 | notificationPlatforms = append(notificationPlatforms, config.GitHubIssue) 86 | } 87 | 88 | if config.EmailNotificationSMTP != nil { 89 | notificationPlatforms = append(notificationPlatforms, config.EmailNotificationSMTP) 90 | } 91 | 92 | if config.EmailNotificationSendGrid != nil { 93 | notificationPlatforms = append(notificationPlatforms, config.EmailNotificationSendGrid) 94 | } 95 | 96 | if config.EmailNotificationMailgun != nil { 97 | notificationPlatforms = append(notificationPlatforms, config.EmailNotificationMailgun) 98 | } 99 | 100 | return notificationPlatforms 101 | } 102 | 103 | func TriggerNotifications(notificationPlatforms []NotificationPlatform, identities []identity.MonitoredIdentity) error { 104 | // update this as new notification platforms are implemented within rekor-monitor 105 | for _, notificationPlatform := range notificationPlatforms { 106 | if err := notificationPlatform.Send(context.Background(), identities); err != nil { 107 | return fmt.Errorf("error sending notification from platform: %v", err) 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/notifications/notifications_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "strings" 21 | "testing" 22 | 23 | "github.com/sigstore/rekor-monitor/pkg/identity" 24 | ) 25 | 26 | type MockNotificationPlatform struct { 27 | } 28 | 29 | func (mockNotificationPlatform MockNotificationPlatform) Send(_ context.Context, _ []identity.MonitoredIdentity) error { 30 | return errors.New("successfully sent from mock notification platform") 31 | } 32 | 33 | func TestCreateAndSendNotifications(t *testing.T) { 34 | config := IdentityMonitorConfiguration{ 35 | GitHubIssue: &GitHubIssueInput{ 36 | AssigneeUsername: "test-user", 37 | RepositoryOwner: "test-repo-owner", 38 | RepositoryName: "test-repo", 39 | AuthenticationToken: "test-auth-token", 40 | }, 41 | EmailNotificationSMTP: &EmailNotificationInput{ 42 | RecipientEmailAddress: "test-receiver-email-address", 43 | SenderEmailAddress: "test-sender-email-address", 44 | }, 45 | } 46 | 47 | mockNotificationPlatform := MockNotificationPlatform{} 48 | 49 | notificationPool := CreateNotificationPool(config) 50 | notificationPoolLength := len(notificationPool) 51 | if notificationPoolLength != 2 { 52 | t.Errorf("expected 2 notification platforms to be created, received %d", notificationPoolLength) 53 | } 54 | 55 | err := TriggerNotifications([]NotificationPlatform{mockNotificationPlatform}, []identity.MonitoredIdentity{}) 56 | if !strings.Contains(err.Error(), "successfully sent from mock notification platform") { 57 | t.Errorf("did not trigger notification from mock notification platform") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pkg/notifications/sendgrid.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/sendgrid/sendgrid-go" 21 | "github.com/sendgrid/sendgrid-go/helpers/mail" 22 | "github.com/sigstore/rekor-monitor/pkg/identity" 23 | ) 24 | 25 | // SendGrid extends the NotificationPlatform interface to support 26 | // found identity notification by sending emails to a specified user via SendGrid. 27 | type SendGridNotificationInput struct { 28 | RecipientName string `yaml:"recipientName"` 29 | RecipientEmailAddress string `yaml:"recipientEmailAddress"` 30 | SenderName string `yaml:"senderName"` 31 | SenderEmailAddress string `yaml:"senderEmailAddress"` 32 | SendGridAPIKey string `yaml:"sendGridAPIKey"` 33 | } 34 | 35 | // Send takes in an SendGridNotificationInput and attempts to send the 36 | // following list of found identities to the given email address. 37 | // It returns an error in the case of failure. 38 | func (sendGridNotificationInput SendGridNotificationInput) Send(ctx context.Context, monitoredIdentities []identity.MonitoredIdentity) error { 39 | from := mail.NewEmail(sendGridNotificationInput.SenderName, sendGridNotificationInput.SenderEmailAddress) 40 | to := mail.NewEmail(sendGridNotificationInput.RecipientName, sendGridNotificationInput.RecipientEmailAddress) 41 | subject := NotificationSubject 42 | emailHTMLBody, err := GenerateEmailBody(monitoredIdentities) 43 | if err != nil { 44 | return err 45 | } 46 | email := mail.NewSingleEmail(from, subject, to, "", emailHTMLBody) 47 | client := sendgrid.NewSendClient(sendGridNotificationInput.SendGridAPIKey) 48 | _, err = client.SendWithContext(ctx, email) 49 | return err 50 | } 51 | -------------------------------------------------------------------------------- /pkg/notifications/sendgrid_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package notifications 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | 21 | "github.com/sigstore/rekor-monitor/pkg/identity" 22 | ) 23 | 24 | func TestSendGridSendFailure(t *testing.T) { 25 | sendGridNotificationInput := SendGridNotificationInput{ 26 | RecipientEmailAddress: "test-recipient@example.com", 27 | SenderEmailAddress: "test-sender@example.com", 28 | RecipientName: "", 29 | SenderName: "", 30 | SendGridAPIKey: "", 31 | } 32 | monitoredIdentity := identity.MonitoredIdentity{ 33 | Identity: "test-identity", 34 | FoundIdentityEntries: []identity.LogEntry{ 35 | { 36 | CertSubject: "test-cert-subject", 37 | UUID: "test-uuid", 38 | Index: 0, 39 | }, 40 | }, 41 | } 42 | 43 | err := sendGridNotificationInput.Send(context.Background(), []identity.MonitoredIdentity{monitoredIdentity}) 44 | if err != nil { 45 | t.Errorf("expected nil, received error %v", err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/rekor/client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rekor 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | 21 | "github.com/sigstore/rekor-monitor/pkg/util" 22 | "github.com/sigstore/rekor/pkg/generated/client" 23 | "github.com/sigstore/rekor/pkg/generated/client/entries" 24 | "github.com/sigstore/rekor/pkg/generated/client/pubkey" 25 | "github.com/sigstore/rekor/pkg/generated/client/tlog" 26 | "github.com/sigstore/rekor/pkg/generated/models" 27 | ) 28 | 29 | // GetPublicKey fetches the current public key from Rekor 30 | func GetPublicKey(ctx context.Context, rekorClient *client.Rekor) ([]byte, error) { 31 | p := pubkey.NewGetPublicKeyParamsWithContext(ctx) 32 | resp, err := util.Retry(ctx, func() (any, error) { 33 | return rekorClient.Pubkey.GetPublicKey(p) 34 | }) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | pubkeyResp := resp.(*pubkey.GetPublicKeyOK) 40 | return []byte(pubkeyResp.Payload), nil 41 | } 42 | 43 | // GetLogInfo fetches the latest checkpoint for each log shard 44 | func GetLogInfo(ctx context.Context, rekorClient *client.Rekor) (*models.LogInfo, error) { 45 | p := tlog.NewGetLogInfoParamsWithContext(ctx) 46 | 47 | resp, err := util.Retry(ctx, func() (any, error) { 48 | return rekorClient.Tlog.GetLogInfo(p) 49 | }) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | logInfoResp := resp.(*tlog.GetLogInfoOK) 55 | 56 | return logInfoResp.GetPayload(), nil 57 | } 58 | 59 | // GetEntriesByIndexRange fetches all entries by log index, from (start, end] 60 | // If start == end, returns a single entry for that index 61 | // Returns error if start > end 62 | func GetEntriesByIndexRange(ctx context.Context, rekorClient *client.Rekor, start, end int) ([]models.LogEntry, error) { 63 | if start > end { 64 | return nil, fmt.Errorf("start (%d) must be less than or equal to end (%d)", start, end) 65 | } 66 | 67 | // handle case where we initialize log monitor 68 | if start == end { 69 | start-- 70 | } 71 | 72 | var logEntries []models.LogEntry 73 | for i := start + 1; i <= end; i += 10 { 74 | var logIndices []*int64 75 | minVal := computeMin(i+10, end+1) 76 | for j := i; j < minVal; j++ { 77 | j := int64(j) 78 | logIndices = append(logIndices, &j) 79 | } 80 | slq := models.SearchLogQuery{} 81 | slq.LogIndexes = logIndices 82 | 83 | p := entries.NewSearchLogQueryParamsWithContext(ctx) 84 | p.SetEntry(&slq) 85 | 86 | resp, err := util.Retry(ctx, func() (any, error) { 87 | return rekorClient.Entries.SearchLogQuery(p) 88 | }) 89 | if err != nil { 90 | return nil, err 91 | } 92 | logEntries = append(logEntries, resp.(*entries.SearchLogQueryOK).Payload...) 93 | } 94 | return logEntries, nil 95 | } 96 | 97 | // computeMin calculates the minimum of two integers. Preferred over math.Min due to verbose type conversions 98 | func computeMin(a, b int) int { 99 | if a < b { 100 | return a 101 | } 102 | return b 103 | } 104 | -------------------------------------------------------------------------------- /pkg/rekor/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rekor 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "reflect" 21 | "strings" 22 | "testing" 23 | 24 | "github.com/sigstore/rekor-monitor/pkg/rekor/mock" 25 | "github.com/sigstore/rekor/pkg/generated/client" 26 | "github.com/sigstore/rekor/pkg/generated/models" 27 | ) 28 | 29 | func TestGetPublicKey(t *testing.T) { 30 | key := "hellokey" 31 | var mClient client.Rekor 32 | mClient.Pubkey = &mock.PubkeyClient{ 33 | PEMPubKey: key, 34 | } 35 | result, err := GetPublicKey(context.Background(), &mClient) 36 | if err != nil { 37 | t.Fatalf("unexpected error: %v", err) 38 | } 39 | if string(result) != key { 40 | t.Fatalf("expected key value: %v, got: %v", key, result) 41 | } 42 | } 43 | 44 | func TestGetLogInfo(t *testing.T) { 45 | logInfo := &models.LogInfo{} 46 | treeSize := int64(1234) 47 | logInfo.TreeSize = &treeSize 48 | 49 | var mClient client.Rekor 50 | mClient.Tlog = &mock.TlogClient{ 51 | LogInfo: logInfo, 52 | } 53 | result, err := GetLogInfo(context.Background(), &mClient) 54 | if err != nil { 55 | t.Fatalf("unexpected error: %v", err) 56 | } 57 | if treeSize != *result.TreeSize { 58 | t.Fatalf("expected tree size: %v, got: %v", treeSize, *result.TreeSize) 59 | } 60 | } 61 | 62 | func TestGetEntriesByIndexRange(t *testing.T) { 63 | maxIndex := 100 64 | var logEntries []*models.LogEntry 65 | 66 | // the contents of the LogEntryAnon don't matter 67 | // test will verify the indices returned by looking at the map keys 68 | for i := 0; i <= maxIndex; i++ { 69 | lea := models.LogEntryAnon{} 70 | data := models.LogEntry{ 71 | fmt.Sprint(i): lea, 72 | } 73 | logEntries = append(logEntries, &data) 74 | } 75 | 76 | var mClient client.Rekor 77 | mClient.Entries = &mock.EntriesClient{ 78 | Entries: logEntries, 79 | } 80 | 81 | // should return 1 through 100 for index range 82 | result, err := GetEntriesByIndexRange(context.TODO(), &mClient, 0, maxIndex) 83 | if err != nil { 84 | t.Fatalf("unexpected error getting entries: %v", err) 85 | } 86 | if len(result) != 100 { 87 | t.Fatalf("expected 100 entries, got %d", len(result)) 88 | } 89 | index := 0 90 | for i := 1; i <= 100; i++ { 91 | if !reflect.DeepEqual(result[index], *logEntries[i]) { 92 | t.Fatalf("entries should be equal for index %d, log index %d", index, i) 93 | } 94 | index++ 95 | } 96 | 97 | // should return 42 through 67 for index range 98 | result, err = GetEntriesByIndexRange(context.TODO(), &mClient, 41, 67) 99 | if err != nil { 100 | t.Fatalf("unexpected error getting entries: %v", err) 101 | } 102 | if len(result) != 26 { 103 | t.Fatalf("expected 26 entries, got %d", len(result)) 104 | } 105 | index = 0 106 | for i := 42; i <= 67; i++ { 107 | if !reflect.DeepEqual(result[index], *logEntries[i]) { 108 | t.Fatalf("entries should be equal for index %d, log index %d", index, i) 109 | } 110 | index++ 111 | } 112 | 113 | // should return index 42 114 | result, err = GetEntriesByIndexRange(context.TODO(), &mClient, 42, 42) 115 | if err != nil { 116 | t.Fatalf("unexpected error getting entries: %v", err) 117 | } 118 | if len(result) != 1 { 119 | t.Fatalf("expected 1 entry, got %d", len(result)) 120 | } 121 | if !reflect.DeepEqual(result[0], *logEntries[42]) { 122 | t.Fatalf("entries should be equal for index 0, log index 42") 123 | } 124 | 125 | // failure: start greater than end 126 | _, err = GetEntriesByIndexRange(context.TODO(), &mClient, 11, 10) 127 | if err == nil || !strings.Contains(err.Error(), "less than or equal to") { 128 | t.Fatalf("expected error with start greater than end index, got %v", err) 129 | } 130 | } 131 | 132 | func Test_min(t *testing.T) { 133 | tests := []struct { 134 | a int 135 | b int 136 | result int 137 | }{ 138 | { 139 | a: 1, 140 | b: 1, 141 | result: 1, 142 | }, 143 | { 144 | a: 2, 145 | b: 1, 146 | result: 1, 147 | }, 148 | { 149 | a: 10, 150 | b: 11, 151 | result: 10, 152 | }, 153 | } 154 | 155 | for _, tt := range tests { 156 | m := computeMin(tt.a, tt.b) 157 | if m != tt.result { 158 | t.Errorf("expected min value of %d for inputs(%d,%d), got %d", tt.result, tt.a, tt.b, m) 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /pkg/rekor/identity.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rekor 16 | 17 | import ( 18 | "bytes" 19 | "context" 20 | "crypto/x509" 21 | "encoding/base64" 22 | "errors" 23 | "fmt" 24 | "os" 25 | "regexp" 26 | 27 | "github.com/go-openapi/runtime" 28 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 29 | "github.com/sigstore/rekor-monitor/pkg/identity" 30 | "github.com/sigstore/rekor-monitor/pkg/util/file" 31 | "github.com/sigstore/rekor/pkg/generated/client" 32 | "github.com/sigstore/rekor/pkg/generated/models" 33 | "github.com/sigstore/rekor/pkg/pki" 34 | "github.com/sigstore/rekor/pkg/types" 35 | "github.com/sigstore/rekor/pkg/util" 36 | 37 | // required imports to call init methods 38 | _ "github.com/sigstore/rekor/pkg/types/alpine/v0.0.1" 39 | _ "github.com/sigstore/rekor/pkg/types/cose/v0.0.1" 40 | _ "github.com/sigstore/rekor/pkg/types/dsse/v0.0.1" 41 | _ "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1" 42 | _ "github.com/sigstore/rekor/pkg/types/helm/v0.0.1" 43 | _ "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1" 44 | _ "github.com/sigstore/rekor/pkg/types/intoto/v0.0.2" 45 | _ "github.com/sigstore/rekor/pkg/types/jar/v0.0.1" 46 | _ "github.com/sigstore/rekor/pkg/types/rekord/v0.0.1" 47 | _ "github.com/sigstore/rekor/pkg/types/rfc3161/v0.0.1" 48 | _ "github.com/sigstore/rekor/pkg/types/rpm/v0.0.1" 49 | _ "github.com/sigstore/rekor/pkg/types/tuf/v0.0.1" 50 | ) 51 | 52 | func MatchLogEntryFingerprints(logEntryAnon models.LogEntryAnon, uuid string, entryFingerprints []string, monitoredFingerprints []string) []identity.LogEntry { 53 | matchedEntries := []identity.LogEntry{} 54 | for _, monitoredFp := range monitoredFingerprints { 55 | for _, fp := range entryFingerprints { 56 | if fp == monitoredFp { 57 | matchedEntries = append(matchedEntries, identity.LogEntry{ 58 | Fingerprint: fp, 59 | Index: *logEntryAnon.LogIndex, 60 | UUID: uuid, 61 | }) 62 | } 63 | } 64 | } 65 | return matchedEntries 66 | } 67 | 68 | func MatchLogEntryCertificateIdentities(logEntryAnon models.LogEntryAnon, uuid string, entryCertificates []*x509.Certificate, monitoredCertIDs []identity.CertificateIdentity) ([]identity.LogEntry, error) { 69 | matchedEntries := []identity.LogEntry{} 70 | for _, monitoredCertID := range monitoredCertIDs { 71 | for _, cert := range entryCertificates { 72 | match, sub, iss, err := identity.CertMatchesPolicy(cert, monitoredCertID.CertSubject, monitoredCertID.Issuers) 73 | if err != nil { 74 | return nil, fmt.Errorf("error with policy matching for UUID %s at index %d: %w", uuid, logEntryAnon.LogIndex, err) 75 | } else if match { 76 | matchedEntries = append(matchedEntries, identity.LogEntry{ 77 | CertSubject: sub, 78 | Issuer: iss, 79 | Index: *logEntryAnon.LogIndex, 80 | UUID: uuid, 81 | }) 82 | } 83 | } 84 | } 85 | return matchedEntries, nil 86 | } 87 | 88 | func MatchLogEntrySubjects(logEntryAnon models.LogEntryAnon, uuid string, entrySubjects []string, monitoredSubjects []string) ([]identity.LogEntry, error) { 89 | matchedEntries := []identity.LogEntry{} 90 | for _, monitoredSub := range monitoredSubjects { 91 | regex, err := regexp.Compile(monitoredSub) 92 | if err != nil { 93 | return nil, fmt.Errorf("error compiling regex for UUID %s at index %d: %w", uuid, logEntryAnon.LogIndex, err) 94 | } 95 | for _, sub := range entrySubjects { 96 | if regex.MatchString(sub) { 97 | matchedEntries = append(matchedEntries, identity.LogEntry{ 98 | Subject: sub, 99 | Index: *logEntryAnon.LogIndex, 100 | UUID: uuid, 101 | }) 102 | } 103 | } 104 | } 105 | return matchedEntries, nil 106 | } 107 | 108 | func MatchLogEntryOIDs(logEntryAnon models.LogEntryAnon, uuid string, entryCertificates []*x509.Certificate, monitoredOIDMatchers []extensions.OIDExtension) ([]identity.LogEntry, error) { 109 | matchedEntries := []identity.LogEntry{} 110 | for _, monitoredOID := range monitoredOIDMatchers { 111 | for _, cert := range entryCertificates { 112 | match, oid, extValue, err := identity.OIDMatchesPolicy(cert, monitoredOID.ObjectIdentifier, monitoredOID.ExtensionValues) 113 | if err != nil { 114 | return nil, fmt.Errorf("error with policy matching for UUID %s at index %d: %w", uuid, logEntryAnon.LogIndex, err) 115 | } 116 | if match { 117 | matchedEntries = append(matchedEntries, identity.LogEntry{ 118 | Index: *logEntryAnon.LogIndex, 119 | UUID: uuid, 120 | OIDExtension: oid, 121 | ExtensionValue: extValue, 122 | }) 123 | } 124 | } 125 | } 126 | return matchedEntries, nil 127 | } 128 | 129 | // MatchedIndices returns a list of log indices that contain the requested identities. 130 | func MatchedIndices(logEntries []models.LogEntry, mvs identity.MonitoredValues) ([]identity.LogEntry, error) { 131 | if err := verifyMonitoredValues(mvs); err != nil { 132 | return nil, err 133 | } 134 | 135 | var matchedEntries []identity.LogEntry 136 | 137 | for _, entries := range logEntries { 138 | for uuid, entry := range entries { 139 | entry := entry 140 | 141 | verifiers, err := extractVerifiers(&entry) 142 | if err != nil { 143 | return nil, fmt.Errorf("error extracting verifiers for UUID %s at index %d: %w", uuid, *entry.LogIndex, err) 144 | } 145 | subjects, certs, fps, err := extractAllIdentities(verifiers) 146 | if err != nil { 147 | return nil, fmt.Errorf("error extracting identities for UUID %s at index %d: %w", uuid, *entry.LogIndex, err) 148 | } 149 | 150 | matchedFingerprintEntries := MatchLogEntryFingerprints(entry, uuid, fps, mvs.Fingerprints) 151 | matchedEntries = append(matchedEntries, matchedFingerprintEntries...) 152 | 153 | matchedSubjectEntries, err := MatchLogEntrySubjects(entry, uuid, subjects, mvs.Subjects) 154 | if err != nil { 155 | return nil, fmt.Errorf("error matching subjects for UUID %s at index %d: %w", uuid, *entry.LogIndex, err) 156 | } 157 | matchedEntries = append(matchedEntries, matchedSubjectEntries...) 158 | 159 | matchedCertIDEntries, err := MatchLogEntryCertificateIdentities(entry, uuid, certs, mvs.CertificateIdentities) 160 | if err != nil { 161 | return nil, fmt.Errorf("error matching certificate identities for UUID %s at index %d: %w", uuid, *entry.LogIndex, err) 162 | } 163 | matchedEntries = append(matchedEntries, matchedCertIDEntries...) 164 | 165 | matchedOIDEntries, err := MatchLogEntryOIDs(entry, uuid, certs, mvs.OIDMatchers) 166 | if err != nil { 167 | return nil, fmt.Errorf("error matching object identifier extensions and values for UUID %s at index %d: %w", uuid, *entry.LogIndex, err) 168 | } 169 | matchedEntries = append(matchedEntries, matchedOIDEntries...) 170 | } 171 | } 172 | 173 | return matchedEntries, nil 174 | } 175 | 176 | // verifyMonitoredValues checks that monitored values are valid 177 | func verifyMonitoredValues(mvs identity.MonitoredValues) error { 178 | if !identity.MonitoredValuesExist(mvs) { 179 | return errors.New("no identities provided to monitor") 180 | } 181 | for _, certID := range mvs.CertificateIdentities { 182 | if len(certID.CertSubject) == 0 { 183 | return errors.New("certificate subject empty") 184 | } 185 | // issuers can be empty 186 | for _, iss := range certID.Issuers { 187 | if len(iss) == 0 { 188 | return errors.New("issuer empty") 189 | } 190 | } 191 | } 192 | for _, fp := range mvs.Fingerprints { 193 | if len(fp) == 0 { 194 | return errors.New("fingerprint empty") 195 | } 196 | } 197 | for _, sub := range mvs.Subjects { 198 | if len(sub) == 0 { 199 | return errors.New("subject empty") 200 | } 201 | } 202 | err := verifyMonitoredOIDs(mvs) 203 | if err != nil { 204 | return err 205 | } 206 | return nil 207 | } 208 | 209 | // verifyMonitoredOIDs checks that monitored OID extensions and matching values are valid 210 | func verifyMonitoredOIDs(mvs identity.MonitoredValues) error { 211 | for _, oidMatcher := range mvs.OIDMatchers { 212 | if len(oidMatcher.ObjectIdentifier) == 0 { 213 | return errors.New("oid extension empty") 214 | } 215 | if len(oidMatcher.ExtensionValues) == 0 { 216 | return errors.New("oid matched values empty") 217 | } 218 | for _, extensionValue := range oidMatcher.ExtensionValues { 219 | if len(extensionValue) == 0 { 220 | return errors.New("oid matched value empty") 221 | } 222 | } 223 | } 224 | return nil 225 | } 226 | 227 | // extractVerifiers extracts a set of keys or certificates that can verify an 228 | // artifact signature from a Rekor entry 229 | func extractVerifiers(e *models.LogEntryAnon) ([]pki.PublicKey, error) { 230 | b, err := base64.StdEncoding.DecodeString(e.Body.(string)) 231 | if err != nil { 232 | return nil, err 233 | } 234 | 235 | pe, err := models.UnmarshalProposedEntry(bytes.NewReader(b), runtime.JSONConsumer()) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | eimpl, err := types.UnmarshalEntry(pe) 241 | if err != nil { 242 | return nil, err 243 | } 244 | 245 | return eimpl.Verifiers() 246 | } 247 | 248 | // extractAllIdentities gets all certificates, email addresses, and key fingerprints 249 | // from a list of verifiers 250 | func extractAllIdentities(verifiers []pki.PublicKey) ([]string, []*x509.Certificate, []string, error) { 251 | var subjects []string 252 | var certificates []*x509.Certificate 253 | var fps []string 254 | 255 | for _, v := range verifiers { 256 | // append all verifier subjects (email or SAN) 257 | subjects = append(subjects, v.Subjects()...) 258 | ids, err := v.Identities() 259 | if err != nil { 260 | return nil, nil, nil, err 261 | } 262 | // append all certificate and key fingerprints 263 | for _, i := range ids { 264 | fps = append(fps, i.Fingerprint) 265 | if cert, ok := i.Crypto.(*x509.Certificate); ok { 266 | certificates = append(certificates, cert) 267 | } 268 | } 269 | } 270 | return subjects, certificates, fps, nil 271 | } 272 | 273 | // GetCheckpointIndex fetches the index of a checkpoint and returns it. 274 | func GetCheckpointIndex(logInfo *models.LogInfo, checkpoint *util.SignedCheckpoint) int { 275 | // Get log size of inactive shards 276 | totalSize := 0 277 | for _, s := range logInfo.InactiveShards { 278 | totalSize += int(*s.TreeSize) 279 | } 280 | index := int(checkpoint.Size) + totalSize - 1 //nolint: gosec // G115 281 | 282 | return index 283 | } 284 | 285 | func IdentitySearch(startIndex int, endIndex int, rekorClient *client.Rekor, monitoredValues identity.MonitoredValues, outputIdentitiesFile string, idMetadataFile *string) ([]identity.MonitoredIdentity, error) { 286 | entries, err := GetEntriesByIndexRange(context.Background(), rekorClient, startIndex, endIndex) 287 | if err != nil { 288 | return nil, fmt.Errorf("error getting entries by index range: %v", err) 289 | } 290 | 291 | idEntries, err := MatchedIndices(entries, monitoredValues) 292 | if err != nil { 293 | return nil, fmt.Errorf("error finding log indices: %v", err) 294 | } 295 | 296 | if len(idEntries) > 0 { 297 | for _, idEntry := range idEntries { 298 | fmt.Fprintf(os.Stderr, "Found %s\n", idEntry.String()) 299 | 300 | if err := file.WriteIdentity(outputIdentitiesFile, idEntry); err != nil { 301 | return nil, fmt.Errorf("failed to write entry: %v", err) 302 | } 303 | } 304 | } 305 | 306 | // TODO: idMetadataFile currently takes in a string pointer to not cause a regression in the current reusable monitoring workflow. 307 | // Once the reusable monitoring workflow is split into a consistency check and identity search, idMetadataFile should always take in a string value. 308 | if idMetadataFile != nil { 309 | idMetadata := file.IdentityMetadata{ 310 | LatestIndex: endIndex, 311 | } 312 | err = file.WriteIdentityMetadata(*idMetadataFile, idMetadata) 313 | if err != nil { 314 | return nil, fmt.Errorf("failed to write id metadata: %v", err) 315 | } 316 | } 317 | 318 | identities := identity.CreateIdentitiesList(monitoredValues) 319 | monitoredIdentities := identity.CreateMonitoredIdentities(idEntries, identities) 320 | return monitoredIdentities, nil 321 | } 322 | -------------------------------------------------------------------------------- /pkg/rekor/mock/mock_rekor_client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package mock 16 | 17 | import ( 18 | "errors" 19 | 20 | "github.com/go-openapi/runtime" 21 | 22 | "github.com/sigstore/rekor/pkg/generated/client/entries" 23 | "github.com/sigstore/rekor/pkg/generated/client/pubkey" 24 | "github.com/sigstore/rekor/pkg/generated/client/tlog" 25 | "github.com/sigstore/rekor/pkg/generated/models" 26 | ) 27 | 28 | // EntriesClient is a client that implements entries.ClientService for Rekor 29 | // To use: 30 | // var mClient client.Rekor 31 | // mClient.Entries = &mock.EntriesClient{Entries: , ETag: , Location: , LogEntry: } 32 | type EntriesClient struct { 33 | Entries []*models.LogEntry 34 | LogEntry models.LogEntry 35 | Error error 36 | } 37 | 38 | func (m *EntriesClient) CreateLogEntry(_ *entries.CreateLogEntryParams, _ ...entries.ClientOption) (*entries.CreateLogEntryCreated, error) { 39 | if m.Error != nil { 40 | return nil, m.Error 41 | } 42 | return &entries.CreateLogEntryCreated{ 43 | Payload: m.LogEntry, 44 | }, nil 45 | } 46 | 47 | func (m *EntriesClient) GetLogEntryByIndex(_ *entries.GetLogEntryByIndexParams, _ ...entries.ClientOption) (*entries.GetLogEntryByIndexOK, error) { 48 | return nil, errors.New("not implemented") 49 | } 50 | 51 | func (m *EntriesClient) GetLogEntryByUUID(_ *entries.GetLogEntryByUUIDParams, _ ...entries.ClientOption) (*entries.GetLogEntryByUUIDOK, error) { 52 | return nil, errors.New("not implemented") 53 | } 54 | 55 | func (m *EntriesClient) SearchLogQuery(params *entries.SearchLogQueryParams, _ ...entries.ClientOption) (*entries.SearchLogQueryOK, error) { 56 | if m.Error != nil { 57 | return nil, m.Error 58 | } 59 | resp := []models.LogEntry{} 60 | if m.Entries != nil { 61 | for _, i := range params.Entry.LogIndexes { 62 | resp = append(resp, *m.Entries[*i]) 63 | } 64 | } 65 | return &entries.SearchLogQueryOK{ 66 | Payload: resp, 67 | }, nil 68 | } 69 | 70 | func (m *EntriesClient) SetTransport(_ runtime.ClientTransport) {} 71 | 72 | // TlogClient is a client that implements tlog.ClientService for Rekor 73 | // To use: 74 | // var mClient client.Rekor 75 | // mClient.Entries = &mock.TlogClient{LogInfo: , ConsistencyProof: } 76 | type TlogClient struct { 77 | LogInfo *models.LogInfo 78 | ConsistencyProof *models.ConsistencyProof 79 | Error error 80 | } 81 | 82 | func (m *TlogClient) GetLogInfo(_ *tlog.GetLogInfoParams, _ ...tlog.ClientOption) (*tlog.GetLogInfoOK, error) { 83 | if m.Error != nil { 84 | return nil, m.Error 85 | } 86 | return &tlog.GetLogInfoOK{ 87 | Payload: m.LogInfo, 88 | }, nil 89 | } 90 | 91 | func (m *TlogClient) GetLogProof(_ *tlog.GetLogProofParams, _ ...tlog.ClientOption) (*tlog.GetLogProofOK, error) { 92 | if m.Error != nil { 93 | return nil, m.Error 94 | } 95 | return &tlog.GetLogProofOK{ 96 | Payload: m.ConsistencyProof, 97 | }, nil 98 | } 99 | 100 | func (m *TlogClient) SetTransport(_ runtime.ClientTransport) {} 101 | 102 | // PubkeyClient is a client that implements pubkey.ClientService for Rekor 103 | // To use: 104 | // var mClient client.Rekor 105 | // mClient.Entries = &mock.PubkeyClient{PEMPubKey: } 106 | type PubkeyClient struct { 107 | PEMPubKey string 108 | } 109 | 110 | func (m *PubkeyClient) GetPublicKey(_ *pubkey.GetPublicKeyParams, _ ...pubkey.ClientOption) (*pubkey.GetPublicKeyOK, error) { 111 | return &pubkey.GetPublicKeyOK{ 112 | Payload: m.PEMPubKey, 113 | }, nil 114 | } 115 | 116 | func (m *PubkeyClient) SetTransport(_ runtime.ClientTransport) {} 117 | -------------------------------------------------------------------------------- /pkg/rekor/verifier.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rekor 16 | 17 | import ( 18 | "context" 19 | "crypto" 20 | "encoding/hex" 21 | "fmt" 22 | "os" 23 | 24 | "github.com/sigstore/rekor-monitor/pkg/util/file" 25 | "github.com/sigstore/rekor/pkg/generated/client" 26 | "github.com/sigstore/rekor/pkg/generated/models" 27 | "github.com/sigstore/rekor/pkg/util" 28 | "github.com/sigstore/rekor/pkg/verify" 29 | "github.com/sigstore/sigstore/pkg/cryptoutils" 30 | "github.com/sigstore/sigstore/pkg/signature" 31 | ) 32 | 33 | // GetLogVerifier creates a verifier from the log's public key 34 | // TODO: Fetch the public key from TUF 35 | func GetLogVerifier(ctx context.Context, rekorClient *client.Rekor) (signature.Verifier, error) { 36 | pemPubKey, err := GetPublicKey(ctx, rekorClient) 37 | if err != nil { 38 | return nil, err 39 | } 40 | pubKey, err := cryptoutils.UnmarshalPEMToPublicKey(pemPubKey) 41 | if err != nil { 42 | return nil, err 43 | } 44 | verifier, err := signature.LoadVerifier(pubKey, crypto.SHA256) 45 | if err != nil { 46 | return nil, err 47 | } 48 | return verifier, nil 49 | } 50 | 51 | // ReadLatestCheckpoint fetches the latest checkpoint from log info fetched from Rekor. 52 | // It returns the checkpoint if it successfully fetches one; otherwise, it returns an error. 53 | func ReadLatestCheckpoint(logInfo *models.LogInfo) (*util.SignedCheckpoint, error) { 54 | checkpoint := &util.SignedCheckpoint{} 55 | if err := checkpoint.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil { 56 | return nil, fmt.Errorf("unmarshalling logInfo.SignedTreeHead to Checkpoint: %v", err) 57 | } 58 | return checkpoint, nil 59 | } 60 | 61 | // verifyLatestCheckpoint fetches and verifies the signature of the latest checkpoint from log info fetched from Rekor. 62 | // If it successfully verifies the checkpoint's signature, it returns the checkpoint; otherwise, it returns an error. 63 | func verifyLatestCheckpointSignature(logInfo *models.LogInfo, verifier signature.Verifier) (*util.SignedCheckpoint, error) { 64 | checkpoint, err := ReadLatestCheckpoint(logInfo) 65 | if err != nil { 66 | return nil, fmt.Errorf("unmarshalling logInfo.SignedTreeHead to Checkpoint: %v", err) 67 | } 68 | if !checkpoint.Verify(verifier) { 69 | return nil, fmt.Errorf("verifying checkpoint (size %d, hash %s) failed", checkpoint.Size, hex.EncodeToString(checkpoint.Hash)) 70 | } 71 | return checkpoint, nil 72 | } 73 | 74 | // verifyCheckpointConsistency reads and verifies the consistency of the previous latest checkpoint from a log info file against the current up-to-date checkpoint. 75 | // If it successfully fetches and verifies the consistency between these two checkpoints, it returns the previous checkpoint; otherwise, it returns an error. 76 | func verifyCheckpointConsistency(logInfoFile string, checkpoint *util.SignedCheckpoint, treeID string, rekorClient *client.Rekor, verifier signature.Verifier) (*util.SignedCheckpoint, error) { 77 | var prevCheckpoint *util.SignedCheckpoint 78 | prevCheckpoint, err := file.ReadLatestCheckpoint(logInfoFile) 79 | if err != nil { 80 | return nil, fmt.Errorf("reading checkpoint log: %v", err) 81 | } 82 | if !prevCheckpoint.Verify(verifier) { 83 | return nil, fmt.Errorf("verifying checkpoint (size %d, hash %s) failed", checkpoint.Size, hex.EncodeToString(checkpoint.Hash)) 84 | } 85 | if err := verify.ProveConsistency(context.Background(), rekorClient, prevCheckpoint, checkpoint, treeID); err != nil { 86 | return nil, fmt.Errorf("failed to verify log consistency: %v", err) 87 | } 88 | fmt.Fprintf(os.Stderr, "Root hash consistency verified - Current Size: %d Root Hash: %s - Previous Size: %d Root Hash %s\n", 89 | checkpoint.Size, hex.EncodeToString(checkpoint.Hash), prevCheckpoint.Size, hex.EncodeToString(prevCheckpoint.Hash)) 90 | return prevCheckpoint, nil 91 | } 92 | 93 | // RunConsistencyCheck periodically verifies the root hash consistency of a Rekor log. 94 | func RunConsistencyCheck(rekorClient *client.Rekor, verifier signature.Verifier, logInfoFile string) (*util.SignedCheckpoint, *models.LogInfo, error) { 95 | logInfo, err := GetLogInfo(context.Background(), rekorClient) 96 | if err != nil { 97 | return nil, nil, fmt.Errorf("failed to get log info: %v", err) 98 | } 99 | checkpoint, err := verifyLatestCheckpointSignature(logInfo, verifier) 100 | if err != nil { 101 | return nil, nil, fmt.Errorf("failed to verify signature of latest checkpoint: %v", err) 102 | } 103 | 104 | fi, err := os.Stat(logInfoFile) 105 | // File containing previous checkpoints exists 106 | var prevCheckpoint *util.SignedCheckpoint 107 | if err == nil && fi.Size() != 0 { 108 | prevCheckpoint, err = verifyCheckpointConsistency(logInfoFile, checkpoint, *logInfo.TreeID, rekorClient, verifier) 109 | if err != nil { 110 | return nil, nil, fmt.Errorf("failed to verify previous checkpoint: %v", err) 111 | } 112 | 113 | } 114 | 115 | // Write if there was no stored checkpoint or the sizes differ 116 | if prevCheckpoint == nil || prevCheckpoint.Size != checkpoint.Size { 117 | if err := file.WriteCheckpoint(checkpoint, logInfoFile); err != nil { 118 | // TODO: Once the consistency check and identity search are split into separate tasks, this should hard fail. 119 | // Temporarily skipping this to allow this job to succeed, remediating the issue noted here: https://github.com/sigstore/rekor-monitor/issues/271 120 | fmt.Fprintf(os.Stderr, "failed to write checkpoint: %v", err) 121 | } 122 | } 123 | 124 | // TODO: Switch to writing checkpoints to GitHub so that the history is preserved. Then we only need 125 | // to persist the last checkpoint. 126 | // Delete old checkpoints to avoid the log growing indefinitely 127 | if err := file.DeleteOldCheckpoints(logInfoFile); err != nil { 128 | return nil, nil, fmt.Errorf("failed to delete old checkpoints: %v", err) 129 | } 130 | 131 | return prevCheckpoint, logInfo, nil 132 | } 133 | -------------------------------------------------------------------------------- /pkg/rekor/verifier_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package rekor 16 | 17 | import ( 18 | "context" 19 | "crypto/ecdsa" 20 | "crypto/elliptic" 21 | "crypto/rand" 22 | "testing" 23 | 24 | "github.com/sigstore/rekor-monitor/pkg/rekor/mock" 25 | "github.com/sigstore/rekor/pkg/generated/client" 26 | "github.com/sigstore/sigstore/pkg/cryptoutils" 27 | ) 28 | 29 | func TestGetLogVerifier(t *testing.T) { 30 | key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 31 | pemKey, err := cryptoutils.MarshalPublicKeyToPEM(key.Public()) 32 | if err != nil { 33 | t.Fatalf("unexpected error marshalling key: %v", err) 34 | } 35 | 36 | var mClient client.Rekor 37 | mClient.Pubkey = &mock.PubkeyClient{ 38 | PEMPubKey: string(pemKey), 39 | } 40 | 41 | verifier, err := GetLogVerifier(context.Background(), &mClient) 42 | if err != nil { 43 | t.Fatalf("unexpected error getting log verifier: %v", err) 44 | } 45 | pubkey, _ := verifier.PublicKey() 46 | if err := cryptoutils.EqualKeys(key.Public(), pubkey); err != nil { 47 | t.Fatalf("expected equal keys: %v", err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pkg/test/cert_utils.go: -------------------------------------------------------------------------------- 1 | // Copyright 2022 The Sigstore Authors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package test 16 | 17 | import ( 18 | "crypto" 19 | "crypto/ecdsa" 20 | "crypto/elliptic" 21 | "crypto/rand" 22 | "crypto/x509" 23 | "crypto/x509/pkix" 24 | "encoding/asn1" 25 | "math/big" 26 | "time" 27 | ) 28 | 29 | /* 30 | To use: 31 | rootCert, rootKey, _ := GenerateRootCa() 32 | subCert, subKey, _ := GenerateSubordinateCa(rootCert, rootKey) 33 | leafCert, _, _ := GenerateLeafCert("subject", "oidc-issuer", subCert, subKey) 34 | roots := x509.NewCertPool() 35 | subs := x509.NewCertPool() 36 | roots.AddCert(rootCert) 37 | subs.AddCert(subCert) 38 | opts := x509.VerifyOptions{ 39 | Roots: roots, 40 | Intermediates: subs, 41 | KeyUsages: []x509.ExtKeyUsage{ 42 | x509.ExtKeyUsageCodeSigning, 43 | }, 44 | } 45 | _, err := leafCert.Verify(opts) 46 | */ 47 | 48 | func createCertificate(template *x509.Certificate, parent *x509.Certificate, pub interface{}, priv crypto.Signer) (*x509.Certificate, error) { 49 | certBytes, err := x509.CreateCertificate(rand.Reader, template, parent, pub, priv) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | cert, err := x509.ParseCertificate(certBytes) 55 | if err != nil { 56 | return nil, err 57 | } 58 | return cert, nil 59 | } 60 | 61 | func GenerateRootCA() (*x509.Certificate, *ecdsa.PrivateKey, error) { 62 | rootTemplate := &x509.Certificate{ 63 | SerialNumber: big.NewInt(1), 64 | Subject: pkix.Name{ 65 | CommonName: "sigstore", 66 | Organization: []string{"sigstore.dev"}, 67 | }, 68 | NotBefore: time.Now().Add(-5 * time.Hour), 69 | NotAfter: time.Now().Add(5 * time.Hour), 70 | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, 71 | BasicConstraintsValid: true, 72 | IsCA: true, 73 | } 74 | 75 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 76 | if err != nil { 77 | return nil, nil, err 78 | } 79 | 80 | cert, err := createCertificate(rootTemplate, rootTemplate, &priv.PublicKey, priv) 81 | if err != nil { 82 | return nil, nil, err 83 | } 84 | 85 | return cert, priv, nil 86 | } 87 | 88 | func GenerateLeafCert(subject string, oidcIssuer string, parentTemplate *x509.Certificate, parentPriv crypto.Signer, exts ...pkix.Extension) (*x509.Certificate, *ecdsa.PrivateKey, error) { 89 | val, err := asn1.MarshalWithParams(oidcIssuer, "utf8") 90 | if err != nil { 91 | return nil, nil, err 92 | } 93 | exts = append(exts, pkix.Extension{ 94 | // OID for OIDC Issuer V2 extension 95 | Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, 96 | Critical: false, 97 | Value: val, 98 | }) 99 | certTemplate := &x509.Certificate{ 100 | SerialNumber: big.NewInt(1), 101 | EmailAddresses: []string{subject}, 102 | NotBefore: time.Now().Add(-1 * time.Minute), 103 | NotAfter: time.Now().Add(time.Hour), 104 | KeyUsage: x509.KeyUsageDigitalSignature, 105 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, 106 | IsCA: false, 107 | ExtraExtensions: exts, 108 | } 109 | 110 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 111 | if err != nil { 112 | return nil, nil, err 113 | } 114 | 115 | cert, err := createCertificate(certTemplate, parentTemplate, &priv.PublicKey, parentPriv) 116 | if err != nil { 117 | return nil, nil, err 118 | } 119 | 120 | return cert, priv, nil 121 | } 122 | 123 | // GenerateDeprecatedLeafCert generates a certificate using the deprecated raw string extensions (1.1 - 1.6) 124 | func GenerateDeprecatedLeafCert(subject string, oidcIssuer string, parentTemplate *x509.Certificate, parentPriv crypto.Signer) (*x509.Certificate, *ecdsa.PrivateKey, error) { 125 | exts := []pkix.Extension{ 126 | { 127 | // OID for OIDC Issuer extension 128 | Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, 129 | Critical: false, 130 | Value: []byte(oidcIssuer), 131 | }, 132 | } 133 | certTemplate := &x509.Certificate{ 134 | SerialNumber: big.NewInt(1), 135 | EmailAddresses: []string{subject}, 136 | NotBefore: time.Now().Add(-1 * time.Minute), 137 | NotAfter: time.Now().Add(time.Hour), 138 | KeyUsage: x509.KeyUsageDigitalSignature, 139 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, 140 | IsCA: false, 141 | ExtraExtensions: exts, 142 | } 143 | 144 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 145 | if err != nil { 146 | return nil, nil, err 147 | } 148 | 149 | cert, err := createCertificate(certTemplate, parentTemplate, &priv.PublicKey, parentPriv) 150 | if err != nil { 151 | return nil, nil, err 152 | } 153 | 154 | return cert, priv, nil 155 | } 156 | -------------------------------------------------------------------------------- /pkg/test/rekor_e2e/rekor_monitor_e2e_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build e2e 16 | // +build e2e 17 | 18 | package e2e 19 | 20 | import ( 21 | "bytes" 22 | "context" 23 | "crypto" 24 | "crypto/sha256" 25 | "crypto/x509/pkix" 26 | "encoding/asn1" 27 | "encoding/hex" 28 | "fmt" 29 | "log" 30 | "os" 31 | "runtime" 32 | "strings" 33 | "testing" 34 | 35 | "github.com/sigstore/rekor-monitor/pkg/fulcio/extensions" 36 | "github.com/sigstore/rekor-monitor/pkg/identity" 37 | "github.com/sigstore/rekor-monitor/pkg/notifications" 38 | "github.com/sigstore/rekor-monitor/pkg/rekor" 39 | "github.com/sigstore/rekor-monitor/pkg/test" 40 | "github.com/sigstore/rekor/pkg/client" 41 | "github.com/sigstore/rekor/pkg/generated/client/entries" 42 | "github.com/sigstore/rekor/pkg/types" 43 | "github.com/sigstore/rekor/pkg/util" 44 | "github.com/sigstore/sigstore/pkg/cryptoutils" 45 | "github.com/sigstore/sigstore/pkg/signature" 46 | "sigs.k8s.io/release-utils/version" 47 | 48 | hashedrekord_v001 "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1" 49 | ) 50 | 51 | const ( 52 | rekorURL = "http://127.0.0.1:3000" 53 | subject = "subject@example.com" 54 | issuer = "oidc-issuer@domain.com" 55 | extValueString = "test cert value" 56 | ) 57 | 58 | // Test IdentitySearch: 59 | // Check that Rekor-monitor reusable identity search workflow successfully 60 | // finds a monitored identity within the checkpoint indices and writes it to file. 61 | func TestIdentitySearch(t *testing.T) { 62 | rekorClient, err := client.GetRekorClient(rekorURL, client.WithUserAgent(strings.TrimSpace(fmt.Sprintf("rekor-monitor/%s (%s; %s)", version.GetVersionInfo().GitVersion, runtime.GOOS, runtime.GOARCH)))) 63 | if err != nil { 64 | log.Fatalf("getting Rekor client: %v", err) 65 | } 66 | 67 | oid := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 9} 68 | extValue, err := asn1.Marshal(extValueString) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | extension := pkix.Extension{ 73 | Id: oid, 74 | Critical: false, 75 | Value: extValue, 76 | } 77 | 78 | rootCert, rootKey, _ := test.GenerateRootCA() 79 | leafCert, leafKey, _ := test.GenerateLeafCert(subject, issuer, rootCert, rootKey, extension) 80 | 81 | signer, err := signature.LoadECDSASignerVerifier(leafKey, crypto.SHA256) 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | pemCert, _ := cryptoutils.MarshalCertificateToPEM(leafCert) 86 | 87 | payload := []byte{1, 2, 3, 4} 88 | sig, err := signer.SignMessage(bytes.NewReader(payload)) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | hashedrekord := &hashedrekord_v001.V001Entry{} 94 | hash := sha256.Sum256(payload) 95 | pe, err := hashedrekord.CreateFromArtifactProperties(context.Background(), types.ArtifactProperties{ 96 | ArtifactHash: hex.EncodeToString(hash[:]), 97 | SignatureBytes: sig, 98 | PublicKeyBytes: [][]byte{pemCert}, 99 | PKIFormat: "x509", 100 | }) 101 | if err != nil { 102 | t.Fatalf("error creating hashed rekord entry: %v", err) 103 | } 104 | 105 | x509Cert, err := cryptoutils.UnmarshalCertificatesFromPEM(pemCert) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | digest := sha256.Sum256(x509Cert[0].Raw) 110 | certFingerprint := hex.EncodeToString(digest[:]) 111 | 112 | params := entries.NewCreateLogEntryParams() 113 | params.SetProposedEntry(pe) 114 | resp, err := rekorClient.Entries.CreateLogEntry(params) 115 | if !resp.IsSuccess() || err != nil { 116 | t.Errorf("error creating log entry: %v", err) 117 | } 118 | 119 | logInfo, err := rekor.GetLogInfo(context.Background(), rekorClient) 120 | if err != nil { 121 | t.Errorf("error getting log info: %v", err) 122 | } 123 | checkpoint := &util.SignedCheckpoint{} 124 | if err := checkpoint.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil { 125 | t.Errorf("%v", err) 126 | } 127 | if checkpoint.Size != 1 { 128 | t.Errorf("expected checkpoint size of 1, received size %d", checkpoint.Size) 129 | } 130 | 131 | tempDir := t.TempDir() 132 | tempLogInfoFile, err := os.CreateTemp(tempDir, "") 133 | if err != nil { 134 | t.Errorf("failed to create temp log file: %v", err) 135 | } 136 | tempLogInfoFileName := tempLogInfoFile.Name() 137 | defer os.Remove(tempLogInfoFileName) 138 | 139 | tempOutputIdentitiesFile, err := os.CreateTemp(tempDir, "") 140 | if err != nil { 141 | t.Errorf("failed to create temp output identities file: %v", err) 142 | } 143 | tempOutputIdentitiesFileName := tempOutputIdentitiesFile.Name() 144 | defer os.Remove(tempOutputIdentitiesFileName) 145 | 146 | tempMetadataFile, err := os.CreateTemp(tempDir, "") 147 | if err != nil { 148 | t.Errorf("failed to create temp output identities file: %v", err) 149 | } 150 | tempMetadataFileName := tempMetadataFile.Name() 151 | defer os.Remove(tempMetadataFileName) 152 | 153 | configMonitoredValues := notifications.ConfigMonitoredValues{ 154 | Subjects: []string{subject}, 155 | CertificateIdentities: []identity.CertificateIdentity{ 156 | { 157 | CertSubject: ".*ubje.*", 158 | Issuers: []string{".+@domain.com"}, 159 | }, 160 | }, 161 | OIDMatchers: extensions.OIDMatchers{ 162 | OIDExtensions: []extensions.OIDExtension{ 163 | { 164 | ObjectIdentifier: oid, 165 | ExtensionValues: []string{extValueString}, 166 | }, 167 | }, 168 | FulcioExtensions: extensions.FulcioExtensions{}, 169 | CustomExtensions: []extensions.CustomExtension{}, 170 | }, 171 | Fingerprints: []string{ 172 | certFingerprint, 173 | }, 174 | } 175 | 176 | verifier, err := rekor.GetLogVerifier(context.Background(), rekorClient) 177 | if err != nil { 178 | t.Errorf("error getting log verifier: %v", err) 179 | } 180 | 181 | prevCheckpoint, logInfo, err := rekor.RunConsistencyCheck(rekorClient, verifier, tempLogInfoFileName) 182 | if err != nil { 183 | t.Errorf("first consistency check failed: %v", err) 184 | } 185 | if logInfo == nil { 186 | t.Errorf("first consistency check did not return log info") 187 | } 188 | if prevCheckpoint != nil { 189 | t.Errorf("first consistency check should not have returned checkpoint") 190 | } 191 | 192 | configRenderedOIDMatchers, err := configMonitoredValues.OIDMatchers.RenderOIDMatchers() 193 | if err != nil { 194 | t.Errorf("error rendering OID matchers: %v", err) 195 | } 196 | 197 | monitoredVals := identity.MonitoredValues{ 198 | Subjects: configMonitoredValues.Subjects, 199 | Fingerprints: configMonitoredValues.Fingerprints, 200 | OIDMatchers: configRenderedOIDMatchers, 201 | CertificateIdentities: configMonitoredValues.CertificateIdentities, 202 | } 203 | 204 | payload = []byte{1, 2, 3, 4, 5, 6} 205 | sig, err = signer.SignMessage(bytes.NewReader(payload)) 206 | if err != nil { 207 | t.Fatalf("error signing message: %v", err) 208 | } 209 | hashedrekord = &hashedrekord_v001.V001Entry{} 210 | hash = sha256.Sum256(payload) 211 | pe, err = hashedrekord.CreateFromArtifactProperties(context.Background(), types.ArtifactProperties{ 212 | ArtifactHash: hex.EncodeToString(hash[:]), 213 | SignatureBytes: sig, 214 | PublicKeyBytes: [][]byte{pemCert}, 215 | PKIFormat: "x509", 216 | }) 217 | if err != nil { 218 | t.Fatalf("error creating hashed rekord log entry: %v", err) 219 | } 220 | params = entries.NewCreateLogEntryParams() 221 | params.SetProposedEntry(pe) 222 | resp, err = rekorClient.Entries.CreateLogEntry(params) 223 | if !resp.IsSuccess() || err != nil { 224 | t.Errorf("error creating log entry: %v", err) 225 | } 226 | 227 | prevCheckpoint, logInfo, err = rekor.RunConsistencyCheck(rekorClient, verifier, tempLogInfoFileName) 228 | if err != nil { 229 | t.Errorf("second consistency check failed: %v", err) 230 | } 231 | checkpoint = &util.SignedCheckpoint{} 232 | if err := checkpoint.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil { 233 | t.Errorf("%v", err) 234 | } 235 | if checkpoint.Size != 2 { 236 | t.Errorf("expected checkpoint size of 2, received size %d", checkpoint.Size) 237 | } 238 | if prevCheckpoint.Size != 1 { 239 | t.Errorf("expected previous checkpoint size of 1, received size %d", prevCheckpoint.Size) 240 | } 241 | 242 | _, err = rekor.IdentitySearch(0, 1, rekorClient, monitoredVals, tempOutputIdentitiesFileName, nil) 243 | if err != nil { 244 | log.Fatal(err.Error()) 245 | } 246 | 247 | tempOutputIdentities, err := os.ReadFile(tempOutputIdentitiesFileName) 248 | if err != nil { 249 | t.Errorf("error reading from output identities file: %v", err) 250 | } 251 | tempOutputIdentitiesString := string(tempOutputIdentities) 252 | if !strings.Contains(tempOutputIdentitiesString, subject) { 253 | t.Errorf("expected to find subject %s, did not", subject) 254 | } 255 | if !strings.Contains(tempOutputIdentitiesString, issuer) { 256 | t.Errorf("expected to find issuer %s, did not", issuer) 257 | } 258 | if !strings.Contains(tempOutputIdentitiesString, oid.String()) { 259 | t.Errorf("expected to find oid %s, did not", oid.String()) 260 | } 261 | if !strings.Contains(tempOutputIdentitiesString, oid.String()) { 262 | t.Errorf("expected to find oid value %s, did not", extValueString) 263 | } 264 | if !strings.Contains(tempOutputIdentitiesString, certFingerprint) { 265 | t.Errorf("expected to find fingerprint %s, did not", certFingerprint) 266 | } 267 | 268 | tempMetadata, err := os.ReadFile(tempMetadataFileName) 269 | if err != nil { 270 | t.Errorf("error reading from output identities file: %v", err) 271 | } 272 | tempMetadataString := string(tempMetadata) 273 | if !strings.Contains(tempOutputIdentitiesString, "2") { 274 | t.Errorf("expected to find latest index 2 in %s, did not", tempMetadataString) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /pkg/test/rekor_e2e/rekor_monitor_e2e_test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2024 The Sigstore Authors. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | set -ex 18 | 19 | pushd $HOME 20 | 21 | echo "downloading service repos" 22 | for repo in rekor ; do 23 | if [[ ! -d $repo ]]; then 24 | git clone https://github.com/sigstore/${repo}.git 25 | else 26 | pushd $repo 27 | git pull 28 | popd 29 | fi 30 | done 31 | 32 | docker_compose="docker compose" 33 | 34 | 35 | echo "starting services" 36 | for repo in rekor ; do 37 | pushd $repo 38 | ${docker_compose} up -d 39 | echo -n "waiting up to 60 sec for system to start" 40 | count=0 41 | until [ $(${docker_compose} ps | grep -c "(healthy)") == 5 ]; 42 | do 43 | if [ $count -eq 6 ]; then 44 | echo "! timeout reached" 45 | exit 1 46 | else 47 | echo -n "." 48 | sleep 10 49 | let 'count+=1' 50 | fi 51 | done 52 | popd 53 | done 54 | 55 | function cleanup_services() { 56 | echo "cleaning up" 57 | for repo in rekor; do 58 | pushd $HOME/$repo 59 | ${docker_compose} down 60 | popd 61 | done 62 | } 63 | trap cleanup_services EXIT 64 | 65 | echo 66 | echo "running tests" 67 | 68 | popd 69 | go test -tags=e2e -v -race ./pkg/test/rekor_e2e/... 70 | -------------------------------------------------------------------------------- /pkg/util/file/file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package file 16 | 17 | import ( 18 | "bufio" 19 | "encoding/json" 20 | "fmt" 21 | "os" 22 | "strings" 23 | 24 | ct "github.com/google/certificate-transparency-go" 25 | "github.com/sigstore/rekor-monitor/pkg/identity" 26 | "github.com/sigstore/rekor/pkg/util" 27 | ) 28 | 29 | type IdentityMetadata struct { 30 | LatestIndex int `json:"latestIndex"` 31 | } 32 | 33 | func (idMetadata IdentityMetadata) String() string { 34 | return fmt.Sprint(idMetadata.LatestIndex) 35 | } 36 | 37 | // ReadLatestCheckpoint reads the most recent signed checkpoint from the log file 38 | func ReadLatestCheckpoint(logInfoFile string) (*util.SignedCheckpoint, error) { 39 | // Each line in the file is one signed checkpoint 40 | file, err := os.Open(logInfoFile) 41 | if err != nil { 42 | return nil, err 43 | } 44 | defer file.Close() 45 | 46 | // Read line by line and get the last line 47 | scanner := bufio.NewScanner(file) 48 | line := "" 49 | for scanner.Scan() { 50 | line = scanner.Text() 51 | } 52 | 53 | checkpoint := util.SignedCheckpoint{} 54 | if err := checkpoint.UnmarshalText([]byte(strings.ReplaceAll(line, "\\n", "\n"))); err != nil { 55 | return nil, err 56 | } 57 | 58 | if err := scanner.Err(); err != nil { 59 | return nil, err 60 | } 61 | 62 | return &checkpoint, nil 63 | } 64 | 65 | // ReadLatestCTSignedTreeHead reads the most recent signed tree head from the log file 66 | func ReadLatestCTSignedTreeHead(logInfoFile string) (*ct.SignedTreeHead, error) { 67 | // Each line in the file is one signed tree head 68 | signedTreeHead, err := os.ReadFile(logInfoFile) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | var checkpoint ct.SignedTreeHead 74 | err = json.Unmarshal([]byte(strings.ReplaceAll(string(signedTreeHead), "\\n", "\n")), &checkpoint) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return &checkpoint, nil 80 | } 81 | 82 | // WriteCTSignedTreeHead writes a signed tree head to a given log file 83 | func WriteCTSignedTreeHead(sth *ct.SignedTreeHead, logInfoFile string) error { 84 | marshalledSTH, err := json.Marshal(sth) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | if err := os.WriteFile(logInfoFile, []byte(fmt.Sprintf("%s\n", strings.ReplaceAll(string(marshalledSTH), "\n", "\\n"))), 0600); err != nil { 90 | return fmt.Errorf("failed to write to file: %w", err) 91 | } 92 | return nil 93 | } 94 | 95 | // WriteCheckpoint writes a signed checkpoint to the log file 96 | func WriteCheckpoint(checkpoint *util.SignedCheckpoint, logInfoFile string) error { 97 | // Write latest checkpoint to file 98 | s, err := checkpoint.MarshalText() 99 | if err != nil { 100 | return fmt.Errorf("failed to marshal checkpoint: %w", err) 101 | } 102 | // Open file to append new snapshot 103 | file, err := os.OpenFile(logInfoFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 104 | if err != nil { 105 | return fmt.Errorf("error opening file: %w", err) 106 | } 107 | defer file.Close() 108 | // Replace newlines to flatten checkpoint to single line 109 | if _, err := fmt.Fprintf(file, "%s\n", strings.ReplaceAll(string(s), "\n", "\\n")); err != nil { 110 | return fmt.Errorf("failed to write to file: %w", err) 111 | } 112 | return nil 113 | } 114 | 115 | // DeleteOldCheckpoints persists the latest 100 checkpoints. This expects that the log file 116 | // is not being concurrently written to 117 | func DeleteOldCheckpoints(logInfoFile string) error { 118 | // read all lines from file 119 | file, err := os.Open(logInfoFile) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | scanner := bufio.NewScanner(file) 125 | var lines []string 126 | for scanner.Scan() { 127 | lines = append(lines, scanner.Text()) 128 | } 129 | if err := scanner.Err(); err != nil { 130 | return err 131 | } 132 | if err := file.Close(); err != nil { 133 | return err 134 | } 135 | 136 | // exit early if there aren't checkpoints to truncate 137 | if len(lines) <= 100 { 138 | return nil 139 | } 140 | 141 | // open file again to overwrite 142 | file, err = os.OpenFile(logInfoFile, os.O_RDWR|os.O_TRUNC, 0666) 143 | if err != nil { 144 | return err 145 | } 146 | defer file.Close() 147 | 148 | for i := len(lines) - 100; i < len(lines); i++ { 149 | if _, err := fmt.Fprintf(file, "%s\n", lines[i]); err != nil { 150 | return err 151 | } 152 | } 153 | 154 | return nil 155 | } 156 | 157 | // WriteIdentity writes an identity found in the log to a file 158 | func WriteIdentity(idFile string, idEntry identity.LogEntry) error { 159 | file, err := os.OpenFile(idFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 160 | if err != nil { 161 | return fmt.Errorf("failed to open identities file: %w", err) 162 | } 163 | defer file.Close() 164 | 165 | if _, err := fmt.Fprintf(file, "%s\n", idEntry.String()); err != nil { 166 | return fmt.Errorf("failed to write to file: %w", err) 167 | } 168 | 169 | return nil 170 | } 171 | 172 | // WriteIdentityMetadata writes information about what log indices have been scanned to a file 173 | func WriteIdentityMetadata(metadataFile string, idMetadata IdentityMetadata) error { 174 | marshalled, err := json.Marshal(idMetadata) 175 | if err != nil { 176 | return fmt.Errorf("failed to marshal identity metadata: %v", err) 177 | } 178 | if err := os.WriteFile(metadataFile, marshalled, 0600); err != nil { 179 | return fmt.Errorf("failed to write to file: %w", err) 180 | } 181 | return nil 182 | } 183 | 184 | // ReadIdentityMetadata reads the latest information about what log indices have been scanned to a file 185 | func ReadIdentityMetadata(metadataFile string) (*IdentityMetadata, error) { 186 | // Each line represents a piece of identity metadata 187 | idMetadataBytes, err := os.ReadFile(metadataFile) 188 | if err != nil { 189 | return nil, err 190 | } 191 | 192 | idMetadata := &IdentityMetadata{} 193 | err = json.Unmarshal(idMetadataBytes, idMetadata) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | return idMetadata, nil 199 | } 200 | -------------------------------------------------------------------------------- /pkg/util/file/file_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package file 16 | 17 | import ( 18 | "bytes" 19 | "encoding/hex" 20 | "fmt" 21 | "log" 22 | "os" 23 | "path/filepath" 24 | "strings" 25 | "testing" 26 | 27 | ct "github.com/google/certificate-transparency-go" 28 | "github.com/sigstore/rekor/pkg/util" 29 | "golang.org/x/mod/sumdb/note" 30 | ) 31 | 32 | func TestReadLatestCheckpoint(t *testing.T) { 33 | f := filepath.Join(t.TempDir(), "logfile") 34 | root, _ := hex.DecodeString("1a341bc342ff4e567387de9789ab14000b147124317841489172419874198147") 35 | 36 | // success: read checkpoint 37 | // generate fake checkpoint 38 | sc, err := util.CreateSignedCheckpoint(util.Checkpoint{ 39 | Origin: "origin", 40 | Size: uint64(123), 41 | Hash: root, 42 | }) 43 | sc.Signatures = []note.Signature{{Name: "name", Hash: 1, Base64: "adbadbadb"}} 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | text, err := sc.MarshalText() 48 | if err != nil { 49 | t.Fatal(err) 50 | } 51 | data := fmt.Sprintf("%s\n", strings.ReplaceAll(string(text), "\n", "\\n")) 52 | err = os.WriteFile(f, []byte(data), 0644) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | c, err := ReadLatestCheckpoint(f) 57 | if err != nil { 58 | t.Fatalf("error reading checkpoint: %v", err) 59 | } 60 | result, _ := c.MarshalText() 61 | if !bytes.Equal(text, result) { 62 | log.Fatalf("checkpoints are not equal") 63 | } 64 | 65 | // failure: no log file 66 | _, err = ReadLatestCheckpoint("empty") 67 | if err == nil || !strings.Contains(err.Error(), "no such file or directory") { 68 | t.Fatalf("expected no error, got: %v", err) 69 | } 70 | 71 | // failure: malformed note 72 | os.WriteFile(f, []byte{1}, 0644) 73 | _, err = ReadLatestCheckpoint(f) 74 | if err == nil || !strings.Contains(err.Error(), "malformed note") { 75 | t.Fatalf("expected no error, got: %v", err) 76 | } 77 | } 78 | 79 | func TestWriteAndRead(t *testing.T) { 80 | f := filepath.Join(t.TempDir(), "logfile") 81 | root, _ := hex.DecodeString("1a341bc342ff4e567387de9789ab14000b147124317841489172419874198147") 82 | 83 | // success: read and write checkpoint 84 | // generate fake checkpoint 85 | sc, err := util.CreateSignedCheckpoint(util.Checkpoint{ 86 | Origin: "origin", 87 | Size: uint64(123), 88 | Hash: root, 89 | }) 90 | sc.Signatures = []note.Signature{{Name: "name", Hash: 1, Base64: "adbadbadb"}} 91 | if err != nil { 92 | t.Fatal(err) 93 | } 94 | if err := WriteCheckpoint(sc, f); err != nil { 95 | t.Fatalf("error writing checkpoint: %v", err) 96 | } 97 | c, err := ReadLatestCheckpoint(f) 98 | if err != nil { 99 | t.Fatalf("error reading checkpoint: %v", err) 100 | } 101 | input, _ := sc.MarshalText() 102 | result, _ := c.MarshalText() 103 | if !bytes.Equal(input, result) { 104 | log.Fatalf("checkpoints are not equal") 105 | } 106 | } 107 | 108 | func TestDeleteOldCheckpoints(t *testing.T) { 109 | f := filepath.Join(t.TempDir(), "logfile") 110 | file, _ := os.OpenFile(f, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 111 | // log size will be 200 by end of loop 112 | for i := 0; i < 200; i++ { 113 | file.WriteString("\n") 114 | } 115 | fi, _ := os.Stat(f) 116 | if fi.Size() != 200 { 117 | t.Fatalf("log size should be 200, got %d", fi.Size()) 118 | } 119 | 120 | if err := DeleteOldCheckpoints(f); err != nil { 121 | t.Fatalf("error deleting: %v", err) 122 | } 123 | 124 | fi, _ = os.Stat(f) 125 | if fi.Size() != 100 { 126 | t.Fatalf("log size should be 100, got %d", fi.Size()) 127 | } 128 | } 129 | 130 | func TestReadWriteCTSignedTreeHead(t *testing.T) { 131 | sth := &ct.SignedTreeHead{ 132 | TreeSize: 1, 133 | } 134 | 135 | tempDir := t.TempDir() 136 | tempSTHFile, err := os.CreateTemp(tempDir, "") 137 | if err != nil { 138 | t.Errorf("failed to create temp STH file: %v", err) 139 | } 140 | tempSTHFileName := tempSTHFile.Name() 141 | defer os.Remove(tempSTHFileName) 142 | 143 | err = WriteCTSignedTreeHead(sth, tempSTHFileName) 144 | if err != nil { 145 | t.Errorf("failed to write STH: %v", err) 146 | } 147 | 148 | readSTH, err := ReadLatestCTSignedTreeHead(tempSTHFileName) 149 | if err != nil { 150 | t.Errorf("failed to read STH: %v", err) 151 | } 152 | 153 | if readSTH.String() != sth.String() { 154 | t.Errorf("expected STH: %s, received STH: %s", sth, readSTH) 155 | } 156 | } 157 | 158 | func TestReadWriteIdentityMetadata(t *testing.T) { 159 | tempDir := t.TempDir() 160 | tempMetadataFile, err := os.CreateTemp(tempDir, "") 161 | if err != nil { 162 | t.Errorf("failed to create temp log file: %v", err) 163 | } 164 | tempMetadataFileName := tempMetadataFile.Name() 165 | defer os.Remove(tempMetadataFileName) 166 | 167 | WriteIdentityMetadata(tempMetadataFileName, IdentityMetadata{ 168 | LatestIndex: 1, 169 | }) 170 | 171 | idMetadata, err := ReadIdentityMetadata(tempMetadataFileName) 172 | if err != nil { 173 | t.Errorf("failed to read identity metadata: %v", err) 174 | } 175 | if idMetadata == nil || idMetadata.LatestIndex != 1 { 176 | t.Errorf("expected latest index of 1, received incorrect or nil") 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /pkg/util/retry.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package util 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "strings" 23 | "time" 24 | ) 25 | 26 | type RetryConfig struct { 27 | MaxAttempts int 28 | InitialInterval time.Duration 29 | MaxInterval time.Duration 30 | Multiplier float64 31 | MaxElapsedTime time.Duration 32 | } 33 | 34 | func DefaultRetryConfig() *RetryConfig { 35 | return &RetryConfig{ 36 | MaxAttempts: 5, 37 | InitialInterval: 1 * time.Second, 38 | MaxInterval: 5 * time.Second, 39 | Multiplier: 1.5, 40 | MaxElapsedTime: 30 * time.Second, 41 | } 42 | } 43 | 44 | type RetryError struct { 45 | Err error 46 | } 47 | 48 | func (r RetryError) Error() string { 49 | return r.Err.Error() 50 | } 51 | 52 | func (r RetryError) ShouldRetry() bool { 53 | if r.Err == nil { 54 | return false 55 | } 56 | 57 | if httpErr, ok := r.Err.(interface{ StatusCode() int }); ok { 58 | statusCode := httpErr.StatusCode() 59 | return statusCode >= 500 || statusCode == http.StatusTooManyRequests 60 | } 61 | 62 | if http2Err, ok := r.Err.(interface{ Error() string }); ok { 63 | if strings.Contains(http2Err.Error(), "http2: server sent GOAWAY") { 64 | return true 65 | } 66 | } 67 | 68 | return false 69 | } 70 | 71 | func WrapError(err error) error { 72 | if err == nil { 73 | return nil 74 | } 75 | return RetryError{Err: err} 76 | } 77 | 78 | // Retry executes the provided function with retry logic based on the configuration. 79 | // It stops on success, unrecoverable errors, context cancellation, or exceeding the retry limits. 80 | func Retry(ctx context.Context, f func() (any, error), opts ...func(*RetryConfig)) (any, error) { 81 | config := DefaultRetryConfig() 82 | for _, opt := range opts { 83 | opt(config) 84 | } 85 | 86 | if err := validateRetryConfig(config); err != nil { 87 | return nil, fmt.Errorf("invalid retry configuration: %w", err) 88 | } 89 | 90 | var lastErr error 91 | startTime := time.Now() 92 | currentInterval := config.InitialInterval 93 | 94 | for attempt := 1; attempt <= config.MaxAttempts; attempt++ { 95 | if config.MaxElapsedTime > 0 && time.Since(startTime) > config.MaxElapsedTime { 96 | return nil, fmt.Errorf("max elapsed time exceeded after %d attempts, last error: %w", attempt-1, lastErr) 97 | } 98 | 99 | result, err := f() 100 | if err == nil { 101 | return result, nil 102 | } 103 | 104 | lastErr = err 105 | 106 | if retryableErr, ok := err.(RetryError); ok && !retryableErr.ShouldRetry() { 107 | return nil, fmt.Errorf("non-retryable error encountered after %d attempts: %w", attempt, err) 108 | } 109 | 110 | if attempt == config.MaxAttempts { 111 | return nil, fmt.Errorf("retry cancelled after %d attempts: %w", attempt, context.DeadlineExceeded) 112 | } 113 | 114 | select { 115 | case <-ctx.Done(): 116 | return nil, fmt.Errorf("retry cancelled after %d attempts: %w", attempt, ctx.Err()) 117 | case <-time.After(currentInterval): 118 | currentInterval = time.Duration(float64(currentInterval) * config.Multiplier) 119 | if currentInterval > config.MaxInterval { 120 | currentInterval = config.MaxInterval 121 | } 122 | } 123 | } 124 | 125 | return nil, fmt.Errorf("retry logic exited unexpectedly, last error: %w", lastErr) 126 | } 127 | 128 | func validateRetryConfig(config *RetryConfig) error { 129 | if config.MaxAttempts <= 0 { 130 | return errors.New("MaxAttempts must be greater than zero") 131 | } 132 | if config.InitialInterval <= 0 { 133 | return errors.New("InitialInterval must be greater than zero") 134 | } 135 | if config.MaxInterval <= 0 { 136 | return errors.New("MaxInterval must be greater than zero") 137 | } 138 | if config.Multiplier <= 1 { 139 | return errors.New("multiplier must be greater than one") 140 | } 141 | if config.MaxElapsedTime <= 0 { 142 | return errors.New("MaxElapsedTime must be greater than zero") 143 | } 144 | return nil 145 | } 146 | 147 | // WithMaxAttempts sets the maximum number of retry attempts. 148 | func WithMaxAttempts(attempts int) func(*RetryConfig) { 149 | return func(c *RetryConfig) { 150 | if attempts > 0 { 151 | c.MaxAttempts = attempts 152 | } 153 | } 154 | } 155 | 156 | // WithInitialInterval sets the initial interval between retries. 157 | func WithInitialInterval(interval time.Duration) func(*RetryConfig) { 158 | return func(c *RetryConfig) { 159 | if interval > 0 { 160 | c.InitialInterval = interval 161 | } 162 | } 163 | } 164 | 165 | // WithMaxInterval sets the maximum interval between retries. 166 | func WithMaxInterval(interval time.Duration) func(*RetryConfig) { 167 | return func(c *RetryConfig) { 168 | if interval > 0 { 169 | c.MaxInterval = interval 170 | } 171 | } 172 | } 173 | 174 | // WithMultiplier sets the backoff multiplier for exponential backoff. 175 | func WithMultiplier(multiplier float64) func(*RetryConfig) { 176 | return func(c *RetryConfig) { 177 | if multiplier > 1 { 178 | c.Multiplier = multiplier 179 | } 180 | } 181 | } 182 | 183 | // WithMaxElapsedTime sets the maximum time allowed for retries. 184 | func WithMaxElapsedTime(duration time.Duration) func(*RetryConfig) { 185 | return func(c *RetryConfig) { 186 | if duration > 0 { 187 | c.MaxElapsedTime = duration 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /pkg/util/retry_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 The Sigstore Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package util 16 | 17 | import ( 18 | "context" 19 | "errors" 20 | "fmt" 21 | "net/http" 22 | "testing" 23 | "time" 24 | ) 25 | 26 | type HTTPError struct { 27 | Code int 28 | } 29 | 30 | func (e *HTTPError) Error() string { 31 | return fmt.Sprintf("HTTP error with status code %d", e.Code) 32 | } 33 | 34 | func (e *HTTPError) StatusCode() int { 35 | return e.Code 36 | } 37 | 38 | type HTTP2Error struct { 39 | Message string 40 | } 41 | 42 | func (e *HTTP2Error) Error() string { 43 | return e.Message 44 | } 45 | 46 | func TestRetry(t *testing.T) { 47 | t.Run("retry_with_recoverable_error", func(t *testing.T) { 48 | attempts := 0 49 | result, err := Retry(context.Background(), func() (any, error) { 50 | attempts++ 51 | if attempts < 3 { 52 | return nil, WrapError(&HTTPError{Code: http.StatusInternalServerError}) 53 | } 54 | return "success after retries", nil 55 | }) 56 | if err != nil { 57 | t.Fatalf("unexpected error: %v", err) 58 | } 59 | if result != "success after retries" { 60 | t.Fatalf("expected 'success after retries', got %v", result) 61 | } 62 | if attempts != 3 { 63 | t.Fatalf("expected 3 attempts, got %d", attempts) 64 | } 65 | }) 66 | 67 | t.Run("retry_with_http2_goaway_error", func(t *testing.T) { 68 | attempts := 0 69 | result, err := Retry(context.Background(), func() (any, error) { 70 | attempts++ 71 | if attempts < 3 { 72 | return nil, WrapError(&HTTP2Error{Message: "http2: server sent GOAWAY"}) 73 | } 74 | return "success after retries", nil 75 | }) 76 | if err != nil { 77 | t.Fatalf("unexpected error: %v", err) 78 | } 79 | if result != "success after retries" { 80 | t.Fatalf("expected 'success after retries', got %v", result) 81 | } 82 | if attempts != 3 { 83 | t.Fatalf("expected 3 attempts, got %d", attempts) 84 | } 85 | }) 86 | 87 | t.Run("stop_retry_on_non_retryable_error", func(t *testing.T) { 88 | attempts := 0 89 | result, err := Retry(context.Background(), func() (any, error) { 90 | attempts++ 91 | return nil, WrapError(errors.New("non-retryable error")) 92 | }) 93 | if err == nil { 94 | t.Fatalf("expected error, got nil") 95 | } 96 | if result != nil { 97 | t.Fatalf("expected nil result, got %v", result) 98 | } 99 | if attempts != 1 { 100 | t.Fatalf("expected 1 attempt, got %d", attempts) 101 | } 102 | }) 103 | 104 | t.Run("max_attempts_reached", func(t *testing.T) { 105 | attempts := 0 106 | result, err := Retry(context.Background(), func() (any, error) { 107 | attempts++ 108 | return nil, WrapError(&HTTPError{Code: http.StatusInternalServerError}) 109 | }, WithMaxAttempts(3)) 110 | 111 | if err == nil { 112 | t.Fatalf("expected error after max attempts, got nil") 113 | } 114 | if err.Error() != "retry cancelled after 3 attempts: context deadline exceeded" { 115 | t.Fatalf("expected context deadline exceeded, got %v", err) 116 | } 117 | 118 | if result != nil { 119 | t.Fatalf("expected nil result, got %v", result) 120 | } 121 | if attempts != 3 { 122 | t.Fatalf("expected 3 attempts, got %d", attempts) 123 | } 124 | }) 125 | 126 | t.Run("context_cancellation", func(t *testing.T) { 127 | ctx, cancel := context.WithCancel(context.Background()) 128 | attempts := 0 129 | go func() { 130 | time.Sleep(1 * time.Second) 131 | cancel() 132 | }() 133 | _, err := Retry(ctx, func() (any, error) { 134 | attempts++ 135 | return nil, WrapError(errors.New("temporary error")) 136 | }) 137 | if err == nil { 138 | t.Fatalf("expected error due to context cancellation, got nil") 139 | } 140 | if attempts < 1 { 141 | t.Fatalf("expected at least 1 attempt, got %d", attempts) 142 | } 143 | }) 144 | 145 | t.Run("validate_retry_configuration", func(t *testing.T) { 146 | invalidConfig := func() *RetryConfig { 147 | return &RetryConfig{ 148 | MaxAttempts: 0, 149 | } 150 | } 151 | err := validateRetryConfig(invalidConfig()) 152 | if err == nil { 153 | t.Fatalf("expected validation error, got nil") 154 | } 155 | 156 | validConfig := func() *RetryConfig { 157 | return &RetryConfig{ 158 | MaxAttempts: 5, 159 | InitialInterval: 1 * time.Second, 160 | MaxInterval: 5 * time.Second, 161 | Multiplier: 1.5, 162 | MaxElapsedTime: 30 * time.Second, 163 | } 164 | } 165 | err = validateRetryConfig(validConfig()) 166 | if err != nil { 167 | t.Fatalf("unexpected validation error: %v", err) 168 | } 169 | }) 170 | } 171 | --------------------------------------------------------------------------------