mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 12:22:37 +02:00
coverage: make the negative space of a scan a first-class artifact
Promote the coverage ledger from runtime state to a deliverable: coverage.json beside vulnerabilities.json, a Coverage section rendered into the report from the ledger rather than transcribed by an agent, and SARIF pass / notApplicable / open results so a consumer can tell 'tested and clean' from 'never tested'. Ground it in what the runtime observed rather than only what agents claimed: a risk class an agent carried a skill for and never accounted for is published as a gap (and surfaced back to the root agent from finish_scan while it can still act), and a run cut short is stamped incomplete on both the artifact and the SARIF invocation. Also: make the ledger's duplicate check and insertion one critical section and persist under the lock; key a checkout and the URL it was cloned from onto one threat-model identity; render the calibration metadata (counterevidence, confidence, severity change conditions, fix verification) that was being stored and then dropped.
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
"""``coverage.json`` — the negative space of a scan, with provenance.
|
||||
|
||||
A findings list answers "what is wrong". It cannot answer "what did you
|
||||
check", and in a compliance context that second question is the one that
|
||||
decides whether a clean result means anything: an auditor reading zero SQL
|
||||
injection findings cannot tell "tested fourteen endpoints, all parameterized"
|
||||
apart from "never looked".
|
||||
|
||||
This module assembles the artifact that answers it. Two kinds of statement go
|
||||
in, and they are kept apart on purpose:
|
||||
|
||||
- ``agent_reported`` — the coverage ledger (:mod:`strix.tools.coverage.tools`).
|
||||
Rich and specific, but it is an agent's account of its own work.
|
||||
- ``machine_observed`` — facts the runtime recorded regardless of what any
|
||||
agent claimed: which agents ran and how they terminated, which skills they
|
||||
carried, how many findings were filed, whether the run finished or was cut
|
||||
short.
|
||||
|
||||
A coverage claim is an attestation, so conflating the two would be the worst
|
||||
possible failure: a hallucinated "tested and clean" is strictly less honest
|
||||
than no coverage section at all. Every entry therefore carries its ``source``,
|
||||
and machine-observed facts contradict rather than confirm — an agent that
|
||||
carried the ``sql_injection`` skill and recorded nothing about SQL injection
|
||||
shows up under ``gaps``, and a run that hit its budget ceiling is stamped
|
||||
``complete: false`` no matter how tidy the ledger looks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from strix.report.writer import atomic_write_text
|
||||
from strix.skills import get_available_skills
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COVERAGE_FILENAME = "coverage.json"
|
||||
COVERAGE_SCHEMA_VERSION = 1
|
||||
|
||||
#: Ledger outcomes rendered for a reader who has never seen our enum.
|
||||
OUTCOME_LABELS: dict[str, str] = {
|
||||
"reported": "Finding reported",
|
||||
"no_issue_found": "No issue identified",
|
||||
"ruled_out": "Ruled out",
|
||||
"not_applicable": "Not applicable",
|
||||
"needs_follow_up": "Requires further review",
|
||||
}
|
||||
|
||||
#: Statuses that mean the agent stopped early rather than finishing its task.
|
||||
_INCOMPLETE_AGENT_STATUSES = frozenset({"crashed", "stopped", "running", "waiting"})
|
||||
|
||||
#: Run statuses that mean the scan itself did not run to completion.
|
||||
_INCOMPLETE_RUN_STATUSES = frozenset({"failed", "interrupted", "stopped", "running"})
|
||||
|
||||
#: Only this skill category names a vulnerability class. ``tooling`` and
|
||||
#: ``reconnaissance`` skills describe how an agent works, not what it hunts,
|
||||
#: so holding one implies no coverage obligation.
|
||||
_RISK_SKILL_CATEGORY = "vulnerabilities"
|
||||
|
||||
#: Alternate phrasings for skills whose common name and whose canonical risk
|
||||
#: wording share no words. Matching a skill to a ledger row is textual, so
|
||||
#: without these an agent that recorded "object-level authorization" would be
|
||||
#: reported as never having looked at ``idor``. Only genuine synonyms belong
|
||||
#: here — a wrong alias suppresses a real gap, which is the costlier error.
|
||||
_SKILL_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"idor": ("object level authorization", "bola", "broken object level"),
|
||||
"xss": ("cross site scripting",),
|
||||
"csrf": ("cross site request forgery",),
|
||||
"ssrf": ("server side request forgery",),
|
||||
"rce": ("remote code execution", "command injection"),
|
||||
"sqli": ("sql injection",),
|
||||
"xxe": ("xml external entity",),
|
||||
"lfi": ("local file inclusion", "path traversal"),
|
||||
"ssti": ("server side template injection",),
|
||||
}
|
||||
|
||||
|
||||
def read_agent_graph(state_dir: Path) -> dict[str, Any]:
|
||||
"""Load the coordinator's snapshot, or ``{}`` when it isn't readable.
|
||||
|
||||
The snapshot is the runtime's own record of the agent tree, written on
|
||||
every graph mutation. Reading it here (rather than holding a coordinator
|
||||
reference) keeps artifact assembly usable from a finished or resumed run,
|
||||
where the live coordinator is gone but the file is still on disk.
|
||||
"""
|
||||
path = state_dir / "agents.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("agent graph snapshot at %s is unreadable", path, exc_info=True)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _normalized(text: str) -> str:
|
||||
"""Lowercase *text* with punctuation flattened to spaces, for matching."""
|
||||
return "".join(char if char.isalnum() else " " for char in text.lower())
|
||||
|
||||
|
||||
def _skill_leaf(skill: str) -> str:
|
||||
return skill.rsplit("/", maxsplit=1)[-1].strip().lower()
|
||||
|
||||
|
||||
def _risk_skill_names() -> frozenset[str]:
|
||||
"""Bare names of every skill that denotes a vulnerability class."""
|
||||
try:
|
||||
return frozenset(get_available_skills().get(_RISK_SKILL_CATEGORY, ()))
|
||||
except OSError:
|
||||
logger.warning("could not enumerate skills for coverage gaps", exc_info=True)
|
||||
return frozenset()
|
||||
|
||||
|
||||
def agents_from_graph(graph: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Flatten the coordinator snapshot into one record per agent."""
|
||||
statuses = graph.get("statuses")
|
||||
if not isinstance(statuses, dict):
|
||||
return []
|
||||
raw_names = graph.get("names")
|
||||
names: dict[str, Any] = raw_names if isinstance(raw_names, dict) else {}
|
||||
raw_metadata = graph.get("metadata")
|
||||
metadata: dict[str, Any] = raw_metadata if isinstance(raw_metadata, dict) else {}
|
||||
|
||||
agents: list[dict[str, Any]] = []
|
||||
for agent_id, status in statuses.items():
|
||||
raw_meta = metadata.get(agent_id)
|
||||
meta: dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
|
||||
raw_skills = meta.get("skills")
|
||||
skills: list[Any] = raw_skills if isinstance(raw_skills, list) else []
|
||||
agents.append(
|
||||
{
|
||||
"agent_id": agent_id,
|
||||
"agent_name": names.get(agent_id) or agent_id,
|
||||
"status": str(status),
|
||||
"skills": [str(skill) for skill in skills],
|
||||
"task": str(meta.get("task") or ""),
|
||||
}
|
||||
)
|
||||
agents.sort(key=lambda agent: str(agent["agent_name"]))
|
||||
return agents
|
||||
|
||||
|
||||
def _skill_phrasings(skill: str) -> list[list[str]]:
|
||||
"""Word lists that would each count as a ledger row naming *skill*."""
|
||||
phrasings = [skill, *_SKILL_ALIASES.get(skill, ())]
|
||||
return [terms for phrase in phrasings if (terms := _normalized(phrase).split())]
|
||||
|
||||
|
||||
def _entry_is_about(entry: dict[str, Any], phrasings: list[list[str]]) -> bool:
|
||||
"""True when a ledger row plausibly concerns any phrasing of a risk class."""
|
||||
haystack = _normalized(f"{entry.get('risk_area', '')} {entry.get('surface', '')}")
|
||||
return any(all(term in haystack for term in terms) for terms in phrasings)
|
||||
|
||||
|
||||
def skill_coverage_gaps(
|
||||
entries: list[dict[str, Any]], agents: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Vulnerability classes an agent was equipped for but never recorded.
|
||||
|
||||
A skill assigned to an agent is a declaration of intent that the runtime
|
||||
observed independently of anything the agent later said. When no ledger
|
||||
row mentions that class, the class is unaccounted for — which is a very
|
||||
different report line from "tested, nothing found".
|
||||
"""
|
||||
risk_skills = _risk_skill_names()
|
||||
if not risk_skills:
|
||||
return []
|
||||
|
||||
carriers: dict[str, list[str]] = {}
|
||||
for agent in agents:
|
||||
for skill in agent["skills"]:
|
||||
leaf = _skill_leaf(skill)
|
||||
if leaf in risk_skills:
|
||||
carriers.setdefault(leaf, []).append(str(agent["agent_name"]))
|
||||
|
||||
gaps: list[dict[str, Any]] = []
|
||||
for skill, agent_names in sorted(carriers.items()):
|
||||
phrasings = _skill_phrasings(skill)
|
||||
if any(_entry_is_about(entry, phrasings) for entry in entries):
|
||||
continue
|
||||
gaps.append(
|
||||
{
|
||||
"kind": "unrecorded_risk_class",
|
||||
"risk_area": skill.replace("_", " "),
|
||||
"detail": (
|
||||
f"Agent(s) {', '.join(sorted(set(agent_names)))} were assigned the "
|
||||
f"'{skill}' skill, but no coverage entry records this class being "
|
||||
"assessed. Treat it as unexamined, not as clean."
|
||||
),
|
||||
}
|
||||
)
|
||||
return gaps
|
||||
|
||||
|
||||
def _silent_agent_gaps(
|
||||
entries: list[dict[str, Any]], agents: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Agents that ran and recorded nothing at all."""
|
||||
recorded_ids = {str(entry.get("agent_id")) for entry in entries if entry.get("agent_id")}
|
||||
gaps: list[dict[str, Any]] = []
|
||||
for agent in agents:
|
||||
if agent["agent_id"] in recorded_ids:
|
||||
continue
|
||||
gaps.append(
|
||||
{
|
||||
"kind": "agent_recorded_no_coverage",
|
||||
"agent_name": agent["agent_name"],
|
||||
"detail": (
|
||||
f"{agent['agent_name']} ran (status: {agent['status']}) without "
|
||||
"recording any coverage. Whatever it examined is absent from this "
|
||||
"record."
|
||||
),
|
||||
}
|
||||
)
|
||||
return gaps
|
||||
|
||||
|
||||
def _unresolved_gaps(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Ledger rows the agents themselves left open."""
|
||||
return [
|
||||
{
|
||||
"kind": "needs_follow_up",
|
||||
"surface": entry.get("surface", ""),
|
||||
"risk_area": entry.get("risk_area", ""),
|
||||
"detail": str(entry.get("evidence") or "Left open without a stated reason."),
|
||||
}
|
||||
for entry in entries
|
||||
if entry.get("outcome") == "needs_follow_up"
|
||||
]
|
||||
|
||||
|
||||
def _completeness(
|
||||
run_record: dict[str, Any],
|
||||
agents: list[dict[str, Any]],
|
||||
exit_reason: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Whether this record can be read as a complete account of the scan.
|
||||
|
||||
Any of these makes it partial, and the caveats say which: the run did not
|
||||
reach ``completed``, an agent was still live or died when the scan ended,
|
||||
or the run stopped for a reason other than the root agent deciding it was
|
||||
done (budget ceilings are the common case).
|
||||
"""
|
||||
status = str(run_record.get("status") or "unknown")
|
||||
caveats: list[str] = []
|
||||
|
||||
if status in _INCOMPLETE_RUN_STATUSES:
|
||||
caveats.append(
|
||||
f"The scan ended with status '{status}' rather than completing, so coverage "
|
||||
"reflects only the work finished before it stopped."
|
||||
)
|
||||
unfinished = [agent for agent in agents if agent["status"] in _INCOMPLETE_AGENT_STATUSES]
|
||||
if unfinished:
|
||||
names = ", ".join(sorted(str(agent["agent_name"]) for agent in unfinished))
|
||||
caveats.append(
|
||||
f"{len(unfinished)} agent(s) did not finish cleanly ({names}); any surface they "
|
||||
"held is under-covered."
|
||||
)
|
||||
if exit_reason and exit_reason not in {"finished_by_tool", "completed"}:
|
||||
caveats.append(
|
||||
f"The run terminated via '{exit_reason}' rather than the root agent finishing, "
|
||||
"so remaining scope was not reached."
|
||||
)
|
||||
|
||||
return {
|
||||
"complete": not caveats,
|
||||
"scan_status": status,
|
||||
"exit_reason": exit_reason,
|
||||
"caveats": caveats,
|
||||
}
|
||||
|
||||
|
||||
def _outcome_counts(entries: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
outcome = str(entry.get("outcome", ""))
|
||||
counts[outcome] = counts.get(outcome, 0) + 1
|
||||
return {label: counts[label] for label in OUTCOME_LABELS if label in counts}
|
||||
|
||||
|
||||
def build_coverage_document(
|
||||
*,
|
||||
run_record: dict[str, Any],
|
||||
entries: list[dict[str, Any]],
|
||||
agent_graph: dict[str, Any],
|
||||
vulnerability_reports: list[dict[str, Any]],
|
||||
exit_reason: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble the ``coverage.json`` document."""
|
||||
agents = agents_from_graph(agent_graph)
|
||||
skills_exercised = sorted(
|
||||
{_skill_leaf(skill) for agent in agents for skill in agent["skills"] if skill}
|
||||
)
|
||||
|
||||
ledger = [
|
||||
{
|
||||
"surface": entry.get("surface", ""),
|
||||
"risk_area": entry.get("risk_area", ""),
|
||||
"outcome": entry.get("outcome", ""),
|
||||
"outcome_label": OUTCOME_LABELS.get(str(entry.get("outcome", "")), ""),
|
||||
"evidence": entry.get("evidence", ""),
|
||||
"recorded_by": entry.get("agent_name", ""),
|
||||
"recorded_at": entry.get("created_at", ""),
|
||||
"updated_at": entry.get("updated_at", ""),
|
||||
"previous_outcomes": [
|
||||
str(previous.get("outcome", ""))
|
||||
for previous in entry.get("history", [])
|
||||
if isinstance(previous, dict)
|
||||
],
|
||||
"source": "agent_reported",
|
||||
}
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
gaps = [
|
||||
*_unresolved_gaps(entries),
|
||||
*skill_coverage_gaps(entries, agents),
|
||||
*_silent_agent_gaps(entries, agents),
|
||||
]
|
||||
|
||||
return {
|
||||
"schema_version": COVERAGE_SCHEMA_VERSION,
|
||||
"generated_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
"run_id": run_record.get("run_id"),
|
||||
"run_name": run_record.get("run_name"),
|
||||
"scope": {
|
||||
"targets": run_record.get("targets_info") or [],
|
||||
"scan_mode": run_record.get("scan_mode"),
|
||||
"scope_mode": run_record.get("scope_mode"),
|
||||
"diff_scope": run_record.get("diff_scope"),
|
||||
"instruction": run_record.get("instruction") or "",
|
||||
},
|
||||
"summary": {
|
||||
"surfaces_reviewed": len(ledger),
|
||||
"outcomes": _outcome_counts(entries),
|
||||
"findings_filed": len(vulnerability_reports),
|
||||
"gaps": len(gaps),
|
||||
},
|
||||
"machine_observed": {
|
||||
"agents": agents,
|
||||
"skills_exercised": skills_exercised,
|
||||
"findings_filed": len(vulnerability_reports),
|
||||
"source": "runtime",
|
||||
},
|
||||
"completeness": _completeness(run_record, agents, exit_reason),
|
||||
"entries": ledger,
|
||||
"gaps": gaps,
|
||||
}
|
||||
|
||||
|
||||
def write_coverage(run_dir: Path, document: dict[str, Any]) -> Path:
|
||||
"""Write ``coverage.json`` into the run directory and return its path."""
|
||||
path = run_dir / COVERAGE_FILENAME
|
||||
atomic_write_text(path, json.dumps(document, ensure_ascii=False, indent=2, default=str))
|
||||
logger.info(
|
||||
"Saved coverage record to: %s (%d surface(s), %d gap(s))",
|
||||
path,
|
||||
len(document.get("entries", [])),
|
||||
len(document.get("gaps", [])),
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def render_coverage_markdown(document: dict[str, Any]) -> str:
|
||||
"""Render the coverage appendix appended to the executive report.
|
||||
|
||||
Built from the document rather than written by an agent, so the table in
|
||||
the client-facing report cannot drift from the ledger it summarizes.
|
||||
"""
|
||||
summary = document.get("summary", {})
|
||||
completeness = document.get("completeness", {})
|
||||
entries = document.get("entries", [])
|
||||
gaps = document.get("gaps", [])
|
||||
|
||||
lines = [
|
||||
"# Coverage",
|
||||
"",
|
||||
"Surfaces assessed during this engagement and how each was closed. Entries are "
|
||||
"reported by the testing agents; the completeness notes below are recorded by "
|
||||
"the runtime.",
|
||||
"",
|
||||
f"**Surfaces reviewed:** {summary.get('surfaces_reviewed', 0)} ",
|
||||
f"**Findings filed:** {summary.get('findings_filed', 0)} ",
|
||||
f"**Open items and gaps:** {summary.get('gaps', 0)}",
|
||||
"",
|
||||
]
|
||||
|
||||
if entries:
|
||||
lines += [
|
||||
"| Surface | Risk Area | Outcome | Evidence |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
lines += [
|
||||
"| {} | {} | {} | {} |".format(
|
||||
_cell(entry.get("surface")),
|
||||
_cell(entry.get("risk_area")),
|
||||
_cell(entry.get("outcome_label") or entry.get("outcome")),
|
||||
_cell(entry.get("evidence")),
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
lines.append("")
|
||||
else:
|
||||
lines += [
|
||||
"No surfaces were recorded for this scan. The absence of findings below "
|
||||
"cannot be read as evidence that any particular area was tested.",
|
||||
"",
|
||||
]
|
||||
|
||||
if gaps:
|
||||
lines += ["## Not Covered", ""]
|
||||
lines += [f"- {_inline(gap.get('detail'))}" for gap in gaps]
|
||||
lines.append("")
|
||||
|
||||
caveats = completeness.get("caveats") or []
|
||||
if caveats:
|
||||
lines += ["## Completeness", ""]
|
||||
lines += [f"- {_inline(caveat)}" for caveat in caveats]
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cell(value: Any) -> str:
|
||||
"""Flatten *value* for a markdown table cell."""
|
||||
text = _inline(value)
|
||||
return text.replace("|", "\\|") if text else "—"
|
||||
|
||||
|
||||
def _inline(value: Any) -> str:
|
||||
"""Collapse *value* to a single line of plain text."""
|
||||
return " ".join(str(value or "").split())
|
||||
@@ -40,6 +40,15 @@ Design notes:
|
||||
* Findings without safe locations still appear in the SARIF output,
|
||||
anchored to SECURITY.md and flagged via
|
||||
``properties.synthetic_location`` rather than being dropped silently.
|
||||
* Coverage — what was examined and cleared — rides in the same document
|
||||
as non-failing results (``kind`` of ``pass`` / ``notApplicable`` /
|
||||
``open``, which SARIF defines precisely for "the rule ran and did not
|
||||
fire"). Consumers that only want alerts filter on ``kind == "fail"``
|
||||
and are unaffected; consumers that need to distinguish "tested and
|
||||
clean" from "never tested" now can.
|
||||
* Whether the run itself completed is recorded on ``run.invocations``:
|
||||
``executionSuccessful`` plus a ``toolExecutionNotifications`` entry per
|
||||
completeness caveat. A truncated run must not read as a clean one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -199,6 +208,7 @@ def build_sarif_report(
|
||||
*,
|
||||
tool_version: str | None = None,
|
||||
repository_context: dict[str, Any] | None = None,
|
||||
coverage: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a SARIF 2.1.0 document for findings.
|
||||
|
||||
@@ -209,6 +219,12 @@ def build_sarif_report(
|
||||
can bind alerts to the scanned commit; it is omitted for URL / IP
|
||||
(DAST) targets that have no repository.
|
||||
|
||||
``coverage`` (optional) is the document from
|
||||
:func:`strix.report.coverage.build_coverage_document`. Its cleared
|
||||
surfaces become non-failing results and its completeness caveats become
|
||||
invocation notifications, so a consumer can tell an untested area from a
|
||||
tested-and-clean one without reading a second artifact.
|
||||
|
||||
Findings without safe source locations are anchored synthetically
|
||||
to SECURITY.md and flagged via ``properties.synthetic_location``.
|
||||
They're still emitted as proper SARIF results so they (a) flow
|
||||
@@ -247,6 +263,9 @@ def build_sarif_report(
|
||||
)
|
||||
)
|
||||
|
||||
if coverage:
|
||||
_append_coverage(coverage, rules_by_id, rule_index_by_id, results)
|
||||
|
||||
driver: dict[str, Any] = {
|
||||
"name": TOOL_NAME,
|
||||
"informationUri": TOOL_INFORMATION_URI,
|
||||
@@ -260,6 +279,9 @@ def build_sarif_report(
|
||||
"results": results,
|
||||
}
|
||||
|
||||
if coverage:
|
||||
run["invocations"] = [_coverage_invocation(coverage)]
|
||||
|
||||
run_properties: dict[str, Any] = {}
|
||||
if synthetic_location_count:
|
||||
# Surface the count for observability without duplicating the
|
||||
@@ -292,6 +314,7 @@ def write_sarif_report(
|
||||
*,
|
||||
tool_version: str | None = None,
|
||||
repository_context: dict[str, Any] | None = None,
|
||||
coverage: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Write a SARIF report to disk, creating parent directories first.
|
||||
|
||||
@@ -304,6 +327,7 @@ def write_sarif_report(
|
||||
vulnerability_reports,
|
||||
tool_version=tool_version,
|
||||
repository_context=repository_context,
|
||||
coverage=coverage,
|
||||
)
|
||||
tmp_path = output_path.with_name(f"{output_path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
@@ -321,6 +345,7 @@ def write_sarif(
|
||||
*,
|
||||
tool_version: str | None = None,
|
||||
repository_context: dict[str, Any] | None = None,
|
||||
coverage: dict[str, Any] | None = None,
|
||||
filename: str = "findings.sarif",
|
||||
) -> Path:
|
||||
"""Write ``findings.sarif`` alongside existing outputs in ``run_dir``.
|
||||
@@ -335,6 +360,7 @@ def write_sarif(
|
||||
reports,
|
||||
tool_version=tool_version,
|
||||
repository_context=repository_context,
|
||||
coverage=coverage,
|
||||
)
|
||||
logger.info(
|
||||
"Wrote SARIF 2.1.0 report: %s (%d results)",
|
||||
@@ -526,6 +552,14 @@ def _result_properties(
|
||||
"impact",
|
||||
"technical_analysis",
|
||||
"remediation_steps",
|
||||
# Calibration metadata. A downstream triager deciding whether to act
|
||||
# on an alert needs the case against it and how firm the call is, not
|
||||
# just the case for it.
|
||||
"counterevidence",
|
||||
"confidence",
|
||||
"confidence_rationale",
|
||||
"severity_change_conditions",
|
||||
"fix_verification",
|
||||
):
|
||||
value = report.get(key)
|
||||
if value not in (None, ""):
|
||||
@@ -613,6 +647,130 @@ def _build_fixes(report: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||||
return [fix]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_COVERAGE_RULE_PREFIX = "strix-coverage"
|
||||
|
||||
# SARIF's ``result.kind`` already models every closure state the coverage
|
||||
# ledger tracks, so no bespoke vocabulary is needed: ``pass`` is "checked and
|
||||
# it holds", ``notApplicable`` is "the rule does not apply to this artifact",
|
||||
# ``open`` is "a reviewer still has to decide". ``reported`` is absent on
|
||||
# purpose — those surfaces are already in ``results`` as real ``fail``
|
||||
# findings, and emitting them twice would double-count them.
|
||||
_OUTCOME_TO_KIND = {
|
||||
"no_issue_found": "pass",
|
||||
"ruled_out": "pass",
|
||||
"not_applicable": "notApplicable",
|
||||
"needs_follow_up": "open",
|
||||
}
|
||||
|
||||
|
||||
def _coverage_rule_id(risk_area: str) -> str:
|
||||
slug = _slugify(risk_area) or "unspecified"
|
||||
return f"{_COVERAGE_RULE_PREFIX}/{slug}"
|
||||
|
||||
|
||||
def _build_coverage_rule(rule_id: str, risk_area: str) -> dict[str, Any]:
|
||||
"""A rule descriptor for a risk area that was assessed but did not fire."""
|
||||
description = f"Coverage of {risk_area} across the assessed attack surface."
|
||||
return {
|
||||
"id": rule_id,
|
||||
"name": _rule_name(rule_id, risk_area),
|
||||
"shortDescription": {"text": f"Coverage: {risk_area}"},
|
||||
"fullDescription": {"text": description},
|
||||
# Coverage results never raise an alert; the level lives on the
|
||||
# result as ``none`` and the rule's default has to agree.
|
||||
"defaultConfiguration": {"level": "none"},
|
||||
"help": {"text": description, "markdown": description},
|
||||
"properties": {"tags": ["coverage"]},
|
||||
}
|
||||
|
||||
|
||||
def _build_coverage_result(
|
||||
rule_id: str,
|
||||
rule_index: int,
|
||||
kind: str,
|
||||
entry: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""One non-failing result recording that a surface was assessed."""
|
||||
surface = _string_value(entry.get("surface")) or "unspecified surface"
|
||||
risk_area = _string_value(entry.get("risk_area")) or "unspecified risk"
|
||||
evidence = _string_value(entry.get("evidence"))
|
||||
label = _string_value(entry.get("outcome_label")) or str(entry.get("outcome", ""))
|
||||
|
||||
message = f"{risk_area} — {label}: {surface}"
|
||||
if evidence:
|
||||
message = f"{message}\n\n{evidence}"
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ruleId": rule_id,
|
||||
"ruleIndex": rule_index,
|
||||
"kind": kind,
|
||||
# SARIF requires ``level: none`` for any result whose kind is not
|
||||
# ``fail``; anything else makes the document invalid.
|
||||
"level": "none",
|
||||
"message": {"text": message},
|
||||
"locations": [{"logicalLocations": [{"fullyQualifiedName": surface}]}],
|
||||
"properties": {
|
||||
"strix": {
|
||||
"coverage_outcome": entry.get("outcome", ""),
|
||||
"risk_area": risk_area,
|
||||
"surface": surface,
|
||||
"recorded_by": entry.get("recorded_by", ""),
|
||||
"source": entry.get("source", "agent_reported"),
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _append_coverage(
|
||||
coverage: dict[str, Any],
|
||||
rules_by_id: dict[str, dict[str, Any]],
|
||||
rule_index_by_id: dict[str, int],
|
||||
results: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Add the coverage ledger's cleared surfaces to an in-progress run."""
|
||||
entries = coverage.get("entries")
|
||||
if not isinstance(entries, list):
|
||||
return
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
kind = _OUTCOME_TO_KIND.get(str(entry.get("outcome", "")))
|
||||
if kind is None:
|
||||
continue
|
||||
rule_id = _coverage_rule_id(str(entry.get("risk_area", "")))
|
||||
if rule_id not in rules_by_id:
|
||||
rule_index_by_id[rule_id] = len(rules_by_id)
|
||||
rules_by_id[rule_id] = _build_coverage_rule(
|
||||
rule_id, _string_value(entry.get("risk_area")) or "unspecified risk"
|
||||
)
|
||||
results.append(_build_coverage_result(rule_id, rule_index_by_id[rule_id], kind, entry))
|
||||
|
||||
|
||||
def _coverage_invocation(coverage: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record whether the run was complete enough for its results to be final.
|
||||
|
||||
``executionSuccessful: false`` is the standard signal that a consumer must
|
||||
not read "no results" as "nothing to find" — a scan cut short by its
|
||||
budget produces a document that otherwise looks identical to a clean one.
|
||||
"""
|
||||
completeness = coverage.get("completeness")
|
||||
completeness = completeness if isinstance(completeness, dict) else {}
|
||||
caveats = completeness.get("caveats")
|
||||
caveats = caveats if isinstance(caveats, list) else []
|
||||
|
||||
invocation: dict[str, Any] = {"executionSuccessful": bool(completeness.get("complete", True))}
|
||||
if caveats:
|
||||
invocation["toolExecutionNotifications"] = [
|
||||
{"level": "warning", "message": {"text": str(caveat)}} for caveat in caveats
|
||||
]
|
||||
return invocation
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+37
-2
@@ -13,7 +13,8 @@ from agents.usage import Usage
|
||||
|
||||
from strix.config import codex
|
||||
from strix.config.loader import load_settings
|
||||
from strix.core.paths import run_dir_for
|
||||
from strix.core.paths import run_dir_for, runtime_state_dir
|
||||
from strix.report.coverage import render_coverage_markdown, write_coverage
|
||||
from strix.report.sarif import write_sarif
|
||||
from strix.report.usage import LLMUsageLedger
|
||||
from strix.report.writer import (
|
||||
@@ -432,14 +433,47 @@ class ReportState:
|
||||
{str(scan_results.get("recommendations", "")).strip()}
|
||||
"""
|
||||
|
||||
def _coverage_document(self) -> dict[str, Any] | None:
|
||||
"""Assemble the coverage record, or None when it can't be built.
|
||||
|
||||
Coverage is a secondary artifact: a failure here must not cost the
|
||||
caller its findings, so this swallows and logs rather than raising
|
||||
into :meth:`_save_artifacts`.
|
||||
"""
|
||||
try:
|
||||
from strix.report.coverage import build_coverage_document, read_agent_graph
|
||||
from strix.tools.coverage.tools import get_coverage_entries
|
||||
|
||||
return build_coverage_document(
|
||||
run_record=self.run_record,
|
||||
entries=get_coverage_entries(),
|
||||
agent_graph=read_agent_graph(runtime_state_dir(self.get_run_dir())),
|
||||
vulnerability_reports=self.vulnerability_reports,
|
||||
exit_reason=self.scan_ended_exit_reason,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("coverage document build failed (non-fatal)")
|
||||
return None
|
||||
|
||||
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)
|
||||
|
||||
coverage = self._coverage_document()
|
||||
if coverage is not None:
|
||||
try:
|
||||
write_coverage(run_dir, coverage)
|
||||
except OSError:
|
||||
logger.exception("coverage.json write failed (non-fatal)")
|
||||
|
||||
if self.final_scan_result:
|
||||
write_executive_report(run_dir, self.final_scan_result)
|
||||
write_executive_report(
|
||||
run_dir,
|
||||
self.final_scan_result,
|
||||
render_coverage_markdown(coverage) if coverage else None,
|
||||
)
|
||||
|
||||
if self.vulnerability_reports:
|
||||
write_vulnerabilities(run_dir, self.vulnerability_reports, self._saved_vuln_ids)
|
||||
@@ -456,6 +490,7 @@ class ReportState:
|
||||
self.vulnerability_reports,
|
||||
tool_version=_strix_version(),
|
||||
repository_context=self._sarif_repository_context(),
|
||||
coverage=coverage,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("SARIF emit failed (non-fatal; CSV/MD unaffected)")
|
||||
|
||||
+47
-10
@@ -107,18 +107,32 @@ def read_run_record(run_dir: Path) -> dict[str, Any]:
|
||||
|
||||
|
||||
def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
|
||||
_atomic_write_text(
|
||||
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:
|
||||
def write_executive_report(
|
||||
run_dir: Path,
|
||||
final_scan_result: str,
|
||||
coverage_markdown: str | None = None,
|
||||
) -> None:
|
||||
"""Write the client-facing report, optionally with a coverage appendix.
|
||||
|
||||
``coverage_markdown`` is rendered from the coverage ledger rather than
|
||||
authored by an agent, so what the report claims was examined stays tied to
|
||||
what was actually recorded.
|
||||
"""
|
||||
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")
|
||||
sections = [
|
||||
"# Security Penetration Test Report\n",
|
||||
f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n",
|
||||
f"{final_scan_result}\n",
|
||||
]
|
||||
if coverage_markdown:
|
||||
sections.append(f"{coverage_markdown.rstrip()}\n")
|
||||
atomic_write_text(path, "\n".join(sections))
|
||||
logger.info("Saved final penetration test report to: %s", path)
|
||||
|
||||
|
||||
@@ -133,7 +147,7 @@ def write_vulnerabilities(
|
||||
new_reports = [r for r in vulnerability_reports if r["id"] not in saved_vuln_ids]
|
||||
|
||||
for report in new_reports:
|
||||
_atomic_write_text(
|
||||
atomic_write_text(
|
||||
vuln_dir / f"{report['id']}.md",
|
||||
render_vulnerability_md(report),
|
||||
)
|
||||
@@ -158,9 +172,9 @@ def write_vulnerabilities(
|
||||
"file": f"vulnerabilities/{report['id']}.md",
|
||||
},
|
||||
)
|
||||
_atomic_write_text(csv_path, csv_buf.getvalue())
|
||||
atomic_write_text(csv_path, csv_buf.getvalue())
|
||||
|
||||
_atomic_write_text(
|
||||
atomic_write_text(
|
||||
run_dir / "vulnerabilities.json",
|
||||
json.dumps(vulnerability_reports, ensure_ascii=False, indent=2, default=str),
|
||||
)
|
||||
@@ -175,7 +189,8 @@ def write_vulnerabilities(
|
||||
return len(new_reports)
|
||||
|
||||
|
||||
def _atomic_write_text(path: Path, payload: str) -> None:
|
||||
def atomic_write_text(path: Path, payload: str) -> None:
|
||||
"""Write *payload* to *path* via a sibling temp file and an atomic rename."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
@@ -215,6 +230,8 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
cvss = report.get("cvss")
|
||||
if cvss is not None:
|
||||
metadata.append(("CVSS", cvss))
|
||||
if report.get("confidence"):
|
||||
metadata.append(("Confidence", str(report["confidence"]).title()))
|
||||
if report.get("fix_effort"):
|
||||
metadata.append(("Fix Effort", str(report["fix_effort"]).title()))
|
||||
for label, value in metadata:
|
||||
@@ -236,6 +253,21 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["impact"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("counterevidence"):
|
||||
lines.append("## Counterevidence\n")
|
||||
lines.append(str(report["counterevidence"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("confidence_rationale"):
|
||||
lines.append("## Confidence Rationale\n")
|
||||
lines.append(str(report["confidence_rationale"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("severity_change_conditions"):
|
||||
lines.append("## What Would Change This Severity\n")
|
||||
lines.append(str(report["severity_change_conditions"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("technical_analysis"):
|
||||
lines.append("## Technical Analysis\n")
|
||||
lines.append(str(report["technical_analysis"]))
|
||||
@@ -289,6 +321,11 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL
|
||||
lines.append(str(report["remediation_steps"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("fix_verification"):
|
||||
lines.append("## Fix Verification\n")
|
||||
lines.append(str(report["fix_verification"]))
|
||||
lines.append("")
|
||||
|
||||
if report.get("assumptions"):
|
||||
lines.append("## Assumptions\n")
|
||||
lines.append(str(report["assumptions"]))
|
||||
|
||||
Reference in New Issue
Block a user