From 1f725c56d671a55ace8194ae0beae9a1abbdbde2 Mon Sep 17 00:00:00 2001 From: Conor Sloan Date: Thu, 22 Aug 2024 14:08:50 +0100 Subject: [PATCH] upload attestation to GHCR instead of attestations API --- __tests__/main.test.ts | 193 +++++++--- badges/coverage.svg | 2 +- dist/index.js | 839 +++++++++++++++++++++++++++++++++++++++-- dist/licenses.txt | 3 + package-lock.json | 13 + package.json | 5 +- src/main.ts | 67 +++- 7 files changed, 1016 insertions(+), 106 deletions(-) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index fbe21d3..652e3b6 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -13,6 +13,7 @@ import * as cfg from '../src/config' import * as fsHelper from '../src/fs-helper' import * as ghcr from '../src/ghcr-client' import * as ociContainer from '../src/oci-container' +import * as oci from '@sigstore/oci' const ghcrUrl = new URL('https://ghcr.io') @@ -38,6 +39,9 @@ let resolvePublishActionOptionsMock: jest.SpyInstance // Mock generating attestation let generateAttestationMock: jest.SpyInstance +// Mock uploading attestation with oci lib +let attachArtifactToImageMock: jest.SpyInstance + describe('run', () => { beforeEach(() => { jest.clearAllMocks() @@ -79,6 +83,9 @@ describe('run', () => { generateAttestationMock = jest .spyOn(attest, 'attestProvenance') .mockImplementation() + attachArtifactToImageMock = jest + .spyOn(oci, 'attachArtifactToImage') + .mockImplementation() }) it('fails if the action ref is not a tag', async () => { @@ -200,47 +207,6 @@ describe('run', () => { expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') }) - it('fails if creating attestation fails', async () => { - resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) - - ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) - - createTempDirMock.mockImplementation(() => { - return 'stagingOrArchivesDir' - }) - - stageActionFilesMock.mockImplementation(() => {}) - - createArchivesMock.mockImplementation(() => { - return { - zipFile: { - path: 'test', - size: 5, - sha256: '123' - }, - tarFile: { - path: 'test2', - size: 52, - sha256: '1234' - } - } - }) - - calculateManifestDigestMock.mockImplementation(() => { - return 'sha256:my-test-digest' - }) - - generateAttestationMock.mockImplementation(async () => { - throw new Error('Something went wrong') - }) - - // Run the action - await main.run() - - // Check the results - expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') - }) - it('fails if publishing OCI artifact fails', async () => { resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) @@ -272,7 +238,7 @@ describe('run', () => { }) generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + expect(options).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', @@ -330,7 +296,7 @@ describe('run', () => { }) generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + expect(options).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', @@ -362,6 +328,119 @@ describe('run', () => { ) }) + it('fails if uploading attestation to GHCR fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'test', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'test2', + size: 52, + sha256: '1234' + } + } + }) + + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + + publishOCIArtifactMock.mockImplementation(() => { + return { + packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', + publishedDigest: 'sha256:my-test-digest' + } + }) + + generateAttestationMock.mockImplementation(async options => { + expect(options).toHaveProperty('skipWrite', true) + + return { + attestationID: 'test-attestation-id', + certificate: 'test', + bundle: { + mediaType: 'application/vnd.cncf.notary.v2+jwt', + verificationMaterial: { + publicKey: { + hint: 'test-hint' + } + } + } + } + }) + + attachArtifactToImageMock.mockImplementation(() => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + + it('fails if creating attestation fails', async () => { + resolvePublishActionOptionsMock.mockReturnValue(baseOptions()) + + ensureCorrectShaCheckedOutMock.mockImplementation(() => {}) + + createTempDirMock.mockImplementation(() => { + return 'stagingOrArchivesDir' + }) + + stageActionFilesMock.mockImplementation(() => {}) + + calculateManifestDigestMock.mockImplementation(() => { + return 'sha256:my-test-digest' + }) + + createArchivesMock.mockImplementation(() => { + return { + zipFile: { + path: 'test', + size: 5, + sha256: '123' + }, + tarFile: { + path: 'test2', + size: 52, + sha256: '1234' + } + } + }) + + publishOCIArtifactMock.mockImplementation(() => { + return { + packageURL: 'https://ghcr.io/v2/test-org/test-repo:1.2.3', + publishedDigest: 'sha256:my-test-digest' + } + }) + + generateAttestationMock.mockImplementation(async () => { + throw new Error('Something went wrong') + }) + + // Run the action + await main.run() + + // Check the results + expect(setFailedMock).toHaveBeenCalledWith('Something went wrong') + }) + it('uploads the artifact, returns package metadata from GHCR, and skips writing attestation in enterprise', async () => { const options = baseOptions() options.isEnterprise = true @@ -464,7 +543,7 @@ describe('run', () => { }) generateAttestationMock.mockImplementation(async options => { - expect(options).toHaveProperty('skipWrite', false) + expect(options).toHaveProperty('skipWrite', true) return { attestationID: 'test-attestation-id', @@ -480,6 +559,15 @@ describe('run', () => { } }) + attachArtifactToImageMock.mockImplementation(async () => { + return { + digest: 'sha256:my-test-attestation-digest', + urls: [ + 'ghcr.io/v2/test-org/test-package/manifests/sha256:my-test-attestation-digest' + ] + } + }) + // Run the action await main.run() @@ -487,7 +575,17 @@ describe('run', () => { expect(publishOCIArtifactMock).toHaveBeenCalledTimes(1) // Check outputs - expect(setOutputMock).toHaveBeenCalledTimes(4) + expect(setOutputMock).toHaveBeenCalledTimes(5) + + expect(setOutputMock).toHaveBeenCalledWith( + 'attestation-manifest-sha', + 'sha256:my-test-attestation-digest' + ) + + expect(setOutputMock).toHaveBeenCalledWith( + 'attestation-url', + 'ghcr.io/v2/test-org/test-package/manifests/sha256:my-test-attestation-digest' + ) expect(setOutputMock).toHaveBeenCalledWith( 'package-url', @@ -503,11 +601,6 @@ describe('run', () => { 'package-manifest-sha', 'sha256:my-test-digest' ) - - expect(setOutputMock).toHaveBeenCalledWith( - 'attestation-id', - 'test-attestation-id' - ) }) }) diff --git a/badges/coverage.svg b/badges/coverage.svg index b75435b..c5876fe 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.06%Coverage97.06% \ No newline at end of file +Coverage: 97.17%Coverage97.17% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index de11e31..4f139f9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -52,7 +52,7 @@ function attest(options) { // Store the attestation let attestationID; if (options.skipWrite !== true) { - attestationID = yield (0, store_1.writeAttestation)((0, bundle_1.bundleToJSON)(bundle), options.token, { headers: options.headers }); + attestationID = yield (0, store_1.writeAttestation)((0, bundle_1.bundleToJSON)(bundle), options.token); } return toAttestation(bundle, attestationID); }); @@ -249,10 +249,6 @@ const core_1 = __nccwpck_require__(42186); const http_client_1 = __nccwpck_require__(96255); const jose = __importStar(__nccwpck_require__(34061)); const OIDC_AUDIENCE = 'nobody'; -const VALID_SERVER_URLS = [ - 'https://github.com', - new RegExp('^https://[a-z0-9-]+\\.ghe\\.com$') -]; const REQUIRED_CLAIMS = [ 'iss', 'ref', @@ -268,7 +264,6 @@ const REQUIRED_CLAIMS = [ 'run_attempt' ]; const getIDTokenClaims = (issuer) => __awaiter(void 0, void 0, void 0, function* () { - issuer = issuer || getIssuer(); try { const token = yield (0, core_1.getIDToken)(OIDC_AUDIENCE); const claims = yield decodeOIDCToken(token, issuer); @@ -312,19 +307,6 @@ function assertClaimSet(claims) { throw new Error(`Missing claims: ${missingClaims.join(', ')}`); } } -// Derive the current OIDC issuer based on the server URL -function getIssuer() { - const serverURL = process.env.GITHUB_SERVER_URL || 'https://github.com'; - // Ensure the server URL is a valid GitHub server URL - if (!VALID_SERVER_URLS.some(valid_url => serverURL.match(valid_url))) { - throw new Error(`Invalid server URL: ${serverURL}`); - } - let host = new URL(serverURL).hostname; - if (host === 'github.com') { - host = 'githubusercontent.com'; - } - return `https://token.actions.${host}`; -} //# sourceMappingURL=oidc.js.map /***/ }), @@ -349,6 +331,7 @@ const attest_1 = __nccwpck_require__(46373); const oidc_1 = __nccwpck_require__(95847); const SLSA_PREDICATE_V1_TYPE = 'https://slsa.dev/provenance/v1'; const GITHUB_BUILD_TYPE = 'https://actions.github.io/buildtypes/workflow/v1'; +const DEFAULT_ISSUER = 'https://token.actions.githubusercontent.com'; /** * Builds an SLSA (Supply Chain Levels for Software Artifacts) provenance * predicate using the GitHub Actions Workflow build type. @@ -358,7 +341,7 @@ const GITHUB_BUILD_TYPE = 'https://actions.github.io/buildtypes/workflow/v1'; * issuer. * @returns The SLSA provenance predicate. */ -const buildSLSAProvenancePredicate = (issuer) => __awaiter(void 0, void 0, void 0, function* () { +const buildSLSAProvenancePredicate = (issuer = DEFAULT_ISSUER) => __awaiter(void 0, void 0, void 0, function* () { const serverURL = process.env.GITHUB_SERVER_URL; const claims = yield (0, oidc_1.getIDTokenClaims)(issuer); // Split just the path and ref from the workflow string. @@ -557,7 +540,6 @@ const writeAttestation = (attestation, token, options = {}) => __awaiter(void 0, const response = yield octokit.request(CREATE_ATTESTATION_REQUEST, { owner: github.context.repo.owner, repo: github.context.repo.repo, - headers: options.headers, data: { bundle: attestation } }); const data = typeof response.data == 'string' @@ -11756,6 +11738,761 @@ class SignedCertificateTimestamp { exports.SignedCertificateTimestamp = SignedCertificateTimestamp; +/***/ }), + +/***/ 61319: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.HEADER_OCI_SUBJECT = exports.HEADER_LOCATION = exports.HEADER_IF_MATCH = exports.HEADER_ETAG = exports.HEADER_DIGEST = exports.HEADER_CONTENT_TYPE = exports.HEADER_CONTENT_LENGTH = exports.HEADER_AUTHORIZATION = exports.HEADER_AUTHENTICATE = exports.HEADER_API_VERSION = exports.HEADER_ACCEPT = exports.CONTENT_TYPE_EMPTY_DESCRIPTOR = exports.CONTENT_TYPE_OCTET_STREAM = exports.CONTENT_TYPE_DOCKER_MANIFEST_LIST = exports.CONTENT_TYPE_DOCKER_MANIFEST = exports.CONTENT_TYPE_OCI_MANIFEST = exports.CONTENT_TYPE_OCI_INDEX = void 0; +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +exports.CONTENT_TYPE_OCI_INDEX = 'application/vnd.oci.image.index.v1+json'; +exports.CONTENT_TYPE_OCI_MANIFEST = 'application/vnd.oci.image.manifest.v1+json'; +exports.CONTENT_TYPE_DOCKER_MANIFEST = 'application/vnd.docker.distribution.manifest.v2+json'; +exports.CONTENT_TYPE_DOCKER_MANIFEST_LIST = 'application/vnd.docker.distribution.manifest.list.v2+json'; +exports.CONTENT_TYPE_OCTET_STREAM = 'application/octet-stream'; +exports.CONTENT_TYPE_EMPTY_DESCRIPTOR = 'application/vnd.oci.empty.v1+json'; +exports.HEADER_ACCEPT = 'Accept'; +exports.HEADER_API_VERSION = 'Docker-Distribution-API-Version'; +exports.HEADER_AUTHENTICATE = 'WWW-Authenticate'; +exports.HEADER_AUTHORIZATION = 'Authorization'; +exports.HEADER_CONTENT_LENGTH = 'Content-Length'; +exports.HEADER_CONTENT_TYPE = 'Content-Type'; +exports.HEADER_DIGEST = 'Docker-Content-Digest'; +exports.HEADER_ETAG = 'Etag'; +exports.HEADER_IF_MATCH = 'If-Match'; +exports.HEADER_LOCATION = 'Location'; +exports.HEADER_OCI_SUBJECT = 'OCI-Subject'; + + +/***/ }), + +/***/ 95475: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.fromBasicAuth = exports.toBasicAuth = exports.getRegistryCredentials = void 0; +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const node_fs_1 = __importDefault(__nccwpck_require__(87561)); +const node_os_1 = __importDefault(__nccwpck_require__(70612)); +const node_path_1 = __importDefault(__nccwpck_require__(49411)); +const name_1 = __nccwpck_require__(44520); +// Returns the credentials for a given registry by reading the Docker config +// file. +const getRegistryCredentials = (imageName) => { + const { registry } = (0, name_1.parseImageName)(imageName); + const dockerConfigFile = node_path_1.default.join(node_os_1.default.homedir(), '.docker', 'config.json'); + let content; + try { + content = node_fs_1.default.readFileSync(dockerConfigFile, 'utf8'); + } + catch (err) { + throw new Error(`No credential file found at ${dockerConfigFile}`); + } + const dockerConfig = JSON.parse(content); + const credKey = Object.keys(dockerConfig?.auths || {}).find((key) => key.includes(registry)) || registry; + const creds = dockerConfig?.auths?.[credKey]; + if (!creds) { + throw new Error(`No credentials found for registry ${registry}`); + } + // Extract username/password from auth string + const { username, password } = (0, exports.fromBasicAuth)(creds.auth); + // If the identitytoken is present, use it as the password (primarily for ACR) + const pass = creds.identitytoken ? creds.identitytoken : password; + return { username, password: pass }; +}; +exports.getRegistryCredentials = getRegistryCredentials; +// Encode the username and password as base64-encoded basicauth value +const toBasicAuth = (creds) => Buffer.from(`${creds.username}:${creds.password}`).toString('base64'); +exports.toBasicAuth = toBasicAuth; +// Decode the base64-encoded basicauth value +const fromBasicAuth = (auth) => { + // Need to account for the possibility of ':' in the password + const [username, ...rest] = Buffer.from(auth, 'base64').toString().split(':'); + const password = rest.join(':'); + return { username, password }; +}; +exports.fromBasicAuth = fromBasicAuth; + + +/***/ }), + +/***/ 60064: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OCIError = exports.ensureStatus = exports.HTTPError = void 0; +class HTTPError extends Error { + constructor({ status, message }) { + super(message); + this.statusCode = status; + } +} +exports.HTTPError = HTTPError; +// Inspects the response status and throws an HTTPError if it does not match the +// expected status code +const ensureStatus = (expectedStatus) => { + return (response) => { + if (response.status !== expectedStatus) { + throw new HTTPError({ + message: `Error fetching ${response.url} - expected ${expectedStatus}, received ${response.status}`, + status: response.status, + }); + } + return response; + }; +}; +exports.ensureStatus = ensureStatus; +class OCIError extends Error { + constructor({ message, cause, }) { + super(message); + this.cause = cause; + this.name = this.constructor.name; + } +} +exports.OCIError = OCIError; + + +/***/ }), + +/***/ 437: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +/* +Copyright 2024 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const http2_1 = __nccwpck_require__(85158); +const make_fetch_happen_1 = __importDefault(__nccwpck_require__(9525)); +const proc_log_1 = __nccwpck_require__(56528); +const promise_retry_1 = __importDefault(__nccwpck_require__(54742)); +const { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_TOO_MANY_REQUESTS, HTTP_STATUS_REQUEST_TIMEOUT, } = http2_1.constants; +const fetchWithRetry = async (url, options = {}) => { + return (0, promise_retry_1.default)(async (retry, attemptNum) => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const logRetry = (reason) => { + proc_log_1.log.http('fetch', `${options.method} ${url} attempt ${attemptNum} failed with ${reason}`); + }; + const response = await (0, make_fetch_happen_1.default)(url, { + ...options, + retry: false, // We're handling retries ourselves + }).catch((reason) => { + logRetry(reason); + return retry(reason); + }); + if (retryable(response.status)) { + logRetry(response.status); + return retry(response); + } + return response; + }, retryOpts(options.retry)).catch((err) => { + // If we got an actual error, throw it + if (err instanceof Error) { + throw err; + } + // Otherwise, return the response (this is simply a retry-able response for + // which we exceeded the retry limit) + return err; + }); +}; +// Returns a wrapped fetch function with default options +fetchWithRetry.defaults = (defaultOptions = {}, wrappedFetch = fetchWithRetry) => { + const defaultedFetch = (url, options = {}) => { + const finalOptions = { + ...defaultOptions, + ...options, + headers: { ...defaultOptions.headers, ...options.headers }, + }; + return wrappedFetch(url, finalOptions); + }; + defaultedFetch.defaults = (newDefaults = {}) => fetchWithRetry.defaults(newDefaults, defaultedFetch); + return defaultedFetch; +}; +// Determine if a status code is retryable. This includes 5xx errors, 408, and +// 429. +const retryable = (status) => [HTTP_STATUS_REQUEST_TIMEOUT, HTTP_STATUS_TOO_MANY_REQUESTS].includes(status) || status >= HTTP_STATUS_INTERNAL_SERVER_ERROR; +// Normalize the retry options to the format expected by promise-retry +const retryOpts = (retry) => { + if (typeof retry === 'boolean') { + return { retries: retry ? 1 : 0 }; + } + else if (typeof retry === 'number') { + return { retries: retry }; + } + else { + return { retries: 0, ...retry }; + } +}; +exports["default"] = fetchWithRetry; + + +/***/ }), + +/***/ 79539: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; +}; +var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +}; +var _OCIImage_instances, _OCIImage_client, _OCIImage_credentials, _OCIImage_createReferrersIndexByTag; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OCIImage = void 0; +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const constants_1 = __nccwpck_require__(61319); +const error_1 = __nccwpck_require__(60064); +const registry_1 = __nccwpck_require__(27464); +const DOCKER_DEFAULT_REGISTRY = 'registry-1.docker.io'; +const EMPTY_BLOB = Buffer.from('{}'); +class OCIImage { + constructor(image, creds, opts) { + _OCIImage_instances.add(this); + _OCIImage_client.set(this, void 0); + _OCIImage_credentials.set(this, void 0); + __classPrivateFieldSet(this, _OCIImage_client, new registry_1.RegistryClient(canonicalizeRegistryName(image.registry), image.path, opts), "f"); + __classPrivateFieldSet(this, _OCIImage_credentials, creds, "f"); + } + async addArtifact(opts) { + let artifactDescriptor; + const annotations = { + 'org.opencontainers.image.created': new Date().toISOString(), + ...opts.annotations, + }; + try { + if (__classPrivateFieldGet(this, _OCIImage_credentials, "f")) { + await __classPrivateFieldGet(this, _OCIImage_client, "f").signIn(__classPrivateFieldGet(this, _OCIImage_credentials, "f")); + } + // Check that the image exists + const imageDescriptor = await __classPrivateFieldGet(this, _OCIImage_client, "f").checkManifest(opts.imageDigest); + // Upload the artifact blob + const artifactBlob = await __classPrivateFieldGet(this, _OCIImage_client, "f").uploadBlob(opts.artifact); + // Upload the empty blob (needed for the manifest config) + const emptyBlob = await __classPrivateFieldGet(this, _OCIImage_client, "f").uploadBlob(EMPTY_BLOB); + // Construct artifact manifest + const manifest = buildManifest({ + artifactDescriptor: { ...artifactBlob, mediaType: opts.mediaType }, + subjectDescriptor: imageDescriptor, + configDescriptor: { + ...emptyBlob, + mediaType: constants_1.CONTENT_TYPE_EMPTY_DESCRIPTOR, + }, + annotations, + }); + // Upload artifact manifest + artifactDescriptor = await __classPrivateFieldGet(this, _OCIImage_client, "f").uploadManifest(JSON.stringify(manifest)); + // Check to see if registry supports the referrers API. For most + // registries the presence of a subjectDigest response header when + // uploading the artifact manifest indicates that the referrers API IS + // supported -- however, this is not a guarantee (AWS ECR does NOT support + // the referrers API but still reports a subjectDigest). + const referrersSupported = await __classPrivateFieldGet(this, _OCIImage_client, "f").pingReferrers(); + // Manually update the referrers list if the referrers API is not supported. + if (!artifactDescriptor.subjectDigest || !referrersSupported) { + // Strip subjectDigest from the artifact descriptor (in case it was returned) + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const { subjectDigest, ...descriptor } = artifactDescriptor; + await __classPrivateFieldGet(this, _OCIImage_instances, "m", _OCIImage_createReferrersIndexByTag).call(this, { + artifact: { + ...descriptor, + artifactType: opts.mediaType, + annotations, + }, + imageDigest: opts.imageDigest, + }); + } + } + catch (err) { + throw new error_1.OCIError({ + message: `Error uploading artifact to container registry`, + cause: err, + }); + } + return artifactDescriptor; + } + async getDigest(tag) { + try { + if (__classPrivateFieldGet(this, _OCIImage_credentials, "f")) { + await __classPrivateFieldGet(this, _OCIImage_client, "f").signIn(__classPrivateFieldGet(this, _OCIImage_credentials, "f")); + } + const imageDescriptor = await __classPrivateFieldGet(this, _OCIImage_client, "f").checkManifest(tag); + return imageDescriptor.digest; + } + catch (err) { + throw new error_1.OCIError({ + message: `Error retrieving image digest from container registry`, + cause: err, + }); + } + } +} +exports.OCIImage = OCIImage; +_OCIImage_client = new WeakMap(), _OCIImage_credentials = new WeakMap(), _OCIImage_instances = new WeakSet(), _OCIImage_createReferrersIndexByTag = +// Create a referrers index by tag. This is a fallback for registries that do +// not support the referrers API. +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests-with-subject +async function _OCIImage_createReferrersIndexByTag(opts) { + const referrerTag = digestToTag(opts.imageDigest); + let referrerManifest; + let etag; + try { + // Retrieve any existing referrer index + const referrerIndex = await __classPrivateFieldGet(this, _OCIImage_client, "f").getManifest(referrerTag); + if (referrerIndex.mediaType !== constants_1.CONTENT_TYPE_OCI_INDEX) { + throw new Error(`Expected referrer manifest type ${constants_1.CONTENT_TYPE_OCI_INDEX}, got ${referrerIndex.mediaType}`); + } + referrerManifest = referrerIndex.body; + etag = referrerIndex.etag; + } + catch (err) { + // If the referrer index does not exist, create a new one + if (err instanceof error_1.HTTPError && err.statusCode === 404) { + referrerManifest = newIndex(); + } + else { + throw err; + } + } + // If the artifact is not already in the index, add it to the list and + // re-upload the index + if (!referrerManifest.manifests.some((manifest) => manifest.digest === opts.artifact.digest)) { + // Add the artifact to the index + referrerManifest.manifests.push(opts.artifact); + await __classPrivateFieldGet(this, _OCIImage_client, "f").uploadManifest(JSON.stringify(referrerManifest), { + mediaType: constants_1.CONTENT_TYPE_OCI_INDEX, + reference: referrerTag, + etag, + }); + } +}; +// Build an OCI manifest document with references to the given artifact, +// subject, and config +const buildManifest = (opts) => ({ + schemaVersion: 2, + mediaType: constants_1.CONTENT_TYPE_OCI_MANIFEST, + artifactType: opts.artifactDescriptor.mediaType, + config: opts.configDescriptor, + layers: [opts.artifactDescriptor], + subject: opts.subjectDescriptor, + annotations: opts.annotations, +}); +// Return an empty OCI index document +const newIndex = () => ({ + mediaType: constants_1.CONTENT_TYPE_OCI_INDEX, + schemaVersion: 2, + manifests: [], +}); +// Convert an image digest to a tag per the Referrers Tag Schema +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#referrers-tag-schema +const digestToTag = (digest) => { + return digest.replace(':', '-'); +}; +// Canonicalize the registry name to match the format used by the registry +// client. This is used primarily to handle the special case of the Docker Hub +// registry. +// https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 +const canonicalizeRegistryName = (registry) => { + return registry.endsWith('docker.io') ? DOCKER_DEFAULT_REGISTRY : registry; +}; + + +/***/ }), + +/***/ 47353: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getImageDigest = exports.attachArtifactToImage = exports.OCIError = exports.getRegistryCredentials = void 0; +const image_1 = __nccwpck_require__(79539); +const name_1 = __nccwpck_require__(44520); +var credentials_1 = __nccwpck_require__(95475); +Object.defineProperty(exports, "getRegistryCredentials", ({ enumerable: true, get: function () { return credentials_1.getRegistryCredentials; } })); +var error_1 = __nccwpck_require__(60064); +Object.defineProperty(exports, "OCIError", ({ enumerable: true, get: function () { return error_1.OCIError; } })); +// Associates the given artifact with an OCI image. The artifact is identified +// by its media type and a buffer containing the artifact. The image is +// identified by its FQDN and digest. +const attachArtifactToImage = async (opts) => { + const image = (0, name_1.parseImageName)(opts.imageName); + return new image_1.OCIImage(image, opts.credentials, opts.fetchOpts).addArtifact(opts); +}; +exports.attachArtifactToImage = attachArtifactToImage; +// Returns the digest of the given image tag in the remote registry. +const getImageDigest = async (opts) => { + const image = (0, name_1.parseImageName)(opts.imageName); + return new image_1.OCIImage(image, opts.credentials, opts.fetchOpts).getDigest(opts.imageTag); +}; +exports.getImageDigest = getImageDigest; + + +/***/ }), + +/***/ 44520: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.parseImageName = void 0; +const expression = (...res) => res.join(''); +const group = (...res) => `(?:${expression(...res)})`; +const repeated = (...res) => `${group(expression(...res))}+`; +const optional = (...res) => `${group(expression(...res))}?`; +const capture = (...res) => `(${expression(...res)})`; +const anchored = (...res) => `^${expression(...res)}$`; +// Lower case letters, numbers +const ALPHA_NUMERIC_RE = '[a-z0-9]+'; +// Separators allowed to be embedded in name components. This allows one period, +// one or two underscore or multiple dashes. +const SEPARATOR_RE = group('\\.|_|__|-+'); +// Registry path component names to start with at least one letter or number, +// with following parts able to be separated by one period, one or two +// underscores or multiple dashes. +const NAME_COMPONENT_RE = expression(ALPHA_NUMERIC_RE, optional(repeated(SEPARATOR_RE, ALPHA_NUMERIC_RE))); +const NAME_RE = expression(NAME_COMPONENT_RE, repeated(optional('\\/', NAME_COMPONENT_RE))); +// Component of the registry domain must be at least one letter or number, with +// following parts able to be separated by a dash. +const DOMAIN_COMPONENT_RE = group('[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]'); +// Restricts the registry domain to be one or more period separated components +// followed by an optional port. +const DOMAIN_RE = expression(DOMAIN_COMPONENT_RE, optional(repeated('\\.', DOMAIN_COMPONENT_RE)), optional(':[0-9]+')); +// Capture the registry domain and path components of a repository name. +const ANCHORED_NAME_RE = anchored(capture(DOMAIN_RE), '\\/', capture(NAME_RE)); +// Parses a fully qualified image name into its registry and path components. +const parseImageName = (image) => { + const matches = image.match(ANCHORED_NAME_RE); + if (!matches) { + throw new Error(`Invalid image name: ${image}`); + } + return { + registry: matches[1], + path: matches[2], + }; +}; +exports.parseImageName = parseImageName; + + +/***/ }), + +/***/ 27464: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { + if (kind === "m") throw new TypeError("Private method is not writable"); + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); + return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; +}; +var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { + if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); + if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); + return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +var _RegistryClient_instances, _RegistryClient_baseURL, _RegistryClient_repository, _RegistryClient_fetch, _RegistryClient_fetchDistributionToken, _RegistryClient_fetchOAuth2Token; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.RegistryClient = exports.ZERO_DIGEST = void 0; +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const node_crypto_1 = __importDefault(__nccwpck_require__(6005)); +const constants_1 = __nccwpck_require__(61319); +const credentials_1 = __nccwpck_require__(95475); +const error_1 = __nccwpck_require__(60064); +const fetch_1 = __importDefault(__nccwpck_require__(437)); +const ALL_MANIFEST_MEDIA_TYPES = [ + constants_1.CONTENT_TYPE_OCI_INDEX, + constants_1.CONTENT_TYPE_OCI_MANIFEST, + constants_1.CONTENT_TYPE_DOCKER_MANIFEST, + constants_1.CONTENT_TYPE_DOCKER_MANIFEST_LIST, +].join(','); +exports.ZERO_DIGEST = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; +class RegistryClient { + constructor(registry, repository, opts) { + _RegistryClient_instances.add(this); + _RegistryClient_baseURL.set(this, void 0); + _RegistryClient_repository.set(this, void 0); + _RegistryClient_fetch.set(this, void 0); + __classPrivateFieldSet(this, _RegistryClient_repository, repository, "f"); + __classPrivateFieldSet(this, _RegistryClient_fetch, fetch_1.default.defaults(opts), "f"); + // Use http for localhost registries, https otherwise + const hostname = new URL(`http://${registry}`).hostname; + /* istanbul ignore next */ + const protocol = hostname === 'localhost' || hostname === '127.0.0.1' ? 'http' : 'https'; + __classPrivateFieldSet(this, _RegistryClient_baseURL, `${protocol}://${registry}`, "f"); + } + // Authenticate with the registry. Sends an unauthenticated request to the + // registry in order to get an auth challenge. If the challenge scheme is + // "basic" we don't need a token and can authenticate requests using basic + // auth. Otherwise, we fetch a token from the auth server and use that to + // authenticate requests. + // https://github.com/google/go-containerregistry/blob/main/pkg/authn/README.md#the-registry + async signIn(creds) { + // Initiate a blob upload to get the auth challenge + const probeResponse = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/blobs/uploads/`, { method: 'POST' }); + // If we get a 200 response, we're already authenticated + if (probeResponse.status === 200) { + return; + } + const authHeader = probeResponse.headers.get(constants_1.HEADER_AUTHENTICATE) || + /* istanbul ignore next */ ''; + const challenge = parseChallenge(authHeader); + // If the challenge scheme is "basic" we don't need a token and can + // authenticate requests using basic auth + if (challenge.scheme === 'basic') { + const basicAuth = (0, credentials_1.toBasicAuth)(creds); + __classPrivateFieldSet(this, _RegistryClient_fetch, __classPrivateFieldGet(this, _RegistryClient_fetch, "f").defaults({ + headers: { [constants_1.HEADER_AUTHORIZATION]: `Basic ${basicAuth}` }, + }), "f"); + return; + } + let token; + if (creds.username === '') { + // If the OAUth2 token request fails, try to fetch a distribution token + token = await __classPrivateFieldGet(this, _RegistryClient_instances, "m", _RegistryClient_fetchOAuth2Token).call(this, creds, challenge).catch(() => undefined); + } + if (!token) { + token = await __classPrivateFieldGet(this, _RegistryClient_instances, "m", _RegistryClient_fetchDistributionToken).call(this, creds, challenge); + } + // Ensure the token is sent with all future requests + __classPrivateFieldSet(this, _RegistryClient_fetch, __classPrivateFieldGet(this, _RegistryClient_fetch, "f").defaults({ + headers: { [constants_1.HEADER_AUTHORIZATION]: `Bearer ${token}` }, + }), "f"); + } + // Check the registry API version + async checkVersion() { + const response = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/`); + return response.headers.get(constants_1.HEADER_API_VERSION) || ''; + } + // Upload a blob to the registry using the post/put method. Calculates the + // digest of the blob and checks to make sure the blob doesn't already exist + // in the registry before uploading. + async uploadBlob(blob) { + const digest = RegistryClient.digest(blob); + const size = blob.length; + // Check if blob already exists + const headResponse = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/blobs/${digest}`, { method: 'HEAD', redirect: 'follow' }); + if (headResponse.status === 200) { + return { + mediaType: constants_1.CONTENT_TYPE_OCTET_STREAM, + digest, + size, + }; + } + // Retrieve upload location (session ID) + const postResponse = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/blobs/uploads/`, { method: 'POST' }).then((0, error_1.ensureStatus)(202)); + const location = postResponse.headers.get(constants_1.HEADER_LOCATION); + if (!location) { + throw new Error('Missing location for blob upload'); + } + // Translate location to a full URL + const uploadLocation = new URL(location.startsWith('/') ? `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}${location}` : location); + // Add digest to query string + uploadLocation.searchParams.set('digest', digest); + // Upload blob + await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, uploadLocation.href, { + method: 'PUT', + body: blob, + headers: { [constants_1.HEADER_CONTENT_TYPE]: constants_1.CONTENT_TYPE_OCTET_STREAM }, + }).then((0, error_1.ensureStatus)(201)); + return { mediaType: constants_1.CONTENT_TYPE_OCTET_STREAM, digest, size }; + } + // Checks for the existence of a manifest by reference + async checkManifest(reference) { + const response = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/manifests/${reference}`, { + method: 'HEAD', + headers: { [constants_1.HEADER_ACCEPT]: ALL_MANIFEST_MEDIA_TYPES }, + }).then((0, error_1.ensureStatus)(200)); + const mediaType = response.headers.get(constants_1.HEADER_CONTENT_TYPE) || + /* istanbul ignore next */ ''; + const digest = response.headers.get(constants_1.HEADER_DIGEST) || /* istanbul ignore next */ ''; + const size = Number(response.headers.get(constants_1.HEADER_CONTENT_LENGTH)) || + /* istanbul ignore next */ 0; + return { mediaType, digest, size }; + } + // Retrieves a manifest by reference + async getManifest(reference) { + const response = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/manifests/${reference}`, { + headers: { [constants_1.HEADER_ACCEPT]: ALL_MANIFEST_MEDIA_TYPES }, + }).then((0, error_1.ensureStatus)(200)); + const body = await response.json(); + const mediaType = response.headers.get(constants_1.HEADER_CONTENT_TYPE) || + /* istanbul ignore next */ ''; + const digest = response.headers.get(constants_1.HEADER_DIGEST) || /* istanbul ignore next */ ''; + const size = Number(response.headers.get(constants_1.HEADER_CONTENT_LENGTH)) || 0; + const etag = response.headers.get(constants_1.HEADER_ETAG) || undefined; + return { body, mediaType, digest, size, etag }; + } + // Uploads a manifest by digest. If specified, the reference will be used as + // the manifest tag. + async uploadManifest(manifest, options = {}) { + const digest = RegistryClient.digest(manifest); + const reference = options.reference || digest; + const contentType = options.mediaType || constants_1.CONTENT_TYPE_OCI_MANIFEST; + const headers = { [constants_1.HEADER_CONTENT_TYPE]: contentType }; + if (options.etag) { + headers[constants_1.HEADER_IF_MATCH] = options.etag; + } + const response = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/manifests/${reference}`, { method: 'PUT', body: manifest, headers }).then((0, error_1.ensureStatus)(201)); + const subjectDigest = response.headers.get(constants_1.HEADER_OCI_SUBJECT) || undefined; + return { + mediaType: contentType, + digest, + size: manifest.length, + subjectDigest, + }; + } + // Returns true if the registry supports the referrers API + async pingReferrers() { + const response = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, `${__classPrivateFieldGet(this, _RegistryClient_baseURL, "f")}/v2/${__classPrivateFieldGet(this, _RegistryClient_repository, "f")}/referrers/${exports.ZERO_DIGEST}`); + return response.status === 200; + } + static digest(blob) { + const hash = node_crypto_1.default.createHash('sha256'); + hash.update(blob); + return `sha256:${hash.digest('hex')}`; + } +} +exports.RegistryClient = RegistryClient; +_RegistryClient_baseURL = new WeakMap(), _RegistryClient_repository = new WeakMap(), _RegistryClient_fetch = new WeakMap(), _RegistryClient_instances = new WeakSet(), _RegistryClient_fetchDistributionToken = async function _RegistryClient_fetchDistributionToken(creds, challenge) { + const basicAuth = (0, credentials_1.toBasicAuth)(creds); + const authURL = new URL(challenge.realm); + authURL.searchParams.set('service', challenge.service); + authURL.searchParams.set('scope', challenge.scope); + // Make token request with basic auth + const tokenResponse = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, authURL.toString(), { + headers: { [constants_1.HEADER_AUTHORIZATION]: `Basic ${basicAuth}` }, + }).then((0, error_1.ensureStatus)(200)); + return tokenResponse.json().then((json) => json.access_token || json.token); +}, _RegistryClient_fetchOAuth2Token = async function _RegistryClient_fetchOAuth2Token(creds, challenge) { + const body = new URLSearchParams({ + service: challenge.service, + scope: challenge.scope, + username: creds.username, + password: creds.password, + grant_type: 'password', + }); + // Make OAuth token request + const tokenResponse = await __classPrivateFieldGet(this, _RegistryClient_fetch, "f").call(this, challenge.realm, { + method: 'POST', + body, + }).then((0, error_1.ensureStatus)(200)); + return tokenResponse.json().then((json) => json.access_token); +}; +// Parses an auth challenge header into its components +// https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 +function parseChallenge(challenge) { + // Account for the possibility of spaces in the auth params + const [scheme, ...rest] = challenge.split(' '); + const authParams = rest.join(' '); + if (!['Basic', 'Bearer'].includes(scheme)) { + throw new Error(`Invalid challenge: ${challenge}`); + } + return { + scheme: scheme.toLocaleLowerCase(), + realm: singleMatch(authParams, /realm="(.+?)"/), + service: singleMatch(authParams, /service="(.+?)"/), + scope: singleMatch(authParams, /scope="(.+?)"/), + }; +} +// Returns the first capture group of a regex match, or an empty string +const singleMatch = (str, regex) => str.match(regex)?.[1] || ''; + + /***/ }), /***/ 70714: @@ -106880,6 +107617,7 @@ const ociContainer = __importStar(__nccwpck_require__(33207)); const ghcr = __importStar(__nccwpck_require__(62894)); const attest = __importStar(__nccwpck_require__(74113)); const cfg = __importStar(__nccwpck_require__(96373)); +const oci_1 = __nccwpck_require__(47353); /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. @@ -106898,17 +107636,22 @@ async function run() { const archives = await fsHelper.createArchives(stagedActionFilesDir, archiveDir); const manifest = ociContainer.createActionPackageManifest(archives.tarFile, archives.zipFile, options.nameWithOwner, options.repositoryId, options.repositoryOwnerId, options.sha, semverTag.raw, new Date()); const manifestDigest = ociContainer.sha256Digest(manifest); - // Attestations are not currently supported in GHES. - if (!options.isEnterprise) { - const attestation = await generateAttestation(manifestDigest, semverTag.raw, options); - if (attestation.attestationID !== undefined) { - core.setOutput('attestation-id', attestation.attestationID); - } - } const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact(options.token, options.containerRegistryUrl, options.nameWithOwner, semverTag.raw, archives.zipFile, archives.tarFile, manifest); if (manifestDigest !== publishedDigest) { throw new Error(`Unexpected digest returned for manifest. Expected ${manifestDigest}, got ${publishedDigest}`); } + // Attestations are not currently supported in GHES. + if (!options.isEnterprise) { + const attestation = await uploadAttestation(publishedDigest, semverTag.raw, options); + if (attestation.digest !== undefined) { + core.info(`Uploaded attestation ${attestation.digest}`); + core.setOutput('attestation-manifest-sha', attestation.digest); + } + if (attestation.urls !== undefined && attestation.urls.length > 0) { + core.info(`Attestation URL: ${attestation.digest}`); + core.setOutput('attestation-url', attestation.urls[0]); + } + } core.setOutput('package-url', packageURL.toString()); core.setOutput('package-manifest', JSON.stringify(manifest)); core.setOutput('package-manifest-sha', publishedDigest); @@ -106936,19 +107679,39 @@ function parseSemverTagFromRef(opts) { } // Generate an attestation using the actions toolkit // Subject name will contain the repo/package name and the tag name -async function generateAttestation(manifestDigest, semverTag, options) { +async function uploadAttestation(manifestDigest, semverTag, options) { + const OCI_TIMEOUT = 30000; + const OCI_RETRY = 3; + const PREDICATE_TYPE = 'https://slsa.dev/provenance/v1'; const subjectName = `${options.nameWithOwner}@${semverTag}`; const subjectDigest = removePrefix(manifestDigest, 'sha256:'); core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`); - return await attest.attestProvenance({ + const attestation = await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - // Always store the attestation using the GitHub Attestations API - skipWrite: false, - // Identify the attestation to our API as an Immutable Action - headers: { 'X-GitHub-Publish-Action': subjectName } + skipWrite: true // We will upload attestations to GHCR + }); + // Upload the attestation to the GitHub Container Registry + const credentials = { username: 'token', password: options.token }; + return await (0, oci_1.attachArtifactToImage)({ + credentials, + imageName: `${options.containerRegistryUrl.host}/${options.nameWithOwner}`, + imageDigest: manifestDigest, + artifact: Buffer.from(JSON.stringify(attestation.bundle)), + mediaType: attestation.bundle.mediaType, + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': PREDICATE_TYPE, + 'com.github.package.type': 'actions_oci_pkg_attestation' + }, + fetchOpts: { + timeout: OCI_TIMEOUT, + retry: OCI_RETRY, + proxy: undefined, + noProxy: undefined + } }); } function removePrefix(str, prefix) { @@ -107259,6 +108022,14 @@ module.exports = require("node:https"); /***/ }), +/***/ 70612: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:os"); + +/***/ }), + /***/ 49411: /***/ ((module) => { diff --git a/dist/licenses.txt b/dist/licenses.txt index cd594d7..d1bedc9 100644 --- a/dist/licenses.txt +++ b/dist/licenses.txt @@ -786,6 +786,9 @@ Apache-2.0 limitations under the License. +@sigstore/oci +Apache-2.0 + @sigstore/protobuf-specs Apache-2.0 diff --git a/package-lock.json b/package-lock.json index 12663b9..560b92a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "@sigstore/oci": "^0.3.7", "@types/fs-extra": "^11.0.4", "archiver": "^7.0.1", "fs-extra": "^11.2.0", @@ -1680,6 +1681,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@sigstore/oci": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@sigstore/oci/-/oci-0.3.7.tgz", + "integrity": "sha512-1JmebwEXil+NVzugFURbC+D3Vzj6WyTI1B+7damUk94dWXamE9cJ057iSo72rupiSozM6N7lVMjtD1c/P5Rrrw==", + "dependencies": { + "make-fetch-happen": "^13.0.1", + "proc-log": "^4.2.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, "node_modules/@sigstore/protobuf-specs": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz", diff --git a/package.json b/package.json index 31af38a..77e25fc 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,12 @@ "@actions/core": "^1.10.1", "@actions/exec": "^1.1.1", "@actions/github": "^6.0.0", + "@sigstore/oci": "^0.3.7", "@types/fs-extra": "^11.0.4", "archiver": "^7.0.1", "fs-extra": "^11.2.0", - "tar": "^7.4.3", - "simple-git": "^3.22.0" + "simple-git": "^3.22.0", + "tar": "^7.4.3" }, "devDependencies": { "@types/archiver": "^6.0.2", diff --git a/src/main.ts b/src/main.ts index a9fe215..f8180b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import * as ociContainer from './oci-container' import * as ghcr from './ghcr-client' import * as attest from '@actions/attest' import * as cfg from './config' +import { attachArtifactToImage, Descriptor } from '@sigstore/oci' /** * The main function for the action. @@ -52,18 +53,6 @@ export async function run(): Promise { const manifestDigest = ociContainer.sha256Digest(manifest) - // Attestations are not currently supported in GHES. - if (!options.isEnterprise) { - const attestation = await generateAttestation( - manifestDigest, - semverTag.raw, - options - ) - if (attestation.attestationID !== undefined) { - core.setOutput('attestation-id', attestation.attestationID) - } - } - const { packageURL, publishedDigest } = await ghcr.publishOCIArtifact( options.token, options.containerRegistryUrl, @@ -80,6 +69,23 @@ export async function run(): Promise { ) } + // Attestations are not currently supported in GHES. + if (!options.isEnterprise) { + const attestation = await uploadAttestation( + publishedDigest, + semverTag.raw, + options + ) + if (attestation.digest !== undefined) { + core.info(`Uploaded attestation ${attestation.digest}`) + core.setOutput('attestation-manifest-sha', attestation.digest) + } + if (attestation.urls !== undefined && attestation.urls.length > 0) { + core.info(`Attestation URL: ${attestation.digest}`) + core.setOutput('attestation-url', attestation.urls[0]) + } + } + core.setOutput('package-url', packageURL.toString()) core.setOutput('package-manifest', JSON.stringify(manifest)) core.setOutput('package-manifest-sha', publishedDigest) @@ -112,25 +118,48 @@ function parseSemverTagFromRef(opts: cfg.PublishActionOptions): semver.SemVer { // Generate an attestation using the actions toolkit // Subject name will contain the repo/package name and the tag name -async function generateAttestation( +async function uploadAttestation( manifestDigest: string, semverTag: string, options: cfg.PublishActionOptions -): Promise { +): Promise { + const OCI_TIMEOUT = 30000 + const OCI_RETRY = 3 + const PREDICATE_TYPE = 'https://slsa.dev/provenance/v1' + const subjectName = `${options.nameWithOwner}@${semverTag}` const subjectDigest = removePrefix(manifestDigest, 'sha256:') core.info(`Generating attestation ${subjectName} for digest ${subjectDigest}`) - return await attest.attestProvenance({ + const attestation = await attest.attestProvenance({ subjectName, subjectDigest: { sha256: subjectDigest }, token: options.token, sigstore: 'github', - // Always store the attestation using the GitHub Attestations API - skipWrite: false, - // Identify the attestation to our API as an Immutable Action - headers: { 'X-GitHub-Publish-Action': subjectName } + skipWrite: true // We will upload attestations to GHCR + }) + + // Upload the attestation to the GitHub Container Registry + const credentials = { username: 'token', password: options.token } + + return await attachArtifactToImage({ + credentials, + imageName: `${options.containerRegistryUrl.host}/${options.nameWithOwner}`, + imageDigest: manifestDigest, + artifact: Buffer.from(JSON.stringify(attestation.bundle)), + mediaType: attestation.bundle.mediaType, + annotations: { + 'dev.sigstore.bundle.content': 'dsse-envelope', + 'dev.sigstore.bundle.predicateType': PREDICATE_TYPE, + 'com.github.package.type': 'actions_oci_pkg_attestation' + }, + fetchOpts: { + timeout: OCI_TIMEOUT, + retry: OCI_RETRY, + proxy: undefined, + noProxy: undefined + } }) }