Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ed7835c
test(security): expose stalled audit response cancellation
seonghobae Aug 31, 2026
6518945
fix(security): keep audit size failure independent of stream cleanup
seonghobae Aug 31, 2026
039ed54
test(security): reject unsafe private-reporting evidence paths
seonghobae Aug 31, 2026
95e24e0
fix(security): bind private-reporting evidence to safe filesystem aut…
seonghobae Aug 31, 2026
e7947f1
merge: incorporate protected main into private-reporting hardening
seonghobae Aug 31, 2026
e9caeae
test(security): preserve retained-report source authority
seonghobae Aug 31, 2026
05ff000
merge: converge private reporting audit with protected main
seonghobae Aug 31, 2026
d94b796
docs(changelog): record private-reporting hardening
seonghobae Aug 31, 2026
c0b2cec
test(security): reject private-report repository dot segments
seonghobae Sep 1, 2026
1c7e143
fix(security): reject private-report repository dot segments
seonghobae Sep 1, 2026
be1db4d
chore(security): converge private-report audit with protected main
seonghobae Sep 1, 2026
cab8944
test(security): retain SHA-256 audit source identity
seonghobae Sep 1, 2026
a1eabf6
fix(security): accept SHA-256 audit source commits
seonghobae Sep 1, 2026
d5b3743
test(security): reject deceptive repository failure evidence
seonghobae Sep 1, 2026
2f38166
fix(security): bind failure evidence to validated repository
seonghobae Sep 1, 2026
9ee8a36
docs(security): record private reporting evidence authority
seonghobae Sep 1, 2026
d8e153d
test(security): retain valid repository on source failure
seonghobae Sep 1, 2026
3e8461f
fix(security): preserve independently validated audit identity
seonghobae Sep 1, 2026
d8c84f4
merge(main): converge private-reporting hardening
seonghobae Sep 1, 2026
840268f
docs(security): align private-reporting source identities
seonghobae Sep 1, 2026
5877669
docs(security): record private reporting audit hardening
seonghobae Sep 1, 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- private vulnerability reporting 감사가 크기 제한 초과 후 끝나지 않는 stream cancellation을 기다리지 않으며, retained report를 기존 private no-follow atomic writer로 발행해 symlink leaf/parent가 다른 인수 증거를 덮어쓰지 못하게 한다.
- production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다.
- Governance and Maintainer App GitHub CLI subprocesses now keep CLI config and XDG state inside the validated capability file's private parent directory, preventing a missing ambient home from writing `.local` state into the checkout.
- Maintainer App readiness now requires the retained governance audit's `protected_main_sha` to equal the freshly collected default-branch head, preventing evidence from different protected-main revisions from being combined into one passing report. The governance collector authenticates every tracked checkout byte against exact HEAD before and after live collection, so modified audit source cannot emit PASS evidence attributed to protected main. Governance and readiness report paths also retain their existing non-symlink private-output authority. The product/technical gap baseline is refreshed to the same protected-main and live issue/run/release observation, and describes the hourly loop through `contextual-orchestrator` rather than retired direct-provider execution.
Expand Down
25 changes: 16 additions & 9 deletions scripts/private-vulnerability-reporting-audit.mjs
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
assertAcquisitionPrivatePathParents,
writeAcquisitionPrivateFile,
} from "./lib/acquisition-private-output.mjs";
import {
evaluatePrivateVulnerabilityReporting,
privateVulnerabilityReportingUrl,
Expand Down Expand Up @@ -95,11 +99,9 @@ export async function readBoundedJson(response) {
}
totalBytes += value.byteLength;
if (totalBytes > MAX_GITHUB_RESPONSE_BYTES) {
try {
await reader.cancel();
} catch {
// Preserve the deterministic size-limit failure if cancellation itself fails.
}
void reader.cancel().catch(() => {
// Cancellation is cleanup only; it cannot delay or replace the size-limit failure.
});
throw new Error("GitHub private vulnerability reporting response exceeded the size limit.");
}
text += decoder.decode(value, { stream: true });
Expand Down Expand Up @@ -164,8 +166,13 @@ async function collectPrivateVulnerabilityReporting(repository) {
*/
function writeReport(path, report) {
const absolutePath = resolve(path);
mkdirSync(dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
assertAcquisitionPrivatePathParents(absolutePath);
mkdirSync(dirname(absolutePath), { recursive: true, mode: 0o700 });
assertAcquisitionPrivatePathParents(absolutePath);
writeAcquisitionPrivateFile(
absolutePath,
`${JSON.stringify(report, null, 2)}\n`,
);
return absolutePath;
}

Expand Down Expand Up @@ -263,4 +270,4 @@ async function main() {
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
if (invokedPath === import.meta.url) {
await main();
}
}
109 changes: 108 additions & 1 deletion test/private-vulnerability-reporting-audit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readBoundedJson } from "../scripts/private-vulnerability-reporting-audit.mjs";
import {
evaluatePrivateVulnerabilityReporting,
privateVulnerabilityReportingUrl,
Expand Down Expand Up @@ -69,6 +80,102 @@ describe("private vulnerability reporting operational audit", () => {
expect(script).toContain("does not prove notification routing or an end-to-end private-report exercise");
});

it("does not let stalled stream cancellation suppress the size-limit failure", async () => {
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array((16 * 1024) + 1));
},
cancel() {
return new Promise<void>(() => {});
},
}),
{ headers: { "content-type": "application/json" } },
);

