From 36fdaad31957241b55e759733206603deb0e5ada Mon Sep 17 00:00:00 2001 From: avikstroem Date: Fri, 4 Nov 2022 15:28:36 +0100 Subject: [PATCH 1/4] Initial code --- .flake8 | 7 + .github/ci.yaml | 26 + .github/publish.yaml | 19 + README.md | 96 +++ pyproject.toml | 61 ++ src/security_constraints/__init__.py | 0 src/security_constraints/common.py | 96 +++ .../github_security_advisory.py | 128 ++++ src/security_constraints/main.py | 272 ++++++++ src/security_constraints/py.typed | 0 test/test_common.py | 50 ++ test/test_github_security_advisory.py | 132 ++++ test/test_main.py | 643 ++++++++++++++++++ tox.ini | 12 + 14 files changed, 1542 insertions(+) create mode 100644 .flake8 create mode 100644 .github/ci.yaml create mode 100644 .github/publish.yaml create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/security_constraints/__init__.py create mode 100644 src/security_constraints/common.py create mode 100644 src/security_constraints/github_security_advisory.py create mode 100644 src/security_constraints/main.py create mode 100644 src/security_constraints/py.typed create mode 100644 test/test_common.py create mode 100644 test/test_github_security_advisory.py create mode 100644 test/test_main.py create mode 100644 tox.ini diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4a66515 --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203 +extend-exclude = + .tox, + build, + venv \ No newline at end of file diff --git a/.github/ci.yaml b/.github/ci.yaml new file mode 100644 index 0000000..7d6d4fe --- /dev/null +++ b/.github/ci.yaml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + strategy: + matrix: + python_version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python_version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python_version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run tox + run: tox -e py$(sed 's/\.//' ${{ matrix.python_version }}) \ No newline at end of file diff --git a/.github/publish.yaml b/.github/publish.yaml new file mode 100644 index 0000000..487c3d8 --- /dev/null +++ b/.github/publish.yaml @@ -0,0 +1,19 @@ +# Thanks to: Sean Hammond +# https://www.seanh.cc/2022/05/21/publishing-python-packages-from-github-actions +name: Publish to PyPI.org +on: + release: + types: [published] +jobs: + pypi: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - run: python3 -m pip install --upgrade build && python3 -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md new file mode 100644 index 0000000..cce5fa8 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# security-constraints + +Security-constraints is a command-line application used +to fetch security vulnerabilities in Python packages from +external sources and from them generate version constraints +for the packages. + +The constraints can then be given to `pip install` with the `-c` option, +either on the command line or in a requirements file. + +## Installation + +Just install it with `pip`: +```bash +pip install security-constraints +``` + +## Usage + +The environment variable `SC_GITHUB_TOKEN` needs to be set +to a valid GitHub token which provides read access to public +repositories. This is needed in order to access GitHub Security +Advisory. Once this is set, you can simply run the program to +output safe pip constraints to stdout. + +```bash +>security-constraints +# Generated by security-constraints on 2022-11-04T08:33:54.523625 +# Data sources: Github Security Advisory +# Configuration: {'ignore_ids': []} +... +vncauthproxy<0,>=1.2.0 # CVE-2022-36436 (ID: GHSA-237r-mx84-7x8c) +waitress!=1.4.2 # CVE-2020-5236 (ID: GHSA-73m2-3pwg-5fgc) +waitress>=1.4.0 # GHSA-4ppp-gpcr-7qf6 (ID: GHSA-4ppp-gpcr-7qf6) +ymlref>0.1.1 # CVE-2018-20133 (ID: GHSA-8r8j-xvfj-36f9) +> +``` + +You can use `--output` to instead output to a file. + +```bash +>security-constraints --output constraints.txt +>cat constraints.txt +# Generated by security-constraints on 2022-11-04T08:33:54.523625 +# Data sources: Github Security Advisory +# Configuration: {'ignore_ids': []} +... +vncauthproxy<0,>=1.2.0 # CVE-2022-36436 (ID: GHSA-237r-mx84-7x8c) +waitress!=1.4.2 # CVE-2020-5236 (ID: GHSA-73m2-3pwg-5fgc) +waitress>=1.4.0 # GHSA-4ppp-gpcr-7qf6 (ID: GHSA-4ppp-gpcr-7qf6) +ymlref>0.1.1 # CVE-2018-20133 (ID: GHSA-8r8j-xvfj-36f9) +> +``` + +You can provide a space-separated list of IDs of vulnerabilities that +should be ignored. The IDs in question are those that appear in after +`ID:` in the comments in the output. + +```bash +>security-constraints --ignore-ids GHSA-4ppp-gpcr-7qf6 GHSA-8r8j-xvfj-36f9 +# Generated by security-constraints on 2022-11-04T08:33:54.523625 +# Data sources: Github Security Advisory +# Configuration: {'ignore_ids': ['GHSA-4ppp-gpcr-7qf6', 'GHSA-8r8j-xvfj-36f9']} +... +vncauthproxy<0,>=1.2.0 # CVE-2022-36436 (ID: GHSA-237r-mx84-7x8c) +waitress!=1.4.2 # CVE-2020-5236 (ID: GHSA-73m2-3pwg-5fgc) +> +``` + +The IDs to ignore can also be given in a configuration file using `--config`. +To create an initial configuration file, you can use `--dump-config`. This +will dump the current configuration (including any `--ignore-ids` passed) to +stdout and then exit. You can redirect this into a file to create an +initial configuration file. The configuration file is in yaml format. + +```bash +>security-constraints --ignore-ids GHSA-4ppp-gpcr-7qf6 GHSA-8r8j-xvfj-36f9 --dump-config > sc_config.yaml +>cat sc_config.yaml +ignore_ids: +- GHSA-4ppp-gpcr-7qf6 +- GHSA-8r8j-xvfj-36f9 +>security-constraints --config sc_config.yaml +# Generated by security-constraints on 2022-11-04T08:33:54.523625 +# Data sources: Github Security Advisory +# Configuration: {'ignore_ids': ['GHSA-4ppp-gpcr-7qf6', 'GHSA-8r8j-xvfj-36f9']} +... +vncauthproxy<0,>=1.2.0 # CVE-2022-36436 (ID: GHSA-237r-mx84-7x8c) +waitress!=1.4.2 # CVE-2020-5236 (ID: GHSA-73m2-3pwg-5fgc) +> +``` + +## Contributing +Pull requests as well as new issues are welcome. + +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +![example workflow](https://github.com/mam-dev/security-constraints/actions/workflows/ci.yml/badge.svg) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4b9b8ba --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "security-constraints" +version = "1.0.0" +description = "Fetches security vulnerabilities and creates pip-constraints based on them." +readme = "README.md" +license = {file = "LICENSE"} +requires-python = ">=3.7" +dependencies = [ + "requests", + "pyyaml", + "importlib-metadata >= 1.0 ; python_version < '3.8'" +] + +[project.optional-dependencies] +test = [ + "pytest", + "requests-mock", + "freezegun" +] +lint = [ + "isort", + "black", + "flake8", + "mypy", + "types-requests", + "types-PyYAML" +] + +[project.scripts] +security-constraints = "security_constraints.main:main" + +[build-system] +requires = ["setuptools>=51", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = false + +[tool.isort] +profile = "black" +src_paths = ["src", "test"] + +[tool.pytest.ini_options] +minversion = "6.0" +usefixtures = ["requests_mock"] +testpaths = ["test"] + +[tool.mypy] +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +files = ["src", "test"] + +[[tool.mypy.overrides]] +module = 'py' +ignore_missing_imports = true \ No newline at end of file diff --git a/src/security_constraints/__init__.py b/src/security_constraints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/security_constraints/common.py b/src/security_constraints/common.py new file mode 100644 index 0000000..84a408c --- /dev/null +++ b/src/security_constraints/common.py @@ -0,0 +1,96 @@ +"""This module contains common definitions for use in any other module.""" +import abc +import dataclasses +from typing import Dict, List + + +class SecurityConstraintsError(Exception): + """Base class for all exceptions in this application.""" + + +class FailedPrerequisitesError(SecurityConstraintsError): + """Error raised when something is missing in order to run the application.""" + + +class FetchVulnerabilitiesError(SecurityConstraintsError): + """Error which occurred when fetching vulnerabilities.""" + + +@dataclasses.dataclass +class Configuration: + """The application configuration. + + Corresponds to the contents of a configuration file. + + """ + + ignore_ids: List[str] = dataclasses.field(default_factory=list) + + def to_dict(self) -> Dict: + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, json: Dict) -> "Configuration": + return cls(**json) + + @classmethod + def supported_keys(cls) -> List[str]: + """Return a list of keys which are supported in the config file.""" + return list(cls().to_dict().keys()) + + +@dataclasses.dataclass +class PackageConstraints: + """Version constraints for a single python package. + + Attributes: + package: The name of the package. + specifies: A list of version specifiers, e.g. ">3.0". + + """ + + package: str + specifiers: List[str] = dataclasses.field(default_factory=list) + + def __str__(self) -> str: + return f"{self.package}{','.join(self.specifiers)}" + + +@dataclasses.dataclass +class SecurityVulnerability: + """A security vulnerability in a Python package. + + Attributes: + name: Human-readable name of the vulnerability. + identifier: Used to uniquely identify this vulnerability, + e.g. when ignoring it. + package: The name of the affected Python package. + vulnerable_range: String specifying which versions are vulnerable. + Syntax: + = 0.2.0 denotes a single vulnerable version. + <= 1.0.8 denotes a version range up to and including the specified version + < 0.1.11 denotes a version range up to, but excluding, the specified version + >= 4.3.0, < 4.3.5 denotes a version range with a known min and max version. + >= 0.0.1 denotes a version range with a known minimum, but no known maximum. + + """ + + name: str + identifier: str + package: str + vulnerable_range: str + + def __str__(self) -> str: + return self.name + + +class SecurityVulnerabilityDatabaseAPI(abc.ABC): + """An API toward a database of security vulnerabilities in Python packages.""" + + @abc.abstractmethod + def get_database_name(self) -> str: + """Return the name of the vulnerability database in human-readable text.""" + + @abc.abstractmethod + def get_vulnerabilities(self) -> List[SecurityVulnerability]: + """Fetch and return all relevant security vulnerabilities from the database.""" diff --git a/src/security_constraints/github_security_advisory.py b/src/security_constraints/github_security_advisory.py new file mode 100644 index 0000000..505127e --- /dev/null +++ b/src/security_constraints/github_security_advisory.py @@ -0,0 +1,128 @@ +"""Module for fetching vulnerabilities from the GitHub Security Advisory.""" +import logging +import os +import string +from typing import Any, Dict, List, Optional + +import requests + +from security_constraints.common import ( + FailedPrerequisitesError, + FetchVulnerabilitiesError, + SecurityVulnerability, + SecurityVulnerabilityDatabaseAPI, +) + +LOGGER = logging.getLogger(__name__) + + +QUERY_TEMPLATE = string.Template( + "{" + "securityVulnerabilities(" + " first: $first" + " ecosystem:PIP" + " severities:$severities" + " $additional" + ") {" + " totalCount" + " pageInfo { endCursor startCursor hasNextPage }" + " nodes {" + " advisory {" + " ghsaId" + " identifiers { value type }" + " }" + " vulnerableVersionRange" + " package { name }" + " }" + "}" + "}" +) + + +class GithubSecurityAdvisoryAPI(SecurityVulnerabilityDatabaseAPI): + """API toward the GitHub Security Advisory database. + + Instantiation requires that the environment variable SC_GITHUB_TOKEN + has been set to a valid token with permissions to read from public + GitHub repositories. + + """ + + URL = "https://api.github.com/graphql" + + def __init__(self): + self._session = requests.Session() + self._current_cursor: Optional[str] = None + try: + self._token: str = os.environ["SC_GITHUB_TOKEN"] + except KeyError as missing_key: + raise FailedPrerequisitesError(f"Missing from environment: {missing_key}") + + def get_database_name(self) -> str: + return "Github Security Advisory" + + def get_vulnerabilities(self) -> List[SecurityVulnerability]: + """Fetch all CRITICAL vulnerabilities from GitHub Security Advisory.""" + after: Optional[str] = None + vulnerabilities: List[SecurityVulnerability] = [] + more_data_exists = True + while more_data_exists: + json_response: Dict = self._do_graphql_request( + severities=["CRITICAL"], after=after + ) + try: + json_data: Dict = json_response["data"] + vulnerabilities.extend( + [ + SecurityVulnerability( + name=",".join( + identifier["value"] + for identifier in node["advisory"]["identifiers"] + if identifier["type"] != "GHSA" + ) + or node["advisory"]["ghsaId"], + identifier=node["advisory"]["ghsaId"], + package=node["package"]["name"], + vulnerable_range=node["vulnerableVersionRange"], + ) + for node in json_data["securityVulnerabilities"]["nodes"] + ] + ) + more_data_exists = json_data["securityVulnerabilities"]["pageInfo"][ + "hasNextPage" + ] + after = json_data["securityVulnerabilities"]["pageInfo"]["endCursor"] + except KeyError as missing_key: + error_msg = f"Key {missing_key} not found in: {json_response}" + LOGGER.error(error_msg) + raise FetchVulnerabilitiesError(error_msg) + + return vulnerabilities + + def _do_graphql_request( + self, severities: List[str], after: Optional[str] = None + ) -> Any: + query = QUERY_TEMPLATE.substitute( + first=100, + severities=",".join(severities), + additional=f'after:"{after}"' if after is not None else "", + ) + response: requests.Response = self._session.post( + url=self.URL, + headers={"Authorization": f"bearer {self._token}"}, + json={"query": query}, + ) + try: + response.raise_for_status() + return response.json() + except requests.HTTPError as error: + LOGGER.error( + "HTTP error (status %s) received from URL %s: %s", + response.status_code, + self.URL, + error, + ) + raise FetchVulnerabilitiesError from error + except requests.JSONDecodeError as error: + LOGGER.error("Could not decode json data in response: %s", response.text) + raise FetchVulnerabilitiesError from error diff --git a/src/security_constraints/main.py b/src/security_constraints/main.py new file mode 100644 index 0000000..ca7ef3d --- /dev/null +++ b/src/security_constraints/main.py @@ -0,0 +1,272 @@ +"""Main module.""" +import argparse +import logging +import sys +from datetime import datetime + +if sys.version_info >= (3, 8): + from importlib.metadata import version +else: + from importlib_metadata import version + +from typing import IO, Any, List, Optional, Sequence + +import yaml + +from security_constraints.common import ( + Configuration, + PackageConstraints, + SecurityConstraintsError, + SecurityVulnerability, + SecurityVulnerabilityDatabaseAPI, +) +from security_constraints.github_security_advisory import GithubSecurityAdvisoryAPI + +LOGGER = logging.getLogger(__name__) + + +def get_security_vulnerability_database_apis() -> List[ + SecurityVulnerabilityDatabaseAPI +]: + """Return the APIs to use for fetching vulnerabilities.""" + return [GithubSecurityAdvisoryAPI()] + + +def fetch_vulnerabilities( + apis: Sequence[SecurityVulnerabilityDatabaseAPI], +) -> List[SecurityVulnerability]: + """Use apis to fetch and return vulnerabilities.""" + vulnerabilities: List[SecurityVulnerability] = [] + for api in apis: + LOGGER.debug("Fetching vulnerabilities from %s...", api.get_database_name()) + vulnerabilities.extend(api.get_vulnerabilities()) + return vulnerabilities + + +def filter_vulnerabilities( + config: Configuration, vulnerabilities: List[SecurityVulnerability] +) -> List[SecurityVulnerability]: + """Filter out vulnerabilities that should be ignored and return the rest.""" + if config.ignore_ids: + LOGGER.debug("Applying ignore-ids...") + vulnerabilities = [ + v for v in vulnerabilities if v.identifier not in config.ignore_ids + ] + return vulnerabilities + + +def sort_vulnerabilities( + vulnerabilities: List[SecurityVulnerability], +) -> List[SecurityVulnerability]: + """Sort vulnerabilities into the order they should appear in the constraints.""" + return sorted(vulnerabilities, key=lambda v: v.package) + + +def get_safe_version_constraints( + vulnerability: SecurityVulnerability, +) -> PackageConstraints: + """Invert range of a vulnerability into constraints specifying unaffected versions. + + See SecurityVulnerability documentation for more information. + + """ + safe_specs: List[str] = [] + vulnerable_specs = [p.strip() for p in vulnerability.vulnerable_range.split(",")] + for vulnerable_spec in vulnerable_specs: + if vulnerable_spec.startswith("= "): + safe_specs.append(f"!={vulnerable_spec[2:]}") + elif vulnerable_spec.startswith("<= "): + safe_specs.append(f">{vulnerable_spec[3:]}") + elif vulnerable_spec.startswith("< "): + safe_specs.append(f">={vulnerable_spec[2:]}") + elif vulnerable_spec.startswith(">= "): + safe_specs.append(f"<{vulnerable_spec[3:]}") + return PackageConstraints( + package=vulnerability.package, + specifiers=safe_specs, + ) + + +def are_constraints_pip_friendly(constraints: PackageConstraints) -> bool: + """Return if the given PackageConstraints is understandable by pip. + + Pip does not understand versions like "2.5.0a05" when it is + an inequality, e.g. "<= 2.5.0a05". That gets replaced by a strict + equality. Then this function will return False, because pip + cannot properly parse the constraint. + + """ + for part in constraints.specifiers: + if part.startswith("="): + continue + version = part.strip("<>=! ") + if not version.replace(".", "").isnumeric(): + LOGGER.debug( + "Pip-unfriendly constraint '%s' (%s) -> ignore.", + part, + constraints.package, + ) + return False + return True + + +def create_header( + apis: Sequence[SecurityVulnerabilityDatabaseAPI], config: Configuration +) -> str: + """Create the comment header which goes at the top of the output.""" + timestamp: str = datetime.utcnow().isoformat() + sources: List[str] = [api.get_database_name() for api in apis] + lines: List[str] = [ + f"Generated by security-constraints on {timestamp}", + f"Data sources: {', '.join(sources)}", + f"Configuration: {config.to_dict()}", + ] + return "\n".join([f"# {line}" for line in lines]) + + +def format_constraints_file_line( + constraints: PackageConstraints, vulnerability: SecurityVulnerability +) -> str: + """Format a line in the final pip constraints output. + + Args: + constraints: The relevant package and the constraints to place upon it. + vulnerability: The vulnerability tackled by the constraints. + + """ + if constraints.package != vulnerability.package: + raise AssertionError( + "Constraints and vulnerability are for different packages!" + " This suggests a programming error!" + ) + return f"{constraints}" f" # {vulnerability.name} (ID: {vulnerability.identifier})" + + +def get_args() -> Any: + """Parse arguments from the command line and return them.""" + parser = argparse.ArgumentParser( + description=( + "Fetches security vulnerabilities from external sources " + "and creates a list of pip-compatible version constraints " + "that can be used to avoid versions affected by the " + "vulnerabilities." + ) + ) + parser.add_argument( + "--dump-config", + action="store_true", + help=( + "Print config file corresponding to the current settings to stdout " + "and exit. Config file can be used as a template." + ), + ) + parser.add_argument( + "--debug", action="store_true", default=False, help="Debugging output." + ) + parser.add_argument( + "-v", "--version", action="store_true", help="Print version and exit." + ) + parser.add_argument( + "--output", + type=argparse.FileType(mode="w"), + action="store", + default="-", + help="Output file name or '-' for stdout.", + ) + parser.add_argument( + "--ignore-ids", + type=str, + action="store", + nargs="+", + default=[], + help=( + "IDs of vulnerabilities to ignore." + " Can also be given as 'ignore_ids' in config file." + ), + ) + parser.add_argument( + "--config", + type=str, + action="store", + help=( + "Path to configuration file." + f" Supported keys: {Configuration.supported_keys()}" + ), + ) + return parser.parse_args() + + +def get_config(config_file: Optional[str]) -> Configuration: + """Return configuration read from config_file. + + Default config will be returned if config_file is None. + + """ + if config_file is None: + return Configuration() + + with open(config_file, mode="r") as fh: + return Configuration.from_dict(yaml.safe_load(fh)) + + +def setup_logging(debug: bool = False) -> None: + logging.getLogger().setLevel(logging.DEBUG if debug else logging.INFO) + + +def main() -> int: + """Main flow of the application. + + Returns: + The program exit code as an integer. + + """ + output: Optional[IO] = None + try: + args = get_args() + if args.version: + print(version("security-constraints")) + return 0 + setup_logging(debug=args.debug) + output = args.output + if output is None: + raise AssertionError( + "'output' is not a stream! This suggests a programming error" + ) + config: Configuration = get_config(config_file=args.config) + config.ignore_ids.extend(args.ignore_ids) + + if args.dump_config: + yaml.safe_dump(config.to_dict(), stream=sys.stdout) + return 0 + + apis: List[ + SecurityVulnerabilityDatabaseAPI + ] = get_security_vulnerability_database_apis() + + vulnerabilities: List[SecurityVulnerability] = fetch_vulnerabilities(apis) + vulnerabilities = filter_vulnerabilities(config, vulnerabilities) + vulnerabilities = sort_vulnerabilities(vulnerabilities) + + LOGGER.debug("Writing constraints...") + output.write(f"{create_header(apis, config)}\n") + for vulnerability in vulnerabilities: + constraints: PackageConstraints = get_safe_version_constraints( + vulnerability + ) + if are_constraints_pip_friendly(constraints): + output.write( + f"{format_constraints_file_line(constraints, vulnerability)}\n" + ) + except SecurityConstraintsError as error: + LOGGER.error(error) + return 1 + except Exception as error: + LOGGER.critical( + "Caught unhandled exception at top-level: %s", error, exc_info=True + ) + return 2 + else: + return 0 + finally: + if output is not None and not output.isatty(): + output.close() diff --git a/src/security_constraints/py.typed b/src/security_constraints/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/test/test_common.py b/test/test_common.py new file mode 100644 index 0000000..1e55eb2 --- /dev/null +++ b/test/test_common.py @@ -0,0 +1,50 @@ +from typing import List + +import pytest + +from security_constraints.common import ( + Configuration, + PackageConstraints, + SecurityVulnerability, +) + +IGNORE_IDS = ["A-1", "B-2"] + + +def test_configuration_to_dict() -> None: + actual_dict = Configuration(ignore_ids=IGNORE_IDS).to_dict() + assert actual_dict == {"ignore_ids": IGNORE_IDS} + + +def test_configuration_from_dict() -> None: + created_from_dict = Configuration.from_dict({"ignore_ids": IGNORE_IDS}) + assert created_from_dict == Configuration(ignore_ids=IGNORE_IDS) + + +def test_configuration_supported_keys() -> None: + assert Configuration.supported_keys() == ["ignore_ids"] + + +@pytest.mark.parametrize( + "package, specifiers, expected", + [ + ("pystuff", [">=2.0"], "pystuff>=2.0"), + ("vectorflow", [">=2.0"], "vectorflow>=2.0"), + ("pystuff", [">=2.0", "<5"], "pystuff>=2.0,<5"), + ("pystuff", [">=2.0", "<5", "!=3.2.1"], "pystuff>=2.0,<5,!=3.2.1"), + ], +) +def test_package_constraints_str( + package: str, specifiers: List[str], expected: str +) -> None: + assert str(PackageConstraints(package=package, specifiers=specifiers)) == expected + + +def test_security_vulnerability_str() -> None: + vulnerability = SecurityVulnerability( + name="vulnerability-name", + identifier="MY-ID", + package="pystuff", + vulnerable_range="<3.2.1", + ) + assert str(vulnerability) == "vulnerability-name" diff --git a/test/test_github_security_advisory.py b/test/test_github_security_advisory.py new file mode 100644 index 0000000..45deed9 --- /dev/null +++ b/test/test_github_security_advisory.py @@ -0,0 +1,132 @@ +from typing import Dict, List + +import pytest + +from security_constraints.github_security_advisory import ( + FailedPrerequisitesError, + FetchVulnerabilitiesError, + GithubSecurityAdvisoryAPI, + SecurityVulnerability, +) + + +@pytest.fixture(name="github_token") +def fixture_token_in_env(monkeypatch) -> str: + """Set SC_GITHUB_TOKEN environment variable and return it.""" + token = "3e00409b-f017-4ecc-b7bf-f11f6e2a5693" + monkeypatch.setenv("SC_GITHUB_TOKEN", token) + return token + + +def test_instantiate_without_token_in_env() -> None: + with pytest.raises(FailedPrerequisitesError): + _ = GithubSecurityAdvisoryAPI() + + +def test_get_database_name(github_token) -> None: + assert GithubSecurityAdvisoryAPI().get_database_name() == "Github Security Advisory" + + +def test_get_vulnerabilities(github_token, requests_mock) -> None: + cursors = ( + "Y3Vyc29yOnYyOpK5MjAyMi0wMy0yM1QyMDo1NDoyNSswMTowMM0X4Q==", + "Y3Vyc29yOnYyOpK5MjAyMC0wOS0yNVQxOTo0MjowMCswMjowMM0UeQ==", + "Y3Vyc29yOnYyOpK5MjAyMC0wOS0yNVQxOTo0MjowMCswMXowMM0DeQ==", + "Y3Vyc29yOnYyOpK5MjAyMC0wOS0yNVQxOTo0MjowMCswMHowMM0LeQ==", + ) + expected_vulnerabilities: List[SecurityVulnerability] = [] + vulnerability_nodes: List[Dict] = [] + for request_index in range(3): + for i in range(100 if request_index < 2 else 41): + ghsa = f"GHSA-{request_index}-{i}" + package = f"package_{request_index}_{i}" + expected_vulnerabilities.append( + SecurityVulnerability( + name="CVE-2020-12345", + identifier=ghsa, + package=package, + vulnerable_range="< 1.2.3", + ) + ) + vulnerability_nodes.append( + { + "advisory": { + "ghsaId": ghsa, + "identifiers": [ + { + "value": ghsa, + "type": "GHSA", + }, + {"value": "CVE-2020-12345", "type": "CVE"}, + ], + }, + "vulnerableVersionRange": "< 1.2.3", + "package": {"name": package}, + } + ) + + requests_mock.post( + "https://api.github.com/graphql", + [ + { + "json": { + "data": { + "securityVulnerabilities": { + "totalCount": 241, + "pageInfo": { + "endCursor": cursors[request_index + 1], + "startCursor": cursors[request_index], + "hasNextPage": request_index < 2, + }, + "nodes": vulnerability_nodes[ + request_index + * 100 : min(request_index * 100 + 100, 241) + ], + } + } + } + } + for request_index in range(3) + ], + request_headers={"Authorization": f"bearer {github_token}"}, + ) + + api = GithubSecurityAdvisoryAPI() + vulnerabilities = api.get_vulnerabilities() + + assert vulnerabilities == expected_vulnerabilities + assert requests_mock.call_count == 3 + + +def test_get_vulnerabilities__http_error(github_token, requests_mock) -> None: + requests_mock.post( + "https://api.github.com/graphql", + status_code=500, + ) + with pytest.raises(FetchVulnerabilitiesError): + api = GithubSecurityAdvisoryAPI() + _ = api.get_vulnerabilities() + + +def test_get_vulnerabilities__malformed_data(github_token, requests_mock) -> None: + requests_mock.post( + "https://api.github.com/graphql", + json={"data": {"error": "something went wrong"}}, + request_headers={"Authorization": f"bearer {github_token}"}, + ) + + with pytest.raises(FetchVulnerabilitiesError): + api = GithubSecurityAdvisoryAPI() + _ = api.get_vulnerabilities() + + +def test_get_vulnerabilities__json_decode_error(github_token, requests_mock) -> None: + requests_mock.post( + "https://api.github.com/graphql", + body=r"", + request_headers={"Authorization": f"bearer {github_token}"}, + ) + + with pytest.raises(FetchVulnerabilitiesError): + api = GithubSecurityAdvisoryAPI() + _ = api.get_vulnerabilities() diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..5d25457 --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,643 @@ +import argparse +import datetime +import logging +import sys +from pathlib import Path +from typing import List, Type +from unittest.mock import Mock, call, create_autospec + +import freezegun +import pytest +import yaml + +from security_constraints.common import ( + Configuration, + PackageConstraints, + SecurityConstraintsError, + SecurityVulnerability, + SecurityVulnerabilityDatabaseAPI, +) +from security_constraints.main import ( + are_constraints_pip_friendly, + create_header, + fetch_vulnerabilities, + filter_vulnerabilities, + format_constraints_file_line, + get_args, + get_config, + get_safe_version_constraints, + get_security_vulnerability_database_apis, + main, + setup_logging, + sort_vulnerabilities, +) + + +def test_get_security_vulnerability_database_apis(monkeypatch) -> None: + mock = Mock() + monkeypatch.setattr("security_constraints.main.GithubSecurityAdvisoryAPI", mock) + assert get_security_vulnerability_database_apis() == [mock.return_value] + + +@pytest.mark.parametrize( + "vulnerability, expected", + [ + ( + SecurityVulnerability( + name="CVE-2020-123", + identifier="GHSA-1-2-3", + package="pystuff", + vulnerable_range="= 0.2.0", + ), + PackageConstraints(package="pystuff", specifiers=["!=0.2.0"]), + ), + ( + SecurityVulnerability( + name="CVE-2020-123", + identifier="GHSA-1-2-3", + package="pystuff", + vulnerable_range="<= 1.0.8", + ), + PackageConstraints(package="pystuff", specifiers=[">1.0.8"]), + ), + ( + SecurityVulnerability( + name="CVE-2020-123", + identifier="GHSA-1-2-3", + package="pystuff", + vulnerable_range="< 0.1.11", + ), + PackageConstraints(package="pystuff", specifiers=[">=0.1.11"]), + ), + ( + SecurityVulnerability( + name="CVE-2020-123", + identifier="GHSA-1-2-3", + package="pystuff", + vulnerable_range=">= 4.3.0, < 4.3.5", + ), + PackageConstraints(package="pystuff", specifiers=["<4.3.0", ">=4.3.5"]), + ), + ( + SecurityVulnerability( + name="CVE-2020-123", + identifier="GHSA-1-2-3", + package="pystuff", + vulnerable_range=">= 0.0.1", + ), + PackageConstraints(package="pystuff", specifiers=["<0.0.1"]), + ), + ], +) +def test_get_safe_version_constraints( + vulnerability: SecurityVulnerability, expected: PackageConstraints +) -> None: + assert get_safe_version_constraints(vulnerability) == expected + + +@pytest.mark.parametrize( + "constraints, expected", + [ + (PackageConstraints(package="pystuff", specifiers=[">1.2"]), True), + (PackageConstraints(package="pystuff", specifiers=["!=1.2.4"]), True), + (PackageConstraints(package="pystuff", specifiers=["<2"]), True), + ( + PackageConstraints(package="pystuff", specifiers=[">1.2", "<2", "!=1.2.4"]), + True, + ), + (PackageConstraints(package="pystuff", specifiers=[">1.2dev0"]), False), + (PackageConstraints(package="pystuff", specifiers=["<1.0.1b1"]), False), + (PackageConstraints(package="pystuff", specifiers=["<=1.0.2b1deb1"]), False), + (PackageConstraints(package="pystuff", specifiers=[">banana-peel"]), False), + (PackageConstraints(package="pystuff", specifiers=["==1.2dev0"]), True), + ], +) +def test_are_constraints_pip_friendly( + constraints: PackageConstraints, expected: bool +) -> None: + assert are_constraints_pip_friendly(constraints) == expected + + +def test_get_args(monkeypatch) -> None: + mock = create_autospec(argparse.ArgumentParser) + monkeypatch.setattr("argparse.ArgumentParser", mock) + args = get_args() + mock.return_value.add_argument.assert_called() + assert args is mock.return_value.parse_args.return_value + + +def test_get_config__no_file() -> None: + assert get_config(config_file=None) == Configuration() + + +def test_get_config__config_file(tmp_path) -> None: + config_file: Path = tmp_path / "sc_conf.yaml" + with open(config_file, mode="w") as fh: + yaml.safe_dump({"ignore_ids": ["GHSA-1", "GHSA-3"]}, fh) + assert get_config(config_file=str(config_file)) == Configuration( + ignore_ids=["GHSA-1", "GHSA-3"] + ) + + +@pytest.mark.parametrize("debug", [False, True]) +def test_setup_logging(monkeypatch, debug: bool) -> None: + mock = Mock() + monkeypatch.setattr("logging.getLogger", mock) + setup_logging(debug=debug) + mock.return_value.setLevel.assert_called_once_with( + logging.DEBUG if debug else logging.INFO + ) + + +def test_fetch_vulnerabilities() -> None: + mock_vulnerabilities = [create_autospec(SecurityVulnerability) for _ in range(3)] + mock_apis = [ + Mock( + spec=SecurityVulnerabilityDatabaseAPI, + get_vulnerabilities=Mock(return_value=mock_vulnerabilities[:2]), + ), + Mock( + spec=SecurityVulnerabilityDatabaseAPI, + get_vulnerabilities=Mock(return_value=[mock_vulnerabilities[2]]), + ), + ] + assert fetch_vulnerabilities(mock_apis) == mock_vulnerabilities + + +@pytest.mark.parametrize( + "vulnerabilities, config, expected", + [ + ( + [ + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="= 1.0", + ), + SecurityVulnerability( + name="CVE-X1", + identifier="GHSA-X1", + package="nonsense", + vulnerable_range="= 1.0", + ), + SecurityVulnerability( + name="CVE-2", + identifier="GHSA-2", + package="pybanana", + vulnerable_range="= 2.0", + ), + SecurityVulnerability( + name="CVE-X2", + identifier="GHSA-X1", + package="some-package", + vulnerable_range="= 1.0", + ), + SecurityVulnerability( + name="CVE-3U", + identifier="GHSA-3", + package="pypeel", + vulnerable_range="< 3.0dev1", + ), + ], + Configuration(ignore_ids=["GHSA-X1", "GHSA-X2"]), + [ + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="= 1.0", + ), + SecurityVulnerability( + name="CVE-2", + identifier="GHSA-2", + package="pybanana", + vulnerable_range="= 2.0", + ), + SecurityVulnerability( + name="CVE-3U", + identifier="GHSA-3", + package="pypeel", + vulnerable_range="< 3.0dev1", + ), + ], + ), + ([], Configuration(), []), + ], +) +def test_filter_vulnerabilities( + vulnerabilities: List[SecurityVulnerability], + config: Configuration, + expected: List[SecurityVulnerability], +) -> None: + assert ( + filter_vulnerabilities(config=config, vulnerabilities=vulnerabilities) + == expected + ) + + +@pytest.mark.parametrize( + "vulnerabilities, expected", + [ + ( + [ + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="= 1.0", + ), + SecurityVulnerability( + name="CVE-2", + identifier="GHSA-2", + package="pybanana", + vulnerable_range="= 2.0", + ), + SecurityVulnerability( + name="CVE-3U", + identifier="GHSA-3", + package="pypeel", + vulnerable_range="< 3.0dev1", + ), + ], + [ + SecurityVulnerability( + name="CVE-2", + identifier="GHSA-2", + package="pybanana", + vulnerable_range="= 2.0", + ), + SecurityVulnerability( + name="CVE-3U", + identifier="GHSA-3", + package="pypeel", + vulnerable_range="< 3.0dev1", + ), + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="= 1.0", + ), + ], + ), + ([], []), + ], +) +def test_sort_vulnerabilities( + vulnerabilities: List[SecurityVulnerability], expected: List[SecurityVulnerability] +) -> None: + assert sort_vulnerabilities(vulnerabilities=vulnerabilities) == expected + + +@freezegun.freeze_time(time_to_freeze=datetime.datetime(1986, 4, 9, 12, 11, 10, 9)) +@pytest.mark.parametrize( + "db_names, config, expected", + [ + ( + ["FakeDB"], + Configuration(), + ( + "# Generated by security-constraints on 1986-04-09T12:11:10.000009\n" + "# Data sources: FakeDB\n" + r"# Configuration: {'ignore_ids': []}" + ), + ), + ( + ["FakeDB", "Another DB"], + Configuration(ignore_ids=["GHSA-1", "GHSA-2"]), + ( + "# Generated by security-constraints on 1986-04-09T12:11:10.000009\n" + "# Data sources: FakeDB, Another DB\n" + r"# Configuration: {'ignore_ids': ['GHSA-1', 'GHSA-2']}" + ), + ), + ( + [], + Configuration(), + ( + "# Generated by security-constraints on 1986-04-09T12:11:10.000009\n" + "# Data sources: \n" + r"# Configuration: {'ignore_ids': []}" + ), + ), + ], +) +def test_create_header(db_names: List[str], config: Configuration, expected: str): + assert ( + create_header( + [ + Mock( + spec=SecurityVulnerabilityDatabaseAPI, + get_database_name=Mock(return_value=db_name), + ) + for db_name in db_names + ], + config, + ) + == expected + ) + + +@pytest.mark.parametrize( + "constraints, vulnerability, expected", + [ + ( + PackageConstraints(package="pystuff", specifiers=[">=5.2"]), + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="< 5.2", + ), + "pystuff>=5.2 # CVE-1 (ID: GHSA-1)", + ), + ( + PackageConstraints(package="pystuff", specifiers=[">=5", "!=6.0.0"]), + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="pystuff", + vulnerable_range="< 5,= 6.0.0", + ), + "pystuff>=5,!=6.0.0 # CVE-1 (ID: GHSA-1)", + ), + ], +) +def test_format_constraints_file_line( + constraints: PackageConstraints, vulnerability: SecurityVulnerability, expected: str +) -> None: + assert format_constraints_file_line(constraints, vulnerability) == expected + + +def test_format_constraints_file_line__package_mismatch() -> None: + with pytest.raises(AssertionError): + _ = format_constraints_file_line( + PackageConstraints(package="a-package", specifiers=[]), + SecurityVulnerability( + name="CVE-1", + identifier="GHSA-1", + package="another-package", + vulnerable_range="< 1", + ), + ) + + +@pytest.mark.parametrize("to_stdout", [True, False]) +def test_main(monkeypatch, to_stdout: bool) -> None: + mock_stream = Mock(isatty=Mock(return_value=to_stdout)) + mock_vulnerabilities = [create_autospec(SecurityVulnerability) for _ in range(3)] + mock_sorted_vulnerabilities = [ + mock_vulnerabilities[2], + mock_vulnerabilities[0], + mock_vulnerabilities[1], + ] + mock_constraints = [create_autospec(PackageConstraints) for _ in range(3)] + mock_fetch_vulnerabilities = create_autospec(fetch_vulnerabilities) + mock_api = Mock(get_database_name=Mock(return_value="fake database")) + mock_get_args = create_autospec(get_args) + mock_setup_logging = create_autospec(setup_logging) + mock_get_config = create_autospec(get_config) + mock_yaml_dump = create_autospec(yaml.safe_dump) + mock_get_apis = create_autospec(get_security_vulnerability_database_apis) + mock_filter_vulnerabilities = create_autospec(filter_vulnerabilities) + mock_sort_vulnerabilities = create_autospec(sort_vulnerabilities) + mock_create_header = create_autospec(create_header) + mock_create_header.return_value = "# Fake header" + mock_format_constraints_file_line = create_autospec(format_constraints_file_line) + mock_get_safe_constraints = create_autospec(get_safe_version_constraints) + mock_are_constraints_pip_friendly = create_autospec(are_constraints_pip_friendly) + monkeypatch.setattr("security_constraints.main.get_args", mock_get_args) + monkeypatch.setattr("security_constraints.main.setup_logging", mock_setup_logging) + monkeypatch.setattr("security_constraints.main.get_config", mock_get_config) + monkeypatch.setattr("security_constraints.main.yaml.safe_dump", mock_yaml_dump) + monkeypatch.setattr( + "security_constraints.main.get_security_vulnerability_database_apis", + mock_get_apis, + ) + monkeypatch.setattr( + "security_constraints.main.fetch_vulnerabilities", mock_fetch_vulnerabilities + ) + monkeypatch.setattr( + "security_constraints.main.filter_vulnerabilities", mock_filter_vulnerabilities + ) + monkeypatch.setattr( + "security_constraints.main.sort_vulnerabilities", mock_sort_vulnerabilities + ) + monkeypatch.setattr("security_constraints.main.create_header", mock_create_header) + monkeypatch.setattr( + "security_constraints.main.format_constraints_file_line", + mock_format_constraints_file_line, + ) + monkeypatch.setattr( + "security_constraints.main.get_safe_version_constraints", + mock_get_safe_constraints, + ) + monkeypatch.setattr( + "security_constraints.main.are_constraints_pip_friendly", + mock_are_constraints_pip_friendly, + ) + + mock_get_args.return_value.version = False + mock_get_args.return_value.output = mock_stream + mock_get_args.return_value.dump_config = False + mock_get_args.return_value.ignore_ids = ["GHSA-X1"] + mock_get_config.return_value = Configuration(ignore_ids=["GHSA-X2"]) + mock_get_apis.return_value = [mock_api] + mock_fetch_vulnerabilities.return_value = mock_vulnerabilities + mock_sort_vulnerabilities.return_value = mock_sorted_vulnerabilities + mock_get_safe_constraints.side_effect = mock_constraints + mock_are_constraints_pip_friendly.side_effect = [True, False, True] + mock_format_constraints_file_line.side_effect = [ + "constraints-line-1", + "constraints-line-2", + ] + + exit_code = main() + + assert exit_code == 0 + mock_yaml_dump.assert_not_called() + mock_get_args.assert_called_once_with() + mock_setup_logging.assert_called_once_with(debug=mock_get_args.return_value.debug) + mock_get_config.assert_called_once_with( + config_file=mock_get_args.return_value.config + ) + mock_get_apis.assert_called_once_with() + mock_fetch_vulnerabilities.assert_called_once_with([mock_api]) + mock_filter_vulnerabilities.assert_called_once_with( + config=Configuration(ignore_ids=["GHSA-X2", "GHSA-X1"]), + vulnerabilities=mock_vulnerabilities, + ) + mock_sort_vulnerabilities.assert_called_once_with( + mock_filter_vulnerabilities.return_value + ) + mock_get_safe_constraints.assert_has_calls( + [call(v) for v in mock_sorted_vulnerabilities], + any_order=False, + ) + mock_format_constraints_file_line.assert_has_calls( + [ + call(mock_constraints[0], mock_sorted_vulnerabilities[0]), + call(mock_constraints[2], mock_sorted_vulnerabilities[2]), + ] + ) + mock_stream.write.assert_has_calls( + [ + call("# Fake header\n"), + call("constraints-line-1\n"), + call("constraints-line-2\n"), + ], + any_order=False, + ) + mock_stream.isatty.assert_called_once_with() + if to_stdout: + mock_stream.close.assert_not_called() + else: + mock_stream.close.assert_called_once_with() + + +@pytest.mark.parametrize("to_stdout", [True, False]) +def test_main__dump_config(monkeypatch, to_stdout: bool) -> None: + mock_stream = Mock(isatty=Mock(return_value=to_stdout)) + mock_get_args = create_autospec(get_args) + mock_setup_logging = create_autospec(setup_logging) + mock_get_config = create_autospec(get_config) + mock_yaml_dump = create_autospec(yaml.safe_dump) + mock_get_apis = create_autospec(get_security_vulnerability_database_apis) + monkeypatch.setattr("security_constraints.main.get_args", mock_get_args) + monkeypatch.setattr("security_constraints.main.setup_logging", mock_setup_logging) + monkeypatch.setattr("security_constraints.main.get_config", mock_get_config) + monkeypatch.setattr("security_constraints.main.yaml.safe_dump", mock_yaml_dump) + monkeypatch.setattr( + "security_constraints.main.get_security_vulnerability_database_apis", + mock_get_apis, + ) + + mock_get_args.return_value.version = False + mock_get_args.return_value.output = mock_stream + mock_get_args.return_value.dump_config = True + mock_get_args.return_value.ignore_ids = ["GHSA-X1"] + mock_get_config.return_value = Configuration(ignore_ids=["GHSA-X2"]) + + exit_code = main() + + assert exit_code == 0 + mock_yaml_dump.assert_called_once_with( + {"ignore_ids": ["GHSA-X2", "GHSA-X1"]}, stream=sys.stdout + ) + mock_get_args.assert_called_once_with() + mock_setup_logging.assert_called_once_with(debug=mock_get_args.return_value.debug) + mock_get_config.assert_called_once_with( + config_file=mock_get_args.return_value.config + ) + mock_get_apis.assert_not_called() + mock_stream.write.assert_not_called() + mock_stream.isatty.assert_called_once_with() + if to_stdout: + mock_stream.close.assert_not_called() + else: + mock_stream.close.assert_called_once_with() + + +def test_main__version(monkeypatch, capsys) -> None: + mock_version = Mock(return_value="x.y.z") + monkeypatch.setattr("security_constraints.main.version", mock_version) + mock_stream = Mock() + mock_get_args = create_autospec(get_args) + mock_setup_logging = create_autospec(setup_logging) + mock_get_config = create_autospec(get_config) + mock_yaml_dump = create_autospec(yaml.safe_dump) + mock_get_apis = create_autospec(get_security_vulnerability_database_apis) + monkeypatch.setattr("security_constraints.main.get_args", mock_get_args) + monkeypatch.setattr("security_constraints.main.setup_logging", mock_setup_logging) + monkeypatch.setattr("security_constraints.main.get_config", mock_get_config) + monkeypatch.setattr("security_constraints.main.yaml.safe_dump", mock_yaml_dump) + monkeypatch.setattr( + "security_constraints.main.get_security_vulnerability_database_apis", + mock_get_apis, + ) + + mock_get_args.return_value.version = True + mock_get_args.return_value.output = mock_stream + mock_get_args.return_value.dump_config = True + mock_get_args.return_value.ignore_ids = ["GHSA-X1"] + mock_get_config.return_value = Configuration(ignore_ids=["GHSA-X2"]) + + exit_code = main() + + assert exit_code == 0 + out, err = capsys.readouterr() + mock_version.assert_called_once_with("security-constraints") + assert "x.y.z" in out + assert not err + mock_yaml_dump.assert_not_called() + mock_get_args.assert_called_once_with() + mock_setup_logging.assert_not_called() + mock_get_config.assert_not_called() + mock_get_apis.assert_not_called() + mock_stream.write.assert_not_called() + + +@pytest.mark.parametrize( + "exception_type, expected_exit_code", + [(SecurityConstraintsError, 1), (Exception, 2)], +) +def test_main__exception( + monkeypatch, exception_type: Type[Exception], expected_exit_code: int +) -> None: + mock_stream = Mock(isatty=Mock(return_value=True)) + mock_get_args = create_autospec(get_args) + mock_setup_logging = create_autospec(setup_logging) + mock_get_config = create_autospec(get_config) + mock_yaml_dump = create_autospec(yaml.safe_dump) + mock_get_apis = create_autospec(get_security_vulnerability_database_apis) + mock_get_apis.side_effect = exception_type("intentional") + mock_filter_vulnerabilities = create_autospec(filter_vulnerabilities) + + monkeypatch.setattr("security_constraints.main.get_args", mock_get_args) + monkeypatch.setattr("security_constraints.main.setup_logging", mock_setup_logging) + monkeypatch.setattr("security_constraints.main.get_config", mock_get_config) + monkeypatch.setattr("security_constraints.main.yaml.safe_dump", mock_yaml_dump) + monkeypatch.setattr( + "security_constraints.main.get_security_vulnerability_database_apis", + mock_get_apis, + ) + monkeypatch.setattr( + "security_constraints.main.filter_vulnerabilities", mock_filter_vulnerabilities + ) + + mock_get_args.return_value.version = False + mock_get_args.return_value.output = mock_stream + mock_get_args.return_value.dump_config = False + mock_get_args.return_value.ignore_ids = ["GHSA-X1"] + mock_get_config.return_value = Configuration(ignore_ids=["GHSA-X2"]) + + exit_code = main() + + assert exit_code == expected_exit_code + mock_yaml_dump.assert_not_called() + mock_get_args.assert_called_once_with() + mock_setup_logging.assert_called_once_with(debug=mock_get_args.return_value.debug) + mock_get_config.assert_called_once_with( + config_file=mock_get_args.return_value.config + ) + mock_get_apis.assert_called_once_with() + mock_filter_vulnerabilities.assert_not_called() + mock_stream.write.assert_not_called() + mock_stream.isatty.assert_called_once_with() + + +def test_main__output_none_exception(monkeypatch) -> None: + mock_get_args = create_autospec(get_args) + mock_get_args.return_value.version = False + mock_get_args.return_value.output = None + mock_setup_logging = create_autospec(setup_logging) + mock_get_config = create_autospec(get_config) + monkeypatch.setattr("security_constraints.main.get_args", mock_get_args) + monkeypatch.setattr("security_constraints.main.setup_logging", mock_setup_logging) + monkeypatch.setattr("security_constraints.main.get_config", mock_get_config) + + exit_code = main() + + assert exit_code == 2 + mock_get_config.assert_not_called() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f121406 --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = py37,py38,py39,py310,py311 +isolated_build = True + +[testenv] +deps = .[test,lint] +commands = + isort --check-only . + black --check src test + flake8 . + pytest + mypy \ No newline at end of file From f84a46b9509d92fd525ceb253ce4606162226a46 Mon Sep 17 00:00:00 2001 From: avikstroem Date: Fri, 4 Nov 2022 15:41:42 +0100 Subject: [PATCH 2/4] Move workflow files into the workflow dir --- .github/{ => workflows}/ci.yaml | 0 .github/{ => workflows}/publish.yaml | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename .github/{ => workflows}/ci.yaml (100%) rename .github/{ => workflows}/publish.yaml (100%) diff --git a/.github/ci.yaml b/.github/workflows/ci.yaml similarity index 100% rename from .github/ci.yaml rename to .github/workflows/ci.yaml diff --git a/.github/publish.yaml b/.github/workflows/publish.yaml similarity index 100% rename from .github/publish.yaml rename to .github/workflows/publish.yaml From 1e1f1e9df8671a5effc5b25f073ab7036b6bcb78 Mon Sep 17 00:00:00 2001 From: avikstroem Date: Fri, 4 Nov 2022 16:19:50 +0100 Subject: [PATCH 3/4] Remove version from pyproject.toml since setuptools_scm is used --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4b9b8ba..2b55f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,5 @@ [project] name = "security-constraints" -version = "1.0.0" description = "Fetches security vulnerabilities and creates pip-constraints based on them." readme = "README.md" license = {file = "LICENSE"} From 7c8ba3d881e1cbe0ec3fac4fe3fea2475a072b52 Mon Sep 17 00:00:00 2001 From: avikstroem Date: Fri, 4 Nov 2022 16:21:44 +0100 Subject: [PATCH 4/4] Add version back in pyproject.toml since checks broke --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2b55f79..4b9b8ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "security-constraints" +version = "1.0.0" description = "Fetches security vulnerabilities and creates pip-constraints based on them." readme = "README.md" license = {file = "LICENSE"}