feat(devkit): add secret scanning

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 05:37:28 +00:00
parent b7d70883e9
commit a4d8aba714
3 changed files with 221 additions and 0 deletions

154
devkit/secret.go Normal file
View file

@ -0,0 +1,154 @@
package devkit
import (
"bufio"
"bytes"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// Finding describes a secret-like match discovered while scanning source files.
type Finding struct {
Path string `json:"path"`
Line int `json:"line"`
Column int `json:"column"`
Rule string `json:"rule"`
Snippet string `json:"snippet"`
}
var secretRules = []struct {
name string
match *regexp.Regexp
}{
{
name: "aws-access-key-id",
match: regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`),
},
{
name: "github-token",
match: regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9_]{20,}\b`),
},
{
name: "generic-secret-assignment",
match: regexp.MustCompile(`(?i)\b(?:api[_-]?key|client[_-]?secret|secret|token|password)\b\s*[:=]\s*["']?([A-Za-z0-9._\-+/]{8,})["']?`),
},
}
var skipDirs = map[string]struct{}{
".git": {},
"vendor": {},
"node_modules": {},
}
var textExts = map[string]struct{}{
".go": {},
".md": {},
".txt": {},
".json": {},
".yaml": {},
".yml": {},
".toml": {},
".env": {},
".ini": {},
".cfg": {},
".conf": {},
".sh": {},
".tf": {},
".tfvars": {},
}
// ScanDir recursively scans a directory for secret-like patterns.
func ScanDir(root string) ([]Finding, error) {
var findings []Finding
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
name := d.Name()
if d.IsDir() {
if _, ok := skipDirs[name]; ok || strings.HasPrefix(name, ".") && path != root {
return filepath.SkipDir
}
return nil
}
if !isTextCandidate(name) {
return nil
}
fileFindings, err := scanFile(path)
if err != nil {
return err
}
findings = append(findings, fileFindings...)
return nil
}); err != nil {
return nil, err
}
return findings, nil
}
func scanFile(path string) ([]Finding, error) {
data, err := fileRead(path)
if err != nil {
return nil, err
}
if len(data) == 0 || bytes.IndexByte(data, 0) >= 0 {
return nil, nil
}
var findings []Finding
scanner := bufio.NewScanner(bytes.NewReader(data))
lineNo := 0
for scanner.Scan() {
lineNo++
line := scanner.Text()
matchedSpecific := false
for _, rule := range secretRules {
if rule.name == "generic-secret-assignment" && matchedSpecific {
continue
}
if loc := rule.match.FindStringIndex(line); loc != nil {
findings = append(findings, Finding{
Path: path,
Line: lineNo,
Column: loc[0] + 1,
Rule: rule.name,
Snippet: strings.TrimSpace(line),
})
if rule.name != "generic-secret-assignment" {
matchedSpecific = true
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return findings, nil
}
func isTextCandidate(name string) bool {
if ext := strings.ToLower(filepath.Ext(name)); ext != "" {
_, ok := textExts[ext]
return ok
}
// Allow extension-less files such as Makefile, LICENSE, and .env.
switch name {
case "Makefile", "Dockerfile", "LICENSE", "README", "CLAUDE.md":
return true
}
return strings.HasPrefix(name, ".")
}
// fileRead is factored out for tests.
var fileRead = func(path string) ([]byte, error) {
return os.ReadFile(path)
}

57
devkit/secret_test.go Normal file
View file

@ -0,0 +1,57 @@
package devkit
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestScanDir_Good(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "config.yml"), []byte(`
api_key: "ghp_abcdefghijklmnopqrstuvwxyz1234"
`), 0o600))
require.NoError(t, os.Mkdir(filepath.Join(root, "nested"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, "nested", "creds.txt"), []byte("access_key = AKIA1234567890ABCDEF\n"), 0o600))
findings, err := ScanDir(root)
require.NoError(t, err)
require.Len(t, findings, 2)
require.Equal(t, "github-token", findings[0].Rule)
require.Equal(t, 2, findings[0].Line)
require.Equal(t, "config.yml", filepath.Base(findings[0].Path))
require.Equal(t, "aws-access-key-id", findings[1].Rule)
require.Equal(t, 1, findings[1].Line)
require.Equal(t, "creds.txt", filepath.Base(findings[1].Path))
}
func TestScanDir_SkipsBinaryAndIgnoredDirs(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(root, ".git", "config"), []byte("token=ghp_abcdefghijklmnopqrstuvwxyz1234"), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(root, "blob.bin"), []byte{0, 1, 2, 3, 4}, 0o600))
findings, err := ScanDir(root)
require.NoError(t, err)
require.Empty(t, findings)
}
func TestScanDir_ReportsGenericAssignments(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "secrets.env"), []byte("client_secret: abcdefghijklmnop\n"), 0o600))
findings, err := ScanDir(root)
require.NoError(t, err)
require.Len(t, findings, 1)
require.Equal(t, "generic-secret-assignment", findings[0].Rule)
require.Equal(t, 1, findings[0].Line)
require.Equal(t, 1, findings[0].Column)
}

10
go.sum
View file

@ -2,8 +2,18 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs= codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
dappco.re/go/agent v0.3.3 h1:hVF+ExuJ/WHuQjEdje6bSUPcUpy6jUscVl9fiuV8l74=
dappco.re/go/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.1.7 h1:JhJeptA/I42c7GhmtJDPDlvhO8Y3izQ82wpaXCy/XZ0=
dappco.re/go/core/i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
dappco.re/go/core/io v0.1.7 h1:tYyOnNFQcF//mqDLTNjBu4PV/CBizW7hm2ZnwdQQi40=
dappco.re/go/core/io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
dappco.re/go/core/log v0.0.4 h1:qy54NYLh9nA4Kvo6XBsuAdyDD5jRc9PVnJLz9R0LiBw=
dappco.re/go/core/log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
dappco.re/go/core/scm v0.3.6 h1:QUHaaPggP0+zfg7y4Q+BChQaVjx6PW+LKkOzcWYPpZ0=
dappco.re/go/core/scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8=
forge.lthn.ai/core/agent v0.3.3 h1:lGpoD5OgvdJ5z+qofw8fBWkDB186QM7I2jjXEbtzSdA= forge.lthn.ai/core/agent v0.3.3 h1:lGpoD5OgvdJ5z+qofw8fBWkDB186QM7I2jjXEbtzSdA=
forge.lthn.ai/core/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw= forge.lthn.ai/core/agent v0.3.3/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=