File size: 18,697 Bytes
d2507b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 | package bundle
import (
"regexp"
"sort"
"strings"
)
// Severity ranks the confidence/impact of a Finding. The numeric ordering is
// load-bearing: bundleEscalates and the heuristic bridge both key off
// severityToWeight(SevHigh) >= 0.85 as the single source of truth.
type Severity int
const (
SevInfo Severity = iota
SevLow
SevMedium
SevHigh
SevCritical
)
// String renders a Severity for JSON/SARIF output and human logs.
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"
}
}
// Finding is a single per-sibling (or per-archive-member) detection produced by
// an Analyzer. Structural marks shape-only signals (ships-a-.so,
// delegates-to-a-file) that must NOT escalate to malicious on their own;
// Corroborated marks that a behavioral co-factor (exfil+env, source mismatch,
// padding, hidden placement) was observed. The precision-aware escalation in
// aggregate.go consumes exactly these two flags.
type Finding struct {
Analyzer string `json:"analyzer"`
File string `json:"file"` // RelPath of offending file (or member path inside an archive)
Signal string `json:"signal"` // stable signal name, e.g. "archive-contains-executable"
Severity Severity `json:"severity"`
Detail string `json:"detail"`
Line int `json:"line,omitempty"`
Opaque bool `json:"opaque,omitempty"` // content could not be fully analyzed
Structural bool `json:"structural,omitempty"` // signal is about shape, not behavior
Corroborated bool `json:"corroborated,omitempty"` // a behavioral co-factor was observed
}
// Analyzer inspects one File (by FileKind) within a Bundle and returns Findings.
// Handles reports which kinds an analyzer claims; Analyze must never panic and
// should flag opaque artifacts rather than silently ignoring them.
type Analyzer interface {
Name() string
Handles(kind FileKind) bool
Analyze(f *File, b *Bundle) ([]Finding, error)
}
// severityToWeight is the SINGLE SOURCE OF TRUTH mapping severities to the
// heuristic-engine weight space. SevHigh maps to 0.85 so that the bridged
// CategoryScore clears the scorer's cat.Score>=0.85 escalation gate.
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
}
}
// DefaultAnalyzers returns the registry of concrete analyzers. File-content
// analyzers come first; the SKILL.md-level cross-reference analyzers
// (Indirection, NLDirective) are listed last because aggregate.Analyze runs
// them after the per-file pass so they can corroborate against the full
// findings set.
func DefaultAnalyzers() []Analyzer {
return []Analyzer{
ShellAnalyzer{},
PythonSourceAnalyzer{},
ScriptOtherAnalyzer{},
DataAnalyzer{},
PycAnalyzer{},
ArchiveAnalyzer{},
BinaryAnalyzer{},
ImageAnalyzer{},
IndirectionAnalyzer{},
NLDirectiveAnalyzer{},
}
}
// AnalyzeFile dispatches a single File to every analyzer that Handles its kind
// and concatenates their Findings. Errors from individual analyzers are folded
// into an opaque Finding rather than aborting (graceful degradation); a nil
// File yields nothing.
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
}
// safeAnalyze runs an analyzer and converts a panic on malformed input into an
// error so a single corrupt artifact can never crash a scan.
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"
}
// ---- Shared indicator vocabulary used across the source analyzers ----
// indicator families. A "source" read CO-OCCURRING (within a small line window)
// with a "sink" is the high-confidence exfil pattern; isolated members are
// lower confidence. Kept lowercase; callers lowercase the text.
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 are host-agnostic reverse/bind-shell idioms: the payload
// is "give an attacker an interactive shell", independent of any host string.
reverseShellTerms = []string{
"/dev/tcp/", "/dev/udp/", // bash pseudo-device network shell
"nc -e", "ncat -e", "nc -c", "ncat -c", // netcat -e/-c command execution
"exec 5<>/dev/tcp", "0>&1", // fd-dup reverse shell plumbing
}
// persistenceTerms are host-agnostic persistence/scheduling mechanisms (cron,
// init, login shells, service managers). Persistence ALONE is only INFO (many
// installers schedule jobs); persistence CO-OCCURRING with a network sink in
// the same file is the host-agnostic "scheduled phone-home" escalator.
persistenceTerms = []string{
"crontab", "/etc/cron", "cron.d", "* * * *", "*/", // cron
"systemctl enable", "systemctl --user enable", "/etc/systemd", "/lib/systemd",
"launchctl load", "launchagents", "launchdaemons", // macOS launchd
"/etc/rc.local", "/etc/profile.d", "schtasks /create", "schtasks/create",
".bashrc", ".zshrc", ".bash_profile", ".zprofile", ".profile", // login-shell rc append
}
base64BlobRe = regexp.MustCompile(`[A-Za-z0-9+/]{120,}={0,2}`)
longHexRe = regexp.MustCompile(`(?i)(?:0x)?[0-9a-f]{80,}`)
// The defanged exfil host the corpus uses; appearance anywhere is critical.
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 captures a shell variable reference ($MIRROR / ${MIRROR:-...})
// so a registry rewrite that points at "$MIRROR" can be resolved to the URL in
// the variable's assignment line elsewhere in the same script.
shellVarRefRe = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`)
)
// sharedIndicatorScan applies the language-agnostic source/sink vocabulary over
// a block of text and returns line-anchored Findings. SevHigh+Corroborated when
// an env/secret source co-occurs with a network sink within a 3-line window, or
// on RCE/destructive/registry-rewrite patterns; isolated indicators are
// SevMedium. The defanged exfil host alone is SevCritical. A merely-present
// script with no indicator returns nothing (presence != malice).
//
// When an embedded base64/hex/gzip blob is found, it is also DECODED and
// re-scanned (decode.go); a behavioral hit in the recovered bytes adds a
// corroborated SevHigh decoded-* finding so the precision gate escalates an
// otherwise opaque obfuscated payload.
func sharedIndicatorScan(text string, fileRel string, analyzer string) []Finding {
return sharedIndicatorScanInner(text, fileRel, analyzer, true)
}
// sharedIndicatorScanInner is the implementation; decode controls whether
// embedded blobs are decoded-and-rescanned. decodeAndRescan re-scans recovered
// bytes with decode=false so the bounded multi-layer decode (handled inside
// decode.go) is the ONLY recursion path — this scanner never re-enters itself.
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)
}
// Exfil host (defanged) — EVIDENCE ONLY. This matches the corpus's own
// defanged host literally (attacker.example / TEST-NET), so escalating on it
// is self-grading: swap the host and detection vanishes. It is kept as
// SevMedium, non-corroborated NETWORK_EGRESS supporting evidence and never
// escalates a bundle on its own — host-agnostic signals (reverse-shell,
// exfil-env-to-network source+sink, scheduled-network-callback, RCE, etc.)
// carry the escalation.
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,
})
}
}
// Reverse/bind shell — host-agnostic remote-control payload.
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,
})
}
}
// Persistence: scheduling/init/login-shell install. Escalates (host-agnostic)
// only when a network sink also appears in the file (scheduled phone-home);
// persistence on its own is reported as a low-power PERSISTENCE capability.
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 // one persistence finding per file is enough
}
// RCE: pipe-to-shell / decode-and-exec.
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,
})
}
}
// Destructive commands.
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,
})
}
}
// Registry rewrite to a non-default host (dev-env-setup vector).
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,
})
}
// Source<->sink co-occurrence within a small window.
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,
})
}
}
// Long base64 / hex blob: obfuscated payload carrier.
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,
})
}
}
// Decode-and-rescan: invert hex/base64/gzip(zlib)+base64/split-runs carriers
// and re-scan the recovered bytes. A behavioral hit there yields a corroborated
// SevHigh decoded-* finding so an obfuscated payload (the docx-indirection
// evasion) escalates instead of staying an opaque structural blob.
if decode {
out = append(out, decodeAndRescan(text, fileRel, analyzer, 0)...)
}
return dedupeFindings(out)
}
// extractHost pulls the host out of the first URL in a line, if any.
func extractHost(line string) string {
m := hostFromURL.FindStringSubmatch(line)
if len(m) < 2 {
return ""
}
return strings.ToLower(m[1])
}
// resolveRegistryHost returns the registry/index host for the rewrite directive on
// lines[idx]. If the directive points at a literal URL the host is taken directly;
// if it points at a shell variable (e.g. `pip config set index-url "$MIRROR"`), the
// variable's assignment line elsewhere in the same script is resolved one level
// (e.g. MIRROR="${PIP_MIRROR:-https://pypi.internal.example.com/simple}") so an
// internal/benign mirror is not misclassified as a high-severity exfil rewrite.
// SAFETY: this only feeds the host into isInternalRegistryHost/isKnownBenignHost,
// both of which reject the defanged exfil host first, so a malicious host that is
// reached through a variable still escalates.
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 ""
}
// assignsShellVar reports whether line is a shell assignment to name
// (VAR=..., export VAR=...).
func assignsShellVar(line, name string) bool {
s := strings.TrimSpace(line)
s = strings.TrimSpace(strings.TrimPrefix(s, "export "))
return strings.HasPrefix(s, name+"=")
}
// matchedAny reports whether lowered text contains any of the substrings.
func matchedAny(lowerText string, terms []string) bool {
for _, t := range terms {
if strings.Contains(lowerText, t) {
return true
}
}
return false
}
// dedupeFindings collapses exact duplicates (same signal+line+file) keeping the
// highest severity, and returns them in a stable order.
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 {
// keep corroboration if either had it
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
}
// paddingEvasionFinding builds the standard padding-evasion finding emitted by
// source analyzers when a file was truncated at the read cap with a high
// newline ratio (front-padding to push the payload past the scanner).
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
}
|