Compare commits

...
11 changed files with 705 additions and 34 deletions
+2
View File
@@ -48,6 +48,7 @@ from strix.tools.reporting.tool import (
create_vulnerability_report, create_vulnerability_report,
get_report, get_report,
list_reports, list_reports,
update_vulnerability_report,
) )
from strix.tools.respond.tool import respond_to_user from strix.tools.respond.tool import respond_to_user
from strix.tools.thinking.tool import think from strix.tools.thinking.tool import think
@@ -500,6 +501,7 @@ _BASE_TOOLS: tuple[Tool, ...] = (
delete_note, delete_note,
web_search, web_search,
create_vulnerability_report, create_vulnerability_report,
update_vulnerability_report,
create_dependency_report, create_dependency_report,
list_reports, list_reports,
get_report, get_report,
+5 -4
View File
@@ -214,11 +214,12 @@ VALIDATION REQUIREMENTS:
- Treat public metadata, internal-looking identifiers, source maps without secrets, and transport/configuration hygiene as observations unless validation proves unauthorized restricted-data access, modification, or service disruption - Treat public metadata, internal-looking identifiers, source maps without secrets, and transport/configuration hygiene as observations unless validation proves unauthorized restricted-data access, modification, or service disruption
- Every non-None Confidentiality, Integrity, or Availability metric must map to explicit evidence in the report; use Scope Changed only for a demonstrated crossing of security authorities - Every non-None Confidentiality, Integrity, or Availability metric must map to explicit evidence in the report; use Scope Changed only for a demonstrated crossing of security authorities
- Independent verification through subagent - Independent verification through subagent
- Document complete attack chain - Document complete attack chains. Amend an existing finding when new evidence increases or decreases its impact on the same asset and root cause
- Keep going until you find something that matters - Keep going until you find something that matters
- A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient - A vulnerability is ONLY considered reported when a reporting agent uses create_vulnerability_report or update_vulnerability_report (or create_dependency_report for known-CVE dependency/supply-chain findings) with full details. Mentions in agent_finish, finish_scan, or generic messages are NOT sufficient
- Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.) - Reporting and fixing are ONE step, not two: when source is available, the reporting agent derives the concrete fix and files it INLINE via create_vulnerability_report (`code_locations` with `fix_before`/`fix_after` + `fix_pr_body`) — the report is not complete without it. Do NOT report first and then spawn a separate downstream agent to re-derive and re-apply the same patch; that just re-does the analysis and wastes tokens. (Do not silently patch a finding WITHOUT filing a report — the report, with its embedded fix, is the deliverable.)
- DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, DO NOT attempt to re-submit the same vulnerability. Accept the rejection and move on to testing other areas. The vulnerability has already been reported by another agent - DEDUPLICATION: The create_vulnerability_report tool uses LLM-based deduplication. If it rejects your report as a duplicate, do not re-submit the same vulnerability. If new validated evidence shows greater or lower impact for the same finding, use update_vulnerability_report with the returned duplicate_of id. Otherwise, move on to testing other areas. The vulnerability has already been reported
- DISTINCT ISSUE VS AMENDMENT: File a distinct issue with create_vulnerability_report. Use update_vulnerability_report when new evidence proves greater or lower impact for the same root cause on the same asset. Supply an evidence-backed update_reason and recalculate the rating from cvss_breakdown
- REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes. - REVIEWING FILED FINDINGS (orchestrator/root agent): use list_reports to see every vulnerability filed so far in this scan (by any agent, root or child) — metadata-first with per-severity counts — and get_report to read one finding in full by its id. These are read-only orchestration tools: the root agent uses them to track coverage, avoid dispatching work on already-covered ground, assemble the finish_scan executive summary, and reason about attack-chaining across confirmed findings. Leaf/specialist agents should NOT call them — just do your assigned testing and file findings. Each entry shows which agent filed it (agent_name), and your own entries are flagged by_you. list_notes/get_note do the same for notes.
</execution_guidelines> </execution_guidelines>
@@ -372,7 +373,7 @@ CRITICAL RULES:
- **REALISTIC OUTCOMES** - Some tests find nothing, some validations fail - **REALISTIC OUTCOMES** - Some tests find nothing, some validations fail
- **ONE AGENT = ONE TASK** - Don't let agents do multiple unrelated jobs - **ONE AGENT = ONE TASK** - Don't let agents do multiple unrelated jobs
- **SPAWN REACTIVELY** - Create new agents based on what you discover - **SPAWN REACTIVELY** - Create new agents based on what you discover
- **ONLY REPORTING AGENTS** can use create_vulnerability_report tool - **ONLY REPORTING AGENTS** can use create_vulnerability_report or update_vulnerability_report tools
- **AGENT SPECIALIZATION MANDATORY** - Each agent must be highly specialized; prefer 13 skills, up to 5 for complex contexts - **AGENT SPECIALIZATION MANDATORY** - Each agent must be highly specialized; prefer 13 skills, up to 5 for complex contexts
- **NO GENERIC AGENTS** - Avoid creating broad, multi-purpose agents that dilute focus - **NO GENERIC AGENTS** - Avoid creating broad, multi-purpose agents that dilute focus
+30 -17
View File
@@ -38,6 +38,35 @@ def _resolve_sandbox_image() -> str:
return image return image
def _configure_report_callbacks(report_state: ReportState, console: Console) -> None:
def display_vulnerability(report: dict[str, Any], *, updated: bool = False) -> None:
report_id = report.get("id", "unknown")
vuln_text = format_vulnerability_report(report)
title = (
f"[bold yellow]{report_id.upper()} — UPDATED FINDING"
if updated
else f"[bold red]{report_id.upper()}"
)
vuln_panel = Panel(
vuln_text,
title=title,
title_align="left",
border_style="yellow" if updated else "red",
padding=(1, 2),
)
console.print(vuln_panel)
console.print()
report_state.vulnerability_found_callback = display_vulnerability
report_state.vulnerability_updated_callback = lambda report: display_vulnerability(
report,
updated=True,
)
async def run_cli(args: Any) -> None: # noqa: PLR0915 async def run_cli(args: Any) -> None: # noqa: PLR0915
console = Console() console = Console()
@@ -105,23 +134,7 @@ async def run_cli(args: Any) -> None: # noqa: PLR0915
report_state.set_scan_config(scan_config) report_state.set_scan_config(scan_config)
report_state.save_run_data() report_state.save_run_data()
def display_vulnerability(report: dict[str, Any]) -> None: _configure_report_callbacks(report_state, console)
report_id = report.get("id", "unknown")
vuln_text = format_vulnerability_report(report)
vuln_panel = Panel(
vuln_text,
title=f"[bold red]{report_id.upper()}",
title_align="left",
border_style="red",
padding=(1, 2),
)
console.print(vuln_panel)
console.print()
report_state.vulnerability_found_callback = display_vulnerability
def cleanup_on_exit() -> None: def cleanup_on_exit() -> None:
report_state.cleanup() report_state.cleanup()
+3
View File
@@ -102,6 +102,9 @@ class GoTuiRuntime:
self.report_state.vulnerability_found_callback = lambda _report: ( self.report_state.vulnerability_found_callback = lambda _report: (
self.controller.notify_changed() self.controller.notify_changed()
) )
self.report_state.vulnerability_updated_callback = lambda _report: (
self.controller.notify_changed()
)
self.controller.notify_changed() self.controller.notify_changed()
async def start_from_setup(self, verify: bool = True) -> None: async def start_from_setup(self, verify: bool = True) -> None:
+48
View File
@@ -149,6 +149,7 @@ class ReportState:
self.caido_url: str | None = None self.caido_url: str | None = None
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
self.vulnerability_updated_callback: Callable[[dict[str, Any]], None] | None = None
self._sarif_repo_ctx: dict[str, Any] | None = None self._sarif_repo_ctx: dict[str, Any] | None = None
self._sarif_repo_ctx_ready: bool = False self._sarif_repo_ctx_ready: bool = False
@@ -315,6 +316,53 @@ class ReportState:
self.save_run_data() self.save_run_data()
return report_id return report_id
def update_vulnerability_report(
self,
report_id: str,
update_reason: str,
**updates: Any,
) -> dict[str, Any] | None:
"""Amend a filed vulnerability report and persist the updated state."""
report = next(
(item for item in self.vulnerability_reports if item.get("id") == report_id),
None,
)
if report is None:
return None
timestamp = datetime.now(UTC).isoformat()
previous_severity = report.get("severity")
previous_cvss = report.get("cvss")
changed_fields: list[str] = []
for field, value in updates.items():
if report.get(field) != value:
changed_fields.append(field)
report[field] = value
history_entry: dict[str, Any] = {
"timestamp": timestamp,
"update_reason": update_reason.strip(),
"fields_changed": changed_fields,
}
if ("severity" in updates and updates.get("severity") != previous_severity) or (
"cvss" in updates and updates.get("cvss") != previous_cvss
):
history_entry["previous_severity"] = previous_severity
history_entry["previous_cvss_score"] = previous_cvss
history = report.setdefault("update_history", [])
if not isinstance(history, list):
history = []
report["update_history"] = history
history.append(history_entry)
report["updated_at"] = timestamp
self._saved_vuln_ids.discard(report_id)
if self.vulnerability_updated_callback:
self.vulnerability_updated_callback(report)
self.save_run_data()
return report
def get_existing_vulnerabilities(self) -> list[dict[str, Any]]: def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
return list(self.vulnerability_reports) return list(self.vulnerability_reports)
+21
View File
@@ -197,6 +197,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
f"**Severity:** {report.get('severity', 'unknown').upper()}", f"**Severity:** {report.get('severity', 'unknown').upper()}",
f"**Found:** {report.get('timestamp', 'unknown')}", f"**Found:** {report.get('timestamp', 'unknown')}",
] ]
if report.get("updated_at"):
lines.append(f"**Updated:** {report['updated_at']}")
dep_meta = report.get("dependency_metadata") or {} dep_meta = report.get("dependency_metadata") or {}
metadata: list[tuple[str, Any]] = [ metadata: list[tuple[str, Any]] = [
@@ -304,4 +306,23 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
lines.append(str(report["assumptions"])) lines.append(str(report["assumptions"]))
lines.append("") lines.append("")
update_history = report.get("update_history")
if isinstance(update_history, list) and update_history:
lines.append("## Amendment History\n")
for entry in update_history:
if not isinstance(entry, dict):
continue
timestamp = entry.get("timestamp", "unknown")
reason = entry.get("update_reason", "")
fields = ", ".join(str(field) for field in entry.get("fields_changed", []))
lines.append(f"- **{timestamp}:** {reason}")
lines.append(f" Changed fields: {fields or 'none'}")
if "previous_severity" in entry or "previous_cvss_score" in entry:
lines.append(
" Previous rating: "
f"{str(entry.get('previous_severity', 'unknown')).upper()} "
f"(CVSS {entry.get('previous_cvss_score', 'unknown')})"
)
lines.append("")
return "\n".join(lines) return "\n".join(lines)
+8 -7
View File
@@ -133,14 +133,15 @@ async def finish_scan(
combination. You may rule out combinations you can confidently combination. You may rule out combinations you can confidently
call unrelated — note why instead of padding chains. Any call unrelated — note why instead of padding chains. Any
validated chain must already be filed via validated chain must already be filed via
``create_vulnerability_report`` — a demonstrated end-to-end chain ``create_vulnerability_report`` — or update the existing finding with
is a PoC-backed vulnerability, so it uses that tool even when one ``update_vulnerability_report`` when the chain amplifies that finding
link is a dependency CVE (the standalone CVE stays in its own on the same asset and root cause. A demonstrated new chain is a
``create_dependency_report``) — and surfaced prominently in PoC-backed vulnerability, so file it even when one link is a
dependency CVE. Keep the standalone CVE in its own
``create_dependency_report``. Surface the result prominently in
``executive_summary`` / ``technical_analysis``. Finding no real ``executive_summary`` / ``technical_analysis``. Finding no real
chain after a serious attempt is acceptable; skipping the chain after a serious attempt is acceptable. Skipping the chaining
chaining reasoning, or ignoring a plausibly-related combination, reasoning, or ignoring a plausibly-related combination, is not.
is not.
**Calling this multiple times overwrites the previous report.** **Calling this multiple times overwrites the previous report.**
Make the single call comprehensive. Make the single call comprehensive.
+277 -6
View File
@@ -1,8 +1,8 @@
"""Reporting tools — file vuln findings (with dedup + CVSS) and read them back. """Reporting tools — file findings (with dedup + CVSS) and read them back.
``create_vulnerability_report`` / ``create_dependency_report`` file findings; ``create_vulnerability_report`` / ``create_dependency_report`` file findings;
``list_reports`` / ``get_report`` let any agent (notably the root orchestrator) ``update_vulnerability_report`` amends a known dynamic finding;
review what's been filed so far across the whole scan. ``list_reports`` / ``get_report`` let the root orchestrator review findings.
""" """
from __future__ import annotations from __future__ import annotations
@@ -162,6 +162,24 @@ _REQUIRED_FIELDS = {
_VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"}) _VALID_FIX_EFFORT = frozenset({"trivial", "low", "medium", "high"})
_AMENDABLE_FIELDS = (
"title",
"description",
"impact",
"technical_analysis",
"poc_description",
"poc_script_code",
"remediation_steps",
"evidence",
"assumptions",
"fix_effort",
"cvss_breakdown",
"endpoint",
"method",
"cwe",
"code_locations",
)
async def _do_create( # noqa: PLR0912 async def _do_create( # noqa: PLR0912
*, *,
@@ -277,7 +295,10 @@ async def _do_create( # noqa: PLR0912
"success": False, "success": False,
"error": ( "error": (
f"Potential duplicate of '{duplicate_title}' " f"Potential duplicate of '{duplicate_title}' "
f"(id={duplicate_id[:8]}...) — do not re-report the same vulnerability" f"(id={duplicate_id[:8]}...) — do not re-report the same vulnerability. "
f"If new validated evidence shows greater or lower impact than filed, "
f"amend this finding with update_vulnerability_report using id "
f"'{duplicate_id}' instead."
), ),
"duplicate_of": duplicate_id, "duplicate_of": duplicate_id,
"duplicate_title": duplicate_title, "duplicate_title": duplicate_title,
@@ -329,6 +350,189 @@ async def _do_create( # noqa: PLR0912
} }
async def _do_update( # noqa: PLR0911, PLR0912, PLR0915
*,
report_id: str,
update_reason: str,
title: str | None = None,
description: str | None = None,
impact: str | None = None,
technical_analysis: str | None = None,
poc_description: str | None = None,
poc_script_code: str | None = None,
remediation_steps: str | None = None,
evidence: str | None = None,
assumptions: str | None = None,
fix_effort: str | None = None,
cvss_breakdown: dict[str, str] | None = None,
endpoint: str | None = None,
method: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Validate and amend one known dynamic vulnerability report."""
if not report_id.strip():
return {"success": False, "error": "report_id cannot be empty"}
if not update_reason.strip():
return {"success": False, "error": "update_reason cannot be empty"}
raw_updates = {
name: value
for name, value in {
"title": title,
"description": description,
"impact": impact,
"technical_analysis": technical_analysis,
"poc_description": poc_description,
"poc_script_code": poc_script_code,
"remediation_steps": remediation_steps,
"evidence": evidence,
"assumptions": assumptions,
"fix_effort": fix_effort,
"cvss_breakdown": cvss_breakdown,
"endpoint": endpoint,
"method": method,
"cwe": cwe,
"code_locations": code_locations,
}.items()
if value is not None and name in _AMENDABLE_FIELDS
}
if not raw_updates:
return {
"success": False,
"error": "At least one amendable field must be supplied",
}
try:
from strix.report.state import get_global_report_state
report_state = get_global_report_state()
if report_state is None:
return {
"success": False,
"error": "Report state is unavailable",
}
existing = report_state.get_existing_vulnerabilities()
report = next((item for item in existing if item.get("id") == report_id), None)
if report is None:
valid_ids = [str(item["id"]) for item in existing if item.get("id")]
result: dict[str, Any] = {
"success": False,
"error": f"Report with id '{report_id}' was not found",
}
if valid_ids:
result["valid_report_ids"] = valid_ids
return result
errors: list[str] = []
updates: dict[str, Any] = {}
for field, value in raw_updates.items():
if field in {
"title",
"description",
"impact",
"technical_analysis",
"poc_description",
"poc_script_code",
"remediation_steps",
"evidence",
"assumptions",
"endpoint",
"method",
}:
normalized_value = str(value).strip()
if not normalized_value:
errors.append(
_REQUIRED_FIELDS.get(
field,
f"{field.replace('_', ' ').capitalize()} cannot be empty",
),
)
else:
updates[field] = normalized_value
if "fix_effort" in raw_updates:
normalized_fix_effort = str(fix_effort).strip().lower()
if normalized_fix_effort not in _VALID_FIX_EFFORT:
errors.append(
f"Invalid fix_effort: {normalized_fix_effort!r}. "
f"Must be one of: {sorted(_VALID_FIX_EFFORT)}"
)
else:
updates["fix_effort"] = normalized_fix_effort
if "cvss_breakdown" in raw_updates:
if not isinstance(cvss_breakdown, dict) or not cvss_breakdown:
errors.append("cvss_breakdown: must be an object with the 8 CVSS metrics")
else:
for name, valid in _CVSS_VALID.items():
metric_value = cvss_breakdown.get(name)
if metric_value not in valid:
errors.append(
f"Invalid {name}: {metric_value}. Must be one of: {valid}",
)
updates["cvss_breakdown"] = cvss_breakdown
if "cwe" in raw_updates:
parsed_cwe = _extract_cwe(str(cwe))
cwe_err = _validate_cwe(parsed_cwe)
if cwe_err:
errors.append(cwe_err)
else:
updates["cwe"] = parsed_cwe
if "code_locations" in raw_updates:
if not isinstance(code_locations, list) or not code_locations:
errors.append("code_locations must contain at least one location")
else:
parsed_locations = _normalize_code_locations(code_locations)
if not parsed_locations:
errors.append("code_locations must contain at least one valid location")
else:
errors.extend(_validate_code_locations(parsed_locations))
updates["code_locations"] = parsed_locations
if errors:
return {"success": False, "error": "Validation failed", "errors": errors}
if "cvss_breakdown" in updates:
try:
cvss_score, severity, _vector = _calculate_cvss(updates["cvss_breakdown"])
except ValueError as exc:
return {"success": False, "error": "Validation failed", "errors": [str(exc)]}
updates["cvss"] = cvss_score
updates["severity"] = severity
updated = report_state.update_vulnerability_report(
report_id,
update_reason,
**updates,
)
if updated is None:
return {
"success": False,
"error": f"Report with id '{report_id}' was not found",
}
except (AttributeError, KeyError, TypeError, ValueError) as exc:
logger.exception("update_vulnerability_report persistence failed")
return {"success": False, "error": f"Failed to update vulnerability report: {exc!s}"}
logger.info(
"Vulnerability report updated: id=%s fields=%s",
report_id,
sorted(raw_updates),
)
return {
"success": True,
"message": f"Vulnerability report '{report_id}' updated successfully",
"report_id": report_id,
"severity": updated.get("severity"),
"cvss_score": updated.get("cvss"),
"updated_at": updated.get("updated_at"),
}
def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]: def _caller_identity(ctx: RunContextWrapper) -> tuple[str | None, str | None]:
"""Return the (agent_id, agent_name) of the agent invoking this tool.""" """Return the (agent_id, agent_name) of the agent invoking this tool."""
inner = ctx.context if isinstance(ctx.context, dict) else {} inner = ctx.context if isinstance(ctx.context, dict) else {}
@@ -377,6 +581,8 @@ async def create_vulnerability_report(
- Suspicions you haven't confirmed with a PoC. - Suspicions you haven't confirmed with a PoC.
- Tracking multiple vulnerabilities at once — one report per vuln. - Tracking multiple vulnerabilities at once — one report per vuln.
- Re-reporting something you (or another agent) already filed. - Re-reporting something you (or another agent) already filed.
- A chain that only amplifies an existing finding's impact on the same
asset and root cause. Use ``update_vulnerability_report`` instead.
- Known-CVE dependency / supply-chain findings that can't be - Known-CVE dependency / supply-chain findings that can't be
dynamically PoC'd — a vulnerable dependency version pinned in a dynamically PoC'd — a vulnerable dependency version pinned in a
lockfile/manifest that matches a published advisory. File those lockfile/manifest that matches a published advisory. File those
@@ -408,8 +614,10 @@ async def create_vulnerability_report(
Automatic LLM-based **deduplication** rejects reports that describe Automatic LLM-based **deduplication** rejects reports that describe
the same root cause on the same asset as an existing report. If you the same root cause on the same asset as an existing report. If you
get a ``duplicate_of`` response, do NOT retry — move on to other get a ``duplicate_of`` response, do not re-submit the same vulnerability.
areas. If new validated evidence shows greater or lower impact for that finding,
amend it with ``update_vulnerability_report`` using the returned id.
Otherwise, move on to other areas.
**Report output rules** (this content may be rendered into generated **Report output rules** (this content may be rendered into generated
reports): reports):
@@ -701,6 +909,69 @@ async def create_vulnerability_report(
return json.dumps(result, ensure_ascii=False, default=str) return json.dumps(result, ensure_ascii=False, default=str)
@function_tool(timeout=180, strict_mode=False)
async def update_vulnerability_report(
ctx: RunContextWrapper,
report_id: str,
update_reason: str,
title: str | None = None,
description: str | None = None,
impact: str | None = None,
technical_analysis: str | None = None,
poc_description: str | None = None,
poc_script_code: str | None = None,
remediation_steps: str | None = None,
evidence: str | None = None,
assumptions: str | None = None,
fix_effort: str | None = None,
cvss_breakdown: dict[str, str] | None = None,
endpoint: str | None = None,
method: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
) -> str:
"""Amend a known vulnerability report when new evidence changes its impact.
Use ``create_vulnerability_report`` when the chain proves a distinct issue.
Use this tool when the same root cause on the same asset has greater impact.
The tool recomputes severity and score from ``cvss_breakdown``.
Apply the same report output rules and CVSS calibration discipline as the
create tool. An amendment must use evidence from a validated result. Do
not use this tool for a speculative upgrade.
Pass at least one amendable field:
- ``title``, ``description``, ``impact``, ``technical_analysis``,
``poc_description``, ``poc_script_code``, ``remediation_steps``,
``evidence``, ``assumptions``, ``fix_effort``, ``cvss_breakdown``,
``endpoint``, ``method``, ``cwe``, or ``code_locations``.
Do not change ``target``, ``cve``, or dependency metadata. File a new
report instead when those values must change.
"""
result = await _do_update(
report_id=report_id,
update_reason=update_reason,
title=title,
description=description,
impact=impact,
technical_analysis=technical_analysis,
poc_description=poc_description,
poc_script_code=poc_script_code,
remediation_steps=remediation_steps,
evidence=evidence,
assumptions=assumptions,
fix_effort=fix_effort,
cvss_breakdown=cvss_breakdown,
endpoint=endpoint,
method=method,
cwe=cwe,
code_locations=code_locations,
)
return json.dumps(result, ensure_ascii=False, default=str)
_DEP_SEVERITY_FROM_CVSS = { _DEP_SEVERITY_FROM_CVSS = {
(9.0, 10.0): "critical", (9.0, 10.0): "critical",
(7.0, 9.0): "high", (7.0, 9.0): "high",
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import Mock
from rich.console import Console
from rich.panel import Panel
from strix.interface import cli
def test_cli_report_callbacks_render_new_and_updated_findings() -> None:
report_state = SimpleNamespace(
vulnerability_found_callback=None,
vulnerability_updated_callback=None,
)
console = Mock(spec=Console)
cli._configure_report_callbacks(cast("Any", report_state), console)
report = {"id": "vuln-0001", "title": "Unsafe redirect"}
report_state.vulnerability_found_callback(report)
report_state.vulnerability_updated_callback(report)
panels = [call.args[0] for call in console.print.call_args_list if call.args]
assert all(isinstance(panel, Panel) for panel in panels)
assert panels[0].title == "[bold red]VULN-0001"
assert panels[1].title == "[bold yellow]VULN-0001 — UPDATED FINDING"
+20
View File
@@ -12,6 +12,7 @@ import threading
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import Mock
import pytest import pytest
@@ -95,6 +96,25 @@ def test_binary_command_ignores_unconstrained_path_sidecar(
GoTuiRuntime.binary_command() GoTuiRuntime.binary_command()
@pytest.mark.asyncio
async def test_init_run_state_wires_updated_report_callback(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.chdir(tmp_path)
runtime = GoTuiRuntime(args())
notify_changed = Mock()
monkeypatch.setattr(runtime.controller, "notify_changed", notify_changed)
runtime.init_run_state()
assert runtime.report_state is not None
assert runtime.report_state.vulnerability_updated_callback is not None
notify_changed.reset_mock()
runtime.report_state.vulnerability_updated_callback({"id": "vuln-0001"})
notify_changed.assert_called_once_with()
def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None: def test_child_environment_excludes_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id") monkeypatch.setenv("AWS_ACCESS_KEY_ID", "aws-id")
+262
View File
@@ -0,0 +1,262 @@
"""Tests for amending filed vulnerability reports."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any, cast
import pytest
from strix.report.state import ReportState, set_global_report_state
from strix.tools.reporting.tool import _do_update
if TYPE_CHECKING:
from pathlib import Path
_LOW_CVSS = {
"attack_vector": "N",
"attack_complexity": "H",
"privileges_required": "H",
"user_interaction": "R",
"scope": "U",
"confidentiality": "L",
"integrity": "N",
"availability": "N",
}
_CRITICAL_CVSS = {
"attack_vector": "N",
"attack_complexity": "L",
"privileges_required": "N",
"user_interaction": "N",
"scope": "U",
"confidentiality": "H",
"integrity": "H",
"availability": "H",
}
@pytest.fixture(autouse=True)
def report_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ReportState:
monkeypatch.chdir(tmp_path)
state = ReportState(run_name="test-run")
set_global_report_state(state)
state.add_vulnerability_report(
title="Unsafe redirect",
severity="low",
description="The redirect accepts attacker input.",
impact="Limited redirect manipulation.",
target="https://app.example.com",
technical_analysis="The handler does not validate the destination.",
poc_description="Send a crafted redirect value.",
poc_script_code="GET /redirect?url=https://example.net",
remediation_steps="Validate redirect destinations.",
evidence="The response contains the attacker-controlled destination.",
assumptions="Assumes a victim follows the link.",
fix_effort="medium",
cvss=3.1,
cvss_breakdown=_LOW_CVSS,
endpoint="/redirect",
method="GET",
cwe="CWE-601",
code_locations=[
{
"file": "src/redirect.py",
"start_line": 10,
"end_line": 12,
"snippet": "return redirect(url)",
},
],
)
return state
async def test_update_rejects_unknown_report_id() -> None:
result = await _do_update(
report_id="vuln-9999",
update_reason="The validation pass proved broader impact.",
impact="Broader impact.",
)
assert result["success"] is False
assert "vuln-9999" in result["error"]
assert result["valid_report_ids"] == ["vuln-0001"]
async def test_update_requires_an_amendable_field() -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The validation pass found no new field to amend.",
)
assert result == {
"success": False,
"error": "At least one amendable field must be supplied",
}
async def test_update_requires_nonempty_reason() -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason=" ",
impact="The impact is broader.",
)
assert result == {"success": False, "error": "update_reason cannot be empty"}
@pytest.mark.parametrize(
("field", "value", "expected_error"),
[
("impact", "", "Impact cannot be empty"),
("impact", " ", "Impact cannot be empty"),
("endpoint", "", "Endpoint cannot be empty"),
("endpoint", " ", "Endpoint cannot be empty"),
],
)
async def test_update_rejects_blank_text_fields(
report_state: ReportState,
field: str,
value: str,
expected_error: str,
) -> None:
original_value = report_state.vulnerability_reports[0][field]
update = cast("dict[str, Any]", {field: value})
result = await _do_update(
report_id="vuln-0001",
update_reason="The source review supplied no content for this field.",
**update,
)
assert result["success"] is False
assert expected_error in result["errors"]
assert report_state.vulnerability_reports[0][field] == original_value
async def test_update_changes_impact_only(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The confirmed chain exposes account data.",
impact="The chain exposes account data.",
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["impact"] == "The chain exposes account data."
assert report["severity"] == "low"
assert report["cvss"] == 3.1
assert report["update_history"][0]["fields_changed"] == ["impact"]
assert "previous_severity" not in report["update_history"][0]
async def test_cvss_update_recomputes_score_and_severity(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The exploit chain proves full account compromise.",
cvss_breakdown=_CRITICAL_CVSS,
)
assert result["success"] is True
report = report_state.vulnerability_reports[0]
assert report["severity"] == "critical"
assert report["cvss"] == 9.8
history = report["update_history"][0]
assert history["fields_changed"] == ["cvss_breakdown", "cvss", "severity"]
assert history["previous_severity"] == "low"
assert history["previous_cvss_score"] == 3.1
async def test_update_history_is_append_only(report_state: ReportState) -> None:
await _do_update(
report_id="vuln-0001",
update_reason="The chain proves account access.",
impact="Account access is possible.",
)
await _do_update(
report_id="vuln-0001",
update_reason="The second proof confirms persistent access.",
evidence="The second proof confirms persistent access.",
)
history = report_state.vulnerability_reports[0]["update_history"]
assert len(history) == 2
assert history[0]["update_reason"] == "The chain proves account access."
assert history[1]["update_reason"] == "The second proof confirms persistent access."
assert all("description" not in entry for entry in history)
async def test_update_callback_fires(report_state: ReportState) -> None:
updated: list[dict[str, Any]] = []
report_state.vulnerability_updated_callback = updated.append
await _do_update(
report_id="vuln-0001",
update_reason="The new proof confirms data exposure.",
evidence="The new proof confirms data exposure.",
)
assert len(updated) == 1
assert updated[0] is report_state.vulnerability_reports[0]
async def test_update_persists_all_report_artifacts(report_state: ReportState) -> None:
result = await _do_update(
report_id="vuln-0001",
update_reason="The chain proves account takeover.",
description="The redirect reaches the account takeover flow.",
impact="An attacker can take over an account.",
cvss_breakdown=_CRITICAL_CVSS,
)
assert result["success"] is True
run_dir = report_state.get_run_dir()
finding_md = (run_dir / "vulnerabilities" / "vuln-0001.md").read_text(encoding="utf-8")
findings = json.loads((run_dir / "vulnerabilities.json").read_text(encoding="utf-8"))
sarif = json.loads((run_dir / "findings.sarif").read_text(encoding="utf-8"))
sarif_finding = sarif["runs"][0]["results"][0]
assert "An attacker can take over an account." in finding_md
assert findings[0]["impact"] == "An attacker can take over an account."
assert findings[0]["severity"] == "critical"
assert sarif_finding["properties"]["strix"]["impact"] == (
"An attacker can take over an account."
)
assert sarif_finding["properties"]["strix"]["severity"] == "critical"
@pytest.mark.parametrize(
"code_locations",
[[], [{"file": "../invalid.py", "start_line": 1}]],
)
async def test_update_rejects_empty_code_locations(
report_state: ReportState,
code_locations: list[dict[str, Any]],
) -> None:
original_locations = report_state.vulnerability_reports[0]["code_locations"]
result = await _do_update(
report_id="vuln-0001",
update_reason="The source review did not provide a valid location.",
code_locations=code_locations,
)
assert result["success"] is False
assert any("code_locations" in error for error in result["errors"])
assert report_state.vulnerability_reports[0]["code_locations"] == original_locations
async def test_update_fails_without_global_report_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("strix.report.state._global_report_state", None)
result = await _do_update(
report_id="vuln-0001",
update_reason="The new proof confirms broader impact.",
impact="Broader impact.",
)
assert result == {"success": False, "error": "Report state is unavailable"}