├── .editorconfig ├── .env.example ├── .github ├── bin │ └── test_provenance.sh ├── dependabot.yml ├── test_resource │ └── example_provenance.json └── workflows │ ├── ci.yaml │ ├── example-local.yaml │ └── example-publish.yaml ├── .gitignore ├── .goreleaser.draft.yml ├── .goreleaser.yml ├── .vscode ├── extensions.json └── settings.json ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── RELEASE.md ├── action.yaml ├── cmd └── slsa-provenance │ ├── cli │ ├── commands.go │ ├── commands_test.go │ ├── common_test.go │ ├── container.go │ ├── container_test.go │ ├── files.go │ ├── files_test.go │ ├── generate.go │ ├── generate_test.go │ ├── github-release.go │ ├── github-release_test.go │ ├── options │ │ ├── files.go │ │ ├── generate.go │ │ ├── github-release.go │ │ ├── oci.go │ │ └── root.go │ ├── version.go │ └── version_test.go │ └── main.go ├── codecov.yaml ├── cosign.pub ├── go.mod ├── go.sum ├── install-slsa-provenance.sh ├── internal └── transport │ ├── transport.go │ └── transport_test.go ├── pkg ├── github │ ├── github.go │ ├── github_test.go │ ├── provenance.go │ ├── provenance_test.go │ ├── releases.go │ └── releases_test.go ├── intoto │ ├── intoto.go │ ├── intoto_test.go │ ├── materials.go │ ├── materials_test.go │ ├── subjects.go │ └── subjects_test.go └── oci │ ├── auth.go │ ├── subjects.go │ └── subjects_test.go └── test-data ├── materials-broken.not-json ├── materials-no-digest.json ├── materials-no-uri.json └── materials-valid.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | tab_width = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | 12 | [{*.go,go.mod,go.sum,Makefile}] 13 | indent_size = 4 14 | tab_width = 4 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN= 2 | -------------------------------------------------------------------------------- /.github/bin/test_provenance.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | diff_artifacts() { 5 | if [ ! -f "$1" ] || [ ! -f "$2" ] ; then 6 | echo "Please provide two files to compare" >&2 7 | exit 1 8 | fi 9 | 10 | local temp_file1=/tmp/file1 11 | local temp_file2=/tmp/file2 12 | (grep -v "buildInvocationId" | grep -v "buildFinishedOn" | grep -v "sha1") > $temp_file1 < "$1" 13 | (grep -v "buildInvocationId" | grep -v "buildFinishedOn" | grep -v "sha1") > $temp_file2 < "$2" 14 | 15 | diff -wf $temp_file1 $temp_file2 > /dev/null 16 | local exit_code=$? 17 | rm -rf /tmp/{file1,file2} 18 | exit $exit_code 19 | } 20 | 21 | diff_artifacts "$1" "$2" 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | commit-message: 11 | prefix: ":arrow_up:" 12 | schedule: 13 | interval: "daily" 14 | 15 | - package-ecosystem: "github-actions" 16 | # Workflow files stored in the 17 | # default location of `.github/workflows` 18 | directory: "/" 19 | commit-message: 20 | prefix: ":arrow_up:" 21 | schedule: 22 | interval: "weekly" 23 | -------------------------------------------------------------------------------- /.github/test_resource/example_provenance.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "https://in-toto.io/Statement/v0.1", 3 | "subject": [ 4 | { 5 | "name": "salsa.txt", 6 | "digest": { 7 | "sha256": "f8161d035cdf328c7bb124fce192cb90b603f34ca78d73e33b736b4f6bddf993" 8 | } 9 | } 10 | ], 11 | "predicateType": "https://slsa.dev/provenance/v0.2", 12 | "predicate": { 13 | "builder": { 14 | "id": "https://github.com/philips-labs/slsa-provenance-action/Attestations/GitHubHostedActions@v1" 15 | }, 16 | "buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1", 17 | "invocation": { 18 | "configSource": { 19 | "entryPoint": "ci.yaml:build", 20 | "uri": "git+https://github.com/philips-labs/slsa-provenance-action", 21 | "digest": { 22 | "sha1": "a3bc1c27230caa1cc3c27961f7e9cab43cd208dc" 23 | } 24 | }, 25 | "parameters": null, 26 | "environment": null 27 | }, 28 | "buildConfig": null, 29 | "metadata": { 30 | "buildInvocationId": "https://github.com/philips-labs/slsa-provenance-action/actions/runs/1303916967", 31 | "buildFinishedOn": "2021-10-04T11:08:34Z", 32 | "completeness": { 33 | "parameters": true, 34 | "environment": false, 35 | "materials": false 36 | }, 37 | "reproducible": false 38 | }, 39 | "materials": [ 40 | { 41 | "uri": "git+https://github.com/philips-labs/slsa-provenance-action", 42 | "digest": { 43 | "sha1": "a3bc1c27230caa1cc3c27961f7e9cab43cd208dc" 44 | } 45 | } 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [ opened, reopened, synchronize ] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-20.04 13 | 14 | permissions: 15 | contents: write # tests create releases 16 | packages: read 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4.2.2 21 | - name: Set up Go 22 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5.5.0 23 | with: 24 | go-version-file: go.mod 25 | check-latest: true 26 | cache: true 27 | 28 | - name: Get dependencies 29 | run: make download 30 | 31 | - name: Lint 32 | run: | 33 | result="$(make lint)" 34 | echo "$result" 35 | [ -n "$(echo "$result" | grep 'diff -u')" ] && exit 1 || exit 0 36 | 37 | - name: Build 38 | run: make build 39 | 40 | - name: Test 41 | env: 42 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 43 | run: make test-race 44 | 45 | - name: Coverage 46 | env: 47 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 48 | run: make coverage-out 49 | 50 | - name: Upload Code Coverage 51 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # ratchet:codecov/codecov-action@v5.4.3 52 | with: 53 | token: ${{ secrets.CODECOV_TOKEN }} 54 | files: ./coverage.out 55 | flags: unittests 56 | name: codecov-umbrella 57 | fail_ci_if_error: true 58 | verbose: true 59 | 60 | release: 61 | name: release 62 | needs: [build] 63 | 64 | permissions: 65 | contents: write 66 | packages: write 67 | id-token: write 68 | 69 | outputs: 70 | container_digest: ${{ steps.container_info.outputs.container_digest }} 71 | container_tags: ${{ steps.container_info.outputs.container_tags }} 72 | container_repos: ${{ steps.container_info.outputs.container_repos }} 73 | 74 | runs-on: ubuntu-20.04 75 | 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4.2.2 79 | with: 80 | fetch-depth: 0 81 | 82 | - name: Set up Go 83 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # ratchet:actions/setup-go@v5.5.0 84 | with: 85 | go-version-file: ./go.mod 86 | check-latest: true 87 | cache: true 88 | 89 | - name: Install cosign 90 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # ratchet:sigstore/cosign-installer@v3.8.1 91 | with: 92 | cosign-release: 'v2.4.3' 93 | 94 | - name: Install Syft 95 | uses: anchore/sbom-action/download-syft@e11c554f704a0b820cbf8c51673f6945e0731532 # ratchet:anchore/sbom-action/download-syft@v0.20.0 96 | - name: Login to Container registries 97 | if: startsWith(github.ref, 'refs/tags/') 98 | run: | 99 | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u philipssoftware --password-stdin 100 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin ghcr.io 101 | 102 | - name: Set release variables 103 | id: release-vars 104 | run: | 105 | make release-vars > /tmp/spiffe-vault-release-vars.env 106 | source /tmp/spiffe-vault-release-vars.env 107 | if [[ -n "$LDFLAGS" ]]; then 108 | echo "LDFLAGS=$LDFLAGS" >> $GITHUB_OUTPUT 109 | fi 110 | if [[ -n "$GIT_HASH" ]]; then 111 | echo "GIT_HASH=$GIT_HASH" >> $GITHUB_OUTPUT 112 | fi 113 | rm -f /tmp/spiffe-vault-release-vars.env 114 | 115 | - name: Release ${{ (!startsWith(github.ref, 'refs/tags/') && 'snapshot') || '' }} 116 | uses: goreleaser/goreleaser-action@5742e2a039330cbb23ebf35f046f814d4c6ff811 # ratchet:goreleaser/goreleaser-action@v4 117 | with: 118 | version: latest 119 | args: release --clean ${{ (!startsWith(github.ref, 'refs/tags/') && '--snapshot') || '' }} ${{ ((startsWith(github.ref, 'refs/tags/') && endsWith(github.ref, '-draft')) && '-f .goreleaser.draft.yml') || '' }} 120 | env: 121 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 122 | LDFLAGS: ${{ steps.release-vars.outputs.LDFLAGS }} 123 | GIT_HASH: ${{ steps.release-vars.outputs.GIT_HASH }} 124 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 125 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 126 | 127 | - name: Get container info 128 | id: container_info 129 | if: startsWith(github.ref, 'refs/tags/') 130 | run: | 131 | export CONTAINER_DIGEST="$(make container-digest GITHUB_REF=${{ github.ref_name }})" 132 | echo "container_digest=$CONTAINER_DIGEST" >> $GITHUB_OUTPUT 133 | echo "container_tags=$(make container-tags CONTAINER_DIGEST="${CONTAINER_DIGEST}" | paste -s -d ',' -)" >> $GITHUB_OUTPUT 134 | echo "container_repos=$(make container-repos CONTAINER_DIGEST="${CONTAINER_DIGEST}" | jq --raw-input . | jq --slurp -c)" >> $GITHUB_OUTPUT 135 | 136 | - name: Logout from Container registries 137 | if: ${{ always() }} 138 | run: | 139 | docker logout 140 | docker logout ghcr.io 141 | 142 | sbom: 143 | name: sbom 144 | needs: [release] 145 | if: startsWith(github.ref, 'refs/tags/') 146 | runs-on: ubuntu-20.04 147 | env: 148 | TAGS: "${{ needs.release.outputs.container_tags }}" 149 | 150 | strategy: 151 | matrix: 152 | repo: ${{ fromJSON(needs.release.outputs.container_repos) }} 153 | 154 | steps: 155 | - name: Install cosign 156 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # ratchet:sigstore/cosign-installer@v3.8.1 157 | with: 158 | cosign-release: 'v2.4.3' 159 | 160 | - name: Install Syft 161 | uses: anchore/sbom-action/download-syft@e11c554f704a0b820cbf8c51673f6945e0731532 # ratchet:anchore/sbom-action/download-syft@v0.20.0 162 | - name: Login to Container registries 163 | run: | 164 | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u philipssoftware --password-stdin 165 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin ghcr.io 166 | 167 | - name: Attach SBOM 168 | env: 169 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 170 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 171 | run: | 172 | echo '${{ secrets.COSIGN_PUBLIC_KEY }}' > cosign.pub 173 | IFS=, 174 | for t in ${TAGS}; do 175 | cosign verify --key cosign.pub ${{ matrix.repo }}:${t} 176 | syft ${{ matrix.repo }}:${t} -o spdx-json > sbom-spdx.json 177 | cosign attest --predicate sbom-spdx.json --type spdx --yes --key env://COSIGN_PRIVATE_KEY ${{ matrix.repo }}:${t} 178 | cosign verify-attestation -o verified-sbom-spdx.json --key cosign.pub ${{ matrix.repo }}:${t} 179 | done 180 | 181 | - name: Clean up & Logout from Container registries 182 | if: ${{ always() }} 183 | run: | 184 | docker logout 185 | docker logout ghcr.io 186 | 187 | provenance: 188 | name: provenance 189 | needs: [release] 190 | if: startsWith(github.ref, 'refs/tags/') 191 | runs-on: ubuntu-20.04 192 | 193 | steps: 194 | - name: Generate provenance for Release 195 | uses: philips-labs/slsa-provenance-action@6b2fd198d38ba72fb3cc08fbc52da2ebaef2efad # ratchet:philips-labs/slsa-provenance-action@v0.9.0 196 | with: 197 | command: generate 198 | subcommand: github-release 199 | arguments: --artifact-path release-assets --output-path provenance.att --tag-name ${{ github.ref_name }} 200 | env: 201 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 202 | 203 | - name: Install cosign 204 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # ratchet:sigstore/cosign-installer@v3.8.1 205 | with: 206 | cosign-release: 'v2.4.3' 207 | 208 | - name: Sign provenance 209 | run: | 210 | cosign sign-blob --yes --key env://COSIGN_PRIVATE_KEY --output-signature "${SIGNATURE}" provenance.att 211 | cat "${SIGNATURE}" 212 | 213 | curl_args=(-s -H "Authorization: token ${GITHUB_TOKEN}") 214 | curl_args+=(-H "Accept: application/vnd.github.v3+json") 215 | release_id="$(curl "${curl_args[@]}" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases?per_page=10" | jq "map(select(.name == \"${GITHUB_REF_NAME}\"))" | jq -r '.[0].id')" 216 | 217 | echo "Upload ${SIGNATURE} to release with id ${release_id}…" 218 | curl_args+=(-H "Content-Type: $(file -b --mime-type "${SIGNATURE}")") 219 | curl "${curl_args[@]}" \ 220 | --data-binary @"${SIGNATURE}" \ 221 | "https://uploads.github.com/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${SIGNATURE}" 222 | env: 223 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 224 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 225 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 226 | SIGNATURE: provenance.att.sig 227 | 228 | container-provenance: 229 | name: container-provenance 230 | needs: [release] 231 | if: startsWith(github.ref, 'refs/tags/') 232 | runs-on: ubuntu-20.04 233 | 234 | strategy: 235 | matrix: 236 | repo: ${{ fromJSON(needs.release.outputs.container_repos) }} 237 | 238 | steps: 239 | - name: Install cosign 240 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # ratchet:sigstore/cosign-installer@v3.8.1 241 | with: 242 | cosign-release: 'v2.4.3' 243 | 244 | - name: Generate provenance for ${{ matrix.repo }} 245 | uses: philips-labs/slsa-provenance-action@6b2fd198d38ba72fb3cc08fbc52da2ebaef2efad # ratchet:philips-labs/slsa-provenance-action@v0.9.0 246 | with: 247 | command: generate 248 | subcommand: container 249 | arguments: --repository ${{ matrix.repo }} --output-path provenance.att --digest ${{ needs.release.outputs.container_digest }} --tags ${{ needs.release.outputs.container_tags }} 250 | env: 251 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 252 | 253 | - name: Get slsa-provenance predicate 254 | run: | 255 | cat provenance.att | jq .predicate > provenance-predicate.att 256 | 257 | - name: Login to Container registries 258 | if: startsWith(github.ref, 'refs/tags/') 259 | run: | 260 | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u philipssoftware --password-stdin 261 | echo "${{ secrets.GITHUB_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin ghcr.io 262 | 263 | - name: Attach provenance to image 264 | run: | 265 | cosign attest --predicate provenance-predicate.att --type slsaprovenance --yes --key env://COSIGN_PRIVATE_KEY ${{ matrix.repo }}@${{ needs.release.outputs.container_digest }} 266 | env: 267 | COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} 268 | COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} 269 | 270 | - name: Verify attestation 271 | run: | 272 | echo '${{ secrets.COSIGN_PUBLIC_KEY }}' > cosign.pub 273 | cosign verify-attestation --key cosign.pub ${{ matrix.repo }}@${{ needs.release.outputs.container_digest }} 274 | 275 | - name: Logout from Container registries 276 | if: ${{ always() }} 277 | run: | 278 | docker logout 279 | docker logout ghcr.io 280 | -------------------------------------------------------------------------------- /.github/workflows/example-local.yaml: -------------------------------------------------------------------------------- 1 | name: Create a provenance 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ingredient_input: 7 | description: 'Ingredient to input in the artifact text file' 8 | required: false 9 | car_input: 10 | description: 'Car to input in the artifact text file' 11 | required: false 12 | 13 | jobs: 14 | build: 15 | name: Build artifact 16 | runs-on: ubuntu-20.04 17 | steps: 18 | # traditionally you would build your code here and generate an artifact 19 | - name: Create first artifact 20 | run: echo "onion, tomato, jalapeno, cilantro, lime, salt" > salsa.txt 21 | 22 | - name: Create second artifact 23 | run: echo "audi, tesla, bmw" > cars.txt 24 | 25 | - name: Input workflow ingredient 26 | if: ${{ github.event.inputs.ingredient_input != '' }} 27 | run: | 28 | echo ", ${{github.event.inputs.ingredient_input}}" >> salsa.txt 29 | 30 | - name: Input workflow cars 31 | if: ${{ github.event.inputs.car_input != '' }} 32 | run: | 33 | echo ", ${{github.event.inputs.car_input}}" >> cars.txt 34 | 35 | - name: Upload artifact 36 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 37 | with: 38 | path: '*.txt' 39 | 40 | generate-provenance: 41 | needs: build 42 | name: Generate build provenance 43 | runs-on: ubuntu-20.04 44 | steps: 45 | # checkout only required to run local action (defined in this repo) 46 | - name: Checkout 47 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4.2.2 48 | - name: Download build artifact 49 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 50 | - name: Generate some extra materials (this usually happens as part of the build process) 51 | run: | 52 | echo '[{"uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", "digest": {"sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa"}}]' > extra-materials.json 53 | 54 | - name: Generate provenance 55 | # use action in the root directory 56 | uses: ./ 57 | with: 58 | artifact_path: artifact/ 59 | extra_materials: extra-materials.json 60 | 61 | - name: Upload provenance 62 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 63 | with: 64 | path: provenance.json 65 | -------------------------------------------------------------------------------- /.github/workflows/example-publish.yaml: -------------------------------------------------------------------------------- 1 | name: Create a provenance from marketplace 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | name: Build artifact 9 | runs-on: ubuntu-latest 10 | steps: 11 | # traditionally you would build your code here and generate an artifact 12 | - name: Create artifact 13 | run: echo "onion, tomato, jalapeno, cilantro, lime, salt" > salsa.txt 14 | 15 | - name: Upload artifact 16 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 17 | with: 18 | path: salsa.txt 19 | 20 | generate-provenance: 21 | needs: build 22 | name: Generate build provenance 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Download build artifact 26 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # ratchet:actions/download-artifact@v4.3.0 27 | - name: Generate provenance 28 | uses: philips-labs/slsa-provenance-action@6b2fd198d38ba72fb3cc08fbc52da2ebaef2efad # ratchet:philips-labs/slsa-provenance-action@v0.9.0 29 | with: 30 | command: generate 31 | subcommand: files 32 | arguments: --artifact-path artifact/ --output-path provenance.json 33 | 34 | - name: Upload provenance 35 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4.6.2 36 | with: 37 | path: provenance.json 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | dist/ 3 | coverage.out 4 | .DS_Store 5 | .env 6 | cosign.key 7 | .slsa-provenance 8 | -------------------------------------------------------------------------------- /.goreleaser.draft.yml: -------------------------------------------------------------------------------- 1 | project_name: slsa-provenance 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | env: 8 | - CGO_ENABLED=0 9 | 10 | builds: 11 | - id: binary 12 | dir: cmd/slsa-provenance 13 | goos: 14 | - windows 15 | - darwin 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm64 20 | goarm: 21 | - 8 22 | ldflags: 23 | - "{{ .Env.LDFLAGS }}" 24 | 25 | archives: 26 | - id: archive 27 | format: tar.gz 28 | files: 29 | - LICENSE* 30 | - README* 31 | - cosign.pub 32 | - dist/*.sig 33 | format_overrides: 34 | - goos: windows 35 | format: zip 36 | 37 | sboms: 38 | - id: archive-sbom 39 | cmd: syft 40 | args: ["${artifact}", "--file", "${artifact}.sbom.json", "--output", "spdx-json"] 41 | documents: 42 | - "${artifact}.sbom.json" 43 | artifacts: archive 44 | 45 | checksum: 46 | name_template: 'checksums.txt' 47 | 48 | dockers: 49 | - goos: linux 50 | goarch: amd64 51 | image_templates: 52 | - "philipssoftware/{{ .ProjectName }}:v{{ .Version }}" 53 | - "philipssoftware/{{ .ProjectName }}:{{ .FullCommit }}" 54 | - "ghcr.io/philips-labs/{{ .ProjectName }}:v{{ .Version }}" 55 | - "ghcr.io/philips-labs/{{ .ProjectName }}:{{ .FullCommit }}" 56 | build_flag_templates: 57 | - "--pull" 58 | - "--label=org.opencontainers.image.created={{.Date}}" 59 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 60 | - "--label=org.opencontainers.image.description={{.ProjectName}}" 61 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 62 | - "--label=org.opencontainers.image.version=v{{.Version}}" 63 | - "--label=org.opencontainers.image.licenses=MIT" 64 | - "--label=org.opencontainers.image.vendor=Koninklijke Philips N.V." 65 | extra_files: 66 | - "Makefile" 67 | - "go.mod" 68 | - "go.sum" 69 | - ".git" 70 | - ".gitignore" 71 | - "cmd" 72 | - "internal" 73 | - "pkg" 74 | 75 | signs: 76 | - id: checksums 77 | cmd: cosign 78 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 79 | output: true 80 | artifacts: checksum 81 | args: 82 | - sign-blob 83 | - --yes 84 | - --key 85 | - env://COSIGN_PRIVATE_KEY 86 | - '--output-certificate=${certificate}' 87 | - '--output-signature=${signature}' 88 | - '${artifact}' 89 | - id: binaries 90 | cmd: cosign 91 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 92 | output: true 93 | artifacts: binary 94 | args: 95 | - sign-blob 96 | - --yes 97 | - --key 98 | - env://COSIGN_PRIVATE_KEY 99 | - '--output-certificate=${certificate}' 100 | - '--output-signature=${signature}' 101 | - '${artifact}' 102 | - id: archives 103 | cmd: cosign 104 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 105 | output: true 106 | artifacts: archive 107 | args: 108 | - sign-blob 109 | - --yes 110 | - --key 111 | - env://COSIGN_PRIVATE_KEY 112 | - '--output-certificate=${certificate}' 113 | - '--output-signature=${signature}' 114 | - '${artifact}' 115 | - id: sboms 116 | cmd: cosign 117 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 118 | output: true 119 | artifacts: sbom 120 | args: 121 | - sign-blob 122 | - --yes 123 | - --key 124 | - env://COSIGN_PRIVATE_KEY 125 | - '--output-certificate=${certificate}' 126 | - '--output-signature=${signature}' 127 | - '${artifact}' 128 | 129 | docker_signs: 130 | - id: selfsigned 131 | cmd: cosign 132 | artifacts: all 133 | output: true 134 | args: 135 | - 'sign' 136 | - --yes 137 | - --key 138 | - env://COSIGN_PRIVATE_KEY 139 | - '${artifact}' 140 | - id: keyless 141 | cmd: cosign 142 | artifacts: all 143 | output: true 144 | args: 145 | - sign 146 | - --yes 147 | - '${artifact}' 148 | 149 | snapshot: 150 | name_template: "{{ .Version }}-next" 151 | 152 | changelog: 153 | sort: asc 154 | use: github 155 | filters: 156 | exclude: 157 | - '^docs:' 158 | - '^test:' 159 | - Merge pull request 160 | - Merge branch 161 | 162 | release: 163 | draft: true 164 | prerelease: auto 165 | extra_files: 166 | - glob: "./cosign.pub" 167 | footer: | 168 | **Full Changelog**: https://github.com/philips-labs/slsa-provenance-action/compare/{{ .PreviousTag }}...{{ .Tag }} 169 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: slsa-provenance 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | env: 8 | - CGO_ENABLED=0 9 | 10 | builds: 11 | - id: binary 12 | dir: cmd/slsa-provenance 13 | goos: 14 | - windows 15 | - darwin 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm64 20 | goarm: 21 | - 8 22 | ldflags: 23 | - "{{ .Env.LDFLAGS }}" 24 | 25 | archives: 26 | - id: archive 27 | format: tar.gz 28 | files: 29 | - LICENSE* 30 | - README* 31 | - cosign.pub 32 | - dist/*.sig 33 | format_overrides: 34 | - goos: windows 35 | format: zip 36 | 37 | sboms: 38 | - id: archive-sbom 39 | cmd: syft 40 | args: ["${artifact}", "--file", "${artifact}.sbom.json", "--output", "spdx-json"] 41 | documents: 42 | - "${artifact}.sbom.json" 43 | artifacts: archive 44 | 45 | checksum: 46 | name_template: 'checksums.txt' 47 | 48 | dockers: 49 | - goos: linux 50 | goarch: amd64 51 | image_templates: 52 | - "philipssoftware/{{ .ProjectName }}:v{{ .Version }}" 53 | - "philipssoftware/{{ .ProjectName }}:{{ .FullCommit }}" 54 | - "ghcr.io/philips-labs/{{ .ProjectName }}:v{{ .Version }}" 55 | - "ghcr.io/philips-labs/{{ .ProjectName }}:{{ .FullCommit }}" 56 | build_flag_templates: 57 | - "--pull" 58 | - "--label=org.opencontainers.image.created={{.Date}}" 59 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 60 | - "--label=org.opencontainers.image.description={{.ProjectName}}" 61 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 62 | - "--label=org.opencontainers.image.version=v{{.Version}}" 63 | - "--label=org.opencontainers.image.licenses=MIT" 64 | - "--label=org.opencontainers.image.vendor=Koninklijke Philips N.V." 65 | extra_files: 66 | - "Makefile" 67 | - "go.mod" 68 | - "go.sum" 69 | - ".git" 70 | - ".gitignore" 71 | - "cmd" 72 | - "internal" 73 | - "pkg" 74 | 75 | signs: 76 | - id: checksums 77 | cmd: cosign 78 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 79 | output: true 80 | artifacts: checksum 81 | args: 82 | - sign-blob 83 | - --yes 84 | - --key 85 | - env://COSIGN_PRIVATE_KEY 86 | - '--output-certificate=${certificate}' 87 | - '--output-signature=${signature}' 88 | - '${artifact}' 89 | - id: binaries 90 | cmd: cosign 91 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 92 | output: true 93 | artifacts: binary 94 | args: 95 | - sign-blob 96 | - --yes 97 | - --key 98 | - env://COSIGN_PRIVATE_KEY 99 | - '--output-certificate=${certificate}' 100 | - '--output-signature=${signature}' 101 | - '${artifact}' 102 | - id: archives 103 | cmd: cosign 104 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 105 | output: true 106 | artifacts: archive 107 | args: 108 | - sign-blob 109 | - --yes 110 | - --key 111 | - env://COSIGN_PRIVATE_KEY 112 | - '--output-certificate=${certificate}' 113 | - '--output-signature=${signature}' 114 | - '${artifact}' 115 | - id: sboms 116 | cmd: cosign 117 | stdin: '{{ .Env.COSIGN_PASSWORD }}' 118 | output: true 119 | artifacts: sbom 120 | args: 121 | - sign-blob 122 | - --yes 123 | - --key 124 | - env://COSIGN_PRIVATE_KEY 125 | - '--output-certificate=${certificate}' 126 | - '--output-signature=${signature}' 127 | - '${artifact}' 128 | 129 | docker_signs: 130 | - id: selfsigned 131 | cmd: cosign 132 | artifacts: all 133 | output: true 134 | args: 135 | - sign 136 | - --yes 137 | - --key 138 | - env://COSIGN_PRIVATE_KEY 139 | - '${artifact}' 140 | - id: keyless 141 | cmd: cosign 142 | artifacts: all 143 | output: true 144 | args: 145 | - sign 146 | - --yes 147 | - '${artifact}' 148 | 149 | snapshot: 150 | name_template: "{{ .Version }}-next" 151 | 152 | changelog: 153 | sort: asc 154 | use: github 155 | filters: 156 | exclude: 157 | - '^docs:' 158 | - '^test:' 159 | - Merge pull request 160 | - Merge branch 161 | 162 | release: 163 | draft: false 164 | prerelease: auto 165 | extra_files: 166 | - glob: "./cosign.pub" 167 | footer: | 168 | **Full Changelog**: https://github.com/philips-labs/slsa-provenance-action/compare/{{ .PreviousTag }}...{{ .Tag }} 169 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 6 | "golang.go", 7 | "editorconfig.editorconfig", 8 | "esbenp.prettier-vscode", 9 | "timonwong.shellcheck", 10 | "DavidAnson.vscode-markdownlint" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "go.testEnvFile": "${workspaceFolder}/.env" 3 | } 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # they will be requested for review when someone opens a 4 | # pull request. 5 | * @philips-labs/secure-software-supply-chain 6 | 7 | # See CODEOWNERS syntax here: https://help.github.com/articles/about-codeowners/#codeowners-syntax 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing slsa-provenance-action 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | The following is a set of guidelines for contributing to SLSA-provenance-action, which is hosted at . These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. 6 | 7 | ## Styleguides 8 | 9 | ### Git Commit Messages 10 | 11 | - Use the present tense ("Add feature" not "Added feature") 12 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to...") 13 | - Limit the first line to 72 characters or less 14 | - Reference issues and pull requests liberally after the first line 15 | - Sign-off your commits with ``git commit -s -m "Normal Commit Message here"``, this will add ``Signed-off-by: Random J Developer `` at the end of the commit. 16 | 17 | ### Code 18 | 19 | This repository has a `.editorconfig`, please ensure to follow this `.editorconfig` styles to prevent unnecessary `diffs` on the codebase. For your convenience you can choose to install a `editorconfig` plugin in the IDEA of your choice. 20 | 21 | In summary: 22 | 23 | - Go files 24 | - indented with Tabs 25 | - Tabwidth of 4 26 | - The Makefile 27 | - indented with Tabs 28 | - Tabwidth of 4 29 | - Other files 30 | - indented with spaces 31 | - tabwidth of 2 32 | - Files end with a newline 33 | - Whitespace is trimmed from the end of a line 34 | 35 | ### Update README.md 36 | 37 | Update README.md when input parameters change. 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine as build 2 | WORKDIR /build 3 | 4 | COPY go.mod go.sum ./ 5 | RUN go mod download 6 | 7 | RUN apk add --no-cache make git 8 | COPY . . 9 | RUN make build 10 | 11 | FROM alpine:3.14.2 as certs 12 | RUN apk update && apk add ca-certificates 13 | 14 | FROM gcr.io/distroless/base 15 | COPY --from=certs /etc/ssl/certs /etc/ssl/certs 16 | COPY --from=build /build/bin/slsa-provenance /usr/local/bin/slsa-provenance 17 | # Code file to execute when the docker container starts up (`entrypoint.sh`) 18 | ENTRYPOINT ["slsa-provenance"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Koninklijke Philips N.V., https://www.philips.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_TAG ?= dirty-tag 2 | GIT_VERSION ?= $(shell git describe --tags --always --dirty) 3 | GIT_HASH ?= $(shell git rev-parse HEAD) 4 | DATE_FMT = +'%Y-%m-%dT%H:%M:%SZ' 5 | SOURCE_DATE_EPOCH ?= $(shell git log -1 --pretty=%ct) 6 | ifdef SOURCE_DATE_EPOCH 7 | BUILD_DATE ?= $(shell date -u -d "@$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u -r "$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u "$(DATE_FMT)") 8 | else 9 | BUILD_DATE ?= $(shell date "$(DATE_FMT)") 10 | endif 11 | GIT_TREESTATE = "clean" 12 | DIFF = $(shell git diff --quiet >/dev/null 2>&1; if [ $$? -eq 1 ]; then echo "1"; fi) 13 | ifeq ($(DIFF), 1) 14 | GIT_TREESTATE = "dirty" 15 | endif 16 | 17 | PKG=github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli 18 | LDFLAGS="-X $(PKG).GitVersion=$(GIT_VERSION) -X $(PKG).gitCommit=$(GIT_HASH) -X $(PKG).gitTreeState=$(GIT_TREESTATE) -X $(PKG).buildDate=$(BUILD_DATE)" 19 | 20 | GO_BUILD_FLAGS := -trimpath -ldflags $(LDFLAGS) 21 | COMMANDS := slsa-provenance 22 | 23 | HUB_REPO := philipssoftware/slsa-provenance 24 | GHCR_REPO := ghcr.io/philips-labs/slsa-provenance 25 | 26 | check_defined = \ 27 | $(strip $(foreach 1,$1, \ 28 | $(call __check_defined,$1,$(strip $(value 2))))) 29 | __check_defined = \ 30 | $(if $(value $1),, \ 31 | $(error Undefined $1$(if $2, ($2)))) 32 | 33 | UNAME_S := $(shell uname -s) 34 | 35 | ifeq ($(UNAME_S), Darwin) 36 | SED ?= gsed 37 | else 38 | SED ?= sed 39 | endif 40 | 41 | .PHONY: help 42 | help: 43 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 44 | 45 | FORCE: ; 46 | 47 | bin/%: cmd/% FORCE 48 | CGO_ENABLED=0 go build $(GO_BUILD_FLAGS) -o $@ ./$< 49 | 50 | .PHONY: download 51 | download: ## download dependencies via go mod 52 | go mod download 53 | 54 | $(GO_PATH)/bin/goimports: 55 | go install golang.org/x/tools/cmd/goimports@latest 56 | 57 | $(GO_PATH)/bin/golint: 58 | go install golang.org/x/lint/golint@latest 59 | 60 | .PHONY: lint 61 | lint: $(GO_PATH)/bin/goimports $(GO_PATH)/bin/golint ## runs linting 62 | @echo Linting using golint 63 | @golint -set_exit_status $(shell go list -f '{{ .Dir }}' ./...) 64 | @echo Linting imports 65 | @goimports -d -e -local github.com/philips-labs/slsa-provenance-action $(shell go list -f '{{ .Dir }}' ./...) 66 | 67 | .PHONY: test 68 | test: ## runs the tests 69 | go test -v -count=1 ./... 70 | 71 | .PHONY: test-race 72 | test-race: ## runs the tests 73 | go test -race -v -count=1 ./... 74 | 75 | coverage.out: 76 | go test -race -v -count=1 -covermode=atomic -coverprofile=coverage.out ./... || true 77 | 78 | .PHONY: coverage.out 79 | coverage-out: coverage.out ## Ouput code coverage to stdout 80 | go tool cover -func=$< 81 | 82 | .PHONY: coverage.out 83 | coverage-html: coverage.out ## Ouput code coverage as HTML 84 | go tool cover -html=$< 85 | 86 | .PHONY: build 87 | build: $(addprefix bin/,$(COMMANDS)) ## builds binaries 88 | 89 | .PHONY: image 90 | image: ## build the binary in a docker image 91 | docker build \ 92 | -t "$(HUB_REPO):$(GIT_TAG)" \ 93 | -t "$(HUB_REPO):$(GIT_HASH)" \ 94 | -t "$(GHCR_REPO):$(GIT_TAG)" \ 95 | -t "$(GHCR_REPO):$(GIT_HASH)" \ 96 | . 97 | 98 | $(GO_PATH)/bin/goreleaser: 99 | go install github.com/goreleaser/goreleaser@v1.2.5 100 | 101 | .PHONY: snapshot-release 102 | snapshot-release: $(GO_PATH)/bin/goreleaser ## creates a snapshot release using goreleaser 103 | LDFLAGS=$(LDFLAGS) goreleaser release --snapshot --clean 104 | 105 | .PHONY: release 106 | release: $(GO_PATH)/bin/goreleaser ## creates a release using goreleaser 107 | LDFLAGS=$(LDFLAGS) goreleaser release 108 | 109 | .PHONY: release-vars 110 | release-vars: ## print the release variables for goreleaser 111 | @echo export LDFLAGS=\"$(LDFLAGS)\" 112 | 113 | .PHONY: gh-release 114 | gh-release: ## Creates a new release by creating a new tag and pushing it 115 | @:$(call check_defined, OLD_VERSION) 116 | @:$(call check_defined, NEW_VERSION) 117 | @:$(call check_defined, DESCRIPTION) 118 | @git stash -u 119 | @echo Bumping $(OLD_VERSION) to $(NEW_VERSION)… 120 | @$(SED) -i 's/$(OLD_VERSION)/$(NEW_VERSION)/g' .github/workflows/*.yaml *.yaml *.md *.sh 121 | @git add . 122 | @git commit -s -m "Bump $(OLD_VERSION) to $(NEW_VERSION) for release" 123 | @git tag -sam "$(DESCRIPTION)" $(NEW_VERSION) 124 | @git push origin $(NEW_VERSION) 125 | @echo 126 | @echo ATTENTION: MANUAL ACTION REQUIRED!! -- Wait for the release workflow to finish 127 | @echo 128 | @echo Check status here https://github.com/philips-labs/slsa-provenance-action/actions/workflows/ci.yaml 129 | @echo 130 | @echo Once finished, push the main branch using 'git push' 131 | @echo 132 | @echo Visit https://github.com/philips-labs/slsa-provenance-action/releases 133 | @echo Edit the release and save it to publish to GitHub Marketplace. 134 | @echo 135 | @git stash pop 136 | 137 | .PHONY: container-digest 138 | container-digest: ## retrieves the container digest from the given tag 139 | @:$(call check_defined, GITHUB_REF) 140 | @docker inspect $(GHCR_REPO):$(subst refs/tags/,,$(GITHUB_REF)) --format '{{ index .RepoDigests 0 }}' | cut -d '@' -f 2 141 | 142 | .PHONY: container-tags 143 | container-tags: ## retrieves the container tags applied to the image with a given digest 144 | @:$(call check_defined, CONTAINER_DIGEST) 145 | @docker inspect ghcr.io/philips-labs/slsa-provenance@$(CONTAINER_DIGEST) --format '{{ join .RepoTags "\n" }}' | sed 's/.*://' | awk '!_[$$0]++' 146 | 147 | .PHONY: container-repos 148 | container-repos: ## retrieves the container repos applied to the image with a given digest 149 | @:$(call check_defined, CONTAINER_DIGEST) 150 | @docker inspect ghcr.io/philips-labs/slsa-provenance@$(CONTAINER_DIGEST) --format '{{ join .RepoTags "\n" }}' | sed 's/:.*//' | awk '!_[$$0]++' 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | [![Contributors][contributors-shield]][contributors-url] 6 | [![Forks][forks-shield]][forks-url] 7 | [![Stargazers][stars-shield]][stars-url] 8 | [![Issues][issues-shield]][issues-url] 9 | [![MIT License][license-shield]][license-url] 10 | 11 |
12 | 13 |
14 |
15 | 16 | Logo 17 | 18 | 19 |

