feat(devkit): add secret scanning
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
b7d70883e9
commit
a4d8aba714
3 changed files with 221 additions and 0 deletions
154
devkit/secret.go
Normal file
154
devkit/secret.go
Normal 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
57
devkit/secret_test.go
Normal 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
10
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=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue