Skip to content

Commit a7a3b25

Browse files
committed
feat: add sarif endpoint and ci action guard
1 parent 8952d8e commit a7a3b25

File tree

6 files changed

+109
-0
lines changed

6 files changed

+109
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ Copy `.env.example` to `.env` and adjust values locally if you prefer dotenv-sty
123123
| `/v1/analysis/{id}` | `GET` | Poll analysis status, findings count, and risk summary snapshot |
124124
| `/v1/analysis/{id}/decision` | `GET` | Fetch the governance decision (allow/block/warn) with evidence |
125125
| `/v1/analysis/{id}/bundle` | `GET` | Retrieve the signed DSSE decision bundle |
126+
| `/v1/analysis/{id}/sarif` | `GET` | Retrieve the SARIF 2.1.0 findings report for the analysis |
126127
| `/v1/analytics/summary` | `GET` | Retrieve aggregated KPIs (risk rate, provenance, volume, churn, complexity, etc.) |
127128
| `/v1/analytics/agents/behavior` | `GET` | Retrieve composite behavioral snapshots for each agent |
128129
| `/v1/detectors/capabilities` | `GET` | Enumerate active detectors (Semgrep configs, versions, metadata) |
@@ -180,6 +181,13 @@ To smoke-test the GitHub resolver end-to-end:
180181

181182
The same process works against forks or sandboxes—helpful when validating new heuristics without polluting production repositories.
182183

184+
## CI Integration
185+
186+
- A composite GitHub Action is bundled at `clients/github-action/`. Reference it from `.github/workflows/provenance.yml` and pass `api_url` + `api_token` secrets to submit each pull request diff. The action fails automatically when the governance outcome is `block`.
187+
- The workflow helper collects the PR diff (`base_sha..head_sha`), submits it to `/v1/analysis`, polls `/v1/analysis/{id}`, and prints the enriched decision payload so reviewers can inspect risk summaries inline.
188+
- Consume `/v1/analysis/{id}/sarif` when you need static-analysis interoperability (e.g., uploading to GitHub code scanning or aggregating findings in other dashboards).
189+
- Surface decision bundles in CI by hitting `/v1/analysis/{id}/bundle` (e.g., attach the DSSE envelope as a build artifact) to preserve signed provenance for downstream policy checks.
190+
183191
## Telemetry Export
184192

185193
- Each analysis generates an `analysis_metrics` event written to `data/timeseries_events.jsonl` by default.

app/routers/analysis.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
6+
from fastapi.responses import JSONResponse
67

78
from app.core.config import settings
89
from app.dependencies import get_analysis_service, get_store
@@ -14,6 +15,7 @@
1415
DecisionBundleResponse,
1516
)
1617
from app.services.analysis import AnalysisService
18+
from app.services.sarif import build_sarif
1719

1820

1921
router = APIRouter(prefix=f"{settings.api_v1_prefix}/analysis", tags=["analysis"])
@@ -46,12 +48,16 @@ def get_analysis_status(
4648
findings_total = len(store.list_findings(analysis_id))
4749
decision = store.get_policy_decision(analysis_id)
4850
risk_summary = decision.risk_summary if decision else {}
51+
decision_payload = decision.model_dump() if decision else None
52+
if decision_payload and "risk_summary" in decision_payload:
53+
decision_payload.pop("risk_summary")
4954
return AnalysisStatusResponse(
5055
analysis_id=record.analysis_id,
5156
status=record.status,
5257
updated_at=record.updated_at,
5358
findings_total=findings_total,
5459
risk_summary=risk_summary,
60+
decision=decision_payload,
5561
)
5662

5763

@@ -68,3 +74,17 @@ def get_decision_bundle(
6874
bundle=bundle,
6975
request_id=f"rq_{settings.default_policy_version}-{analysis_id}",
7076
)
77+
78+
79+
@router.get("/{analysis_id}/sarif")
80+
def get_analysis_sarif(
81+
analysis_id: str,
82+
analysis_service: AnalysisService = Depends(get_analysis_service),
83+
store: RedisWarehouse = Depends(get_store),
84+
) -> JSONResponse:
85+
record = analysis_service.get_analysis(analysis_id)
86+
if not record:
87+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Analysis not found")
88+
findings = store.list_findings(analysis_id)
89+
sarif_report = build_sarif(record, findings)
90+
return JSONResponse(sarif_report)

app/schemas/analysis.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class AnalysisStatusResponse(BaseModel):
8383
updated_at: datetime
8484
findings_total: int
8585
risk_summary: dict = Field(default_factory=dict)
86+
decision: dict | None = None
8687

8788

8889
class DecisionBundleResponse(BaseModel):

app/services/sarif.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Utilities for generating SARIF reports from findings."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
from typing import Iterable
7+
8+
from app.models.domain import AnalysisRecord, Finding, SeverityLevel
9+
10+
_SEVERITY_MAP = {
11+
SeverityLevel.CRITICAL: "error",
12+
SeverityLevel.HIGH: "error",
13+
SeverityLevel.MEDIUM: "warning",
14+
SeverityLevel.LOW: "note",
15+
}
16+
17+
18+
def build_sarif(analysis: AnalysisRecord, findings: Iterable[Finding]) -> dict:
19+
results = []
20+
for finding in findings:
21+
level = _SEVERITY_MAP.get(finding.severity, "warning")
22+
results.append(
23+
{
24+
"ruleId": finding.rule_key,
25+
"level": level,
26+
"message": {"text": finding.message},
27+
"locations": [
28+
{
29+
"physicalLocation": {
30+
"artifactLocation": {"uri": finding.file_path},
31+
"region": {
32+
"startLine": finding.line_number,
33+
},
34+
}
35+
}
36+
],
37+
"properties": {
38+
"analysis_id": analysis.analysis_id,
39+
"repo_id": analysis.repo_id,
40+
"pr_number": analysis.pr_number,
41+
"engine_name": finding.engine_name,
42+
},
43+
}
44+
)
45+
46+
sarif = {
47+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
48+
"version": "2.1.0",
49+
"runs": [
50+
{
51+
"tool": {
52+
"driver": {
53+
"name": "Provenance Governance",
54+
"informationUri": "https://github.com/evalops/provenance",
55+
"rules": [],
56+
}
57+
},
58+
"invocations": [
59+
{
60+
"executionSuccessful": True,
61+
"startTimeUtc": analysis.created_at.isoformat(),
62+
"endTimeUtc": analysis.updated_at.isoformat(),
63+
}
64+
],
65+
"results": results,
66+
}
67+
],
68+
}
69+
return sarif

clients/github-action/run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ def main() -> None:
104104
analysis_id = response["analysis_id"]
105105
decision = poll_decision(args.api_url, args.api_token, analysis_id)
106106
print(json.dumps(decision, indent=2))
107+
decision_info = decision.get("decision") or {}
108+
outcome_value = decision_info.get("outcome") or decision.get("status")
109+
if isinstance(outcome_value, str) and outcome_value.lower() == "block":
110+
raise SystemExit(1)
107111

108112

109113
if __name__ == "__main__":

tests/test_api_endpoints.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def test_full_analysis_flow_via_api():
9999
body = status_resp.json()
100100
assert body["findings_total"] == 1
101101
assert body["risk_summary"]["findings_by_category"] == {"code_execution": 1}
102+
assert body["decision"]["outcome"] == "allow"
102103

103104
summary = client.get("/v1/analytics/summary", params={"time_window": "1d", "metric": "code_volume"})
104105
assert summary.status_code == 200
@@ -119,3 +120,9 @@ def test_full_analysis_flow_via_api():
119120
bundle_json = bundle_resp.json()
120121
assert bundle_json["analysis_id"] == analysis_id
121122
assert bundle_json["bundle"]["payloadType"] == "application/provenance.decision+json"
123+
124+
sarif_resp = client.get(f"/v1/analysis/{analysis_id}/sarif")
125+
assert sarif_resp.status_code == 200
126+
sarif_json = sarif_resp.json()
127+
assert sarif_json["version"] == "2.1.0"
128+
assert sarif_json["runs"]

0 commit comments

Comments
 (0)