SLSA Provenance GitHub Action

20 | 21 |

22 | Github Action to generate [SLSA provenance][slsa-provenance] 23 |
24 | Report Bug 25 | · 26 | Request Feature 27 |

28 |
29 | 30 | 31 | ## About This Project 32 | 33 | This GitHub action implements the level 1 requirements of the [SLSA framework](https://slsa.dev/). By using this GitHub Action it is possible to easily generate the provenance file for different artifact types. 34 | Different artifact types include, but not limited to: 35 | 36 | - Files 37 | - Push event (Docker Hub, trigger different workflow, etc) 38 | 39 | While there are no integrity guarantees on the produced provenance at L1, 40 | publishing artifact provenance in a common format opens up opportunities for 41 | automated analysis and auditing. Additionally, moving build definitions into 42 | source control and onto well-supported, secure build systems represents a marked 43 | improvement from the ecosystem's current state. 44 | 45 | This is not an official GitHub Action set up and maintained by the SLSA team. This GitHub Action is built for research purposes by Philips Research. It is heavily inspired by the original [Provenance Action example](https://github.com/slsa-framework/github-actions-demo) built by SLSA. 46 | 47 |

(back to top)

48 | 49 | ## Background 50 | 51 | [SLSA](https://github.com/slsa-framework/slsa) is a framework intended to codify 52 | and promote secure software supply-chain practices. SLSA helps trace software 53 | artifacts (e.g. binaries) back to the build and source control systems that 54 | produced them using in-toto's 55 | [Attestation](https://github.com/in-toto/attestation/blob/main/spec/README.md) 56 | metadata format. 57 | 58 | ### Built With 59 | 60 | - [SLSA Framework](https://github.com/slsa-framework/slsa/) 61 | - [Golang](https://golang.org/) 62 | - [GitHub Actions](https://github.com/features/actions) 63 | 64 |

(back to top)

65 | 66 | ## Getting Started 67 | 68 | Get started quickly by reading the information below. 69 | 70 | ### Prerequisites 71 | 72 | Ensure you have the following installed: 73 | 74 | - Golang 75 | - Docker 76 | 77 | #### Recommendations 78 | 79 | The following IDE is recommended when working on this codebase: 80 | 81 | - [VSCode](https://code.visualstudio.com/) 82 | 83 | ### Local Installation 84 | 85 | 1. Clone the repo. 86 | 87 | ```sh 88 | git clone git@github.com:philips-labs/slsa-provenance-action.git 89 | ``` 90 | 91 | 1. Build the binary. 92 | 93 | ```sh 94 | make build 95 | ``` 96 | 97 | 1. Execute the binary. 98 | 99 | ```sh 100 | ./bin/slsa-provenance help 101 | ``` 102 | 103 | ### Docker Image 104 | 105 | Our Docker images are available at both GitHub Container Registry (ghcr) and Docker Hub. 106 | 107 | **Docker Hub** 108 | See all available images [here.](https://hub.docker.com/r/philipssoftware/slsa-provenance/tags) 109 | Run the Docker image by doing: 110 | 111 | ```sh 112 | docker run philipssoftware/slsa-provenance:v0.7.2 113 | ``` 114 | 115 | **GitHub Container Registry** 116 | See all available images [here.](https://github.com/philips-labs/slsa-provenance-action/pkgs/container/slsa-provenance) 117 | Run the Docker image by doing: 118 | 119 | ```sh 120 | docker run ghcr.io/philips-labs/slsa-provenance:v0.7.2 121 | ``` 122 | 123 | The Docker image includes the working binary that can be executed by using the ``slsa-provenance`` command. 124 | 125 |

(back to top)

126 | 127 | ## Usage 128 | 129 | The easiest way to use this action is to add the following into your workflow file. Additional configuration might be necessary to fit your usecase. 130 | 131 |
132 | GitHub Releases 133 | 134 | Add the following part in your workflow file: 135 | 136 | See [ci workflow](.github/workflows/ci.yaml) for a full example using GitHub releases. 137 | 138 | > :warning: **NOTE:** this job depends on a release job that publishes the release assets to a GitHub Release. 139 | 140 | ```yaml 141 | provenance: 142 | name: provenance 143 | needs: [release] 144 | runs-on: ubuntu-20.04 145 | permissions: 146 | # required to update the release. 147 | contents: write 148 | 149 | steps: 150 | - name: Generate provenance for Release 151 | uses: philips-labs/slsa-provenance-action@v0.7.2 152 | with: 153 | command: generate 154 | subcommand: github-release 155 | arguments: --artifact-path release-assets --output-path 'provenance.json' --tag-name ${{ github.ref_name }} 156 | env: 157 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 158 | ``` 159 | 160 |
161 | 162 |
163 | GitHub artifacts 164 | 165 | Add the following part in your workflow file: 166 | 167 | See [example workflow](.github/workflows/example-publish.yaml) for a full example using GitHub artifacts. 168 | 169 | ```yaml 170 | generate-provenance: 171 | name: Generate build provenance 172 | runs-on: ubuntu-latest 173 | steps: 174 | - name: Download build artifact 175 | uses: actions/download-artifact@v2 176 | with: 177 | path: artifact/ 178 | 179 | - name: Download extra materials for provenance 180 | uses: actions/download-artifact@v2 181 | with: 182 | name: extra-materials 183 | path: extra-materials/ 184 | 185 | - name: Generate provenance 186 | uses: philips-labs/slsa-provenance-action@v0.7.2 187 | with: 188 | command: generate 189 | subcommand: files 190 | arguments: --artifact-path artifact/ --extra-materials extra-materials/file1.json,extra-materials/some-more.json 191 | 192 | - name: Upload provenance 193 | uses: actions/upload-artifact@v2 194 | with: 195 | path: provenance.json 196 | ``` 197 | 198 |
199 | 200 | ### Description 201 | 202 | An action to generate SLSA build provenance for an artifact 203 | 204 | ### Inputs 205 | 206 | | parameter | description | required | default | 207 | | - | - | - | - | 208 | | command | The slsa-provenance command to run | `false` | generate | 209 | | subcommand | The subcommand to use when generating provenance | `false` | files | 210 | | github_context | internal (do not set): the "github" context object in json | `true` | ${{ toJSON(github) }} | 211 | | runner_context | internal (do not set): the "runner" context object in json | `true` | ${{ toJSON(runner) }} | 212 | | arguments | the arguments for the given `command` and `subcommand` | `true` | | 213 | 214 |

(back to top)

215 | 216 | ## Contributing 217 | 218 | If you have a suggestion that would make this project better, please fork the repository and create a pull request. You can also simply open an issue with the tag "enhancement". 219 | 220 | 1. Fork the Project 221 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 222 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 223 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 224 | 5. Open a Pull Request 225 | 226 | Please refer to the [Contributing Guidelines](/CONTRIBUTING.md) for all the guidelines. 227 | 228 |

(back to top)

229 | 230 | ## License 231 | 232 | Distributed under the MIT License. See [LICENSE](/LICENSE.md) for more information. 233 | 234 |

(back to top)

235 | 236 | ## Contact 237 | 238 | *Powered by Philips SWAT Eindhoven* 239 | 240 | - [Brend Smits](https://github.com/Brend-Smits) - brend.smits@philips.com 241 | - [Marco Franssen](https://github.com/marcofranssen) 242 | - [Jeroen Knoops](https://github.com/JeroenKnoops) 243 | - [Annie Jovitha](https://github.com/AnnieJovitha) 244 | 245 |

(back to top)

246 | 247 | ## Acknowledgments 248 | 249 | This project is inspired by: 250 | 251 | - [SLSA Framework](https://slsa.dev/) 252 | - [SLSA GitHub Action Example](https://github.com/slsa-framework/github-actions-demo) 253 | 254 |

(back to top)

