Skip to content

feat(security): guard Actions-secret expiry with a committed record - #129

Merged
bketelsen merged 1 commit into
mainfrom
security/org-pat-expiry-guard
Aug 28, 2026
Merged

feat(security): guard Actions-secret expiry with a committed record#129
bketelsen merged 1 commit into
mainfrom
security/org-pat-expiry-guard

Conversation

@bketelsen

Copy link
Copy Markdown
Contributor

Summary

ORG_PAT — the one fine-grained PAT the skills sync depends on — expires
2027-08-11. Until now that date lived only in a sentence in
docs/design/skills-sync-operations.md and in
core#15, which is closed.
Neither can fail, so nothing in the repository would notice the window
opening; the sync would just start failing consumer clones with
Resource not accessible by personal access token.

This adds a credential-free, repository-enforced pre-expiry guard:

  • .github/secrets-expiry.json — the canonical record. Closed schema
    (unknown fields are rejected, so there is no field a token value could live
    in), never_relax: true, and one entry per secret with expires_on,
    warn_days_before, owner, used_by, and rotation_runbook. ORG_PAT
    declares 2027-08-11 with a 60-day lead time.
  • scripts/check-secret-expiry.mjs + scripts/lib/secret-expiry.mjs
    validates the record (30-day floor on warn_days_before, never_relax must
    stay true) and classifies each secret against a reference day: ok,
    expiring (days_remaining <= warn_days_before), expired. Anything but
    ok exits non-zero naming the secret, the date, and the runbook. Its only
    inputs are the record and the day — it never reads, resolves, fingerprints,
    or prints the secret, and never touches process.env for one.
  • .github/workflows/secrets-expiry.yml — runs the guard daily (06:41 UTC)
    and on dispatch. permissions: {} at the workflow, contents: read on the
    job, SHA-pinned actions, persist-credentials: false (ADR-0021). A failing
    scheduled run is the notification.
  • Deliberately not in make verify / make ci. An approaching rotation is
    an operations task, not a defect in the PR under review; blocking every merge
    for 60 days is how guardrails get bypassed. What is in the PR gate is
    test/secret-expiry.test.mjs, which pins the behaviour by injecting dates.
  • Docs: ADR-0045 records the decision; the design doc gains an "Expiry and
    rotation" section that cites the record instead of core#15 (plus a recovery
    table row); plan 0001's open question is resolved to the ADR; AGENTS.md
    gains the convention.

Rotation stays a human act: replace the secret in repository settings, then
update expires_on in the record in the same PR.

Closes nothing on GitHub; supersedes closed core#15 as the tracking mechanism.

Risk tier

Risk tier: high — touches two review-required protected boundaries in
policies/agent-governance.json: workflow-and-permissions (new
.github/workflows/secrets-expiry.yml) and quality-gates (scripts/lib/**,
test/**). Scale per that policy's risk_classification
(low/moderate/high/critical, highest-applicable); ADR-0019. No production
behaviour changes and no existing gate is relaxed — never_relax guardrails
are added, not loosened.

Docs housekeeping

  • New docs started from their category's TEMPLATE.md — ADR-0045 follows
    docs/adr/TEMPLATE.md
  • Every new doc indexed in docs/README.md
  • Cross-links added in both directions — ADR-0045 → design doc + plan 0001;
    design doc Rationale/Executable-contracts → ADR-0045; plan 0001
    References → ADR-0045
  • New significant decision recorded as an ADR first, in this PR —
    ADR-0045
  • Conformance aliases (ADR-0029) untouched — canonical targets edited
    instead (AGENTS.md, not its CLAUDE.md/GEMINI.md/CONTRIBUTING.md
    symlinks)

Verification

  • make verify green (exit 0) on the committed tree:
ok   docs_index_coverage: 1.000 (required 1)
ok   link_integrity: 1.000 (required 1)
ok   symlink_resolution: 1.000 (required 1)
checked: 59 docs, 466 links, 10 symlinks
organization authority valid: 7 declarations, 0 verification profiles, 0 Goals, 7 valid fixtures, 18 rejection fixtures
ok   bash_syntax: 6/6
# tests 51
# pass 51
# fail 0
  • node scripts/check-docs.mjs green — the three lines above are its
    output (run inside make verify)
  • Scaffold e2e suite green — npm ci && npm test in
    .agents/skills/frostyard-docs-site/scaffold/:
1..6
# tests 6
# pass 6
# fail 0
# duration_ms 34.418003
  • Workflows changed? Every action SHA-pinned (40 chars + # vX.Y.Z),
    permissions: {}, persist-credentials: false (ADR-0021) —
    actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2,
    jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5,
    the same pins ci.yml already uses. actionlint is not installed on
    this host; the workflow was parsed with js-yaml instead and the
    resulting object was inspected (schedule, permissions, job, pins).

The guard's own boundaries, demonstrated against the committed record:

$ SECRET_EXPIRY_TODAY=2027-06-11 node scripts/check-secret-expiry.mjs   # 61 days out
ok   ORG_PAT expires on 2027-08-11 in 61 day(s); warns at 60 day(s)
ok   secret_expiry: 1/1 outside their rotation window (as of 2027-06-11)
exit=0

$ SECRET_EXPIRY_TODAY=2027-06-12 node scripts/check-secret-expiry.mjs   # window opens
FAIL ORG_PAT expires on 2027-08-11 in 60 day(s), inside its 60-day rotation window — rotate it and update .github/secrets-expiry.json; runbook: docs/design/skills-sync-operations.md
FAIL secret_expiry: 0/1 outside their rotation window (as of 2027-06-12)
exit=1

$ SECRET_EXPIRY_TODAY=2027-08-12 node scripts/check-secret-expiry.mjs   # after expiry
FAIL ORG_PAT expired on 2027-08-11 (1 day(s) ago) — rotate it and update .github/secrets-expiry.json; runbook: docs/design/skills-sync-operations.md
FAIL secret_expiry: 0/1 outside their rotation window (as of 2027-08-12)
exit=1

test/secret-expiry.test.mjs (12 cases, included in the 51 above) derives
those three dates from the record itself, so they stay correct if the lead
time changes; it also pins the exact window boundaries, the schema floors,
that an unknown field such as value is rejected, and that the guard's output
contains no canary token placed in ORG_PAT/GH_TOKEN in its environment.

Also verified the workflow's npm run check:secret-expiry step works with no
node_modules present
(the script is dependency-free, so the job needs no
npm ci and no registry access).

The skills sync depends on one fine-grained PAT, ORG_PAT, expiring
2027-08-11. That date lived only in prose and in closed core#15, so
nothing in the repository noticed the window opening.

Add .github/secrets-expiry.json (closed, credential-free schema; 60-day
lead time, 30-day floor, never_relax), scripts/check-secret-expiry.mjs
plus scripts/lib/secret-expiry.mjs, and a daily
.github/workflows/secrets-expiry.yml that fails once a declared secret
is inside its rotation window or past expiry. The guard reads only the
record and a reference day — never the secret — and stays outside
verify/check so a pending rotation cannot block unrelated pull requests.

Record the decision as ADR-0045, index it, rewrite the design doc's
rotation section to cite the record instead of core#15, resolve plan
0001's open question, and add the AGENTS.md convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UigLsmd17TVDuaaSN4yfdU
@bketelsen
bketelsen marked this pull request as ready for review August 28, 2026 01:50
@bketelsen
bketelsen added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 8e15f6d Aug 28, 2026
3 checks passed
@bketelsen
bketelsen deleted the security/org-pat-expiry-guard branch August 28, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant