diff --git a/composer/pipeline/core.py b/composer/pipeline/core.py index 97ce1b0b..3b174c14 100644 --- a/composer/pipeline/core.py +++ b/composer/pipeline/core.py @@ -36,7 +36,7 @@ from composer.spec.util import string_hash from composer.input.files import Document from composer.spec.source.report.build import build_report -from composer.spec.source.report.collect import ReportComponentInput, Verdict +from composer.spec.source.report.collect import ReportComponentInput, RuleEvidence, Verdict from composer.spec.source.report.schema import RuleName, ReportBackend from composer.spec.source.report import build as report_build from composer.spec.source.task_ids import SYSTEM_ANALYSIS_TASK_ID, REPORT_TASK_ID @@ -55,7 +55,7 @@ class Formalizer[FormT: BackendResult](ABC): state — never set post-hoc. `FormT: ReportableResult` is what makes the report a core step.""" formalized_type: type[FormT] backend_tag: ReportBackend - + @abstractmethod async def formalize( self, @@ -77,6 +77,11 @@ async def fetch_verdicts(self, inp: ReportComponentInput[FormT]) -> dict[RuleNam off-thread. Foundry: read straight off inp.formalized.result.""" ... + async def fetch_evidence(self, link: str | None, rule_name: str) -> RuleEvidence | None: + """Per-violated-rule evidence for findings synthesis — the prover returns its captured + counterexample analysis for `rule_name`. Default: none (backend produces no findings).""" + return None + async def finalize(self, outcomes: list[ComponentOutcome[FormT]], run: PipelineRun) -> None: """Emit any backend-specific run-level artifacts from the full outcome set (prover: components_to_prover_runs.json). Default: none.""" @@ -209,7 +214,7 @@ async def _run(batch: _Batch) -> ComponentOutcome[FormT]: await child.cache_put(result) else: result = cached_result - + outcome: Delivered[FormT] | GaveUp = ( result if isinstance(result, GaveUp) else Delivered(result, backend.artifact_store.write_artifact(result_key, result)) @@ -239,6 +244,8 @@ async def _run(batch: _Batch) -> ComponentOutcome[FormT]: job=lambda: build_report( contract_name=source.contract_name, backend=formalizer.backend_tag, components=inputs, llm=run.env.llm_lite(), fetch_verdicts=formalizer.fetch_verdicts, + findings_llm=run.env.llm_heavy(), + fetch_evidence=formalizer.fetch_evidence, ), task_info=TaskInfo(REPORT_TASK_ID, label="Report Extraction", phase=backend.core_phases["report"]) ) diff --git a/composer/spec/source/autoprove_common.py b/composer/spec/source/autoprove_common.py index 24eb0a7e..ce819aad 100644 --- a/composer/spec/source/autoprove_common.py +++ b/composer/spec/source/autoprove_common.py @@ -20,6 +20,7 @@ ) from composer.pipeline.cli import cli_pipeline, user_ns from composer.spec.source.pipeline import ProverBackend, GeneratedCVL +from composer.spec.source.cex_capture import CexAnalysisStore from composer.prover.core import make_prover_options from composer.spec.source.source_env import build_source_env from composer.spec.source.artifacts import ProverArtifactStore @@ -144,7 +145,8 @@ async def callback( ) backend = ProverBackend( ProverArtifactStore(staged.source.project_root, staged.source.contract_name), - make_prover_options(cloud=args.cloud) + make_prover_options(cloud=args.cloud), + CexAnalysisStore(), ) return await cont(source_env, backend) yield callback diff --git a/composer/spec/source/cex_capture.py b/composer/spec/source/cex_capture.py new file mode 100644 index 00000000..ab3d3400 --- /dev/null +++ b/composer/spec/source/cex_capture.py @@ -0,0 +1,40 @@ +"""Run-scoped capture of per-rule counterexample analysis. + +The autoprove prover tool already runs an LLM analysis of every violated rule during the run +(``TrivialFanoutCexHandler`` -> ``analyze_cex_raw``); that text is otherwise consumed only as agent +feedback and discarded. This in-memory, run-scoped store captures it keyed by rule name +(last-write-wins across prover iterations) so the report phase can reshape the *final* iteration's +analysis into a finding without re-reasoning about the counterexample. + +In-memory is sufficient: the report phase runs in the same process as formalization within +``composer.pipeline.core.run_pipeline``. A ``BaseStore``-backed variant (cf. +``composer.prover.report_store``) would be the resume-safe upgrade. +""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CexAnalysis: + """One rule's captured counterexample analysis: the root-cause / fix explanation and, when + available, the counterexample call-trace dump it was derived from.""" + analysis: str + counterexample: str | None = None + + +class CexAnalysisStore: + """In-memory ``{rule name -> CexAnalysis}``, last-write-wins. Written by the prover tool's + callbacks as analysis completes each iteration; read by the report's findings synthesizer. + + A violated rule that remains violated to the end was analyzed on the final prover run, so + last-write-wins holds its final-iteration analysis; a rule fixed before the end is GOOD in the + report and its (stale) analysis is simply never looked up.""" + + def __init__(self) -> None: + self._by_rule: dict[str, CexAnalysis] = {} + + def record(self, rule_name: str, analysis: str, counterexample: str | None = None) -> None: + """Store one rule's analysis under ``rule_name``.""" + self._by_rule[rule_name] = CexAnalysis(analysis=analysis, counterexample=counterexample) + + def get(self, rule_name: str) -> CexAnalysis | None: + return self._by_rule.get(rule_name) diff --git a/composer/spec/source/pipeline.py b/composer/spec/source/pipeline.py index c6ac5120..a58bbb1d 100644 --- a/composer/spec/source/pipeline.py +++ b/composer/spec/source/pipeline.py @@ -46,8 +46,9 @@ from composer.spec.source.author import batch_cvl_generation from composer.spec.source.artifacts import ProverArtifactStore, ComponentSpec, InvariantSpec from composer.spec.source.report_prover import make_prover_fetcher -from composer.spec.source.report.collect import ReportComponentInput, Verdict, VerdictFetcher +from composer.spec.source.report.collect import ReportComponentInput, RuleEvidence, Verdict, VerdictFetcher from composer.spec.source.report.schema import RuleName +from composer.spec.source.cex_capture import CexAnalysisStore from composer.spec.source.task_ids import ( HARNESS_TASK_ID, AUTOSETUP_TASK_ID, SUMMARIES_TASK_ID, INVARIANTS_TASK_ID, INVARIANT_CVL_TASK_ID, @@ -108,6 +109,7 @@ class ProverRunner(Formalizer[GeneratedCVL]): _resources: list[CVLResource] _invariant: tuple[list[PropertyFormulation], Delivered[GeneratedCVL]] | None _fetch: VerdictFetcher[GeneratedCVL] + _analysis_store: CexAnalysisStore @override async def formalize( @@ -148,6 +150,16 @@ async def fetch_verdicts( ) -> dict[RuleName, Verdict]: return await self._fetch(inp) + @override + async def fetch_evidence(self, link: str | None, rule_name: str) -> RuleEvidence | None: + # The run captured each violated rule's counterexample analysis as it happened; hand the + # final-iteration analysis to the report's findings synthesizer. None -> the finding degrades + # to property/group text. + rec = self._analysis_store.get(rule_name) + if rec is None: + return None + return RuleEvidence(analysis=rec.analysis, counterexample=rec.counterexample) + @override async def finalize(self, outcomes: list[ComponentOutcome[GeneratedCVL]], run: PipelineRun) -> None: # components_to_prover_runs.json: {run_key (slug): prover /output/ link}. @@ -173,6 +185,7 @@ class ProverPrepared(PreparedSystem[GeneratedCVL]): _prover_tool: BaseTool _prover_opts: ProverOptions _analyzed: SourceApplication + _analysis_store: CexAnalysisStore @override async def prepare_formalization(self, run: PipelineRun) -> Formalizer[GeneratedCVL]: @@ -233,7 +246,7 @@ async def prepare_formalization(self, run: PipelineRun) -> Formalizer[GeneratedC return ProverRunner( GeneratedCVL, "prover", self._store, self._prover_tool, setup_config.prover_config, resources, invariant, - make_prover_fetcher(), + make_prover_fetcher(), self._analysis_store, ) async def _autosetup(self, run: PipelineRun) -> tuple[SetupSuccess, list[CVLResource]]: @@ -284,6 +297,7 @@ class ProverBackend: artifact_store: ProverArtifactStore _prover_opts: ProverOptions + analysis_store: CexAnalysisStore async def prepare_system( self, analyzed: SourceApplication, run: PipelineRun[AutoProvePhase, None], @@ -295,12 +309,12 @@ async def prepare_system( harnessed = _lift_harnessed(analyzed, sys_desc) prover_tool = get_prover_tool( run.env.llm_heavy(), run.source.contract_name, run.source.project_root, - prover_opts=self._prover_opts, + prover_opts=self._prover_opts, analysis_store=self.analysis_store, ) return ProverPrepared( main_instance(harnessed, run.source), self.artifact_store, sys_desc, harnessed, prover_tool, - self._prover_opts, analyzed, + self._prover_opts, analyzed, self.analysis_store, ) def to_artifact_id(self, c: ContractComponentInstance) -> ComponentSpec: diff --git a/composer/spec/source/prover.py b/composer/spec/source/prover.py index f6715372..e46f2382 100644 --- a/composer/spec/source/prover.py +++ b/composer/spec/source/prover.py @@ -40,6 +40,7 @@ from graphcore.graph import tool_state_update from composer.spec.util import temp_certora_file from composer.spec.gen_types import CERTORA_DIR, SPECS_DIR +from composer.spec.source.cex_capture import CexAnalysisStore _logger = logging.getLogger("composer.prover") @@ -99,12 +100,14 @@ def __init__( tool_call_id: str, summary: RunSummary, config: dict, + analysis_store: CexAnalysisStore | None = None, ) -> None: super().__init__(writer, tool_call_id) self._writer = writer self._tool_call_id = tool_call_id self._summary = summary self._config = config + self._analysis_store = analysis_store self._started_mono: float | None = None @override @@ -158,6 +161,19 @@ async def on_prover_result(self, results: dict[str, RuleResult]) -> None: results ) + @override + async def on_analysis_complete(self, rule: RuleResult, explanation: str) -> None: + # Capture the per-rule counterexample analysis (last-write-wins across iterations) so the + # report phase can reshape the final iteration's analysis into a finding without re-running + # the analysis. Key by the bare rule name (rule.path.rule). + # Never let a capture error disturb the run. + if self._analysis_store is not None: + try: + self._analysis_store.record(rule.path.rule, explanation, rule.cex_dump) + except Exception: + _logger.exception("failed to capture cex analysis for %s", rule.name) + await super().on_analysis_complete(rule, explanation) + class VerifySpecSchema(BaseModel): """ @@ -216,6 +232,7 @@ def get_prover_tool( main_contract: str, project_root: str, prover_opts: ProverOptions, + analysis_store: CexAnalysisStore | None = None, ) -> BaseTool: sem = _prover_sem(prover_opts.cloud) stamper = make_validation_stamper(VALIDATION_KEY) @@ -268,7 +285,8 @@ async def verify_spec( [config_path], tool_call_id, prover_opts, - _SpecCallbacks(get_stream_writer(), tool_call_id, summary, config), + _SpecCallbacks(get_stream_writer(), tool_call_id, summary, config, + analysis_store=analysis_store), DefaultCexHandler(llm, state, summarization_threshold=10) ) diff --git a/composer/spec/source/report/build.py b/composer/spec/source/report/build.py index 70f1ec64..b7ef76e4 100644 --- a/composer/spec/source/report/build.py +++ b/composer/spec/source/report/build.py @@ -13,14 +13,15 @@ from langchain_core.language_models.chat_models import BaseChatModel from composer.spec.source.report.collect import ( - ReportableResult, ReportComponentInput, VerdictFetcher, collect, + EvidenceFetcher, ReportableResult, ReportComponentInput, VerdictFetcher, collect, ) from composer.spec.source.report.coverage import ValidationError, validate +from composer.spec.source.report.findings import build_findings from composer.spec.source.report.grouping import ( build_fallback_grouping, build_groups, call_grouping_llm, ) from composer.spec.source.report.schema import ( - AutoProverReport, Outcome, PropertyKey, ReportBackend, RuleRef, + AutoProverReport, Finding, Outcome, PropertyKey, ReportBackend, RuleRef, ) _log = logging.getLogger(__name__) @@ -42,8 +43,15 @@ async def build_report[R: ReportableResult]( components: list[ReportComponentInput[R]], llm: BaseChatModel, fetch_verdicts: VerdictFetcher[R], + findings_llm: BaseChatModel | None = None, + fetch_evidence: EvidenceFetcher | None = None, ) -> AutoProverReport: - """Build and return the in-memory `AutoProverReport`. Persistence is the caller's job.""" + """Build and return the in-memory `AutoProverReport`. Persistence is the caller's job. + + When ``findings_llm`` is supplied, violated rules are additionally synthesized into + Sherlock-``IssueIn``-shaped `Finding`s (best-effort; a synthesis failure yields no findings + rather than failing the report). ``fetch_evidence`` supplies each violation's captured + counterexample analysis; it is optional.""" properties, rules, skipped, gave_up, dropped = await collect( components, fetch_verdicts=fetch_verdicts ) @@ -84,6 +92,22 @@ async def build_report[R: ReportableResult]( ) coverage.warnings = ["FALLBACK GROUPING APPLIED"] + coverage.warnings + # Violated rules -> findings. Its own guard: findings synthesis must never fail the report + # (the whole phase is also best-effort in the caller, but this keeps a working report even when + # only findings break). + findings: list[Finding] = [] + if findings_llm is not None: + try: + findings = await build_findings( + contract_name=contract_name, backend=backend, rules=rules, + properties=properties, groups=groups, fetch_evidence=fetch_evidence, + llm=findings_llm, + ) + except Exception as e: # noqa: BLE001 + if RERAISE_REPORT_FAILURES: + raise + _log.warning("report: findings synthesis failed (%s); continuing without findings", e) + report = AutoProverReport( backend=backend, contract_name=contract_name, @@ -96,5 +120,6 @@ async def build_report[R: ReportableResult]( skipped=skipped, gave_up_components=gave_up, coverage=coverage, + findings=findings, ) return report diff --git a/composer/spec/source/report/collect.py b/composer/spec/source/report/collect.py index ddff5b8e..071a3a19 100644 --- a/composer/spec/source/report/collect.py +++ b/composer/spec/source/report/collect.py @@ -101,6 +101,25 @@ async def __call__(self, input: ReportComponentInput[R], /) -> dict[RuleName, Ve ... +@dataclass(frozen=True) +class RuleEvidence: + """Backend-supplied raw material for synthesizing a finding from a violated rule. + + ``analysis`` is the backend's pre-computed root-cause / fix explanation for the violation + (prover: the ``analyze_cex_raw`` output captured during the run); ``counterexample`` is a concrete + failing trace excerpt (prover: the rule's ``cex_dump``). Both optional — a finding degrades to + property/group text when absent.""" + analysis: str | None = None + counterexample: str | None = None + + +class EvidenceFetcher(Protocol): + """Backend hook: given a violated rule's run link + name, return its `RuleEvidence` (or ``None`` + when the backend has none). Prover reads the run-scoped CEX-analysis capture; foundry has none.""" + async def __call__(self, link: str | None, rule_name: str, /) -> "RuleEvidence | None": + ... + + async def collect[R: ReportableResult]( inputs: list[ReportComponentInput[R]], *, diff --git a/composer/spec/source/report/findings.py b/composer/spec/source/report/findings.py new file mode 100644 index 00000000..46340d13 --- /dev/null +++ b/composer/spec/source/report/findings.py @@ -0,0 +1,153 @@ +"""Synthesize Sherlock-``IssueIn``-shaped findings from violated rules. + +For each violated rule (a `RuleVerdict` with ``outcome == Outcome.BAD``) this reshapes the +counterexample analysis the run already produced — looked up via the backend `EvidenceFetcher` — into +a `Finding`. One structured LLM call per violation, fed the *distilled* analysis rather than the raw +counterexample, so the expensive counterexample reasoning is not repeated. Best-effort per finding: +any failure drops that one finding, never the report. + +v1 scope: prover-only (a foundry BAD is an author-declared demonstration, not a discovered bug). +Severity is LLM-assigned from the default Sherlock rubric via the Impact × Likelihood matrix in the +system template. ``IssueIn.locations`` is not produced here — a run knows only local paths and CVL-spec +lines, so the submission layer reconstructs source locations from the engagement scope + counterexample +(the accurate report-time locator is on ``FindingProvenance``: rule name, spec file, prover-run link). +""" +import asyncio +import logging +from typing import Literal + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel, Field + +from composer.templates.loader import load_jinja_template +from composer.spec.source.report.collect import EvidenceFetcher, RuleEvidence +from composer.spec.source.report.schema import ( + Finding, FindingProvenance, FormalizedProperty, IssueContent, Outcome, + PropertyGroup, PropertyKey, ReportBackend, RuleRef, RuleVerdict, +) + +_log = logging.getLogger(__name__) + +Severity = Literal["critical", "high", "medium", "low", "informational"] + +#: Bound the counterexample we inline (as prompt input and as ``proof_of_concept``). +_MAX_CEX_CHARS = 8000 + + +class FindingDraft(BaseModel): + """The LLM-authored prose + severity for one finding. Code fills locations / PoC / references / + provenance around it. Mirrors the Sherlock ``IssueContent`` (minus the machine-filled fields).""" + title: str = Field(max_length=200, description="One-line, issue-specific title.") + severity: Severity = Field(description="critical | high | medium | low | informational.") + severity_reasoning: str = Field( + max_length=4000, + description="1-3 sentences: the Impact tier, the Likelihood tier, and the matrix cell they select.", + ) + summary: str = Field(max_length=2000, description="1-3 sentence tl;dr.") + description: str = Field(max_length=50000, description="Full technical description grounded in the counterexample.") + impact: str = Field(max_length=20000, description="Concrete consequence if exploited.") + attack_path: str | None = Field(default=None, max_length=20000, description="Step-by-step exploit path, or null.") + assumptions_and_uncertainties: str | None = Field(default=None, max_length=10000, description="Assumptions / uncertainties, or null.") + + +async def build_findings( + *, + contract_name: str, + backend: ReportBackend, + rules: list[RuleVerdict], + properties: list[FormalizedProperty], + groups: list[PropertyGroup], + fetch_evidence: EvidenceFetcher | None, + llm: BaseChatModel, +) -> list[Finding]: + """One `Finding` per violated rule (concurrent, best-effort). Returns ``[]`` for a non-prover + backend or when nothing is violated.""" + if backend != "prover": + return [] + bad = [r for r in rules if r.outcome == Outcome.BAD] + if not bad: + return [] + + # Reverse index a violated rule -> the properties it breaks -> their audit group (for prose context). + props_by_ref: dict[RuleRef, list[FormalizedProperty]] = {} + for p in properties: + for ref in p.rule_refs: + props_by_ref.setdefault(ref, []).append(p) + group_by_key: dict[PropertyKey, PropertyGroup] = {} + for g in groups: + for k in g.members: + group_by_key.setdefault(k, g) + + system = load_jinja_template("autoprove_report_findings_system.j2") + bound = llm.with_structured_output(FindingDraft) + + async def _one(rule: RuleVerdict) -> Finding | None: + try: + ev = await fetch_evidence(rule.prover_link, rule.name) if fetch_evidence else None + prop = _pick_property(props_by_ref.get(rule.ref, [])) + group = group_by_key.get(prop.key) if prop else None + user = load_jinja_template( + "autoprove_report_findings_prompt.j2", + contract_name=contract_name, + rule_name=rule.name, + property_title=prop.title if prop else None, + property_description=prop.description if prop else None, + property_sort=prop.sort if prop else None, + group_title=group.title if group else None, + group_description=group.description if group else None, + analysis=ev.analysis if ev else None, + counterexample=_trim(ev.counterexample) if ev else None, + ) + draft = await bound.ainvoke([SystemMessage(system), HumanMessage(user)]) + assert isinstance(draft, FindingDraft) + return _compose(rule, draft, ev, group_slug=group.slug if group else None) + except Exception: # noqa: BLE001 — one finding failing must never fail the report + _log.warning("report: finding synthesis failed for rule %r; skipping", rule.name, exc_info=True) + return None + + findings = await asyncio.gather(*[_one(r) for r in bad]) + return [f for f in findings if f is not None] + + +def _pick_property(props: list[FormalizedProperty]) -> FormalizedProperty | None: + """A violated rule may formalize several properties; pick a stable representative (first by + title) to describe the finding. They describe the same violation, so the choice affects only + which prose seeds the write-up.""" + return sorted(props, key=lambda p: p.title)[0] if props else None + + +def _trim(text: str | None) -> str | None: + if not text: + return None + return text if len(text) <= _MAX_CEX_CHARS else text[:_MAX_CEX_CHARS] + "\n…(truncated)" + + +def _compose( + rule: RuleVerdict, + draft: FindingDraft, + ev: RuleEvidence | None, + *, + group_slug: str | None, +) -> Finding: + return Finding( + title=draft.title, + severity=draft.severity, + content=IssueContent( + summary=draft.summary, + description=draft.description, + impact=draft.impact, + attack_path=draft.attack_path, + assumptions_and_uncertainties=draft.assumptions_and_uncertainties, + proof_of_concept=_trim(ev.counterexample) if ev else None, + references=[rule.prover_link] if rule.prover_link else None, + ), + provenance=FindingProvenance( + rule_name=rule.name, + spec_file=rule.spec_file, + outcome=rule.outcome, + group_slug=group_slug, + prover_link=rule.prover_link, + severity_reasoning=draft.severity_reasoning, + ), + ) diff --git a/composer/spec/source/report/render.py b/composer/spec/source/report/render.py index 25986c51..0e197ea6 100644 --- a/composer/spec/source/report/render.py +++ b/composer/spec/source/report/render.py @@ -26,8 +26,8 @@ from composer.spec.gen_types import TypedTemplate from composer.templates.loader import load_jinja_template from composer.spec.source.report.schema import ( - AutoProverReport, CoverageReport, FormalizedProperty, GaveUpComponent, GroupStatus, Outcome, - PropertyGroup, PropertyKey, ReportBackend, RuleRef, RuleVerdict, SkippedClaim, + AutoProverReport, CoverageReport, Finding, FormalizedProperty, GaveUpComponent, GroupStatus, + Outcome, PropertyGroup, PropertyKey, ReportBackend, RuleRef, RuleVerdict, SkippedClaim, ) @@ -45,6 +45,14 @@ GroupStatus.PARTIAL: "warn", GroupStatus.UNKNOWN: "muted", } +# Finding severity (default Sherlock rubric) -> CSS badge kind. Unknown/custom severities render muted. +_SEVERITY_KIND: dict[str, str] = { + "critical": "bad", + "high": "bad", + "medium": "warn", + "low": "info", + "informational": "muted", +} # Per-backend human labels: the data carries the neutral `Outcome`; these turn it into the words an # auditor reads ("Verified" for a proof, "Successful test" for a forge run). @@ -134,6 +142,20 @@ class GroupView(TypedDict): rows: list[RowView] +class FindingView(TypedDict): + title: str + severity: str + severity_kind: str + severity_reasoning: str | None + summary: str + impact: str + description: str + attack_path: str | None + link: LinkView + rule_name: str | None + spec_file: str | None + + class ReportTemplateParams(TypedDict): """The full, typed context of ``autoprove_report.html.j2``.""" report: AutoProverReport @@ -143,6 +165,7 @@ class ReportTemplateParams(TypedDict): prover_runs: list[RunView] rule_counts: list[ChipView] group_counts: list[ChipView] + findings: list[FindingView] groups: list[GroupView] skipped: list[SkippedClaim] gave_up: list[GaveUpComponent] @@ -227,6 +250,25 @@ def _group_view( } +def _finding_view(f: Finding) -> FindingView: + """Project a `Finding` into the template shape: the Sherlock fields the page shows, plus a + severity->CSS kind and the prover-run link pulled from provenance.""" + prov = f.provenance + return { + "title": f.title, + "severity": f.severity, + "severity_kind": _SEVERITY_KIND.get(f.severity.lower(), "muted"), + "severity_reasoning": prov.severity_reasoning if prov else None, + "summary": f.content.summary, + "impact": f.content.impact, + "description": f.content.description, + "attack_path": f.content.attack_path, + "link": _link_view(prov.prover_link if prov else None), + "rule_name": prov.rule_name if prov else None, + "spec_file": prov.spec_file if prov else None, + } + + def _build_context(report: AutoProverReport) -> ReportTemplateParams: props_by_key = {p.key: p for p in report.properties} rules_by_ref = {r.ref: r for r in report.rules} @@ -244,6 +286,7 @@ def _build_context(report: AutoProverReport) -> ReportTemplateParams: ], "rule_counts": _outcome_counts([r.outcome for r in report.rules], unit_labels), "group_counts": _group_counts([g.status for g in report.groups], group_labels), + "findings": [_finding_view(f) for f in report.findings], "groups": [ _group_view(g, props_by_key, rules_by_ref, unit_labels, group_labels) for g in report.groups diff --git a/composer/spec/source/report/schema.py b/composer/spec/source/report/schema.py index a791ac7e..450b6999 100644 --- a/composer/spec/source/report/schema.py +++ b/composer/spec/source/report/schema.py @@ -157,6 +157,55 @@ class CoverageReport(BaseModel): for a report.json it reads cold.""" +# --------------------------------------------------------------------------- +# Findings — violated rules surfaced as audit issues. +# +# `Finding` carries the Sherlock Audit Engine "Submit Issue" fields (schema ``IssueIn``, +# POST /v1/engagements/{id}/issues) that a run can populate at report time: ``title``, ``severity``, +# and the ``content`` prose. One finding is emitted per violated rule (a `RuleVerdict` with +# ``outcome == Outcome.BAD``); its prose is synthesized from the rule's counterexample analysis and +# the property/group it breaks (see ``report/findings.py``). Sherlock length caps are mirrored but +# not enforced (report.json is lenient — the submission endpoint is the validating gate). +# +# ``IssueIn.locations`` is deliberately NOT carried here: a run only knows local paths and CVL-spec +# lines, not the source ``{owner}/{repo}`` / file / line a Sherlock location needs, so the submission +# layer reconstructs locations from the engagement scope + the counterexample. The accurate +# report-time locator lives in ``provenance`` (rule name, spec file, prover-run link). +# --------------------------------------------------------------------------- + +class IssueContent(BaseModel): + """A finding's free-text sections — the ``IssueIn.content`` shape.""" + summary: str = Field(max_length=2000, description="Short tl;dr of the finding, in one to three sentences.") + description: str = Field(max_length=50000, description="Full technical description of the vulnerability and how it manifests.") + impact: str = Field(max_length=20000, description="Concrete consequence if exploited (funds at risk, DoS, data exposure, ...).") + attack_path: str | None = Field(default=None, max_length=20000, description="Step-by-step exploit path, if applicable.") + assumptions_and_uncertainties: str | None = Field(default=None, max_length=10000, description="Assumptions relied on and anything the submitter is unsure about.") + proof_of_concept: str | None = Field(default=None, max_length=65536, description="Executable PoC, or the prover counterexample demonstrating the issue.") + references: list[str] | None = Field(default=None, description="Supporting links (e.g. the prover run).") + + +class FindingProvenance(BaseModel): + """Report-only trace from a finding back to the verdict/property that produced it. NOT part of + the Sherlock ``IssueIn`` payload — a submitter drops it.""" + rule_name: RuleName + spec_file: str + outcome: Outcome + group_slug: str | None = None + prover_link: str | None = None + #: The finding-synthesis LLM's justification for the assigned severity (Impact × Likelihood). + severity_reasoning: str | None = None + + +class Finding(BaseModel): + """A violated rule rendered as an audit issue. ``title``/``severity``/``content`` are the Sherlock + ``IssueIn`` fields a run can fill at report time; ``provenance`` is report-only. ``IssueIn.locations`` + is added by the submission layer (see the module comment), not stored here.""" + title: str = Field(max_length=200, description="One-line title of the finding.") + severity: str = Field(description="Severity tier. Default rubric: critical / high / medium / low / informational.") + content: IssueContent + provenance: FindingProvenance | None = None + + class AutoProverReport(BaseModel): """Top-level report document — written to ``certora/ap_report/report.json``.""" schema_version: Literal["3.0"] = "3.0" @@ -172,3 +221,7 @@ class AutoProverReport(BaseModel): skipped: list[SkippedClaim] = Field(default_factory=list) gave_up_components: list[GaveUpComponent] = Field(default_factory=list) coverage: CoverageReport + #: Violated rules surfaced as Sherlock-``IssueIn``-shaped audit issues (one per BAD rule; empty + #: when nothing is violated, when synthesis was unavailable, or for a non-prover backend). Prose + #: is synthesized at report time — see ``report/findings.py``. + findings: list[Finding] = Field(default_factory=list) diff --git a/composer/templates/autoprove_report.html.j2 b/composer/templates/autoprove_report.html.j2 index 894c6c9c..311b2fa9 100644 --- a/composer/templates/autoprove_report.html.j2 +++ b/composer/templates/autoprove_report.html.j2 @@ -134,6 +134,58 @@ section.prop .desc { .badge-warn { color: var(--warn-fg); background: var(--warn-bg); } .badge-info { color: var(--info-fg); background: var(--info-bg); } .badge-muted { color: var(--muted-fg); background: var(--muted-bg); } +.findings-head { margin: 20px 0 12px; } +.findings-head h2 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} +section.finding { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 3px solid var(--bad-fg); + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 14px; +} +section.finding h3 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.finding-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: baseline; + font-size: 12px; + color: var(--muted-fg); + margin-bottom: 10px; +} +.finding-meta code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + font-size: 11.5px; + word-break: break-all; +} +.finding-meta a { color: var(--link); text-decoration: none; } +.finding-meta a:hover { text-decoration: underline; } +.finding-summary { margin: 0 0 12px; } +dl.finding-body { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0; + font-size: 13px; +} +dl.finding-body dt { color: var(--muted-fg); font-weight: 600; } +dl.finding-body dd { margin: 0; white-space: pre-wrap; } table.rules { width: 100%; border-collapse: collapse; @@ -247,6 +299,29 @@ footer.report-foot { {%- endif %} +{%- if findings %} +
+

