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:
@@ -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(),
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user