diff --git a/devkit/scan_secrets.go b/devkit/scan_secrets.go new file mode 100644 index 0000000..2ab02fd --- /dev/null +++ b/devkit/scan_secrets.go @@ -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 +} diff --git a/devkit/scan_secrets_test.go b/devkit/scan_secrets_test.go new file mode 100644 index 0000000..bfe6aab --- /dev/null +++ b/devkit/scan_secrets_test.go @@ -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) +}