From 1d89775612827c2cd227737e1e1a4739d1d0f4e1 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Tue, 28 Jul 2026 14:23:46 -0500 Subject: [PATCH] Automatically credit remediation developers and reviewers --- src/psrt_ghsa_bot/app.py | 62 +++++++++++++++++++++++++++++++++++++ tests/test_app.py | 66 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/psrt_ghsa_bot/app.py b/src/psrt_ghsa_bot/app.py index 971a5d6..2edc7d4 100644 --- a/src/psrt_ghsa_bot/app.py +++ b/src/psrt_ghsa_bot/app.py @@ -96,6 +96,64 @@ def get_repository_advisories( raise RuntimeError("Request to paginate advisories failed.") +def get_security_advisory_credits( + github: GitHub, + security_advisory: dict[str, typing.Any], +) -> list[dict[str, str]]: + """Generates a list of credits to apply to a security + advisory, such as developing or reviewing a remediation. + Respects credits that already exist on an advisory. + """ + + credits = [] + + def credit_if_uncredited(login: str, type: str) -> None: + # GHSA only allows one credit type per user, + # so we don't want to overwrite existing credits. + nonlocal credits + if any(c["login"] == login for c in (security_advisory["credits"] + credits)): + return + credits.append( + { + "login": login, + "type": type, + } + ) + + if (private_fork := security_advisory.get("private_fork")) is not None: + private_fork_owner = private_fork["owner"]["login"] + private_fork_repo = private_fork["name"] + + pull_requests = json.loads( + github.rest.pulls.list( + owner=private_fork_owner, + repo=private_fork_repo, + state="open", + ).content + ) + for pull_request in pull_requests: + # fmt: off + credit_if_uncredited( + login=pull_request["user"]["login"], + type="remediation_developer" + ) + # fmt: on + reviews = json.loads( + github.rest.pulls.list_reviews( + owner=private_fork_owner, + repo=private_fork_repo, + pull_number=pull_request["number"], + ).content + ) + for review in reviews: + credit_if_uncredited( + login=review["user"]["login"], + type="remediation_reviewer", + ) + + return credits + + def github_client_request(client: typing.Any, method: str, url: str, params: dict[str, str | int]) -> typing.Any: """Sends a raw HTTP request using a GitHub API client""" headers = {"X-GitHub-Api-Version": client._REST_API_VERSION} @@ -184,6 +242,10 @@ def apply_to_repo(github: GitHub, owner: str, repo: str, cve_api: CveApi, *, res patch_data["collaborating_teams"] = sorted(collaborating_teams) print(f" ➕ Will ensure team present: {PSRT_GITHUB_TEAM_SLUG}") + # Find new credits for the security advisory. + if credits := get_security_advisory_credits(github, security_advisory): + patch_data["credits"] = credits + # Apply updates, if any, to the security advisory. if patch_data: try: diff --git a/tests/test_app.py b/tests/test_app.py index e130477..e64316f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,5 @@ import datetime +import json from unittest import mock import pytest @@ -43,7 +44,6 @@ def _create_advisory_dict(state, cve_id, collaborating_teams, summary=""): "cve_id": cve_id, "collaborating_teams": [{"slug": team} for team in collaborating_teams], "collaborating_users": [{"login": "octocat", "id": 1, "type": "User"}], - "private_fork": {"name": "repo-ghsa-xxxx-xxxx-xxxx", "owner": {"login": "owner"}}, } @@ -252,6 +252,70 @@ def test_accepts_advisory_with_accept_tag(summary, cve_id, cve_reserve_response) ) +def test_get_security_advisory_credits_no_private_fork(): + github = mock.Mock() + credits = app.get_security_advisory_credits( + github=github, + security_advisory={ + "private_fork": None, + }, + ) + assert credits == [] + + +def test_get_security_advisory_credits_no_prs(): + github = mock.Mock() + pulls_list = mock.Mock() + pulls_list.content = "[]" + github.rest.pulls.list.return_value = pulls_list + credits = app.get_security_advisory_credits( + github=github, + security_advisory={ + "private_fork": { + "owner": {"login": "fork-owner"}, + "name": "fork-name", + }, + }, + ) + assert credits == [] + github.rest.pulls.list.assert_called_with( + owner="fork-owner", + repo="fork-name", + state="open", + ) + + +def test_get_security_advisory_credits(): + github = mock.Mock() + + pulls_list = mock.Mock() + pulls_list.content = json.dumps([{"number": 1, "user": {"login": "author"}}]) + github.rest.pulls.list.return_value = pulls_list + + reviews_list = mock.Mock() + reviews_list.content = json.dumps([{"user": {"login": "reviewer1"}}, {"user": {"login": "reviewer2"}}]) + github.rest.pulls.list_reviews.return_value = reviews_list + + credits = app.get_security_advisory_credits( + github=github, + security_advisory={ + "private_fork": { + "owner": {"login": "fork-owner"}, + "name": "fork-name", + }, + "credits": [ + {"type": "coordinator", "login": "reviewer1"}, + ], + }, + ) + + # reviewer1 is skipped because they are already coordinator. + assert credits == [ + {"login": "author", "type": "remediation_developer"}, + {"login": "reviewer2", "type": "remediation_reviewer"}, + ] + + def test_reserve_one_cve_id(cve_reserve_response, cve_id, year) -> None: cve_api = mock.Mock() cve_api.reserve.return_value = cve_reserve_response