mirror of
https://github.com/actions/attest-build-provenance.git
synced 2025-12-14 03:12:20 +00:00
Merge pull request #3 from actions/eugene/init-attest-build-provenance
init attest build provenance
This commit is contained in:
commit
69d7380581
@ -2,3 +2,4 @@ lib/
|
|||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
coverage/
|
coverage/
|
||||||
|
packages/
|
||||||
|
|||||||
5
.github/linters/tsconfig.json
vendored
5
.github/linters/tsconfig.json
vendored
@ -5,5 +5,8 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["../../__tests__/**/*", "../../src/**/*"],
|
"include": ["../../__tests__/**/*", "../../src/**/*"],
|
||||||
"exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"]
|
"exclude": ["../../dist", "../../node_modules", "../../coverage", "*.json"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./packages/attest" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
3
.github/workflows/check-dist.yml
vendored
3
.github/workflows/check-dist.yml
vendored
@ -39,6 +39,9 @@ jobs:
|
|||||||
id: install
|
id: install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build @actions/attest
|
||||||
|
run: npm run build --workspace packages/attest
|
||||||
|
|
||||||
- name: Build dist/ Directory
|
- name: Build dist/ Directory
|
||||||
id: build
|
id: build
|
||||||
run: npm run bundle
|
run: npm run bundle
|
||||||
|
|||||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@ -5,31 +5,36 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- 'releases/*'
|
||||||
|
|
||||||
permissions:
|
permissions: {}
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-typescript:
|
test-typescript:
|
||||||
name: TypeScript Tests
|
name: TypeScript Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
id: checkout
|
id: checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
id: setup-node
|
id: setup-node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
id: npm-ci
|
id: npm-ci
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build @actions/attest
|
||||||
|
run: npm run build --workspace packages/attest
|
||||||
|
|
||||||
- name: Check Format
|
- name: Check Format
|
||||||
id: npm-format-check
|
id: npm-format-check
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
@ -37,26 +42,29 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
id: npm-lint
|
id: npm-lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
# - name: Test
|
||||||
|
# id: npm-ci-test
|
||||||
|
# run: npm run ci-test
|
||||||
|
|
||||||
- name: Test
|
test-attest-provenance:
|
||||||
id: npm-ci-test
|
name: Test attest-provenance action
|
||||||
run: npm run ci-test
|
|
||||||
|
|
||||||
test-action:
|
|
||||||
name: GitHub Actions Test
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
id: checkout
|
id: checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||||
|
- name: Run attest-provenance
|
||||||
- name: Test Local Action
|
id: attest-provenance
|
||||||
id: test-action
|
|
||||||
uses: ./
|
uses: ./
|
||||||
with:
|
with:
|
||||||
milliseconds: 2000
|
subject-digest: 'sha256:7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
subject-name: 'subject'
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Dump output
|
||||||
|
run: jq < ${{ steps.attest-provenance.outputs.bundle-path }}
|
||||||
|
|
||||||
- name: Print Output
|
|
||||||
id: output
|
|
||||||
run: echo "${{ steps.test-action.outputs.time }}"
|
|
||||||
|
|||||||
3
.github/workflows/linter.yml
vendored
3
.github/workflows/linter.yml
vendored
@ -32,6 +32,9 @@ jobs:
|
|||||||
id: install
|
id: install
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build @actions/attest
|
||||||
|
run: npm run build --workspace packages/attest
|
||||||
|
|
||||||
- name: Lint Codebase
|
- name: Lint Codebase
|
||||||
id: super-linter
|
id: super-linter
|
||||||
uses: super-linter/super-linter/slim@v5
|
uses: super-linter/super-linter/slim@v5
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -101,3 +101,5 @@ __tests__/runner/*
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
|
||||||
|
packages/attest/dist
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for the action's entrypoint, src/index.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as main from '../src/main'
|
|
||||||
|
|
||||||
// Mock the action's entrypoint
|
|
||||||
const runMock = jest.spyOn(main, 'run').mockImplementation()
|
|
||||||
|
|
||||||
describe('index', () => {
|
|
||||||
it('calls run when imported', async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
require('../src/index')
|
|
||||||
|
|
||||||
expect(runMock).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for the action's main functionality, src/main.ts
|
|
||||||
*
|
|
||||||
* These should be run as if the action was called from a workflow.
|
|
||||||
* Specifically, the inputs listed in `action.yml` should be set as environment
|
|
||||||
* variables following the pattern `INPUT_<INPUT_NAME>`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as core from '@actions/core'
|
|
||||||
import * as main from '../src/main'
|
|
||||||
|
|
||||||
// Mock the action's main function
|
|
||||||
const runMock = jest.spyOn(main, 'run')
|
|
||||||
|
|
||||||
// Other utilities
|
|
||||||
const timeRegex = /^\d{2}:\d{2}:\d{2}/
|
|
||||||
|
|
||||||
// Mock the GitHub Actions core library
|
|
||||||
let debugMock: jest.SpyInstance
|
|
||||||
let errorMock: jest.SpyInstance
|
|
||||||
let getInputMock: jest.SpyInstance
|
|
||||||
let setFailedMock: jest.SpyInstance
|
|
||||||
let setOutputMock: jest.SpyInstance
|
|
||||||
|
|
||||||
describe('action', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks()
|
|
||||||
|
|
||||||
debugMock = jest.spyOn(core, 'debug').mockImplementation()
|
|
||||||
errorMock = jest.spyOn(core, 'error').mockImplementation()
|
|
||||||
getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
|
|
||||||
setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
|
|
||||||
setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets the time output', async () => {
|
|
||||||
// Set the action's inputs as return values from core.getInput()
|
|
||||||
getInputMock.mockImplementation((name: string): string => {
|
|
||||||
switch (name) {
|
|
||||||
case 'milliseconds':
|
|
||||||
return '500'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await main.run()
|
|
||||||
expect(runMock).toHaveReturned()
|
|
||||||
|
|
||||||
// Verify that all of the core library functions were called correctly
|
|
||||||
expect(debugMock).toHaveBeenNthCalledWith(1, 'Waiting 500 milliseconds ...')
|
|
||||||
expect(debugMock).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.stringMatching(timeRegex)
|
|
||||||
)
|
|
||||||
expect(debugMock).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
expect.stringMatching(timeRegex)
|
|
||||||
)
|
|
||||||
expect(setOutputMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'time',
|
|
||||||
expect.stringMatching(timeRegex)
|
|
||||||
)
|
|
||||||
expect(errorMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets a failed status', async () => {
|
|
||||||
// Set the action's inputs as return values from core.getInput()
|
|
||||||
getInputMock.mockImplementation((name: string): string => {
|
|
||||||
switch (name) {
|
|
||||||
case 'milliseconds':
|
|
||||||
return 'this is not a number'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await main.run()
|
|
||||||
expect(runMock).toHaveReturned()
|
|
||||||
|
|
||||||
// Verify that all of the core library functions were called correctly
|
|
||||||
expect(setFailedMock).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'milliseconds not a number'
|
|
||||||
)
|
|
||||||
expect(errorMock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for src/wait.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { wait } from '../src/wait'
|
|
||||||
import { expect } from '@jest/globals'
|
|
||||||
|
|
||||||
describe('wait.ts', () => {
|
|
||||||
it('throws an invalid number', async () => {
|
|
||||||
const input = parseInt('foo', 10)
|
|
||||||
expect(isNaN(input)).toBe(true)
|
|
||||||
|
|
||||||
await expect(wait(input)).rejects.toThrow('milliseconds not a number')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('waits with a valid number', async () => {
|
|
||||||
const start = new Date()
|
|
||||||
await wait(500)
|
|
||||||
const end = new Date()
|
|
||||||
|
|
||||||
const delta = Math.abs(end.getTime() - start.getTime())
|
|
||||||
|
|
||||||
expect(delta).toBeGreaterThan(450)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
73
action.yml
73
action.yml
@ -1,24 +1,59 @@
|
|||||||
name: 'The name of your action here'
|
name: 'Attest Build Provenance'
|
||||||
description: 'Provide a description here'
|
description: 'Generate provenance attestations for build artifacts'
|
||||||
author: 'Your name or organization here'
|
author: 'GitHub'
|
||||||
|
|
||||||
# Add your action's branding here. This will appear on the GitHub Marketplace.
|
|
||||||
branding:
|
|
||||||
icon: 'heart'
|
|
||||||
color: 'red'
|
|
||||||
|
|
||||||
# Define your inputs here.
|
|
||||||
inputs:
|
inputs:
|
||||||
milliseconds:
|
github-token:
|
||||||
description: 'Your input description here'
|
description: >
|
||||||
required: true
|
The GitHub token used to make authenticated API requests.
|
||||||
default: '1000'
|
default: ${{ github.token }}
|
||||||
|
required: false
|
||||||
# Define your outputs here.
|
subject-path:
|
||||||
|
description: >
|
||||||
|
Path to the artifact for which provenance will be generated. Must specify
|
||||||
|
exactly one of "subject-path" or "subject-digest".
|
||||||
|
required: false
|
||||||
|
subject-digest:
|
||||||
|
description: >
|
||||||
|
Digest of the subject for which provenance will be generated. Must be in
|
||||||
|
the form "algorithm:hex_digest" (e.g. "sha256:abc123..."). Must specify
|
||||||
|
exactly one of "subject-path" or "subject-digest".
|
||||||
|
required: false
|
||||||
|
subject-name:
|
||||||
|
description: >
|
||||||
|
Subject name as it should appear in the provenance statement. Required
|
||||||
|
unless "subject-path" is specified, in which case it will be inferred from
|
||||||
|
the path.
|
||||||
|
push-to-registry:
|
||||||
|
description: >
|
||||||
|
Whether to push the provenance statement to the image registry. Requires
|
||||||
|
that the "subject-name" parameter specify the fully-qualified image name
|
||||||
|
and that the "subject-digest" parameter be specified. Defaults to false.
|
||||||
|
default: false
|
||||||
|
required: false
|
||||||
outputs:
|
outputs:
|
||||||
time:
|
bundle-path:
|
||||||
description: 'Your output description here'
|
description: 'The path to the file containing the attestation bundle(s).'
|
||||||
|
value: ${{ steps.attest.outputs.bundle-path }}
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: node20
|
using: 'composite'
|
||||||
main: dist/index.js
|
steps:
|
||||||
|
- uses: ./generate-build-provenance-statement
|
||||||
|
id: generate-build-provenance-statement
|
||||||
|
with:
|
||||||
|
github-token: ${{ inputs.github-token }}
|
||||||
|
subject-path: ${{ inputs.subject-path }}
|
||||||
|
subject-digest: ${{ inputs.subject-digest }}
|
||||||
|
subject-name: ${{ inputs.subject-name }}
|
||||||
|
push-to-registry: ${{ inputs.push-to-registry }}
|
||||||
|
- uses: actions/attest@main
|
||||||
|
id: attest
|
||||||
|
with:
|
||||||
|
github-token: ${{ inputs.github-token }}
|
||||||
|
subject-path: ${{ inputs.subject-path }}
|
||||||
|
subject-digest: ${{ inputs.subject-digest }}
|
||||||
|
subject-name: ${{ inputs.subject-name }}
|
||||||
|
push-to-registry: ${{ inputs.push-to-registry }}
|
||||||
|
predicate-type: ${{ steps.generate-build-provenance-statement.outputs.predicate-type }}
|
||||||
|
predicate: ${{ steps.generate-build-provenance-statement.outputs.predicate }}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="92" height="20" role="img" aria-label="Coverage: 0%"><title>Coverage: 0%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="92" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="29" height="20" fill="#e05d44"/><rect width="92" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="765" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="190">0%</text><text x="765" y="140" transform="scale(.1)" fill="#fff" textLength="190">0%</text></g></svg>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
dist/index.js
generated
vendored
BIN
dist/index.js
generated
vendored
Binary file not shown.
BIN
dist/licenses.txt
generated
vendored
BIN
dist/licenses.txt
generated
vendored
Binary file not shown.
43
generate-build-provenance-statement/action.yml
Normal file
43
generate-build-provenance-statement/action.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
name: 'Generate Build Provenance Statement'
|
||||||
|
description: 'Generate provenance statement for build artifacts'
|
||||||
|
author: 'GitHub'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
github-token:
|
||||||
|
description: >
|
||||||
|
The GitHub token used to make authenticated API requests.
|
||||||
|
default: ${{ github.token }}
|
||||||
|
required: false
|
||||||
|
subject-path:
|
||||||
|
description: >
|
||||||
|
Path to the artifact for which provenance will be generated. Must specify
|
||||||
|
exactly one of "subject-path" or "subject-digest".
|
||||||
|
required: false
|
||||||
|
subject-digest:
|
||||||
|
description: >
|
||||||
|
Digest of the subject for which provenance will be generated. Must be in
|
||||||
|
the form "algorithm:hex_digest" (e.g. "sha256:abc123..."). Must specify
|
||||||
|
exactly one of "subject-path" or "subject-digest".
|
||||||
|
required: false
|
||||||
|
subject-name:
|
||||||
|
description: >
|
||||||
|
Subject name as it should appear in the provenance statement. Required
|
||||||
|
unless "subject-path" is specified, in which case it will be inferred from
|
||||||
|
the path.
|
||||||
|
push-to-registry:
|
||||||
|
description: >
|
||||||
|
Whether to push the provenance statement to the image registry. Requires
|
||||||
|
that the "subject-name" parameter specify the fully-qualified image name
|
||||||
|
and that the "subject-digest" parameter be specified. Defaults to false.
|
||||||
|
default: false
|
||||||
|
required: false
|
||||||
|
outputs:
|
||||||
|
predicate:
|
||||||
|
description: >
|
||||||
|
The JSON-serialized of the attestation predicate.
|
||||||
|
predicate-type:
|
||||||
|
description: >
|
||||||
|
URI identifying the type of the predicate.
|
||||||
|
runs:
|
||||||
|
using: node20
|
||||||
|
main: ../dist/index.js
|
||||||
1529
package-lock.json
generated
1529
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -25,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bundle": "npm run format:write && npm run package",
|
"bundle": "npm run format:write && npm run package",
|
||||||
|
"prepackage": "npm run build --workspace packages/attest",
|
||||||
"ci-test": "jest",
|
"ci-test": "jest",
|
||||||
"coverage": "make-coverage-badge --output-path ./badges/coverage.svg",
|
"coverage": "make-coverage-badge --output-path ./badges/coverage.svg",
|
||||||
"format:write": "prettier --write **/*.ts",
|
"format:write": "prettier --write **/*.ts",
|
||||||
@ -85,5 +86,8 @@
|
|||||||
"prettier-eslint": "^16.3.0",
|
"prettier-eslint": "^16.3.0",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"./packages/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
5
packages/attest/jest.config.js
Normal file
5
packages/attest/jest.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/__tests__/*.test.ts'],
|
||||||
|
};
|
||||||
44
packages/attest/package.json
Normal file
44
packages/attest/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "@actions/attest",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Base library for Sigstore",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "shx rm -rf dist *.tsbuildinfo",
|
||||||
|
"build": "tsc --build",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"author": "bdehamer@github.com",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/github/attest-js.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/github/attest-js/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/github/attest-js/tree/main/packages/core#readme",
|
||||||
|
"publishConfig": {
|
||||||
|
"provenance": true
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sigstore/mock": "^0.6.4",
|
||||||
|
"@total-typescript/shoehorn": "^0.1.1",
|
||||||
|
"@tsconfig/node18": "^18.2.2",
|
||||||
|
"@types/make-fetch-happen": "^10.0.4",
|
||||||
|
"nock": "^13.5.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@actions/github": "^6.0.0",
|
||||||
|
"@sigstore/bundle": "^2.2.0",
|
||||||
|
"@sigstore/sign": "^2.2.3",
|
||||||
|
"make-fetch-happen": "^13.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`generateProvenance returns a provenance hydrated from env vars 1`] = `
|
||||||
|
{
|
||||||
|
"_type": "https://in-toto.io/Statement/v1",
|
||||||
|
"predicate": {
|
||||||
|
"buildDefinition": {
|
||||||
|
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
|
||||||
|
"externalParameters": {
|
||||||
|
"workflow": {
|
||||||
|
"path": ".github/workflows/main.yml",
|
||||||
|
"ref": "main",
|
||||||
|
"repository": "https://github.com/owner/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"internalParameters": {
|
||||||
|
"github": {
|
||||||
|
"event_name": "push",
|
||||||
|
"repository_id": "repo-id",
|
||||||
|
"repository_owner_id": "owner-id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"resolvedDependencies": [
|
||||||
|
{
|
||||||
|
"digest": {
|
||||||
|
"gitCommit": "babca52ab0c93ae16539e5923cb0d7403b9a093b",
|
||||||
|
},
|
||||||
|
"uri": "git+https://github.com/owner/repo@refs/heads/main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"runDetails": {
|
||||||
|
"builder": {
|
||||||
|
"id": "https://github.com/actions/runner/github-hosted",
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"invocationId": "https://github.com/owner/repo/actions/runs/run-id/attempts/run-attempt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"predicateType": "https://slsa.dev/provenance/v1",
|
||||||
|
"subject": [
|
||||||
|
{
|
||||||
|
"digest": {
|
||||||
|
"sha256": "7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32",
|
||||||
|
},
|
||||||
|
"name": "subjecty",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`;
|
||||||
154
packages/attest/src/__tests__/attest.test.ts
Normal file
154
packages/attest/src/__tests__/attest.test.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
|
||||||
|
import nock from 'nock'
|
||||||
|
import { attestProvenance } from '../attest'
|
||||||
|
|
||||||
|
describe('attest functions', () => {
|
||||||
|
// Capture original environment variables and GitHub context so we can restore
|
||||||
|
// them after each test
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
// Fake an OIDC token
|
||||||
|
const subject = 'foo@bar.com'
|
||||||
|
const oidcPayload = { sub: subject, iss: '' }
|
||||||
|
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
||||||
|
'base64'
|
||||||
|
)}.}`
|
||||||
|
|
||||||
|
const tokenURL = 'https://token.url'
|
||||||
|
const fulcioURL = 'https://fulcio.url'
|
||||||
|
const rekorURL = 'https://rekor.url'
|
||||||
|
const tsaServerURL = 'https://tsa.url'
|
||||||
|
const attestationID = '1234567890'
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
nock(tokenURL)
|
||||||
|
.get('/')
|
||||||
|
.query({ audience: 'sigstore' })
|
||||||
|
.reply(200, { value: oidcToken })
|
||||||
|
|
||||||
|
// Mock Fulcio endpoint
|
||||||
|
await mockFulcio({ baseURL: fulcioURL, strict: false })
|
||||||
|
|
||||||
|
// Set-up GHA environment variables
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore the original environment
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('#attestProvenance', () => {
|
||||||
|
const env = {
|
||||||
|
GITHUB_REPOSITORY: 'owner/repo',
|
||||||
|
GITHUB_REF: 'refs/heads/main',
|
||||||
|
GITHUB_SHA: 'babca52ab0c93ae16539e5923cb0d7403b9a093b',
|
||||||
|
GITHUB_WORKFLOW_REF: 'owner/repo/.github/workflows/main.yml@main',
|
||||||
|
GITHUB_SERVER_URL: 'https://github.com',
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REPOSITORY_ID: 'repo-id',
|
||||||
|
GITHUB_REPOSITORY_OWNER_ID: 'owner-id',
|
||||||
|
GITHUB_RUN_ID: 'run-id',
|
||||||
|
GITHUB_RUN_ATTEMPT: 'run-attempt',
|
||||||
|
RUNNER_ENVIRONMENT: 'github-hosted'
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...process.env, ...env }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the timestamp authority URL is set', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockTSA({ baseURL: tsaServerURL })
|
||||||
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, { id: attestationID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName: 'subjective',
|
||||||
|
subjectDigest: {
|
||||||
|
sha256:
|
||||||
|
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
},
|
||||||
|
token: 'token',
|
||||||
|
fulcioURL,
|
||||||
|
tsaServerURL
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeUndefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the transparency log URL is set', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockRekor({ baseURL: rekorURL })
|
||||||
|
|
||||||
|
// Mock GH attestations API
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.post(/^\/repos\/.*\/.*\/attestations$/)
|
||||||
|
.reply(201, { id: attestationID })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName: 'subjective',
|
||||||
|
subjectDigest: {
|
||||||
|
sha256:
|
||||||
|
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
},
|
||||||
|
token: 'token',
|
||||||
|
fulcioURL,
|
||||||
|
rekorURL
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBe(attestationID)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when skipWrite is set to true', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockRekor({ baseURL: rekorURL })
|
||||||
|
await mockTSA({ baseURL: tsaServerURL })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attests provenance', async () => {
|
||||||
|
const attestation = await attestProvenance({
|
||||||
|
subjectName: 'subjective',
|
||||||
|
subjectDigest: {
|
||||||
|
sha256:
|
||||||
|
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
},
|
||||||
|
token: 'token',
|
||||||
|
fulcioURL,
|
||||||
|
rekorURL,
|
||||||
|
tsaServerURL,
|
||||||
|
skipWrite: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
expect(attestation.bundle).toBeDefined()
|
||||||
|
expect(attestation.certificate).toMatch(/-----BEGIN CERTIFICATE-----/)
|
||||||
|
expect(attestation.tlogID).toBeDefined()
|
||||||
|
expect(attestation.attestationID).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
32
packages/attest/src/__tests__/index.test.ts
Normal file
32
packages/attest/src/__tests__/index.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { fromPartial } from '@total-typescript/shoehorn'
|
||||||
|
import {
|
||||||
|
AttestOptions,
|
||||||
|
AttestProvenanceOptions,
|
||||||
|
Attestation,
|
||||||
|
Predicate,
|
||||||
|
Subject,
|
||||||
|
attest,
|
||||||
|
attestProvenance
|
||||||
|
} from '..'
|
||||||
|
|
||||||
|
it('exports functions', () => {
|
||||||
|
expect(attestProvenance).toBeInstanceOf(Function)
|
||||||
|
expect(attest).toBeInstanceOf(Function)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports types', async () => {
|
||||||
|
const attestation: Attestation = fromPartial({})
|
||||||
|
expect(attestation).toBeDefined()
|
||||||
|
|
||||||
|
const attestOptions: AttestOptions = fromPartial({})
|
||||||
|
expect(attestOptions).toBeDefined()
|
||||||
|
|
||||||
|
const attestProvenanceOptions: AttestProvenanceOptions = fromPartial({})
|
||||||
|
expect(attestProvenanceOptions).toBeDefined()
|
||||||
|
|
||||||
|
const subject: Subject = fromPartial({})
|
||||||
|
expect(subject).toBeDefined()
|
||||||
|
|
||||||
|
const predicate: Predicate = fromPartial({})
|
||||||
|
expect(predicate).toBeDefined()
|
||||||
|
})
|
||||||
30
packages/attest/src/__tests__/provenance.test.ts
Normal file
30
packages/attest/src/__tests__/provenance.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { generateProvenance } from '../provenance'
|
||||||
|
import type { Subject } from '../shared.types'
|
||||||
|
|
||||||
|
describe('generateProvenance', () => {
|
||||||
|
const subject: Subject = {
|
||||||
|
name: 'subjecty',
|
||||||
|
digest: {
|
||||||
|
sha256: '7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
GITHUB_REPOSITORY: 'owner/repo',
|
||||||
|
GITHUB_REF: 'refs/heads/main',
|
||||||
|
GITHUB_SHA: 'babca52ab0c93ae16539e5923cb0d7403b9a093b',
|
||||||
|
GITHUB_WORKFLOW_REF: 'owner/repo/.github/workflows/main.yml@main',
|
||||||
|
GITHUB_SERVER_URL: 'https://github.com',
|
||||||
|
GITHUB_EVENT_NAME: 'push',
|
||||||
|
GITHUB_REPOSITORY_ID: 'repo-id',
|
||||||
|
GITHUB_REPOSITORY_OWNER_ID: 'owner-id',
|
||||||
|
GITHUB_RUN_ID: 'run-id',
|
||||||
|
GITHUB_RUN_ATTEMPT: 'run-attempt',
|
||||||
|
RUNNER_ENVIRONMENT: 'github-hosted'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns a provenance hydrated from env vars', () => {
|
||||||
|
const provenance = generateProvenance(subject, env)
|
||||||
|
expect(provenance).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
105
packages/attest/src/__tests__/sign.test.ts
Normal file
105
packages/attest/src/__tests__/sign.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'
|
||||||
|
import nock from 'nock'
|
||||||
|
import { Payload, signPayload } from '../sign'
|
||||||
|
|
||||||
|
describe('signProvenance', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
// Fake an OIDC token
|
||||||
|
const subject = 'foo@bar.com'
|
||||||
|
const oidcPayload = { sub: subject, iss: '' }
|
||||||
|
const oidcToken = `.${Buffer.from(JSON.stringify(oidcPayload)).toString(
|
||||||
|
'base64'
|
||||||
|
)}.}`
|
||||||
|
|
||||||
|
// Dummy provenance to be signed
|
||||||
|
const provenance = {
|
||||||
|
_type: 'https://in-toto.io/Statement/v1',
|
||||||
|
subject: {
|
||||||
|
name: 'subjective',
|
||||||
|
digest: {
|
||||||
|
sha256:
|
||||||
|
'7d070f6b64d9bcc530fe99cc21eaaa4b3c364e0b2d367d7735671fa202a03b32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Payload = {
|
||||||
|
body: Buffer.from(JSON.stringify(provenance)),
|
||||||
|
type: 'application/vnd.in-toto+json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fulcioURL = 'https://fulcio.url'
|
||||||
|
const rekorURL = 'https://rekor.url'
|
||||||
|
const tsaServerURL = 'https://tsa.url'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock OIDC token endpoint
|
||||||
|
const tokenURL = 'https://token.url'
|
||||||
|
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_URL: tokenURL,
|
||||||
|
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'token'
|
||||||
|
}
|
||||||
|
|
||||||
|
nock(tokenURL)
|
||||||
|
.get('/')
|
||||||
|
.query({ audience: 'sigstore' })
|
||||||
|
.reply(200, { value: oidcToken })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when visibility is public', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockFulcio({ baseURL: fulcioURL, strict: false })
|
||||||
|
await mockRekor({ baseURL: rekorURL })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a bundle', async () => {
|
||||||
|
const att = await signPayload(payload, { fulcioURL, rekorURL })
|
||||||
|
|
||||||
|
expect(att).toBeDefined()
|
||||||
|
expect(att.mediaType).toEqual(
|
||||||
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when visibility is private', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockFulcio({ baseURL: fulcioURL, strict: false })
|
||||||
|
await mockTSA({ baseURL: tsaServerURL })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a bundle', async () => {
|
||||||
|
const att = await signPayload(payload, { fulcioURL, tsaServerURL })
|
||||||
|
|
||||||
|
expect(att).toBeDefined()
|
||||||
|
expect(att.mediaType).toEqual(
|
||||||
|
'application/vnd.dev.sigstore.bundle+json;version=0.2'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(att.content.$case).toEqual('dsseEnvelope')
|
||||||
|
expect(att.verificationMaterial.content.$case).toEqual(
|
||||||
|
'x509CertificateChain'
|
||||||
|
)
|
||||||
|
expect(att.verificationMaterial.tlogEntries).toHaveLength(0)
|
||||||
|
expect(
|
||||||
|
att.verificationMaterial.timestampVerificationData?.rfc3161Timestamps
|
||||||
|
).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
45
packages/attest/src/__tests__/store.test.ts
Normal file
45
packages/attest/src/__tests__/store.test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import nock from 'nock'
|
||||||
|
import { writeAttestation } from '../store'
|
||||||
|
|
||||||
|
describe('writeAttestation', () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
const attestation = { foo: 'bar ' }
|
||||||
|
const token = 'token'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...originalEnv,
|
||||||
|
GITHUB_REPOSITORY: 'foo/bar'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the api call is successful', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.matchHeader('authorization', `token ${token}`)
|
||||||
|
.post('/repos/foo/bar/attestations', { bundle: attestation })
|
||||||
|
.reply(201, { id: '123' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the attestation', async () => {
|
||||||
|
await expect(writeAttestation(attestation, token)).resolves.toEqual('123')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the api call fails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nock('https://api.github.com')
|
||||||
|
.matchHeader('authorization', `token ${token}`)
|
||||||
|
.post('/repos/foo/bar/attestations', { bundle: attestation })
|
||||||
|
.reply(500, 'oops')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists the attestation', async () => {
|
||||||
|
await expect(writeAttestation(attestation, token)).rejects.toThrow(/oops/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
88
packages/attest/src/attest.ts
Normal file
88
packages/attest/src/attest.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Bundle, bundleToJSON } from '@sigstore/bundle'
|
||||||
|
import { generateProvenancePredicate } from './provenance'
|
||||||
|
import { Payload, SignOptions, signPayload } from './sign'
|
||||||
|
import { writeAttestation } from './store'
|
||||||
|
|
||||||
|
import assert from 'assert'
|
||||||
|
import { X509Certificate } from 'crypto'
|
||||||
|
import type { Attestation, Subject } from './shared.types'
|
||||||
|
|
||||||
|
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json'
|
||||||
|
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1'
|
||||||
|
|
||||||
|
type AttestBaseOptions = SignOptions & {
|
||||||
|
subjectName: string
|
||||||
|
subjectDigest: Record<string, string>
|
||||||
|
token: string
|
||||||
|
skipWrite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttestOptions = AttestBaseOptions & {
|
||||||
|
predicateType: string
|
||||||
|
predicate: object
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttestProvenanceOptions = AttestBaseOptions
|
||||||
|
|
||||||
|
export async function attest(options: AttestOptions): Promise<Attestation> {
|
||||||
|
const subject: Subject = {
|
||||||
|
name: options.subjectName,
|
||||||
|
digest: options.subjectDigest
|
||||||
|
}
|
||||||
|
|
||||||
|
const statement = {
|
||||||
|
_type: INTOTO_STATEMENT_V1_TYPE,
|
||||||
|
subject: [subject],
|
||||||
|
predicateType: options.predicateType,
|
||||||
|
predicate: options.predicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the provenance statement
|
||||||
|
const payload: Payload = {
|
||||||
|
body: Buffer.from(JSON.stringify(statement)),
|
||||||
|
type: INTOTO_PAYLOAD_TYPE
|
||||||
|
}
|
||||||
|
const bundle = await signPayload(payload, options)
|
||||||
|
|
||||||
|
// Store the attestation
|
||||||
|
let attestationID: string | undefined
|
||||||
|
if (options.skipWrite !== true) {
|
||||||
|
attestationID = await writeAttestation(bundleToJSON(bundle), options.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAttestation(bundle, attestationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attestProvenance(
|
||||||
|
options: AttestProvenanceOptions
|
||||||
|
): Promise<Attestation> {
|
||||||
|
const predicate = generateProvenancePredicate(process.env)
|
||||||
|
return attest({
|
||||||
|
...options,
|
||||||
|
predicateType: predicate.type,
|
||||||
|
predicate: predicate.params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAttestation(bundle: Bundle, attestationID?: string): Attestation {
|
||||||
|
// Extract the signing certificate from the bundle
|
||||||
|
assert(
|
||||||
|
bundle.verificationMaterial.content.$case === 'x509CertificateChain',
|
||||||
|
'Bundle must contain an x509 certificate chain'
|
||||||
|
)
|
||||||
|
|
||||||
|
const signingCert = new X509Certificate(
|
||||||
|
bundle.verificationMaterial.content.x509CertificateChain.certificates[0].rawBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
// Determine if we can provide a link to the transparency log
|
||||||
|
const tlogEntries = bundle.verificationMaterial.tlogEntries
|
||||||
|
const tlogID = tlogEntries.length > 0 ? tlogEntries[0].logIndex : undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
bundle: bundleToJSON(bundle),
|
||||||
|
certificate: signingCert.toString(),
|
||||||
|
tlogID,
|
||||||
|
attestationID
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/attest/src/index.ts
Normal file
10
packages/attest/src/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
AttestOptions,
|
||||||
|
AttestProvenanceOptions,
|
||||||
|
attest,
|
||||||
|
attestProvenance
|
||||||
|
} from './attest'
|
||||||
|
export { generateProvenancePredicate } from './provenance'
|
||||||
|
export { generateSBOMPredicate } from './sbom'
|
||||||
|
|
||||||
|
export type { Attestation, Predicate, Subject, SBOM } from './shared.types'
|
||||||
72
packages/attest/src/provenance.ts
Normal file
72
packages/attest/src/provenance.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { Predicate, Subject } from './shared.types'
|
||||||
|
|
||||||
|
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1'
|
||||||
|
export const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'
|
||||||
|
|
||||||
|
const GITHUB_BUILDER_ID_PREFIX = 'https://github.com/actions/runner'
|
||||||
|
const GITHUB_BUILD_TYPE =
|
||||||
|
'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
|
||||||
|
|
||||||
|
export const generateProvenancePredicate = (
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): Predicate => {
|
||||||
|
const workflow = env.GITHUB_WORKFLOW_REF || /* istanbul ignore next */ ''
|
||||||
|
// Split just the path and ref from the workflow string.
|
||||||
|
// owner/repo/.github/workflows/main.yml@main =>
|
||||||
|
// .github/workflows/main.yml, main
|
||||||
|
const [workflowPath, workflowRef] = workflow
|
||||||
|
.replace(`${env.GITHUB_REPOSITORY}/`, '')
|
||||||
|
.split('@')
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: SLSA_PREDICATE_V1_TYPE,
|
||||||
|
params: {
|
||||||
|
buildDefinition: {
|
||||||
|
buildType: GITHUB_BUILD_TYPE,
|
||||||
|
externalParameters: {
|
||||||
|
workflow: {
|
||||||
|
ref: workflowRef,
|
||||||
|
repository: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`,
|
||||||
|
path: workflowPath
|
||||||
|
}
|
||||||
|
},
|
||||||
|
internalParameters: {
|
||||||
|
github: {
|
||||||
|
event_name: env.GITHUB_EVENT_NAME,
|
||||||
|
repository_id: env.GITHUB_REPOSITORY_ID,
|
||||||
|
repository_owner_id: env.GITHUB_REPOSITORY_OWNER_ID
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolvedDependencies: [
|
||||||
|
{
|
||||||
|
uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`,
|
||||||
|
digest: {
|
||||||
|
gitCommit: env.GITHUB_SHA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
runDetails: {
|
||||||
|
builder: {
|
||||||
|
id: `${GITHUB_BUILDER_ID_PREFIX}/${env.RUNNER_ENVIRONMENT}`
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
invocationId: `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateProvenance = (
|
||||||
|
subject: Subject,
|
||||||
|
env: NodeJS.ProcessEnv
|
||||||
|
): object => {
|
||||||
|
const predicate = generateProvenancePredicate(env)
|
||||||
|
return {
|
||||||
|
_type: INTOTO_STATEMENT_V1_TYPE,
|
||||||
|
subject: [subject],
|
||||||
|
predicateType: predicate.type,
|
||||||
|
predicate: predicate.params
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/attest/src/sbom.ts
Normal file
34
packages/attest/src/sbom.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { SBOM, Predicate } from './shared.types'
|
||||||
|
|
||||||
|
export const generateSBOMPredicate = (sbom: SBOM): Predicate => {
|
||||||
|
if (sbom.type === 'spdx') {
|
||||||
|
return generateSPDXIntoto(sbom.object)
|
||||||
|
}
|
||||||
|
if (sbom.type === 'cyclonedx') {
|
||||||
|
return generateCycloneDXIntoto(sbom.object)
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported SBOM format')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/spdx.md
|
||||||
|
const generateSPDXIntoto = (sbom: object): Predicate => {
|
||||||
|
const spdxVersion = (sbom as { spdxVersion?: string })?.['spdxVersion']
|
||||||
|
if (!spdxVersion) {
|
||||||
|
throw new Error('Cannot find spdxVersion in the SBOM')
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = spdxVersion.split('-')[1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: `https://spdx.dev/Document/v${version}`,
|
||||||
|
params: sbom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/cyclonedx.md
|
||||||
|
const generateCycloneDXIntoto = (sbom: object): Predicate => {
|
||||||
|
return {
|
||||||
|
type: 'https://cyclonedx.org/bom',
|
||||||
|
params: sbom
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/attest/src/shared.types.ts
Normal file
22
packages/attest/src/shared.types.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { SerializedBundle } from '@sigstore/bundle'
|
||||||
|
export type Subject = {
|
||||||
|
name: string
|
||||||
|
digest: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Predicate = {
|
||||||
|
type: string
|
||||||
|
params: object
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Attestation = {
|
||||||
|
bundle: SerializedBundle
|
||||||
|
certificate: string
|
||||||
|
tlogID?: string
|
||||||
|
attestationID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SBOM = {
|
||||||
|
type: 'spdx' | 'cyclonedx'
|
||||||
|
object: object
|
||||||
|
}
|
||||||
82
packages/attest/src/sign.ts
Normal file
82
packages/attest/src/sign.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Bundle } from '@sigstore/bundle'
|
||||||
|
import {
|
||||||
|
BundleBuilder,
|
||||||
|
CIContextProvider,
|
||||||
|
DSSEBundleBuilder,
|
||||||
|
FulcioSigner,
|
||||||
|
IdentityProvider,
|
||||||
|
RekorWitness,
|
||||||
|
TSAWitness,
|
||||||
|
Witness
|
||||||
|
} from '@sigstore/sign'
|
||||||
|
|
||||||
|
const OIDC_AUDIENCE = 'sigstore'
|
||||||
|
const DEFAULT_TIMEOUT = 10000
|
||||||
|
const DEFAULT_RETRIES = 3
|
||||||
|
|
||||||
|
export type Payload = {
|
||||||
|
body: Buffer
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignOptions = {
|
||||||
|
fulcioURL: string
|
||||||
|
rekorURL?: string
|
||||||
|
tsaServerURL?: string
|
||||||
|
identityProvider?: IdentityProvider
|
||||||
|
timeout?: number
|
||||||
|
retry?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signs the provided payload with Sigstore.
|
||||||
|
export const signPayload = async (
|
||||||
|
payload: Payload,
|
||||||
|
options: SignOptions
|
||||||
|
): Promise<Bundle> => {
|
||||||
|
const artifact = {
|
||||||
|
data: payload.body,
|
||||||
|
type: payload.type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the artifact and build the bundle
|
||||||
|
return initBundleBuilder(options).create(artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assembles the Sigstore bundle builder with the appropriate options
|
||||||
|
const initBundleBuilder = (opts: SignOptions): BundleBuilder => {
|
||||||
|
const identityProvider =
|
||||||
|
opts.identityProvider || new CIContextProvider(OIDC_AUDIENCE)
|
||||||
|
const timeout = opts.timeout || DEFAULT_TIMEOUT
|
||||||
|
const retry = opts.retry || DEFAULT_RETRIES
|
||||||
|
const witnesses: Witness[] = []
|
||||||
|
|
||||||
|
const signer = new FulcioSigner({
|
||||||
|
identityProvider: identityProvider,
|
||||||
|
fulcioBaseURL: opts.fulcioURL,
|
||||||
|
timeout: timeout,
|
||||||
|
retry: retry
|
||||||
|
})
|
||||||
|
|
||||||
|
if (opts.rekorURL) {
|
||||||
|
witnesses.push(
|
||||||
|
new RekorWitness({
|
||||||
|
rekorBaseURL: opts.rekorURL,
|
||||||
|
entryType: 'dsse',
|
||||||
|
timeout: timeout,
|
||||||
|
retry: retry
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.tsaServerURL) {
|
||||||
|
witnesses.push(
|
||||||
|
new TSAWitness({
|
||||||
|
tsaBaseURL: opts.tsaServerURL,
|
||||||
|
timeout: timeout,
|
||||||
|
retry: retry
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DSSEBundleBuilder({ signer, witnesses })
|
||||||
|
}
|
||||||
27
packages/attest/src/store.ts
Normal file
27
packages/attest/src/store.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as github from '@actions/github'
|
||||||
|
import fetch from 'make-fetch-happen'
|
||||||
|
|
||||||
|
const CREATE_ATTESTATION_REQUEST = 'POST /repos/{owner}/{repo}/attestations'
|
||||||
|
|
||||||
|
// Upload the attestation to the repository's attestations endpoint. Returns the
|
||||||
|
// ID of the uploaded attestation.
|
||||||
|
export const writeAttestation = async (
|
||||||
|
attestation: unknown,
|
||||||
|
token: string
|
||||||
|
): Promise<string> => {
|
||||||
|
const octokit = github.getOctokit(token, { request: { fetch } })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await octokit.request(CREATE_ATTESTATION_REQUEST, {
|
||||||
|
owner: github.context.repo.owner,
|
||||||
|
repo: github.context.repo.repo,
|
||||||
|
data: { bundle: attestation }
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.data?.id
|
||||||
|
} catch (err) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
const message = err instanceof Error ? err.message : err
|
||||||
|
throw new Error(`Failed to persist attestation: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/attest/tsconfig.json
Normal file
18
packages/attest/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"declaration": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"./dist",
|
||||||
|
"**/__tests__"
|
||||||
|
]
|
||||||
|
}
|
||||||
22
src/main.ts
22
src/main.ts
@ -1,5 +1,5 @@
|
|||||||
|
import { generateProvenancePredicate } from '@actions/attest'
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import { wait } from './wait'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main function for the action.
|
* The main function for the action.
|
||||||
@ -7,20 +7,14 @@ import { wait } from './wait'
|
|||||||
*/
|
*/
|
||||||
export async function run(): Promise<void> {
|
export async function run(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const ms: string = core.getInput('milliseconds')
|
// Calculate subject from inputs and generate provenance
|
||||||
|
const predicate = generateProvenancePredicate(process.env)
|
||||||
|
|
||||||
// Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true
|
core.setOutput('predicate', predicate.params)
|
||||||
core.debug(`Waiting ${ms} milliseconds ...`)
|
core.setOutput('predicate-type', predicate.type)
|
||||||
|
} catch (err) {
|
||||||
// Log the current timestamp, wait, then log the new timestamp
|
const error = err instanceof Error ? err : new Error(`${err}`)
|
||||||
core.debug(new Date().toTimeString())
|
|
||||||
await wait(parseInt(ms, 10))
|
|
||||||
core.debug(new Date().toTimeString())
|
|
||||||
|
|
||||||
// Set outputs for other workflow steps to use
|
|
||||||
core.setOutput('time', new Date().toTimeString())
|
|
||||||
} catch (error) {
|
|
||||||
// Fail the workflow run if an error occurs
|
// Fail the workflow run if an error occurs
|
||||||
if (error instanceof Error) core.setFailed(error.message)
|
core.setFailed(error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/wait.ts
14
src/wait.ts
@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wait for a number of milliseconds.
|
|
||||||
* @param milliseconds The number of milliseconds to wait.
|
|
||||||
* @returns {Promise<string>} Resolves with 'done!' after the wait is over.
|
|
||||||
*/
|
|
||||||
export async function wait(milliseconds: number): Promise<string> {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
if (isNaN(milliseconds)) {
|
|
||||||
throw new Error('milliseconds not a number')
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => resolve('done!'), milliseconds)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -15,5 +15,9 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"newLine": "lf"
|
"newLine": "lf"
|
||||||
},
|
},
|
||||||
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"]
|
"include": [ "/src/*" ],
|
||||||
|
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./packages/attest" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user