Ctrl K
ComunidadGuías…

scripts/verify-release-assets.mjs

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Verify a downloaded WindsurfAPI release against its cosign keyless signatures.
//
// The release workflow signs five blobs — the four published binaries/zips and
// SHA256SUMS — and publishes three artifacts per blob:
//
//   <name>.sigstore.json   Sigstore bundle (signature + Fulcio certificate +
//                          Rekor inclusion proof), the only artifact the
//                          release workflow publishes since v3.10.0
//
// The bundle is the durable proof. Fulcio certificates live for minutes, so a
// detached certificate only verifies while it is still inside its validity
// window; the bundle's signed Rekor entry is what lets cosign verify the same
// bytes years later. This helper therefore verifies bundles only.
//
// It never downloads anything: point it at a directory that already contains
// the release assets. cosign must be installed (https://docs.sigstore.dev).
//
// Usage:
//   node scripts/verify-release-assets.mjs [dir]
//   node scripts/verify-release-assets.mjs --dir downloads --cosign /usr/local/bin/cosign
//
// Exit codes: 0 verified, 1 verification failed, 2 usage/environment error.

import { createHash } from 'node:crypto';
import { createReadStream, readFileSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

/** The four assets the release `files:` block publishes, by release-asset name. */
export const RELEASE_ASSETS = Object.freeze([
  'windsurfapi.exe',
  'windsurfapi-windows.zip',
  'windsurfapi-macos-arm64',
  'windsurfapi-macos.zip',
]);

export const MANIFEST_NAME = 'SHA256SUMS';
export const SIGNATURE_SUFFIXES = Object.freeze(['.sigstore.json']);

// The certificate identity is the workflow ref that GitHub's OIDC token
// carries for a tag push. Keep this in sync with the workflow path; the
// workflow test asserts both.
export const DEFAULT_IDENTITY_REGEXP =
  String.raw`^https://github\.com/dwgx/WindsurfAPI/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$`;
export const DEFAULT_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';

class CliError extends Error {
  constructor(message, exitCode = 1) {
    super(message);
    this.name = 'CliError';
    this.exitCode = exitCode;
  }
}

function isFile(path) {
  try {
    return statSync(path).isFile();
  } catch {
    return false;
  }
}

export function sha256File(path) {
  return new Promise((resolvePromise, reject) => {
    const hash = createHash('sha256');
    createReadStream(path)
      .on('error', reject)
      .on('data', (chunk) => hash.update(chunk))
      .on('end', () => resolvePromise(hash.digest('hex')));
  });
}

/**
 * Parse `sha256sum` output into a name -> digest map.
 *
 * The manifest is flat by contract: release assets have no directories, and
 * accepting `../` or absolute names here would let a manifest read outside the
 * download directory. Reject both instead of silently normalising.
 */
export function parseManifest(text) {
  const entries = new Map();
  const lines = text.split(/\r?\n/);
  for (let index = 0; index < lines.length; index += 1) {
    const raw = lines[index];
    const line = raw.trim();
    if (!line) continue;
    const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line);
    if (!match) {
      throw new CliError(`SHA256SUMS line ${index + 1} is not "<64 hex> <name>": ${raw}`);
    }
    const [, digest, name] = match;
    if (
      name !== name.trim()
      || name === '.'
      || name === '..'
      || name.startsWith('/')
      || name.includes('/')
      || name.includes('\\')
      || name.includes('\0')
      || name.includes(':')
    ) {
      throw new CliError(`SHA256SUMS line ${index + 1} has an unsafe or non-flat name: ${name}`);
    }
    if (entries.has(name)) {
      throw new CliError(`SHA256SUMS lists ${name} more than once`);
    }
    entries.set(name, digest);
  }
  return entries;
}

/** Require the manifest to cover exactly the published assets and match their bytes. */
export async function verifyManifest(entries, dir) {
  const expected = new Set(RELEASE_ASSETS);
  for (const name of entries.keys()) {
    if (!expected.has(name)) {
      throw new CliError(`SHA256SUMS names a file this release does not publish: ${name}`);
    }
  }
  for (const name of expected) {
    if (!entries.has(name)) {
      throw new CliError(`SHA256SUMS is missing ${name}`);
    }
  }
  for (const [name, digest] of entries) {
    const actual = await sha256File(join(dir, name));
    if (actual !== digest) {
      throw new CliError(`SHA-256 mismatch for ${name}: manifest ${digest}, file ${actual}`);
    }
  }
}

export function runCosignCommand(cosign, args) {
  const result = spawnSync(cosign, args, { encoding: 'utf8' });
  if (result.error) {
    if (result.error.code === 'ENOENT') {
      throw new CliError(
        `cosign not found (${cosign}); install it from https://docs.sigstore.dev or pass --cosign <path>`,
        2,
      );
    }
    throw new CliError(`failed to run cosign: ${result.error.message}`);
  }
  if (result.status !== 0) {
    const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
    const blob = args[args.length - 1];
    throw new CliError(`cosign verification failed for ${blob}${detail ? `:\n${detail}` : ''}`);
  }
}

function verifyArgs(dir, name, identity, issuer) {
  return [
    'verify-blob',
    '--bundle',
    join(dir, `${name}.sigstore.json`),
    '--certificate-identity-regexp',
    identity,
    '--certificate-oidc-issuer',
    issuer,
    join(dir, name),
  ];
}

