first commit

This commit is contained in:
Zakaria
2026-07-02 12:31:49 -04:00
commit 1c7784cae9
189 changed files with 32427 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Report/finding helpers."""
from strix.report.dedupe import check_duplicate
from strix.report.state import ReportState, get_global_report_state, set_global_report_state
__all__ = [
"ReportState",
"check_duplicate",
"get_global_report_state",
"set_global_report_state",
]
+240
View File
@@ -0,0 +1,240 @@
"""SDK-native vulnerability-report deduplication."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING, Any
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from openai.types.responses import ResponseOutputMessage
from strix.config import load_settings
from strix.config.models import (
DEFAULT_MODEL_RETRY,
StrixProvider,
configure_sdk_model_defaults,
)
from strix.report.state import get_global_report_state
if TYPE_CHECKING:
from agents.items import ModelResponse
logger = logging.getLogger(__name__)
DEDUPE_SYSTEM_PROMPT = """You are an expert vulnerability report deduplication judge.
Your task is to determine if a candidate vulnerability report describes the SAME vulnerability
as any existing report.
CRITICAL DEDUPLICATION RULES:
1. SAME VULNERABILITY means:
- Same root cause (e.g., "missing input validation" not just "SQL injection")
- Same affected component/endpoint/file (exact match or clear overlap)
- Same exploitation method or attack vector
- Would be fixed by the same code change/patch
2. NOT DUPLICATES if:
- Different endpoints even with same vulnerability type (e.g., SQLi in /login vs /search)
- Different parameters in same endpoint (e.g., XSS in 'name' vs 'comment' field)
- Different root causes (e.g., stored XSS vs reflected XSS in same field)
- Different severity levels due to different impact
- One is authenticated, other is unauthenticated
3. ARE DUPLICATES even if:
- Titles are worded differently
- Descriptions have different level of detail
- PoC uses different payloads but exploits same issue
- One report is more thorough than another
- Minor variations in technical analysis
COMPARISON GUIDELINES:
- Focus on the technical root cause, not surface-level similarities
- Same vulnerability type (SQLi, XSS) doesn't mean duplicate - location matters
- Consider the fix: would fixing one also fix the other?
- When uncertain, lean towards NOT duplicate
FIELDS TO ANALYZE:
- title, description: General vulnerability info
- target, endpoint, method: Exact location of vulnerability
- technical_analysis: Root cause details
- poc_description: How it's exploited
- impact: What damage it can cause
Respond with a single JSON object and nothing else:
{
"is_duplicate": true,
"duplicate_id": "vuln-0001",
"confidence": 0.95,
"reason": "Both reports describe SQL injection in /api/login via the username parameter"
}
Or, if not a duplicate:
{
"is_duplicate": false,
"duplicate_id": "",
"confidence": 0.90,
"reason": "Different endpoints: candidate is /api/search, existing is /api/login"
}
Rules:
- ``is_duplicate`` is a boolean.
- ``duplicate_id`` is the exact id from existing reports, or "" if not a duplicate.
- ``confidence`` is a number between 0 and 1.
- ``reason`` is a specific explanation mentioning endpoint/parameter/root cause.
- Output ONLY the JSON object — no surrounding prose, no code fences."""
def _prepare_report_for_comparison(report: dict[str, Any]) -> dict[str, Any]:
relevant_fields = [
"id",
"title",
"description",
"impact",
"target",
"technical_analysis",
"poc_description",
"endpoint",
"method",
]
cleaned = {}
for field in relevant_fields:
if report.get(field):
value = report[field]
if isinstance(value, str) and len(value) > 8000:
value = value[:8000] + "...[truncated]"
cleaned[field] = value
return cleaned
def _parse_dedupe_response(content: str) -> dict[str, Any]:
text = content.strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:]
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object found in dedupe response: {content[:500]}")
parsed = json.loads(text[start : end + 1])
duplicate_id = str(parsed.get("duplicate_id") or "")[:64]
reason = str(parsed.get("reason") or "")[:500]
try:
confidence = float(parsed.get("confidence", 0.0))
except (TypeError, ValueError):
confidence = 0.0
return {
"is_duplicate": bool(parsed.get("is_duplicate", False)),
"duplicate_id": duplicate_id,
"confidence": confidence,
"reason": reason,
}
def _extract_text(response: ModelResponse) -> str:
parts: list[str] = []
for item in response.output:
if not isinstance(item, ResponseOutputMessage):
continue
for chunk in item.content:
text = getattr(chunk, "text", None)
if text:
parts.append(text)
return "".join(parts)
async def check_duplicate(
candidate: dict[str, Any], existing_reports: list[dict[str, Any]]
) -> dict[str, Any]:
if not existing_reports:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 1.0,
"reason": "No existing reports to compare against",
}
try:
settings = load_settings()
model_name = settings.llm.model
if not model_name:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "STRIX_LLM not configured; skipping dedupe check",
}
candidate_cleaned = _prepare_report_for_comparison(candidate)
existing_cleaned = [_prepare_report_for_comparison(r) for r in existing_reports]
comparison_data = {"candidate": candidate_cleaned, "existing_reports": existing_cleaned}
user_msg = (
f"Compare this candidate vulnerability against existing reports:\n\n"
f"{json.dumps(comparison_data, indent=2)}\n\n"
f"Respond with ONLY the JSON object described in the system prompt."
)
configure_sdk_model_defaults(settings)
resolved_model = model_name.strip()
model = StrixProvider().get_model(resolved_model)
response = await model.get_response(
system_instructions=DEDUPE_SYSTEM_PROMPT,
input=user_msg,
model_settings=ModelSettings(retry=DEFAULT_MODEL_RETRY, include_usage=True),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
report_state = get_global_report_state()
if report_state is not None:
report_state.record_sdk_usage(
agent_id="dedupe",
agent_name="dedupe",
model=resolved_model,
usage=response.usage,
)
content = _extract_text(response)
if not content:
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": "Empty response from LLM",
}
result = _parse_dedupe_response(content)
logger.info(
"Deduplication check: is_duplicate=%s, confidence=%.2f, reason=%s",
result["is_duplicate"],
result["confidence"],
result["reason"][:100],
)
except Exception as e:
logger.exception("Error during vulnerability deduplication check")
return {
"is_duplicate": False,
"duplicate_id": "",
"confidence": 0.0,
"reason": f"Deduplication check failed: {e}",
"error": str(e),
}
else:
return result
+394
View File
@@ -0,0 +1,394 @@
import json
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Optional
from uuid import uuid4
from agents.usage import Usage
from strix.core.paths import run_dir_for
from strix.report.usage import LLMUsageLedger
from strix.report.writer import (
read_run_record,
write_executive_report,
write_run_record,
write_vulnerabilities,
)
from strix.telemetry import posthog, scarf
logger = logging.getLogger(__name__)
_global_report_state: Optional["ReportState"] = None
def get_global_report_state() -> Optional["ReportState"]:
return _global_report_state
def set_global_report_state(report_state: "ReportState") -> None:
global _global_report_state # noqa: PLW0603
_global_report_state = report_state
class ReportState:
"""Per-scan product artifact state plus artifact writer.
The Agents SDK owns model/tool execution, tracing, and conversation
persistence. This store keeps only Strix-owned scan artifacts and
report metadata. Live UI projections belong to the interface layer.
It does not consume SDK tracing processors.
"""
def __init__(self, run_name: str | None = None):
self.run_name = run_name
self.run_id = run_name or f"run-{uuid4().hex[:8]}"
self.start_time = datetime.now(UTC).isoformat()
self.end_time: str | None = None
self.vulnerability_reports: list[dict[str, Any]] = []
self.final_scan_result: str | None = None
self.scan_results: dict[str, Any] | None = None
self.scan_config: dict[str, Any] | None = None
self._llm_usage = LLMUsageLedger()
self.run_record: dict[str, Any] = {
"run_id": self.run_id,
"run_name": self.run_name,
"start_time": self.start_time,
"end_time": None,
"status": "running",
"targets_info": [],
"llm_usage": self._build_llm_usage_record(),
}
self._run_dir: Path | None = None
self._saved_vuln_ids: set[str] = set()
self.caido_url: str | None = None
self.vulnerability_found_callback: Callable[[dict[str, Any]], None] | None = None
def get_run_dir(self) -> Path:
if self._run_dir is None:
run_dir_name = self.run_name if self.run_name else self.run_id
self._run_dir = run_dir_for(run_dir_name)
self._run_dir.mkdir(parents=True, exist_ok=True)
return self._run_dir
def hydrate_from_run_dir(self) -> None:
"""Reload prior-scan state from ``{run_dir}/`` for resume.
Restores:
- ``vulnerability_reports`` from ``vulnerabilities.json`` so
:meth:`add_vulnerability_report` doesn't allocate a colliding
``vuln-0001`` and overwrite the prior on-disk MD.
- ``run_record`` from ``run.json`` so timestamps, run inputs,
status, and final report state have one public source of truth.
Idempotent on missing files (fresh runs land here too via the
same code path). **Raises on corruption** — silently swallowing
a corrupt ``vulnerabilities.json`` would let the next vuln
allocate ``vuln-0001`` and overwrite the prior MD on disk
(data loss). Caller is expected to fail the run loud and let
the user inspect ``{run_dir}`` or pick a fresh ``--run-name``.
"""
run_dir = self.get_run_dir()
data = read_run_record(run_dir)
if data:
self.run_record.update(data)
if isinstance(data.get("start_time"), str):
self.start_time = data["start_time"]
if isinstance(data.get("end_time"), str):
self.end_time = data["end_time"]
scan_results = data.get("scan_results")
if isinstance(scan_results, dict):
self.scan_results = scan_results
self.final_scan_result = self._format_final_scan_result(scan_results)
self._hydrate_llm_usage(data.get("llm_usage"))
logger.info("report state hydrated run.json from %s", run_dir)
json_path = run_dir / "vulnerabilities.json"
if json_path.exists():
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"vulnerabilities.json at {json_path} is corrupt ({exc}); "
f"refusing to start fresh — that would overwrite prior "
f"vulnerability MDs on disk. Inspect or delete the run dir.",
) from exc
if not isinstance(data, list):
raise RuntimeError(
f"vulnerabilities.json at {json_path} is not a list",
)
self.vulnerability_reports = [r for r in data if isinstance(r, dict)]
for r in self.vulnerability_reports:
rid = r.get("id")
if isinstance(rid, str):
self._saved_vuln_ids.add(rid)
logger.info(
"report state hydrated %d vulnerability report(s)",
len(self.vulnerability_reports),
)
def add_vulnerability_report(
self,
title: str,
severity: str,
description: str | None = None,
impact: str | None = None,
target: str | None = None,
technical_analysis: str | None = None,
poc_description: str | None = None,
poc_script_code: str | None = None,
remediation_steps: str | None = None,
cvss: float | None = None,
cvss_breakdown: dict[str, str] | None = None,
endpoint: str | None = None,
method: str | None = None,
cve: str | None = None,
cwe: str | None = None,
code_locations: list[dict[str, Any]] | None = None,
agent_id: str | None = None,
agent_name: str | None = None,
) -> str:
report_id = f"vuln-{len(self.vulnerability_reports) + 1:04d}"
report: dict[str, Any] = {
"id": report_id,
"title": title.strip(),
"severity": severity.lower().strip(),
"timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
}
if description:
report["description"] = description.strip()
if impact:
report["impact"] = impact.strip()
if target:
report["target"] = target.strip()
if technical_analysis:
report["technical_analysis"] = technical_analysis.strip()
if poc_description:
report["poc_description"] = poc_description.strip()
if poc_script_code:
report["poc_script_code"] = poc_script_code.strip()
if remediation_steps:
report["remediation_steps"] = remediation_steps.strip()
if cvss is not None:
report["cvss"] = cvss
if cvss_breakdown:
report["cvss_breakdown"] = cvss_breakdown
if endpoint:
report["endpoint"] = endpoint.strip()
if method:
report["method"] = method.strip()
if cve:
report["cve"] = cve.strip()
if cwe:
report["cwe"] = cwe.strip()
if code_locations:
report["code_locations"] = code_locations
if agent_id:
report["agent_id"] = agent_id
if agent_name:
report["agent_name"] = agent_name
self.vulnerability_reports.append(report)
logger.info(f"Added vulnerability report: {report_id} - {title}")
posthog.finding(severity)
scarf.finding(severity)
if self.vulnerability_found_callback:
self.vulnerability_found_callback(report)
self.save_run_data()
return report_id
def get_existing_vulnerabilities(self) -> list[dict[str, Any]]:
return list(self.vulnerability_reports)
def record_sdk_usage(
self,
*,
agent_id: str,
usage: Usage | None,
agent_name: str | None = None,
model: str | None = None,
) -> None:
"""Record SDK-native token usage for one completed model run/cycle."""
if self._llm_usage.record(
agent_id=agent_id,
agent_name=agent_name,
model=model,
usage=usage,
):
self.save_run_data()
def record_observed_llm_cost(self, cost: float) -> None:
self._llm_usage.record_observed_cost(cost)
def get_total_llm_usage(self) -> dict[str, Any]:
return dict(self.run_record.get("llm_usage") or self._build_llm_usage_record())
def get_total_llm_cost(self) -> float:
"""Live accumulated LLM cost, independent of the persisted run-record snapshot."""
return self._llm_usage.total_cost
def update_scan_final_fields(
self,
executive_summary: str,
methodology: str,
technical_analysis: str,
recommendations: str,
) -> None:
self.scan_results = {
"scan_completed": True,
"executive_summary": executive_summary.strip(),
"methodology": methodology.strip(),
"technical_analysis": technical_analysis.strip(),
"recommendations": recommendations.strip(),
"success": True,
}
self.final_scan_result = self._format_final_scan_result(self.scan_results)
self.run_record["scan_results"] = self.scan_results
logger.info("Updated scan final fields")
self.save_run_data(mark_complete=True)
posthog.end(self, exit_reason="finished_by_tool")
scarf.end(self, exit_reason="finished_by_tool")
def set_scan_config(self, config: dict[str, Any]) -> None:
self.scan_config = config
self.run_record["status"] = "running"
self.run_record["end_time"] = None
self.run_record.pop("scan_results", None)
self.end_time = None
self.scan_results = None
self.final_scan_result = None
self.run_record.update(
{
"targets_info": config.get("targets", []),
"instruction": config.get("user_instructions", ""),
"scan_mode": config.get("scan_mode", "deep"),
"diff_scope": config.get("diff_scope", {"active": False}),
"non_interactive": bool(config.get("non_interactive", False)),
"local_sources": config.get("local_sources", []),
"scope_mode": config.get("scope_mode", "auto"),
"diff_base": config.get("diff_base"),
}
)
def save_run_data(self, mark_complete: bool = False, status: str | None = None) -> None:
if mark_complete:
self.end_time = datetime.now(UTC).isoformat()
self.run_record["end_time"] = self.end_time
self.run_record["status"] = "completed"
elif status and self.run_record.get("status") != "completed":
current_status = self.run_record.get("status")
if status == "stopped" and current_status in {"failed", "interrupted"}:
status = str(current_status)
if self.end_time is None:
self.end_time = datetime.now(UTC).isoformat()
self.run_record["end_time"] = self.end_time
self.run_record["status"] = status
self._sync_llm_usage_record()
self._save_artifacts()
def cleanup(self, status: str = "stopped") -> None:
self.save_run_data(status=status)
def _format_final_scan_result(self, scan_results: dict[str, Any]) -> str:
return f"""# Executive Summary
{str(scan_results.get("executive_summary", "")).strip()}
# Methodology
{str(scan_results.get("methodology", "")).strip()}
# Technical Analysis
{str(scan_results.get("technical_analysis", "")).strip()}
# Recommendations
{str(scan_results.get("recommendations", "")).strip()}
"""
def _save_artifacts(self) -> None:
"""Write scan artifacts under ``run_dir``."""
run_dir = self.get_run_dir()
try:
run_dir.mkdir(parents=True, exist_ok=True)
if self.final_scan_result:
write_executive_report(run_dir, self.final_scan_result)
if self.vulnerability_reports:
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
write_run_record(run_dir, self.run_record)
logger.info("Essential scan data saved to: %s", run_dir)
except (OSError, RuntimeError):
logger.exception("Failed to save scan data")
def _sync_llm_usage_record(self) -> None:
self.run_record["llm_usage"] = self._build_llm_usage_record()
def _build_llm_usage_record(self) -> dict[str, Any]:
return self._llm_usage.to_record()
def _hydrate_llm_usage(self, raw_usage: Any) -> None:
self._llm_usage.hydrate(raw_usage)
self._sync_llm_usage_record()
def litellm_cost_callback(
kwargs: Any,
completion_response: Any,
_start_time: Any = None,
_end_time: Any = None,
) -> None:
"""LiteLLM ``success_callback`` adapter; forwards observed cost to the active scan."""
cost: float | None = None
raw = kwargs.get("response_cost") if isinstance(kwargs, dict) else None
if isinstance(raw, int | float) and raw > 0:
cost = float(raw)
if cost is None:
hidden = getattr(completion_response, "_hidden_params", None) or {}
candidate = hidden.get("response_cost") if isinstance(hidden, dict) else None
if isinstance(candidate, int | float) and candidate > 0:
cost = float(candidate)
else:
headers = hidden.get("additional_headers") or {} if isinstance(hidden, dict) else {}
raw = (
headers.get("llm_provider-x-litellm-response-cost")
if isinstance(headers, dict)
else None
)
try:
value = float(raw) if raw is not None else None
except (TypeError, ValueError):
value = None
if value is not None and value > 0:
cost = value
if cost is None or cost <= 0:
return
report_state = get_global_report_state()
if report_state is None:
return
try:
report_state.record_observed_llm_cost(cost)
except Exception:
logger.exception("Failed to record observed LiteLLM cost")
+262
View File
@@ -0,0 +1,262 @@
"""SDK-native LLM usage aggregation for scan reports."""
from __future__ import annotations
import logging
from typing import Any
from agents.usage import Usage, deserialize_usage, serialize_usage
logger = logging.getLogger(__name__)
class LLMUsageLedger:
"""Aggregate SDK ``Usage`` objects and attach best-effort cost estimates."""
def __init__(self) -> None:
self._total_usage = Usage()
self._agent_usage: dict[str, Usage] = {}
self._agent_metadata: dict[str, dict[str, str]] = {}
self._total_cost = 0.0
def record(
self,
*,
agent_id: str,
usage: Usage | None,
agent_name: str | None = None,
model: str | None = None,
) -> bool:
if usage is None or not _usage_has_activity(usage):
return False
normalized_agent_id = str(agent_id or "unknown")
self._total_usage.add(usage)
self._agent_usage.setdefault(normalized_agent_id, Usage()).add(usage)
metadata = self._agent_metadata.setdefault(normalized_agent_id, {})
if agent_name:
metadata["agent_name"] = agent_name
if model:
metadata["model"] = model
if not _is_litellm_routed(model):
estimated = _estimate_litellm_cost(usage, model)
if estimated:
self._total_cost += estimated
return True
def record_observed_cost(self, cost: float) -> None:
if isinstance(cost, int | float) and cost > 0:
self._total_cost += float(cost)
@property
def total_cost(self) -> float:
return _round_cost(self._total_cost)
def to_record(self) -> dict[str, Any]:
record = serialize_usage(self._total_usage)
record["cost"] = _round_cost(self._total_cost)
record["agents"] = []
agent_tokens = {aid: _resolve_total_tokens(u) for aid, u in self._agent_usage.items()}
total_tokens = sum(agent_tokens.values())
for agent_id in sorted(self._agent_usage):
usage = self._agent_usage[agent_id]
metadata = self._agent_metadata.get(agent_id, {})
agent_cost = (
self._total_cost * (agent_tokens[agent_id] / total_tokens) if total_tokens else 0.0
)
agent_record = serialize_usage(usage)
agent_record.update(
{
"agent_id": agent_id,
"agent_name": metadata.get("agent_name") or agent_id,
"model": metadata.get("model"),
"cost": _round_cost(agent_cost),
}
)
record["agents"].append(agent_record)
return record
def hydrate(self, raw_usage: Any) -> None:
self._total_usage = Usage()
self._agent_usage.clear()
self._agent_metadata.clear()
self._total_cost = 0.0
if not isinstance(raw_usage, dict):
return
try:
self._total_usage = deserialize_usage(raw_usage)
except Exception:
logger.exception("Failed to hydrate aggregate llm_usage from run.json")
self._total_usage = Usage()
self._total_cost = _float_or_zero(raw_usage.get("cost"))
for raw_agent in raw_usage.get("agents") or []:
if not isinstance(raw_agent, dict):
continue
agent_id = str(raw_agent.get("agent_id") or "").strip()
if not agent_id:
continue
try:
self._agent_usage[agent_id] = deserialize_usage(raw_agent)
except Exception:
logger.exception("Failed to hydrate llm_usage for agent %s", agent_id)
self._agent_usage[agent_id] = Usage()
metadata: dict[str, str] = {}
agent_name = raw_agent.get("agent_name")
model = raw_agent.get("model")
if isinstance(agent_name, str) and agent_name:
metadata["agent_name"] = agent_name
if isinstance(model, str) and model:
metadata["model"] = model
self._agent_metadata[agent_id] = metadata
def _resolve_total_tokens(usage: Usage) -> int:
total = max(0, int(usage.total_tokens or 0))
if total > 0:
return total
prompt = _int_or_zero(getattr(usage, "input_tokens", 0))
completion = _int_or_zero(getattr(usage, "output_tokens", 0))
return prompt + completion
def _is_litellm_routed(model: str | None) -> bool:
if not model:
return False
name = model.strip().lower()
if "/" not in name:
return False
return not name.startswith("openai/")
def _usage_has_activity(usage: Usage) -> bool:
return bool(
usage.requests
or usage.input_tokens
or usage.output_tokens
or usage.total_tokens
or usage.request_usage_entries
)
def _estimate_litellm_cost(usage: Usage, model: str | None) -> float | None:
litellm_model = _litellm_model_name(model)
if not litellm_model:
return None
entries = list(usage.request_usage_entries)
if not entries:
return _estimate_litellm_entry_cost(usage, litellm_model)
total = 0.0
estimated_any = False
for entry in entries:
cost = _estimate_litellm_entry_cost(entry, litellm_model)
if cost is None:
continue
total += cost
estimated_any = True
return total if estimated_any else None
def _estimate_litellm_entry_cost(entry: Any, model: str) -> float | None:
prompt_tokens = _int_or_zero(getattr(entry, "input_tokens", 0))
completion_tokens = _int_or_zero(getattr(entry, "output_tokens", 0))
total_tokens = _int_or_zero(getattr(entry, "total_tokens", 0))
if total_tokens <= 0:
total_tokens = prompt_tokens + completion_tokens
if total_tokens <= 0:
return None
usage_payload: dict[str, Any] = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
prompt_details = _details_to_dict(getattr(entry, "input_tokens_details", None))
completion_details = _details_to_dict(getattr(entry, "output_tokens_details", None))
if prompt_details:
usage_payload["prompt_tokens_details"] = prompt_details
if completion_details:
usage_payload["completion_tokens_details"] = completion_details
from litellm import completion_cost
candidates = [model]
if "/" in model:
candidates.append(model.split("/", 1)[-1])
cost: Any = None
for candidate in candidates:
try:
cost = completion_cost(
completion_response={"model": candidate, "usage": usage_payload},
model=model,
)
break
except Exception: # nosec B112 # noqa: BLE001, S112
continue
if cost is None:
logger.debug("LiteLLM cost estimate unavailable for model %s", model)
return None
return cost if isinstance(cost, int | float) and cost >= 0 else None
def _litellm_model_name(model: str | None) -> str | None:
if not model:
return None
normalized = model.strip()
for prefix in ("litellm/", "any-llm/", "openai/"):
if normalized.startswith(prefix):
normalized = normalized.removeprefix(prefix)
break
return normalized or None
def _details_to_dict(details: Any) -> dict[str, Any]:
if details is None:
return {}
if isinstance(details, list):
for item in details:
result = _details_to_dict(item)
if result:
return result
return {}
if hasattr(details, "model_dump"):
return _details_to_dict(details.model_dump())
if not isinstance(details, dict):
return {}
return {str(k): v for k, v in details.items() if v is not None}
def _int_or_zero(value: Any) -> int:
try:
return max(0, int(value or 0))
except (TypeError, ValueError):
return 0
def _float_or_zero(value: Any) -> float:
try:
result = float(value or 0.0)
except (TypeError, ValueError):
return 0.0
return result if result >= 0 else 0.0
def _round_cost(cost: float) -> float:
return round(max(0.0, cost), 10)
+197
View File
@@ -0,0 +1,197 @@
"""Artifact writers for Strix scan reports."""
from __future__ import annotations
import csv
import io
import json
import logging
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from strix.core.paths import run_record_path
logger = logging.getLogger(__name__)
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
def read_run_record(run_dir: Path) -> dict[str, Any]:
path = run_record_path(run_dir)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"run.json at {path} is unreadable: {exc}") from exc
if not isinstance(data, dict):
raise TypeError(f"run.json at {path} is not an object")
return data
def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
_atomic_write_text(
run_record_path(run_dir),
json.dumps(run_record, ensure_ascii=False, indent=2, default=str),
)
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
with path.open("w", encoding="utf-8") as f:
f.write("# Security Penetration Test Report\n\n")
f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n")
f.write(f"{final_scan_result}\n")
logger.info("Saved final penetration test report to: %s", path)
def write_vulnerabilities(
run_dir: Path,
vulnerability_reports: list[dict[str, Any]],
saved_vuln_ids: set[str],
) -> int:
vuln_dir = run_dir / "vulnerabilities"
vuln_dir.mkdir(exist_ok=True)
new_reports = [r for r in vulnerability_reports if r["id"] not in saved_vuln_ids]
for report in new_reports:
_atomic_write_text(
vuln_dir / f"{report['id']}.md",
render_vulnerability_md(report),
)
saved_vuln_ids.add(report["id"])
sorted_reports = sorted(
vulnerability_reports,
key=lambda r: (_SEVERITY_ORDER.get(r["severity"], 5), r["timestamp"]),
)
csv_path = run_dir / "vulnerabilities.csv"
csv_buf = io.StringIO()
fieldnames = ["id", "title", "severity", "timestamp", "file"]
csv_writer = csv.DictWriter(csv_buf, fieldnames=fieldnames, lineterminator="\r\n")
csv_writer.writeheader()
for report in sorted_reports:
csv_writer.writerow(
{
"id": report["id"],
"title": report["title"],
"severity": report["severity"].upper(),
"timestamp": report["timestamp"],
"file": f"vulnerabilities/{report['id']}.md",
},
)
_atomic_write_text(csv_path, csv_buf.getvalue())
_atomic_write_text(
run_dir / "vulnerabilities.json",
json.dumps(vulnerability_reports, ensure_ascii=False, indent=2, default=str),
)
if new_reports:
logger.info(
"Saved %d new vulnerability report(s) to: %s",
len(new_reports),
vuln_dir,
)
logger.info("Updated vulnerability index: %s", csv_path)
return len(new_reports)
def _atomic_write_text(path: Path, payload: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as tmp:
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(path)
def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PLR0915
lines: list[str] = [
f"# {report.get('title', 'Untitled Vulnerability')}\n",
f"**ID:** {report.get('id', 'unknown')}",
f"**Severity:** {report.get('severity', 'unknown').upper()}",
f"**Found:** {report.get('timestamp', 'unknown')}",
]
metadata: list[tuple[str, Any]] = [
("Target", report.get("target")),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
("CVE", report.get("cve")),
("CWE", report.get("cwe")),
]
cvss = report.get("cvss")
if cvss is not None:
metadata.append(("CVSS", cvss))
for label, value in metadata:
if value:
lines.append(f"**{label}:** {value}")
lines.append("")
lines.append("## Description\n")
lines.append(report.get("description") or "No description provided.")
lines.append("")
if report.get("impact"):
lines.append("## Impact\n")
lines.append(str(report["impact"]))
lines.append("")
if report.get("technical_analysis"):
lines.append("## Technical Analysis\n")
lines.append(str(report["technical_analysis"]))
lines.append("")
if report.get("poc_description") or report.get("poc_script_code"):
lines.append("## Proof of Concept\n")
if report.get("poc_description"):
lines.append(str(report["poc_description"]))
lines.append("")
if report.get("poc_script_code"):
lines.append("```")
lines.append(str(report["poc_script_code"]))
lines.append("```")
lines.append("")
if report.get("code_locations"):
lines.append("## Code Analysis\n")
for i, loc in enumerate(report["code_locations"]):
file_ref = loc.get("file", "unknown")
line_ref = ""
if loc.get("start_line") is not None:
if loc.get("end_line") and loc["end_line"] != loc["start_line"]:
line_ref = f" (lines {loc['start_line']}-{loc['end_line']})"
else:
line_ref = f" (line {loc['start_line']})"
lines.append(f"**Location {i + 1}:** `{file_ref}`{line_ref}")
if loc.get("label"):
lines.append(f" {loc['label']}")
if loc.get("snippet"):
lines.append(f" ```\n {loc['snippet']}\n ```")
if loc.get("fix_before") or loc.get("fix_after"):
lines.append("\n **Suggested Fix:**")
lines.append("```diff")
if loc.get("fix_before"):
lines.extend(f"- {ln}" for ln in str(loc["fix_before"]).splitlines())
if loc.get("fix_after"):
lines.extend(f"+ {ln}" for ln in str(loc["fix_after"]).splitlines())
lines.append("```")
lines.append("")
if report.get("remediation_steps"):
lines.append("## Remediation\n")
lines.append(str(report["remediation_steps"]))
lines.append("")
return "\n".join(lines)