Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions .github/workflows/private-vulnerability-reporting-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: private-vulnerability-reporting-audit

on:
workflow_dispatch:
schedule:
- cron: "17 2 * * *"

permissions:
contents: read

jobs:
audit:
name: private-vulnerability-reporting-setting
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: checkout exact source without persisted credentials
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24.19.0"

- name: verify exact checkout
shell: bash
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
test -z "$(git status --porcelain=v2 --untracked-files=all --ignored=matching)"

- name: collect read-only private vulnerability reporting evidence
env:
NOEMA_AUDIT_REPOSITORY: ${{ github.repository }}
NOEMA_AUDIT_SOURCE_SHA: ${{ github.sha }}
NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH: artifacts/security/private-vulnerability-reporting-audit.json
run: npm run security:private-reporting-audit

- name: upload bounded audit evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: private-vulnerability-reporting-audit
path: artifacts/security/private-vulnerability-reporting-audit.json
if-no-files-found: error
retention-days: 30
22 changes: 16 additions & 6 deletions docs/security/private-vulnerability-reporting-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ 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.

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 40-character source revision. Run the probe from a trusted operator environment with outbound HTTPS access to GitHub:

```bash
node scripts/private-vulnerability-reporting-audit.mjs
NOEMA_AUDIT_SOURCE_SHA="$(git rev-parse HEAD)" \
node scripts/private-vulnerability-reporting-audit.mjs
```

The equivalent package command is:

```bash
NOEMA_AUDIT_SOURCE_SHA="$(git rev-parse HEAD)" \
npm run security:private-reporting-audit
```

