init attest action
This commit is contained in:
@@ -2,3 +2,4 @@ lib/
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
||||
packages/
|
||||
|
||||
+29
-21
@@ -5,31 +5,36 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'releases/*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test-typescript:
|
||||
name: TypeScript Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Setup Node.js
|
||||
id: setup-node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install Dependencies
|
||||
id: npm-ci
|
||||
run: npm ci
|
||||
|
||||
- name: Build @actions/attest
|
||||
run: npm run build --workspace packages/attest
|
||||
|
||||
- name: Check Format
|
||||
id: npm-format-check
|
||||
run: npm run format:check
|
||||
@@ -37,26 +42,29 @@ jobs:
|
||||
- name: Lint
|
||||
id: npm-lint
|
||||
run: npm run lint
|
||||
# - name: Test
|
||||
# id: npm-ci-test
|
||||
# run: npm run ci-test
|
||||
|
||||
- name: Test
|
||||
id: npm-ci-test
|
||||
run: npm run ci-test
|
||||
|
||||
test-action:
|
||||
name: GitHub Actions Test
|
||||
test-attest:
|
||||
name: Test attest action
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test Local Action
|
||||
id: test-action
|
||||
uses: ./
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- name: Run attest
|
||||
id: attest
|
||||
uses: ./attest
|
||||
with:
|
||||
milliseconds: 2000
|
||||
|
||||
- name: Print Output
|
||||
id: output
|
||||
run: echo "${{ steps.test-action.outputs.time }}"
|
||||
subject-path: ${{ github.workspace }}/README.md
|
||||
predicate-path: ${{ github.workspace }}/testing/sbom.json
|
||||
predicate-type: 'https://spdx.dev/Document/v2.3'
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Dump output
|
||||
run: jq < ${{ steps.attest.outputs.bundle-path }}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+48
-17
@@ -1,24 +1,55 @@
|
||||
name: 'The name of your action here'
|
||||
description: 'Provide a description here'
|
||||
author: 'Your name or organization here'
|
||||
name: 'Generate Generic Attestations'
|
||||
description: 'Generate attestations for build artifacts'
|
||||
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:
|
||||
milliseconds:
|
||||
description: 'Your input description here'
|
||||
subject-path:
|
||||
description: >
|
||||
Path to the artifact serving as the subject of the attestation. Must
|
||||
specify exactly one of "subject-path" or "subject-digest".
|
||||
required: false
|
||||
subject-digest:
|
||||
description: >
|
||||
Digest of the subject for for the attestation. 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 attestation. Required unless
|
||||
"subject-path" is specified, in which case it will be inferred from the
|
||||
path.
|
||||
required: false
|
||||
predicate-type:
|
||||
description: >
|
||||
URI identifying the type of the predicate.
|
||||
required: true
|
||||
default: '1000'
|
||||
|
||||
# Define your outputs here.
|
||||
predicate:
|
||||
description: >
|
||||
String containing the value for the attestation predicate. Must supply
|
||||
exactly one of "predicate-path" or "predicate".
|
||||
required: false
|
||||
predicate-path:
|
||||
description: >
|
||||
Path to the file which contains the content for the attestation predicate.
|
||||
Must supply exactly one of "predicate-path" or "predicate".
|
||||
required: false
|
||||
push-to-registry:
|
||||
description: >
|
||||
Whether to push the attestation 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
|
||||
github-token:
|
||||
description: >
|
||||
The GitHub token used to make authenticated API requests.
|
||||
default: ${{ github.token }}
|
||||
required: false
|
||||
outputs:
|
||||
time:
|
||||
description: 'Your output description here'
|
||||
bundle-path:
|
||||
description: 'The path to the file containing the attestation bundle(s).'
|
||||
|
||||
runs:
|
||||
using: node20
|
||||
main: dist/index.js
|
||||
main: ./dist/index.js
|
||||
+1
-1
@@ -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 |
+75036
-133
File diff suppressed because one or more lines are too long
+2355
File diff suppressed because it is too large
Load Diff
Generated
+6895
-1295
File diff suppressed because it is too large
Load Diff
+12
-5
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "npm run format:write && npm run package",
|
||||
"prepackage": "npm run build --workspace packages/attest",
|
||||
"ci-test": "jest",
|
||||
"coverage": "make-coverage-badge --output-path ./badges/coverage.svg",
|
||||
"format:write": "prettier --write **/*.ts",
|
||||
@@ -66,24 +67,30 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1"
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/glob": "^0.4.0",
|
||||
"@sigstore/oci": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@types/node": "^20.11.17",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@types/make-fetch-happen": "^10.0.4",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-github": "^4.10.1",
|
||||
"eslint-plugin-jest": "^27.9.0",
|
||||
"eslint-plugin-jest": "^27.8.0",
|
||||
"eslint-plugin-jsonc": "^2.13.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"jest": "^29.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"make-coverage-badge": "^1.2.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-eslint": "^16.3.0",
|
||||
"ts-jest": "^29.1.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
import { SignOptions } from './sign';
|
||||
import type { Attestation } from './shared.types';
|
||||
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 declare function attest(options: AttestOptions): Promise<Attestation>;
|
||||
export declare function attestProvenance(options: AttestProvenanceOptions): Promise<Attestation>;
|
||||
export {};
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.attestProvenance = exports.attest = void 0;
|
||||
const bundle_1 = require("@sigstore/bundle");
|
||||
const provenance_1 = require("./provenance");
|
||||
const sign_1 = require("./sign");
|
||||
const store_1 = require("./store");
|
||||
const assert_1 = __importDefault(require("assert"));
|
||||
const crypto_1 = require("crypto");
|
||||
const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
||||
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1';
|
||||
async function attest(options) {
|
||||
const 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 = {
|
||||
body: Buffer.from(JSON.stringify(statement)),
|
||||
type: INTOTO_PAYLOAD_TYPE
|
||||
};
|
||||
const bundle = await (0, sign_1.signPayload)(payload, options);
|
||||
// Store the attestation
|
||||
let attestationID;
|
||||
if (options.skipWrite !== true) {
|
||||
attestationID = await (0, store_1.writeAttestation)((0, bundle_1.bundleToJSON)(bundle), options.token);
|
||||
}
|
||||
return toAttestation(bundle, attestationID);
|
||||
}
|
||||
exports.attest = attest;
|
||||
async function attestProvenance(options) {
|
||||
const predicate = (0, provenance_1.generateProvenancePredicate)(process.env);
|
||||
return attest({
|
||||
...options,
|
||||
predicateType: predicate.type,
|
||||
predicate: predicate.params
|
||||
});
|
||||
}
|
||||
exports.attestProvenance = attestProvenance;
|
||||
function toAttestation(bundle, attestationID) {
|
||||
// Extract the signing certificate from the bundle
|
||||
(0, assert_1.default)(bundle.verificationMaterial.content.$case === 'x509CertificateChain', 'Bundle must contain an x509 certificate chain');
|
||||
const signingCert = new crypto_1.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: (0, bundle_1.bundleToJSON)(bundle),
|
||||
certificate: signingCert.toString(),
|
||||
tlogID,
|
||||
attestationID
|
||||
};
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
export { AttestOptions, AttestProvenanceOptions, attest, attestProvenance } from './attest';
|
||||
export { generateProvenancePredicate } from './provenance';
|
||||
export { generateSBOMPredicate } from './sbom';
|
||||
export type { Attestation, Predicate, Subject, SBOM } from './shared.types';
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateSBOMPredicate = exports.generateProvenancePredicate = exports.attestProvenance = exports.attest = void 0;
|
||||
var attest_1 = require("./attest");
|
||||
Object.defineProperty(exports, "attest", { enumerable: true, get: function () { return attest_1.attest; } });
|
||||
Object.defineProperty(exports, "attestProvenance", { enumerable: true, get: function () { return attest_1.attestProvenance; } });
|
||||
var provenance_1 = require("./provenance");
|
||||
Object.defineProperty(exports, "generateProvenancePredicate", { enumerable: true, get: function () { return provenance_1.generateProvenancePredicate; } });
|
||||
var sbom_1 = require("./sbom");
|
||||
Object.defineProperty(exports, "generateSBOMPredicate", { enumerable: true, get: function () { return sbom_1.generateSBOMPredicate; } });
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="node" />
|
||||
/// <reference types="node" />
|
||||
/// <reference types="node" />
|
||||
/// <reference types="node" />
|
||||
/// <reference types="node" />
|
||||
import type { Predicate, Subject } from './shared.types';
|
||||
export declare const SLSA_PREDICATE_V1_TYPE = "https://slsa.dev/provenance/v1";
|
||||
export declare const generateProvenancePredicate: (env: NodeJS.ProcessEnv) => Predicate;
|
||||
export declare const generateProvenance: (subject: Subject, env: NodeJS.ProcessEnv) => object;
|
||||
Vendored
+65
@@ -0,0 +1,65 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateProvenance = exports.generateProvenancePredicate = exports.SLSA_PREDICATE_V1_TYPE = void 0;
|
||||
const INTOTO_STATEMENT_V1_TYPE = 'https://in-toto.io/Statement/v1';
|
||||
exports.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';
|
||||
const generateProvenancePredicate = (env) => {
|
||||
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: exports.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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
exports.generateProvenancePredicate = generateProvenancePredicate;
|
||||
const generateProvenance = (subject, env) => {
|
||||
const predicate = (0, exports.generateProvenancePredicate)(env);
|
||||
return {
|
||||
_type: INTOTO_STATEMENT_V1_TYPE,
|
||||
subject: [subject],
|
||||
predicateType: predicate.type,
|
||||
predicate: predicate.params
|
||||
};
|
||||
};
|
||||
exports.generateProvenance = generateProvenance;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import type { SBOM, Predicate } from './shared.types';
|
||||
export declare const generateSBOMPredicate: (sbom: SBOM) => Predicate;
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.generateSBOMPredicate = void 0;
|
||||
const generateSBOMPredicate = (sbom) => {
|
||||
if (sbom.type === 'spdx') {
|
||||
return generateSPDXIntoto(sbom.object);
|
||||
}
|
||||
if (sbom.type === 'cyclonedx') {
|
||||
return generateCycloneDXIntoto(sbom.object);
|
||||
}
|
||||
throw new Error('Unsupported SBOM format');
|
||||
};
|
||||
exports.generateSBOMPredicate = generateSBOMPredicate;
|
||||
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/spdx.md
|
||||
const generateSPDXIntoto = (sbom) => {
|
||||
const spdxVersion = sbom?.['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) => {
|
||||
return {
|
||||
type: 'https://cyclonedx.org/bom',
|
||||
params: sbom
|
||||
};
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
};
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="node" />
|
||||
import { Bundle } from '@sigstore/bundle';
|
||||
import { IdentityProvider } from '@sigstore/sign';
|
||||
export type Payload = {
|
||||
body: Buffer;
|
||||
type: string;
|
||||
};
|
||||
export type SignOptions = {
|
||||
fulcioURL: string;
|
||||
rekorURL?: string;
|
||||
tsaServerURL?: string;
|
||||
identityProvider?: IdentityProvider;
|
||||
timeout?: number;
|
||||
retry?: number;
|
||||
};
|
||||
export declare const signPayload: (payload: Payload, options: SignOptions) => Promise<Bundle>;
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.signPayload = void 0;
|
||||
const sign_1 = require("@sigstore/sign");
|
||||
const OIDC_AUDIENCE = 'sigstore';
|
||||
const DEFAULT_TIMEOUT = 10000;
|
||||
const DEFAULT_RETRIES = 3;
|
||||
// Signs the provided payload with Sigstore.
|
||||
const signPayload = async (payload, options) => {
|
||||
const artifact = {
|
||||
data: payload.body,
|
||||
type: payload.type
|
||||
};
|
||||
// Sign the artifact and build the bundle
|
||||
return initBundleBuilder(options).create(artifact);
|
||||
};
|
||||
exports.signPayload = signPayload;
|
||||
// Assembles the Sigstore bundle builder with the appropriate options
|
||||
const initBundleBuilder = (opts) => {
|
||||
const identityProvider = opts.identityProvider || new sign_1.CIContextProvider(OIDC_AUDIENCE);
|
||||
const timeout = opts.timeout || DEFAULT_TIMEOUT;
|
||||
const retry = opts.retry || DEFAULT_RETRIES;
|
||||
const witnesses = [];
|
||||
const signer = new sign_1.FulcioSigner({
|
||||
identityProvider: identityProvider,
|
||||
fulcioBaseURL: opts.fulcioURL,
|
||||
timeout: timeout,
|
||||
retry: retry
|
||||
});
|
||||
if (opts.rekorURL) {
|
||||
witnesses.push(new sign_1.RekorWitness({
|
||||
rekorBaseURL: opts.rekorURL,
|
||||
entryType: 'dsse',
|
||||
timeout: timeout,
|
||||
retry: retry
|
||||
}));
|
||||
}
|
||||
if (opts.tsaServerURL) {
|
||||
witnesses.push(new sign_1.TSAWitness({
|
||||
tsaBaseURL: opts.tsaServerURL,
|
||||
timeout: timeout,
|
||||
retry: retry
|
||||
}));
|
||||
}
|
||||
return new sign_1.DSSEBundleBuilder({ signer, witnesses });
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export declare const writeAttestation: (attestation: unknown, token: string) => Promise<string>;
|
||||
Vendored
+51
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.writeAttestation = void 0;
|
||||
const github = __importStar(require("@actions/github"));
|
||||
const make_fetch_happen_1 = __importDefault(require("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.
|
||||
const writeAttestation = async (attestation, token) => {
|
||||
const octokit = github.getOctokit(token, { request: { fetch: make_fetch_happen_1.default } });
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
exports.writeAttestation = writeAttestation;
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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__"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const FULCIO_PUBLIC_GOOD_URL = 'https://fulcio.sigstore.dev'
|
||||
export const REKOR_PUBLIC_GOOD_URL = 'https://rekor.sigstore.dev'
|
||||
export const SEARCH_PUBLIC_GOOD_URL = 'https://search.sigstore.dev'
|
||||
|
||||
export const FULCIO_INTERNAL_URL = 'https://fulcio.githubapp.com'
|
||||
export const TSA_INTERNAL_URL = 'https://timestamp.githubapp.com'
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as core from '@actions/core'
|
||||
import fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type { Predicate } from '@actions/attest'
|
||||
|
||||
// Returns the predicate specified by the action's inputs. The predicate value
|
||||
// may be specified as a path to a file or as a string.
|
||||
export const predicateFromInputs = (): Predicate => {
|
||||
const predicateType = core.getInput('predicate-type', { required: true })
|
||||
const predicateStr = core.getInput('predicate', { required: false })
|
||||
const predicatePath = core.getInput('predicate-path', { required: false })
|
||||
|
||||
if (!predicatePath && !predicateStr) {
|
||||
throw new Error('One of predicate-path or predicate must be provided')
|
||||
}
|
||||
|
||||
const params = predicatePath
|
||||
? fs.readFileSync(predicatePath, 'utf-8')
|
||||
: predicateStr
|
||||
|
||||
return { type: predicateType, params: JSON.parse(params) }
|
||||
}
|
||||
|
||||
export const storePredicate = (predicate: Predicate): string => {
|
||||
// random tempfile
|
||||
const basePath = process.env['RUNNER_TEMP']
|
||||
|
||||
if (!basePath) {
|
||||
throw new Error('Missing RUNNER_TEMP environment variable')
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(basePath, path.sep))
|
||||
const tempFile = path.join(tmpDir, 'predicate.json')
|
||||
|
||||
// write predicate to file
|
||||
fs.writeFileSync(tempFile, JSON.stringify(predicate.params))
|
||||
return tempFile
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'fs'
|
||||
import { SBOM } from '@actions/attest'
|
||||
|
||||
export async function parseSBOMFromPath(path: string): Promise<SBOM> {
|
||||
// Read the file content
|
||||
const fileContent = await fs.promises.readFile(path, 'utf8')
|
||||
|
||||
const sbom = JSON.parse(fileContent)
|
||||
|
||||
if (checkIsSPDX(sbom)) {
|
||||
return { type: 'spdx', object: sbom }
|
||||
} else if (checkIsCycloneDX(sbom)) {
|
||||
return { type: 'cyclonedx', object: sbom }
|
||||
}
|
||||
throw new Error('Unsupported SBOM format')
|
||||
}
|
||||
|
||||
function checkIsSPDX(sbomObject: {
|
||||
spdxVersion?: string
|
||||
SPDXID?: string
|
||||
}): boolean {
|
||||
if (sbomObject?.spdxVersion && sbomObject?.SPDXID) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function checkIsCycloneDX(sbomObject: {
|
||||
bomFormat?: string
|
||||
serialNumber?: string
|
||||
specVersion?: string
|
||||
}): boolean {
|
||||
if (
|
||||
sbomObject?.bomFormat &&
|
||||
sbomObject?.serialNumber &&
|
||||
sbomObject?.specVersion
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as core from '@actions/core'
|
||||
import * as glob from '@actions/glob'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import type { Subject } from '@actions/attest'
|
||||
|
||||
const DIGEST_ALGORITHM = 'sha256'
|
||||
|
||||
// Returns the subject specified by the action's inputs. The subject may be
|
||||
// specified as a path to a file or as a digest. If a path is provided, the
|
||||
// file's digest is calculated and returned along with the subject's name. If a
|
||||
// digest is provided, the name must also be provided.
|
||||
export const subjectFromInputs = async (): Promise<Subject[]> => {
|
||||
const subjectPath = core.getInput('subject-path', { required: false })
|
||||
const subjectDigest = core.getInput('subject-digest', { required: false })
|
||||
const subjectName = core.getInput('subject-name', { required: false })
|
||||
|
||||
if (!subjectPath && !subjectDigest) {
|
||||
throw new Error('One of subject-path or subject-digest must be provided')
|
||||
}
|
||||
|
||||
if (subjectPath && subjectDigest) {
|
||||
throw new Error(
|
||||
'Only one of subject-path or subject-digest may be provided'
|
||||
)
|
||||
}
|
||||
|
||||
if (subjectDigest && !subjectName) {
|
||||
throw new Error('subject-name must be provided when using subject-digest')
|
||||
}
|
||||
|
||||
if (subjectPath) {
|
||||
return await getSubjectFromPath(subjectPath, subjectName)
|
||||
} else {
|
||||
return [getSubjectFromDigest(subjectDigest, subjectName)]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the subject specified by the path to a file. The file's digest is
|
||||
// calculated and returned along with the subject's name.
|
||||
const getSubjectFromPath = async (
|
||||
subjectPath: string,
|
||||
subjectName?: string
|
||||
): Promise<Subject[]> => {
|
||||
/* eslint-disable-next-line github/no-then */
|
||||
const files = await glob.create(subjectPath).then(async g => g.glob())
|
||||
|
||||
const subjects = files.map(async file => {
|
||||
const name = subjectName || path.parse(file).base
|
||||
const digest = await digestFile(DIGEST_ALGORITHM, file)
|
||||
return { name, digest: { [DIGEST_ALGORITHM]: digest } }
|
||||
})
|
||||
|
||||
if (subjects.length === 0) {
|
||||
throw new Error(`Could not find subject at path ${subjectPath}`)
|
||||
}
|
||||
|
||||
return Promise.all(subjects)
|
||||
}
|
||||
|
||||
// Returns the subject specified by the digest of a file. The digest is returned
|
||||
// along with the subject's name.
|
||||
const getSubjectFromDigest = (
|
||||
subjectDigest: string,
|
||||
subjectName: string
|
||||
): Subject => {
|
||||
if (!subjectDigest.match(/^sha256:[A-Za-z0-9]{64}$/)) {
|
||||
throw new Error(
|
||||
'subject-digest must be in the format "sha256:<hex-digest>"'
|
||||
)
|
||||
}
|
||||
const [alg, digest] = subjectDigest.split(':')
|
||||
|
||||
return {
|
||||
name: subjectName,
|
||||
digest: { [alg]: digest }
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the digest of a file using the specified algorithm. The file is
|
||||
// streamed into the digest function to avoid loading the entire file into
|
||||
// memory. The returned digest is a hex string.
|
||||
const digestFile = async (
|
||||
algorithm: string,
|
||||
filePath: string
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash(algorithm).setEncoding('hex')
|
||||
fs.createReadStream(filePath)
|
||||
.once('error', reject)
|
||||
.pipe(hash)
|
||||
.once('finish', () => resolve(hash.read()))
|
||||
})
|
||||
}
|
||||
+157
-12
@@ -1,26 +1,171 @@
|
||||
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
|
||||
import * as core from '@actions/core'
|
||||
import { wait } from './wait'
|
||||
import * as github from '@actions/github'
|
||||
import { BUNDLE_V02_MEDIA_TYPE } from '@sigstore/bundle'
|
||||
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import {
|
||||
FULCIO_INTERNAL_URL,
|
||||
FULCIO_PUBLIC_GOOD_URL,
|
||||
REKOR_PUBLIC_GOOD_URL,
|
||||
SEARCH_PUBLIC_GOOD_URL,
|
||||
TSA_INTERNAL_URL
|
||||
} from './helper/endpoints'
|
||||
import { predicateFromInputs } from './helper/predicate'
|
||||
import { subjectFromInputs } from './helper/subject'
|
||||
|
||||
type Endpoints = {
|
||||
fulcioURL: string
|
||||
rekorURL?: string
|
||||
tsaServerURL?: string
|
||||
}
|
||||
|
||||
const COLOR_CYAN = '\x1B[36m'
|
||||
const COLOR_DEFAULT = '\x1B[39m'
|
||||
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
|
||||
|
||||
const SIGSTORE_PUBLIC_GOOD_ENDPOINTS: Endpoints = {
|
||||
fulcioURL: FULCIO_PUBLIC_GOOD_URL,
|
||||
rekorURL: REKOR_PUBLIC_GOOD_URL
|
||||
}
|
||||
|
||||
const SIGSTORE_INTERNAL_ENDPOINTS: Endpoints = {
|
||||
fulcioURL: FULCIO_INTERNAL_URL,
|
||||
tsaServerURL: TSA_INTERNAL_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* The main function for the action.
|
||||
* @returns {Promise<void>} Resolves when the action is complete.
|
||||
*/
|
||||
export async function run(): Promise<void> {
|
||||
// Provenance visibility will be public ONLY if we can confirm that the
|
||||
// repository is public AND the undocumented "private-signing" arg is NOT set.
|
||||
// Otherwise, it will be private.
|
||||
const endpoints =
|
||||
github.context.payload.repository?.visibility === 'public' &&
|
||||
core.getInput('private-signing') !== 'true'
|
||||
? SIGSTORE_PUBLIC_GOOD_ENDPOINTS
|
||||
: SIGSTORE_INTERNAL_ENDPOINTS
|
||||
|
||||
try {
|
||||
const ms: string = core.getInput('milliseconds')
|
||||
// Calculate subject from inputs and generate provenance
|
||||
const subjects = await subjectFromInputs()
|
||||
const predicate = predicateFromInputs()
|
||||
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
|
||||
|
||||
// Debug logs are only output if the `ACTIONS_STEP_DEBUG` secret is true
|
||||
core.debug(`Waiting ${ms} milliseconds ...`)
|
||||
// Generate attestations for each subject serially
|
||||
for (const subject of subjects) {
|
||||
const att = await createAttestation(subject, predicate, endpoints)
|
||||
|
||||
// Log the current timestamp, wait, then log the new timestamp
|
||||
core.debug(new Date().toTimeString())
|
||||
await wait(parseInt(ms, 10))
|
||||
core.debug(new Date().toTimeString())
|
||||
// Write attestation bundle to output file
|
||||
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
|
||||
encoding: 'utf-8',
|
||||
flag: 'a'
|
||||
})
|
||||
|
||||
// Set outputs for other workflow steps to use
|
||||
core.setOutput('time', new Date().toTimeString())
|
||||
} catch (error) {
|
||||
if (att.attestationID) {
|
||||
core.summary.addLink(
|
||||
`${subject.name}@${subjectDigest(subject)}`,
|
||||
attestationURL(att.attestationID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!core.summary.isEmptyBuffer()) {
|
||||
core.summary.addHeading('Attestation(s) Created', 3)
|
||||
core.summary.write()
|
||||
}
|
||||
|
||||
core.setOutput('bundle-path', outputPath)
|
||||
} catch (err) {
|
||||
// Fail the workflow run if an error occurs
|
||||
if (error instanceof Error) core.setFailed(error.message)
|
||||
core.setFailed(
|
||||
err instanceof Error ? err.message : /* istanbul ignore next */ `${err}`
|
||||
)
|
||||
|
||||
/* istanbul ignore if */
|
||||
if (err instanceof Error && 'cause' in err) {
|
||||
const innerErr = err.cause
|
||||
core.debug(innerErr instanceof Error ? innerErr.message : `${innerErr}}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createAttestation = async (
|
||||
subject: Subject,
|
||||
predicate: Predicate,
|
||||
endpoints: Endpoints
|
||||
): Promise<Attestation> => {
|
||||
// Sign provenance w/ Sigstore
|
||||
const attestation = await attest({
|
||||
...endpoints,
|
||||
subjectName: subject.name,
|
||||
subjectDigest: subject.digest,
|
||||
predicateType: predicate.type,
|
||||
predicate: predicate.params,
|
||||
token: core.getInput('github-token')
|
||||
})
|
||||
|
||||
core.startGroup(
|
||||
highlight(
|
||||
`Attestation signed using ephemeral certificate from ${endpoints.fulcioURL}`
|
||||
)
|
||||
)
|
||||
core.info(attestation.certificate)
|
||||
core.endGroup()
|
||||
|
||||
if (attestation.tlogID) {
|
||||
core.info(
|
||||
highlight('Attestation signature uploaded to Rekor transparency log')
|
||||
)
|
||||
core.info(`${SEARCH_PUBLIC_GOOD_URL}?logIndex=${attestation.tlogID}`)
|
||||
}
|
||||
|
||||
if (attestation.attestationID) {
|
||||
core.info(highlight('Attestation uploaded to repository'))
|
||||
core.info(attestationURL(attestation.attestationID))
|
||||
}
|
||||
|
||||
if (core.getBooleanInput('push-to-registry', { required: false })) {
|
||||
const credentials = getRegistryCredentials(subject.name)
|
||||
const artifact = await attachArtifactToImage({
|
||||
credentials,
|
||||
imageName: subject.name,
|
||||
imageDigest: subjectDigest(subject),
|
||||
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
||||
mediaType: BUNDLE_V02_MEDIA_TYPE,
|
||||
annotations: {
|
||||
'dev.sigstore.bundle/predicateType': core.getInput('predicate-type')
|
||||
}
|
||||
})
|
||||
core.info(highlight('Attestation uploaded to registry'))
|
||||
core.info(`${subject.name}@${artifact.digest}`)
|
||||
}
|
||||
|
||||
return attestation
|
||||
}
|
||||
|
||||
const highlight = (str: string): string => `${COLOR_CYAN}${str}${COLOR_DEFAULT}`
|
||||
|
||||
const tempDir = (): string => {
|
||||
const basePath = process.env['RUNNER_TEMP']
|
||||
|
||||
if (!basePath) {
|
||||
throw new Error('Missing RUNNER_TEMP environment variable')
|
||||
}
|
||||
|
||||
return fs.mkdtempSync(path.join(basePath, path.sep))
|
||||
}
|
||||
|
||||
// Returns the subject's digest as a formatted string of the form
|
||||
// "<algorithm>:<digest>".
|
||||
const subjectDigest = (subject: Subject): string => {
|
||||
const alg = Object.keys(subject.digest).sort()[0]
|
||||
return `${alg}:${subject.digest[alg]}`
|
||||
}
|
||||
|
||||
const attestationURL = (id: string): string =>
|
||||
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
|
||||
|
||||
-14
@@ -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)
|
||||
})
|
||||
}
|
||||
+5
-1
@@ -15,5 +15,9 @@
|
||||
"skipLibCheck": true,
|
||||
"newLine": "lf"
|
||||
},
|
||||
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"]
|
||||
"include": [ "/src/*" ],
|
||||
"exclude": ["./dist", "./node_modules", "./__tests__", "./coverage"],
|
||||
"references": [
|
||||
{ "path": "./packages/attest" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user