Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Changelog

## Unreleased
- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다.
- External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed.
- 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 상태로 승인되지 않게 한다.
- acquisition tracked-byte 인증이 descriptor에서 읽은 bytes를 Git blob framing으로 Node 표준 crypto에서 직접 해시해, 파일마다 `git hash-object` subprocess를 만들던 대형 checkout 병목을 제거한다. exact tree inventory는 Git 2.36 전용 `ls-tree --format` 대신 호환되는 기본 NUL 형식을 사용하며, object ID, SHA-1/SHA-256 저장소, no-follow·descriptor identity·byte limit 실패-폐쇄 계약은 유지한다. dependency-license inventory가 실제로 소비한 `package-lock.json` bytes도 pinned source commit의 Git blob과 직접 대조해 transient file swap을 차단한다. 실패한 audit stage 뒤에도 source를 다시 인증한 다음 원래 child status로 종료하므로 failure evidence가 stale revision으로 남지 않으며, release·publication·deployment evidence producer와 acquisition consumer는 canonical SHA-1/SHA-256 commit identity를 동일하게 지원한다.
Expand Down Expand Up @@ -84,4 +85,4 @@
- 배포 스모크가 `/health`와 `/exchange`의 no-store/nosniff 보안 헤더 및 `/exchange` 401 Bearer challenge까지 검증하도록 `smoke-readiness.sh`와 회귀 테스트를 보강.
- `/exchange` 401 응답에 `WWW-Authenticate: Bearer realm="noema"` challenge를 추가하고 인증 누락은 `invalid_request`, 잘못된 토큰은 `invalid_token`으로 구분.
- `x-request-id`/`x-correlation-id` 및 client IP 계열 헤더를 길이/문자 기준으로 제한해 로그 오염과 rate-limit key 폭주를 방지.
- `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증.
- `KRW 2,000,000,000` 매각 가능성 Goal 등록서, buyer due diligence index, library/submodule 경계 판단서를 추가하고 `npm run acquisition:audit`로 ARR/LOI/이전성/saleable evidence를 실패-폐쇄 방식으로 검증.
2 changes: 1 addition & 1 deletion docs/security/private-vulnerability-reporting-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This runbook explains the repository-owned, read-only evidence probe for GitHub

The repository can test whether GitHub currently reports private vulnerability reporting as enabled without granting the audit code permission to change that setting. The audit is deliberately evidence-only: it cannot enable or disable private vulnerability reporting, create a security advisory, change repository permissions, approve a pull request, merge, release, or deploy.

The report is fail-closed unless it can bind the observation to one exact 40-character source revision. Run the probe from a trusted operator environment with outbound HTTPS access to GitHub:
The report is fail-closed unless it can bind the observation to one exact full 40-character SHA-1 or 64-character SHA-256 source revision. Run the probe from a trusted operator environment with outbound HTTPS access to GitHub:

```bash
NOEMA_AUDIT_SOURCE_SHA="$(git rev-parse HEAD)" \
Expand Down
10 changes: 9 additions & 1 deletion scripts/lib/private-vulnerability-reporting-audit.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const repositoryPattern = /^ContextualWisdomLab\/[A-Za-z0-9_.-]+$/;
const repositoryPrefix = "ContextualWisdomLab/";

