mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Two branches had independently rebuilt the same two packages, which is one
implementation too many. This resolves them into one.
Their side is the base for detection and screening, because it is better:
- Rules name evidence, and SetRules refuses a set whose evidence this
deployment cannot supply — so the eleven stub helpers that silently disabled
thirteen rules cannot exist. A rule that cannot fire is worse than an absent
one, because it still appears in the catalog a regulator reads.
- One screening store, filled by the refresh and read by the endpoints. Two of
them is why sanctions search answered "no match" for every name while
designations were being loaded somewhere else.
- Parsers for all four publishers against their real files, recovering what the
single-parser path could not read at all: OFAC's digital-currency addresses,
weak aliases held apart from screening candidates, and OFSI's row-per-name
layout collapsed to subjects.
- Name matching over runes, not bytes. Two distinct Han names scored 0.867
byte-wise, above the 0.85 threshold, and 0.700 correctly.
- Deviation from the median and MAD rather than the mean, because a monitoring
baseline contains the outliers it is used to hunt.
- A rule vocabulary with providers behind every term, in place of expressions
reaching into a struct.
Kept from this side, because their branch did not have it:
- The tenant comes from an authenticated identity, not from a request header.
- A rule that could not run carries no weight. Their action() already capped a
failing rule at review, which is right, but the hit still entered the score
at full weight — so a broken rule saturated every transaction's score and the
queue stopped ranking. Faults are now excluded from the aggregate while still
raising their own alert and still forcing review.
- Per-source readiness: which list, how many designations, how long ago, and
the reason if the last attempt failed. Their store answers whether screening
can be relied on at all; this answers what is behind it, which is what an
operator can act on.
- Behavioural scoring, retention, tokenisation, replay and reference data.
Conversion now refuses an unknown currency instead of passing the amount
through. Passing it through understates every currency worth more than a dollar,
so a payment three times over a reporting limit tested as sitting under it.
One ordering table for actions (types.ActionRank), one EvalAll signature, one
screening store, one readiness surface. 15 packages, tests and race clean.
Co-authored-by: Hanzo Dev <[email protected]>
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
// 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"
|
|
"math"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
// Engine evaluates transactions against an admitted rule set.
|
|
type Engine struct {
|
|
eval *Evaluator
|
|
|
|
mu sync.RWMutex
|
|
rules []types.Rule
|
|
scorer Scorer
|
|
|
|
faults atomic.Int64
|
|
rejects atomic.Int64
|
|
}
|
|
|
|
// 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 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 {
|
|
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 installed rule set.
|
|
func (e *Engine) Rules() []types.Rule {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
out := make([]types.Rule, len(e.rules))
|
|
copy(out, e.rules)
|
|
return out
|
|
}
|
|
|
|
// Evaluator 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 }
|
|
|
|
// SetScorer installs the Scorer consulted alongside the rules, or nil to run on
|
|
// rules alone.
|
|
func (e *Engine) SetScorer(s Scorer) {
|
|
e.mu.Lock()
|
|
e.scorer = s
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
// ScorerFaults counts the times the Scorer's evidence was discarded — a panic, a
|
|
// negative weight, or a weight that is not a number.
|
|
//
|
|
// It has to be readable. A Scorer that faults on every transaction contributes
|
|
// nothing while the engine keeps answering, and this counter is the only thing
|
|
// that distinguishes that from a Scorer finding nothing to report.
|
|
func (e *Engine) ScorerFaults() int64 { return e.faults.Load() + e.rejects.Load() }
|
|
|
|
// assess consults the Scorer, and confines it.
|
|
//
|
|
// Three things a Scorer must not be able to do, each enforced here rather than
|
|
// trusted to the Scorer, because the property has to hold for a broken one as
|
|
// well as an honest one:
|
|
//
|
|
// It must not take the rule plane down. Every rule has already been evaluated by
|
|
// the time this runs, and losing that verdict to a fault in the statistical plane
|
|
// would turn a degraded control into no control — so a panic is contained,
|
|
// counted, and the rules' answer stands.
|
|
//
|
|
// It must not weaken a verdict. Weight-of-evidence is a sum, so a negative weight
|
|
// would subtract from a score the rules built and a model could argue a
|
|
// transaction down. Non-negative weights are already required of rules; a
|
|
// computed weight is held to the same bar, along with the NaN a computation can
|
|
// produce and a constant cannot.
|
|
//
|
|
// It must not act. Its action is capped at ActionCeiling, so the strongest thing a
|
|
// statistical judgement can do is put a transaction in front of a person.
|
|
func (e *Engine) assess(tx types.Transaction, ent types.Entity) (hit types.RuleHit, ok bool) {
|
|
e.mu.RLock()
|
|
sc := e.scorer
|
|
e.mu.RUnlock()
|
|
if sc == nil {
|
|
return types.RuleHit{}, false
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
e.faults.Add(1)
|
|
hit, ok = types.RuleHit{}, false
|
|
}
|
|
}()
|
|
|
|
hit, ok = sc.Assess(tx, ent)
|
|
if !ok {
|
|
return types.RuleHit{}, false
|
|
}
|
|
if hit.Rule.Weight < 0 || math.IsNaN(hit.Rule.Weight) || math.IsInf(hit.Rule.Weight, 0) {
|
|
e.rejects.Add(1)
|
|
return types.RuleHit{}, false
|
|
}
|
|
if types.ActionRank(hit.Rule.Action) > types.ActionRank(types.ActionCeiling) {
|
|
hit.Rule.Action = types.ActionCeiling
|
|
}
|
|
hit.Match = true
|
|
return hit, true
|
|
}
|
|
|
|
// 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()
|
|
|
|
hits := e.eval.EvalAll(ctx, rules, tx, ent)
|
|
|
|
// The Scorer contributes evidence the rule library cannot express. It is
|
|
// consulted after the rules so it can only add to what they found: it can
|
|
// raise a transaction no rule matched, which is why it exists, and cannot
|
|
// lower one they did.
|
|
if hit, ok := e.assess(tx, ent); ok {
|
|
hits = append(hits, hit)
|
|
}
|
|
if len(hits) == 0 {
|
|
return nil, 0, types.ActionAllow
|
|
}
|
|
|
|
// Only rules that reached a verdict carry weight. A rule that failed to
|
|
// evaluate has established nothing about this transaction, and because a rule
|
|
// that fails once fails on every transaction, letting it score saturates every
|
|
// score and the queue stops ranking — which is how an ordinary payment comes
|
|
// back at the maximum. It still raises its own alert naming its own failure,
|
|
// and action() still sends that to a person.
|
|
evidential := make([]types.RuleHit, 0, len(hits))
|
|
for _, h := range hits {
|
|
if h.EvalErr == "" {
|
|
evidential = append(evidential, h)
|
|
}
|
|
}
|
|
|
|
score, breakdown := Score(evidential)
|
|
|
|
now := time.Now().UTC()
|
|
alerts := make([]types.Alert, 0, len(hits))
|
|
for _, h := range hits {
|
|
alerts = append(alerts, types.Alert{
|
|
ID: uuid.NewString(),
|
|
OrgID: tx.OrgID,
|
|
TxID: tx.ID,
|
|
RuleID: h.Rule.ID,
|
|
RuleName: h.Rule.Name,
|
|
Typology: h.Rule.Typology,
|
|
Citations: h.Rule.Citations,
|
|
Severity: h.Rule.Severity,
|
|
Score: breakdown[h.Rule.ID],
|
|
ScoreBreakdown: breakdown,
|
|
ActionTaken: action(h),
|
|
Causes: h.Causes,
|
|
EvalErr: h.EvalErr,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
return alerts, score, resolve(alerts)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// resolve picks the most restrictive action across a set of alerts.
|
|
func resolve(alerts []types.Alert) string {
|
|
best, bestRank := types.ActionAllow, 0
|
|
for _, a := range alerts {
|
|
if r := types.ActionRank(a.ActionTaken); r > bestRank {
|
|
best, bestRank = a.ActionTaken, r
|
|
}
|
|
}
|
|
return best
|
|
}
|