init attest build provenance

This commit is contained in:
ejahnGithub 2024-02-22 08:29:05 -08:00
parent 1a4323c55b
commit 381fca5703
45 changed files with 2789 additions and 238 deletions

View File

@ -2,3 +2,4 @@ lib/
dist/
node_modules/
coverage/
packages/

View File

@ -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-provenance:
name: Test attest-provenance 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: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Run attest-provenance
id: attest-provenance
uses: ./
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 }}"

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -1,24 +1,59 @@
name: 'The name of your action here'
description: 'Provide a description here'
author: 'Your name or organization here'
name: 'Attest Build Provenance'
description: 'Generate provenance 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'
required: true
default: '1000'
# Define your outputs here.
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:
time:
description: 'Your output description here'
bundle-path:
description: 'The path to the file containing the attestation bundle(s).'
value: ${{ steps.attest.outputs.bundle-path }}
runs:
using: node20
main: dist/index.js
using: 'composite'
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
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 }}

View File

@ -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

Binary file not shown.

BIN
dist/licenses.txt generated vendored

Binary file not shown.

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",
@ -85,5 +86,8 @@
"prettier-eslint": "^16.3.0",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
},
"workspaces": [
"./packages/*"
]
}

16
packages/attest/dist/attest.d.ts vendored Normal file
View File

@ -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 {};

62
packages/attest/dist/attest.js vendored Normal file
View File

@ -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
};
}

4
packages/attest/dist/index.d.ts vendored Normal file
View File

@ -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';

10
packages/attest/dist/index.js vendored Normal file
View File

@ -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; } });

9
packages/attest/dist/provenance.d.ts vendored Normal file
View File

@ -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;

65
packages/attest/dist/provenance.js vendored Normal file
View File

@ -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;

2
packages/attest/dist/sbom.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
import type { SBOM, Predicate } from './shared.types';
export declare const generateSBOMPredicate: (sbom: SBOM) => Predicate;

32
packages/attest/dist/sbom.js vendored Normal file
View File

@ -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
packages/attest/dist/shared.types.d.ts vendored Normal file
View File

@ -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;
};

2
packages/attest/dist/shared.types.js vendored Normal file
View File

@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

16
packages/attest/dist/sign.d.ts vendored Normal file
View File

@ -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>;

46
packages/attest/dist/sign.js vendored Normal file
View File

@ -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 });
};

1
packages/attest/dist/store.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export declare const writeAttestation: (attestation: unknown, token: string) => Promise<string>;

51
packages/attest/dist/store.js vendored Normal file
View File

@ -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;

View File

@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/*.test.ts'],
};

View 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"
}
}

View File

@ -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",
},
],
}
`;

View 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()
})
})
})
})

View 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()
})

View 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()
})
})

View 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)
})
})
})

View 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/)
})
})
})

View 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
}
}

View 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'

View 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
}
}

View 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
}
}

View 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
}

View 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 })
}

View 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}`)
}
}

View 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__"
]
}

View File

@ -1,5 +1,5 @@
import { generateProvenancePredicate } from '@actions/attest'
import * as core from '@actions/core'
import { wait } from './wait'
/**
* The main function for the action.
@ -7,20 +7,14 @@ import { wait } from './wait'
*/
export async function run(): Promise<void> {
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.debug(`Waiting ${ms} milliseconds ...`)
// 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())
// Set outputs for other workflow steps to use
core.setOutput('time', new Date().toTimeString())
} catch (error) {
core.setOutput('predicate', predicate.params)
core.setOutput('predicate-type', predicate.type)
} catch (err) {
const error = err instanceof Error ? err : new Error(`${err}`)
// Fail the workflow run if an error occurs
if (error instanceof Error) core.setFailed(error.message)
core.setFailed(error.message)
}
}

View File

@ -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)
})
}

View File

@ -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" }
]
}