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.
This commit is contained in:
Kashif Khan
2026-08-05 19:48:20 +05:00
committed by GitHub
parent 24c98ca20e
commit 74bb454e0d
6 changed files with 446 additions and 15 deletions
+15
View File
@@ -233,6 +233,8 @@ Expected output:
...
```
TruffleHog can also output [SARIF](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) with `--sarif` instead of `--json`. GitHub code scanning understands SARIF natively, so uploading it surfaces findings inline on pull request diffs and in the repository's Security tab, and tracks findings as new/fixed across scans instead of reporting the same one every run — see the [TruffleHog Github Action](#octocat-trufflehog-github-action) section below for how to upload it. Note that, unlike the other output formats, SARIF results are buffered in memory for the full scan and written out at the end, since SARIF requires a single JSON document rather than a stream — fine for typical scans, but scans producing a very large number of results will use proportionally more memory.
## 5: Scan a GitHub Repo + its Issues and Pull Requests
```bash
@@ -464,6 +466,8 @@ Flags:
--[no-]json-legacy Use the pre-v3.0 JSON format. Only works with git, gitlab,
and github sources.
--[no-]github-actions Output in GitHub Actions format.
--[no-]sarif Output in SARIF format for upload to GitHub code scanning (e.g.
via github/codeql-action/upload-sarif).
--concurrency=12 Number of concurrent workers.
--[no-]no-verification Don't verify the results.
--results=RESULTS Specifies which type(s) of results to output: verified (confirmed
@@ -725,6 +729,17 @@ TruffleHog statically detects [https://canarytokens.org/](https://canarytokens.o
If you'd like to specify specific `base` and `head` refs, you can use the `base` argument (`--since-commit` flag in TruffleHog CLI) and the `head` argument (`--branch` flag in the TruffleHog CLI). We only recommend using these arguments for very specific use cases, where the default behavior does not work.
To upload results to GitHub code scanning instead, run TruffleHog directly with `--sarif` and pass the output to `github/codeql-action/upload-sarif`:
```yaml
- name: TruffleHog
run: trufflehog filesystem . --sarif --no-verification > results.sarif
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
#### Advanced Usage: Scan entire branch
```
+3
View File
@@ -38,6 +38,9 @@ Use the pre-v3.0 JSON format. Only works with git, gitlab, and github sources.
\fB--github-actions\fR
Output in GitHub Actions format.
.TP
\fB--sarif\fR
Output in SARIF format for upload to GitHub code scanning (e.g. via github/codeql-action/upload-sarif).
.TP
\fB--concurrency=N\fR
Number of concurrent workers.
.TP
+13 -1
View File
@@ -55,6 +55,7 @@ var (
jsonOut = cli.Flag("json", "Output in JSON format.").Short('j').Bool()
jsonLegacy = cli.Flag("json-legacy", "Use the pre-v3.0 JSON format. Only works with git, gitlab, and github sources.").Bool()
gitHubActionsFormat = cli.Flag("github-actions", "Output in GitHub Actions format.").Bool()
sarifOut = cli.Flag("sarif", "Output in SARIF format for upload to GitHub code scanning (e.g. via github/codeql-action/upload-sarif).").Bool()
concurrency = cli.Flag("concurrency", "Number of concurrent workers.").PlaceHolder("N").Int()
noVerification = cli.Flag("no-verification", "Don't verify the results.").Bool()
onlyVerified = cli.Flag("only-verified", "Only output verified results.").Hidden().Bool()
@@ -603,11 +604,13 @@ func run(state overseer.State, logSync func() error) {
printer = new(output.JSONPrinter)
case *gitHubActionsFormat:
printer = new(output.GitHubActionsPrinter)
case *sarifOut:
printer = new(output.SarifPrinter)
default:
printer = new(output.PlainPrinter)
}
if !*jsonLegacy && !*jsonOut {
if !*jsonLegacy && !*jsonOut && !*sarifOut {
fmt.Fprintf(os.Stderr, "🐷🔑🐷 TruffleHog. Unearth your secrets. 🐷🔑🐷\n\n")
}
@@ -673,6 +676,15 @@ func run(state overseer.State, logSync func() error) {
logFatal(err, "error running scan")
}
// SARIF can't be streamed like the other output formats: it's a single JSON document
// wrapping every result, so it's buffered by the printer and written out here, once
// scanning has fully finished.
if sarifPrinter, ok := printer.(*output.SarifPrinter); ok {
if err := sarifPrinter.Flush(os.Stdout); err != nil {
logFatal(err, "error writing SARIF output")
}
}
verificationCacheMetricsSnapshot := struct {
Hits int32
Misses int32
+1 -14
View File
@@ -29,20 +29,7 @@ func (p *GitHubActionsPrinter) Print(_ context.Context, r *detectors.ResultWithM
return fmt.Errorf("could not marshal result: %w", err)
}
for _, data := range meta {
for k, v := range data {
if k == "line" {
if line, ok := v.(float64); ok {
out.StartLine = int64(line)
}
}
if k == "file" {
if filename, ok := v.(string); ok {
out.Filename = filename
}
}
}
}
out.Filename, out.StartLine = extractFileAndLine(meta)
verifiedStatus := "unverified"
if out.Verified {
+268
View File
@@ -0,0 +1,268 @@
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"`
}
+146
View File
@@ -0,0 +1,146 @@
package output
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
)
// gitResult builds a verified result from a git-family source, which has file/line metadata.
func gitResult(verified bool) *detectors.ResultWithMetadata {
return &detectors.ResultWithMetadata{
SourceMetadata: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Git{
Git: &source_metadatapb.Git{File: "config/prod.yaml", Line: 42},
},
},
SourceType: sourcespb.SourceType_SOURCE_TYPE_GIT,
SourceName: "my-repo",
DetectorDescription: "AWS credentials",
Result: detectors.Result{
DetectorType: detector_typepb.DetectorType_AWS,
Raw: []byte("secret"),
Verified: verified,
},
}
}
// elasticsearchResult builds a result from a source with no file/line concept.
func elasticsearchResult() *detectors.ResultWithMetadata {
return &detectors.ResultWithMetadata{
SourceMetadata: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Elasticsearch{
Elasticsearch: &source_metadatapb.Elasticsearch{Index: "logs", DocumentId: "123"},
},
},
SourceType: sourcespb.SourceType_SOURCE_TYPE_ELASTICSEARCH,
SourceName: "my-cluster",
DetectorDescription: "Slack token",
Result: detectors.Result{
DetectorType: detector_typepb.DetectorType_Slack,
Raw: []byte("secret"),
Verified: false,
},
}
}
func TestSarifPrinter_PrintAndFlush(t *testing.T) {
p := &SarifPrinter{}
ctx := context.Background()
require.NoError(t, p.Print(ctx, gitResult(true)))
require.NoError(t, p.Print(ctx, elasticsearchResult()))
var buf bytes.Buffer
require.NoError(t, p.Flush(&buf))
var doc sarifLog
require.NoError(t, json.Unmarshal(buf.Bytes(), &doc))
assert.Equal(t, sarifVersion, doc.Version)
assert.Equal(t, sarifSchemaURI, doc.Schema)
require.Len(t, doc.Runs, 1)
run := doc.Runs[0]
assert.Equal(t, "trufflehog", run.Tool.Driver.Name)
require.Len(t, run.Tool.Driver.Rules, 2, "one rule per distinct detector type")
require.Len(t, run.Results, 2)
// Verified git result: file/line populated, level "error".
gitFinding := run.Results[0]
assert.Equal(t, "AWS", gitFinding.RuleID)
assert.Equal(t, sarifLevelError, gitFinding.Level)
assert.Equal(t, "config/prod.yaml", gitFinding.Locations[0].PhysicalLocation.ArtifactLocation.URI)
require.NotNil(t, gitFinding.Locations[0].PhysicalLocation.Region)
assert.EqualValues(t, 42, gitFinding.Locations[0].PhysicalLocation.Region.StartLine)
assert.NotEmpty(t, gitFinding.PartialFingerprints["trufflehogFingerprint/v1"])
// Unverified elasticsearch result: no file/line, falls back to a source URI, level "warning".
esFinding := run.Results[1]
assert.Equal(t, "Slack", esFinding.RuleID)
assert.Equal(t, sarifLevelWarning, esFinding.Level)
assert.Equal(t, "source_type_elasticsearch://my-cluster", esFinding.Locations[0].PhysicalLocation.ArtifactLocation.URI)
assert.Nil(t, esFinding.Locations[0].PhysicalLocation.Region)
}
func TestSarifPrinter_FlushWithNoResults(t *testing.T) {
p := &SarifPrinter{}
var buf bytes.Buffer
require.NoError(t, p.Flush(&buf))
var doc sarifLog
require.NoError(t, json.Unmarshal(buf.Bytes(), &doc))
require.Len(t, doc.Runs, 1)
assert.NotNil(t, doc.Runs[0].Results, "results must be an empty array, not null, per the SARIF spec")
assert.Empty(t, doc.Runs[0].Results)
}
func TestSarifFingerprint_StableAndDistinct(t *testing.T) {
a := sarifFingerprint("AWS", "config/prod.yaml", 42, []byte("secret1"))
b := sarifFingerprint("AWS", "config/prod.yaml", 42, []byte("secret1"))
assert.Equal(t, a, b, "fingerprint must be stable across calls with identical inputs")
c := sarifFingerprint("AWS", "config/prod.yaml", 43, []byte("secret1"))
assert.NotEqual(t, a, c, "fingerprint must change when the finding location changes")
// Same rule/location but no file/line (e.g. Postman, Elasticsearch) must not collapse
// distinct secrets into the same fingerprint.
d := sarifFingerprint("Slack", "elasticsearch://my-cluster", 0, []byte("secret1"))
e := sarifFingerprint("Slack", "elasticsearch://my-cluster", 0, []byte("secret2"))
assert.NotEqual(t, d, e, "fingerprint must differ for distinct secrets sharing a fileless location")
}
// TestSarifPrinter_FingerprintStableAcrossVerificationChange guards against regressing to a
// fingerprint that includes verification status: if a secret's status flips between scans (API
// error, rate limit, credential rotation), the fingerprint must stay the same so GitHub code
// scanning tracks it as the same finding rather than closing/reopening an alert.
func TestSarifPrinter_FingerprintStableAcrossVerificationChange(t *testing.T) {
ctx := context.Background()
unverified := &SarifPrinter{}
require.NoError(t, unverified.Print(ctx, gitResult(false)))
verified := &SarifPrinter{}
require.NoError(t, verified.Print(ctx, gitResult(true)))
assert.Equal(t,
unverified.results[0].PartialFingerprints["trufflehogFingerprint/v1"],
verified.results[0].PartialFingerprints["trufflehogFingerprint/v1"],
)
}
func TestSarifArtifactURI(t *testing.T) {
assert.Equal(t, "config/prod.yaml", sarifArtifactURI("config/prod.yaml", "SOURCE_TYPE_GIT", "my-repo"))
assert.Equal(t, "source_type_elasticsearch://my-cluster", sarifArtifactURI("", "SOURCE_TYPE_ELASTICSEARCH", "my-cluster"))
}