The default machine-readable result is written to:
Expand All @@ -18,13 +26,15 @@ The default machine-readable result is written to:
artifacts/security/private-vulnerability-reporting-audit.json
```

`NOEMA_AUDIT_REPOSITORY` may select another `ContextualWisdomLab/<repository>` for the same read-only check. The audit rejects repositories outside that organization boundary. `NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH` may select an operator-controlled local evidence path.
`NOEMA_AUDIT_REPOSITORY` may select another `ContextualWisdomLab/<repository>` for the same read-only check. The audit rejects repositories outside that organization boundary. `NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH` may select an operator-controlled local evidence path. `NOEMA_AUDIT_SOURCE_SHA` is required outside GitHub Actions unless `GITHUB_SHA` already identifies the exact source commit.

Protected source also contains `.github/workflows/private-vulnerability-reporting-audit.yml`. It runs the same probe on manual dispatch and a daily schedule with read-only repository permission, credential-free checkout, exact `github.sha` binding, and a 30-day bounded artifact retention period. The workflow does not change the GitHub setting and does not create reporter, staffing, review, merge, release, or deployment authority.

## Acceptance semantics

The probe performs only GitHub's status `GET` for private vulnerability reporting, with a bounded request timeout and response byte ceiling. It fails closed when the request is unavailable, GitHub returns a non-success status, the body is empty or malformed, the response exceeds the byte limit, or `enabled` is anything other than the boolean `true`.
The probe performs only GitHub's status `GET` for private vulnerability reporting, with a bounded request timeout and response byte ceiling. It fails closed when source identity is absent/malformed, the request is unavailable, GitHub returns a non-success status, the body is empty or malformed, the response exceeds the byte limit, or `enabled` is anything other than the boolean `true`.

`status: PASS` proves only that GitHub reported the setting enabled for the named repository at the evidence timestamp. Preserve the report together with the exact source revision used to run the probe and the trusted execution context when using it for an operational or acquisition review.
`status: PASS` proves only that GitHub reported the setting enabled for the named repository at the evidence timestamp and that the report records the exact source revision that performed the probe. Preserve the immutable workflow/run identity with the retained artifact when using it for an operational or acquisition review.

A passing report does not prove reporter UI visibility, notification routing, staffing, case access, or an end-to-end exercise. It also does not prove that two independent maintainers can receive and handle a private report, that the fallback public-contact lifecycle works, or that a security advisory can be taken through remediation and disclosure. Those live acceptance items remain tracked by issue #73 until separately exercised and evidenced.

Expand All @@ -40,7 +50,7 @@ Do not edit a failing report into PASS. Re-run the read-only probe after an auth

The following evidence is required before issue #73 can be considered operationally complete:

1. the read-only setting probe records `status: PASS` against the intended repository;
1. the read-only setting probe records `status: PASS` against the intended repository and binds the exact source revision;
2. an external non-maintainer view can reach GitHub's private vulnerability reporting entry point without exposing vulnerability details publicly;
3. at least two authorized maintainers or the approved security team can access the resulting private case through least-privileged roles;
4. a benign synthetic report exercises acknowledgement, ownership, private transition, and closure without using real secrets or exploit payloads;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"release:verify:strict": "npm run typecheck && npm run test && npm run security:scan && npm run kpi:verify:strict && npm run acquisition:manifest && npm run acquisition:integrity",
"security:scan": "npm audit --audit-level=high",
"security:evidence": "node scripts/security-validation-evidence.mjs",
"security:private-reporting-audit": "node scripts/private-vulnerability-reporting-audit.mjs",
"test": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"governance:audit": "node scripts/main-governance-audit.mjs",
Expand Down
41 changes: 36 additions & 5 deletions scripts/private-vulnerability-reporting-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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}$/;

/**
* Normalize an untrusted diagnostic value into a bounded single-line string.
Expand All @@ -27,6 +28,26 @@ function bound(value, limit = MAX_ERROR_CHARS) {
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
}

/**
* 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.
*/
function sourceRevisionFromEnvironment() {
const revision = String(
process.env.NOEMA_AUDIT_SOURCE_SHA
|| process.env.GITHUB_SHA
|| "",
).trim().toLowerCase();
if (!fullCommitPattern.test(revision)) {
throw new Error(
"NOEMA_AUDIT_SOURCE_SHA or GITHUB_SHA must identify the exact 40-character source commit.",
);
}
return revision;
}

/**
* Read a GitHub JSON response without buffering an unbounded remote body.
*
Expand Down Expand Up @@ -153,12 +174,14 @@ function writeReport(path, report) {
*
* @param {string} repository Repository that was being audited.
* @param {unknown} error Collection failure.
* @param {string | null} sourceRevision Exact source commit when available.
* @returns {object} Fail-closed audit report with bounded diagnostic detail.
*/
function buildCollectionFailure(repository, error) {
function buildCollectionFailure(repository, error, sourceRevision = null) {
return {
schema_version: 1,
repository,
source_revision: sourceRevision,
generated_at: new Date().toISOString(),
source: "github-private-vulnerability-reporting-status",
status: "FAIL",
Expand All @@ -176,16 +199,18 @@ function buildCollectionFailure(repository, error) {
}

/**
* Bind a normalized evaluation to repository identity and observation time.
* Bind a normalized evaluation to repository identity, exact source, and observation time.
*
* @param {string} repository Audited repository identity.
* @param {{status: "PASS" | "FAIL", enabled: boolean | null, failures: Array<{code: string, detail: string}>}} evaluation Evaluation returned by the policy helper.
* @param {string} sourceRevision Exact source commit that performed the observation.
* @returns {object} Machine-readable operational evidence report.
*/
function buildReport(repository, evaluation) {
function buildReport(repository, evaluation, sourceRevision) {
return {
schema_version: 1,
repository,
source_revision: sourceRevision,
generated_at: new Date().toISOString(),
source: "github-private-vulnerability-reporting-status",
status: evaluation.status,
Expand Down Expand Up @@ -213,12 +238,18 @@ async function main() {
process.env.NOEMA_PRIVATE_VULNERABILITY_REPORTING_AUDIT_PATH || defaultReportPath,
).trim() || defaultReportPath;

let sourceRevision = null;
let report;
try {
sourceRevision = sourceRevisionFromEnvironment();
const payload = await collectPrivateVulnerabilityReporting(repository);
report = buildReport(repository, evaluatePrivateVulnerabilityReporting(payload));
report = buildReport(
repository,
evaluatePrivateVulnerabilityReporting(payload),
sourceRevision,
);
} catch (error) {
report = buildCollectionFailure(repository || "unknown", error);
report = buildCollectionFailure(repository || "unknown", error, sourceRevision);
}

const absoluteReportPath = writeReport(reportPath, report);
Expand Down
25 changes: 24 additions & 1 deletion test/private-vulnerability-reporting-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ describe("private vulnerability reporting operational audit", () => {
expect(script).not.toContain('method: "PUT"');
expect(script).not.toContain('method: "DELETE"');
expect(script).toContain("artifacts/security/private-vulnerability-reporting-audit.json");
expect(script).toContain("NOEMA_AUDIT_SOURCE_SHA");
expect(script).toContain("source_revision");
expect(script).toContain('if (report.status !== "PASS")');
expect(script).toContain("process.exitCode = 1");
expect(script).toContain("does not prove notification routing or an end-to-end private-report exercise");
Expand Down Expand Up @@ -113,4 +115,25 @@ describe("private vulnerability reporting operational audit", () => {
"[private vulnerability reporting operational audit](docs/security/private-vulnerability-reporting-audit.md)",
);
});
});

it("retains protected-source setting evidence through a read-only scheduled workflow", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
const workflow = readFileSync(".github/workflows/private-vulnerability-reporting-audit.yml", "utf8");

expect(packageJson.scripts["security:private-reporting-audit"]).toBe(
"node scripts/private-vulnerability-reporting-audit.mjs",
);
expect(workflow).toContain("name: private-vulnerability-reporting-audit");
expect(workflow).toContain("workflow_dispatch:");
expect(workflow).toContain("schedule:");
expect(workflow).toContain("contents: read");
expect(workflow).not.toContain("contents: write");
expect(workflow).not.toContain("pull-requests: write");
expect(workflow).not.toContain("secrets:");
expect(workflow).toContain("persist-credentials: false");
expect(workflow).toContain("NOEMA_AUDIT_SOURCE_SHA: ${{ github.sha }}");
expect(workflow).toContain("npm run security:private-reporting-audit");
expect(workflow).toContain("artifacts/security/private-vulnerability-reporting-audit.json");
expect(workflow).toContain("retention-days: 30");
});
});
1 change: 1 addition & 0 deletions test/upload-artifact-node24-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const supportedWorkflowPaths = [
".github/workflows/hourly-commercial-readiness.yml",
".github/workflows/hourly-product-development.yml",
".github/workflows/maintainer-app-readiness.yml",
".github/workflows/private-vulnerability-reporting-audit.yml",
".github/workflows/readiness-scan.yml",
".github/workflows/release-evidence.yml",
] as const;
Expand Down
Loading