diff --git a/.github/workflows/security-advisory-access-tests.yml b/.github/workflows/security-advisory-access-tests.yml new file mode 100644 index 0000000..c894a12 --- /dev/null +++ b/.github/workflows/security-advisory-access-tests.yml @@ -0,0 +1,36 @@ +name: Advisory access policy tests + +on: + pull_request: + paths: + - scripts/reconcile-security-advisory-access.mjs + - tests/security-advisory-access.test.mjs + - security-advisory-access.config.json + - .github/workflows/security-advisory-access.yml + - .github/workflows/security-advisory-access-tests.yml + push: + branches: + - main + paths: + - scripts/reconcile-security-advisory-access.mjs + - tests/security-advisory-access.test.mjs + - security-advisory-access.config.json + - .github/workflows/security-advisory-access.yml + - .github/workflows/security-advisory-access-tests.yml + +permissions: + contents: read + +jobs: + policy-tests: + name: Advisory access policy tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run offline tests + run: node --test tests/security-advisory-access.test.mjs diff --git a/.github/workflows/security-advisory-access.yml b/.github/workflows/security-advisory-access.yml new file mode 100644 index 0000000..89a9361 --- /dev/null +++ b/.github/workflows/security-advisory-access.yml @@ -0,0 +1,54 @@ +name: Reconcile private advisory access + +on: + workflow_dispatch: + inputs: + apply: + description: Grant missing team access (manual verification is read-only by default) + required: false + type: boolean + default: false + schedule: + - cron: '27 * * * *' + +permissions: + contents: read + +concurrency: + group: security-advisory-access + cancel-in-progress: false + +jobs: + reconcile: + name: Reconcile advisory response team + if: >- + github.ref == 'refs/heads/main' && + vars.ADVISORY_ACCESS_ENABLED == 'true' && + (github.event_name != 'schedule' || vars.ADVISORY_ACCESS_AUTO_APPLY == 'true') + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: security-advisory-access + steps: + - name: Checkout declarative policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Authenticate dedicated advisory GitHub App + id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # zizmor: ignore[github-app] dedicated single-repository advisory-write App (v3.3.0) + with: + client-id: ${{ vars.ADVISORY_APP_CLIENT_ID }} + private-key: ${{ secrets.ADVISORY_APP_PRIVATE_KEY }} + owner: LibreSign # zizmor: ignore[github-app] strictly scoped installation + repositories: libresign + # The dedicated App must have ONLY repository security advisories:write. + # No other permissions are requested; never reuse the ruleset manager. + + - name: Apply narrowly scoped team access + env: + ADVISORY_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} + ADVISORY_ALLOWED_REPOSITORY: LibreSign/libresign + ADVISORY_ALLOWED_TEAM: security-response + ADVISORY_ACCESS_APPLY: ${{ github.event_name == 'schedule' || inputs.apply }} + run: node scripts/reconcile-security-advisory-access.mjs diff --git a/README.md b/README.md index 5bbf38e..18f83bd 100644 --- a/README.md +++ b/README.md @@ -21,5 +21,6 @@ organization profile, community conventions and project governance so contributors can focus on improving the digital-signature platform. Maintainer information is available in the -[repository governance guide](docs/repository-governance.md) and +[repository governance guide](docs/repository-governance.md), +[confidential advisory access policy](docs/security-advisory-access.md) and [testing and validation guide](docs/testing.md). diff --git a/docs/security-advisory-access.md b/docs/security-advisory-access.md new file mode 100644 index 0000000..64d23c8 --- /dev/null +++ b/docs/security-advisory-access.md @@ -0,0 +1,106 @@ +# Confidential security advisory access + +LibreSign uses a dedicated, **secret** organization team, `security-response`, +for coordinated remediation of privately reported vulnerabilities. The team is +separate from `maintainers`. Membership in one does not automatically imply +membership in the other. The team is not granted repository-wide access merely +to view advisories. + +## Responsibilities and authority + +- **Maintainers:** code review, normal repository maintenance, stable branch + support and CI. Repository `Maintain` is **not** advisory administration. +- **Security response:** confidential triage, investigation, remediation, + review and coordination within advisories to which the team is invited. + Advisory collaboration grants advisory write access, not the organization + `Security Manager` role or unrestricted administration of all advisories. +- **Advisory administrators:** repository administrators and organization + owners authorize collaborators, the security fork merge, disclosures, CVE + requests, and publication. Publication requires a separate human decision + after confirming the support matrix, available fixes and reporter agreement; + no advisory-access workflow publishes, merges, changes state or requests a CVE. + +A security-response team maintainer and an organization owner jointly review +membership changes when possible. Add only active organization members with +a demonstrated security response responsibility; the intended roster is +maintained privately in GitHub team settings, **not** in this public repository. +To revoke access, remove a former responder from `security-response` and +review any advisory-specific individual invitations or other inherited roles +that may independently retain access. Review membership after personnel or +role changes and before any coordinated disclosure. + +## How an advisory receives access + +Advisory administrators can add `security-response` through **Security > +Advisories > Collaborators**, or use the dedicated reconciler described below. +A review request on a pull request does not grant access to a private advisory. +The reporter and any other collaborators must be preserved. Access to the +associated temporary private fork is managed by GitHub through the advisory; +ordinary fork repository-collaborator endpoints do not always expose this +access. The invited member should confirm they can open the advisory and fork +in their own authenticated session. + +## Declarative, scoped reconciliation + +[`security-advisory-access.config.json`](../security-advisory-access.config.json) +contains only the repository, team slug and adoption cutoff. It never contains +GHSA identifiers, embargoed descriptions, reporter identities or private fork +paths. The reconciler is deliberately isolated from the generic repository +ruleset reconciler: confidential advisory credentials cannot be used to alter +organization rulesets or product CI. + +The workflow runs hourly **only after it is enabled**. It reads repository +advisories and considers only unpublished `draft` or `triage` advisories created +on or after the adoption timestamp. Older advisories require explicit invitation +by an advisory administrator; this prevents unintended retroactive disclosure. +For a missing team it sends only the `collaborating_teams` field, preserving +existing teams and individual collaborators. Repeated runs are idempotent. +The code never changes state, releases, CVEs, forks, credits or other advisory +metadata. Workflow logs contain no advisory names, IDs, counts or paths. +Failures are not silently ignored; they are reported without identifying the +private advisory. + +### One-time GitHub App setup + +A dedicated GitHub App is required because no currently installed application +has `Repository security advisories: write`. Register it with only that +repository permission and install it on **LibreSign/libresign only**. Do not +reuse the organization ruleset administrator App, an organization-wide +`Security Manager` role, or a personal access token. GitHub has no narrower +API permission specifically for changing advisory collaborators, so this +repository-level advisory write permission is a residual risk. Keep the App +private key in an environment secret, protect the workflow through branch +rules and Code Owner review, and rotate the key if the responder roster or +administration changes. + +In `LibreSign/.github`, configure the dedicated `security-advisory-access` +Actions environment with `ADVISORY_APP_CLIENT_ID` (environment variable) and +`ADVISORY_APP_PRIVATE_KEY` (environment secret). Restrict deployment to +protected branches. The workflow itself restricts execution to `main` and +requests a token valid only for the `libresign` repository. + +Set repository variable `ADVISORY_ACCESS_ENABLED=true` once the App is installed +and credentials are configured; initially leave `ADVISORY_ACCESS_AUTO_APPLY` +set to `false`. Run **Reconcile private advisory access** manually with +`apply=false` to verify credentials and a read-only pass. After reviewing +this successful run, enable hourly automatic grants by setting +`ADVISORY_ACCESS_AUTO_APPLY=true`. Manual runs with `apply=true` use the +same tightly scoped logic. Disabling either variable stops scheduled writes. + +The workflow cannot subscribe to unpublished draft advisory creation through +the published-only `repository_advisory` webhook. Periodic reconciliation +is therefore used instead. It does not widen access for historical advisories. + +### Auditing + +Authorized organization owners should periodically compare active team +membership, explicit advisory collaborators and GitHub audit-log events where +available. Never paste unpublished advisory API responses or fork URLs into +public issues, PRs, CI logs or this repository. The team must have no direct +repository grants unless independently justified. Check the GitHub App's +installation scope and permissions when reviewing the automation. + +Changes to the secret team, the access cutoff, the App installation, or the +advisory collaboration policy are security-sensitive and require reviewed +human authorization. They are separate from authorization to disclose an +advisory or publish a release. diff --git a/scripts/reconcile-security-advisory-access.mjs b/scripts/reconcile-security-advisory-access.mjs new file mode 100644 index 0000000..48fa32f --- /dev/null +++ b/scripts/reconcile-security-advisory-access.mjs @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 LibreSign contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/; +const OPEN_STATES = new Set(['draft', 'triage']); + +export function validatePolicy(raw, allowedRepository = 'LibreSign/libresign') { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid security access policy'); + } + const keys = Object.keys(raw).sort(); + if (JSON.stringify(keys) !== JSON.stringify(['created_after', 'repository', 'schema_version', 'team'].sort()) || + raw.schema_version !== 1 || raw.repository !== allowedRepository || + typeof raw.team !== 'string' || !/^[a-z0-9][a-z0-9-]{1,38}$/.test(raw.team) || + typeof raw.created_after !== 'string' || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/.test(raw.created_after) || + !Number.isFinite(Date.parse(raw.created_after))) { + throw new Error('Invalid or unexpected security access policy'); + } + return Object.freeze({ + repository: raw.repository, + team: raw.team, + createdAfter: Date.parse(raw.created_after), + }); +} + +function collaboratorLogins(advisory) { + if (!Array.isArray(advisory.collaborating_users) || + !advisory.collaborating_users.every((user) => typeof user?.login === 'string')) { + throw new Error('Cannot verify existing advisory collaborators'); + } + return advisory.collaborating_users.map((user) => user.login).sort(); +} + +function teamSlugs(advisory) { + if (!Array.isArray(advisory.collaborating_teams) || + !advisory.collaborating_teams.every((team) => typeof team?.slug === 'string')) { + throw new Error('Cannot verify advisory team membership'); + } + return advisory.collaborating_teams.map((team) => team.slug); +} + +function eligible(advisory, policy) { + if (!OPEN_STATES.has(advisory.state) || advisory.published_at !== null || + typeof advisory.created_at !== 'string' || + !Number.isFinite(Date.parse(advisory.created_at))) return false; + return Date.parse(advisory.created_at) >= policy.createdAfter; +} + +function equalSets(left, right) { + return JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()); +} + +export async function reconcileAdvisoryAccess({ policy, token, apply = false, fetchImpl = fetch, baseUrl = 'https://api.github.com' }) { + if (typeof token !== 'string' || token.length < 8 || !/^https:\/\//.test(baseUrl)) { + throw new Error('Missing advisory access credentials'); + } + const prefix = `/repos/${policy.repository.split('/').map(encodeURIComponent).join('/')}/security-advisories`; + async function api(method, path, body) { + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) throw new Error('GitHub advisory access API request failed'); + return response.json(); + } + + const report = { checked: 0, alreadyGranted: 0, wouldGrant: 0, granted: 0 }; + for (let page = 1; page <= 20; page++) { + // Do not set state=all: GitHub treats it as a literal state and returns no advisories. + const batch = await api('GET', `${prefix}?per_page=100&page=${page}`); + if (!Array.isArray(batch)) throw new Error('Unexpected security advisory listing'); + for (const item of batch) { + if (!eligible(item, policy)) continue; + if (typeof item.ghsa_id !== 'string' || !GHSA_PATTERN.test(item.ghsa_id)) { + throw new Error('Unexpected advisory identifier'); + } + const path = `${prefix}/${encodeURIComponent(item.ghsa_id)}`; + const advisory = await api('GET', path); + if (!eligible(advisory, policy) || advisory.ghsa_id !== item.ghsa_id) continue; + const existingTeams = teamSlugs(advisory); + const existingUsers = collaboratorLogins(advisory); + report.checked++; + if (existingTeams.includes(policy.team)) { + report.alreadyGranted++; + continue; + } + report.wouldGrant++; + if (!apply) continue; + // This is the ONLY mutation. Never send state, credits, collaborating_users, + // summary, vulnerabilities, private_fork, or any embargoed metadata. + await api('PATCH', path, { collaborating_teams: [...new Set([...existingTeams, policy.team])] }); + const verified = await api('GET', path); + if (verified.state !== advisory.state || verified.published_at !== null || + !teamSlugs(verified).includes(policy.team) || + !existingTeams.every((slug) => teamSlugs(verified).includes(slug)) || + !equalSets(existingUsers, collaboratorLogins(verified))) { + throw new Error('Advisory access postcondition could not be verified'); + } + report.granted++; + } + if (batch.length < 100) return report; + } + throw new Error('Advisory listing exceeds reviewable limit'); +} + +async function main() { + const config = JSON.parse(await readFile(resolve('security-advisory-access.config.json'), 'utf8')); + const allowedRepository = process.env.ADVISORY_ALLOWED_REPOSITORY; + const expectedTeam = process.env.ADVISORY_ALLOWED_TEAM; + const policy = validatePolicy(config, allowedRepository); + if (expectedTeam !== policy.team) throw new Error('Expected team does not match policy'); + const apply = process.env.ADVISORY_ACCESS_APPLY === 'true'; + await reconcileAdvisoryAccess({ policy, token: process.env.ADVISORY_ACCESS_TOKEN, apply }); + // Never log advisory identifiers, names, fork information, or aggregate counts. + process.stdout.write('Security advisory access reconciliation finished.\n'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(() => { + process.stderr.write('Security advisory access reconciliation could not be verified.\n'); + process.exitCode = 1; + }); +} diff --git a/security-advisory-access.config.json b/security-advisory-access.config.json new file mode 100644 index 0000000..e63422e --- /dev/null +++ b/security-advisory-access.config.json @@ -0,0 +1,6 @@ +{ + "schema_version": 1, + "repository": "LibreSign/libresign", + "team": "security-response", + "created_after": "2026-11-04T15:23:29Z" +} diff --git a/tests/security-advisory-access.test.mjs b/tests/security-advisory-access.test.mjs new file mode 100644 index 0000000..1291ff6 --- /dev/null +++ b/tests/security-advisory-access.test.mjs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 LibreSign contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import assert from 'node:assert/strict'; +import { reconcileAdvisoryAccess, validatePolicy } from '../scripts/reconcile-security-advisory-access.mjs'; + +const id = 'GHSA-2345-6789-cfgh'; +const repository = 'LibreSign/libresign'; +const data = { + ghsa_id: id, state: 'draft', created_at: '2026-12-01T00:00:00Z', + published_at: null, collaborating_teams: [], + collaborating_users: [{ login: 'independent-reporter' }], +}; +const policy = validatePolicy({ + schema_version: 1, repository, team: 'security-response', + created_after: '2026-11-04T15:00:00Z', +}); + +function fakeGithub(initial = [data], status = 200) { + const records = new Map(initial.map((item) => [item.ghsa_id, structuredClone(item)])); + const writes = []; + const calls = []; + const fetchImpl = async (url, options) => { + const target = new URL(url); + const path = target.pathname; + calls.push({ method: options.method, path, query: target.search }); + if (status !== 200) return new Response('{}', { status }); + if (path.endsWith('/security-advisories')) { + return Response.json([...records.values()]); + } + const actualId = path.split('/').at(-1); + const current = records.get(actualId); + if (!current) return new Response('{}', { status: 404 }); + if (options.method === 'PATCH') { + const update = JSON.parse(options.body); + writes.push(update); + if (Object.keys(update).join(',') !== 'collaborating_teams') { + throw new Error('Forbidden advisory field mutation'); + } + current.collaborating_teams = update.collaborating_teams.map((slug) => ({ slug })); + records.set(actualId, current); + } + return Response.json(current); + }; + return { records, writes, calls, fetchImpl }; +} +const run = (github, apply = false, currentPolicy = policy) => reconcileAdvisoryAccess({ + policy: currentPolicy, token: 'synthetic-token', apply, fetchImpl: github.fetchImpl, +}); + +test('accepts only exact repository, constrained team and explicit adoption time', () => { + assert.equal(policy.repository, repository); + assert.throws(() => validatePolicy({ schema_version: 1, repository: 'LibreSign/unrelated', team: 'security-response', created_after: '2026-12-01T00:00:00Z' })); + assert.throws(() => validatePolicy({ schema_version: 1, repository, team: '../invalid', created_after: '2026-12-01T00:00:00Z' })); + assert.throws(() => validatePolicy({ schema_version: 1, repository, team: 'security-response', created_after: 'not-a-date' })); + assert.throws(() => validatePolicy({ schema_version: 1, repository, team: 'security-response', created_after: '2026-12-01T00:00:00Z', secret: 'unexpected' })); +}); + +test('dry run never patches the private advisory', async () => { + const github = fakeGithub(); + const report = await run(github); + assert.deepEqual(report, { checked: 1, alreadyGranted: 0, wouldGrant: 1, granted: 0 }); + assert.deepEqual(github.writes, []); + assert.ok(github.calls.every(({ method }) => method === 'GET')); +}); + +test('apply changes only team membership, preserving reporter and other teams', async () => { + const github = fakeGithub([{ ...data, collaborating_teams: [{ slug: 'trusted-researchers' }] }]); + const report = await run(github, true); + assert.equal(report.granted, 1); + assert.deepEqual(github.writes, [{ collaborating_teams: ['trusted-researchers', 'security-response'] }]); + const record = github.records.get(id); + assert.deepEqual(record.collaborating_users, [{ login: 'independent-reporter' }]); + assert.equal(record.state, 'draft'); + assert.equal(record.published_at, null); + assert.ok(github.calls.every(({ path }) => path.includes('/LibreSign/libresign/security-advisories'))); +}); + +test('idempotent replay does not write again', async () => { + const github = fakeGithub(); + await run(github, true); + const second = await run(github, true); + assert.equal(second.alreadyGranted, 1); + assert.equal(github.writes.length, 1); +}); + +test('ignores older, published, and closed advisories without touching them', async () => { + const github = fakeGithub([ + { ...data, created_at: '2026-01-01T00:00:00Z' }, + { ...data, ghsa_id: 'GHSA-2345-6789-cfg2', state: 'published', published_at: '2026-12-02T00:00:00Z' }, + { ...data, ghsa_id: 'GHSA-2345-6789-cfg3', state: 'closed' }, + ]); + const result = await run(github, true); + assert.equal(result.checked, 0); + assert.equal(github.writes.length, 0); +}); + +test('supports a privately reported advisory in triage without changing its state', async () => { + const github = fakeGithub([{ ...data, state: 'triage' }]); + assert.equal((await run(github, true)).granted, 1); + assert.equal(github.records.get(id).state, 'triage'); +}); + +test('fails closed if collaborator metadata is missing', async () => { + const github = fakeGithub([{ ...data, collaborating_users: undefined }]); + await assert.rejects(run(github, true), /existing advisory collaborators/); + assert.equal(github.writes.length, 0); +}); + +test('fails closed when GitHub denies access', async () => { + const github = fakeGithub([data], 403); + await assert.rejects(run(github, true), /GitHub advisory access API request failed/); + assert.equal(github.writes.length, 0); +}); + +test('fails closed on invalid GHSA identifiers', async () => { + const github = fakeGithub([{ ...data, ghsa_id: '../../another-resource' }]); + await assert.rejects(run(github, true), /Unexpected advisory identifier/); + assert.equal(github.writes.length, 0); +}); + +test('does not accept unusable credentials', async () => { + await assert.rejects(reconcileAdvisoryAccess({ policy, token: '' }), /Missing advisory access credentials/); +}); + +test('workflow requires explicit enablement and pins the dedicated App to a single repository', () => { + const workflow = readFileSync(new URL('../.github/workflows/security-advisory-access.yml', import.meta.url), 'utf8'); + assert.match(workflow, /environment: security-advisory-access/); + assert.match(workflow, /vars\.ADVISORY_ACCESS_ENABLED/); + assert.match(workflow, /vars\.ADVISORY_ACCESS_AUTO_APPLY/); + assert.match(workflow, /repositories: libresign/); + assert.match(workflow, /owner: LibreSign/); + assert.match(workflow, /github\.ref == 'refs\/heads\/main'/); + assert.doesNotMatch(workflow, /permission-administration: write/); + assert.doesNotMatch(workflow, /permission-members: write/); +});