Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cff6c72
test(security): require stable descriptor-bound release inputs
seonghobae Aug 15, 2026
81297cc
fix(security): bind release inputs to stable descriptors
seonghobae Aug 15, 2026
7d3417e
fix(security): authenticate stable release publication bytes
seonghobae Aug 15, 2026
b0c115c
docs(security): record release file stability rationale
seonghobae Aug 15, 2026
fabf23e
test(security): require atomic publication receipt output
seonghobae Aug 15, 2026
66e6733
fix(security): write publication receipt atomically
seonghobae Aug 15, 2026
b933efe
test(security): require stable release materialization bytes
seonghobae Aug 15, 2026
d91087c
test(security): preserve output-directory validation in release evidence
seonghobae Aug 15, 2026
c5bb620
fix(security): bind release materialization to accepted bytes
seonghobae Aug 15, 2026
7349f79
test(release): require complete publication receipt runtime handoff
seonghobae Aug 15, 2026
5c30326
test(release): require self-contained receipt handoff
seonghobae Aug 15, 2026
80b2641
test(release): specify atomic output behavior independent of helper p…
seonghobae Aug 15, 2026
b00bec9
fix(release): keep publication receipt runtime self-contained
seonghobae Aug 15, 2026
34d8d63
docs(release): align file-stability evidence with isolated runtime
seonghobae Aug 15, 2026
5602fb7
chore(release): converge stable-input lane on protected main
seonghobae Aug 15, 2026
f72c166
Finish release stable-input coverage on current main
seonghobae Aug 15, 2026
bfc4d5a
test: cover stable file metadata failure branches
seonghobae Aug 15, 2026
c91d04b
merge: refresh release publication hardening on protected main
seonghobae Aug 15, 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
47 changes: 47 additions & 0 deletions docs/doctoring/release-publication-file-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Release publication file stability

## Scope

Noema's release materialization and immutable-release publication receipt are buyer-facing supply-chain evidence. Before this change, release materialization used pathname-based reads for the source archive and SBOM, and the receipt validated a pathname before reopening it for JSON parsing or SHA-256 hashing. Those split check/use boundaries allowed local pathname replacement or in-place mutation to substitute different bytes without changing the operator-visible argument. The publication receipt was also copied into an isolated artifact as one file even though it imported a sibling module, so the first real publication would not have had a closed runtime dependency set.

The protected publication workflow already constrains the release bundle to a fixed artifact handoff and rejects symbolic links before publication. The scripts still have to authenticate the exact local bytes they parse and hash, and the executable copied across the isolated handoff must remain runnable without relying on files that are not shipped with it.

## Implemented boundary

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

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

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

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.

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.

## References

Node.js contributors. (2026). *File system: Node.js v25.9.0 documentation*. Node.js. https://nodejs.org/download/release/v25.9.0/docs/api/fs.html

Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218

The Open Group. (2024). *open, openat — open a file*. In *The Open Group Base Specifications Issue 8, IEEE Std 1003.1-2024*. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html
158 changes: 158 additions & 0 deletions scripts/lib/stable-file-evidence.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
} from "node:fs";

const MAXIMUM_SIGNED_OPEN_FLAG = 0x7fff_ffff;
const defaultFileSystem = Object.freeze({
closeSync,
constants,
fstatSync,
lstatSync,
openSync,
readSync,
});

function fail(label, detail) {
throw new Error(`${label} ${detail}`);
}

function safeOpenFlag(value, { allowZero }) {
return Number.isSafeInteger(value)
&& value >= 0
&& value <= MAXIMUM_SIGNED_OPEN_FLAG
&& (allowZero || value !== 0);
}

function requireRegularMetadata(metadata, label, maximumBytes) {
if (!metadata || typeof metadata !== "object" || typeof metadata.isFile !== "function") {
fail(label, "metadata is unavailable");
}
if (typeof metadata.isSymbolicLink === "function" && metadata.isSymbolicLink()) {
fail(label, "must not be a symbolic link");
}
if (!metadata.isFile()) {
fail(label, "must be a regular file");
}
if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) {
fail(label, "has an invalid byte size");
}
if (metadata.size === 0) {
fail(label, "must not be empty");
}
if (metadata.size > maximumBytes) {
fail(label, `exceeds the ${maximumBytes}-byte ceiling`);
}
return metadata;
}

