Skip to content

Commit 914301e

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

File tree

6 files changed

+293
-5
lines changed

6 files changed

+293
-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: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
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
15+
from typing import Tuple
916

1017
import nox
1118
import tomlkit
@@ -211,8 +218,87 @@ def _normalize_package_name(name: str) -> str:
211218
return template.format(heading=heading(), rows=rows)
212219

213220

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

217303

218304
@nox.session(name="dependency:licenses", python=False)
@@ -227,4 +313,4 @@ def dependency_licenses(session: Session) -> None:
227313
@nox.session(name="dependency:audit", python=False)
228314
def audit(session: Session) -> None:
229315
"""Check for known vulnerabilities"""
230-
_audit(session)
316+
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,10 @@
1616
from functools import partial
1717
from inspect import cleandoc
1818
from pathlib import Path
19-
from typing import Tuple
19+
from typing import (
20+
List,
21+
Tuple,
22+
)
2023

2124
import typer
2225

@@ -102,6 +105,59 @@ def from_maven(report: str) -> Iterable[Issue]:
102105
)
103106

104107

108+
class VulnerabilitySource(str, Enum):
109+
CVE = "CVE"
110+
CWE = "CWE"
111+
GHSA = "GHSA"
112+
PYSEC = "PYSEC"
113+
114+
def get_link(self, package: str, vuln_id: str) -> str:
115+
if self.value == VulnerabilitySource.CVE:
116+
return f"https://nvd.nist.gov/vuln/detail/{vuln_id}"
117+
if self.value == VulnerabilitySource.CWE:
118+
cwe_id = vuln_id.upper().replace(f"{VulnerabilitySource.CWE}-", "")
119+
return f"https://cwe.mitre.org/data/definitions/{cwe_id}.html"
120+
if self.value == VulnerabilitySource.GHSA:
121+
return f"https://github.com/advisories/{vuln_id}"
122+
return f"https://github.com/pypa/advisory-database/blob/main/vulns/{package}/{vuln_id}.yaml"
123+
124+
125+
def identify_pypi_references(references: List[str], package_name: 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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from inspect import cleandoc
2+
3+
import pytest
4+
from exasol.toolbox.tools import security
5+
6+
7+
@pytest.fixture(scope="session")
8+
def pip_audit_issue():
9+
return security.Issue(
10+
cve="CVE-2025-27516",
11+
cwe="None",
12+
description=cleandoc(
13+
"""An oversight in how the Jinja sandboxed environment
14+
interacts with the `|attr` filter allows an attacker
15+
that controls the content of a template to execute
16+
arbitrary Python code. To exploit the vulnerability,
17+
an attacker needs to control the content of a template.
18+
Whether that is the case depends on the type of
19+
application using Jinja. This vulnerability impacts
20+
users of applications which execute untrusted templates.
21+
Jinja's sandbox does catch calls to `str.format` and
22+
ensures they don't escape the sandbox. However, it's
23+
possible to use the `|attr` filter to get a reference to
24+
a string's plain format method, bypassing the sandbox.
25+
After the fix, the `|attr` filter no longer bypasses
26+
the environment's attribute lookup.
27+
"""
28+
),
29+
coordinates="jinja2:3.1.5",
30+
references=(
31+
"https://github.com/advisories/GHSA-cpwx-vrp4-4pq7",
32+
"https://nvd.nist.gov/vuln/detail/CVE-2025-27516",
33+
),
34+
)
35+
36+
37+
@pytest.fixture(scope="session")
38+
def pip_audit_report(pip_audit_issue):
39+
name, version = pip_audit_issue.coordinates.split(":")
40+
return {
41+
"dependencies": [
42+
{
43+
"name": name,
44+
"version": version,
45+
"vulns": [
46+
{
47+
"id": "GHSA-cpwx-vrp4-4pq7",
48+
"fix_versions": ["3.1.6"],
49+
"aliases": [pip_audit_issue.cve],
50+
"description": pip_audit_issue.description,
51+
}
52+
],
53+
}
54+
]
55+
}

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_vulnerability_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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,62 @@ 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(pip_audit_report, pip_audit_issue):
515+
audit_json = json.dumps(pip_audit_report)
516+
517+
actual = set(security.from_python(audit_json)).pop()
518+
519+
assert actual.cve == pip_audit_issue.cve
520+
assert actual.cwe == pip_audit_issue.cwe
521+
assert actual.description == pip_audit_issue.description
522+
assert actual.coordinates == pip_audit_issue.coordinates
523+
assert actual.references == pip_audit_issue.references

0 commit comments

Comments
 (0)