| package bundle |
|
|
| import ( |
| "archive/zip" |
| "bytes" |
| "io" |
| "path" |
| "strings" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| type ArchiveAnalyzer struct{} |
|
|
| func (ArchiveAnalyzer) Name() string { return "archive" } |
|
|
| func (ArchiveAnalyzer) Handles(kind FileKind) bool { return kind == KindArchive } |
|
|
| const ( |
| maxArchiveMembers = 1024 |
| maxArchiveUncompressed = 67108864 |
| maxArchiveDepth = 3 |
| maxMemberScanBytes = 1048576 |
| ) |
|
|
| func (ArchiveAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) { |
| if f == nil { |
| return nil, nil |
| } |
| return analyzeArchiveBytes(f.Sniff, f.RelPath, 0, b) |
| } |
|
|
| |
| |
| |
| func analyzeArchiveBytes(data []byte, originRel string, depth int, b *Bundle) ([]Finding, error) { |
| if depth > maxArchiveDepth { |
| return []Finding{{ |
| Analyzer: "archive", |
| File: originRel, |
| Signal: "archive-too-deep", |
| Severity: SevMedium, |
| Detail: "nested archive exceeds maximum recursion depth (possible burial evasion)", |
| Opaque: true, |
| Structural: true, |
| }}, nil |
| } |
|
|
| zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) |
| if err != nil { |
| return []Finding{{ |
| Analyzer: "archive", |
| File: originRel, |
| Signal: "opaque-archive", |
| Severity: SevMedium, |
| Detail: "could not open archive (corrupt or unsupported): " + err.Error(), |
| Opaque: true, |
| }}, nil |
| } |
|
|
| var out []Finding |
| var totalUncompressed uint64 |
| members := 0 |
|
|
| for _, zf := range zr.File { |
| members++ |
| if members > maxArchiveMembers { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: originRel, |
| Signal: "archive-member-limit", |
| Severity: SevMedium, |
| Detail: "archive exceeds member count limit; remaining members not scanned", |
| Opaque: true, |
| }) |
| break |
| } |
|
|
| name := zf.Name |
| |
| if isUnsafeMemberPath(name) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: joinMember(originRel, name), |
| Signal: "archive-path-traversal", |
| Severity: SevHigh, |
| Detail: "archive member uses an absolute or parent-traversal path (zip-slip)", |
| Corroborated: true, |
| }) |
| continue |
| } |
| if zf.FileInfo().IsDir() { |
| continue |
| } |
|
|
| |
| memberData, readErr := readZipMember(zf, &totalUncompressed) |
| if readErr != nil { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: joinMember(originRel, name), |
| Signal: "opaque-archive-member", |
| Severity: SevMedium, |
| Detail: "could not read archive member: " + readErr.Error(), |
| Opaque: true, |
| }) |
| continue |
| } |
| if totalUncompressed > maxArchiveUncompressed { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: originRel, |
| Signal: "archive-bomb-guard", |
| Severity: SevMedium, |
| Detail: "archive uncompressed size limit reached; remaining members not scanned", |
| Opaque: true, |
| }) |
| break |
| } |
|
|
| memberRel := joinMember(originRel, name) |
| out = append(out, scanArchiveMember(memberData, memberRel, depth, b)...) |
| } |
|
|
| return dedupeFindings(out), nil |
| } |
|
|
| |
| |
| |
| func scanArchiveMember(memberData []byte, memberRel string, depth int, b *Bundle) []Finding { |
| kind, _ := sniffMagicKind(memberData) |
| |
| if kind == KindUnknown { |
| kind = classifyKind(path.Base(memberRel), memberData) |
| } |
|
|
| var out []Finding |
|
|
| switch kind { |
| case KindArchive: |
| |
| nested, _ := analyzeArchiveBytes(truncateBytes(memberData), memberRel, depth+1, b) |
| if len(nested) > 0 { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "archive-contains-executable", |
| Severity: SevHigh, |
| Detail: "archive member is a nested archive carrying suspicious content", |
| Corroborated: true, |
| }) |
| out = append(out, nested...) |
| } |
| case KindShell, KindPythonSource, KindScriptOther: |
| sub := sharedIndicatorScan(string(truncateBytes(memberData)), memberRel, "archive") |
| if hasActionable(sub) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "archive-contains-executable", |
| Severity: SevHigh, |
| Detail: "archive bundles a script with suspicious content", |
| Corroborated: true, |
| }) |
| } |
| out = append(out, sub...) |
| case KindPyc, KindNativeBinary, KindWasm: |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "archive-contains-executable", |
| Severity: SevHigh, |
| Detail: "archive bundles compiled bytecode or a native binary", |
| Opaque: true, |
| Structural: true, |
| Corroborated: exfilHostRe.Match(memberData), |
| }) |
| |
| if exfilHostRe.Match(memberData) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "exfil-host-reference", |
| Severity: SevCritical, |
| Detail: "archived binary references known exfiltration host", |
| Corroborated: true, |
| }) |
| } |
| case KindData, KindText: |
| |
| |
| text := string(truncateBytes(memberData)) |
| if exfilHostRe.MatchString(text) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "exfil-host-reference", |
| Severity: SevCritical, |
| Detail: "archived data member references known exfiltration host", |
| Corroborated: true, |
| }) |
| } |
| for _, dir := range scanImperativeDirectives(text) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "data-embedded-directive", |
| Severity: SevMedium, |
| Detail: "archived data member embeds an imperative directive: " + dir, |
| }) |
| } |
| out = append(out, sharedIndicatorScan(text, memberRel, "archive")...) |
| default: |
| |
| if exfilHostRe.Match(memberData) { |
| out = append(out, Finding{ |
| Analyzer: "archive", |
| File: memberRel, |
| Signal: "exfil-host-reference", |
| Severity: SevCritical, |
| Detail: "archived member references known exfiltration host", |
| Corroborated: true, |
| }) |
| } |
| } |
|
|
| return out |
| } |
|
|
| |
| |
| func readZipMember(zf *zip.File, total *uint64) ([]byte, error) { |
| rc, err := zf.Open() |
| if err != nil { |
| return nil, err |
| } |
| defer rc.Close() |
| limited := io.LimitReader(rc, maxMemberScanBytes+1) |
| data, err := io.ReadAll(limited) |
| if err != nil { |
| return data, err |
| } |
| *total += uint64(len(data)) |
| if len(data) > maxMemberScanBytes { |
| data = data[:maxMemberScanBytes] |
| } |
| return data, nil |
| } |
|
|
| |
| |
| func truncateBytes(data []byte) []byte { |
| if len(data) > maxMemberScanBytes { |
| return data[:maxMemberScanBytes] |
| } |
| return data |
| } |
|
|
| |
| |
| |
| |
| func isUnsafeMemberPath(name string) bool { |
| if name == "" { |
| return true |
| } |
| if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") { |
| return true |
| } |
| |
| if len(name) >= 2 && name[1] == ':' { |
| return true |
| } |
| for _, seg := range strings.Split(strings.ReplaceAll(name, "\\", "/"), "/") { |
| if seg == ".." { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| func joinMember(origin, member string) string { |
| member = strings.ReplaceAll(member, "\\", "/") |
| return origin + "!/" + member |
| } |
|
|
| |
| |
| func hasActionable(fs []Finding) bool { |
| for _, f := range fs { |
| if f.Severity >= SevMedium { |
| return true |
| } |
| } |
| return false |
| } |
|
|