function sameIdentity(left, right) {
return left.dev === right.dev
&& left.ino === right.ino
&& left.mode === right.mode
&& left.size === right.size;
}

function sameStableDescriptor(left, right) {
return sameIdentity(left, right)
&& left.mtimeMs === right.mtimeMs
&& left.ctimeMs === right.ctimeMs;
}

/**
* 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.
*
* @param {string} path filesystem path to read
* @param {string} label bounded diagnostic label that never contains file bytes
* @param {number} maximumBytes positive safe byte ceiling
* @param {object} fileSystem injectable Node-compatible filesystem adapter for deterministic race tests
* @returns {Buffer} exact accepted bytes
*/
export function readStableRegularFile(
path,
label,
maximumBytes,
fileSystem = defaultFileSystem,
) {
if (typeof path !== "string" || path.length === 0) {
fail("stable file", "path must be a non-empty string");
}
if (typeof label !== "string" || label.length === 0) {
fail("stable file", "label must be a non-empty string");
}
if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) {
fail(label, "requires a positive safe byte ceiling");
}

const noFollow = fileSystem.constants?.O_NOFOLLOW;
const readOnly = fileSystem.constants?.O_RDONLY;
if (!safeOpenFlag(noFollow, { allowZero: false })) {
fail(label, "requires a supported no-follow open flag");
}
if (!safeOpenFlag(readOnly, { allowZero: true })) {
fail(label, "requires a supported read-only open flag");
}

const pathMetadata = requireRegularMetadata(
fileSystem.lstatSync(path),
label,
maximumBytes,
);
const descriptor = fileSystem.openSync(path, readOnly | noFollow);
try {
const openedMetadata = requireRegularMetadata(
fileSystem.fstatSync(descriptor),
label,
maximumBytes,
);
if (!sameIdentity(pathMetadata, openedMetadata)) {
fail(label, "changed before read");
}

const chunks = [];
let totalBytes = 0;
while (totalBytes <= maximumBytes) {
const remaining = maximumBytes + 1 - totalBytes;
const target = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const bytesRead = fileSystem.readSync(descriptor, target, 0, target.length, null);
if (bytesRead === 0) {
break;
}
chunks.push(target.subarray(0, bytesRead));
totalBytes += bytesRead;
}
if (totalBytes > maximumBytes) {
fail(label, `exceeded the ${maximumBytes}-byte ceiling while reading`);
}

const finalMetadata = requireRegularMetadata(
fileSystem.fstatSync(descriptor),
label,
maximumBytes,
);
if (!sameStableDescriptor(openedMetadata, finalMetadata)) {
fail(label, "changed while being read");
}
if (totalBytes !== openedMetadata.size) {
fail(label, "byte count differs from the opened descriptor size");
}

const finalPathMetadata = requireRegularMetadata(
fileSystem.lstatSync(path),
label,
maximumBytes,
);
if (!sameIdentity(openedMetadata, finalPathMetadata)) {
fail(label, "pathname changed while being read");
}
return Buffer.concat(chunks, totalBytes);
} finally {
fileSystem.closeSync(descriptor);
}
}
80 changes: 29 additions & 51 deletions scripts/release-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { basename, resolve } from "node:path";
import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs";
import { readStableRegularFile } from "./lib/stable-file-evidence.mjs";
import {
hasDuplicateJsonObjectKeys,
writeAtomically,
} from "./normalize-commercial-readiness-evidence.mjs";

const EXPECTED_REPOSITORY = "ContextualWisdomLab/noema";
const EXPECTED_SBOM_NAME = "noema.cdx.json";
Expand Down Expand Up @@ -52,38 +54,25 @@ function parseArguments(argv) {
};
}

function requireRegularFile(path, label, maxBytes) {
if (!existsSync(path)) {
fail(`${label} does not exist: ${path}`);
}
const linkStatus = lstatSync(path);
if (linkStatus.isSymbolicLink()) {
fail(`${label} must not be a symbolic link`);
}
const status = statSync(path);
if (!status.isFile()) {
fail(`${label} must be a regular file`);
}
if (status.size <= 0) {
fail(`${label} must not be empty`);
}
if (status.size > maxBytes) {
fail(`${label} exceeds the ${maxBytes}-byte limit`);
}
return status;
}

