Skip to content

Commit a1734f5

Browse files
committed
Add nox task dependency:update for workflows
1 parent d4ffc3f commit a1734f5

File tree

5 files changed

+559
-81
lines changed

5 files changed

+559
-81
lines changed

doc/changes/unreleased.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
## ✨ Features
44

5-
* [#73](https://github.com/exasol/python-toolbox/issues/73): Added nox target for auditing work spaces in regard to known vulnerabilities
5+
* [#73](https://github.com/exasol/python-toolbox/issues/73): Added Nox task 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`
88
* [#372](https://github.com/exasol/python-toolbox/issues/372): Added conversion from pip-audit JSON to expected GitHub Issue format
9+
* [#382](https://github.com/exasol/python-toolbox/issues/382) Added Nox task to update vulnerable dependencies
910

1011
## ⚒️ Refactorings
1112
* [#388](https://github.com/exasol/python-toolbox/issues/388): Switch GitHub workflows to use pinned OS version

exasol/toolbox/nox/_dependencies.py

Lines changed: 255 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22

33
import argparse
44
import json
5+
import re
56
import subprocess
67
import tempfile
7-
from dataclasses import dataclass
8+
from contextlib import contextmanager
9+
from dataclasses import (
10+
dataclass,
11+
field,
12+
)
813
from inspect import cleandoc
914
from json import loads
1015
from pathlib import Path
16+
from subprocess import CompletedProcess
1117

1218
import nox
1319
import tomlkit
1420
from nox import Session
1521

22+
from exasol.toolbox.security import GitHubVulnerabilityIssue
23+
1624

1725
@dataclass(frozen=True)
1826
class Package:
@@ -256,13 +264,15 @@ def _parse_args(session) -> argparse.Namespace:
256264
)
257265
return parser.parse_args(args=session.posargs)
258266

259-
def run(self, session: Session) -> None:
260-
args = self._parse_args(session)
261-
262-
command = ["poetry", "run", "pip-audit", "-f", "json"]
267+
def audit(self) -> tuple[dict, CompletedProcess]:
268+
command = ("poetry", "run", "pip-audit", "-f", "json")
263269
output = subprocess.run(command, capture_output=True)
264-
265270
audit_json = self._filter_json_for_vulnerabilities(output.stdout)
271+
return audit_json, output
272+
273+
def run(self, session: Session) -> None:
274+
args = self._parse_args(session)
275+
audit_json, output = self.audit()
266276
if args.output:
267277
with open(args.output, "w") as file:
268278
json.dump(audit_json, file)
@@ -271,8 +281,230 @@ def run(self, session: Session) -> None:
271281

272282
if output.returncode != 0:
273283
session.warn(
274-
f"Command {' '.join(command)} failed with exit code {output.returncode}",
284+
f"Command {' '.join(output.args)} failed with exit code {output.returncode}",
285+
)
286+
287+
288+
@dataclass(frozen=True)
289+
class PackageVersion:
290+
name: str
291+
version: str
292+
293+
294+
@dataclass
295+
class PackageVersionTracker:
296+
"""
297+
Tracks direct dependencies for package versions before & after updates
298+
299+
Assumption:
300+
- The dependency ranges in the pyproject.toml allows users to often update
301+
transitive dependencies on their own. It is, therefore, more important for us to
302+
track the changes of direct dependencies and, if present, the resolution of both
303+
vulnerabilities for direct and transitive dependencies.
304+
"""
305+
306+
before_env: set[PackageVersion] = field(default_factory=set)
307+
after_env: set[PackageVersion] = field(default_factory=set)
308+
309+
@staticmethod
310+
def _obtain_version_set() -> set[PackageVersion]:
311+
def _get_package_version(line: str) -> PackageVersion:
312+
pattern = r"\s+(\d+(?:\.\d+)*)\s+"
313+
groups = re.split(pattern, line)
314+
return PackageVersion(name=groups[0], version=groups[1])
315+
316+
command = ("poetry", "show", "--top-level")
317+
result = subprocess.run(command, capture_output=True, check=True)
318+
return {
319+
_get_package_version(line)
320+
for line in result.stdout.decode("utf-8").splitlines()
321+
}
322+
323+
@property
324+
def changes(self) -> tuple:
325+
before_update_dict = {pkg.name: pkg for pkg in self.before_env}
326+
after_update_dict = {pkg.name: pkg for pkg in self.after_env}
327+
328+
def _get_change_str(pkg_name: str) -> str | None:
329+
if pkg_name not in after_update_dict.keys():
330+
entry = before_update_dict[pkg_name]
331+
return f"* Removed {entry.name} ({entry.version})"
332+
if pkg_name not in before_update_dict.keys():
333+
entry = after_update_dict[pkg_name]
334+
return f"* Added {entry.name} ({entry.version})"
335+
before_entry = before_update_dict[pkg_name]
336+
after_entry = after_update_dict[pkg_name]
337+
if before_entry.version != after_entry.version:
338+
return f"* Updated {pkg_name} ({before_entry.version}{after_entry.version})"
339+
return None
340+
341+
all_packages = before_update_dict.keys() | after_update_dict.keys()
342+
return tuple(
343+
change_str
344+
for pkg_name in all_packages
345+
if (change_str := _get_change_str(pkg_name))
346+
)
347+
348+
@property
349+
def packages(self) -> set[str]:
350+
return {pkg.name for pkg in self.before_env}
351+
352+
def __enter__(self) -> PackageVersionTracker:
353+
self.before_env = self._obtain_version_set()
354+
return self
355+
356+
def __exit__(self, exc_type, exc_val, exc_tb):
357+
self.after_env = self._obtain_version_set()
358+
359+
360+
@contextmanager
361+
def managed_file(file_obj: argparse.FileType):
362+
"""Context manager to manage a file provided by argparse"""
363+
yield file_obj
364+
365+
366+
@dataclass
367+
class VulnerabilityTracker:
368+
"""Tracks the resolution of GitHubVulnerabilityIssues before & after updates"""
369+
370+
to_resolve: set[GitHubVulnerabilityIssue] = field(default_factory=set)
371+
resolved: set[GitHubVulnerabilityIssue] = field(default_factory=set)
372+
not_resolved: set[GitHubVulnerabilityIssue] = field(default_factory=set)
373+
374+
def __init__(self, vulnerability_issues: argparse.FileType | None):
375+
self.to_resolve: set[GitHubVulnerabilityIssue] = self._set_to_resolve(
376+
vulnerability_issues
377+
)
378+
379+
@staticmethod
380+
def _set_to_resolve(
381+
vulnerability_issues: argparse.FileType | None,
382+
) -> set[GitHubVulnerabilityIssue]:
383+
if not vulnerability_issues:
384+
return set()
385+
with managed_file(vulnerability_issues) as f:
386+
lines = f.readlines()
387+
return set(GitHubVulnerabilityIssue.extract_from_jsonl(lines))
388+
389+
def _split_resolution_status(self) -> None:
390+
to_resolve_by_cve = {vuln.cve: vuln for vuln in self.to_resolve}
391+
cves_to_resolve = to_resolve_by_cve.keys()
392+
audit_json, _ = Audit().audit()
393+
394+
not_resolved: list = []
395+
for dependency in audit_json["dependencies"]:
396+
for v in dependency["vulns"]:
397+
vuln_ids = set([v["id"]] + v["aliases"])
398+
if remaining_cves := (cves_to_resolve & vuln_ids):
399+
not_resolved.extend(
400+
to_resolve_by_cve[cve] for cve in remaining_cves
401+
)
402+
self.not_resolved = set(not_resolved)
403+
self.resolved = self.to_resolve - self.not_resolved
404+
405+
def get_packages(self) -> set[str]:
406+
return {vuln.coordinates.split(":")[0] for vuln in self.to_resolve}
407+
408+
@property
409+
def issues_not_resolved(self) -> tuple[str, ...]:
410+
return tuple(
411+
f"* Did NOT resolve {vuln.issue_url} ({vuln.cve})"
412+
for vuln in self.not_resolved
413+
)
414+
415+
@property
416+
def issues_resolved(self) -> tuple[str, ...]:
417+
return tuple(
418+
f"* Closes {vuln.issue_url} ({vuln.cve})" for vuln in self.resolved
419+
)
420+
421+
@property
422+
def summary(self) -> tuple[str, ...]:
423+
return tuple(
424+
cleandoc(
425+
f"""{vuln.cve} in dependency `{vuln.coordinates}`\n {vuln.description}
426+
"""
275427
)
428+
for vuln in self.resolved
429+
)
430+
431+
@property
432+
def vulnerabilities_resolved(self) -> tuple[str, ...]:
433+
def get_issue_number(issue_url: str) -> str | None:
434+
pattern = r"/issues/(\d+)$"
435+
match = re.search(pattern, issue_url)
436+
return match.group(1) if match else None
437+
438+
return tuple(
439+
f"* #{get_issue_number(vuln.issue_url)} Fixed vulnerability {vuln.cve} in `{vuln.coordinates}`"
440+
for vuln in self.resolved
441+
)
442+
443+
def __enter__(self) -> VulnerabilityTracker:
444+
return self
445+
446+
def __exit__(self, exc_type, exc_val, exc_tb):
447+
self._split_resolution_status()
448+
449+
450+
@dataclass(frozen=True)
451+
class DependencyChanges:
452+
package_changes: tuple[str, ...]
453+
issues_resolved: tuple[str, ...]
454+
issues_not_resolved: tuple[str, ...]
455+
vulnerabilities_resolved: tuple[str, ...]
456+
vulnerabilities_resolved_summary: tuple[str, ...]
457+
458+
459+
class DependencyUpdate:
460+
"""Update dependencies"""
461+
462+
@staticmethod
463+
def _parse_args(session) -> argparse.Namespace:
464+
parser = argparse.ArgumentParser(
465+
description="Updates dependencies & returns changes",
466+
usage="nox -s dependency:audit -- -- [options]",
467+
)
468+
parser.add_argument(
469+
"-v",
470+
"--vulnerability-issues",
471+
type=argparse.FileType("r"),
472+
default=None,
473+
help="JSONL of vulnerabilities (of type `GitHubVulnerabilityIssue`)",
474+
)
475+
return parser.parse_args(args=session.posargs)
476+
477+
@staticmethod
478+
def _perform_basic_vulnerability_update(
479+
pkg_tracker: PackageVersionTracker, vuln_tracker: VulnerabilityTracker
480+
) -> None:
481+
vuln_packages = vuln_tracker.get_packages()
482+
483+
# vulnerabilities of direct dependencies require a pyproject.toml update
484+
vuln_direct_dependencies = vuln_packages.intersection(pkg_tracker.packages)
485+
if vuln_direct_dependencies:
486+
command = ("poetry", "up") + tuple(vuln_direct_dependencies)
487+
subprocess.run(command, capture_output=True)
488+
489+
command = ("poetry", "update") + tuple(vuln_packages)
490+
subprocess.run(command, capture_output=True)
491+
492+
def run(self, session: Session) -> DependencyChanges:
493+
"""Update the dependencies associated with GitHubVulnerabilityIssues"""
494+
args = self._parse_args(session)
495+
with PackageVersionTracker() as pkg_tracker:
496+
with VulnerabilityTracker(args.vulnerability_issues) as vuln_tracker:
497+
self._perform_basic_vulnerability_update(
498+
pkg_tracker=pkg_tracker, vuln_tracker=vuln_tracker
499+
)
500+
501+
return DependencyChanges(
502+
package_changes=pkg_tracker.changes,
503+
issues_resolved=vuln_tracker.issues_resolved,
504+
issues_not_resolved=vuln_tracker.issues_not_resolved,
505+
vulnerabilities_resolved=vuln_tracker.vulnerabilities_resolved,
506+
vulnerabilities_resolved_summary=vuln_tracker.summary,
507+
)
276508

277509

278510
@nox.session(name="dependency:licenses", python=False)
@@ -288,3 +520,19 @@ def dependency_licenses(session: Session) -> None:
288520
def audit(session: Session) -> None:
289521
"""Check for known vulnerabilities"""
290522
Audit().run(session=session)
523+
524+
525+
@nox.session(name="dependency:update", python=False)
526+
def update(session: Session) -> None:
527+
"""Updates dependencies & returns changes"""
528+
dependency_changes = DependencyUpdate().run(session)
529+
print("Resolved issues")
530+
print(*dependency_changes.issues_resolved, sep="\n")
531+
print("\nNot resolved issues")
532+
print(*dependency_changes.issues_not_resolved, sep="\n")
533+
print("\nSummary")
534+
print(*dependency_changes.vulnerabilities_resolved_summary, sep="\n")
535+
print("\nSecurity fixes")
536+
print(*dependency_changes.vulnerabilities_resolved, sep="\n")
537+
print("\nDependencies")
538+
print(*dependency_changes.package_changes, sep="\n")

test/unit/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ def pip_audit_cryptography_issue():
5454
)
5555

5656

57+
@pytest.fixture(scope="session")
58+
def pip_audit_cryptography_github_issue(pip_audit_cryptography_issue):
59+
return security.GitHubVulnerabilityIssue.from_vulnerability_issue(
60+
issue=pip_audit_cryptography_issue,
61+
issue_url="https://github.com/exasol/<repo-name>/issues/394",
62+
)
63+
64+
5765
@pytest.fixture(scope="session")
5866
def pip_audit_report(pip_audit_jinja2_issue, pip_audit_cryptography_issue):
5967
jinja2_name, jinja2_version = pip_audit_jinja2_issue.coordinates.split(":")

0 commit comments

Comments
 (0)