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.
269 lines
8.9 KiB
Go
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"`
|
|
}
|