Files
9fffbaf537 ci(platform): sync public security credits and release notes (#16151)
### Why / What / How

**Why:** our public researcher credits and each release's security notes
drift from the published advisories. Advisories get published after the
release that fixed them, credits get accepted later, and records get
corrected or withdrawn. `SECURITY.md`'s list was last edited by hand in
September, and no release note links its advisories.

Separately, the Classic Docker release job runs on every Platform
release. Its `startsWith(github.ref, 'refs/tags/autogpt-')` filter
matches `autogpt-platform-beta-v*`, so it has started on every Platform
release since at least v0.6.69, and again whenever a maintainer edits
release notes (`types: [published, edited]`; v0.7.1 and v0.7.4 each ran
twice). Every run failed only because `Dockerfile.autogpt` no longer
exists. A successful build would have pushed
`significantgravitas/auto-gpt:latest` plus a Platform-numbered tag over
the Classic image.

**What:** a scheduled sync that keeps the `SECURITY.md` acknowledgments
and a "Security fixes" section in each release's notes in step with the
published advisories. It also limits the Classic Docker release job to
published Classic tags.

**How:** the sync never has access to private reports.

- It reads advisories anonymously and keeps four identifier fields per
advisory in a committed public snapshot: GHSA ID, CVE ID, accepted
researcher handles and release tags.
- A read-only job validates the data and previews the exact sections a
run would write.
- Two writers are each off until their own variable is set: one opens a
credits PR against `dev`, the other edits only the managed block of
existing releases.
- Both writers run pinned `master` code, re-fetch the public data and
stop if it changed since the read-only job.

### Changes 🏗️

- **Security sync** (`.github/workflows/security-advisory-sync.yml`,
every six hours, plus a manual dry run). Scripts:
- `security_public_api.py`: anonymous, bounded HTTPS reads. No auth,
proxy or redirects. Failures log only a fixed reason.
- `security_public_sync.py`: the four-field projection, the generated
`SECURITY.md` block and the release section.
  - `security_public_publish.py`: preview, or the release-body update.
- `security_release_notes.py`: credit consent and the release each
advisory was fixed in.
- **Release matching:** an advisory is listed under a release when its
description links that release's tag URL *and* one of its package rows
gives that release's version as patched. A hidden `security-release`
mapping is needed only when the rows report different patched versions.
- **Release writer:** preserves every note outside the managed block,
from a fresh read. It sends `make_latest` on every edit: `true` for the
current Latest release, `false` for the rest. It also checks
`releases/latest` before the first edit and after the last.
- **`SECURITY.md`:**
- Generated credits, keeping each researcher's chosen name beside their
handle where they gave one.
  - No team members and no stale per-person counts.
- The one acknowledgment with no published advisory credit is kept as
historical.
- **Classic Docker release workflow:** `types: [published]`, a
`refs/tags/autogpt-v` filter and `permissions: contents: read`.
- **CI:** `security-advisory-sync-test.yml` runs the tests against the
PR head. The required "Check PR Status" job runs them only from the base
commit.
- **Runbook:** `docs/engineering/security-advisory-release-notes.md`
covers rollout, duplicate consolidation and Docker package metadata.

**Takeover changes (since `0fdc05b`):**

- Advisories whose package rows agree on the fixed version are now
matched to their release. Before, an advisory with a Platform row and a
Docker image row, both patched in `0.9.0`, made the projection raise
`RELEASE_MAPPING`. The reader job runs that projection, so publishing
one advisory in that format would have stopped every run until someone
added hidden comments by hand.
- The release writer now keeps the current Latest release. GitHub
documents `make_latest` as defaulting to `true` on updates. The first
run edits 19 older releases, newest first and ending on v0.4.3, while
v0.9.0 is Latest.
- Removed the manual release-note helper. It was a second writer for the
same block (with titles), and the next scheduled run would have replaced
its output. Its tests now drive the automated path.
- Restored the chosen names of 16 researchers, such as "Joshua Rogers
(`@MegaManSec`)", and left the team's handles out of the acknowledgments
(`TEAM_LOGINS`). `SECURITY.md` and the snapshot are regenerated from
live public data.
- Dropped the mention of a `PUBLIC_SECURITY_SYNC_ENABLED` switch that
`dev` never had, and merged `dev`.

**Rollout:**

1. Merge through `dev` to `master` with both writer variables unset.
2. Dispatch a dry run and review the job summary.
3. Set `PUBLIC_SECURITY_CREDITS_ENABLED=true`. CI on the PR it opens
waits for a maintainer to approve the workflow runs, as it does on other
`GITHUB_TOKEN`-created PRs here.
4. After reviewing the preview, set
`PUBLIC_SECURITY_RELEASE_NOTES_ENABLED=true`.

A manual release-note edit made during the writer's run can still be
overwritten, because GitHub has no conditional PATCH for releases. The
guide says so.

### Agents and large language models used

- Codex with GPT-6 Astra: the original implementation and its review
rounds.
- Claude Code with Claude Opus 5.5: the takeover design review, the
changes listed under "Takeover changes", and the validation below.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
'test_*.py'`: 98 passed on Python 3.11. The security tests also pass on
Python 3.10, which the required "Check PR Status" job uses.
- [x] Reproduced the two-row failure with the PR's own `public_snapshot`
before the fix (`RELEASE_MAPPING`); passes after.
- [x] 11 mutants of the new code, each killed by its own failing
assertions:
    - the two-row rule;
    - `make_latest` in the payload;
    - which release claims Latest;
    - the Latest pre-read and post-check;
    - the team filter and its case-insensitive match;
    - name rendering.
  - [x] Live anonymous run against the public API:
- `digest` and `credits` regenerate the committed `SECURITY.md` and
snapshot byte for byte.
- `--preview` proposes sections for 19 releases, with the chosen names
and no team handles.
    - The current Latest release is v0.9.0.
- [x] `pre-commit` (Black, gitleaks, merge-conflict and debug checks) on
the changed files.
- [ ] Live write path during rollout: no variables set and no release
edited during development.

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**): two repository variables, both unset by
default.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: GPT-6 Astra (Codex) <[email protected]>
Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
2026-10-25 13:07:39 +00:00

129 lines
5.0 KiB
Python

"""Bounded, anonymous reads from this repository's public GitHub API."""
import json
import re
from enum import Enum
from urllib.parse import parse_qs, urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
REPOSITORY = "Significant-Gravitas/AutoGPT"
REPOSITORY_ID = 614765452
API_ROOT = f"https://api.github.com/repos/{REPOSITORY}/"
MAX_BYTES = 10 * 1024 * 1024
MAX_PAGES = 20
class PublicErrorCode(Enum):
API_REDIRECT = "Public API redirects are not allowed"
API_PATH = "Invalid public API path"
API_RESPONSE = "Invalid public API response"
API_READ = "Public API read failed; no authenticated fallback"
API_COLLECTION = "Invalid public API collection"
PAGE_REPEATED = "Public API pagination repeated a page"
PAGE_LINK = "Invalid public API pagination"
PAGE_TARGET = "Invalid public API pagination target"
PAGE_LIMIT = "Public API pagination limit exceeded"
SNAPSHOT_SCHEMA = "Invalid public snapshot schema"
SNAPSHOT_RECORD = "Invalid public snapshot record"
SNAPSHOT_FIELD = "Invalid public snapshot field"
ADVISORY_RECORD = "Invalid public advisory record"
ADVISORY_ID = "Invalid published advisory identifier"
ADVISORIES_CHANGED = "Public advisories changed; rerun the workflow"
MARKER_PAIR = "Expected exactly one complete managed marker pair"
MARKER_ORDER = "Managed markers are reversed"
RELEASE_TAG = "Unsupported release tag"
RELEASE_MAPPING = "Published advisory release mapping is invalid or ambiguous"
RELEASE_TARGET = "Invalid release write target"
RELEASE_UPDATE = "Release update failed; rerun after checking public notes"
RELEASE_CHANGED = "Release changed; rerun the workflow"
LATEST_RELEASE = "Could not identify the latest release; no notes were updated"
LATEST_CHANGED = "The latest release changed during the update; check it"
WRITE_CONTEXT = "Writes require the trusted master workflow"
FILE_SYMLINK = "Managed files must not be symlinks"
UNKNOWN = "Unexpected public-data failure; no raw response or report was logged"
class PublicDataError(ValueError):
def __init__(self, code):
self.code = (
code if isinstance(code, PublicErrorCode) else PublicErrorCode.UNKNOWN
)
super().__init__(self.code.value)
class NoRedirects(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise PublicDataError(PublicErrorCode.API_REDIRECT)
def public_json(path):
url = API_ROOT + path
parsed = urlsplit(url)
if (
parsed.scheme != "https"
or parsed.netloc != "api.github.com"
or not parsed.path.startswith(f"/repos/{REPOSITORY}/")
or parsed.fragment
or ".." in parsed.path
):
raise PublicDataError(PublicErrorCode.API_PATH)
request = Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "AutoGPT-public-security-sync",
"X-GitHub-Api-Version": "2022-11-28",
},
)
# Do not inherit proxies, netrc, gh credentials, auth handlers, or redirects.
opener = build_opener(ProxyHandler({}), NoRedirects())
try:
with opener.open(request, timeout=20) as response:
raw = response.read(MAX_BYTES + 1)
if response.status != 200 or len(raw) > MAX_BYTES:
raise PublicDataError(PublicErrorCode.API_RESPONSE)
return json.loads(raw), response.headers
except PublicDataError:
raise
except Exception:
raise PublicDataError(PublicErrorCode.API_READ) from None
def public_pages(endpoint, query=""):
path = f"{endpoint}?per_page=100" + (f"&{query}" if query else "")
seen = set()
result = []
for _ in range(MAX_PAGES):
if path in seen:
raise PublicDataError(PublicErrorCode.PAGE_REPEATED)
seen.add(path)
page, headers = public_json(path)
if not isinstance(page, list) or any(
not isinstance(item, dict) for item in page
):
raise PublicDataError(PublicErrorCode.API_COLLECTION)
result.extend(page)
matches = re.findall(r'<([^>]+)>;\s*rel="next"', headers.get("Link", ""))
if not matches:
return result
if len(matches) != 1:
raise PublicDataError(PublicErrorCode.PAGE_LINK)
target = urlsplit(matches[0])
parameters = parse_qs(target.query)
if (
target.scheme != "https"
or target.netloc != "api.github.com"
or target.path
not in {
urlsplit(API_ROOT + endpoint).path,
f"/repositories/{REPOSITORY_ID}/{endpoint}",
}
or target.fragment
or parameters.get("per_page") != ["100"]
or not set(parameters) <= {"per_page", "page", "after", "before", "state"}
or (query and parameters.get("state") != ["published"])
):
raise PublicDataError(PublicErrorCode.PAGE_TARGET)
path = endpoint + "?" + target.query
raise PublicDataError(PublicErrorCode.PAGE_LIMIT)