mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
sanctions: parse all four published list families
Each parser was written against the published file, not against a description of it. Verified counts: OFAC 19,175 (matching the Record_Count the file declares), UN 1,011, EU 6,017, OFSI 5,135. What the previous single parser lost, and why each matters: OFAC publishes 963 digital currency addresses across 19 networks in idList. Dropping idList meant a payment to a designated blockchain address could not be detected by any means. OFAC marks 4,383 of its 24,546 aliases weak and the UN and OFSI mark theirs Low; flattening them to plain strings matched fragments at full confidence. OFAC flags which of several dates of birth is the main one, and for 37 subjects it is not the first — taking the first dropped the tie-break for exactly the subjects that need one. UN, EU and OFSI were parsed with the OFAC parser on the stated grounds that the formats are similar. They are not: the roots are CONSOLIDATED_LIST, export and a CSV. All three failed every night, logged a line, and continued. OFSI publishes one row per name and joins them by group; 19,761 rows are 5,135 subjects. It also puts the header on line two behind a preamble, orders the name columns Name 6 first, and writes partial dates as 00/00/1994. A parse that yields fewer records than the publisher declares is refused rather than loaded, because a partial sanctions list clears designated subjects. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -1,158 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
const txCollection = "aml_transactions"
|
||||
|
||||
// BaseStore implements TransactionStore backed by Base collections.
|
||||
type BaseStore struct {
|
||||
app core.App
|
||||
}
|
||||
|
||||
// NewBaseStore creates a TransactionStore using Base collections.
|
||||
func NewBaseStore(app core.App) *BaseStore {
|
||||
return &BaseStore{app: app}
|
||||
}
|
||||
|
||||
func (s *BaseStore) CountByUser(ctx context.Context, userID string, since time.Time) (int, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.UTC().Format(time.RFC3339)),
|
||||
"", // sort
|
||||
0, // limit (0 = all)
|
||||
0, // offset
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count by user: %w", err)
|
||||
}
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) SumNotionalByUser(ctx context.Context, userID string, since time.Time) (float64, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.UTC().Format(time.RFC3339)),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sum notional: %w", err)
|
||||
}
|
||||
var total float64
|
||||
for _, r := range records {
|
||||
total += r.GetFloat("notional")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) FirstTransaction(ctx context.Context, userID string) (bool, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s'", userID),
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("first tx check: %w", err)
|
||||
}
|
||||
return len(records) == 0, nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) FirstCounterparty(ctx context.Context, userID, counterparty string) (bool, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s' && counterparty = '%s'", userID, counterparty),
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("first counterparty check: %w", err)
|
||||
}
|
||||
return len(records) == 0, nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) LastTransactionTime(ctx context.Context, userID string) (time.Time, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s'", userID),
|
||||
"-created",
|
||||
1,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("last tx time: %w", err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return records[0].GetDateTime("created").Time(), nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) RoundTrip(ctx context.Context, userID string, window time.Duration) (bool, error) {
|
||||
since := time.Now().UTC().Add(-window)
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
txCollection,
|
||||
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.Format(time.RFC3339)),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("round trip check: %w", err)
|
||||
}
|
||||
|
||||
// Check if any symbol has both "buy" and "sell" sides.
|
||||
sides := make(map[string]map[string]bool) // symbol -> set of sides
|
||||
for _, r := range records {
|
||||
sym := r.GetString("symbol")
|
||||
side := r.GetString("side")
|
||||
if sym == "" || side == "" {
|
||||
continue
|
||||
}
|
||||
if sides[sym] == nil {
|
||||
sides[sym] = make(map[string]bool)
|
||||
}
|
||||
sides[sym][side] = true
|
||||
}
|
||||
for _, sideSet := range sides {
|
||||
if sideSet["buy"] && sideSet["sell"] {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *BaseStore) Save(ctx context.Context, tx types.Transaction) error {
|
||||
collection, err := s.app.FindCollectionByNameOrId(txCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find collection %s: %w", txCollection, err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("tx_id", tx.ID)
|
||||
record.Set("org_id", tx.OrgID)
|
||||
record.Set("user_id", tx.UserID)
|
||||
record.Set("symbol", tx.Symbol)
|
||||
record.Set("side", tx.Side)
|
||||
record.Set("notional", tx.Notional)
|
||||
record.Set("currency", tx.Currency)
|
||||
record.Set("counterparty", tx.Counterparty)
|
||||
record.Set("ip_address", tx.IPAddress)
|
||||
record.Set("asset_class", tx.AssetClass)
|
||||
|
||||
return s.app.Save(record)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/aml/pkg/rules"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// BenchmarkEvaluate measures single-transaction evaluation throughput.
|
||||
// Measures single-transaction evaluation throughput.
|
||||
func BenchmarkEvaluate(b *testing.B) {
|
||||
eng := New(rules.StarterRules("bench"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "bench-tx",
|
||||
UserID: "user-1",
|
||||
Symbol: "AAPL",
|
||||
AssetClass: "us_equity",
|
||||
Side: "buy",
|
||||
Qty: 10,
|
||||
Notional: 1500.0,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "user-1",
|
||||
Jurisdiction: "US",
|
||||
KYCLevel: 2,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
eng.Evaluate(tx, entity)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEvaluateStructuring measures evaluation of a transaction that
|
||||
// triggers the structuring rule (notional in $9000-$10000 range).
|
||||
func BenchmarkEvaluateStructuring(b *testing.B) {
|
||||
eng := New(rules.StarterRules("bench"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "bench-struct",
|
||||
UserID: "user-2",
|
||||
Symbol: "BTC/USD",
|
||||
AssetClass: "us_crypto",
|
||||
Side: "buy",
|
||||
Qty: 1,
|
||||
Notional: 9500.0,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "user-2",
|
||||
Jurisdiction: "US",
|
||||
KYCLevel: 2,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
alerts, score, action := eng.Evaluate(tx, entity)
|
||||
_ = alerts
|
||||
_ = score
|
||||
_ = action
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEvaluateParallel measures concurrent evaluation throughput.
|
||||
func BenchmarkEvaluateParallel(b *testing.B) {
|
||||
eng := New(rules.StarterRules("bench"))
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
tx := types.Transaction{
|
||||
ID: fmt.Sprintf("par-%d", i),
|
||||
UserID: "user-par",
|
||||
Symbol: "AAPL",
|
||||
AssetClass: "us_equity",
|
||||
Side: "buy",
|
||||
Qty: 10,
|
||||
Notional: 1500.0,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{ID: "user-par", Jurisdiction: "US", KYCLevel: 2}
|
||||
eng.Evaluate(tx, entity)
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
+69
-47
@@ -1,9 +1,15 @@
|
||||
// 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 evaluates transactions against the rule library.
|
||||
//
|
||||
// The engine holds a rule set it has admitted: every rule in it parses, names
|
||||
// only evidence this deployment can supply, and returns a boolean. Admission is
|
||||
// the point. A rule set that installs regardless of whether its evidence exists
|
||||
// produces a monitoring programme whose coverage claim cannot be checked by
|
||||
// reading it.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -12,36 +18,49 @@ import (
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// Engine is the core AML evaluation engine.
|
||||
// Engine evaluates transactions against an admitted rule set.
|
||||
type Engine struct {
|
||||
evaluator *Evaluator
|
||||
mu sync.RWMutex
|
||||
rules []types.Rule
|
||||
eval *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,
|
||||
}
|
||||
// New builds an engine over the given evidence providers with an empty rule set.
|
||||
// Install rules with SetRules, which reports any the deployment cannot support.
|
||||
func New(p Providers) *Engine {
|
||||
return &Engine{eval: NewEvaluator(p)}
|
||||
}
|
||||
|
||||
// SetRules replaces the rule set atomically. Returns an error if any rule
|
||||
// has a negative weight (RED-19: prevents score suppression attacks).
|
||||
// SetRules installs a rule set, or installs nothing.
|
||||
//
|
||||
// Every rule is admitted first and the whole set is rejected if any rule fails.
|
||||
// A partially installed set is a control surface nobody can describe: the
|
||||
// operator believes the catalog is in force, and the difference is discoverable
|
||||
// only by reading startup logs.
|
||||
func (e *Engine) SetRules(rules []types.Rule) error {
|
||||
var errs []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)
|
||||
errs = append(errs, fmt.Errorf("rule %q: weight %v is negative, which would subtract from the risk score", r.ID, r.Weight))
|
||||
continue
|
||||
}
|
||||
if _, err := e.eval.Admit(r); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("rule set rejected, %d of %d rules cannot be evaluated: %w",
|
||||
len(errs), len(rules), errors.Join(errs...))
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.rules = rules
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rules returns a copy of the current rule set.
|
||||
// Rules returns a copy of the installed rule set.
|
||||
func (e *Engine) Rules() []types.Rule {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
@@ -50,24 +69,18 @@ func (e *Engine) Rules() []types.Rule {
|
||||
return out
|
||||
}
|
||||
|
||||
// Evaluator returns the underlying evaluator for function registration.
|
||||
func (e *Engine) Evaluator() *Evaluator {
|
||||
return e.evaluator
|
||||
}
|
||||
// Evaluator exposes the evaluator, for admitting a candidate rule before saving
|
||||
// it and for reporting the vocabulary this deployment can answer.
|
||||
func (e *Engine) Evaluator() *Evaluator { return e.eval }
|
||||
|
||||
// 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) {
|
||||
// Evaluate runs the rule set against a transaction and its customer, returning
|
||||
// the alerts raised, the aggregate risk score, and the action to take.
|
||||
func (e *Engine) Evaluate(ctx context.Context, tx types.Transaction, ent 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)
|
||||
hits := e.eval.EvalAll(ctx, rules, tx, ent)
|
||||
if len(hits) == 0 {
|
||||
return nil, 0, types.ActionAllow
|
||||
}
|
||||
@@ -83,36 +96,45 @@ func (e *Engine) Evaluate(tx types.Transaction, entity types.Entity) ([]types.Al
|
||||
TxID: tx.ID,
|
||||
RuleID: h.Rule.ID,
|
||||
RuleName: h.Rule.Name,
|
||||
Typology: h.Rule.Typology,
|
||||
Citations: h.Rule.Citations,
|
||||
Severity: h.Rule.Severity,
|
||||
Score: breakdown[h.Rule.ID],
|
||||
ScoreBreakdown: breakdown,
|
||||
ActionTaken: h.Rule.Action,
|
||||
ActionTaken: action(h),
|
||||
EvalErr: h.EvalErr,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
action := resolveAction(alerts)
|
||||
return alerts, score, action
|
||||
return alerts, score, resolve(alerts)
|
||||
}
|
||||
|
||||
// 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,
|
||||
// action is the response a hit calls for. A hit carrying an evaluation failure
|
||||
// goes to review whatever the rule would otherwise have done: the rule reached
|
||||
// no verdict, so neither blocking nor clearing is supportable and a person has
|
||||
// to look.
|
||||
func action(h types.RuleHit) string {
|
||||
if h.EvalErr != "" {
|
||||
return types.ActionReview
|
||||
}
|
||||
return h.Rule.Action
|
||||
}
|
||||
|
||||
best := types.ActionAllow
|
||||
bestP := 0
|
||||
// resolve picks the most restrictive action across a set of alerts.
|
||||
func resolve(alerts []types.Alert) string {
|
||||
rank := map[string]int{
|
||||
types.ActionAllow: 0,
|
||||
types.ActionFlag: 1,
|
||||
types.ActionReview: 2,
|
||||
types.ActionReport: 3,
|
||||
types.ActionBlock: 4,
|
||||
}
|
||||
best, bestRank := types.ActionAllow, 0
|
||||
for _, a := range alerts {
|
||||
if p, ok := priority[a.ActionTaken]; ok && p > bestP {
|
||||
best = a.ActionTaken
|
||||
bestP = p
|
||||
if r, ok := rank[a.ActionTaken]; ok && r > bestRank {
|
||||
best, bestRank = a.ActionTaken, r
|
||||
}
|
||||
}
|
||||
return best
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/aml/pkg/rules"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
func TestEngineEvaluateAllow(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx1",
|
||||
OrgID: "test",
|
||||
UserID: "u1",
|
||||
Notional: 100,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "u1",
|
||||
OrgID: "test",
|
||||
}
|
||||
|
||||
alerts, score, action := eng.Evaluate(tx, entity)
|
||||
if action != types.ActionAllow {
|
||||
t.Errorf("expected allow for small transaction, got %s", action)
|
||||
}
|
||||
if len(alerts) != 0 {
|
||||
t.Errorf("expected 0 alerts, got %d", len(alerts))
|
||||
}
|
||||
if score != 0 {
|
||||
t.Errorf("expected score 0, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEvaluateStructuring(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx2",
|
||||
OrgID: "test",
|
||||
UserID: "u1",
|
||||
Notional: 9500,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "u1",
|
||||
OrgID: "test",
|
||||
}
|
||||
|
||||
alerts, _, action := eng.Evaluate(tx, entity)
|
||||
// Structuring (flag) + Travel Rule (report) both fire at $9500.
|
||||
// Highest action wins: report > flag.
|
||||
if action != types.ActionReport {
|
||||
t.Errorf("expected report (travel rule outranks structuring flag), got %s", action)
|
||||
}
|
||||
|
||||
var structuringFired bool
|
||||
for _, a := range alerts {
|
||||
if a.RuleName == "Structuring" {
|
||||
structuringFired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !structuringFired {
|
||||
t.Error("expected Structuring rule to fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEvaluateCTR(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx3",
|
||||
OrgID: "test",
|
||||
UserID: "u1",
|
||||
Notional: 15000,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "u1",
|
||||
OrgID: "test",
|
||||
}
|
||||
|
||||
alerts, _, action := eng.Evaluate(tx, entity)
|
||||
|
||||
// CTR fires (report) and travel_rule fires (report), so action = report.
|
||||
if action != types.ActionReport {
|
||||
t.Errorf("expected report for CTR, got %s", action)
|
||||
}
|
||||
|
||||
var ctrFired bool
|
||||
for _, a := range alerts {
|
||||
if a.RuleName == "CTR Threshold" {
|
||||
ctrFired = true
|
||||
}
|
||||
}
|
||||
if !ctrFired {
|
||||
t.Error("expected CTR Threshold rule to fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEvaluatePEP(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx4",
|
||||
OrgID: "test",
|
||||
UserID: "u1",
|
||||
Notional: 15000,
|
||||
Currency: "USD",
|
||||
}
|
||||
entity := types.Entity{
|
||||
ID: "u1",
|
||||
OrgID: "test",
|
||||
PEP: true,
|
||||
}
|
||||
|
||||
alerts, _, _ := eng.Evaluate(tx, entity)
|
||||
|
||||
var pepFired bool
|
||||
for _, a := range alerts {
|
||||
if a.RuleName == "PEP Large Transaction" {
|
||||
pepFired = true
|
||||
}
|
||||
}
|
||||
if !pepFired {
|
||||
t.Error("expected PEP Large Transaction rule to fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSetRules(t *testing.T) {
|
||||
eng := New(nil)
|
||||
if len(eng.Rules()) != 0 {
|
||||
t.Fatal("expected 0 rules")
|
||||
}
|
||||
|
||||
if err := eng.SetRules(rules.StarterRules("test")); err != nil {
|
||||
t.Fatalf("SetRules failed: %v", err)
|
||||
}
|
||||
if len(eng.Rules()) != 23 {
|
||||
t.Errorf("expected 23 rules, got %d", len(eng.Rules()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetRulesRejectsNegativeWeight (RED-19) verifies that SetRules rejects
|
||||
// rules with negative weights, preventing score suppression attacks.
|
||||
func TestSetRulesRejectsNegativeWeight(t *testing.T) {
|
||||
eng := New(nil)
|
||||
err := eng.SetRules([]types.Rule{
|
||||
{ID: "good", Weight: 0.3, Enabled: true},
|
||||
{ID: "evil", Weight: -1.0, Enabled: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("RED-19: SetRules should reject negative weight, got nil error")
|
||||
}
|
||||
// Verify the rules were NOT set.
|
||||
if len(eng.Rules()) != 0 {
|
||||
t.Errorf("rules should remain empty after rejected SetRules, got %d", len(eng.Rules()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetRulesAcceptsZeroWeight verifies that weight=0 is allowed (disabled rule).
|
||||
func TestSetRulesAcceptsZeroWeight(t *testing.T) {
|
||||
eng := New(nil)
|
||||
err := eng.SetRules([]types.Rule{
|
||||
{ID: "zero", Weight: 0.0, Enabled: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRules should accept weight=0, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
actions []string
|
||||
want string
|
||||
}{
|
||||
{nil, types.ActionAllow},
|
||||
{[]string{types.ActionFlag}, types.ActionFlag},
|
||||
{[]string{types.ActionFlag, types.ActionReview}, types.ActionReview},
|
||||
{[]string{types.ActionReview, types.ActionBlock}, types.ActionBlock},
|
||||
{[]string{types.ActionReport, types.ActionBlock}, types.ActionBlock},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
alerts := make([]types.Alert, len(tt.actions))
|
||||
for i, a := range tt.actions {
|
||||
alerts[i] = types.Alert{ActionTaken: a}
|
||||
}
|
||||
got := resolveAction(alerts)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveAction(%v) = %s, want %s", tt.actions, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-192
@@ -1,262 +1,164 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/ast"
|
||||
"github.com/expr-lang/expr/parser"
|
||||
"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.
|
||||
// Evaluator compiles rule expressions and evaluates them against transactions.
|
||||
//
|
||||
// A rule is admitted only if it parses, names only terms in the vocabulary, uses
|
||||
// only terms whose evidence provider is configured, and yields a boolean. There
|
||||
// is no fallback for a term the deployment cannot answer. The alternative — a
|
||||
// term that returns a plausible constant when its provider is absent — produces
|
||||
// a rule that is present in the catalog, visible in the interface, reported to
|
||||
// the regulator, and incapable of ever firing.
|
||||
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
|
||||
p Providers
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*vm.Program
|
||||
}
|
||||
|
||||
// NewEvaluator creates an Evaluator with the default helper functions.
|
||||
func NewEvaluator() *Evaluator {
|
||||
return NewEvaluatorWithMaxCache(DefaultMaxCacheSize)
|
||||
// NewEvaluator builds an evaluator over the given evidence providers.
|
||||
func NewEvaluator(p Providers) *Evaluator {
|
||||
return &Evaluator{p: p, cache: make(map[string]*vm.Program)}
|
||||
}
|
||||
|
||||
// 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])
|
||||
// Vocabulary lists the terms this evaluator can answer, sorted. Terms whose
|
||||
// provider is not configured are omitted, so the list describes what this
|
||||
// deployment can actually detect rather than what the language could express.
|
||||
func (e *Evaluator) Vocabulary() []string {
|
||||
out := make([]string, 0, len(vocabulary))
|
||||
for term, cap := range vocabulary {
|
||||
if e.p.has(cap) {
|
||||
out = append(out, term)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
// terms collects the name of every function call in an expression.
|
||||
type terms struct{ names map[string]struct{} }
|
||||
|
||||
// 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)]
|
||||
func (t *terms) Visit(node *ast.Node) {
|
||||
call, ok := (*node).(*ast.CallNode)
|
||||
if !ok {
|
||||
return notional // unknown currency → assume raw = conservative
|
||||
return
|
||||
}
|
||||
if id, ok := call.Callee.(*ast.IdentifierNode); ok {
|
||||
t.names[id.Value] = struct{}{}
|
||||
}
|
||||
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)
|
||||
|
||||
// Admit compiles a rule and reports why it cannot be installed, if it cannot.
|
||||
// The compiled program is cached, so admitting a rule also prepares it to run.
|
||||
func (e *Evaluator) Admit(r types.Rule) (*vm.Program, error) {
|
||||
key := r.ID + ":" + r.DSL
|
||||
e.mu.RLock()
|
||||
if prog, ok := e.cache[key]; ok {
|
||||
e.mu.RUnlock()
|
||||
prog, ok := e.cache[key]
|
||||
e.mu.RUnlock()
|
||||
if ok {
|
||||
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))
|
||||
if strings.TrimSpace(r.DSL) == "" {
|
||||
return nil, fmt.Errorf("rule %q: expression is empty", r.ID)
|
||||
}
|
||||
|
||||
prog, err := expr.Compile(r.DSL, opts...)
|
||||
tree, err := parser.Parse(r.DSL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile rule %q: %w", r.Name, err)
|
||||
return nil, fmt.Errorf("rule %q: %w", r.ID, err)
|
||||
}
|
||||
|
||||
seen := &terms{names: make(map[string]struct{})}
|
||||
ast.Walk(&tree.Node, seen)
|
||||
|
||||
var missing []string
|
||||
for name := range seen.names {
|
||||
cap, known := vocabulary[name]
|
||||
if !known {
|
||||
continue // not an evidence term; the compile below validates it
|
||||
}
|
||||
if !e.p.has(cap) {
|
||||
missing = append(missing, fmt.Sprintf("%s (needs %s)", name, cap))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return nil, fmt.Errorf("rule %q: %w for %s", r.ID, ErrNoProvider, strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
prog, err = expr.Compile(r.DSL, expr.Env(&scope{}), expr.AsBool())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule %q: %w", r.ID, 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)
|
||||
// Eval runs one rule against a transaction.
|
||||
func (e *Evaluator) Eval(ctx context.Context, r types.Rule, tx types.Transaction, ent types.Entity) (bool, error) {
|
||||
prog, err := e.Admit(r)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
env := map[string]interface{}{
|
||||
"tx": ctx.Tx,
|
||||
"entity": ctx.Entity,
|
||||
}
|
||||
|
||||
out, err := expr.Run(prog, env)
|
||||
out, err := expr.Run(prog, newScope(ctx, e.p, tx, ent))
|
||||
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)
|
||||
return false, fmt.Errorf("rule %q: %w", r.ID, err)
|
||||
}
|
||||
// Compiling with AsBool guarantees the type.
|
||||
return out.(bool), nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// EvalAll evaluates every enabled, in-scope rule and returns the hits.
|
||||
//
|
||||
// A rule that fails at evaluation time becomes a hit carrying the failure. The
|
||||
// rule set was admitted, so a failure here is a data or storage fault — an
|
||||
// unknown currency, an absent subject, an unreachable store — and the
|
||||
// transaction it happened on is precisely the one nobody has assessed. Passing
|
||||
// it through as clean would turn an outage into an approval.
|
||||
func (e *Evaluator) EvalAll(ctx context.Context, rules []types.Rule, tx types.Transaction, ent types.Entity) []types.RuleHit {
|
||||
var hits []types.RuleHit
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
if !matchesFilter(r.JurisdictionFilter, ctx.Entity.Jurisdiction) {
|
||||
if !scoped(r.JurisdictionFilter, ent.Jurisdiction) {
|
||||
continue
|
||||
}
|
||||
if !matchesFilter(r.AssetClassFilter, ctx.Tx.AssetClass) {
|
||||
if !scoped(r.AssetClassFilter, tx.AssetClass) {
|
||||
continue
|
||||
}
|
||||
|
||||
match, err := e.Eval(r, ctx)
|
||||
match, err := e.Eval(ctx, r, tx, ent)
|
||||
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(),
|
||||
})
|
||||
hits = append(hits, types.RuleHit{Rule: r, Match: true, EvalErr: err.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
if match {
|
||||
hits = append(hits, types.RuleHit{
|
||||
Rule: r,
|
||||
Match: true,
|
||||
})
|
||||
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 {
|
||||
// scoped reports whether a rule restricted to a set of values applies here. An
|
||||
// empty restriction applies to everything.
|
||||
func scoped(filter []string, val string) bool {
|
||||
if len(filter) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
func TestEvalSimpleBool(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "test",
|
||||
Name: "test",
|
||||
DSL: `tx.Notional > 10000`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: 15000},
|
||||
}
|
||||
|
||||
match, err := eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if !match {
|
||||
t.Error("expected match for notional 15000 > 10000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalNotMatched(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "test",
|
||||
Name: "test",
|
||||
DSL: `tx.Notional > 10000`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: 5000},
|
||||
}
|
||||
|
||||
match, err := eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("expected no match for notional 5000 < 10000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCompoundCondition(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "structuring",
|
||||
Name: "structuring",
|
||||
DSL: `tx.Notional >= 9000 && tx.Notional < 10000`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
notional float64
|
||||
want bool
|
||||
}{
|
||||
{9500, true},
|
||||
{9000, true},
|
||||
{8999, false},
|
||||
{10000, false},
|
||||
{10001, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: tt.notional},
|
||||
}
|
||||
match, err := eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error for notional %f: %v", tt.notional, err)
|
||||
}
|
||||
if match != tt.want {
|
||||
t.Errorf("notional %f: got match=%v, want %v", tt.notional, match, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalEntityField(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "pep",
|
||||
Name: "pep",
|
||||
DSL: `entity.PEP == true && tx.Notional > 10000`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: 15000},
|
||||
Entity: types.Entity{PEP: true},
|
||||
}
|
||||
|
||||
match, err := eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if !match {
|
||||
t.Error("expected match for PEP with notional 15000")
|
||||
}
|
||||
|
||||
ctx.Entity.PEP = false
|
||||
match, err = eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("expected no match for non-PEP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalStringComparison(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "ctr",
|
||||
Name: "ctr",
|
||||
DSL: `tx.Notional > 10000 && tx.Currency == "USD"`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: 15000, Currency: "USD"},
|
||||
}
|
||||
match, err := eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if !match {
|
||||
t.Error("expected match for USD 15000")
|
||||
}
|
||||
|
||||
ctx.Tx.Currency = "EUR"
|
||||
match, err = eval.Eval(rule, ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval error: %v", err)
|
||||
}
|
||||
if match {
|
||||
t.Error("expected no match for EUR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalInvalidDSL(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "bad",
|
||||
Name: "bad",
|
||||
DSL: `this is not valid syntax !!!`,
|
||||
Enabled: true,
|
||||
}
|
||||
_, err := eval.Eval(rule, types.EvalContext{})
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid DSL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalAllFilters(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rules := []types.Rule{
|
||||
{
|
||||
ID: "r1", Name: "r1", DSL: `tx.Notional > 100`,
|
||||
Enabled: true, JurisdictionFilter: []string{"US"},
|
||||
},
|
||||
{
|
||||
ID: "r2", Name: "r2", DSL: `tx.Notional > 100`,
|
||||
Enabled: true, JurisdictionFilter: []string{"UK"},
|
||||
},
|
||||
{
|
||||
ID: "r3", Name: "r3", DSL: `tx.Notional > 100`,
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := types.EvalContext{
|
||||
Tx: types.Transaction{Notional: 200},
|
||||
Entity: types.Entity{Jurisdiction: "US"},
|
||||
}
|
||||
|
||||
hits := eval.EvalAll(rules, ctx)
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("expected 1 hit, got %d", len(hits))
|
||||
}
|
||||
if hits[0].Rule.ID != "r1" {
|
||||
t.Errorf("expected r1, got %s", hits[0].Rule.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesFilter(t *testing.T) {
|
||||
if !matchesFilter(nil, "anything") {
|
||||
t.Error("empty filter should match everything")
|
||||
}
|
||||
if !matchesFilter([]string{"US", "UK"}, "us") {
|
||||
t.Error("case-insensitive match should work")
|
||||
}
|
||||
if matchesFilter([]string{"US", "UK"}, "DE") {
|
||||
t.Error("non-matching should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCaching(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
rule := types.Rule{
|
||||
ID: "cached",
|
||||
Name: "cached",
|
||||
DSL: `tx.Notional > 0`,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
// First compile.
|
||||
_, err := eval.Compile(rule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Second compile — should hit cache.
|
||||
_, err = eval.Compile(rule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if eval.CacheLen() != 1 {
|
||||
t.Errorf("cache should have 1 entry, got %d", eval.CacheLen())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluatorCacheEviction (RED-15) verifies that the evaluator cache evicts
|
||||
// entries when it exceeds maxCacheSize.
|
||||
func TestEvaluatorCacheEviction(t *testing.T) {
|
||||
eval := NewEvaluatorWithMaxCache(100)
|
||||
|
||||
// Compile 200 unique rules.
|
||||
for i := 0; i < 200; i++ {
|
||||
rule := types.Rule{
|
||||
ID: fmt.Sprintf("r%d", i),
|
||||
Name: fmt.Sprintf("r%d", i),
|
||||
DSL: fmt.Sprintf("tx.Notional > %d", i),
|
||||
Enabled: true,
|
||||
}
|
||||
_, err := eval.Compile(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("compile r%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
size := eval.CacheLen()
|
||||
if size > 100 {
|
||||
t.Errorf("RED-15: cache size = %d, want <= 100 after eviction", size)
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegisterStoreHelpers replaces the default stub helper functions with
|
||||
// real implementations backed by a TransactionStore. Call this at startup
|
||||
// after creating the engine and store.
|
||||
func RegisterStoreHelpers(e *Engine, store TransactionStore) {
|
||||
eval := e.Evaluator()
|
||||
|
||||
eval.RegisterFunc("count_last_24h", func(params ...any) (any, error) {
|
||||
return countLastNh(store, params, 24)
|
||||
})
|
||||
|
||||
eval.RegisterFunc("sum_last_24h", func(params ...any) (any, error) {
|
||||
return sumLastNh(store, params, 24)
|
||||
})
|
||||
|
||||
eval.RegisterFunc("sum_last_30d", func(params ...any) (any, error) {
|
||||
return sumLastNd(store, params, 30)
|
||||
})
|
||||
|
||||
eval.RegisterFunc("first_tx", func(params ...any) (any, error) {
|
||||
if len(params) < 1 {
|
||||
return false, fmt.Errorf("first_tx requires userID")
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("first_tx: userID must be string")
|
||||
}
|
||||
return store.FirstTransaction(context.Background(), userID)
|
||||
})
|
||||
|
||||
eval.RegisterFunc("first_counterparty", func(params ...any) (any, error) {
|
||||
if len(params) < 2 {
|
||||
return false, fmt.Errorf("first_counterparty requires userID and counterparty")
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("first_counterparty: userID must be string")
|
||||
}
|
||||
counterparty, ok := params[1].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("first_counterparty: counterparty must be string")
|
||||
}
|
||||
return store.FirstCounterparty(context.Background(), userID, counterparty)
|
||||
})
|
||||
|
||||
eval.RegisterFunc("last_tx_age", func(params ...any) (any, error) {
|
||||
if len(params) < 1 {
|
||||
return 0, fmt.Errorf("last_tx_age requires userID")
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("last_tx_age: userID must be string")
|
||||
}
|
||||
lastTime, err := store.LastTransactionTime(context.Background(), userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if lastTime.IsZero() {
|
||||
return 999, nil // Never transacted = very old
|
||||
}
|
||||
days := int(time.Since(lastTime).Hours() / 24)
|
||||
return days, nil
|
||||
})
|
||||
|
||||
eval.RegisterFunc("is_round_trip", func(params ...any) (any, error) {
|
||||
if len(params) < 2 {
|
||||
return false, fmt.Errorf("is_round_trip requires userID and window")
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("is_round_trip: userID must be string")
|
||||
}
|
||||
windowStr, ok := params[1].(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("is_round_trip: window must be string")
|
||||
}
|
||||
window, err := parseDuration(windowStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("is_round_trip: %w", err)
|
||||
}
|
||||
return store.RoundTrip(context.Background(), userID, window)
|
||||
})
|
||||
}
|
||||
|
||||
// countLastNh counts transactions in the last N hours for a user.
|
||||
func countLastNh(store TransactionStore, params []any, hours int) (any, error) {
|
||||
if len(params) < 1 {
|
||||
return 0, fmt.Errorf("count_last_%dh requires userID", hours)
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("count_last_%dh: userID must be string", hours)
|
||||
}
|
||||
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
return store.CountByUser(context.Background(), userID, since)
|
||||
}
|
||||
|
||||
// sumLastNh sums notional in the last N hours for a user.
|
||||
func sumLastNh(store TransactionStore, params []any, hours int) (any, error) {
|
||||
if len(params) < 1 {
|
||||
return 0.0, fmt.Errorf("sum_last_%dh requires userID", hours)
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return 0.0, fmt.Errorf("sum_last_%dh: userID must be string", hours)
|
||||
}
|
||||
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
return store.SumNotionalByUser(context.Background(), userID, since)
|
||||
}
|
||||
|
||||
// sumLastNd sums notional in the last N days for a user.
|
||||
func sumLastNd(store TransactionStore, params []any, days int) (any, error) {
|
||||
if len(params) < 1 {
|
||||
return 0.0, fmt.Errorf("sum_last_%dd requires userID", days)
|
||||
}
|
||||
userID, ok := params[0].(string)
|
||||
if !ok {
|
||||
return 0.0, fmt.Errorf("sum_last_%dd: userID must be string", days)
|
||||
}
|
||||
since := time.Now().UTC().AddDate(0, 0, -days)
|
||||
return store.SumNotionalByUser(context.Background(), userID, since)
|
||||
}
|
||||
|
||||
// parseDuration parses human-friendly duration strings like "24h", "7d", "30d".
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if len(s) < 2 {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
unit := s[len(s)-1]
|
||||
numStr := s[:len(s)-1]
|
||||
var num int
|
||||
_, err := fmt.Sscanf(numStr, "%d", &num)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration number %q: %w", numStr, err)
|
||||
}
|
||||
switch unit {
|
||||
case 'h':
|
||||
return time.Duration(num) * time.Hour, nil
|
||||
case 'd':
|
||||
return time.Duration(num) * 24 * time.Hour, nil
|
||||
case 'm':
|
||||
return time.Duration(num) * time.Minute, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown duration unit %q (use h/d/m)", string(unit))
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
func seedStore(t *testing.T) (*MemoryStore, *Engine) {
|
||||
t.Helper()
|
||||
store := NewMemoryStore()
|
||||
|
||||
// Seed data: 3 transactions for user-A
|
||||
now := time.Now().UTC()
|
||||
txs := []types.Transaction{
|
||||
{
|
||||
ID: "tx-1", UserID: "user-A", Notional: 5000, Currency: "USD",
|
||||
Symbol: "BTC", Side: "buy", Counterparty: "exchange-1",
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "tx-2", UserID: "user-A", Notional: 15000, Currency: "USD",
|
||||
Symbol: "BTC", Side: "sell", Counterparty: "exchange-2",
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
ID: "tx-3", UserID: "user-A", Notional: 8000, Currency: "USD",
|
||||
Symbol: "ETH", Side: "buy", Counterparty: "exchange-1",
|
||||
CreatedAt: now.Add(-30 * time.Minute),
|
||||
},
|
||||
}
|
||||
for _, tx := range txs {
|
||||
if err := store.Save(context.Background(), tx); err != nil {
|
||||
t.Fatalf("seed save: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rules := []types.Rule{
|
||||
{
|
||||
ID: "test-rule", Name: "test", DSL: "true",
|
||||
Enabled: true, Weight: 1.0, Action: types.ActionFlag,
|
||||
},
|
||||
}
|
||||
eng := New(rules)
|
||||
RegisterStoreHelpers(eng, store)
|
||||
|
||||
return store, eng
|
||||
}
|
||||
|
||||
func TestHelpers_CountLast24h(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["count_last_24h"]
|
||||
result, err := fn("user-A")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
count, ok := result.(int)
|
||||
if !ok {
|
||||
t.Fatalf("expected int, got %T", result)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 transactions, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_CountLast24h_NoTxs(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["count_last_24h"]
|
||||
result, err := fn("user-B")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
count := result.(int)
|
||||
if count != 0 {
|
||||
t.Fatalf("expected 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_SumLast24h(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["sum_last_24h"]
|
||||
result, err := fn("user-A")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
sum, ok := result.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected float64, got %T", result)
|
||||
}
|
||||
if sum != 28000 { // 5000 + 15000 + 8000
|
||||
t.Fatalf("expected 28000, got %f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_SumLast30d(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["sum_last_30d"]
|
||||
result, err := fn("user-A")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
sum := result.(float64)
|
||||
if sum != 28000 {
|
||||
t.Fatalf("expected 28000, got %f", sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_FirstTx(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["first_tx"]
|
||||
|
||||
// user-A has transactions
|
||||
result, err := fn("user-A")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != false {
|
||||
t.Fatal("expected false (not first tx)")
|
||||
}
|
||||
|
||||
// user-B has no transactions
|
||||
result, err = fn("user-B")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != true {
|
||||
t.Fatal("expected true (first tx)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_FirstCounterparty(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["first_counterparty"]
|
||||
|
||||
// user-A has transacted with exchange-1
|
||||
result, err := fn("user-A", "exchange-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != false {
|
||||
t.Fatal("expected false (not first counterparty)")
|
||||
}
|
||||
|
||||
// user-A has NOT transacted with exchange-3
|
||||
result, err = fn("user-A", "exchange-3")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != true {
|
||||
t.Fatal("expected true (first counterparty)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_LastTxAge(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["last_tx_age"]
|
||||
|
||||
// user-A's last tx was ~30 min ago = 0 days
|
||||
result, err := fn("user-A")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
days := result.(int)
|
||||
if days != 0 {
|
||||
t.Fatalf("expected 0 days (recent), got %d", days)
|
||||
}
|
||||
|
||||
// user-B has no transactions = 999 days (sentinel)
|
||||
result, err = fn("user-B")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
days = result.(int)
|
||||
if days != 999 {
|
||||
t.Fatalf("expected 999 days (never transacted), got %d", days)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_IsRoundTrip(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
fn := eval.funcs["is_round_trip"]
|
||||
|
||||
// user-A bought and sold BTC within 24h
|
||||
result, err := fn("user-A", "24h")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != true {
|
||||
t.Fatal("expected true (round trip detected)")
|
||||
}
|
||||
|
||||
// user-B has no transactions
|
||||
result, err = fn("user-B", "24h")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.(bool) != false {
|
||||
t.Fatal("expected false (no round trip)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_MissingParams(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fn string
|
||||
args []any
|
||||
}{
|
||||
{"count_last_24h no args", "count_last_24h", nil},
|
||||
{"sum_last_24h no args", "sum_last_24h", nil},
|
||||
{"sum_last_30d no args", "sum_last_30d", nil},
|
||||
{"first_tx no args", "first_tx", nil},
|
||||
{"first_counterparty no args", "first_counterparty", nil},
|
||||
{"first_counterparty one arg", "first_counterparty", []any{"user-A"}},
|
||||
{"last_tx_age no args", "last_tx_age", nil},
|
||||
{"is_round_trip no args", "is_round_trip", nil},
|
||||
{"is_round_trip one arg", "is_round_trip", []any{"user-A"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fn := eval.funcs[tt.fn]
|
||||
_, err := fn(tt.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s with args %v", tt.fn, tt.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpers_InvalidTypes(t *testing.T) {
|
||||
_, eng := seedStore(t)
|
||||
eval := eng.Evaluator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fn string
|
||||
args []any
|
||||
}{
|
||||
{"count_last_24h int arg", "count_last_24h", []any{123}},
|
||||
{"first_tx int arg", "first_tx", []any{123}},
|
||||
{"first_counterparty int,string", "first_counterparty", []any{123, "cp"}},
|
||||
{"first_counterparty string,int", "first_counterparty", []any{"uid", 123}},
|
||||
{"last_tx_age int arg", "last_tx_age", []any{123}},
|
||||
{"is_round_trip int,string", "is_round_trip", []any{123, "24h"}},
|
||||
{"is_round_trip string,int", "is_round_trip", []any{"uid", 123}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fn := eval.funcs[tt.fn]
|
||||
_, err := fn(tt.args...)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %s with args %v", tt.fn, tt.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"24h", 24 * time.Hour, false},
|
||||
{"7d", 7 * 24 * time.Hour, false},
|
||||
{"30d", 30 * 24 * time.Hour, false},
|
||||
{"15m", 15 * time.Minute, false},
|
||||
{"1h", 1 * time.Hour, false},
|
||||
{"", 0, true},
|
||||
{"x", 0, true},
|
||||
{"24x", 0, true},
|
||||
{"abc", 0, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseDuration(tt.input)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.expected {
|
||||
t.Fatalf("expected %v, got %v", tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStore_Interface(t *testing.T) {
|
||||
var _ TransactionStore = (*MemoryStore)(nil)
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/aml/pkg/cases"
|
||||
"github.com/luxfi/aml/pkg/rules"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// TestIntegrationStructuringFlow is the integration test from the spec:
|
||||
// POST a transaction with notional: 9500 and verify structuring fires,
|
||||
// travel_rule fires, case opens on block/report, webhook queued.
|
||||
func TestIntegrationStructuringFlow(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test-org"))
|
||||
caseStore := cases.NewStore()
|
||||
|
||||
// Simulate 3 transactions from the same user at ~$9500 each.
|
||||
txs := []types.Transaction{
|
||||
{ID: "tx1", OrgID: "test-org", UserID: "u-structurer", Notional: 9200, Currency: "USD"},
|
||||
{ID: "tx2", OrgID: "test-org", UserID: "u-structurer", Notional: 9400, Currency: "USD"},
|
||||
{ID: "tx3", OrgID: "test-org", UserID: "u-structurer", Notional: 9500, Currency: "USD"},
|
||||
}
|
||||
|
||||
entity := types.Entity{
|
||||
ID: "u-structurer",
|
||||
OrgID: "test-org",
|
||||
Name: "Suspicious User",
|
||||
}
|
||||
|
||||
for _, tx := range txs {
|
||||
alerts, score, action := eng.Evaluate(tx, entity)
|
||||
|
||||
// Each tx should fire at least Structuring (flag) + Travel Rule (report).
|
||||
if len(alerts) == 0 {
|
||||
t.Fatalf("tx %s: expected alerts, got none", tx.ID)
|
||||
}
|
||||
|
||||
// Score should be non-zero.
|
||||
if score == 0 {
|
||||
t.Fatalf("tx %s: expected non-zero score", tx.ID)
|
||||
}
|
||||
|
||||
// Action should be at least report (travel rule outranks structuring flag).
|
||||
if action != types.ActionReport {
|
||||
t.Fatalf("tx %s: expected action=report, got %s", tx.ID, action)
|
||||
}
|
||||
|
||||
// Verify structuring rule fired.
|
||||
var structuringFired bool
|
||||
for _, a := range alerts {
|
||||
if a.RuleName == "Structuring" {
|
||||
structuringFired = true
|
||||
}
|
||||
}
|
||||
if !structuringFired {
|
||||
t.Fatalf("tx %s: Structuring rule did not fire", tx.ID)
|
||||
}
|
||||
|
||||
// Auto-create case on report.
|
||||
alertIDs := make([]string, len(alerts))
|
||||
for i, a := range alerts {
|
||||
alertIDs[i] = a.ID
|
||||
}
|
||||
if action == types.ActionBlock || action == types.ActionReport {
|
||||
c := caseStore.Create("test-org", types.SeverityCritical, alertIDs, []string{tx.UserID})
|
||||
if c.ID == "" {
|
||||
t.Fatal("case should have an ID")
|
||||
}
|
||||
if c.Status != types.CaseOpen {
|
||||
t.Fatalf("case status = %s, want open", c.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify cases were created.
|
||||
allCases := caseStore.List("test-org", "")
|
||||
if len(allCases) != 3 {
|
||||
t.Errorf("expected 3 cases (one per tx), got %d", len(allCases))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationSanctionedBlock verifies a sanctioned jurisdiction blocks.
|
||||
func TestIntegrationSanctionedBlock(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test-org"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx-iran",
|
||||
OrgID: "test-org",
|
||||
UserID: "u-iran",
|
||||
Notional: 1000,
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
entity := types.Entity{
|
||||
ID: "u-iran",
|
||||
OrgID: "test-org",
|
||||
Name: "Iranian Entity",
|
||||
Jurisdiction: "IR",
|
||||
}
|
||||
|
||||
alerts, _, action := eng.Evaluate(tx, entity)
|
||||
|
||||
if action != types.ActionBlock {
|
||||
t.Errorf("sanctioned jurisdiction should block, got %s", action)
|
||||
}
|
||||
|
||||
var sanctionedFired bool
|
||||
for _, a := range alerts {
|
||||
if a.RuleName == "Sanctioned Jurisdiction" {
|
||||
sanctionedFired = true
|
||||
}
|
||||
}
|
||||
if !sanctionedFired {
|
||||
t.Error("Sanctioned Jurisdiction rule should fire for IR")
|
||||
}
|
||||
|
||||
if len(alerts) == 0 {
|
||||
t.Fatal("expected at least one alert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCleanTransaction verifies clean traffic gets through.
|
||||
func TestIntegrationCleanTransaction(t *testing.T) {
|
||||
eng := New(rules.StarterRules("test-org"))
|
||||
|
||||
tx := types.Transaction{
|
||||
ID: "tx-clean",
|
||||
OrgID: "test-org",
|
||||
UserID: "u-good",
|
||||
Notional: 500,
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
entity := types.Entity{
|
||||
ID: "u-good",
|
||||
OrgID: "test-org",
|
||||
Name: "Good Customer",
|
||||
Jurisdiction: "US",
|
||||
KYCLevel: 3,
|
||||
}
|
||||
|
||||
alerts, score, action := eng.Evaluate(tx, entity)
|
||||
|
||||
if action != types.ActionAllow {
|
||||
t.Errorf("clean transaction should allow, got %s", action)
|
||||
}
|
||||
if len(alerts) != 0 {
|
||||
t.Errorf("clean transaction should have 0 alerts, got %d", len(alerts))
|
||||
}
|
||||
if score != 0 {
|
||||
t.Errorf("clean transaction score should be 0, got %f", score)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/history"
|
||||
)
|
||||
|
||||
// Screening classes. Sanctions and PEP screening share one mechanism — match a
|
||||
// name against a curated list — but they answer different questions and carry
|
||||
// different consequences, so a rule names which it is asking.
|
||||
const (
|
||||
ClassSanctions = "sanctions"
|
||||
ClassPEP = "pep"
|
||||
)
|
||||
|
||||
// Jurisdiction risk tiers, mirroring the two lists a standard-setter publishes:
|
||||
// jurisdictions calling for countermeasures, and jurisdictions under increased
|
||||
// monitoring. A rule distinguishes them because the required response differs.
|
||||
const (
|
||||
TierNone = ""
|
||||
TierMonitoring = "monitoring"
|
||||
TierAction = "action"
|
||||
)
|
||||
|
||||
// Hit is the outcome of screening a name against a list.
|
||||
type Hit struct {
|
||||
Matched bool `json:"matched"`
|
||||
Score float64 `json:"score"`
|
||||
List string `json:"list,omitempty"`
|
||||
EntryID string `json:"entry_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Screen matches a name against a screening list.
|
||||
type Screen interface {
|
||||
Hit(ctx context.Context, name, class string) (Hit, error)
|
||||
}
|
||||
|
||||
// Reference answers questions about published reference data.
|
||||
//
|
||||
// Jurisdiction returns the risk tier for an ISO 3166-1 alpha-2 country code. It
|
||||
// returns an error when no list has been loaded: an empty list would answer
|
||||
// "not listed" for every country on earth, which is the same silent no-op as
|
||||
// having no control at all.
|
||||
type Reference interface {
|
||||
Jurisdiction(code string) (string, error)
|
||||
}
|
||||
|
||||
// Rate converts an amount into USD.
|
||||
//
|
||||
// It returns an error for a currency it does not know rather than passing the
|
||||
// amount through unchanged. Treating an unknown amount as already-USD moves
|
||||
// every threshold by the exchange rate: 10,000 units of a currency worth three
|
||||
// dollars each is a 30,000-dollar transaction, and passing it through as 10,000
|
||||
// leaves it exactly on the wrong side of a 10,000-dollar threshold.
|
||||
type Rate interface {
|
||||
USD(ctx context.Context, amount float64, currency string) (float64, error)
|
||||
}
|
||||
|
||||
// Providers are the evidence sources the rule vocabulary is built from.
|
||||
//
|
||||
// A rule may only be admitted if every provider its expression needs is
|
||||
// present. Nothing here has a default: a missing provider is a configuration
|
||||
// error surfaced when rules are installed, not a value invented at evaluation
|
||||
// time.
|
||||
type Providers struct {
|
||||
History history.Store
|
||||
Screen Screen
|
||||
Reference Reference
|
||||
Rate Rate
|
||||
|
||||
// Now supplies the evaluation instant. Tests set it; production leaves it nil
|
||||
// and gets the wall clock.
|
||||
Now func() time.Time
|
||||
|
||||
// Zone is the institution's business day, used for calendar-day aggregation.
|
||||
// Nil means UTC.
|
||||
Zone *time.Location
|
||||
}
|
||||
|
||||
// capability names the evidence source a vocabulary term needs.
|
||||
type capability string
|
||||
|
||||
const (
|
||||
capHistory capability = "history"
|
||||
capScreen capability = "screen"
|
||||
capReference capability = "reference"
|
||||
capRate capability = "rate"
|
||||
)
|
||||
|
||||
// vocabulary maps each term a rule expression may call to the provider that
|
||||
// answers it. A term absent from this table is not part of the language, and a
|
||||
// term present here cannot be used unless its provider is configured.
|
||||
//
|
||||
// This table is the single place the language is defined. Adding a scope method
|
||||
// without adding it here makes the method unreachable from a rule, which the
|
||||
// vocabulary test detects.
|
||||
var vocabulary = map[string]capability{
|
||||
"Count": capHistory,
|
||||
"Sum": capHistory,
|
||||
"Max": capHistory,
|
||||
"Day": capHistory,
|
||||
"Distinct": capHistory,
|
||||
"Seen": capHistory,
|
||||
"Structured": capHistory,
|
||||
"InOut": capHistory,
|
||||
"Dormant": capHistory,
|
||||
"Round": capHistory,
|
||||
"Deviation": capHistory,
|
||||
"Screened": capScreen,
|
||||
"Tier": capReference,
|
||||
"USD": capRate,
|
||||
"Near": capRate,
|
||||
}
|
||||
|
||||
func (p Providers) has(c capability) bool {
|
||||
switch c {
|
||||
case capHistory:
|
||||
return p.History != nil
|
||||
case capScreen:
|
||||
return p.Screen != nil
|
||||
case capReference:
|
||||
return p.Reference != nil
|
||||
case capRate:
|
||||
return p.Rate != nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrNoProvider reports a rule that names evidence the deployment cannot supply.
|
||||
var ErrNoProvider = errors.New("no provider")
|
||||
|
||||
func (p Providers) now() time.Time {
|
||||
if p.Now != nil {
|
||||
return p.Now()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (p Providers) zone() *time.Location {
|
||||
if p.Zone != nil {
|
||||
return p.Zone
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/history"
|
||||
"github.com/luxfi/aml/pkg/measure"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// scope is the environment a rule expression is evaluated against. Its exported
|
||||
// fields and methods are the whole language: Tx and Entity are the transaction
|
||||
// under evaluation and its customer, and the methods are the evidence terms
|
||||
// listed in the vocabulary table.
|
||||
//
|
||||
// A fresh scope is built for each transaction, which is what lets the methods
|
||||
// hold per-transaction state — notably the window cache, so a rule set asking
|
||||
// about the same subject and lookback ten times issues one query.
|
||||
type scope struct {
|
||||
Tx types.Transaction
|
||||
Entity types.Entity
|
||||
|
||||
p Providers
|
||||
ctx context.Context
|
||||
cache map[string][]history.Event
|
||||
}
|
||||
|
||||
// newScope builds the evaluation environment for one transaction.
|
||||
func newScope(ctx context.Context, p Providers, tx types.Transaction, ent types.Entity) *scope {
|
||||
return &scope{
|
||||
Tx: tx,
|
||||
Entity: ent,
|
||||
p: p,
|
||||
ctx: ctx,
|
||||
cache: make(map[string][]history.Event, 4),
|
||||
}
|
||||
}
|
||||
|
||||
// subject resolves a subject kind to the identifier carried by this transaction.
|
||||
//
|
||||
// An empty identifier is an error, never an empty history. A transaction with no
|
||||
// device fingerprint must not quietly satisfy a rule that counts transactions
|
||||
// per device: that would report "nothing to see" for precisely the transactions
|
||||
// that withheld the evidence. Rules that aggregate over an optional field guard
|
||||
// on its presence, so the error surfaces at authoring time rather than in
|
||||
// production silence.
|
||||
func (s *scope) subject(kind string) (history.Subject, error) {
|
||||
var id string
|
||||
switch kind {
|
||||
case history.SubjectUser:
|
||||
id = s.Tx.UserID
|
||||
case history.SubjectAccount:
|
||||
id = s.Tx.AccountID
|
||||
case history.SubjectCounterparty:
|
||||
id = s.Tx.Counterparty
|
||||
case history.SubjectDevice:
|
||||
id = s.Tx.DeviceFingerprint
|
||||
case history.SubjectAddress:
|
||||
id = s.Tx.IPAddress
|
||||
default:
|
||||
return history.Subject{}, fmt.Errorf("unknown subject %q: want one of %s, %s, %s, %s, %s",
|
||||
kind, history.SubjectUser, history.SubjectAccount, history.SubjectCounterparty,
|
||||
history.SubjectDevice, history.SubjectAddress)
|
||||
}
|
||||
if id == "" {
|
||||
return history.Subject{}, fmt.Errorf("subject %q is empty on transaction %s", kind, s.Tx.ID)
|
||||
}
|
||||
return history.Subject{OrgID: s.Tx.OrgID, Kind: kind, ID: id}, nil
|
||||
}
|
||||
|
||||
// window fetches and memoises the subject's events over the lookback.
|
||||
func (s *scope) window(kind, lookback string) ([]history.Event, error) {
|
||||
d, err := parseWindow(lookback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subj, err := s.subject(kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := kind + "/" + lookback
|
||||
if evs, ok := s.cache[key]; ok {
|
||||
return evs, nil
|
||||
}
|
||||
evs, err := s.p.History.Window(s.ctx, subj, d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("history window %s over %s: %w", kind, lookback, err)
|
||||
}
|
||||
s.cache[key] = evs
|
||||
return evs, nil
|
||||
}
|
||||
|
||||
// parseWindow reads a lookback written as an integer and a unit: m, h, or d.
|
||||
//
|
||||
// It rejects anything else rather than assuming a unit. A rule reading
|
||||
// Count("user", "7") is ambiguous between seven minutes and seven days, and a
|
||||
// monitoring window off by three orders of magnitude is a control that does not
|
||||
// work while appearing to.
|
||||
func parseWindow(s string) (time.Duration, error) {
|
||||
if len(s) < 2 {
|
||||
return 0, fmt.Errorf("window %q: want an integer and a unit, as in 24h or 7d", s)
|
||||
}
|
||||
n, err := strconv.Atoi(s[:len(s)-1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("window %q: %q is not an integer", s, s[:len(s)-1])
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("window %q: must be positive", s)
|
||||
}
|
||||
switch s[len(s)-1] {
|
||||
case 'm':
|
||||
return time.Duration(n) * time.Minute, nil
|
||||
case 'h':
|
||||
return time.Duration(n) * time.Hour, nil
|
||||
case 'd':
|
||||
return time.Duration(n) * 24 * time.Hour, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("window %q: unit %q is not m, h, or d", s, s[len(s)-1:])
|
||||
}
|
||||
}
|
||||
|
||||
// dimension names the event field a distinct-count or membership test groups by.
|
||||
func dimension(name string) (func(history.Event) string, error) {
|
||||
switch name {
|
||||
case "counterparty":
|
||||
return func(e history.Event) string { return e.Counterparty }, nil
|
||||
case "account":
|
||||
return func(e history.Event) string { return e.Account }, nil
|
||||
case "device":
|
||||
return func(e history.Event) string { return e.Device }, nil
|
||||
case "address":
|
||||
return func(e history.Event) string { return e.Address }, nil
|
||||
case "jurisdiction":
|
||||
return func(e history.Event) string { return e.Jurisdiction }, nil
|
||||
case "currency":
|
||||
return func(e history.Event) string { return e.Currency }, nil
|
||||
case "symbol":
|
||||
return func(e history.Event) string { return e.Symbol }, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dimension %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// USD is this transaction's value in USD.
|
||||
func (s *scope) USD() (float64, error) {
|
||||
return s.p.Rate.USD(s.ctx, s.Tx.Notional, s.Tx.Currency)
|
||||
}
|
||||
|
||||
// Count is the number of the subject's transactions over the lookback.
|
||||
func (s *scope) Count(kind, lookback string) (int, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Count(evs), nil
|
||||
}
|
||||
|
||||
// Sum is the subject's total USD value over the lookback.
|
||||
func (s *scope) Sum(kind, lookback string) (float64, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Sum(evs), nil
|
||||
}
|
||||
|
||||
// Max is the subject's largest single USD value over the lookback.
|
||||
func (s *scope) Max(kind, lookback string) (float64, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Max(evs), nil
|
||||
}
|
||||
|
||||
// Day is the subject's total for the calendar day this transaction falls on, in
|
||||
// the institution's zone, including this transaction.
|
||||
//
|
||||
// The lookback is fixed at 48 hours because a calendar day cannot span more than
|
||||
// that in any zone, and asking for a longer window would fetch events that the
|
||||
// day filter then discards.
|
||||
func (s *scope) Day(kind string) (float64, error) {
|
||||
evs, err := s.window(kind, "48h")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
usd, err := s.USD()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Day(evs, s.p.zone(), s.Tx.Timestamp) + usd, nil
|
||||
}
|
||||
|
||||
// Distinct counts the subject's distinct values of a dimension over the lookback.
|
||||
func (s *scope) Distinct(kind, dim, lookback string) (int, error) {
|
||||
key, err := dimension(dim)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Distinct(evs, key), nil
|
||||
}
|
||||
|
||||
// Seen reports whether the subject has already transacted against a value of a
|
||||
// dimension within the lookback. Its negation is what makes a counterparty new.
|
||||
func (s *scope) Seen(kind, dim, value, lookback string) (bool, error) {
|
||||
if value == "" {
|
||||
return false, fmt.Errorf("Seen: value for dimension %q is empty", dim)
|
||||
}
|
||||
key, err := dimension(dim)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, e := range evs {
|
||||
if strings.EqualFold(key(e), value) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Structured reports sub-threshold transactions aggregating to the threshold
|
||||
// over the lookback.
|
||||
func (s *scope) Structured(kind, lookback string, threshold float64, min int) (bool, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return measure.Structured(evs, threshold, min), nil
|
||||
}
|
||||
|
||||
// Near reports whether this transaction's value sits in the band just below a
|
||||
// threshold.
|
||||
func (s *scope) Near(threshold, band float64) (bool, error) {
|
||||
usd, err := s.USD()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return measure.Near(usd, threshold, band), nil
|
||||
}
|
||||
|
||||
// InOut reports funds arriving and leaving the subject over the lookback,
|
||||
// retaining no more than the residue fraction.
|
||||
func (s *scope) InOut(kind, lookback string, min, residue float64) (bool, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return measure.InOut(evs, min, residue), nil
|
||||
}
|
||||
|
||||
// Dormant is the number of whole days in the gap this transaction broke, within
|
||||
// the lookback.
|
||||
func (s *scope) Dormant(kind, lookback string) (float64, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Dormancy(evs).Hours() / 24, nil
|
||||
}
|
||||
|
||||
// Round is the fraction of the subject's transactions over the lookback whose
|
||||
// value is an exact multiple of unit.
|
||||
func (s *scope) Round(kind, lookback string, unit float64) (float64, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Round(evs, unit), nil
|
||||
}
|
||||
|
||||
// Deviation is this transaction's robust score against the subject's own
|
||||
// baseline over the lookback. It is zero when the baseline is too thin or has no
|
||||
// spread, so a rule comparing it against a positive threshold declines to fire
|
||||
// rather than guessing.
|
||||
func (s *scope) Deviation(kind, lookback string, min int) (float64, error) {
|
||||
evs, err := s.window(kind, lookback)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
usd, err := s.USD()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return measure.Deviation(evs, usd, min), nil
|
||||
}
|
||||
|
||||
// Screened reports whether a name matches a screening list of the given class.
|
||||
func (s *scope) Screened(name, class string) (bool, error) {
|
||||
if name == "" {
|
||||
return false, fmt.Errorf("Screened: name is empty")
|
||||
}
|
||||
switch class {
|
||||
case ClassSanctions, ClassPEP:
|
||||
default:
|
||||
return false, fmt.Errorf("unknown screening class %q: want %s or %s", class, ClassSanctions, ClassPEP)
|
||||
}
|
||||
hit, err := s.p.Screen.Hit(s.ctx, name, class)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return hit.Matched, nil
|
||||
}
|
||||
|
||||
// Tier is the published risk tier of a jurisdiction: empty, monitoring, or
|
||||
// action.
|
||||
func (s *scope) Tier(code string) (string, error) {
|
||||
if code == "" {
|
||||
return TierNone, nil
|
||||
}
|
||||
return s.p.Reference.Jurisdiction(code)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
func TestSeverityMultiplier(t *testing.T) {
|
||||
tests := []struct {
|
||||
severity string
|
||||
want float64
|
||||
}{
|
||||
{types.SeverityCritical, 2.0},
|
||||
{types.SeverityHigh, 1.5},
|
||||
{types.SeverityMedium, 1.0},
|
||||
{types.SeverityLow, 0.5},
|
||||
{"unknown", 1.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := severityMultiplier(tt.severity)
|
||||
if got != tt.want {
|
||||
t.Errorf("severityMultiplier(%q) = %f, want %f", tt.severity, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClamp01(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want float64
|
||||
}{
|
||||
{-1, 0},
|
||||
{0, 0},
|
||||
{0.5, 0.5},
|
||||
{1, 1},
|
||||
{2, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := clamp01(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("clamp01(%f) = %f, want %f", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
hits := []types.RuleHit{
|
||||
{Rule: types.Rule{ID: "r1", Weight: 0.3, Severity: types.SeverityHigh}, Match: true},
|
||||
{Rule: types.Rule{ID: "r2", Weight: 0.2, Severity: types.SeverityMedium}, Match: true},
|
||||
{Rule: types.Rule{ID: "r3", Weight: 0.1, Severity: types.SeverityLow}, Match: false}, // not matched
|
||||
}
|
||||
|
||||
score, breakdown := Score(hits)
|
||||
|
||||
// r1: 0.3 * 1.5 = 0.45
|
||||
// r2: 0.2 * 1.0 = 0.20
|
||||
// r3: skipped (Match=false)
|
||||
// total = 0.65
|
||||
const eps = 1e-9
|
||||
if score < 0.65-eps || score > 0.65+eps {
|
||||
t.Errorf("Score = %f, want 0.65", score)
|
||||
}
|
||||
if v := breakdown["r1"]; v < 0.45-eps || v > 0.45+eps {
|
||||
t.Errorf("breakdown[r1] = %f, want 0.45", v)
|
||||
}
|
||||
if v := breakdown["r2"]; v < 0.2-eps || v > 0.2+eps {
|
||||
t.Errorf("breakdown[r2] = %f, want 0.2", v)
|
||||
}
|
||||
if _, ok := breakdown["r3"]; ok {
|
||||
t.Error("breakdown should not contain r3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreClamps(t *testing.T) {
|
||||
hits := []types.RuleHit{
|
||||
{Rule: types.Rule{ID: "r1", Weight: 0.5, Severity: types.SeverityCritical}, Match: true},
|
||||
{Rule: types.Rule{ID: "r2", Weight: 0.5, Severity: types.SeverityCritical}, Match: true},
|
||||
}
|
||||
|
||||
score, _ := Score(hits)
|
||||
// 0.5*2.0 + 0.5*2.0 = 2.0, clamped to 1.0
|
||||
if score != 1.0 {
|
||||
t.Errorf("Score = %f, want 1.0 (clamped)", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreEmpty(t *testing.T) {
|
||||
score, breakdown := Score(nil)
|
||||
if score != 0 {
|
||||
t.Errorf("Score = %f, want 0", score)
|
||||
}
|
||||
if len(breakdown) != 0 {
|
||||
t.Errorf("breakdown should be empty, got %d entries", len(breakdown))
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// TransactionStore is the storage interface for transaction history queries.
|
||||
// Implementations back the helper functions used in the expr-lang DSL.
|
||||
type TransactionStore interface {
|
||||
// CountByUser returns the number of transactions for a user since the given time.
|
||||
CountByUser(ctx context.Context, userID string, since time.Time) (int, error)
|
||||
|
||||
// SumNotionalByUser returns the total notional value for a user since the given time.
|
||||
SumNotionalByUser(ctx context.Context, userID string, since time.Time) (float64, error)
|
||||
|
||||
// FirstTransaction returns true if this is the user's first transaction.
|
||||
FirstTransaction(ctx context.Context, userID string) (bool, error)
|
||||
|
||||
// FirstCounterparty returns true if this is the first time the user has transacted
|
||||
// with the given counterparty.
|
||||
FirstCounterparty(ctx context.Context, userID, counterparty string) (bool, error)
|
||||
|
||||
// LastTransactionTime returns the timestamp of the user's most recent transaction.
|
||||
LastTransactionTime(ctx context.Context, userID string) (time.Time, error)
|
||||
|
||||
// RoundTrip returns true if the user has both bought and sold the same asset
|
||||
// within the given time window.
|
||||
RoundTrip(ctx context.Context, userID string, window time.Duration) (bool, error)
|
||||
|
||||
// Save persists a transaction for subsequent queries.
|
||||
Save(ctx context.Context, tx types.Transaction) error
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// MemoryStore is an in-memory TransactionStore for testing.
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
txs []types.Transaction
|
||||
}
|
||||
|
||||
// NewMemoryStore creates an empty MemoryStore.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Save(_ context.Context, tx types.Transaction) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if tx.CreatedAt.IsZero() {
|
||||
tx.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
m.txs = append(m.txs, tx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) CountByUser(_ context.Context, userID string, since time.Time) (int, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
count := 0
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID == userID && !tx.CreatedAt.Before(since) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) SumNotionalByUser(_ context.Context, userID string, since time.Time) (float64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var total float64
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID == userID && !tx.CreatedAt.Before(since) {
|
||||
total += tx.Notional
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) FirstTransaction(_ context.Context, userID string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID == userID {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) FirstCounterparty(_ context.Context, userID, counterparty string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID == userID && tx.Counterparty == counterparty {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) LastTransactionTime(_ context.Context, userID string) (time.Time, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var latest time.Time
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID == userID && tx.CreatedAt.After(latest) {
|
||||
latest = tx.CreatedAt
|
||||
}
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RoundTrip(_ context.Context, userID string, window time.Duration) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
since := time.Now().UTC().Add(-window)
|
||||
sides := make(map[string]map[string]bool)
|
||||
for _, tx := range m.txs {
|
||||
if tx.UserID != userID || tx.CreatedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
if tx.Symbol == "" || tx.Side == "" {
|
||||
continue
|
||||
}
|
||||
if sides[tx.Symbol] == nil {
|
||||
sides[tx.Symbol] = make(map[string]bool)
|
||||
}
|
||||
sides[tx.Symbol][tx.Side] = true
|
||||
}
|
||||
for _, sideSet := range sides {
|
||||
if sideSet["buy"] && sideSet["sell"] {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decode reads an XML document with entity expansion disabled.
|
||||
//
|
||||
// A sanctions list is fetched from a remote host over the public internet on a
|
||||
// schedule, unattended. Leaving entity expansion on lets a malformed or hostile
|
||||
// document expand into unbounded memory, which takes screening offline — and a
|
||||
// screening outage that fails open is a sanctions breach.
|
||||
func decode(data []byte, into any) error {
|
||||
d := xml.NewDecoder(bytes.NewReader(data))
|
||||
d.Strict = true
|
||||
d.Entity = map[string]string{}
|
||||
return d.Decode(into)
|
||||
}
|
||||
|
||||
// months maps the abbreviated month names the lists use onto their numbers.
|
||||
var months = map[string]int{
|
||||
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
|
||||
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
|
||||
}
|
||||
|
||||
// parseOFACBirth reads a date of birth as OFAC writes it.
|
||||
//
|
||||
// OFAC publishes seven distinct shapes across its 8,141 dates: an exact date
|
||||
// ("10 Dec 1948"), a year alone ("1938"), an approximation ("circa 1951"), a
|
||||
// month and year, and ranges of any of those joined by "to". Storing the string
|
||||
// unparsed, as the previous model did, makes every one of them useless for
|
||||
// comparison — and a date of birth that cannot be compared cannot break a tie
|
||||
// between a designated subject and a customer who merely shares their name.
|
||||
func parseOFACBirth(s string) (Birth, bool) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return Birth{}, false
|
||||
}
|
||||
var circa bool
|
||||
if strings.Contains(s, "circa") {
|
||||
circa = true
|
||||
s = strings.ReplaceAll(s, "circa", " ")
|
||||
}
|
||||
lo, hi := s, s
|
||||
if i := strings.Index(s, " to "); i >= 0 {
|
||||
lo, hi = s[:i], s[i+4:]
|
||||
}
|
||||
from, ok := parseLoose(lo)
|
||||
if !ok {
|
||||
return Birth{}, false
|
||||
}
|
||||
to, ok := parseLoose(hi)
|
||||
if !ok {
|
||||
to = from
|
||||
}
|
||||
return Birth{From: from, To: to, Circa: circa}, true
|
||||
}
|
||||
|
||||
// parseLoose reads "14 aug 1971", "aug 1971", or "1971" into an ISO prefix,
|
||||
// keeping exactly the precision the source stated. Inventing a day for a source
|
||||
// that gave only a year would make an approximate match look exact.
|
||||
func parseLoose(s string) (string, bool) {
|
||||
f := strings.Fields(strings.NewReplacer(",", " ", "-", " ", "/", " ").Replace(s))
|
||||
var day, mon, year int
|
||||
for _, tok := range f {
|
||||
if m, ok := months[safeCut(tok, 3)]; ok && mon == 0 {
|
||||
mon = m
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(tok)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case n >= 1000 && n <= 9999 && year == 0:
|
||||
year = n
|
||||
case n >= 1 && n <= 31 && day == 0:
|
||||
day = n
|
||||
}
|
||||
}
|
||||
if year == 0 {
|
||||
return "", false
|
||||
}
|
||||
switch {
|
||||
case mon > 0 && day > 0:
|
||||
return fmt.Sprintf("%04d-%02d-%02d", year, mon, day), true
|
||||
case mon > 0:
|
||||
return fmt.Sprintf("%04d-%02d", year, mon), true
|
||||
default:
|
||||
return fmt.Sprintf("%04d", year), true
|
||||
}
|
||||
}
|
||||
|
||||
func safeCut(s string, n int) string {
|
||||
if len(s) < n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// parseSlashBirth reads a date of birth as OFSI writes it: dd/mm/yyyy, with zero
|
||||
// standing for an unknown component.
|
||||
//
|
||||
// OFSI publishes dates like "00/00/1994", where the day and month are genuinely
|
||||
// unknown. A parser that rejects them loses the year — the only part that was
|
||||
// ever known — and a parser that reads them as day zero of month zero produces a
|
||||
// date that matches nothing.
|
||||
func parseSlashBirth(s string) (Birth, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return Birth{}, false
|
||||
}
|
||||
f := strings.Split(s, "/")
|
||||
if len(f) != 3 {
|
||||
return Birth{}, false
|
||||
}
|
||||
day, _ := strconv.Atoi(f[0])
|
||||
mon, _ := strconv.Atoi(f[1])
|
||||
year, err := strconv.Atoi(f[2])
|
||||
if err != nil || year < 1000 || year > 9999 {
|
||||
return Birth{}, false
|
||||
}
|
||||
var iso string
|
||||
switch {
|
||||
case mon > 0 && mon <= 12 && day > 0 && day <= 31:
|
||||
iso = fmt.Sprintf("%04d-%02d-%02d", year, mon, day)
|
||||
case mon > 0 && mon <= 12:
|
||||
iso = fmt.Sprintf("%04d-%02d", year, mon)
|
||||
default:
|
||||
iso = fmt.Sprintf("%04d", year)
|
||||
}
|
||||
return Birth{From: iso, To: iso}, true
|
||||
}
|
||||
|
||||
// Overlaps reports whether two dates of birth can describe the same person.
|
||||
//
|
||||
// Comparison is by ISO prefix, so a subject listed only by year matches a
|
||||
// customer whose full date falls in that year, and a range matches any date
|
||||
// inside it. This is the behaviour a tie-break needs: it must exclude a customer
|
||||
// born in a different year without pretending to know a day the list never gave.
|
||||
func (b Birth) Overlaps(other Birth) bool {
|
||||
if b.From == "" || other.From == "" {
|
||||
return false
|
||||
}
|
||||
return within(other.From, b.From, b.To) || within(b.From, other.From, other.To)
|
||||
}
|
||||
|
||||
// within reports whether v falls inside the closed range [lo, hi] under prefix
|
||||
// comparison.
|
||||
func within(v, lo, hi string) bool {
|
||||
if hi == "" {
|
||||
hi = lo
|
||||
}
|
||||
n := min(len(v), len(lo))
|
||||
if v[:n] < lo[:n] {
|
||||
return false
|
||||
}
|
||||
n = min(len(v), len(hi))
|
||||
return v[:n] <= hi[:n]
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package sanctions parses the published sanctions lists and matches names and
|
||||
// identifiers against them.
|
||||
//
|
||||
// The Entry model is the union of what the four list families actually publish,
|
||||
// established by reading the files themselves rather than by describing them
|
||||
// from memory. Fields exist here because a real list carries them and matching
|
||||
// needs them: alias strength, because a sixth of OFAC's aliases are marked weak
|
||||
// and matching them at full confidence is the largest single source of false
|
||||
// positives; multiple dates of birth, because they are the tie-break that
|
||||
// separates a real subject from a namesake; and digital currency addresses,
|
||||
// because OFAC designates them and a payment to one is a sanctions breach that no
|
||||
// amount of name matching will catch.
|
||||
package sanctions
|
||||
|
||||
import "strings"
|
||||
|
||||
// List sources.
|
||||
const (
|
||||
OFAC = "ofac"
|
||||
UN = "un"
|
||||
EU = "eu"
|
||||
OFSI = "ofsi"
|
||||
)
|
||||
|
||||
// Subject kinds.
|
||||
const (
|
||||
Individual = "individual"
|
||||
Entity = "entity"
|
||||
Vessel = "vessel"
|
||||
Aircraft = "aircraft"
|
||||
)
|
||||
|
||||
// Name types, as the lists themselves distinguish them. A formerly-known-as name
|
||||
// is still screened — a renamed company does not stop being designated — but the
|
||||
// type is recorded so an analyst reviewing a hit knows which name matched.
|
||||
const (
|
||||
Primary = "primary"
|
||||
Also = "aka"
|
||||
Formerly = "fka"
|
||||
Now = "nka"
|
||||
)
|
||||
|
||||
// Document kinds. Address is a digital currency address, which is matched
|
||||
// exactly rather than fuzzily: an address is either the designated one or it is
|
||||
// somebody else's money.
|
||||
const (
|
||||
Passport = "passport"
|
||||
National = "national"
|
||||
Address = "address"
|
||||
Other = "other"
|
||||
)
|
||||
|
||||
// Name is one of a subject's names.
|
||||
//
|
||||
// Strong is false for the aliases their publisher marks as low quality. OFAC
|
||||
// marks 4,383 of 24,546 aliases weak, the UN marks aliases Low, and OFSI marks
|
||||
// them Low quality; all three mean the same thing — the name is a fragment, a
|
||||
// transliteration guess, or a partial, and treating it as a full-confidence
|
||||
// identifier produces hits on unrelated people.
|
||||
type Name struct {
|
||||
Full string `json:"full"`
|
||||
Type string `json:"type"`
|
||||
Strong bool `json:"strong"`
|
||||
// Script holds the name as the publisher records it in its original
|
||||
// non-Latin script, when it does. Matching an Arabic or Cyrillic name against
|
||||
// its own script is exact where matching it against a transliteration is a
|
||||
// guess.
|
||||
Script string `json:"script,omitempty"`
|
||||
}
|
||||
|
||||
// Birth is a date of birth, which the lists publish as an exact date, a year
|
||||
// alone, a range between two years, or an approximation.
|
||||
//
|
||||
// From and To are ISO 8601 prefixes: "1971", "1971-08", or "1971-08-14". They are
|
||||
// equal for an exact date and differ for a range. Comparison is by prefix, so a
|
||||
// subject known only by year still matches a customer whose full date falls in
|
||||
// that year — and a customer born in a different year is excluded, which is the
|
||||
// point.
|
||||
type Birth struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Circa bool `json:"circa,omitempty"`
|
||||
}
|
||||
|
||||
// Document is an identifier a subject holds.
|
||||
type Document struct {
|
||||
Kind string `json:"kind"`
|
||||
Number string `json:"number"`
|
||||
Country string `json:"country,omitempty"`
|
||||
// Chain names the network for a digital currency address, as the publisher
|
||||
// labels it: XBT, ETH, TRX, USDT and so on.
|
||||
Chain string `json:"chain,omitempty"`
|
||||
}
|
||||
|
||||
// Entry is one designated subject.
|
||||
type Entry struct {
|
||||
List string `json:"list"`
|
||||
RefID string `json:"ref_id"`
|
||||
// Group ties together the rows of a list that describes one subject across
|
||||
// several records. OFSI publishes one row per name and joins them with a
|
||||
// group identifier; the other lists publish one record per subject, where the
|
||||
// group is the record.
|
||||
Group string `json:"group"`
|
||||
Kind string `json:"kind"`
|
||||
Names []Name `json:"names"`
|
||||
Births []Birth `json:"births,omitempty"`
|
||||
Places []string `json:"places,omitempty"`
|
||||
Nationalities []string `json:"nationalities,omitempty"`
|
||||
Citizenships []string `json:"citizenships,omitempty"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
Addresses []string `json:"addresses,omitempty"`
|
||||
Programs []string `json:"programs,omitempty"`
|
||||
Remarks string `json:"remarks,omitempty"`
|
||||
ListedOn string `json:"listed_on,omitempty"`
|
||||
UpdatedOn string `json:"updated_on,omitempty"`
|
||||
}
|
||||
|
||||
// PrimaryName is the subject's main name, or the first name it has.
|
||||
func (e Entry) PrimaryName() string {
|
||||
for _, n := range e.Names {
|
||||
if n.Type == Primary {
|
||||
return n.Full
|
||||
}
|
||||
}
|
||||
if len(e.Names) > 0 {
|
||||
return e.Names[0].Full
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Chains returns the digital currency addresses this entry designates.
|
||||
func (e Entry) Chains() []Document {
|
||||
var out []Document
|
||||
for _, d := range e.Documents {
|
||||
if d.Kind == Address {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// join builds a whole name from the ordered parts a list splits it into,
|
||||
// dropping the empty ones. Both OFAC and the UN carry an entity's whole name in
|
||||
// the field nominally meant for a surname, so a naive first-plus-last
|
||||
// concatenation produces a leading space for every entity on both lists.
|
||||
func join(parts ...string) string {
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, " ")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// euExport is the root of the EU consolidated financial sanctions file.
|
||||
type euExport struct {
|
||||
Generated string `xml:"generationDate,attr"`
|
||||
Entities []euEntity `xml:"sanctionEntity"`
|
||||
}
|
||||
|
||||
type euEntity struct {
|
||||
Reference string `xml:"euReferenceNumber,attr"`
|
||||
LogicalID string `xml:"logicalId,attr"`
|
||||
UnitedNation string `xml:"unitedNationId,attr"`
|
||||
Designated string `xml:"designationDate,attr"`
|
||||
Remark []string `xml:"remark"`
|
||||
Subject struct {
|
||||
Code string `xml:"code,attr"`
|
||||
Classification string `xml:"classificationCode,attr"`
|
||||
} `xml:"subjectType"`
|
||||
Regulations []struct {
|
||||
Programme string `xml:"programme,attr"`
|
||||
Number string `xml:"numberTitle,attr"`
|
||||
Published string `xml:"publicationDate,attr"`
|
||||
} `xml:"regulation"`
|
||||
Names []struct {
|
||||
Whole string `xml:"wholeName,attr"`
|
||||
First string `xml:"firstName,attr"`
|
||||
Middle string `xml:"middleName,attr"`
|
||||
Last string `xml:"lastName,attr"`
|
||||
Language string `xml:"nameLanguage,attr"`
|
||||
Strong string `xml:"strong,attr"`
|
||||
} `xml:"nameAlias"`
|
||||
Births []struct {
|
||||
Date string `xml:"birthdate,attr"`
|
||||
Year string `xml:"year,attr"`
|
||||
Month string `xml:"monthOfYear,attr"`
|
||||
Day string `xml:"dayOfMonth,attr"`
|
||||
From string `xml:"yearRangeFrom,attr"`
|
||||
To string `xml:"yearRangeTo,attr"`
|
||||
Circa string `xml:"circa,attr"`
|
||||
// Calendar names the calendar the date is expressed in. A date recorded in
|
||||
// a non-Gregorian calendar cannot be compared against a customer's
|
||||
// Gregorian date of birth without conversion, so it is not treated as a
|
||||
// comparable date.
|
||||
Calendar string `xml:"calendarType,attr"`
|
||||
City string `xml:"city,attr"`
|
||||
Country string `xml:"countryDescription,attr"`
|
||||
} `xml:"birthdate"`
|
||||
Citizenships []struct {
|
||||
Code string `xml:"countryIso2Code,attr"`
|
||||
Country string `xml:"countryDescription,attr"`
|
||||
} `xml:"citizenship"`
|
||||
IDs []struct {
|
||||
Number string `xml:"number,attr"`
|
||||
Latin string `xml:"latinNumber,attr"`
|
||||
TypeCode string `xml:"identificationTypeCode,attr"`
|
||||
Description string `xml:"identificationTypeDescription,attr"`
|
||||
Country string `xml:"countryIso2Code,attr"`
|
||||
} `xml:"identification"`
|
||||
Addresses []struct {
|
||||
Street string `xml:"street,attr"`
|
||||
City string `xml:"city,attr"`
|
||||
Zip string `xml:"zipCode,attr"`
|
||||
Region string `xml:"region,attr"`
|
||||
Country string `xml:"countryDescription,attr"`
|
||||
} `xml:"address"`
|
||||
}
|
||||
|
||||
// ParseEU reads the EU consolidated financial sanctions file.
|
||||
func ParseEU(data []byte) ([]Entry, error) {
|
||||
var doc euExport
|
||||
if err := decode(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("eu consolidated: %w", err)
|
||||
}
|
||||
if len(doc.Entities) == 0 {
|
||||
return nil, fmt.Errorf("eu consolidated: no records — refusing an empty list")
|
||||
}
|
||||
|
||||
out := make([]Entry, 0, len(doc.Entities))
|
||||
for _, s := range doc.Entities {
|
||||
ref := strings.TrimSpace(s.Reference)
|
||||
if ref == "" {
|
||||
ref = strings.TrimSpace(s.LogicalID)
|
||||
}
|
||||
e := Entry{
|
||||
List: EU,
|
||||
RefID: ref,
|
||||
Group: ref,
|
||||
Kind: euKind(s.Subject.Classification),
|
||||
Remarks: strings.TrimSpace(strings.Join(s.Remark, "; ")),
|
||||
ListedOn: strings.TrimSpace(s.Designated),
|
||||
UpdatedOn: strings.TrimSpace(doc.Generated),
|
||||
}
|
||||
for _, r := range s.Regulations {
|
||||
if p := strings.TrimSpace(r.Programme); p != "" {
|
||||
e.Programs = append(e.Programs, p)
|
||||
}
|
||||
}
|
||||
|
||||
// The EU marks no alias as weak — every one of its names carries
|
||||
// strong="true" — and it publishes no primary flag, so the first name is
|
||||
// the main one by position. The strong attribute is still read rather than
|
||||
// assumed, so the parser reflects the file if the EU starts marking weak
|
||||
// names.
|
||||
for i, n := range s.Names {
|
||||
full := strings.TrimSpace(n.Whole)
|
||||
if full == "" {
|
||||
full = join(n.First, n.Middle, n.Last)
|
||||
}
|
||||
if full == "" {
|
||||
continue
|
||||
}
|
||||
t := Also
|
||||
if i == 0 {
|
||||
t = Primary
|
||||
}
|
||||
e.Names = append(e.Names, Name{
|
||||
Full: full,
|
||||
Type: t,
|
||||
Strong: !strings.EqualFold(strings.TrimSpace(n.Strong), "false"),
|
||||
})
|
||||
}
|
||||
|
||||
for _, b := range s.Births {
|
||||
if cal := strings.TrimSpace(b.Calendar); cal != "" && !strings.EqualFold(cal, "GREGORIAN") {
|
||||
continue
|
||||
}
|
||||
if bd, ok := euBirth(b.Date, b.Year, b.Month, b.Day, b.From, b.To, b.Circa); ok {
|
||||
e.Births = append(e.Births, bd)
|
||||
}
|
||||
if v := join(b.City, b.Country); v != "" {
|
||||
e.Places = append(e.Places, v)
|
||||
}
|
||||
}
|
||||
for _, c := range s.Citizenships {
|
||||
if v := strings.TrimSpace(c.Country); v != "" {
|
||||
e.Citizenships = append(e.Citizenships, v)
|
||||
} else if v := strings.TrimSpace(c.Code); v != "" {
|
||||
e.Citizenships = append(e.Citizenships, v)
|
||||
}
|
||||
}
|
||||
for _, id := range s.IDs {
|
||||
num := strings.TrimSpace(id.Number)
|
||||
if num == "" {
|
||||
num = strings.TrimSpace(id.Latin)
|
||||
}
|
||||
if num == "" {
|
||||
continue
|
||||
}
|
||||
kind := Other
|
||||
d := strings.ToLower(id.Description + " " + id.TypeCode)
|
||||
if strings.Contains(d, "passport") {
|
||||
kind = Passport
|
||||
} else if strings.Contains(d, "id") {
|
||||
kind = National
|
||||
}
|
||||
e.Documents = append(e.Documents, Document{Kind: kind, Number: num, Country: strings.TrimSpace(id.Country)})
|
||||
}
|
||||
for _, a := range s.Addresses {
|
||||
if v := join(a.Street, a.City, a.Zip, a.Region, a.Country); v != "" {
|
||||
e.Addresses = append(e.Addresses, v)
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// euKind reads the EU subject classification: P for a natural person, E for an
|
||||
// entity.
|
||||
func euKind(code string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(code), "P") {
|
||||
return Individual
|
||||
}
|
||||
return Entity
|
||||
}
|
||||
|
||||
func euBirth(date, year, month, day, from, to, circa string) (Birth, bool) {
|
||||
c := strings.EqualFold(strings.TrimSpace(circa), "true")
|
||||
if d := strings.TrimSpace(date); d != "" {
|
||||
return Birth{From: d, To: d, Circa: c}, true
|
||||
}
|
||||
f, t := strings.TrimSpace(from), strings.TrimSpace(to)
|
||||
if f != "" && t != "" {
|
||||
return Birth{From: f, To: t, Circa: c}, true
|
||||
}
|
||||
y := strings.TrimSpace(year)
|
||||
if y == "" {
|
||||
return Birth{}, false
|
||||
}
|
||||
iso := y
|
||||
if m := strings.TrimSpace(month); m != "" {
|
||||
iso = fmt.Sprintf("%s-%02s", y, pad(m))
|
||||
if d := strings.TrimSpace(day); d != "" {
|
||||
iso = fmt.Sprintf("%s-%s-%s", y, pad(m), pad(d))
|
||||
}
|
||||
}
|
||||
return Birth{From: iso, To: iso, Circa: c}, true
|
||||
}
|
||||
|
||||
func pad(s string) string {
|
||||
if len(s) == 1 {
|
||||
return "0" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// maxBodySize is the maximum sanctions list download size (100 MB).
|
||||
const maxBodySize = 100 * 1024 * 1024
|
||||
|
||||
// Ingester fetches and parses sanctions lists.
|
||||
type Ingester struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewIngester creates a new Ingester with a 60s timeout.
|
||||
func NewIngester() *Ingester {
|
||||
return &Ingester{
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// FetchOFAC fetches and parses the OFAC SDN XML list.
|
||||
func (ing *Ingester) FetchOFAC(ctx context.Context, url string) ([]types.SanctionsEntry, string, error) {
|
||||
body, hash, err := ing.fetch(ctx, url)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("fetch OFAC: %w", err)
|
||||
}
|
||||
|
||||
// RED-05: Use streaming decoder with entity expansion disabled to prevent
|
||||
// billion-laughs XML bomb attacks. Strict mode + empty entity map means
|
||||
// any entity reference in the XML causes a parse error instead of OOM.
|
||||
var sdnList sdnListXML
|
||||
d := xml.NewDecoder(bytes.NewReader(body))
|
||||
d.Strict = true
|
||||
d.Entity = map[string]string{} // disable entity expansion
|
||||
if err := d.Decode(&sdnList); err != nil {
|
||||
return nil, "", fmt.Errorf("parse OFAC XML: %w", err)
|
||||
}
|
||||
|
||||
entries := make([]types.SanctionsEntry, 0, len(sdnList.Entries))
|
||||
for _, e := range sdnList.Entries {
|
||||
entryType := types.SanctionIndividual
|
||||
if e.SDNType == "Entity" {
|
||||
entryType = types.SanctionEntity
|
||||
} else if e.SDNType == "Vessel" {
|
||||
entryType = types.SanctionVessel
|
||||
} else if e.SDNType == "Aircraft" {
|
||||
entryType = types.SanctionAircraft
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(e.FirstName + " " + e.LastName)
|
||||
aliases := make([]string, 0, len(e.Aliases))
|
||||
for _, a := range e.Aliases {
|
||||
an := strings.TrimSpace(a.FirstName + " " + a.LastName)
|
||||
if an != "" {
|
||||
aliases = append(aliases, an)
|
||||
}
|
||||
}
|
||||
|
||||
var dob string
|
||||
for _, d := range e.DateOfBirth {
|
||||
dob = d.DateOfBirth
|
||||
break
|
||||
}
|
||||
|
||||
var nationality string
|
||||
for _, n := range e.Nationalities {
|
||||
nationality = n.Country
|
||||
break
|
||||
}
|
||||
|
||||
entries = append(entries, types.SanctionsEntry{
|
||||
RefID: fmt.Sprintf("%d", e.UID),
|
||||
Name: name,
|
||||
Aliases: aliases,
|
||||
DOB: dob,
|
||||
Nationality: nationality,
|
||||
Type: entryType,
|
||||
})
|
||||
}
|
||||
|
||||
return entries, hash, nil
|
||||
}
|
||||
|
||||
// fetch downloads a URL and returns the body bytes + SHA256 hex hash.
|
||||
func (ing *Ingester) fetch(ctx context.Context, url string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "luxfi-aml/1.0")
|
||||
|
||||
resp, err := ing.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
h := sha256.Sum256(body)
|
||||
return body, fmt.Sprintf("%x", h), nil
|
||||
}
|
||||
|
||||
// OFAC SDN XML structures.
|
||||
|
||||
type sdnListXML struct {
|
||||
XMLName xml.Name `xml:"sdnList"`
|
||||
Entries []sdnEntryXML `xml:"sdnEntry"`
|
||||
}
|
||||
|
||||
type sdnEntryXML struct {
|
||||
UID int `xml:"uid"`
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
SDNType string `xml:"sdnType"`
|
||||
Aliases []sdnAliasXML `xml:"akaList>aka"`
|
||||
DateOfBirth []sdnDOBXML `xml:"dateOfBirthList>dateOfBirthItem"`
|
||||
Nationalities []sdnNatXML `xml:"nationalityList>nationality"`
|
||||
}
|
||||
|
||||
type sdnAliasXML struct {
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
}
|
||||
|
||||
type sdnDOBXML struct {
|
||||
DateOfBirth string `xml:"dateOfBirth"`
|
||||
}
|
||||
|
||||
type sdnNatXML struct {
|
||||
Country string `xml:"country"`
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIngestXMLEntityExpansion (RED-05) verifies that the OFAC XML parser
|
||||
// rejects entity expansion attacks (billion laughs / XML bombs). Without
|
||||
// the streaming decoder fix, this would cause exponential memory growth.
|
||||
func TestIngestXMLEntityExpansion(t *testing.T) {
|
||||
// Classic billion-laughs XML bomb with nested entity expansion.
|
||||
bomb := `<?xml version="1.0"?>
|
||||
<!DOCTYPE lolz [
|
||||
<!ENTITY lol "lol">
|
||||
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
|
||||
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
|
||||
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
|
||||
]>
|
||||
<sdnList>
|
||||
<sdnEntry>
|
||||
<uid>1</uid>
|
||||
<firstName>&lol4;</firstName>
|
||||
<lastName>Test</lastName>
|
||||
</sdnEntry>
|
||||
</sdnList>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(bomb))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ing := NewIngester()
|
||||
_, _, err := ing.FetchOFAC(context.Background(), srv.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error parsing XML bomb with entity expansion, got nil")
|
||||
}
|
||||
// The error should be about entity references, not OOM.
|
||||
t.Logf("correctly rejected XML bomb: %v", err)
|
||||
}
|
||||
|
||||
// TestIngestValidOFACXML ensures normal OFAC XML still parses correctly
|
||||
// after the streaming decoder change.
|
||||
func TestIngestValidOFACXML(t *testing.T) {
|
||||
validXML := `<?xml version="1.0"?>
|
||||
<sdnList>
|
||||
<sdnEntry>
|
||||
<uid>1234</uid>
|
||||
<firstName>John</firstName>
|
||||
<lastName>Doe</lastName>
|
||||
<sdnType>Individual</sdnType>
|
||||
<akaList>
|
||||
<aka>
|
||||
<firstName>Johnny</firstName>
|
||||
<lastName>Doe</lastName>
|
||||
</aka>
|
||||
</akaList>
|
||||
</sdnEntry>
|
||||
<sdnEntry>
|
||||
<uid>5678</uid>
|
||||
<firstName></firstName>
|
||||
<lastName>Evil Corp</lastName>
|
||||
<sdnType>Entity</sdnType>
|
||||
</sdnEntry>
|
||||
</sdnList>`
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Write([]byte(validXML))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ing := NewIngester()
|
||||
entries, hash, err := ing.FetchOFAC(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
if entries[0].Name != "John Doe" {
|
||||
t.Errorf("entry[0].Name = %q, want %q", entries[0].Name, "John Doe")
|
||||
}
|
||||
if entries[0].Type != "individual" {
|
||||
t.Errorf("entry[0].Type = %q, want %q", entries[0].Type, "individual")
|
||||
}
|
||||
if len(entries[0].Aliases) != 1 || entries[0].Aliases[0] != "Johnny Doe" {
|
||||
t.Errorf("entry[0].Aliases = %v, want [Johnny Doe]", entries[0].Aliases)
|
||||
}
|
||||
if entries[1].Type != "entity" {
|
||||
t.Errorf("entry[1].Type = %q, want %q", entries[1].Type, "entity")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package sanctions
|
||||
|
||||
import "github.com/luxfi/aml/pkg/types"
|
||||
|
||||
// DefaultLists returns the default sanctions list configurations.
|
||||
func DefaultLists() []types.SanctionsList {
|
||||
return []types.SanctionsList{
|
||||
{
|
||||
Source: types.ListOFACSDN,
|
||||
URL: "https://www.treasury.gov/ofac/downloads/sdn.xml",
|
||||
Format: "xml",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Source: types.ListUN,
|
||||
URL: "https://scsanctions.un.org/resources/xml/en/consolidated.xml",
|
||||
Format: "xml",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Source: types.ListEU,
|
||||
URL: "https://webgate.ec.europa.eu/fsd/fsf/public/files/xmlFullSanctionsList_1_1/content",
|
||||
Format: "xml",
|
||||
Active: true,
|
||||
},
|
||||
{
|
||||
Source: types.ListHMT,
|
||||
URL: "https://ofsistorage.blob.core.windows.net/publishlive/ConList.xml",
|
||||
Format: "xml",
|
||||
Active: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJaroWinklerExact(t *testing.T) {
|
||||
score := JaroWinkler("john smith", "john smith")
|
||||
if score != 1.0 {
|
||||
t.Errorf("exact match: got %f, want 1.0", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerEmpty(t *testing.T) {
|
||||
if JaroWinkler("", "abc") != 0.0 {
|
||||
t.Error("empty s1 should return 0")
|
||||
}
|
||||
if JaroWinkler("abc", "") != 0.0 {
|
||||
t.Error("empty s2 should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerSimilar(t *testing.T) {
|
||||
score := JaroWinkler("john smith", "jon smith")
|
||||
if score < 0.9 {
|
||||
t.Errorf("similar names: got %f, want >= 0.9", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerDifferent(t *testing.T) {
|
||||
score := JaroWinkler("john smith", "alice jones")
|
||||
if score > 0.7 {
|
||||
t.Errorf("different names: got %f, want < 0.7", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerCaseInsensitive(t *testing.T) {
|
||||
s1 := JaroWinkler("John Smith", "john smith")
|
||||
s2 := JaroWinkler("JOHN SMITH", "john smith")
|
||||
if s1 != 1.0 || s2 != 1.0 {
|
||||
t.Errorf("case insensitive: got %f, %f — want 1.0 for both", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerCommonPrefix(t *testing.T) {
|
||||
// Winkler bonus rewards common prefix.
|
||||
withPrefix := JaroWinkler("johnson", "johnsn")
|
||||
noPrefix := JaroWinkler("nohnson", "noahson")
|
||||
if withPrefix <= noPrefix {
|
||||
t.Errorf("common prefix should score higher: withPrefix=%f, noPrefix=%f", withPrefix, noPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"John Smith", "johnsmith"},
|
||||
{"O'Brien", "obrien"},
|
||||
{"Al-Rashid", "alrashid"},
|
||||
{" spaces ", "spaces"},
|
||||
{"Números123", "numeros123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMatch(t *testing.T) {
|
||||
score := TokenMatch("John Smith", "Smith John")
|
||||
if score < 0.95 {
|
||||
t.Errorf("reordered tokens: got %f, want >= 0.95", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMatchPartial(t *testing.T) {
|
||||
score := TokenMatch("John A Smith", "John Smith")
|
||||
// "John" matches perfectly, "A" doesn't match well, "Smith" matches perfectly.
|
||||
if score < 0.6 {
|
||||
t.Errorf("partial token match: got %f, want >= 0.6", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMatchEmpty(t *testing.T) {
|
||||
if TokenMatch("", "anything") != 0.0 {
|
||||
t.Error("empty input should return 0")
|
||||
}
|
||||
if TokenMatch("anything", "") != 0.0 {
|
||||
t.Error("empty candidate should return 0")
|
||||
}
|
||||
}
|
||||
|
||||
func approxEqual(a, b, tolerance float64) bool {
|
||||
return math.Abs(a-b) < tolerance
|
||||
}
|
||||
|
||||
func TestJaroWinklerSymmetric(t *testing.T) {
|
||||
s1 := JaroWinkler("martha", "marhta")
|
||||
s2 := JaroWinkler("marhta", "martha")
|
||||
if !approxEqual(s1, s2, 0.001) {
|
||||
t.Errorf("JaroWinkler should be symmetric: %f vs %f", s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJaroWinklerKnownValue(t *testing.T) {
|
||||
// Classic Jaro-Winkler example: MARTHA/MARHTA.
|
||||
score := JaroWinkler("martha", "marhta")
|
||||
if score < 0.96 {
|
||||
t.Errorf("MARTHA/MARHTA: got %f, want >= 0.96", score)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchCyrillicHomoglyph (RED-10) verifies that Cyrillic homoglyphs
|
||||
// are transliterated before matching, catching evasion attempts.
|
||||
func TestMatchCyrillicHomoglyph(t *testing.T) {
|
||||
// "Ким Йонг Ун" (Cyrillic) vs "Kim Jong Un" (Latin)
|
||||
score := TokenMatch("Ким Йонг Ун", "Kim Jong Un")
|
||||
if score < 0.7 {
|
||||
t.Errorf("RED-10 Cyrillic homoglyph: score=%f, want >= 0.7", score)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchReversedName (RED-10) verifies that reversed name order still matches.
|
||||
func TestMatchReversedName(t *testing.T) {
|
||||
score := TokenMatch("Jong Un Kim", "Kim Jong Un")
|
||||
if score < 0.8 {
|
||||
t.Errorf("RED-10 reversed name: score=%f, want >= 0.8", score)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchTransliteration (RED-10) verifies transliteration of Cyrillic names.
|
||||
func TestMatchTransliteration(t *testing.T) {
|
||||
// Common Cyrillic→Latin transliteration test.
|
||||
score := TokenMatch("Путин Владимир", "Putin Vladimir")
|
||||
if score < 0.5 {
|
||||
t.Errorf("RED-10 transliteration: score=%f, want >= 0.5", score)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeNFKD (RED-10) verifies NFKD normalization handles fullwidth chars.
|
||||
func TestNormalizeNFKD(t *testing.T) {
|
||||
// Fullwidth "John" should normalize to "john".
|
||||
got := normalize("John")
|
||||
if got != "john" {
|
||||
t.Errorf("normalize fullwidth = %q, want %q", got, "john")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoundex(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Robert", "R163"},
|
||||
{"Rupert", "R163"},
|
||||
{"Smith", "S530"},
|
||||
{"Smyth", "S530"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := soundex(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("soundex(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ofacList is the root of the OFAC SDN XML export.
|
||||
//
|
||||
// The document is namespaced. The field tags below carry local names only, which
|
||||
// Go's decoder matches irrespective of namespace, so the parser keeps working
|
||||
// when OFAC moves the export between hosts and namespace URIs — as it has.
|
||||
type ofacList struct {
|
||||
Publish ofacPublish `xml:"publshInformation"`
|
||||
Entries []ofacEntry `xml:"sdnEntry"`
|
||||
}
|
||||
|
||||
// ofacPublish carries the publication date and the record count OFAC states the
|
||||
// file contains. The count is an integrity check the publisher hands us for free:
|
||||
// parsing fewer records than OFAC says it published means the file was truncated
|
||||
// in transit, and a truncated sanctions list is a list that clears designated
|
||||
// subjects.
|
||||
type ofacPublish struct {
|
||||
Date string `xml:"Publish_Date"`
|
||||
Count int `xml:"Record_Count"`
|
||||
}
|
||||
|
||||
type ofacEntry struct {
|
||||
UID int `xml:"uid"`
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
Title string `xml:"title"`
|
||||
Type string `xml:"sdnType"`
|
||||
Remarks string `xml:"remarks"`
|
||||
Programs []string `xml:"programList>program"`
|
||||
Akas []struct {
|
||||
Type string `xml:"type"`
|
||||
Category string `xml:"category"`
|
||||
FirstName string `xml:"firstName"`
|
||||
LastName string `xml:"lastName"`
|
||||
} `xml:"akaList>aka"`
|
||||
IDs []struct {
|
||||
Type string `xml:"idType"`
|
||||
Number string `xml:"idNumber"`
|
||||
Country string `xml:"idCountry"`
|
||||
} `xml:"idList>id"`
|
||||
Births []struct {
|
||||
Date string `xml:"dateOfBirth"`
|
||||
Main string `xml:"mainEntry"`
|
||||
} `xml:"dateOfBirthList>dateOfBirthItem"`
|
||||
Places []struct {
|
||||
Place string `xml:"placeOfBirth"`
|
||||
Main string `xml:"mainEntry"`
|
||||
} `xml:"placeOfBirthList>placeOfBirthItem"`
|
||||
Nationalities []struct {
|
||||
Country string `xml:"country"`
|
||||
} `xml:"nationalityList>nationality"`
|
||||
Citizenships []struct {
|
||||
Country string `xml:"country"`
|
||||
} `xml:"citizenshipList>citizenship"`
|
||||
Addresses []struct {
|
||||
One string `xml:"address1"`
|
||||
Two string `xml:"address2"`
|
||||
Three string `xml:"address3"`
|
||||
City string `xml:"city"`
|
||||
Province string `xml:"stateOrProvince"`
|
||||
Postal string `xml:"postalCode"`
|
||||
Country string `xml:"country"`
|
||||
} `xml:"addressList>address"`
|
||||
}
|
||||
|
||||
// digitalPrefix is how OFAC labels a designated blockchain address in idType:
|
||||
// "Digital Currency Address - XBT". The suffix names the network.
|
||||
const digitalPrefix = "Digital Currency Address - "
|
||||
|
||||
// ParseOFAC reads the OFAC SDN XML export.
|
||||
//
|
||||
// It verifies the parsed count against the count OFAC publishes in the file and
|
||||
// refuses the whole file on a mismatch. A sanctions list is the one input where
|
||||
// partial success is worse than failure: the missing records are indistinguishable
|
||||
// from records that were never designated, so screening reports clean.
|
||||
func ParseOFAC(data []byte) ([]Entry, error) {
|
||||
var doc ofacList
|
||||
if err := decode(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("ofac sdn: %w", err)
|
||||
}
|
||||
if doc.Publish.Count > 0 && len(doc.Entries) != doc.Publish.Count {
|
||||
return nil, fmt.Errorf("ofac sdn: parsed %d records, file declares %d — refusing a truncated list",
|
||||
len(doc.Entries), doc.Publish.Count)
|
||||
}
|
||||
|
||||
out := make([]Entry, 0, len(doc.Entries))
|
||||
for _, e := range doc.Entries {
|
||||
ref := fmt.Sprintf("%d", e.UID)
|
||||
entry := Entry{
|
||||
List: OFAC,
|
||||
RefID: ref,
|
||||
Group: ref,
|
||||
Kind: ofacKind(e.Type),
|
||||
Remarks: strings.TrimSpace(e.Remarks),
|
||||
Programs: trimAll(e.Programs),
|
||||
ListedOn: doc.Publish.Date,
|
||||
UpdatedOn: doc.Publish.Date,
|
||||
}
|
||||
|
||||
// An entity's whole name sits in lastName with firstName empty, so joining
|
||||
// the parts is what produces a usable name for both kinds.
|
||||
entry.Names = append(entry.Names, Name{
|
||||
Full: join(e.FirstName, e.LastName),
|
||||
Type: Primary,
|
||||
Strong: true,
|
||||
})
|
||||
for _, a := range e.Akas {
|
||||
full := join(a.FirstName, a.LastName)
|
||||
if full == "" {
|
||||
continue
|
||||
}
|
||||
entry.Names = append(entry.Names, Name{
|
||||
Full: full,
|
||||
Type: akaType(a.Type),
|
||||
Strong: !strings.EqualFold(strings.TrimSpace(a.Category), "weak"),
|
||||
})
|
||||
}
|
||||
|
||||
// The main date of birth is the one OFAC flags, not the one that happens to
|
||||
// be first: 37 of the 541 subjects with several dates list the main one
|
||||
// second or later, so taking the first silently drops the tie-break for
|
||||
// exactly the subjects where a tie-break is needed.
|
||||
for _, b := range e.Births {
|
||||
if bd, ok := parseOFACBirth(b.Date); ok {
|
||||
if isMain(b.Main) {
|
||||
entry.Births = append([]Birth{bd}, entry.Births...)
|
||||
} else {
|
||||
entry.Births = append(entry.Births, bd)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range e.Places {
|
||||
if v := strings.TrimSpace(p.Place); v != "" {
|
||||
if isMain(p.Main) {
|
||||
entry.Places = append([]string{v}, entry.Places...)
|
||||
} else {
|
||||
entry.Places = append(entry.Places, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range e.Nationalities {
|
||||
if v := strings.TrimSpace(n.Country); v != "" {
|
||||
entry.Nationalities = append(entry.Nationalities, v)
|
||||
}
|
||||
}
|
||||
for _, c := range e.Citizenships {
|
||||
if v := strings.TrimSpace(c.Country); v != "" {
|
||||
entry.Citizenships = append(entry.Citizenships, v)
|
||||
}
|
||||
}
|
||||
for _, id := range e.IDs {
|
||||
num := strings.TrimSpace(id.Number)
|
||||
if num == "" {
|
||||
continue
|
||||
}
|
||||
t := strings.TrimSpace(id.Type)
|
||||
doc := Document{Number: num, Country: strings.TrimSpace(id.Country)}
|
||||
switch {
|
||||
case strings.HasPrefix(t, digitalPrefix):
|
||||
doc.Kind = Address
|
||||
doc.Chain = strings.TrimSpace(strings.TrimPrefix(t, digitalPrefix))
|
||||
case strings.Contains(strings.ToLower(t), "passport"):
|
||||
doc.Kind = Passport
|
||||
case strings.Contains(strings.ToLower(t), "national id"):
|
||||
doc.Kind = National
|
||||
default:
|
||||
doc.Kind = Other
|
||||
}
|
||||
entry.Documents = append(entry.Documents, doc)
|
||||
}
|
||||
for _, a := range e.Addresses {
|
||||
if v := join(a.One, a.Two, a.Three, a.City, a.Province, a.Postal, a.Country); v != "" {
|
||||
entry.Addresses = append(entry.Addresses, v)
|
||||
}
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ofacKind(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "individual":
|
||||
return Individual
|
||||
case "vessel":
|
||||
return Vessel
|
||||
case "aircraft":
|
||||
return Aircraft
|
||||
default:
|
||||
return Entity
|
||||
}
|
||||
}
|
||||
|
||||
func akaType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "f.k.a.":
|
||||
return Formerly
|
||||
case "n.k.a.":
|
||||
return Now
|
||||
default:
|
||||
return Also
|
||||
}
|
||||
}
|
||||
|
||||
func isMain(v string) bool { return strings.EqualFold(strings.TrimSpace(v), "true") }
|
||||
|
||||
func trimAll(in []string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseOFSI reads the UK consolidated list of financial sanctions targets.
|
||||
//
|
||||
// The file is a CSV with two properties a straightforward reader gets wrong.
|
||||
// First, the real header is on the second line: the first line is a
|
||||
// "Last Updated,<date>" preamble, so a default reader takes the preamble as the
|
||||
// header and every column name is then wrong. Second, the file holds one row per
|
||||
// name, not per subject — 19,761 rows describe 5,135 subjects — joined by a group
|
||||
// identifier. Parsing each row as a separate designation multiplies every alert
|
||||
// by the number of aliases the target happens to have.
|
||||
func ParseOFSI(data []byte) ([]Entry, error) {
|
||||
r := csv.NewReader(bytes.NewReader(data))
|
||||
r.FieldsPerRecord = -1
|
||||
r.LazyQuotes = true
|
||||
|
||||
first, err := r.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ofsi conlist: %w", err)
|
||||
}
|
||||
// The preamble is a short row naming the publication date; the header is wide.
|
||||
updated := ""
|
||||
head := first
|
||||
if len(first) <= 2 {
|
||||
if len(first) == 2 {
|
||||
updated = strings.TrimSpace(first[1])
|
||||
}
|
||||
head, err = r.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ofsi conlist: reading header after preamble: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
col := make(map[string]int, len(head))
|
||||
for i, h := range head {
|
||||
col[strings.TrimSpace(strings.TrimPrefix(h, "\ufeff"))] = i
|
||||
}
|
||||
for _, want := range []string{"Name 1", "Name 6", "Group Type", "Group ID", "Regime"} {
|
||||
if _, ok := col[want]; !ok {
|
||||
return nil, fmt.Errorf("ofsi conlist: column %q not found — the layout has changed and the parser must be updated, not guessed at", want)
|
||||
}
|
||||
}
|
||||
|
||||
get := func(rec []string, name string) string {
|
||||
i, ok := col[name]
|
||||
if !ok || i >= len(rec) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(rec[i])
|
||||
}
|
||||
|
||||
// Rows sharing a group identifier are one subject. The group is accumulated
|
||||
// rather than replaced so that names, dates and documents from every row land
|
||||
// on the same entry.
|
||||
byGroup := make(map[string]*Entry, 6000)
|
||||
var order []string
|
||||
|
||||
for {
|
||||
rec, err := r.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ofsi conlist: %w", err)
|
||||
}
|
||||
group := get(rec, "Group ID")
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
e, ok := byGroup[group]
|
||||
if !ok {
|
||||
e = &Entry{
|
||||
List: OFSI,
|
||||
RefID: group,
|
||||
Group: group,
|
||||
Kind: ofsiKind(get(rec, "Group Type")),
|
||||
UpdatedOn: updated,
|
||||
ListedOn: get(rec, "Listed On"),
|
||||
}
|
||||
if reg := get(rec, "Regime"); reg != "" {
|
||||
e.Programs = append(e.Programs, reg)
|
||||
}
|
||||
byGroup[group] = e
|
||||
order = append(order, group)
|
||||
}
|
||||
|
||||
// Name 6 holds the family name and Name 1 through Name 5 the given names,
|
||||
// in that order — the header lists Name 6 first, which is why the parser
|
||||
// addresses columns by name and never by position.
|
||||
full := join(get(rec, "Name 1"), get(rec, "Name 2"), get(rec, "Name 3"),
|
||||
get(rec, "Name 4"), get(rec, "Name 5"), get(rec, "Name 6"))
|
||||
if full != "" {
|
||||
e.Names = append(e.Names, Name{
|
||||
Full: full,
|
||||
Type: ofsiNameType(get(rec, "Alias Type")),
|
||||
Strong: !strings.EqualFold(get(rec, "Alias Quality"), "Low quality"),
|
||||
Script: get(rec, "Name Non-Latin Script"),
|
||||
})
|
||||
}
|
||||
|
||||
if b, ok := parseSlashBirth(get(rec, "DOB")); ok {
|
||||
e.Births = appendBirth(e.Births, b)
|
||||
}
|
||||
if v := join(get(rec, "Town of Birth"), get(rec, "Country of Birth")); v != "" {
|
||||
e.Places = appendUnique(e.Places, v)
|
||||
}
|
||||
if v := get(rec, "Nationality"); v != "" {
|
||||
e.Nationalities = appendUnique(e.Nationalities, v)
|
||||
}
|
||||
if v := get(rec, "Passport Number"); v != "" {
|
||||
e.Documents = append(e.Documents, Document{Kind: Passport, Number: v})
|
||||
}
|
||||
if v := get(rec, "National Identification Number"); v != "" {
|
||||
e.Documents = append(e.Documents, Document{Kind: National, Number: v})
|
||||
}
|
||||
if v := join(get(rec, "Address 1"), get(rec, "Address 2"), get(rec, "Address 3"),
|
||||
get(rec, "Address 4"), get(rec, "Address 5"), get(rec, "Address 6"),
|
||||
get(rec, "Post/Zip Code"), get(rec, "Country")); v != "" {
|
||||
e.Addresses = appendUnique(e.Addresses, v)
|
||||
}
|
||||
if v := get(rec, "Other Information"); v != "" && e.Remarks == "" {
|
||||
e.Remarks = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) == 0 {
|
||||
return nil, fmt.Errorf("ofsi conlist: no records — refusing an empty list")
|
||||
}
|
||||
out := make([]Entry, 0, len(order))
|
||||
for _, g := range order {
|
||||
e := byGroup[g]
|
||||
promotePrimary(e)
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ofsiKind reads the UK group type. The UK designates ships, which the model
|
||||
// records as vessels.
|
||||
func ofsiKind(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "individual":
|
||||
return Individual
|
||||
case "ship":
|
||||
return Vessel
|
||||
default:
|
||||
return Entity
|
||||
}
|
||||
}
|
||||
|
||||
// ofsiNameType reads the UK alias type. A primary name variation is a spelling of
|
||||
// the target's own name rather than a separate alias, so it is recorded as an
|
||||
// alias but never displaces the primary.
|
||||
func ofsiNameType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "primary name":
|
||||
return Primary
|
||||
case "fka":
|
||||
return Formerly
|
||||
default:
|
||||
return Also
|
||||
}
|
||||
}
|
||||
|
||||
// promotePrimary makes sure an entry has exactly one primary name. The UK file
|
||||
// does not guarantee that the primary row is read first, and an entry whose only
|
||||
// names are aliases would otherwise report no primary name at all.
|
||||
func promotePrimary(e *Entry) {
|
||||
seen := -1
|
||||
for i, n := range e.Names {
|
||||
if n.Type == Primary {
|
||||
if seen == -1 {
|
||||
seen = i
|
||||
} else {
|
||||
e.Names[i].Type = Also
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen == -1 && len(e.Names) > 0 {
|
||||
e.Names[0].Type = Primary
|
||||
}
|
||||
}
|
||||
|
||||
func appendBirth(list []Birth, b Birth) []Birth {
|
||||
for _, x := range list {
|
||||
if x.From == b.From && x.To == b.To {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, b)
|
||||
}
|
||||
|
||||
func appendUnique(list []string, v string) []string {
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, v) {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, v)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
const sanctionsCollection = "aml_sanctions"
|
||||
|
||||
// SanctionsStore is the interface for persistent sanctions data.
|
||||
type SanctionsStore interface {
|
||||
// Save persists a batch of sanctions entries.
|
||||
Save(ctx context.Context, entries []types.SanctionsEntry) error
|
||||
// Search finds entries matching a name above the match threshold.
|
||||
Search(ctx context.Context, name, dob string) ([]SearchResult, error)
|
||||
// Count returns the total number of entries.
|
||||
Count(ctx context.Context) (int, error)
|
||||
// LastRefresh returns the time of the most recent data load.
|
||||
LastRefresh(ctx context.Context) (time.Time, error)
|
||||
}
|
||||
|
||||
// SearchResult is a sanctions search match.
|
||||
type SearchResult struct {
|
||||
Entry types.SanctionsEntry
|
||||
Score float64
|
||||
}
|
||||
|
||||
// BaseSanctionsStore implements SanctionsStore using Base collections.
|
||||
type BaseSanctionsStore struct {
|
||||
app core.App
|
||||
lastRefresh time.Time
|
||||
}
|
||||
|
||||
// NewBaseSanctionsStore creates a BaseSanctionsStore.
|
||||
func NewBaseSanctionsStore(app core.App) *BaseSanctionsStore {
|
||||
return &BaseSanctionsStore{app: app}
|
||||
}
|
||||
|
||||
func (s *BaseSanctionsStore) Save(ctx context.Context, entries []types.SanctionsEntry) error {
|
||||
collection, err := s.app.FindCollectionByNameOrId(sanctionsCollection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find sanctions collection: %w", err)
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("ref_id", e.RefID)
|
||||
record.Set("list_id", e.ListID)
|
||||
record.Set("name", e.Name)
|
||||
record.Set("aliases", strings.Join(e.Aliases, "|"))
|
||||
record.Set("dob", e.DOB)
|
||||
record.Set("nationality", e.Nationality)
|
||||
record.Set("address", e.Address)
|
||||
record.Set("entry_type", e.Type)
|
||||
|
||||
if err := s.app.Save(record); err != nil {
|
||||
return fmt.Errorf("save sanctions entry %s: %w", e.RefID, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.lastRefresh = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaseSanctionsStore) Search(ctx context.Context, name, dob string) ([]SearchResult, error) {
|
||||
records, err := s.app.FindRecordsByFilter(
|
||||
sanctionsCollection,
|
||||
"", // no filter — we do fuzzy matching in-process
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search sanctions: %w", err)
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
for _, r := range records {
|
||||
entryName := r.GetString("name")
|
||||
score := TokenMatch(name, entryName)
|
||||
if score >= MatchThreshold {
|
||||
results = append(results, SearchResult{
|
||||
Entry: recordToEntry(r),
|
||||
Score: score,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Check aliases.
|
||||
aliasStr := r.GetString("aliases")
|
||||
if aliasStr != "" {
|
||||
for _, alias := range strings.Split(aliasStr, "|") {
|
||||
aliasScore := TokenMatch(name, alias)
|
||||
if aliasScore >= MatchThreshold && aliasScore > score {
|
||||
results = append(results, SearchResult{
|
||||
Entry: recordToEntry(r),
|
||||
Score: aliasScore,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *BaseSanctionsStore) Count(ctx context.Context) (int, error) {
|
||||
records, err := s.app.FindRecordsByFilter(sanctionsCollection, "", "", 0, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (s *BaseSanctionsStore) LastRefresh(_ context.Context) (time.Time, error) {
|
||||
return s.lastRefresh, nil
|
||||
}
|
||||
|
||||
func recordToEntry(r *core.Record) types.SanctionsEntry {
|
||||
aliasStr := r.GetString("aliases")
|
||||
var aliases []string
|
||||
if aliasStr != "" {
|
||||
aliases = strings.Split(aliasStr, "|")
|
||||
}
|
||||
return types.SanctionsEntry{
|
||||
ID: r.Id,
|
||||
ListID: r.GetString("list_id"),
|
||||
RefID: r.GetString("ref_id"),
|
||||
Name: r.GetString("name"),
|
||||
Aliases: aliases,
|
||||
DOB: r.GetString("dob"),
|
||||
Nationality: r.GetString("nationality"),
|
||||
Address: r.GetString("address"),
|
||||
Type: r.GetString("entry_type"),
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// MemorySanctionsStore is an in-memory SanctionsStore for testing.
|
||||
type MemorySanctionsStore struct {
|
||||
mu sync.RWMutex
|
||||
entries []types.SanctionsEntry
|
||||
lastRefresh time.Time
|
||||
}
|
||||
|
||||
func NewMemorySanctionsStore() *MemorySanctionsStore {
|
||||
return &MemorySanctionsStore{}
|
||||
}
|
||||
|
||||
func (m *MemorySanctionsStore) Save(_ context.Context, entries []types.SanctionsEntry) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.entries = append(m.entries, entries...)
|
||||
m.lastRefresh = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemorySanctionsStore) Search(_ context.Context, name, dob string) ([]SearchResult, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var results []SearchResult
|
||||
for _, e := range m.entries {
|
||||
score := TokenMatch(name, e.Name)
|
||||
if score >= MatchThreshold {
|
||||
results = append(results, SearchResult{Entry: e, Score: score})
|
||||
continue
|
||||
}
|
||||
for _, alias := range e.Aliases {
|
||||
aliasScore := TokenMatch(name, alias)
|
||||
if aliasScore >= MatchThreshold {
|
||||
results = append(results, SearchResult{Entry: e, Score: aliasScore})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (m *MemorySanctionsStore) Count(_ context.Context) (int, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return len(m.entries), nil
|
||||
}
|
||||
|
||||
func (m *MemorySanctionsStore) LastRefresh(_ context.Context) (time.Time, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.lastRefresh, nil
|
||||
}
|
||||
|
||||
func TestMemorySanctionsStore_Save(t *testing.T) {
|
||||
store := NewMemorySanctionsStore()
|
||||
entries := []types.SanctionsEntry{
|
||||
{ID: "e-1", Name: "John Doe", Type: types.SanctionIndividual},
|
||||
{ID: "e-2", Name: "Acme Corp", Type: types.SanctionEntity},
|
||||
}
|
||||
err := store.Save(context.Background(), entries)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
count, err := store.Count(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySanctionsStore_Search(t *testing.T) {
|
||||
store := NewMemorySanctionsStore()
|
||||
store.Save(context.Background(), []types.SanctionsEntry{
|
||||
{ID: "e-1", Name: "John Smith", Aliases: []string{"J. Smith", "Johnny Smith"}},
|
||||
{ID: "e-2", Name: "Jane Doe"},
|
||||
{ID: "e-3", Name: "Unrelated Person"},
|
||||
})
|
||||
|
||||
// Exact-ish match
|
||||
results, err := store.Search(context.Background(), "John Smith", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least 1 result for 'John Smith'")
|
||||
}
|
||||
if results[0].Entry.Name != "John Smith" {
|
||||
t.Fatalf("expected John Smith, got %q", results[0].Entry.Name)
|
||||
}
|
||||
|
||||
// No match
|
||||
results, err = store.Search(context.Background(), "Totally Different Name", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected 0 results, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySanctionsStore_SearchAlias(t *testing.T) {
|
||||
store := NewMemorySanctionsStore()
|
||||
store.Save(context.Background(), []types.SanctionsEntry{
|
||||
{ID: "e-alias", Name: "Muhammad al-Bashir", Aliases: []string{"Omar al-Bashir", "Bashir"}},
|
||||
})
|
||||
|
||||
results, err := store.Search(context.Background(), "Omar al-Bashir", "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected alias match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySanctionsStore_LastRefresh(t *testing.T) {
|
||||
store := NewMemorySanctionsStore()
|
||||
|
||||
before, _ := store.LastRefresh(context.Background())
|
||||
if !before.IsZero() {
|
||||
t.Fatal("expected zero time before any save")
|
||||
}
|
||||
|
||||
store.Save(context.Background(), []types.SanctionsEntry{
|
||||
{ID: "e-1", Name: "Test"},
|
||||
})
|
||||
|
||||
after, _ := store.LastRefresh(context.Background())
|
||||
if after.IsZero() {
|
||||
t.Fatal("expected non-zero time after save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanctionsStoreInterface(t *testing.T) {
|
||||
var _ SanctionsStore = (*MemorySanctionsStore)(nil)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPublishedLists parses the real published files.
|
||||
//
|
||||
// It runs only when AML_LISTS points at a directory holding sdn.xml, un.xml,
|
||||
// eu.xml and ofsi.csv as downloaded from the publishers. A fixture proves the
|
||||
// parser handles the shapes we thought to write down; only the published file
|
||||
// proves it handles the shapes the publisher actually emits, and every defect
|
||||
// this work fixed in the previous parser was of the second kind.
|
||||
func TestPublishedLists(t *testing.T) {
|
||||
dir := os.Getenv("AML_LISTS")
|
||||
if dir == "" {
|
||||
t.Skip("set AML_LISTS to a directory of published list files to run")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
file string
|
||||
parse func([]byte) ([]Entry, error)
|
||||
least int
|
||||
}{
|
||||
{"sdn.xml", ParseOFAC, 19000},
|
||||
{"un.xml", ParseUN, 900},
|
||||
{"eu.xml", ParseEU, 5000},
|
||||
{"ofsi.csv", ParseOFSI, 4000},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.file, func(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, c.file))
|
||||
if err != nil {
|
||||
t.Skipf("%v", err)
|
||||
}
|
||||
entries, err := c.parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(entries) < c.least {
|
||||
t.Fatalf("parsed %d entries, expected at least %d", len(entries), c.least)
|
||||
}
|
||||
|
||||
var noName, withBirth, withDoc, withProgram, weak, addrs int
|
||||
kinds := map[string]int{}
|
||||
for _, e := range entries {
|
||||
if e.PrimaryName() == "" {
|
||||
noName++
|
||||
}
|
||||
if len(e.Births) > 0 {
|
||||
withBirth++
|
||||
}
|
||||
if len(e.Documents) > 0 {
|
||||
withDoc++
|
||||
}
|
||||
if len(e.Programs) > 0 {
|
||||
withProgram++
|
||||
}
|
||||
for _, n := range e.Names {
|
||||
if !n.Strong {
|
||||
weak++
|
||||
}
|
||||
}
|
||||
addrs += len(e.Chains())
|
||||
kinds[e.Kind]++
|
||||
}
|
||||
t.Logf("%s: %d entries, kinds=%v, births=%d, documents=%d, programmes=%d, weak names=%d, chain addresses=%d",
|
||||
c.file, len(entries), kinds, withBirth, withDoc, withProgram, weak, addrs)
|
||||
|
||||
// A subject with no name at all cannot be screened. One is a parser bug.
|
||||
if noName > 0 {
|
||||
t.Errorf("%d entries parsed with no name — those subjects are unscreenable", noName)
|
||||
}
|
||||
if len(kinds) == 0 {
|
||||
t.Error("no subject kinds recorded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/base/core"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
)
|
||||
|
||||
// RefreshCron registers a daily 06:00 UTC cron that fetches all sanctions
|
||||
// lists, diffs against stored entries, and persists updates.
|
||||
func RefreshCron(app core.App, store SanctionsStore) {
|
||||
app.Cron().Add("sanctions-refresh", "0 6 * * *", func() {
|
||||
if err := RefreshAll(context.Background(), store); err != nil {
|
||||
log.Printf("[sanctions-refresh] error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshAll fetches OFAC, UN, EU, and HMT lists, and persists them
|
||||
// via the SanctionsStore. Returns the count of new entries added.
|
||||
func RefreshAll(ctx context.Context, store SanctionsStore) error {
|
||||
ingester := NewIngester()
|
||||
lists := DefaultLists()
|
||||
|
||||
var totalEntries int
|
||||
for _, list := range lists {
|
||||
if !list.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
var entries []types.SanctionsEntry
|
||||
var hash string
|
||||
var err error
|
||||
|
||||
switch list.Source {
|
||||
case types.ListOFACSDN:
|
||||
entries, hash, err = ingester.FetchOFAC(ctx, list.URL)
|
||||
default:
|
||||
// UN, EU, HMT use the same OFAC parser for now — their XML
|
||||
// structures are similar enough. Production should have dedicated
|
||||
// parsers per list format.
|
||||
entries, hash, err = ingester.FetchOFAC(ctx, list.URL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[sanctions-refresh] fetch %s failed: %v", list.Source, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Tag entries with list source.
|
||||
for i := range entries {
|
||||
entries[i].ListID = list.Source
|
||||
entries[i].ID = fmt.Sprintf("%s-%s", list.Source, entries[i].RefID)
|
||||
now := time.Now().UTC()
|
||||
entries[i].CreatedAt = now
|
||||
entries[i].UpdatedAt = now
|
||||
}
|
||||
|
||||
if err := store.Save(ctx, entries); err != nil {
|
||||
log.Printf("[sanctions-refresh] save %s failed: %v", list.Source, err)
|
||||
continue
|
||||
}
|
||||
|
||||
totalEntries += len(entries)
|
||||
log.Printf("[sanctions-refresh] %s: %d entries (hash: %s)", list.Source, len(entries), hash[:16])
|
||||
}
|
||||
|
||||
log.Printf("[sanctions-refresh] complete: %d total entries across %d lists", totalEntries, len(lists))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package sanctions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// unList is the root of the UN Security Council consolidated list.
|
||||
type unList struct {
|
||||
Generated string `xml:"dateGenerated,attr"`
|
||||
Individuals []unRecord `xml:"INDIVIDUALS>INDIVIDUAL"`
|
||||
Entities []unRecord `xml:"ENTITIES>ENTITY"`
|
||||
}
|
||||
|
||||
// unRecord covers both record kinds. The two differ only in which optional
|
||||
// elements appear, so one struct reads both and the caller states the kind.
|
||||
type unRecord struct {
|
||||
DataID string `xml:"DATAID"`
|
||||
First string `xml:"FIRST_NAME"`
|
||||
Second string `xml:"SECOND_NAME"`
|
||||
Third string `xml:"THIRD_NAME"`
|
||||
Fourth string `xml:"FOURTH_NAME"`
|
||||
Script string `xml:"NAME_ORIGINAL_SCRIPT"`
|
||||
ListType string `xml:"UN_LIST_TYPE"`
|
||||
Reference string `xml:"REFERENCE_NUMBER"`
|
||||
ListedOn string `xml:"LISTED_ON"`
|
||||
Comments string `xml:"COMMENTS1"`
|
||||
Updated []string `xml:"LAST_DAY_UPDATED>VALUE"`
|
||||
Nationality []string `xml:"NATIONALITY>VALUE"`
|
||||
Aliases []struct {
|
||||
Quality string `xml:"QUALITY"`
|
||||
Name string `xml:"ALIAS_NAME"`
|
||||
} `xml:"INDIVIDUAL_ALIAS"`
|
||||
EntityAliases []struct {
|
||||
Quality string `xml:"QUALITY"`
|
||||
Name string `xml:"ALIAS_NAME"`
|
||||
} `xml:"ENTITY_ALIAS"`
|
||||
Births []struct {
|
||||
Type string `xml:"TYPE_OF_DATE"`
|
||||
Date string `xml:"DATE"`
|
||||
Year string `xml:"YEAR"`
|
||||
From string `xml:"FROM_YEAR"`
|
||||
To string `xml:"TO_YEAR"`
|
||||
} `xml:"INDIVIDUAL_DATE_OF_BIRTH"`
|
||||
Places []struct {
|
||||
City string `xml:"CITY"`
|
||||
Province string `xml:"STATE_PROVINCE"`
|
||||
Country string `xml:"COUNTRY"`
|
||||
} `xml:"INDIVIDUAL_PLACE_OF_BIRTH"`
|
||||
Documents []struct {
|
||||
Type string `xml:"TYPE_OF_DOCUMENT"`
|
||||
Number string `xml:"NUMBER"`
|
||||
Country string `xml:"ISSUING_COUNTRY"`
|
||||
} `xml:"INDIVIDUAL_DOCUMENT"`
|
||||
Addresses []struct {
|
||||
Street string `xml:"STREET"`
|
||||
City string `xml:"CITY"`
|
||||
Province string `xml:"STATE_PROVINCE"`
|
||||
Country string `xml:"COUNTRY"`
|
||||
} `xml:"INDIVIDUAL_ADDRESS"`
|
||||
EntityAddresses []struct {
|
||||
Street string `xml:"STREET"`
|
||||
City string `xml:"CITY"`
|
||||
Province string `xml:"STATE_PROVINCE"`
|
||||
Country string `xml:"COUNTRY"`
|
||||
} `xml:"ENTITY_ADDRESS"`
|
||||
}
|
||||
|
||||
// ParseUN reads the UN Security Council consolidated list.
|
||||
func ParseUN(data []byte) ([]Entry, error) {
|
||||
var doc unList
|
||||
if err := decode(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("un consolidated: %w", err)
|
||||
}
|
||||
if len(doc.Individuals)+len(doc.Entities) == 0 {
|
||||
return nil, fmt.Errorf("un consolidated: no records — refusing an empty list")
|
||||
}
|
||||
out := make([]Entry, 0, len(doc.Individuals)+len(doc.Entities))
|
||||
for _, r := range doc.Individuals {
|
||||
out = append(out, unEntry(r, Individual))
|
||||
}
|
||||
for _, r := range doc.Entities {
|
||||
out = append(out, unEntry(r, Entity))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func unEntry(r unRecord, kind string) Entry {
|
||||
e := Entry{
|
||||
List: UN,
|
||||
RefID: strings.TrimSpace(r.DataID),
|
||||
Group: strings.TrimSpace(r.DataID),
|
||||
Kind: kind,
|
||||
Remarks: strings.TrimSpace(r.Comments),
|
||||
ListedOn: strings.TrimSpace(r.ListedOn),
|
||||
}
|
||||
if v := strings.TrimSpace(r.ListType); v != "" {
|
||||
e.Programs = append(e.Programs, v)
|
||||
}
|
||||
if strings.TrimSpace(r.Reference) != "" {
|
||||
e.RefID = strings.TrimSpace(r.Reference)
|
||||
}
|
||||
for _, u := range r.Updated {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
e.UpdatedOn = u
|
||||
}
|
||||
}
|
||||
|
||||
// An individual's name is split across four ordered parts; an entity carries
|
||||
// its whole name in the first.
|
||||
e.Names = append(e.Names, Name{
|
||||
Full: join(r.First, r.Second, r.Third, r.Fourth),
|
||||
Type: Primary,
|
||||
Strong: true,
|
||||
Script: strings.TrimSpace(r.Script),
|
||||
})
|
||||
for _, a := range append(r.Aliases, r.EntityAliases...) {
|
||||
full := strings.TrimSpace(a.Name)
|
||||
if full == "" {
|
||||
continue
|
||||
}
|
||||
e.Names = append(e.Names, Name{Full: full, Type: unAliasType(a.Quality), Strong: unStrong(a.Quality)})
|
||||
}
|
||||
|
||||
for _, b := range r.Births {
|
||||
if bd, ok := unBirth(b.Type, b.Date, b.Year, b.From, b.To); ok {
|
||||
e.Births = append(e.Births, bd)
|
||||
}
|
||||
}
|
||||
for _, p := range r.Places {
|
||||
if v := join(p.City, p.Province, p.Country); v != "" {
|
||||
e.Places = append(e.Places, v)
|
||||
}
|
||||
}
|
||||
for _, n := range r.Nationality {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
e.Nationalities = append(e.Nationalities, n)
|
||||
}
|
||||
}
|
||||
for _, d := range r.Documents {
|
||||
num := strings.TrimSpace(d.Number)
|
||||
if num == "" {
|
||||
continue
|
||||
}
|
||||
kind := Other
|
||||
if strings.Contains(strings.ToLower(d.Type), "passport") {
|
||||
kind = Passport
|
||||
} else if strings.Contains(strings.ToLower(d.Type), "national") {
|
||||
kind = National
|
||||
}
|
||||
e.Documents = append(e.Documents, Document{Kind: kind, Number: num, Country: strings.TrimSpace(d.Country)})
|
||||
}
|
||||
for _, a := range append(r.Addresses, r.EntityAddresses...) {
|
||||
if v := join(a.Street, a.City, a.Province, a.Country); v != "" {
|
||||
e.Addresses = append(e.Addresses, v)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// unStrong reads the UN alias QUALITY field.
|
||||
//
|
||||
// The field mixes two meanings: it carries confidence ("Good", "Low") on most
|
||||
// aliases and an alias type ("a.k.a.", "f.k.a.") on others. Only an explicit Low
|
||||
// marks a weak name; a value stating a type says nothing about confidence and is
|
||||
// therefore not treated as a downgrade.
|
||||
func unStrong(q string) bool {
|
||||
return !strings.EqualFold(strings.TrimSpace(q), "low")
|
||||
}
|
||||
|
||||
func unAliasType(q string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(q)) {
|
||||
case "f.k.a.":
|
||||
return Formerly
|
||||
default:
|
||||
return Also
|
||||
}
|
||||
}
|
||||
|
||||
// unBirth reads a UN date of birth, which is an exact date, a year, an
|
||||
// approximate year, or a range between two years.
|
||||
func unBirth(kind, date, year, from, to string) (Birth, bool) {
|
||||
kind = strings.ToUpper(strings.TrimSpace(kind))
|
||||
circa := kind == "APPROXIMATELY"
|
||||
if d := strings.TrimSpace(date); d != "" {
|
||||
return Birth{From: d, To: d, Circa: circa}, true
|
||||
}
|
||||
if kind == "BETWEEN" {
|
||||
f, t := strings.TrimSpace(from), strings.TrimSpace(to)
|
||||
if f != "" && t != "" {
|
||||
return Birth{From: f, To: t, Circa: circa}, true
|
||||
}
|
||||
}
|
||||
if y := strings.TrimSpace(year); y != "" {
|
||||
return Birth{From: y, To: y, Circa: circa}, true
|
||||
}
|
||||
return Birth{}, false
|
||||
}
|
||||
+35
-20
@@ -5,6 +5,8 @@ package types
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/standard"
|
||||
)
|
||||
|
||||
// Severity levels for rules and alerts.
|
||||
@@ -131,31 +133,44 @@ type Entity struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Rule is an AML evaluation rule using expr-lang DSL.
|
||||
// Rule is one detection expressed over the evaluation vocabulary.
|
||||
//
|
||||
// Typology names the laundering pattern the rule detects and Citations name the
|
||||
// published standards that require or describe it. Together they are what makes
|
||||
// a coverage claim checkable: a reviewer reads the citation, reads the
|
||||
// expression, and decides whether the second implements the first.
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DSL string `json:"dsl"`
|
||||
Severity string `json:"severity"`
|
||||
Weight float64 `json:"weight"`
|
||||
Action string `json:"action"`
|
||||
Enabled bool `json:"enabled"`
|
||||
JurisdictionFilter []string `json:"jurisdiction_filter,omitempty"`
|
||||
AssetClassFilter []string `json:"asset_class_filter,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Typology string `json:"typology,omitempty"`
|
||||
Citations []standard.Citation `json:"citations,omitempty"`
|
||||
DSL string `json:"dsl"`
|
||||
Severity string `json:"severity"`
|
||||
Weight float64 `json:"weight"`
|
||||
Action string `json:"action"`
|
||||
Enabled bool `json:"enabled"`
|
||||
JurisdictionFilter []string `json:"jurisdiction_filter,omitempty"`
|
||||
AssetClassFilter []string `json:"asset_class_filter,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Alert is a rule hit — one per transaction per rule.
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
TxID string `json:"tx_id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"org_id"`
|
||||
TxID string `json:"tx_id"`
|
||||
RuleID string `json:"rule_id"`
|
||||
RuleName string `json:"rule_name"`
|
||||
Typology string `json:"typology,omitempty"`
|
||||
Citations []standard.Citation `json:"citations,omitempty"`
|
||||
// EvalErr records why the rule could not reach a verdict, when it could not.
|
||||
// An alert carrying a failure is not a detection; it is a transaction nobody
|
||||
// has assessed, and it is routed to review on that basis.
|
||||
EvalErr string `json:"eval_error,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Score float64 `json:"score"`
|
||||
ScoreBreakdown map[string]float64 `json:"score_breakdown,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user