Files
trufflehog/pkg/output/sarif.go
Kashif Khan 74bb454e0d feat(output): add SARIF output format for GitHub code scanning (#5165)
Adds --sarif flag emitting a SARIF 2.1.0 log, buffered across the scan
and flushed once finished since SARIF isn't a streamable format like
the existing printers. Verified results map to "error", unverified to
"warning", with a stable per-finding fingerprint for cross-scan
new/fixed tracking when uploaded via github/codeql-action/upload-sarif.
2026-08-05 19:48:20 +05:00

269 lines
8.9 KiB
Go

package output
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"sync"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/version"
)
// SARIF (Static Analysis Results Interchange Format) 2.1.0 identifiers.
// See https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
const (
sarifSchemaURI = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
sarifVersion = "2.1.0"
// sarifLevelError/sarifLevelWarning map to SARIF's result.level. Verified secrets are
// confirmed live credentials and are reported as errors; unverified secrets matched a
// detector pattern but could not be confirmed, and are reported as warnings so that tools
// consuming SARIF (e.g. GitHub code scanning) can triage them separately.
sarifLevelError = "error"
sarifLevelWarning = "warning"
)
// SarifPrinter is a printer that accumulates results and, once the scan completes, emits them
// as a single SARIF 2.1.0 log. Unlike the other Printer implementations, SARIF results cannot be
// streamed one-per-line: the spec requires one JSON document containing every run and result, so
// Print only buffers results and Flush performs the actual marshal/write.
//
// TODO: results are held in memory for the full scan and Flush marshals them in one pass, so peak
// memory grows with result count (roughly 2x at Flush, for the struct plus its marshaled JSON).
// Fine for typical scan sizes; if scans with very large result counts start OOMing, switch to
// writing results to the underlying writer incrementally as they're produced in Print.
type SarifPrinter struct {
mu sync.Mutex
results []sarifResult
rules map[string]*sarifRule // keyed by detector type name, de-duplicated across results
}
// Print buffers a single result for inclusion in the SARIF document written by Flush.
func (p *SarifPrinter) Print(_ context.Context, r *detectors.ResultWithMetadata) error {
meta, err := structToMap(r.SourceMetadata.Data)
if err != nil {
return fmt.Errorf("could not marshal result: %w", err)
}
file, line := extractFileAndLine(meta)
ruleID := r.DetectorType.String()
level := sarifLevelWarning
if r.Verified {
level = sarifLevelError
}
verifiedStatus := "unverified"
if r.Verified {
verifiedStatus = "verified"
}
location := sarifLocation{
PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{
URI: sarifArtifactURI(file, r.SourceType.String(), r.SourceName),
},
},
}
// SARIF's region is optional; only sources whose metadata carries a line number (git,
// filesystem, S3, etc.) can populate it. Sources like Postman or Elasticsearch have no
// concept of a line, so region is omitted rather than reported as a misleading zero.
if line > 0 {
location.PhysicalLocation.Region = &sarifRegion{StartLine: line}
}
result := sarifResult{
RuleID: ruleID,
Level: level,
Message: sarifMessage{Text: fmt.Sprintf("Found %s result for detector %s.", verifiedStatus, ruleID)},
Locations: []sarifLocation{
location,
},
// PartialFingerprints lets GitHub code scanning (and other SARIF consumers) match the
// same finding across scans, so it can track a secret as "new" or "fixed" instead of
// reporting it fresh on every run.
PartialFingerprints: map[string]string{
"trufflehogFingerprint/v1": sarifFingerprint(ruleID, location.PhysicalLocation.ArtifactLocation.URI, line, r.Raw),
},
}
p.mu.Lock()
defer p.mu.Unlock()
if p.rules == nil {
p.rules = make(map[string]*sarifRule)
}
if _, ok := p.rules[ruleID]; !ok {
p.rules[ruleID] = &sarifRule{
ID: ruleID,
Name: ruleID,
ShortDescription: sarifMessage{Text: r.DetectorDescription},
}
}
p.results = append(p.results, result)
return nil
}
// Flush marshals every result buffered by Print into a single SARIF 2.1.0 log and writes it to
// w. It must be called exactly once, after all Print calls have completed (i.e. once the scan
// has finished), since SARIF is a single JSON document rather than a streamable format.
func (p *SarifPrinter) Flush(w io.Writer) error {
p.mu.Lock()
defer p.mu.Unlock()
rules := make([]*sarifRule, 0, len(p.rules))
for _, rule := range p.rules {
rules = append(rules, rule)
}
// Sort for deterministic output; map iteration order is randomized in Go.
sort.Slice(rules, func(i, j int) bool { return rules[i].ID < rules[j].ID })
results := p.results
if results == nil {
// Emit an empty array rather than JSON null when nothing was found.
results = []sarifResult{}
}
doc := sarifLog{
Schema: sarifSchemaURI,
Version: sarifVersion,
Runs: []sarifRun{
{
Tool: sarifTool{
Driver: sarifDriver{
Name: "trufflehog",
InformationURI: "https://github.com/trufflesecurity/trufflehog",
Version: version.BuildVersion,
Rules: rules,
},
},
Results: results,
},
},
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("could not marshal SARIF output: %w", err)
}
if _, err := w.Write(out); err != nil {
return fmt.Errorf("could not write SARIF output: %w", err)
}
_, err = w.Write([]byte("\n"))
return err
}
// extractFileAndLine pulls the "file" and "line" fields out of a result's source metadata, if
// present. Most source metadata types (git, filesystem, S3, docker, ...) carry these fields, but
// not all do, so both return values may be zero.
func extractFileAndLine(meta map[string]map[string]any) (file string, line int64) {
for _, data := range meta {
for k, v := range data {
switch k {
case "file":
if f, ok := v.(string); ok {
file = f
}
case "line":
if l, ok := v.(float64); ok {
line = int64(l)
}
}
}
}
return file, line
}
// sarifArtifactURI returns the best-effort identifier for where a secret was found. It prefers
// the file path from source metadata; when a source has no file concept (e.g. Postman,
// Elasticsearch) it falls back to a "<sourcetype>://<sourcename>" URI so the location field is
// never empty, which the SARIF spec requires.
func sarifArtifactURI(file, sourceType, sourceName string) string {
if file != "" {
return file
}
return fmt.Sprintf("%s://%s", strings.ToLower(sourceType), sourceName)
}
// sarifFingerprint derives a stable identifier for a finding so SARIF consumers can recognize
// the same secret across repeated scans (e.g. to mark it "fixed" once it no longer appears).
// Verification status is deliberately excluded: it's already carried in the result's "level"
// field, and including it here would change the fingerprint (and reset alert history) whenever
// a secret's verification flips between runs. The raw secret value is included so that sources
// with no file/line concept (Postman, Elasticsearch, ...) don't collapse every finding of the
// same detector type into one fingerprint.
func sarifFingerprint(ruleID, uri string, line int64, raw []byte) string {
key := fmt.Sprintf("%s:%s:%d:%x", ruleID, uri, line, sha256.Sum256(raw))
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
// The following types implement a minimal subset of the SARIF 2.1.0 object model needed to
// describe trufflehog's results. Only fields trufflehog actually populates are included; the
// full spec has many optional fields that aren't relevant here.
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"`
InformationURI string `json:"informationUri"`
Version string `json:"version"`
Rules []*sarifRule `json:"rules"`
}
// sarifRule describes a detector as a SARIF "rule". One rule is emitted per distinct detector
// type that produced at least one result.
type sarifRule struct {
ID string `json:"id"`
Name string `json:"name"`
ShortDescription sarifMessage `json:"shortDescription"`
}
type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations"`
PartialFingerprints map[string]string `json:"partialFingerprints,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
}
type sarifPhysicalLocation struct {
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifactLocation struct {
URI string `json:"uri"`
}
type sarifRegion struct {
StartLine int64 `json:"startLine"`
}