function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}

function requireString(value, label) {
if (typeof value !== "string" || value.trim().length === 0) {
fail(`${label} must be a non-empty string`);
}
return value.trim();
}

function readStableBytes(path, label, maximumBytes) {
try {
return readStableRegularFile(path, label, maximumBytes);
} catch (error) {
fail(`${label} could not be read safely: ${error instanceof Error ? error.message : String(error)}`);
}
}

function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}

function validateReleaseIdentity() {
const repository = requireString(process.env.GITHUB_REPOSITORY, "GITHUB_REPOSITORY");
const commitSha = requireString(
Expand Down Expand Up @@ -183,12 +172,6 @@ function validateSbom(sbom, version) {
};
}

function requireSafeOutputPath(path, label) {
if (existsSync(path) && lstatSync(path).isSymbolicLink()) {
fail(`${label} must not be a symbolic link`);
}
}

function run() {
const { sourcePath, sbomPath, outputDir } = parseArguments(process.argv.slice(2));
const identity = validateReleaseIdentity();
Expand All @@ -203,11 +186,11 @@ function run() {
fail("source archive and SBOM paths must be different files");
}

const sourceStatus = requireRegularFile(sourcePath, "source archive", MAX_SOURCE_BYTES);
const sbomStatus = requireRegularFile(sbomPath, "SBOM", MAX_SBOM_BYTES);
const sourceBytes = readStableBytes(sourcePath, "source archive", MAX_SOURCE_BYTES);
const sbomBytes = readStableBytes(sbomPath, "SBOM", MAX_SBOM_BYTES);
let sbomText;
try {
sbomText = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(sbomPath));
sbomText = new TextDecoder("utf-8", { fatal: true }).decode(sbomBytes);
} catch (error) {
fail(`SBOM is not valid UTF-8: ${error instanceof Error ? error.message : String(error)}`);
}
Expand Down Expand Up @@ -235,11 +218,8 @@ function run() {

const manifestPath = resolve(outputDir, "release-evidence.json");
const checksumsPath = resolve(outputDir, "SHA256SUMS");
requireSafeOutputPath(manifestPath, "release evidence manifest");
requireSafeOutputPath(checksumsPath, "checksum manifest");

const sourceDigest = sha256(sourcePath);
const sbomDigest = sha256(sbomPath);
const sourceDigest = sha256(sourceBytes);
const sbomDigest = sha256(sbomBytes);
const manifest = {
schemaVersion: 1,
generatedAt: identity.generatedAt,
Expand All @@ -252,28 +232,26 @@ function run() {
subject: {
name: basename(sourcePath),
sha256: sourceDigest,
bytes: sourceStatus.size,
bytes: sourceBytes.byteLength,
mediaType: "application/gzip",
},
sbom: {
name: basename(sbomPath),
sha256: sbomDigest,
bytes: sbomStatus.size,
bytes: sbomBytes.byteLength,
...sbomSummary,
},
};

writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
mode: 0o644,
});
const manifestDigest = sha256(manifestPath);
const manifestText = `${JSON.stringify(manifest, null, 2)}\n`;
const manifestDigest = sha256(Buffer.from(manifestText, "utf8"));
writeAtomically(manifestPath, manifestText);
const checksums = [
`${sourceDigest} ${basename(sourcePath)}`,
`${sbomDigest} ${basename(sbomPath)}`,
`${manifestDigest} ${basename(manifestPath)}`,
].join("\n");
writeFileSync(checksumsPath, `${checksums}\n`, { encoding: "utf8", mode: 0o644 });
writeAtomically(checksumsPath, `${checksums}\n`);

console.log(
`release-evidence: PASS repository=${identity.repository} version=${identity.version} head=${identity.commitSha}`,
Expand All @@ -286,4 +264,4 @@ try {
const message = error instanceof Error ? error.message : String(error);
console.error(`release-evidence: FAIL: ${message.slice(0, 1000)}`);
process.exitCode = 1;
}
}
Loading
Loading