diff --git a/docs/doctoring/release-publication-file-stability.md b/docs/doctoring/release-publication-file-stability.md index 1f7594cc1..1243f5c49 100644 --- a/docs/doctoring/release-publication-file-stability.md +++ b/docs/doctoring/release-publication-file-stability.md @@ -11,16 +11,17 @@ The protected publication workflow already constrains the release bundle to a fi `scripts/lib/stable-file-evidence.mjs` provides the release-materialization step with a bounded no-follow descriptor reader. The isolated `scripts/release-publication-receipt.mjs` carries the same small descriptor-read contract inline so the existing one-file handoff remains self-contained rather than silently depending on repository files that are absent in the publication job. The descriptor contract: 1. requires a positive reviewed byte ceiling and an available `O_NOFOLLOW`/read-only open contract; -2. rejects a pathname that is a symlink, non-regular file, empty file, or declared oversize before opening; -3. opens the exact pathname with `O_NOFOLLOW` and compares pathname metadata with the opened descriptor; -4. reads only through that descriptor, with a streaming `maximum + 1` ceiling instead of trusting the initial size alone; -5. revalidates descriptor device, inode, mode, size, modification time, change time, and observed byte count after the read; -6. re-resolves the pathname after the read and requires it still to identify the same regular-file device/inode/mode/size; and -7. closes the descriptor on success and failure. +2. requires every ancestor component to remain a real non-symlink directory before open, immediately after open, and again after the bounded descriptor read; +3. rejects a final pathname that is a symlink, non-regular file, empty file, or declared oversize before opening; +4. opens the exact pathname with `O_NOFOLLOW` and compares pathname metadata with the opened descriptor; +5. reads only through that descriptor, with a streaming `maximum + 1` ceiling instead of trusting the initial size alone; +6. revalidates descriptor device, inode, mode, size, modification time, change time, and observed byte count after the read; +7. re-resolves the final pathname after the read and requires it still to identify the same regular-file device/inode/mode/size; and +8. closes the descriptor on success and failure. -`release-evidence.mjs` now hashes the exact accepted source-archive and SBOM bytes instead of reopening their pathnames. Its manifest and `SHA256SUMS` outputs use an unpredictable owner-only temporary file followed by atomic rename. The output-directory real-directory check remains in place; this change does not claim immunity to a hostile replacement of an ancestor directory outside the reviewed GitHub-hosted-runner threat boundary. +`release-evidence.mjs` now hashes the exact accepted source-archive and SBOM bytes instead of reopening their pathnames. Its manifest and `SHA256SUMS` outputs use an unpredictable owner-only temporary file followed by atomic rename. The output-directory real-directory check remains in place. The reader now rejects symlinked ancestor paths as well as final-component symlinks; it does not claim immunity to an adversary capable of repeatedly replacing real ancestor directories between every metadata observation and filesystem operation. -The publication receipt retains one accepted snapshot of `release-evidence.json` for both semantic validation and release-asset hashing. It requires `--release-evidence` to identify the exact `release-evidence.json` inside the supplied release asset directory, preventing a separately parsed manifest from being combined with a different hashed asset. `SHA256SUMS` is parsed from the same retained bytes that were hashed into the local asset map. The receipt output likewise uses an unpredictable temporary file plus atomic rename. +The publication receipt retains one accepted snapshot of `release-evidence.json` for both semantic validation and release-asset hashing. It requires `--release-evidence` to identify the exact `release-evidence.json` inside the supplied release asset directory, preventing a separately parsed manifest from being combined with a different hashed asset. `SHA256SUMS` is parsed from the same retained bytes that were hashed into the local asset map. The receipt output likewise uses an unpredictable temporary file plus atomic rename. Its self-contained reader applies the same non-symlink ancestor requirement so a policy, verification response, evidence manifest, or release asset cannot be accepted through a symlinked parent merely because the final component is regular. The publication workflow deliberately copies only `release-publication-receipt.mjs` into the sterile handoff. The receipt therefore contains no relative module imports; its duplicate-decoded-JSON-key scanner, stable descriptor reader, and atomic output helper are intentionally self-contained boundary code. This avoids an acquisition-path defect in which tests run the script from the repository successfully but the isolated publication job cannot resolve an unshipped sibling module. @@ -28,13 +29,13 @@ The descriptor reader fails closed when a runtime cannot provide the no-follow f ## Verification strategy -`test/stable-release-file-evidence.test.ts` includes a real temporary-file/symlink case plus deterministic filesystem-adapter cases for path-to-descriptor replacement, in-place descriptor mutation, same-byte pathname replacement after reading, short reads, streamed oversize, unsupported open flags, non-files, empty files, and descriptor closure after failure. `test/release-evidence-file-stability.test.ts` binds release materialization to the stable reader and atomic evidence writer. `test/release-publication-output-atomicity.test.ts` requires temporary exclusive creation plus rename for the final receipt, and `test/release-publication-runtime-handoff.test.ts` verifies that the isolated one-file executable has no relative runtime dependency while the workflow authenticates that exact file across its handoffs. Existing immutable-release publication tests continue to exercise the complete receipt CLI, exact asset set, digest checks, malformed UTF-8 handling, duplicate decoded JSON keys, immutable-policy checks, and publication evidence contract. +`test/stable-release-file-evidence.test.ts` includes real final-component and parent-directory symlink cases plus deterministic filesystem-adapter cases for malformed/non-directory parent authority, path-to-descriptor replacement, in-place descriptor mutation, same-byte pathname replacement after reading, short reads, streamed oversize, unsupported open flags, non-files, empty files, and descriptor closure after failure. `test/release-evidence-file-stability.test.ts` binds release materialization to the stable reader and atomic evidence writer. `test/immutable-release-publication.test.ts` executes the complete self-contained publication CLI through a symlinked parent and requires it to fail closed. `test/release-publication-output-atomicity.test.ts` requires temporary exclusive creation plus rename for the final receipt, and `test/release-publication-runtime-handoff.test.ts` verifies that the isolated one-file executable has no relative runtime dependency while the workflow authenticates that exact file across its handoffs. Existing immutable-release publication tests continue to exercise the exact asset set, digest checks, malformed UTF-8 handling, duplicate decoded JSON keys, immutable-policy checks, and publication evidence contract. This change does **not** prove that a GitHub Release exists, that Cloudflare deployment succeeded, that production KPIs are healthy, or that legal/IP transfer rights are complete. It strengthens only the local evidence-consumption and publication boundary used when those external facts are eventually captured. ## Standards and implementation basis -POSIX.1-2024 specifies that `open()` with `O_NOFOLLOW` fails when the final pathname component is a symbolic link. Node.js exposes the corresponding filesystem constant and documents that it causes open to fail for a symbolic-link path. Those primitives remove the final-component symlink-follow step from the open operation; the additional descriptor/path identity checks are Noema's application-level control for replacement and mutation across the full read window. +POSIX.1-2024 specifies that `open()` with `O_NOFOLLOW` fails when the final pathname component is a symbolic link. Node.js exposes the corresponding filesystem constant and documents that it causes open to fail for a symbolic-link path. Those primitives remove the final-component symlink-follow step from the open operation; Noema separately validates ancestor components and descriptor/path identity because `O_NOFOLLOW` alone does not authenticate the parent traversal used to reach the final component. NIST SSDF 1.1 recommends defining, implementing, and verifying software security requirements throughout the development lifecycle. Noema treats release evidence byte identity and executable handoff closure as such requirements because publication receipts are later consumed as acquisition and supply-chain evidence. diff --git a/scripts/acquisition-data-room-integrity-audit.mjs b/scripts/acquisition-data-room-integrity-audit.mjs index b30ed6e4a..267fd083a 100644 --- a/scripts/acquisition-data-room-integrity-audit.mjs +++ b/scripts/acquisition-data-room-integrity-audit.mjs @@ -10,7 +10,8 @@ import { writeAcquisitionPrivateFile, } from "./lib/acquisition-private-output.mjs"; -const fullShaPattern = /^[0-9a-f]{40}$/i; +const fullShaPattern = /^[0-9a-f]{40}$/; +const releaseTagPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR || process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR @@ -24,14 +25,14 @@ let auditPath = join(outputDir, "data-room-integrity-audit.json"); /** Bind an optional caller expectation to the already authenticated checkout. */ function expectedSourceCommit(authenticatedHead) { - const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || "").trim(); + const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || ""); if (!supplied) { return authenticatedHead; } if (!fullShaPattern.test(supplied)) { - throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be a full commit SHA."); + throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be an exact lowercase full commit SHA."); } - if (supplied.toLowerCase() !== authenticatedHead) { + if (supplied !== authenticatedHead) { throw new Error("NOEMA_DATA_ROOM_SOURCE_COMMIT does not match the exact checked-out HEAD."); } return authenticatedHead; @@ -39,12 +40,12 @@ function expectedSourceCommit(authenticatedHead) { /** Resolve an optional immutable release selection through the local-only Git trust root. */ function expectedRelease() { - const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "").trim(); + const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || ""); if (!tag) { return { expectedReleaseTag: "", expectedReleaseCommitSha: "" }; } - if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) { - throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must be an immutable SemVer tag."); + if (!releaseTagPattern.test(tag)) { + throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must use exact canonical SemVer bytes."); } return { expectedReleaseTag: tag, diff --git a/scripts/acquisition-data-room-manifest-secure.mjs b/scripts/acquisition-data-room-manifest-secure.mjs index 02d69bf3f..fb3878b90 100644 --- a/scripts/acquisition-data-room-manifest-secure.mjs +++ b/scripts/acquisition-data-room-manifest-secure.mjs @@ -10,7 +10,8 @@ import { writeAcquisitionPrivateFile, } from "./lib/acquisition-private-output.mjs"; -const fullShaPattern = /^[0-9a-f]{40}$/i; +const fullShaPattern = /^[0-9a-f]{40}$/; +const releaseTagPattern = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const now = new Date().toISOString(); const configuredOutputDir = process.env.NOEMA_DATA_ROOM_OUTPUT_DIR || process.env.NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR @@ -19,14 +20,14 @@ const configuredManifestPath = process.env.NOEMA_DATA_ROOM_MANIFEST_PATH || ""; /** Bind an optional caller expectation to the already authenticated checkout. */ function resolveSourceCommit(authenticatedHead) { - const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || "").trim(); + const supplied = String(process.env.NOEMA_DATA_ROOM_SOURCE_COMMIT || ""); if (!supplied) { return authenticatedHead; } if (!fullShaPattern.test(supplied)) { - throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be a full commit SHA."); + throw new TypeError("NOEMA_DATA_ROOM_SOURCE_COMMIT must be an exact lowercase full commit SHA."); } - if (supplied.toLowerCase() !== authenticatedHead) { + if (supplied !== authenticatedHead) { throw new Error("NOEMA_DATA_ROOM_SOURCE_COMMIT does not match the exact checked-out HEAD."); } return authenticatedHead; @@ -34,12 +35,12 @@ function resolveSourceCommit(authenticatedHead) { /** Resolve the selected immutable release tag from the same local-only Git trust root. */ function resolveRelease() { - const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "").trim(); + const tag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || ""); if (!tag) { return { releaseTag: "", releaseCommitSha: "" }; } - if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag)) { - throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must be an immutable SemVer tag."); + if (!releaseTagPattern.test(tag)) { + throw new TypeError("NOEMA_RELEASE_UNDER_DILIGENCE_TAG must use exact canonical SemVer bytes."); } return { releaseTag: tag, diff --git a/scripts/acquisition-readiness-audit.mjs b/scripts/acquisition-readiness-audit.mjs index bfa494d84..f258cd791 100755 --- a/scripts/acquisition-readiness-audit.mjs +++ b/scripts/acquisition-readiness-audit.mjs @@ -29,7 +29,7 @@ const transferEvidencePath = process.env.NOEMA_TRANSFER_EVIDENCE_PATH || "artifacts/acquisition/transfer-evidence.json"; const releasePublicationReceiptPath = process.env.NOEMA_RELEASE_PUBLICATION_RECEIPT_PATH || "artifacts/acquisition/release-publication-receipt.json"; -const releaseUnderDiligenceTag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || "").trim(); +const releaseUnderDiligenceTag = String(process.env.NOEMA_RELEASE_UNDER_DILIGENCE_TAG || ""); const pilotLogPath = process.env.NOEMA_PILOT_LOG_PATH || "docs/pilot-readiness-log.md"; const saleableEvidencePath = process.env.NOEMA_SALEABLE_AUDIT_PATH diff --git a/scripts/lib/acquisition-private-output.mjs b/scripts/lib/acquisition-private-output.mjs index 869d0be6e..d0b81b316 100644 --- a/scripts/lib/acquisition-private-output.mjs +++ b/scripts/lib/acquisition-private-output.mjs @@ -56,6 +56,21 @@ function sameOutputIdentity(left, right) { ); } +function cleanupIdentityMatchedPath(path, expectedMetadata, fileSystem) { + if (!expectedMetadata || typeof fileSystem.unlinkSync !== "function") { + return; + } + try { + const cleanupCandidate = fileSystem.lstatSync(path, { throwIfNoEntry: false }) ?? null; + if (sameOutputIdentity(expectedMetadata, cleanupCandidate)) { + fileSystem.unlinkSync(path); + } + } catch { + // Preserve the original write/validation error. Cleanup authority is + // limited to the same inode; a replaced pathname is never unlinked. + } +} + /** * Refuse an acquisition output path when any existing parent component is a * symbolic link or a non-directory filesystem object. @@ -63,7 +78,7 @@ function sameOutputIdentity(left, right) { * The walk starts at the output leaf's parent and continues to the filesystem * root, so a missing intermediate directory does not hide an unsafe higher * ancestor. This boundary is intentionally checked before directory creation - * and again by the private writer immediately before opening the leaf. + * and again around the private writer's no-follow leaf opens. */ export function assertAcquisitionPrivatePathParents( path, @@ -89,16 +104,20 @@ export function assertAcquisitionPrivatePathParents( function writeNewPrivateFile(path, contents, fileSystem, flags) { const descriptor = fileSystem.openSync(path, flags, 0o600); + let createdMetadata = null; + let accepted = false; try { - const opened = fileSystem.fstatSync(descriptor); - if (!safeOutputMetadata(opened)) { + createdMetadata = fileSystem.fstatSync(descriptor); + if (!safeOutputMetadata(createdMetadata)) { throw new Error("acquisition output path changed before writing"); } + assertAcquisitionPrivatePathParents(path, fileSystem); fileSystem.fchmodSync(descriptor, 0o600); fileSystem.ftruncateSync(descriptor, 0); fileSystem.writeFileSync(descriptor, contents, { encoding: "utf8" }); const afterDescriptor = fileSystem.fstatSync(descriptor); + assertAcquisitionPrivatePathParents(path, fileSystem); const afterPath = fileSystem.lstatSync(path); if ( !safeOutputMetadata(afterDescriptor) @@ -107,8 +126,12 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { ) { throw new Error("acquisition output path changed while writing"); } + accepted = true; } finally { fileSystem.closeSync(descriptor); + if (!accepted) { + cleanupIdentityMatchedPath(path, createdMetadata, fileSystem); + } } } @@ -120,9 +143,12 @@ function writeNewPrivateFile(path, contents, fileSystem, flags) { * written completely to an owner-only, exclusive sibling file and atomically * renamed over the verified target only after the write succeeds, so a failed * replacement cannot truncate or partially overwrite trusted prior evidence. - * Newly created targets use O_EXCL directly. Existing parent components are - * required to be real directories, never symbolic links or non-directory - * objects, both before staging and immediately before replacement. + * Newly created targets use O_EXCL directly and remove their identity-matched + * leaf when a synchronous validation/write failure occurs. Existing parent + * components are required to be real directories, never symbolic links or + * non-directory objects, before and immediately after each leaf/staging open + * and again before a new file is accepted or an existing target is atomically + * replaced. */ export function writeAcquisitionPrivateFile( path, @@ -162,6 +188,7 @@ export function writeAcquisitionPrivateFile( const existingDescriptor = fileSystem.openSync(path, writeOnly | noFollow, 0o600); try { + assertAcquisitionPrivatePathParents(path, fileSystem); const opened = fileSystem.fstatSync(existingDescriptor); if (!safeOutputMetadata(opened) || !sameOutputIdentity(before, opened)) { throw new Error("acquisition output path changed before writing"); @@ -182,6 +209,7 @@ export function writeAcquisitionPrivateFile( ); staged = true; try { + assertAcquisitionPrivatePathParents(tempPath, fileSystem); stagedMetadata = fileSystem.fstatSync(stagedDescriptor); if (!safeOutputMetadata(stagedMetadata)) { throw new Error("acquisition staged output must remain a single-link regular file"); @@ -229,15 +257,7 @@ export function writeAcquisitionPrivateFile( } } finally { if (staged && stagedMetadata) { - try { - const cleanupCandidate = fileSystem.lstatSync(tempPath, { throwIfNoEntry: false }) ?? null; - if (sameOutputIdentity(stagedMetadata, cleanupCandidate)) { - fileSystem.unlinkSync(tempPath); - } - } catch { - // Preserve the original write/validation error. Cleanup authority is - // limited to the same staged inode; a replaced pathname is never unlinked. - } + cleanupIdentityMatchedPath(tempPath, stagedMetadata, fileSystem); } } } diff --git a/scripts/lib/release-sbom-authority.mjs b/scripts/lib/release-sbom-authority.mjs new file mode 100644 index 000000000..ac79d47c5 --- /dev/null +++ b/scripts/lib/release-sbom-authority.mjs @@ -0,0 +1,17 @@ +const unsafeBomRefCharacterPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/u; + +export function requireCanonicalReleaseBomRef(value, label) { + if ( + typeof value !== "string" + || value.length === 0 + || value !== value.trim() + || value !== value.normalize("NFC") + || unsafeBomRefCharacterPattern.test(value) + || value.startsWith("urn:cdx:") + ) { + throw new Error( + `${label} must be a canonical non-empty bom-ref identity in NFC without control, format, surrogate, non-ASCII Unicode separator, or BOM-Link prefix ambiguity`, + ); + } + return value; +} diff --git a/scripts/lib/stable-file-evidence.mjs b/scripts/lib/stable-file-evidence.mjs index 9de318f13..7d2577f64 100644 --- a/scripts/lib/stable-file-evidence.mjs +++ b/scripts/lib/stable-file-evidence.mjs @@ -6,6 +6,7 @@ import { openSync, readSync, } from "node:fs"; +import { dirname, normalize, parse, resolve } from "node:path"; const MAXIMUM_SIGNED_OPEN_FLAG = 0x7fff_ffff; const defaultFileSystem = Object.freeze({ @@ -38,6 +39,9 @@ function requireRegularMetadata(metadata, label, maximumBytes) { if (!metadata.isFile()) { fail(label, "must be a regular file"); } + if (!Number.isSafeInteger(metadata.nlink) || metadata.nlink !== 1) { + fail(label, "must be a single-link regular file"); + } if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) { fail(label, "has an invalid byte size"); } @@ -50,6 +54,28 @@ function requireRegularMetadata(metadata, label, maximumBytes) { return metadata; } +function requireParentDirectoryMetadata(metadata, label) { + if (!metadata || typeof metadata !== "object" || typeof metadata.isDirectory !== "function") { + fail(label, "parent directory metadata is unavailable"); + } + if (typeof metadata.isSymbolicLink === "function" && metadata.isSymbolicLink()) { + fail(label, "must not traverse symbolic-link parent directories"); + } + if (!metadata.isDirectory()) { + fail(label, "parent path must be a real directory"); + } +} + +function assertNoSymlinkedParentDirectories(path, label, fileSystem) { + const absolutePath = resolve(path); + let current = dirname(absolutePath); + const root = parse(current).root; + while (current !== root) { + requireParentDirectoryMetadata(fileSystem.lstatSync(current), label); + current = dirname(current); + } +} + function sameIdentity(left, right) { return left.dev === right.dev && left.ino === right.ino @@ -66,7 +92,17 @@ function sameStableDescriptor(left, right) { /** * Read one bounded regular file through a no-follow descriptor and accept the * bytes only while both descriptor state and the pathname-to-inode mapping stay - * stable for the complete read. + * stable for the complete read. The caller-supplied path must already be in + * lexical-canonical form so a raw `symlink/../file` lookup cannot diverge from + * the parent chain that is inspected with `path.resolve()`. Every ancestor + * directory is also required to be a real non-symlink directory before open, + * after open, and after the bounded descriptor read so a final-component + * O_NOFOLLOW check cannot be bypassed by a symlinked parent path. The accepted + * evidence inode must also have exactly one hard link so another pathname cannot + * mutate the same inode outside this canonical evidence path during or after + * validation. Pathname/descriptor comparisons include modification/change time + * so same-inode rewrites cannot cross either edge of the bounded read unnoticed + * merely by preserving size. * * @param {string} path filesystem path to read * @param {string} label bounded diagnostic label that never contains file bytes @@ -89,6 +125,9 @@ export function readStableRegularFile( if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) { fail(label, "requires a positive safe byte ceiling"); } + if (normalize(path) !== path) { + fail(label, "path must be a lexical-canonical path"); + } const noFollow = fileSystem.constants?.O_NOFOLLOW; const readOnly = fileSystem.constants?.O_RDONLY; @@ -99,6 +138,7 @@ export function readStableRegularFile( fail(label, "requires a supported read-only open flag"); } + assertNoSymlinkedParentDirectories(path, label, fileSystem); const pathMetadata = requireRegularMetadata( fileSystem.lstatSync(path), label, @@ -106,12 +146,13 @@ export function readStableRegularFile( ); const descriptor = fileSystem.openSync(path, readOnly | noFollow); try { + assertNoSymlinkedParentDirectories(path, label, fileSystem); const openedMetadata = requireRegularMetadata( fileSystem.fstatSync(descriptor), label, maximumBytes, ); - if (!sameIdentity(pathMetadata, openedMetadata)) { + if (!sameStableDescriptor(pathMetadata, openedMetadata)) { fail(label, "changed before read"); } @@ -143,12 +184,13 @@ export function readStableRegularFile( fail(label, "byte count differs from the opened descriptor size"); } + assertNoSymlinkedParentDirectories(path, label, fileSystem); const finalPathMetadata = requireRegularMetadata( fileSystem.lstatSync(path), label, maximumBytes, ); - if (!sameIdentity(openedMetadata, finalPathMetadata)) { + if (!sameStableDescriptor(openedMetadata, finalPathMetadata)) { fail(label, "pathname changed while being read"); } return Buffer.concat(chunks, totalBytes); diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs index 9ee27968b..03351be2e 100644 --- a/scripts/release-evidence.mjs +++ b/scripts/release-evidence.mjs @@ -7,7 +7,9 @@ import { statSync, } from "node:fs"; import { basename, resolve } from "node:path"; +import { gunzipSync } from "node:zlib"; import { assertAcquisitionPrivatePathParents } from "./lib/acquisition-private-output.mjs"; +import { requireCanonicalReleaseBomRef } from "./lib/release-sbom-authority.mjs"; import { readStableRegularFile } from "./lib/stable-file-evidence.mjs"; import { hasDuplicateJsonObjectKeys, @@ -18,10 +20,11 @@ const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const EXPECTED_SBOM_NAME = "noema.cdx.json"; const MAX_SBOM_BYTES = 16 * 1024 * 1024; const MAX_SOURCE_BYTES = 512 * 1024 * 1024; +const MAX_SBOM_NESTING_DEPTH = 128; const shaPattern = /^[0-9a-f]{40}$/; const versionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; const canonicalUtcTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; -const cycloneDxSerialNumberPattern = /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const cycloneDxSerialNumberPattern = /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; function fail(message) { throw new Error(message); @@ -60,7 +63,13 @@ function requireString(value, label) { if (typeof value !== "string" || value.trim().length === 0) { fail(`${label} must be a non-empty string`); } - return value.trim(); + return value; +} + +function preferExplicitEnvironment(name, fallback) { + return Object.prototype.hasOwnProperty.call(process.env, name) + ? process.env[name] + : fallback; } function readStableBytes(path, label, maximumBytes) { @@ -71,23 +80,37 @@ function readStableBytes(path, label, maximumBytes) { } } +function requireValidSourceGzip(bytes) { + try { + gunzipSync(bytes, { maxOutputLength: MAX_SOURCE_BYTES }); + } catch { + fail("source archive must be a valid gzip stream within the bounded expanded-size limit"); + } +} + function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function validateReleaseIdentity() { const repository = requireString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); - const commitShaSource = process.env.NOEMA_RELEASE_COMMIT_SHA || process.env.GITHUB_SHA; + const commitShaSource = preferExplicitEnvironment( + "NOEMA_RELEASE_COMMIT_SHA", + process.env.GITHUB_SHA, + ); const commitSha = requireString( commitShaSource, "release commit SHA", ); const ref = requireString( - process.env.NOEMA_RELEASE_REF || process.env.GITHUB_REF, + preferExplicitEnvironment("NOEMA_RELEASE_REF", process.env.GITHUB_REF), "release ref", ); const version = requireString(process.env.NOEMA_RELEASE_VERSION, "NOEMA_RELEASE_VERSION"); - const generatedAtSource = process.env.NOEMA_RELEASE_GENERATED_AT || new Date().toISOString(); + const generatedAtSource = preferExplicitEnvironment( + "NOEMA_RELEASE_GENERATED_AT", + new Date().toISOString(), + ); const generatedAt = requireString( generatedAtSource, "NOEMA_RELEASE_GENERATED_AT", @@ -124,34 +147,110 @@ function validateReleaseIdentity() { function validateUniqueBomRefs(value) { const seen = new Set(); - function visit(node) { + function visit(node, depth = 0) { + if (depth > MAX_SBOM_NESTING_DEPTH) { + fail("SBOM nesting depth exceeds supported maximum"); + } if (!node || typeof node !== "object") { return; } if (Array.isArray(node)) { for (const item of node) { - visit(item); + visit(item, depth + 1); } return; } if (Object.prototype.hasOwnProperty.call(node, "bom-ref")) { - const bomRef = node["bom-ref"]; - if (typeof bomRef !== "string" || bomRef.length === 0) { - fail("SBOM bom-ref values must be non-empty strings"); - } + const bomRef = requireCanonicalReleaseBomRef(node["bom-ref"], "SBOM bom-ref"); if (seen.has(bomRef)) { - fail(`SBOM bom-ref must be unique within the BOM: ${bomRef.slice(0, 200)}`); + fail("SBOM bom-ref must be unique within the BOM"); } seen.add(bomRef); } for (const child of Object.values(node)) { - visit(child); + visit(child, depth + 1); } } visit(value); + return seen; +} + +function collectComponentBomRefs(components) { + const refs = []; + const pending = [...components]; + while (pending.length > 0) { + const component = pending.pop(); + refs.push(component?.["bom-ref"]); + const nestedComponents = component?.components; + if (nestedComponents === undefined) { + continue; + } + if (!Array.isArray(nestedComponents)) { + fail("SBOM nested component components must be an array when present"); + } + pending.push(...nestedComponents); + } + return refs; +} + +function requireDeclaredBomRef(value, label, bomRefs) { + const bomRef = requireCanonicalReleaseBomRef(value, label); + if (!bomRefs.has(bomRef)) { + fail(`${label} must reference a declared bom-ref identity`); + } + return bomRef; +} + +function validateDependencyGraph(dependencies, bomRefs, requiredDependencyRefs) { + const seenDependencyRefs = new Set(); + + for (const dependency of dependencies) { + if (!dependency || typeof dependency !== "object" || Array.isArray(dependency)) { + fail("SBOM dependency entries must be objects"); + } + const dependencyRef = requireDeclaredBomRef( + dependency.ref, + "SBOM dependency ref", + bomRefs, + ); + if (seenDependencyRefs.has(dependencyRef)) { + fail("SBOM dependency ref must be unique"); + } + seenDependencyRefs.add(dependencyRef); + + if (dependency.dependsOn === undefined) { + continue; + } + if (!Array.isArray(dependency.dependsOn)) { + fail("SBOM dependency dependsOn must be an array when present"); + } + const seenTargets = new Set(); + for (const target of dependency.dependsOn) { + const targetRef = requireDeclaredBomRef( + target, + "SBOM dependency dependsOn target", + bomRefs, + ); + if (seenTargets.has(targetRef)) { + fail("SBOM dependency dependsOn target must be unique"); + } + seenTargets.add(targetRef); + } + } + + for (const requiredRef of requiredDependencyRefs) { + const declaredRef = requireDeclaredBomRef( + requiredRef, + "SBOM dependency graph component", + bomRefs, + ); + if (!seenDependencyRefs.has(declaredRef)) { + fail("SBOM dependency graph must include every declared component bom-ref"); + } + } } function validateSbom(sbom, version) { @@ -159,31 +258,31 @@ function validateSbom(sbom, version) { fail("SBOM must be a JSON object"); } if (sbom.bomFormat !== "CycloneDX") { - fail(`SBOM bomFormat must be CycloneDX, received ${String(sbom.bomFormat)}`); + fail("SBOM bomFormat must be CycloneDX"); } if (sbom.specVersion !== "1.5") { - fail(`SBOM specVersion must be 1.5, received ${String(sbom.specVersion)}`); + fail("SBOM specVersion must be 1.5"); } if (sbom.version !== 1) { - fail(`SBOM document version must be 1, received ${String(sbom.version)}`); + fail("SBOM document version must be 1"); } if (typeof sbom.serialNumber !== "string" || !cycloneDxSerialNumberPattern.test(sbom.serialNumber)) { fail("SBOM serialNumber must be a canonical RFC 4122 urn:uuid value"); } - validateUniqueBomRefs(sbom); + const bomRefs = validateUniqueBomRefs(sbom); const root = sbom.metadata?.component; if (!root || typeof root !== "object" || Array.isArray(root)) { fail("SBOM metadata.component is required"); } if (root.type !== "application") { - fail(`SBOM root component type must be application, received ${String(root.type)}`); + fail("SBOM root component type must be application"); } if (root.name !== "noema") { - fail(`SBOM root component name must be noema, received ${String(root.name)}`); + fail("SBOM root component name must be noema"); } if (root.version !== version) { - fail(`SBOM root component version must be ${version}, received ${String(root.version)}`); + fail(`SBOM root component version must match release version ${version}`); } if (typeof root["bom-ref"] !== "string" || root["bom-ref"].trim().length === 0) { fail("SBOM root component bom-ref is required"); @@ -194,6 +293,8 @@ function validateSbom(sbom, version) { if (!Array.isArray(sbom.dependencies)) { fail("SBOM dependencies must be an array"); } + const requiredDependencyRefs = collectComponentBomRefs([root, ...sbom.components]); + validateDependencyGraph(sbom.dependencies, bomRefs, requiredDependencyRefs); if (!sbom.dependencies.some((dependency) => dependency?.ref === root["bom-ref"])) { fail("SBOM dependencies must include the root component bom-ref"); } @@ -202,7 +303,7 @@ function validateSbom(sbom, version) { bomFormat: sbom.bomFormat, specVersion: sbom.specVersion, serialNumber: sbom.serialNumber, - componentCount: sbom.components.length, + componentCount: requiredDependencyRefs.length - 1, dependencyCount: sbom.dependencies.length, rootComponent: { type: root.type, @@ -227,6 +328,7 @@ function run() { } const sourceBytes = readStableBytes(sourcePath, "source archive", MAX_SOURCE_BYTES); + requireValidSourceGzip(sourceBytes); const sbomBytes = readStableBytes(sbomPath, "SBOM", MAX_SBOM_BYTES); let sbomText; try { @@ -244,7 +346,7 @@ function run() { if (error instanceof Error && error.message === "SBOM contains duplicate decoded JSON object keys") { throw error; } - fail(`SBOM is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + fail("SBOM is not valid JSON"); } const sbomSummary = validateSbom(sbom, identity.version); const manifestPath = resolve(outputDir, "release-evidence.json"); diff --git a/scripts/release-publication-receipt.mjs b/scripts/release-publication-receipt.mjs index 7163dd9fc..6783c2f8a 100644 --- a/scripts/release-publication-receipt.mjs +++ b/scripts/release-publication-receipt.mjs @@ -13,7 +13,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, join, parse, resolve } from "node:path"; const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema"; const MAX_JSON_BYTES = 16 * 1024 * 1024; @@ -23,6 +23,7 @@ const MAXIMUM_SIGNED_OPEN_FLAG = 0x7fff_ffff; const SHA_PATTERN = /^[0-9a-f]{40}$/; const DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/; const SEMVER_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?$/; +const WORKFLOW_RUN_URL_PATTERN = /^https:\/\/github\.com\/ContextualWisdomLab\/noema\/actions\/runs\/[1-9]\d*$/; const CANONICAL_UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const JSON_PRIMITIVE_PATTERN = @@ -61,6 +62,9 @@ function requireRegularMetadata(metadata, label, maximumBytes) { if (!metadata.isFile()) { fileFail(label, "must be a regular file"); } + if (!Number.isSafeInteger(metadata.nlink) || metadata.nlink !== 1) { + fileFail(label, "must be a single-link regular file"); + } if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) { fileFail(label, "has an invalid byte size"); } @@ -73,6 +77,21 @@ function requireRegularMetadata(metadata, label, maximumBytes) { return metadata; } +function assertNoSymlinkedParentDirectories(path, label, fileSystem) { + let current = dirname(resolve(path)); + const root = parse(current).root; + while (current !== root) { + const parent = fileSystem.lstatSync(current); + if (parent.isSymbolicLink()) { + fileFail(label, "must not traverse symbolic-link parent directories"); + } + if (!parent.isDirectory()) { + fileFail(label, "parent path must be a real directory"); + } + current = dirname(current); + } +} + function sameIdentity(left, right) { return left.dev === right.dev && left.ino === right.ino @@ -88,7 +107,8 @@ function sameStableDescriptor(left, right) { /** * Read one bounded regular file through a no-follow descriptor and accept the - * bytes only while descriptor state and pathname identity stay stable. + * bytes only while descriptor state, pathname identity, non-symlink parent + * traversal, and single-link inode authority stay stable. */ function readStableRegularFile( path, @@ -115,6 +135,7 @@ function readStableRegularFile( fileFail(label, "requires a supported read-only open flag"); } + assertNoSymlinkedParentDirectories(path, label, fileSystem); const pathMetadata = requireRegularMetadata( fileSystem.lstatSync(path), label, @@ -122,6 +143,7 @@ function readStableRegularFile( ); const descriptor = fileSystem.openSync(path, readOnly | noFollow); try { + assertNoSymlinkedParentDirectories(path, label, fileSystem); const openedMetadata = requireRegularMetadata( fileSystem.fstatSync(descriptor), label, @@ -159,6 +181,7 @@ function readStableRegularFile( fileFail(label, "byte count differs from the opened descriptor size"); } + assertNoSymlinkedParentDirectories(path, label, fileSystem); const finalPathMetadata = requireRegularMetadata( fileSystem.lstatSync(path), label, @@ -372,7 +395,13 @@ function requireString(value, label) { if (typeof value !== "string" || value.trim().length === 0) { fail(`${label} must be a non-empty string`); } - return value.trim(); + return value; +} + +function preferExplicitEnvironment(name, fallback) { + return Object.prototype.hasOwnProperty.call(process.env, name) + ? process.env[name] + : fallback; } function requireCanonicalUtcTimestamp(value, label) { @@ -438,11 +467,14 @@ function sha256(bytes) { function validateIdentity() { const repository = requireString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY"); const tag = requireString(process.env.NOEMA_RELEASE_TAG, "NOEMA_RELEASE_TAG"); - const rawCommitSha = process.env.NOEMA_RELEASE_COMMIT_SHA || process.env.GITHUB_SHA; + const rawCommitSha = preferExplicitEnvironment( + "NOEMA_RELEASE_COMMIT_SHA", + process.env.GITHUB_SHA, + ); const commitSha = requireString(rawCommitSha, "release commit SHA"); const version = requireString(process.env.NOEMA_RELEASE_VERSION, "NOEMA_RELEASE_VERSION"); const generatedAt = requireCanonicalUtcTimestamp( - process.env.NOEMA_RELEASE_GENERATED_AT || new Date().toISOString(), + preferExplicitEnvironment("NOEMA_RELEASE_GENERATED_AT", new Date().toISOString()), "NOEMA_RELEASE_GENERATED_AT", ); @@ -490,10 +522,16 @@ function validateChecksums(checksumsBytes, assetsByName) { } catch (error) { fail(`SHA256SUMS is not valid UTF-8: ${error instanceof Error ? error.message : String(error)}`); } - const lines = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = text.split(/\r?\n/); + if (lines.at(-1) === "") { + lines.pop(); + } + if ( + lines.length === 0 + || lines.some((line) => line.length === 0 || line !== line.trim()) + ) { + fail("SHA256SUMS must contain canonical non-empty lines without surrounding whitespace"); + } const expectedNames = new Set(["release-evidence.json", "noema.cdx.json"]); for (const [name] of assetsByName) { if (name.startsWith("noema-") && name.endsWith(".tar.gz")) { @@ -502,7 +540,7 @@ function validateChecksums(checksumsBytes, assetsByName) { } const found = new Set(); for (const line of lines) { - const match = /^([0-9A-Fa-f]{64})\s{2}([^/\\]+)$/.exec(line); + const match = /^([0-9A-Fa-f]{64}) {2}([^/\\]+)$/.exec(line); if (!match) { fail(`SHA256SUMS contains an invalid line: ${line}`); } @@ -513,6 +551,9 @@ function validateChecksums(checksumsBytes, assetsByName) { if (!expectedNames.has(name)) { fail(`SHA256SUMS contains an unexpected asset ${name}`); } + if (found.has(name)) { + fail(`SHA256SUMS contains a duplicate entry for ${name}`); + } const asset = assetsByName.get(name); if (!asset || asset.sha256 !== expectedDigest) { fail(`SHA256SUMS digest mismatch for ${name}`); @@ -563,8 +604,8 @@ function validateVerification(verification, expectedNames, identity) { verification.workflowRunUrl, "release verification workflowRunUrl", ); - if (!workflowRunUrl.startsWith(`https://github.com/${EXPECTED_REPOSITORY}/actions/runs/`)) { - fail("release verification workflowRunUrl must identify this repository's Actions run"); + if (!WORKFLOW_RUN_URL_PATTERN.test(workflowRunUrl)) { + fail("release verification workflowRunUrl must identify an exact Actions run"); } return { releaseVerified: true, diff --git a/src/index.ts b/src/index.ts index d5739cc70..85703f1c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -725,7 +725,7 @@ async function createRepositoryInstallationToken(request: Request, claims: JwtPa received_type: valueType(rawTargetRepository), }); } - const requestedRepository = (rawTargetRepository ?? claims.repository ?? "").trim(); + const requestedRepository = rawTargetRepository ?? claims.repository ?? ""; const repository = validateRepositoryName(requestedRepository, env); if (claims.repository !== repository && claims.repository !== env.ALLOWED_WORKFLOW_REPOSITORY) { throw new ApiError("ERR_REPO_NOT_ALLOWED", 403, "OIDC repository cannot request token for target_repository"); diff --git a/test/acquisition-data-room-authority-canonical.test.ts b/test/acquisition-data-room-authority-canonical.test.ts new file mode 100644 index 000000000..50a118e28 --- /dev/null +++ b/test/acquisition-data-room-authority-canonical.test.ts @@ -0,0 +1,147 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const manifestEntrypoint = fileURLToPath(new URL("../scripts/acquisition-data-room-manifest.mjs", import.meta.url)); +const integrityEntrypoint = fileURLToPath(new URL("../scripts/acquisition-data-room-integrity-audit.mjs", import.meta.url)); +const entrypoints = [ + ["manifest", manifestEntrypoint], + ["integrity", integrityEntrypoint], +] as const; + +function runGit(root: string, args: string[]) { + const result = spawnSync("git", args, { + cwd: root, + encoding: "utf8", + timeout: 10_000, + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function cleanTrackedRepository() { + const root = mkdtempSync(join(tmpdir(), "noema-data-room-authority-")); + writeFileSync(join(root, "README.md"), "committed evidence\n"); + runGit(root, ["init", "--quiet"]); + runGit(root, ["add", "README.md"]); + runGit(root, [ + "-c", + "user.name=Noema Tests", + "-c", + "user.email=noema-tests@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ]); + runGit(root, ["tag", "v1.2.3"]); + return { root, head: runGit(root, ["rev-parse", "HEAD"]) }; +} + +function runEntrypoint( + entrypoint: string, + root: string, + overrides: Record, +) { + const outputDirectory = join(root, "artifacts"); + return spawnSync(process.execPath, [entrypoint], { + cwd: root, + env: { + ...process.env, + NOEMA_DATA_ROOM_OUTPUT_DIR: outputDirectory, + NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDirectory, + NOEMA_DATA_ROOM_MANIFEST_PATH: join(outputDirectory, "data-room-manifest.json"), + NOEMA_DATA_ROOM_SOURCE_COMMIT: "", + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "", + ...overrides, + }, + encoding: "utf8", + timeout: 30_000, + }); +} + +function combinedOutput(result: ReturnType) { + return `${String(result.stdout || "")}\n${String(result.stderr || "")}`; +} + +describe("acquisition data-room authority canonicalization", () => { + it.each(entrypoints)("rejects surrounding whitespace around the exact source commit in %s", (_label, entrypoint) => { + const { root, head } = cleanTrackedRepository(); + try { + const result = runEntrypoint(entrypoint, root, { + NOEMA_DATA_ROOM_SOURCE_COMMIT: ` ${head}\t`, + }); + expect(result.status).not.toBe(0); + expect(combinedOutput(result)).toContain( + "NOEMA_DATA_ROOM_SOURCE_COMMIT must be an exact lowercase full commit SHA.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(entrypoints)("rejects uppercase full-SHA source authority in %s", (_label, entrypoint) => { + const { root } = cleanTrackedRepository(); + try { + const result = runEntrypoint(entrypoint, root, { + NOEMA_DATA_ROOM_SOURCE_COMMIT: "A".repeat(40), + }); + expect(result.status).not.toBe(0); + expect(combinedOutput(result)).toContain( + "NOEMA_DATA_ROOM_SOURCE_COMMIT must be an exact lowercase full commit SHA.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(entrypoints)("rejects surrounding whitespace around the release tag in %s", (_label, entrypoint) => { + const { root } = cleanTrackedRepository(); + try { + const result = runEntrypoint(entrypoint, root, { + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: " v1.2.3\n", + }); + expect(result.status).not.toBe(0); + expect(combinedOutput(result)).toContain( + "NOEMA_RELEASE_UNDER_DILIGENCE_TAG must use exact canonical SemVer bytes.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(entrypoints)("rejects leading-zero SemVer core authority in %s", (_label, entrypoint) => { + const { root } = cleanTrackedRepository(); + try { + const result = runEntrypoint(entrypoint, root, { + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "v01.2.3", + }); + expect(result.status).not.toBe(0); + expect(combinedOutput(result)).toContain( + "NOEMA_RELEASE_UNDER_DILIGENCE_TAG must use exact canonical SemVer bytes.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(entrypoints)("rejects leading-zero numeric prerelease authority in %s", (_label, entrypoint) => { + const { root } = cleanTrackedRepository(); + try { + const result = runEntrypoint(entrypoint, root, { + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: "v1.2.3-01", + }); + expect(result.status).not.toBe(0); + expect(combinedOutput(result)).toContain( + "NOEMA_RELEASE_UNDER_DILIGENCE_TAG must use exact canonical SemVer bytes.", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/acquisition-private-output-new-file-failure-cleanup.test.ts b/test/acquisition-private-output-new-file-failure-cleanup.test.ts new file mode 100644 index 000000000..650c97b88 --- /dev/null +++ b/test/acquisition-private-output-new-file-failure-cleanup.test.ts @@ -0,0 +1,79 @@ +import { + closeSync, + constants, + existsSync, + fchmodSync, + fstatSync, + ftruncateSync, + lstatSync, + mkdtempSync, + openSync, + rmSync, + unlinkSync, + writeFileSync as fsWriteFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +describe("acquisition private output new-file failure cleanup", () => { + it.skipIf(process.platform === "win32")( + "removes the identity-matched partial leaf when a new private write fails", + () => { + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-failure-")); + const output = join(directory, "evidence.json"); + const fileSystem = { + constants, + lstatSync, + openSync, + fstatSync, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync(descriptor: number) { + fsWriteFileSync(descriptor, "partial\n", { encoding: "utf8" }); + throw new Error("simulated acquisition write failure"); + }, + }; + + try { + expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) + .toThrow("simulated acquisition write failure"); + expect(existsSync(output)).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "never unlinks an unauthenticated new pathname when descriptor identity cannot be established", + () => { + const directory = mkdtempSync(join(tmpdir(), "noema-private-new-identity-failure-")); + const output = join(directory, "evidence.json"); + const fileSystem = { + constants, + lstatSync, + openSync, + fstatSync() { + throw new Error("simulated descriptor identity failure"); + }, + fchmodSync, + ftruncateSync, + closeSync, + unlinkSync, + writeFileSync: fsWriteFileSync, + }; + + try { + expect(() => writeAcquisitionPrivateFile(output, "complete\n", fileSystem as never)) + .toThrow("simulated descriptor identity failure"); + expect(existsSync(output)).toBe(true); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/test/acquisition-private-output-parent-race.test.ts b/test/acquisition-private-output-parent-race.test.ts new file mode 100644 index 000000000..8b3d9957a --- /dev/null +++ b/test/acquisition-private-output-parent-race.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +function fileMetadata() { + return { + dev: 1, + ino: 2, + nlink: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }; +} + +function directoryMetadata({ symbolicLink = false } = {}) { + return { + dev: 1, + ino: 3, + nlink: 1, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => symbolicLink, + }; +} + +describe("acquisition private output parent integrity", () => { + it("fails closed when a parent becomes a symbolic link after exclusive leaf open", () => { + let parentBecameSymbolicLink = false; + const fileSystem = { + constants: { O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + lstatSync: vi.fn((path: string) => { + if (path === "output") { + return parentBecameSymbolicLink ? fileMetadata() : null; + } + return directoryMetadata({ symbolicLink: parentBecameSymbolicLink }); + }), + openSync: vi.fn(() => { + parentBecameSymbolicLink = true; + return 17; + }), + fstatSync: vi.fn(() => fileMetadata()), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile("output", "value", fileSystem as never)) + .toThrow("parent must be a real directory"); + expect(fileSystem.writeFileSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(17); + }); +}); diff --git a/test/acquisition-private-output-staging-parent-race.test.ts b/test/acquisition-private-output-staging-parent-race.test.ts new file mode 100644 index 000000000..4e6e80b59 --- /dev/null +++ b/test/acquisition-private-output-staging-parent-race.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { writeAcquisitionPrivateFile } from "../scripts/lib/acquisition-private-output.mjs"; + +function fileMetadata(ino = 2) { + return { + dev: 1, + ino, + nlink: 1, + isFile: () => true, + isDirectory: () => false, + isSymbolicLink: () => false, + }; +} + +function directoryMetadata({ symbolicLink = false } = {}) { + return { + dev: 1, + ino: 3, + nlink: 1, + isFile: () => false, + isDirectory: () => true, + isSymbolicLink: () => symbolicLink, + }; +} + +describe("acquisition private output staging parent integrity", () => { + it("fails closed when a parent becomes a symbolic link after staging-file open", () => { + let openCount = 0; + let parentBecameSymbolicLink = false; + const existing = fileMetadata(2); + const staged = fileMetadata(4); + const fileSystem = { + constants: { O_WRONLY: 1, O_CREAT: 2, O_EXCL: 4, O_NOFOLLOW: 8 }, + lstatSync: vi.fn((path: string) => { + if (path === "output") { + return existing; + } + if (path.startsWith("output.tmp-")) { + return staged; + } + return directoryMetadata({ symbolicLink: parentBecameSymbolicLink }); + }), + openSync: vi.fn((path: string) => { + openCount += 1; + if (openCount === 2 && path.startsWith("output.tmp-")) { + parentBecameSymbolicLink = true; + return 18; + } + return 17; + }), + fstatSync: vi.fn((descriptor: number) => descriptor === 18 ? staged : existing), + fchmodSync: vi.fn(), + ftruncateSync: vi.fn(), + writeFileSync: vi.fn(), + closeSync: vi.fn(), + renameSync: vi.fn(), + unlinkSync: vi.fn(), + }; + + expect(() => writeAcquisitionPrivateFile("output", "replacement", fileSystem as never)) + .toThrow("parent must be a real directory"); + expect(fileSystem.writeFileSync).not.toHaveBeenCalled(); + expect(fileSystem.renameSync).not.toHaveBeenCalled(); + expect(fileSystem.closeSync).toHaveBeenCalledWith(18); + }); +}); diff --git a/test/acquisition-release-asset-byte-domain.test.ts b/test/acquisition-release-asset-byte-domain.test.ts index 564eafb95..8d8f24f9b 100644 --- a/test/acquisition-release-asset-byte-domain.test.ts +++ b/test/acquisition-release-asset-byte-domain.test.ts @@ -15,7 +15,7 @@ const expectedAssets = [ "release-evidence.json", ].sort(); -function runReleaseAudit(assetBytes: unknown) { +function runReleaseAudit(assetBytes: unknown, releaseUnderDiligenceTag = tag) { const root = mkdtempSync(join(tmpdir(), "noema-acquisition-release-bytes-")); const receiptPath = join(root, "release-publication-receipt.json"); const outputDir = join(root, "audit"); @@ -58,7 +58,7 @@ function runReleaseAudit(assetBytes: unknown) { ...inheritedEnvironment, NOEMA_AUDIT_REPORT_ONLY: "1", NOEMA_ACQUISITION_AUDIT_OUTPUT_DIR: outputDir, - NOEMA_RELEASE_UNDER_DILIGENCE_TAG: tag, + NOEMA_RELEASE_UNDER_DILIGENCE_TAG: releaseUnderDiligenceTag, NOEMA_RELEASE_PUBLICATION_RECEIPT_PATH: receiptPath, NOEMA_REVENUE_EVIDENCE_PATH: join(root, "missing-revenue.json"), NOEMA_TRANSFER_EVIDENCE_PATH: join(root, "missing-transfer.json"), @@ -91,4 +91,13 @@ describe("acquisition release asset byte authority", () => { expect(releaseCheck.pass).toBe(true); expect(releaseCheck.details.failures).toEqual([]); }); + + it("rejects a release-under-diligence tag whose authority bytes contain surrounding whitespace", () => { + const { releaseCheck } = runReleaseAudit(1, ` ${tag} `); + + expect(releaseCheck.pass).toBe(false); + expect(releaseCheck.details.failures).toEqual( + expect.arrayContaining([expect.stringContaining("release under diligence")]), + ); + }); }); diff --git a/test/immutable-release-publication.test.ts b/test/immutable-release-publication.test.ts index ac15fbecd..241bd658f 100644 --- a/test/immutable-release-publication.test.ts +++ b/test/immutable-release-publication.test.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -269,6 +270,50 @@ describe("immutable buyer release publication", () => { } }); + it.each([ + `https://github.com/${repository}/actions/runs/123 `, + `https://github.com/${repository}/actions/runs/123?token=secret`, + `https://github.com/${repository}/actions/runs/123/attempts/1`, + ])("rejects ambiguous workflow-run evidence URL %s", (workflowRunUrl) => { + const temp = mkdtempSync(join(tmpdir(), "noema-immutable-release-workflow-url-")); + try { + const { fixture, result } = runReceipt(temp, (value) => { + const verification = JSON.parse(readFileSync(value.verificationPath, "utf8")); + verification.workflowRunUrl = workflowRunUrl; + writeJson(value.verificationPath, verification); + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("workflowRunUrl must identify an exact Actions run"); + expect(existsSync(fixture.outputPath)).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects publication inputs reached through a symlinked parent directory", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-immutable-release-symlink-parent-")); + try { + const { fixture, result } = runReceipt(temp, (value) => { + const realParent = join(temp, "real-policy-parent"); + const linkedParent = join(temp, "linked-policy-parent"); + mkdirSync(realParent); + writeJson(join(realParent, "immutable-policy.json"), { + enabled: true, + enforced_by_owner: true, + }); + symlinkSync(realParent, linkedParent, "dir"); + value.policyPath = join(linkedParent, "immutable-policy.json"); + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/parent|symlink/i); + expect(existsSync(fixture.outputPath)).toBe(false); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("fails closed on malformed UTF-8 publication JSON", () => { const temp = mkdtempSync(join(tmpdir(), "noema-immutable-release-invalid-utf8-")); try { diff --git a/test/release-evidence-canonical-time.test.ts b/test/release-evidence-canonical-time.test.ts index 56aaf0ce6..0b8b9d65c 100644 --- a/test/release-evidence-canonical-time.test.ts +++ b/test/release-evidence-canonical-time.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; const repository = "ContextualWisdomLab/noema"; @@ -31,7 +32,7 @@ function runReleaseEvidence(generatedAt: string) { const sourcePath = join(directory, `noema-${commitSha}.tar.gz`); const sbomPath = join(directory, "noema.cdx.json"); const outputDir = join(directory, "release"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); writeFileSync(sbomPath, JSON.stringify(validSbom()), "utf8"); const completed = spawnSync( diff --git a/test/release-evidence-coverage-contract.test.ts b/test/release-evidence-coverage-contract.test.ts new file mode 100644 index 000000000..168b66e44 --- /dev/null +++ b/test/release-evidence-coverage-contract.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("release evidence owned-production coverage contract", () => { + it("keeps release SBOM authority inside exact 100% coverage", () => { + const config = readFileSync("vitest.config.ts", "utf8"); + const executable = readFileSync("scripts/release-evidence.mjs", "utf8"); + + expect(config).toContain('"scripts/lib/release-sbom-authority.mjs"'); + expect(executable).toContain( + 'from "./lib/release-sbom-authority.mjs"', + ); + expect(config).toContain("lines: 100"); + expect(config).toContain("branches: 100"); + expect(config).toContain("functions: 100"); + expect(config).toContain("statements: 100"); + }); +}); diff --git a/test/release-evidence-explicit-override.test.ts b/test/release-evidence-explicit-override.test.ts new file mode 100644 index 000000000..1e3a35776 --- /dev/null +++ b/test/release-evidence-explicit-override.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repository = "ContextualWisdomLab/noema"; +const commitSha = "a".repeat(40); + +function validSbom() { + return { + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { + type: "application", + name: "noema", + version: "0.1.0", + "bom-ref": "noema@0.1.0", + }, + }, + components: [ + { + type: "library", + name: "vitest", + version: "4.1.9", + "bom-ref": "pkg:npm/vitest@4.1.9", + }, + ], + dependencies: [ + { ref: "noema@0.1.0", dependsOn: ["pkg:npm/vitest@4.1.9"] }, + { ref: "pkg:npm/vitest@4.1.9", dependsOn: [] }, + ], + }; +} + +function runEvidence(overrides: Record) { + const temp = mkdtempSync(join(tmpdir(), "noema-release-explicit-override-")); + const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); + const sbomPath = join(temp, "noema.cdx.json"); + const outputDir = join(temp, "release"); + writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sbomPath, JSON.stringify(validSbom()), "utf8"); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-evidence.mjs", + "--source", + sourcePath, + "--sbom", + sbomPath, + "--output-dir", + outputDir, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: repository, + GITHUB_SHA: commitSha, + GITHUB_REF: "refs/tags/v0.1.0", + NOEMA_RELEASE_VERSION: "0.1.0", + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", + ...overrides, + }, + encoding: "utf8", + }, + ); + + rmSync(temp, { recursive: true, force: true }); + return result; +} + +describe("explicit release identity overrides", () => { + it.each([ + ["NOEMA_RELEASE_COMMIT_SHA", { NOEMA_RELEASE_COMMIT_SHA: "" }], + ["NOEMA_RELEASE_REF", { NOEMA_RELEASE_REF: "" }], + ["NOEMA_RELEASE_GENERATED_AT", { NOEMA_RELEASE_GENERATED_AT: "" }], + ])("fails closed when %s is explicitly present but empty", (_label, overrides) => { + const result = runEvidence(overrides); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("must be a non-empty string"); + }); +}); diff --git a/test/release-evidence-output-parent-symlink.test.ts b/test/release-evidence-output-parent-symlink.test.ts index 7f69a2570..d068ca108 100644 --- a/test/release-evidence-output-parent-symlink.test.ts +++ b/test/release-evidence-output-parent-symlink.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { gzipSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; const repository = "ContextualWisdomLab/noema"; @@ -38,7 +39,7 @@ function validSbom() { function runReleaseEvidence(root: string, outputDir: string) { const sourcePath = join(root, `noema-${commitSha}.tar.gz`); const sbomPath = join(root, "noema.cdx.json"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); writeFileSync(sbomPath, JSON.stringify(validSbom()), "utf8"); return spawnSync( diff --git a/test/release-evidence.test.ts b/test/release-evidence.test.ts index 7c68cc5c5..1b2571e6d 100644 --- a/test/release-evidence.test.ts +++ b/test/release-evidence.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; const repository = "ContextualWisdomLab/noema"; @@ -33,6 +34,7 @@ function validSbom() { ], dependencies: [ { ref: "noema@0.1.0", dependsOn: ["pkg:npm/vitest@4.1.9"] }, + { ref: "pkg:npm/vitest@4.1.9", dependsOn: [] }, ], }; } @@ -47,7 +49,7 @@ function runEvidence( const sourcePath = join(temp, `noema-${sourceCommitSha}.tar.gz`); const sbomPath = join(temp, "noema.cdx.json"); const outputDir = join(temp, "release"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); if (sbomBytes) { writeFileSync(sbomPath, sbomBytes); } else { @@ -105,12 +107,13 @@ describe("signed release evidence", () => { expect(manifest.subject.name).toBe(`noema-${commitSha}.tar.gz`); expect(manifest.subject.sha256).toMatch(/^[a-f0-9]{64}$/); expect(manifest.subject.bytes).toBeGreaterThan(0); + expect(manifest.subject.mediaType).toBe("application/gzip"); expect(manifest.sbom).toMatchObject({ name: "noema.cdx.json", bomFormat: "CycloneDX", specVersion: "1.5", componentCount: 1, - dependencyCount: 1, + dependencyCount: 2, rootComponent: { type: "application", name: "noema", @@ -201,6 +204,23 @@ describe("signed release evidence", () => { } }); + it("does not echo untrusted SBOM metadata into failure output", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-redaction-")); + const sensitiveName = ["ghp", "_", "B".repeat(36)].join(""); + try { + const sbom = validSbom(); + sbom.metadata.component.name = sensitiveName; + const { result, outputDir } = runEvidence(temp, sbom); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("root component name must be noema"); + expect(result.stderr).not.toContain(sensitiveName); + expect(() => readFileSync(join(outputDir, "release-evidence.json"))).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("fails closed on malformed UTF-8 SBOM bytes", () => { const temp = mkdtempSync(join(tmpdir(), "noema-release-invalid-utf8-")); try { @@ -239,6 +259,26 @@ describe("signed release evidence", () => { } }); + it("does not echo malformed JSON content into failure output", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-invalid-json-redaction-")); + const sensitiveValue = ["ghp", "_", "C".repeat(36)].join(""); + try { + const malformedJson = Buffer.from(`{"broken": ${sensitiveValue}}`, "utf8"); + const { result, outputDir } = runEvidence( + temp, + validSbom(), + malformedJson, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("SBOM is not valid JSON"); + expect(result.stderr).not.toContain(sensitiveValue.slice(0, 12)); + expect(() => readFileSync(join(outputDir, "release-evidence.json"))).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + it("pins an isolated tag/manual workflow with provenance and SBOM attestations", () => { const workflow = readFileSync(".github/workflows/release-evidence.yml", "utf8"); diff --git a/test/release-publication-checksum-canonicality.test.ts b/test/release-publication-checksum-canonicality.test.ts new file mode 100644 index 000000000..73b606253 --- /dev/null +++ b/test/release-publication-checksum-canonicality.test.ts @@ -0,0 +1,221 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repository = "ContextualWisdomLab/noema"; +const commitSha = "a".repeat(40); +const version = "0.1.0"; +const tag = `v${version}`; + +function digest(path: string) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function writeJson(path: string, value: unknown) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function buildFixture(temp: string) { + const releaseDir = join(temp, "release"); + const attestationsDir = join(releaseDir, "attestations"); + mkdirSync(attestationsDir, { recursive: true }); + + const sourceName = `noema-${commitSha}.tar.gz`; + const sourcePath = join(releaseDir, sourceName); + const sbomPath = join(releaseDir, "noema.cdx.json"); + const evidencePath = join(releaseDir, "release-evidence.json"); + const checksumsPath = join(releaseDir, "SHA256SUMS"); + const provenancePath = join(attestationsDir, "provenance.sigstore.json"); + const cyclonedxPath = join(attestationsDir, "cyclonedx-sbom.sigstore.json"); + + writeFileSync(sourcePath, "bounded source archive", "utf8"); + writeJson(sbomPath, { + bomFormat: "CycloneDX", + specVersion: "1.5", + metadata: { component: { type: "application", name: "noema", version } }, + }); + writeJson(provenancePath, { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }); + writeJson(cyclonedxPath, { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }); + writeJson(evidencePath, { + schemaVersion: 1, + source: { + repository, + commitSha, + ref: `refs/tags/${tag}`, + version, + }, + subject: { + name: sourceName, + sha256: digest(sourcePath), + bytes: readFileSync(sourcePath).length, + }, + sbom: { + name: basename(sbomPath), + sha256: digest(sbomPath), + bytes: readFileSync(sbomPath).length, + bomFormat: "CycloneDX", + specVersion: "1.5", + rootComponent: { type: "application", name: "noema", version }, + }, + }); + writeFileSync( + checksumsPath, + [ + `${digest(sourcePath)} ${sourceName}`, + `${digest(sbomPath)} noema.cdx.json`, + `${digest(evidencePath)} release-evidence.json`, + ].join("\n") + "\n", + "utf8", + ); + + const assetPaths = [ + sourcePath, + sbomPath, + evidencePath, + checksumsPath, + provenancePath, + cyclonedxPath, + ]; + const releaseAssets = assetPaths.map((path) => ({ + name: basename(path), + size: readFileSync(path).length, + digest: `sha256:${digest(path)}`, + })); + + const policyPath = join(temp, "immutable-policy.json"); + const releaseViewPath = join(temp, "release-view.json"); + const releaseApiPath = join(temp, "release-api.json"); + const verificationPath = join(temp, "release-verification.json"); + const outputPath = join(temp, "release-publication-receipt.json"); + + writeJson(policyPath, { enabled: true, enforced_by_owner: true }); + writeJson(releaseViewPath, { + isImmutable: true, + tagName: tag, + targetCommitish: "main", + url: `https://github.com/${repository}/releases/tag/${tag}`, + assets: releaseAssets.map(({ name, size }) => ({ name, size })), + }); + writeJson(releaseApiPath, { + immutable: true, + tag_name: tag, + target_commitish: "main", + html_url: `https://github.com/${repository}/releases/tag/${tag}`, + assets: releaseAssets, + }); + writeJson(verificationPath, { + releaseVerified: true, + resolvedTagCommitSha: commitSha, + verifiedAssets: releaseAssets.map(({ name }) => name), + verifiedAt: "2026-08-03T14:00:00.000Z", + workflowRunUrl: `https://github.com/${repository}/actions/runs/123`, + }); + + return { + releaseDir, + checksumsPath, + policyPath, + releaseViewPath, + releaseApiPath, + verificationPath, + outputPath, + }; +} + +function synchronizeChecksumAssetMetadata(fixture: ReturnType) { + const name = "SHA256SUMS"; + const size = readFileSync(fixture.checksumsPath).length; + const checksumDigest = `sha256:${digest(fixture.checksumsPath)}`; + + const view = JSON.parse(readFileSync(fixture.releaseViewPath, "utf8")); + const viewAsset = view.assets.find((asset: { name: string }) => asset.name === name); + viewAsset.size = size; + writeJson(fixture.releaseViewPath, view); + + const api = JSON.parse(readFileSync(fixture.releaseApiPath, "utf8")); + const apiAsset = api.assets.find((asset: { name: string }) => asset.name === name); + apiAsset.size = size; + apiAsset.digest = checksumDigest; + writeJson(fixture.releaseApiPath, api); +} + +function runReceipt(temp: string, mutate: (fixture: ReturnType) => void) { + const fixture = buildFixture(temp); + mutate(fixture); + synchronizeChecksumAssetMetadata(fixture); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-publication-receipt.mjs", + "--policy", + fixture.policyPath, + "--release-view", + fixture.releaseViewPath, + "--release-api", + fixture.releaseApiPath, + "--verification", + fixture.verificationPath, + "--release-evidence", + join(fixture.releaseDir, "release-evidence.json"), + "--asset-dir", + fixture.releaseDir, + "--output", + fixture.outputPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: repository, + NOEMA_RELEASE_TAG: tag, + NOEMA_RELEASE_COMMIT_SHA: commitSha, + NOEMA_RELEASE_VERSION: version, + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T14:00:01.000Z", + }, + encoding: "utf8", + }, + ); + return result; +} + +describe("release publication checksum authority", () => { + it.each([ + ["leading whitespace", (text: string) => ` ${text}`], + ["trailing whitespace", (text: string) => text.replace(/\n$/, " \n")], + ["tab separator", (text: string) => text.replace(" ", "\t\t")], + ["blank line", (text: string) => text.replace("\n", "\n\n")], + ])("rejects non-canonical %s instead of normalizing it", (_label, mutateText) => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-checksum-canonical-")); + try { + const result = runReceipt(temp, (fixture) => { + const original = readFileSync(fixture.checksumsPath, "utf8"); + writeFileSync(fixture.checksumsPath, mutateText(original), "utf8"); + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("SHA256SUMS"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("rejects duplicate checksum entries instead of collapsing their authority", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-checksum-duplicate-")); + try { + const result = runReceipt(temp, (fixture) => { + const original = readFileSync(fixture.checksumsPath, "utf8"); + const [firstLine] = original.split("\n"); + writeFileSync(fixture.checksumsPath, `${firstLine}\n${original}`, "utf8"); + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("SHA256SUMS"); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); \ No newline at end of file diff --git a/test/release-publication-explicit-override.test.ts b/test/release-publication-explicit-override.test.ts new file mode 100644 index 000000000..fd3cbf8dc --- /dev/null +++ b/test/release-publication-explicit-override.test.ts @@ -0,0 +1,58 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const repository = "ContextualWisdomLab/noema"; +const commitSha = "a".repeat(40); +const version = "0.1.0"; +const tag = `v${version}`; +const missingPath = "test/fixtures/does-not-exist-release-publication-evidence.json"; + +function runReceipt(overrides: Record) { + return spawnSync( + process.execPath, + [ + "scripts/release-publication-receipt.mjs", + "--policy", + missingPath, + "--release-view", + missingPath, + "--release-api", + missingPath, + "--verification", + missingPath, + "--release-evidence", + missingPath, + "--asset-dir", + "test/fixtures", + "--output", + "test/fixtures/does-not-exist-release-publication-receipt.json", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: repository, + GITHUB_SHA: commitSha, + NOEMA_RELEASE_TAG: tag, + NOEMA_RELEASE_COMMIT_SHA: commitSha, + NOEMA_RELEASE_VERSION: version, + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T14:00:01.000Z", + ...overrides, + }, + encoding: "utf8", + }, + ); +} + +describe("release publication explicit identity overrides", () => { + it.each([ + ["NOEMA_RELEASE_COMMIT_SHA", { NOEMA_RELEASE_COMMIT_SHA: "" }], + ["NOEMA_RELEASE_GENERATED_AT", { NOEMA_RELEASE_GENERATED_AT: "" }], + ])("fails closed when %s is explicitly present but empty", (_label, overrides) => { + const result = runReceipt(overrides); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("must be a non-empty string"); + expect(result.stderr).not.toContain("could not be read safely"); + }); +}); diff --git a/test/release-publication-hardlink-evidence.test.ts b/test/release-publication-hardlink-evidence.test.ts new file mode 100644 index 000000000..bb662730c --- /dev/null +++ b/test/release-publication-hardlink-evidence.test.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repository = "ContextualWisdomLab/noema"; +const commitSha = "a".repeat(40); +const version = "0.1.0"; +const tag = `v${version}`; + +function digest(path: string) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function writeJson(path: string, value: unknown) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function buildFixture(temp: string) { + const releaseDir = join(temp, "release"); + const attestationsDir = join(releaseDir, "attestations"); + mkdirSync(attestationsDir, { recursive: true }); + + const sourceName = `noema-${commitSha}.tar.gz`; + const sourcePath = join(releaseDir, sourceName); + const sbomPath = join(releaseDir, "noema.cdx.json"); + const evidencePath = join(releaseDir, "release-evidence.json"); + const checksumsPath = join(releaseDir, "SHA256SUMS"); + const provenancePath = join(attestationsDir, "provenance.sigstore.json"); + const cyclonedxPath = join(attestationsDir, "cyclonedx-sbom.sigstore.json"); + + writeFileSync(sourcePath, "bounded source archive", "utf8"); + writeJson(sbomPath, { + bomFormat: "CycloneDX", + specVersion: "1.5", + metadata: { component: { type: "application", name: "noema", version } }, + }); + writeJson(provenancePath, { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }); + writeJson(cyclonedxPath, { mediaType: "application/vnd.dev.sigstore.bundle.v0.3+json" }); + writeJson(evidencePath, { + schemaVersion: 1, + source: { + repository, + commitSha, + ref: `refs/tags/${tag}`, + version, + }, + subject: { + name: sourceName, + sha256: digest(sourcePath), + bytes: readFileSync(sourcePath).length, + }, + sbom: { + name: basename(sbomPath), + sha256: digest(sbomPath), + bytes: readFileSync(sbomPath).length, + bomFormat: "CycloneDX", + specVersion: "1.5", + rootComponent: { type: "application", name: "noema", version }, + }, + }); + writeFileSync( + checksumsPath, + [ + `${digest(sourcePath)} ${sourceName}`, + `${digest(sbomPath)} noema.cdx.json`, + `${digest(evidencePath)} release-evidence.json`, + ].join("\n") + "\n", + "utf8", + ); + + const assetPaths = [ + sourcePath, + sbomPath, + evidencePath, + checksumsPath, + provenancePath, + cyclonedxPath, + ]; + const releaseAssets = assetPaths.map((path) => ({ + name: basename(path), + size: readFileSync(path).length, + digest: `sha256:${digest(path)}`, + })); + + const policyPath = join(temp, "immutable-policy.json"); + const releaseViewPath = join(temp, "release-view.json"); + const releaseApiPath = join(temp, "release-api.json"); + const verificationPath = join(temp, "release-verification.json"); + const outputPath = join(temp, "release-publication-receipt.json"); + + writeJson(policyPath, { enabled: true, enforced_by_owner: true }); + writeJson(releaseViewPath, { + isImmutable: true, + tagName: tag, + targetCommitish: "main", + url: `https://github.com/${repository}/releases/tag/${tag}`, + assets: releaseAssets.map(({ name, size }) => ({ name, size })), + }); + writeJson(releaseApiPath, { + immutable: true, + tag_name: tag, + target_commitish: "main", + html_url: `https://github.com/${repository}/releases/tag/${tag}`, + assets: releaseAssets, + }); + writeJson(verificationPath, { + releaseVerified: true, + resolvedTagCommitSha: commitSha, + verifiedAssets: releaseAssets.map(({ name }) => name), + verifiedAt: "2026-08-03T14:00:00.000Z", + workflowRunUrl: `https://github.com/${repository}/actions/runs/123`, + }); + + return { + releaseDir, + policyPath, + releaseViewPath, + releaseApiPath, + verificationPath, + outputPath, + }; +} + +describe("release publication retained-input link authority", () => { + it("rejects hard-linked publication inputs so an alternate pathname cannot mutate accepted evidence", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-publication-hardlink-")); + try { + const fixture = buildFixture(temp); + linkSync(fixture.policyPath, join(temp, "policy-alias.json")); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-publication-receipt.mjs", + "--policy", + fixture.policyPath, + "--release-view", + fixture.releaseViewPath, + "--release-api", + fixture.releaseApiPath, + "--verification", + fixture.verificationPath, + "--release-evidence", + join(fixture.releaseDir, "release-evidence.json"), + "--asset-dir", + fixture.releaseDir, + "--output", + fixture.outputPath, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: repository, + NOEMA_RELEASE_TAG: tag, + NOEMA_RELEASE_COMMIT_SHA: commitSha, + NOEMA_RELEASE_VERSION: version, + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T14:00:01.000Z", + }, + encoding: "utf8", + }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/single-link|hard link/i); + expect(() => readFileSync(fixture.outputPath)).toThrow(); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/release-sbom-authority-coverage.test.mjs b/test/release-sbom-authority-coverage.test.mjs new file mode 100644 index 000000000..3a802f75f --- /dev/null +++ b/test/release-sbom-authority-coverage.test.mjs @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { requireCanonicalReleaseBomRef } from "../scripts/lib/release-sbom-authority.mjs"; + +describe("release SBOM canonical authority coverage", () => { + it.each([ + [null, "non-string"], + ["", "empty"], + [" dependency@1.0.0 ", "surrounding whitespace"], + ["dependency\n@1.0.0", "control character"], + ["dependency\u200B@1.0.0", "format character"], + ["dependency\u2028@1.0.0", "line separator"], + ["dependency\u2029@1.0.0", "paragraph separator"], + ["cafe\u0301@1.0.0", "non-NFC Unicode identity"], + ["dependency\uD800@1.0.0", "lone surrogate code unit"], + ["urn:cdx:00000000-0000-4000-8000-000000000001/1#component", "BOM-Link-prefixed identity"], + ])("rejects %s as %s authority", (value) => { + expect(() => requireCanonicalReleaseBomRef(value, "SBOM bom-ref")).toThrow( + "canonical non-empty bom-ref identity", + ); + }); + + it("preserves a distinct canonical bom-ref byte-for-byte", () => { + expect(requireCanonicalReleaseBomRef("dependency@1.0.0", "SBOM bom-ref")).toBe( + "dependency@1.0.0", + ); + }); + + it("preserves NFC Unicode identity byte-for-byte rather than normalizing it", () => { + expect(requireCanonicalReleaseBomRef("caf\u00e9@1.0.0", "SBOM bom-ref")).toBe( + "caf\u00e9@1.0.0", + ); + }); +}); diff --git a/test/release-sbom-bom-ref-uniqueness.test.ts b/test/release-sbom-bom-ref-uniqueness.test.ts index 246d63c00..448458959 100644 --- a/test/release-sbom-bom-ref-uniqueness.test.ts +++ b/test/release-sbom-bom-ref-uniqueness.test.ts @@ -2,17 +2,22 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; const commitSha = "a".repeat(40); -function runReleaseEvidence(componentBomRef: string) { +function runReleaseEvidence( + componentBomRef: string, + rootBomRef = "noema@0.1.0", + extraSbomFields: Record = {}, +) { const temp = mkdtempSync(join(tmpdir(), "noema-release-bom-ref-")); const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); const sbomPath = join(temp, "noema.cdx.json"); const outputDir = join(temp, "release"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); writeFileSync( sbomPath, JSON.stringify({ @@ -26,7 +31,7 @@ function runReleaseEvidence(componentBomRef: string) { type: "application", name: "noema", version: "0.1.0", - "bom-ref": "noema@0.1.0", + "bom-ref": rootBomRef, }, }, components: [{ @@ -36,9 +41,10 @@ function runReleaseEvidence(componentBomRef: string) { "bom-ref": componentBomRef, }], dependencies: [ - { ref: "noema@0.1.0", dependsOn: [componentBomRef] }, + { ref: rootBomRef, dependsOn: [componentBomRef] }, { ref: componentBomRef, dependsOn: [] }, ], + ...extraSbomFields, }), "utf8", ); @@ -80,7 +86,65 @@ describe("CycloneDX release SBOM bom-ref identity", () => { expect(result.stderr).toContain("bom-ref"); }); - it("accepts distinct bom-ref identities", () => { + it("rejects surrounding whitespace in a component bom-ref authority", () => { + const result = runReleaseEvidence(" dependency@1.0.0 "); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("bom-ref"); + }); + + it("rejects surrounding whitespace in the root bom-ref authority", () => { + const result = runReleaseEvidence("dependency@1.0.0", " noema@0.1.0 "); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("bom-ref"); + }); + + it.each([ + "dependency\n@1.0.0", + "dependency\u200B@1.0.0", + ])("rejects control or format characters inside a bom-ref authority (%j)", (bomRef) => { + const result = runReleaseEvidence(bomRef); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("bom-ref"); + }); + + it.each([ + "dependency\u00A0@1.0.0", + "dependency\u202F@1.0.0", + "dependency\u3000@1.0.0", + ])("rejects non-canonical Unicode space separators inside a bom-ref authority (%j)", (bomRef) => { + const result = runReleaseEvidence(bomRef); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("bom-ref"); + }); + + it("fails closed on excessively deep SBOM nesting", () => { + let nested: Record = {}; + for (let depth = 0; depth < 130; depth += 1) { + nested = { child: nested }; + } + + const result = runReleaseEvidence( + "dependency@1.0.0", + "noema@0.1.0", + { extensions: nested }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("nesting depth"); + }); + + it("preserves an internal ASCII space in an otherwise canonical bom-ref authority", () => { + const result = runReleaseEvidence("dependency alias@1.0.0"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("release-evidence: PASS"); + }); + + it("accepts distinct canonical bom-ref identities", () => { const result = runReleaseEvidence("dependency@1.0.0"); expect(result.status).toBe(0); diff --git a/test/release-sbom-dependency-graph-integrity.test.ts b/test/release-sbom-dependency-graph-integrity.test.ts new file mode 100644 index 000000000..cba44fe98 --- /dev/null +++ b/test/release-sbom-dependency-graph-integrity.test.ts @@ -0,0 +1,251 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; + +const commitSha = "a".repeat(40); +const rootBomRef = "noema@0.1.0"; +const componentBomRef = "dependency@1.0.0"; +const nestedComponentBomRef = "nested-dependency@2.0.0"; + +const defaultComponents = [{ + type: "library", + name: "dependency", + version: "1.0.0", + "bom-ref": componentBomRef, +}]; + +function runReleaseEvidence(dependencies: unknown[], components: unknown[] = defaultComponents) { + const temp = mkdtempSync(join(tmpdir(), "noema-release-sbom-graph-")); + const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); + const sbomPath = join(temp, "noema.cdx.json"); + const outputDir = join(temp, "release"); + + writeFileSync(sourcePath, gzipSync("bounded-source-archive")); + writeFileSync( + sbomPath, + JSON.stringify({ + $schema: "http://cyclonedx.org/schema/bom-1.5.schema.json", + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { + type: "application", + name: "noema", + version: "0.1.0", + "bom-ref": rootBomRef, + }, + }, + components, + dependencies, + }), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-evidence.mjs", + "--source", + sourcePath, + "--sbom", + sbomPath, + "--output-dir", + outputDir, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: "ContextualWisdomLab/noema", + GITHUB_SHA: commitSha, + GITHUB_REF: "refs/tags/v0.1.0", + NOEMA_RELEASE_VERSION: "0.1.0", + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", + }, + encoding: "utf8", + }, + ); + + rmSync(temp, { recursive: true, force: true }); + return result; +} + +function expectRejected( + dependencies: unknown[], + components?: unknown[], + expectedDiagnostic = "dependency", +) { + const result = runReleaseEvidence(dependencies, components); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(expectedDiagnostic); +} + +describe("CycloneDX release SBOM dependency graph identity", () => { + it("rejects a dependency entry whose ref is not a declared bom-ref", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: "ghost@1.0.0", dependsOn: [] }, + ]); + }); + + it("does not echo undeclared bom-ref authority into failure output", () => { + const sensitiveBomRef = ["ghp", "_", "A".repeat(36)].join(""); + const result = runReleaseEvidence([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: sensitiveBomRef, dependsOn: [] }, + ]); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("must reference a declared bom-ref identity"); + expect(result.stderr).not.toContain(sensitiveBomRef); + }); + + it("rejects a dependsOn edge whose target is not a declared bom-ref", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: ["ghost@1.0.0"] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + }); + + it("rejects non-canonical dependency ref bytes", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: ` ${componentBomRef} `, dependsOn: [] }, + ]); + }); + + it("rejects non-canonical dependsOn target bytes", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [` ${componentBomRef} `] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + }); + + it("rejects non-object dependency entries", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + null, + ]); + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + "dependency@1.0.0", + ]); + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + [], + ]); + }); + + it("rejects malformed and duplicate dependency authorities", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: componentBomRef }, + ]); + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef, componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + }); + + it("rejects non-string dependency references", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: 42, dependsOn: [] }, + ]); + expectRejected([ + { ref: rootBomRef, dependsOn: [42] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + }); + + it("rejects a listed component omitted from the dependency graph", () => { + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + ]); + }); + + it("rejects a nested component omitted from the dependency graph", () => { + const nestedComponents = [{ + ...defaultComponents[0], + components: [{ + type: "library", + name: "nested-dependency", + version: "2.0.0", + "bom-ref": nestedComponentBomRef, + }], + }]; + + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + ], nestedComponents); + }); + + it("rejects malformed nested component assembly authority", () => { + const malformedNestedComponents = [{ + ...defaultComponents[0], + components: { + type: "library", + name: "nested-dependency", + version: "2.0.0", + "bom-ref": nestedComponentBomRef, + }, + }]; + + expectRejected([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + { ref: nestedComponentBomRef, dependsOn: [] }, + ], malformedNestedComponents, "nested component components must be an array when present"); + }); + + it("accepts nested component assemblies when each component has an explicit graph node", () => { + const nestedComponents = [{ + ...defaultComponents[0], + components: [{ + type: "library", + name: "nested-dependency", + version: "2.0.0", + "bom-ref": nestedComponentBomRef, + }], + }]; + const result = runReleaseEvidence([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + { ref: nestedComponentBomRef, dependsOn: [] }, + ], nestedComponents); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("release-evidence: PASS"); + }); + + it("accepts dependency entries that omit optional dependsOn", () => { + const result = runReleaseEvidence([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef }, + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("release-evidence: PASS"); + }); + + it("accepts a dependency graph whose refs resolve to canonical bom-ref identities", () => { + const result = runReleaseEvidence([ + { ref: rootBomRef, dependsOn: [componentBomRef] }, + { ref: componentBomRef, dependsOn: [] }, + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("release-evidence: PASS"); + }); +}); \ No newline at end of file diff --git a/test/release-sbom-json-integrity.test.ts b/test/release-sbom-json-integrity.test.ts index 389a2e370..0abb94d5b 100644 --- a/test/release-sbom-json-integrity.test.ts +++ b/test/release-sbom-json-integrity.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; const repository = "ContextualWisdomLab/noema"; @@ -11,7 +12,7 @@ function runReleaseEvidence(temp: string, sbomText: string) { const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); const sbomPath = join(temp, "noema.cdx.json"); const outputDir = join(temp, "release"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); writeFileSync(sbomPath, sbomText, "utf8"); const result = spawnSync( diff --git a/test/release-sbom-nested-component-count.test.ts b/test/release-sbom-nested-component-count.test.ts new file mode 100644 index 000000000..5ddd6d451 --- /dev/null +++ b/test/release-sbom-nested-component-count.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; +import { expect, it } from "vitest"; + +it("counts recursively nested CycloneDX components in retained release evidence", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-release-nested-count-")); + const commitSha = "a".repeat(40); + const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); + const sbomPath = join(temp, "noema.cdx.json"); + const outputDir = join(temp, "release"); + + try { + writeFileSync(sourcePath, gzipSync("bounded-source-archive")); + writeFileSync(sbomPath, JSON.stringify({ + $schema: "http://cyclonedx.org/schema/bom-1.5.schema.json", + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { + type: "application", + name: "noema", + version: "0.1.0", + "bom-ref": "noema@0.1.0", + }, + }, + components: [{ + type: "library", + name: "parent", + version: "1.0.0", + "bom-ref": "parent@1.0.0", + components: [{ + type: "library", + name: "nested", + version: "2.0.0", + "bom-ref": "nested@2.0.0", + }], + }], + dependencies: [ + { ref: "noema@0.1.0", dependsOn: ["parent@1.0.0"] }, + { ref: "parent@1.0.0", dependsOn: [] }, + { ref: "nested@2.0.0", dependsOn: [] }, + ], + }), "utf8"); + + const result = spawnSync(process.execPath, [ + "scripts/release-evidence.mjs", + "--source", + sourcePath, + "--sbom", + sbomPath, + "--output-dir", + outputDir, + ], { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: "ContextualWisdomLab/noema", + GITHUB_SHA: commitSha, + GITHUB_REF: "refs/tags/v0.1.0", + NOEMA_RELEASE_VERSION: "0.1.0", + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", + }, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + const manifest = JSON.parse( + readFileSync(join(outputDir, "release-evidence.json"), "utf8"), + ); + expect(manifest.sbom.componentCount).toBe(2); + } finally { + rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/test/release-sbom-serial-number-integrity.test.ts b/test/release-sbom-serial-number-integrity.test.ts index 50d0c36c4..3eb6e45c8 100644 --- a/test/release-sbom-serial-number-integrity.test.ts +++ b/test/release-sbom-serial-number-integrity.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; +import { gzipSync } from "node:zlib"; import { describe, expect, it } from "vitest"; const commitSha = "a".repeat(40); @@ -12,7 +13,7 @@ function runReleaseEvidence(serialNumber: string) { const sbomPath = join(temp, "noema.cdx.json"); const outputDir = join(temp, "release"); - writeFileSync(sourcePath, "bounded-source-archive", "utf8"); + writeFileSync(sourcePath, gzipSync(Buffer.from("bounded-source-archive", "utf8"))); writeFileSync( sbomPath, JSON.stringify({ @@ -69,14 +70,17 @@ describe("CycloneDX release SBOM serial-number integrity", () => { "urn:uuid:not-a-uuid", "urn:uuid:00000000000040008000000000000001", "urn:uuid:00000000-0000-4000-8000-00000000000g", - ])("rejects malformed RFC 4122 serial number %s", (serialNumber) => { + "urn:uuid:00000000-0000-6000-8000-000000000001", + "urn:uuid:00000000-0000-4000-c000-000000000001", + "urn:uuid:00000000-0000-0000-0000-000000000000", + ])("rejects non-authoritative RFC 4122 serial number %s", (serialNumber) => { const result = runReleaseEvidence(serialNumber); expect(result.status).not.toBe(0); expect(result.stderr).toContain("SBOM serialNumber"); }); - it("accepts the canonical CycloneDX urn:uuid form", () => { + it("accepts a canonical non-nil CycloneDX urn:uuid form", () => { const result = runReleaseEvidence("urn:uuid:00000000-0000-4000-8000-000000000001"); expect(result.status).toBe(0); diff --git a/test/release-semver-canonicality.test.ts b/test/release-semver-canonicality.test.ts index fa742e818..8d3f4113a 100644 --- a/test/release-semver-canonicality.test.ts +++ b/test/release-semver-canonicality.test.ts @@ -26,7 +26,10 @@ function releaseEnvironment(version: string) { }; } -function runReleaseEvidence(version: string) { +function runReleaseEvidence( + version: string, + overrides: Record = {}, +) { return spawnSync( process.execPath, [ @@ -40,13 +43,16 @@ function runReleaseEvidence(version: string) { ], { cwd: process.cwd(), - env: releaseEnvironment(version), + env: { ...releaseEnvironment(version), ...overrides }, encoding: "utf8", }, ); } -function runPublicationReceipt(version: string) { +function runPublicationReceipt( + version: string, + overrides: Record = {}, +) { return spawnSync( process.execPath, [ @@ -68,7 +74,7 @@ function runPublicationReceipt(version: string) { ], { cwd: process.cwd(), - env: releaseEnvironment(version), + env: { ...releaseEnvironment(version), ...overrides }, encoding: "utf8", }, ); @@ -107,4 +113,64 @@ describe("canonical release SemVer identity", () => { expect(publication.stderr).not.toContain("release version is not valid SemVer"); }, ); + + it("rejects whitespace-normalized repository authority before artifact access", () => { + const result = runReleaseEvidence("0.1.0", { + GITHUB_REPOSITORY: ` ${repository}\t`, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`release repository must be ${repository}`); + expect(result.stderr).not.toContain("source archive could not be read safely"); + }); + + it("rejects whitespace-normalized release version before artifact access", () => { + const result = runReleaseEvidence("0.1.0", { + NOEMA_RELEASE_VERSION: " 0.1.0\n", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("release version is not valid SemVer"); + expect(result.stderr).not.toContain("source archive could not be read safely"); + }); + + it("rejects whitespace-normalized release ref before artifact access", () => { + const result = runReleaseEvidence("0.1.0", { + GITHUB_REF: " refs/tags/v0.1.0\n", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("release ref must be refs/tags/v0.1.0"); + expect(result.stderr).not.toContain("source archive could not be read safely"); + }); + + it("rejects whitespace-normalized publication repository authority before evidence access", () => { + const result = runPublicationReceipt("0.1.0", { + GITHUB_REPOSITORY: ` ${repository}\t`, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`release repository must be ${repository}`); + expect(result.stderr).not.toContain("could not be read safely"); + }); + + it("rejects whitespace-normalized publication version before evidence access", () => { + const result = runPublicationReceipt("0.1.0", { + NOEMA_RELEASE_VERSION: " 0.1.0\n", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("release version is not valid SemVer"); + expect(result.stderr).not.toContain("could not be read safely"); + }); + + it("rejects whitespace-normalized publication tag before evidence access", () => { + const result = runPublicationReceipt("0.1.0", { + NOEMA_RELEASE_TAG: " v0.1.0\n", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("release tag must be v0.1.0"); + expect(result.stderr).not.toContain("could not be read safely"); + }); }); diff --git a/test/release-source-gzip-integrity.test.ts b/test/release-source-gzip-integrity.test.ts new file mode 100644 index 000000000..8d36b74c5 --- /dev/null +++ b/test/release-source-gzip-integrity.test.ts @@ -0,0 +1,82 @@ +import { gzipSync } from "node:zlib"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const commitSha = "a".repeat(40); + +function runReleaseEvidence(sourceBytes: Uint8Array) { + const temp = mkdtempSync(join(tmpdir(), "noema-release-source-gzip-")); + const sourcePath = join(temp, `noema-${commitSha}.tar.gz`); + const sbomPath = join(temp, "noema.cdx.json"); + const outputDir = join(temp, "release"); + + writeFileSync(sourcePath, sourceBytes); + writeFileSync( + sbomPath, + JSON.stringify({ + $schema: "http://cyclonedx.org/schema/bom-1.5.schema.json", + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { + type: "application", + name: "noema", + version: "0.1.0", + "bom-ref": "noema@0.1.0", + }, + }, + components: [], + dependencies: [{ ref: "noema@0.1.0", dependsOn: [] }], + }), + "utf8", + ); + + const result = spawnSync( + process.execPath, + [ + "scripts/release-evidence.mjs", + "--source", + sourcePath, + "--sbom", + sbomPath, + "--output-dir", + outputDir, + ], + { + cwd: process.cwd(), + env: { + ...process.env, + GITHUB_REPOSITORY: "ContextualWisdomLab/noema", + GITHUB_SHA: commitSha, + GITHUB_REF: "refs/tags/v0.1.0", + NOEMA_RELEASE_VERSION: "0.1.0", + NOEMA_RELEASE_GENERATED_AT: "2026-08-03T00:00:00.000Z", + }, + encoding: "utf8", + }, + ); + + rmSync(temp, { recursive: true, force: true }); + return result; +} + +describe("release source gzip authority", () => { + it("rejects non-gzip bytes presented as the release tar.gz subject", () => { + const result = runReleaseEvidence(Buffer.from("bounded-source-archive", "utf8")); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("gzip"); + }); + + it("accepts a real gzip envelope for the release subject", () => { + const result = runReleaseEvidence(gzipSync(Buffer.from("bounded-source-archive", "utf8"))); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("release-evidence: PASS"); + }); +}); diff --git a/test/replay-target-authorization-coverage.test.ts b/test/replay-target-authorization-coverage.test.ts index 3139bb179..031210428 100644 --- a/test/replay-target-authorization-coverage.test.ts +++ b/test/replay-target-authorization-coverage.test.ts @@ -120,4 +120,26 @@ describe("target authorization through the public exchange path", () => { message: "target_repository is not a valid owner/name repository", }); }); + + it.each([ + ["leading ASCII space", " ContextualWisdomLab/noema"], + ["trailing ASCII space", "ContextualWisdomLab/noema "], + ["leading tab", "\tContextualWisdomLab/noema"], + ])("rejects non-canonical target_repository authority with %s", async (_label, targetRepository) => { + const { token, jwk } = await createToken("ContextualWisdomLab/.github"); + mockOidc(jwk); + + const response = await exchange( + token, + JSON.stringify({ target_repository: targetRepository }), + `203.0.113.${220 + targetRepository.length % 10}`, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + message: "target_repository is not a valid owner/name repository", + }); + }); }); diff --git a/test/stable-release-file-dot-segment-symlink.test.ts b/test/stable-release-file-dot-segment-symlink.test.ts new file mode 100644 index 000000000..eac8ae666 --- /dev/null +++ b/test/stable-release-file-dot-segment-symlink.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, sep } from "node:path"; +import { tmpdir } from "node:os"; +import { readStableRegularFile } from "../scripts/lib/stable-file-evidence.mjs"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("stable release evidence path canonicality", () => { + it("rejects a dot-segment path whose raw lookup traverses a symlinked parent", () => { + const root = mkdtempSync(join(tmpdir(), "noema-stable-path-")); + temporaryRoots.push(root); + const safeDirectory = join(root, "safe"); + const attackerDirectory = join(root, "attacker"); + const attackerNestedDirectory = join(attackerDirectory, "nested"); + mkdirSync(safeDirectory, { recursive: true }); + mkdirSync(attackerNestedDirectory, { recursive: true }); + writeFileSync(join(attackerDirectory, "evidence.json"), "attacker-controlled-evidence"); + symlinkSync(attackerNestedDirectory, join(safeDirectory, "link"), "dir"); + + const ambiguousPath = `${safeDirectory}${sep}link${sep}..${sep}evidence.json`; + + expect(() => readStableRegularFile( + ambiguousPath, + "release evidence", + 1024, + )).toThrow(/lexical-canonical path/); + }); +}); diff --git a/test/stable-release-file-evidence.test.ts b/test/stable-release-file-evidence.test.ts index ac0d3f825..9bd7fe8c3 100644 --- a/test/stable-release-file-evidence.test.ts +++ b/test/stable-release-file-evidence.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { linkSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -9,8 +9,10 @@ type Metadata = { ino: number; mode: number; size: number; + nlink: number; mtimeMs: number; ctimeMs: number; + isDirectory: () => boolean; isFile: () => boolean; isSymbolicLink: () => boolean; }; @@ -21,19 +23,32 @@ function metadata(overrides: Partial = {}): Metadata { ino: 2, mode: 0o100600, size: 3, + nlink: 1, mtimeMs: 10, ctimeMs: 11, + isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, ...overrides, }; } +function directoryMetadata(overrides: Partial = {}): Metadata { + return metadata({ + mode: 0o040700, + size: 0, + isDirectory: () => true, + isFile: () => false, + ...overrides, + }); +} + function fakeFileSystem({ pathMetadata = metadata(), openedMetadata = metadata(), finalMetadata = metadata(), finalPathMetadata = metadata(), + parentMetadata = directoryMetadata(), chunks = [Buffer.from("abc")], constants = { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, }: { @@ -41,17 +56,24 @@ function fakeFileSystem({ openedMetadata?: Metadata; finalMetadata?: Metadata; finalPathMetadata?: Metadata; + parentMetadata?: Metadata | null; chunks?: Buffer[]; constants?: { O_RDONLY?: number; O_NOFOLLOW?: number }; } = {}) { - let statCalls = 0; + let leafStatCalls = 0; + let descriptorStatCalls = 0; let chunkIndex = 0; let closed = false; const fileSystem = { constants, - lstatSync: () => (statCalls++ === 0 ? pathMetadata : finalPathMetadata), + lstatSync: (path: string) => { + if (path === "evidence") { + return leafStatCalls++ === 0 ? pathMetadata : finalPathMetadata; + } + return parentMetadata as Metadata; + }, openSync: () => 7, - fstatSync: () => (statCalls++ === 1 ? openedMetadata : finalMetadata), + fstatSync: () => descriptorStatCalls++ === 0 ? openedMetadata : finalMetadata, readSync: (_fd: number, target: Buffer, offset: number, length: number) => { const chunk = chunks[chunkIndex++]; if (!chunk) return 0; @@ -67,7 +89,7 @@ function fakeFileSystem({ } describe("stable release file evidence", () => { - it("reads exact bytes from a bounded regular file and rejects a real symlink", () => { + it("reads exact bytes from a bounded regular file and rejects symlinked evidence paths", () => { const temp = mkdtempSync(join(tmpdir(), "noema-stable-release-file-")); try { const target = join(temp, "target.json"); @@ -77,11 +99,49 @@ describe("stable release file evidence", () => { expect(readStableRegularFile(target, "release input", 16)).toEqual(Buffer.from("abc")); expect(() => readStableRegularFile(link, "release input", 16)).toThrow(/symbolic link|no-follow/i); + + const realParent = join(temp, "real-parent"); + const linkedParent = join(temp, "linked-parent"); + mkdirSync(realParent); + writeFileSync(join(realParent, "nested.json"), "abc", "utf8"); + symlinkSync(realParent, linkedParent, "dir"); + + expect(() => + readStableRegularFile(join(linkedParent, "nested.json"), "release input", 16), + ).toThrow(/parent|symlink/i); } finally { rmSync(temp, { recursive: true, force: true }); } }); + it("rejects hard-linked release evidence so another pathname cannot mutate the accepted inode", () => { + const temp = mkdtempSync(join(tmpdir(), "noema-stable-release-hardlink-")); + try { + const target = join(temp, "target.json"); + const alias = join(temp, "alias.json"); + writeFileSync(target, "abc", "utf8"); + linkSync(target, alias); + + expect(() => readStableRegularFile(target, "release input", 16)).toThrow(/single-link/i); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("fails closed when parent directory authority is unavailable or not a real directory", () => { + for (const [parentMetadata, expected] of [ + [null, /parent directory metadata/i], + [directoryMetadata({ isDirectory: undefined as unknown as () => boolean }), /parent directory metadata/i], + [directoryMetadata({ isSymbolicLink: () => true }), /symbolic-link parent/i], + [directoryMetadata({ isDirectory: () => false }), /real directory/i], + ] as const) { + const fake = fakeFileSystem({ parentMetadata }); + expect(() => readStableRegularFile("evidence", "release input", 16, fake.fileSystem)).toThrow( + expected, + ); + } + }); + it("fails closed when no-follow or read-only flags are unavailable", () => { const missingNoFollow = fakeFileSystem({ constants: { O_RDONLY: 0 } }); expect(() => @@ -104,6 +164,7 @@ describe("stable release file evidence", () => { [{ ...metadata(), isFile: undefined } as unknown as Metadata, /metadata is unavailable/i], [metadata({ size: Number.NaN }), /invalid byte size/i], [metadata({ size: -1 }), /invalid byte size/i], + [metadata({ nlink: 2 }), /single-link/i], [metadata({ isSymbolicLink: () => true }), /symbolic link/i], [metadata({ isFile: () => false }), /regular file/i], [metadata({ size: 0 }), /empty/i], diff --git a/test/stable-release-file-version-race.test.ts b/test/stable-release-file-version-race.test.ts new file mode 100644 index 000000000..c8b30e82a --- /dev/null +++ b/test/stable-release-file-version-race.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { readStableRegularFile } from "../scripts/lib/stable-file-evidence.mjs"; + +type Metadata = { + dev: number; + ino: number; + mode: number; + size: number; + nlink: number; + mtimeMs: number; + ctimeMs: number; + isDirectory: () => boolean; + isFile: () => boolean; + isSymbolicLink: () => boolean; +}; + +function fileMetadata(overrides: Partial = {}): Metadata { + return { + dev: 1, + ino: 2, + mode: 0o100600, + size: 3, + nlink: 1, + mtimeMs: 10, + ctimeMs: 11, + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + ...overrides, + }; +} + +const parentMetadata: Metadata = { + ...fileMetadata({ mode: 0o040700, size: 0 }), + isDirectory: () => true, + isFile: () => false, +}; + +function raceFileSystem({ + pathMetadata, + openedMetadata, + finalMetadata, + finalPathMetadata, +}: { + pathMetadata: Metadata; + openedMetadata: Metadata; + finalMetadata: Metadata; + finalPathMetadata: Metadata; +}) { + let leafStats = 0; + let descriptorStats = 0; + let readCount = 0; + return { + constants: { O_RDONLY: 0, O_NOFOLLOW: 0x20000 }, + lstatSync(path: string) { + if (path === "evidence") { + return leafStats++ === 0 ? pathMetadata : finalPathMetadata; + } + return parentMetadata; + }, + openSync: () => 7, + fstatSync: () => descriptorStats++ === 0 ? openedMetadata : finalMetadata, + readSync(_fd: number, target: Buffer, offset: number) { + if (readCount++ > 0) return 0; + Buffer.from("abc").copy(target, offset); + return 3; + }, + closeSync: () => undefined, + }; +} + +describe("stable release evidence file-version races", () => { + it("rejects a same-inode file whose modification metadata changes between path stat and open", () => { + const before = fileMetadata(); + const opened = fileMetadata({ mtimeMs: 12, ctimeMs: 13 }); + const fileSystem = raceFileSystem({ + pathMetadata: before, + openedMetadata: opened, + finalMetadata: opened, + finalPathMetadata: opened, + }); + + expect(() => readStableRegularFile("evidence", "release input", 16, fileSystem)).toThrow( + /changed before read/i, + ); + }); + + it("rejects a same-inode pathname version change after the final descriptor stat", () => { + const stable = fileMetadata(); + const changedPath = fileMetadata({ mtimeMs: 12, ctimeMs: 13 }); + const fileSystem = raceFileSystem({ + pathMetadata: stable, + openedMetadata: stable, + finalMetadata: stable, + finalPathMetadata: changedPath, + }); + + expect(() => readStableRegularFile("evidence", "release input", 16, fileSystem)).toThrow( + /pathname changed/i, + ); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c775feaf3..dfd51c28d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["test/**/*.test.ts", "test/**/*.test.mjs"], + // Real acquisition-integrity tests execute the production audit, whose + // child-process boundary is itself capped at 30 seconds. Keep the outer + // harness bounded but give it enough time to observe that explicit result + // instead of failing first at Vitest's 5-second default on hosted runners. + testTimeout: 35_000, coverage: { reporter: ["json-summary", "text"], include: [ @@ -29,6 +34,7 @@ export default defineConfig({ "scripts/lib/acquisition-data-room-integrity.mjs", "scripts/lib/acquisition-git-preflight.mjs", "scripts/lib/acquisition-private-output.mjs", + "scripts/lib/release-sbom-authority.mjs", "scripts/lib/patch-validator-binary-grype-database-binding.mjs", "scripts/lib/patch-validator-image-receipts.mjs", "scripts/lib/patch-validator-smoke-diagnostic.mjs", @@ -43,4 +49,4 @@ export default defineConfig({ }, }, }, -}); +}); \ No newline at end of file