diff --git a/devkit/secret.go b/devkit/secret.go new file mode 100644 index 0000000..d6dc1e8 --- /dev/null +++ b/devkit/secret.go @@ -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) +} diff --git a/devkit/secret_test.go b/devkit/secret_test.go new file mode 100644 index 0000000..434f578 --- /dev/null +++ b/devkit/secret_test.go @@ -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) +} diff --git a/go.sum b/go.sum index 1f1d7ba..90c9424 100644 --- a/go.sum +++ b/go.sum @@ -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= 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= +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/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/go.mod h1:UnrGApmKd/GzHEFcgy/tYuSfeJwxRx8UsxPhTjU5Ntw= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=