const outcome = await Promise.race([
readBoundedJson(response).then(
() => ({ kind: "resolved" as const }),
(error) => ({
kind: "rejected" as const,
message: String(error?.message || error),
}),
),
new Promise<{ kind: "timeout" }>((resolve) => {
setTimeout(() => resolve({ kind: "timeout" }), 250);
}),
]);

expect(outcome).toEqual({
kind: "rejected",
message: "GitHub private vulnerability reporting response exceeded the size limit.",
});
});

it("does not follow a symlinked retained-report leaf into unrelated evidence", () => {
const root = mkdtempSync(join(tmpdir(), "noema-private-reporting-"));
try {
const reportDirectory = join(root, "reports");
mkdirSync(reportDirectory);
const victimPath = join(root, "victim.json");
const reportPath = join(reportDirectory, "audit.json");
writeFileSync(victimPath, "buyer-evidence-must-survive\n", "utf8");
symlinkSync(victimPath, reportPath);

const result = spawnSync(
process.execPath,
["scripts/private-vulnerability-reporting-audit.mjs"],
{
cwd: process.cwd(),
env: {
...process.env,
NOEMA_AUDIT_SOURCE_SHA: "not-a-commit",
NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH: reportPath,
},
encoding: "utf8",
timeout: 5_000,
},
);

expect(result.status).not.toBe(0);
expect(readFileSync(victimPath, "utf8")).toBe("buyer-evidence-must-survive\n");
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("does not traverse a symlinked retained-report parent directory", () => {
const root = mkdtempSync(join(tmpdir(), "noema-private-reporting-parent-"));
try {
const outsideDirectory = join(root, "outside");
mkdirSync(outsideDirectory);
const linkedDirectory = join(root, "reports");
symlinkSync(outsideDirectory, linkedDirectory, "dir");
const reportPath = join(linkedDirectory, "audit.json");
const outsideReportPath = join(outsideDirectory, "audit.json");

const result = spawnSync(
process.execPath,
["scripts/private-vulnerability-reporting-audit.mjs"],
{
cwd: process.cwd(),
env: {
...process.env,
NOEMA_AUDIT_SOURCE_SHA: "not-a-commit",
NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH: reportPath,
},
encoding: "utf8",
timeout: 5_000,
},
);

expect(result.status).not.toBe(0);
expect(() => readFileSync(outsideReportPath, "utf8")).toThrow();
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("keeps every named audit helper adjacent to a JSDoc contract", () => {
const script = readFileSync("scripts/private-vulnerability-reporting-audit.mjs", "utf8");
const contracts = [
Expand Down
Loading