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=
|
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=
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue