mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 04:41:59 -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]>
197 lines
8.1 KiB
Python
197 lines
8.1 KiB
Python
import copy
|
|
import json
|
|
import unittest
|
|
|
|
import security_public_sync as sync
|
|
from security_public_api import PublicDataError, PublicErrorCode
|
|
from security_release_notes import BASE_URL, END, START
|
|
|
|
TAG = "autogpt-platform-beta-v0.9.0"
|
|
|
|
|
|
def advisory(ghsa="GHSA-aaaa-bbbb-cccc"):
|
|
return {
|
|
"ghsa_id": ghsa,
|
|
"state": "published",
|
|
"published_at": "2026-10-24T12:00:00Z",
|
|
"withdrawn_at": None,
|
|
"summary": "Example fix",
|
|
"description": f"Fixed in [{TAG}]({BASE_URL}/releases/tag/{TAG}).",
|
|
"vulnerabilities": [{"patched_versions": "0.9.0"}],
|
|
"credits_detailed": [
|
|
{"user": {"login": "researcher"}, "state": "accepted", "type": "reporter"}
|
|
],
|
|
}
|
|
|
|
|
|
def release_tags(item):
|
|
return sync.public_snapshot([item])["advisories"][0]["release_tags"]
|
|
|
|
|
|
def notes(items, existing=""):
|
|
return sync.public_release_notes(sync.public_snapshot(items), TAG, existing)
|
|
|
|
|
|
class ReleaseAttributionTest(unittest.TestCase):
|
|
def test_publication_and_consent_boundaries(self):
|
|
public = advisory()
|
|
public["credits_detailed"] += [
|
|
{"user": {"login": "pending"}, "state": "pending", "type": "reporter"},
|
|
{"user": {"login": "declined"}, "state": "declined", "type": "reporter"},
|
|
{
|
|
"user": {"login": "maintainer"},
|
|
"state": "accepted",
|
|
"type": "remediation_developer",
|
|
},
|
|
]
|
|
excluded = [dict(advisory(), state=s) for s in ("triage", "draft", "closed")]
|
|
excluded += [
|
|
dict(advisory(), published_at=None),
|
|
dict(advisory(), withdrawn_at="2026-10-24"),
|
|
]
|
|
snapshot = sync.public_snapshot([public, *excluded])
|
|
self.assertEqual(len(snapshot["advisories"]), 1)
|
|
result = sync.public_release_notes(snapshot, TAG, "")
|
|
self.assertIn("@researcher", result)
|
|
for forbidden in ("@pending", "@declined", "@maintainer", "Example fix"):
|
|
self.assertNotIn(forbidden, result)
|
|
|
|
def test_deleted_credit_users_are_skipped_and_bad_ids_fail_closed(self):
|
|
valid = advisory()
|
|
valid["credits_detailed"] += [
|
|
{"user": None, "state": "accepted", "type": "reporter"},
|
|
{"state": "accepted", "type": "reporter"},
|
|
]
|
|
self.assertEqual(
|
|
sync.public_snapshot([valid])["advisories"][0]["researchers"],
|
|
["researcher"],
|
|
)
|
|
missing = advisory()
|
|
del missing["ghsa_id"]
|
|
for broken in (missing, advisory(None), advisory("GHSA-not-valid")):
|
|
with self.subTest(ghsa=broken.get("ghsa_id")), self.assertRaises(
|
|
PublicDataError
|
|
) as raised:
|
|
sync.public_snapshot([valid, broken])
|
|
self.assertIs(raised.exception.code, PublicErrorCode.ADVISORY_ID)
|
|
|
|
def test_only_valid_cve_identifiers_are_rendered(self):
|
|
item = advisory()
|
|
item["cve_id"] = "CVE-2026-12345"
|
|
self.assertIn("(CVE-2026-12345)", notes([item]))
|
|
for cve in (None, "CVE-2026-12", "CVE-2026-12345<script>"):
|
|
with self.subTest(cve=cve):
|
|
item["cve_id"] = cve
|
|
self.assertNotIn("CVE-", notes([item]))
|
|
|
|
def test_requires_matching_fixed_version_and_exact_release_reference(self):
|
|
good = advisory()
|
|
wrong_version = copy.deepcopy(good)
|
|
wrong_version["vulnerabilities"][0]["patched_versions"] = "0.8.0"
|
|
wrong_tag = copy.deepcopy(good)
|
|
wrong_tag["description"] = wrong_tag["description"].replace(TAG, TAG + "0")
|
|
missing_reference = copy.deepcopy(good)
|
|
missing_reference["description"] = "Unrelated release"
|
|
for item in (wrong_version, wrong_tag, missing_reference):
|
|
with self.subTest(description=item["description"]):
|
|
self.assertEqual(release_tags(item), [])
|
|
for fixed in ("0.9.0", "v0.9.0", TAG, ">= " + TAG):
|
|
good["vulnerabilities"][0]["patched_versions"] = fixed
|
|
self.assertEqual(release_tags(good), [TAG])
|
|
|
|
def test_repeated_generation_preserves_surrounding_notes(self):
|
|
original = "# Features\n\nExisting text.\n"
|
|
once = notes([advisory()], original)
|
|
self.assertTrue(once.startswith(original))
|
|
self.assertEqual(notes([advisory()], once), once)
|
|
with_suffix = once + "\n## Contributors\nKeep this too.\n"
|
|
updated = advisory()
|
|
updated["cve_id"] = "CVE-2026-12345"
|
|
result = notes([updated], with_suffix)
|
|
self.assertTrue(result.startswith(original))
|
|
self.assertTrue(result.endswith("## Contributors\nKeep this too.\n"))
|
|
self.assertIn("(CVE-2026-12345)", result)
|
|
self.assertEqual(result.count(START), 1)
|
|
|
|
def test_rows_fixed_in_different_versions_need_a_mapping_for_the_patched_row(self):
|
|
item = advisory()
|
|
platform = {"ecosystem": "other", "name": "autogpt-platform"}
|
|
classic = {"ecosystem": "other", "name": "autogpt-classic"}
|
|
item["vulnerabilities"] = [
|
|
{"package": platform, "patched_versions": "0.8.0"},
|
|
{"package": classic, "patched_versions": "0.9.0"},
|
|
]
|
|
with self.assertRaises(PublicDataError) as raised:
|
|
release_tags(item)
|
|
self.assertIs(raised.exception.code, PublicErrorCode.RELEASE_MAPPING)
|
|
|
|
# The mapped row was fixed in a different release, so this one isn't credited.
|
|
reference = json.dumps({"tag": TAG, "package": platform})
|
|
item["description"] += f"\n<!-- security-release: {reference} -->"
|
|
self.assertEqual(release_tags(item), [])
|
|
|
|
item["vulnerabilities"][0]["patched_versions"] = "0.9.0"
|
|
item["vulnerabilities"][1]["patched_versions"] = "0.8.0"
|
|
self.assertEqual(release_tags(item), [TAG])
|
|
|
|
item["description"] = advisory()["description"]
|
|
reference = json.dumps({"tag": TAG + "0", "package": platform})
|
|
item["description"] += f"\n<!-- security-release: {reference} -->"
|
|
with self.assertRaises(PublicDataError):
|
|
release_tags(item)
|
|
|
|
def test_package_references_are_validated(self):
|
|
item = advisory()
|
|
package = {"ecosystem": "other", "name": "significantgravitas/autogpt"}
|
|
item["vulnerabilities"] = [
|
|
{"package": package, "patched_versions": "0.9.0"},
|
|
{
|
|
"package": {"ecosystem": "other", "name": "autogpt-platform"},
|
|
"patched_versions": "0.8.0",
|
|
},
|
|
]
|
|
for invalid in ("not-json", "null", "[]", '{"tag": "x"}'):
|
|
item["description"] = advisory()["description"]
|
|
item["description"] += f"\n<!-- security-release: {invalid} -->"
|
|
with self.subTest(invalid=invalid), self.assertRaises(PublicDataError):
|
|
release_tags(item)
|
|
reference = json.dumps({"tag": TAG, "package": package})
|
|
item["description"] = advisory()["description"]
|
|
item["description"] += f"\n<!-- security-release: {reference} -->"
|
|
self.assertEqual(release_tags(item), [TAG])
|
|
|
|
def test_release_links_allow_prose_punctuation_but_reject_tag_extensions(self):
|
|
item = advisory()
|
|
url = f"{BASE_URL}/releases/tag/{TAG}"
|
|
for suffix in (
|
|
".",
|
|
", next",
|
|
"; next",
|
|
": next",
|
|
"!",
|
|
"]",
|
|
'"',
|
|
"'",
|
|
"`",
|
|
'."',
|
|
"?download=1",
|
|
"#notes",
|
|
):
|
|
with self.subTest(suffix=suffix):
|
|
item["description"] = url + suffix
|
|
self.assertEqual(release_tags(item), [TAG])
|
|
for suffix in ("0", ".1", ".other", "-rc1", "/different", ",next"):
|
|
with self.subTest(suffix=suffix):
|
|
item["description"] = url + suffix
|
|
self.assertEqual(release_tags(item), [])
|
|
|
|
def test_malformed_markers_fail_without_overwriting_notes(self):
|
|
snapshot = sync.public_snapshot([advisory()])
|
|
for existing in (START, END, END + START, START + START + END):
|
|
with self.subTest(existing=existing), self.assertRaises(PublicDataError):
|
|
sync.public_release_notes(snapshot, TAG, existing)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|