| package bundle |
|
|
| import ( |
| "regexp" |
| "sort" |
| "strings" |
| ) |
|
|
| |
| |
| |
| type Severity int |
|
|
| const ( |
| SevInfo Severity = iota |
| SevLow |
| SevMedium |
| SevHigh |
| SevCritical |
| ) |
|
|
| |
| func (s Severity) String() string { |
| switch s { |
| case SevInfo: |
| return "info" |
| case SevLow: |
| return "low" |
| case SevMedium: |
| return "medium" |
| case SevHigh: |
| return "high" |
| case SevCritical: |
| return "critical" |
| default: |
| return "unknown" |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| type Finding struct { |
| Analyzer string `json:"analyzer"` |
| File string `json:"file"` |
| Signal string `json:"signal"` |
| Severity Severity `json:"severity"` |
| Detail string `json:"detail"` |
| Line int `json:"line,omitempty"` |
| Opaque bool `json:"opaque,omitempty"` |
| Structural bool `json:"structural,omitempty"` |
| Corroborated bool `json:"corroborated,omitempty"` |
| } |
|
|
| |
| |
| |
| type Analyzer interface { |
| Name() string |
| Handles(kind FileKind) bool |
| Analyze(f *File, b *Bundle) ([]Finding, error) |
| } |
|
|
| |
| |
| |
| func severityToWeight(s Severity) float64 { |
| switch s { |
| case SevInfo: |
| return 0.0 |
| case SevLow: |
| return 0.4 |
| case SevMedium: |
| return 0.6 |
| case SevHigh: |
| return 0.85 |
| case SevCritical: |
| return 0.95 |
| default: |
| return 0.0 |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| func DefaultAnalyzers() []Analyzer { |
| return []Analyzer{ |
| ShellAnalyzer{}, |
| PythonSourceAnalyzer{}, |
| ScriptOtherAnalyzer{}, |
| DataAnalyzer{}, |
| PycAnalyzer{}, |
| ArchiveAnalyzer{}, |
| BinaryAnalyzer{}, |
| ImageAnalyzer{}, |
| IndirectionAnalyzer{}, |
| NLDirectiveAnalyzer{}, |
| } |
| } |
|
|
| |
| |
| |
| |
| func AnalyzeFile(f *File, b *Bundle) []Finding { |
| if f == nil { |
| return nil |
| } |
| var out []Finding |
| for _, a := range DefaultAnalyzers() { |
| if !a.Handles(f.Kind) { |
| continue |
| } |
| findings, err := safeAnalyze(a, f, b) |
| if err != nil { |
| out = append(out, Finding{ |
| Analyzer: a.Name(), |
| File: f.RelPath, |
| Signal: "analyzer-error", |
| Severity: SevLow, |
| Opaque: true, |
| Detail: "analyzer failed: " + err.Error(), |
| }) |
| continue |
| } |
| out = append(out, findings...) |
| } |
| return out |
| } |
|
|
| |
| |
| func safeAnalyze(a Analyzer, f *File, b *Bundle) (findings []Finding, err error) { |
| defer func() { |
| if r := recover(); r != nil { |
| findings = nil |
| err = &analyzerPanic{name: a.Name(), v: r} |
| } |
| }() |
| return a.Analyze(f, b) |
| } |
|
|
| type analyzerPanic struct { |
| name string |
| v any |
| } |
|
|
| func (e *analyzerPanic) Error() string { |
| return e.name + " panicked on malformed input" |
| } |
|
|
| |
|
|
| |
| |
| |
| var ( |
| envSourceTerms = []string{ |
| "os.environ", "os.getenv", "process.env", "getenv(", "$env:", |
| "printenv", "/proc/self/environ", "env |", "env >", |
| ".aws/credentials", "~/.aws", "~/.ssh", "id_rsa", "id_ed25519", |
| ".npmrc", ".pypirc", ".netrc", "aws_secret_access_key", |
| "aws_access_key_id", "anthropic_api_key", "openai_api_key", |
| "access_token", "auth_token", "secret_key", "private key", |
| "cat .env", "read .env", "${{ secrets", "secrets.", |
| } |
| networkSinkTerms = []string{ |
| "curl ", "wget ", "requests.post", "requests.get", "urllib", |
| "http.client", "socket.", "net::http", "open-uri", "net/http", |
| "invoke-webrequest", "invoke-restmethod", "system.net.webclient", |
| "child_process", "fetch(", "axios", "nc ", "ncat ", " -d ", |
| "--data", "xmlhttprequest", "webclient", "uploadstring", |
| } |
| rceTerms = []string{ |
| "curl | sh", "curl|sh", "curl | bash", "curl|bash", |
| "wget | sh", "wget|sh", "| sudo bash", "|sh", "|bash", |
| "base64 -d | sh", "base64 --decode | sh", "iex(", "iex (", |
| "eval(atob", "eval(base64", "exec(base64", "exec(__import__", |
| } |
| destructiveTerms = []string{ |
| "rm -rf /", "rm -rf ~", "rm -rf .", ":(){ :|:& };:", |
| "dd if=/dev/zero", "dd if=/dev/random", "mkfs", "mkfs.", |
| "> /dev/sda", "chmod -r 777 /", "format c:", |
| } |
| registryRewriteTerms = []string{ |
| "registry=", "registry =", "set registry", "config set registry", |
| "npm config set registry", "yarn config set registry", |
| "--index-url", "--extra-index-url", "global.index-url", |
| "pip config set global.index-url", "[global]\nindex-url", |
| "publishconfig", ".npmrc", "set-pypiserver", |
| } |
| |
| |
| reverseShellTerms = []string{ |
| "/dev/tcp/", "/dev/udp/", |
| "nc -e", "ncat -e", "nc -c", "ncat -c", |
| "exec 5<>/dev/tcp", "0>&1", |
| } |
| |
| |
| |
| |
| persistenceTerms = []string{ |
| "crontab", "/etc/cron", "cron.d", "* * * *", "*/", |
| "systemctl enable", "systemctl --user enable", "/etc/systemd", "/lib/systemd", |
| "launchctl load", "launchagents", "launchdaemons", |
| "/etc/rc.local", "/etc/profile.d", "schtasks /create", "schtasks/create", |
| ".bashrc", ".zshrc", ".bash_profile", ".zprofile", ".profile", |
| } |
| base64BlobRe = regexp.MustCompile(`[A-Za-z0-9+/]{120,}={0,2}`) |
| longHexRe = regexp.MustCompile(`(?i)(?:0x)?[0-9a-f]{80,}`) |
| |
| exfilHostRe = regexp.MustCompile(`(?i)(attacker\.example|198\.51\.100\.\d{1,3})`) |
| urlRe = regexp.MustCompile(`(?i)https?://[^\s'")>]+`) |
| hostFromURL = regexp.MustCompile(`(?i)https?://([^/\s:'")>]+)`) |
| |
| |
| |
| shellVarRefRe = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`) |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func sharedIndicatorScan(text string, fileRel string, analyzer string) []Finding { |
| return sharedIndicatorScanInner(text, fileRel, analyzer, true) |
| } |
|
|
| |
| |
| |
| |
| func sharedIndicatorScanInner(text string, fileRel string, analyzer string, decode bool) []Finding { |
| var out []Finding |
| lines := strings.Split(text, "\n") |
| lower := make([]string, len(lines)) |
| for i, l := range lines { |
| lower[i] = strings.ToLower(l) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| for i, l := range lines { |
| if exfilHostRe.MatchString(l) { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "exfil-host-reference", |
| Severity: SevMedium, |
| Detail: "references a known exfiltration host (evidence only): " + exfilHostRe.FindString(l), |
| Line: i + 1, |
| }) |
| } |
| } |
|
|
| |
| for i, l := range lower { |
| if matchedAny(l, reverseShellTerms) { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "reverse-shell", |
| Severity: SevHigh, |
| Detail: "reverse/bind shell idiom — hands an attacker an interactive shell", |
| Line: i + 1, |
| Corroborated: true, |
| }) |
| } |
| } |
|
|
| |
| |
| |
| fileHasNetworkSink := matchedAny(strings.ToLower(text), networkSinkTerms) |
| for i, l := range lower { |
| if !matchedAny(l, persistenceTerms) { |
| continue |
| } |
| if fileHasNetworkSink { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "scheduled-network-callback", |
| Severity: SevHigh, |
| Detail: "installs a scheduled/persistent job that also performs network I/O", |
| Line: i + 1, |
| Corroborated: true, |
| }) |
| } else { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "persistence", |
| Severity: SevMedium, |
| Detail: "installs a persistence/scheduling mechanism (cron/init/login-shell)", |
| Line: i + 1, |
| }) |
| } |
| break |
| } |
|
|
| |
| for i, l := range lower { |
| if matchedAny(l, rceTerms) { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "remote-code-execution", |
| Severity: SevHigh, |
| Detail: "downloads-and-executes or decodes-and-executes a payload", |
| Line: i + 1, |
| Corroborated: true, |
| }) |
| } |
| } |
|
|
| |
| for i, l := range lower { |
| if matchedAny(l, destructiveTerms) { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "destructive-command", |
| Severity: SevHigh, |
| Detail: "destructive filesystem/disk command", |
| Line: i + 1, |
| Corroborated: true, |
| }) |
| } |
| } |
|
|
| |
| for i, l := range lower { |
| if !matchedAny(l, registryRewriteTerms) { |
| continue |
| } |
| host := resolveRegistryHost(lines, i) |
| sev := SevHigh |
| detail := "package-manager registry/index rewrite to a non-default host" |
| if host != "" && isKnownBenignHost(host) { |
| sev = SevLow |
| detail = "registry rewrite to known-benign corporate mirror: " + host |
| } else if host != "" && isInternalRegistryHost(host) { |
| sev = SevLow |
| detail = "registry rewrite to internal/private mirror host: " + host |
| } else if isKnownBenignScriptIdiom(lines[i]) { |
| sev = SevLow |
| detail = "registry rewrite matching a known-benign idiom" |
| } |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "registry-rewrite", |
| Severity: sev, |
| Detail: detail, |
| Line: i + 1, |
| Corroborated: sev >= SevHigh, |
| }) |
| } |
|
|
| |
| for i := range lower { |
| window := lower[i] |
| if i+1 < len(lower) { |
| window += "\n" + lower[i+1] |
| } |
| if i+2 < len(lower) { |
| window += "\n" + lower[i+2] |
| } |
| hasSource := matchedAny(window, envSourceTerms) |
| hasSink := matchedAny(window, networkSinkTerms) |
| switch { |
| case hasSource && hasSink: |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "exfil-env-to-network", |
| Severity: SevHigh, |
| Detail: "environment/credential read co-occurs with a network sink", |
| Line: i + 1, |
| Corroborated: true, |
| }) |
| case hasSource: |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "env-credential-access", |
| Severity: SevMedium, |
| Detail: "reads environment variables or credential material", |
| Line: i + 1, |
| }) |
| } |
| } |
|
|
| |
| for i, l := range lines { |
| if base64BlobRe.MatchString(l) || longHexRe.MatchString(l) { |
| out = append(out, Finding{ |
| Analyzer: analyzer, |
| File: fileRel, |
| Signal: "embedded-encoded-blob", |
| Severity: SevMedium, |
| Detail: "long base64/hex blob (possible obfuscated payload)", |
| Line: i + 1, |
| }) |
| } |
| } |
|
|
| |
| |
| |
| |
| if decode { |
| out = append(out, decodeAndRescan(text, fileRel, analyzer, 0)...) |
| } |
|
|
| return dedupeFindings(out) |
| } |
|
|
| |
| func extractHost(line string) string { |
| m := hostFromURL.FindStringSubmatch(line) |
| if len(m) < 2 { |
| return "" |
| } |
| return strings.ToLower(m[1]) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func resolveRegistryHost(lines []string, idx int) string { |
| if h := extractHost(lines[idx]); h != "" { |
| return h |
| } |
| for _, m := range shellVarRefRe.FindAllStringSubmatch(lines[idx], -1) { |
| varName := m[1] |
| for _, l := range lines { |
| if assignsShellVar(l, varName) { |
| if h := extractHost(l); h != "" { |
| return h |
| } |
| } |
| } |
| } |
| return "" |
| } |
|
|
| |
| |
| func assignsShellVar(line, name string) bool { |
| s := strings.TrimSpace(line) |
| s = strings.TrimSpace(strings.TrimPrefix(s, "export ")) |
| return strings.HasPrefix(s, name+"=") |
| } |
|
|
| |
| func matchedAny(lowerText string, terms []string) bool { |
| for _, t := range terms { |
| if strings.Contains(lowerText, t) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| |
| func dedupeFindings(in []Finding) []Finding { |
| if len(in) <= 1 { |
| return in |
| } |
| type key struct { |
| sig string |
| line int |
| file string |
| } |
| best := map[key]Finding{} |
| order := []key{} |
| for _, f := range in { |
| k := key{f.Signal, f.Line, f.File} |
| if prev, ok := best[k]; ok { |
| if f.Severity > prev.Severity { |
| |
| f.Corroborated = f.Corroborated || prev.Corroborated |
| best[k] = f |
| } |
| continue |
| } |
| best[k] = f |
| order = append(order, k) |
| } |
| out := make([]Finding, 0, len(order)) |
| for _, k := range order { |
| out = append(out, best[k]) |
| } |
| sort.SliceStable(out, func(i, j int) bool { |
| if out[i].Line != out[j].Line { |
| return out[i].Line < out[j].Line |
| } |
| return out[i].Signal < out[j].Signal |
| }) |
| return out |
| } |
|
|
| |
| |
| |
| func paddingEvasionFinding(f *File, analyzer string) (Finding, bool) { |
| if !f.Truncated { |
| return Finding{}, false |
| } |
| if f.NewlineRatio < 0.30 { |
| return Finding{}, false |
| } |
| return Finding{ |
| Analyzer: analyzer, |
| File: f.RelPath, |
| Signal: "padding-evasion", |
| Severity: SevHigh, |
| Detail: "file exceeded read cap with a high newline ratio (front/newline padding)", |
| Corroborated: true, |
| }, true |
| } |
|
|