diff --git a/pontos/github/api/client.py b/pontos/github/api/client.py index a512fd796..fa6146c9f 100644 --- a/pontos/github/api/client.py +++ b/pontos/github/api/client.py @@ -18,7 +18,16 @@ from contextlib import AbstractAsyncContextManager from types import TracebackType -from typing import Any, AsyncContextManager, AsyncIterator, Dict, Optional, Type +from typing import ( + Any, + AsyncContextManager, + AsyncIterator, + Dict, + Mapping, + Optional, + Type, + Union, +) import httpx @@ -31,8 +40,9 @@ ) from pontos.github.models.base import GitHubModel -Headers = Dict[str, str] -Params = Dict[str, str] +Headers = Mapping[str, str] +ParamValue = Union[str, None] +Params = Mapping[str, ParamValue] # supported GitHub API version # https://docs.github.com/en/rest/overview/api-versions @@ -303,12 +313,12 @@ async def _get_paged_items( Internal method to get the paged items information from different REST URLs. """ - if not params: - params = {} - - params["per_page"] = "100" # max number + request_params: dict[str, ParamValue] = {} + if params: + request_params.update(params) + request_params["per_page"] = "100" # max number - async for response in self._client.get_all(api, params=params): + async for response in self._client.get_all(api, params=request_params): response.raise_for_status() data: JSON_OBJECT = response.json() for item in data.get(name, []): # type: ignore diff --git a/pontos/github/api/code_scanning.py b/pontos/github/api/code_scanning.py new file mode 100644 index 000000000..78e11ba09 --- /dev/null +++ b/pontos/github/api/code_scanning.py @@ -0,0 +1,309 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import AsyncIterator, Optional, Union + +from pontos.github.api.client import GitHubAsyncREST +from pontos.github.models.base import SortOrder +from pontos.github.models.code_scanning import ( + AlertSort, + AlertState, + CodeScanningAlert, + DismissedReason, + Instance, + Severity, +) +from pontos.helper import enum_or_value + + +class GitHubAsyncRESTCodeScanning(GitHubAsyncREST): + async def _alerts( + self, + api: str, + *, + tool_name: Optional[str] = None, + tool_guid: Optional[str] = "", + severity: Union[Severity, str, None] = None, + state: Union[AlertState, str, None] = None, + sort: Union[AlertSort, str] = AlertSort.CREATED, + direction: Union[str, SortOrder] = SortOrder.DESC, + ) -> AsyncIterator[CodeScanningAlert]: + params: dict[str, Union[str, None]] = {"per_page": "100"} + + if tool_name: + params["tool_name"] = tool_name + if tool_guid or tool_guid is None: + params["tool_guid"] = tool_guid + if severity: + params["severity"] = enum_or_value(severity) + if state: + params["state"] = enum_or_value(state) + if sort: + params["sort"] = enum_or_value(sort) + if direction: + params["direction"] = enum_or_value(direction) + + async for response in self._client.get_all(api, params=params): + for alert in response.json(): + yield CodeScanningAlert.from_dict(alert) + + async def organization_alerts( + self, + organization: str, + *, + tool_name: Optional[str] = None, + tool_guid: Optional[str] = "", + severity: Union[Severity, str, None] = None, + state: Union[AlertState, str, None] = None, + sort: Union[AlertSort, str] = AlertSort.CREATED, + direction: Union[str, SortOrder] = SortOrder.DESC, + ) -> AsyncIterator[CodeScanningAlert]: + """ + Get the list of code scanning alerts for all repositories of a GitHub + organization + + https://docs.github.com/en/rest/code-scanning/code-scanning#list-code-scanning-alerts-for-an-organization + + Args: + organization: Name of the organization + tool_name: The name of a code scanning tool. Only results by this + tool will be listed. You can specify the tool by using either + tool_name or tool_guid, but not both. + tool_guid: The GUID of a code scanning tool. Only results by this + tool will be listed. Note that some code scanning tools may not + include a GUID in their analysis data. You can specify the tool + by using either tool_guid or tool_name, but not both + severity: If specified, only code scanning alerts with this severity + will be returned + state: Filter alerts by state + resolutions + sort: The property by which to sort the results. Default is to sort + alerts by creation date. + direction: The direction to sort the results by. Default is desc. + + Raises: + HTTPStatusError: A httpx.HTTPStatusError is raised if the request + failed. + + Returns: + An async iterator yielding the code scanning alerts + + Example: + .. code-block:: python + + from pontos.github.api import GitHubAsyncRESTApi + + async with GitHubAsyncRESTApi(token) as api: + async for alert in api.code_scanning.organization_alerts( + "my-org" + ): + print(alert) + """ + + api = f"/orgs/{organization}/code-scanning/alerts" + async for alert in self._alerts( + api, + state=state, + severity=severity, + tool_guid=tool_guid, + tool_name=tool_name, + sort=sort, + direction=direction, + ): + yield alert + + async def alerts( + self, + repo: str, + *, + tool_name: Optional[str] = None, + tool_guid: Optional[str] = "", + severity: Union[Severity, str, None] = None, + state: Union[AlertState, str, None] = None, + sort: Union[AlertSort, str] = AlertSort.CREATED, + direction: Union[str, SortOrder] = SortOrder.DESC, + ) -> AsyncIterator[CodeScanningAlert]: + """ + Get the list of code scanning alerts for a repository + + https://docs.github.com/en/rest/code-scanning/code-scanning#list-code-scanning-alerts-for-a-repository + + Args: + repo: GitHub repository (owner/name) + + Raises: + HTTPStatusError: A httpx.HTTPStatusError is raised if the request + failed. + + Returns: + An async iterator yielding the code scanning alerts + + Example: + .. code-block:: python + + from pontos.github.api import GitHubAsyncRESTApi + + async with GitHubAsyncRESTApi(token) as api: + async for alert in api.code_scanning.alerts( + "org/repo" + ): + print(alert) + """ + + api = f"/repos/{repo}/code-scanning/alerts" + async for alert in self._alerts( + api, + state=state, + severity=severity, + tool_guid=tool_guid, + tool_name=tool_name, + sort=sort, + direction=direction, + ): + yield alert + + async def alert( + self, + repo: str, + alert_number: Union[str, int], + ) -> CodeScanningAlert: + """ + Get a single code scanning alert + + https://docs.github.com/en/rest/code-scanning/code-scanning#get-a-code-scanning-alert + + Args: + repo: GitHub repository (owner/name) + alert_number: The number that identifies a code scanning alert in + its repository + + Raises: + HTTPStatusError: A httpx.HTTPStatusError is raised if the request + failed. + + Returns: + Code scanning alert information + + Example: + .. code-block:: python + + from pontos.github.api import GitHubAsyncRESTApi + + async with GitHubAsyncRESTApi(token) as api: + alert = await api.code_scanning.alert("foo/bar", 123) + """ + api = f"/repos/{repo}/code-scanning/alerts/{alert_number}" + response = await self._client.get(api) + response.raise_for_status() + return CodeScanningAlert.from_dict(response.json()) + + async def update_alert( + self, + repo: str, + alert_number: Union[str, int], + state: Union[AlertState, str], + *, + dismissed_reason: Union[DismissedReason, str, None] = None, + dismissed_comment: Optional[str] = None, + ) -> CodeScanningAlert: + """ + Update a single code scanning alert + + https://docs.github.com/en/rest/code-scanning/code-scanning#update-a-code-scanning-alert + + Args: + repo: GitHub repository (owner/name) + alert_number: The number that identifies a code scanning alert in + its repository + state: The state of the alert + dismissed_reason: The reason for dismissing or closing the alert + dismissed_comment: The dismissal comment associated with the + dismissal of the alert. + + Raises: + HTTPStatusError: A httpx.HTTPStatusError is raised if the request + failed. + + Returns: + Code scanning alert information + + Example: + .. code-block:: python + + from pontos.github.api import GitHubAsyncRESTApi + from pontos.github.models.code_scanning import ( + AlertState, + DismissedReason, + ) + + async with GitHubAsyncRESTApi(token) as api: + alert = await api.code_scanning.update_alert( + "foo/bar", + 123, + AlertState.DISMISSED, + dismissed_reason=DismissedReason.WONT_FIX, + dismissed_comment="Not applicable", + ) + """ + api = f"/repos/{repo}/code-scanning/alerts/{alert_number}" + + data = {"state": enum_or_value(state)} + if dismissed_reason: + data["dismissed_reason"] = enum_or_value(dismissed_reason) + if dismissed_comment: + data["dismissed_comment"] = dismissed_comment + + response = await self._client.patch(api, data=data) + response.raise_for_status() + return CodeScanningAlert.from_dict(response.json()) + + async def instances( + self, + repo: str, + alert_number: Union[str, int], + *, + ref: Optional[str] = None, + ) -> AsyncIterator[Instance]: + """ + Lists all instances of the specified code scanning alert + + https://docs.github.com/en/rest/code-scanning/code-scanning#list-instances-of-a-code-scanning-alert + + Args: + repo: GitHub repository (owner/name) + alert_number: The number that identifies a code scanning alert in + its repository + ref: The Git reference for the results you want to list. The ref + for a branch can be formatted either as refs/heads/ + or simply . To reference a pull request use + refs/pull//merge. + + Raises: + HTTPStatusError: A httpx.HTTPStatusError is raised if the request + failed. + + Returns: + An async iterator yielding the code scanning alert instances + + Example: + .. code-block:: python + + from pontos.github.api import GitHubAsyncRESTApi + + async with GitHubAsyncRESTApi(token) as api: + async for instance in api.code_scanning.instances( + "org/repo", 1 + ): + print(instance) + """ + + api = f"/repos/{repo}/code-scanning/alerts/{alert_number}/instances" + params = {"per_page": "100"} + + if ref: + params["ref"] = ref + + async for response in self._client.get_all(api, params=params): + for alert in response.json(): + yield Instance.from_dict(alert) diff --git a/tests/github/api/test_code_scanning.py b/tests/github/api/test_code_scanning.py new file mode 100644 index 000000000..d56644eeb --- /dev/null +++ b/tests/github/api/test_code_scanning.py @@ -0,0 +1,824 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# ruff: noqa:E501 + +from pontos.github.api.code_scanning import GitHubAsyncRESTCodeScanning +from pontos.github.models.base import SortOrder +from pontos.github.models.code_scanning import ( + AlertSort, + AlertState, + DismissedReason, + Severity, +) +from tests import AsyncIteratorMock, aiter, anext +from tests.github.api import GitHubAsyncRESTTestCase, create_response + +ALERTS = [ + { + "number": 4, + "created_at": "2020-02-13T12:29:18Z", + "url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/4", + "html_url": "https://github.com/octocat/hello-world/code-scanning/4", + "state": "open", + "dismissed_by": None, + "dismissed_at": None, + "dismissed_reason": None, + "dismissed_comment": None, + "rule": { + "id": "js/zipslip", + "severity": "error", + "tags": ["security", "external/cwe/cwe-022"], + "description": "Arbitrary file write during zip extraction", + "name": "js/zipslip", + }, + "tool": {"name": "CodeQL", "guid": None, "version": "2.4.0"}, + "most_recent_instance": { + "ref": "refs/heads/main", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "{}", + "state": "open", + "commit_sha": "39406e42cb832f683daa691dd652a8dc36ee8930", + "message": {"text": "This path depends on a user-provided value."}, + "location": { + "path": "spec-main/api-session-spec.ts", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + }, + "classifications": ["test"], + }, + "instances_url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/4/instances", + "repository": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "private": False, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": False, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + }, + }, + { + "number": 3, + "created_at": "2020-02-13T12:29:18Z", + "url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/3", + "html_url": "https://github.com/octocat/hello-world/code-scanning/3", + "state": "dismissed", + "dismissed_by": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "dismissed_at": "2020-02-14T12:29:18Z", + "dismissed_reason": "false positive", + "dismissed_comment": "This alert is not actually correct, because there's a sanitizer included in the library.", + "rule": { + "id": "js/zipslip", + "severity": "error", + "tags": ["security", "external/cwe/cwe-022"], + "description": "Arbitrary file write during zip extraction", + "name": "js/zipslip", + }, + "tool": {"name": "CodeQL", "guid": None, "version": "2.4.0"}, + "most_recent_instance": { + "ref": "refs/heads/main", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "{}", + "state": "open", + "commit_sha": "39406e42cb832f683daa691dd652a8dc36ee8930", + "message": {"text": "This path depends on a user-provided value."}, + "location": { + "path": "lib/ab12-gen.js", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + }, + "classifications": [], + }, + "instances_url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/3/instances", + "repository": { + "id": 1296269, + "node_id": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5", + "name": "Hello-World", + "full_name": "octocat/Hello-World", + "owner": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "private": False, + "html_url": "https://github.com/octocat/Hello-World", + "description": "This your first repo!", + "fork": False, + "url": "https://api.github.com/repos/octocat/Hello-World", + "archive_url": "https://api.github.com/repos/octocat/Hello-World/{archive_format}{/ref}", + "assignees_url": "https://api.github.com/repos/octocat/Hello-World/assignees{/user}", + "blobs_url": "https://api.github.com/repos/octocat/Hello-World/git/blobs{/sha}", + "branches_url": "https://api.github.com/repos/octocat/Hello-World/branches{/branch}", + "collaborators_url": "https://api.github.com/repos/octocat/Hello-World/collaborators{/collaborator}", + "comments_url": "https://api.github.com/repos/octocat/Hello-World/comments{/number}", + "commits_url": "https://api.github.com/repos/octocat/Hello-World/commits{/sha}", + "compare_url": "https://api.github.com/repos/octocat/Hello-World/compare/{base}...{head}", + "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/{+path}", + "contributors_url": "https://api.github.com/repos/octocat/Hello-World/contributors", + "deployments_url": "https://api.github.com/repos/octocat/Hello-World/deployments", + "downloads_url": "https://api.github.com/repos/octocat/Hello-World/downloads", + "events_url": "https://api.github.com/repos/octocat/Hello-World/events", + "forks_url": "https://api.github.com/repos/octocat/Hello-World/forks", + "git_commits_url": "https://api.github.com/repos/octocat/Hello-World/git/commits{/sha}", + "git_refs_url": "https://api.github.com/repos/octocat/Hello-World/git/refs{/sha}", + "git_tags_url": "https://api.github.com/repos/octocat/Hello-World/git/tags{/sha}", + "issue_comment_url": "https://api.github.com/repos/octocat/Hello-World/issues/comments{/number}", + "issue_events_url": "https://api.github.com/repos/octocat/Hello-World/issues/events{/number}", + "issues_url": "https://api.github.com/repos/octocat/Hello-World/issues{/number}", + "keys_url": "https://api.github.com/repos/octocat/Hello-World/keys{/key_id}", + "labels_url": "https://api.github.com/repos/octocat/Hello-World/labels{/name}", + "languages_url": "https://api.github.com/repos/octocat/Hello-World/languages", + "merges_url": "https://api.github.com/repos/octocat/Hello-World/merges", + "milestones_url": "https://api.github.com/repos/octocat/Hello-World/milestones{/number}", + "notifications_url": "https://api.github.com/repos/octocat/Hello-World/notifications{?since,all,participating}", + "pulls_url": "https://api.github.com/repos/octocat/Hello-World/pulls{/number}", + "releases_url": "https://api.github.com/repos/octocat/Hello-World/releases{/id}", + "stargazers_url": "https://api.github.com/repos/octocat/Hello-World/stargazers", + "statuses_url": "https://api.github.com/repos/octocat/Hello-World/statuses/{sha}", + "subscribers_url": "https://api.github.com/repos/octocat/Hello-World/subscribers", + "subscription_url": "https://api.github.com/repos/octocat/Hello-World/subscription", + "tags_url": "https://api.github.com/repos/octocat/Hello-World/tags", + "teams_url": "https://api.github.com/repos/octocat/Hello-World/teams", + "trees_url": "https://api.github.com/repos/octocat/Hello-World/git/trees{/sha}", + "hooks_url": "https://api.github.com/repos/octocat/Hello-World/hooks", + }, + }, +] + + +class GitHubAsyncRESTCodeScanningTestCase(GitHubAsyncRESTTestCase): + api_cls = GitHubAsyncRESTCodeScanning + + async def test_organization_alerts(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.organization_alerts("foo")) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={"per_page": "100", "sort": "created", "direction": "desc"}, + ) + + async def test_organization_alerts_state(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter( + self.api.organization_alerts("foo", state=AlertState.FIXED) + ) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "state": "fixed", + }, + ) + + async def test_organization_alerts_tool_name(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter( + self.api.organization_alerts("foo", tool_name="CodeQL") + ) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "tool_name": "CodeQL", + }, + ) + + async def test_organization_alerts_tool_guid(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.organization_alerts("foo", tool_guid=None)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "tool_guid": None, + }, + ) + + async def test_organization_alerts_severity(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter( + self.api.organization_alerts("foo", severity=Severity.ERROR) + ) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "severity": "error", + }, + ) + + async def test_organization_alerts_sort(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter( + self.api.organization_alerts("foo", sort=AlertSort.UPDATED) + ) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "updated", + "direction": "desc", + }, + ) + + async def test_organization_alerts_direction(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter( + self.api.organization_alerts("foo", direction=SortOrder.ASC) + ) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/orgs/foo/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "asc", + }, + ) + + async def test_alerts(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar")) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={"per_page": "100", "sort": "created", "direction": "desc"}, + ) + + async def test_alerts_state(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", state=AlertState.FIXED)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "state": "fixed", + }, + ) + + async def test_alerts_tool_name(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", tool_name="CodeQL")) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "tool_name": "CodeQL", + }, + ) + + async def test_alerts_tool_guid(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", tool_guid=None)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "tool_guid": None, + }, + ) + + async def test_alerts_severity(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", severity=Severity.ERROR)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "desc", + "severity": "error", + }, + ) + + async def test_alerts_sort(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", sort=AlertSort.UPDATED)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "updated", + "direction": "desc", + }, + ) + + async def test_alerts_direction(self): + response = create_response() + response.json.return_value = ALERTS + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.alerts("foo/bar", direction=SortOrder.ASC)) + alert = await anext(async_it) + self.assertEqual(alert.number, 4) + alert = await anext(async_it) + self.assertEqual(alert.number, 3) + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts", + params={ + "per_page": "100", + "sort": "created", + "direction": "asc", + }, + ) + + async def test_alert(self): + response = create_response() + response.json.return_value = { + "number": 42, + "created_at": "2020-06-19T11:21:34Z", + "url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/42", + "html_url": "https://github.com/octocat/hello-world/code-scanning/42", + "state": "dismissed", + "fixed_at": None, + "dismissed_by": { + "login": "octocat", + "id": 54933897, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "dismissed_at": "2020-02-14T12:29:18Z", + "dismissed_reason": "false positive", + "dismissed_comment": "This alert is not actually correct, because there's a sanitizer included in the library.", + "rule": { + "id": "js/zipslip", + "severity": "error", + "security_severity_level": "high", + "description": 'Arbitrary file write during zip extraction ("Zip Slip")', + "name": "js/zipslip", + "full_description": "Extracting files from a malicious zip archive without validating that the destination file path is within the destination directory can cause files outside the destination directory to be overwritten.", + "tags": ["security", "external/cwe/cwe-022"], + "help": '# Arbitrary file write during zip extraction ("Zip Slip")\\nExtracting files from a malicious zip archive without validating that the destination file path is within the destination directory can cause files outside the destination directory to be overwritten ...', + "help_uri": "https://codeql.github.com/", + }, + "tool": {"name": "CodeQL", "guid": None, "version": "2.4.0"}, + "most_recent_instance": { + "ref": "refs/heads/main", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "{}", + "state": "dismissed", + "commit_sha": "39406e42cb832f683daa691dd652a8dc36ee8930", + "message": { + "text": "This path depends on a user-provided value." + }, + "location": { + "path": "spec-main/api-session-spec.ts", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + }, + "classifications": ["test"], + }, + "instances_url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/42/instances", + } + self.client.get.return_value = response + + alert = await self.api.alert( + "foo/bar", + 42, + ) + + self.client.get.assert_awaited_once_with( + "/repos/foo/bar/code-scanning/alerts/42", + ) + + self.assertEqual(alert.number, 42) + + async def test_update(self): + response = create_response() + response.json.return_value = { + "number": 42, + "created_at": "2020-08-25T21:28:36Z", + "url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/42", + "html_url": "https://github.com/octocat/hello-world/code-scanning/42", + "state": "dismissed", + "fixed_at": None, + "dismissed_by": { + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": False, + }, + "dismissed_at": "2020-09-02T22:34:56Z", + "dismissed_reason": "false positive", + "dismissed_comment": "This alert is not actually correct, because there's a sanitizer included in the library.", + "rule": { + "id": "js/zipslip", + "severity": "error", + "security_severity_level": "high", + "description": 'Arbitrary file write during zip extraction ("Zip Slip")', + "name": "js/zipslip", + "full_description": "Extracting files from a malicious zip archive without validating that the destination file path is within the destination directory can cause files outside the destination directory to be overwritten.", + "tags": ["security", "external/cwe/cwe-022"], + "help": '# Arbitrary file write during zip extraction ("Zip Slip")\\nExtracting files from a malicious zip archive without validating that the destination file path is within the destination directory can cause files outside the destination directory to be overwritten ...', + "help_uri": "https://codeql.github.com/", + }, + "tool": {"name": "CodeQL", "guid": None, "version": "2.4.0"}, + "most_recent_instance": { + "ref": "refs/heads/main", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "{}", + "state": "dismissed", + "commit_sha": "39406e42cb832f683daa691dd652a8dc36ee8930", + "message": { + "text": "This path depends on a user-provided value." + }, + "location": { + "path": "spec-main/api-session-spec.ts", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + }, + "classifications": ["test"], + }, + "instances_url": "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/42/instances", + } + self.client.patch.return_value = response + + alert = await self.api.update_alert( + "foo/bar", + 42, + AlertState.DISMISSED, + dismissed_reason=DismissedReason.USED_IN_TESTS, + dismissed_comment="Only used in tests", + ) + + self.client.patch.assert_awaited_once_with( + "/repos/foo/bar/code-scanning/alerts/42", + data={ + "state": "dismissed", + "dismissed_reason": "used in tests", + "dismissed_comment": "Only used in tests", + }, + ) + + self.assertEqual(alert.number, 42) + self.assertIsNone(alert.repository) + + async def test_alerts_instances(self): + response = create_response() + response.json.return_value = [ + { + "ref": "refs/heads/main", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "state": "open", + "fixed_at": None, + "commit_sha": "39406e42cb832f683daa691dd652a8dc36ee8930", + "message": { + "text": "This path depends on a user-provided value." + }, + "location": { + "path": "lib/ab12-gen.js", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + }, + "classifications": ["library"], + }, + { + "ref": "refs/pull/3740/merge", + "analysis_key": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "environment": "", + "category": ".github/workflows/codeql-analysis.yml:CodeQL-Build", + "state": "fixed", + "fixed_at": "2020-02-14T12:29:18Z", + "commit_sha": "b09da05606e27f463a2b49287684b4ae777092f2", + "message": { + "text": "This suffix check is missing a length comparison to correctly handle lastIndexOf returning -1." + }, + "location": { + "path": "app/script.js", + "start_line": 2, + "end_line": 2, + "start_column": 10, + "end_column": 50, + }, + "classifications": ["source"], + }, + ] + + self.client.get_all.return_value = AsyncIteratorMock([response]) + + async_it = aiter(self.api.instances("foo/bar", 1)) + instance = await anext(async_it) + self.assertEqual(instance.ref, "refs/heads/main") + instance = await anext(async_it) + self.assertEqual(instance.ref, "refs/pull/3740/merge") + + with self.assertRaises(StopAsyncIteration): + await anext(async_it) + + self.client.get_all.assert_called_once_with( + "/repos/foo/bar/code-scanning/alerts/1/instances", + params={ + "per_page": "100", + }, + )