Files
strix/tests/test_vulnerability_update_tool.py
T

234 lines
7.7 KiB
Python

"""Tests for amending filed vulnerability reports."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
import pytest
from strix.report.state import ReportState, set_global_report_state
from strix.tools.reporting.tool import _do_update
if TYPE_CHECKING:
from pathlib import Path
_LOW_CVSS = {
"attack_vector": "N",
"attack_complexity": "H",
"privileges_required": "H",
"user_interaction": "R",
"scope": "U",
"confidentiality": "L",
"integrity": "N",
"availability": "N",
}
_CRITICAL_CVSS = {
"attack_vector": "N",
"attack_complexity": "L",
"privileges_required": "N",
"user_interaction": "N",
"scope": "U",
"confidentiality": "H",
"integrity": "H",
"availability": "H",
}
@pytest.fixture(autouse=True)
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ReportState:
monkeypatch.chdir(tmp_path)
state = ReportState(run_name="test-run")
set_global_report_state(state)
state.add_vulnerability_report(
title="Unsafe redirect",
severity="low",
description="The redirect accepts attacker input.",
impact="Limited redirect manipulation.",
target="https://app.example.com",
technical_analysis="The handler does not validate the destination.",
poc_description="Send a crafted redirect value.",
poc_script_code="GET /redirect?url=https://example.net",
remediation_steps="Validate redirect destinations.",
evidence="The response contains the attacker-controlled destination.",
assumptions="Assumes a victim follows the link.",
fix_effort="medium",
cvss=3.1,
cvss_breakdown=_LOW_CVSS,
endpoint="/redirect",
method="GET",
cwe="CWE-601",
code_locations=[
{
"file": "src/redirect.py",
"start_line": 10,
"end_line": 12,
"snippet": "return redirect(url)",
},
],
)
return state
async def test_update_rejects_unknown_report_id() -> None:
result = await _do_update(
report_id="vuln-9999",
update_reason="The validation pass proved broader impact.",
impact="Broader impact.",
)
assert result["success"] is False
assert "vuln-9999" in result["error"]
assert result["valid_report_ids"] == ["vuln-0001"]
async def test_update_requires_an_amendable_field() -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The validation pass found no new field to amend.",
)
assert result == {
"success": False,
"error": "At least one amendable field must be supplied",
}
async def test_update_requires_nonempty_reason() -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason=" ",
impact="The impact is broader.",
)
assert result == {"success": False, "error": "update_reason cannot be empty"}
async def test_update_changes_impact_only(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The confirmed chain exposes account data.",
impact="The chain exposes account data.",
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["impact"] == "The chain exposes account data."
assert report["severity"] == "low"
assert report["cvss"] == 3.1
assert report["update_history"][0]["fields_changed"] == ["impact"]
assert "previous_severity" not in report["update_history"][0]
async def test_cvss_update_recomputes_score_and_severity(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The exploit chain proves full account compromise.",
cvss_breakdown=_CRITICAL_CVSS,
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["severity"] == "critical"
assert report["cvss"] == 9.8
history = report["update_history"][0]
assert history["fields_changed"] == ["cvss_breakdown", "cvss", "severity"]
assert history["previous_severity"] == "low"
assert history["previous_cvss_score"] == 3.1
async def test_update_history_is_append_only(report_state: ReportState) -> None:
await _do_update(
report_id="vuln-0001",
update_reason="The chain proves account access.",
impact="Account access is possible.",
)
await _do_update(
report_id="vuln-0001",
update_reason="The second proof confirms persistent access.",
evidence="The second proof confirms persistent access.",
)
history = report_state.vulnerability_reports[0]["update_history"]
assert len(history) == 2
assert history[0]["update_reason"] == "The chain proves account access."
assert history[1]["update_reason"] == "The second proof confirms persistent access."
assert all("description" not in entry for entry in history)
async def test_update_callback_fires(report_state: ReportState) -> None:
updated: list[dict[str, Any]] = []
report_state.vulnerability_updated_callback = updated.append
await _do_update(
report_id="vuln-0001",
update_reason="The new proof confirms data exposure.",
evidence="The new proof confirms data exposure.",
)
assert len(updated) == 1
assert updated[0] is report_state.vulnerability_reports[0]
async def test_update_persists_all_report_artifacts(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The chain proves account takeover.",
description="The redirect reaches the account takeover flow.",
impact="An attacker can take over an account.",
cvss_breakdown=_CRITICAL_CVSS,
)
assert result["success"] is True
run_dir = report_state.get_run_dir()
finding_md = (run_dir / "vulnerabilities" / "vuln-0001.md").read_text(encoding="utf-8")
findings = json.loads((run_dir / "vulnerabilities.json").read_text(encoding="utf-8"))
sarif = json.loads((run_dir / "findings.sarif").read_text(encoding="utf-8"))
sarif_finding = sarif["runs"][0]["results"][0]
assert "An attacker can take over an account." in finding_md
assert findings[0]["impact"] == "An attacker can take over an account."
assert findings[0]["severity"] == "critical"
assert sarif_finding["properties"]["strix"]["impact"] == (
"An attacker can take over an account."
)
assert sarif_finding["properties"]["strix"]["severity"] == "critical"
@pytest.mark.parametrize(
"code_locations",
[[], [{"file": "../invalid.py", "start_line": 1}]],
)
async def test_update_rejects_empty_code_locations(
report_state: ReportState,
code_locations: list[dict[str, Any]],
) -> None:
original_locations = report_state.vulnerability_reports[0]["code_locations"]
result = await _do_update(
report_id="vuln-0001",
update_reason="The source review did not provide a valid location.",
code_locations=code_locations,
)
assert result["success"] is False
assert any("code_locations" in error for error in result["errors"])
assert report_state.vulnerability_reports[0]["code_locations"] == original_locations
async def test_update_fails_without_global_report_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("strix.report.state._global_report_state", None)
result = await _do_update(
report_id="vuln-0001",
update_reason="The new proof confirms broader impact.",
impact="Broader impact.",
)
assert result == {"success": False, "error": "Report state is unavailable"}