mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-09 04:49:50 -10:00
### Why / What / How **Why:** two gaps in the public security sync from #16151. 1. **The credits PR's CI never starts by itself.** The sync opens its credits PR with `GITHUB_TOKEN`. In this repo, every CI run on a PR opened that way waits as `action_required` until a maintainer approves it. #13152, the weekly Anthropic-rates PR, has sat like that since May. So each credits update would need that click before it could even be reviewed. 2. **A forgotten release link goes unnoticed.** An advisory is listed under a release only when its description links that release. A published advisory without the link is left out of every release's notes, with no warning anywhere. **What:** the credits PR is opened by the batch-bot App, so its CI runs like any other PR's. The dry-run summary and the credits PR both name any published advisory that no release lists. **How:** - The credits job mints a batch-bot App token with `actions/create-github-app-token`, scoped to `contents: write` and `pull-requests: write`, the same way `docs-gitbook-sync.yml` does. It mints the token after the files are generated and passes it only to the `create-pull-request` step. The job's own `GITHUB_TOKEN` now has no permissions. - The release writer keeps `GITHUB_TOKEN` on purpose. Release edits made with it never start release workflows, which is what keeps old tags' `release.edited` image builds from firing. - A new `unlinked_advisories(snapshot)` helper returns the published advisories with no release tags. The `credits` command's JSON output carries that list, the credits PR body lists it, and the preview prints it ("none." today). ### Changes 🏗️ - `.github/workflows/security-advisory-sync.yml` (credits job): - The PR token is a batch-bot App token, and the job's own `GITHUB_TOKEN` has no permissions. - The PR body gets a line listing unlinked advisories. - The checklist no longer asks the maintainer to approve CI runs. - `security_public_sync.py`: adds `unlinked_advisories()`; `prepare_credits()` returns it under `unlinked`. - `security_public_publish.py`: the preview names unlinked published advisories, or says none. - `test_security_public_sync.py`: a new test covers the list in the credits output and the preview, including that drafts never appear. The exact-output assertions now include `unlinked`. - `docs/engineering/security-advisory-release-notes.md`: the permissions paragraph explains why each writer uses the token it does, and rollout step 4 and the read-only job's description are updated. ### Agents and large language models used - Claude Code with Claude Opus 5.5: design, implementation and validation. ### 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'`: 101 passed. The security tests also pass on Python 3.10, which "Check PR Status" uses. - [x] Three mutants are each killed by the new test: the inverted filter, the list dropped from the credits output, and the preview always saying none. - [x] Live anonymous dry run against the public API: `credits` reports `"unlinked": []`, and the preview still proposes sections for 19 releases and reports no unlinked advisories. - [x] `actionlint` 1.7.12 is clean on the three security and Classic workflows. The new step's `jq` expression and `errexit` handling were exercised with both empty and non-empty lists and a failing `credits` command. - [x] `pre-commit` (Black, gitleaks, merge-conflict and debug checks) on the changed files. - [ ] Live App-token PR creation: this runs only after the variables are enabled on `master`. The batch-bot App already opens and updates PRs here (`batch-command-handler.yml` uses `permission-pull-requests: write`). #### 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**). It uses the existing `BATCH_BOT_APP_ID` variable and `BATCH_BOT_PRIVATE_KEY` secret; nothing new. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""Generate public credits and release sections from a strict advisory projection."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from security_public_api import PublicDataError, PublicErrorCode, public_pages
|
|
from security_release_notes import (
|
|
BASE_URL,
|
|
END,
|
|
LOGIN_PATTERN,
|
|
START,
|
|
accepted_researchers,
|
|
fetch_advisories,
|
|
fixed_in,
|
|
)
|
|
|
|
SNAPSHOT_PATH = Path(".github/security-advisories.public.json")
|
|
CREDIT_START = "<!-- security-credits:start -->"
|
|
CREDIT_END = "<!-- security-credits:end -->"
|
|
GHSA_PATTERN = r"GHSA-[a-z0-9]{4}(?:-[a-z0-9]{4}){2}"
|
|
CVE_PATTERN = r"CVE-\d{4}-\d{4,}"
|
|
# Names researchers asked to be credited by, shown beside their handle in SECURITY.md
|
|
# and release notes. Add one only at the researcher's request.
|
|
RESEARCHER_NAMES = {
|
|
"1024wlsdud": "Dirstibone",
|
|
"DavidCarliez": "David Carliez",
|
|
"Fried-Squid": "Ace",
|
|
"Fushuling": "狗and猫",
|
|
"geckosecurity": "Gecko Security",
|
|
"Greg-Kim": "Minjoon Gregorio Kim",
|
|
"jjjutla": "JJ",
|
|
"MegaManSec": "Joshua Rogers",
|
|
"nkoorty": "Artemiy",
|
|
"pavanchow": "Pavan Nallamothu",
|
|
"programsurf": "Sunwoo Lee",
|
|
"RacerZ-fighting": "RacerZ",
|
|
"sivaadityacoder": "Panuganti Siva Aditya",
|
|
"TrebledJ": "Johnathan",
|
|
"YLChen-007": "Edward-x",
|
|
"yoonsh": "Seunghyun Yoon",
|
|
}
|
|
TAG_PATTERN = (
|
|
r"(?:autogpt-platform-beta-v|agpt-platform-beta-v|autogpt-v|v)\d+\.\d+\.\d+"
|
|
)
|
|
|
|
|
|
def matches(pattern, value):
|
|
return isinstance(value, str) and re.fullmatch(pattern, value) is not None
|
|
|
|
|
|
def validate_snapshot(snapshot):
|
|
if (
|
|
not isinstance(snapshot, dict)
|
|
or set(snapshot) != {"schema_version", "advisories"}
|
|
or type(snapshot["schema_version"]) is not int
|
|
or snapshot["schema_version"] != 1
|
|
or not isinstance(snapshot["advisories"], list)
|
|
):
|
|
raise PublicDataError(PublicErrorCode.SNAPSHOT_SCHEMA)
|
|
ids = set()
|
|
for item in snapshot["advisories"]:
|
|
if (
|
|
not isinstance(item, dict)
|
|
or set(item) != {"ghsa_id", "cve_id", "researchers", "release_tags"}
|
|
or not matches(GHSA_PATTERN, item["ghsa_id"])
|
|
or item["ghsa_id"] in ids
|
|
or (item["cve_id"] is not None and not matches(CVE_PATTERN, item["cve_id"]))
|
|
):
|
|
raise PublicDataError(PublicErrorCode.SNAPSHOT_RECORD)
|
|
ids.add(item["ghsa_id"])
|
|
for field, pattern in (
|
|
("researchers", LOGIN_PATTERN),
|
|
("release_tags", TAG_PATTERN),
|
|
):
|
|
values = item[field]
|
|
if (
|
|
not isinstance(values, list)
|
|
or not all(matches(pattern, v) for v in values)
|
|
or len(set(values)) != len(values)
|
|
):
|
|
raise PublicDataError(PublicErrorCode.SNAPSHOT_FIELD)
|
|
return snapshot
|
|
|
|
|
|
def public_snapshot(advisories):
|
|
records = []
|
|
for advisory in advisories:
|
|
if not isinstance(advisory, dict):
|
|
raise PublicDataError(PublicErrorCode.ADVISORY_RECORD)
|
|
if (
|
|
advisory.get("state") != "published"
|
|
or not advisory.get("published_at")
|
|
or advisory.get("withdrawn_at")
|
|
):
|
|
continue
|
|
if not matches(GHSA_PATTERN, advisory.get("ghsa_id")):
|
|
raise PublicDataError(PublicErrorCode.ADVISORY_ID)
|
|
tags = set(
|
|
re.findall(
|
|
re.escape(BASE_URL + "/releases/tag/") + "(" + TAG_PATTERN + ")",
|
|
advisory.get("description") or "",
|
|
)
|
|
)
|
|
cve = advisory.get("cve_id")
|
|
try:
|
|
release_tags = sorted(tag for tag in tags if fixed_in(advisory, tag))
|
|
except ValueError:
|
|
raise PublicDataError(PublicErrorCode.RELEASE_MAPPING) from None
|
|
records.append(
|
|
{
|
|
"ghsa_id": advisory["ghsa_id"],
|
|
"cve_id": cve if matches(CVE_PATTERN, cve) else None,
|
|
"researchers": accepted_researchers(advisory),
|
|
"release_tags": release_tags,
|
|
}
|
|
)
|
|
return validate_snapshot(
|
|
{
|
|
"schema_version": 1,
|
|
"advisories": sorted(records, key=lambda item: item["ghsa_id"]),
|
|
}
|
|
)
|
|
|
|
|
|
def current_snapshot(expected_digest=None):
|
|
snapshot = public_snapshot(fetch_advisories())
|
|
if expected_digest is not None and snapshot_digest(snapshot) != expected_digest:
|
|
raise PublicDataError(PublicErrorCode.ADVISORIES_CHANGED)
|
|
return snapshot
|
|
|
|
|
|
def snapshot_text(snapshot):
|
|
return json.dumps(validate_snapshot(snapshot), indent=2, sort_keys=True) + "\n"
|
|
|
|
|
|
def snapshot_digest(snapshot):
|
|
return hashlib.sha256(snapshot_text(snapshot).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def changes(previous, current):
|
|
old, new = (
|
|
{a["ghsa_id"]: a for a in validate_snapshot(s)["advisories"]}
|
|
for s in (previous, current)
|
|
)
|
|
return {
|
|
"added": len(new.keys() - old.keys()),
|
|
"removed": len(old.keys() - new.keys()),
|
|
"updated": sum(old[key] != new[key] for key in old.keys() & new.keys()),
|
|
}
|
|
|
|
|
|
def managed_section(existing, start, end, section, *, append=False):
|
|
if start not in existing and end not in existing and append:
|
|
return existing.rstrip() + ("\n\n" if existing.strip() else "") + section + "\n"
|
|
if existing.count(start) != 1 or existing.count(end) != 1:
|
|
raise PublicDataError(PublicErrorCode.MARKER_PAIR)
|
|
left, right = existing.index(start), existing.index(end)
|
|
if right < left:
|
|
raise PublicDataError(PublicErrorCode.MARKER_ORDER)
|
|
return existing[:left] + section + existing[right + len(end) :]
|
|
|
|
|
|
def researcher_links(logins):
|
|
links = []
|
|
for login in logins:
|
|
link = f"[@{login}](https://github.com/{login})"
|
|
name = RESEARCHER_NAMES.get(login)
|
|
links.append(f"{name} ({link})" if name else link)
|
|
return ", ".join(links)
|
|
|
|
|
|
def acknowledgments(snapshot, existing):
|
|
logins = sorted(
|
|
{
|
|
login
|
|
for a in validate_snapshot(snapshot)["advisories"]
|
|
for login in a["researchers"]
|
|
},
|
|
key=str.casefold,
|
|
)
|
|
section = "\n".join(
|
|
[
|
|
CREDIT_START,
|
|
"### Published advisory credits",
|
|
"",
|
|
"Accepted reporter, finder and analyst credits on published advisories:",
|
|
"",
|
|
*(f"- {researcher_links([login])}" for login in logins),
|
|
"",
|
|
CREDIT_END,
|
|
]
|
|
)
|
|
return managed_section(existing, CREDIT_START, CREDIT_END, section)
|
|
|
|
|
|
def public_release_notes(snapshot, tag, existing):
|
|
validate_snapshot(snapshot)
|
|
if not matches(TAG_PATTERN, tag):
|
|
raise PublicDataError(PublicErrorCode.RELEASE_TAG)
|
|
selected = [a for a in snapshot["advisories"] if tag in a["release_tags"]]
|
|
if not selected and START not in existing and END not in existing:
|
|
return existing
|
|
lines = [START, "## Security fixes", ""]
|
|
for item in selected:
|
|
ghsa = item["ghsa_id"]
|
|
line = f"- [{ghsa}]({BASE_URL}/security/advisories/{ghsa})"
|
|
if item["cve_id"]:
|
|
line += f" ({item['cve_id']})"
|
|
if item["researchers"]:
|
|
line += ". Thanks to " + researcher_links(item["researchers"])
|
|
lines.append(line + ".")
|
|
if not selected:
|
|
lines.append("No currently published advisories are linked to this release.")
|
|
lines += ["", END]
|
|
return managed_section(existing, START, END, "\n".join(lines), append=True)
|
|
|
|
|
|
UNLINKED_HINT = (
|
|
"Check that each one's description links the release that fixed it, that its "
|
|
"patched version matches that release, and that the release is published."
|
|
)
|
|
|
|
|
|
def published_release(release):
|
|
return (
|
|
isinstance(release, dict)
|
|
and type(release.get("id")) is int
|
|
and release["id"] > 0
|
|
and release.get("draft") is False
|
|
and bool(release.get("published_at"))
|
|
and matches(TAG_PATTERN, release.get("tag_name"))
|
|
# GitHub returns null for a release with no notes.
|
|
and (release.get("body") is None or isinstance(release["body"], str))
|
|
)
|
|
|
|
|
|
def fetch_releases():
|
|
return public_pages("releases")
|
|
|
|
|
|
def published_release_tags(releases):
|
|
return {release["tag_name"] for release in releases if published_release(release)}
|
|
|
|
|
|
def unlinked_advisories(snapshot, release_tags):
|
|
"""Published advisories that no published release lists: the description doesn't
|
|
link the release that fixed it, the patched version doesn't match that release,
|
|
or the release isn't published."""
|
|
return [
|
|
a["ghsa_id"]
|
|
for a in validate_snapshot(snapshot)["advisories"]
|
|
if not set(a["release_tags"]) & release_tags
|
|
]
|
|
|
|
|
|
def prepare_credits(root, expected_digest):
|
|
for path in (root, root / ".github", root / SNAPSHOT_PATH, root / "SECURITY.md"):
|
|
if path.is_symlink():
|
|
raise PublicDataError(PublicErrorCode.FILE_SYMLINK)
|
|
snapshot = current_snapshot(expected_digest)
|
|
previous = validate_snapshot(
|
|
json.loads((root / SNAPSHOT_PATH).read_text(encoding="utf-8"))
|
|
)
|
|
security = root / "SECURITY.md"
|
|
updated = acknowledgments(snapshot, security.read_text(encoding="utf-8"))
|
|
difference = changes(previous, snapshot)
|
|
unlinked = unlinked_advisories(snapshot, published_release_tags(fetch_releases()))
|
|
security.write_text(updated, encoding="utf-8")
|
|
(root / SNAPSHOT_PATH).write_text(snapshot_text(snapshot), encoding="utf-8")
|
|
return dict(difference, unlinked=unlinked)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
subparsers.add_parser("digest")
|
|
credits = subparsers.add_parser("credits")
|
|
credits.add_argument("--root", type=Path, required=True)
|
|
credits.add_argument("--expected-digest", required=True)
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "digest":
|
|
print(snapshot_digest(current_snapshot()))
|
|
else:
|
|
print(
|
|
json.dumps(
|
|
prepare_credits(args.root, args.expected_digest), sort_keys=True
|
|
)
|
|
)
|
|
except PublicDataError as error:
|
|
parser.exit(1, f"Public security sync failed: {error.code.value}\n")
|
|
except Exception:
|
|
parser.exit(
|
|
1, "Public security sync failed; no raw response or report was logged.\n"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|