OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / check-release-image-manifest.mjs

356 lines · javascript ref:
#!/usr/bin/env node

import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { spawnSync } from 'node:child_process'

const usage = `
Usage:
  node scripts/check-release-image-manifest.mjs --image ghcr.io/bigdestiny2/p2p-hiverelay:X.Y.Z@sha256:<digest> --out release-image-manifest-evidence.json
  node scripts/check-release-image-manifest.mjs --image ... --raw imagetools-raw.json --out release-image-manifest-evidence.json

Verifies that a pushed release image digest is a multi-platform manifest with
linux/amd64 and linux/arm64 entries, then writes public-safe evidence.
`

const EXPECTED_IMAGE_NAME = 'ghcr.io/bigdestiny2/p2p-hiverelay'
const MAX_RAW_MANIFEST_BYTES = 2 * 1024 * 1024
const REQUIRED_PLATFORMS = Object.freeze(['linux/amd64', 'linux/arm64'])
const DIGEST_PINNED_IMAGE_REF_PATTERN = /^(ghcr\.io\/[a-z0-9_.-]+\/[a-z0-9._/-]+):(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)@(sha256:[a-f0-9]{64})$/
const INDEX_MEDIA_TYPES = new Set([
  'application/vnd.docker.distribution.manifest.list.v2+json',
  'application/vnd.oci.image.index.v1+json'
])

const FORBIDDEN_PUBLIC_VALUE_PATTERNS = [
  [/-----BEGIN [A-Z ]*(?:PRIVATE|SECRET) KEY-----/, 'private key block'],
  [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/, 'GitHub token'],
  [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/, 'GitHub token'],
  [/\bAuthorization\s*:\s*/i, 'authorization header'],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i, 'bearer token'],
  [/\bAPP_SEED=[^\s'"]+/i, 'APP_SEED'],
  [/\bHIVERELAY_API_KEY=[^\s'"]+/i, 'API key'],
  [/\bsk-[A-Za-z0-9_-]{20,}\b/, 'API key']
]

const args = parseArgs(process.argv.slice(2))
if (args.help) {
  console.log(usage.trim())
  process.exit(0)
}

const image = parseImageRef(args.image || '')
const raw = args.raw ? readRawManifest(args.raw) : inspectImageRaw(args.image)
const manifest = parseRawJson(raw, 'image manifest')
const evidence = buildEvidence(image, manifest, raw)
validateEvidence(evidence)
writeJson(path.resolve(args.out || 'release-image-manifest-evidence.json'), evidence)
console.log(`Release image manifest evidence written for ${evidence.image.ref}`)

function parseArgs (argv) {
  const out = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      out.help = true
      continue
    }
    if (arg === '--image') {
      out.image = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--raw') {
      out.raw = readValue(argv, ++i, arg)
      continue
    }
    if (arg === '--out') {
      out.out = readValue(argv, ++i, arg)
      continue
    }
    die(`Unknown argument: ${arg}`)
  }
  return out
}

function readValue (argv, index, flag) {
  const value = argv[index]
  if (!value || value.startsWith('--')) die(`Missing value for ${flag}`)
  return value
}

function parseImageRef (value) {
  const match = DIGEST_PINNED_IMAGE_REF_PATTERN.exec(String(value || ''))
  if (!match) {
    die('release image manifest check requires a GHCR semver tag plus sha256 digest image ref')
  }
  const image = {
    ref: value,
    name: match[1],
    tag: match[2],
    digest: match[3]
  }
  if (image.name !== EXPECTED_IMAGE_NAME) {
    die(`release image name must be ${EXPECTED_IMAGE_NAME}; got ${image.name}`)
  }
  return image
}

function readRawManifest (file) {
  const resolved = path.resolve(file)
  const stat = fs.lstatSync(resolved)
  if (stat.isSymbolicLink()) die(`raw manifest file must not be a symlink: ${resolved}`)
  if (!stat.isFile()) die(`raw manifest file must be a regular file: ${resolved}`)
  if (stat.size > MAX_RAW_MANIFEST_BYTES) {
    die(`raw manifest file must be ${MAX_RAW_MANIFEST_BYTES} bytes or smaller: ${resolved} is ${stat.size} bytes`)
  }
  return fs.readFileSync(resolved, 'utf8')
}

function inspectImageRaw (imageRef) {
  const result = spawnSync('docker', ['buildx', 'imagetools', 'inspect', '--raw', imageRef], {
    encoding: null,
    maxBuffer: MAX_RAW_MANIFEST_BYTES
  })
  if (result.status !== 0) {
    const message = redactSensitiveOutput(result.stderr || result.stdout || `docker exited ${result.status}`)
    die(`docker buildx imagetools inspect failed: ${message}`)
  }
  return result.stdout
}

function parseRawJson (value, label) {
  assertRawDoesNotContainSecrets(value, `${label} raw JSON`)
  try {
    return JSON.parse(value.toString('utf8'))
  } catch (err) {
    die(`${label} must be JSON: ${err.message}`)
  }
}

function buildEvidence (image, manifest, raw) {
  const rawDigest = `sha256:${crypto.createHash('sha256').update(raw).digest('hex')}`
  if (rawDigest !== image.digest) {
    die(`release image digest does not match the exact raw index bytes: claimed ${image.digest}, computed ${rawDigest}`)
  }
  if (!INDEX_MEDIA_TYPES.has(manifest?.mediaType)) {
    die(`release image manifest must be a Docker manifest list or OCI image index; got ${JSON.stringify(manifest?.mediaType)}`)
  }
  if (!Array.isArray(manifest.manifests) || manifest.manifests.length === 0) {
    die('release image manifest must include platform manifests')
  }

  const platforms = []
  for (const entry of manifest.manifests) {
    if (isAttestationManifest(entry)) continue
    const platform = entry?.platform || {}
    const os = String(platform.os || '')
    const architecture = String(platform.architecture || '')
    const variant = String(platform.variant || '')
    const digest = String(entry?.digest || '')
    if (!os || !architecture) continue
    if (!/^sha256:[a-f0-9]{64}$/i.test(digest)) {
      die(`release image platform ${os}/${architecture} has malformed digest ${JSON.stringify(digest)}`)
    }
    platforms.push({ os, architecture, variant, digest })
  }

  const labels = new Set()
  for (const platform of platforms) {
    const label = platformLabel(platform)
    if (labels.has(label)) die(`release image manifest has duplicate platform ${label}`)
    labels.add(label)
  }
  for (const required of REQUIRED_PLATFORMS) {
    if (!labels.has(required)) die(`release image manifest is missing required platform ${required}`)
  }

  platforms.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b)))

  return {
    schemaVersion: 1,
    generatedAt: new Date().toISOString(),
    kind: 'release-image-manifest',
    status: 'verified',
    image: {
      name: image.name,
      tag: image.tag,
      digest: image.digest,
      ref: image.ref
    },
    requiredPlatforms: [...REQUIRED_PLATFORMS],
    platforms,
    manifest: {
      mediaType: manifest.mediaType,
      manifestCount: manifest.manifests.length
    }
  }
}

