diff --git a/pontos/github/models/code_scanning.py b/pontos/github/models/code_scanning.py new file mode 100644 index 00000000..546ac336 --- /dev/null +++ b/pontos/github/models/code_scanning.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + +from pontos.github.models.base import GitHubModel, User +from pontos.github.models.organization import Repository + + +class AlertState(Enum): + """ + State of a code scanning alert + """ + + OPEN = "open" + DISMISSED = "dismissed" + FIXED = "fixed" + + +class AlertSort(Enum): + """ + The property by which to sort the alerts + """ + + CREATED = "created" + UPDATED = "updated" + + +class DismissedReason(Enum): + """ + The reason for dismissing or closing the alert + """ + + FALSE_POSITIVE = "false positive" + WONT_FIX = "won't fix" + USED_IN_TESTS = "used in tests" + + +class Severity(Enum): + """ + The severity of the alert + """ + + NONE = "none" + NOTE = "note" + WARNING = "warning" + ERROR = "error" + + +class SecuritySeverityLevel(Enum): + """ + The security severity of the alert + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class Classification(Enum): + """ + A classification of the file. For example to identify it as generated + """ + + SOURCE = "source" + GENERATED = "generated" + TEST = "test" + LIBRARY = "library" + + +@dataclass +class Rule(GitHubModel): + """ + A rule used to detect the alert + + Attributes: + id: A unique identifier for the rule used to detect the alert + name: The name of the rule used to detect the alert + severity: The severity of the alert + security_severity_level: The security severity of the alert + description: A short description of the rule used to detect the alert + full_description: description of the rule used to detect the alert + tags: A set of tags applicable for the rule + help: Detailed documentation for the rule as GitHub Flavored Markdown + help_uri: A link to the documentation for the rule used to detect the + alert + """ + + name: str + description: str + id: Optional[str] = None + full_description: Optional[str] = None + severity: Optional[Severity] = None + security_severity_level: Optional[SecuritySeverityLevel] = None + tags: Optional[list[str]] = None + help: Optional[str] = None + help_uri: Optional[str] = None + + +@dataclass +class Message(GitHubModel): + """ + Attributes: + text: + """ + + text: str + + +@dataclass +class Location(GitHubModel): + """ + Describes a region within a file for the alert + + Attributes: + path: The file path in the repository + start_line: Line number at which the vulnerable code starts in the file + end_line: Line number at which the vulnerable code ends in the file + start_column: The column at which the vulnerable code starts within the + start line + end_column: The column at which the vulnerable code ends within the end + line + """ + + path: str + start_line: int + end_line: int + start_column: int + end_column: int + + +@dataclass +class Instance(GitHubModel): + """ + Attributes: + ref: The full Git reference, formatted as `refs/heads/`, + `refs/pull//merge`, or `refs/pull//head` + analysis_key: Identifies the configuration under which the analysis was + executed. For example, in GitHub Actions this includes the workflow + filename and job name + environment: Identifies the variable values associated with the + environment in which the analysis that generated this alert instance + was performed, such as the language that was analyzed + category: Identifies the configuration under which the analysis was + executed. Used to distinguish between multiple analyses for the same + tool and commit, but performed on different languages or different + parts of the code + state: State of a code scanning alert + commit_sha: + message: + location: Describes a region within a file for the alert + html_url: + classifications: Classifications that have been applied to the file that + triggered the alert. For example identifying it as documentation, or + a generated file + """ + + ref: str + analysis_key: str + environment: str + category: str + state: AlertState + commit_sha: str + message: Message + location: Location + html_url: Optional[str] = None + classifications: Optional[list[Classification]] = None + + +@dataclass +class Tool(GitHubModel): + """ + A tool used to generate the code scanning analysis + + Attributes: + name: The name of the tool used to generate the code scanning analysis + version: The version of the tool used to generate the code scanning + analysis + guid: he GUID of the tool used to generate the code scanning analysis, + if provided in the uploaded SARIF data + """ + + name: str + version: Optional[str] = None + guid: Optional[str] = None + + +@dataclass +class CodeScanningAlert(GitHubModel): + """ + A GitHub Code Scanning Alert + + Attributes: + number: The security alert number + created_at: The time that the alert was created + updated_at: The time that the alert was last updated + url: The REST API URL of the alert resource + html_url: The GitHub URL of the alert resource + instances_url: The REST API URL for fetching the list of instances for + an alert + state: State of a code scanning alert + fixed_at: The time that the alert was no longer detected and was + considered fixed + dismissed_by: A GitHub user who dismissed the alert + dismissed_at: The time that the alert was dismissed + dismissed_reason: The reason for dismissing or closing the alert + dismissed_comment: The dismissal comment associated with the dismissal + of the alert + rule: The rule used to detect the alert + tool: The tool used to generate the code scanning analysis + most_recent_instance: + repository: A GitHub repository + """ + + number: int + created_at: datetime + url: str + html_url: str + instances_url: str + state: AlertState + rule: Rule + tool: Tool + most_recent_instance: Instance + repository: Optional[Repository] = None + updated_at: Optional[datetime] = None + fixed_at: Optional[datetime] = None + dismissed_by: Optional[User] = None + dismissed_at: Optional[datetime] = None + dismissed_reason: Optional[DismissedReason] = None + dismissed_comment: Optional[str] = None diff --git a/tests/github/models/test_code_scanning.py b/tests/github/models/test_code_scanning.py new file mode 100644 index 00000000..54280bf4 --- /dev/null +++ b/tests/github/models/test_code_scanning.py @@ -0,0 +1,287 @@ +# SPDX-FileCopyrightText: 2023 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +# ruff: noqa:E501 + +import unittest +from datetime import datetime, timezone + +from pontos.github.models.code_scanning import ( + AlertState, + CodeScanningAlert, + Instance, + Location, + Rule, + Severity, + Tool, +) + +ALERT = { + "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 RuleTestCase(unittest.TestCase): + def test_from_dict(self): + rule = Rule.from_dict( + { + "id": "js/zipslip", + "severity": "error", + "tags": ["security", "external/cwe/cwe-022"], + "description": "Arbitrary file write during zip extraction", + "name": "js/zipslip", + } + ) + + self.assertEqual(rule.id, "js/zipslip") + self.assertEqual(rule.name, "js/zipslip") + self.assertEqual(rule.severity, Severity.ERROR) + self.assertEqual(len(rule.tags), 2) + self.assertEqual(rule.tags, ["security", "external/cwe/cwe-022"]) + self.assertEqual( + rule.description, "Arbitrary file write during zip extraction" + ) + + +class LocationTestCase(unittest.TestCase): + def test_from_dict(self): + location = Location.from_dict( + { + "path": "lib/ab12-gen.js", + "start_line": 917, + "end_line": 917, + "start_column": 7, + "end_column": 18, + } + ) + + self.assertEqual(location.path, "lib/ab12-gen.js") + self.assertEqual(location.start_line, 917) + self.assertEqual(location.end_line, 917) + self.assertEqual(location.start_column, 7) + self.assertEqual(location.end_column, 18) + + +class InstanceTestCase(unittest.TestCase): + def test_from_dict(self): + instance = Instance.from_dict( + { + "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": [], + } + ) + + self.assertEqual(instance.ref, "refs/heads/main") + self.assertEqual( + instance.analysis_key, + ".github/workflows/codeql-analysis.yml:CodeQL-Build", + ) + self.assertEqual( + instance.category, + ".github/workflows/codeql-analysis.yml:CodeQL-Build", + ) + self.assertEqual(instance.environment, "{}") + self.assertEqual(instance.state, AlertState.OPEN) + self.assertEqual( + instance.commit_sha, "39406e42cb832f683daa691dd652a8dc36ee8930" + ) + self.assertEqual( + instance.message.text, "This path depends on a user-provided value." + ) + + self.assertEqual(instance.location.path, "lib/ab12-gen.js") + self.assertEqual(instance.location.start_line, 917) + self.assertEqual(instance.location.end_line, 917) + self.assertEqual(instance.location.start_column, 7) + self.assertEqual(instance.location.end_column, 18) + + self.assertEqual(len(instance.classifications), 0) + + +class ToolTestCase(unittest.TestCase): + def test_from_dict(self): + tool = Tool.from_dict( + {"name": "CodeQL", "guid": None, "version": "2.4.0"} + ) + + self.assertEqual(tool.name, "CodeQL") + self.assertEqual(tool.version, "2.4.0") + self.assertIsNone(tool.guid) + + +class CodeScanningAlertTestCase(unittest.TestCase): + def test_from_dict(self): + alert = CodeScanningAlert.from_dict(ALERT) + + self.assertEqual(alert.number, 3) + self.assertEqual( + alert.created_at, + datetime(2020, 2, 13, 12, 29, 18, tzinfo=timezone.utc), + ) + self.assertEqual( + alert.url, + "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/3", + ) + self.assertEqual( + alert.html_url, + "https://github.com/octocat/hello-world/code-scanning/3", + ) + self.assertEqual(alert.state, AlertState.DISMISSED) + self.assertEqual( + alert.dismissed_at, + datetime(2020, 2, 14, 12, 29, 18, tzinfo=timezone.utc), + ) + self.assertEqual(alert.dismissed_by.login, "octocat") + self.assertEqual( + alert.dismissed_comment, + "This alert is not actually correct, because there's a sanitizer " + "included in the library.", + ) + self.assertEqual(alert.rule.id, "js/zipslip") + self.assertEqual(alert.tool.name, "CodeQL") + self.assertEqual(alert.most_recent_instance.ref, "refs/heads/main") + self.assertEqual( + alert.most_recent_instance.location.path, "lib/ab12-gen.js" + ) + self.assertEqual( + alert.instances_url, + "https://api.github.com/repos/octocat/hello-world/code-scanning/alerts/3/instances", + ) + self.assertEqual(alert.repository.id, 1296269)