diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9b86cf6a..016be819f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,18 @@ jobs: merge-gate: name: Merge Gate uses: ./.github/workflows/merge-gate.yml + with: + root-event: ${{ github.event_name }} secrets: inherit permissions: contents: read report: + # Job merge-gate requires manual approval for running the slow checks. If + # current workflow ci.yml is triggered by schedule, there is no manual + # interaction, manual approval will never be given, slow checks will not + # be executed, merge-gate will never terminate, and the report will never + # be called. If this were later changed, then we would want `report.yml` to create a `metrics.json`. name: Report needs: - merge-gate diff --git a/.github/workflows/merge-gate.yml b/.github/workflows/merge-gate.yml index 45c93c415..9e3d87547 100644 --- a/.github/workflows/merge-gate.yml +++ b/.github/workflows/merge-gate.yml @@ -2,6 +2,12 @@ name: Merge-Gate on: workflow_call: + inputs: + root-event: + description: GitHub event triggering the root workflow ci.yml + required: false + type: string + default: unknown jobs: run-fast-checks: @@ -15,12 +21,15 @@ jobs: needs: - run-fast-checks uses: ./.github/workflows/report.yml + with: + upload-metrics: false secrets: inherit permissions: contents: read approve-run-slow-tests: name: Approve Running Slow Tests? + if: ${{ inputs.root-event != 'schedule' }} runs-on: "ubuntu-24.04" permissions: contents: read diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 482647ee8..1a38c2c83 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -27,6 +27,8 @@ jobs: needs: - run-fast-checks uses: ./.github/workflows/report.yml + with: + upload-metrics: true secrets: inherit permissions: contents: read diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml index 922894e0f..32a71cd6e 100644 --- a/.github/workflows/report.yml +++ b/.github/workflows/report.yml @@ -2,6 +2,11 @@ name: Status Report on: workflow_call: + inputs: + upload-metrics: + description: Whether to upload file metrics.json as artifact + type: boolean + default: false jobs: @@ -50,6 +55,7 @@ jobs: run: poetry run -- nox -s project:report -- --format json | tee metrics.json - name: Upload Artifacts + if: ${{ inputs.upload-metrics }} id: upload-artifacts uses: actions/upload-artifact@v7 with: diff --git a/doc/changes/changes_0.15.0.md b/doc/changes/changes_0.15.0.md index 7188f56b9..f937252ec 100644 --- a/doc/changes/changes_0.15.0.md +++ b/doc/changes/changes_0.15.0.md @@ -24,5 +24,5 @@ ## 🔩 Internal -* Update depdency constraints -* Relock dependencies \ No newline at end of file +* Update dependency constraints +* Relock dependencies diff --git a/doc/changes/unreleased.md b/doc/changes/unreleased.md index 3c5fabec2..008c6a99d 100644 --- a/doc/changes/unreleased.md +++ b/doc/changes/unreleased.md @@ -11,6 +11,8 @@ The `report.yml` is also called after the `checks.yml` completes. This allows us to get linting, security, and unit test coverage before running the `slow-checks.yml`, as described in the [Pull Request description](https://exasol.github.io/python-toolbox/main/user_guide/features/github_workflows/index.html#pull-request). +This release also adds a `vulnerabilities:resolved` Nox session, which reports GitHub security issues resolved since the last release. + This release fixes a vulnerability by updating the `poetry.lock` file. | Name | Version | ID | Fix Versions | Updated to | @@ -19,6 +21,10 @@ This release fixes a vulnerability by updating the `poetry.lock` file. To ensure usage of secure packages, it is up to the user to similarly relock their dependencies. +## Features + +* #402: Created nox session `vulnerabilities:resolved` to report resolved GitHub security issues + ## Refactoring * #764: Updated `action/upload-pages-artifact` from v4 to [v5](https://github.com/actions/upload-pages-artifact/releases/tag/v5.0.0) diff --git a/doc/user_guide/features/managing_dependencies.rst b/doc/user_guide/features/managing_dependencies.rst index 4c636e170..cc4a349a7 100644 --- a/doc/user_guide/features/managing_dependencies.rst +++ b/doc/user_guide/features/managing_dependencies.rst @@ -1,12 +1,17 @@ -Managing dependencies -===================== +Managing Dependencies and Vulnerabilities +========================================= -+--------------------------+------------------+----------------------------------------+ -| Nox session | CI Usage | Action | -+==========================+==================+========================================+ -| ``dependency:licenses`` | ``report.yml`` | Uses ``pip-licenses`` to return | -| | | packages with their licenses | -+--------------------------+------------------+----------------------------------------+ -| ``dependency:audit`` | No | Uses ``pip-audit`` to return active | -| | | vulnerabilities in our dependencies | -+--------------------------+------------------+----------------------------------------+ ++------------------------------+----------------+-------------------------------------+ +| Nox session | CI Usage | Action | ++==============================+================+=====================================+ +| ``dependency:licenses`` | ``report.yml`` | Uses ``pip-licenses`` to return | +| | | packages with their licenses | ++------------------------------+----------------+-------------------------------------+ +| ``dependency:audit`` | No | Uses ``pip-audit`` to report active | +| | | vulnerabilities in our dependencies | ++------------------------------+----------------+-------------------------------------+ +| ``vulnerabilities:resolved`` | No | Uses ``pip-audit`` to report known | +| | | vulnerabilities in dependencies | +| | | that have been resolved in | +| | | comparison to the last release. | ++------------------------------+----------------+-------------------------------------+ diff --git a/exasol/toolbox/nox/_dependencies.py b/exasol/toolbox/nox/_dependencies.py index dc860875a..c6c64401e 100644 --- a/exasol/toolbox/nox/_dependencies.py +++ b/exasol/toolbox/nox/_dependencies.py @@ -9,17 +9,21 @@ from exasol.toolbox.util.dependencies.audit import ( PipAuditException, Vulnerabilities, + get_vulnerabilities, + get_vulnerabilities_from_latest_tag, ) from exasol.toolbox.util.dependencies.licenses import ( PackageLicenseReport, get_licenses, ) from exasol.toolbox.util.dependencies.poetry_dependencies import get_dependencies +from exasol.toolbox.util.dependencies.track_vulnerabilities import DependenciesAudit +from noxconfig import PROJECT_CONFIG @nox.session(name="dependency:licenses", python=False) def dependency_licenses(session: Session) -> None: - """Return the packages with their licenses""" + """Report licenses for all dependencies.""" dependencies = get_dependencies(working_directory=Path()) licenses = get_licenses() license_markdown = PackageLicenseReport( @@ -30,7 +34,7 @@ def dependency_licenses(session: Session) -> None: @nox.session(name="dependency:audit", python=False) def audit(session: Session) -> None: - """Check for known vulnerabilities""" + """Report known vulnerabilities.""" try: vulnerabilities = Vulnerabilities.load_from_pip_audit(working_directory=Path()) @@ -39,3 +43,14 @@ def audit(session: Session) -> None: security_issue_dict = vulnerabilities.security_issue_dict print(json.dumps(security_issue_dict, indent=2)) + + +@nox.session(name="vulnerabilities:resolved", python=False) +def report_resolved_vulnerabilities(session: Session) -> None: + """Report resolved vulnerabilities in dependencies.""" + path = PROJECT_CONFIG.root_path + audit = DependenciesAudit( + previous_vulnerabilities=get_vulnerabilities_from_latest_tag(path), + current_vulnerabilities=get_vulnerabilities(path), + ) + print(audit.report_resolved_vulnerabilities()) diff --git a/exasol/toolbox/templates/github/workflows/cd.yml b/exasol/toolbox/templates/github/workflows/cd.yml index a1004456d..23176904f 100644 --- a/exasol/toolbox/templates/github/workflows/cd.yml +++ b/exasol/toolbox/templates/github/workflows/cd.yml @@ -15,7 +15,7 @@ jobs: build-and-publish: needs: - - check-release-tag + - check-release-tag name: Build & Publish uses: ./.github/workflows/build-and-publish.yml permissions: @@ -25,7 +25,7 @@ jobs: publish-docs: needs: - - build-and-publish + - build-and-publish name: Publish Documentation uses: ./.github/workflows/gh-pages.yml permissions: diff --git a/exasol/toolbox/templates/github/workflows/ci.yml b/exasol/toolbox/templates/github/workflows/ci.yml index 505f10b55..f58bbfe3c 100644 --- a/exasol/toolbox/templates/github/workflows/ci.yml +++ b/exasol/toolbox/templates/github/workflows/ci.yml @@ -11,14 +11,21 @@ jobs: merge-gate: name: Merge Gate uses: ./.github/workflows/merge-gate.yml + with: + root-event: ${{ github.event_name }} secrets: inherit permissions: contents: read report: + # Job merge-gate requires manual approval for running the slow checks. If + # current workflow ci.yml is triggered by schedule, there is no manual + # interaction, manual approval will never be given, slow checks will not + # be executed, merge-gate will never terminate, and the report will never + # be called. name: Report needs: - - merge-gate + - merge-gate uses: ./.github/workflows/report.yml secrets: inherit permissions: diff --git a/exasol/toolbox/templates/github/workflows/gh-pages.yml b/exasol/toolbox/templates/github/workflows/gh-pages.yml index de637ab1c..e0b3b856b 100644 --- a/exasol/toolbox/templates/github/workflows/gh-pages.yml +++ b/exasol/toolbox/templates/github/workflows/gh-pages.yml @@ -38,7 +38,7 @@ jobs: deploy-documentation: needs: - - build-documentation + - build-documentation permissions: contents: read pages: write diff --git a/exasol/toolbox/templates/github/workflows/merge-gate.yml b/exasol/toolbox/templates/github/workflows/merge-gate.yml index 53d128a10..40cfedbee 100644 --- a/exasol/toolbox/templates/github/workflows/merge-gate.yml +++ b/exasol/toolbox/templates/github/workflows/merge-gate.yml @@ -2,6 +2,12 @@ name: Merge-Gate on: workflow_call: + inputs: + root-event: + description: GitHub event triggering the root workflow ci.yml + required: false + type: string + default: unknown jobs: run-fast-checks: @@ -15,12 +21,15 @@ jobs: needs: - run-fast-checks uses: ./.github/workflows/report.yml + with: + upload-metrics: false secrets: inherit permissions: contents: read approve-run-slow-tests: name: Approve Running Slow Tests? + if: ${{ inputs.root-event != 'schedule' }} runs-on: "(( os_version ))" permissions: contents: read @@ -35,7 +44,7 @@ jobs: run-slow-checks: name: Slow Checks needs: - - approve-run-slow-tests + - approve-run-slow-tests uses: ./.github/workflows/slow-checks.yml secrets: inherit permissions: @@ -49,8 +58,8 @@ jobs: contents: read # If you need additional jobs to be part of the merge gate, add them below needs: - - run-fast-checks - - run-slow-checks + - run-fast-checks + - run-slow-checks # Each job requires a step, so we added this dummy step. steps: - name: Approve diff --git a/exasol/toolbox/templates/github/workflows/pr-merge.yml b/exasol/toolbox/templates/github/workflows/pr-merge.yml index 8397b92d8..1a38c2c83 100644 --- a/exasol/toolbox/templates/github/workflows/pr-merge.yml +++ b/exasol/toolbox/templates/github/workflows/pr-merge.yml @@ -25,8 +25,10 @@ jobs: report: needs: - - run-fast-checks + - run-fast-checks uses: ./.github/workflows/report.yml + with: + upload-metrics: true secrets: inherit permissions: contents: read diff --git a/exasol/toolbox/templates/github/workflows/report.yml b/exasol/toolbox/templates/github/workflows/report.yml index 12539c152..883fefab8 100644 --- a/exasol/toolbox/templates/github/workflows/report.yml +++ b/exasol/toolbox/templates/github/workflows/report.yml @@ -2,6 +2,11 @@ name: Status Report on: workflow_call: + inputs: + upload-metrics: + description: Whether to upload file metrics.json as artifact + type: boolean + default: false jobs: diff --git a/exasol/toolbox/util/dependencies/audit.py b/exasol/toolbox/util/dependencies/audit.py index 53fb67352..1983fc4f1 100644 --- a/exasol/toolbox/util/dependencies/audit.py +++ b/exasol/toolbox/util/dependencies/audit.py @@ -177,7 +177,11 @@ def audit_poetry_files(working_directory: Path) -> str: tmpdir = Path(path) (tmpdir / requirements_txt).write_text(output.stdout) - command = ["pip-audit", "-r", requirements_txt, "-f", "json"] + # CLI option `--disable-pip` skips dependency resolution in pip. The + # option can be used with hashed requirements files (which is the case + # here) to avoid `pip-audit` installing an isolated environment and + # speed up the audit significantly. + command = ["pip-audit", "--disable-pip", "-r", requirements_txt, "-f", "json"] output = subprocess.run( command, capture_output=True, @@ -239,6 +243,6 @@ def get_vulnerabilities(working_directory: Path) -> list[Vulnerability]: ).vulnerabilities -def get_vulnerabilities_from_latest_tag(root_path: Path): +def get_vulnerabilities_from_latest_tag(root_path: Path) -> list[Vulnerability]: with poetry_files_from_latest_tag(root_path=root_path) as tmp_dir: return get_vulnerabilities(working_directory=tmp_dir) diff --git a/exasol/toolbox/util/dependencies/track_vulnerabilities.py b/exasol/toolbox/util/dependencies/track_vulnerabilities.py index d9d663797..db074ba3e 100644 --- a/exasol/toolbox/util/dependencies/track_vulnerabilities.py +++ b/exasol/toolbox/util/dependencies/track_vulnerabilities.py @@ -1,3 +1,5 @@ +from inspect import cleandoc + from pydantic import ( BaseModel, ConfigDict, @@ -6,37 +8,77 @@ from exasol.toolbox.util.dependencies.audit import Vulnerability -class ResolvedVulnerabilities(BaseModel): - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - previous_vulnerabilities: list[Vulnerability] - current_vulnerabilities: list[Vulnerability] +class VulnerabilityMatcher: + def __init__(self, current_vulnerabilities: list[Vulnerability]): + # Dict of current vulnerabilities: + # * keys: package names + # * values: set of each vulnerability's references + self._references = { + v.package.name: set(v.references) for v in current_vulnerabilities + } - def _is_resolved(self, previous_vuln: Vulnerability): + def is_resolved(self, vuln: Vulnerability) -> bool: """ Detects if a vulnerability has been resolved. - A vulnerability is said to be resolved when it cannot be found - in the `current_vulnerabilities`. In order to see if a vulnerability - is still present, its id and aliases are compared to values in the - `current_vulnerabilities`. It is hoped that if an ID were to change - that this would still be present in the aliases. + A vulnerability is said to be resolved when it cannot be found in + the `current_vulnerabilities`. + + Vulnerabilities are matched by the name of the affected package + and the vulnerability's "references" (set of ID and aliases). + + The vulnerability is rated as "resolved" only if there is not + intersection between previous and current references. + + This hopefully compensates in case a different ID is assigned to a + vulnerability. """ - previous_vuln_set = {previous_vuln.id, *previous_vuln.aliases} - for current_vuln in self.current_vulnerabilities: - if previous_vuln.package.name == current_vuln.package.name: - current_vuln_id_set = {current_vuln.id, *current_vuln.aliases} - if previous_vuln_set.intersection(current_vuln_id_set): - return False - return True + refs = set(vuln.references) + current = self._references.get(vuln.package.name, set()) + return not refs.intersection(current) + + +class DependenciesAudit(BaseModel): + """ + Compare previous vulnerabilities to current ones and create a report + about the resolved vulnerabilities. + """ + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + previous_vulnerabilities: list[Vulnerability] + current_vulnerabilities: list[Vulnerability] @property - def resolutions(self) -> list[Vulnerability]: + def resolved_vulnerabilities(self) -> list[Vulnerability]: """ - Return resolved vulnerabilities + Return the list of resolved vulnerabilities. """ - resolved_vulnerabilities = [] - for previous_vuln in self.previous_vulnerabilities: - if self._is_resolved(previous_vuln): - resolved_vulnerabilities.append(previous_vuln) - return resolved_vulnerabilities + matcher = VulnerabilityMatcher(self.current_vulnerabilities) + return [ + vuln for vuln in self.previous_vulnerabilities if matcher.is_resolved(vuln) + ] + + def report_resolved_vulnerabilities(self) -> str: + if not (resolved := self.resolved_vulnerabilities): + return "" + header = cleandoc(""" + ## Fixed Vulnerabilities + + This release fixes vulnerabilities by updating dependencies: + + | Dependency | Vulnerability | Affected | Fixed in | + |------------|---------------|----------|----------| + """) + + def formatted(vuln: Vulnerability) -> str: + columns = ( + vuln.package.name, + vuln.id, + str(vuln.package.version), + vuln.fix_versions[0], + ) + return f'| {" | ".join(columns)} |' + + body = "\n".join(formatted(v) for v in resolved) + return f"{header}\n{body}" diff --git a/test/unit/config_test.py b/test/unit/config_test.py index 385ab2aad..035f7a6b0 100644 --- a/test/unit/config_test.py +++ b/test/unit/config_test.py @@ -1,5 +1,6 @@ from collections.abc import Iterable from pathlib import Path +from unittest.mock import Mock import pytest from pydantic_core._pydantic_core import ValidationError @@ -9,6 +10,7 @@ BaseConfig, DependencyManager, valid_version_string, + warnings, ) from exasol.toolbox.nox.plugin import hookimpl from exasol.toolbox.util.version import Version @@ -202,9 +204,19 @@ def test_raises_exception_without_hook(test_project_config_factory): class TestDependencyManager: @staticmethod - @pytest.mark.parametrize("version", ["2.1.4", "2.3.0", "2.9.9"]) - def test_works_as_expected(version): + @pytest.mark.parametrize( + "version, expected_warning", + [ + ("2.1.4", None), + ("2.3.0", None), + ("2.9.9", "Poetry version exceeds last tested version"), + ], + ) + def test_works_as_expected(version, expected_warning, monkeypatch): + monkeypatch.setattr(warnings, "warn", Mock()) DependencyManager(name="poetry", version=version) + if expected_warning: + assert expected_warning in warnings.warn.call_args.args[0] @staticmethod def test_raises_exception_when_not_supported_name(): diff --git a/test/unit/nox/_dependencies_test.py b/test/unit/nox/_dependencies_test.py index b841d46d3..b3525a7ec 100644 --- a/test/unit/nox/_dependencies_test.py +++ b/test/unit/nox/_dependencies_test.py @@ -1,18 +1,26 @@ -from unittest import mock +from unittest.mock import Mock -from exasol.toolbox.nox._dependencies import audit +from exasol.toolbox.nox import _dependencies from exasol.toolbox.util.dependencies.audit import Vulnerabilities -class TestAudit: - @staticmethod - def test_works_as_expected_with_mock(nox_session, sample_vulnerability, capsys): - with mock.patch( - "exasol.toolbox.nox._dependencies.Vulnerabilities" - ) as mock_class: - mock_class.load_from_pip_audit.return_value = Vulnerabilities( - vulnerabilities=[sample_vulnerability.vulnerability] - ) - audit(nox_session) +def test_audit(monkeypatch, nox_session, sample_vulnerability, capsys): + monkeypatch.setattr(_dependencies, "Vulnerabilities", Mock()) + _dependencies.Vulnerabilities.load_from_pip_audit.return_value = Vulnerabilities( + vulnerabilities=[sample_vulnerability.vulnerability] + ) + _dependencies.audit(nox_session) + assert capsys.readouterr().out == sample_vulnerability.nox_dependencies_audit - assert capsys.readouterr().out == sample_vulnerability.nox_dependencies_audit + +def test_report_resolved_vulnerabilities( + monkeypatch, nox_session, capsys, sample_vulnerability +): + monkeypatch.setattr( + _dependencies, + "get_vulnerabilities_from_latest_tag", + Mock(return_value=[sample_vulnerability.vulnerability]), + ) + monkeypatch.setattr(_dependencies, "get_vulnerabilities", Mock(return_value=[])) + _dependencies.report_resolved_vulnerabilities(nox_session) + assert "| jinja2 | CVE-2025-27516 | 3.1.5 | 3.1.6 |" in capsys.readouterr().out diff --git a/test/unit/util/dependencies/track_vulnerabilities_test.py b/test/unit/util/dependencies/track_vulnerabilities_test.py index 1dd584ac9..5ea3911f1 100644 --- a/test/unit/util/dependencies/track_vulnerabilities_test.py +++ b/test/unit/util/dependencies/track_vulnerabilities_test.py @@ -1,55 +1,80 @@ +import pytest + +from exasol.toolbox.util.dependencies.audit import Vulnerability from exasol.toolbox.util.dependencies.track_vulnerabilities import ( - ResolvedVulnerabilities, + DependenciesAudit, + VulnerabilityMatcher, ) -class TestResolvedVulnerabilities: - def test_vulnerability_present_for_previous_and_current(self, sample_vulnerability): +@pytest.fixture +def flipped_id_vulnerability(sample_vulnerability) -> Vulnerability: + """ + Returns an instance of SampleVulnerability equal to + sample_vulnerability() but with ID and first alias flipped to verify + handling of vulnerabilities with changed ID. + """ + + other = sample_vulnerability + vuln_entry = { + "aliases": [other.cve_id], + "id": other.vulnerability_id, + "fix_versions": other.vulnerability.fix_versions, + "description": other.description, + } + return Vulnerability.from_audit_entry( + package_name=other.package_name, + version=other.version, + vuln_entry=vuln_entry, + ) + + +class TestVulnerabilityMatcher: + def test_not_resolved(self, sample_vulnerability): vuln = sample_vulnerability.vulnerability - resolved = ResolvedVulnerabilities( - previous_vulnerabilities=[vuln], current_vulnerabilities=[vuln] - ) - assert resolved._is_resolved(vuln) is False + matcher = VulnerabilityMatcher(current_vulnerabilities=[vuln]) + assert not matcher.is_resolved(vuln) - def test_vulnerability_present_for_previous_and_current_with_different_id( - self, sample_vulnerability + def test_changed_id_not_resolved( + self, sample_vulnerability, flipped_id_vulnerability ): - vuln2 = sample_vulnerability.vulnerability.__dict__.copy() - vuln2["version"] = sample_vulnerability.version - # flipping aliases & id to ensure can match across types - vuln2["aliases"] = [sample_vulnerability.vulnerability_id] - vuln2["id"] = sample_vulnerability.cve_id + """ + Simulate a vulnerability to be still present, but it's ID having + changed over time. - resolved = ResolvedVulnerabilities( - previous_vulnerabilities=[sample_vulnerability.vulnerability], - current_vulnerabilities=[vuln2], + The test verifies that the vulnerability (using the original ID) is + still matched as "not resolved". + """ + + matcher = VulnerabilityMatcher( + current_vulnerabilities=[flipped_id_vulnerability] ) - assert resolved._is_resolved(sample_vulnerability.vulnerability) is False + assert not matcher.is_resolved(sample_vulnerability.vulnerability) - def test_vulnerability_in_previous_resolved_in_current(self, sample_vulnerability): + def test_resolved(self, sample_vulnerability): vuln = sample_vulnerability.vulnerability - resolved = ResolvedVulnerabilities( - previous_vulnerabilities=[vuln], current_vulnerabilities=[] - ) - assert resolved._is_resolved(vuln) is True + matcher = VulnerabilityMatcher(current_vulnerabilities=[]) + assert matcher.is_resolved(vuln) + +class TestDependenciesAudit: def test_no_vulnerabilities_for_previous_and_current(self): - resolved = ResolvedVulnerabilities( + audit = DependenciesAudit( previous_vulnerabilities=[], current_vulnerabilities=[] ) - assert resolved.resolutions == [] + assert audit.resolved_vulnerabilities == [] def test_vulnerability_in_current_but_not_present(self, sample_vulnerability): - resolved = ResolvedVulnerabilities( + audit = DependenciesAudit( previous_vulnerabilities=[], current_vulnerabilities=[sample_vulnerability.vulnerability], ) # only care about "resolved" vulnerabilities, not new ones - assert resolved.resolutions == [] + assert audit.resolved_vulnerabilities == [] def test_resolved_vulnerabilities(self, sample_vulnerability): - resolved = ResolvedVulnerabilities( + audit = DependenciesAudit( previous_vulnerabilities=[sample_vulnerability.vulnerability], current_vulnerabilities=[], ) - assert resolved.resolutions == [sample_vulnerability.vulnerability] + assert audit.resolved_vulnerabilities == [sample_vulnerability.vulnerability]