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
36 changes: 36 additions & 0 deletions .github/workflows/security-advisory-access-tests.yml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions .github/workflows/security-advisory-access.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
106 changes: 106 additions & 0 deletions docs/security-advisory-access.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions scripts/reconcile-security-advisory-access.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
6 changes: 6 additions & 0 deletions security-advisory-access.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"schema_version": 1,
"repository": "LibreSign/libresign",
"team": "security-response",
"created_after": "2026-11-04T15:23:29Z"
}
Loading
Loading