function validateEvidence (body) {
  assertPublicSafeValues(body, 'release image manifest evidence')
  assertEvidenceSchema(body)
  requireEqual('release image manifest evidence schemaVersion', body.schemaVersion, 1)
  requireEqual('release image manifest evidence kind', body.kind, 'release-image-manifest')
  requireEqual('release image manifest evidence status', body.status, 'verified')
  requirePattern('release image manifest evidence generatedAt', body.generatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
  requireEqual('release image manifest image name', body.image.name, EXPECTED_IMAGE_NAME)
  requirePattern('release image manifest image tag', body.image.tag, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/)
  requirePattern('release image manifest image digest', body.image.digest, /^sha256:[a-f0-9]{64}$/i)
  requireEqual('release image manifest image ref', body.image.ref, `${body.image.name}:${body.image.tag}@${body.image.digest}`)
  requireArrayEqual('release image manifest required platforms', body.requiredPlatforms, REQUIRED_PLATFORMS)
  if (!Array.isArray(body.platforms) || body.platforms.length < REQUIRED_PLATFORMS.length) {
    die('release image manifest evidence must include platform entries')
  }
  const labels = new Set()
  for (const platform of body.platforms) {
    requirePattern('release image manifest platform os', platform.os, /^[a-z0-9._-]{1,32}$/)
    requirePattern('release image manifest platform architecture', platform.architecture, /^[a-z0-9._-]{1,32}$/)
    if (platform.variant) requirePattern('release image manifest platform variant', platform.variant, /^[A-Za-z0-9._-]{1,32}$/)
    requirePattern('release image manifest platform digest', platform.digest, /^sha256:[a-f0-9]{64}$/i)
    const label = platformLabel(platform)
    if (labels.has(label)) die(`release image manifest evidence has duplicate platform ${label}`)
    labels.add(label)
  }
  for (const required of REQUIRED_PLATFORMS) {
    if (!labels.has(required)) die(`release image manifest evidence is missing required platform ${required}`)
  }
  requireOneOf('release image manifest media type', body.manifest.mediaType, [...INDEX_MEDIA_TYPES])
  if (!Number.isSafeInteger(body.manifest.manifestCount) || body.manifest.manifestCount < body.platforms.length) {
    die('release image manifest manifestCount is malformed')
  }
}

function assertEvidenceSchema (body) {
  requireOnlyKeys('release image manifest evidence', body, [
    'schemaVersion',
    'generatedAt',
    'kind',
    'status',
    'image',
    'requiredPlatforms',
    'platforms',
    'manifest'
  ])
  requireOnlyKeys('release image manifest image', body.image, ['name', 'tag', 'digest', 'ref'])
  if (Array.isArray(body.platforms)) {
    for (const platform of body.platforms) {
      requireOnlyKeys(`release image manifest platform ${platform?.os || ''}/${platform?.architecture || ''}`, platform, [
        'os',
        'architecture',
        'variant',
        'digest'
      ])
    }
  }
  requireOnlyKeys('release image manifest manifest', body.manifest, ['mediaType', 'manifestCount'])
}

function isAttestationManifest (entry) {
  return entry?.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest'
}

function platformLabel (platform) {
  return `${platform.os}/${platform.architecture}`
}

function writeJson (file, body) {
  fs.mkdirSync(path.dirname(file), { recursive: true })
  const tmp = `${file}.tmp-${process.pid}`
  fs.writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n')
  fs.renameSync(tmp, file)
}

function requireEqual (label, actual, expected) {
  if (actual === expected) return
  die(`${label} must be ${JSON.stringify(expected)}; got ${JSON.stringify(actual)}`)
}

function requirePattern (label, value, pattern) {
  if (typeof value === 'string' && pattern.test(value)) return
  die(`${label} is required and malformed; got ${JSON.stringify(value)}`)
}

function requireOneOf (label, value, allowed) {
  if (allowed.includes(value)) return
  die(`${label} must be ${allowed.join(' or ')}; got ${JSON.stringify(value)}`)
}

function requireArrayEqual (label, actual, expected) {
  if (Array.isArray(actual) && actual.length === expected.length && actual.every((item, i) => item === expected[i])) return
  die(`${label} must be ${JSON.stringify(expected)}; got ${JSON.stringify(actual)}`)
}

function requireOnlyKeys (label, value, allowed) {
  if (value == null || typeof value !== 'object' || Array.isArray(value)) {
    die(`${label} must be an object`)
  }
  const allowedSet = new Set(allowed)
  const extra = Object.keys(value).filter(key => !allowedSet.has(key))
  if (extra.length > 0) die(`${label} has unsupported fields: ${extra.join(', ')}`)
}

function assertPublicSafeValues (value, label) {
  visit(value, '$')

  function visit (node, at) {
    if (node == null) return
    if (typeof node === 'string') {
      assertPublicSafeString(node, label, at)
      return
    }
    if (Array.isArray(node)) {
      for (let i = 0; i < node.length; i++) visit(node[i], `${at}[${i}]`)
      return
    }
    if (typeof node === 'object') {
      for (const [key, child] of Object.entries(node)) visit(child, `${at}.${key}`)
    }
  }
}

function assertPublicSafeString (value, label, at) {
  if (hasControlChars(value)) die(`${label} must not contain control characters at ${at}`)
  for (const [pattern, name] of FORBIDDEN_PUBLIC_VALUE_PATTERNS) {
    if (pattern.test(value)) die(`${label} must not contain ${name} at ${at}`)
  }
  try {
    const url = new URL(value)
    if (url.username || url.password) die(`${label} must not expose URL credentials at ${at}`)
  } catch (_) {}
}

function assertRawDoesNotContainSecrets (value, label) {
  const string = value.toString('utf8')
  for (const [pattern, name] of FORBIDDEN_PUBLIC_VALUE_PATTERNS) {
    if (pattern.test(string)) die(`${label} must not contain ${name}`)
  }
}

function hasControlChars (value) {
  for (let i = 0; i < value.length; i++) {
    const code = value.charCodeAt(i)
    if (code <= 31 || code === 127) return true
  }
  return false
}

function redactSensitiveOutput (value) {
  const output = Buffer.isBuffer(value) ? value.toString('utf8') : String(value)
  return output
    .replace(/-----BEGIN [A-Z ]*(?:PRIVATE|SECRET) KEY-----[\s\S]*?-----END [A-Z ]*(?:PRIVATE|SECRET) KEY-----/g, '[redacted key block]')
    .replace(/\bAuthorization\s*:\s*[^\r\n]*/gi, '[redacted authorization header]')
    .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, '[redacted bearer token]')
    .replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/g, '[redacted GitHub token]')
    .replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '[redacted GitHub token]')
    .replace(/\bAPP_SEED=[^\s'"]+/gi, '[redacted APP_SEED]')
    .replace(/\bHIVERELAY_API_KEY=[^\s'"]+/gi, '[redacted HIVERELAY_API_KEY]')
    .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, '[redacted API key]')
    .replace(/(\bhttps?:\/\/)([^/\s:@]+):([^@\s/]+)@/gi, '$1[redacted]@')
}

function die (message) {
  console.error(message)
  process.exit(1)
}