Add finding review states, ECO export, and a report release signature.

Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:21:36 +02:00
co-authored by Cursor
parent 6fc2ac583d
commit de15402309
14 changed files with 611 additions and 13 deletions
+107
View File
@@ -0,0 +1,107 @@
"""Finding review disposition, ECO export, and release signature.
Review state lives beside comments on the report JSON — it is not a
Finding field, so a pipeline re-run can keep dispositions by finding_id.
Empty reason is invalid. false_positive / wontfix / open are not ECO rows.
"""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import datetime, timezone
from typing import Any, Iterable, Literal
from backend.pinscopex.models import Finding
ReviewState = Literal["open", "false_positive", "accepted", "wontfix"]
VALID_STATES: frozenset[str] = frozenset({"open", "false_positive", "accepted", "wontfix"})
class ReviewError(ValueError):
"""Invalid review payload; do not store a silent default."""
def apply_review_state(
current: dict[str, dict[str, Any]],
finding_id: str,
*,
state: str,
reason: str,
user_id: str,
user_name: str = "",
updated_at: str | None = None,
) -> dict[str, dict[str, Any]]:
if not finding_id:
raise ReviewError("finding_id is required")
if state not in VALID_STATES:
raise ReviewError(f"invalid review state {state!r}")
text = (reason or "").strip()
if state != "open" and not text:
raise ReviewError("reason is required")
rec = {
"state": state,
"reason": text,
"user_id": user_id,
"user_name": user_name,
"updated_at": updated_at or datetime.now(timezone.utc).isoformat(),
}
next_states = dict(current)
if state == "open":
next_states.pop(finding_id, None)
return next_states
next_states[finding_id] = rec
return next_states
def _state_of(states: dict[str, dict[str, Any]], finding_id: str | None) -> str:
if not finding_id:
return "open"
rec = states.get(finding_id)
if not rec:
return "open"
return rec.get("state") or "open"
def build_eco(
findings: Iterable[Finding],
review_states: dict[str, dict[str, Any]],
) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for f in findings:
fid = f.finding_id
if _state_of(review_states, fid) != "accepted":
continue
rec = review_states.get(fid or "", {})
items.append({
"finding_id": fid or "",
"rule_id": f.rule_id or "",
"ref": f.designator,
"before": f.finding,
"after": f.recommendation or "",
"reason": rec.get("reason") or "",
})
return items
def eco_csv(items: list[dict[str, str]]) -> str:
buf = io.StringIO()
writer = csv.DictWriter(
buf,
fieldnames=["finding_id", "rule_id", "ref", "before", "after", "reason"],
)
writer.writeheader()
writer.writerows(items)
return buf.getvalue()
def sign_report(report: dict[str, Any], *, user_id: str, timestamp: str | None = None) -> dict[str, str]:
payload = json.dumps(report.get("findings") or [], sort_keys=True, default=str)
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return {
"sha256": digest,
"user_id": user_id,
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
}
+90 -3
View File
@@ -8,9 +8,17 @@ import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from backend.pinscopex.models import Finding
from backend.pinscopex.review_workflow import (
ReviewError,
apply_review_state,
build_eco,
eco_csv,
sign_report,
)
from backend.pinscopex.utils import safe_mpn
from backend.routers.deps import get_storage, get_user_id, resolve_or_404
from backend.services import projects as proj_svc
@@ -100,8 +108,87 @@ async def delete_comment(project_id: str, comment_id: str, request: Request):
comment_list.pop(i)
if not comment_list:
del comments[finding_id]
storage.write_json(key, report_data)
return JSONResponse({"ok": True})
storage.write_json(key, report_data)
return JSONResponse({"ok": True})
class ReviewBody(BaseModel):
state: str
reason: str = ""
user_name: str = ""
def _load_report(storage, owner_user_id: str, project_id: str) -> tuple[str, dict]:
prefix = proj_svc.project_prefix(owner_user_id, project_id)
key = f"{prefix}/report.json"
if not storage.exists(key):
raise HTTPException(404, "Report not found")
return key, storage.read_json(key)
def _findings_from_report(report_data: dict) -> list[Finding]:
out: list[Finding] = []
for raw in report_data.get("findings") or []:
try:
out.append(Finding.model_validate(raw))
except Exception:
continue
return out
@router.put("/report/{project_id}/findings/{finding_id}/review")
async def put_finding_review(project_id: str, finding_id: str, body: ReviewBody, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
key, report_data = _load_report(storage, owner_user_id, project_id)
ids = {f.finding_id for f in _findings_from_report(report_data) if f.finding_id}
if finding_id not in ids:
raise HTTPException(404, "Finding not found")
try:
states = apply_review_state(
report_data.get("review_states") or {},
finding_id,
state=body.state,
reason=body.reason,
user_id=user_id,
user_name=body.user_name,
)
except ReviewError as exc:
raise HTTPException(400, str(exc)) from exc
report_data["review_states"] = states
storage.write_json(key, report_data)
return JSONResponse(states.get(finding_id) or {"state": "open", "reason": ""})
@router.get("/report/{project_id}/eco.json")
async def get_eco_json(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_, report_data = _load_report(storage, owner_user_id, project_id)
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
return JSONResponse({"items": items})
@router.get("/report/{project_id}/eco.csv")
async def get_eco_csv(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
_, report_data = _load_report(storage, owner_user_id, project_id)
items = build_eco(_findings_from_report(report_data), report_data.get("review_states") or {})
return Response(eco_csv(items), media_type="text/csv")
@router.post("/report/{project_id}/sign")
async def post_sign_report(project_id: str, request: Request):
storage = get_storage(request)
owner_user_id, _ = await resolve_or_404(request, project_id)
user_id = get_user_id(request)
key, report_data = _load_report(storage, owner_user_id, project_id)
release = sign_report(report_data, user_id=user_id)
report_data["release"] = release
storage.write_json(key, report_data)
return JSONResponse(release)
raise HTTPException(404, "Comment not found")
+4
View File
@@ -740,10 +740,12 @@ async def validate_design_async(
preserved_findings: list[Finding] = []
preserved_coverage: dict[str, list[str]] = {}
preserved_comments = None
preserved_review_states = None
if existing_path.is_file():
try:
existing = json.loads(existing_path.read_text())
preserved_comments = existing.get("comments")
preserved_review_states = existing.get("review_states")
if before_ic is not None:
# Resume mode — keep findings for refs we're about to skip
for f in existing.get("findings", []):
@@ -805,6 +807,8 @@ async def validate_design_async(
report_dict = json.loads(report.model_dump_json(indent=2))
if preserved_comments is not None:
report_dict["comments"] = preserved_comments
if preserved_review_states is not None:
report_dict["review_states"] = preserved_review_states
if paused:
report_dict["partial"] = True
existing_path.write_text(json.dumps(report_dict, indent=2))