155 lines
3.1 KiB
Go
155 lines
3.1 KiB
Go
|
|
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)
|
||
|
|
}
|