package bundle import ( "archive/zip" "bytes" "io" "path" "strings" ) // ArchiveAnalyzer inspects .docx/.zip/.xlsx/.pptx and any PK-zip-sniffed file — // the context-loader vector (a zip-of-XML hiding a sync script). It enumerates // members via archive/zip, classifies each member by SNIFFING ITS BYTES (magic, // never the member name/extension), scans member bytes with the matching // analyzer logic, and RECURSES into nested archives up to maxArchiveDepth to // unwrap a script buried two zips deep. All analysis is in-memory; members with // ".." or absolute paths are rejected; total uncompressed size, member count, // and depth are bounded against zip bombs. Corrupt/over-depth => SevMedium // Opaque, never panic. type ArchiveAnalyzer struct{} func (ArchiveAnalyzer) Name() string { return "archive" } func (ArchiveAnalyzer) Handles(kind FileKind) bool { return kind == KindArchive } const ( maxArchiveMembers = 1024 maxArchiveUncompressed = 67108864 // 64 MiB maxArchiveDepth = 3 maxMemberScanBytes = 1048576 // 1 MiB per member fed to analyzers ) func (ArchiveAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) { if f == nil { return nil, nil } return analyzeArchiveBytes(f.Sniff, f.RelPath, 0, b) } // analyzeArchiveBytes opens a zip from raw bytes and walks its members. originRel // is the path used to label findings (the outer archive's RelPath, with nested // member paths appended). depth guards recursion. 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 // Reject zip-slip: absolute or parent-traversal member paths. 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 } // Bound per-member read and total uncompressed budget. 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 } // scanArchiveMember sniffs a member's bytes by MAGIC (not name) and routes it to // the appropriate analyzer logic, recursing into nested archives. An executable // member with suspicious content yields 'archive-contains-executable'. func scanArchiveMember(memberData []byte, memberRel string, depth int, b *Bundle) []Finding { kind, _ := sniffMagicKind(memberData) // Fall back to name-based classification only when magic is inconclusive. if kind == KindUnknown { kind = classifyKind(path.Base(memberRel), memberData) } var out []Finding switch kind { case KindArchive: // Nested archive: recurse (magic-sniffed, depth-bounded). 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), }) // surface exfil-host hits inside the binary blob too 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: // XML/relationships/text inside a docx can carry imperative directives // or exfil hosts (the context-loader payload). 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: // Unknown/opaque member: only worth flagging if it carries the exfil host. 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 } // readZipMember reads a member with a hard per-member cap, updating the running // uncompressed total. Returns at most maxMemberScanBytes. 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 } // truncateBytes caps a byte slice at the per-member scan budget for downstream // analyzers (defense against pathological members). func truncateBytes(data []byte) []byte { if len(data) > maxMemberScanBytes { return data[:maxMemberScanBytes] } return data } // isUnsafeMemberPath rejects absolute paths and parent-traversal segments. It // inspects the raw segments directly rather than path.Clean-ing against root, // because cleaning against "/" silently absorbs leading ".." segments and would // hide a zip-slip member like "../../etc/evil". func isUnsafeMemberPath(name string) bool { if name == "" { return true } if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") { return true } // Windows drive-letter absolute path. if len(name) >= 2 && name[1] == ':' { return true } for _, seg := range strings.Split(strings.ReplaceAll(name, "\\", "/"), "/") { if seg == ".." { return true } } return false } // joinMember labels a nested finding as "!/". func joinMember(origin, member string) string { member = strings.ReplaceAll(member, "\\", "/") return origin + "!/" + member } // hasActionable reports whether any finding is at least SevMedium (i.e. worth // escalating the archive itself). func hasActionable(fs []Finding) bool { for _, f := range fs { if f.Severity >= SevMedium { return true } } return false }