Findings {{ findings | length }}

+
Violated {{ terms.unit_plural }} surfaced as audit issues — fields follow the standard issue-submission format.
+
+{%- for f in findings %} +
+

{{ f.severity }} {{ f.title }}

+
+ {%- if f.rule_name %}rule {{ f.rule_name }}{% if f.spec_file %} in {{ f.spec_file }}{% endif %}{% endif %} + {%- if f.link.href %}{{ f.link.label }}{% endif %} +
+

{{ f.summary }}

+
+ {%- if f.severity_reasoning %}
Severity rationale
{{ f.severity_reasoning }}
{% endif %} +
Impact
{{ f.impact }}
+
Description
{{ f.description }}
+ {%- if f.attack_path %}
Attack path
{{ f.attack_path }}
{% 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..3f4cfb29 --- /dev/null +++ b/composer/templates/autoprove_report_findings_prompt.j2 @@ -0,0 +1,21 @@ +Contract: {{ contract_name }} + +Violated rule: {{ rule_name }} +{% if property_description -%} +Property it formalizes ({{ property_sort }}): {{ property_title }} + {{ property_description }} +{% endif -%} +{% if group_title -%} +Audit-level claim it belongs to: {{ group_title }} + {{ group_description }} +{% 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 property.) +{%- 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..c4247571 --- /dev/null +++ b/composer/templates/autoprove_report_findings_system.j2 @@ -0,0 +1,50 @@ +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 property it formalizes, the audit-level claim +(group) it belongs to, the prover's own root-cause analysis of the counterexample, and — when +available — the counterexample trace itself. + +Write the finding as these fields: + - title: one line (<= 200 chars), specific to THIS issue — name the broken guarantee, not the rule. + - severity: exactly one of `critical`, `high`, `medium`, `low`, `informational`, assigned via the + Impact × Likelihood matrix in the SEVERITY CLASSIFICATION section below. + - severity_reasoning: 1-3 sentences justifying the severity — state the Impact tier and the + Likelihood tier you assessed and the matrix cell they select. + - summary: a 1-3 sentence tl;dr. + - description: the full technical explanation of the vulnerability and how it manifests, grounded + in the supplied analysis and counterexample. + - impact: the concrete consequence if exploited. + - attack_path: the step-by-step path derived from the counterexample, or null if not applicable. + - assumptions_and_uncertainties: anything the finding relies on or you are unsure about, or null. + +SEVERITY CLASSIFICATION +Assess two axes independently, then read the severity off the matrix — do not eyeball it. + +Impact — what an attacker gains or users lose when the property break is exploited: + - High: direct loss or theft of funds, protocol insolvency, permanent freezing of 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 or cosmetic deviation from the intended guarantee. + +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. + + | Likelihood \ Impact | High | Medium | Low | + | High | critical | high | medium | + | Medium | high | medium | low | + | Low | medium | low | low | + +Use `informational` ONLY for a property break with no real-world exploit path — a code-quality or +specification observation, not a vulnerability. A prover counterexample proves the property CAN be +broken, so never downgrade a genuine break to `informational` just because it looks hard to reach; +instead lower the Likelihood tier and let the matrix decide. + +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. Return output exactly matching the requested schema. diff --git a/tests/test_autoprove_report.py b/tests/test_autoprove_report.py index 9849699a..03742296 100644 --- a/tests/test_autoprove_report.py +++ b/tests/test_autoprove_report.py @@ -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, IssueContent, Outcome, PropertyGroup, + RuleVerdict, 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 # --------------------------------------------------------------------------- @@ -506,3 +511,182 @@ 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 -> Sherlock IssueIn shape) +# --------------------------------------------------------------------------- + +class _FindingStubModel(BaseChatModel): + """A `BaseChatModel` whose structured-output binding returns a preset `FindingDraft`, so the + real `build_findings` (templates + compose) runs without a live model.""" + draft: FindingDraft + + def with_structured_output(self, schema, **kwargs) -> Runnable: # type: ignore[override] + draft = self.draft + return RunnableLambda(lambda _messages: draft) + + 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 "finding-stub" + + +def _draft(severity: str = "high", title: str = "Reentrancy drains vault") -> FindingDraft: + return FindingDraft( + title=title, severity=severity, + severity_reasoning="High impact (fund loss) x Medium likelihood -> high.", + summary="s", description="d", impact="funds at risk", attack_path="1..2..3", + ) + + +def _evidence(by_rule: dict[str, RuleEvidence]): + async def fetch(link, rule_name): + return by_rule.get(rule_name) + return fetch + + +def _finding(severity: str = "high") -> Finding: + return Finding( + title="Reentrancy drains vault", severity=severity, + content=IssueContent(summary="s", description="d", impact="funds at risk", + proof_of_concept=""), + provenance=FindingProvenance(rule_name="r_bad", spec_file="c.spec", outcome=Outcome.BAD, + group_slug="g", prover_link="https://prover.example/run/abc", + severity_reasoning="High impact x Medium likelihood -> high."), + ) + + +@pytest.mark.asyncio +async def test_build_report_synthesizes_one_finding_per_violation(): + """Only the violated rule becomes a finding; it carries the LLM severity, the counterexample as + proof_of_concept, the run link as a reference, and provenance back to the violated 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 = _GroupingStubModel(result=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=_FindingStubModel(draft=_draft()), fetch_evidence=evidence, + ) + + assert len(report.findings) == 1 + f = report.findings[0] + assert f.severity == "high" + assert f.provenance.rule_name == "r_bad" and f.provenance.outcome == Outcome.BAD + assert f.provenance.group_slug == "g" and f.provenance.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"] + assert f.provenance.severity_reasoning # the impact x likelihood justification is captured + + +@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 = _GroupingStubModel(result=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_evidence(): + """A violated rule with no captured analysis still yields a finding (from property/group text); + proof_of_concept is absent, and the accurate locator (spec_file) rides provenance.""" + 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", backend="prover", rules=rules, properties=props, groups=groups, + fetch_evidence=None, llm=_FindingStubModel(draft=_draft(severity="medium")), + ) + assert len(findings) == 1 + assert findings[0].severity == "medium" + assert findings[0].content.proof_of_concept is None + assert findings[0].provenance.spec_file == "c.spec" + + +@pytest.mark.asyncio +async def test_build_findings_foundry_is_empty(): + """v1 is prover-only: a foundry BAD (author-declared expected-failure demonstration) is not a + discovered vulnerability, so no findings are synthesized.""" + rules = [_rv("c.t.sol", "t_bad", Outcome.BAD)] + findings = await build_findings( + contract_name="V", backend="foundry", rules=rules, properties=[], groups=[], + fetch_evidence=None, llm=_FindingStubModel(draft=_draft()), + ) + assert findings == [] + + +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 and "Medium likelihood" 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 + assert reloaded.findings[0].content.impact == "funds at risk" + assert reloaded.findings[0].provenance.rule_name == "r_bad" + assert reloaded.findings[0].provenance.severity_reasoning == "High impact x Medium likelihood -> high." + assert not hasattr(reloaded.findings[0], "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 (under both the bare and pretty-printed rule name) while still emitting the stream event.""" + from composer.spec.source.prover import _SpecCallbacks + from composer.prover.ptypes import RulePath, RuleResult + + store = CexAnalysisStore() + 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 = 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 store.get(rule.name) is None + # the UI stream event still fires + assert any(e.get("type") == "rule_analysis" for e in events)