mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-09 13:01: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]>
129 lines
5.0 KiB
Python
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)
|