Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
100 commits
Select commit Hold shift + click to select a range
38e5e6e
test(acquisition): reject normalized data-room authority
seonghobae Aug 23, 2026
5c2cd77
fix(acquisition): preserve exact data-room authority bytes
seonghobae Aug 23, 2026
6c4d4e8
fix(acquisition): validate exact retained authority
seonghobae Aug 23, 2026
1fcd47c
test(acquisition): require lowercase source authority
seonghobae Aug 23, 2026
357e5e7
test(acquisition): reject non-canonical SemVer tags
seonghobae Aug 23, 2026
22249d2
fix(acquisition): require canonical SemVer tag authority
seonghobae Aug 23, 2026
723819f
fix(acquisition): require canonical retained SemVer authority
seonghobae Aug 23, 2026
b0b0aa7
test(release): reject normalized release identity authority
seonghobae Aug 23, 2026
8837e0b
fix(release): preserve exact identity authority bytes
seonghobae Aug 23, 2026
d085664
test(release): reject normalized publication identity authority
seonghobae Aug 23, 2026
e8476ce
fix(release): preserve exact publication authority bytes
seonghobae Aug 23, 2026
92a015e
test(release): reject ambiguous workflow-run evidence URLs
seonghobae Aug 23, 2026
7bdbe47
fix(release): bind verification to exact Actions run URL
seonghobae Aug 23, 2026
bb89c8b
test(release): reject ambiguous SBOM bom-ref identities
seonghobae Aug 23, 2026
864b5c0
fix(release): require canonical SBOM bom-ref identities
seonghobae Aug 23, 2026
8513add
test(release): reject dangling SBOM dependency identities
seonghobae Aug 23, 2026
da61018
fix(release): bind SBOM dependency graph to declared identities
seonghobae Aug 23, 2026
dc376df
test(release): cover fail-closed SBOM dependency graph branches
seonghobae Aug 23, 2026
2fb1a4b
test(acquisition): reject normalized diligence tag authority
seonghobae Aug 23, 2026
5e4682a
fix(acquisition): preserve exact diligence tag authority
seonghobae Aug 23, 2026
2c83355
fix(exchange): reject non-canonical target repository authority (#499)
seonghobae Aug 24, 2026
0df3391
test(release): require complete SBOM dependency graph
seonghobae Aug 24, 2026
b5b6063
fix(release): require complete SBOM dependency graph
seonghobae Aug 24, 2026
a666b02
Merge b5b60632a1b3a138766ac057574c5a4c8bcff08f into 2c83355529447248c…
seonghobae Aug 24, 2026
13d65f8
test(release): model complete buyer SBOM dependency graph
seonghobae Aug 24, 2026
94484c5
test(release): reject control bytes in bom-ref authority
seonghobae Aug 24, 2026
11936b3
fix(release): reject ambiguous bom-ref control bytes
seonghobae Aug 24, 2026
a11048c
test(release): require release evidence in exact coverage
seonghobae Aug 24, 2026
33f9af3
fix(release): cover canonical SBOM authority
seonghobae Aug 24, 2026
c4f4720
test(release): reject ambiguous Unicode bom-ref spaces
seonghobae Aug 24, 2026
5c371d6
fix(release): reject ambiguous Unicode bom-ref spaces
seonghobae Aug 24, 2026
7618c3d
test(release): preserve canonical ASCII bom-ref spacing
seonghobae Aug 24, 2026
3143c97
fix(release): preserve canonical ASCII bom-ref spacing
seonghobae Aug 24, 2026
31e8b8c
test(release): reject normalized checksum authority
seonghobae Aug 24, 2026
4a25e38
fix(release): require canonical checksum authority
seonghobae Aug 24, 2026
62f3520
test(release): cover blank checksum authority lines
seonghobae Aug 24, 2026
97970e6
test(release): reject duplicate checksum authority
seonghobae Aug 24, 2026
11a199d
fix(release): reject duplicate checksum authority
seonghobae Aug 24, 2026
4677fc5
test(release): reject non-NFC SBOM authority
seonghobae Aug 24, 2026
218d455
fix(release): require NFC SBOM authority
seonghobae Aug 24, 2026
ad0baff
test(release): reject surrogate SBOM authority
seonghobae Aug 24, 2026
0cf5982
fix(release): reject surrogate SBOM authority
seonghobae Aug 24, 2026
2f4c289
test(acquisition): align integration timeout with bounded audit
seonghobae Aug 24, 2026
fbadde3
test(release): prevent bom-ref leakage in failures
seonghobae Aug 24, 2026
7823997
fix(release): redact bom-ref failure authority
seonghobae Aug 24, 2026
3fd357f
test(release): redact hostile SBOM metadata
seonghobae Aug 24, 2026
5fcf215
fix(release): redact untrusted SBOM metadata
seonghobae Aug 24, 2026
00590e5
test(release): avoid static token-like fixture
seonghobae Aug 24, 2026
6fdd411
test(release): avoid static metadata token fixture
seonghobae Aug 24, 2026
297a862
test(release): prevent malformed JSON log leakage
seonghobae Aug 24, 2026
fc353ac
fix(release): redact malformed JSON parse context
seonghobae Aug 24, 2026
22907f7
test(release): fail closed on excessive SBOM nesting
seonghobae Aug 24, 2026
13acdab
fix(release): bound CycloneDX nesting depth
seonghobae Aug 24, 2026
67e9eaf
test(release): require nested components in dependency graph
seonghobae Aug 24, 2026
058f409
fix(release): require nested components in dependency graph
seonghobae Aug 24, 2026
df97e32
test(release): reject malformed nested component assemblies
seonghobae Aug 24, 2026
6aaaa00
fix(release): validate nested component assembly shape
seonghobae Aug 24, 2026
0738f90
test(release): count nested SBOM components
seonghobae Aug 24, 2026
c2a7d30
fix(release): count nested SBOM components
seonghobae Aug 24, 2026
7e1f1d2
chore(release): preserve source byte boundary
seonghobae Aug 24, 2026
3c869cf
test(release): reject empty explicit identity overrides
seonghobae Aug 24, 2026
db4fafa
test(release): cover empty explicit generated-at override
seonghobae Aug 24, 2026
d353ca4
fix(release): fail closed on empty explicit identity overrides
seonghobae Aug 24, 2026
7dfd5c1
test(release): reject empty publication identity overrides
seonghobae Aug 24, 2026
60b8eb2
fix(release): fail closed on empty publication identity overrides
seonghobae Aug 24, 2026
a4b7a12
test(release): reject symlinked evidence parents
seonghobae Aug 24, 2026
a1dae7c
fix(release): reject symlinked evidence parents
seonghobae Aug 24, 2026
58e1eb6
test(release): reject symlinked publication parents
seonghobae Aug 24, 2026
48fd6b3
fix(release): bind publication inputs to real parents
seonghobae Aug 24, 2026
5e5e61b
docs(release): describe parent-path evidence binding
seonghobae Aug 24, 2026
c2ad18b
test(release): reject non-RFC4122 SBOM serial UUIDs
seonghobae Aug 24, 2026
699ab92
fix(release): enforce RFC4122 SBOM serial UUID semantics
seonghobae Aug 24, 2026
b63066c
test(sbom): reject BOM-Link-prefixed bom-ref authority
seonghobae Aug 24, 2026
608b70f
fix(sbom): reject BOM-Link-prefixed bom-ref authority
seonghobae Aug 24, 2026
67577fb
test(release): reject non-gzip source evidence
seonghobae Aug 24, 2026
61d4f74
fix(release): verify bounded gzip source evidence
seonghobae Aug 24, 2026
b8c1b9b
test(release): use real gzip source fixture
seonghobae Aug 24, 2026
cb6ec44
test(release): use real gzip source fixture
seonghobae Aug 24, 2026
baef8a1
test(release): normalize gzip source fixtures
seonghobae Aug 24, 2026
7947bdc
test(sbom): reject nil release serial identity
seonghobae Aug 24, 2026
d5e9809
fix(sbom): require unique non-nil release serial identity
seonghobae Aug 24, 2026
e88e626
test(acquisition): reject parent symlink after output open
seonghobae Aug 24, 2026
b964029
fix(acquisition): recheck parents after private output open
seonghobae Aug 24, 2026
a102704
test(acquisition): reject parent symlink after staging open
seonghobae Aug 24, 2026
5f4d11e
fix(acquisition): recheck parents after staging output open
seonghobae Aug 24, 2026
2be21fd
test(acquisition): remove partial new output after write failure
seonghobae Aug 24, 2026
a48a136
fix(acquisition): clean partial new private output on failure
seonghobae Aug 24, 2026
400c7d7
test(release): use valid gzip source fixture
seonghobae Aug 24, 2026
5e88e31
test(release): gzip nested component fixture
seonghobae Aug 24, 2026
a22a5c7
test(release): assert malformed nested component diagnostic
seonghobae Aug 24, 2026
1d9d590
test(acquisition): cover unauthenticated cleanup guard
seonghobae Aug 24, 2026
1a006ff
test(acquisition): reject hard-linked release evidence
seonghobae Aug 24, 2026
6b2b596
fix(acquisition): reject hard-linked release evidence
seonghobae Aug 24, 2026
5fc3fd6
test(release): reject hard-linked publication evidence
seonghobae Aug 24, 2026
621e421
fix(release): reject hard-linked publication evidence
seonghobae Aug 24, 2026
cc3d93d
test(acquisition): detect retained-evidence version races
seonghobae Aug 24, 2026
af0780b
fix(acquisition): bind evidence path to file version
seonghobae Aug 24, 2026
2f6bcfc
test(release): reject dot-segment symlink parent bypass
seonghobae Aug 27, 2026
e2c676a
test(release): clarify canonical evidence path contract
seonghobae Aug 27, 2026
8789922
fix(release): reject dot-segment evidence path aliases
seonghobae Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions docs/doctoring/release-publication-file-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,31 @@ 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.

The descriptor reader fails closed when a runtime cannot provide the no-follow flag. Noema's publication workflow currently executes on Ubuntu GitHub-hosted runners; this control does not claim equivalent filesystem semantics on runtimes where Node does not expose the required flag.

## 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.

Expand Down
15 changes: 8 additions & 7 deletions scripts/acquisition-data-room-integrity-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,27 +25,27 @@ 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;
}

/** 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,
Expand Down
15 changes: 8 additions & 7 deletions scripts/acquisition-data-room-manifest-secure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,27 +20,27 @@ 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;
}

/** 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,
Expand Down
2 changes: 1 addition & 1 deletion scripts/acquisition-readiness-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 35 additions & 15 deletions scripts/lib/acquisition-private-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,29 @@ 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.
*
* 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,
Expand All @@ -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)
Expand All @@ -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);
}
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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);
}
}
}
17 changes: 17 additions & 0 deletions scripts/lib/release-sbom-authority.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading