diff --git a/.cookiecutter/includes/setuptools/install_requires b/.cookiecutter/includes/setuptools/install_requires deleted file mode 100644 index f229360..0000000 --- a/.cookiecutter/includes/setuptools/install_requires +++ /dev/null @@ -1 +0,0 @@ -requests diff --git a/setup.cfg b/setup.cfg index f1872cd..ec7c6c0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,6 @@ package_dir = packages = find: python_requires = >=3.11 install_requires = - requests [options.packages.find] where = src diff --git a/src/dependabot_alerts/cli.py b/src/dependabot_alerts/cli.py index f245b93..08e9182 100644 --- a/src/dependabot_alerts/cli.py +++ b/src/dependabot_alerts/cli.py @@ -1,49 +1,13 @@ from argparse import ArgumentParser from importlib.metadata import version -from dependabot_alerts.core import GitHubClient, Vulnerability, fetch_alerts - -def cli(_argv=None): # pragma: no cover +def cli(_argv=None): # pylint:disable=inconsistent-return-statements parser = ArgumentParser() - parser.add_argument( - "-v", "--version", action="version", version=version("dependabot-alerts") - ) - parser.add_argument("organization", help="GitHub user or organization") + parser.add_argument("-v", "--version", action="store_true") args = parser.parse_args(_argv) - gh_client = GitHubClient.init() - vulns = fetch_alerts(gh_client, args.organization) - print(format_slack_message(args.organization, vulns)) - - return 0 - - -def format_slack_message( - organization: str, vulns: list[Vulnerability] -) -> str: # pragma: no cover - """ - Format a Slack status report from a list of vulnerabilities. - - Returns a message using Slack's "mrkdwn" format. See - https://api.slack.com/reference/surfaces/formatting. - """ - if not vulns: - return "Found no open vulnerabilities." - - n_repos = len(set(vuln.repo for vuln in vulns)) - - msg_parts = [] - msg_parts.append(f"*Found {len(vulns)} vulnerabilities in {n_repos} repositories.*") - - for vuln in vulns: - vuln_msg = [] - vuln_msg.append( - f"{organization}/{vuln.repo}: <{vuln.url}|{vuln.package_name} {vuln.severity} - {vuln.title}>" - ) - if vuln.pr: - vuln_msg.append(f" Resolved by {vuln.pr}") - msg_parts.append("\n".join(vuln_msg)) - - return "\n\n".join(msg_parts) + if args.version: + print(version("dependabot-alerts")) + return 0 diff --git a/src/dependabot_alerts/core.py b/src/dependabot_alerts/core.py index c28f5f7..d1b7a0d 100644 --- a/src/dependabot_alerts/core.py +++ b/src/dependabot_alerts/core.py @@ -1,184 +1,2 @@ -import json -import os -from dataclasses import dataclass -from getpass import getpass -from typing import Optional - -import requests - - -class GitHubClient: # pragma: no cover - """ - Client for GitHub's GraphQL API. - - See https://docs.github.com/en/graphql. - """ - - def __init__(self, token): - self.token = token - self.endpoint = "https://api.github.com/graphql" - - def query(self, query, variables=None): - data = {"query": query, "variables": variables if variables is not None else {}} - result = requests.post( - url=self.endpoint, - headers={"Authorization": f"Bearer {self.token}"}, - data=json.dumps(data), - timeout=(30, 30), - ) - body = result.json() - result.raise_for_status() - if "errors" in body: - errors = body["errors"] - raise RuntimeError(f"Query failed: {json.dumps(errors)}") - return body["data"] - - @classmethod - def init(cls): - """ - Initialize an authenticated GitHubClient. - - This will read from the `GITHUB_TOKEN` env var if set, or prompt for - a token otherwise. - """ - access_token = os.environ.get("GITHUB_TOKEN") - if not access_token: - access_token = getpass("GitHub API token: ") - return GitHubClient(access_token) - - -@dataclass -class Vulnerability: # pylint:disable=too-many-instance-attributes - repo: str - """Repository where this vulnerability was reported.""" - - created_at: str - """ISO date when this alert was created.""" - - package_name: str - """Name of the vulnerable package.""" - - ecosystem: str - """Package ecosytem (eg. npm) that the package comes from.""" - - severity: str - """Vulnerability severity level.""" - - version_range: str - """Version ranges of package affected by vulnerability.""" - - number: str - """Number of this vulnerability report.""" - - url: str - """Link to the vulernability report on GitHub.""" - - pr: Optional[str] - """Link to the Dependabot update PR that resolves this vulnerability.""" - - title: str - """Summary of what the vulnerability is.""" - - -def fetch_alerts( - gh: GitHubClient, organization: str -) -> list[Vulnerability]: # pragma: no cover - """ - Fetch details of all open vulnerability alerts in `organization`. - - To reduce the volume of noise, especially for repositories which include the - same dependency in multiple lockfiles, only one vulnerability is reported - per package per repository. - - Vulnerabilities are not reported from archived repositories. - """ - # pylint:disable=too-many-locals - - query = """ -query($organization: String!, $cursor: String) { - organization(login: $organization) { - repositories(first: 100, after: $cursor) { - pageInfo { - endCursor - hasNextPage - } - nodes { - name - vulnerabilityAlerts(first: 100, states:OPEN) { - nodes { - number - createdAt - dependabotUpdate { - pullRequest { - url - } - } - securityAdvisory { - summary - } - securityVulnerability { - package { - name - ecosystem - } - severity - vulnerableVersionRange - } - } - } - } - } - } -} -""" - - vulns = [] - cursor = None - has_next_page = True - - while has_next_page: - result = gh.query( - query=query, variables={"organization": organization, "cursor": cursor} - ) - page_info = result["organization"]["repositories"]["pageInfo"] - cursor = page_info["endCursor"] - has_next_page = page_info["hasNextPage"] - - for repo in result["organization"]["repositories"]["nodes"]: - alerts = repo["vulnerabilityAlerts"]["nodes"] - - if alerts: - repo_name = repo["name"] - vulnerable_packages = set() - - for alert in alerts: - sa = alert["securityAdvisory"] - sv = alert["securityVulnerability"] - number = alert["number"] - package_name = sv["package"]["name"] - - if package_name in vulnerable_packages: - continue - vulnerable_packages.add(package_name) - - pr = None - - dep_update = alert["dependabotUpdate"] - if dep_update and dep_update["pullRequest"]: - pr = dep_update["pullRequest"]["url"] - - vuln = Vulnerability( - repo=repo_name, - created_at=alert["createdAt"], - ecosystem=sv["package"]["ecosystem"], - number=number, - package_name=sv["package"]["name"], - pr=pr, - severity=sv["severity"], - title=sa["summary"], - url=f"https://github.com/{organization}/{repo_name}/security/dependabot/{number}", - version_range=sv["vulnerableVersionRange"], - ) - vulns.append(vuln) - - return vulns +def hello_world(): + return "Hello, world!" diff --git a/tests/unit/dependabot_alerts/cli_test.py b/tests/unit/dependabot_alerts/cli_test.py index e7816ae..941bec9 100644 --- a/tests/unit/dependabot_alerts/cli_test.py +++ b/tests/unit/dependabot_alerts/cli_test.py @@ -5,6 +5,10 @@ from dependabot_alerts.cli import cli +def test_it(): + cli([]) + + def test_help(): with pytest.raises(SystemExit) as exc_info: cli(["--help"]) @@ -13,8 +17,7 @@ def test_help(): def test_version(capsys): - with pytest.raises(SystemExit) as exc_info: - cli(["--version"]) + exit_code = cli(["--version"]) assert capsys.readouterr().out.strip() == version("dependabot-alerts") - assert not exc_info.value.code + assert not exit_code diff --git a/tests/unit/dependabot_alerts/core_test.py b/tests/unit/dependabot_alerts/core_test.py new file mode 100644 index 0000000..efa2fa6 --- /dev/null +++ b/tests/unit/dependabot_alerts/core_test.py @@ -0,0 +1,6 @@ +from dependabot_alerts.core import hello_world + + +class TestHelloWorld: + def test_it(self): + assert hello_world() == "Hello, world!"