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

116 lines
3.9 KiB
Python

"""Match published security advisories to the releases that fixed them."""
import json
import re
from security_public_api import public_pages
REPOSITORY = "Significant-Gravitas/AutoGPT"
BASE_URL = f"https://github.com/{REPOSITORY}"
START = "<!-- security-advisories:start -->"
END = "<!-- security-advisories:end -->"
RESEARCH_ROLES = {"reporter", "finder", "analyst"}
LOGIN_PATTERN = r"[A-Za-z0-9][A-Za-z0-9-]{0,38}"
# Our own maintainers can hold research credits on our advisories (an internal find,
# a triage analysis); they aren't outside researchers. Add one when that happens.
TEAM_LOGINS = frozenset(
login.casefold()
for login in ("Abhi1992002", "Bentlybro", "kcze", "ntindle", "Pwuts", "Torantulino")
)
def accepted_researchers(advisory):
return sorted(
{
login
for credit in advisory.get("credits_detailed") or []
if credit.get("state") == "accepted"
and credit.get("type") in RESEARCH_ROLES
and isinstance(login := (credit.get("user") or {}).get("login"), str)
and re.fullmatch(LOGIN_PATTERN, login)
and login.casefold() not in TEAM_LOGINS
},
key=str.casefold,
)
def version_number(value):
match = re.fullmatch(
r"(?:>=\s*)?(?:autogpt-platform-beta-v|agpt-platform-beta-v|autogpt-v|v)?"
r"(\d+\.\d+\.\d+)",
(value or "").strip(),
)
return match[1] if match else None
def release_packages(advisory, tag):
packages = []
for raw in re.findall(
r"<!-- security-release:\s*(.*?)\s*-->",
advisory.get("description") or "",
re.DOTALL,
):
try:
reference = json.loads(raw)
package = reference["package"]
if not isinstance(reference["tag"], str) or not all(
isinstance(package[key], str) and package[key]
for key in ("ecosystem", "name")
):
raise ValueError
except (ValueError, KeyError, TypeError) as error:
raise ValueError(
f"Invalid package-specific release reference in {advisory['ghsa_id']}"
) from error
if reference["tag"] == tag:
packages.append(package)
return packages
def fixed_in(advisory, tag):
version = version_number(tag)
if not version:
raise ValueError(
"Use a stable release tag, such as autogpt-platform-beta-v0.9.0"
)
release_url = f"{BASE_URL}/releases/tag/{tag}"
# Accept closing delimiters and prose punctuation without matching tag extensions.
has_reference = re.search(
re.escape(release_url) + r"""(?=$|[\s)#?<>\]"'`]|[.,;:!]+(?=$|[\s)<>\]"'`]))""",
advisory.get("description") or "",
)
vulnerabilities = advisory.get("vulnerabilities") or []
matching = [
vulnerability
for vulnerability in vulnerabilities
if version_number(vulnerability.get("patched_versions")) == version
]
if not has_reference or not matching:
return False
# Rows that agree on the fixed version (e.g. the Platform source and its Docker image,
# both patched in 0.9.0) can't be misattributed; only disagreeing rows need a mapping.
patched = {version_number(v.get("patched_versions")) for v in vulnerabilities}
if len(patched - {None}) > 1:
packages = release_packages(advisory, tag)
if not packages:
raise ValueError(
f"{advisory['ghsa_id']} needs a package-specific release reference "
f"for {tag}; notes unchanged"
)
matching = [
v
for v in matching
if any(
all(
(v.get("package") or {}).get(key) == p[key]
for key in ("ecosystem", "name")
)
for p in packages
)
]
return bool(matching)
def fetch_advisories():
return public_pages("security-advisories", "state=published")