feat(devkit): add gitleaks-backed secret scanning
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a4d8aba714
commit
fa20cb8aa5
2 changed files with 173 additions and 0 deletions
109
devkit/scan_secrets.go
Normal file
109
devkit/scan_secrets.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package devkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var scanSecretsRunner = runGitleaksDetect
|
||||
|
||||
// ScanSecrets runs gitleaks against the supplied directory and parses the CSV report.
|
||||
func ScanSecrets(dir string) ([]Finding, error) {
|
||||
output, err := scanSecretsRunner(dir)
|
||||
findings, parseErr := parseGitleaksCSV(output)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if err != nil && len(findings) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func runGitleaksDetect(dir string) ([]byte, error) {
|
||||
bin, err := exec.LookPath("gitleaks")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), bin,
|
||||
"detect",
|
||||
"--no-banner",
|
||||
"--no-color",
|
||||
"--no-git",
|
||||
"--source", dir,
|
||||
"--report-format", "csv",
|
||||
"--report-path", "-",
|
||||
)
|
||||
|
||||
return cmd.Output()
|
||||
}
|
||||
|
||||
func parseGitleaksCSV(data []byte) ([]Finding, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(string(data)))
|
||||
reader.FieldsPerRecord = -1
|
||||
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
header := make(map[string]int, len(rows[0]))
|
||||
for idx, name := range rows[0] {
|
||||
header[normalizeCSVHeader(name)] = idx
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
for _, row := range rows[1:] {
|
||||
finding := Finding{
|
||||
Path: csvField(row, header, "file", "path"),
|
||||
Line: csvIntField(row, header, "startline", "line"),
|
||||
Column: csvIntField(row, header, "startcolumn", "column"),
|
||||
Rule: csvField(row, header, "ruleid", "rule", "name"),
|
||||
Snippet: csvField(row, header, "match", "secret", "description", "message"),
|
||||
}
|
||||
|
||||
if finding.Snippet == "" {
|
||||
finding.Snippet = csvField(row, header, "filename")
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func normalizeCSVHeader(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(name, "_", ""), " ", "")))
|
||||
}
|
||||
|
||||
func csvField(row []string, header map[string]int, names ...string) string {
|
||||
for _, name := range names {
|
||||
if idx, ok := header[name]; ok && idx < len(row) {
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func csvIntField(row []string, header map[string]int, names ...string) int {
|
||||
value := csvField(row, header, names...)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
64
devkit/scan_secrets_test.go
Normal file
64
devkit/scan_secrets_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package devkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScanSecrets_Good(t *testing.T) {
|
||||
originalRunner := scanSecretsRunner
|
||||
t.Cleanup(func() {
|
||||
scanSecretsRunner = originalRunner
|
||||
})
|
||||
|
||||
scanSecretsRunner = func(dir string) ([]byte, error) {
|
||||
require.Equal(t, "/tmp/project", dir)
|
||||
return []byte(`RuleID,File,StartLine,StartColumn,Description,Match
|
||||
github-token,config.yml,12,4,GitHub token detected,ghp_exampletoken1234567890
|
||||
aws-access-key-id,creds.txt,7,1,AWS access key detected,AKIA1234567890ABCDEF
|
||||
`), nil
|
||||
}
|
||||
|
||||
findings, err := ScanSecrets("/tmp/project")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, findings, 2)
|
||||
|
||||
require.Equal(t, "github-token", findings[0].Rule)
|
||||
require.Equal(t, "config.yml", findings[0].Path)
|
||||
require.Equal(t, 12, findings[0].Line)
|
||||
require.Equal(t, 4, findings[0].Column)
|
||||
require.Equal(t, "ghp_exampletoken1234567890", findings[0].Snippet)
|
||||
|
||||
require.Equal(t, "aws-access-key-id", findings[1].Rule)
|
||||
require.Equal(t, "creds.txt", findings[1].Path)
|
||||
require.Equal(t, 7, findings[1].Line)
|
||||
require.Equal(t, 1, findings[1].Column)
|
||||
require.Equal(t, "AKIA1234567890ABCDEF", findings[1].Snippet)
|
||||
}
|
||||
|
||||
func TestScanSecrets_ReportsFindingsOnExitError(t *testing.T) {
|
||||
originalRunner := scanSecretsRunner
|
||||
t.Cleanup(func() {
|
||||
scanSecretsRunner = originalRunner
|
||||
})
|
||||
|
||||
scanSecretsRunner = func(dir string) ([]byte, error) {
|
||||
return []byte(`rule_id,file,start_line,start_column,description,match
|
||||
token,test.txt,3,2,Token detected,secret-value
|
||||
`), errors.New("exit status 1")
|
||||
}
|
||||
|
||||
findings, err := ScanSecrets("/tmp/project")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, findings, 1)
|
||||
require.Equal(t, "token", findings[0].Rule)
|
||||
require.Equal(t, 3, findings[0].Line)
|
||||
require.Equal(t, 2, findings[0].Column)
|
||||
}
|
||||
|
||||
func TestParseGitleaksCSV_Bad(t *testing.T) {
|
||||
_, err := parseGitleaksCSV([]byte("rule_id,file,start_line\nunterminated,\"broken"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue