mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
* fix(report): sort sarif driver rules for deterministic output driver.rules was built by ranging a go map, so the emitted order varied between runs over the same input, producing byte-unstable sarif for identical scans. collect the rule ids into a slice and sort them before building the rules list; ruleId still references by id so no result is affected. * fix(report): strip crlf from markdown target headers target and module id are operator-supplied and were written verbatim into "## " / "### " heading lines, so a target containing an embedded newline followed by "## " text could inject a fake standalone heading into the markdown report. sanitizeHeading collapses cr/lf in both before they're written; the finding data block itself was already unaffected.
164 lines
5.1 KiB
Go
164 lines
5.1 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package report
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
// sarif format/version constants pinned to the 2.1.0 schema so the output is
|
|
// ingestable by github code scanning and other sarif consumers.
|
|
const (
|
|
sarifVersion = "2.1.0"
|
|
sarifSchema = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
toolName = "sif"
|
|
)
|
|
|
|
// sarifLog is the minimal valid 2.1.0 shape: one run from one tool.
|
|
type sarifLog struct {
|
|
Schema string `json:"$schema"`
|
|
Version string `json:"version"`
|
|
Runs []sarifRun `json:"runs"`
|
|
}
|
|
|
|
type sarifRun struct {
|
|
Tool sarifTool `json:"tool"`
|
|
Results []sarifResult `json:"results"`
|
|
}
|
|
|
|
type sarifTool struct {
|
|
Driver sarifDriver `json:"driver"`
|
|
}
|
|
|
|
type sarifDriver struct {
|
|
Name string `json:"name"`
|
|
Rules []sarifRule `json:"rules"`
|
|
}
|
|
|
|
type sarifRule struct {
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type sarifResult struct {
|
|
RuleID string `json:"ruleId"`
|
|
Level string `json:"level"`
|
|
Message sarifMessage `json:"message"`
|
|
Locations []sarifLocation `json:"locations"`
|
|
}
|
|
|
|
type sarifMessage struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type sarifLocation struct {
|
|
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
|
|
}
|
|
|
|
type sarifPhysicalLocation struct {
|
|
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
|
|
}
|
|
|
|
type sarifArtifactLocation struct {
|
|
URI string `json:"uri"`
|
|
}
|
|
|
|
// sarif levels this writer emits. the 2.1.0 spec also defines "none", but every
|
|
// sif result is at least informational, so the floor here is "note".
|
|
const (
|
|
sarifError = "error"
|
|
sarifWarning = "warning"
|
|
sarifNote = "note"
|
|
)
|
|
|
|
// sarifLevelFor maps a normalized finding severity onto a sarif level. an empty
|
|
// or unrecognized severity falls back to "warning", the neutral middle ground
|
|
// that preserves the old behavior for results that carry no severity.
|
|
func sarifLevelFor(severity string) string {
|
|
switch severity {
|
|
case "critical", "high":
|
|
return sarifError
|
|
case "low", "info":
|
|
return sarifNote
|
|
case "medium":
|
|
return sarifWarning
|
|
default:
|
|
return sarifWarning
|
|
}
|
|
}
|
|
|
|
// SARIF serializes results to a minimal valid sarif 2.1.0 log. Each module
|
|
// result becomes one sarif result tagged with its module id (the rule) and the
|
|
// target uri, with the raw module data inlined into the message for context.
|
|
func SARIF(results []Result) ([]byte, error) {
|
|
sarifResults := make([]sarifResult, 0, len(results))
|
|
ruleSet := make(map[string]struct{}, len(results))
|
|
|
|
for i := 0; i < len(results); i++ {
|
|
res := results[i]
|
|
ruleSet[res.Module] = struct{}{}
|
|
|
|
sarifResults = append(sarifResults, sarifResult{
|
|
RuleID: res.Module,
|
|
Level: sarifLevelFor(res.Severity),
|
|
Message: sarifMessage{Text: messageFor(res)},
|
|
Locations: []sarifLocation{{
|
|
PhysicalLocation: sarifPhysicalLocation{
|
|
ArtifactLocation: sarifArtifactLocation{URI: res.Target},
|
|
},
|
|
}},
|
|
})
|
|
}
|
|
|
|
// rules must list each id exactly once; build it from the set so duplicate
|
|
// modules across targets don't duplicate the rule. the set itself ranges
|
|
// in random map order, so sort the ids first: otherwise driver.rules
|
|
// would come out in a different order on every run, making the sarif
|
|
// output byte-unstable across identical scans.
|
|
ruleIDs := make([]string, 0, len(ruleSet))
|
|
for id := range ruleSet {
|
|
ruleIDs = append(ruleIDs, id)
|
|
}
|
|
sort.Strings(ruleIDs)
|
|
|
|
rules := make([]sarifRule, 0, len(ruleIDs))
|
|
for _, id := range ruleIDs {
|
|
rules = append(rules, sarifRule{ID: id})
|
|
}
|
|
|
|
doc := sarifLog{
|
|
Schema: sarifSchema,
|
|
Version: sarifVersion,
|
|
Runs: []sarifRun{{
|
|
Tool: sarifTool{Driver: sarifDriver{Name: toolName, Rules: rules}},
|
|
Results: sarifResults,
|
|
}},
|
|
}
|
|
|
|
out, err := json.MarshalIndent(doc, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal sarif: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// messageFor builds a human-readable result message: the module id plus the raw
|
|
// finding json so a sarif viewer shows what was actually found.
|
|
func messageFor(res Result) string {
|
|
if len(res.Data) == 0 {
|
|
return fmt.Sprintf("%s finding on %s", res.Module, res.Target)
|
|
}
|
|
return fmt.Sprintf("%s finding on %s: %s", res.Module, res.Target, string(res.Data))
|
|
}
|