/**
* Build the canonical GitHub REST endpoint used to read Noema's private
Expand All @@ -9,7 +10,14 @@ const repositoryPattern = /^ContextualWisdomLab\/[A-Za-z0-9_.-]+$/;
*/
export function privateVulnerabilityReportingUrl(repository) {
const normalized = String(repository ?? "").trim();
if (!repositoryPattern.test(normalized)) {
const repositoryName = normalized.startsWith(repositoryPrefix)
? normalized.slice(repositoryPrefix.length)
: "";
if (
!repositoryPattern.test(normalized)
|| repositoryName === "."
|| repositoryName === ".."
) {
throw new Error("Repository must identify a ContextualWisdomLab repository.");
}
return `https://api.github.com/repos/${normalized}/private-vulnerability-reporting`;
Expand Down
57 changes: 43 additions & 14 deletions scripts/private-vulnerability-reporting-audit.mjs
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 All @@ -12,7 +16,7 @@ const MAX_ERROR_CHARS = 2_000;
const MAX_GITHUB_REQUEST_MILLISECONDS = 20_000;
const MAX_GITHUB_RESPONSE_BYTES = 16 * 1024;
const defaultReportPath = "artifacts/security/private-vulnerability-reporting-audit.json";
const fullCommitPattern = /^[0-9a-f]{40}$/;
const fullCommitPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.

/**
* Normalize an untrusted diagnostic value into a bounded single-line string.
Expand All @@ -32,7 +36,7 @@ function bound(value, limit = MAX_ERROR_CHARS) {
* Resolve the exact source commit that produced the audit evidence.
*
* @returns {string} Lowercase full Git commit SHA supplied by the trusted execution environment.
* @throws {Error} When source identity is absent or not one full hexadecimal commit SHA.
* @throws {Error} When source identity is absent or not one full hexadecimal SHA-1/SHA-256 commit.
*/
function sourceRevisionFromEnvironment() {
const revision = String(
Expand All @@ -42,7 +46,7 @@ function sourceRevisionFromEnvironment() {
).trim().toLowerCase();
if (!fullCommitPattern.test(revision)) {
throw new Error(
"NOEMA_AUDIT_SOURCE_SHA or GITHUB_SHA must identify the exact 40-character source commit.",
"NOEMA_AUDIT_SOURCE_SHA or GITHUB_SHA must identify an exact 40-character SHA-1 or 64-character SHA-256 source commit.",
);
}
return revision;
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 @@ -239,17 +246,39 @@ async function main() {
).trim() || defaultReportPath;

let sourceRevision = null;
let report;
let evidenceRepository = "unknown";
let sourceRevisionError = null;
let repositoryError = null;

try {
sourceRevision = sourceRevisionFromEnvironment();
} catch (error) {
sourceRevisionError = error;
}

try {
privateVulnerabilityReportingUrl(repository);
evidenceRepository = repository;
Comment thread
seonghobae marked this conversation as resolved.
} catch (error) {
repositoryError = error;
}

let report;
try {
if (sourceRevisionError) {
throw sourceRevisionError;
}
if (repositoryError) {
throw repositoryError;
}
const payload = await collectPrivateVulnerabilityReporting(repository);
Comment thread
seonghobae marked this conversation as resolved.
report = buildReport(
repository,
evidenceRepository,
evaluatePrivateVulnerabilityReporting(payload),
sourceRevision,
);
} catch (error) {
report = buildCollectionFailure(repository || "unknown", error, sourceRevision);
report = buildCollectionFailure(evidenceRepository, error, sourceRevision);
}

const absoluteReportPath = writeReport(reportPath, report);
Expand Down
142 changes: 141 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 @@ -39,6 +50,39 @@ describe("private vulnerability reporting operational audit", () => {
expect(evaluatePrivateVulnerabilityReporting(null).status).toBe("FAIL");
});

it("retains SHA-256 Git source identity in fail-closed audit evidence", () => {
const root = mkdtempSync(join(tmpdir(), "noema-private-reporting-sha256-"));
const reportPath = join(root, "audit.json");
const sourceRevision = "a".repeat(64);
try {
const result = spawnSync(
process.execPath,
["scripts/private-vulnerability-reporting-audit.mjs"],
{
cwd: process.cwd(),
env: {
...process.env,
NOEMA_AUDIT_SOURCE_SHA: sourceRevision,
NOEMA_AUDIT_REPOSITORY: "OtherOrg/noema",
NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH: reportPath,
},
encoding: "utf8",
timeout: 5_000,
},
);

expect(result.status).toBe(1);
const report = JSON.parse(readFileSync(reportPath, "utf8"));
expect(report.source_revision).toBe(sourceRevision);
expect(report.status).toBe("FAIL");
expect(report.failures[0]?.detail).toContain(
"Repository must identify a ContextualWisdomLab repository.",
);
Comment thread
seonghobae marked this conversation as resolved.
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it("binds collection to the expected public GitHub repository endpoint", () => {
expect(privateVulnerabilityReportingUrl("ContextualWisdomLab/noema")).toBe(
"https://api.github.com/repos/ContextualWisdomLab/noema/private-vulnerability-reporting",
Expand Down Expand Up @@ -69,6 +113,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

describe("private vulnerability reporting failure evidence repository authority", () => {
it("does not retain a deceptive invalid repository identity", () => {
const root = mkdtempSync(join(tmpdir(), "noema-private-reporting-invalid-repository-"));
const reportPath = join(root, "audit.json");
const deceptiveRepository = "ContextualWisdomLab/noema\u202Egpj";

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

expect(result.status).toBe(1);
const report = JSON.parse(readFileSync(reportPath, "utf8"));
expect(report.status).toBe("FAIL");
expect(report.repository).toBe("unknown");
expect(report.failures[0]?.detail).toContain(
"Repository must identify a ContextualWisdomLab repository.",
);
expect(JSON.stringify(report)).not.toContain("\u202E");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading
Loading