mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-09 17:53:56 -10:00
### 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]>
116 lines
3.9 KiB
Python
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")
|