mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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]>
120 lines
2.9 KiB
Go
120 lines
2.9 KiB
Go
// Package engine implements the real-time AML rule evaluation engine.
|
|
// It evaluates transactions against rules using expr-lang, scores hits
|
|
// using weight-of-evidence, and determines the enforcement action.
|
|
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
// Engine is the core AML evaluation engine.
|
|
type Engine struct {
|
|
evaluator *Evaluator
|
|
mu sync.RWMutex
|
|
rules []types.Rule
|
|
}
|
|
|
|
// New creates an Engine with the given rules.
|
|
func New(rules []types.Rule) *Engine {
|
|
return &Engine{
|
|
evaluator: NewEvaluator(),
|
|
rules: rules,
|
|
}
|
|
}
|
|
|
|
// SetRules replaces the rule set atomically. Returns an error if any rule
|
|
// has a negative weight (RED-19: prevents score suppression attacks).
|
|
func (e *Engine) SetRules(rules []types.Rule) error {
|
|
for _, r := range rules {
|
|
if r.Weight < 0 {
|
|
return fmt.Errorf("rule %q has negative weight %f: negative weights are not allowed", r.ID, r.Weight)
|
|
}
|
|
}
|
|
e.mu.Lock()
|
|
e.rules = rules
|
|
e.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Rules returns a copy of the current rule set.
|
|
func (e *Engine) Rules() []types.Rule {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
out := make([]types.Rule, len(e.rules))
|
|
copy(out, e.rules)
|
|
return out
|
|
}
|
|
|
|
// Evaluator returns the underlying evaluator for function registration.
|
|
func (e *Engine) Evaluator() *Evaluator {
|
|
return e.evaluator
|
|
}
|
|
|
|
// Evaluate runs all rules against a transaction and its primary entity.
|
|
// Returns the computed alerts, the aggregate score, and the enforcement action.
|
|
func (e *Engine) Evaluate(tx types.Transaction, entity types.Entity) ([]types.Alert, float64, string) {
|
|
e.mu.RLock()
|
|
rules := e.rules
|
|
e.mu.RUnlock()
|
|
|
|
ctx := types.EvalContext{
|
|
Tx: tx,
|
|
Entity: entity,
|
|
}
|
|
|
|
hits := e.evaluator.EvalAll(rules, ctx)
|
|
if len(hits) == 0 {
|
|
return nil, 0, types.ActionAllow
|
|
}
|
|
|
|
score, breakdown := Score(hits)
|
|
|
|
now := time.Now().UTC()
|
|
alerts := make([]types.Alert, 0, len(hits))
|
|
for _, h := range hits {
|
|
alerts = append(alerts, types.Alert{
|
|
ID: uuid.NewString(),
|
|
OrgID: tx.OrgID,
|
|
TxID: tx.ID,
|
|
RuleID: h.Rule.ID,
|
|
RuleName: h.Rule.Name,
|
|
Severity: h.Rule.Severity,
|
|
Score: breakdown[h.Rule.ID],
|
|
ScoreBreakdown: breakdown,
|
|
ActionTaken: h.Rule.Action,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
action := resolveAction(alerts)
|
|
return alerts, score, action
|
|
}
|
|
|
|
// resolveAction picks the most severe action from a set of alerts.
|
|
// Priority: block > report > review > flag > allow.
|
|
func resolveAction(alerts []types.Alert) string {
|
|
priority := map[string]int{
|
|
types.ActionBlock: 4,
|
|
types.ActionReport: 3,
|
|
types.ActionReview: 2,
|
|
types.ActionFlag: 1,
|
|
types.ActionAllow: 0,
|
|
}
|
|
|
|
best := types.ActionAllow
|
|
bestP := 0
|
|
for _, a := range alerts {
|
|
if p, ok := priority[a.ActionTaken]; ok && p > bestP {
|
|
best = a.ActionTaken
|
|
bestP = p
|
|
}
|
|
}
|
|
return best
|
|
}
|