support multi-subject attestations (#164)
Signed-off-by: Brian DeHamer <[email protected]>
This commit is contained in:
@@ -117,18 +117,14 @@ See [action.yml](action.yml)
|
|||||||
|
|
||||||
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
|
Attestations are saved in the JSON-serialized [Sigstore bundle][6] format.
|
||||||
|
|
||||||
If multiple subjects are being attested at the same time, each attestation will
|
If multiple subjects are being attested at the same time, a single attestation
|
||||||
be written to the output file on a separate line (using the [JSON Lines][7]
|
will be created with references to each of the supplied subjects.
|
||||||
format).
|
|
||||||
|
|
||||||
## Attestation Limits
|
## Attestation Limits
|
||||||
|
|
||||||
### Subject Limits
|
### Subject Limits
|
||||||
|
|
||||||
No more than 2500 subjects can be attested at the same time. Subjects will be
|
No more than 1024 subjects can be attested at the same time.
|
||||||
processed in batches 50. After the initial group of 50, each subsequent batch
|
|
||||||
will incur an exponentially increasing amount of delay (capped at 1 minute of
|
|
||||||
delay per batch) to avoid overwhelming the attestation API.
|
|
||||||
|
|
||||||
### Predicate Limits
|
### Predicate Limits
|
||||||
|
|
||||||
@@ -269,7 +265,6 @@ jobs:
|
|||||||
[5]: https://cli.github.com/manual/gh_attestation_verify
|
[5]: https://cli.github.com/manual/gh_attestation_verify
|
||||||
[6]:
|
[6]:
|
||||||
https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto
|
https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto
|
||||||
[7]: https://jsonlines.org/
|
|
||||||
[8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
|
[8]: https://github.com/actions/toolkit/tree/main/packages/glob#patterns
|
||||||
[9]:
|
[9]:
|
||||||
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
|
https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds
|
||||||
|
|||||||
+15
-46
@@ -46,8 +46,7 @@ const defaultInputs: main.RunInputs = {
|
|||||||
pushToRegistry: false,
|
pushToRegistry: false,
|
||||||
showSummary: true,
|
showSummary: true,
|
||||||
githubToken: '',
|
githubToken: '',
|
||||||
privateSigning: false,
|
privateSigning: false
|
||||||
batchSize: 50
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('action', () => {
|
describe('action', () => {
|
||||||
@@ -290,15 +289,11 @@ describe('action', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the subject count exceeds the batch size', () => {
|
describe('when the subject count is greater than 1', () => {
|
||||||
let dir = ''
|
let dir = ''
|
||||||
const filename = 'subject'
|
const filename = 'subject'
|
||||||
let scope: nock.Scope
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Start from scratch
|
|
||||||
nock.cleanAll()
|
|
||||||
|
|
||||||
const subjectCount = 5
|
const subjectCount = 5
|
||||||
const content = 'file content'
|
const content = 'file content'
|
||||||
|
|
||||||
@@ -309,38 +304,22 @@ describe('action', () => {
|
|||||||
// Add files for glob testing
|
// Add files for glob testing
|
||||||
for (let i = 0; i < subjectCount; i++) {
|
for (let i = 0; i < subjectCount; i++) {
|
||||||
await fs.writeFile(path.join(dir, `${filename}-${i}`), content)
|
await fs.writeFile(path.join(dir, `${filename}-${i}`), content)
|
||||||
|
|
||||||
// Set-up a Fulcio mock for each subject
|
|
||||||
await mockFulcio({
|
|
||||||
baseURL: 'https://fulcio.githubapp.com',
|
|
||||||
strict: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set-up a TSA mock for each subject
|
|
||||||
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
|
|
||||||
|
|
||||||
// Set-up a GH API mock for each subject
|
|
||||||
mockAgent
|
|
||||||
.get('https://api.github.com')
|
|
||||||
.intercept({
|
|
||||||
path: /^\/repos\/.*\/.*\/attestations$/,
|
|
||||||
method: 'post'
|
|
||||||
})
|
|
||||||
.reply(201, { id: attestationID })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set-up a OIDC token mock for each subject
|
|
||||||
scope = nock(tokenURL)
|
|
||||||
.get('/')
|
|
||||||
.query({ audience: 'sigstore' })
|
|
||||||
.times(subjectCount)
|
|
||||||
.reply(200, { value: oidcToken })
|
|
||||||
|
|
||||||
// Set the GH context with private repository visibility and a repo owner.
|
// Set the GH context with private repository visibility and a repo owner.
|
||||||
setGHContext({
|
setGHContext({
|
||||||
payload: { repository: { visibility: 'private' } },
|
payload: { repository: { visibility: 'private' } },
|
||||||
repo: { owner: 'foo', repo: 'bar' }
|
repo: { owner: 'foo', repo: 'bar' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set-up a Fulcio mock for each subject
|
||||||
|
await mockFulcio({
|
||||||
|
baseURL: 'https://fulcio.githubapp.com',
|
||||||
|
strict: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set-up a TSA mock for each subject
|
||||||
|
await mockTSA({ baseURL: 'https://timestamp.githubapp.com' })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
@@ -354,8 +333,7 @@ describe('action', () => {
|
|||||||
subjectPath: path.join(dir, `${filename}-*`),
|
subjectPath: path.join(dir, `${filename}-*`),
|
||||||
predicateType,
|
predicateType,
|
||||||
predicate,
|
predicate,
|
||||||
githubToken: 'gh-token',
|
githubToken: 'gh-token'
|
||||||
batchSize: 2
|
|
||||||
}
|
}
|
||||||
await main.run(inputs)
|
await main.run(inputs)
|
||||||
|
|
||||||
@@ -363,17 +341,8 @@ describe('action', () => {
|
|||||||
expect(setFailedMock).not.toHaveBeenCalled()
|
expect(setFailedMock).not.toHaveBeenCalled()
|
||||||
expect(infoMock).toHaveBeenNthCalledWith(
|
expect(infoMock).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
expect.stringMatching('Processing subject batch 1/3')
|
expect.stringMatching('Attestation created for 5 subjects')
|
||||||
)
|
)
|
||||||
expect(infoMock).toHaveBeenNthCalledWith(
|
|
||||||
10,
|
|
||||||
expect.stringMatching('Processing subject batch 2/3')
|
|
||||||
)
|
|
||||||
expect(infoMock).toHaveBeenNthCalledWith(
|
|
||||||
19,
|
|
||||||
expect.stringMatching('Processing subject batch 3/3')
|
|
||||||
)
|
|
||||||
expect(scope.isDone()).toBe(true)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -382,7 +351,7 @@ describe('action', () => {
|
|||||||
const filename = 'subject'
|
const filename = 'subject'
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const subjectCount = 2501
|
const subjectCount = 1025
|
||||||
const content = 'file content'
|
const content = 'file content'
|
||||||
|
|
||||||
// Set-up temp directory
|
// Set-up temp directory
|
||||||
@@ -419,7 +388,7 @@ describe('action', () => {
|
|||||||
expect(runMock).toHaveReturned()
|
expect(runMock).toHaveReturned()
|
||||||
expect(setFailedMock).toHaveBeenCalledWith(
|
expect(setFailedMock).toHaveBeenCalledWith(
|
||||||
new Error(
|
new Error(
|
||||||
'Too many subjects specified. The maximum number of subjects is 2500.'
|
'Too many subjects specified. The maximum number of subjects is 1024.'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import crypto from 'crypto'
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { subjectFromInputs, SubjectInputs } from '../src/subject'
|
import {
|
||||||
|
formatSubjectDigest,
|
||||||
|
subjectFromInputs,
|
||||||
|
SubjectInputs
|
||||||
|
} from '../src/subject'
|
||||||
|
|
||||||
describe('subjectFromInputs', () => {
|
describe('subjectFromInputs', () => {
|
||||||
const blankInputs: SubjectInputs = {
|
const blankInputs: SubjectInputs = {
|
||||||
@@ -360,3 +364,15 @@ describe('subjectFromInputs', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('subjectDigest', () => {
|
||||||
|
it('returns the digest', () => {
|
||||||
|
const subject = {
|
||||||
|
name: 'foo',
|
||||||
|
digest: { sha1: 'deadbeef' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = formatSubjectDigest(subject)
|
||||||
|
expect(digest).toEqual('sha1:deadbeef')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+47
-82
@@ -70761,30 +70761,26 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|||||||
exports.createAttestation = void 0;
|
exports.createAttestation = void 0;
|
||||||
const attest_1 = __nccwpck_require__(11485);
|
const attest_1 = __nccwpck_require__(11485);
|
||||||
const oci_1 = __nccwpck_require__(81057);
|
const oci_1 = __nccwpck_require__(81057);
|
||||||
|
const subject_1 = __nccwpck_require__(36303);
|
||||||
const OCI_TIMEOUT = 30000;
|
const OCI_TIMEOUT = 30000;
|
||||||
const OCI_RETRY = 3;
|
const OCI_RETRY = 3;
|
||||||
const createAttestation = async (subject, predicate, opts) => {
|
const createAttestation = async (subjects, predicate, opts) => {
|
||||||
// Sign provenance w/ Sigstore
|
// Sign provenance w/ Sigstore
|
||||||
const attestation = await (0, attest_1.attest)({
|
const attestation = await (0, attest_1.attest)({
|
||||||
subjectName: subject.name,
|
subjects,
|
||||||
subjectDigest: subject.digest,
|
|
||||||
predicateType: predicate.type,
|
predicateType: predicate.type,
|
||||||
predicate: predicate.params,
|
predicate: predicate.params,
|
||||||
sigstore: opts.sigstoreInstance,
|
sigstore: opts.sigstoreInstance,
|
||||||
token: opts.githubToken
|
token: opts.githubToken
|
||||||
});
|
});
|
||||||
const subDigest = subjectDigest(subject);
|
const result = attestation;
|
||||||
const result = {
|
if (subjects.length === 1 && opts.pushToRegistry) {
|
||||||
...attestation,
|
const subject = subjects[0];
|
||||||
subjectName: subject.name,
|
|
||||||
subjectDigest: subDigest
|
|
||||||
};
|
|
||||||
if (opts.pushToRegistry) {
|
|
||||||
const credentials = (0, oci_1.getRegistryCredentials)(subject.name);
|
const credentials = (0, oci_1.getRegistryCredentials)(subject.name);
|
||||||
const artifact = await (0, oci_1.attachArtifactToImage)({
|
const artifact = await (0, oci_1.attachArtifactToImage)({
|
||||||
credentials,
|
credentials,
|
||||||
imageName: subject.name,
|
imageName: subject.name,
|
||||||
imageDigest: subDigest,
|
imageDigest: (0, subject_1.formatSubjectDigest)(subject),
|
||||||
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
||||||
mediaType: attestation.bundle.mediaType,
|
mediaType: attestation.bundle.mediaType,
|
||||||
annotations: {
|
annotations: {
|
||||||
@@ -70799,12 +70795,6 @@ const createAttestation = async (subject, predicate, opts) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
exports.createAttestation = createAttestation;
|
exports.createAttestation = createAttestation;
|
||||||
// Returns the subject's digest as a formatted string of the form
|
|
||||||
// "<algorithm>:<digest>".
|
|
||||||
const subjectDigest = (subject) => {
|
|
||||||
const alg = Object.keys(subject.digest).sort()[0];
|
|
||||||
return `${alg}:${subject.digest[alg]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/***/ }),
|
/***/ }),
|
||||||
@@ -70855,7 +70845,6 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
|
|||||||
*/
|
*/
|
||||||
const core = __importStar(__nccwpck_require__(37484));
|
const core = __importStar(__nccwpck_require__(37484));
|
||||||
const main_1 = __nccwpck_require__(41730);
|
const main_1 = __nccwpck_require__(41730);
|
||||||
const DEFAULT_BATCH_SIZE = 50;
|
|
||||||
const inputs = {
|
const inputs = {
|
||||||
subjectPath: core.getInput('subject-path'),
|
subjectPath: core.getInput('subject-path'),
|
||||||
subjectName: core.getInput('subject-name'),
|
subjectName: core.getInput('subject-name'),
|
||||||
@@ -70867,9 +70856,7 @@ const inputs = {
|
|||||||
showSummary: core.getBooleanInput('show-summary'),
|
showSummary: core.getBooleanInput('show-summary'),
|
||||||
githubToken: core.getInput('github-token'),
|
githubToken: core.getInput('github-token'),
|
||||||
// undocumented -- not part of public interface
|
// undocumented -- not part of public interface
|
||||||
privateSigning: ['true', 'True', 'TRUE', '1'].includes(core.getInput('private-signing')),
|
privateSigning: ['true', 'True', 'TRUE', '1'].includes(core.getInput('private-signing'))
|
||||||
// internal only
|
|
||||||
batchSize: DEFAULT_BATCH_SIZE
|
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
(0, main_1.run)(inputs);
|
(0, main_1.run)(inputs);
|
||||||
@@ -70921,8 +70908,6 @@ const predicate_1 = __nccwpck_require__(84982);
|
|||||||
const style = __importStar(__nccwpck_require__(64542));
|
const style = __importStar(__nccwpck_require__(64542));
|
||||||
const subject_1 = __nccwpck_require__(36303);
|
const subject_1 = __nccwpck_require__(36303);
|
||||||
const ATTESTATION_FILE_NAME = 'attestation.jsonl';
|
const ATTESTATION_FILE_NAME = 'attestation.jsonl';
|
||||||
const DELAY_INTERVAL_MS = 75;
|
|
||||||
const DELAY_MAX_MS = 1200;
|
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
const logHandler = (level, ...args) => {
|
const logHandler = (level, ...args) => {
|
||||||
// Send any HTTP-related log events to the GitHub Actions debug log
|
// Send any HTTP-related log events to the GitHub Actions debug log
|
||||||
@@ -70944,7 +70929,6 @@ async function run(inputs) {
|
|||||||
? 'public-good'
|
? 'public-good'
|
||||||
: 'github';
|
: 'github';
|
||||||
try {
|
try {
|
||||||
const atts = [];
|
|
||||||
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
||||||
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
|
throw new Error('missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.');
|
||||||
}
|
}
|
||||||
@@ -70955,35 +70939,19 @@ async function run(inputs) {
|
|||||||
const predicate = (0, predicate_1.predicateFromInputs)(inputs);
|
const predicate = (0, predicate_1.predicateFromInputs)(inputs);
|
||||||
const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME);
|
const outputPath = path_1.default.join(tempDir(), ATTESTATION_FILE_NAME);
|
||||||
core.setOutput('bundle-path', outputPath);
|
core.setOutput('bundle-path', outputPath);
|
||||||
const subjectChunks = chunkArray(subjects, inputs.batchSize);
|
const att = await (0, attest_1.createAttestation)(subjects, predicate, {
|
||||||
// Generate attestations for each subject serially, working in batches
|
sigstoreInstance,
|
||||||
for (let i = 0; i < subjectChunks.length; i++) {
|
pushToRegistry: inputs.pushToRegistry,
|
||||||
if (subjectChunks.length > 1) {
|
githubToken: inputs.githubToken
|
||||||
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`);
|
});
|
||||||
}
|
logAttestation(subjects, att, sigstoreInstance);
|
||||||
// Calculate the delay time for this batch
|
// Write attestation bundle to output file
|
||||||
const delayTime = delay(i);
|
fs_1.default.writeFileSync(outputPath, JSON.stringify(att.bundle) + os_1.default.EOL, {
|
||||||
for (const subject of subjectChunks[i]) {
|
encoding: 'utf-8',
|
||||||
// Delay between attestations (only when chunk size > 1)
|
flag: 'a'
|
||||||
if (i > 0) {
|
});
|
||||||
await new Promise(resolve => setTimeout(resolve, delayTime));
|
|
||||||
}
|
|
||||||
const att = await (0, attest_1.createAttestation)(subject, predicate, {
|
|
||||||
sigstoreInstance,
|
|
||||||
pushToRegistry: inputs.pushToRegistry,
|
|
||||||
githubToken: inputs.githubToken
|
|
||||||
});
|
|
||||||
atts.push(att);
|
|
||||||
logAttestation(att, sigstoreInstance);
|
|
||||||
// Write attestation bundle to output file
|
|
||||||
fs_1.default.writeFileSync(outputPath, JSON.stringify(att.bundle) + os_1.default.EOL, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
flag: 'a'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inputs.showSummary) {
|
if (inputs.showSummary) {
|
||||||
logSummary(atts);
|
logSummary(att);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -71001,8 +70969,13 @@ async function run(inputs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Log details about the attestation to the GitHub Actions run
|
// Log details about the attestation to the GitHub Actions run
|
||||||
const logAttestation = (attestation, sigstoreInstance) => {
|
const logAttestation = (subjects, attestation, sigstoreInstance) => {
|
||||||
core.info(`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`);
|
if (subjects.length === 1) {
|
||||||
|
core.info(`Attestation created for ${subjects[0].name}@${(0, subject_1.formatSubjectDigest)(subjects[0])}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
core.info(`Attestation created for ${subjects.length} subjects`);
|
||||||
|
}
|
||||||
const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub';
|
const instanceName = sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub';
|
||||||
core.startGroup(style.highlight(`Attestation signed using certificate from ${instanceName} Sigstore instance`));
|
core.startGroup(style.highlight(`Attestation signed using certificate from ${instanceName} Sigstore instance`));
|
||||||
core.info(attestation.certificate);
|
core.info(attestation.certificate);
|
||||||
@@ -71017,22 +70990,16 @@ const logAttestation = (attestation, sigstoreInstance) => {
|
|||||||
}
|
}
|
||||||
if (attestation.attestationDigest) {
|
if (attestation.attestationDigest) {
|
||||||
core.info(style.highlight('Attestation uploaded to registry'));
|
core.info(style.highlight('Attestation uploaded to registry'));
|
||||||
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`);
|
core.info(`${subjects[0].name}@${attestation.attestationDigest}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Attach summary information to the GitHub Actions run
|
// Attach summary information to the GitHub Actions run
|
||||||
const logSummary = (attestations) => {
|
const logSummary = (attestation) => {
|
||||||
if (attestations.length > 0) {
|
const { attestationID } = attestation;
|
||||||
core.summary.addHeading(
|
if (attestationID) {
|
||||||
/* istanbul ignore next */
|
const url = attestationURL(attestationID);
|
||||||
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created', 3);
|
core.summary.addHeading('Attestation Created', 3);
|
||||||
const listItems = [];
|
core.summary.addList([`<a href="${url}">${url}</a>`]);
|
||||||
for (const { subjectName, subjectDigest, attestationID } of attestations) {
|
|
||||||
if (attestationID) {
|
|
||||||
listItems.push(`<a href="${attestationURL(attestationID)}">${subjectName}@${subjectDigest}</a>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
core.summary.addList(listItems);
|
|
||||||
core.summary.write();
|
core.summary.write();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -71044,13 +71011,6 @@ const tempDir = () => {
|
|||||||
}
|
}
|
||||||
return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep));
|
return fs_1.default.mkdtempSync(path_1.default.join(basePath, path_1.default.sep));
|
||||||
};
|
};
|
||||||
// Transforms an array into an array of arrays, each containing at most
|
|
||||||
// `chunkSize` elements.
|
|
||||||
const chunkArray = (array, chunkSize) => {
|
|
||||||
return Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, index) => array.slice(index * chunkSize, (index + 1) * chunkSize));
|
|
||||||
};
|
|
||||||
// Calculate the delay time for a given iteration
|
|
||||||
const delay = (iteration) => Math.min(DELAY_INTERVAL_MS * 2 ** iteration, DELAY_MAX_MS);
|
|
||||||
const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`;
|
const attestationURL = (id) => `${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`;
|
||||||
|
|
||||||
|
|
||||||
@@ -71157,13 +71117,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
Object.defineProperty(exports, "__esModule", ({ value: true }));
|
||||||
exports.subjectFromInputs = void 0;
|
exports.formatSubjectDigest = exports.subjectFromInputs = void 0;
|
||||||
const glob = __importStar(__nccwpck_require__(47206));
|
const glob = __importStar(__nccwpck_require__(47206));
|
||||||
const crypto_1 = __importDefault(__nccwpck_require__(76982));
|
const crypto_1 = __importDefault(__nccwpck_require__(76982));
|
||||||
const sync_1 = __nccwpck_require__(61110);
|
const sync_1 = __nccwpck_require__(61110);
|
||||||
const fs_1 = __importDefault(__nccwpck_require__(79896));
|
const fs_1 = __importDefault(__nccwpck_require__(79896));
|
||||||
const path_1 = __importDefault(__nccwpck_require__(16928));
|
const path_1 = __importDefault(__nccwpck_require__(16928));
|
||||||
const MAX_SUBJECT_COUNT = 2500;
|
const MAX_SUBJECT_COUNT = 1024;
|
||||||
const DIGEST_ALGORITHM = 'sha256';
|
const DIGEST_ALGORITHM = 'sha256';
|
||||||
// Returns the subject specified by the action's inputs. The subject may be
|
// 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
|
// specified as a path to a file or as a digest. If a path is provided, the
|
||||||
@@ -71191,23 +71151,28 @@ const subjectFromInputs = async (inputs) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
exports.subjectFromInputs = subjectFromInputs;
|
exports.subjectFromInputs = subjectFromInputs;
|
||||||
|
// Returns the subject's digest as a formatted string of the form
|
||||||
|
// "<algorithm>:<digest>".
|
||||||
|
const formatSubjectDigest = (subject) => {
|
||||||
|
const alg = Object.keys(subject.digest).sort()[0];
|
||||||
|
return `${alg}:${subject.digest[alg]}`;
|
||||||
|
};
|
||||||
|
exports.formatSubjectDigest = formatSubjectDigest;
|
||||||
// Returns the subject specified by the path to a file. The file's digest is
|
// Returns the subject specified by the path to a file. The file's digest is
|
||||||
// calculated and returned along with the subject's name.
|
// calculated and returned along with the subject's name.
|
||||||
const getSubjectFromPath = async (subjectPath, subjectName) => {
|
const getSubjectFromPath = async (subjectPath, subjectName) => {
|
||||||
const digestedSubjects = [];
|
const digestedSubjects = [];
|
||||||
// Parse the list of subject paths
|
// Parse the list of subject paths
|
||||||
const subjectPaths = parseList(subjectPath).join('\n');
|
const subjectPaths = parseList(subjectPath).join('\n');
|
||||||
// Expand the globbed paths to a list of files
|
// Expand the globbed paths to a list of actual paths
|
||||||
/* eslint-disable-next-line github/no-then */
|
/* eslint-disable-next-line github/no-then */
|
||||||
const files = await glob.create(subjectPaths).then(async (g) => g.glob());
|
const paths = await glob.create(subjectPaths).then(async (g) => g.glob());
|
||||||
|
// Filter path list to just the files (not directories)
|
||||||
|
const files = paths.filter(p => fs_1.default.statSync(p).isFile());
|
||||||
if (files.length > MAX_SUBJECT_COUNT) {
|
if (files.length > MAX_SUBJECT_COUNT) {
|
||||||
throw new Error(`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`);
|
throw new Error(`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`);
|
||||||
}
|
}
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Skip anything that is NOT a file
|
|
||||||
if (!fs_1.default.statSync(file).isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const name = subjectName || path_1.default.parse(file).base;
|
const name = subjectName || path_1.default.parse(file).base;
|
||||||
const digest = await digestFile(DIGEST_ALGORITHM, file);
|
const digest = await digestFile(DIGEST_ALGORITHM, file);
|
||||||
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } });
|
digestedSubjects.push({ name, digest: { [DIGEST_ALGORITHM]: digest } });
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "actions/attest",
|
"name": "actions/attest",
|
||||||
"version": "1.4.1",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "actions/attest",
|
"name": "actions/attest",
|
||||||
"version": "1.4.1",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/attest": "^1.5.0",
|
"@actions/attest": "^1.5.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "actions/attest",
|
"name": "actions/attest",
|
||||||
"description": "Generate signed attestations for workflow artifacts",
|
"description": "Generate signed attestations for workflow artifacts",
|
||||||
"version": "1.4.1",
|
"version": "2.0.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://github.com/actions/attest",
|
"homepage": "https://github.com/actions/attest",
|
||||||
|
|||||||
+7
-20
@@ -1,18 +1,17 @@
|
|||||||
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
|
import { Attestation, Predicate, Subject, attest } from '@actions/attest'
|
||||||
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
|
import { attachArtifactToImage, getRegistryCredentials } from '@sigstore/oci'
|
||||||
|
import { formatSubjectDigest } from './subject'
|
||||||
|
|
||||||
const OCI_TIMEOUT = 30000
|
const OCI_TIMEOUT = 30000
|
||||||
const OCI_RETRY = 3
|
const OCI_RETRY = 3
|
||||||
|
|
||||||
export type SigstoreInstance = 'public-good' | 'github'
|
export type SigstoreInstance = 'public-good' | 'github'
|
||||||
export type AttestResult = Attestation & {
|
export type AttestResult = Attestation & {
|
||||||
subjectName: string
|
|
||||||
subjectDigest: string
|
|
||||||
attestationDigest?: string
|
attestationDigest?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAttestation = async (
|
export const createAttestation = async (
|
||||||
subject: Subject,
|
subjects: Subject[],
|
||||||
predicate: Predicate,
|
predicate: Predicate,
|
||||||
opts: {
|
opts: {
|
||||||
sigstoreInstance: SigstoreInstance
|
sigstoreInstance: SigstoreInstance
|
||||||
@@ -22,27 +21,22 @@ export const createAttestation = async (
|
|||||||
): Promise<AttestResult> => {
|
): Promise<AttestResult> => {
|
||||||
// Sign provenance w/ Sigstore
|
// Sign provenance w/ Sigstore
|
||||||
const attestation = await attest({
|
const attestation = await attest({
|
||||||
subjectName: subject.name,
|
subjects,
|
||||||
subjectDigest: subject.digest,
|
|
||||||
predicateType: predicate.type,
|
predicateType: predicate.type,
|
||||||
predicate: predicate.params,
|
predicate: predicate.params,
|
||||||
sigstore: opts.sigstoreInstance,
|
sigstore: opts.sigstoreInstance,
|
||||||
token: opts.githubToken
|
token: opts.githubToken
|
||||||
})
|
})
|
||||||
|
|
||||||
const subDigest = subjectDigest(subject)
|
const result: AttestResult = attestation
|
||||||
const result: AttestResult = {
|
|
||||||
...attestation,
|
|
||||||
subjectName: subject.name,
|
|
||||||
subjectDigest: subDigest
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.pushToRegistry) {
|
if (subjects.length === 1 && opts.pushToRegistry) {
|
||||||
|
const subject = subjects[0]
|
||||||
const credentials = getRegistryCredentials(subject.name)
|
const credentials = getRegistryCredentials(subject.name)
|
||||||
const artifact = await attachArtifactToImage({
|
const artifact = await attachArtifactToImage({
|
||||||
credentials,
|
credentials,
|
||||||
imageName: subject.name,
|
imageName: subject.name,
|
||||||
imageDigest: subDigest,
|
imageDigest: formatSubjectDigest(subject),
|
||||||
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
artifact: Buffer.from(JSON.stringify(attestation.bundle)),
|
||||||
mediaType: attestation.bundle.mediaType,
|
mediaType: attestation.bundle.mediaType,
|
||||||
annotations: {
|
annotations: {
|
||||||
@@ -58,10 +52,3 @@ export const createAttestation = async (
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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]}`
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-5
@@ -4,8 +4,6 @@
|
|||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import { run, RunInputs } from './main'
|
import { run, RunInputs } from './main'
|
||||||
|
|
||||||
const DEFAULT_BATCH_SIZE = 50
|
|
||||||
|
|
||||||
const inputs: RunInputs = {
|
const inputs: RunInputs = {
|
||||||
subjectPath: core.getInput('subject-path'),
|
subjectPath: core.getInput('subject-path'),
|
||||||
subjectName: core.getInput('subject-name'),
|
subjectName: core.getInput('subject-name'),
|
||||||
@@ -19,9 +17,7 @@ const inputs: RunInputs = {
|
|||||||
// undocumented -- not part of public interface
|
// undocumented -- not part of public interface
|
||||||
privateSigning: ['true', 'True', 'TRUE', '1'].includes(
|
privateSigning: ['true', 'True', 'TRUE', '1'].includes(
|
||||||
core.getInput('private-signing')
|
core.getInput('private-signing')
|
||||||
),
|
)
|
||||||
// internal only
|
|
||||||
batchSize: DEFAULT_BATCH_SIZE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
|||||||
+34
-71
@@ -7,11 +7,15 @@ import { AttestResult, SigstoreInstance, createAttestation } from './attest'
|
|||||||
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
|
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
|
||||||
import { PredicateInputs, predicateFromInputs } from './predicate'
|
import { PredicateInputs, predicateFromInputs } from './predicate'
|
||||||
import * as style from './style'
|
import * as style from './style'
|
||||||
import { SubjectInputs, subjectFromInputs } from './subject'
|
import {
|
||||||
|
SubjectInputs,
|
||||||
|
formatSubjectDigest,
|
||||||
|
subjectFromInputs
|
||||||
|
} from './subject'
|
||||||
|
|
||||||
|
import type { Subject } from '@actions/attest'
|
||||||
|
|
||||||
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
|
const ATTESTATION_FILE_NAME = 'attestation.jsonl'
|
||||||
const DELAY_INTERVAL_MS = 75
|
|
||||||
const DELAY_MAX_MS = 1200
|
|
||||||
|
|
||||||
export type RunInputs = SubjectInputs &
|
export type RunInputs = SubjectInputs &
|
||||||
PredicateInputs & {
|
PredicateInputs & {
|
||||||
@@ -19,7 +23,6 @@ export type RunInputs = SubjectInputs &
|
|||||||
githubToken: string
|
githubToken: string
|
||||||
showSummary: boolean
|
showSummary: boolean
|
||||||
privateSigning: boolean
|
privateSigning: boolean
|
||||||
batchSize: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
@@ -47,7 +50,6 @@ export async function run(inputs: RunInputs): Promise<void> {
|
|||||||
: 'github'
|
: 'github'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const atts: AttestResult[] = []
|
|
||||||
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
|
'missing "id-token" permission. Please add "permissions: id-token: write" to your workflow.'
|
||||||
@@ -63,42 +65,22 @@ export async function run(inputs: RunInputs): Promise<void> {
|
|||||||
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
|
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
|
||||||
core.setOutput('bundle-path', outputPath)
|
core.setOutput('bundle-path', outputPath)
|
||||||
|
|
||||||
const subjectChunks = chunkArray(subjects, inputs.batchSize)
|
const att = await createAttestation(subjects, predicate, {
|
||||||
|
sigstoreInstance,
|
||||||
|
pushToRegistry: inputs.pushToRegistry,
|
||||||
|
githubToken: inputs.githubToken
|
||||||
|
})
|
||||||
|
|
||||||
// Generate attestations for each subject serially, working in batches
|
logAttestation(subjects, att, sigstoreInstance)
|
||||||
for (let i = 0; i < subjectChunks.length; i++) {
|
|
||||||
if (subjectChunks.length > 1) {
|
|
||||||
core.info(`Processing subject batch ${i + 1}/${subjectChunks.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the delay time for this batch
|
// Write attestation bundle to output file
|
||||||
const delayTime = delay(i)
|
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
|
||||||
|
encoding: 'utf-8',
|
||||||
for (const subject of subjectChunks[i]) {
|
flag: 'a'
|
||||||
// Delay between attestations (only when chunk size > 1)
|
})
|
||||||
if (i > 0) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delayTime))
|
|
||||||
}
|
|
||||||
|
|
||||||
const att = await createAttestation(subject, predicate, {
|
|
||||||
sigstoreInstance,
|
|
||||||
pushToRegistry: inputs.pushToRegistry,
|
|
||||||
githubToken: inputs.githubToken
|
|
||||||
})
|
|
||||||
atts.push(att)
|
|
||||||
|
|
||||||
logAttestation(att, sigstoreInstance)
|
|
||||||
|
|
||||||
// Write attestation bundle to output file
|
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(att.bundle) + os.EOL, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
flag: 'a'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputs.showSummary) {
|
if (inputs.showSummary) {
|
||||||
logSummary(atts)
|
logSummary(att)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fail the workflow run if an error occurs
|
// Fail the workflow run if an error occurs
|
||||||
@@ -123,12 +105,17 @@ export async function run(inputs: RunInputs): Promise<void> {
|
|||||||
|
|
||||||
// Log details about the attestation to the GitHub Actions run
|
// Log details about the attestation to the GitHub Actions run
|
||||||
const logAttestation = (
|
const logAttestation = (
|
||||||
|
subjects: Subject[],
|
||||||
attestation: AttestResult,
|
attestation: AttestResult,
|
||||||
sigstoreInstance: SigstoreInstance
|
sigstoreInstance: SigstoreInstance
|
||||||
): void => {
|
): void => {
|
||||||
core.info(
|
if (subjects.length === 1) {
|
||||||
`Attestation created for ${attestation.subjectName}@${attestation.subjectDigest}`
|
core.info(
|
||||||
)
|
`Attestation created for ${subjects[0].name}@${formatSubjectDigest(subjects[0])}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
core.info(`Attestation created for ${subjects.length} subjects`)
|
||||||
|
}
|
||||||
|
|
||||||
const instanceName =
|
const instanceName =
|
||||||
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
|
sigstoreInstance === 'public-good' ? 'Public Good' : 'GitHub'
|
||||||
@@ -156,29 +143,18 @@ const logAttestation = (
|
|||||||
|
|
||||||
if (attestation.attestationDigest) {
|
if (attestation.attestationDigest) {
|
||||||
core.info(style.highlight('Attestation uploaded to registry'))
|
core.info(style.highlight('Attestation uploaded to registry'))
|
||||||
core.info(`${attestation.subjectName}@${attestation.attestationDigest}`)
|
core.info(`${subjects[0].name}@${attestation.attestationDigest}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach summary information to the GitHub Actions run
|
// Attach summary information to the GitHub Actions run
|
||||||
const logSummary = (attestations: AttestResult[]): void => {
|
const logSummary = (attestation: AttestResult): void => {
|
||||||
if (attestations.length > 0) {
|
const { attestationID } = attestation
|
||||||
core.summary.addHeading(
|
|
||||||
/* istanbul ignore next */
|
|
||||||
attestations.length > 1 ? 'Attestations Created' : 'Attestation Created',
|
|
||||||
3
|
|
||||||
)
|
|
||||||
|
|
||||||
const listItems = []
|
if (attestationID) {
|
||||||
for (const { subjectName, subjectDigest, attestationID } of attestations) {
|
const url = attestationURL(attestationID)
|
||||||
if (attestationID) {
|
core.summary.addHeading('Attestation Created', 3)
|
||||||
listItems.push(
|
core.summary.addList([`<a href="${url}">${url}</a>`])
|
||||||
`<a href="${attestationURL(attestationID)}">${subjectName}@${subjectDigest}</a>`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
core.summary.addList(listItems)
|
|
||||||
core.summary.write()
|
core.summary.write()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,18 +170,5 @@ const tempDir = (): string => {
|
|||||||
return fs.mkdtempSync(path.join(basePath, path.sep))
|
return fs.mkdtempSync(path.join(basePath, path.sep))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transforms an array into an array of arrays, each containing at most
|
|
||||||
// `chunkSize` elements.
|
|
||||||
const chunkArray = <T>(array: T[], chunkSize: number): T[][] => {
|
|
||||||
return Array.from(
|
|
||||||
{ length: Math.ceil(array.length / chunkSize) },
|
|
||||||
(_, index) => array.slice(index * chunkSize, (index + 1) * chunkSize)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the delay time for a given iteration
|
|
||||||
const delay = (iteration: number): number =>
|
|
||||||
Math.min(DELAY_INTERVAL_MS * 2 ** iteration, DELAY_MAX_MS)
|
|
||||||
|
|
||||||
const attestationURL = (id: string): string =>
|
const attestationURL = (id: string): string =>
|
||||||
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
|
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
|
||||||
|
|||||||
+13
-8
@@ -6,7 +6,7 @@ import path from 'path'
|
|||||||
|
|
||||||
import type { Subject } from '@actions/attest'
|
import type { Subject } from '@actions/attest'
|
||||||
|
|
||||||
const MAX_SUBJECT_COUNT = 2500
|
const MAX_SUBJECT_COUNT = 1024
|
||||||
const DIGEST_ALGORITHM = 'sha256'
|
const DIGEST_ALGORITHM = 'sha256'
|
||||||
|
|
||||||
export type SubjectInputs = {
|
export type SubjectInputs = {
|
||||||
@@ -49,6 +49,13 @@ export const subjectFromInputs = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the subject's digest as a formatted string of the form
|
||||||
|
// "<algorithm>:<digest>".
|
||||||
|
export const formatSubjectDigest = (subject: Subject): string => {
|
||||||
|
const alg = Object.keys(subject.digest).sort()[0]
|
||||||
|
return `${alg}:${subject.digest[alg]}`
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the subject specified by the path to a file. The file's digest is
|
// Returns the subject specified by the path to a file. The file's digest is
|
||||||
// calculated and returned along with the subject's name.
|
// calculated and returned along with the subject's name.
|
||||||
const getSubjectFromPath = async (
|
const getSubjectFromPath = async (
|
||||||
@@ -60,9 +67,12 @@ const getSubjectFromPath = async (
|
|||||||
// Parse the list of subject paths
|
// Parse the list of subject paths
|
||||||
const subjectPaths = parseList(subjectPath).join('\n')
|
const subjectPaths = parseList(subjectPath).join('\n')
|
||||||
|
|
||||||
// Expand the globbed paths to a list of files
|
// Expand the globbed paths to a list of actual paths
|
||||||
/* eslint-disable-next-line github/no-then */
|
/* eslint-disable-next-line github/no-then */
|
||||||
const files = await glob.create(subjectPaths).then(async g => g.glob())
|
const paths = await glob.create(subjectPaths).then(async g => g.glob())
|
||||||
|
|
||||||
|
// Filter path list to just the files (not directories)
|
||||||
|
const files = paths.filter(p => fs.statSync(p).isFile())
|
||||||
|
|
||||||
if (files.length > MAX_SUBJECT_COUNT) {
|
if (files.length > MAX_SUBJECT_COUNT) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -71,11 +81,6 @@ const getSubjectFromPath = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Skip anything that is NOT a file
|
|
||||||
if (!fs.statSync(file).isFile()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = subjectName || path.parse(file).base
|
const name = subjectName || path.parse(file).base
|
||||||
const digest = await digestFile(DIGEST_ALGORITHM, file)
|
const digest = await digestFile(DIGEST_ALGORITHM, file)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user