Skip to content

Commit bc4a21d

Browse files
authored
Refactoring/518 make audit class more extendable (#520)
* Extract nox session to conftest.py * Refactor audit to work in a variety of contexts, not just "dependency:audit" * Ignore non-critical security issues * Finish release sentence * Add poetry export locally; though might be better globally if in many projects * Fix file name and make unique so doesn't interfere with local running of both unit & integration tests * Add poetry export to integration pyproject.toml * Try alternate test with global poetry export * Try local poetry again but with install afterwards * Add to documentation * Isolate poetry add and install to local virtual env in tmp_path * Remove global poetry export addition * Update dependencies to latest versions * Fix over-indentation * Use IDE to apply suggested change locally per comment and add closing bracket * Use IDE to apply suggested change locally per comment for doctring * Use ellipses to shorten docstring example * Fix unreleased.md changes missed in merge commit * Switch to output in tmp directory, so not needed in .gitignore
1 parent 31b1ad7 commit bc4a21d

File tree

23 files changed

+769
-431
lines changed

23 files changed

+769
-431
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,4 @@ nosetests.xml
5757
.vscode/settings.json
5858

5959
# Emacs
60-
TAGS
60+
TAGS

doc/changes/unreleased.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
# Unreleased
2+
3+
With the refactoring of the `dependency:audit`, we use `poetry export`. For how it can
4+
be added (project-specific or globally), see the
5+
[poetry export documentation](https://github.com/python-poetry/poetry-plugin-export).
6+
7+
## Refactoring
8+
9+
* #517: Refactored `dependency:audit` & split up to support upcoming work

doc/user_guide/dependencies.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Dependencies
2+
============
3+
4+
Core dependencies
5+
+++++++++++++++++
6+
7+
- Python >= 3.9
8+
- poetry >= 2.1.2
9+
- `poetry export <https://github.com/python-poetry/poetry-plugin-export>`__

doc/user_guide/features/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Features
99
metrics/collecting_metrics
1010
creating_a_release
1111
documentation/index
12+
managing_dependencies
1213

1314
Uniform Project Layout
1415
----------------------
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Managing dependencies
2+
=====================
3+
4+
+--------------------------+------------------+----------------------------------------+
5+
| Nox session | CI Usage | Action |
6+
+==========================+==================+========================================+
7+
| ``dependency:licenses`` | ``report.yml`` | Uses ``pip-licenses`` to return |
8+
| | | packages with their licenses |
9+
+--------------------------+------------------+----------------------------------------+
10+
| ``dependency:audit`` | No | Uses ``pip-audit`` to return active |
11+
| | | vulnerabilities in our dependencies |
12+
+--------------------------+------------------+----------------------------------------+

doc/user_guide/user_guide.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
.. toctree::
77
:maxdepth: 2
88

9+
dependencies
910
getting_started
1011
features/index
1112
workflows
Lines changed: 12 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,22 @@
11
from __future__ import annotations
22

3-
import argparse
43
import json
5-
import subprocess
64
from pathlib import Path
75

86
import nox
97
from nox import Session
108

9+
from exasol.toolbox.util.dependencies.audit import (
10+
PipAuditException,
11+
Vulnerabilities,
12+
)
1113
from exasol.toolbox.util.dependencies.licenses import (
1214
PackageLicenseReport,
1315
get_licenses,
1416
)
1517
from exasol.toolbox.util.dependencies.poetry_dependencies import get_dependencies
1618

1719

18-
class Audit:
19-
@staticmethod
20-
def _filter_json_for_vulnerabilities(audit_json_bytes: bytes) -> dict:
21-
"""
22-
Filters JSON from pip-audit for only packages with vulnerabilities
23-
24-
Examples:
25-
>>> audit_json_dict = {"dependencies": [
26-
... {"name": "alabaster", "version": "0.7.16", "vulns": []},
27-
... {"name": "cryptography", "version": "43.0.3", "vulns":
28-
... [{"id": "GHSA-79v4-65xg-pq4g", "fix_versions": ["44.0.1"],
29-
... "aliases": ["CVE-2024-12797"],
30-
... "description": "pyca/cryptography\'s wheels..."}]}]}
31-
>>> audit_json = json.dumps(audit_json_dict).encode()
32-
>>> Audit._filter_json_for_vulnerabilities(audit_json)
33-
{"dependencies": [{"name": "cryptography", "version": "43.0.3", "vulns":
34-
[{"id": "GHSA-79v4-65xg-pq4g", "fix_versions": ["44.0.1"], "aliases":
35-
["CVE-2024-12797"], "description": "pyca/cryptography\'s wheels..."}]}]}
36-
"""
37-
audit_dict = json.loads(audit_json_bytes.decode("utf-8"))
38-
return {
39-
"dependencies": [
40-
{
41-
"name": entry["name"],
42-
"version": entry["version"],
43-
"vulns": entry["vulns"],
44-
}
45-
for entry in audit_dict["dependencies"]
46-
if entry["vulns"]
47-
]
48-
}
49-
50-
@staticmethod
51-
def _parse_args(session) -> argparse.Namespace:
52-
parser = argparse.ArgumentParser(
53-
description="Audits dependencies for security vulnerabilities",
54-
usage="nox -s dependency:audit -- -- [options]",
55-
)
56-
parser.add_argument(
57-
"-o",
58-
"--output",
59-
type=Path,
60-
default=None,
61-
help="Output results to the given file",
62-
)
63-
return parser.parse_args(args=session.posargs)
64-
65-
def run(self, session: Session) -> None:
66-
args = self._parse_args(session)
67-
68-
command = ["pip-audit", "-f", "json"]
69-
output = subprocess.run(command, capture_output=True)
70-
71-
audit_json = self._filter_json_for_vulnerabilities(output.stdout)
72-
if args.output:
73-
with open(args.output, "w") as file:
74-
json.dump(audit_json, file)
75-
else:
76-
print(json.dumps(audit_json, indent=2))
77-
78-
if output.returncode != 0:
79-
session.warn(
80-
f"Command {' '.join(command)} failed with exit code {output.returncode}",
81-
)
82-
83-
8420
@nox.session(name="dependency:licenses", python=False)
8521
def dependency_licenses(session: Session) -> None:
8622
"""Return the packages with their licenses"""
@@ -95,4 +31,11 @@ def dependency_licenses(session: Session) -> None:
9531
@nox.session(name="dependency:audit", python=False)
9632
def audit(session: Session) -> None:
9733
"""Check for known vulnerabilities"""
98-
Audit().run(session=session)
34+
35+
try:
36+
vulnerabilities = Vulnerabilities.load_from_pip_audit(working_directory=Path())
37+
except PipAuditException as e:
38+
session.error(e.return_code, e.stdout, e.stderr)
39+
40+
security_issue_dict = vulnerabilities.security_issue_dict
41+
print(json.dumps(security_issue_dict, indent=2))

exasol/toolbox/tools/security.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -157,32 +157,38 @@ def from_pip_audit(report: str) -> Iterable[Issue]:
157157
- the same vulnerability ID (CVE, PYSEC, GHSA, etc.) is present across
158158
multiple coordinates.
159159
160-
Input:
161-
'{"dependencies": [{"name": "<package_name>", "version": "<package_version>",
162-
"vulns": [{"id": "<vuln_id>", "fix_versions": ["<fix_version>"],
163-
"aliases": ["<vuln_id2>"], "description": "<vuln_description>"}]}]}'
160+
Input as string:
161+
[
162+
{
163+
"name": "jinja2",
164+
"version": "3.1.5",
165+
"refs": [
166+
"GHSA-cpwx-vrp4-4pq7",
167+
"CVE-2025-27516"
168+
],
169+
"description": "An oversight ..."
170+
}
171+
]
172+
164173
165174
Args:
166175
report:
167176
the JSON output of `nox -s dependency:audit` provided as a str
168177
"""
169-
report_dict = json.loads(report)
170-
dependencies = report_dict.get("dependencies", [])
171-
for dependency in dependencies:
172-
package = dependency["name"]
173-
for v in dependency["vulns"]:
174-
refs = [v["id"]] + v["aliases"]
175-
cves, cwes, links = identify_pypi_references(
176-
references=refs, package_name=package
178+
vulnerabilities = json.loads(report)
179+
180+
for vulnerability in vulnerabilities:
181+
cves, cwes, links = identify_pypi_references(
182+
references=vulnerability["refs"], package_name=vulnerability["name"]
183+
)
184+
if cves:
185+
yield Issue(
186+
cve=sorted(cves)[0],
187+
cwe="None" if not cwes else ", ".join(cwes),
188+
description=vulnerability["description"],
189+
coordinates=f"{vulnerability['name']}:{vulnerability['version']}",
190+
references=tuple(links),
177191
)
178-
if cves:
179-
yield Issue(
180-
cve=sorted(cves)[0],
181-
cwe="None" if not cwes else ", ".join(cwes),
182-
description=v["description"],
183-
coordinates=f"{package}:{dependency['version']}",
184-
references=tuple(links),
185-
)
186192

187193

188194
@dataclass(frozen=True)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess # nosec
5+
import tempfile
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from re import search
9+
from typing import (
10+
Any,
11+
Union,
12+
)
13+
14+
from pydantic import BaseModel
15+
16+
from exasol.toolbox.util.dependencies.shared_models import Package
17+
18+
PIP_AUDIT_VULNERABILITY_PATTERN = (
19+
r"^Found \d+ known vulnerabilit\w{1,3} in \d+ package\w?$"
20+
)
21+
22+
23+
@dataclass
24+
class PipAuditException(Exception):
25+
return_code: int
26+
stdout: str
27+
stderr: str
28+
29+
def __init__(self, subprocess_output: subprocess.CompletedProcess) -> None:
30+
self.return_code = subprocess_output.returncode
31+
self.stdout = subprocess_output.stdout
32+
self.stderr = subprocess_output.stderr
33+
34+
35+
class Vulnerability(Package):
36+
id: str
37+
aliases: list[str]
38+
fix_versions: list[str]
39+
description: str
40+
41+
@classmethod
42+
def from_audit_entry(
43+
cls, package_name: str, version: str, vuln_entry: dict[str, Any]
44+
) -> Vulnerability:
45+
"""
46+
Create a Vulnerability from a pip-audit vulnerability entry
47+
"""
48+
return cls(
49+
name=package_name,
50+
version=version,
51+
id=vuln_entry["id"],
52+
aliases=vuln_entry["aliases"],
53+
fix_versions=vuln_entry["fix_versions"],
54+
description=vuln_entry["description"],
55+
)
56+
57+
@property
58+
def security_issue_entry(self) -> dict[str, Union[str, list[str]]]:
59+
return {
60+
"name": self.name,
61+
"version": str(self.version),
62+
"refs": [self.id] + self.aliases,
63+
"description": self.description,
64+
}
65+
66+
67+
def audit_poetry_files(working_directory: Path) -> str:
68+
"""
69+
Audit the `pyproject.toml` and `poetry.lock` files
70+
71+
pip-audit evaluates installed packages. This is to provide
72+
additional security-related information beyond seeing if a given package
73+
has a known vulnerability. Thus, to audit our `pyproject.toml` and
74+
`poetry.lock` files without altering a locally sourced poetry environment,
75+
this function first exports the locked packages to a requirements.txt file.
76+
Then, pip-audit evaluates the requirements.txt by installing them to a virtualenv
77+
and then inspecting the dependencies.
78+
"""
79+
80+
requirements_txt = "requirements.txt"
81+
output = subprocess.run(
82+
["poetry", "export", "--format=requirements.txt"],
83+
capture_output=True,
84+
text=True,
85+
cwd=working_directory,
86+
) # nosec
87+
if output.returncode != 0:
88+
raise PipAuditException(subprocess_output=output)
89+
90+
with tempfile.TemporaryDirectory() as path:
91+
tmpdir = Path(path)
92+
(tmpdir / requirements_txt).write_text(output.stdout)
93+
94+
command = ["pip-audit", "-r", requirements_txt, "-f", "json"]
95+
output = subprocess.run(
96+
command,
97+
capture_output=True,
98+
text=True,
99+
cwd=tmpdir,
100+
) # nosec
101+
102+
if output.returncode != 0:
103+
# pip-audit does not distinguish between 1) finding vulnerabilities
104+
# and 2) other errors performing the pip-audit (i.e. malformed file);
105+
# they both map to returncode = 1, so we have our own logic to raise errors
106+
# for the case of 2) and not 1).
107+
if not search(PIP_AUDIT_VULNERABILITY_PATTERN, output.stderr.strip()):
108+
raise PipAuditException(subprocess_output=output)
109+
return output.stdout
110+
111+
112+
class Vulnerabilities(BaseModel):
113+
vulnerabilities: list[Vulnerability]
114+
115+
@classmethod
116+
def load_from_pip_audit(cls, working_directory: Path) -> Vulnerabilities:
117+
"""
118+
Convert the pip-audit JSON output into a Vulnerabilities model
119+
120+
The output from pip-audit is a JSON, which as a dictionary looks like:
121+
>>> audit_dict = {"dependencies": [
122+
... {"name": "alabaster", "version": "0.7.16", "vulns": []},
123+
... {"name": "cryptography", "version": "43.0.3", "vulns":
124+
... [{"id": "GHSA-79v4-65xg-pq4g", "fix_versions": ["44.0.1"],
125+
... "aliases": ["CVE-2024-12797"],
126+
... "description": "pyca/cryptography\'s wheels..."}, ...]}]}
127+
"""
128+
audit_json = audit_poetry_files(working_directory)
129+
audit_dict = json.loads(audit_json)
130+
131+
vulnerabilities = []
132+
for entry in audit_dict["dependencies"]:
133+
for vuln_entry in entry["vulns"]:
134+
vulnerabilities.append(
135+
Vulnerability.from_audit_entry(
136+
package_name=entry["name"],
137+
version=entry["version"],
138+
vuln_entry=vuln_entry,
139+
)
140+
)
141+
return Vulnerabilities(vulnerabilities=vulnerabilities)
142+
143+
@property
144+
def security_issue_dict(self) -> list[dict[str, Union[str, list[str]]]]:
145+
return [
146+
vulnerability.security_issue_entry for vulnerability in self.vulnerabilities
147+
]

0 commit comments

Comments
 (0)