mirror of
https://github.com/usestrix/strix.git
synced 2026-10-11 03:37:54 +00:00
Every renderer of the final report already titles each section, so the heading the docstring asked for printed twice. Describe the fields as section bodies and drop the example headings.
2250 lines
81 KiB
Python
2250 lines
81 KiB
Python
"""Tests for restored report fields, SCA tool, and report formatting guidance."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any, cast
|
|
|
|
import pytest
|
|
from agents.tool_context import ToolContext
|
|
|
|
from strix.report.dedupe import (
|
|
_check_dependency_duplicate,
|
|
_prepare_report_for_comparison,
|
|
check_duplicate,
|
|
)
|
|
from strix.report.state import ReportRollbackError, ReportState, set_global_report_state
|
|
from strix.report.writer import write_run_record
|
|
from strix.tools.finish.tool import finish_scan
|
|
from strix.tools.reporting import tool as reporting_tool
|
|
from strix.tools.reporting.tool import (
|
|
_do_create,
|
|
_do_create_dependency,
|
|
_do_delete,
|
|
_do_update,
|
|
_normalize_http_exchange_ids,
|
|
_verify_http_exchange_ids,
|
|
create_dependency_report,
|
|
create_vulnerability_report,
|
|
delete_vulnerability_report,
|
|
update_vulnerability_report,
|
|
)
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
_CVSS = {
|
|
"attack_vector": "N",
|
|
"attack_complexity": "L",
|
|
"privileges_required": "N",
|
|
"user_interaction": "N",
|
|
"scope": "U",
|
|
"confidentiality": "H",
|
|
"integrity": "H",
|
|
"availability": "H",
|
|
}
|
|
|
|
|
|
_DEP_CONTEXT = {
|
|
"attack_vector": "N",
|
|
"attack_complexity": "L",
|
|
"privileges_required": "N",
|
|
"user_interaction": "N",
|
|
"scope": "U",
|
|
"confidentiality": "N",
|
|
"integrity": "N",
|
|
"availability": "H",
|
|
}
|
|
|
|
_DEP_CONTEXT_VECTOR = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
|
|
|
|
_DEP_EVIDENCE = "src/render.ts:14 imports the package."
|
|
|
|
_DEP_REASONING = "Only scripts/import.py reaches the sink, so the impact is availability only."
|
|
|
|
|
|
@pytest.fixture
|
|
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)
|
|
return state
|
|
|
|
|
|
def test_record_mcp_connection_status_persists_and_dedupes(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The roster lands on the run record so run.json carries it for the viewer,
|
|
and an unchanged re-write is a no-op (it does not re-save)."""
|
|
roster = [{"name": "local_fs", "provider": None, "tool_count": 3, "dead": False}]
|
|
report_state.record_mcp_connection_status(roster)
|
|
assert report_state.run_record["mcp_connection_status"] == roster
|
|
|
|
saves = 0
|
|
original_save = report_state.save_run_data
|
|
|
|
def _counting_save(*args: Any, **kwargs: Any) -> None:
|
|
nonlocal saves
|
|
saves += 1
|
|
original_save(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(report_state, "save_run_data", _counting_save)
|
|
report_state.record_mcp_connection_status(roster)
|
|
assert saves == 0, "an identical roster must not trigger another save"
|
|
|
|
report_state.record_mcp_connection_status(
|
|
[{"name": "local_fs", "provider": None, "tool_count": 3, "dead": True}]
|
|
)
|
|
assert saves == 1
|
|
assert report_state.run_record["mcp_connection_status"][0]["dead"] is True
|
|
|
|
|
|
async def test_create_report_persists_new_fields(report_state: ReportState) -> None:
|
|
result = await _do_create(
|
|
title="Reflected XSS in search",
|
|
description="q reflects unencoded input.",
|
|
impact="Session theft.",
|
|
target="https://app.example.com",
|
|
technical_analysis="Input interpolated into HTML.",
|
|
poc_description="1. open /search?q=<payload>",
|
|
poc_script_code="GET /search?q=<script>alert(1)</script>",
|
|
remediation_steps="Context-encode output.",
|
|
evidence="Response echoes the payload verbatim.",
|
|
assumptions="Assumes a victim opens a crafted link.",
|
|
counterevidence="No output encoding or CSP observed on this response.",
|
|
confidence="HIGH",
|
|
severity_change_conditions="A strict CSP would lower the severity.",
|
|
fix_effort="LOW",
|
|
cvss_breakdown=_CVSS,
|
|
endpoint="/search",
|
|
method="GET",
|
|
cve=None,
|
|
cwe="CWE-79",
|
|
code_locations=None,
|
|
http_exchange_ids=["1042", "1042", "1088"],
|
|
fix_pr_body="## Fix\nEncode output.",
|
|
)
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["evidence"] == "Response echoes the payload verbatim."
|
|
assert report["assumptions"] == "Assumes a victim opens a crafted link."
|
|
assert report["fix_effort"] == "low"
|
|
assert report["fix_pr_body"] == "## Fix\nEncode output."
|
|
assert report["finding_class"] == "dynamic"
|
|
assert report["counterevidence"] == "No output encoding or CSP observed on this response."
|
|
assert report["confidence"] == "high"
|
|
assert report["severity_change_conditions"] == "A strict CSP would lower the severity."
|
|
assert report["http_exchange_ids"] == ["1042", "1088"]
|
|
|
|
|
|
def test_create_report_does_not_commit_when_callback_fails(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
def fail_persistence(_report: dict[str, Any]) -> None:
|
|
raise RuntimeError("persistence failed")
|
|
|
|
report_state.vulnerability_found_callback = fail_persistence
|
|
|
|
with pytest.raises(RuntimeError, match="persistence failed"):
|
|
report_state.add_vulnerability_report(
|
|
title="Unstored finding",
|
|
severity="high",
|
|
http_exchange_ids=["1042"],
|
|
)
|
|
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
def test_failed_revision_keeps_old_evidence_and_can_be_retried(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
report_id = report_state.add_vulnerability_report(
|
|
title="Original finding", severity="high", http_exchange_ids=["1042"]
|
|
)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
|
|
def fail_persistence(revised: dict[str, Any]) -> None:
|
|
assert revised["http_exchange_ids"] == ["1088"]
|
|
assert report_state.vulnerability_reports[0] == original
|
|
raise RuntimeError("persistence failed")
|
|
|
|
report_state.vulnerability_updated_callback = fail_persistence
|
|
changes = {"title": "Revised finding", "http_exchange_ids": ["1088"]}
|
|
with pytest.raises(RuntimeError, match="persistence failed"):
|
|
report_state.update_vulnerability_report(report_id, changes)
|
|
assert report_state.vulnerability_reports[0] == original
|
|
|
|
report_state.vulnerability_updated_callback = None
|
|
revised = report_state.update_vulnerability_report(report_id, changes)
|
|
assert revised is not None
|
|
assert revised["http_exchange_ids"] == ["1088"]
|
|
assert len(revised["update_history"]) == 1
|
|
|
|
|
|
async def test_create_report_requires_evidence_and_assumptions(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create(
|
|
title="X",
|
|
description="d",
|
|
impact="i",
|
|
target="t",
|
|
technical_analysis="ta",
|
|
poc_description="p",
|
|
poc_script_code="c",
|
|
remediation_steps="r",
|
|
evidence=" ",
|
|
assumptions="",
|
|
counterevidence="none found",
|
|
confidence="high",
|
|
severity_change_conditions="n/a",
|
|
fix_effort="low",
|
|
cvss_breakdown=_CVSS,
|
|
endpoint=None,
|
|
method=None,
|
|
cve=None,
|
|
cwe=None,
|
|
code_locations=None,
|
|
)
|
|
assert result["success"] is False
|
|
joined = " ".join(result["errors"])
|
|
assert "Evidence" in joined
|
|
assert "Assumptions" in joined
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_create_report_rejects_invalid_fix_effort(report_state: ReportState) -> None:
|
|
result = await _do_create(
|
|
title="X",
|
|
description="d",
|
|
impact="i",
|
|
target="t",
|
|
technical_analysis="ta",
|
|
poc_description="p",
|
|
poc_script_code="c",
|
|
remediation_steps="r",
|
|
evidence="e",
|
|
assumptions="a",
|
|
counterevidence="none found",
|
|
confidence="high",
|
|
severity_change_conditions="n/a",
|
|
fix_effort="enormous",
|
|
cvss_breakdown=_CVSS,
|
|
endpoint=None,
|
|
method=None,
|
|
cve=None,
|
|
cwe=None,
|
|
code_locations=None,
|
|
)
|
|
assert result["success"] is False
|
|
assert any("fix_effort" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def _create_with(report_state: ReportState, **overrides: object) -> dict[str, Any]:
|
|
kwargs: dict[str, object] = {
|
|
"title": "X",
|
|
"description": "d",
|
|
"impact": "i",
|
|
"target": "t",
|
|
"technical_analysis": "ta",
|
|
"poc_description": "p",
|
|
"poc_script_code": "c",
|
|
"remediation_steps": "r",
|
|
"evidence": "e",
|
|
"assumptions": "a",
|
|
"counterevidence": "No guard found on this path.",
|
|
"confidence": "high",
|
|
"severity_change_conditions": "Proof of internet exposure would raise it.",
|
|
"fix_effort": "low",
|
|
"cvss_breakdown": _CVSS,
|
|
"endpoint": None,
|
|
"method": None,
|
|
"cve": None,
|
|
"cwe": None,
|
|
"code_locations": None,
|
|
}
|
|
kwargs.update(overrides)
|
|
assert report_state is not None
|
|
return await _do_create(**kwargs) # type: ignore[arg-type]
|
|
|
|
|
|
async def test_create_report_requires_counterevidence(report_state: ReportState) -> None:
|
|
result = await _create_with(report_state, counterevidence=" ")
|
|
assert result["success"] is False
|
|
assert any("Counterevidence" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_create_report_requires_severity_change_conditions(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _create_with(report_state, severity_change_conditions="")
|
|
assert result["success"] is False
|
|
assert any("severity_change_conditions" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_create_report_rejects_invalid_confidence(report_state: ReportState) -> None:
|
|
result = await _create_with(report_state, confidence="pretty sure")
|
|
assert result["success"] is False
|
|
assert any("confidence" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_create_report_requires_rationale_when_confidence_not_high(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _create_with(report_state, confidence="medium")
|
|
assert result["success"] is False
|
|
assert any("confidence_rationale" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_create_report_accepts_medium_confidence_with_rationale(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _create_with(
|
|
report_state,
|
|
confidence="medium",
|
|
confidence_rationale="Static-only trace; could not stand up the service.",
|
|
)
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["confidence"] == "medium"
|
|
assert report["confidence_rationale"] == "Static-only trace; could not stand up the service."
|
|
|
|
|
|
async def test_dependency_report_sets_class_and_metadata(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Arbitrary command execution.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="4.17.21",
|
|
cwe="CWE-94",
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fix_effort="trivial",
|
|
reachability="imported",
|
|
reachability_evidence=_DEP_EVIDENCE,
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["finding_class"] == "dependency_cve"
|
|
assert report["cve"] == "CVE-2021-23337"
|
|
assert report["severity"] == "high"
|
|
assert report["evidence"].startswith(
|
|
"**Advisory evidence:** `CVE-2021-23337` applies to `lodash` "
|
|
"at installed version `4.17.20`. The advisory is fixed in `4.17.21`."
|
|
)
|
|
assert report["dependency_metadata"] == {
|
|
"package_name": "lodash",
|
|
"installed_version": "4.17.20",
|
|
"advisory_cvss": 7.2,
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "package-lock.json",
|
|
"fixed_version": "4.17.21",
|
|
"reachability": "imported",
|
|
"reachability_evidence": _DEP_EVIDENCE,
|
|
"contextual_cvss_breakdown": _DEP_CONTEXT,
|
|
"contextual_cvss_score": pytest.approx(7.5, abs=0.05),
|
|
"contextual_cvss_vector": _DEP_CONTEXT_VECTOR,
|
|
"contextual_cvss_reasoning": _DEP_REASONING,
|
|
}
|
|
|
|
|
|
async def test_dependency_report_records_transitive_chain(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2022-24999 in qs 6.10.2",
|
|
description="Prototype pollution in qs parsing.",
|
|
target="repo/package.json",
|
|
cve="CVE-2022-24999",
|
|
package_name="qs",
|
|
installed_version="6.10.2",
|
|
impact="Denial of service via crafted query strings.",
|
|
remediation_steps="Upgrade express to 4.18.2, which resolves qs 6.11.0.",
|
|
assumptions="qs parses all incoming query strings by default.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="6.10.3",
|
|
cwe="CWE-1321",
|
|
advisory_cvss=7.5,
|
|
technical_analysis=None,
|
|
fix_effort="trivial",
|
|
introduced_by="express@4.18.1",
|
|
dependency_path="express@4.18.1 > body-parser@1.20.0 > qs@6.10.2",
|
|
reachability="imported",
|
|
reachability_evidence=_DEP_EVIDENCE,
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["dependency_metadata"]["introduced_by"] == "express@4.18.1"
|
|
assert (
|
|
report["dependency_metadata"]["dependency_path"]
|
|
== "express@4.18.1 > body-parser@1.20.0 > qs@6.10.2"
|
|
)
|
|
assert (
|
|
"**Transitive dependency:** introduced by the direct dependency `express@4.18.1`."
|
|
in report["evidence"]
|
|
)
|
|
assert (
|
|
"**Dependency chain:** `express@4.18.1 > body-parser@1.20.0 > qs@6.10.2`"
|
|
in report["evidence"]
|
|
)
|
|
|
|
|
|
async def test_dependency_report_omits_blank_chain_fields(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Impact.",
|
|
remediation_steps="Upgrade.",
|
|
assumptions="Assumptions.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version=None,
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="trivial",
|
|
introduced_by=" ",
|
|
dependency_path=None,
|
|
reachability="imported",
|
|
reachability_evidence=_DEP_EVIDENCE,
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert "introduced_by" not in report["dependency_metadata"]
|
|
assert "dependency_path" not in report["dependency_metadata"]
|
|
|
|
|
|
async def test_dependency_report_with_no_contextual_impact_is_info(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Low-impact dependency advisory.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package is included in deployed builds.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=0.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability="not_imported",
|
|
reachability_evidence="No file imports the package.",
|
|
contextual_cvss_breakdown={**_DEP_CONTEXT, "availability": "N"},
|
|
contextual_cvss_reasoning="No application code imports the package.",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["severity"] == "info"
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["severity"] == "info"
|
|
assert report["cvss"] == 0.0
|
|
|
|
|
|
async def test_dependency_report_records_reachability(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Command injection where template is used.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="4.17.21",
|
|
cwe=None,
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability="vulnerable_symbol_used",
|
|
reachability_evidence="src/render.ts:14 calls `_.template()`.",
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["dependency_metadata"]["reachability"] == "vulnerable_symbol_used"
|
|
assert (
|
|
report["dependency_metadata"]["reachability_evidence"]
|
|
== "src/render.ts:14 calls `_.template()`."
|
|
)
|
|
assert "**Usage analysis:**" in report["evidence"]
|
|
assert "not a proof of exploitability or of safety" in report["evidence"]
|
|
# The level must never influence the rating — that comes from the contextual
|
|
# breakdown, or from advisory_cvss when no breakdown applies.
|
|
assert report["severity"] == "high"
|
|
|
|
|
|
async def test_dependency_report_rejects_reachability_without_evidence(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Impact.",
|
|
remediation_steps="Upgrade.",
|
|
assumptions="Assumptions.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability="not_imported",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("reachability_evidence is required" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_dependency_report_rejects_unknown_reachability_level(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Impact.",
|
|
remediation_steps="Upgrade.",
|
|
assumptions="Assumptions.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability="not_exploitable",
|
|
reachability_evidence="vibes",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("Invalid reachability" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_dependency_report_records_unknown_reachability(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Impact.",
|
|
remediation_steps="Upgrade.",
|
|
assumptions="Analysis was inconclusive.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability_evidence="Grep for the package found no import.",
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
|
|
assert result["success"] is True, result
|
|
metadata = report_state.vulnerability_reports[0]["dependency_metadata"]
|
|
assert metadata["reachability"] == "unknown"
|
|
assert metadata["reachability_evidence"] == "Grep for the package found no import."
|
|
|
|
|
|
async def test_dependency_report_requires_advisory_cvss(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Some impact.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package ships in deployed builds.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=None,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("advisory_cvss is required" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_dependency_report_dedupe_candidate_includes_dependency_metadata(
|
|
report_state: ReportState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
async def fake_check_duplicate(
|
|
candidate: dict[str, object],
|
|
existing: list[dict[str, object]],
|
|
) -> dict[str, object]:
|
|
captured["candidate"] = candidate
|
|
captured["existing"] = existing
|
|
return {"is_duplicate": False}
|
|
|
|
monkeypatch.setattr("strix.report.dedupe.check_duplicate", fake_check_duplicate)
|
|
report_state.vulnerability_reports.append(
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in other 1.0.0",
|
|
"severity": "low",
|
|
"timestamp": "2026-01-01 00:00:00 UTC",
|
|
"description": "Existing dependency finding.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "other",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
)
|
|
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Low-impact dependency advisory.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package is included in deployed builds.",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=0.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
reachability="imported",
|
|
reachability_evidence=_DEP_EVIDENCE,
|
|
contextual_cvss_breakdown=_DEP_CONTEXT,
|
|
contextual_cvss_reasoning=_DEP_REASONING,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert captured["candidate"] == {
|
|
"title": "CVE-2024-0001 in sample 1.0.0",
|
|
"description": "Published advisory affects the pinned version.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"advisory_cvss": 0.0,
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "package-lock.json",
|
|
"fixed_version": "1.0.1",
|
|
"reachability": "imported",
|
|
"reachability_evidence": _DEP_EVIDENCE,
|
|
"contextual_cvss_breakdown": _DEP_CONTEXT,
|
|
"contextual_cvss_score": pytest.approx(7.5, abs=0.05),
|
|
"contextual_cvss_vector": _DEP_CONTEXT_VECTOR,
|
|
"contextual_cvss_reasoning": _DEP_REASONING,
|
|
},
|
|
"technical_analysis": None,
|
|
}
|
|
|
|
|
|
async def test_dependency_report_rejects_bad_cve(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="bad",
|
|
description="d",
|
|
target="t",
|
|
cve="not-a-cve",
|
|
package_name="pkg",
|
|
installed_version="1.0.0",
|
|
impact="i",
|
|
remediation_steps="r",
|
|
assumptions="a",
|
|
package_ecosystem="npm",
|
|
manifest_path="package-lock.json",
|
|
fixed_version=None,
|
|
cwe=None,
|
|
advisory_cvss=None,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
)
|
|
assert result["success"] is False
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_dependency_report_requires_ecosystem(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Low-impact dependency advisory.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package is included in deployed builds.",
|
|
package_ecosystem="",
|
|
manifest_path="package-lock.json",
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=0.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("package_ecosystem" in error for error in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_dependency_report_requires_manifest_path(report_state: ReportState) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Low-impact dependency advisory.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package is included in deployed builds.",
|
|
package_ecosystem="npm",
|
|
manifest_path=None,
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("manifest_path is required" in error for error in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_path",
|
|
["/etc/passwd", "..\\pom.xml", "services/../pom.xml", "./package.json", "C:/repo/pom.xml"],
|
|
)
|
|
async def test_dependency_report_rejects_unsafe_manifest_path(
|
|
report_state: ReportState, bad_path: str
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2024-0001 in sample 1.0.0",
|
|
description="Published advisory affects the pinned version.",
|
|
target="repo/package.json",
|
|
cve="CVE-2024-0001",
|
|
package_name="sample",
|
|
installed_version="1.0.0",
|
|
impact="Low-impact dependency advisory.",
|
|
remediation_steps="Upgrade to 1.0.1.",
|
|
assumptions="Assumes the package is included in deployed builds.",
|
|
package_ecosystem="npm",
|
|
manifest_path=bad_path,
|
|
fixed_version="1.0.1",
|
|
cwe=None,
|
|
advisory_cvss=5.0,
|
|
technical_analysis=None,
|
|
fix_effort="low",
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("manifest_path" in error for error in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
def test_dedupe_comparison_preserves_cve_identity() -> None:
|
|
cleaned = _prepare_report_for_comparison(
|
|
{
|
|
"title": "CVE-2021-23337 in lodash",
|
|
"description": "Pinned vulnerable dependency.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2021-23337",
|
|
"dependency_metadata": {"package_name": "lodash"},
|
|
}
|
|
)
|
|
|
|
assert cleaned["cve"] == "CVE-2021-23337"
|
|
assert cleaned["dependency_metadata"] == {"package_name": "lodash"}
|
|
|
|
|
|
async def test_dependency_dedupe_uses_cve_package_identity() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in other",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "other",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Similar advisory prose.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
result = await check_duplicate(candidate, existing)
|
|
|
|
assert result["is_duplicate"] is False
|
|
assert result["confidence"] == 1.0
|
|
|
|
|
|
async def test_dependency_dedupe_rejects_same_cve_package_identity() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in sample",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample with different prose",
|
|
"description": "Different prose for the same dependency identity.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
result = await check_duplicate(candidate, existing)
|
|
|
|
assert result["is_duplicate"] is True
|
|
assert result["duplicate_id"] == "vuln-0001"
|
|
assert result["confidence"] == 1.0
|
|
|
|
|
|
async def test_dependency_dedupe_keeps_findings_from_distinct_manifests() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in sample",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "services/api/package-lock.json",
|
|
},
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample (web)",
|
|
"description": "Same advisory observed in a second workspace.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "services/web/package-lock.json",
|
|
},
|
|
}
|
|
|
|
result = await check_duplicate(candidate, existing)
|
|
|
|
assert result["is_duplicate"] is False
|
|
assert result["confidence"] == 1.0
|
|
|
|
|
|
async def test_dependency_dedupe_rejects_same_manifest_identity() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in sample",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "services/api/package-lock.json",
|
|
},
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample re-reported",
|
|
"description": "Same advisory, same manifest.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
"package_ecosystem": "npm",
|
|
"manifest_path": "services/api/package-lock.json",
|
|
},
|
|
}
|
|
|
|
result = await check_duplicate(candidate, existing)
|
|
|
|
assert result["is_duplicate"] is True
|
|
assert result["duplicate_id"] == "vuln-0001"
|
|
|
|
|
|
async def test_dependency_dedupe_detects_legacy_same_cve_package() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in npm sample package",
|
|
"description": "Legacy dependency finding without structured metadata.",
|
|
"cve": "CVE-2024-0001",
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Different prose for the same dependency identity.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
result = await check_duplicate(candidate, existing)
|
|
|
|
assert result["is_duplicate"] is True
|
|
assert result["duplicate_id"] == "vuln-0001"
|
|
assert result["confidence"] == 1.0
|
|
|
|
|
|
def test_dependency_dedupe_defers_unclear_legacy_same_cve() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 dependency finding",
|
|
"description": "Legacy dependency finding without package identity.",
|
|
"cve": "CVE-2024-0001",
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Candidate dependency finding.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
assert _check_dependency_duplicate(candidate, existing) is None
|
|
|
|
|
|
def test_dependency_dedupe_defers_legacy_package_substring_match() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in sample-package",
|
|
"description": "Legacy dependency finding for a different package.",
|
|
"cve": "CVE-2024-0001",
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Candidate dependency finding.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
assert _check_dependency_duplicate(candidate, existing) is None
|
|
|
|
|
|
def test_dependency_dedupe_defers_legacy_ecosystem_mismatch() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in npm sample",
|
|
"description": "Legacy dependency finding for a different ecosystem.",
|
|
"cve": "CVE-2024-0001",
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Candidate dependency finding.",
|
|
"target": "repo/requirements.txt",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "pypi",
|
|
},
|
|
}
|
|
|
|
assert _check_dependency_duplicate(candidate, existing) is None
|
|
|
|
|
|
def test_dependency_dedupe_matches_structured_missing_ecosystem() -> None:
|
|
existing = [
|
|
{
|
|
"id": "vuln-0001",
|
|
"title": "CVE-2024-0001 in sample",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.0",
|
|
},
|
|
}
|
|
]
|
|
candidate = {
|
|
"title": "CVE-2024-0001 in sample",
|
|
"description": "Candidate dependency finding.",
|
|
"target": "repo/package.json",
|
|
"cve": "CVE-2024-0001",
|
|
"dependency_metadata": {
|
|
"package_name": "sample",
|
|
"installed_version": "1.0.1",
|
|
"package_ecosystem": "npm",
|
|
},
|
|
}
|
|
|
|
result = _check_dependency_duplicate(candidate, existing)
|
|
|
|
assert result is not None
|
|
assert result["is_duplicate"] is True
|
|
assert result["duplicate_id"] == "vuln-0001"
|
|
|
|
|
|
def test_tool_descriptions_include_formatting_guidance() -> None:
|
|
vuln_desc = create_vulnerability_report.description
|
|
assert "markdown" in vuln_desc.lower()
|
|
assert "fenced code" in vuln_desc.lower()
|
|
|
|
finish_desc = finish_scan.description
|
|
assert "markdown" in finish_desc.lower()
|
|
assert "section body" in finish_desc.lower()
|
|
assert "do not start a field with a heading" in finish_desc.lower()
|
|
assert "\n # Executive Summary" not in finish_desc
|
|
|
|
dep_desc = create_dependency_report.description
|
|
assert "cve" in dep_desc.lower()
|
|
assert "reachab" in dep_desc.lower()
|
|
|
|
|
|
def test_git_blame_hint_is_a_trailing_note() -> None:
|
|
desc = create_vulnerability_report.description
|
|
assert desc.count("blame") == 1
|
|
tail = desc[desc.index("Nice to have:") :]
|
|
assert "git blame" in tail
|
|
assert "technical_analysis" in tail
|
|
assert "Example" not in tail
|
|
assert "blame" not in update_vulnerability_report.description
|
|
|
|
|
|
def test_vuln_tool_exposes_new_params() -> None:
|
|
props = create_vulnerability_report.params_json_schema["properties"]
|
|
for field in (
|
|
"evidence",
|
|
"assumptions",
|
|
"fix_effort",
|
|
"fix_pr_body",
|
|
"http_exchange_ids",
|
|
):
|
|
assert field in props
|
|
|
|
dep_props = create_dependency_report.params_json_schema["properties"]
|
|
for field in ("package_name", "installed_version", "cve", "advisory_cvss"):
|
|
assert field in dep_props
|
|
for field in ("reachability", "reachability_evidence", "manifest_path"):
|
|
assert field in dep_props
|
|
dep_required = create_dependency_report.params_json_schema["required"]
|
|
assert "package_ecosystem" in dep_required
|
|
assert "advisory_cvss" in dep_required
|
|
|
|
|
|
_FIX_LOCATION = {
|
|
"file": "app/views.py",
|
|
"start_line": 10,
|
|
"end_line": 12,
|
|
"fix_before": 'query = f"SELECT * FROM t WHERE id={uid}"',
|
|
"fix_after": 'query = "SELECT * FROM t WHERE id=%s"',
|
|
}
|
|
|
|
_INFO_LOCATION = {
|
|
"file": "app/views.py",
|
|
"start_line": 10,
|
|
"end_line": 12,
|
|
"snippet": 'query = f"SELECT * FROM t WHERE id={uid}"',
|
|
}
|
|
|
|
|
|
async def test_fix_after_requires_verification(report_state: ReportState) -> None:
|
|
result = await _create_with(report_state, code_locations=[_FIX_LOCATION])
|
|
assert result["success"] is False
|
|
assert any("fix_verification" in e for e in result["errors"])
|
|
assert not report_state.vulnerability_reports
|
|
|
|
|
|
async def test_fix_after_with_verification_persists(report_state: ReportState) -> None:
|
|
verification = (
|
|
"Re-ran the PoC against the patched handler: the payload is now bound as a "
|
|
"parameter and returns no extra rows. Checked the two sibling call sites of "
|
|
"the same helper and the admin export path; both already parameterized. "
|
|
"Legitimate numeric ids still resolve and the 404 path is unchanged. "
|
|
"Ran the focused view tests and ruff."
|
|
)
|
|
result = await _create_with(
|
|
report_state,
|
|
code_locations=[_FIX_LOCATION],
|
|
fix_verification=verification,
|
|
)
|
|
assert result["success"] is True
|
|
assert report_state.vulnerability_reports[0]["fix_verification"] == verification
|
|
|
|
|
|
async def test_informational_location_needs_no_verification(report_state: ReportState) -> None:
|
|
result = await _create_with(report_state, code_locations=[_INFO_LOCATION])
|
|
assert result["success"] is True
|
|
assert "fix_verification" not in report_state.vulnerability_reports[0]
|
|
|
|
|
|
def test_vuln_tool_exposes_fix_verification() -> None:
|
|
assert "fix_verification" in create_vulnerability_report.params_json_schema["properties"]
|
|
|
|
|
|
def test_dep_tool_exposes_contextual_cvss_params() -> None:
|
|
dep_props = create_dependency_report.params_json_schema["properties"]
|
|
for field in (
|
|
"contextual_cvss_breakdown",
|
|
"contextual_cvss_reasoning",
|
|
):
|
|
assert field in dep_props
|
|
assert "source-to-sink" in dep_props["contextual_cvss_breakdown"]["description"].lower()
|
|
assert "source-to-sink" in dep_props["reachability_evidence"]["description"].lower()
|
|
assert "file:line" in dep_props["contextual_cvss_reasoning"]["description"].lower()
|
|
|
|
|
|
_CONTEXTUAL_BREAKDOWN = {
|
|
"attack_vector": "L",
|
|
"attack_complexity": "H",
|
|
"privileges_required": "H",
|
|
"user_interaction": "N",
|
|
"scope": "U",
|
|
"confidentiality": "L",
|
|
"integrity": "L",
|
|
"availability": "N",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_report_computes_contextual_cvss(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Arbitrary command execution.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fixed_version="4.17.21",
|
|
cwe="CWE-94",
|
|
fix_effort="trivial",
|
|
manifest_path="package-lock.json",
|
|
reachability="vulnerable_symbol_used",
|
|
reachability_evidence="scripts/import.py:88 calls `_.template()`.",
|
|
contextual_cvss_breakdown=_CONTEXTUAL_BREAKDOWN,
|
|
contextual_cvss_reasoning="Only scripts/import.py reaches the sink.",
|
|
)
|
|
assert result["success"] is True, result
|
|
report = report_state.vulnerability_reports[0]
|
|
metadata = report["dependency_metadata"]
|
|
assert metadata["advisory_cvss"] == 7.2
|
|
assert metadata["contextual_cvss_breakdown"] == _CONTEXTUAL_BREAKDOWN
|
|
assert metadata["contextual_cvss_vector"] == ("CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N")
|
|
assert metadata["contextual_cvss_score"] == pytest.approx(3.0, abs=0.05)
|
|
assert metadata["contextual_cvss_reasoning"] == "Only scripts/import.py reaches the sink."
|
|
# The contextual rating determines the finding's score/severity, exactly
|
|
# like a normal finding's cvss_breakdown.
|
|
assert report["cvss"] == metadata["contextual_cvss_score"]
|
|
assert report["severity"] == "low"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_report_requires_contextual_breakdown(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Arbitrary command execution.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fixed_version="4.17.21",
|
|
cwe="CWE-94",
|
|
fix_effort="trivial",
|
|
manifest_path="package-lock.json",
|
|
reachability="imported",
|
|
reachability_evidence=_DEP_EVIDENCE,
|
|
)
|
|
assert result["success"] is False
|
|
assert any("contextual_cvss_breakdown is required" in error for error in result["errors"])
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_report_rejects_incomplete_contextual_breakdown(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Arbitrary command execution.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fixed_version="4.17.21",
|
|
cwe="CWE-94",
|
|
fix_effort="trivial",
|
|
manifest_path="package-lock.json",
|
|
contextual_cvss_breakdown={"attack_vector": "L", "attack_complexity": "Z"},
|
|
contextual_cvss_reasoning="Only scripts/import.py reaches the sink.",
|
|
)
|
|
assert result["success"] is False
|
|
assert any("attack_complexity" in error for error in result["errors"])
|
|
assert any("privileges_required" in error for error in result["errors"])
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dependency_report_rejects_contextual_breakdown_without_reasoning(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
result = await _do_create_dependency(
|
|
title="CVE-2021-23337 in lodash 4.17.20",
|
|
description="Command injection via template.",
|
|
target="repo/package.json",
|
|
cve="CVE-2021-23337",
|
|
package_name="lodash",
|
|
installed_version="4.17.20",
|
|
impact="Arbitrary command execution.",
|
|
remediation_steps="Upgrade to 4.17.21.",
|
|
assumptions="Assumes the template sink is reachable.",
|
|
package_ecosystem="npm",
|
|
advisory_cvss=7.2,
|
|
technical_analysis=None,
|
|
fixed_version="4.17.21",
|
|
cwe="CWE-94",
|
|
fix_effort="trivial",
|
|
manifest_path="package-lock.json",
|
|
contextual_cvss_breakdown=_CONTEXTUAL_BREAKDOWN,
|
|
contextual_cvss_reasoning=" ",
|
|
)
|
|
assert result["success"] is False
|
|
assert any("contextual_cvss_reasoning is required" in error for error in result["errors"])
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
_CONFIRMED_KWARGS: dict[str, Any] = {
|
|
"title": "Unauthenticated file write on /files/{id}",
|
|
"description": "A multipart PATCH writes attacker content before the permission check.",
|
|
"impact": "Any anonymous user overwrites stored files and serves attacker content.",
|
|
"target": "https://cms.example.com",
|
|
"technical_analysis": "disk.write runs before the authorization guard.",
|
|
"poc_description": "1. PATCH /files/<uuid> with a multipart body as an anonymous user.",
|
|
"poc_script_code": "PATCH /files/2f1c HTTP/1.1\n\n--x\nowned\n--x--",
|
|
"remediation_steps": "Authorize before the write.",
|
|
"evidence": "The stored file returns the injected payload after the 403 response.",
|
|
"assumptions": "Assumes the uuid of one existing file is known.",
|
|
"counterevidence": "The endpoint answers 403, yet the write already landed.",
|
|
"confidence": "HIGH",
|
|
"confidence_rationale": "The write was observed end to end against the live host.",
|
|
"severity_change_conditions": "A guard before disk.write would remove the impact.",
|
|
"fix_effort": "MEDIUM",
|
|
"cvss_breakdown": _CVSS,
|
|
"endpoint": "/files/{id}",
|
|
"method": "PATCH",
|
|
"cve": "CVE-2025-55746",
|
|
"cwe": "CWE-863",
|
|
"code_locations": None,
|
|
}
|
|
|
|
|
|
def _seed_weak_report(report_state: ReportState) -> None:
|
|
"""A version-based, unproven entry for the same issue, as an earlier agent files it."""
|
|
report_state.vulnerability_reports.append(
|
|
{
|
|
"id": "vuln-0009",
|
|
"title": "Directus 11.5.1 exposed on public host (in scope for CVE-2025-55746)",
|
|
"severity": "medium",
|
|
"timestamp": "2026-01-01 00:00:00 UTC",
|
|
"description": "The banner reports a version affected by CVE-2025-55746.",
|
|
"target": "https://cms.example.com",
|
|
"confidence": "low",
|
|
"evidence": "The version banner only.",
|
|
"cvss": 5.3,
|
|
"finding_class": "dynamic",
|
|
"agent_id": "aaaa1111",
|
|
}
|
|
)
|
|
report_state._saved_vuln_ids.add("vuln-0009")
|
|
|
|
|
|
async def test_duplicate_verdict_rejects_without_touching_the_existing_report(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Deduplication only answers identity. A duplicate is rejected and points at the
|
|
finding it matched; revising that finding is a separate, explicit operation."""
|
|
_seed_weak_report(report_state)
|
|
|
|
async def fake_check_duplicate(
|
|
_candidate: dict[str, Any], _existing: list[dict[str, Any]]
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"is_duplicate": True,
|
|
"duplicate_id": "vuln-0009",
|
|
"confidence": 0.9,
|
|
"reason": "Same root cause on the same endpoint.",
|
|
}
|
|
|
|
monkeypatch.setattr("strix.report.dedupe.check_duplicate", fake_check_duplicate)
|
|
|
|
result = await _do_create(**_CONFIRMED_KWARGS, agent_id="834f79fb", agent_name="Validation")
|
|
|
|
assert result["success"] is False
|
|
assert result["duplicate_of"] == "vuln-0009"
|
|
assert "action" not in result
|
|
assert len(report_state.vulnerability_reports) == 1
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["severity"] == "medium", "a duplicate verdict never edits the matched finding"
|
|
assert "poc_script_code" not in report
|
|
assert "update_history" not in report
|
|
|
|
|
|
def test_update_vulnerability_report_records_chained_impact(report_state: ReportState) -> None:
|
|
"""Attack chaining raises the impact of a finding already on file."""
|
|
_seed_weak_report(report_state)
|
|
|
|
updated = report_state.update_vulnerability_report(
|
|
"vuln-0009",
|
|
{
|
|
"severity": "CRITICAL",
|
|
"cvss": 9.8,
|
|
"impact": "The overwritten file loads in an admin session and takes over the account.",
|
|
"id": "vuln-9999",
|
|
"finding_class": "static",
|
|
},
|
|
update_reason="A chained admin takeover follows the file write.",
|
|
)
|
|
|
|
assert updated is not None
|
|
assert updated["id"] == "vuln-0009", "identity fields are not updatable"
|
|
assert updated["finding_class"] == "dynamic"
|
|
assert updated["severity"] == "critical"
|
|
assert updated["updated_at"]
|
|
assert report_state.update_vulnerability_report("vuln-0404", {"severity": "high"}) is None
|
|
|
|
|
|
def test_update_replaces_http_exchange_ids(report_state: ReportState) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="A replay produced a clearer proving exchange.",
|
|
fields={"http_exchange_ids": ["204", "204", "205"]},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert report_state.vulnerability_reports[0]["http_exchange_ids"] == ["204", "205"]
|
|
|
|
|
|
async def test_create_rejects_invalid_http_exchange_ids(report_state: ReportState) -> None:
|
|
result = await _do_create(
|
|
**_CONFIRMED_KWARGS,
|
|
http_exchange_ids=["ok", "contains space"],
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("visible ASCII" in error for error in result["errors"])
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
def test_http_exchange_id_limit_applies_after_deduplication() -> None:
|
|
request_ids, errors = _normalize_http_exchange_ids(["1042"] * 11)
|
|
|
|
assert errors == []
|
|
assert request_ids == ["1042"]
|
|
|
|
|
|
async def test_http_exchange_ids_must_exist_in_current_proxy_project(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def existing_request_ids(
|
|
_ctx: Any,
|
|
_request_ids: list[str],
|
|
) -> set[str]:
|
|
return {"1042"}
|
|
|
|
monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids)
|
|
|
|
request_ids, errors, warning = await _verify_http_exchange_ids(
|
|
cast("Any", object()),
|
|
["1042", "1088"],
|
|
)
|
|
|
|
assert request_ids is None
|
|
assert errors == ["http_exchange_ids do not exist in the current proxy project: 1088"]
|
|
assert warning is None
|
|
|
|
|
|
async def test_http_exchange_ids_are_dropped_when_proxy_cannot_be_queried(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def existing_request_ids(
|
|
_ctx: Any,
|
|
_request_ids: list[str],
|
|
) -> set[str]:
|
|
raise RuntimeError("Caido client is not available")
|
|
|
|
monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids)
|
|
|
|
request_ids, errors, warning = await _verify_http_exchange_ids(
|
|
cast("Any", object()),
|
|
["1042", "1042", "1088"],
|
|
)
|
|
|
|
assert request_ids is None
|
|
assert errors == []
|
|
assert warning is not None
|
|
assert "not stored" in warning
|
|
assert "update_vulnerability_report" in warning
|
|
|
|
|
|
async def test_create_reports_persistence_failure_as_tool_error(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
def fail_persistence(_report: dict[str, Any]) -> None:
|
|
raise RuntimeError("persistence failed")
|
|
|
|
report_state.vulnerability_found_callback = fail_persistence
|
|
|
|
result = await _do_create(**_CONFIRMED_KWARGS, http_exchange_ids=["1042"])
|
|
|
|
assert result["success"] is False
|
|
assert "persistence failed" in result["error"]
|
|
assert "file it again" in result["error"]
|
|
assert report_state.vulnerability_reports == []
|
|
|
|
|
|
async def test_evidence_only_update_reports_proxy_outage_as_retryable(
|
|
report_state: ReportState,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
|
|
async def existing_request_ids(
|
|
_ctx: Any,
|
|
_request_ids: list[str],
|
|
) -> set[str]:
|
|
raise RuntimeError("Caido client is not available")
|
|
|
|
monkeypatch.setattr(reporting_tool, "existing_request_ids", existing_request_ids)
|
|
|
|
ctx = ToolContext(
|
|
context={"agent_id": "root"},
|
|
tool_name="update_vulnerability_report",
|
|
tool_call_id="call-1",
|
|
tool_arguments="{}",
|
|
)
|
|
raw = await update_vulnerability_report.on_invoke_tool(
|
|
ctx,
|
|
json.dumps(
|
|
{
|
|
"report_id": "vuln-0009",
|
|
"update_reason": "A replay produced a clearer proving exchange.",
|
|
"http_exchange_ids": ["204"],
|
|
}
|
|
),
|
|
)
|
|
result = json.loads(raw)
|
|
|
|
assert result["success"] is False
|
|
assert "No fields to update" not in result["error"]
|
|
assert "update_vulnerability_report" in result["error"]
|
|
assert result["report_id"] == "vuln-0009"
|
|
assert report_state.vulnerability_reports[0] == original
|
|
|
|
|
|
def test_update_reports_persistence_failure_as_tool_error(report_state: ReportState) -> None:
|
|
_seed_weak_report(report_state)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
|
|
def fail_persistence(_report: dict[str, Any]) -> None:
|
|
raise RuntimeError("persistence failed")
|
|
|
|
report_state.vulnerability_updated_callback = fail_persistence
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="A replay produced a clearer proving exchange.",
|
|
fields={"http_exchange_ids": ["204"]},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "persistence failed" in result["error"]
|
|
assert result["report_id"] == "vuln-0009"
|
|
assert report_state.vulnerability_reports[0] == original
|
|
|
|
|
|
def test_update_vulnerability_report_ignores_identical_content(report_state: ReportState) -> None:
|
|
_seed_weak_report(report_state)
|
|
assert report_state.update_vulnerability_report("vuln-0009", {"severity": "medium"}) is None
|
|
assert "update_history" not in report_state.vulnerability_reports[0]
|
|
|
|
|
|
def test_update_drops_reasoning_left_behind_by_the_field_it_describes(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""A rating the update replaces must not keep the rationale for the old one."""
|
|
_seed_weak_report(report_state)
|
|
report = report_state.vulnerability_reports[0]
|
|
report["confidence_rationale"] = "Nothing was executed; the version banner is the only signal."
|
|
report["cvss_breakdown"] = {"attack_vector": "network", "user_interaction": "required"}
|
|
report["severity_change_conditions"] = "Confirming the write would raise this."
|
|
|
|
updated = report_state.update_vulnerability_report(
|
|
"vuln-0009",
|
|
{
|
|
"confidence": "high",
|
|
"severity": "critical",
|
|
"cvss": 9.8,
|
|
"severity_change_conditions": "A guard before the write would remove the impact.",
|
|
},
|
|
)
|
|
|
|
assert updated is not None
|
|
assert "confidence_rationale" not in updated, "the superseded rationale must not survive"
|
|
assert "cvss_breakdown" not in updated
|
|
assert updated["severity_change_conditions"].startswith("A guard"), (
|
|
"a replacement the update supplies is kept, not dropped"
|
|
)
|
|
assert updated["update_history"][0]["dropped_fields"] == [
|
|
"confidence_rationale",
|
|
"cvss_breakdown",
|
|
]
|
|
|
|
run_dir = report_state._run_dir
|
|
assert run_dir is not None
|
|
markdown = (run_dir / "vulnerabilities" / "vuln-0009.md").read_text(encoding="utf-8")
|
|
assert "version banner is the only signal" not in markdown
|
|
assert "Dropped as superseded: confidence_rationale, cvss_breakdown" in markdown
|
|
|
|
|
|
def test_agent_revises_its_own_report_without_a_duplicate_verdict(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""Editing a finding is its own operation: no dedupe verdict is involved."""
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="An unauthenticated PATCH wrote the file, so the finding is confirmed.",
|
|
fields={
|
|
"poc_script_code": "PATCH /files/2f1c HTTP/1.1",
|
|
"confidence": "HIGH",
|
|
"confidence_rationale": "The write was replayed twice.",
|
|
"cvss_breakdown": _CVSS,
|
|
"severity_change_conditions": "A guard before the write would remove the impact.",
|
|
},
|
|
agent_id="834f79fb",
|
|
agent_name="Directus CVE-2025-55746 Validation Agent",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["action"] == "updated"
|
|
assert result["severity"] == "critical"
|
|
assert result["cvss_score"] == pytest.approx(9.8)
|
|
assert "cvss" in result["updated_fields"], "a new vector carries its own score"
|
|
assert len(report_state.vulnerability_reports) == 1
|
|
|
|
report = report_state.vulnerability_reports[0]
|
|
assert report["id"] == "vuln-0009"
|
|
assert report["confidence"] == "high"
|
|
assert report["agent_id"] == "aaaa1111", "the original reporter stays on the finding"
|
|
history = report["update_history"]
|
|
assert history[0]["agent_name"] == "Directus CVE-2025-55746 Validation Agent"
|
|
assert history[0]["reason"].startswith("An unauthenticated PATCH")
|
|
|
|
run_dir = report_state._run_dir
|
|
assert run_dir is not None
|
|
markdown = (run_dir / "vulnerabilities" / "vuln-0009.md").read_text(encoding="utf-8")
|
|
assert "PATCH /files/2f1c" in markdown
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("report_id", "update_reason", "fields", "expected"),
|
|
[
|
|
(" ", "reason", {"impact": "x"}, "report_id cannot be empty"),
|
|
("vuln-0009", " ", {"impact": "x"}, "update_reason cannot be empty"),
|
|
("vuln-0009", "reason", {}, "No fields to update"),
|
|
("vuln-0404", "reason", {"impact": "x"}, "not found"),
|
|
],
|
|
)
|
|
def test_update_rejects_a_call_it_cannot_act_on(
|
|
report_state: ReportState,
|
|
report_id: str,
|
|
update_reason: str,
|
|
fields: dict[str, Any],
|
|
expected: str,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(report_id=report_id, update_reason=update_reason, fields=fields)
|
|
|
|
assert result["success"] is False
|
|
assert expected in result["error"]
|
|
assert "update_history" not in report_state.vulnerability_reports[0]
|
|
|
|
|
|
def test_update_reports_every_invalid_field_at_once(report_state: ReportState) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Raising the rating.",
|
|
fields={
|
|
"confidence": "very high",
|
|
"fix_effort": "weeks",
|
|
"cvss_breakdown": {**_CVSS, "attack_vector": "X"},
|
|
"cve": "CVE-BAD",
|
|
},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
joined = " ".join(result["errors"])
|
|
assert "confidence" in joined
|
|
assert "fix_effort" in joined
|
|
assert "attack_vector" in joined
|
|
assert "CVE" in joined
|
|
assert report_state.vulnerability_reports[0]["confidence"] == "low", "nothing was applied"
|
|
|
|
|
|
def test_update_wants_verification_for_a_fix_it_would_apply(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Adding the file the write lands in.",
|
|
fields={
|
|
"code_locations": [
|
|
{
|
|
"file": "api/src/controllers/files.ts",
|
|
"start_line": 42,
|
|
"fix_before": "await storage.write(id, body)",
|
|
"fix_after": "await assertPermission(req); await storage.write(id, body)",
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("fix_verification" in error for error in result["errors"])
|
|
|
|
|
|
def test_update_says_so_when_the_report_already_carries_it(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Restating the severity.",
|
|
fields={"confidence": "low"},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "already says this" in result["error"]
|
|
assert result["report_id"] == "vuln-0009"
|
|
|
|
|
|
def test_update_tool_asks_for_the_report_and_the_reason() -> None:
|
|
schema = update_vulnerability_report.params_json_schema
|
|
assert set(schema["required"]) >= {"report_id", "update_reason"}
|
|
assert "cvss_breakdown" in schema["properties"]
|
|
assert "id" not in schema["properties"], "identity fields are not editable"
|
|
description = update_vulnerability_report.description
|
|
assert "not deduplication" in description
|
|
|
|
|
|
def test_update_keeps_an_exploit_out_of_a_dependency_finding(report_state: ReportState) -> None:
|
|
"""A dependency record is rated from its advisory, so a revision must not write a
|
|
PoC and a dynamic rating onto it. The proof belongs in its own finding."""
|
|
_seed_weak_report(report_state)
|
|
dependency_report = report_state.vulnerability_reports[0]
|
|
dependency_report["finding_class"] = "dependency_cve"
|
|
dependency_report["dependency_metadata"] = {
|
|
"package_name": "directus",
|
|
"installed_version": "11.5.1",
|
|
}
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="An unauthenticated PATCH wrote the file.",
|
|
fields={
|
|
"poc_script_code": "PATCH /files/2f1c HTTP/1.1",
|
|
"endpoint": "/files/{uuid}",
|
|
"cvss_breakdown": _CVSS,
|
|
},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "dependency_cve" in result["error"]
|
|
assert set(result["rejected_fields"]) == {"endpoint", "poc_script_code"}
|
|
assert dependency_report["severity"] == "medium"
|
|
assert "poc_script_code" not in dependency_report
|
|
assert "update_history" not in dependency_report
|
|
|
|
|
|
def _seed_dependency_report(report_state: ReportState) -> dict[str, Any]:
|
|
_seed_weak_report(report_state)
|
|
dependency_report = report_state.vulnerability_reports[0]
|
|
dependency_report["finding_class"] = "dependency_cve"
|
|
dependency_report["dependency_metadata"] = {
|
|
"package_name": "directus",
|
|
"installed_version": "11.5.1",
|
|
"manifest_path": "package-lock.json",
|
|
"advisory_cvss": 9.8,
|
|
"contextual_cvss_breakdown": {**_CVSS, "confidentiality": "L"},
|
|
"contextual_cvss_score": 5.3,
|
|
"contextual_cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
|
|
"contextual_cvss_reasoning": "The vulnerable API is imported but never called.",
|
|
}
|
|
return dependency_report
|
|
|
|
|
|
def test_update_re_rates_a_dependency_finding_through_its_contextual_cvss(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""A dependency finding is rated in the context of the codebase. A revised
|
|
breakdown replaces that contextual rating, with the reasoning a reader can
|
|
check, and leaves the package identity alone."""
|
|
dependency_report = _seed_dependency_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="A call path from the upload handler to the vulnerable API was found.",
|
|
fields={
|
|
"cvss_breakdown": _CVSS,
|
|
"contextual_cvss_reasoning": (
|
|
"routes/upload.ts:88 reaches the affected parser with user input."
|
|
),
|
|
},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["severity"] == "critical"
|
|
assert dependency_report["severity"] == "critical"
|
|
assert dependency_report["cvss"] == 9.8
|
|
assert "cvss_breakdown" not in dependency_report
|
|
assert "contextual_cvss_reasoning" not in dependency_report
|
|
metadata = dependency_report["dependency_metadata"]
|
|
assert metadata["package_name"] == "directus"
|
|
assert metadata["installed_version"] == "11.5.1"
|
|
assert metadata["manifest_path"] == "package-lock.json"
|
|
assert metadata["advisory_cvss"] == 9.8
|
|
assert metadata["contextual_cvss_breakdown"] == _CVSS
|
|
assert metadata["contextual_cvss_score"] == 9.8
|
|
assert metadata["contextual_cvss_vector"] == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
|
|
assert metadata["contextual_cvss_reasoning"].startswith("routes/upload.ts:88")
|
|
assert dependency_report["finding_class"] == "dependency_cve"
|
|
history = dependency_report["update_history"]
|
|
assert history[-1]["previous_severity"] == "medium"
|
|
assert set(history[-1]["fields"]) == {"cvss", "dependency_metadata", "severity"}
|
|
|
|
|
|
def test_update_wants_the_reasoning_behind_a_dependency_re_rating(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
dependency_report = _seed_dependency_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="The parser is reachable.",
|
|
fields={"cvss_breakdown": _CVSS},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("contextual_cvss_reasoning" in error for error in result["errors"])
|
|
assert dependency_report["severity"] == "medium"
|
|
assert dependency_report["dependency_metadata"]["contextual_cvss_score"] == 5.3
|
|
assert "update_history" not in dependency_report
|
|
|
|
|
|
def test_update_corrects_the_reasoning_behind_a_dependency_rating_alone(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""The rating on file stays; only its explanation is replaced."""
|
|
dependency_report = _seed_dependency_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="The reasoning named the wrong module.",
|
|
fields={"contextual_cvss_reasoning": "lib/parser.ts imports it; no call site reaches it."},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["updated_fields"] == ["dependency_metadata"]
|
|
assert dependency_report["severity"] == "medium"
|
|
assert dependency_report["cvss"] == 5.3
|
|
metadata = dependency_report["dependency_metadata"]
|
|
assert metadata["contextual_cvss_breakdown"] == {**_CVSS, "confidentiality": "L"}
|
|
assert metadata["contextual_cvss_score"] == 5.3
|
|
assert metadata["contextual_cvss_reasoning"].startswith("lib/parser.ts")
|
|
assert metadata["package_name"] == "directus"
|
|
|
|
|
|
def test_update_wants_a_rating_before_reasoning_about_one(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
dependency_report = report_state.vulnerability_reports[0]
|
|
dependency_report["finding_class"] = "dependency_cve"
|
|
dependency_report["dependency_metadata"] = {
|
|
"package_name": "directus",
|
|
"installed_version": "11.5.1",
|
|
}
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Explaining the rating.",
|
|
fields={"contextual_cvss_reasoning": "Reachable."},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("cvss_breakdown is required" in error for error in result["errors"])
|
|
assert "contextual_cvss_reasoning" not in dependency_report["dependency_metadata"]
|
|
|
|
|
|
def test_update_keeps_contextual_reasoning_off_a_dynamic_finding(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Re-rating.",
|
|
fields={"cvss_breakdown": _CVSS, "contextual_cvss_reasoning": "Reachable."},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert result["rejected_fields"] == ["contextual_cvss_reasoning"]
|
|
assert report_state.vulnerability_reports[0]["severity"] == "medium"
|
|
|
|
|
|
def test_update_reads_a_legacy_dependency_record_by_its_metadata(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""A dependency finding filed before finding_class was persisted still carries
|
|
package metadata, so its class is read from that, not defaulted to dynamic."""
|
|
_seed_weak_report(report_state)
|
|
dependency_report = report_state.vulnerability_reports[0]
|
|
dependency_report.pop("finding_class", None)
|
|
dependency_report["dependency_metadata"] = {
|
|
"package_name": "directus",
|
|
"installed_version": "11.5.1",
|
|
}
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="An unauthenticated PATCH wrote the file.",
|
|
fields={"poc_script_code": "PATCH /files/2f1c HTTP/1.1", "cvss_breakdown": _CVSS},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert "dependency_cve" in result["error"]
|
|
assert "poc_script_code" not in dependency_report
|
|
|
|
|
|
def test_update_still_corrects_the_prose_of_a_dependency_finding(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""Fields every class carries stay editable on a dependency record."""
|
|
_seed_weak_report(report_state)
|
|
dependency_report = report_state.vulnerability_reports[0]
|
|
dependency_report["finding_class"] = "dependency_cve"
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="The advisory names a later fixed release than the report says.",
|
|
fields={"remediation_steps": "Upgrade to 11.5.2 or later."},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert dependency_report["remediation_steps"] == "Upgrade to 11.5.2 or later."
|
|
assert dependency_report["finding_class"] == "dependency_cve"
|
|
|
|
|
|
def test_update_refuses_code_locations_it_cannot_use(report_state: ReportState) -> None:
|
|
"""A location without a usable file and line is reported, not dropped in silence."""
|
|
_seed_weak_report(report_state)
|
|
|
|
result = _do_update(
|
|
report_id="vuln-0009",
|
|
update_reason="Naming the vulnerable handler.",
|
|
fields={"code_locations": [{"label": "the file write"}]},
|
|
)
|
|
|
|
assert result["success"] is False
|
|
assert any("start_line" in error for error in result["errors"])
|
|
|
|
|
|
def _seed_saved_report(report_state: ReportState) -> Path:
|
|
"""The weak report, written to disk the way a filed finding is."""
|
|
_seed_weak_report(report_state)
|
|
report_state._saved_vuln_ids.clear()
|
|
report_state.save_run_data()
|
|
run_dir = report_state._run_dir
|
|
assert run_dir is not None
|
|
md_path = run_dir / "vulnerabilities" / "vuln-0009.md"
|
|
assert md_path.exists()
|
|
return run_dir
|
|
|
|
|
|
def test_delete_withdraws_a_disproved_report_and_its_artifacts(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""A deleted finding leaves the run's state and every rendered artifact."""
|
|
run_dir = _seed_saved_report(report_state)
|
|
persisted: list[dict[str, Any]] = []
|
|
report_state.vulnerability_deleted_callback = persisted.append
|
|
|
|
result = _do_delete(
|
|
report_id="vuln-0009",
|
|
delete_reason="The cross-tenant read came from the harness reusing the victim's session.",
|
|
agent_id="aaaa1111",
|
|
agent_name="Recon Agent",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["action"] == "deleted"
|
|
assert result["report_id"] == "vuln-0009"
|
|
assert result["title"].startswith("Directus 11.5.1")
|
|
assert report_state.vulnerability_reports == []
|
|
assert not (run_dir / "vulnerabilities" / "vuln-0009.md").exists()
|
|
assert json.loads((run_dir / "vulnerabilities.json").read_text(encoding="utf-8")) == []
|
|
assert "vuln-0009" not in (run_dir / "vulnerabilities.csv").read_text(encoding="utf-8")
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0]["id"] == "vuln-0009"
|
|
assert persisted[0]["agent_id"] == "aaaa1111", "the callback sees the finding as filed"
|
|
deletion = persisted[0]["deletion"]
|
|
assert deletion["agent_id"] == "aaaa1111"
|
|
assert deletion["agent_name"] == "Recon Agent"
|
|
assert deletion["filed_by_agent_id"] == "aaaa1111"
|
|
assert deletion["reason"].startswith("The cross-tenant read")
|
|
|
|
run = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
|
history = run["deleted_vulnerability_reports"]
|
|
assert [entry["id"] for entry in history] == ["vuln-0009"]
|
|
assert history[0]["severity"] == "medium"
|
|
assert history[0]["deleted_at"]
|
|
|
|
|
|
def test_delete_never_reissues_the_withdrawn_id(report_state: ReportState) -> None:
|
|
"""The next finding must not inherit a deleted report's id, file or history."""
|
|
_seed_saved_report(report_state)
|
|
assert report_state.delete_vulnerability_report(
|
|
"vuln-0009", delete_reason="Disproved.", deleted_by_agent_id="aaaa1111"
|
|
)
|
|
|
|
new_id = report_state.add_vulnerability_report(title="A real finding", severity="high")
|
|
|
|
assert new_id == "vuln-0010"
|
|
assert [r["id"] for r in report_state.vulnerability_reports] == ["vuln-0010"]
|
|
|
|
|
|
def _assert_report_still_filed(
|
|
report_state: ReportState, run_dir: Path, original: dict[str, Any]
|
|
) -> None:
|
|
"""The report is in memory and in every artifact exactly as it was filed."""
|
|
assert report_state.vulnerability_reports == [original]
|
|
assert "vuln-0009" in report_state._saved_vuln_ids
|
|
assert (run_dir / "vulnerabilities" / "vuln-0009.md").exists()
|
|
indexed = json.loads((run_dir / "vulnerabilities.json").read_text(encoding="utf-8"))
|
|
assert [r["id"] for r in indexed] == ["vuln-0009"]
|
|
assert "vuln-0009" in (run_dir / "vulnerabilities.csv").read_text(encoding="utf-8")
|
|
run = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
|
assert "deleted_vulnerability_reports" not in run
|
|
assert "deleted_vulnerability_reports" not in report_state.run_record
|
|
assert report_state._next_report_id() == "vuln-0010"
|
|
|
|
|
|
def test_any_agent_in_the_run_may_delete_a_report(report_state: ReportState) -> None:
|
|
"""Agents in a run share one trust boundary: a report another agent filed can be
|
|
withdrawn once disproved, and the history keeps both identities apart."""
|
|
run_dir = _seed_saved_report(report_state)
|
|
calls: list[dict[str, Any]] = []
|
|
report_state.vulnerability_deleted_callback = calls.append
|
|
|
|
result = _do_delete(
|
|
report_id="vuln-0009",
|
|
delete_reason="Disproved.",
|
|
agent_id="834f79fb",
|
|
agent_name="Validation Agent",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert report_state.vulnerability_reports == []
|
|
assert [c["id"] for c in calls] == ["vuln-0009"]
|
|
assert not (run_dir / "vulnerabilities" / "vuln-0009.md").exists()
|
|
(deletion,) = report_state.run_record["deleted_vulnerability_reports"]
|
|
assert deletion["filed_by_agent_id"] == "aaaa1111"
|
|
assert deletion["agent_id"] == "834f79fb"
|
|
assert deletion["agent_name"] == "Validation Agent"
|
|
|
|
|
|
def test_delete_reports_persistence_failure_and_keeps_the_report(
|
|
report_state: ReportState,
|
|
) -> None:
|
|
"""Persistence refused after the local indexes were rewritten: they are rewritten back."""
|
|
run_dir = _seed_saved_report(report_state)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
|
|
def fail_persistence(_report: dict[str, Any]) -> None:
|
|
assert report_state.vulnerability_reports == [], "indexes are rewritten first"
|
|
raise RuntimeError("persistence failed")
|
|
|
|
report_state.vulnerability_deleted_callback = fail_persistence
|
|
|
|
result = _do_delete(report_id="vuln-0009", delete_reason="Disproved.", agent_id="aaaa1111")
|
|
|
|
assert result["success"] is False
|
|
assert "persistence failed" in result["error"]
|
|
assert "still on file" in result["error"]
|
|
_assert_report_still_filed(report_state, run_dir, original)
|
|
|
|
|
|
def test_delete_reports_an_artifact_write_failure_and_keeps_the_report(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A disk that cannot take the rewritten run record is a failed deletion, not a
|
|
success with stale artifacts; persistence is never told about it."""
|
|
run_dir = _seed_saved_report(report_state)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
calls: list[dict[str, Any]] = []
|
|
report_state.vulnerability_deleted_callback = calls.append
|
|
attempts = 0
|
|
|
|
def write_run_record_once_failing(run_dir_: Path, record: dict[str, Any]) -> None:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
raise OSError(28, "No space left on device")
|
|
(run_dir_ / "run.json").write_text(json.dumps(record), encoding="utf-8")
|
|
|
|
monkeypatch.setattr("strix.report.state.write_run_record", write_run_record_once_failing)
|
|
|
|
result = _do_delete(report_id="vuln-0009", delete_reason="Disproved.", agent_id="aaaa1111")
|
|
|
|
assert result["success"] is False
|
|
assert "No space left on device" in result["error"]
|
|
assert "still on file" in result["error"]
|
|
assert calls == []
|
|
assert attempts == 2, "the run record is written back after the failed rewrite"
|
|
_assert_report_still_filed(report_state, run_dir, original)
|
|
|
|
|
|
def test_delete_reports_when_the_rollback_itself_cannot_be_written(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A disk that stays full: memory is restored, the caller is told the indexes
|
|
may be stale, and the next save rewrites them."""
|
|
run_dir = _seed_saved_report(report_state)
|
|
original = dict(report_state.vulnerability_reports[0])
|
|
calls: list[dict[str, Any]] = []
|
|
report_state.vulnerability_deleted_callback = calls.append
|
|
disk_full = True
|
|
|
|
def write_run_record_while_disk_full(run_dir_: Path, record: dict[str, Any]) -> None:
|
|
if disk_full:
|
|
raise OSError(28, "No space left on device")
|
|
write_run_record(run_dir_, record)
|
|
|
|
monkeypatch.setattr("strix.report.state.write_run_record", write_run_record_while_disk_full)
|
|
|
|
with pytest.raises(ReportRollbackError) as excinfo:
|
|
report_state.delete_vulnerability_report(
|
|
"vuln-0009", delete_reason="Disproved.", deleted_by_agent_id="aaaa1111"
|
|
)
|
|
|
|
assert isinstance(excinfo.value.cause, OSError)
|
|
assert calls == []
|
|
assert report_state.vulnerability_reports == [original]
|
|
assert "vuln-0009" in report_state._saved_vuln_ids
|
|
assert "deleted_vulnerability_reports" not in report_state.run_record
|
|
|
|
result = _do_delete(report_id="vuln-0009", delete_reason="Disproved.", agent_id="aaaa1111")
|
|
assert result["success"] is False
|
|
assert "No space left on device" in result["error"]
|
|
assert "may be stale" in result["error"]
|
|
|
|
disk_full = False
|
|
report_state.save_run_data()
|
|
_assert_report_still_filed(report_state, run_dir, original)
|
|
|
|
|
|
def test_delete_can_be_retried_after_persistence_recovers(report_state: ReportState) -> None:
|
|
run_dir = _seed_saved_report(report_state)
|
|
outcomes = iter([RuntimeError("persistence failed"), None])
|
|
|
|
def flaky_persistence(_report: dict[str, Any]) -> None:
|
|
outcome = next(outcomes)
|
|
if outcome is not None:
|
|
raise outcome
|
|
|
|
report_state.vulnerability_deleted_callback = flaky_persistence
|
|
|
|
first = _do_delete(report_id="vuln-0009", delete_reason="Disproved.", agent_id="aaaa1111")
|
|
second = _do_delete(report_id="vuln-0009", delete_reason="Disproved.", agent_id="aaaa1111")
|
|
|
|
assert first["success"] is False
|
|
assert second["success"] is True
|
|
assert report_state.vulnerability_reports == []
|
|
assert not (run_dir / "vulnerabilities" / "vuln-0009.md").exists()
|
|
assert json.loads((run_dir / "vulnerabilities.json").read_text(encoding="utf-8")) == []
|
|
run = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
|
|
assert [e["id"] for e in run["deleted_vulnerability_reports"]] == ["vuln-0009"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("report_id", "delete_reason", "expected"),
|
|
[
|
|
(" ", "reason", "report_id cannot be empty"),
|
|
("vuln-0009", " ", "delete_reason cannot be empty"),
|
|
("vuln-0404", "reason", "not found"),
|
|
],
|
|
)
|
|
def test_delete_rejects_a_call_it_cannot_act_on(
|
|
report_state: ReportState,
|
|
report_id: str,
|
|
delete_reason: str,
|
|
expected: str,
|
|
) -> None:
|
|
_seed_weak_report(report_state)
|
|
calls: list[dict[str, Any]] = []
|
|
report_state.vulnerability_deleted_callback = calls.append
|
|
|
|
result = _do_delete(report_id=report_id, delete_reason=delete_reason, agent_id="aaaa1111")
|
|
|
|
assert result["success"] is False
|
|
assert expected in result["error"]
|
|
assert len(report_state.vulnerability_reports) == 1
|
|
assert calls == [], "nothing reaches persistence for a call that is refused"
|
|
|
|
|
|
async def test_delete_tool_carries_the_caller_identity(report_state: ReportState) -> None:
|
|
_seed_saved_report(report_state)
|
|
coordinator = type("Coordinator", (), {"names": {"aaaa1111": "Recon Agent"}})()
|
|
ctx = ToolContext(
|
|
context={"agent_id": "aaaa1111", "coordinator": coordinator},
|
|
tool_name="delete_vulnerability_report",
|
|
tool_call_id="call-1",
|
|
tool_arguments="{}",
|
|
)
|
|
|
|
raw = await delete_vulnerability_report.on_invoke_tool(
|
|
ctx,
|
|
json.dumps({"report_id": "vuln-0009", "delete_reason": "Disproved on re-test."}),
|
|
)
|
|
result = json.loads(raw)
|
|
|
|
assert result["success"] is True
|
|
history = report_state.run_record["deleted_vulnerability_reports"]
|
|
assert history[0]["agent_id"] == "aaaa1111"
|
|
assert history[0]["agent_name"] == "Recon Agent"
|
|
assert "update_vulnerability_report" in delete_vulnerability_report.description
|