mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/usestrix/strix.git
synced 2026-09-20 08:03:42 +08:00
feat(reporting): link HTTP exchange evidence (#1281)
Co-authored-by: Ahmed Allam <ahmed39652003@gmail.com>
This commit is contained in:
@@ -19,6 +19,7 @@ from strix.config.settings import DEFAULT_MAX_TURNS
|
||||
from strix.interface.tui import runtime as go_tui
|
||||
from strix.interface.tui import sidecar
|
||||
from strix.interface.tui.runtime import GoTuiRuntime
|
||||
from strix.report.state import ReportState
|
||||
|
||||
|
||||
def args() -> argparse.Namespace:
|
||||
@@ -1027,3 +1028,29 @@ async def test_prepare_and_start_runs_the_scan_after_preparation(
|
||||
|
||||
assert order == ["preflight", "persist", "prepare", "telemetry", "state", "scan"]
|
||||
assert runtime.controller.scan_state == "running"
|
||||
|
||||
|
||||
def test_sync_fingerprint_tracks_report_revisions(tmp_path: Path) -> None:
|
||||
runtime = GoTuiRuntime(args())
|
||||
runtime.report_state = ReportState(run_name="test-run")
|
||||
runtime.report_state.vulnerability_reports = [{"id": "vuln-0001", "title": "Old title"}]
|
||||
runtime.report_state.get_run_dir = lambda: tmp_path # type: ignore[method-assign]
|
||||
|
||||
report = runtime.report_state.vulnerability_reports[0]
|
||||
timestamp = "2026-09-09 10:00:00 UTC"
|
||||
|
||||
before = runtime._runtime_sync_fingerprint()
|
||||
report.update(
|
||||
{
|
||||
"title": "New title",
|
||||
"updated_at": timestamp,
|
||||
"update_history": [{"timestamp": timestamp, "fields": ["title"]}],
|
||||
}
|
||||
)
|
||||
first_revision = runtime._runtime_sync_fingerprint()
|
||||
assert first_revision != before
|
||||
|
||||
report["title"] = "Newer title"
|
||||
report["update_history"].append({"timestamp": timestamp, "fields": ["title"]})
|
||||
|
||||
assert runtime._runtime_sync_fingerprint() != first_revision
|
||||
|
||||
@@ -9,6 +9,7 @@ call at a time against the shared client.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -227,3 +228,27 @@ async def test_ctx_client_degrades_when_bootstrap_failed() -> None:
|
||||
|
||||
handle = CaidoBootstrapHandle(asyncio.ensure_future(_bootstrap()))
|
||||
assert await tools._ctx_client(cast("Any", _Ctx({"caido_client": handle}))) is None
|
||||
|
||||
|
||||
async def test_existing_request_ids_queries_current_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client = _FakeClient("host")
|
||||
looked_up: list[str] = []
|
||||
|
||||
async def get_request_with_client(passed_client: Any, request_id: str) -> Any:
|
||||
assert passed_client is client
|
||||
looked_up.append(request_id)
|
||||
if request_id == "1042":
|
||||
return SimpleNamespace(request=SimpleNamespace(id="1042"))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(caido_api, "get_request_with_client", get_request_with_client)
|
||||
|
||||
existing = await tools.existing_request_ids(
|
||||
cast("Any", _Ctx({"caido_client": client})),
|
||||
["1042", "1088"],
|
||||
)
|
||||
|
||||
assert existing == {"1042"}
|
||||
assert looked_up == ["1042", "1088"]
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
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,
|
||||
@@ -13,10 +15,13 @@ from strix.report.dedupe import (
|
||||
)
|
||||
from strix.report.state import ReportState, set_global_report_state
|
||||
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_update,
|
||||
_normalize_http_exchange_ids,
|
||||
_verify_http_exchange_ids,
|
||||
create_dependency_report,
|
||||
create_vulnerability_report,
|
||||
update_vulnerability_report,
|
||||
@@ -115,6 +120,7 @@ async def test_create_report_persists_new_fields(report_state: ReportState) -> N
|
||||
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
|
||||
@@ -127,6 +133,51 @@ async def test_create_report_persists_new_fields(report_state: ReportState) -> N
|
||||
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(
|
||||
@@ -1040,7 +1091,13 @@ def test_tool_descriptions_include_formatting_guidance() -> None:
|
||||
|
||||
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"):
|
||||
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"]
|
||||
@@ -1355,6 +1412,158 @@ def test_update_vulnerability_report_records_chained_impact(report_state: Report
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user