mirror of
https://github.com/usestrix/strix.git
synced 2026-11-14 09:01:25 +00:00
159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
"""Only validated model decisions may suppress a finding."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from agents.items import ModelResponse
|
|
from agents.usage import Usage
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from strix.config import loader
|
|
from strix.report import dedupe
|
|
from strix.report.state import ReportState, get_global_report_state, set_global_report_state
|
|
from strix.tools.reporting.tool import _do_create
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture
|
|
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ReportState]:
|
|
monkeypatch.chdir(tmp_path)
|
|
previous = get_global_report_state()
|
|
state = ReportState(run_name="dedupe-decisions")
|
|
set_global_report_state(state)
|
|
state.add_vulnerability_report(title="Existing finding", severity="low")
|
|
try:
|
|
yield state
|
|
finally:
|
|
set_global_report_state(previous)
|
|
|
|
|
|
def _mock_judge(monkeypatch: pytest.MonkeyPatch, decision: dict[str, Any]) -> AsyncMock:
|
|
settings = loader.load_settings().model_copy(deep=True)
|
|
settings.llm.model = "openai/synthetic-model"
|
|
model = AsyncMock()
|
|
model.get_response.return_value = ModelResponse(
|
|
output=[
|
|
ResponseOutputMessage(
|
|
id="msg_synthetic",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[
|
|
ResponseOutputText(
|
|
type="output_text",
|
|
annotations=[],
|
|
text=json.dumps({"confidence": 0.95, "reason": "Synthetic", **decision}),
|
|
)
|
|
],
|
|
)
|
|
],
|
|
usage=Usage(),
|
|
response_id="synthetic",
|
|
)
|
|
monkeypatch.setattr(dedupe, "load_settings", lambda: settings)
|
|
monkeypatch.setattr(dedupe, "configure_sdk_model_defaults", lambda _: None)
|
|
monkeypatch.setattr(dedupe, "resolve_dedupe_model", lambda *_: model)
|
|
return model
|
|
|
|
|
|
async def _file_finding() -> dict[str, Any]:
|
|
return await _do_create(
|
|
title="New finding",
|
|
description="The endpoint reflects unencoded input.",
|
|
impact="The reflected script executes in a victim's browser.",
|
|
target="https://example.invalid",
|
|
technical_analysis="Input is interpolated into HTML without encoding.",
|
|
poc_description="Open the endpoint with the reflected parameter.",
|
|
poc_script_code="print('synthetic reproduction')",
|
|
remediation_steps="Encode output for its HTML context.",
|
|
evidence="The synthetic response contains the unencoded parameter.",
|
|
assumptions="A victim follows the crafted link.",
|
|
counterevidence="No encoding or content security policy is present.",
|
|
confidence="high",
|
|
severity_change_conditions="A strict content security policy would lower the impact.",
|
|
fix_effort="low",
|
|
cvss_breakdown={
|
|
"attack_vector": "N",
|
|
"attack_complexity": "L",
|
|
"privileges_required": "N",
|
|
"user_interaction": "R",
|
|
"scope": "U",
|
|
"confidentiality": "L",
|
|
"integrity": "L",
|
|
"availability": "N",
|
|
},
|
|
endpoint="/search",
|
|
method="GET",
|
|
cve=None,
|
|
cwe="CWE-79",
|
|
code_locations=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["false", "true", 0, 1, None, [], {}])
|
|
def test_dedupe_parser_requires_a_boolean(value: Any) -> None:
|
|
with pytest.raises(TypeError, match="is_duplicate"):
|
|
dedupe._parse_dedupe_response(json.dumps({"is_duplicate": value}))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"decision",
|
|
[
|
|
{"is_duplicate": "false", "duplicate_id": "vuln-0001"},
|
|
{"is_duplicate": "true", "duplicate_id": "vuln-0001"},
|
|
{"is_duplicate": 1, "duplicate_id": "vuln-0001"},
|
|
{"is_duplicate": True, "duplicate_id": "vuln-9999"},
|
|
{"is_duplicate": True, "duplicate_id": ""},
|
|
{"is_duplicate": True, "duplicate_id": None},
|
|
{"is_duplicate": True, "duplicate_id": ["vuln-0001"]},
|
|
{"is_duplicate": True},
|
|
],
|
|
)
|
|
async def test_invalid_dedupe_decision_does_not_suppress_finding(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch, decision: dict[str, Any]
|
|
) -> None:
|
|
model = _mock_judge(monkeypatch, decision)
|
|
|
|
result = await _file_finding()
|
|
|
|
assert result["success"] is True
|
|
model.get_response.assert_awaited_once()
|
|
saved = json.loads((report_state.get_run_dir() / "vulnerabilities.json").read_text())
|
|
assert [report["title"] for report in saved] == ["Existing finding", "New finding"]
|
|
assert (report_state.get_run_dir() / "vulnerabilities" / "vuln-0002.md").is_file()
|
|
|
|
|
|
@pytest.mark.parametrize("is_duplicate", [False, True])
|
|
async def test_valid_dedupe_decisions_keep_their_meaning(
|
|
report_state: ReportState, monkeypatch: pytest.MonkeyPatch, is_duplicate: bool
|
|
) -> None:
|
|
# An extraneous ID must not turn a valid false decision into a duplicate.
|
|
_mock_judge(monkeypatch, {"is_duplicate": is_duplicate, "duplicate_id": "vuln-0001"})
|
|
|
|
result = await _file_finding()
|
|
|
|
assert result["success"] is not is_duplicate
|
|
saved = json.loads((report_state.get_run_dir() / "vulnerabilities.json").read_text())
|
|
assert len(saved) == (1 if is_duplicate else 2)
|
|
if is_duplicate:
|
|
assert result["duplicate_of"] == "vuln-0001"
|
|
|
|
|
|
async def test_dedupe_id_is_matched_without_truncation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
existing_id = "x" * 64
|
|
_mock_judge(monkeypatch, {"is_duplicate": True, "duplicate_id": existing_id + "-unknown"})
|
|
|
|
result = await dedupe.check_duplicate(
|
|
{"title": "New finding"}, [{"id": existing_id, "title": "Existing finding"}]
|
|
)
|
|
|
|
assert result["is_duplicate"] is False
|
|
assert "error" in result
|