diff --git a/workspaces/libnpmpublish/lib/provenance.js b/workspaces/libnpmpublish/lib/provenance.js index 1eb870da5f24f..faa6419c6771a 100644 --- a/workspaces/libnpmpublish/lib/provenance.js +++ b/workspaces/libnpmpublish/lib/provenance.js @@ -1,68 +1,203 @@ const { sigstore } = require('sigstore') +const ci = require('ci-info') +const { env } = process const INTOTO_PAYLOAD_TYPE = 'application/vnd.in-toto+json' const INTOTO_STATEMENT_TYPE = 'https://in-toto.io/Statement/v0.1' const SLSA_PREDICATE_TYPE = 'https://slsa.dev/provenance/v0.2' -const BUILDER_ID = 'https://github.com/actions/runner' -const BUILD_TYPE_PREFIX = 'https://github.com/npm/cli/gha' -const BUILD_TYPE_VERSION = 'v2' +const GITHUB_BUILDER_ID = 'https://github.com/actions/runner' +const GITHUB_BUILD_TYPE_PREFIX = 'https://github.com/npm/cli/gha' +const GITHUB_BUILD_TYPE_VERSION = 'v2' + +const GITLAB_BUILD_TYPE_PREFIX = 'https://github.com/npm/cli/gitlab' +const GITLAB_BUILD_TYPE_VERSION = 'v0alpha1' const generateProvenance = async (subject, opts) => { - const { env } = process - /* istanbul ignore next - not covering missing env var case */ - const [workflowPath] = (env.GITHUB_WORKFLOW_REF || '') - .replace(env.GITHUB_REPOSITORY + '/', '') - .split('@') - const payload = { - _type: INTOTO_STATEMENT_TYPE, - subject, - predicateType: SLSA_PREDICATE_TYPE, - predicate: { - buildType: `${BUILD_TYPE_PREFIX}/${BUILD_TYPE_VERSION}`, - builder: { id: BUILDER_ID }, - invocation: { - configSource: { - uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`, - digest: { - sha1: env.GITHUB_SHA, + let payload + if (ci.GITHUB_ACTIONS) { + /* istanbul ignore next - not covering missing env var case */ + const [workflowPath] = (env.GITHUB_WORKFLOW_REF || '') + .replace(env.GITHUB_REPOSITORY + '/', '') + .split('@') + payload = { + _type: INTOTO_STATEMENT_TYPE, + subject, + predicateType: SLSA_PREDICATE_TYPE, + predicate: { + buildType: `${GITHUB_BUILD_TYPE_PREFIX}/${GITHUB_BUILD_TYPE_VERSION}`, + builder: { id: GITHUB_BUILDER_ID }, + invocation: { + configSource: { + uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`, + digest: { + sha1: env.GITHUB_SHA, + }, + entryPoint: workflowPath, + }, + parameters: {}, + environment: { + GITHUB_EVENT_NAME: env.GITHUB_EVENT_NAME, + GITHUB_REF: env.GITHUB_REF, + GITHUB_REPOSITORY: env.GITHUB_REPOSITORY, + GITHUB_REPOSITORY_ID: env.GITHUB_REPOSITORY_ID, + GITHUB_REPOSITORY_OWNER_ID: env.GITHUB_REPOSITORY_OWNER_ID, + GITHUB_RUN_ATTEMPT: env.GITHUB_RUN_ATTEMPT, + GITHUB_RUN_ID: env.GITHUB_RUN_ID, + GITHUB_SHA: env.GITHUB_SHA, + GITHUB_WORKFLOW_REF: env.GITHUB_WORKFLOW_REF, + GITHUB_WORKFLOW_SHA: env.GITHUB_WORKFLOW_SHA, }, - entryPoint: workflowPath, }, - parameters: {}, - environment: { - GITHUB_EVENT_NAME: env.GITHUB_EVENT_NAME, - GITHUB_REF: env.GITHUB_REF, - GITHUB_REPOSITORY: env.GITHUB_REPOSITORY, - GITHUB_REPOSITORY_ID: env.GITHUB_REPOSITORY_ID, - GITHUB_REPOSITORY_OWNER_ID: env.GITHUB_REPOSITORY_OWNER_ID, - GITHUB_RUN_ATTEMPT: env.GITHUB_RUN_ATTEMPT, - GITHUB_RUN_ID: env.GITHUB_RUN_ID, - GITHUB_SHA: env.GITHUB_SHA, - GITHUB_WORKFLOW_REF: env.GITHUB_WORKFLOW_REF, - GITHUB_WORKFLOW_SHA: env.GITHUB_WORKFLOW_SHA, + metadata: { + buildInvocationId: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, + completeness: { + parameters: false, + environment: false, + materials: false, + }, + reproducible: false, }, + materials: [ + { + uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`, + digest: { + sha1: env.GITHUB_SHA, + }, + }, + ], }, - metadata: { - buildInvocationId: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, - completeness: { - parameters: false, - environment: false, - materials: false, + } + } + if (ci.GITLAB) { + payload = { + _type: INTOTO_STATEMENT_TYPE, + subject, + predicateType: SLSA_PREDICATE_TYPE, + predicate: { + buildType: `${GITLAB_BUILD_TYPE_PREFIX}/${GITLAB_BUILD_TYPE_VERSION}`, + builder: { id: `${env.CI_PROJECT_URL}/-/runners/${env.CI_RUNNER_ID}` }, + invocation: { + configSource: { + uri: `git+${env.CI_PROJECT_URL}`, + digest: { + sha1: env.CI_COMMIT_SHA, + }, + entryPoint: env.CI_JOB_NAME, + }, + parameters: { + CI: env.CI, + CI_API_GRAPHQL_URL: env.CI_API_GRAPHQL_URL, + CI_API_V4_URL: env.CI_API_V4_URL, + CI_BUILD_BEFORE_SHA: env.CI_BUILD_BEFORE_SHA, + CI_BUILD_ID: env.CI_BUILD_ID, + CI_BUILD_NAME: env.CI_BUILD_NAME, + CI_BUILD_REF: env.CI_BUILD_REF, + CI_BUILD_REF_NAME: env.CI_BUILD_REF_NAME, + CI_BUILD_REF_SLUG: env.CI_BUILD_REF_SLUG, + CI_BUILD_STAGE: env.CI_BUILD_STAGE, + CI_COMMIT_BEFORE_SHA: env.CI_COMMIT_BEFORE_SHA, + CI_COMMIT_BRANCH: env.CI_COMMIT_BRANCH, + CI_COMMIT_REF_NAME: env.CI_COMMIT_REF_NAME, + CI_COMMIT_REF_PROTECTED: env.CI_COMMIT_REF_PROTECTED, + CI_COMMIT_REF_SLUG: env.CI_COMMIT_REF_SLUG, + CI_COMMIT_SHA: env.CI_COMMIT_SHA, + CI_COMMIT_SHORT_SHA: env.CI_COMMIT_SHORT_SHA, + CI_COMMIT_TIMESTAMP: env.CI_COMMIT_TIMESTAMP, + CI_COMMIT_TITLE: env.CI_COMMIT_TITLE, + CI_CONFIG_PATH: env.CI_CONFIG_PATH, + CI_DEFAULT_BRANCH: env.CI_DEFAULT_BRANCH, + CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX: + env.CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX, + CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX: env.CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX, + CI_DEPENDENCY_PROXY_SERVER: env.CI_DEPENDENCY_PROXY_SERVER, + CI_DEPENDENCY_PROXY_USER: env.CI_DEPENDENCY_PROXY_USER, + CI_JOB_ID: env.CI_JOB_ID, + CI_JOB_NAME: env.CI_JOB_NAME, + CI_JOB_NAME_SLUG: env.CI_JOB_NAME_SLUG, + CI_JOB_STAGE: env.CI_JOB_STAGE, + CI_JOB_STARTED_AT: env.CI_JOB_STARTED_AT, + CI_JOB_URL: env.CI_JOB_URL, + CI_NODE_TOTAL: env.CI_NODE_TOTAL, + CI_PAGES_DOMAIN: env.CI_PAGES_DOMAIN, + CI_PAGES_URL: env.CI_PAGES_URL, + CI_PIPELINE_CREATED_AT: env.CI_PIPELINE_CREATED_AT, + CI_PIPELINE_ID: env.CI_PIPELINE_ID, + CI_PIPELINE_IID: env.CI_PIPELINE_IID, + CI_PIPELINE_SOURCE: env.CI_PIPELINE_SOURCE, + CI_PIPELINE_URL: env.CI_PIPELINE_URL, + CI_PROJECT_CLASSIFICATION_LABEL: env.CI_PROJECT_CLASSIFICATION_LABEL, + CI_PROJECT_DESCRIPTION: env.CI_PROJECT_DESCRIPTION, + CI_PROJECT_ID: env.CI_PROJECT_ID, + CI_PROJECT_NAME: env.CI_PROJECT_NAME, + CI_PROJECT_NAMESPACE: env.CI_PROJECT_NAMESPACE, + CI_PROJECT_NAMESPACE_ID: env.CI_PROJECT_NAMESPACE_ID, + CI_PROJECT_PATH: env.CI_PROJECT_PATH, + CI_PROJECT_PATH_SLUG: env.CI_PROJECT_PATH_SLUG, + CI_PROJECT_REPOSITORY_LANGUAGES: env.CI_PROJECT_REPOSITORY_LANGUAGES, + CI_PROJECT_ROOT_NAMESPACE: env.CI_PROJECT_ROOT_NAMESPACE, + CI_PROJECT_TITLE: env.CI_PROJECT_TITLE, + CI_PROJECT_URL: env.CI_PROJECT_URL, + CI_PROJECT_VISIBILITY: env.CI_PROJECT_VISIBILITY, + CI_REGISTRY: env.CI_REGISTRY, + CI_REGISTRY_IMAGE: env.CI_REGISTRY_IMAGE, + CI_REGISTRY_USER: env.CI_REGISTRY_USER, + CI_RUNNER_DESCRIPTION: env.CI_RUNNER_DESCRIPTION, + CI_RUNNER_ID: env.CI_RUNNER_ID, + CI_RUNNER_TAGS: env.CI_RUNNER_TAGS, + CI_SERVER_HOST: env.CI_SERVER_HOST, + CI_SERVER_NAME: env.CI_SERVER_NAME, + CI_SERVER_PORT: env.CI_SERVER_PORT, + CI_SERVER_PROTOCOL: env.CI_SERVER_PROTOCOL, + CI_SERVER_REVISION: env.CI_SERVER_REVISION, + CI_SERVER_SHELL_SSH_HOST: env.CI_SERVER_SHELL_SSH_HOST, + CI_SERVER_SHELL_SSH_PORT: env.CI_SERVER_SHELL_SSH_PORT, + CI_SERVER_URL: env.CI_SERVER_URL, + CI_SERVER_VERSION: env.CI_SERVER_VERSION, + CI_SERVER_VERSION_MAJOR: env.CI_SERVER_VERSION_MAJOR, + CI_SERVER_VERSION_MINOR: env.CI_SERVER_VERSION_MINOR, + CI_SERVER_VERSION_PATCH: env.CI_SERVER_VERSION_PATCH, + CI_TEMPLATE_REGISTRY_HOST: env.CI_TEMPLATE_REGISTRY_HOST, + GITLAB_CI: env.GITLAB_CI, + GITLAB_FEATURES: env.GITLAB_FEATURES, + GITLAB_USER_ID: env.GITLAB_USER_ID, + GITLAB_USER_LOGIN: env.GITLAB_USER_LOGIN, + RUNNER_GENERATE_ARTIFACTS_METADATA: env.RUNNER_GENERATE_ARTIFACTS_METADATA, + }, + environment: { + name: env.CI_RUNNER_DESCRIPTION, + architecture: env.CI_RUNNER_EXECUTABLE_ARCH, + server: env.CI_SERVER_URL, + project: env.CI_PROJECT_PATH, + job: { + id: env.CI_JOB_ID, + }, + pipeline: { + id: env.CI_PIPELINE_ID, + ref: env.CI_CONFIG_PATH, + }, + }, }, - reproducible: false, - }, - materials: [ - { - uri: `git+${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}@${env.GITHUB_REF}`, - digest: { - sha1: env.GITHUB_SHA, + metadata: { + buildInvocationId: `${env.CI_JOB_URL}`, + completeness: { + parameters: true, + environment: true, + materials: false, }, + reproducible: false, }, - ], - }, + materials: [ + { + uri: `git+${env.CI_PROJECT_URL}`, + digest: { + sha1: env.CI_COMMIT_SHA, + }, + }, + ], + }, + } } - return sigstore.attest(Buffer.from(JSON.stringify(payload)), INTOTO_PAYLOAD_TYPE, opts) } diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 89ca01662cdb5..9786fea98bd95 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -144,19 +144,27 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { digest: { sha512: integrity.sha512[0].hexDigest() }, } - // Ensure that we're running in GHA, currently the only supported build environment - if (ciInfo.name !== 'GitHub Actions') { + if (ciInfo.GITHUB_ACTIONS) { + // Ensure that the GHA OIDC token is available + if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { + throw Object.assign( + /* eslint-disable-next-line max-len */ + new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'), + { code: 'EUSAGE' } + ) + } + } else if (ciInfo.GITLAB) { + // Ensure that the Sigstore OIDC token is available + if (!process.env.SIGSTORE_ID_TOKEN) { + throw Object.assign( + /* eslint-disable-next-line max-len */ + new Error('Provenance generation in GitLab CI requires "SIGSTORE_ID_TOKEN" with "sigstore" audience to be present in "id_tokens". For more info see:\nhttps://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html'), + { code: 'EUSAGE' } + ) + } + } else { throw Object.assign( - new Error('Automatic provenance generation not supported outside of GitHub Actions'), - { code: 'EUSAGE' } - ) - } - - // Ensure that the GHA OIDC token is available - if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { - throw Object.assign( - /* eslint-disable-next-line max-len */ - new Error('Provenance generation in GitHub Actions requires "write" access to the "id-token" permission'), + new Error('Automatic provenance generation not supported for provider: ' + ciInfo.name), { code: 'EUSAGE' } ) } @@ -173,7 +181,7 @@ const buildMetadata = async (registry, manifest, tarballData, spec, opts) => { const provenanceBundle = await generateProvenance([subject], opts) /* eslint-disable-next-line max-len */ - log.notice('publish', 'Signed provenance statement with source and build information from GitHub Actions') + log.notice('publish', `Signed provenance statement with source and build information from ${ciInfo.name}`) const tlogEntry = provenanceBundle?.verificationMaterial?.tlogEntries[0] /* istanbul ignore else */ diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js index 065765807b889..2fdca5dfd6fac 100644 --- a/workspaces/libnpmpublish/test/publish.js +++ b/workspaces/libnpmpublish/test/publish.js @@ -903,3 +903,233 @@ t.test('automatic provenance with incorrect permissions', async t => { } ) }) + +t.test('publish existing package with provenance in gitlab', async t => { + // Environment variables + const jobName = 'job' + const repository = 'gitlab/foo' + const serverUrl = 'https://gitlab.com' + const sha = 'deadbeef' + const runnerID = 1 + + // Data for mocking the OIDC token request + const oidcClaims = { + iss: 'https://oauth2.sigstore.dev/auth', + email: 'foo@bar.com', + } + const idToken = `.${Buffer.from(JSON.stringify(oidcClaims)).toString('base64')}.` + + // Set-up GitLab environment variables + mockGlobals(t, { + 'process.env': { + CI: true, + GITLAB_CI: true, + GITHUB_ACTIONS: undefined, + SIGSTORE_ID_TOKEN: idToken, + CI_RUNNER_ID: runnerID, + CI_PROJECT_URL: `${serverUrl}/${repository}`, + CI_COMMIT_SHA: sha, + CI_JOB_NAME: jobName, + }, + }) + + const expectedSubject = { + name: 'pkg:npm/%40npmcli/libnpmpublish-test@1.0.0', + digest: { + sha512: integrity.sha512[0].hexDigest(), + }, + } + + const expectedConfigSource = { + uri: `git+${serverUrl}/${repository}`, + digest: { sha1: sha }, + entryPoint: jobName, + } + + const log = [] + const { publish } = t.mock('..', { + 'ci-info': t.mock('ci-info'), + 'proc-log': { notice: (...msg) => log.push(['notice', ...msg]) }, + }) + const registry = new MockRegistry({ + tap: t, + registry: opts.registry, + authorization: token, + }) + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + const spec = npa(manifest.name) + + // Data for mocking Fulcio certifcate request + const fulcioURL = 'https://mock.fulcio' + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` + const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n` + const certificateResponse = { + signedCertificateEmbeddedSct: { + chain: { + certificates: [leafCertificate, rootCertificate], + }, + }, + } + + // Data for mocking Rekor upload + const rekorURL = 'https://mock.rekor' + const signature = 'ABC123' + const b64Cert = Buffer.from(leafCertificate).toString('base64') + const logIndex = 2513258 + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6' + + const signatureBundle = { + kind: 'hashedrekord', + apiVersion: '0.0.1', + spec: { + signature: { + content: signature, + publicKey: { content: b64Cert }, + }, + }, + } + + const rekorEntry = { + [uuid]: { + body: Buffer.from(JSON.stringify(signatureBundle)).toString( + 'base64' + ), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex, + verification: { + // eslint-disable-next-line max-len + signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + } + + const packument = { + _id: manifest.name, + name: manifest.name, + description: manifest.description, + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: `${manifest.name}@${manifest.version}`, + _nodeVersion: process.versions.node, + ...manifest, + dist: { + shasum, + integrity: integrity.sha512[0].toString(), + // eslint-disable-next-line max-len + tarball: 'http://mock.reg/@npmcli/libnpmpublish-test/-/@npmcli/libnpmpublish-test-1.0.0.tgz', + }, + }, + }, + access: 'public', + _attachments: { + '@npmcli/libnpmpublish-test-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + '@npmcli/libnpmpublish-test-1.0.0.sigstore': { + // Can't match data against static value as signature is always + // different. + // Can't match length because in github actions certain environment + // variables are present that are not present when running locally, + // changing the payload size. + content_type: 'application/vnd.dev.sigstore.bundle+json;version=0.1', + }, + }, + } + + const fulcioSrv = MockRegistry.tnock(t, fulcioURL) + fulcioSrv.matchHeader('Content-Type', 'application/json') + .post('/api/v2/signingCert', { + credentials: { oidcIdentityToken: idToken }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: /.+/i, + }, + proofOfPossession: /.+/i, + }, + }) + .reply(200, certificateResponse) + + const rekorSrv = MockRegistry.tnock(t, rekorURL) + rekorSrv + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries') + .reply(201, rekorEntry) + + registry.getVisibility({ spec, visibility: { public: true } }) + registry.nock.put(`/${spec.escapedName}`, body => { + const bundleAttachment = body._attachments['@npmcli/libnpmpublish-test-1.0.0.sigstore'] + const bundle = JSON.parse(bundleAttachment.data) + const provenance = JSON.parse(Buffer.from(bundle.dsseEnvelope.payload, 'base64').toString()) + + t.hasStrict(body, packument, 'posted packument matches expectations') + t.hasStrict(provenance.subject[0], + expectedSubject, + 'provenance subject matches expectations') + t.hasStrict(provenance.predicate.buildType, + 'https://github.com/npm/cli/gitlab/v0alpha1', + 'buildType matches expectations') + t.hasStrict(provenance.predicate.builder.id, + `${serverUrl}/${repository}/-/runners/${runnerID}`, + 'builder id matches expectations') + t.hasStrict(provenance.predicate.invocation.configSource, + expectedConfigSource, + 'configSource matches expectations') + return true + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...opts, + provenance: true, + fulcioURL: fulcioURL, + rekorURL: rekorURL, + }) + t.ok(ret, 'publish succeeded') + t.match(log, [ + ['notice', 'publish', + 'Signed provenance statement with source and build information from GitLab CI'], + ['notice', 'publish', + // eslint-disable-next-line max-len + `Provenance statement published to transparency log: https://search.sigstore.dev/?logIndex=${logIndex}`], + ]) +}) + +t.test('gitlab provenance, no token available', async t => { + mockGlobals(t, { + 'process.env': { + CI: true, + GITLAB_CI: true, + GITHUB_ACTIONS: undefined, + }, + }) + const manifest = { + name: '@npmcli/libnpmpublish-test', + version: '1.0.0', + description: 'test libnpmpublish package', + } + const { publish } = t.mock('..', { 'ci-info': t.mock('ci-info') }) + await t.rejects( + publish(manifest, Buffer.from(''), { + ...opts, + access: null, + provenance: true, + }), + { + message: /requires "SIGSTORE_ID_TOKEN"/, + code: 'EUSAGE', + } + ) +})