diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e3bdb90..589afeda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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를 동일하게 지원한다. @@ -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를 실패-폐쇄 방식으로 검증. \ No newline at end of file diff --git a/docs/security/private-vulnerability-reporting-audit.md b/docs/security/private-vulnerability-reporting-audit.md index 49b872361..5ff5ced42 100644 --- a/docs/security/private-vulnerability-reporting-audit.md +++ b/docs/security/private-vulnerability-reporting-audit.md @@ -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)" \ diff --git a/scripts/lib/private-vulnerability-reporting-audit.mjs b/scripts/lib/private-vulnerability-reporting-audit.mjs index 0909d3f6a..fde183c42 100644 --- a/scripts/lib/private-vulnerability-reporting-audit.mjs +++ b/scripts/lib/private-vulnerability-reporting-audit.mjs @@ -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 @@ -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`; diff --git a/scripts/private-vulnerability-reporting-audit.mjs b/scripts/private-vulnerability-reporting-audit.mjs index d964f27fe..e1d6a7b86 100644 --- a/scripts/private-vulnerability-reporting-audit.mjs +++ b/scripts/private-vulnerability-reporting-audit.mjs @@ -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, @@ -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})$/; /** * Normalize an untrusted diagnostic value into a bounded single-line string. @@ -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( @@ -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; @@ -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 }); @@ -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; } @@ -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; + } catch (error) { + repositoryError = error; + } + + let report; + try { + if (sourceRevisionError) { + throw sourceRevisionError; + } + if (repositoryError) { + throw repositoryError; + } const payload = await collectPrivateVulnerabilityReporting(repository); 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); diff --git a/test/private-vulnerability-reporting-audit.test.ts b/test/private-vulnerability-reporting-audit.test.ts index 3d6978844..58c90c59a 100644 --- a/test/private-vulnerability-reporting-audit.test.ts +++ b/test/private-vulnerability-reporting-audit.test.ts @@ -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, @@ -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.", + ); + } 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", @@ -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({ + start(controller) { + controller.enqueue(new Uint8Array((16 * 1024) + 1)); + }, + cancel() { + return new Promise(() => {}); + }, + }), + { 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 = [ diff --git a/test/private-vulnerability-reporting-invalid-repository-evidence.test.ts b/test/private-vulnerability-reporting-invalid-repository-evidence.test.ts new file mode 100644 index 000000000..cc2cb6090 --- /dev/null +++ b/test/private-vulnerability-reporting-invalid-repository-evidence.test.ts @@ -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 }); + } + }); +}); diff --git a/test/private-vulnerability-reporting-invalid-source-evidence.test.ts b/test/private-vulnerability-reporting-invalid-source-evidence.test.ts new file mode 100644 index 000000000..8398aa054 --- /dev/null +++ b/test/private-vulnerability-reporting-invalid-source-evidence.test.ts @@ -0,0 +1,41 @@ +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 source failure evidence repository authority", () => { + it("retains a separately validated repository when source identity is invalid", () => { + const root = mkdtempSync(join(tmpdir(), "noema-private-reporting-invalid-source-")); + const reportPath = join(root, "audit.json"); + + try { + const result = spawnSync( + process.execPath, + ["scripts/private-vulnerability-reporting-audit.mjs"], + { + cwd: process.cwd(), + env: { + ...process.env, + NOEMA_AUDIT_SOURCE_SHA: "a".repeat(39), + NOEMA_AUDIT_REPOSITORY: "ContextualWisdomLab/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.status).toBe("FAIL"); + expect(report.repository).toBe("ContextualWisdomLab/noema"); + expect(report.source_revision).toBeNull(); + expect(report.failures[0]?.detail).toContain( + "must identify an exact 40-character SHA-1 or 64-character SHA-256 source commit", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/private-vulnerability-reporting-repository-authority.test.ts b/test/private-vulnerability-reporting-repository-authority.test.ts new file mode 100644 index 000000000..f5e80248b --- /dev/null +++ b/test/private-vulnerability-reporting-repository-authority.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { privateVulnerabilityReportingUrl } from "../scripts/lib/private-vulnerability-reporting-audit.mjs"; + +describe("private vulnerability reporting repository authority", () => { + it.each([ + "ContextualWisdomLab/.", + "ContextualWisdomLab/..", + ])("rejects dot-segment repository names before constructing the GitHub endpoint: %s", (repository) => { + expect(() => privateVulnerabilityReportingUrl(repository)).toThrow( + "Repository must identify a ContextualWisdomLab repository.", + ); + }); +});