All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
155 lines
4.6 KiB
Go
155 lines
4.6 KiB
Go
// 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, ", ")
|
|
}
|