Files
aml/pkg/engine/evaluator.go
T
zeekayandHanzo Dev 07e69e3ee2 luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence
scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored
alerting. Single binary, embedded SQLite per org, embedded UI.

Surface (all /v1, org-scoped):
  /v1/aml/transactions        ingest and score
  /v1/aml/rules  /rules/test  the rules DSL and its dry-run
  /v1/aml/sanctions/search    list screening
  /v1/aml/cases               investigation workflow
  /v1/aml/health

Build: make build produces one static binary with the UI embedded (embedui tag);
bare Go tooling needs no Node. Third-party license and notice texts are generated
from the module graph the linker actually walks and ship beside every artifact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 22:51:25 -07:00

270 lines
8.4 KiB
Go

package engine
import (
"fmt"
"strings"
"sync"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/luxfi/aml/pkg/types"
)
// ExprFunc is the function signature required by expr-lang.
type ExprFunc = func(params ...any) (any, error)
// DefaultMaxCacheSize is the default max number of compiled programs in the cache.
const DefaultMaxCacheSize = 10_000
// Evaluator compiles and evaluates expr-lang rule DSL against transaction contexts.
// Compiled programs are cached per rule ID + DSL hash for reuse.
// RED-15: Cache has a max size with LRU eviction to prevent memory exhaustion.
type Evaluator struct {
mu sync.RWMutex
cache map[string]*vm.Program // key = rule.ID + ":" + rule.DSL
cacheOrder []string // insertion order for LRU eviction
maxCacheSize int
funcs map[string]ExprFunc
}
// NewEvaluator creates an Evaluator with the default helper functions.
func NewEvaluator() *Evaluator {
return NewEvaluatorWithMaxCache(DefaultMaxCacheSize)
}
// NewEvaluatorWithMaxCache creates an Evaluator with a custom cache limit.
func NewEvaluatorWithMaxCache(maxCache int) *Evaluator {
if maxCache <= 0 {
maxCache = DefaultMaxCacheSize
}
e := &Evaluator{
cache: make(map[string]*vm.Program),
maxCacheSize: maxCache,
funcs: make(map[string]ExprFunc),
}
e.registerDefaults()
return e
}
// CacheLen returns the number of compiled programs in the cache.
func (e *Evaluator) CacheLen() int {
e.mu.RLock()
defer e.mu.RUnlock()
return len(e.cache)
}
// RegisterFunc adds a named function available in the expr-lang environment.
func (e *Evaluator) RegisterFunc(name string, fn ExprFunc) {
e.mu.Lock()
e.funcs[name] = fn
e.mu.Unlock()
}
// registerDefaults registers stub helper functions for the DSL.
// These return conservative defaults. Production wires real implementations.
func (e *Evaluator) registerDefaults() {
e.funcs["count_last_24h"] = func(params ...any) (any, error) { return 0, nil }
e.funcs["sum_last_24h"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["sum_last_30d"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["first_tx"] = func(params ...any) (any, error) { return false, nil }
e.funcs["first_counterparty"] = func(params ...any) (any, error) { return false, nil }
e.funcs["last_tx_age"] = func(params ...any) (any, error) { return 0, nil }
e.funcs["is_round_trip"] = func(params ...any) (any, error) { return false, nil }
e.funcs["geo_match"] = func(params ...any) (any, error) { return true, nil }
e.funcs["sanctions_hit"] = func(params ...any) (any, error) { return false, nil }
e.funcs["sanctioned_jurisdictions"] = func(params ...any) (any, error) {
return []string{"IR", "KP", "SY", "CU", "AF", "BY", "CF", "CD", "LY", "ML", "SO", "SS", "SD", "YE"}, nil
}
e.funcs["hour"] = func(params ...any) (any, error) { return 12, nil }
e.funcs["is_mixer_address"] = func(params ...any) (any, error) { return false, nil }
e.funcs["is_darknet_market"] = func(params ...any) (any, error) { return false, nil }
e.funcs["layering_score"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["smurfing_detected"] = func(params ...any) (any, error) { return false, nil }
e.funcs["wash_trade_detected"] = func(params ...any) (any, error) { return false, nil }
e.funcs["travel_rule_threshold"] = func(params ...any) (any, error) { return 3000.0, nil }
e.funcs["travel_rule_complete"] = func(params ...any) (any, error) { return true, nil }
e.funcs["chain_risk_score"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["chain_is_sanctioned"] = func(params ...any) (any, error) { return false, nil }
// RED-13: notional_usd converts tx.Notional to USD using approximate FX rates.
// This ensures CTR rules fire correctly for non-USD currencies.
e.funcs["notional_usd"] = func(params ...any) (any, error) {
if len(params) < 1 {
return 0.0, fmt.Errorf("notional_usd requires a transaction")
}
// expr-lang passes the struct directly.
switch v := params[0].(type) {
case types.Transaction:
return notionalUSD(v.Notional, v.Currency), nil
case *types.Transaction:
return notionalUSD(v.Notional, v.Currency), nil
case map[string]interface{}:
notional, _ := v["Notional"].(float64)
currency, _ := v["Currency"].(string)
return notionalUSD(notional, currency), nil
default:
return 0.0, fmt.Errorf("notional_usd: unexpected type %T", params[0])
}
}
}
// RED-13: fxRates maps currency codes to their approximate USD exchange rate.
// Updated by the sanctions refresh cron in production; these are conservative defaults.
var fxRates = map[string]float64{
"USD": 1.0, "EUR": 1.08, "GBP": 1.27, "CHF": 1.12,
"JPY": 0.0067, "INR": 0.012, "BRL": 0.20, "AUD": 0.66,
"CAD": 0.74, "SGD": 0.75, "AED": 0.27, "KRW": 0.00075,
"CNY": 0.14, "MXN": 0.058, "ZAR": 0.055, "TRY": 0.031,
"RUB": 0.011, "HKD": 0.13, "NZD": 0.61, "SEK": 0.096,
"NOK": 0.094, "DKK": 0.14, "PLN": 0.25, "THB": 0.028,
}
// notionalUSD converts a notional amount to USD using approximate FX rates.
// Unknown currencies return the raw notional value (conservative — assumes USD).
func notionalUSD(notional float64, currency string) float64 {
if currency == "" || currency == "USD" {
return notional
}
rate, ok := fxRates[strings.ToUpper(currency)]
if !ok {
return notional // unknown currency → assume raw = conservative
}
return notional * rate
}
// cacheKey returns the cache key for a compiled rule program.
func cacheKey(r types.Rule) string {
return r.ID + ":" + r.DSL
}
// Compile compiles a rule DSL into a cached vm.Program.
func (e *Evaluator) Compile(r types.Rule) (*vm.Program, error) {
key := cacheKey(r)
e.mu.RLock()
if prog, ok := e.cache[key]; ok {
e.mu.RUnlock()
return prog, nil
}
e.mu.RUnlock()
opts := []expr.Option{
expr.Env(map[string]interface{}{
"tx": types.Transaction{},
"entity": types.Entity{},
}),
}
for name, fn := range e.funcs {
opts = append(opts, expr.Function(name, fn))
}
prog, err := expr.Compile(r.DSL, opts...)
if err != nil {
return nil, fmt.Errorf("compile rule %q: %w", r.Name, err)
}
e.mu.Lock()
// RED-15: Evict oldest entries when cache exceeds max size.
if len(e.cache) >= e.maxCacheSize {
evictCount := e.maxCacheSize / 10
if evictCount < 1 {
evictCount = 1
}
for i := 0; i < evictCount && i < len(e.cacheOrder); i++ {
delete(e.cache, e.cacheOrder[i])
}
if evictCount <= len(e.cacheOrder) {
e.cacheOrder = e.cacheOrder[evictCount:]
} else {
e.cacheOrder = nil
}
}
e.cache[key] = prog
e.cacheOrder = append(e.cacheOrder, key)
e.mu.Unlock()
return prog, nil
}
// Eval evaluates a single rule against a transaction + entity context.
func (e *Evaluator) Eval(r types.Rule, ctx types.EvalContext) (bool, error) {
prog, err := e.Compile(r)
if err != nil {
return false, err
}
env := map[string]interface{}{
"tx": ctx.Tx,
"entity": ctx.Entity,
}
out, err := expr.Run(prog, env)
if err != nil {
return false, fmt.Errorf("eval rule %q: %w", r.Name, err)
}
switch v := out.(type) {
case bool:
return v, nil
case float64:
return v > 0, nil
case int:
return v > 0, nil
default:
return false, fmt.Errorf("rule %q returned non-bool type %T", r.Name, out)
}
}
// EvalAll evaluates all rules against a context and returns matching hits.
// Rules are filtered by jurisdiction and asset class before evaluation.
func (e *Evaluator) EvalAll(rules []types.Rule, ctx types.EvalContext) []types.RuleHit {
var hits []types.RuleHit
for _, r := range rules {
if !r.Enabled {
continue
}
if !matchesFilter(r.JurisdictionFilter, ctx.Entity.Jurisdiction) {
continue
}
if !matchesFilter(r.AssetClassFilter, ctx.Tx.AssetClass) {
continue
}
match, err := e.Eval(r, ctx)
if err != nil {
// RED-03: Fail closed on eval errors. A broken rule should trigger
// review, not silently allow the transaction through. The error is
// captured in the hit so analysts can see WHY the rule flagged.
hits = append(hits, types.RuleHit{
Rule: r,
Match: true,
EvalErr: err.Error(),
})
continue
}
if match {
hits = append(hits, types.RuleHit{
Rule: r,
Match: true,
})
}
}
return hits
}
// matchesFilter returns true if the filter is empty (matches all)
// or if val is in the filter list.
func matchesFilter(filter []string, val string) bool {
if len(filter) == 0 {
return true
}
for _, f := range filter {
if strings.EqualFold(f, val) {
return true
}
}
return false
}