255 | 256 | [contributors-shield]: https://img.shields.io/github/contributors/philips-labs/slsa-provenance-action.svg?style=for-the-badge 257 | [contributors-url]: https://github.com/philips-labs/slsa-provenance-action/graphs/contributors 258 | [forks-shield]: https://img.shields.io/github/forks/philips-labs/slsa-provenance-action.svg?style=for-the-badge 259 | [forks-url]: https://github.com/philips-labs/slsa-provenance-action/network/members 260 | [stars-shield]: https://img.shields.io/github/stars/philips-labs/slsa-provenance-action.svg?style=for-the-badge 261 | [stars-url]: https://github.com/philips-labs/slsa-provenance-action/stargazers 262 | [issues-shield]: https://img.shields.io/github/issues/philips-labs/slsa-provenance-action.svg?style=for-the-badge 263 | [issues-url]: https://github.com/philips-labs/slsa-provenance-action/issues 264 | [license-shield]: https://img.shields.io/github/license/philips-labs/slsa-provenance-action.svg?style=for-the-badge 265 | [license-url]: https://github.com/philips-labs/slsa-provenance-action/blob/main/LICENSE.md 266 | [slsa-provenance]: https://slsa.dev/provenance/v0.2 267 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release procedures 2 | 3 | ## Automated release procedure 4 | 5 | To make a new release you can make use of the following `make` task. 6 | 7 | ```bash 8 | make gh-release NEW_VERSION=v0.7.2 OLD_VERSION=v0.5.0 DESCRIPTION="A test release to see how it works" 9 | ``` 10 | 11 | `NEW_VERSION` the version that you want to release. 12 | `OLD_VERSION` the current version you wish to replace in the markdown and yaml files. 13 | `DESCRIPTION` the annotation used when tagging the release. 14 | 15 | Visit . 16 | Edit the release and save it to publish to GitHub Marketplace. 17 | 18 | > :warning: **NOTE:** when you need to test some changes in `.goreleaser.yml`, also apply the changes to `.goreleaser.draft.yml`. Then make sure your new `tag` ends with `-draft` (e.g.: `v0.7.2-draft`) to make a draft release to not notify our consumers when testing updates to the release process. 19 | 20 | ### ⚠ Important alert for MacOS users ⚠ 21 | 22 | On MacOS `sed` has different behaviour and therefore doesn't work out of the box. 23 | A workaround to make it work is to install gnu-sed and alias it in your bashrc/zshrc: 24 | 25 | ```bash 26 | brew install gnu-sed 27 | echo "alias sed=gsed" >> ~/.zshrc 28 | ``` 29 | 30 | ## Manual release procedure 31 | 32 | 1. Upgrade version number in all repository files, find & replace previous version number with new version number. 33 | 1. Commit the changed files. 34 | 1. Tag the new commit using `git tag -sam "What is this release about?" v0.1.0`. 35 | 1. Push the tag to remote using `git push origin v0.1.0` 36 | 1. Wait for the release workflow to finish, then push the main branch using `git push` 37 | 1. Visit . 38 | 1. Edit the release and save it to publish to GitHub Marketplace. 39 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: 'SLSA Build Provenance Action' 2 | description: 'An action to generate SLSA build provenance for an artifact' 3 | branding: 4 | icon: lock 5 | color: purple 6 | inputs: 7 | command: 8 | description: 'The command to use (available options: generate)' 9 | required: false 10 | default: 'generate' 11 | subcommand: 12 | description: 'The subcommand to use when generating provenance' 13 | required: false 14 | default: 'files' 15 | github_context: 16 | description: 'internal (do not set): the "github" context object in json' 17 | required: true 18 | default: ${{ toJSON(github) }} 19 | runner_context: 20 | description: 'internal (do not set): the "runner" context object in json' 21 | required: true 22 | default: ${{ toJSON(runner) }} 23 | arguments: 24 | description: 'commandline options for the given subcommand' 25 | required: true 26 | install-dir: 27 | description: 'Where to install the slsa-provenance binary' 28 | required: false 29 | default: '$HOME/.slsa-provenance' 30 | runs: 31 | using: 'composite' 32 | steps: 33 | - name: install binary 34 | shell: bash 35 | run: $GITHUB_ACTION_PATH/install-slsa-provenance.sh 36 | env: 37 | INSTALL_PATH: ${{ inputs.install-dir }} 38 | 39 | - name: compose arguments 40 | id: compose-args 41 | shell: bash 42 | run: | 43 | encoded_github="$(echo ${GITHUB_CONTEXT} | base64 -w 0)" 44 | encoded_runner="$(echo ${RUNNER_CONTEXT} | base64 -w 0)" 45 | 46 | args=(${{ inputs.command }}) 47 | args+=(${{ inputs.subcommand }}) 48 | args+=(--github-context) 49 | args+=("${encoded_github}") 50 | args+=(--runner-context) 51 | args+=("${encoded_runner}") 52 | args+=(${{ inputs.arguments }}) 53 | 54 | echo "provenance_args=${args[@]}" >> $GITHUB_OUTPUT 55 | env: 56 | GITHUB_CONTEXT: ${{ inputs.github_context }} 57 | RUNNER_CONTEXT: ${{ inputs.runner_context }} 58 | 59 | - name: Generate provenance 60 | shell: bash 61 | run: $INSTALL_PATH/slsa-provenance ${{ steps.compose-args.outputs.provenance_args }} 62 | env: 63 | INSTALL_PATH: ${{ inputs.install-dir }} 64 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/commands.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli/options" 9 | ) 10 | 11 | const ( 12 | cliName = "slsa-provenance" 13 | ) 14 | 15 | var ( 16 | ro = &options.RootOptions{} 17 | ) 18 | 19 | // RequiredFlagError creates a required flag error for the given flag name 20 | func RequiredFlagError(flagName string) error { 21 | return fmt.Errorf("no value found for required flag: %s", flagName) 22 | } 23 | 24 | // New creates a new instance of the slsa-provenance commandline interface 25 | func New() *cobra.Command { 26 | cmd := &cobra.Command{ 27 | Use: cliName, 28 | DisableAutoGenTag: true, 29 | SilenceUsage: true, 30 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 31 | return nil 32 | }, 33 | } 34 | 35 | ro.AddFlags(cmd) 36 | 37 | cmd.AddCommand(Version()) 38 | cmd.AddCommand(Generate()) 39 | 40 | return cmd 41 | } 42 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/commands_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 9 | ) 10 | 11 | func TestCli(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | cli := cli.New() 15 | assert.Len(cli.Commands(), 2) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/common_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | const ( 10 | githubContext = `{ 11 | "token": "***", 12 | "job": "generate-provenance", 13 | "ref": "refs/heads/temp/dump-context", 14 | "sha": "c4f679f131dfb7f810fd411ac9475549d1c393df", 15 | "repository": "philips-labs/slsa-provenance-action", 16 | "repository_owner": "philips-labs", 17 | "repositoryUrl": "git://github.com/philips-labs/slsa-provenance-action.git", 18 | "run_id": "1332651620", 19 | "run_number": "91", 20 | "retention_days": "90", 21 | "run_attempt": "1", 22 | "actor": "John Doe", 23 | "workflow": "Integration test file provenance", 24 | "head_ref": "", 25 | "base_ref": "", 26 | "event_name": "push", 27 | "event": { 28 | "after": "c4f679f131dfb7f810fd411ac9475549d1c393df", 29 | "base_ref": null, 30 | "before": "715b4daa0f750f420635ee488ef37a2433608438", 31 | "commits": [ 32 | { 33 | "author": { 34 | "email": "john.doe@philips.com", 35 | "name": "John Doe", 36 | "username": "john-doe" 37 | }, 38 | "committer": { 39 | "email": "noreply@github.com", 40 | "name": "GitHub", 41 | "username": "web-flow" 42 | }, 43 | "distinct": true, 44 | "id": "c4f679f131dfb7f810fd411ac9475549d1c393df", 45 | "message": "Update example-local.yml", 46 | "timestamp": "2021-10-12T12:18:06+02:00", 47 | "tree_id": "a4dda43e9a101031dc6cd14def2d6e34ef9b4d92", 48 | "url": "https://github.com/philips-labs/slsa-provenance-action/commit/c4f679f131dfb7f810fd411ac9475549d1c393df" 49 | } 50 | ], 51 | "compare": "https://github.com/philips-labs/slsa-provenance-action/compare/715b4daa0f75...c4f679f131df", 52 | "created": false, 53 | "deleted": false, 54 | "enterprise": { 55 | "avatar_url": "https://avatars.githubusercontent.com/b/1244?v=4", 56 | "created_at": "2019-11-07T05:37:39Z", 57 | "description": "", 58 | "html_url": "https://github.com/enterprises/royal-philips", 59 | "id": 1244, 60 | "name": "Royal Philips", 61 | "node_id": "MDEwOkVudGVycHJpc2UxMjQ0", 62 | "slug": "royal-philips", 63 | "updated_at": "2020-12-16T12:30:18Z", 64 | "website_url": "https://www.philips.com" 65 | }, 66 | "forced": false, 67 | "head_commit": { 68 | "author": { 69 | "email": "john.doe@philips.com", 70 | "name": "John Doe", 71 | "username": "john-doe" 72 | }, 73 | "committer": { 74 | "email": "noreply@github.com", 75 | "name": "GitHub", 76 | "username": "web-flow" 77 | }, 78 | "distinct": true, 79 | "id": "c4f679f131dfb7f810fd411ac9475549d1c393df", 80 | "message": "Update example-local.yml", 81 | "timestamp": "2021-10-12T12:18:06+02:00", 82 | "tree_id": "a4dda43e9a101031dc6cd14def2d6e34ef9b4d92", 83 | "url": "https://github.com/philips-labs/slsa-provenance-action/commit/c4f679f131dfb7f810fd411ac9475549d1c393df" 84 | }, 85 | "organization": { 86 | "avatar_url": "https://avatars.githubusercontent.com/u/58286953?v=4", 87 | "description": "Philips Labs - Projects in development", 88 | "events_url": "https://api.github.com/orgs/philips-labs/events", 89 | "hooks_url": "https://api.github.com/orgs/philips-labs/hooks", 90 | "id": 58286953, 91 | "issues_url": "https://api.github.com/orgs/philips-labs/issues", 92 | "login": "philips-labs", 93 | "members_url": "https://api.github.com/orgs/philips-labs/members{/member}", 94 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU4Mjg2OTUz", 95 | "public_members_url": "https://api.github.com/orgs/philips-labs/public_members{/member}", 96 | "repos_url": "https://api.github.com/orgs/philips-labs/repos", 97 | "url": "https://api.github.com/orgs/philips-labs" 98 | }, 99 | "pusher": { 100 | "email": "john.doe@philips.com", 101 | "name": "john-doe" 102 | }, 103 | "ref": "refs/heads/temp/dump-context", 104 | "repository": { 105 | "allow_forking": true, 106 | "archive_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/{archive_format}{/ref}", 107 | "archived": false, 108 | "assignees_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/assignees{/user}", 109 | "blobs_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/blobs{/sha}", 110 | "branches_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/branches{/branch}", 111 | "clone_url": "https://github.com/philips-labs/slsa-provenance-action.git", 112 | "collaborators_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/collaborators{/collaborator}", 113 | "comments_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/comments{/number}", 114 | "commits_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/commits{/sha}", 115 | "compare_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/compare/{base}...{head}", 116 | "contents_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/contents/{+path}", 117 | "contributors_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/contributors", 118 | "created_at": 1631537642, 119 | "default_branch": "main", 120 | "deployments_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/deployments", 121 | "description": "Github Action implementation of SLSA Provenance Generation of level 1", 122 | "disabled": false, 123 | "downloads_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/downloads", 124 | "events_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/events", 125 | "fork": false, 126 | "forks": 2, 127 | "forks_count": 2, 128 | "forks_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/forks", 129 | "full_name": "philips-labs/slsa-provenance-action", 130 | "git_commits_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/commits{/sha}", 131 | "git_refs_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/refs{/sha}", 132 | "git_tags_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/tags{/sha}", 133 | "git_url": "git://github.com/philips-labs/slsa-provenance-action.git", 134 | "has_downloads": true, 135 | "has_issues": true, 136 | "has_pages": false, 137 | "has_projects": true, 138 | "has_wiki": true, 139 | "homepage": "", 140 | "hooks_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/hooks", 141 | "html_url": "https://github.com/philips-labs/slsa-provenance-action", 142 | "id": 405972862, 143 | "is_template": false, 144 | "issue_comment_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues/comments{/number}", 145 | "issue_events_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues/events{/number}", 146 | "issues_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues{/number}", 147 | "keys_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/keys{/key_id}", 148 | "labels_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/labels{/name}", 149 | "language": "Go", 150 | "languages_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/languages", 151 | "license": { 152 | "key": "mit", 153 | "name": "MIT License", 154 | "node_id": "MDc6TGljZW5zZTEz", 155 | "spdx_id": "MIT", 156 | "url": "https://api.github.com/licenses/mit" 157 | }, 158 | "master_branch": "main", 159 | "merges_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/merges", 160 | "milestones_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/milestones{/number}", 161 | "mirror_url": null, 162 | "name": "slsa-provenance-action", 163 | "node_id": "MDEwOlJlcG9zaXRvcnk0MDU5NzI4NjI=", 164 | "notifications_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/notifications{?since,all,participating}", 165 | "open_issues": 11, 166 | "open_issues_count": 11, 167 | "organization": "philips-labs", 168 | "owner": { 169 | "avatar_url": "https://avatars.githubusercontent.com/u/58286953?v=4", 170 | "email": "software-program-cto@philips.com", 171 | "events_url": "https://api.github.com/users/philips-labs/events{/privacy}", 172 | "followers_url": "https://api.github.com/users/philips-labs/followers", 173 | "following_url": "https://api.github.com/users/philips-labs/following{/other_user}", 174 | "gists_url": "https://api.github.com/users/philips-labs/gists{/gist_id}", 175 | "gravatar_id": "", 176 | "html_url": "https://github.com/philips-labs", 177 | "id": 58286953, 178 | "login": "philips-labs", 179 | "name": "philips-labs", 180 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU4Mjg2OTUz", 181 | "organizations_url": "https://api.github.com/users/philips-labs/orgs", 182 | "received_events_url": "https://api.github.com/users/philips-labs/received_events", 183 | "repos_url": "https://api.github.com/users/philips-labs/repos", 184 | "site_admin": false, 185 | "starred_url": "https://api.github.com/users/philips-labs/starred{/owner}{/repo}", 186 | "subscriptions_url": "https://api.github.com/users/philips-labs/subscriptions", 187 | "type": "Organization", 188 | "url": "https://api.github.com/users/philips-labs" 189 | }, 190 | "private": false, 191 | "pulls_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/pulls{/number}", 192 | "pushed_at": 1634033886, 193 | "releases_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/releases{/id}", 194 | "size": 76, 195 | "ssh_url": "git@github.com:philips-labs/slsa-provenance-action.git", 196 | "stargazers": 1, 197 | "stargazers_count": 1, 198 | "stargazers_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/stargazers", 199 | "statuses_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/statuses/{sha}", 200 | "subscribers_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/subscribers", 201 | "subscription_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/subscription", 202 | "svn_url": "https://github.com/philips-labs/slsa-provenance-action", 203 | "tags_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/tags", 204 | "teams_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/teams", 205 | "topics": [ 206 | "hacktoberfest" 207 | ], 208 | "trees_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/trees{/sha}", 209 | "updated_at": "2021-10-11T14:10:37Z", 210 | "url": "https://github.com/philips-labs/slsa-provenance-action", 211 | "visibility": "public", 212 | "watchers": 1, 213 | "watchers_count": 1 214 | }, 215 | "sender": { 216 | "avatar_url": "https://avatars.githubusercontent.com/u/15904543?v=4", 217 | "events_url": "https://api.github.com/users/john-doe/events{/privacy}", 218 | "followers_url": "https://api.github.com/users/john-doe/followers", 219 | "following_url": "https://api.github.com/users/john-doe/following{/other_user}", 220 | "gists_url": "https://api.github.com/users/john-doe/gists{/gist_id}", 221 | "gravatar_id": "", 222 | "html_url": "https://github.com/john-doe", 223 | "id": 15904543, 224 | "login": "john-doe", 225 | "node_id": "MDQ6VXNlcjE1OTA0NTQz", 226 | "organizations_url": "https://api.github.com/users/john-doe/orgs", 227 | "received_events_url": "https://api.github.com/users/john-doe/received_events", 228 | "repos_url": "https://api.github.com/users/john-doe/repos", 229 | "site_admin": false, 230 | "starred_url": "https://api.github.com/users/john-doe/starred{/owner}{/repo}", 231 | "subscriptions_url": "https://api.github.com/users/john-doe/subscriptions", 232 | "type": "User", 233 | "url": "https://api.github.com/users/john-doe" 234 | } 235 | }, 236 | "server_url": "https://github.com", 237 | "api_url": "https://api.github.com", 238 | "graphql_url": "https://api.github.com/graphql", 239 | "ref_protected": false, 240 | "ref_type": "branch", 241 | "workspace": "/home/runner/work/slsa-provenance-action/slsa-provenance-action", 242 | "action": "__self", 243 | "event_path": "/home/runner/work/_temp/_github_workflow/event.json", 244 | "action_repository": "", 245 | "action_ref": "", 246 | "path": "/home/runner/work/_temp/_runner_file_commands/add_path_779d6e30-d262-4e4a-bcdf-bf652ff08e12", 247 | "env": "/home/runner/work/_temp/_runner_file_commands/set_env_779d6e30-d262-4e4a-bcdf-bf652ff08e12" 248 | }` 249 | runnerContext = `{ 250 | "os": "Linux", 251 | "name": "Hosted Agent", 252 | "tool_cache": "/opt/hostedtoolcache", 253 | "temp": "/home/runner/work/_temp", 254 | "workspace": "/home/runner/work/slsa-provenance-action" 255 | }` 256 | ) 257 | 258 | func executeCommand(cmd *cobra.Command, args ...string) (output string, err error) { 259 | _, output, err = executeCommandC(cmd, args...) 260 | return output, err 261 | } 262 | 263 | func executeCommandC(cmd *cobra.Command, args ...string) (c *cobra.Command, output string, err error) { 264 | buf := new(bytes.Buffer) 265 | cmd.SetOutput(buf) 266 | cmd.SetArgs(args) 267 | 268 | c, err = cmd.ExecuteC() 269 | 270 | return c, buf.String(), err 271 | } 272 | 273 | func stringPointer(s string) *string { 274 | return &s 275 | } 276 | 277 | func boolPointer(b bool) *bool { 278 | return &b 279 | } 280 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/container.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli/options" 9 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 10 | "github.com/philips-labs/slsa-provenance-action/pkg/oci" 11 | ) 12 | 13 | // OCI creates an instance of *cobra.Command to generate oci provenance 14 | func OCI() *cobra.Command { 15 | o := &options.OCIOptions{} 16 | 17 | cmd := &cobra.Command{ 18 | Use: "container", 19 | Short: "Generate provenance on container assets", 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | outputPath, err := o.GetOutputPath() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | gh, err := o.GetGitHubContext() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | runner, err := o.GetRunnerContext() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | materials, err := o.GetExtraMaterials() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | repo, err := o.GetRepository() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | digest, err := o.GetDigest() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | tags, err := o.GetTags() 52 | if err != nil { 53 | return err 54 | } 55 | 56 | opts := o.GetRegistryClientOpts(cmd.Context()) 57 | subjecter := oci.NewContainerSubjecter(repo, digest, tags, opts...) 58 | 59 | env := &github.Environment{ 60 | Context: gh, 61 | Runner: runner, 62 | } 63 | stmt, err := env.GenerateProvenanceStatement(cmd.Context(), subjecter) 64 | if err != nil { 65 | return fmt.Errorf("failed to generate provenance: %w", err) 66 | } 67 | 68 | stmt.Predicate.Materials = append(stmt.Predicate.Materials, materials...) 69 | 70 | fmt.Fprintf(cmd.OutOrStdout(), "Saving provenance to %s\n", outputPath) 71 | 72 | return env.PersistProvenanceStatement(cmd.Context(), stmt, outputPath) 73 | }, 74 | } 75 | 76 | o.AddFlags(cmd) 77 | 78 | return cmd 79 | } 80 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/container_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "os" 6 | "path" 7 | "runtime" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 13 | ) 14 | 15 | func TestGenerateContainerCliOptions(t *testing.T) { 16 | _, filename, _, _ := runtime.Caller(0) 17 | provenanceFile := path.Join(path.Dir(filename), "provenance.json") 18 | 19 | base64GitHubContext := base64.StdEncoding.EncodeToString([]byte(githubContext)) 20 | base64RunnerContext := base64.StdEncoding.EncodeToString([]byte(runnerContext)) 21 | 22 | testCases := []struct { 23 | name string 24 | err error 25 | arguments []string 26 | }{ 27 | { 28 | name: "without commandline flags", 29 | err: cli.RequiredFlagError("github-context"), 30 | arguments: make([]string, 0), 31 | }, 32 | { 33 | name: "only github-context given", 34 | err: cli.RequiredFlagError("runner-context"), 35 | arguments: []string{ 36 | "--github-context", 37 | base64GitHubContext, 38 | }, 39 | }, 40 | { 41 | name: "only context flags given", 42 | err: cli.RequiredFlagError("repository"), 43 | arguments: []string{ 44 | "--github-context", 45 | base64GitHubContext, 46 | "--runner-context", 47 | base64RunnerContext, 48 | }, 49 | }, 50 | { 51 | name: "contexts and tags given", 52 | err: cli.RequiredFlagError("repository"), 53 | arguments: []string{ 54 | "--github-context", 55 | base64GitHubContext, 56 | "--runner-context", 57 | base64RunnerContext, 58 | "--tags", 59 | "v0.4.0,33ba3da2213c83ce02df0f2f6ba925ec79037f9d", 60 | }, 61 | }, 62 | { 63 | name: "contexts, repo and tags given", 64 | err: cli.RequiredFlagError("digest"), 65 | arguments: []string{ 66 | "--github-context", 67 | base64GitHubContext, 68 | "--runner-context", 69 | base64RunnerContext, 70 | "--repository", 71 | "ghcr.io/philips-labs/slsa-provenance", 72 | "--tags", 73 | "v0.4.0,33ba3da2213c83ce02df0f2f6ba925ec79037f9d", 74 | }, 75 | }, 76 | { 77 | name: "all flags given", 78 | err: nil, 79 | arguments: []string{ 80 | "--github-context", 81 | base64GitHubContext, 82 | "--runner-context", 83 | base64RunnerContext, 84 | "--repository", 85 | "ghcr.io/philips-labs/slsa-provenance", 86 | "--tags", 87 | "v0.4.0,33ba3da2213c83ce02df0f2f6ba925ec79037f9d", 88 | "--digest", 89 | "sha256:194b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a3", 90 | }, 91 | }, 92 | } 93 | 94 | for _, tc := range testCases { 95 | t.Run(tc.name, func(tt *testing.T) { 96 | assert := assert.New(tt) 97 | 98 | output, err := executeCommand(cli.OCI(), tc.arguments...) 99 | defer func() { 100 | _ = os.Remove(provenanceFile) 101 | }() 102 | 103 | if tc.err != nil { 104 | assert.EqualError(err, tc.err.Error()) 105 | } else { 106 | assert.NoError(err) 107 | assert.Contains(output, "Saving provenance to") 108 | if assert.FileExists(provenanceFile) { 109 | content, err := os.ReadFile(provenanceFile) 110 | assert.NoError(err) 111 | assert.Greater(len(content), 1) 112 | } 113 | } 114 | }) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/files.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli/options" 9 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 10 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 11 | ) 12 | 13 | // Files creates an instance of *cobra.Command to manage file provenance 14 | func Files() *cobra.Command { 15 | o := &options.FilesOptions{} 16 | 17 | cmd := &cobra.Command{ 18 | Use: "files", 19 | Short: "Generate provenance on file assets", 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | artifactPath, err := o.GetArtifactPath() 22 | if err != nil { 23 | return err 24 | } 25 | outputPath, err := o.GetOutputPath() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | gh, err := o.GetGitHubContext() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | runner, err := o.GetRunnerContext() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | materials, err := o.GetExtraMaterials() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | env := &github.Environment{ 46 | Context: gh, 47 | Runner: runner, 48 | } 49 | 50 | subjecter := intoto.NewFilePathSubjecter(artifactPath) 51 | stmt, err := env.GenerateProvenanceStatement(cmd.Context(), subjecter, materials...) 52 | if err != nil { 53 | return fmt.Errorf("failed to generate provenance: %w", err) 54 | } 55 | 56 | fmt.Fprintf(cmd.OutOrStdout(), "Saving provenance to %s\n", outputPath) 57 | 58 | return env.PersistProvenanceStatement(cmd.Context(), stmt, outputPath) 59 | }, 60 | } 61 | 62 | o.AddFlags(cmd) 63 | 64 | return cmd 65 | } 66 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/files_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "os" 7 | "path" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | 13 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 14 | ) 15 | 16 | const ( 17 | unknownFile = "non-existing-folder/unknown-file" 18 | ) 19 | 20 | func TestGenerateFilesCliOptions(t *testing.T) { 21 | _, filename, _, _ := runtime.Caller(0) 22 | rootDir := path.Join(path.Dir(filename), "../../..") 23 | provenanceFile := path.Join(rootDir, "bin/unittest-provenance.json") 24 | 25 | base64GitHubContext := base64.StdEncoding.EncodeToString([]byte(githubContext)) 26 | base64RunnerContext := base64.StdEncoding.EncodeToString([]byte(runnerContext)) 27 | 28 | testCases := []struct { 29 | name string 30 | err error 31 | arguments []string 32 | }{ 33 | { 34 | name: "without commandline flags", 35 | err: cli.RequiredFlagError("artifact-path"), 36 | arguments: make([]string, 0), 37 | }, 38 | { 39 | name: "only providing --artifact-path", 40 | err: cli.RequiredFlagError("github-context"), 41 | arguments: []string{ 42 | "--artifact-path", 43 | path.Join(rootDir, "bin/slsa-provenance"), 44 | }, 45 | }, 46 | { 47 | name: "without -runner-context", 48 | err: cli.RequiredFlagError("runner-context"), 49 | arguments: []string{ 50 | "--artifact-path", 51 | path.Join(rootDir, "bin/slsa-provenance"), 52 | "--github-context", 53 | base64GitHubContext, 54 | "--output-path", 55 | provenanceFile, 56 | }, 57 | }, 58 | { 59 | name: "invalid --artifact-path", 60 | err: fmt.Errorf("failed to generate provenance: lstat non-existing-folder/unknown-file: no such file or directory"), 61 | arguments: []string{ 62 | "--artifact-path", 63 | unknownFile, 64 | "--github-context", 65 | base64GitHubContext, 66 | "--runner-context", 67 | base64RunnerContext, 68 | }, 69 | }, 70 | { 71 | name: "all arguments explicit", 72 | err: nil, 73 | arguments: []string{ 74 | "--artifact-path", 75 | path.Join(rootDir, "bin/slsa-provenance"), 76 | "--github-context", 77 | base64GitHubContext, 78 | "--output-path", 79 | provenanceFile, 80 | "--runner-context", 81 | base64RunnerContext, 82 | }, 83 | }, 84 | { 85 | name: "With extra materials", 86 | err: nil, 87 | arguments: []string{ 88 | "--artifact-path", 89 | path.Join(rootDir, "bin/slsa-provenance"), 90 | "--github-context", 91 | base64GitHubContext, 92 | "--output-path", 93 | provenanceFile, 94 | "--runner-context", 95 | base64RunnerContext, 96 | "--extra-materials", 97 | path.Join(rootDir, "test-data/materials-valid.json"), 98 | }, 99 | }, 100 | { 101 | name: "With broken extra materials", 102 | err: fmt.Errorf("failed retrieving extra materials for %s: unexpected EOF", path.Join(rootDir, "test-data/materials-broken.not-json")), 103 | arguments: []string{ 104 | "--artifact-path", 105 | path.Join(rootDir, "bin/slsa-provenance"), 106 | "--github-context", 107 | base64GitHubContext, 108 | "--output-path", 109 | provenanceFile, 110 | "--runner-context", 111 | base64RunnerContext, 112 | "--extra-materials", 113 | path.Join(rootDir, "test-data/materials-broken.not-json"), 114 | }, 115 | }, 116 | { 117 | name: "With non-existent extra materials", 118 | err: fmt.Errorf("failed retrieving extra materials: open %s: no such file or directory", unknownFile), 119 | arguments: []string{ 120 | "--artifact-path", 121 | path.Join(rootDir, "bin/slsa-provenance"), 122 | "--github-context", 123 | base64GitHubContext, 124 | "--output-path", 125 | provenanceFile, 126 | "--runner-context", 127 | base64RunnerContext, 128 | "--extra-materials", 129 | fmt.Sprintf("%s,%s", path.Join(rootDir, "test-data/materials-valid.json"), unknownFile), 130 | }, 131 | }, 132 | { 133 | name: "With broken extra materials (no uri)", 134 | err: fmt.Errorf("failed retrieving extra materials for %s: empty or missing \"uri\" for material", path.Join(rootDir, "test-data/materials-no-uri.json")), 135 | arguments: []string{ 136 | "--artifact-path", 137 | path.Join(rootDir, "bin/slsa-provenance"), 138 | "--github-context", 139 | base64GitHubContext, 140 | "--output-path", 141 | provenanceFile, 142 | "--runner-context", 143 | base64RunnerContext, 144 | "--extra-materials", 145 | path.Join(rootDir, "test-data/materials-no-uri.json"), 146 | }, 147 | }, 148 | { 149 | name: "With broken extra materials (no digest)", 150 | err: fmt.Errorf("failed retrieving extra materials for %s: empty or missing \"digest\" for material", path.Join(rootDir, "test-data/materials-no-digest.json")), 151 | arguments: []string{ 152 | "--artifact-path", 153 | path.Join(rootDir, "bin/slsa-provenance"), 154 | "--github-context", 155 | base64GitHubContext, 156 | "--output-path", 157 | provenanceFile, 158 | "--runner-context", 159 | base64RunnerContext, 160 | "--extra-materials", 161 | path.Join(rootDir, "test-data/materials-no-digest.json"), 162 | }, 163 | }, 164 | } 165 | 166 | for _, tc := range testCases { 167 | t.Run(tc.name, func(tt *testing.T) { 168 | assert := assert.New(tt) 169 | 170 | output, err := executeCommand(cli.Files(), tc.arguments...) 171 | defer func() { 172 | _ = os.Remove(provenanceFile) 173 | }() 174 | 175 | if tc.err != nil { 176 | assert.EqualError(err, tc.err.Error()) 177 | } else { 178 | assert.NoError(err) 179 | assert.Contains(output, "Saving provenance to") 180 | if assert.FileExists(provenanceFile) { 181 | content, err := os.ReadFile(provenanceFile) 182 | assert.NoError(err) 183 | assert.Greater(len(content), 1) 184 | } 185 | } 186 | }) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/generate.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // Generate creates an instance of *cobra.Command to generate provenance 8 | func Generate() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "generate", 11 | Short: "Generate provenance using subcommands", 12 | } 13 | 14 | cmd.AddCommand( 15 | Files(), 16 | GitHubRelease(), 17 | OCI(), 18 | ) 19 | 20 | return cmd 21 | } 22 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/generate_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 9 | ) 10 | 11 | func TestGenerate(t *testing.T) { 12 | assert := assert.New(t) 13 | 14 | cmd := cli.Generate() 15 | 16 | assert.Len(cmd.Commands(), 3) 17 | output, err := executeCommand(cmd) 18 | 19 | assert.NoError(err) 20 | assert.Contains(output, "Generate provenance using subcommands\n\nUsage:\n") 21 | } 22 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/github-release.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli/options" 11 | "github.com/philips-labs/slsa-provenance-action/internal/transport" 12 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 13 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 14 | ) 15 | 16 | // GitHubRelease creates an instance of *cobra.Command to manage GitHub release provenance 17 | func GitHubRelease() *cobra.Command { 18 | o := options.GitHubReleaseOptions{} 19 | 20 | cmd := &cobra.Command{ 21 | Use: "github-release", 22 | Short: "Generate provenance on GitHub release assets", 23 | RunE: func(cmd *cobra.Command, args []string) error { 24 | artifactPath, err := o.GetArtifactPath() 25 | if err != nil { 26 | return err 27 | } 28 | outputPath, err := o.GetOutputPath() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | gh, err := o.GetGitHubContext() 34 | if err != nil { 35 | return err 36 | } 37 | 38 | runner, err := o.GetRunnerContext() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | materials, err := o.GetExtraMaterials() 44 | if err != nil { 45 | return err 46 | } 47 | 48 | tagName, err := o.GetTagName() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | ghToken := os.Getenv("GITHUB_TOKEN") 54 | if ghToken == "" { 55 | return errors.New("GITHUB_TOKEN environment variable not set") 56 | } 57 | tc := github.NewOAuth2Client(cmd.Context(), func() string { return ghToken }) 58 | tc.Transport = transport.TeeRoundTripper{ 59 | RoundTripper: tc.Transport, 60 | Writer: cmd.OutOrStdout(), 61 | } 62 | rc := github.NewReleaseClient(tc) 63 | env := github.NewReleaseEnvironment(*gh, *runner, tagName, rc, artifactPath) 64 | 65 | subjecter := intoto.NewFilePathSubjecter(artifactPath) 66 | stmt, err := env.GenerateProvenanceStatement(cmd.Context(), subjecter, materials...) 67 | if err != nil { 68 | return fmt.Errorf("failed to generate provenance: %w", err) 69 | } 70 | 71 | fmt.Fprintf(cmd.OutOrStdout(), "Saving provenance to %s\n", outputPath) 72 | 73 | return env.PersistProvenanceStatement(cmd.Context(), stmt, outputPath) 74 | }, 75 | } 76 | 77 | o.AddFlags(cmd) 78 | 79 | return cmd 80 | } 81 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/github-release_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "os" 7 | "path" 8 | "runtime" 9 | "testing" 10 | 11 | gh "github.com/google/go-github/v41/github" 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 15 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 16 | ) 17 | 18 | func TestProvenenaceGitHubRelease(t *testing.T) { 19 | githubToken := os.Getenv("GITHUB_TOKEN") 20 | if githubToken == "" { 21 | t.Skip("skipping as GITHUB_TOKEN environment variable isn't set") 22 | } 23 | assert := assert.New(t) 24 | 25 | _, filename, _, _ := runtime.Caller(0) 26 | rootDir := path.Join(path.Dir(filename), "../../..") 27 | artifactPath := path.Join(rootDir, "gh-release-test") 28 | provenanceFile := path.Join(artifactPath, "unittest.provenance") 29 | 30 | ctx := context.Background() 31 | owner, repo := "philips-labs", "slsa-provenance-action" 32 | oauthClient := github.NewOAuth2Client(ctx, func() string { return githubToken }) 33 | client := github.NewReleaseClient(oauthClient) 34 | 35 | releaseID, err := createGitHubRelease( 36 | ctx, 37 | client, 38 | owner, 39 | repo, 40 | "v0.0.0-generate-test", 41 | path.Join(rootDir, "bin", "slsa-provenance"), 42 | path.Join(rootDir, "README.md"), 43 | ) 44 | assert.NoError(err) 45 | 46 | defer func() { 47 | _ = os.RemoveAll(artifactPath) 48 | _, err = client.Repositories.DeleteRelease(ctx, owner, repo, releaseID) 49 | }() 50 | 51 | base64GitHubContext := base64.StdEncoding.EncodeToString([]byte(githubContext)) 52 | base64RunnerContext := base64.StdEncoding.EncodeToString([]byte(runnerContext)) 53 | 54 | output, err := executeCommand(cli.GitHubRelease(), 55 | "--artifact-path", 56 | artifactPath, 57 | "--github-context", 58 | base64GitHubContext, 59 | "--output-path", 60 | provenanceFile, 61 | "--runner-context", 62 | base64RunnerContext, 63 | "--tag-name", 64 | "v0.0.0-generate-test", 65 | ) 66 | 67 | assert.NoError(err) 68 | assert.Contains(output, "Saving provenance to") 69 | 70 | if assert.FileExists(provenanceFile) { 71 | content, err := os.ReadFile(provenanceFile) 72 | assert.NoError(err) 73 | assert.Greater(len(content), 1) 74 | } 75 | } 76 | 77 | func createGitHubRelease(ctx context.Context, client *github.ReleaseClient, owner, repo, version string, assets ...string) (int64, error) { 78 | rel, _, err := client.Repositories.CreateRelease( 79 | ctx, 80 | owner, 81 | repo, 82 | &gh.RepositoryRelease{TagName: stringPointer(version), Name: stringPointer(version), Draft: boolPointer(true), Prerelease: boolPointer(true)}, 83 | ) 84 | if err != nil { 85 | return 0, err 86 | } 87 | 88 | for _, a := range assets { 89 | asset, err := os.Open(a) 90 | if err != nil { 91 | return 0, err 92 | } 93 | defer asset.Close() 94 | client.AddProvenanceToRelease(ctx, owner, repo, rel.GetID(), asset) 95 | } 96 | 97 | return rel.GetID(), nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/options/files.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // RequiredFlagError creates a required flag error for the given flag name 10 | func RequiredFlagError(flagName string) error { 11 | return fmt.Errorf("no value found for required flag: %s", flagName) 12 | } 13 | 14 | // FilesOptions Commandline flags used for the generate files command. 15 | type FilesOptions struct { 16 | GenerateOptions 17 | ArtifactPath string 18 | } 19 | 20 | // GetArtifactPath The file(s) or directory of artifacts to include in provenance. 21 | func (o *FilesOptions) GetArtifactPath() (string, error) { 22 | if o.ArtifactPath == "" { 23 | return "", RequiredFlagError("artifact-path") 24 | } 25 | return o.ArtifactPath, nil 26 | } 27 | 28 | // AddFlags Registers the flags with the cobra.Command. 29 | func (o *FilesOptions) AddFlags(cmd *cobra.Command) { 30 | o.GenerateOptions.AddFlags(cmd) 31 | cmd.PersistentFlags().StringVar(&o.ArtifactPath, "artifact-path", "", "The file(s) or directory of artifacts to include in provenance.") 32 | } 33 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/options/generate.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 12 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 13 | ) 14 | 15 | // GenerateOptions Commandline flags used for the generate command. 16 | type GenerateOptions struct { 17 | GitHubContext string 18 | RunnerContext string 19 | OutputPath string 20 | ExtraMaterials []string 21 | } 22 | 23 | // GetGitHubContext The '${github}' context value, retrieved in a GitHub workflow. 24 | func (o *GenerateOptions) GetGitHubContext() (*github.Context, error) { 25 | if o.GitHubContext == "" { 26 | return nil, RequiredFlagError("github-context") 27 | } 28 | decodedContext, err := base64.StdEncoding.DecodeString(o.GitHubContext) 29 | if err != nil { 30 | return nil, err 31 | } 32 | var gh github.Context 33 | if err := json.Unmarshal(decodedContext, &gh); err != nil { 34 | return nil, fmt.Errorf("failed to unmarshal github context json: %w", err) 35 | } 36 | return &gh, nil 37 | } 38 | 39 | // GetRunnerContext The '${runner}' context value, retrieved in a GitHub workflow. 40 | func (o *GenerateOptions) GetRunnerContext() (*github.RunnerContext, error) { 41 | if o.RunnerContext == "" { 42 | return nil, RequiredFlagError("runner-context") 43 | } 44 | decodedContext, err := base64.StdEncoding.DecodeString(o.RunnerContext) 45 | if err != nil { 46 | return nil, err 47 | } 48 | var runner github.RunnerContext 49 | if err := json.Unmarshal(decodedContext, &runner); err != nil { 50 | return nil, fmt.Errorf("failed to unmarshal runner context json: %w", err) 51 | } 52 | return &runner, nil 53 | } 54 | 55 | // GetOutputPath The location to write the provenance file. 56 | func (o *GenerateOptions) GetOutputPath() (string, error) { 57 | if o.OutputPath == "" { 58 | return "", RequiredFlagError("output-path") 59 | } 60 | return o.OutputPath, nil 61 | } 62 | 63 | // GetExtraMaterials Additional material files to be used when generating provenance. 64 | func (o *GenerateOptions) GetExtraMaterials() ([]intoto.Item, error) { 65 | var materials []intoto.Item 66 | 67 | for _, extra := range o.ExtraMaterials { 68 | file, err := os.Open(extra) 69 | if err != nil { 70 | return nil, fmt.Errorf("failed retrieving extra materials: %w", err) 71 | } 72 | defer file.Close() 73 | 74 | m, err := intoto.ReadMaterials(file) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed retrieving extra materials for %s: %w", extra, err) 77 | } 78 | materials = append(materials, m...) 79 | } 80 | 81 | return materials, nil 82 | } 83 | 84 | // AddFlags Registers the flags with the cobra.Command. 85 | func (o *GenerateOptions) AddFlags(cmd *cobra.Command) { 86 | cmd.PersistentFlags().StringVar(&o.GitHubContext, "github-context", "", "The '${github}' context value.") 87 | cmd.PersistentFlags().StringVar(&o.RunnerContext, "runner-context", "", "The '${runner}' context value.") 88 | cmd.PersistentFlags().StringVar(&o.OutputPath, "output-path", "provenance.json", "The path to which the generated provenance should be written.") 89 | cmd.PersistentFlags().StringSliceVarP(&o.ExtraMaterials, "extra-materials", "m", nil, "The '${runner}' context value.") 90 | } 91 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/options/github-release.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // GitHubReleaseOptions Commandline flags used for the generate command. 8 | type GitHubReleaseOptions struct { 9 | GenerateOptions 10 | ArtifactPath string 11 | TagName string 12 | } 13 | 14 | // GetArtifactPath The location to store the GitHub Release artifact 15 | func (o *GitHubReleaseOptions) GetArtifactPath() (string, error) { 16 | if o.ArtifactPath == "" { 17 | return "", RequiredFlagError("artifact-path") 18 | } 19 | return o.ArtifactPath, nil 20 | } 21 | 22 | // GetTagName The name of the GitHub tag/release 23 | func (o *GitHubReleaseOptions) GetTagName() (string, error) { 24 | if o.TagName == "" { 25 | return "", RequiredFlagError("tag-name") 26 | } 27 | return o.TagName, nil 28 | } 29 | 30 | // AddFlags Registers the flags with the cobra.Command. 31 | func (o *GitHubReleaseOptions) AddFlags(cmd *cobra.Command) { 32 | o.GenerateOptions.AddFlags(cmd) 33 | cmd.PersistentFlags().StringVar(&o.ArtifactPath, "artifact-path", "", "The file(s) or directory of artifacts to include in provenance.") 34 | cmd.PersistentFlags().StringVar(&o.TagName, "tag-name", "", `The github release to generate provenance on. 35 | (if set the artifacts will be downloaded from the release and the provenance will be added as an additional release asset.)`) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/options/oci.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/crane" 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/philips-labs/slsa-provenance-action/pkg/oci" 10 | ) 11 | 12 | // OCIOptions Commandline flags used for the generate oci command. 13 | type OCIOptions struct { 14 | GenerateOptions 15 | Repository string 16 | Digest string 17 | Tags []string 18 | AllowInsecure bool 19 | KubernetesKeychain bool 20 | } 21 | 22 | // GetRepository The oci repository to search for the given tags. 23 | func (o *OCIOptions) GetRepository() (string, error) { 24 | if o.Repository == "" { 25 | return "", RequiredFlagError("repository") 26 | } 27 | return o.Repository, nil 28 | } 29 | 30 | // GetDigest The digest to validate the tag digests against. 31 | func (o *OCIOptions) GetDigest() (string, error) { 32 | if o.Digest == "" { 33 | return "", RequiredFlagError("digest") 34 | } 35 | return o.Digest, nil 36 | } 37 | 38 | // GetTags The tags to add as provenance subjects. 39 | func (o *OCIOptions) GetTags() ([]string, error) { 40 | return o.Tags, nil 41 | } 42 | 43 | // AddFlags Registers the flags with the cobra.Command. 44 | func (o *OCIOptions) AddFlags(cmd *cobra.Command) { 45 | o.GenerateOptions.AddFlags(cmd) 46 | cmd.PersistentFlags().StringVar(&o.Repository, "repository", "", "The repository of the oci artifact.") 47 | cmd.PersistentFlags().StringVar(&o.Digest, "digest", "", "The digest for the oci artifact.") 48 | cmd.PersistentFlags().StringSliceVar(&o.Tags, "tags", []string{"latest"}, "The given tags for this oci release.") 49 | cmd.Flags().BoolVar(&o.AllowInsecure, "allow-insecure", false, "whether to allow insecure connections to registries. Don't use this for anything but testing") 50 | cmd.Flags().BoolVar(&o.KubernetesKeychain, "k8s-keychain", false, "whether to use the kubernetes keychain instead of the default keychain (supports workload identity).") 51 | } 52 | 53 | // GetRegistryClientOpts sets some sane default options for crane to authenticate 54 | // private registries 55 | func (o *OCIOptions) GetRegistryClientOpts(ctx context.Context) []crane.Option { 56 | return oci.WithDefaultClientOptions(ctx, o.KubernetesKeychain, o.AllowInsecure) 57 | } 58 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/options/root.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // RootOptions Commandline flags used for the root command. 8 | type RootOptions struct { 9 | Verbose bool 10 | } 11 | 12 | // AddFlags Registers the flags with the cobra.Command. 13 | func (o *RootOptions) AddFlags(cmd *cobra.Command) { 14 | cmd.PersistentFlags().BoolVarP(&o.Verbose, "verbose", "d", false, "show verbose output") 15 | } 16 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/version.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "runtime" 7 | "strings" 8 | "text/tabwriter" 9 | 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // Version creates an instance of *cobra.Command to print version info 14 | func Version() *cobra.Command { 15 | var outputJSON bool 16 | 17 | cmd := &cobra.Command{ 18 | Use: "version", 19 | Short: fmt.Sprintf("Prints the %s version", cliName), 20 | Long: fmt.Sprintf("Prints the %s version", cliName), 21 | RunE: func(cmd *cobra.Command, args []string) error { 22 | v := VersionInfo() 23 | res := v.String() 24 | if outputJSON { 25 | j, err := v.JSONString() 26 | if err != nil { 27 | return fmt.Errorf("unable to generate JSON from version info: %w", err) 28 | } 29 | res = j 30 | } 31 | fmt.Fprintln(cmd.OutOrStdout(), res) 32 | return nil 33 | }, 34 | } 35 | 36 | cmd.Flags().BoolVar(&outputJSON, "json", false, "print version information as JSON") 37 | 38 | return cmd 39 | } 40 | 41 | // Base version information. 42 | // 43 | // This is the fallback data used when version information from git is not 44 | // provided via go ldflags (e.g. via Makefile). 45 | var ( 46 | // Output of "git describe". The prerequisite is that the branch should be 47 | // tagged using the correct versioning strategy. 48 | GitVersion string = "devel" 49 | // SHA1 from git, output of $(git rev-parse HEAD) 50 | gitCommit = "unknown" 51 | // State of git tree, either "clean" or "dirty" 52 | gitTreeState = "unknown" 53 | // Build date in ISO8601 format, output of $(date -u +'%Y-%m-%dT%H:%M:%SZ') 54 | buildDate = "unknown" 55 | ) 56 | 57 | // Info holds the version information of the binary 58 | type Info struct { 59 | GitVersion string `json:"git_version,omitempty"` 60 | GitCommit string `json:"git_commit,omitempty"` 61 | GitTreeState string `json:"git_tree_state,omitempty"` 62 | BuildDate string `json:"build_date,omitempty"` 63 | GoVersion string `json:"go_version,omitempty"` 64 | Compiler string `json:"compiler,omitempty"` 65 | Platform string `json:"platform,omitempty"` 66 | } 67 | 68 | // VersionInfo creates an instance of the Info structure 69 | func VersionInfo() Info { 70 | return Info{ 71 | GitVersion: GitVersion, 72 | GitCommit: gitCommit, 73 | GitTreeState: gitTreeState, 74 | BuildDate: buildDate, 75 | GoVersion: runtime.Version(), 76 | Compiler: runtime.Compiler, 77 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 78 | } 79 | } 80 | 81 | // String returns the string representation of the version info 82 | func (i *Info) String() string { 83 | b := strings.Builder{} 84 | w := tabwriter.NewWriter(&b, 0, 0, 2, ' ', 0) 85 | 86 | fmt.Fprintf(w, "GitVersion:\t%s\n", i.GitVersion) 87 | fmt.Fprintf(w, "GitCommit:\t%s\n", i.GitCommit) 88 | fmt.Fprintf(w, "GitTreeState:\t%s\n", i.GitTreeState) 89 | fmt.Fprintf(w, "BuildDate:\t%s\n", i.BuildDate) 90 | fmt.Fprintf(w, "GoVersion:\t%s\n", i.GoVersion) 91 | fmt.Fprintf(w, "Compiler:\t%s\n", i.Compiler) 92 | fmt.Fprintf(w, "Platform:\t%s\n", i.Platform) 93 | 94 | w.Flush() 95 | return b.String() 96 | } 97 | 98 | // JSONString returns the JSON representation of the version info 99 | func (i *Info) JSONString() (string, error) { 100 | b, err := json.MarshalIndent(i, "", " ") 101 | if err != nil { 102 | return "", err 103 | } 104 | 105 | return string(b), nil 106 | } 107 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/cli/version_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 11 | ) 12 | 13 | func TestVersionCliText(t *testing.T) { 14 | assert := assert.New(t) 15 | 16 | expected := fmt.Sprintf(`GitVersion: devel 17 | GitCommit: unknown 18 | GitTreeState: unknown 19 | BuildDate: unknown 20 | GoVersion: %s 21 | Compiler: %s 22 | Platform: %s/%s 23 | 24 | `, runtime.Version(), runtime.Compiler, runtime.GOOS, runtime.GOARCH) 25 | 26 | output, err := executeCommand(cli.Version()) 27 | assert.NoError(err) 28 | assert.Equal(expected, output) 29 | } 30 | 31 | func TestVersionCliJSON(t *testing.T) { 32 | assert := assert.New(t) 33 | 34 | expected := fmt.Sprintf(`{ 35 | "git_version": "devel", 36 | "git_commit": "unknown", 37 | "git_tree_state": "unknown", 38 | "build_date": "unknown", 39 | "go_version": "%s", 40 | "compiler": "%s", 41 | "platform": "%s/%s" 42 | } 43 | `, runtime.Version(), runtime.Compiler, runtime.GOOS, runtime.GOARCH) 44 | 45 | output, err := executeCommand(cli.Version(), "--json") 46 | assert.NoError(err) 47 | assert.Equal(expected, output) 48 | } 49 | -------------------------------------------------------------------------------- /cmd/slsa-provenance/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/philips-labs/slsa-provenance-action/cmd/slsa-provenance/cli" 11 | ) 12 | 13 | func main() { 14 | for i, arg := range os.Args { 15 | if (strings.HasPrefix(arg, "-") && len(arg) == 2) || (strings.HasPrefix(arg, "--") && len(arg) >= 4) { 16 | continue 17 | } else if strings.HasPrefix(arg, "-") { 18 | newArg := fmt.Sprintf("-%s", arg) 19 | fmt.Fprintf(os.Stderr, "WARNING: the flag %s is deprecated and will be removed in a future release. Please use the flag %s.\n", arg, newArg) 20 | os.Args[i] = newArg 21 | } 22 | } 23 | 24 | if err := cli.New().ExecuteContext(context.Background()); err != nil { 25 | log.Fatalf("error during command execution: %v", err) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | # adjust accordingly based on how flaky your tests are 7 | # this allows a 8% drop from the previous base commit coverage 8 | threshold: 8% 9 | patch: 10 | default: 11 | informational: true 12 | -------------------------------------------------------------------------------- /cosign.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEP1QqZaWSrMJKZkqoAbr5zq4bV4KW 3 | 9Vj+FQotHLTsxIM16+OAx8NbUOzmga9aaKEtAee5wXD3dvWpFX0gKXMBqA== 4 | -----END PUBLIC KEY----- 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/philips-labs/slsa-provenance-action 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20211215200129-69c85dc22db6 9 | github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21 10 | github.com/google/go-containerregistry v0.20.5 11 | github.com/google/go-github/v41 v41.0.0 12 | github.com/spf13/cobra v1.9.1 13 | github.com/stretchr/testify v1.10.0 14 | golang.org/x/oauth2 v0.30.0 15 | ) 16 | 17 | require ( 18 | cloud.google.com/go v0.99.0 // indirect 19 | github.com/Azure/azure-sdk-for-go v46.4.0+incompatible // indirect 20 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 21 | github.com/Azure/go-autorest/autorest v0.11.8 // indirect 22 | github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect 23 | github.com/Azure/go-autorest/autorest/azure/auth v0.5.2 // indirect 24 | github.com/Azure/go-autorest/autorest/azure/cli v0.4.1 // indirect 25 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 26 | github.com/Azure/go-autorest/logger v0.2.0 // indirect 27 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 28 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect 29 | github.com/dimchansky/utfbom v1.1.0 // indirect 30 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 31 | github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect 32 | github.com/klauspost/compress v1.18.0 // indirect 33 | github.com/mitchellh/go-homedir v1.1.0 // indirect 34 | github.com/vbatts/tar-split v0.12.1 // indirect 35 | golang.org/x/sync v0.14.0 // indirect 36 | ) 37 | 38 | require ( 39 | github.com/aws/aws-sdk-go-v2 v1.7.1 // indirect 40 | github.com/aws/aws-sdk-go-v2/config v1.5.0 // indirect 41 | github.com/aws/aws-sdk-go-v2/credentials v1.3.1 // indirect 42 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.3.0 // indirect 43 | github.com/aws/aws-sdk-go-v2/internal/ini v1.1.1 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/ecr v1.4.1 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.4.1 // indirect 46 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.1 // indirect 47 | github.com/aws/aws-sdk-go-v2/service/sso v1.3.1 // indirect 48 | github.com/aws/aws-sdk-go-v2/service/sts v1.6.0 // indirect 49 | github.com/aws/smithy-go v1.6.0 // indirect 50 | github.com/davecgh/go-spew v1.1.1 // indirect 51 | github.com/docker/cli v28.1.1+incompatible // indirect 52 | github.com/docker/distribution v2.8.3+incompatible // indirect 53 | github.com/google/go-querystring v1.1.0 // indirect 54 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 55 | github.com/jmespath/go-jmespath v0.4.0 // indirect 56 | github.com/kr/pretty v0.3.0 // indirect 57 | github.com/opencontainers/go-digest v1.0.0 // indirect 58 | github.com/opencontainers/image-spec v1.1.1 // indirect 59 | github.com/pkg/errors v0.9.1 // indirect 60 | github.com/pmezard/go-difflib v1.0.0 // indirect 61 | github.com/sirupsen/logrus v1.9.3 // indirect 62 | github.com/spf13/pflag v1.0.6 // indirect 63 | golang.org/x/crypto v0.35.0 // indirect 64 | golang.org/x/sys v0.33.0 // indirect 65 | gopkg.in/yaml.v3 v3.0.1 // indirect 66 | ) 67 | -------------------------------------------------------------------------------- /install-slsa-provenance.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | shopt -s expand_aliases 4 | 5 | if [ -z "$NO_COLOR" ]; then 6 | alias log_info="echo -e \"\033[1;32mINFO\033[0m:\"" 7 | alias log_error="echo -e \"\033[1;31mERROR\033[0m:\"" 8 | alias log_warning="echo -e \"\033[1;33mWARN\033[0m:\"" 9 | else 10 | alias log_info="echo \"INFO:\"" 11 | alias log_error="echo \"ERROR:\"" 12 | alias log_warning="echo \"WARN:\"" 13 | fi 14 | 15 | set -e 16 | 17 | GITHUB_API=${GITHUB_API:-'https://api.github.com'} 18 | 19 | # default to relative path if INSTALL_PATH is not set 20 | INSTALL_PATH=${INSTALL_PATH:-$(realpath ./.slsa-provenance)} 21 | 22 | mkdir -p "${INSTALL_PATH}" 23 | 24 | VERSION=v0.7.2 25 | RELEASE="https://github.com/philips-labs/slsa-provenance-action/releases/download/${VERSION}" 26 | 27 | if [[ "$VERSION" == *-draft ]] ; then 28 | curl_args=(-H "Authorization: token $GITHUB_TOKEN") 29 | assets=$(curl "${curl_args[@]}" -s "${GITHUB_API}/repos/philips-labs/slsa-provenance-action/releases?per_page=10" | jq "map(select(.name == \"${VERSION}\"))" | jq -r '.[0].assets') 30 | fi 31 | 32 | function download { 33 | url="${2}" 34 | if [[ "$VERSION" == *-draft ]] ; then 35 | url="$(echo "${assets}" | jq "map(select(.name == \"$1\"))" | jq -r '.[0].url')" 36 | curl_args+=(-H 'Accept: application/octet-stream') 37 | fi 38 | log_info "Downloading ${1} from ${url}…" 39 | curl -sLo "${1}" --show-error "${curl_args[@]}" "${url}" 40 | echo 41 | } 42 | 43 | OS=${RUNNER_OS:-Linux} 44 | ARCH=${RUNNER_ARCH:-X64} 45 | 46 | case "${ARCH}" in 47 | X64) 48 | ARCH=amd64 49 | ;; 50 | ARM64) 51 | ARCH=arm64 52 | ;; 53 | *) 54 | log_error "unsupported ARCH ${ARCH}" 55 | exit 1 56 | ;; 57 | esac 58 | 59 | BINARY=slsa-provenance 60 | case "${OS}" in 61 | Linux) 62 | OS=linux 63 | ARCHIVE="slsa-provenance_${VERSION/v}_${OS}_${ARCH}.tar.gz" 64 | ;; 65 | macOS) 66 | ARCHIVE="slsa-provenance_${VERSION/v}_${OS}_${ARCH}.tar.gz" 67 | ;; 68 | Windows) 69 | OS=windows 70 | ARCHIVE="slsa-provenance_${VERSION/v}_${OS}_${ARCH}.zip" 71 | BINARY="${BINARY}.exe" 72 | ;; 73 | *) 74 | log_error "unsupported OS ${OS}" 75 | exit 1 76 | ;; 77 | esac 78 | 79 | DOWNLOAD="${RELEASE}/${ARCHIVE}" 80 | 81 | log_info "Installing ${BINARY} (${OS}/${ARCH}) at ${INSTALL_PATH}" 82 | mkdir -p "$INSTALL_PATH" 83 | 84 | trap "popd >/dev/null" EXIT 85 | pushd "$INSTALL_PATH" > /dev/null || exit 86 | 87 | download "${ARCHIVE}" "${DOWNLOAD}" 88 | 89 | if [ -x "$(command -v cosign)" ] ; then 90 | download ${ARCHIVE}.sig "${DOWNLOAD}.sig" 91 | download cosign.pub "$RELEASE/cosign.pub" 92 | 93 | log_info "Verifying signature…" 94 | cosign verify-blob --key cosign.pub --signature "${ARCHIVE}.sig" "${ARCHIVE}" 95 | rm "${ARCHIVE}.sig" cosign.pub 96 | else 97 | log_warning >&2 98 | log_warning " cosign binary not installed in PATH. Unable to verify signature!" >&2 99 | log_warning >&2 100 | log_warning " Consider installing cosign first, to be able to verify the signature!" >&2 101 | log_warning >&2 102 | fi 103 | 104 | log_info "extracting ${BINARY} from ${ARCHIVE}" 105 | tar -xzf "${ARCHIVE}" "${BINARY}" 106 | rm "${ARCHIVE}" 107 | 108 | # for testing purposes fall back to "$INSTALL_PATH/GITHUB_PATH" 109 | echo "$INSTALL_PATH" >> "${GITHUB_PATH:-"$INSTALL_PATH/GITHUB_PATH"}" 110 | -------------------------------------------------------------------------------- /internal/transport/transport.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | // TeeRoundTripper copies request bodies to stdout. 10 | type TeeRoundTripper struct { 11 | http.RoundTripper 12 | Writer io.Writer 13 | } 14 | 15 | // RoundTrip executes a single HTTP transaction, returning 16 | // a Response for the provided Request. 17 | func (t TeeRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 18 | fmt.Fprintf(t.Writer, "%s: %s\n", req.Method, req.URL) 19 | 20 | return t.RoundTripper.RoundTrip(req) 21 | } 22 | -------------------------------------------------------------------------------- /internal/transport/transport_test.go: -------------------------------------------------------------------------------- 1 | package transport_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/philips-labs/slsa-provenance-action/internal/transport" 13 | ) 14 | 15 | func TestTeeRoundTripper(t *testing.T) { 16 | assert := assert.New(t) 17 | 18 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | fmt.Fprintln(w, "Hello world") 20 | })) 21 | defer ts.Close() 22 | 23 | var writer strings.Builder 24 | client := http.Client{ 25 | Transport: transport.TeeRoundTripper{ 26 | RoundTripper: http.DefaultTransport, 27 | Writer: &writer, 28 | }, 29 | } 30 | 31 | jsonString := `{ "say": "hello-world", "to": "marco" }` 32 | json := strings.NewReader(jsonString) 33 | _, err := client.Post(ts.URL, "application/json", json) 34 | 35 | assert.NoError(err) 36 | assert.NotEmpty(writer.String()) 37 | assert.Equal(fmt.Sprintf("POST: %s\n", ts.URL), writer.String()) 38 | 39 | writer.Reset() 40 | _, err = client.Get(ts.URL) 41 | 42 | assert.NoError(err) 43 | assert.NotEmpty(writer.String()) 44 | assert.Equal(fmt.Sprintf("GET: %s\n", ts.URL), writer.String()) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | ) 7 | 8 | const ( 9 | // HostedIDSuffix the GitHub hosted attestation type 10 | HostedIDSuffix = "/Attestations/GitHubHostedActions@v1" 11 | // SelfHostedIDSuffix the GitHub self hosted attestation type 12 | SelfHostedIDSuffix = "/Attestations/SelfHostedActions@v1" 13 | // BuildType URI indicating what type of build was performed. It determines the meaning of invocation, buildConfig and materials. 14 | BuildType = "https://github.com/Attestations/GitHubActionsWorkflow@v1" 15 | // PayloadContentType used to define the Envelope content type 16 | // See: https://github.com/in-toto/attestation#provenance-example 17 | PayloadContentType = "application/vnd.in-toto+json" 18 | ) 19 | 20 | func builderID(repoURI string) string { 21 | if os.Getenv("GITHUB_ACTIONS") == "true" { 22 | return repoURI + HostedIDSuffix 23 | } 24 | return repoURI + SelfHostedIDSuffix 25 | } 26 | 27 | // Environment the environment from which provenance is generated. 28 | type Environment struct { 29 | Context *Context `json:"github,omitempty"` 30 | Runner *RunnerContext `json:"runner,omitempty"` 31 | } 32 | 33 | // Context holds all the information set on Github runners in relation to the job 34 | // 35 | // This information is retrieved from variables during workflow execution 36 | type Context struct { 37 | Action string `json:"action"` 38 | ActionPath string `json:"action_path"` 39 | Actor string `json:"actor"` 40 | BaseRef string `json:"base_ref"` 41 | Event json.RawMessage `json:"event"` 42 | EventName string `json:"event_name"` 43 | EventPath string `json:"event_path"` 44 | HeadRef string `json:"head_ref"` 45 | Job string `json:"job"` 46 | Ref string `json:"ref"` 47 | Repository string `json:"repository"` 48 | RepositoryOwner string `json:"repository_owner"` 49 | RunID string `json:"run_id"` 50 | RunNumber string `json:"run_number"` 51 | SHA string `json:"sha"` 52 | Token Token `json:"token,omitempty"` 53 | Workflow string `json:"workflow"` 54 | Workspace string `json:"workspace"` 55 | } 56 | 57 | // Token the github token used during a workflow 58 | type Token string 59 | 60 | // UnmarshalText Unmarshals the token received from Github 61 | func (t *Token) UnmarshalText(text []byte) error { 62 | *t = Token(text) 63 | return nil 64 | } 65 | 66 | // MarshalText masks the token as *** when marshalling 67 | func (t Token) MarshalText() ([]byte, error) { 68 | return []byte("***"), nil 69 | } 70 | 71 | // RunnerContext holds information about the given Github Runner in which a workflow executes 72 | // 73 | // This information is retrieved from variables during workflow execution 74 | type RunnerContext struct { 75 | OS string `json:"os"` 76 | Temp string `json:"temp"` 77 | ToolCache string `json:"tool_cache"` 78 | } 79 | 80 | // AnyEvent holds the inputs from a Github workflow 81 | // 82 | // See https://docs.github.com/en/actions/reference/events-that-trigger-workflows 83 | // The only Event with dynamically-provided input is workflow_dispatch which 84 | // exposes the user params at the key "input." 85 | type AnyEvent struct { 86 | Inputs json.RawMessage `json:"inputs"` 87 | } 88 | -------------------------------------------------------------------------------- /pkg/github/github_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 10 | ) 11 | 12 | func TestMarshalGitHubContext(t *testing.T) { 13 | assert := assert.New(t) 14 | 15 | data := `{ "token": "superSecret" }` 16 | 17 | var ghc github.Context 18 | err := json.Unmarshal([]byte(data), &ghc) 19 | assert.NoError(err) 20 | assert.Equal(github.Token("superSecret"), ghc.Token) 21 | 22 | j, err := json.Marshal(&ghc) 23 | assert.NoError(err) 24 | assert.NotContains(string(j), `"token":"superSecret"`) 25 | assert.Contains(string(j), `"token":"***"`) 26 | } 27 | -------------------------------------------------------------------------------- /pkg/github/provenance.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "strings" 11 | 12 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 13 | ) 14 | 15 | // GenerateProvenanceStatement generates provenance from the provided artifactPath 16 | // 17 | // The artifactPath can be a file or a directory. 18 | func (e *Environment) GenerateProvenanceStatement(ctx context.Context, subjecter intoto.Subjecter, materials ...intoto.Item) (*intoto.Statement, error) { 19 | subjects, err := subjecter.Subjects() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | repoURI := "https://github.com/" + e.Context.Repository 25 | 26 | event := AnyEvent{} 27 | if err := json.Unmarshal(e.Context.Event, &event); err != nil { 28 | return nil, fmt.Errorf("failed to unmarshal github context event json: %w", err) 29 | } 30 | 31 | stmt := intoto.SLSAProvenanceStatement( 32 | intoto.WithSubject(subjects), 33 | intoto.WithBuilder(builderID(repoURI)), 34 | // NOTE: Re-runs are not uniquely identified and can cause run ID collisions. 35 | intoto.WithMetadata(fmt.Sprintf("%s/actions/runs/%s", repoURI, e.Context.RunID)), 36 | // NOTE: This is inexact as multiple workflows in a repo can have the same name. 37 | // See https://github.com/github/feedback/discussions/4188 38 | intoto.WithInvocation( 39 | BuildType, 40 | e.Context.ActionPath, 41 | nil, 42 | event.Inputs, 43 | []intoto.Item{ 44 | {URI: "git+" + repoURI, Digest: intoto.DigestSet{"sha1": e.Context.SHA}}, 45 | }, 46 | ), 47 | intoto.WithMaterials(materials), 48 | ) 49 | 50 | return stmt, nil 51 | } 52 | 53 | // PersistProvenanceStatement writes the provenance statement at the given path 54 | func (e *Environment) PersistProvenanceStatement(ctx context.Context, stmt *intoto.Statement, path string) error { 55 | // NOTE: At L1, writing the in-toto Statement type is sufficient but, at 56 | // higher SLSA levels, the Statement must be encoded and wrapped in an 57 | // Envelope to support attaching signatures. 58 | payload, err := json.MarshalIndent(stmt, "", " ") 59 | if err != nil { 60 | return fmt.Errorf("failed to marshal provenance: %w", err) 61 | } 62 | if err := os.WriteFile(path, payload, 0755); err != nil { 63 | return fmt.Errorf("failed to write provenance: %w", err) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // ReleaseEnvironment implements intoto.Provenancer to Generate provenance based on a GitHub release 70 | type ReleaseEnvironment struct { 71 | *Environment 72 | rc *ReleaseClient 73 | tagName string 74 | releaseID int64 75 | artifactPath string 76 | } 77 | 78 | // NewReleaseEnvironment creates a new instance of ReleaseEnvironment with the given tagName and provenanceClient 79 | func NewReleaseEnvironment(gh Context, runner RunnerContext, tagName string, rc *ReleaseClient, artifactPath string) *ReleaseEnvironment { 80 | return &ReleaseEnvironment{ 81 | Environment: &Environment{ 82 | Context: &gh, 83 | Runner: &runner, 84 | }, 85 | rc: rc, 86 | tagName: tagName, 87 | releaseID: 0, 88 | artifactPath: artifactPath, 89 | } 90 | } 91 | 92 | // GenerateProvenanceStatement generates provenance from the GitHub release environment 93 | // Release assets will be downloaded to the given artifactPath 94 | // The artifactPath has to be a directory. 95 | func (e *ReleaseEnvironment) GenerateProvenanceStatement(ctx context.Context, subjecter intoto.Subjecter, materials ...intoto.Item) (*intoto.Statement, error) { 96 | err := os.MkdirAll(e.artifactPath, 0755) 97 | if err != nil { 98 | return nil, err 99 | } 100 | isDir, err := isEmptyDirectory(e.artifactPath) 101 | if err != nil { 102 | return nil, err 103 | } 104 | if !isDir { 105 | return nil, errors.New("artifactPath has to be an empty directory") 106 | } 107 | 108 | owner := e.Context.RepositoryOwner 109 | repo := repositoryName(e.Context.Repository) 110 | 111 | releaseID, err := e.GetReleaseID(ctx, e.tagName) 112 | if err != nil { 113 | return nil, err 114 | } 115 | _, err = e.rc.DownloadReleaseAssets(ctx, owner, repo, releaseID, e.artifactPath) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | return e.Environment.GenerateProvenanceStatement(ctx, subjecter, materials...) 121 | } 122 | 123 | // PersistProvenanceStatement writes the provenance statement at the given path and uploads it to the GitHub release 124 | func (e *ReleaseEnvironment) PersistProvenanceStatement(ctx context.Context, stmt *intoto.Statement, path string) error { 125 | err := e.Environment.PersistProvenanceStatement(ctx, stmt, path) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | stmtFile, err := os.Open(path) 131 | if err != nil { 132 | return fmt.Errorf("failed to open provenance statement: %w", err) 133 | } 134 | defer stmtFile.Close() 135 | 136 | owner := e.Context.RepositoryOwner 137 | repo := repositoryName(e.Context.Repository) 138 | _, err = e.rc.AddProvenanceToRelease(ctx, owner, repo, e.releaseID, stmtFile) 139 | if err != nil { 140 | return fmt.Errorf("failed to upload provenance to release: %w", err) 141 | } 142 | 143 | return nil 144 | } 145 | 146 | // GetReleaseID fetches a release and caches the releaseID in the environment 147 | func (e *ReleaseEnvironment) GetReleaseID(ctx context.Context, tagName string) (int64, error) { 148 | owner := e.Context.RepositoryOwner 149 | repo := repositoryName(e.Context.Repository) 150 | 151 | if e.releaseID == 0 { 152 | rel, err := e.rc.FetchRelease(ctx, owner, repo, e.tagName) 153 | if err != nil { 154 | return 0, err 155 | } 156 | e.releaseID = rel.GetID() 157 | } 158 | 159 | return e.releaseID, nil 160 | } 161 | 162 | func isEmptyDirectory(p string) (bool, error) { 163 | f, err := os.Open(p) 164 | if err != nil { 165 | return false, err 166 | } 167 | defer f.Close() 168 | 169 | _, err = f.Readdirnames(1) 170 | if err == io.EOF { 171 | return true, nil 172 | } 173 | return false, err 174 | } 175 | 176 | func repositoryName(repo string) string { 177 | repoParts := strings.Split(repo, "/") 178 | return repoParts[len(repoParts)-1] 179 | } 180 | -------------------------------------------------------------------------------- /pkg/github/provenance_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path" 8 | "runtime" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 15 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 16 | ) 17 | 18 | const ( 19 | pushGitHubEvent = `{ 20 | "after": "c4f679f131dfb7f810fd411ac9475549d1c393df", 21 | "base_ref": null, 22 | "before": "715b4daa0f750f420635ee488ef37a2433608438", 23 | "commits": [ 24 | { 25 | "author": { 26 | "email": "john.doe@philips.com", 27 | "name": "John Doe", 28 | "username": "john-doe" 29 | }, 30 | "committer": { 31 | "email": "noreply@github.com", 32 | "name": "GitHub", 33 | "username": "web-flow" 34 | }, 35 | "distinct": true, 36 | "id": "c4f679f131dfb7f810fd411ac9475549d1c393df", 37 | "message": "Update example-local.yml", 38 | "timestamp": "2021-10-12T12:18:06+02:00", 39 | "tree_id": "a4dda43e9a101031dc6cd14def2d6e34ef9b4d92", 40 | "url": "https://github.com/philips-labs/slsa-provenance-action/commit/c4f679f131dfb7f810fd411ac9475549d1c393df" 41 | } 42 | ], 43 | "compare": "https://github.com/philips-labs/slsa-provenance-action/compare/715b4daa0f75...c4f679f131df", 44 | "created": false, 45 | "deleted": false, 46 | "enterprise": { 47 | "avatar_url": "https://avatars.githubusercontent.com/b/1244?v=4", 48 | "created_at": "2019-11-07T05:37:39Z", 49 | "description": "", 50 | "html_url": "https://github.com/enterprises/royal-philips", 51 | "id": 1244, 52 | "name": "Royal Philips", 53 | "node_id": "MDEwOkVudGVycHJpc2UxMjQ0", 54 | "slug": "royal-philips", 55 | "updated_at": "2020-12-16T12:30:18Z", 56 | "website_url": "https://www.philips.com" 57 | }, 58 | "forced": false, 59 | "head_commit": { 60 | "author": { 61 | "email": "john.doe@philips.com", 62 | "name": "John Doe", 63 | "username": "john-doe" 64 | }, 65 | "committer": { 66 | "email": "noreply@github.com", 67 | "name": "GitHub", 68 | "username": "web-flow" 69 | }, 70 | "distinct": true, 71 | "id": "c4f679f131dfb7f810fd411ac9475549d1c393df", 72 | "message": "Update example-local.yml", 73 | "timestamp": "2021-10-12T12:18:06+02:00", 74 | "tree_id": "a4dda43e9a101031dc6cd14def2d6e34ef9b4d92", 75 | "url": "https://github.com/philips-labs/slsa-provenance-action/commit/c4f679f131dfb7f810fd411ac9475549d1c393df" 76 | }, 77 | "organization": { 78 | "avatar_url": "https://avatars.githubusercontent.com/u/58286953?v=4", 79 | "description": "Philips Labs - Projects in development", 80 | "events_url": "https://api.github.com/orgs/philips-labs/events", 81 | "hooks_url": "https://api.github.com/orgs/philips-labs/hooks", 82 | "id": 58286953, 83 | "issues_url": "https://api.github.com/orgs/philips-labs/issues", 84 | "login": "philips-labs", 85 | "members_url": "https://api.github.com/orgs/philips-labs/members{/member}", 86 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU4Mjg2OTUz", 87 | "public_members_url": "https://api.github.com/orgs/philips-labs/public_members{/member}", 88 | "repos_url": "https://api.github.com/orgs/philips-labs/repos", 89 | "url": "https://api.github.com/orgs/philips-labs" 90 | }, 91 | "pusher": { 92 | "email": "john.doe@philips.com", 93 | "name": "john-doe" 94 | }, 95 | "ref": "refs/heads/temp/dump-context", 96 | "repository": { 97 | "allow_forking": true, 98 | "archive_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/{archive_format}{/ref}", 99 | "archived": false, 100 | "assignees_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/assignees{/user}", 101 | "blobs_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/blobs{/sha}", 102 | "branches_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/branches{/branch}", 103 | "clone_url": "https://github.com/philips-labs/slsa-provenance-action.git", 104 | "collaborators_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/collaborators{/collaborator}", 105 | "comments_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/comments{/number}", 106 | "commits_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/commits{/sha}", 107 | "compare_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/compare/{base}...{head}", 108 | "contents_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/contents/{+path}", 109 | "contributors_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/contributors", 110 | "created_at": 1631537642, 111 | "default_branch": "main", 112 | "deployments_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/deployments", 113 | "description": "Github Action implementation of SLSA Provenance Generation of level 1", 114 | "disabled": false, 115 | "downloads_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/downloads", 116 | "events_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/events", 117 | "fork": false, 118 | "forks": 2, 119 | "forks_count": 2, 120 | "forks_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/forks", 121 | "full_name": "philips-labs/slsa-provenance-action", 122 | "git_commits_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/commits{/sha}", 123 | "git_refs_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/refs{/sha}", 124 | "git_tags_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/tags{/sha}", 125 | "git_url": "git://github.com/philips-labs/slsa-provenance-action.git", 126 | "has_downloads": true, 127 | "has_issues": true, 128 | "has_pages": false, 129 | "has_projects": true, 130 | "has_wiki": true, 131 | "homepage": "", 132 | "hooks_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/hooks", 133 | "html_url": "https://github.com/philips-labs/slsa-provenance-action", 134 | "id": 405972862, 135 | "is_template": false, 136 | "issue_comment_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues/comments{/number}", 137 | "issue_events_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues/events{/number}", 138 | "issues_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/issues{/number}", 139 | "keys_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/keys{/key_id}", 140 | "labels_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/labels{/name}", 141 | "language": "Go", 142 | "languages_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/languages", 143 | "license": { 144 | "key": "mit", 145 | "name": "MIT License", 146 | "node_id": "MDc6TGljZW5zZTEz", 147 | "spdx_id": "MIT", 148 | "url": "https://api.github.com/licenses/mit" 149 | }, 150 | "master_branch": "main", 151 | "merges_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/merges", 152 | "milestones_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/milestones{/number}", 153 | "mirror_url": null, 154 | "name": "slsa-provenance-action", 155 | "node_id": "MDEwOlJlcG9zaXRvcnk0MDU5NzI4NjI=", 156 | "notifications_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/notifications{?since,all,participating}", 157 | "open_issues": 11, 158 | "open_issues_count": 11, 159 | "organization": "philips-labs", 160 | "owner": { 161 | "avatar_url": "https://avatars.githubusercontent.com/u/58286953?v=4", 162 | "email": "software-program-cto@philips.com", 163 | "events_url": "https://api.github.com/users/philips-labs/events{/privacy}", 164 | "followers_url": "https://api.github.com/users/philips-labs/followers", 165 | "following_url": "https://api.github.com/users/philips-labs/following{/other_user}", 166 | "gists_url": "https://api.github.com/users/philips-labs/gists{/gist_id}", 167 | "gravatar_id": "", 168 | "html_url": "https://github.com/philips-labs", 169 | "id": 58286953, 170 | "login": "philips-labs", 171 | "name": "philips-labs", 172 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU4Mjg2OTUz", 173 | "organizations_url": "https://api.github.com/users/philips-labs/orgs", 174 | "received_events_url": "https://api.github.com/users/philips-labs/received_events", 175 | "repos_url": "https://api.github.com/users/philips-labs/repos", 176 | "site_admin": false, 177 | "starred_url": "https://api.github.com/users/philips-labs/starred{/owner}{/repo}", 178 | "subscriptions_url": "https://api.github.com/users/philips-labs/subscriptions", 179 | "type": "Organization", 180 | "url": "https://api.github.com/users/philips-labs" 181 | }, 182 | "private": false, 183 | "pulls_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/pulls{/number}", 184 | "pushed_at": 1634033886, 185 | "releases_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/releases{/id}", 186 | "size": 76, 187 | "ssh_url": "git@github.com:philips-labs/slsa-provenance-action.git", 188 | "stargazers": 1, 189 | "stargazers_count": 1, 190 | "stargazers_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/stargazers", 191 | "statuses_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/statuses/{sha}", 192 | "subscribers_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/subscribers", 193 | "subscription_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/subscription", 194 | "svn_url": "https://github.com/philips-labs/slsa-provenance-action", 195 | "tags_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/tags", 196 | "teams_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/teams", 197 | "topics": [ 198 | "hacktoberfest" 199 | ], 200 | "trees_url": "https://api.github.com/repos/philips-labs/slsa-provenance-action/git/trees{/sha}", 201 | "updated_at": "2021-10-11T14:10:37Z", 202 | "url": "https://github.com/philips-labs/slsa-provenance-action", 203 | "visibility": "public", 204 | "watchers": 1, 205 | "watchers_count": 1 206 | }, 207 | "sender": { 208 | "avatar_url": "https://avatars.githubusercontent.com/u/15904543?v=4", 209 | "events_url": "https://api.github.com/users/john-doe/events{/privacy}", 210 | "followers_url": "https://api.github.com/users/john-doe/followers", 211 | "following_url": "https://api.github.com/users/john-doe/following{/other_user}", 212 | "gists_url": "https://api.github.com/users/john-doe/gists{/gist_id}", 213 | "gravatar_id": "", 214 | "html_url": "https://github.com/john-doe", 215 | "id": 15904543, 216 | "login": "john-doe", 217 | "node_id": "MDQ6VXNlcjE1OTA0NTQz", 218 | "organizations_url": "https://api.github.com/users/john-doe/orgs", 219 | "received_events_url": "https://api.github.com/users/john-doe/received_events", 220 | "repos_url": "https://api.github.com/users/john-doe/repos", 221 | "site_admin": false, 222 | "starred_url": "https://api.github.com/users/john-doe/starred{/owner}{/repo}", 223 | "subscriptions_url": "https://api.github.com/users/john-doe/subscriptions", 224 | "type": "User", 225 | "url": "https://api.github.com/users/john-doe" 226 | } 227 | }` 228 | ) 229 | 230 | func TestGenerateProvenance(t *testing.T) { 231 | assert := assert.New(t) 232 | 233 | ctx := context.Background() 234 | os.Setenv("GITHUB_ACTIONS", "true") 235 | 236 | repoURL := "https://github.com/philips-labs/slsa-provenance-action" 237 | 238 | gh := github.Context{ 239 | RunID: "1029384756", 240 | RepositoryOwner: "philips-labs", 241 | Repository: "philips-labs/slsa-provenance-action", 242 | Event: []byte(pushGitHubEvent), 243 | EventName: "push", 244 | ActionPath: ".github/workflows/build.yml", 245 | SHA: "849fb987efc0c0fc72e26a38f63f0c00225132be", 246 | } 247 | materials := []intoto.Item{ 248 | {URI: "git+" + repoURL, Digest: intoto.DigestSet{"sha1": gh.SHA}}, 249 | } 250 | 251 | runner := github.RunnerContext{} 252 | _, filename, _, _ := runtime.Caller(0) 253 | rootDir := path.Join(path.Dir(filename), "../..") 254 | 255 | artifactPath := path.Join(rootDir, "bin") 256 | fps := intoto.NewFilePathSubjecter(artifactPath) 257 | 258 | env := github.Environment{ 259 | Context: &gh, 260 | Runner: &runner, 261 | } 262 | stmt, err := env.GenerateProvenanceStatement(ctx, fps) 263 | if !assert.NoError(err) { 264 | return 265 | } 266 | 267 | binaryName := "slsa-provenance" 268 | binaryPath := path.Join(artifactPath, binaryName) 269 | 270 | assert.Len(stmt.Subject, 1) 271 | assertSubject(assert, stmt.Subject, binaryName, binaryPath) 272 | 273 | assert.Equal(intoto.SlsaPredicateType, stmt.PredicateType) 274 | assert.Equal(intoto.StatementType, stmt.Type) 275 | 276 | predicate := stmt.Predicate 277 | assert.Equal(github.BuildType, predicate.BuildType) 278 | assert.Equal(fmt.Sprintf("%s%s", repoURL, github.HostedIDSuffix), predicate.ID) 279 | assert.Equal(materials, predicate.Materials) 280 | assert.Equal(fmt.Sprintf("%s%s", repoURL, github.HostedIDSuffix), predicate.Builder.ID) 281 | 282 | assertMetadata(assert, predicate.Metadata, gh, repoURL) 283 | assertInvocation(assert, predicate.Invocation) 284 | } 285 | 286 | func TestGenerateProvenanceFromGitHubRelease(t *testing.T) { 287 | if tokenRetriever() == "" { 288 | t.Skip("skipping as GITHUB_TOKEN environment variable isn't set") 289 | } 290 | assert := assert.New(t) 291 | 292 | ctx := context.Background() 293 | os.Setenv("GITHUB_ACTIONS", "true") 294 | 295 | repoURL := "https://github.com/philips-labs/slsa-provenance-action" 296 | 297 | ghContext := github.Context{ 298 | RunID: "1029384756", 299 | RepositoryOwner: "philips-labs", 300 | Repository: "philips-labs/slsa-provenance-action", 301 | Event: []byte(pushGitHubEvent), 302 | EventName: "push", 303 | ActionPath: ".github/workflows/build.yml", 304 | SHA: "849fb987efc0c0fc72e26a38f63f0c00225132be", 305 | } 306 | materials := []intoto.Item{ 307 | {URI: "git+" + repoURL, Digest: intoto.DigestSet{"sha1": ghContext.SHA}}, 308 | } 309 | 310 | runner := github.RunnerContext{} 311 | _, filename, _, _ := runtime.Caller(0) 312 | rootDir := path.Join(path.Dir(filename), "../..") 313 | artifactPath := path.Join(rootDir, "release-assets") 314 | fps := intoto.NewFilePathSubjecter(artifactPath) 315 | 316 | tc := github.NewOAuth2Client(ctx, tokenRetriever) 317 | client := github.NewReleaseClient(tc) 318 | 319 | version := fmt.Sprintf("v0.0.0-rel-test-%d", time.Now().UnixNano()) 320 | releaseId, err := createGitHubRelease( 321 | ctx, 322 | client, 323 | owner, 324 | repo, 325 | version, 326 | path.Join(rootDir, "bin", "slsa-provenance"), 327 | path.Join(rootDir, "README.md"), 328 | ) 329 | if !assert.NoError(err) { 330 | return 331 | } 332 | defer func() { 333 | _ = os.RemoveAll(artifactPath) 334 | _, err := client.Repositories.DeleteRelease(ctx, owner, repo, releaseId) 335 | assert.NoError(err) 336 | }() 337 | 338 | env := github.NewReleaseEnvironment(ghContext, runner, version, client, artifactPath) 339 | stmt, err := env.GenerateProvenanceStatement(ctx, fps) 340 | if !assert.NoError(err) { 341 | return 342 | } 343 | 344 | binaryName := "slsa-provenance" 345 | binaryPath := path.Join(artifactPath, binaryName) 346 | readmeName := "README.md" 347 | readmePath := path.Join(artifactPath, readmeName) 348 | 349 | assert.Len(stmt.Subject, 2) 350 | assertSubject(assert, stmt.Subject, binaryName, binaryPath) 351 | assertSubject(assert, stmt.Subject, readmeName, readmePath) 352 | 353 | assert.Equal(intoto.SlsaPredicateType, stmt.PredicateType) 354 | assert.Equal(intoto.StatementType, stmt.Type) 355 | 356 | predicate := stmt.Predicate 357 | assert.Equal(fmt.Sprintf("%s%s", repoURL, github.HostedIDSuffix), predicate.ID) 358 | assert.Equal(materials, predicate.Materials) 359 | assert.Equal(fmt.Sprintf("%s%s", repoURL, github.HostedIDSuffix), predicate.Builder.ID) 360 | assert.Equal(github.BuildType, predicate.BuildType) 361 | 362 | assertMetadata(assert, predicate.Metadata, ghContext, repoURL) 363 | assertInvocation(assert, predicate.Invocation) 364 | 365 | stmtPath := path.Join(artifactPath, "provenance.json") 366 | 367 | err = env.PersistProvenanceStatement(ctx, stmt, stmtPath) 368 | assert.NoError(err) 369 | } 370 | 371 | func TestGenerateProvenanceFromGitHubReleaseErrors(t *testing.T) { 372 | assert := assert.New(t) 373 | 374 | ctx := context.Background() 375 | os.Setenv("GITHUB_ACTIONS", "true") 376 | 377 | ghContext := github.Context{ 378 | RunID: "1029384756", 379 | RepositoryOwner: "philips-labs", 380 | Repository: "philips-labs/slsa-provenance-action", 381 | Event: []byte(pushGitHubEvent), 382 | EventName: "push", 383 | SHA: "849fb987efc0c0fc72e26a38f63f0c00225132be", 384 | } 385 | 386 | _, filename, _, _ := runtime.Caller(0) 387 | rootDir := path.Join(path.Dir(filename), "../..") 388 | client := github.NewReleaseClient(nil) 389 | 390 | version := fmt.Sprintf("v0.0.0-rel-test-%d", time.Now().UnixNano()) 391 | 392 | env := github.NewReleaseEnvironment(ghContext, github.RunnerContext{}, version, client, rootDir) 393 | 394 | fps := intoto.NewFilePathSubjecter(rootDir) 395 | stmt, err := env.GenerateProvenanceStatement(ctx, fps) 396 | assert.EqualError(err, "artifactPath has to be an empty directory") 397 | assert.Nil(stmt) 398 | 399 | fps = intoto.NewFilePathSubjecter(path.Join(rootDir, "README.md")) 400 | env = github.NewReleaseEnvironment(ghContext, github.RunnerContext{}, version, client, path.Join(rootDir, "README.md")) 401 | stmt, err = env.GenerateProvenanceStatement(ctx, fps) 402 | assert.EqualError(err, fmt.Sprintf("mkdir %s: not a directory", path.Join(rootDir, "README.md"))) 403 | assert.Nil(stmt) 404 | } 405 | 406 | func assertInvocation(assert *assert.Assertions, recipe intoto.Invocation) { 407 | assert.Equal(".github/workflows/build.yml", recipe.ConfigSource.EntryPoint) 408 | assert.Nil(recipe.Environment) 409 | assert.Nil(recipe.Parameters) 410 | } 411 | 412 | func assertMetadata(assert *assert.Assertions, meta intoto.Metadata, gh github.Context, repoURL string) { 413 | bft, err := time.Parse(time.RFC3339, meta.BuildFinishedOn) 414 | assert.NoError(err) 415 | assert.WithinDuration(time.Now().UTC(), bft, 1200*time.Millisecond) 416 | assert.Equal(fmt.Sprintf("%s/%s/%s", repoURL, "actions/runs", gh.RunID), meta.BuildInvocationID) 417 | assert.Equal(true, meta.Completeness.Parameters) 418 | assert.Equal(false, meta.Completeness.Environment) 419 | assert.Equal(false, meta.Completeness.Materials) 420 | assert.Equal(false, meta.Reproducible) 421 | } 422 | 423 | func assertSubject(assert *assert.Assertions, subject []intoto.Subject, binaryName, binaryPath string) { 424 | binary, err := os.ReadFile(binaryPath) 425 | if !assert.NoError(err) { 426 | return 427 | } 428 | 429 | shaHex := intoto.ShaSum256HexEncoded(binary) 430 | assert.Contains(subject, intoto.Subject{Name: binaryName, Digest: intoto.DigestSet{"sha256": shaHex}}) 431 | } 432 | -------------------------------------------------------------------------------- /pkg/github/releases.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "path" 10 | "time" 11 | 12 | "github.com/google/go-github/v41/github" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | // TokenRetriever allows to implement a function to retrieve the token 17 | // The token is placed in a StaticTokenSource to authenticate using oauth2. 18 | type TokenRetriever func() string 19 | 20 | // NewOAuth2Client creates a oauth2 client using the token from the TokenRetriever 21 | func NewOAuth2Client(ctx context.Context, tokenRetriever TokenRetriever) *http.Client { 22 | ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: tokenRetriever()}) 23 | return oauth2.NewClient(ctx, ts) 24 | } 25 | 26 | // ReleaseClient GitHub client adding convenience methods to add provenance to a release 27 | type ReleaseClient struct { 28 | *github.Client 29 | httpClient *http.Client 30 | } 31 | 32 | // NewReleaseClient create new ReleaseClient instance 33 | func NewReleaseClient(httpClient *http.Client) *ReleaseClient { 34 | return &ReleaseClient{ 35 | Client: github.NewClient(httpClient), 36 | httpClient: httpClient, 37 | } 38 | } 39 | 40 | // FetchRelease get the release by its tagName 41 | func (p *ReleaseClient) FetchRelease(ctx context.Context, owner, repo, tagName string) (*github.RepositoryRelease, error) { 42 | listCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) 43 | defer cancel() 44 | 45 | allReleases, err := p.ListReleases(listCtx, owner, repo, github.ListOptions{PerPage: 25}) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | var rel *github.RepositoryRelease 51 | for _, r := range allReleases { 52 | if *r.TagName == tagName { 53 | rel = r 54 | break 55 | } 56 | } 57 | 58 | return rel, nil 59 | } 60 | 61 | // DownloadReleaseAssets download the assets for a release at the given storage location. 62 | func (p *ReleaseClient) DownloadReleaseAssets(ctx context.Context, owner, repo string, releaseID int64, storageLocation string) ([]*github.ReleaseAsset, error) { 63 | listCtx, cancel := context.WithTimeout(ctx, 1*time.Minute) 64 | defer cancel() 65 | 66 | allAssets, err := p.ListReleaseAssets(listCtx, owner, repo, releaseID, github.ListOptions{PerPage: 10}) 67 | if err != nil { 68 | return nil, err 69 | } 70 | assets := make([]*github.ReleaseAsset, len(allAssets)) 71 | 72 | downloadCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) 73 | defer cancel() 74 | 75 | err = os.MkdirAll(storageLocation, 0755) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | for i, releaseAsset := range allAssets { 81 | asset, _, err := p.Repositories.DownloadReleaseAsset(downloadCtx, owner, repo, releaseAsset.GetID(), p.httpClient) 82 | if err != nil { 83 | return nil, err 84 | } 85 | err = saveFile(path.Join(storageLocation, releaseAsset.GetName()), asset) 86 | if err != nil { 87 | return nil, err 88 | } 89 | assets[i] = releaseAsset 90 | } 91 | 92 | return assets, nil 93 | } 94 | 95 | func saveFile(path string, content io.ReadCloser) error { 96 | assetFile, err := os.Create(path) 97 | if err != nil { 98 | return err 99 | } 100 | defer assetFile.Close() 101 | defer content.Close() 102 | 103 | _, err = io.Copy(assetFile, content) 104 | 105 | return err 106 | } 107 | 108 | // AddProvenanceToRelease uploads the provenance for the given release 109 | func (p *ReleaseClient) AddProvenanceToRelease(ctx context.Context, owner, repo string, releaseID int64, provenance *os.File) (*github.ReleaseAsset, error) { 110 | client := p.Client 111 | 112 | stat, err := provenance.Stat() 113 | if err != nil { 114 | return nil, err 115 | } 116 | uploadOptions := &github.UploadOptions{Name: stat.Name(), MediaType: "application/json; charset=utf-8"} 117 | uploadCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) 118 | defer cancel() 119 | 120 | asset, _, err := client.Repositories.UploadReleaseAsset(uploadCtx, owner, repo, releaseID, uploadOptions, provenance) 121 | return asset, err 122 | } 123 | 124 | // ListReleaseAssets will retrieve the list of all release assets. 125 | func (p *ReleaseClient) ListReleaseAssets(ctx context.Context, owner, repo string, releaseID int64, listOptions github.ListOptions) ([]*github.ReleaseAsset, error) { 126 | var allAssets []*github.ReleaseAsset 127 | for { 128 | select { 129 | case <-ctx.Done(): 130 | return nil, ctx.Err() 131 | default: 132 | } 133 | assets, resp, err := p.Repositories.ListReleaseAssets(ctx, owner, repo, releaseID, &listOptions) 134 | if err != nil { 135 | return nil, fmt.Errorf("failed to list release assets: %w", err) 136 | } 137 | allAssets = append(allAssets, assets...) 138 | if resp.NextPage == 0 { 139 | break 140 | } 141 | listOptions.Page = resp.NextPage 142 | } 143 | return allAssets, nil 144 | } 145 | 146 | // ListReleases will retrieve the list of all releases. 147 | func (p *ReleaseClient) ListReleases(ctx context.Context, owner, repo string, listOptions github.ListOptions) ([]*github.RepositoryRelease, error) { 148 | var allReleases []*github.RepositoryRelease 149 | for { 150 | select { 151 | case <-ctx.Done(): 152 | return nil, ctx.Err() 153 | default: 154 | } 155 | releases, resp, err := p.Repositories.ListReleases(ctx, owner, repo, &listOptions) 156 | if err != nil { 157 | return nil, fmt.Errorf("failed to list releases: %w", err) 158 | } 159 | allReleases = append(allReleases, releases...) 160 | if resp.NextPage == 0 { 161 | break 162 | } 163 | listOptions.Page = resp.NextPage 164 | } 165 | return allReleases, nil 166 | } 167 | -------------------------------------------------------------------------------- /pkg/github/releases_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "path" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | 13 | gh "github.com/google/go-github/v41/github" 14 | "github.com/stretchr/testify/assert" 15 | 16 | "github.com/philips-labs/slsa-provenance-action/internal/transport" 17 | "github.com/philips-labs/slsa-provenance-action/pkg/github" 18 | ) 19 | 20 | const ( 21 | owner = "philips-labs" 22 | repo = "slsa-provenance-action" 23 | ) 24 | 25 | const ( 26 | releasesAPI = "https://api.github.com/repos/philips-labs/slsa-provenance-action/releases" 27 | firstRelease = "v0.1.1" 28 | assetsURLTemplate = "GET: %s/assets/%d" 29 | ) 30 | 31 | var githubToken string 32 | 33 | func tokenRetriever() string { 34 | return os.Getenv("GITHUB_TOKEN") 35 | } 36 | 37 | func stringPointer(s string) *string { 38 | return &s 39 | } 40 | 41 | func boolPointer(b bool) *bool { 42 | return &b 43 | } 44 | 45 | func init() { 46 | githubToken = tokenRetriever() 47 | } 48 | 49 | func TestFetchRelease(t *testing.T) { 50 | assert := assert.New(t) 51 | ctx := context.Background() 52 | 53 | client, requestLogger := createReleaseClient(ctx) 54 | release, err := client.FetchRelease(ctx, owner, repo, firstRelease) 55 | 56 | if !assert.NoError(err) && !assert.NotNil(release) { 57 | return 58 | } 59 | assert.Equal(int64(51517953), release.GetID()) 60 | assert.Equal(firstRelease, release.GetTagName()) 61 | assert.Len(release.Assets, 7) 62 | assert.Equal(fmt.Sprintf("GET: %s?per_page=25\n", releasesAPI), requestLogger.String()) 63 | } 64 | 65 | func TestDownloadReleaseAssets(t *testing.T) { 66 | if tokenRetriever() == "" { 67 | t.Skip("skipping as GITHUB_TOKEN environment variable isn't set") 68 | } 69 | assert := assert.New(t) 70 | 71 | ctx := context.Background() 72 | 73 | api := releasesAPI 74 | version := firstRelease 75 | client, requestLogger := createReleaseClient(ctx) 76 | release, _, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, version) 77 | if !assert.NoError(err) || !assert.NotNil(release) { 78 | return 79 | } 80 | assert.Equal(int64(51517953), release.GetID()) 81 | assert.NotEmpty(requestLogger) 82 | assert.Equal(fmt.Sprintf("GET: %s/tags/%s\n", api, version), requestLogger.String()) 83 | requestLogger.Reset() 84 | 85 | _, filename, _, _ := runtime.Caller(0) 86 | rootDir := path.Join(path.Dir(filename), "../..") 87 | artifactPath := path.Join(rootDir, "download-test") 88 | assets, err := client.DownloadReleaseAssets(ctx, owner, repo, release.GetID(), artifactPath) 89 | if !assert.NoError(err) { 90 | return 91 | } 92 | assert.NotEmpty(requestLogger) 93 | assert.Contains(requestLogger.String(), fmt.Sprintf("GET: %s/%d/assets?per_page=10", api, release.GetID())) 94 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[0].GetID())) 95 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[1].GetID())) 96 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[2].GetID())) 97 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[3].GetID())) 98 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[4].GetID())) 99 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[5].GetID())) 100 | assert.Contains(requestLogger.String(), fmt.Sprintf(assetsURLTemplate, api, assets[6].GetID())) 101 | assert.Contains(requestLogger.String(), "GET: https://objects.githubusercontent.com/github-production-release-asset") 102 | 103 | defer func() { 104 | _ = os.RemoveAll(artifactPath) 105 | }() 106 | 107 | assert.Len(assets, 7) 108 | assert.Equal("checksums.txt", assets[0].GetName()) 109 | assert.FileExists(path.Join(artifactPath, assets[0].GetName())) 110 | assert.Equal("slsa-provenance_0.1.1_linux_amd64.tar.gz", assets[1].GetName()) 111 | assert.FileExists(path.Join(artifactPath, assets[1].GetName())) 112 | assert.Equal("slsa-provenance_0.1.1_linux_arm64.tar.gz", assets[2].GetName()) 113 | assert.FileExists(path.Join(artifactPath, assets[2].GetName())) 114 | assert.Equal("slsa-provenance_0.1.1_macOS_amd64.tar.gz", assets[3].GetName()) 115 | assert.FileExists(path.Join(artifactPath, assets[3].GetName())) 116 | assert.Equal("slsa-provenance_0.1.1_macOS_arm64.tar.gz", assets[4].GetName()) 117 | assert.FileExists(path.Join(artifactPath, assets[4].GetName())) 118 | assert.Equal("slsa-provenance_0.1.1_windows_amd64.zip", assets[5].GetName()) 119 | assert.FileExists(path.Join(artifactPath, assets[5].GetName())) 120 | assert.Equal("slsa-provenance_0.1.1_windows_arm64.zip", assets[6].GetName()) 121 | assert.FileExists(path.Join(artifactPath, assets[6].GetName())) 122 | } 123 | 124 | func TestAddProvenanceToRelease(t *testing.T) { 125 | assert := assert.New(t) 126 | 127 | if githubToken == "" { 128 | t.Skip("skipping as GITHUB_TOKEN environment variable isn't set") 129 | } 130 | 131 | _, filename, _, _ := runtime.Caller(0) 132 | rootDir := path.Join(path.Dir(filename), "../..") 133 | provenanceFile := path.Join(rootDir, ".github/test_resource/example_provenance.json") 134 | 135 | ctx := context.Background() 136 | tc := github.NewOAuth2Client(ctx, tokenRetriever) 137 | client := github.NewReleaseClient(tc) 138 | 139 | releaseId, err := createGitHubRelease(ctx, client, owner, repo, "v0.0.0-test") 140 | if !assert.NoError(err) { 141 | return 142 | } 143 | defer func() { 144 | _, err := client.Repositories.DeleteRelease(ctx, owner, repo, releaseId) 145 | assert.NoError(err) 146 | }() 147 | 148 | provenance, err := os.Open(provenanceFile) 149 | if !assert.NoError(err) || !assert.NotNil(provenance) { 150 | return 151 | } 152 | defer provenance.Close() 153 | 154 | stat, err := provenance.Stat() 155 | if !assert.NoError(err) || !assert.NotNil(stat) { 156 | return 157 | } 158 | assert.Equal("example_provenance.json", stat.Name()) 159 | 160 | asset, err := client.AddProvenanceToRelease(ctx, owner, repo, releaseId, provenance) 161 | if !assert.NoError(err) || !assert.NotNil(asset) { 162 | return 163 | } 164 | assert.Equal(stat.Name(), asset.GetName()) 165 | assert.Equal("application/json; charset=utf-8", asset.GetContentType()) 166 | } 167 | 168 | func TestListReleaseAssets(t *testing.T) { 169 | assert := assert.New(t) 170 | ctx := context.Background() 171 | 172 | api := releasesAPI + "/51517953/assets" 173 | 174 | client, requestLogger := createReleaseClient(ctx) 175 | opt := gh.ListOptions{PerPage: 4} 176 | assets, err := client.ListReleaseAssets(ctx, owner, repo, 51517953, opt) 177 | if !assert.NoError(err) { 178 | return 179 | } 180 | assert.Len(assets, 7) 181 | assert.NotEmpty(requestLogger) 182 | assert.Equal(expectedRequestPages("GET", api, opt.PerPage, 2), requestLogger.String()) 183 | 184 | opt = gh.ListOptions{PerPage: 10} 185 | assets, err = client.ListReleaseAssets(ctx, owner, repo, 51517953, opt) 186 | if !assert.NoError(err) { 187 | return 188 | } 189 | assert.Len(assets, 7) 190 | 191 | _, err = client.ListReleaseAssets(ctx, owner, repo, 0, opt) 192 | assert.EqualError(err, "failed to list release assets: GET "+releasesAPI+"/0/assets?per_page=10: 404 Not Found []") 193 | } 194 | 195 | func TestListReleases(t *testing.T) { 196 | assert := assert.New(t) 197 | ctx := context.Background() 198 | 199 | api := releasesAPI 200 | 201 | client, requestLogger := createReleaseClient(ctx) 202 | opt := gh.ListOptions{PerPage: 25} 203 | releases, err := client.ListReleases(ctx, owner, repo, opt) 204 | if !assert.NoError(err) { 205 | return 206 | } 207 | assert.NotEmpty(requestLogger) 208 | assert.Equal(expectedRequestPages("GET", api, opt.PerPage, 1), requestLogger.String()) 209 | assert.GreaterOrEqual(len(releases), 18) 210 | 211 | opt = gh.ListOptions{PerPage: 2} 212 | releases, err = client.ListReleases(ctx, owner, repo, opt) 213 | if !assert.NoError(err) { 214 | return 215 | } 216 | assert.GreaterOrEqual(len(releases), 18) 217 | 218 | opt = gh.ListOptions{PerPage: 2} 219 | _, err = client.ListReleases(ctx, owner, repo+"-fake", opt) 220 | assert.EqualError(err, "failed to list releases: GET https://api.github.com/repos/philips-labs/slsa-provenance-action-fake/releases?per_page=2: 404 Not Found []") 221 | } 222 | 223 | func expectedRequestPages(method string, api string, size int, count int) string { 224 | w := &strings.Builder{} 225 | 226 | for i := 1; i <= count; i++ { 227 | if i == 1 { 228 | fmt.Fprintf(w, "%s: %s?per_page=%d\n", method, api, size) 229 | } else { 230 | fmt.Fprintf(w, "%s: %s?page=%d&per_page=%d\n", method, api, i, size) 231 | } 232 | } 233 | 234 | return w.String() 235 | } 236 | 237 | func createReleaseClient(ctx context.Context) (*github.ReleaseClient, *strings.Builder) { 238 | var client *github.ReleaseClient 239 | var writer strings.Builder 240 | 241 | if githubToken != "" { 242 | tc := github.NewOAuth2Client(ctx, tokenRetriever) 243 | tc.Transport = transport.TeeRoundTripper{ 244 | RoundTripper: tc.Transport, 245 | Writer: &writer, 246 | } 247 | client = github.NewReleaseClient(tc) 248 | } else { 249 | client = github.NewReleaseClient(&http.Client{ 250 | Transport: transport.TeeRoundTripper{ 251 | RoundTripper: http.DefaultTransport, 252 | Writer: &writer, 253 | }, 254 | }) 255 | } 256 | return client, &writer 257 | } 258 | 259 | func createGitHubRelease(ctx context.Context, client *github.ReleaseClient, owner, repo, version string, assets ...string) (int64, error) { 260 | rel, _, err := client.Repositories.CreateRelease( 261 | ctx, 262 | owner, 263 | repo, 264 | &gh.RepositoryRelease{TagName: stringPointer(version), Name: stringPointer(version), Draft: boolPointer(true), Prerelease: boolPointer(true)}, 265 | ) 266 | if err != nil { 267 | return 0, err 268 | } 269 | 270 | for _, a := range assets { 271 | asset, err := os.Open(a) 272 | if err != nil { 273 | return 0, err 274 | } 275 | defer asset.Close() 276 | 277 | client.AddProvenanceToRelease(ctx, owner, repo, rel.GetID(), asset) 278 | } 279 | 280 | return rel.GetID(), nil 281 | } 282 | -------------------------------------------------------------------------------- /pkg/intoto/intoto.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | ) 8 | 9 | const ( 10 | // SlsaPredicateType the predicate type for SLSA intoto statements 11 | SlsaPredicateType = "https://slsa.dev/provenance/v0.2" 12 | // StatementType the type of the intoto statement 13 | StatementType = "https://in-toto.io/Statement/v0.1" 14 | ) 15 | 16 | // Provenancer generates provenance statements for given artifacts 17 | type Provenancer interface { 18 | GenerateProvenanceStatement(ctx context.Context, subjecter Subjecter) (*Statement, error) 19 | PersistProvenanceStatement(ctx context.Context, stmt *Statement, path string) error 20 | } 21 | 22 | // Envelope wraps an in-toto statement to be able to attach signatures to the Statement 23 | type Envelope struct { 24 | PayloadType string `json:"payloadType"` 25 | Payload string `json:"payload"` 26 | Signatures []interface{} `json:"signatures"` 27 | } 28 | 29 | // SLSAProvenanceStatement builds a in-toto statement with predicate type https://slsa.dev/provenance/v0.1 30 | func SLSAProvenanceStatement(opts ...StatementOption) *Statement { 31 | stmt := &Statement{PredicateType: SlsaPredicateType, Type: StatementType} 32 | for _, opt := range opts { 33 | opt(stmt) 34 | } 35 | return stmt 36 | } 37 | 38 | // StatementOption option flag to build the Statement 39 | type StatementOption func(*Statement) 40 | 41 | // WithSubject sets the Statement subject to the provided value 42 | func WithSubject(s []Subject) StatementOption { 43 | return func(st *Statement) { 44 | st.Subject = s 45 | } 46 | } 47 | 48 | // WithBuilder sets the Statement builder with the given ID 49 | func WithBuilder(id string) StatementOption { 50 | return func(st *Statement) { 51 | st.Predicate.Builder = Builder{ID: id} 52 | } 53 | } 54 | 55 | // WithMetadata sets the Predicate Metadata using the buildInvocationID and the current time 56 | func WithMetadata(buildInvocationID string) StatementOption { 57 | return func(s *Statement) { 58 | s.Predicate.Metadata = Metadata{ 59 | Completeness: Completeness{ 60 | Parameters: true, 61 | Environment: false, 62 | Materials: false, 63 | }, 64 | Reproducible: false, 65 | BuildInvocationID: buildInvocationID, 66 | BuildFinishedOn: time.Now().UTC().Format(time.RFC3339), 67 | } 68 | } 69 | } 70 | 71 | // WithInvocation sets the Predicate Invocation and Materials 72 | func WithInvocation(buildType, entryPoint string, environment json.RawMessage, parameters json.RawMessage, materials []Item) StatementOption { 73 | return func(s *Statement) { 74 | s.Predicate.BuildType = buildType 75 | s.Predicate.Invocation = Invocation{ 76 | ConfigSource: ConfigSource{ 77 | EntryPoint: entryPoint, 78 | URI: materials[0].URI, 79 | Digest: materials[0].Digest, 80 | }, 81 | Parameters: parameters, 82 | Environment: environment, 83 | } 84 | s.Predicate.Materials = append(s.Predicate.Materials, materials...) 85 | } 86 | } 87 | 88 | // Statement The Statement is the middle layer of the attestation, binding it to a particular subject and unambiguously identifying the types of the predicate. 89 | type Statement struct { 90 | Type string `json:"_type"` 91 | Subject []Subject `json:"subject"` 92 | PredicateType string `json:"predicateType"` 93 | Predicate Predicate `json:"predicate"` 94 | } 95 | 96 | // Subject The software artifacts that the attestation applies to. 97 | type Subject struct { 98 | Name string `json:"name"` 99 | Digest DigestSet `json:"digest"` 100 | } 101 | 102 | // Predicate This predicate follows the in-toto attestation parsing rules. 103 | // 104 | // https://github.com/in-toto/attestation/blob/main/spec/README.md#parsing-rules 105 | // 106 | // The Predicate is the innermost layer of the attestation, containing arbitrary metadata about the Statement's subject. 107 | // 108 | // A predicate has a required predicateType (TypeURI) identifying what the predicate means, plus an optional predicate (object) containing additional, type-dependent parameters. 109 | type Predicate struct { 110 | Builder `json:"builder"` 111 | BuildType string `json:"buildType"` 112 | Invocation `json:"invocation"` 113 | BuildConfig *BuildConfig `json:"build_config,omitempty"` 114 | Metadata `json:"metadata,omitempty"` 115 | Materials []Item `json:"materials"` 116 | } 117 | 118 | // BuildConfig Lists the steps in the build. 119 | // If invocation.sourceConfig is not available, buildConfig can be used to verify information about the build. 120 | type BuildConfig struct { 121 | } 122 | 123 | // Builder Identifies the entity that executed the recipe, which is trusted to have correctly performed the operation and populated this provenance. 124 | // The identity MUST reflect the trust base that consumers care about. How detailed to be is a judgement call. For example, GitHub Actions supports both GitHub-hosted runners and self-hosted runners. The GitHub-hosted runner might be a single identity because, it's all GitHub from the consumer's perspective. Meanwhile, each self-hosted runner might have its own identity because not all runners are trusted by all consumers. 125 | // 126 | // Consumers MUST accept only specific (signer, builder) pairs. For example, the "GitHub" can sign provenance for the "GitHub Actions" builder, and "Google" can sign provenance for the "Google Cloud Build" builder, but "GitHub" cannot sign for the "Google Cloud Build" builder. 127 | // 128 | // Design rationale: The builder is distinct from the signer because one signer may generate attestations for more than one builder, as in the GitHub Actions example above. The field is required, even if it is implicit from the signer, to aid readability and debugging. It is an object to allow additional fields in the future, in case one URI is not sufficient. 129 | type Builder struct { 130 | ID string `json:"id"` 131 | } 132 | 133 | // Metadata Other properties of the build. 134 | type Metadata struct { 135 | BuildInvocationID string `json:"buildInvocationId"` 136 | // BuildStartedOn not defined as it's not available from a GitHub Action. 137 | BuildFinishedOn string `json:"buildFinishedOn"` 138 | Completeness `json:"completeness"` 139 | Reproducible bool `json:"reproducible"` 140 | } 141 | 142 | // Invocation Identifies the configuration used for the build. When combined with materials, this SHOULD fully describe the build, such that re-running this recipe results in bit-for-bit identical output (if the build is reproducible). 143 | type Invocation struct { 144 | ConfigSource ConfigSource `json:"configSource"` 145 | Parameters json.RawMessage `json:"parameters"` 146 | Environment json.RawMessage `json:"environment"` 147 | } 148 | 149 | // ConfigSource Describes where the config file that kicked off the build came from. 150 | // This is effectively a pointer to the source where buildConfig came from. 151 | type ConfigSource struct { 152 | EntryPoint string `json:"entryPoint"` 153 | URI string `json:"uri,omitempty"` 154 | Digest DigestSet `json:"digest,omitempty"` 155 | } 156 | 157 | // Completeness Indicates that the builder claims certain fields in this message to be complete. 158 | type Completeness struct { 159 | Parameters bool `json:"parameters"` 160 | Environment bool `json:"environment"` 161 | Materials bool `json:"materials"` 162 | } 163 | 164 | // DigestSet Collection of cryptographic digests for the contents of this artifact. 165 | type DigestSet map[string]string 166 | 167 | // Item The material used as input for producing the output artifact (subject). 168 | type Item struct { 169 | URI string `json:"uri"` 170 | Digest DigestSet `json:"digest"` 171 | } 172 | -------------------------------------------------------------------------------- /pkg/intoto/intoto_test.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | const ( 13 | repoURI = "https://github.com/philips-labs/slsa-provenance-action" 14 | builderID = repoURI + "/Attestations/GitHubHostedActions@v1" 15 | buildInvocationID = repoURI + "/actions/runs/123498765" 16 | buildType = "https://github.com/Attestations/GitHubActionsWorkflow@v1" 17 | ) 18 | 19 | func TestSLSAProvenanceStatement(t *testing.T) { 20 | assert := assert.New(t) 21 | 22 | stmt := SLSAProvenanceStatement() 23 | assert.Equal(SlsaPredicateType, stmt.PredicateType) 24 | assert.Equal(StatementType, stmt.Type) 25 | assert.Len(stmt.Subject, 0) 26 | 27 | stmt = SLSAProvenanceStatement( 28 | WithSubject(make([]Subject, 4)), 29 | ) 30 | assert.Equal(SlsaPredicateType, stmt.PredicateType) 31 | assert.Equal(StatementType, stmt.Type) 32 | assert.Len(stmt.Subject, 4) 33 | 34 | stmt = SLSAProvenanceStatement( 35 | WithSubject(make([]Subject, 3)), 36 | WithBuilder(builderID), 37 | ) 38 | assert.Equal(SlsaPredicateType, stmt.PredicateType) 39 | assert.Equal(StatementType, stmt.Type) 40 | assert.Len(stmt.Subject, 3) 41 | assert.Equal(builderID, stmt.Predicate.Builder.ID) 42 | 43 | stmt = SLSAProvenanceStatement( 44 | WithMetadata(buildInvocationID), 45 | WithBuilder(builderID), 46 | ) 47 | m := stmt.Predicate.Metadata 48 | assert.Equal(SlsaPredicateType, stmt.PredicateType) 49 | assert.Equal(StatementType, stmt.Type) 50 | assert.Len(stmt.Subject, 0) 51 | assert.Equal(builderID, stmt.Predicate.Builder.ID) 52 | assert.Equal(buildInvocationID, m.BuildInvocationID) 53 | bft, err := time.Parse(time.RFC3339, m.BuildFinishedOn) 54 | assert.NoError(err) 55 | assert.WithinDuration(time.Now().UTC(), bft, 1200*time.Millisecond) 56 | assert.Equal(Completeness{Parameters: true, Environment: false, Materials: false}, stmt.Predicate.Metadata.Completeness) 57 | assert.False(m.Reproducible) 58 | 59 | provenanceActionMaterial := []Item{ 60 | { 61 | URI: "git+https://github.com/philips-labs/slsa-provenance-action", 62 | Digest: DigestSet{"sha1": "c4f679f131dfb7f810fd411ac9475549d1c393df"}, 63 | }, 64 | } 65 | 66 | stmt = SLSAProvenanceStatement( 67 | WithSubject([]Subject{{Name: "salsa.txt", Digest: DigestSet{"sha256": "f8161d035cdf328c7bb124fce192cb90b603f34ca78d73e33b736b4f6bddf993"}}}), 68 | WithBuilder(builderID), 69 | WithMetadata("https://github.com/philips-labs/slsa-provenance-action/actions/runs/1303916967"), 70 | WithInvocation( 71 | buildType, 72 | "ci.yaml:build", 73 | nil, 74 | nil, 75 | provenanceActionMaterial, 76 | ), 77 | ) 78 | assertStatement(assert, stmt, builderID, buildType, provenanceActionMaterial, nil) 79 | } 80 | 81 | func TestSLSAProvenanceStatementJSON(t *testing.T) { 82 | assert := assert.New(t) 83 | 84 | materialJSON := `[ 85 | { 86 | "uri": "git+https://github.com/philips-labs/slsa-provenance-action", 87 | "digest": { 88 | "sha1": "a3bc1c27230caa1cc3c27961f7e9cab43cd208dc" 89 | } 90 | } 91 | ]` 92 | parametersJSON := `{ 93 | "inputs": { 94 | "skip_integration": true 95 | } 96 | }` 97 | buildFinishedOn := time.Now().UTC().Format(time.RFC3339) 98 | 99 | var material []Item 100 | err := json.Unmarshal([]byte(materialJSON), &material) 101 | assert.NoError(err) 102 | 103 | jsonStatement := fmt.Sprintf(`{ 104 | "_type": "https://in-toto.io/Statement/v0.1", 105 | "subject": [ 106 | { 107 | "name": "salsa.txt", 108 | "digest": { 109 | "sha256": "f8161d035cdf328c7bb124fce192cb90b603f34ca78d73e33b736b4f6bddf993" 110 | } 111 | } 112 | ], 113 | "predicateType": "https://slsa.dev/provenance/v0.2", 114 | "predicate": { 115 | "builder": { 116 | "id": "%s" 117 | }, 118 | "buildType": "%s", 119 | "invocation": { 120 | "configSource": { 121 | "entryPoint": "ci.yaml:build", 122 | "uri": "git+https://github.com/philips-labs/slsa-provenance-action", 123 | "digest": { 124 | "sha1": "a3bc1c27230caa1cc3c27961f7e9cab43cd208dc" 125 | } 126 | }, 127 | "parameters": %s, 128 | "environment": null 129 | }, 130 | "metadata": { 131 | "buildInvocationId": "https://github.com/philips-labs/slsa-provenance-action/actions/runs/1303916967", 132 | "buildFinishedOn": "%s", 133 | "completeness": { 134 | "parameters": true, 135 | "environment": false, 136 | "materials": false 137 | }, 138 | "reproducible": false 139 | }, 140 | "materials": %s 141 | } 142 | }`, builderID, buildType, parametersJSON, buildFinishedOn, materialJSON) 143 | 144 | var stmt Statement 145 | err = json.Unmarshal([]byte(jsonStatement), &stmt) 146 | assert.NoError(err) 147 | assertStatement(assert, &stmt, builderID, buildType, material, []byte(parametersJSON)) 148 | 149 | newStmt := SLSAProvenanceStatement( 150 | WithSubject([]Subject{{Name: "salsa.txt", Digest: DigestSet{"sha256": "f8161d035cdf328c7bb124fce192cb90b603f34ca78d73e33b736b4f6bddf993"}}}), 151 | WithBuilder(builderID), 152 | WithMetadata("https://github.com/philips-labs/slsa-provenance-action/actions/runs/1303916967"), 153 | WithInvocation(buildType, "ci.yaml:build", nil, []byte(parametersJSON), material), 154 | ) 155 | 156 | newStmtJSON, err := json.MarshalIndent(newStmt, "", "\t") 157 | assert.NoError(err) 158 | 159 | assert.Equal(jsonStatement, string(newStmtJSON)) 160 | } 161 | 162 | func assertStatement(assert *assert.Assertions, stmt *Statement, builderID, buildType string, material []Item, parameters json.RawMessage) { 163 | i := stmt.Predicate.Invocation 164 | assert.Equal(SlsaPredicateType, stmt.PredicateType) 165 | assert.Equal(StatementType, stmt.Type) 166 | assert.Len(stmt.Subject, 1) 167 | assert.Equal(Subject{Name: "salsa.txt", Digest: DigestSet{"sha256": "f8161d035cdf328c7bb124fce192cb90b603f34ca78d73e33b736b4f6bddf993"}}, stmt.Subject[0]) 168 | assert.Equal(builderID, stmt.Predicate.Builder.ID) 169 | assert.Equal(buildType, stmt.Predicate.BuildType) 170 | assertConfigSource(assert, i.ConfigSource, stmt.Predicate.Materials) 171 | assert.Nil(stmt.Predicate.BuildConfig) 172 | assert.Equal(parameters, i.Parameters) 173 | assert.Equal(material, stmt.Predicate.Materials) 174 | assertMetadata(assert, stmt.Predicate.Metadata) 175 | } 176 | 177 | func assertConfigSource(assert *assert.Assertions, cs ConfigSource, materials []Item) { 178 | assert.Equal("ci.yaml:build", cs.EntryPoint) 179 | assert.Equal(materials[0].URI, cs.URI) 180 | assert.Equal(materials[0].Digest, cs.Digest) 181 | } 182 | 183 | func assertMetadata(assert *assert.Assertions, md Metadata) { 184 | assert.Equal("https://github.com/philips-labs/slsa-provenance-action/actions/runs/1303916967", md.BuildInvocationID) 185 | bft, err := time.Parse(time.RFC3339, md.BuildFinishedOn) 186 | assert.NoError(err) 187 | assert.WithinDuration(time.Now().UTC(), bft, 1200*time.Millisecond) 188 | assert.True(md.Completeness.Parameters) 189 | assert.False(md.Completeness.Materials) 190 | assert.False(md.Completeness.Environment) 191 | assert.False(md.Reproducible) 192 | } 193 | -------------------------------------------------------------------------------- /pkg/intoto/materials.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // WithMaterials adds additional materials to the predicate 10 | func WithMaterials(materials []Item) StatementOption { 11 | return func(s *Statement) { 12 | s.Predicate.Materials = append(s.Predicate.Materials, materials...) 13 | } 14 | } 15 | 16 | // ReadMaterials reads the material from file 17 | func ReadMaterials(r io.Reader) ([]Item, error) { 18 | var materials []Item 19 | 20 | if err := json.NewDecoder(r).Decode(&materials); err != nil { 21 | return nil, err 22 | } 23 | 24 | for _, material := range materials { 25 | if material.URI == "" { 26 | return nil, fmt.Errorf("empty or missing \"uri\" for material") 27 | } 28 | if len(material.Digest) == 0 { 29 | return nil, fmt.Errorf("empty or missing \"digest\" for material") 30 | } 31 | } 32 | 33 | return materials, nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/intoto/materials_test.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMaterials(t *testing.T) { 11 | assert := assert.New(t) 12 | 13 | validMaterials := strings.NewReader(`[ 14 | { 15 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 16 | "digest": { 17 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 18 | } 19 | } 20 | ]`) 21 | 22 | nonJSON := strings.NewReader(`[ 23 | { 24 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 25 | "digest": { 26 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 27 | } 28 | }`) 29 | 30 | withoutDigest := strings.NewReader(`[ 31 | { 32 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 33 | "not-digest": { 34 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 35 | } 36 | } 37 | ]`) 38 | 39 | withoutURI := strings.NewReader(`[ 40 | { 41 | "digest": { 42 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 43 | } 44 | } 45 | ]`) 46 | 47 | m, err := ReadMaterials(validMaterials) 48 | assert.NoError(err) 49 | assert.Len(m, 1) 50 | 51 | m, err = ReadMaterials(nonJSON) 52 | assert.EqualError(err, "unexpected EOF") 53 | assert.Nil(m) 54 | 55 | m, err = ReadMaterials(withoutDigest) 56 | assert.EqualError(err, "empty or missing \"digest\" for material") 57 | assert.Nil(m) 58 | 59 | m, err = ReadMaterials(withoutURI) 60 | assert.EqualError(err, "empty or missing \"uri\" for material") 61 | assert.Nil(m) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/intoto/subjects.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Subjecter retrieves subjects 12 | type Subjecter interface { 13 | Subjects() ([]Subject, error) 14 | } 15 | 16 | // FilePathSubjecter implements Subjector to retrieve Subject from filepath 17 | type FilePathSubjecter struct { 18 | root string 19 | } 20 | 21 | // NewFilePathSubjecter walks the file or directory at "root" and hashes all files. 22 | func NewFilePathSubjecter(root string) *FilePathSubjecter { 23 | return &FilePathSubjecter{root} 24 | } 25 | 26 | // Subjects walks the file or directory at "root" and hashes all files. 27 | func (f *FilePathSubjecter) Subjects() ([]Subject, error) { 28 | var s []Subject 29 | return s, filepath.Walk(f.root, func(abspath string, info fs.FileInfo, err error) error { 30 | if err != nil { 31 | return err 32 | } 33 | if info.IsDir() { 34 | return nil 35 | } 36 | relpath, err := filepath.Rel(f.root, abspath) 37 | if err != nil { 38 | return err 39 | } 40 | // Note: filepath.Rel() returns "." when "root" and "abspath" point to the same file. 41 | if relpath == "." { 42 | relpath = filepath.Base(f.root) 43 | } 44 | 45 | binary, err := os.ReadFile(abspath) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | shaHex := ShaSum256HexEncoded(binary) 51 | 52 | s = append(s, Subject{Name: relpath, Digest: DigestSet{"sha256": shaHex}}) 53 | return nil 54 | }) 55 | } 56 | 57 | // ShaSum256HexEncoded calculates a SHA256 checksum from the content 58 | func ShaSum256HexEncoded(b []byte) string { 59 | sha := sha256.Sum256(b) 60 | shaHex := hex.EncodeToString(sha[:]) 61 | 62 | return shaHex 63 | } 64 | -------------------------------------------------------------------------------- /pkg/intoto/subjects_test.go: -------------------------------------------------------------------------------- 1 | package intoto 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSubjects(t *testing.T) { 13 | assert := assert.New(t) 14 | 15 | fps := NewFilePathSubjecter("/invalid-path") 16 | s, err := fps.Subjects() 17 | assert.Error(err) 18 | assert.Nil(s) 19 | 20 | _, filename, _, _ := runtime.Caller(0) 21 | rootDir := path.Join(path.Dir(filename), "../..") 22 | artifactPath := path.Join(rootDir, "bin") 23 | binaryName := "slsa-provenance" 24 | binaryPath := path.Join(artifactPath, binaryName) 25 | 26 | fps = NewFilePathSubjecter(artifactPath) 27 | s, err = fps.Subjects() 28 | assert.NoError(err) 29 | assert.NotNil(s) 30 | assert.Len(s, 1) 31 | assertSubject(assert, s, binaryName, binaryPath) 32 | 33 | fps = NewFilePathSubjecter(binaryPath) 34 | s, err = fps.Subjects() 35 | assert.NoError(err) 36 | assert.NotNil(s) 37 | assert.Len(s, 1) 38 | assertSubject(assert, s, binaryName, binaryPath) 39 | 40 | fps = NewFilePathSubjecter(".") 41 | s, err = fps.Subjects() 42 | assert.NoError(err) 43 | assert.NotNil(s) 44 | 45 | assert.Len(s, 6) 46 | assertSubject(assert, s, "intoto_test.go", path.Join(".", "intoto_test.go")) 47 | assertSubject(assert, s, "intoto.go", path.Join(".", "intoto.go")) 48 | assertSubject(assert, s, "subjects_test.go", path.Join(".", "subjects_test.go")) 49 | assertSubject(assert, s, "subjects.go", path.Join(".", "subjects.go")) 50 | assertSubject(assert, s, "materials_test.go", path.Join(".", "materials_test.go")) 51 | assertSubject(assert, s, "materials.go", path.Join(".", "materials.go")) 52 | } 53 | 54 | func assertSubject(assert *assert.Assertions, subject []Subject, binaryName, binaryPath string) { 55 | binary, err := os.ReadFile(binaryPath) 56 | if !assert.NoError(err) { 57 | return 58 | } 59 | 60 | shaHex := ShaSum256HexEncoded(binary) 61 | assert.Contains(subject, Subject{Name: binaryName, Digest: DigestSet{"sha256": shaHex}}) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/oci/auth.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "net/http" 7 | 8 | "github.com/awslabs/amazon-ecr-credential-helper/ecr-login" 9 | "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/api" 10 | "github.com/chrismellard/docker-credential-acr-env/pkg/credhelper" 11 | "github.com/google/go-containerregistry/pkg/authn" 12 | "github.com/google/go-containerregistry/pkg/crane" 13 | "github.com/google/go-containerregistry/pkg/v1/google" 14 | ) 15 | 16 | // WithDefaultClientOptions sets some sane default options for crane to authenticate 17 | // private registries 18 | func WithDefaultClientOptions(ctx context.Context, k8sKeychain, allowInsecure bool) []crane.Option { 19 | opts := []crane.Option{ 20 | crane.WithContext(ctx), 21 | } 22 | 23 | if k8sKeychain { 24 | kc := authn.NewMultiKeychain( 25 | authn.DefaultKeychain, 26 | google.Keychain, 27 | authn.NewKeychainFromHelper(ecr.ECRHelper{ClientFactory: api.DefaultClientFactory{}}), 28 | authn.NewKeychainFromHelper(credhelper.NewACRCredentialsHelper()), 29 | ) 30 | opts = append(opts, crane.WithAuthFromKeychain(kc)) 31 | } else { 32 | opts = append(opts, crane.WithAuthFromKeychain(authn.DefaultKeychain)) 33 | } 34 | 35 | if allowInsecure { 36 | opts = append(opts, crane.WithTransport(&http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}})) // #nosec G402 37 | } 38 | 39 | return opts 40 | } 41 | -------------------------------------------------------------------------------- /pkg/oci/subjects.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/google/go-containerregistry/pkg/crane" 10 | 11 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 12 | ) 13 | 14 | // ContainerSubjecter implements Subjector to retrieve Subject from given container 15 | // if digest is given, it will also compare matches with the given digest 16 | type ContainerSubjecter struct { 17 | options []crane.Option 18 | repo string 19 | digest string 20 | tags []string 21 | } 22 | 23 | // NewContainerSubjecter walks the docker tags to retrieve the digests. 24 | // If digest is non empty string it will be used to compare the rerieved digest 25 | // to match the given digest 26 | func NewContainerSubjecter(repo, digest string, tags []string, options ...crane.Option) *ContainerSubjecter { 27 | return &ContainerSubjecter{options, repo, digest, tags} 28 | } 29 | 30 | // Subjects walks the file or directory at "root" and hashes all files. 31 | func (c *ContainerSubjecter) Subjects() ([]intoto.Subject, error) { 32 | subjects := make([]intoto.Subject, len(c.tags)) 33 | 34 | if c.tags == nil || len(c.tags) == 0 { 35 | c.tags = []string{"latest"} 36 | } 37 | 38 | for i, t := range c.tags { 39 | digest, err := crane.Digest(fmt.Sprintf("%s:%s", c.repo, t), c.options...) 40 | if err != nil { 41 | return nil, err 42 | } 43 | if c.digest != "" && c.digest != digest { 44 | return nil, fmt.Errorf("did not get expected digest, got %s, expected %s", digest, c.digest) 45 | } 46 | digestParts := strings.Split(digest, ":") 47 | subjects[i] = intoto.Subject{ 48 | Name: fmt.Sprintf("%s:%s", c.repo, t), 49 | Digest: intoto.DigestSet{digestParts[0]: digestParts[1]}, 50 | } 51 | } 52 | 53 | return subjects, nil 54 | } 55 | 56 | // ShaSum256HexEncoded calculates a SHA256 checksum from the content 57 | func ShaSum256HexEncoded(b []byte) string { 58 | sha := sha256.Sum256(b) 59 | shaHex := hex.EncodeToString(sha[:]) 60 | 61 | return shaHex 62 | } 63 | -------------------------------------------------------------------------------- /pkg/oci/subjects_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/philips-labs/slsa-provenance-action/pkg/intoto" 12 | ) 13 | 14 | func TestSubjects(t *testing.T) { 15 | assert := assert.New(t) 16 | 17 | repo := "ghcr.io/philips-labs/slsa-provenance" 18 | 19 | opts := WithDefaultClientOptions(context.Background(), false, false) 20 | 21 | errorCases := []struct { 22 | name string 23 | repo string 24 | tags []string 25 | digest string 26 | err string 27 | }{ 28 | { 29 | name: "without arguments", 30 | repo: "", 31 | tags: nil, 32 | digest: "", 33 | err: "parsing reference \":latest\": could not parse reference: :latest", 34 | }, 35 | { 36 | name: "with non existing tag", 37 | repo: repo, 38 | tags: []string{"non-existing"}, 39 | digest: "", 40 | err: "GET https://ghcr.io/v2/philips-labs/slsa-provenance/manifests/non-existing: MANIFEST_UNKNOWN: manifest unknown", 41 | }, 42 | { 43 | name: "invalid digest", 44 | repo: repo, 45 | tags: []string{"v0.4.0"}, 46 | digest: "sha256:284b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a4", 47 | err: "did not get expected digest, got sha256:194b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a3, expected sha256:284b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a4", 48 | }, 49 | } 50 | 51 | happyCases := []struct { 52 | name string 53 | tags []string 54 | digest string 55 | count int 56 | }{ 57 | { 58 | name: "single tag (git tag)", 59 | tags: []string{"v0.4.0"}, 60 | digest: "sha256:194b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a3", 61 | count: 1, 62 | }, { 63 | name: "single tag (commit hash)", 64 | tags: []string{"33ba3da2213c83ce02df0f2f6ba925ec79037f9d"}, 65 | digest: "sha256:194b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a3", 66 | count: 1, 67 | }, { 68 | name: "muliple tags", 69 | tags: []string{"v0.4.0", "33ba3da2213c83ce02df0f2f6ba925ec79037f9d"}, 70 | digest: "sha256:194b471a878add368bf02a7935fa099024576c029491bcefaeb87f81efa093a3", 71 | count: 2, 72 | }, 73 | } 74 | 75 | for _, tc := range happyCases { 76 | t.Run(tc.name, func(tt *testing.T) { 77 | subjecter := NewContainerSubjecter(repo, tc.digest, tc.tags, opts...) 78 | s, err := subjecter.Subjects() 79 | assert.NoError(err) 80 | assert.NotNil(s) 81 | assert.Len(s, tc.count) 82 | 83 | for i := 0; i < tc.count; i++ { 84 | assertSubject(assert, s, repo, tc.tags[i], tc.digest) 85 | } 86 | }) 87 | } 88 | 89 | for _, tc := range errorCases { 90 | t.Run(tc.name, func(tt *testing.T) { 91 | subjecter := NewContainerSubjecter(tc.repo, tc.digest, tc.tags, opts...) 92 | s, err := subjecter.Subjects() 93 | assert.EqualError(err, tc.err) 94 | assert.Nil(s) 95 | }) 96 | } 97 | } 98 | 99 | func assertSubject(assert *assert.Assertions, subject []intoto.Subject, repo, tag, digest string) { 100 | subjectName := fmt.Sprintf("%s:%s", repo, tag) 101 | digestValue := strings.Split(digest, ":")[1] 102 | assert.Contains(subject, intoto.Subject{Name: subjectName, Digest: intoto.DigestSet{"sha256": digestValue}}) 103 | } 104 | -------------------------------------------------------------------------------- /test-data/materials-broken.not-json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 4 | "digest": { 5 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test-data/materials-no-digest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 4 | "not-digest": { 5 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 6 | } 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /test-data/materials-no-uri.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "not-uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 4 | "digest": { 5 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 6 | } 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /test-data/materials-valid.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "uri": "pkg:deb/debian/stunnel4@5.50-3?arch=amd64", 4 | "digest": { 5 | "sha256": "e1731ae217fcbc64d4c00d707dcead45c828c5f762bcf8cc56d87de511e096fa" 6 | } 7 | } 8 | ] 9 | --------------------------------------------------------------------------------