// Package sandbox — unicode_scan.go // // v0.5.1: Invisible Unicode scanning gate. // // Defends against GlassWorm (variation selector payloads), Trojan Source // (bidi override attacks / CVE-2021-42574), and other invisible Unicode // smuggling techniques. // // Two public functions: // ScanSource(source, filename) → []Finding // Verdict(findings) → (blocked bool, reason string) // // No dependencies beyond stdlib (unicode, fmt, strings). // Single-pass rune iteration. No regex. package sandbox import ( "fmt" "strings" "unicode" ) // invisibleRanges defines the Unicode ranges we scan for, grouped by category. // Uses unicode.RangeTable with R16/R32 slices for efficient lookup. var invisibleRanges = map[string]*unicode.RangeTable{ "zero_width": { R16: []unicode.Range16{ {Lo: 0x200B, Hi: 0x200F, Stride: 1}, // zero-width space, joiners, bidi marks {Lo: 0x2060, Hi: 0x2064, Stride: 1}, // word joiner, invisible operators {Lo: 0xFEFF, Hi: 0xFEFF, Stride: 1}, // BOM / zero-width no-break space }, }, "bidi_override": { R16: []unicode.Range16{ {Lo: 0x202A, Hi: 0x202E, Stride: 1}, // bidi overrides (Trojan Source) {Lo: 0x2066, Hi: 0x2069, Stride: 1}, // bidi isolates }, }, "variation_selector": { R16: []unicode.Range16{ {Lo: 0xFE00, Hi: 0xFE0F, Stride: 1}, // variation selectors (GlassWorm primary) }, R32: []unicode.Range32{ {Lo: 0xE0100, Hi: 0xE01EF, Stride: 1}, // variation selector supplement (GlassWorm secondary) }, }, "tag_character": { R32: []unicode.Range32{ {Lo: 0xE0001, Hi: 0xE007F, Stride: 1}, // tag characters }, }, "other_invisible": { R16: []unicode.Range16{ {Lo: 0x3164, Hi: 0x3164, Stride: 1}, // Hangul filler {Lo: 0xFFF9, Hi: 0xFFFB, Stride: 1}, // interlinear annotations }, }, } // Finding records a single invisible/deceptive Unicode character found in source. type Finding struct { Offset int // byte offset in source Line int // 1-based line number Rune rune // the character found Category string // one of: variation_selector, bidi_override, zero_width, tag_character, other_invisible } // ScanSource scans source code for invisible/deceptive Unicode characters. // Returns a slice of findings (empty if clean). Single-pass rune iteration. func ScanSource(source string, filename string) []Finding { var findings []Finding line := 1 offset := 0 for _, r := range source { for cat, rt := range invisibleRanges { if unicode.Is(rt, r) { findings = append(findings, Finding{ Offset: offset, Line: line, Rune: r, Category: cat, }) break } } if r == '\n' { line++ } offset += len(string(r)) } return findings } // CategoryCounts returns a map of category → count from a slice of findings. func CategoryCounts(findings []Finding) map[string]int { counts := make(map[string]int) for _, f := range findings { counts[f.Category]++ } return counts } // Verdict evaluates findings and returns whether the source should be blocked. // // Blocking rules: // - Any bidi_override → block (Trojan Source, zero legitimate use in Starlark) // - variation_selector + tag_character >= 10 → block (GlassWorm signature) // - zero_width >= 6 → block // // Returns blocked=false, reason="" if clean or below threshold. func Verdict(findings []Finding) (blocked bool, reason string) { if len(findings) == 0 { return false, "" } counts := CategoryCounts(findings) // Rule 1: Any bidi override → always block (Trojan Source / CVE-2021-42574) if counts["bidi_override"] > 0 { return true, fmt.Sprintf( "blocked: %d bidirectional override character(s) detected (Trojan Source / CVE-2021-42574). "+ "These characters can reverse the visual display order of code, hiding malicious logic.", counts["bidi_override"], ) } // Rule 2: GlassWorm signature — variation selectors + tag characters >= 10 glasswormCount := counts["variation_selector"] + counts["tag_character"] if glasswormCount >= 10 { return true, fmt.Sprintf( "blocked: %d variation selector / tag characters detected (GlassWorm pattern). "+ "Legitimate emoji use requires 1-2; %d suggests encoded hidden payload.", glasswormCount, glasswormCount, ) } // Rule 3: Excessive zero-width characters if counts["zero_width"] >= 6 { return true, fmt.Sprintf( "blocked: %d zero-width characters detected. "+ "This exceeds the safe threshold and may indicate hidden content.", counts["zero_width"], ) } // Below threshold — not blocked var parts []string for cat, n := range counts { parts = append(parts, fmt.Sprintf("%d %s", n, cat)) } return false, strings.Join(parts, ", ") }