Skip to content

Commit db4edb8

Browse files
committed
report: add SARIF 2.1.0 output (-f sarif)
Lets `openant report -f sarif results_verified.json` emit a SARIF log that GitHub Code Scanning and GitLab SAST can ingest without a converter, matching what every other SAST in this category supports. Renders Go-side via the same flow as -f html: Python's `report-data` subcommand returns pre-computed JSON, and BuildSARIF turns ReportData into a SARIF map. Findings become results, verdicts get synthesized into a `rules` array (vulnerable+bypassable as level `error`, inconclusive/unclear as `warning`, everything else as `note`). File paths land as artifactLocation.uri without a startLine, since the current Finding struct doesn't carry line numbers and emitting a synthetic 1 would anchor alerts to the wrong row in Code Scanning. Each result carries a partialFingerprints entry keyed "openant/file/function/verdict/v1" so re-runs dedupe cleanly, and versionControlProvenance is populated when ReportData.RepoURL is set (including revisionId from CommitSHA when available). 15 unit tests cover envelope shape, rule dedup by verdict, level mapping, path normalization, logical location, dynamic test property propagation, fingerprint stability, VCS provenance gating, the empty-AttackVector fallback that keeps message.text non-empty per spec, the 4 KiB message cap, and an end-to-end round-trip through json.Unmarshal.
1 parent 7e7d0d4 commit db4edb8

3 files changed

Lines changed: 636 additions & 1 deletion

File tree

apps/openant-cli/cmd/report.go

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Formats:
2626
summary Narrative security overview (uses LLM)
2727
html Interactive HTML report with charts and filters
2828
csv Spreadsheet export of all findings
29+
sarif SARIF 2.1.0 log for GitHub Code Scanning / GitLab SAST upload
2930
3031
If no results path is given, the active project's results_verified.json is used.
3132
Python owns default output paths — you only need -o to override.
@@ -50,7 +51,7 @@ var (
5051
func init() {
5152
reportCmd.Flags().StringVarP(&reportOutput, "output", "o", "", "Output path (default: derived from format)")
5253
reportCmd.Flags().StringVar(&reportDataset, "dataset", "", "Path to dataset JSON (for html/csv)")
53-
reportCmd.Flags().StringVarP(&reportFormat, "format", "f", "", "Report format: disclosure, summary, html, csv")
54+
reportCmd.Flags().StringVarP(&reportFormat, "format", "f", "", "Report format: disclosure, summary, html, csv, sarif")
5455
reportCmd.Flags().StringVar(&reportPipelineOutput, "pipeline-output", "", "Path to pipeline_output.json (for summary/disclosure)")
5556
reportCmd.Flags().StringVar(&reportRepoName, "repo-name", "", "Repository name (used when auto-building pipeline_output)")
5657
reportCmd.Flags().StringVar(&reportExtraDest, "copy-to", "", "Copy reports to an additional location")
@@ -213,6 +214,31 @@ func runReport(cmd *cobra.Command, args []string) {
213214
output.PrintReportSummary(data)
214215
}
215216
allResults = append(allResults, data)
217+
} else if fmt == "sarif" {
218+
// SARIF reports use the Go renderer for the same reason HTML
219+
// does: it's a deterministic data transformation, not an
220+
// LLM-generated narrative, so there's no need to round-trip
221+
// through Python.
222+
outputPath := reportOutput
223+
if outputPath == "" {
224+
resultsDir := filepath.Dir(resultsPath)
225+
outputPath = filepath.Join(resultsDir, "final-reports", "report.sarif")
226+
}
227+
228+
if err := runSARIFReport(rt, resultsPath, outputPath); err != nil {
229+
output.PrintError("sarif: " + err.Error())
230+
exitCode = 2
231+
continue
232+
}
233+
234+
data := map[string]any{
235+
"output_path": outputPath,
236+
"format": "sarif",
237+
}
238+
if !jsonOutput {
239+
output.PrintReportSummary(data)
240+
}
241+
allResults = append(allResults, data)
216242
} else {
217243
// Other formats delegate to Python
218244
pyArgs := buildReportArgs(resultsPath, fmt)
@@ -262,6 +288,7 @@ func promptFormats() ([]string, error) {
262288
huh.NewOption("Summary — narrative security overview written by AI ($)", "summary"),
263289
huh.NewOption("HTML — interactive report with charts and filters", "html"),
264290
huh.NewOption("CSV — spreadsheet export of all findings", "csv"),
291+
huh.NewOption("SARIF — GitHub Code Scanning / GitLab SAST upload", "sarif"),
265292
).
266293
Value(&selected),
267294
),
@@ -349,6 +376,48 @@ func runHTMLReport(rt *python.RuntimeInfo, resultsPath string, outputPath string
349376
return nil
350377
}
351378

379+
// runSARIFReport generates a SARIF 2.1.0 log using the Go renderer. Like
380+
// runHTMLReport, it asks Python's report-data subcommand for pre-computed
381+
// data, then transforms it deterministically here. Driver version is wired
382+
// to the CLI's `version` (set via -ldflags at build time).
383+
func runSARIFReport(rt *python.RuntimeInfo, resultsPath string, outputPath string) error {
384+
pyArgs := []string{"report-data", resultsPath}
385+
if reportDataset != "" {
386+
pyArgs = append(pyArgs, "--dataset", reportDataset)
387+
}
388+
389+
result, err := python.Invoke(rt.Path, pyArgs, "", quiet, resolvedAPIKey())
390+
if err != nil {
391+
return fmt.Errorf("report-data failed: %w", err)
392+
}
393+
if result.Envelope.Status != "success" {
394+
msg := "report-data returned error"
395+
if len(result.Envelope.Errors) > 0 {
396+
msg = result.Envelope.Errors[0]
397+
}
398+
return fmt.Errorf("%s", msg)
399+
}
400+
401+
dataBytes, err := json.Marshal(result.Envelope.Data)
402+
if err != nil {
403+
return fmt.Errorf("failed to marshal report data: %w", err)
404+
}
405+
406+
var reportData report.ReportData
407+
if err := json.Unmarshal(dataBytes, &reportData); err != nil {
408+
return fmt.Errorf("failed to parse report data: %w", err)
409+
}
410+
411+
opts := report.SARIFOptions{
412+
ToolVersion: version,
413+
InformationURI: "https://github.com/knostic/OpenAnt",
414+
}
415+
if err := report.GenerateSARIF(reportData, outputPath, opts); err != nil {
416+
return fmt.Errorf("failed to render SARIF: %w", err)
417+
}
418+
return nil
419+
}
420+
352421
// buildReportArgs constructs the Python CLI arguments for a single format.
353422
func buildReportArgs(resultsPath string, format string) []string {
354423
pyArgs := []string{"report", resultsPath, "--format", format}

0 commit comments

Comments
 (0)