package bundle import ( "bytes" "encoding/binary" "strings" ) // ImageAnalyzer inspects images (.png/.jpg/.jpeg/.gif/.webp). A multimodal agent // reads instructions an analyzer cannot, so this extracts PNG tEXt/iTXt/zTXt // chunk text, JPEG EXIF/COM comment segments, and printable runs, then scans // them for imperative directives / exfil hosts / base64. Standalone hits are // SevMedium 'image-embedded-directive'; IndirectionAnalyzer raises // delegate-to-image to SevHigh when SKILL.md delegates to the image. Undecodable // images degrade to SevLow Opaque (never panic). type ImageAnalyzer struct{} func (ImageAnalyzer) Name() string { return "image" } func (ImageAnalyzer) Handles(kind FileKind) bool { return kind == KindImage } func (ImageAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) { if f == nil { return nil, nil } texts := extractImageText(f.Sniff, f.Kind) joined := strings.Join(texts, "\n") var out []Finding if exfilHostRe.MatchString(joined) { out = append(out, Finding{ Analyzer: "image", File: f.RelPath, Signal: "exfil-host-reference", Severity: SevCritical, Detail: "image metadata references known exfiltration host", Corroborated: true, }) } for _, dir := range scanImperativeDirectives(joined) { out = append(out, Finding{ Analyzer: "image", File: f.RelPath, Signal: "image-embedded-directive", Severity: SevMedium, Detail: "image embeds an imperative agent directive in metadata: " + dir, }) } // base64 blob hidden in metadata. if base64BlobRe.MatchString(joined) { out = append(out, Finding{ Analyzer: "image", File: f.RelPath, Signal: "image-embedded-blob", Severity: SevMedium, Detail: "image metadata contains a long base64 blob (possible hidden payload)", }) } if len(texts) == 0 { // We could not extract any metadata text; flag as opaque (low) so the // artifact is not silently treated as benign, but do not over-escalate. out = append(out, Finding{ Analyzer: "image", File: f.RelPath, Signal: "opaque-image", Severity: SevLow, Detail: "no extractable metadata text (opaque image)", Opaque: true, Structural: true, }) } return dedupeFindings(out), nil } // extractImageText pulls textual metadata from an image: PNG text chunks, JPEG // EXIF/COM segments, and as a fallback the printable runs across the whole blob // (which captures EXIF UserComment / iTXt regardless of exact container quirks). func extractImageText(data []byte, kind FileKind) []string { var out []string defer func() { _ = recover() }() switch { case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")): out = append(out, extractPNGText(data)...) case bytes.HasPrefix(data, []byte{0xFF, 0xD8}): out = append(out, extractJPEGText(data)...) } // Fallback: printable runs of >= 6 chars across the file frequently surface // EXIF tags, iTXt, and embedded comments the container parsers miss. for _, s := range printableStrings(data, 6) { out = append(out, s) } return dedupeStrings(out) } // extractPNGText walks PNG chunks and lifts tEXt/iTXt/zTXt keyword+text. zTXt is // zlib-compressed; we record its keyword and leave the compressed body to the // printable-run fallback (avoids a decompress bomb). func extractPNGText(data []byte) []string { var out []string // Skip the 8-byte signature. pos := 8 const maxChunks = 4096 chunks := 0 for pos+8 <= len(data) && chunks < maxChunks { chunks++ length := int(binary.BigEndian.Uint32(data[pos : pos+4])) if length < 0 || pos+8+length+4 > len(data) { break } ctype := string(data[pos+4 : pos+8]) body := data[pos+8 : pos+8+length] switch ctype { case "tEXt": if s := decodeLatin1KeywordText(body); s != "" { out = append(out, s) } case "iTXt": if s := decodeITXt(body); s != "" { out = append(out, s) } case "zTXt": if i := bytes.IndexByte(body, 0); i >= 0 { out = append(out, "zTXt:"+string(body[:i])) } case "IEND": return out } pos += 8 + length + 4 // length + type + data + CRC } return out } // decodeLatin1KeywordText decodes a PNG tEXt chunk "keyword\0text". func decodeLatin1KeywordText(body []byte) string { i := bytes.IndexByte(body, 0) if i < 0 { return string(body) } keyword := string(body[:i]) text := string(body[i+1:]) return keyword + ": " + text } // decodeITXt decodes a PNG iTXt chunk, returning keyword + (uncompressed) text. // Format: keyword\0 compflag(1) compmethod(1) langtag\0 transkeyword\0 text. func decodeITXt(body []byte) string { i := bytes.IndexByte(body, 0) if i < 0 || i+3 > len(body) { return "" } keyword := string(body[:i]) rest := body[i+1:] if len(rest) < 2 { return keyword } compFlag := rest[0] rest = rest[2:] // skip compflag + compmethod // skip langtag\0 if j := bytes.IndexByte(rest, 0); j >= 0 { rest = rest[j+1:] } // skip translated-keyword\0 if j := bytes.IndexByte(rest, 0); j >= 0 { rest = rest[j+1:] } if compFlag != 0 { // compressed text: leave body to the printable-run fallback. return keyword } return keyword + ": " + string(rest) } // extractJPEGText scans JPEG markers for COM (comment) and APP1 (EXIF) segments // and returns their printable content. func extractJPEGText(data []byte) []string { var out []string pos := 2 // skip SOI const maxSegs = 4096 segs := 0 for pos+4 <= len(data) && segs < maxSegs { segs++ if data[pos] != 0xFF { pos++ continue } marker := data[pos+1] // Standalone markers without length. if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) { pos += 2 continue } if pos+4 > len(data) { break } segLen := int(binary.BigEndian.Uint16(data[pos+2 : pos+4])) if segLen < 2 || pos+2+segLen > len(data) { break } seg := data[pos+4 : pos+2+segLen] switch marker { case 0xFE: // COM out = append(out, "comment: "+string(seg)) case 0xE1: // APP1 (EXIF/XMP) for _, s := range printableStrings(seg, 5) { out = append(out, s) } } if marker == 0xDA { // SOS: start of scan, stop parsing metadata. break } pos += 2 + segLen } return out }