Feat v0.5.1 chat core (#31)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #31.
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
//
|
||||
// Starlark API:
|
||||
//
|
||||
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50)
|
||||
// rows = db.query("logs", filters={"user_id": "abc"}, order="created_at", limit=50, before={"created_at": "2026-01-01"}, after={"count": 5})
|
||||
// row = db.insert("logs", {"message": "hello"})
|
||||
// ok = db.update("logs", row_id, {"message": "updated"})
|
||||
// ok = db.delete("logs", row_id)
|
||||
@@ -221,19 +221,64 @@ func goToStarlark(v any) (starlark.Value, error) {
|
||||
|
||||
// ── Builtins ─────────────────────────────────
|
||||
|
||||
// dbQuery implements db.query(table, filters=None, order=None, limit=100).
|
||||
// starlarkRangeToSQL converts a Starlark dict of {col: val} into range
|
||||
// comparison clauses (e.g. col < $N or col > $N). op must be "<" or ">".
|
||||
func (cfg DBModuleConfig) starlarkRangeToSQL(rangeVal starlark.Value, op string, startIdx int) ([]string, []any, error) {
|
||||
if rangeVal == starlark.None || rangeVal == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
d, ok := rangeVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("db: range param must be a dict, got %s", rangeVal.Type())
|
||||
}
|
||||
if d.Len() == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
var args []any
|
||||
idx := startIdx
|
||||
|
||||
for _, item := range d.Items() {
|
||||
col, ok := item[0].(starlark.String)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("db: range key must be a string, got %s", item[0].Type())
|
||||
}
|
||||
colStr := string(col)
|
||||
if strings.ContainsAny(colStr, " \t\n\"';-") {
|
||||
return nil, nil, fmt.Errorf("db: invalid column name %q", colStr)
|
||||
}
|
||||
|
||||
val, err := starlarkToGoValue(item[1])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("db: range value for %q: %w", colStr, err)
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("%s %s %s", colStr, op, cfg.ph(idx)))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
return parts, args, nil
|
||||
}
|
||||
|
||||
// dbQuery implements db.query(table, filters=None, order=None, limit=100, before=None, after=None).
|
||||
func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var table string
|
||||
var filters starlark.Value = starlark.None
|
||||
var order starlark.Value = starlark.None
|
||||
var limit starlark.Int = starlark.MakeInt(100)
|
||||
var before starlark.Value = starlark.None
|
||||
var after starlark.Value = starlark.None
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"table", &table,
|
||||
"filters?", &filters,
|
||||
"order?", &order,
|
||||
"limit?", &limit,
|
||||
"before?", &before,
|
||||
"after?", &after,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -248,14 +293,41 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build range clauses (before → <, after → >)
|
||||
beforeParts, beforeArgs, err := cfg.starlarkRangeToSQL(before, "<", len(whereArgs)+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
afterParts, afterArgs, err := cfg.starlarkRangeToSQL(after, ">", len(whereArgs)+len(beforeArgs)+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Merge all WHERE conditions
|
||||
var allParts []string
|
||||
var allArgs []any
|
||||
|
||||
// Extract equality parts from whereClause
|
||||
if whereClause != "" {
|
||||
// whereClause is "WHERE x = $1 AND y = $2"; strip the "WHERE " prefix
|
||||
allParts = append(allParts, strings.TrimPrefix(whereClause, "WHERE "))
|
||||
allArgs = append(allArgs, whereArgs...)
|
||||
} else {
|
||||
allArgs = append(allArgs, whereArgs...)
|
||||
}
|
||||
allParts = append(allParts, beforeParts...)
|
||||
allArgs = append(allArgs, beforeArgs...)
|
||||
allParts = append(allParts, afterParts...)
|
||||
allArgs = append(allArgs, afterArgs...)
|
||||
|
||||
lim, ok := limit.Int64()
|
||||
if !ok || lim < 1 || lim > 1000 {
|
||||
lim = 100
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s", physTable)
|
||||
if whereClause != "" {
|
||||
query += " " + whereClause
|
||||
if len(allParts) > 0 {
|
||||
query += " WHERE " + strings.Join(allParts, " AND ")
|
||||
}
|
||||
|
||||
if order != starlark.None {
|
||||
@@ -275,11 +347,11 @@ func dbQuery(ctx context.Context, cfg DBModuleConfig) func(*starlark.Thread, *st
|
||||
query += fmt.Sprintf(" ORDER BY %s %s", colStr, dir)
|
||||
}
|
||||
|
||||
limitPH := cfg.ph(len(whereArgs) + 1)
|
||||
limitPH := cfg.ph(len(allArgs) + 1)
|
||||
query += " LIMIT " + limitPH
|
||||
whereArgs = append(whereArgs, lim)
|
||||
allArgs = append(allArgs, lim)
|
||||
|
||||
rows, err := cfg.DB.QueryContext(ctx, query, whereArgs...)
|
||||
rows, err := cfg.DB.QueryContext(ctx, query, allArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.query: %w", err)
|
||||
}
|
||||
|
||||
@@ -226,6 +226,104 @@ func TestDBViewRejectsUnknownView(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── before / after range parameters ──────────
|
||||
|
||||
func TestDBQueryBefore(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", before={"created_at": "2026-02-15T00:00:00Z"}, order="created_at")`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "old") || !strings.Contains(rowsStr, "mid") {
|
||||
t.Errorf("expected old and mid rows, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "new") {
|
||||
t.Errorf("unexpected 'new' row in before results: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryAfter(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", after={"created_at": "2026-02-01T00:00:00Z"}, order="created_at")`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "new") {
|
||||
t.Errorf("expected 'new' row, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "old") || strings.Contains(rowsStr, "mid") {
|
||||
t.Errorf("unexpected old/mid rows in after results: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryBeforeWithFilters(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('a', 'u1-old', 'u1', '2026-01-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('b', 'u1-new', 'u1', '2026-03-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('c', 'u2-old', 'u2', '2026-01-01T00:00:00Z')`)
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", filters={"user_id": "u1"}, before={"created_at": "2026-02-01T00:00:00Z"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "u1-old") {
|
||||
t.Errorf("expected u1-old, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "u1-new") || strings.Contains(rowsStr, "u2-old") {
|
||||
t.Errorf("unexpected rows: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryBeforeAfterCombined(t *testing.T) {
|
||||
db, cfg := newTestDB(t)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
||||
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
||||
|
||||
result, err := execScript(t, cfg, `rows = db.query("logs", after={"created_at": "2026-01-15T00:00:00Z"}, before={"created_at": "2026-02-15T00:00:00Z"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("script error: %v", err)
|
||||
}
|
||||
rowsStr := result.Globals["rows"].String()
|
||||
if !strings.Contains(rowsStr, "mid") {
|
||||
t.Errorf("expected mid row, got: %s", rowsStr)
|
||||
}
|
||||
if strings.Contains(rowsStr, "old") || strings.Contains(rowsStr, "new") {
|
||||
t.Errorf("unexpected old/new rows: %s", rowsStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryBeforeEmptyDict(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
// Empty before dict should be a no-op (same as no before)
|
||||
_, err := execScript(t, cfg, `rows = db.query("logs", before={})`)
|
||||
if err != nil {
|
||||
t.Fatalf("empty before dict should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBQueryBeforeInvalidColumn(t *testing.T) {
|
||||
_, cfg := newTestDB(t)
|
||||
_, err := execScript(t, cfg, `rows = db.query("logs", before={"bad name": "x"})`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid column name in before")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid column name") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── namespace isolation ──────────────────────
|
||||
|
||||
func TestDBQueryRejectsOtherExtensionTable(t *testing.T) {
|
||||
|
||||
@@ -145,6 +145,14 @@ func (s *Sandbox) ExecWithLoader(ctx context.Context, filename, source string, m
|
||||
}
|
||||
}()
|
||||
|
||||
// Unicode security gate — scan source for invisible/deceptive characters
|
||||
// before execution (defense in depth; install gate is primary).
|
||||
if findings := ScanSource(source, filename); len(findings) > 0 {
|
||||
if blocked, reason := Verdict(findings); blocked {
|
||||
return nil, fmt.Errorf("unicode security gate: %s (file: %s)", reason, filename)
|
||||
}
|
||||
}
|
||||
|
||||
globals, err := starlark.ExecFile(thread, filename, source, predeclared)
|
||||
if err != nil {
|
||||
return nil, wrapError(err)
|
||||
|
||||
154
server/sandbox/unicode_scan.go
Normal file
154
server/sandbox/unicode_scan.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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, ", ")
|
||||
}
|
||||
354
server/sandbox/unicode_scan_test.go
Normal file
354
server/sandbox/unicode_scan_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── ScanSource ──────────────────────────────
|
||||
|
||||
func TestScanSource_CleanStarlark(t *testing.T) {
|
||||
source := `
|
||||
def on_request(req):
|
||||
path = req["path"]
|
||||
method = req["method"]
|
||||
return {"status": 200, "body": "ok"}
|
||||
`
|
||||
findings := ScanSource(source, "clean.star")
|
||||
if len(findings) != 0 {
|
||||
t.Errorf("expected 0 findings for clean source, got %d", len(findings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_FewVariationSelectors(t *testing.T) {
|
||||
// 1-2 variation selectors — legitimate emoji use (e.g. text vs emoji presentation)
|
||||
source := "emoji = \"\u2764\uFE0F\" # heart with variation selector"
|
||||
findings := ScanSource(source, "emoji.star")
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("expected 1 finding for single VS, got %d", len(findings))
|
||||
}
|
||||
if findings[0].Category != "variation_selector" {
|
||||
t.Errorf("expected category variation_selector, got %q", findings[0].Category)
|
||||
}
|
||||
if findings[0].Rune != '\uFE0F' {
|
||||
t.Errorf("expected rune U+FE0F, got U+%04X", findings[0].Rune)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_GlassWormPattern(t *testing.T) {
|
||||
// 50+ variation selectors — GlassWorm payload pattern
|
||||
var sb strings.Builder
|
||||
sb.WriteString("data = \"")
|
||||
for i := 0; i < 50; i++ {
|
||||
sb.WriteRune(rune(0xFE00 + (i % 16))) // cycle through VS1-VS16
|
||||
}
|
||||
sb.WriteString("\"")
|
||||
source := sb.String()
|
||||
|
||||
findings := ScanSource(source, "glassworm.star")
|
||||
count := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "variation_selector" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 50 {
|
||||
t.Errorf("expected 50 variation_selector findings, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_BidiOverride(t *testing.T) {
|
||||
// RLO (U+202E) — Trojan Source attack
|
||||
source := "name = \"\u202Emalicious\""
|
||||
findings := ScanSource(source, "bidi.star")
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(findings))
|
||||
}
|
||||
if findings[0].Category != "bidi_override" {
|
||||
t.Errorf("expected bidi_override, got %q", findings[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_BidiIsolate(t *testing.T) {
|
||||
// LRI (U+2066), PDI (U+2069)
|
||||
source := "text = \"\u2066hidden\u2069\""
|
||||
findings := ScanSource(source, "bidi_isolate.star")
|
||||
bidiCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "bidi_override" {
|
||||
bidiCount++
|
||||
}
|
||||
}
|
||||
if bidiCount != 2 {
|
||||
t.Errorf("expected 2 bidi_override findings, got %d", bidiCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_ZeroWidthBelow(t *testing.T) {
|
||||
// 3 zero-width chars — below threshold
|
||||
source := "a = \"\u200B\u200B\u200B\""
|
||||
findings := ScanSource(source, "zw.star")
|
||||
zwCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "zero_width" {
|
||||
zwCount++
|
||||
}
|
||||
}
|
||||
if zwCount != 3 {
|
||||
t.Errorf("expected 3 zero_width findings, got %d", zwCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_ZeroWidthAbove(t *testing.T) {
|
||||
// 8 zero-width chars — above threshold
|
||||
source := "a = \"\u200B\u200B\u200B\u200B\u200B\u200B\u200B\u200B\""
|
||||
findings := ScanSource(source, "zw_many.star")
|
||||
zwCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "zero_width" {
|
||||
zwCount++
|
||||
}
|
||||
}
|
||||
if zwCount != 8 {
|
||||
t.Errorf("expected 8 zero_width findings, got %d", zwCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_TagCharacters(t *testing.T) {
|
||||
// Tag characters (U+E0001-U+E007F)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("tag = \"")
|
||||
for i := 0; i < 15; i++ {
|
||||
sb.WriteRune(rune(0xE0001 + i))
|
||||
}
|
||||
sb.WriteString("\"")
|
||||
source := sb.String()
|
||||
|
||||
findings := ScanSource(source, "tags.star")
|
||||
tagCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "tag_character" {
|
||||
tagCount++
|
||||
}
|
||||
}
|
||||
if tagCount != 15 {
|
||||
t.Errorf("expected 15 tag_character findings, got %d", tagCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_HangulFiller(t *testing.T) {
|
||||
source := "x = \"\u3164\"" // Hangul filler
|
||||
findings := ScanSource(source, "hangul.star")
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(findings))
|
||||
}
|
||||
if findings[0].Category != "other_invisible" {
|
||||
t.Errorf("expected other_invisible, got %q", findings[0].Category)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_LineTracking(t *testing.T) {
|
||||
source := "line1\nline2\nline3 = \"\u202E\""
|
||||
findings := ScanSource(source, "lines.star")
|
||||
if len(findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(findings))
|
||||
}
|
||||
if findings[0].Line != 3 {
|
||||
t.Errorf("expected line 3, got %d", findings[0].Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSource_VariationSelectorSupplement(t *testing.T) {
|
||||
// U+E0100 — GlassWorm secondary range (R32)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("x = \"")
|
||||
for i := 0; i < 12; i++ {
|
||||
sb.WriteRune(rune(0xE0100 + i))
|
||||
}
|
||||
sb.WriteString("\"")
|
||||
source := sb.String()
|
||||
|
||||
findings := ScanSource(source, "vs_supp.star")
|
||||
vsCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "variation_selector" {
|
||||
vsCount++
|
||||
}
|
||||
}
|
||||
if vsCount != 12 {
|
||||
t.Errorf("expected 12 variation_selector findings from supplement range, got %d", vsCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verdict ─────────────────────────────────
|
||||
|
||||
func TestVerdict_NoFindings(t *testing.T) {
|
||||
blocked, reason := Verdict(nil)
|
||||
if blocked {
|
||||
t.Error("expected not blocked for nil findings")
|
||||
}
|
||||
if reason != "" {
|
||||
t.Errorf("expected empty reason, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_BidiAlwaysBlocks(t *testing.T) {
|
||||
findings := []Finding{{Rune: 0x202E, Category: "bidi_override"}}
|
||||
blocked, reason := Verdict(findings)
|
||||
if !blocked {
|
||||
t.Error("expected blocked for bidi_override")
|
||||
}
|
||||
if !strings.Contains(reason, "bidirectional override") {
|
||||
t.Errorf("expected bidi reason, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_GlassWormBlocks(t *testing.T) {
|
||||
var findings []Finding
|
||||
for i := 0; i < 10; i++ {
|
||||
findings = append(findings, Finding{Rune: rune(0xFE00 + i), Category: "variation_selector"})
|
||||
}
|
||||
blocked, reason := Verdict(findings)
|
||||
if !blocked {
|
||||
t.Error("expected blocked for 10 variation selectors")
|
||||
}
|
||||
if !strings.Contains(reason, "GlassWorm") {
|
||||
t.Errorf("expected GlassWorm reason, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_GlassWormMixedVSAndTag(t *testing.T) {
|
||||
var findings []Finding
|
||||
for i := 0; i < 5; i++ {
|
||||
findings = append(findings, Finding{Rune: rune(0xFE00 + i), Category: "variation_selector"})
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
findings = append(findings, Finding{Rune: rune(0xE0001 + i), Category: "tag_character"})
|
||||
}
|
||||
blocked, _ := Verdict(findings)
|
||||
if !blocked {
|
||||
t.Error("expected blocked for 5 VS + 5 tag = 10 total")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_FewVariationSelectorsPass(t *testing.T) {
|
||||
findings := []Finding{
|
||||
{Rune: 0xFE0F, Category: "variation_selector"},
|
||||
{Rune: 0xFE0E, Category: "variation_selector"},
|
||||
}
|
||||
blocked, _ := Verdict(findings)
|
||||
if blocked {
|
||||
t.Error("expected not blocked for 2 variation selectors (emoji use)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_ZeroWidthBelowThresholdPasses(t *testing.T) {
|
||||
var findings []Finding
|
||||
for i := 0; i < 5; i++ {
|
||||
findings = append(findings, Finding{Rune: 0x200B, Category: "zero_width"})
|
||||
}
|
||||
blocked, _ := Verdict(findings)
|
||||
if blocked {
|
||||
t.Error("expected not blocked for 5 zero_width chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_ZeroWidthAboveThresholdBlocks(t *testing.T) {
|
||||
var findings []Finding
|
||||
for i := 0; i < 6; i++ {
|
||||
findings = append(findings, Finding{Rune: 0x200B, Category: "zero_width"})
|
||||
}
|
||||
blocked, reason := Verdict(findings)
|
||||
if !blocked {
|
||||
t.Error("expected blocked for 6 zero_width chars")
|
||||
}
|
||||
if !strings.Contains(reason, "zero-width") {
|
||||
t.Errorf("expected zero-width reason, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerdict_OtherInvisibleDoesNotBlock(t *testing.T) {
|
||||
findings := []Finding{
|
||||
{Rune: 0x3164, Category: "other_invisible"},
|
||||
{Rune: 0xFFF9, Category: "other_invisible"},
|
||||
}
|
||||
blocked, _ := Verdict(findings)
|
||||
if blocked {
|
||||
t.Error("expected not blocked for 2 other_invisible chars")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GlassWorm payload simulation ────────────
|
||||
|
||||
func TestGlassWormPayloadDetection(t *testing.T) {
|
||||
// Simulate a GlassWorm-style payload: encode each byte of a secret string
|
||||
// as a variation selector offset (U+FE00 + low nibble, U+E0100 + high nibble).
|
||||
// This is the core technique: invisible characters carrying data.
|
||||
secret := "eval(malicious_code)"
|
||||
var sb strings.Builder
|
||||
sb.WriteString("innocent_looking_var = \"")
|
||||
for _, b := range []byte(secret) {
|
||||
// Encode low nibble as VS1-VS16 (U+FE00-U+FE0F)
|
||||
sb.WriteRune(rune(0xFE00 + (int(b) & 0x0F)))
|
||||
// Encode high nibble as VS supplement (U+E0100-U+E010F)
|
||||
sb.WriteRune(rune(0xE0100 + ((int(b) >> 4) & 0x0F)))
|
||||
}
|
||||
sb.WriteString("\"")
|
||||
|
||||
source := sb.String()
|
||||
findings := ScanSource(source, "glassworm_payload.star")
|
||||
|
||||
// Should have 2 findings per character (low + high nibble)
|
||||
expectedFindings := len(secret) * 2
|
||||
vsCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Category == "variation_selector" {
|
||||
vsCount++
|
||||
}
|
||||
}
|
||||
if vsCount != expectedFindings {
|
||||
t.Errorf("expected %d variation_selector findings, got %d", expectedFindings, vsCount)
|
||||
}
|
||||
|
||||
// Verdict should block
|
||||
blocked, reason := Verdict(findings)
|
||||
if !blocked {
|
||||
t.Errorf("GlassWorm payload should be blocked, got: %s", reason)
|
||||
}
|
||||
if !strings.Contains(reason, "GlassWorm") {
|
||||
t.Errorf("expected GlassWorm in reason, got: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Execution gate integration ──────────────
|
||||
|
||||
func TestExecGateBlocksBidiSource(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
// Source with a bidi override character
|
||||
source := "x = \"\u202E\""
|
||||
_, err := sb.Exec(context.Background(), "bidi.star", source, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from unicode gate for bidi override")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unicode security gate") {
|
||||
t.Errorf("expected 'unicode security gate' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecGateAllowsCleanSource(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
_, err := sb.Exec(context.Background(), "clean.star", "x = 42", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("clean source should not be blocked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecGateAllowsFewVS(t *testing.T) {
|
||||
sb := New(DefaultConfig())
|
||||
source := "emoji = \"\u2764\uFE0F\""
|
||||
_, err := sb.Exec(context.Background(), "emoji.star", source, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("2 VS should not be blocked: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user