diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 428196d010d..b811e9c7998 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -43,6 +43,8 @@ jobs: node_archive: node-v24.18.0-darwin-arm64.tar.xz runs-on: ${{ matrix.runner }} timeout-minutes: 20 + env: + ORCA_RUNTIME_REQUESTED_RUNNER: ${{ matrix.runner }} steps: - name: Checkout exact source revision @@ -109,6 +111,11 @@ jobs: node --check config/scripts/ssh-relay-runtime-build.test.mjs node --check config/scripts/ssh-relay-runtime-closure.mjs node --check config/scripts/ssh-relay-runtime-closure.test.mjs + node --check config/scripts/ssh-relay-runtime-sbom.mjs + node --check config/scripts/ssh-relay-runtime-sbom.test.mjs + node --check config/scripts/ssh-relay-runtime-provenance.test.mjs + node --check config/scripts/ssh-relay-runtime-toolchain.mjs + node --check config/scripts/ssh-relay-runtime-toolchain.test.mjs pnpm exec vitest run --config config/vitest.config.ts \ config/scripts/ssh-relay-node-release-verification.test.mjs \ config/scripts/ssh-relay-node-tar-inspection.test.mjs \ @@ -122,6 +129,9 @@ jobs: config/scripts/ssh-relay-runtime-pty-smoke.test.mjs \ config/scripts/ssh-relay-runtime-reproducibility.test.mjs \ config/scripts/ssh-relay-runtime-resource-diagnostics.test.mjs \ + config/scripts/ssh-relay-runtime-provenance.test.mjs \ + config/scripts/ssh-relay-runtime-sbom.test.mjs \ + config/scripts/ssh-relay-runtime-toolchain.test.mjs \ config/scripts/ssh-relay-runtime-windows-tree.test.mjs \ config/scripts/ssh-relay-runtime-zip.test.mjs \ config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -208,6 +218,8 @@ jobs: msvc_arch: arm64 runs-on: ${{ matrix.runner }} timeout-minutes: 30 + env: + ORCA_RUNTIME_REQUESTED_RUNNER: ${{ matrix.runner }} steps: - name: Checkout exact source revision @@ -286,6 +298,16 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'runtime closure script syntax check failed' } node --check config/scripts/ssh-relay-runtime-closure.test.mjs if ($LASTEXITCODE -ne 0) { throw 'runtime closure test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-sbom.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime SBOM script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-sbom.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime SBOM test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-provenance.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime provenance test syntax check failed' } + node --check config/scripts/ssh-relay-runtime-toolchain.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime toolchain script syntax check failed' } + node --check config/scripts/ssh-relay-runtime-toolchain.test.mjs + if ($LASTEXITCODE -ne 0) { throw 'runtime toolchain test syntax check failed' } pnpm exec vitest run --config config/vitest.config.ts ` config/scripts/ssh-relay-node-release-verification.test.mjs ` config/scripts/ssh-relay-node-tar-inspection.test.mjs ` @@ -299,6 +321,9 @@ jobs: config/scripts/ssh-relay-runtime-pty-smoke.test.mjs ` config/scripts/ssh-relay-runtime-reproducibility.test.mjs ` config/scripts/ssh-relay-runtime-resource-diagnostics.test.mjs ` + config/scripts/ssh-relay-runtime-provenance.test.mjs ` + config/scripts/ssh-relay-runtime-sbom.test.mjs ` + config/scripts/ssh-relay-runtime-toolchain.test.mjs ` config/scripts/ssh-relay-runtime-windows-pe-diagnostic.test.mjs ` config/scripts/ssh-relay-runtime-windows-tree.test.mjs ` config/scripts/ssh-relay-runtime-zip.test.mjs ` diff --git a/config/scripts/build-ssh-relay-runtime.mjs b/config/scripts/build-ssh-relay-runtime.mjs index 2ae076aafc0..46906f11591 100644 --- a/config/scripts/build-ssh-relay-runtime.mjs +++ b/config/scripts/build-ssh-relay-runtime.mjs @@ -11,6 +11,11 @@ import { inspectSshRelayRuntimeArchive } from './ssh-relay-runtime-archive.mjs' import { writeSshRelayRuntimeMetadata } from './ssh-relay-runtime-provenance.mjs' +import { + collectSshRelayRuntimeToolchain, + sshRelayRuntimeBuilderIdentity, + sshRelayRuntimeRunnerIdentity +} from './ssh-relay-runtime-toolchain.mjs' import { assembleSshRelayRuntimeTree } from './ssh-relay-runtime-tree.mjs' import { validateSshRelayNodeReleaseContract } from './ssh-relay-node-release-verification.mjs' import { verifyNodeReleaseInputs } from './verify-ssh-relay-node-release-inputs.mjs' @@ -130,36 +135,6 @@ async function run(command, args, options = {}) { return result.stdout.trim() } -async function toolchainDetails(nodePath) { - const commands = - process.platform === 'win32' - ? [ - ['bundledNode', nodePath, ['--version']], - ['compiler', 'cl.exe', []], - ['python', 'python.exe', ['--version']] - ] - : [ - ['bundledNode', nodePath, ['--version']], - ['xz', 'xz', ['--version']], - ['compiler', 'c++', ['--version']], - [ - 'strip', - process.platform === 'darwin' ? 'xcrun' : 'strip', - process.platform === 'darwin' ? ['--find', 'strip'] : ['--version'] - ], - ['python', 'python3', ['--version']] - ] - const details = {} - for (const [name, command, args] of commands) { - details[name] = (await run(command, args)).split(/\r?\n/)[0] - } - if (process.platform === 'win32') { - details.zip = 'yazl 3.3.1' - } - details.buildNode = process.version - return details -} - function relayPlatform(tuple) { return tuple.replace('-glibc', '') } @@ -241,7 +216,7 @@ export async function buildSshRelayRuntime(options) { sourceDateEpoch: options.sourceDateEpoch }) const inspection = await inspectSshRelayRuntimeArchive(archive.path, identity) - const toolchain = await toolchainDetails(extracted.nodePath) + const toolchain = await collectSshRelayRuntimeToolchain(extracted.nodePath) const metadata = await writeSshRelayRuntimeMetadata({ outputDirectory: options.outputDirectory, identity, @@ -249,7 +224,8 @@ export async function buildSshRelayRuntime(options) { nodeRelease: release, sourceDateEpoch: options.sourceDateEpoch, gitCommit: options.gitCommit, - builder: process.env.GITHUB_WORKFLOW_REF ?? `local://${process.platform}/${process.arch}`, + builder: sshRelayRuntimeBuilderIdentity({ gitCommit: options.gitCommit }), + runner: sshRelayRuntimeRunnerIdentity(), toolchain }) return { diff --git a/config/scripts/ssh-relay-runtime-provenance.mjs b/config/scripts/ssh-relay-runtime-provenance.mjs index 9582dd2256c..ee460e39fea 100644 --- a/config/scripts/ssh-relay-runtime-provenance.mjs +++ b/config/scripts/ssh-relay-runtime-provenance.mjs @@ -1,43 +1,14 @@ import { createHash } from 'node:crypto' import { readFile, stat, writeFile } from 'node:fs/promises' -import { createRequire } from 'node:module' import { join } from 'node:path' -const require = createRequire(import.meta.url) +import { createSshRelayRuntimeSbom } from './ssh-relay-runtime-sbom.mjs' +import { assertSshRelayRuntimeToolchain } from './ssh-relay-runtime-toolchain.mjs' function sha256(bytes) { return `sha256:${createHash('sha256').update(bytes).digest('hex')}` } -function spdxId(value) { - return `SPDXRef-${value.replace(/[^A-Za-z0-9.-]/g, '-')}` -} - -function packageRecord(name, version, license) { - return { - name, - SPDXID: spdxId(`Package-${name}`), - versionInfo: version, - downloadLocation: 'NOASSERTION', - filesAnalyzed: false, - licenseConcluded: license, - licenseDeclared: license, - copyrightText: 'NOASSERTION' - } -} - -function installedPackage(name) { - const parsed = require(`${name}/package.json`) - return packageRecord(parsed.name, parsed.version, parsed.license ?? 'NOASSERTION') -} - -function watcherNativePackage(tuple) { - if (tuple.startsWith('linux-')) { - return `@parcel/watcher-linux-${tuple.slice('linux-'.length)}` - } - return `@parcel/watcher-${tuple}` -} - async function writeJson(path, value) { const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8') await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }) @@ -52,48 +23,14 @@ export async function writeSshRelayRuntimeMetadata({ sourceDateEpoch, gitCommit, builder, + runner, toolchain }) { - const createdAt = new Date(sourceDateEpoch * 1000).toISOString() + assertSshRelayRuntimeToolchain(toolchain, identity.tupleId) const sbomName = `orca-ssh-relay-runtime-${identity.tupleId}.spdx.json` const provenanceName = `orca-ssh-relay-runtime-${identity.tupleId}.provenance.json` const identityName = `orca-ssh-relay-runtime-${identity.tupleId}.identity.json` - const files = identity.entries - .filter((entry) => entry.type === 'file') - .map((entry, index) => ({ - fileName: entry.path, - SPDXID: `SPDXRef-File-${index + 1}`, - checksums: [{ algorithm: 'SHA256', checksumValue: entry.sha256.slice('sha256:'.length) }], - licenseConcluded: 'NOASSERTION', - copyrightText: 'NOASSERTION' - })) - const packages = [ - packageRecord('node', identity.nodeVersion, 'NOASSERTION'), - packageRecord('orca-ssh-relay', '0.1.0', 'NOASSERTION'), - installedPackage('node-pty'), - installedPackage('@parcel/watcher'), - installedPackage(watcherNativePackage(identity.tupleId)), - installedPackage('detect-libc'), - installedPackage('is-glob'), - installedPackage('is-extglob'), - installedPackage('picomatch') - ] - const documentId = identity.contentId.slice('sha256:'.length) - const sbom = { - spdxVersion: 'SPDX-2.3', - dataLicense: 'CC0-1.0', - SPDXID: 'SPDXRef-DOCUMENT', - name: `Orca SSH relay runtime ${identity.tupleId}`, - documentNamespace: `https://github.com/stablyai/orca/ssh-relay-runtime/${documentId}`, - creationInfo: { created: createdAt, creators: ['Organization: Stably AI'] }, - packages, - files, - relationships: packages.map((component) => ({ - spdxElementId: 'SPDXRef-DOCUMENT', - relationshipType: 'DESCRIBES', - relatedSpdxElement: component.SPDXID - })) - } + const sbom = createSshRelayRuntimeSbom({ identity, archive, sourceDateEpoch }) const nativeFiles = identity.entries .filter( (entry) => @@ -139,7 +76,10 @@ export async function writeSshRelayRuntimeMetadata({ }, runDetails: { builder: { id: builder }, - metadata: { invocationId: process.env.GITHUB_RUN_ID ?? 'local-unpublished-build' }, + metadata: { + invocationId: process.env.GITHUB_RUN_ID ?? 'local-unpublished-build', + runner + }, byproducts: [{ name: 'native-files', content: nativeFiles }] } } diff --git a/config/scripts/ssh-relay-runtime-provenance.test.mjs b/config/scripts/ssh-relay-runtime-provenance.test.mjs new file mode 100644 index 00000000000..ba5340d6704 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-provenance.test.mjs @@ -0,0 +1,158 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { writeSshRelayRuntimeMetadata } from './ssh-relay-runtime-provenance.mjs' + +const temporaryDirectories = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))) +}) + +const sha256 = (character) => `sha256:${character.repeat(64)}` + +function file(path, role, character) { + return { path, type: 'file', role, size: 1, mode: 0o644, sha256: sha256(character) } +} + +function fixture() { + const tuple = 'linux-x64-glibc' + const identity = { + tupleId: tuple, + os: 'linux', + architecture: 'x64', + nodeVersion: '24.18.0', + contentId: sha256('f'), + fileCount: 5, + expandedSize: 5, + entries: [ + file('bin/node', 'node', '1'), + file('relay.js', 'relay', '2'), + file('node_modules/node-pty/build/Release/pty.node', 'node-pty-native', '3'), + file( + 'node_modules/@parcel/watcher-linux-x64-glibc/watcher.node', + 'parcel-watcher-native', + '4' + ), + file('THIRD_PARTY_LICENSES.txt', 'license', '5') + ] + } + return { + tuple, + identity, + archive: { + name: `orca-ssh-relay-runtime-v1-${tuple}-${'f'.repeat(64)}.tar.xz`, + size: 123, + sha256: sha256('a') + }, + nodeRelease: { + baseUrl: 'https://nodejs.org/dist/v24.18.0', + archives: { + [tuple]: { name: 'node-v24.18.0-linux-x64.tar.xz', sha256: 'b'.repeat(64) } + }, + signature: { + key: { + sourceUrl: 'https://github.com/nodejs/release-keys/raw/commit/gpg/key.asc', + sha256: 'c'.repeat(64) + } + } + } + } +} + +describe('SSH relay runtime published metadata', () => { + it('binds SPDX, SLSA provenance, and identity to the exact archive and runner', async () => { + const outputDirectory = await mkdtemp(join(tmpdir(), 'orca-runtime-metadata-')) + temporaryDirectories.push(outputDirectory) + const input = fixture() + const runner = { + os: 'Linux', + architecture: 'X64', + environment: 'github-hosted', + requestedLabel: 'ubuntu-24.04', + image: { os: 'ubuntu24', version: '20260705.232.1' } + } + const toolchain = Object.fromEntries( + [ + 'buildNode', + 'bundledNode', + 'compiler', + 'buildSystem', + 'python', + 'archive', + 'nodeAddonApi', + 'nodeGyp', + 'strip' + ].map((name, index) => [ + name, + { version: `${name} version`, sha256: sha256((index + 6).toString(16)) } + ]) + ) + const builder = `https://github.com/stablyai/orca/blob/${'1'.repeat(40)}/.github/workflows/ssh-relay-runtime-artifacts.yml` + + const assets = await writeSshRelayRuntimeMetadata({ + outputDirectory, + identity: input.identity, + archive: input.archive, + nodeRelease: input.nodeRelease, + sourceDateEpoch: 1_752_710_400, + gitCommit: '1'.repeat(40), + builder, + runner, + toolchain + }) + const sbom = JSON.parse(await readFile(join(outputDirectory, assets.sbom.name), 'utf8')) + const provenance = JSON.parse( + await readFile(join(outputDirectory, assets.provenance.name), 'utf8') + ) + const identity = JSON.parse(await readFile(join(outputDirectory, assets.identity.name), 'utf8')) + + expect(sbom.files).toHaveLength(input.identity.fileCount) + expect( + sbom.relationships.filter((entry) => entry.relationshipType === 'CONTAINS') + ).toHaveLength(input.identity.fileCount) + expect(provenance.subject).toEqual([ + { name: input.archive.name, digest: { sha256: 'a'.repeat(64) } } + ]) + expect(provenance.predicate.runDetails).toMatchObject({ + builder: { id: builder }, + metadata: { runner } + }) + expect(provenance.predicate.buildDefinition).toMatchObject({ + internalParameters: { toolchain }, + resolvedDependencies: expect.arrayContaining([ + { + uri: 'https://nodejs.org/dist/v24.18.0/node-v24.18.0-linux-x64.tar.xz', + digest: { sha256: 'b'.repeat(64) } + }, + { + uri: `git+https://github.com/stablyai/orca@${'1'.repeat(40)}`, + digest: { gitCommit: '1'.repeat(40) } + } + ]) + }) + expect(identity.archive).toEqual({ + name: input.archive.name, + size: 123, + expandedSize: 5, + fileCount: 5, + sha256: sha256('a') + }) + await expect( + writeSshRelayRuntimeMetadata({ + outputDirectory, + identity: input.identity, + archive: input.archive, + nodeRelease: input.nodeRelease, + sourceDateEpoch: 1_752_710_400, + gitCommit: '1'.repeat(40), + builder, + runner, + toolchain + }) + ).rejects.toThrow() + }) +}) diff --git a/config/scripts/ssh-relay-runtime-sbom.mjs b/config/scripts/ssh-relay-runtime-sbom.mjs new file mode 100644 index 00000000000..a3a29cad6e3 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-sbom.mjs @@ -0,0 +1,125 @@ +import { createRequire } from 'node:module' + +import { sshRelayRuntimeWatcherPackage } from './ssh-relay-runtime-closure.mjs' + +const require = createRequire(import.meta.url) + +function spdxId(value) { + return `SPDXRef-${value.replace(/[^A-Za-z0-9.-]/g, '-')}` +} + +function packageRecord(name, version, license) { + return { + name, + SPDXID: spdxId(`Package-${name}`), + versionInfo: version, + downloadLocation: 'NOASSERTION', + filesAnalyzed: false, + licenseConcluded: license, + licenseDeclared: license, + copyrightText: 'NOASSERTION' + } +} + +function installedPackage(name) { + const parsed = require(`${name}/package.json`) + return packageRecord(parsed.name, parsed.version, parsed.license ?? 'NOASSERTION') +} + +function fileType(role) { + return ['node', 'node-pty-native', 'parcel-watcher-native', 'native-runtime'].includes(role) + ? 'BINARY' + : role === 'license' + ? 'TEXT' + : 'SOURCE' +} + +function ownerPackage(path, packages) { + const packagePath = packages.find((entry) => path.startsWith(`node_modules/${entry.name}/`)) + if (packagePath) { + return packagePath + } + if (path.startsWith('node_modules/')) { + return undefined + } + if (path === 'bin/node' || path === 'bin/node.exe') { + return packages.find((entry) => entry.name === 'node') + } + return packages.find((entry) => entry.name === 'orca-ssh-relay') +} + +export function createSshRelayRuntimeSbom({ identity, archive, sourceDateEpoch }) { + if (!/^sha256:[0-9a-f]{64}$/.test(archive?.sha256 ?? '')) { + throw new Error('Runtime SBOM requires the exact archive SHA-256') + } + const watcherPackage = sshRelayRuntimeWatcherPackage(identity.tupleId) + const packages = [ + packageRecord('node', identity.nodeVersion, 'MIT'), + packageRecord('orca-ssh-relay', identity.contentId, 'NOASSERTION'), + installedPackage('node-pty'), + installedPackage('@parcel/watcher'), + installedPackage(watcherPackage), + installedPackage('detect-libc'), + installedPackage('is-glob'), + installedPackage('is-extglob'), + installedPackage('picomatch') + ] + const files = identity.entries + .filter((entry) => entry.type === 'file') + .map((entry) => ({ + fileName: entry.path, + SPDXID: spdxId(`File-${entry.path}`), + fileTypes: [fileType(entry.role)], + checksums: [{ algorithm: 'SHA256', checksumValue: entry.sha256.slice('sha256:'.length) }], + licenseConcluded: 'NOASSERTION', + copyrightText: 'NOASSERTION' + })) + const identifiers = new Set() + for (const element of [...packages, ...files]) { + if (identifiers.has(element.SPDXID)) { + throw new Error(`Runtime SBOM contains a duplicate SPDX identifier: ${element.SPDXID}`) + } + identifiers.add(element.SPDXID) + } + const contains = files.map((file) => { + const owner = ownerPackage(file.fileName, packages) + if (!owner) { + throw new Error(`Runtime SBOM cannot assign a package owner: ${file.fileName}`) + } + return { + spdxElementId: owner.SPDXID, + relationshipType: 'CONTAINS', + relatedSpdxElement: file.SPDXID + } + }) + const relay = packages.find((entry) => entry.name === 'orca-ssh-relay') + const relationships = [ + ...packages.map((component) => ({ + spdxElementId: 'SPDXRef-DOCUMENT', + relationshipType: 'DESCRIBES', + relatedSpdxElement: component.SPDXID + })), + ...packages + .filter((component) => component !== relay) + .map((component) => ({ + spdxElementId: relay.SPDXID, + relationshipType: 'DEPENDS_ON', + relatedSpdxElement: component.SPDXID + })), + ...contains + ] + return { + spdxVersion: 'SPDX-2.3', + dataLicense: 'CC0-1.0', + SPDXID: 'SPDXRef-DOCUMENT', + name: `Orca SSH relay runtime ${identity.tupleId}`, + documentNamespace: `https://github.com/stablyai/orca/ssh-relay-runtime/spdx/${archive.sha256.slice('sha256:'.length)}`, + creationInfo: { + created: new Date(sourceDateEpoch * 1000).toISOString(), + creators: ['Organization: Stably AI'] + }, + packages, + files, + relationships + } +} diff --git a/config/scripts/ssh-relay-runtime-sbom.test.mjs b/config/scripts/ssh-relay-runtime-sbom.test.mjs new file mode 100644 index 00000000000..c03be12ecc9 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-sbom.test.mjs @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' + +import { createSshRelayRuntimeSbom } from './ssh-relay-runtime-sbom.mjs' + +const sha256 = (character) => `sha256:${character.repeat(64)}` + +function file(path, role, character) { + return { path, type: 'file', role, size: 1, mode: 0o644, sha256: sha256(character) } +} + +function identity(tuple = 'linux-arm64-glibc') { + return { + tupleId: tuple, + nodeVersion: '24.18.0', + contentId: sha256('f'), + entries: [ + file('bin/node', 'node', '1'), + file('relay.js', 'relay', '2'), + file('THIRD_PARTY_LICENSES.txt', 'license', '3'), + file('node_modules/node-pty/lib/index.js', 'runtime-javascript', '4'), + file('node_modules/@parcel/watcher/index.js', 'runtime-javascript', '5'), + file(`node_modules/@parcel/watcher-${tuple}/watcher.node`, 'parcel-watcher-native', '6'), + file('node_modules/detect-libc/lib/detect-libc.js', 'runtime-javascript', '7'), + file('node_modules/is-glob/index.js', 'runtime-javascript', '8'), + file('node_modules/is-extglob/index.js', 'runtime-javascript', '9'), + file('node_modules/picomatch/index.js', 'runtime-javascript', 'a') + ] + } +} + +const archive = { sha256: sha256('e') } + +function ownerName(sbom, fileName) { + const fileId = sbom.files.find((entry) => entry.fileName === fileName).SPDXID + const owners = sbom.relationships.filter( + (entry) => entry.relationshipType === 'CONTAINS' && entry.relatedSpdxElement === fileId + ) + expect(owners).toHaveLength(1) + return sbom.packages.find((entry) => entry.SPDXID === owners[0].spdxElementId).name +} + +describe('SSH relay runtime SPDX SBOM', () => { + it('inventories every runtime file under exactly one package', () => { + const fixture = identity() + const sbom = createSshRelayRuntimeSbom({ + identity: fixture, + archive, + sourceDateEpoch: 1_752_710_400 + }) + + expect(sbom.spdxVersion).toBe('SPDX-2.3') + expect(sbom.documentNamespace).toBe( + `https://github.com/stablyai/orca/ssh-relay-runtime/spdx/${'e'.repeat(64)}` + ) + expect(sbom.creationInfo.created).toBe('2025-07-17T00:00:00.000Z') + expect(sbom.packages.map((entry) => entry.name)).toEqual([ + 'node', + 'orca-ssh-relay', + 'node-pty', + '@parcel/watcher', + '@parcel/watcher-linux-arm64-glibc', + 'detect-libc', + 'is-glob', + 'is-extglob', + 'picomatch' + ]) + expect(sbom.packages.find((entry) => entry.name === 'node')).toMatchObject({ + versionInfo: '24.18.0', + licenseDeclared: 'MIT' + }) + expect(sbom.packages.find((entry) => entry.name === 'orca-ssh-relay')).toMatchObject({ + versionInfo: fixture.contentId, + licenseDeclared: 'NOASSERTION' + }) + expect(sbom.files).toHaveLength(fixture.entries.length) + expect(new Set([...sbom.packages, ...sbom.files].map((entry) => entry.SPDXID)).size).toBe( + sbom.packages.length + sbom.files.length + ) + expect(ownerName(sbom, 'bin/node')).toBe('node') + expect(ownerName(sbom, 'relay.js')).toBe('orca-ssh-relay') + expect(ownerName(sbom, 'THIRD_PARTY_LICENSES.txt')).toBe('orca-ssh-relay') + expect(ownerName(sbom, 'node_modules/node-pty/lib/index.js')).toBe('node-pty') + expect(ownerName(sbom, 'node_modules/@parcel/watcher/index.js')).toBe('@parcel/watcher') + expect(ownerName(sbom, 'node_modules/@parcel/watcher-linux-arm64-glibc/watcher.node')).toBe( + '@parcel/watcher-linux-arm64-glibc' + ) + expect(ownerName(sbom, 'node_modules/detect-libc/lib/detect-libc.js')).toBe('detect-libc') + expect(ownerName(sbom, 'node_modules/is-glob/index.js')).toBe('is-glob') + expect(ownerName(sbom, 'node_modules/is-extglob/index.js')).toBe('is-extglob') + expect(ownerName(sbom, 'node_modules/picomatch/index.js')).toBe('picomatch') + expect( + sbom.relationships.filter((entry) => entry.relationshipType === 'DEPENDS_ON') + ).toHaveLength(8) + }) + + it('rejects an unowned node_modules path and colliding SPDX identifiers', () => { + const unowned = identity() + unowned.entries.push(file('node_modules/unreviewed/index.js', 'runtime-javascript', 'b')) + expect(() => + createSshRelayRuntimeSbom({ identity: unowned, archive, sourceDateEpoch: 1_752_710_400 }) + ).toThrow(/cannot assign a package owner/i) + + const collision = identity() + collision.entries.push( + file('relay/a+b.js', 'runtime-javascript', 'b'), + file('relay/a@b.js', 'runtime-javascript', 'c') + ) + expect(() => + createSshRelayRuntimeSbom({ identity: collision, archive, sourceDateEpoch: 1_752_710_400 }) + ).toThrow(/duplicate SPDX identifier/i) + }) + + it('requires the exact archive digest to make the document namespace immutable', () => { + expect(() => + createSshRelayRuntimeSbom({ + identity: identity(), + archive: { sha256: 'missing-prefix' }, + sourceDateEpoch: 1_752_710_400 + }) + ).toThrow(/archive SHA-256/i) + }) +}) diff --git a/config/scripts/ssh-relay-runtime-toolchain.mjs b/config/scripts/ssh-relay-runtime-toolchain.mjs new file mode 100644 index 00000000000..1583b86c011 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-toolchain.mjs @@ -0,0 +1,252 @@ +import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readdir, realpath, stat } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { isDeepStrictEqual, promisify } from 'node:util' + +const require = createRequire(import.meta.url) +const execFileAsync = promisify(execFile) +const MAX_TOOL_BYTES = 300 * 1024 * 1024 +const TOOL_TIMEOUT_MS = 60_000 +const TOOL_OUTPUT_BYTES = 1024 * 1024 +const MAX_PACKAGE_FILES = 500 +const MAX_PACKAGE_BYTES = 32 * 1024 * 1024 + +async function sha256File(path) { + const metadata = await stat(path) + if (!metadata.isFile() || metadata.size > MAX_TOOL_BYTES) { + throw new Error(`Runtime build tool is not a bounded regular file: ${path}`) + } + const hash = createHash('sha256') + for await (const chunk of createReadStream(path)) { + hash.update(chunk) + } + return `sha256:${hash.digest('hex')}` +} + +async function capture(command, args) { + try { + return await execFileAsync(command, args, { + encoding: 'utf8', + maxBuffer: TOOL_OUTPUT_BYTES, + timeout: TOOL_TIMEOUT_MS, + windowsHide: true + }) + } catch (error) { + if (typeof error?.stdout === 'string' || typeof error?.stderr === 'string') { + return { stdout: error.stdout ?? '', stderr: error.stderr ?? '' } + } + throw error + } +} + +export function selectSshRelayRuntimeToolVersion({ stdout = '', stderr = '' }, pattern) { + const lines = `${stderr}\n${stdout}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + const version = pattern ? lines.find((line) => pattern.test(line)) : lines[0] + if (!version || Buffer.byteLength(version) > 512) { + throw new Error('Runtime build tool did not report a bounded version line') + } + return version +} + +async function locateExecutable(command, { xcrun = false } = {}) { + if (isAbsolute(command)) { + return realpath(command) + } + const locator = xcrun + ? ['xcrun', ['--find', command]] + : process.platform === 'win32' + ? ['where.exe', [command]] + : ['which', [command]] + const result = await capture(...locator) + const path = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + if (!path || !isAbsolute(path)) { + throw new Error(`Runtime build tool executable could not be resolved: ${command}`) + } + return realpath(path) +} + +async function executableRecord({ + command, + args, + versionCommand, + versionArgs, + versionPattern, + xcrun = false +}) { + const path = await locateExecutable(command, { xcrun }) + const result = await capture(versionCommand ?? path, versionArgs ?? args) + return { + version: selectSshRelayRuntimeToolVersion(result, versionPattern), + sha256: await sha256File(path) + } +} + +async function packageTreeRecord(name) { + const parsed = require(`${name}/package.json`) + const root = dirname(require.resolve(`${name}/package.json`)) + const files = [] + let totalBytes = 0 + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + for (const entry of entries) { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + await visit(path) + } else if (entry.isFile()) { + files.push(path) + totalBytes += (await stat(path)).size + } else { + throw new Error(`Runtime build package contains a special entry: ${name}`) + } + if (files.length > MAX_PACKAGE_FILES) { + throw new Error(`Runtime build package exceeds the file-count bound: ${name}`) + } + if (totalBytes > MAX_PACKAGE_BYTES) { + throw new Error(`Runtime build package exceeds the byte bound: ${name}`) + } + } + } + await visit(root) + const hash = createHash('sha256') + for (const path of files) { + hash.update(relative(root, path).replaceAll('\\', '/')) + hash.update('\0') + hash.update(await sha256File(path)) + hash.update('\n') + } + return { version: parsed.version, sha256: `sha256:${hash.digest('hex')}`, files: files.length } +} + +export function sshRelayRuntimeBuilderIdentity({ gitCommit, env = process.env }) { + if (env.GITHUB_ACTIONS !== 'true') { + return `local://${process.platform}/${process.arch}` + } + if ( + !/^[0-9a-f]{40}$/.test(gitCommit) || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY ?? '') + ) { + throw new Error('GitHub runtime builder identity is incomplete') + } + const workflow = (env.GITHUB_WORKFLOW_REF ?? '').split('@')[0] + const prefix = `${env.GITHUB_REPOSITORY}/` + if ( + !workflow.startsWith(prefix) || + workflow.slice(prefix.length) !== '.github/workflows/ssh-relay-runtime-artifacts.yml' + ) { + throw new Error('GitHub runtime builder workflow identity is malformed') + } + return `https://github.com/${env.GITHUB_REPOSITORY}/blob/${gitCommit}/${workflow.slice(prefix.length)}` +} + +export function sshRelayRuntimeRunnerIdentity({ env = process.env } = {}) { + const github = env.GITHUB_ACTIONS === 'true' + const identity = { + os: env.RUNNER_OS ?? process.platform, + architecture: env.RUNNER_ARCH ?? process.arch, + environment: env.RUNNER_ENVIRONMENT ?? (github ? undefined : 'local'), + requestedLabel: env.ORCA_RUNTIME_REQUESTED_RUNNER ?? (github ? undefined : 'local'), + image: { + os: env.ImageOS ?? (github ? undefined : process.platform), + version: env.ImageVersion ?? (github ? undefined : 'local') + } + } + if ( + !identity.os || + !identity.architecture || + !identity.environment || + !identity.requestedLabel || + !identity.image.os || + !identity.image.version + ) { + throw new Error('Runtime runner identity is incomplete') + } + return identity +} + +export function assertSshRelayRuntimeToolchain(toolchain, tuple) { + const common = ['buildNode', 'bundledNode', 'compiler', 'buildSystem', 'python', 'archive'] + const expected = tuple.startsWith('win32-') + ? [...common, 'linker', 'nodeAddonApi', 'nodeGyp'] + : [...common, 'nodeAddonApi', 'nodeGyp', 'strip'] + if (!isDeepStrictEqual(Object.keys(toolchain).sort(), expected.sort())) { + throw new Error(`Runtime toolchain record is incomplete for ${tuple}`) + } + for (const [name, record] of Object.entries(toolchain)) { + const keys = Object.keys(record).sort() + const expectedKeys = + record.files === undefined ? ['sha256', 'version'] : ['files', 'sha256', 'version'] + if ( + !isDeepStrictEqual(keys, expectedKeys) || + typeof record.version !== 'string' || + record.version.length === 0 || + Buffer.byteLength(record.version) > 512 || + record.version.includes('\n') || + !/^sha256:[0-9a-f]{64}$/.test(record.sha256) || + (record.files !== undefined && (!Number.isSafeInteger(record.files) || record.files <= 0)) + ) { + throw new Error(`Runtime toolchain record is malformed: ${name}`) + } + } +} + +export async function collectSshRelayRuntimeToolchain(nodePath) { + const common = { + bundledNode: await executableRecord({ command: nodePath, args: ['--version'] }), + buildNode: await executableRecord({ command: process.execPath, args: ['--version'] }), + nodeGyp: await packageTreeRecord('node-gyp'), + nodeAddonApi: await packageTreeRecord('node-addon-api') + } + if (process.platform === 'win32') { + const toolchain = { + ...common, + compiler: await executableRecord({ + command: 'cl.exe', + args: [], + versionPattern: /Compiler Version/i + }), + linker: await executableRecord({ + command: 'link.exe', + args: [], + versionPattern: /Linker Version/i + }), + buildSystem: await executableRecord({ + command: 'msbuild.exe', + args: ['-version', '-nologo'] + }), + python: await executableRecord({ command: 'python.exe', args: ['--version'] }), + archive: await packageTreeRecord('yazl') + } + assertSshRelayRuntimeToolchain(toolchain, 'win32-x64') + return toolchain + } + const toolchain = { + ...common, + compiler: await executableRecord({ + command: process.platform === 'darwin' ? 'clang++' : 'c++', + args: ['--version'], + xcrun: process.platform === 'darwin' + }), + buildSystem: await executableRecord({ command: 'make', args: ['--version'] }), + python: await executableRecord({ command: 'python3', args: ['--version'] }), + archive: await executableRecord({ command: 'xz', args: ['--version'] }), + strip: await executableRecord({ + command: 'strip', + args: [], + versionCommand: process.platform === 'darwin' ? 'xcodebuild' : undefined, + versionArgs: process.platform === 'darwin' ? ['-version'] : undefined, + xcrun: process.platform === 'darwin' + }) + } + assertSshRelayRuntimeToolchain(toolchain, `${process.platform}-x64`) + return toolchain +} diff --git a/config/scripts/ssh-relay-runtime-toolchain.test.mjs b/config/scripts/ssh-relay-runtime-toolchain.test.mjs new file mode 100644 index 00000000000..eb6795eea09 --- /dev/null +++ b/config/scripts/ssh-relay-runtime-toolchain.test.mjs @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { + assertSshRelayRuntimeToolchain, + collectSshRelayRuntimeToolchain, + selectSshRelayRuntimeToolVersion, + sshRelayRuntimeBuilderIdentity, + sshRelayRuntimeRunnerIdentity +} from './ssh-relay-runtime-toolchain.mjs' + +const commit = 'a'.repeat(40) + +describe('SSH relay runtime build provenance', () => { + it('selects the real Windows compiler version instead of its stdout usage line', () => { + expect( + selectSshRelayRuntimeToolVersion( + { + stdout: 'usage: cl [ option... ] filename... [ /link linkoption... ]', + stderr: [ + 'Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35228 for ARM64', + 'Copyright (C) Microsoft Corporation.' + ].join('\r\n') + }, + /Compiler Version/i + ) + ).toBe('Microsoft (R) C/C++ Optimizing Compiler Version 19.44.35228 for ARM64') + }) + + it('pins GitHub builder identity to the exact source commit', () => { + expect( + sshRelayRuntimeBuilderIdentity({ + gitCommit: commit, + env: { + GITHUB_ACTIONS: 'true', + GITHUB_REPOSITORY: 'stablyai/orca', + GITHUB_WORKFLOW_REF: + 'stablyai/orca/.github/workflows/ssh-relay-runtime-artifacts.yml@refs/pull/8741/merge' + } + }) + ).toBe( + `https://github.com/stablyai/orca/blob/${commit}/.github/workflows/ssh-relay-runtime-artifacts.yml` + ) + expect(() => + sshRelayRuntimeBuilderIdentity({ + gitCommit: commit, + env: { + GITHUB_ACTIONS: 'true', + GITHUB_REPOSITORY: 'stablyai/orca', + GITHUB_WORKFLOW_REF: 'other/repo/.github/workflows/untrusted.yml@refs/heads/main' + } + }) + ).toThrow(/workflow identity/i) + }) + + it('requires the resolved runner label, architecture, environment, and image identity', () => { + expect( + sshRelayRuntimeRunnerIdentity({ + env: { + GITHUB_ACTIONS: 'true', + RUNNER_OS: 'Windows', + RUNNER_ARCH: 'ARM64', + RUNNER_ENVIRONMENT: 'github-hosted', + ORCA_RUNTIME_REQUESTED_RUNNER: 'windows-11-arm', + ImageOS: 'win11-arm64', + ImageVersion: '20260706.102.1' + } + }) + ).toEqual({ + os: 'Windows', + architecture: 'ARM64', + environment: 'github-hosted', + requestedLabel: 'windows-11-arm', + image: { os: 'win11-arm64', version: '20260706.102.1' } + }) + expect(() => + sshRelayRuntimeRunnerIdentity({ + env: { + GITHUB_ACTIONS: 'true', + RUNNER_OS: 'Windows', + RUNNER_ARCH: 'ARM64' + } + }) + ).toThrow(/runner identity/i) + }) + + it('records bounded native tool versions and SHA-256 executable or code digests', async () => { + const toolchain = await collectSshRelayRuntimeToolchain(process.execPath) + const tuple = process.platform === 'win32' ? 'win32-x64' : 'linux-x64-glibc' + expect(() => assertSshRelayRuntimeToolchain(toolchain, tuple)).not.toThrow() + expect(toolchain).toMatchObject({ + bundledNode: { version: process.version }, + buildNode: { version: process.version }, + nodeGyp: { version: expect.any(String) }, + nodeAddonApi: { version: expect.any(String) }, + compiler: { version: expect.any(String) }, + buildSystem: { version: expect.any(String) }, + python: { version: expect.any(String) }, + archive: { version: expect.any(String) } + }) + for (const record of Object.values(toolchain)) { + expect(record.sha256).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(Buffer.byteLength(record.version)).toBeGreaterThan(0) + expect(Buffer.byteLength(record.version)).toBeLessThanOrEqual(512) + } + expect(() => + assertSshRelayRuntimeToolchain( + { ...toolchain, compiler: { version: toolchain.compiler.version } }, + tuple + ) + ).toThrow(/compiler/i) + }) +}) diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index 1815d229a8e..91c351c51fb 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -28,6 +28,7 @@ describe('SSH relay runtime artifact workflow', () => { expect(posixJob['timeout-minutes']).toBe(20) expect(windowsJob['timeout-minutes']).toBe(30) for (const job of [posixJob, windowsJob]) { + expect(job.env.ORCA_RUNTIME_REQUESTED_RUNNER).toBe('${{ matrix.runner }}') expect(job.steps[0].with.ref).toBe('${{ github.event.pull_request.head.sha || github.sha }}') for (const step of job.steps.filter((candidate) => candidate.uses)) { expect(step.uses).toMatch(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+@[0-9a-f]{40}$/) @@ -62,6 +63,9 @@ describe('SSH relay runtime artifact workflow', () => { expect(source).toContain('ssh-relay-runtime-reproducibility.mjs') expect(source).toContain('ssh-relay-runtime-reproducibility.test.mjs') expect(source.match(/ssh-relay-runtime-closure\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-sbom\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-provenance\.test\.mjs/g)).toHaveLength(4) + expect(source.match(/ssh-relay-runtime-toolchain\.test\.mjs/g)).toHaveLength(4) expect( source.match(/node --check config\/scripts\/ssh-relay-runtime-closure\.mjs/g) ).toHaveLength(2)