OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / release-image-manifest-check.test.js

251 lines · javascript ref:
import test from 'brittle'
import crypto from 'node:crypto'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

const AMD64_DIGEST = 'sha256:' + 'a'.repeat(64)
const ARM64_DIGEST = 'sha256:' + 'c'.repeat(64)
const DUPLICATE_AMD64_DIGEST = 'sha256:' + 'd'.repeat(64)
const AMD64_ATTESTATION_DIGEST = 'sha256:' + 'e'.repeat(64)
const ARM64_ATTESTATION_DIGEST = 'sha256:' + 'f'.repeat(64)

function runCheck (argv, options = {}) {
  return new Promise((resolve, reject) => {
    execFile(process.execPath, ['scripts/check-release-image-manifest.mjs', ...argv], {
      cwd: process.cwd(),
      timeout: 10000,
      env: options.env || process.env
    }, (err, stdout, stderr) => {
      if (err) {
        err.stdout = stdout
        err.stderr = stderr
        reject(err)
      } else {
        resolve({ stdout, stderr })
      }
    })
  })
}

test('release image manifest checker writes platform evidence for multi-arch indexes', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const outFile = path.join(dir, 'release-image-manifest-evidence.json')
  const manifest = indexManifest()
  manifest.manifests.push(
    attestationManifest(AMD64_DIGEST, AMD64_ATTESTATION_DIGEST),
    attestationManifest(ARM64_DIGEST, ARM64_ATTESTATION_DIGEST)
  )
  const raw = JSON.stringify(manifest, null, 2) + '\n'
  const imageRef = imageRefFor(raw)
  await writeFile(rawFile, raw)

  await runCheck(['--image', imageRef, '--raw', rawFile, '--out', outFile])

  const body = JSON.parse(await readFile(outFile, 'utf8'))
  t.is(body.schemaVersion, 1)
  t.is(body.kind, 'release-image-manifest')
  t.is(body.status, 'verified')
  t.is(body.image.name, 'ghcr.io/bigdestiny2/p2p-hiverelay')
  t.is(body.image.tag, '9.9.9')
  t.is(body.image.digest, digestFor(raw))
  t.alike(body.requiredPlatforms, ['linux/amd64', 'linux/arm64'])
  t.alike(body.platforms.map((platform) => `${platform.os}/${platform.architecture}`), ['linux/amd64', 'linux/arm64'])
  t.is(body.manifest.manifestCount, 4)
})

test('release image manifest checker rejects missing required platforms', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const outFile = path.join(dir, 'release-image-manifest-evidence.json')
  const manifest = indexManifest()
  manifest.manifests = manifest.manifests.filter((entry) => entry.platform.architecture !== 'arm64')
  const raw = JSON.stringify(manifest)
  await writeFile(rawFile, raw)

  let err = null
  try {
    await runCheck(['--image', imageRefFor(raw), '--raw', rawFile, '--out', outFile])
  } catch (e) {
    err = e
  }

  t.ok(err)
  t.ok(err.stderr.includes('missing required platform linux/arm64'))
})

test('release image manifest checker rejects duplicate platform entries', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const outFile = path.join(dir, 'release-image-manifest-evidence.json')
  const manifest = indexManifest()
  manifest.manifests.push({
    mediaType: 'application/vnd.oci.image.manifest.v1+json',
    digest: DUPLICATE_AMD64_DIGEST,
    platform: { os: 'linux', architecture: 'amd64' }
  })
  const raw = JSON.stringify(manifest)
  await writeFile(rawFile, raw)

  let err = null
  try {
    await runCheck(['--image', imageRefFor(raw), '--raw', rawFile, '--out', outFile])
  } catch (e) {
    err = e
  }

  t.ok(err)
  t.ok(err.stderr.includes('release image manifest has duplicate platform linux/amd64'))
})

test('release image manifest checker rejects mutable or wrong-repository image refs', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const raw = JSON.stringify(indexManifest())
  await writeFile(rawFile, raw)
  const digest = digestFor(raw)

  for (const [image, message] of [
    ['ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9', 'requires a GHCR semver tag plus sha256 digest image ref'],
    [`ghcr.io/attacker/p2p-hiverelay:9.9.9@${digest}`, 'release image name must be ghcr.io/bigdestiny2/p2p-hiverelay']
  ]) {
    let err = null
    try {
      await runCheck(['--image', image, '--raw', rawFile, '--out', path.join(dir, 'out.json')])
    } catch (e) {
      err = e
    }

    t.ok(err, image)
    t.ok(err.stderr.includes(message), image)
  }
})

