Skip to content

Commit ebdbe4a

Browse files
committed
Add JSON output for pip-audit for security pipeline
1 parent d1d2cd5 commit ebdbe4a

File tree

6 files changed

+325
-5
lines changed

6 files changed

+325
-5
lines changed

doc/changes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
* [#73](https://github.com/exasol/python-toolbox/issues/73): Added nox target for auditing work spaces in regard to known vulnerabilities
66
* [#65](https://github.com/exasol/python-toolbox/issues/65): Added a Nox task for checking if the changelog got updated.
77
* [#369](https://github.com/exasol/python-toolbox/issues/369): Removed option `-v` for `isort`
8+
* [#372](https://github.com/exasol/python-toolbox/issues/372): Added conversion from audited dependencies json to expected GitHub Issue format

exasol/toolbox/nox/_dependencies.py

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from __future__ import annotations
22

3+
import argparse
4+
import json
35
import subprocess
46
import tempfile
57
from dataclasses import dataclass
8+
from enum import (
9+
Enum,
10+
auto,
11+
)
612
from inspect import cleandoc
713
from json import loads
814
from pathlib import Path
@@ -211,8 +217,87 @@ def _normalize_package_name(name: str) -> str:
211217
return template.format(heading=heading(), rows=rows)
212218

213219

214-
def _audit(session: Session) -> None:
215-
session.run("poetry", "run", "pip-audit")
220+
class PipAuditFormat(Enum):
221+
columns = auto()
222+
json = auto()
223+
224+
@classmethod
225+
def _missing_(cls, value):
226+
if isinstance(value, str):
227+
for member in cls:
228+
if member.name == value.lower():
229+
return member
230+
return None
231+
232+
@classmethod
233+
def name_tuple(cls) -> tuple:
234+
return tuple(fmt.name for fmt in PipAuditFormat)
235+
236+
237+
class Audit:
238+
@staticmethod
239+
def _filter_json_for_vulnerabilities(audit_json_bytes: bytes) -> dict:
240+
"""filters json for only packages with vulnerabilities"""
241+
audit_dict = json.loads(audit_json_bytes.decode("utf-8"))
242+
return {
243+
"dependencies": [
244+
{
245+
"name": entry["name"],
246+
"version": entry["version"],
247+
"vulns": entry["vulns"],
248+
}
249+
for entry in audit_dict["dependencies"]
250+
if entry["vulns"]
251+
]
252+
}
253+
254+
@staticmethod
255+
def _parse_format(session) -> argparse.Namespace:
256+
parser = argparse.ArgumentParser(
257+
description="Audits dependencies for security vulnerabilities",
258+
usage="nox -s dependency:audit -- -- [options]",
259+
)
260+
parser.add_argument(
261+
"-f",
262+
"--format",
263+
type=str,
264+
default=PipAuditFormat.columns.name,
265+
help="Format to emit audit results in",
266+
choices=PipAuditFormat.name_tuple(),
267+
)
268+
parser.add_argument(
269+
"-o",
270+
"--output",
271+
type=Path,
272+
default=None,
273+
help="Output results to the given file",
274+
)
275+
return parser.parse_args(args=session.posargs)
276+
277+
def run(self, session: Session) -> None:
278+
args = self._parse_format(session)
279+
audit_format = PipAuditFormat[args.format]
280+
281+
command = ["poetry", "run", "pip-audit", "-f", audit_format.name]
282+
if audit_format == PipAuditFormat.columns:
283+
if args.output:
284+
command.extend(["-o", args.output])
285+
session.run(*command)
286+
287+
elif audit_format == PipAuditFormat.json:
288+
output = subprocess.run(command, capture_output=True)
289+
audit_json = self._filter_json_for_vulnerabilities(output.stdout)
290+
291+
if args.output:
292+
with open(args.output, "w") as file:
293+
json.dump(audit_json, file)
294+
else:
295+
print(audit_json)
296+
297+
if output.returncode != 0:
298+
session.warn(
299+
f"Command {' '.join(command)} failed with exit code {output.returncode}",
300+
)
216301

217302

218303
@nox.session(name="dependency:licenses", python=False)
@@ -227,4 +312,4 @@ def dependency_licenses(session: Session) -> None:
227312
@nox.session(name="dependency:audit", python=False)
228313
def audit(session: Session) -> None:
229314
"""Check for known vulnerabilities"""
230-
_audit(session)
315+
Audit().run(session=session)

exasol/toolbox/tools/security.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from functools import partial
1717
from inspect import cleandoc
1818
from pathlib import Path
19-
from typing import Tuple
2019

2120
import typer
2221

@@ -102,6 +101,63 @@ def from_maven(report: str) -> Iterable[Issue]:
102101
)
103102

104103

104+
class VulnerabilitySource(str, Enum):
105+
CVE = "CVE"
106+
CWE = "CWE"
107+
GHSA = "GHSA"
108+
PYSEC = "PYSEC"
109+
110+
def get_link(self, package: str, vuln_id: str) -> str:
111+
if self == VulnerabilitySource.CWE:
112+
cwe_id = vuln_id.upper().replace(f"{VulnerabilitySource.CWE.value}-", "")
113+
return f"https://cwe.mitre.org/data/definitions/{cwe_id}.html"
114+
115+
map_link = {
116+
VulnerabilitySource.CVE: "https://nvd.nist.gov/vuln/detail/{vuln_id}",
117+
VulnerabilitySource.GHSA: "https://github.com/advisories/{vuln_id}",
118+
VulnerabilitySource.PYSEC: "https://github.com/pypa/advisory-database/blob/main/vulns/{package}/{vuln_id}.yaml",
119+
}
120+
return map_link[self].format(package=package, vuln_id=vuln_id)
121+
122+
123+
def identify_pypi_references(
124+
references: list[str], package_name: str
125+
) -> tuple[list[str], list[str], list[str]]:
126+
ref_cves, ref_cwes, ref_links = [], [], []
127+
for reference in references:
128+
for source in VulnerabilitySource:
129+
if reference.upper().startswith(source.value):
130+
if source == VulnerabilitySource.CVE:
131+
ref_cves.append(reference)
132+
elif source == VulnerabilitySource.CWE:
133+
ref_cwes.append(reference)
134+
ref_links.append(
135+
source.get_link(package=package_name, vuln_id=reference)
136+
)
137+
continue
138+
return ref_cves, ref_cwes, ref_links
139+
140+
141+
def from_python(report: str) -> Iterable[Issue]:
142+
# Note: Consider adding warnings if there is the same cve with multiple coordinates
143+
report_dict = json.loads(report)
144+
dependencies = report_dict.get("dependencies", [])
145+
for dependency in dependencies:
146+
package = dependency["name"]
147+
for v in dependency["vulns"]:
148+
refs = [v["id"]] + v["aliases"]
149+
cves, cwes, links = identify_pypi_references(
150+
references=refs, package_name=package
151+
)
152+
yield Issue(
153+
cve="None" if not cves else cves[0],
154+
cwe="None" if not cwes else cwes[0],
155+
description=v["description"],
156+
coordinates=f"{package}:{dependency['version']}",
157+
references=tuple(links),
158+
)
159+
160+
105161
@dataclass(frozen=True)
106162
class SecurityIssue:
107163
file_name: str
@@ -220,6 +276,7 @@ def create_security_issue(issue: Issue, project="") -> tuple[str, str]:
220276

221277
class Format(str, Enum):
222278
Maven = "maven"
279+
Python = "python"
223280

224281

225282
# pylint: disable=redefined-builtin
@@ -243,7 +300,13 @@ def _maven(infile):
243300
stdout(issue)
244301
raise typer.Exit(code=0)
245302

246-
actions = {Format.Maven: _maven}
303+
def _python(infile):
304+
issues = from_python(infile.read())
305+
for issue in _issues_as_json_str(issues):
306+
stdout(issue)
307+
raise typer.Exit(code=0)
308+
309+
actions = {Format.Maven: _maven, Format.Python: _python}
247310
action = actions[format]
248311
action(input_file)
249312

test/unit/conftest.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from inspect import cleandoc
2+
3+
import pytest
4+
5+
from exasol.toolbox.tools import security
6+
7+
8+
@pytest.fixture(scope="session")
9+
def pip_audit_jinja2_issue():
10+
return security.Issue(
11+
cve="CVE-2025-27516",
12+
cwe="None",
13+
description=cleandoc(
14+
"""An oversight in how the Jinja sandboxed environment interacts with the
15+
`|attr` filter allows an attacker that controls the content of a template
16+
to execute arbitrary Python code. To exploit the vulnerability, an
17+
attacker needs to control the content of a template. Whether that is the
18+
case depends on the type of application using Jinja. This vulnerability
19+
impacts users of applications which execute untrusted templates. Jinja's
20+
sandbox does catch calls to `str.format` and ensures they don't escape the
21+
sandbox. However, it's possible to use the `|attr` filter to get a
22+
reference to a string's plain format method, bypassing the sandbox. After
23+
the fix, the `|attr` filter no longer bypasses the environment's attribute
24+
lookup."""
25+
),
26+
coordinates="jinja2:3.1.5",
27+
references=(
28+
"https://github.com/advisories/GHSA-cpwx-vrp4-4pq7",
29+
"https://nvd.nist.gov/vuln/detail/CVE-2025-27516",
30+
),
31+
)
32+
33+
34+
@pytest.fixture(scope="session")
35+
def pip_audit_cryptography_issue():
36+
return security.Issue(
37+
cve="CVE-2024-12797",
38+
cwe="None",
39+
description=cleandoc(
40+
"""pyca / cryptography's wheels include a statically linked copy of
41+
OpenSSL. The versions of OpenSSL included in cryptography 42.0.0 - 44.0.0
42+
are vulnerable to a security issue. More details about the vulnerability
43+
itself can be found in https://openssl-library.org/news/secadv/20250211.txt.
44+
If you are building cryptography source(\"sdist\") then you are responsible
45+
for upgrading your copy of OpenSSL. Only users installing from wheels built
46+
by the cryptography project(i.e., those distributed on PyPI) need to update
47+
their cryptography versions."""
48+
),
49+
coordinates="cryptography:43.0.3",
50+
references=(
51+
"https://github.com/advisories/GHSA-79v4-65xg-pq4g",
52+
"https://nvd.nist.gov/vuln/detail/CVE-2024-12797",
53+
),
54+
)
55+
56+
57+
@pytest.fixture(scope="session")
58+
def pip_audit_report(pip_audit_jinja2_issue, pip_audit_cryptography_issue):
59+
jinja2_name, jinja2_version = pip_audit_jinja2_issue.coordinates.split(":")
60+
cryptography_name, cryptography_version = (
61+
pip_audit_cryptography_issue.coordinates.split(":")
62+
)
63+
return {
64+
"dependencies": [
65+
{
66+
"name": jinja2_name,
67+
"version": jinja2_version,
68+
"vulns": [
69+
{
70+
"id": "GHSA-cpwx-vrp4-4pq7",
71+
"fix_versions": ["3.1.6"],
72+
"aliases": [pip_audit_jinja2_issue.cve],
73+
"description": pip_audit_jinja2_issue.description,
74+
}
75+
],
76+
},
77+
{
78+
"name": cryptography_name,
79+
"version": cryptography_version,
80+
"vulns": [
81+
{
82+
"id": "GHSA-79v4-65xg-pq4g",
83+
"fix_versions": ["44.0.1"],
84+
"aliases": [pip_audit_cryptography_issue.cve],
85+
"description": pip_audit_cryptography_issue.description,
86+
}
87+
],
88+
},
89+
]
90+
}

test/unit/dependencies_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import json
2+
13
import pytest
24

35
from exasol.toolbox.nox._dependencies import (
6+
Audit,
47
Package,
58
_dependencies,
69
_normalize,
@@ -157,3 +160,24 @@ def test_packages_from_json(json, expected):
157160
def test_packages_to_markdown(dependencies, packages, expected):
158161
actual = _packages_to_markdown(dependencies, packages)
159162
assert actual == expected
163+
164+
165+
class TestFilterJsonForVulnerabilities:
166+
167+
@staticmethod
168+
def test_no_vulnerability_returns_empty_list():
169+
audit_dict = {
170+
"dependencies": [{"name": "alabaster", "version": "0.7.16", "vulns": []}]
171+
}
172+
audit_json = json.dumps(audit_dict).encode("utf-8")
173+
expected = {"dependencies": []}
174+
175+
actual = Audit._filter_json_for_vulnerabilities(audit_json)
176+
assert actual == expected
177+
178+
@staticmethod
179+
def test_vulnerabilities_returned_in_list(pip_audit_report):
180+
audit_json = json.dumps(pip_audit_report).encode("utf-8")
181+
182+
actual = Audit._filter_json_for_vulnerabilities(audit_json)
183+
assert actual == pip_audit_report

test/unit/security_test.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,60 @@ def test_from_json(json_file, expected):
462462
references=expected["references"],
463463
)
464464
assert list(actual) == [expected_issue]
465+
466+
467+
@pytest.mark.parametrize(
468+
"reference, expected",
469+
[
470+
pytest.param(
471+
"CVE-2025-27516",
472+
(
473+
["CVE-2025-27516"],
474+
[],
475+
["https://nvd.nist.gov/vuln/detail/CVE-2025-27516"],
476+
),
477+
id="CVE_identified_with_link",
478+
),
479+
pytest.param(
480+
"CWE-611",
481+
([], ["CWE-611"], ["https://cwe.mitre.org/data/definitions/611.html"]),
482+
id="CWE_identified_with_link",
483+
),
484+
pytest.param(
485+
"GHSA-cpwx-vrp4-4pq7",
486+
([], [], ["https://github.com/advisories/GHSA-cpwx-vrp4-4pq7"]),
487+
id="GHSA_link",
488+
),
489+
pytest.param(
490+
"PYSEC-2025-9",
491+
(
492+
[],
493+
[],
494+
[
495+
"https://github.com/pypa/advisory-database/blob/main/vulns/dummy/PYSEC-2025-9.yaml"
496+
],
497+
),
498+
id="PYSEC_link",
499+
),
500+
],
501+
)
502+
def test_identify_pypi_references(reference: str, expected):
503+
actual = security.identify_pypi_references([reference], package_name="dummy")
504+
assert actual == expected
505+
506+
507+
class TestFromPython:
508+
@staticmethod
509+
def test_no_vulnerability_returns_empty_list():
510+
actual = set(security.from_python("{}"))
511+
assert actual == set()
512+
513+
@staticmethod
514+
def test_convert_vulnerability_to_issue(
515+
pip_audit_report, pip_audit_jinja2_issue, pip_audit_cryptography_issue
516+
):
517+
audit_json = json.dumps(pip_audit_report)
518+
expected = {pip_audit_jinja2_issue, pip_audit_cryptography_issue}
519+
520+
actual = set(security.from_python(audit_json))
521+
assert actual == expected

0 commit comments

Comments
 (0)