Repository: trailofbits/pip-audit (python)

Issue:
Design a generic adaptor for Python vulnerability services
Now that we've evaluated osv.dev (#2), we can start designing an abstract interface that individual vulnerability services can satisfy.

From discussion on Slack: we probably want something like this:

```python
class VulnerabilityService(ABC):
    def query(spec: Dependency) -> List[VulnerabilityResult]:
        ...

    def query_all(specs: List[Dependency]) -> Dict[Dependency, List[VulnerabilityResult]]:
        # naive implementation that can be overridden if
        # a particular service supports bulk queries
        for spec in specs:
            ...
```

Pull request description:
service: add `VulnerabilityService` interface type
Closes #9

Patch:
diff --git a/pip_audit/service/__init__.py b/pip_audit/service/__init__.py
new file mode 100644
index 0000000..062781f
--- /dev/null
+++ b/pip_audit/service/__init__.py
@@ -0,0 +1,1 @@
+from .interface import Dependency, VulnerabilityResult, VulnerabilityService  # noqa: F401
diff --git a/pip_audit/service/interface.py b/pip_audit/service/interface.py
new file mode 100644
index 0000000..3fc749e
--- /dev/null
+++ b/pip_audit/service/interface.py
@@ -0,0 +1,31 @@
+from abc import ABC
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+from packaging.version import Version
+
+
+@dataclass(frozen=True)
+class Dependency:
+    package: str
+    version: Version
+
+
+@dataclass(frozen=True)
+class VulnerabilityResult:
+    id: str
+    description: str
+    version_introduced: Optional[Version]
+    version_fixed: Optional[Version]
+
+
+class VulnerabilityService(ABC):
+    def query(self, spec: Dependency) -> List[VulnerabilityResult]:  # pragma: no cover
+        raise NotImplementedError
+
+    def query_all(self, specs: List[Dependency]) -> Dict[Dependency, List[VulnerabilityResult]]:
+        # Naive implementation that can be overridden if a particular service supports bulk queries
+        results: Dict[Dependency, List[VulnerabilityResult]] = {}
+        for spec in specs:
+            results[spec] = self.query(spec)
+        return results
diff --git a/setup.py b/setup.py
index 369cba5..c11edc6 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,9 @@ setup(
     python_requires=">=3.6",
     install_requires=[
         "pip-api==0.0.20",
+        "packaging==21.0.0",
+        # TODO: Remove this once 3.7 is our minimally supported version.
+        "dataclasses==0.6",
     ],
     extras_require={
         "dev": [
@@ -39,10 +42,12 @@ setup(
             "pytest-cov",
             "coverage[toml]",
             "twine",
-            # NOTE(ww): pdoc3 does not support Python 3.6. Re-enable this once 3.7 is
+            # NOTE: pdoc3 does not support Python 3.6. Re-enable this once 3.7 is
             # our minimally supported version.
             # "pdoc3",
             "mypy",
+            # TODO: Remove this once 3.7 is our minimally supported version.
+            "types-dataclasses",
         ]
     },
     classifiers=[


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..0d77720
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,36 @@
+import pytest
+from packaging.version import Version
+
+from pip_audit.service.interface import Dependency, VulnerabilityResult, VulnerabilityService
+
+
+@pytest.fixture(autouse=True)
+def spec():
+    def _spec(version):
+        return Dependency(package="foo", version=Version(version))
+
+    return _spec
+
+
+@pytest.fixture(autouse=True)
+def vuln_service():
+    # A dummy service that only returns results for the "foo" package
+    # between [1.0.0, 1.1.0).
+    class Service(VulnerabilityService):
+        def query(self, spec):
+            introduced = Version("1.0.0")
+            fixed = Version("1.1.0")
+
+            if spec.package == "foo" and (introduced <= spec.version < fixed):
+                return [
+                    VulnerabilityResult(
+                        id="fake-id",
+                        description="this is not a real result",
+                        version_introduced=Version("1.0.0"),
+                        version_fixed=Version("1.1.0"),
+                    )
+                ]
+
+            return []
+
+    return Service
diff --git a/test/service/test_interface.py b/test/service/test_interface.py
new file mode 100644
index 0000000..3b7f7ee
--- /dev/null
+++ b/test/service/test_interface.py
@@ -0,0 +1,16 @@
+def test_vulnerability_service(vuln_service, spec):
+    service = vuln_service()
+    spec = spec("1.0.1")
+
+    assert len(service.query(spec)) == 1
+
+    all_ = service.query_all([spec])
+    assert len(all_) == 1
+    assert len(all_[spec]) == 1
+
+
+def test_vulnerability_service_no_results(vuln_service, spec):
+    service = vuln_service()
+    spec = spec("1.1.1")
+
+    assert len(service.query(spec)) == 0