test('release image manifest checker rejects symlinked raw fixtures and unsafe raw values', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const symlinkFile = path.join(dir, 'link.json')
  const raw = JSON.stringify(indexManifest())
  await writeFile(rawFile, raw)
  await symlink(rawFile, symlinkFile)

  let symlinkErr = null
  try {
    await runCheck(['--image', imageRefFor(raw), '--raw', symlinkFile, '--out', path.join(dir, 'out.json')])
  } catch (e) {
    symlinkErr = e
  }

  t.ok(symlinkErr)
  t.ok(symlinkErr.stderr.includes('raw manifest file must not be a symlink'))

  const unsafeFile = path.join(dir, 'unsafe.json')
  const unsafeRaw = JSON.stringify(indexManifest()).replace(AMD64_DIGEST, 'Bearer abcdefghijklmnopqrstuvwxyz')
  await writeFile(unsafeFile, unsafeRaw)

  let unsafeErr = null
  try {
    await runCheck(['--image', imageRefFor(unsafeRaw), '--raw', unsafeFile, '--out', path.join(dir, 'unsafe-out.json')])
  } catch (e) {
    unsafeErr = e
  }

  t.ok(unsafeErr)
  t.ok(unsafeErr.stderr.includes('bearer token'))
})

test('release image manifest checker rejects raw bytes that do not match the claimed index digest', async (t) => {
  const dir = await fixtureDir(t)
  const rawFile = path.join(dir, 'raw.json')
  const raw = JSON.stringify(indexManifest())
  await writeFile(rawFile, raw)

  let err = null
  try {
    await runCheck([
      '--image', `ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@sha256:${'b'.repeat(64)}`,
      '--raw', rawFile,
      '--out', path.join(dir, 'out.json')
    ])
  } catch (e) {
    err = e
  }

  t.ok(err)
  t.ok(err.stderr.includes('does not match the exact raw index bytes'))
  t.ok(err.stderr.includes(`computed ${digestFor(raw)}`))
})

test('release image manifest checker redacts Buffer stderr from docker inspection failures', async (t) => {
  const dir = await fixtureDir(t)
  const docker = path.join(dir, 'docker')
  const secret = 'Bearer abcdefghijklmnopqrstuvwxyz'
  await writeFile(docker, `#!/bin/sh\nprintf '%s\\n' '${secret}' >&2\nexit 1\n`, { mode: 0o755 })

  let err = null
  try {
    await runCheck([
      '--image', `ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@sha256:${'b'.repeat(64)}`,
      '--out', path.join(dir, 'out.json')
    ], {
      env: { ...process.env, PATH: `${dir}:${process.env.PATH || ''}` }
    })
  } catch (e) {
    err = e
  }

  t.ok(err)
  t.ok(err.stderr.includes('[redacted bearer token]'))
  t.absent(err.stderr.includes(secret))
})

async function fixtureDir (t) {
  const dir = await mkdtemp(path.join(tmpdir(), 'hiverelay-image-manifest-'))
  t.teardown(async () => {
    await rm(dir, { recursive: true, force: true })
  })
  return dir
}

function indexManifest () {
  return {
    schemaVersion: 2,
    mediaType: 'application/vnd.oci.image.index.v1+json',
    manifests: [
      {
        mediaType: 'application/vnd.oci.image.manifest.v1+json',
        digest: AMD64_DIGEST,
        platform: { os: 'linux', architecture: 'amd64' }
      },
      {
        mediaType: 'application/vnd.oci.image.manifest.v1+json',
        digest: ARM64_DIGEST,
        platform: { os: 'linux', architecture: 'arm64', variant: 'v8' }
      }
    ]
  }
}

function attestationManifest (imageDigest, attestationDigest) {
  return {
    mediaType: 'application/vnd.oci.image.manifest.v1+json',
    digest: attestationDigest,
    size: 565,
    annotations: {
      'vnd.docker.reference.digest': imageDigest,
      'vnd.docker.reference.type': 'attestation-manifest'
    },
    platform: { os: 'unknown', architecture: 'unknown' }
  }
}

function digestFor (raw) {
  return `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`
}

function imageRefFor (raw) {
  return `ghcr.io/bigdestiny2/p2p-hiverelay:9.9.9@${digestFor(raw)}`
}