forked from Gitlink/gitlink-cli
211 lines
5.3 KiB
Go
211 lines
5.3 KiB
Go
package compliance
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Finding represents a single scan result.
|
|
type Finding struct {
|
|
ID string `json:"id"`
|
|
Severity string `json:"severity"` // critical, high, medium, low
|
|
Module string `json:"module"` // license, deps, secrets, exposure, vocab
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
// ScanResult holds all findings for a module.
|
|
type ScanResult struct {
|
|
Module string `json:"module"`
|
|
Findings []Finding `json:"findings"`
|
|
}
|
|
|
|
// scanRule defines a pattern to search for.
|
|
type scanRule struct {
|
|
ID string
|
|
Severity string
|
|
Pattern *regexp.Regexp
|
|
Summary string
|
|
Globs []string // file globs to include, empty = all text files
|
|
}
|
|
|
|
// excludedDirs are directories skipped during scanning.
|
|
var excludedDirs = map[string]bool{
|
|
"vendor": true, "node_modules": true, ".git": true, ".claude": true,
|
|
"skills": true, // skill documentation, not project source
|
|
}
|
|
|
|
// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
|
|
var excludedPaths = map[string]bool{
|
|
"shortcuts/compliance": true,
|
|
}
|
|
|
|
// excludedExts are file extensions skipped during scanning.
|
|
var excludedExts = map[string]bool{
|
|
".exe": true, ".dll": true, ".so": true, ".dylib": true,
|
|
".bin": true, ".jpg": true, ".jpeg": true, ".png": true,
|
|
".gif": true, ".ico": true, ".svg": true, ".pdf": true,
|
|
".zip": true, ".gz": true, ".tgz": true,
|
|
}
|
|
|
|
// excludeFiles are specific files skipped during scanning.
|
|
var excludeFiles = map[string]bool{
|
|
"go.sum": true, "package-lock.json": true,
|
|
}
|
|
|
|
// textExts are extensions treated as text files.
|
|
var textExts = map[string]bool{
|
|
".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
|
|
".py": true, ".rb": true, ".java": true, ".c": true, ".h": true,
|
|
".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true,
|
|
".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true,
|
|
".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true,
|
|
".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true,
|
|
".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true,
|
|
".mod": true,
|
|
}
|
|
|
|
func isTextFile(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
return textExts[ext]
|
|
}
|
|
|
|
func shouldSkip(path string, info os.FileInfo) bool {
|
|
name := info.Name()
|
|
if info.IsDir() {
|
|
if excludedDirs[name] {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
if excludedExts[strings.ToLower(filepath.Ext(name))] {
|
|
return true
|
|
}
|
|
if excludeFiles[name] {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// walkFiles walks the repo and yields text file paths (relative to root).
|
|
func walkFiles(root string) ([]string, error) {
|
|
var files []string
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if shouldSkip(path, info) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !info.IsDir() && isTextFile(path) {
|
|
rel, _ := filepath.Rel(root, path)
|
|
rel = filepath.ToSlash(rel)
|
|
// skip excluded paths (scanner's own source)
|
|
for prefix := range excludedPaths {
|
|
if strings.HasPrefix(rel, prefix) {
|
|
return nil
|
|
}
|
|
}
|
|
files = append(files, rel)
|
|
}
|
|
return nil
|
|
})
|
|
return files, err
|
|
}
|
|
|
|
// scanFiles scans files against rules and returns deduplicated findings.
|
|
// When a line matches multiple rules, only the highest-severity rule is reported.
|
|
func scanFiles(root string, rules []scanRule) []Finding {
|
|
files, err := walkFiles(root)
|
|
if err != nil {
|
|
return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}}
|
|
}
|
|
|
|
// dedup by file+line, keeping the highest severity
|
|
sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
|
|
seen := make(map[string]Finding) // key: "file:line"
|
|
|
|
for _, f := range files {
|
|
for _, rule := range rules {
|
|
if !ruleMatchesFile(f, rule.Globs) {
|
|
continue
|
|
}
|
|
for _, m := range scanFile(filepath.Join(root, f), rule) {
|
|
key := fmt.Sprintf("%s:%d", m.File, m.Line)
|
|
if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] {
|
|
seen[key] = m
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var findings []Finding
|
|
for _, f := range seen {
|
|
findings = append(findings, f)
|
|
}
|
|
return findings
|
|
}
|
|
|
|
func ruleMatchesFile(file string, globs []string) bool {
|
|
if len(globs) == 0 {
|
|
return true
|
|
}
|
|
for _, g := range globs {
|
|
matched, _ := filepath.Match(g, filepath.Base(file))
|
|
if matched {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func scanFile(path string, rule scanRule) []Finding {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
var findings []Finding
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
if rule.Pattern.MatchString(scanner.Text()) {
|
|
findings = append(findings, Finding{
|
|
ID: rule.ID,
|
|
Severity: rule.Severity,
|
|
Module: ruleMod(rule.ID),
|
|
File: path,
|
|
Line: lineNum,
|
|
Summary: rule.Summary,
|
|
})
|
|
}
|
|
}
|
|
return findings
|
|
}
|
|
|
|
func ruleMod(id string) string {
|
|
switch {
|
|
case strings.HasPrefix(id, "L-"):
|
|
return "license"
|
|
case strings.HasPrefix(id, "D-"):
|
|
return "deps"
|
|
case strings.HasPrefix(id, "S-"):
|
|
return "secrets"
|
|
case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"):
|
|
return "exposure"
|
|
case strings.HasPrefix(id, "C-"):
|
|
return "vocab"
|
|
}
|
|
return "unknown"
|
|
}
|