Ctrl K
ComunidadGuías…

src/release-attestation.js

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

Archivo original ↗Preguntar a KiBot
/**
 * D9 source-OTA attestation.
 *
 * `release-attestation.json` is a release asset generated by the release
 * workflow. It binds one tag to the exact commit and tree that produced the
 * published assets, plus the SHA-256 of every asset. The file is signed with
 * the same cosign keyless identity as the assets themselves
 * (`release-attestation.json.sigstore.json`).
 *
 * The dashboard `/self-update` path and `update.sh` both call
 * `verifyReleaseAttestation()` before they touch Git. Policy:
 *
 *   - an attestation that is present must name this tag and this commit.
 *     A mismatch is refused in every mode;
 *   - a present signature bundle must verify with the pinned release identity.
 *     A bad signature is refused in every mode;
 *   - missing material (no attestation, no bundle, no cosign binary, or a
 *     network failure) is refused only in `required` mode. The default `warn`
 *     mode logs the gap and continues, so an existing install without cosign
 *     keeps updating.
 *
 * The module is shared by `src/dashboard/api.js` and the two CLI scripts under
 * `scripts/`; it has no dependency outside `node:*`.
 */

import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
  createReadStream,
  mkdtempSync,
  readFileSync,
  rmSync,
  statSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

export const ATTESTATION_FILENAME = 'release-attestation.json';
export const ATTESTATION_BUNDLE_FILENAME = `${ATTESTATION_FILENAME}.sigstore.json`;

/** The certificate identity is the workflow ref GitHub's OIDC token carries. */
export const DEFAULT_ATTESTATION_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';
export const DEFAULT_ATTESTATION_REPOSITORY = 'dwgx/WindsurfAPI';

/** Release-asset name -> build-tree path, used by the release job generator. */
export const DEFAULT_RELEASE_ASSETS = Object.freeze({
  'SHA256SUMS': 'SHA256SUMS',
  'windsurfapi-macos-arm64': 'dist-macos/windsurfapi-macos-arm64',
  'windsurfapi-macos.zip': 'windsurfapi-macos.zip',
  'windsurfapi-windows.zip': 'dist-windows/windsurfapi-windows.zip',
  'windsurfapi.exe': 'dist-windows/windsurfapi.exe',
});

export const ATTESTATION_MODES = Object.freeze(['warn', 'required']);

export class ReleaseAttestationError extends Error {
  constructor(code, message) {
    super(message);
    this.name = 'ReleaseAttestationError';
    this.code = code;
  }
}

export function isReleaseAttestationError(err) {
  const code = err?.code;
  return typeof code === 'string'
    && (code.startsWith('ERR_RELEASE_ATTESTATION_') || code === 'ERR_COSIGN_UNAVAILABLE');
}

export function normalizeAttestationMode(value) {
  const mode = String(value ?? '').trim().toLowerCase();
  if (!mode) return 'warn';
  if (!ATTESTATION_MODES.includes(mode)) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_MODE',
      `WINDSURFAPI_UPDATE_ATTESTATION must be "warn" or "required", got "${value}"`,
    );
  }
  return mode;
}

function validObjectId(value) {
  return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(value || ''));
}

function validStableTag(value) {
  return /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/.test(String(value || ''));
}

function validAssetName(name) {
  return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
    && name !== '.'
    && name !== '..'
    && name !== '__proto__'
    && name !== 'constructor'
    && name !== 'prototype';
}

/**
 * Validate and normalize an attestation object or JSON text. Unknown fields are
 * ignored; the fields that decide an update are required and type-checked.
 */
export function parseReleaseAttestation(input) {
  let value;
  if (typeof input === 'string') {
    try {
      value = JSON.parse(input);
    } catch (err) {
      throw new ReleaseAttestationError(
        'ERR_RELEASE_ATTESTATION_INVALID',
        `release attestation is not valid JSON: ${err.message}`,
      );
    }
  } else {
    value = input;
  }
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation must be a JSON object');
  }
  if (value.schema !== 1) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_INVALID',
      `unsupported release attestation schema: ${JSON.stringify(value.schema)}`,
    );
  }

  const tag = String(value.tag ?? '');
  if (!validStableTag(tag)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation tag is not a stable vX.Y.Z: ${tag || '(missing)'}`);
  }
  const commit = String(value.commit ?? '').toLowerCase();
  if (!validObjectId(commit)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation commit is not a 40/64-hex object id: ${commit || '(missing)'}`);
  }
  const tree = String(value.tree ?? '').toLowerCase();
  if (!validObjectId(tree)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation tree is not a 40/64-hex object id: ${tree || '(missing)'}`);
  }

  const repository = value.repository === undefined ? '' : String(value.repository);
  if (repository && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation repository is not owner/name: ${repository}`);
  }

  if (!value.assets || typeof value.assets !== 'object' || Array.isArray(value.assets)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation assets must be an object');
  }
  const names = Object.keys(value.assets);
  if (names.length === 0) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'release attestation assets must not be empty');
  }
  const assets = {};
  for (const name of names.sort()) {
    if (!validAssetName(name)) {
      throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation asset name is unsafe: ${name}`);
    }
    const digest = String(value.assets[name] ?? '').toLowerCase();
    if (!/^[0-9a-f]{64}$/.test(digest)) {
      throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `release attestation asset ${name} is not a SHA-256: ${digest || '(missing)'}`);
    }
    assets[name] = digest;
  }

  return { schema: 1, repository, tag, commit, tree, assets };
}

/** Deterministic serialization: fixed field order and sorted asset names. */
export function serializeReleaseAttestation(attestation) {
  const parsed = parseReleaseAttestation(attestation);
  const assets = {};
  for (const name of Object.keys(parsed.assets).sort()) assets[name] = parsed.assets[name];
  return `${JSON.stringify({
    schema: 1,
    repository: parsed.repository,
    tag: parsed.tag,
    commit: parsed.commit,
    tree: parsed.tree,
    assets,
  }, null, 2)}\n`;
}

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

/**
 * Build an attestation for a checkout. `assets` maps release-asset name to a
 * path relative to `root`; the default map is the layout of the release job.
 */
export async function buildReleaseAttestation({
  tag,
  commit,
  tree,
  root = '.',
  assets = DEFAULT_RELEASE_ASSETS,
  repository = DEFAULT_ATTESTATION_REPOSITORY,
} = {}) {
  if (!assets || typeof assets !== 'object' || Array.isArray(assets)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', 'assets must be a name -> path object');
  }
  const digests = {};
  for (const name of Object.keys(assets).sort()) {
    digests[name] = await sha256File(resolve(root, assets[name]));
  }
  return parseReleaseAttestation({
    schema: 1,
    repository,
    tag,
    commit,
    tree,
    assets: digests,
  });
}

/** Extract owner/name from the GitHub remote forms Git accepts. */
export function parseGitHubRepository(originUrl) {
  const value = String(originUrl || '').trim();
  let match = /^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(value);
  if (!match) {
    match = /^(?:ssh:\/\/)?git@github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i.exec(value);
  }
  return match ? `${match[1]}/${match[2]}` : '';
}

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

async function fetchText(url, fetchImpl) {
  const fetchFn = fetchImpl || globalThis.fetch;
  if (typeof fetchFn !== 'function') {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
      'no fetch implementation is available to download the release attestation',
    );
  }
  let response;
  try {
    const init = {
      redirect: 'follow',
      headers: { 'user-agent': 'windsurfapi-release-attestation' },
    };
    if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
      init.signal = AbortSignal.timeout(15_000);
    }
    response = await fetchFn(url, init);
  } catch (err) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
      `could not fetch ${url}: ${err?.message || err}`,
    );
  }
  if (response.status === 404) return null;
  if (!response.ok) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_UNAVAILABLE',
      `could not fetch ${url}: HTTP ${response.status}`,
    );
  }
  return response.text();
}

/**
 * Load attestation text from a pre-staged directory, or from the release
 * assets of `repository` on GitHub. Returns null when the attestation is
 * absent (a 404 / missing file), and throws when it exists but cannot be read.
 */
export async function loadAttestationMaterial({ tag, dir = '', repository = '', fetchImpl } = {}) {
  if (dir) {
    const directory = resolve(dir);
    const attestationPath = join(directory, ATTESTATION_FILENAME);
    if (!isFile(attestationPath)) return null;
    const bundlePath = join(directory, ATTESTATION_BUNDLE_FILENAME);
    return {
      text: readFileSync(attestationPath, 'utf8'),
      bundleText: isFile(bundlePath) ? readFileSync(bundlePath, 'utf8') : '',
      dir: directory,
    };
  }
  if (!repository) return null;

  const base = `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}`;
  const text = await fetchText(`${base}/${ATTESTATION_FILENAME}`, fetchImpl);
  if (text === null) return null;
  const bundleText = await fetchText(`${base}/${ATTESTATION_BUNDLE_FILENAME}`, fetchImpl);
  return { text, bundleText: bundleText || '', dir: '' };
}

export function buildCosignVerifyArgs({ bundlePath, blobPath, identity, issuer }) {
  return [
    'verify-blob',
    '--bundle',
    bundlePath,
    '--certificate-identity-regexp',
    identity,
    '--certificate-oidc-issuer',
    issuer,
    blobPath,
  ];
}

export function runCosignVerifyBlob({
  cosign = process.env.COSIGN || 'cosign',
  bundlePath,
  blobPath,
  identity = DEFAULT_ATTESTATION_IDENTITY_REGEXP,
  issuer = DEFAULT_OIDC_ISSUER,
  spawn = spawnSync,
} = {}) {
  const args = buildCosignVerifyArgs({ bundlePath, blobPath, identity, issuer });
  const result = spawn(cosign, args, { encoding: 'utf8', timeout: 60_000 });
  if (result.error) {
    if (result.error.code === 'ENOENT') {
      throw new ReleaseAttestationError(
        'ERR_COSIGN_UNAVAILABLE',
        `cosign not found (${cosign}); install it from https://docs.sigstore.dev or pass --cosign <path>`,
      );
    }
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_INVALID',
      `failed to run cosign: ${result.error.message}`,
    );
  }
  if (result.status !== 0) {
    const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_INVALID',
      `cosign verification failed for ${blobPath}${detail ? `: ${detail}` : ''}`,
    );
  }
}

export function assertAttestationMatches(attestation, { tag, commit, repository = '' } = {}) {
  if (attestation.tag !== tag) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_MISMATCH',
      `release attestation tag ${attestation.tag} does not match selected tag ${tag}`,
    );
  }
  if (attestation.commit !== String(commit || '').toLowerCase()) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_MISMATCH',
      `release attestation commit ${attestation.commit} does not match selected commit ${String(commit || '').toLowerCase()}`,
    );
  }
  if (repository && attestation.repository && attestation.repository.toLowerCase() !== repository.toLowerCase()) {
    throw new ReleaseAttestationError(
      'ERR_RELEASE_ATTESTATION_MISMATCH',
      `release attestation repository ${attestation.repository} does not match ${repository}`,
    );
  }
  return attestation;
}

/**
 * Verify the release attestation for one (tag, commit) pair.
 *
 * Returns {status:'verified'|'missing'|'unsigned'|'cosign-unavailable'|'unavailable'}.
 * Throws ReleaseAttestationError on mismatch/invalid material, and on any
 * missing material when mode === 'required'.
 */
export async function verifyReleaseAttestation(options = {}) {
  const {
    tag,
    commit,
    mode = 'warn',
    dir = '',
    repository = '',
    fetchImpl,
    cosign = process.env.COSIGN || 'cosign',
    identity = DEFAULT_ATTESTATION_IDENTITY_REGEXP,
    issuer = DEFAULT_OIDC_ISSUER,
    runCosign,
    log = () => {},
    tempRoot,
  } = options;

  const normalizedMode = normalizeAttestationMode(mode);
  const normalizedTag = String(tag || '');
  if (!validStableTag(normalizedTag)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `selected tag is not a stable vX.Y.Z: ${normalizedTag || '(missing)'}`);
  }
  const normalizedCommit = String(commit || '').toLowerCase();
  if (!validObjectId(normalizedCommit)) {
    throw new ReleaseAttestationError('ERR_RELEASE_ATTESTATION_INVALID', `selected commit is not a 40/64-hex object id: ${normalizedCommit || '(missing)'}`);
  }

  let material;
  try {
    material = await loadAttestationMaterial({ tag: normalizedTag, dir, repository, fetchImpl });
  } catch (err) {
    if (err?.code === 'ERR_RELEASE_ATTESTATION_UNAVAILABLE' && normalizedMode === 'warn') {
      const warning = `release attestation unavailable for ${normalizedTag}: ${err.message}`;
      log(warning);
      return { status: 'unavailable', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, warning };
    }
    throw err;
  }

  if (!material) {
    if (normalizedMode === 'required') {
      throw new ReleaseAttestationError(
        'ERR_RELEASE_ATTESTATION_MISSING',
        `no release attestation is available for ${normalizedTag}; WINDSURFAPI_UPDATE_ATTESTATION=required refuses an unverified update`,
      );
    }
    const warning = `no release attestation is available for ${normalizedTag}; continuing because WINDSURFAPI_UPDATE_ATTESTATION=warn`;
    log(warning);
    return { status: 'missing', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, warning };
  }

  const attestation = parseReleaseAttestation(material.text);
  assertAttestationMatches(attestation, {
    tag: normalizedTag,
    commit: normalizedCommit,
    repository,
  });

  if (!material.bundleText) {
    if (normalizedMode === 'required') {
      throw new ReleaseAttestationError(
        'ERR_RELEASE_ATTESTATION_UNSIGNED',
        `release attestation for ${normalizedTag} has no .sigstore.json bundle; required mode refuses an unverified statement`,
      );
    }
    const warning = `release attestation for ${normalizedTag} has no signature bundle; commit binding checked but the signature was not verified`;
    log(warning);
    return { status: 'unsigned', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, attestation, warning };
  }

  let workDir = material.dir;
  let cleanup = null;
  if (!workDir) {
    workDir = mkdtempSync(join(tempRoot || tmpdir(), 'windsurfapi-attestation-'));
    writeFileSync(join(workDir, ATTESTATION_FILENAME), material.text, 'utf8');
    writeFileSync(join(workDir, ATTESTATION_BUNDLE_FILENAME), material.bundleText, 'utf8');
    cleanup = () => rmSync(workDir, { recursive: true, force: true });
  }

  try {
    const call = {
      cosign,
      bundlePath: join(workDir, ATTESTATION_BUNDLE_FILENAME),
      blobPath: join(workDir, ATTESTATION_FILENAME),
      identity,
      issuer,
    };
    if (runCosign) await runCosign(call);
    else runCosignVerifyBlob(call);
  } catch (err) {
    if (err?.code === 'ERR_COSIGN_UNAVAILABLE' && normalizedMode === 'warn') {
      const warning = `cosign is unavailable; the release attestation signature was not verified: ${err.message}`;
      log(warning);
      return {
        status: 'cosign-unavailable',
        mode: normalizedMode,
        tag: normalizedTag,
        commit: normalizedCommit,
        attestation,
        warning,
      };
    }
    throw err;
  } finally {
    if (cleanup) cleanup();
  }

  log(`verified release attestation for ${normalizedTag} at ${normalizedCommit}`);
  return { status: 'verified', mode: normalizedMode, tag: normalizedTag, commit: normalizedCommit, attestation };
}