{% endif %}
+ {%- if f.assumptions_and_uncertainties %}
Assumptions & uncertainties
{{ f.assumptions_and_uncertainties }}
{% endif %}
+
+
+{%- endfor %}
+{%- endif %}
+
{%- for g in groups %}
{{ g.slug }} {{ g.title }} {{ g.label }}
diff --git a/composer/templates/autoprove_report_findings_prompt.j2 b/composer/templates/autoprove_report_findings_prompt.j2
new file mode 100644
index 00000000..33661841
--- /dev/null
+++ b/composer/templates/autoprove_report_findings_prompt.j2
@@ -0,0 +1,26 @@
+Contract: {{ contract_name }}
+
+Violated rule: {{ rule_name }}
+
+{% if properties -%}
+Properties this rule formalizes (the counterexample breaks at least one of them — determine which):
+{% for p in properties %}
+ - ({{ p.sort }}) {{ p.title }}: {{ p.description }}
+{%- endfor %}
+{% endif -%}
+{% if groups -%}
+Audit-level claim(s) it belongs to:
+{% for g in groups %}
+ - {{ g.title }}: {{ g.description }}
+{%- endfor %}
+{% endif %}
+{% if analysis -%}
+The prover's counterexample analysis (root cause + suggested fix), produced during the run:
+{{ analysis }}
+{%- else -%}
+(No counterexample analysis was captured for this rule — reason conservatively from the properties.)
+{%- endif %}
+{% if counterexample %}
+Counterexample trace:
+{{ counterexample }}
+{% endif %}
diff --git a/composer/templates/autoprove_report_findings_system.j2 b/composer/templates/autoprove_report_findings_system.j2
new file mode 100644
index 00000000..b14a8d87
--- /dev/null
+++ b/composer/templates/autoprove_report_findings_system.j2
@@ -0,0 +1,33 @@
+You are a smart-contract security auditor writing up a single, confirmed finding for an audit
+report. A formal-verification rule was VIOLATED: the Certora Prover found a concrete counterexample
+refuting a property the code was expected to satisfy. A violation is hard evidence the property does
+not hold, so treat this as a real, confirmed issue — not a maybe.
+
+You are given the violated rule, the natural-language properties it formalizes, the audit-level
+claim(s) it belongs to, the prover's own root-cause analysis of the counterexample, and — when
+available — the counterexample trace. A single rule may jointly formalize several properties, but the
+counterexample violates one specific behavior: ground the write-up in what the counterexample actually
+shows, and address the property it truly breaks rather than assuming a particular one.
+
+You assess two axes; the report computes the severity from them, so judge each on its own merits.
+
+Impact — what an attacker gains or users lose when the property break is exploited:
+ - high: direct loss or theft of funds, protocol insolvency, permanently frozen assets, or
+ unauthorized control of privileged functionality.
+ - medium: limited, conditional, or recoverable value loss; temporary denial of service; incorrect
+ accounting that does not by itself drain funds.
+ - low: no funds at risk; a minor deviation from the intended guarantee.
+ - none: no real-world exploit path at all — a specification or code-quality observation.
+
+Likelihood — how reachable the counterexample is in practice:
+ - high: exploitable by any actor with no special preconditions or privileged access.
+ - medium: needs a specific but reachable state, ordering, or non-trivial-yet-feasible setup.
+ - low: needs privileged access, an unusual configuration, or a narrow / costly window.
+
+A prover counterexample proves the property CAN be broken, so do not rate a genuine break as impact
+'none' merely because it looks hard to reach — lower the Likelihood instead. Reserve impact 'none' for
+a break with no real-world consequence.
+
+Ground every claim in the supplied analysis and counterexample. Do not invent addresses, values,
+function names, or exploit steps beyond what the material supports; if the material is thin, keep the
+finding modest rather than fabricating specifics.
diff --git a/tests/test_autoprove_report.py b/tests/test_autoprove_report.py
index 9849699a..1298c260 100644
--- a/tests/test_autoprove_report.py
+++ b/tests/test_autoprove_report.py
@@ -10,7 +10,7 @@
which is how a caller hands a gap to the report layer).
"""
from types import SimpleNamespace
-from typing import cast
+from typing import Any, cast
import pathlib
import pytest
@@ -35,10 +35,15 @@
)
from composer.spec.source.report.render import render_html
from composer.spec.source.report.schema import (
- AutoProverReport, CoverageReport, FormalizedProperty, GaveUpComponent, GroupStatus,
- Outcome, PropertyGroup, RuleVerdict, SkippedClaim,
+ AutoProverReport, CoverageReport, Finding, FindingProvenance, FormalizedProperty,
+ GaveUpComponent, GroupStatus, ImpactLevel, IssueContent, LikelihoodLevel, Outcome,
+ PropertyGroup, RuleVerdict, SeverityTier, SkippedClaim,
)
from composer.spec.source.report_prover import make_prover_fetcher
+from composer.spec.source.report.collect import RuleEvidence
+from composer.spec.source.report.findings import FindingDraft, build_findings
+from composer.spec.source.cex_capture import CexAnalysisStore
+from composer.diagnostics.timing import RunSummary
# ---------------------------------------------------------------------------
@@ -114,22 +119,21 @@ def _pg(slug, members, status=GroupStatus.GOOD) -> PropertyGroup:
return PropertyGroup(slug=slug, title="T", description="d", status=status, members=members)
-class _GroupingStubModel(BaseChatModel):
- """A `BaseChatModel` whose structured-output binding returns a preset `GroupingResult`.
- Lets the build tests drive the *real* `call_grouping_llm` — template rendering + the
- `isinstance` check — without a live model, only stubbing the model's output."""
- result: GroupingResult
+class _StructuredStubModel(BaseChatModel):
+ """A `BaseChatModel` whose structured-output binding returns a preset object, so tests drive the
+ real caller (template rendering + the `isinstance` check) without a live model."""
+ output: Any
def with_structured_output(self, schema, **kwargs) -> Runnable: # type: ignore[override]
- result = self.result
- return RunnableLambda(lambda _messages: result)
+ out = self.output
+ return RunnableLambda(lambda _messages: out)
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
raise NotImplementedError("stub is structured-output only")
@property
def _llm_type(self) -> str:
- return "grouping-stub"
+ return "structured-stub"
# ---------------------------------------------------------------------------
@@ -451,7 +455,7 @@ def test_artifact_store_write_report_round_trips(tmp_path):
async def test_build_groups_properties(tmp_path):
gen = _gen({"p1": ["r1"], "p2": ["r2"]})
fetch = _fetcher({"L1": [_fake_check("r1", NodeStatus.VERIFIED), _fake_check("r2", NodeStatus.VERIFIED)]})
- llm = _GroupingStubModel(result=GroupingResult(groups=[PropertyGroupDraft(
+ llm = _StructuredStubModel(output=GroupingResult(groups=[PropertyGroupDraft(
slug="g", title="G", description="d", members=[("C", "p1"), ("C", "p2")])]))
report = await build.build_report(
@@ -470,7 +474,7 @@ async def test_build_groups_properties(tmp_path):
async def test_build_empty_grouping_falls_back(tmp_path):
gen = _gen({"p1": ["r1"], "p2": ["r2"]})
fetch = _fetcher({"L1": [_fake_check("r1", NodeStatus.VERIFIED), _fake_check("r2", NodeStatus.VIOLATED)]})
- llm = _GroupingStubModel(result=GroupingResult(groups=[])) # empty grouping -> fallback
+ llm = _StructuredStubModel(output=GroupingResult(groups=[])) # empty grouping -> fallback
report = await build.build_report(
contract_name="C",
@@ -490,7 +494,7 @@ async def test_build_empty_grouping_falls_back(tmp_path):
async def test_build_surfaces_skipped_and_gave_up_gaps(tmp_path):
gen = _gen({"p_ok": ["r1"]}, skipped={"p_skip": "needs a ghost"})
fetch = _fetcher({"L1": [_fake_check("r1", NodeStatus.VERIFIED)]})
- llm = _GroupingStubModel(result=GroupingResult(groups=[PropertyGroupDraft(
+ llm = _StructuredStubModel(output=GroupingResult(groups=[PropertyGroupDraft(
slug="g", title="G", description="d", members=[("C", "p_ok")])]))
report = await build.build_report(
@@ -506,3 +510,210 @@ async def test_build_surfaces_skipped_and_gave_up_gaps(tmp_path):
assert [(s.component, s.title) for s in report.skipped] == [("C", "p_skip")]
assert [g.component for g in report.gave_up_components] == ["D"]
assert report.coverage.skipped_count == 1 and report.coverage.gave_up_component_count == 1
+
+
+# ---------------------------------------------------------------------------
+# findings (violated rules -> audit-issue findings)
+# ---------------------------------------------------------------------------
+
+
+def _draft(*, impact_level: ImpactLevel = "high", likelihood_level: LikelihoodLevel = "medium",
+ title: str = "Reentrancy drains vault") -> FindingDraft:
+ return FindingDraft(
+ title=title, impact_level=impact_level, likelihood_level=likelihood_level,
+ risk_reasoning="High impact (fund loss); medium likelihood (needs a specific state).",
+ summary="s", description="d", impact="funds at risk", attack_path="1..2..3",
+ )
+
+
+def _evidence(by_rule: dict[str, RuleEvidence]):
+ async def fetch(rule_name):
+ return by_rule.get(rule_name)
+ return fetch
+
+
+def _finding(severity: SeverityTier = "high") -> Finding:
+ return Finding(
+ title="Reentrancy drains vault", severity=severity,
+ content=IssueContent(summary="s", description="d", impact="funds at risk",
+ assumptions_and_uncertainties="assumes attacker deploys a contract",
+ proof_of_concept=""),
+ provenance=FindingProvenance(rule_name="r_bad", spec_file="c.spec", outcome=Outcome.BAD,
+ group_slugs=["g"], prover_link="https://prover.example/run/abc",
+ impact="high", likelihood="medium",
+ risk_reasoning="High impact; medium likelihood."),
+ )
+
+
+def test_severity_for_matrix():
+ """Severity is computed from the impact × likelihood matrix, not chosen by the LLM."""
+ from composer.spec.source.report.findings import severity_for
+ assert severity_for("high", "high") == "critical"
+ assert severity_for("high", "medium") == "high"
+ assert severity_for("high", "low") == "medium"
+ assert severity_for("medium", "high") == "high"
+ assert severity_for("medium", "medium") == "medium"
+ assert severity_for("low", "high") == "low" # low impact caps at low regardless of likelihood
+ assert severity_for("low", "low") == "low"
+ assert severity_for("none", "high") == "informational" # no exploit path -> informational
+ assert severity_for("none", "low") == "informational"
+
+
+@pytest.mark.asyncio
+async def test_build_report_synthesizes_one_finding_per_violation():
+ """Only the violated rule becomes a finding; severity is computed from the model's impact/
+ likelihood, the counterexample rides proof_of_concept, the run link is a reference, and provenance
+ traces back to the rule. No locations are produced at report time (submission builds those)."""
+ gen = _gen({"p_good": ["r_ok"], "p_bad": ["r_bad"]})
+ fetch = _fetcher({"L1": [
+ _fake_check("r_ok", NodeStatus.VERIFIED, file="autospec_C.spec"),
+ _fake_check("r_bad", NodeStatus.VIOLATED, line=42, file="autospec_C.spec"),
+ ]})
+ grouping = _StructuredStubModel(output=GroupingResult(groups=[PropertyGroupDraft(
+ slug="g", title="G", description="gd", members=[("C", "p_good"), ("C", "p_bad")])]))
+ evidence = _evidence({"r_bad": RuleEvidence(analysis="root cause X", counterexample="")})
+
+ report = await build.build_report(
+ contract_name="Vault", backend="prover",
+ components=[_input("C", "autospec_C.spec",
+ [_prop("p_good", "d"), _prop("p_bad", "d")], gen)],
+ llm=grouping, fetch_verdicts=fetch,
+ findings_llm=_StructuredStubModel(output=_draft()), fetch_evidence=evidence,
+ )
+
+ assert len(report.findings) == 1
+ f = report.findings[0]
+ assert f.severity == "high" # computed: impact high × likelihood medium
+ prov = f.provenance
+ assert prov is not None
+ assert prov.impact == "high" and prov.likelihood == "medium"
+ assert prov.risk_reasoning # the axis justification is captured
+ assert prov.rule_name == "r_bad" and prov.outcome == Outcome.BAD
+ assert prov.group_slugs == ["g"] and prov.spec_file == "autospec_C.spec"
+ assert not hasattr(f, "locations") # locations are a submission-layer concern, not on the report
+ assert f.content.proof_of_concept == ""
+ assert f.content.references == ["L1"]
+
+
+@pytest.mark.asyncio
+async def test_build_report_no_findings_without_findings_llm():
+ """The findings pass is opt-in: omitting ``findings_llm`` leaves findings empty (back-compat)."""
+ gen = _gen({"p_bad": ["r_bad"]})
+ fetch = _fetcher({"L1": [_fake_check("r_bad", NodeStatus.VIOLATED)]})
+ grouping = _StructuredStubModel(output=GroupingResult(groups=[PropertyGroupDraft(
+ slug="g", title="G", description="d", members=[("C", "p_bad")])]))
+ report = await build.build_report(
+ contract_name="C", backend="prover",
+ components=[_input("C", "autospec_C.spec", [_prop("p_bad", "d")], gen)],
+ llm=grouping, fetch_verdicts=fetch,
+ )
+ assert report.findings == []
+
+
+@pytest.mark.asyncio
+async def test_build_findings_degrades_without_analysis():
+ """A violated rule whose evidence fetch yields nothing (fetcher present, no analysis for that rule)
+ still produces a finding from the property/group text; proof_of_concept is simply absent."""
+ rules = [_rv("c.spec", "r_bad", Outcome.BAD)]
+ props = [_fp("C", "p_bad", [("c.spec", "r_bad")], desc="balances stay solvent")]
+ groups = [_pg("g", [("C", "p_bad")], status=GroupStatus.BAD)]
+ findings = await build_findings(
+ contract_name="Vault", rules=rules, properties=props, groups=groups,
+ fetch_evidence=_evidence({}), # fetcher present, but no evidence recorded for r_bad
+ llm=_StructuredStubModel(output=_draft(impact_level="medium", likelihood_level="medium")),
+ )
+ assert len(findings) == 1
+ assert findings[0].severity == "medium" # medium × medium
+ assert findings[0].content.proof_of_concept is None
+ prov = findings[0].provenance
+ assert prov is not None and prov.spec_file == "c.spec"
+
+
+@pytest.mark.asyncio
+async def test_build_findings_empty_without_evidence_fetcher():
+ """Findings are produced only when the backend supplies an evidence fetcher — no backend-id
+ check. A backend that opts out (fetch_evidence is None) yields no findings."""
+ rules = [_rv("c.spec", "r_bad", Outcome.BAD)]
+ findings = await build_findings(
+ contract_name="V", rules=rules, properties=[], groups=[],
+ fetch_evidence=None, llm=_StructuredStubModel(output=_draft()),
+ )
+ assert findings == []
+
+
+def test_findings_prompt_lists_all_properties_a_rule_formalizes():
+ """A rule may jointly formalize several properties; the prompt presents ALL of them so the model
+ grounds the write-up in the one the counterexample actually breaks (not a pre-picked one)."""
+ from composer.templates.loader import load_jinja_template
+ user = load_jinja_template(
+ "autoprove_report_findings_prompt.j2",
+ contract_name="Vault", rule_name="allowDeposits_revert_characteristic",
+ properties=[_prop("revert_on_non_admin", "reverts if caller is not an admin"),
+ _prop("revert_on_zero_actor", "reverts if allowedActor is address(0)")],
+ groups=[], analysis=None, counterexample="",
+ )
+ assert "reverts if caller is not an admin" in user
+ assert "reverts if allowedActor is address(0)" in user
+
+
+def test_render_html_findings_section():
+ report = _mini_report().model_copy(update={"findings": [_finding()]})
+ h = render_html(report)
+ assert '' in h
+ assert "Reentrancy drains vault" in h
+ assert "badge-bad" in h # high severity
+ assert "r_bad" in h and "c.spec" in h # provenance locator (rule in spec file)
+ assert "Severity rationale" in h
+ assert "Impact high" in h and "Likelihood medium" in h
+ assert "Assumptions" in h and "assumes attacker deploys a contract" in h
+
+
+def test_render_html_omits_findings_section_when_empty():
+ h = render_html(_mini_report()) # _mini_report has no findings
+ assert '' not in h
+ assert '' not in h
+
+
+def test_report_round_trips_with_findings(tmp_path):
+ report = _mini_report().model_copy(update={"findings": [_finding()]})
+ ProverArtifactStore(str(tmp_path), "Counter").write_report(report)
+
+ out = tmp_path / "certora" / "ap_report" / "report.json"
+ reloaded = AutoProverReport.model_validate_json(out.read_text())
+ assert len(reloaded.findings) == 1
+ rf = reloaded.findings[0]
+ assert rf.content.impact == "funds at risk"
+ prov = rf.provenance
+ assert prov is not None
+ assert prov.rule_name == "r_bad"
+ assert prov.impact == "high"
+ assert prov.risk_reasoning == "High impact; medium likelihood."
+ assert not hasattr(rf, "locations")
+
+
+@pytest.mark.asyncio
+async def test_spec_callbacks_captures_cex_analysis():
+ """The source-pipeline prover callback records each violated rule's analysis into the run-scoped
+ store, keyed by the bare rule name, while still emitting the stream event."""
+ from langgraph.store.memory import InMemoryStore
+ from composer.spec.source.prover import _SpecCallbacks
+ from composer.prover.ptypes import RulePath, RuleResult
+
+ store = CexAnalysisStore(store=InMemoryStore(), namespace=("cex_analyses", "t"))
+ events: list = []
+ cb = _SpecCallbacks(events.append, "tc1", cast(RunSummary, SimpleNamespace()), {},
+ analysis_store=store)
+ rule = RuleResult(path=RulePath(rule="no_reentrancy", method="withdraw"),
+ cex_dump="", status="VIOLATED")
+
+ await cb.on_analysis_complete(rule, "root cause: external call before state update")
+
+ # Keyed by the bare rule name (RulePath.rule) — exactly what the report's RuleVerdict.name carries
+ # (POU derives it from the prover tree's context[0]) — so the findings join is an exact match.
+ rec = await store.get("no_reentrancy")
+ assert rec is not None and rec.analysis.startswith("root cause")
+ assert rec.counterexample == ""
+ # NOT keyed by the pretty-printed form ("no_reentrancy for withdraw"); the report never looks that up.
+ assert await store.get(rule.name) is None
+ # the UI stream event still fires
+ assert any(e.get("type") == "rule_analysis" for e in events)