/**
 * Verify every signature bundle and the signed manifest hashes.
 *
 * `runCosign` is injectable so the tests can exercise ordering and failure
 * handling without a cosign binary; production callers use the default.
 */
export async function verifyDirectory(dir, options = {}) {
  const runCosign = options.runCosign ?? runCosignCommand;
  const cosign = options.cosign ?? process.env.COSIGN ?? 'cosign';
  const identity = options.identity ?? DEFAULT_IDENTITY_REGEXP;
  const issuer = options.issuer ?? DEFAULT_OIDC_ISSUER;
  const log = options.log ?? (() => {});
  const signed = [...RELEASE_ASSETS, MANIFEST_NAME];

  const missing = [];
  for (const name of signed) {
    if (!isFile(join(dir, name))) missing.push(name);
    for (const suffix of SIGNATURE_SUFFIXES) {
      if (!isFile(join(dir, `${name}${suffix}`))) missing.push(`${name}${suffix}`);
    }
  }
  if (missing.length > 0) {
    throw new CliError(`missing release files in ${dir}: ${missing.join(', ')}`);
  }

  // Trust anchor first: the manifest's own signature. Everything after this
  // point relies on bytes that signature covers.
  await runCosign(cosign, verifyArgs(dir, MANIFEST_NAME, identity, issuer));
  log(`verified signature: ${MANIFEST_NAME}`);

  const entries = parseManifest(readFileSync(join(dir, MANIFEST_NAME), 'utf8'));
  await verifyManifest(entries, dir);
  log(`verified hashes: ${entries.size} manifest entries`);

  for (const name of RELEASE_ASSETS) {
    await runCosign(cosign, verifyArgs(dir, name, identity, issuer));
    log(`verified signature: ${name}`);
  }

  return { signed, manifestEntries: entries.size };
}

function usage() {
  return `Verify a downloaded WindsurfAPI release against its cosign signatures.

Usage:
  node scripts/verify-release-assets.mjs [options] [dir]

Arguments:
  dir                        directory holding the downloaded assets (default: .)

Options:
  --dir <dir>                same as the positional argument
  --cosign <path>            cosign binary (default: $COSIGN, then "cosign")
  --identity <regexp>        certificate identity regexp
  --tag <vX.Y.Z>             pin the exact release tag (builds the identity regexp)
                             (default: ${DEFAULT_IDENTITY_REGEXP})
  --issuer <url>             OIDC issuer (default: ${DEFAULT_OIDC_ISSUER})
  -h, --help                 show this help

The directory must contain ${[...RELEASE_ASSETS, MANIFEST_NAME].join(', ')}
plus .sig, .pem and .sigstore.json for each. Only the .sigstore.json bundles
are cryptographically verified; .sig and .pem are published for detached-
signature consumers and expire with the Fulcio certificate. Exit codes:
0 verified, 1 verification failed, 2 usage/environment error.`;
}

export async function main(argv) {
  const options = { dir: process.cwd() };
  let positional = null;

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === '-h' || arg === '--help') {
      console.log(usage());
      return 0;
    }
    if (arg === '--dir' || arg === '--cosign' || arg === '--identity' || arg === '--issuer' || arg === '--tag') {
      const value = argv[index + 1];
      if (value === undefined || value === '') {
        throw new CliError(`${arg} needs a value`, 2);
      }
      index += 1;
      if (arg === '--dir') options.dir = value;
      if (arg === '--cosign') options.cosign = value;
      if (arg === '--identity') options.identity = value;
      if (arg === '--issuer') options.issuer = value;
      if (arg === '--tag') options.tag = value;
      continue;
    }
    if (arg.startsWith('-')) {
      throw new CliError(`unknown option: ${arg}`, 2);
    }
    if (positional !== null) {
      throw new CliError('only one download directory may be given', 2);
    }
    positional = arg;
  }
  if (positional !== null) options.dir = positional;

  options.dir = resolve(options.dir);
  if (options.tag) {
    const tag = options.tag.startsWith('v') ? options.tag : `v${options.tag}`;
    if (!/^v[0-9]+\.[0-9]+\.[0-9]+$/.test(tag)) {
      throw new CliError(`--tag must be vX.Y.Z, got ${options.tag}`, 2);
    }
    const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    options.identity = `^https://github\\.com/dwgx/WindsurfAPI/\\.github/workflows/release\\.yml@refs/tags/${escaped}$`;
  }
  const result = await verifyDirectory(options.dir, {
    cosign: options.cosign,
    identity: options.identity,
    issuer: options.issuer,
    log: (message) => console.log(message),
  });
  console.log(
    `Verified ${result.signed.length} signed files and ${result.manifestEntries} manifest entries in ${options.dir}`,
  );
  return 0;
}

const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
if (import.meta.url === invokedPath) {
  main(process.argv.slice(2))
    .then((code) => {
      // Set exitCode instead of calling process.exit so piped stdout is not
      // truncated before it drains.
      process.exitCode = code;
    })
    .catch((err) => {
      console.error(`error: ${err.message}`);
      process.exitCode = err instanceof CliError ? err.exitCode : 1;
    });
}