Files
Open_Ultra_SAST/src/openultrasast/push/report.py
T

249 lines
12 KiB
Python

"""Compact presentation and complete artifacts for an already-decided push."""
from __future__ import annotations
import json
import math
import multiprocessing
import os
import time
import uuid
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from multiprocessing.connection import Connection
from pathlib import Path
from types import MappingProxyType
from typing import Literal
from openultrasast.model.contracts import ChangeContext, ExecutionBudget
from openultrasast.model.scan import ModelScanResult
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult, SnapshotManifest
from openultrasast.push.policy import AdmissionResult, _context_boundaries
@dataclass(frozen=True)
class ScanRecord:
comparison: PushComparison
side: Literal["base", "head"]
scan: ModelScanResult
@dataclass(frozen=True)
class PushReport:
result: PushResult
admission: AdmissionResult
scans: tuple[ScanRecord, ...]
provenance: Mapping[str, str]
timings: Mapping[str, float]
resolution: PushResolution | None = None
snapshots: tuple[SnapshotManifest, ...] = ()
change_context: ChangeContext | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance)))
object.__setattr__(self, "timings", MappingProxyType(dict(self.timings)))
ids = tuple(defect.defect_id for defect in self.admission.defects)
if ids != self.result.actionable_defect_ids:
raise ValueError("report must retain the exact admitted defect set and order")
if any(
not all((d.witnesses, d.locations, d.consequences, d.repairs, d.change_evidence, d.comparisons)) for d in self.admission.defects
):
raise ValueError("actionable summaries require witnesses, locations, consequence, repair and change evidence")
admitted = {d.candidate.delta.defect_id for d in self.admission.dispositions if d.admitted}
if admitted != set(ids):
raise ValueError("report must retain every admitted candidate disposition")
comparisons = {analysis.comparison for analysis in self.result.analyses}
if self.result.coverage_status == "complete_within_scope" and self.admission.coverage_reasons:
raise ValueError("complete comparison cannot hide admission coverage gaps")
if any(record.comparison not in comparisons or record.side not in ("base", "head") for record in self.scans):
raise ValueError("scan evidence must belong to an exact reported comparison and side")
for analysis in self.result.analyses:
heads = [record.scan for record in self.scans if record.comparison == analysis.comparison and record.side == "head"]
if any(scope not in [scan.scope for scan in heads] for scope in analysis.scopes):
raise ValueError("reported scope requires the actual head scan evidence")
completed = {outcome.identity for scan in heads for outcome in scan.question_outcomes if outcome.status == "completed"}
if not set(analysis.completed_questions) <= completed:
raise ValueError("completed questions require completed recorded outcomes")
if analysis.coverage_status == "complete_within_scope":
bases = [record.scan for record in self.scans if record.comparison == analysis.comparison and record.side == "base"]
if not bases or any(not _completed_scan(scan) for scan in (*heads, *bases)):
raise ValueError("complete comparison requires completed head and base evidence")
required = {"repository", "engine", "facts", "queries", "policy"}
if not required <= self.provenance.keys() or any(not isinstance(v, str) or not v.strip() for v in self.provenance.values()):
raise ValueError("report requires repository, engine, facts, queries and policy provenance")
if "total_seconds" not in self.timings or any(
type(value) not in (int, float) or not math.isfinite(value) or value < 0 for value in self.timings.values()
):
raise ValueError("report requires finite nonnegative timings including total_seconds")
@dataclass(frozen=True)
class ReportDelivery:
result: PushResult
text: str
artifact: Path | None
error: str | None
reporting_seconds: float = 0.0
@property
def exit_code(self) -> int:
return int(self.result.push_disposition == "block")
def _completed_scan(scan: ModelScanResult) -> bool:
return bool(
scan.scope
and scan.scope.population_complete
and not scan.scope.deferred
and not _context_boundaries(scan)
and not scan.degradations
and {q.identity for q in scan.scope.selected} == {q.identity for q in scan.question_outcomes if q.status == "completed"}
and all(q.status == "completed" for q in scan.question_outcomes)
)
def _write_artifact(report: PushReport, target: Path, temporary: Path, connection: Connection) -> None:
"""Only this bounded child performs serialization or filesystem operations."""
try:
target.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
payload = {
"schema_version": 1,
"snapshots": [item.to_payload() for item in report.snapshots],
"change_context": report.change_context.to_payload() if report.change_context else None,
"result": report.result.to_payload(),
"admission": report.admission.to_payload(),
"scans": [asdict(record) for record in report.scans],
"provenance": dict(report.provenance),
"timings": dict(report.timings),
"timing_scope": "Caller stage measurements; publication and rendering duration is returned in ReportDelivery.",
"resolution": report.resolution.to_payload() if report.resolution else None,
}
json.dump(payload, stream, ensure_ascii=True, allow_nan=False, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
temporary.unlink(missing_ok=True)
connection.send(None)
except Exception as error:
connection.send("artifact_write_failed:" + type(error).__name__)
finally:
connection.close()
def _save(report: PushReport, target: Path, budget: ExecutionBudget) -> str | None:
# Persist completed evidence during the remaining cancellation/report allowance.
# Never restart that allowance for a late caller.
end = budget.deadline_monotonic + budget.cancellation_allowance_seconds
if time.monotonic() >= end:
return "report_deadline_exhausted"
reserve = min(0.1, budget.cancellation_allowance_seconds / 2)
reader = writer = process = None
try:
temporary = target.with_name("." + target.name + ".partial-" + uuid.uuid4().hex)
context = multiprocessing.get_context("fork")
reader, writer = context.Pipe(duplex=False)
process = context.Process(target=_write_artifact, args=(report, target, temporary, writer), daemon=True)
process.start()
writer.close()
process.join(max(0.0, end - time.monotonic() - reserve))
if process.is_alive():
process.kill()
process.join(max(0.0, end - time.monotonic()))
return "report_deadline_exhausted"
if process.exitcode != 0 or not reader.poll():
return "artifact_writer_failed"
error = reader.recv()
return None if error is None else error if isinstance(error, str) else "artifact_writer_failed"
except (OSError, ValueError, EOFError, RuntimeError):
return "artifact_writer_unavailable"
finally:
if writer is not None:
writer.close()
if reader is not None:
reader.close()
if process is not None:
if process.is_alive():
process.kill()
process.join(max(0.0, end - time.monotonic()))
if not process.is_alive():
process.close()
def _short(value: str, limit: int = 180) -> str:
# Bound before escaping: repository-controlled names/witnesses cannot add lines
# or terminal escapes. Full spelling and every witness remain in the artifact.
clipped = value if len(value) <= limit else value[: limit // 2] + "..." + value[-limit // 2 :]
return json.dumps(clipped, ensure_ascii=True)[1:-1]
def render_report(report: PushReport, *, artifact: Path | None, error: str | None = None) -> str:
lines: list[str] = []
defects = report.admission.defects
for defect in defects[:3]:
path, line = defect.locations[0]
delta = next(
disposition.candidate.delta
for disposition in report.admission.dispositions
if disposition.admitted and disposition.candidate.delta.defect_id == defect.defect_id
)
change = (
"A protection present in the base is missing here."
if delta.reason == "discharge_removed"
else "This change connects input to the operation."
)
lines.extend(
(
f"- {_short(defect.family)} at {_short(path, 4096)}:{line}: {_short(defect.consequences[0])}",
f" Evidence: {_short(defect.witnesses[0])}; Change: {change} ({_short(defect.change_evidence[0])})",
f" Fix: {_short(defect.repairs[0])}",
)
)
if len(defects) > 3:
lines.append(f"{len(defects) - 3} more actionable defects retained in the detailed result.")
if not defects:
coverage = report.result.coverage_status
if coverage == "complete_within_scope":
completed = sum(len(analysis.completed_questions) for analysis in report.result.analyses)
lines.append(f"No actionable defects in {completed} completed checks within the recorded scope.")
elif coverage == "not_applicable":
lines.append("No applicable code updates to check.")
elif coverage == "unavailable":
lines.append("No analysis result is available.")
else:
lines.append("No actionable defects reported; analysis is incomplete.")
notices = []
if report.result.coverage_status in ("incomplete", "unavailable"):
reasons = report.admission.coverage_reasons
if any("deadline" in reason for reason in reasons):
notices.append("Analysis did not finish. Rerun with a longer analysis deadline.")
elif any("capability" in reason for reason in reasons):
notices.append("Some checks are not enabled for normal alerts. Review capability coverage in the details.")
else:
notices.append(
"Some checks could not be completed. Review the comparison details and resolve the recorded gaps before retrying."
)
if error == "artifact_inside_repository":
notices.append("Details could not be saved. Choose an artifact path outside the analyzed repository.")
elif error:
notices.append("Details could not be saved. Retry with a writable artifact path and available reporting time.")
if notices:
lines.append("Notice: " + " ".join(notices))
if artifact is not None:
lines.append("Details: " + _short(str(artifact), 4096))
if report.result.push_disposition == "block":
lines.append("Push blocked by the configured policy.")
return "\n".join(lines) + "\n"
def deliver_report(report: PushReport, artifact: Path, *, execution_budget: ExecutionBudget) -> ReportDelivery:
started = time.monotonic()
error = _save(report, artifact, execution_budget)
saved = artifact if error is None else None
text = render_report(report, artifact=saved, error=error)
return ReportDelivery(report.result, text, saved, error, time.monotonic() - started)