gitlink-cli/shortcuts/compliance/scanner.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"
}