mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored alerting. Single binary, embedded SQLite per org, embedded UI. Surface (all /v1, org-scoped): /v1/aml/transactions ingest and score /v1/aml/rules /rules/test the rules DSL and its dry-run /v1/aml/sanctions/search list screening /v1/aml/cases investigation workflow /v1/aml/health Build: make build produces one static binary with the UI embedded (embedui tag); bare Go tooling needs no Node. Third-party license and notice texts are generated from the module graph the linker actually walks and ship beside every artifact. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# The image build reads go.mod/go.sum, the Go sources, and ui/. Everything else
|
||||||
|
# only inflates the context — 332 MB down to a few MB — and keeps local build
|
||||||
|
# output and the git history from ever reaching a layer.
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
amld
|
||||||
|
/dist/
|
||||||
|
/data/
|
||||||
|
THIRD-PARTY-NOTICES
|
||||||
|
ui/node_modules
|
||||||
|
ui/dist
|
||||||
|
ui/tsconfig.tsbuildinfo
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
# A runner may carry a machine-level go env that routes some module paths around
|
||||||
|
# the proxy and the checksum database. GOENV=off ignores it, so every module
|
||||||
|
# resolves through proxy.golang.org and is verified against sum.golang.org — a
|
||||||
|
# tag that moves later cannot change what this build compiles.
|
||||||
|
env:
|
||||||
|
GOENV: off
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: [self-hosted, linux, amd64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- run: corepack enable && corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- run: go mod download
|
||||||
|
- run: go mod verify
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test -race -count=1 ./...
|
||||||
|
- run: go build ./...
|
||||||
|
|
||||||
|
# Builds the UI and links it with -tags embedui, the only path that
|
||||||
|
# compiles ui/embed_prod.go.
|
||||||
|
- run: make build
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Docker
|
||||||
|
|
||||||
|
# Single-arch (linux/amd64) container build on the working `lux-build` ARC label.
|
||||||
|
# Multi-arch (arm64) will return once the per-arch `lux-build-linux-arm64`
|
||||||
|
# pool is verified online (current per-arch labels did not pick up jobs).
|
||||||
|
# No QEMU, no cross-org reusable.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main, dev, test]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/luxfi/aml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, linux, amd64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
tags=""
|
||||||
|
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
|
||||||
|
v="${GITHUB_REF#refs/tags/}"
|
||||||
|
tags="ghcr.io/luxfi/aml:${v},ghcr.io/luxfi/aml:latest"
|
||||||
|
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
|
||||||
|
tags="ghcr.io/luxfi/aml:main"
|
||||||
|
elif [[ "${GITHUB_REF}" == refs/heads/dev ]]; then
|
||||||
|
tags="ghcr.io/luxfi/aml:dev"
|
||||||
|
elif [[ "${GITHUB_REF}" == refs/heads/test ]]; then
|
||||||
|
tags="ghcr.io/luxfi/aml:test"
|
||||||
|
fi
|
||||||
|
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${v:-$(git describe --tags --always)}" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Build & push
|
||||||
|
if: steps.meta.outputs.tags != ''
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
# The build stage stamps main.version from this; without it every
|
||||||
|
# image would report "dev".
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.meta.outputs.version }}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Native binary release for amld on `v*` tags.
|
||||||
|
#
|
||||||
|
# Six-platform matrix (linux/darwin/windows × amd64/arm64) via goreleaser.
|
||||||
|
# Container image is built independently by .github/workflows/docker.yml
|
||||||
|
# (native multi-arch ARC runners — NO QEMU).
|
||||||
|
#
|
||||||
|
# Runner labels: lux-build self-hosted ARC scale-sets per the org policy.
|
||||||
|
# GitHub-hosted runners are NOT a permitted fallback.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# A runner may carry a machine-level go env that routes some module paths around
|
||||||
|
# the proxy and the checksum database. GOENV=off ignores it, so every module
|
||||||
|
# resolves through proxy.golang.org and is verified against sum.golang.org — a
|
||||||
|
# tag that moves later cannot change what gets released.
|
||||||
|
env:
|
||||||
|
GOENV: off
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
goreleaser:
|
||||||
|
runs-on: [self-hosted, linux, amd64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
# goreleaser builds ui/dist in its before-hooks and links with
|
||||||
|
# -tags embedui, so the release runner needs the Node toolchain. pnpm must
|
||||||
|
# match pnpm-lock.yaml's lockfileVersion (9.0).
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- run: corepack enable && corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install syft for SBOM
|
||||||
|
run: |
|
||||||
|
mkdir -p "$HOME/.local/bin"
|
||||||
|
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
|
||||||
|
| sh -s -- -b "$HOME/.local/bin"
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: "~> v2"
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
/amld
|
||||||
|
/THIRD-PARTY-NOTICES
|
||||||
|
/dist/
|
||||||
|
/data/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
ui/dist/
|
||||||
|
ui/node_modules/
|
||||||
|
ui/tsconfig.tsbuildinfo
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# goreleaser config for amld.
|
||||||
|
#
|
||||||
|
# Triggered by the release workflow on `v*` tag push. Builds native
|
||||||
|
# binaries for all six policy platforms, produces tarballs + zips +
|
||||||
|
# checksums + SBOM, and attaches them to the GitHub Release.
|
||||||
|
#
|
||||||
|
# Container images are built separately by `.github/workflows/docker.yml`
|
||||||
|
# (native multi-arch via hanzoai/.github reusable workflow).
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: amld
|
||||||
|
|
||||||
|
# Reaches the before-hooks and the builds alike. The notice file has to describe
|
||||||
|
# the same graph the binary links, and blst is reachable only under cgo, so the
|
||||||
|
# setting belongs in one place rather than repeated per build.
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
# Never `go mod tidy` here: a release hook that can rewrite go.mod/go.sum
|
||||||
|
# makes the released artifact depend on when it was built.
|
||||||
|
- go mod download
|
||||||
|
- pnpm --dir ui install --frozen-lockfile
|
||||||
|
- pnpm --dir ui build
|
||||||
|
# Third-party license and notice texts, derived from the binary's module
|
||||||
|
# graph. Runs after the UI build so the embedui graph resolves.
|
||||||
|
#
|
||||||
|
# One notice file goes into all six archives, and the module set differs by
|
||||||
|
# platform (darwin links ncruces/go-strftime, linux links prometheus/procfs),
|
||||||
|
# so it has to be the union over the build matrix below. The other two
|
||||||
|
# distribution paths pass no -platforms: `make build` and the image each
|
||||||
|
# produce a single binary, and the host default already describes it.
|
||||||
|
- go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld
|
||||||
|
-platforms linux/amd64,linux/arm64,darwin/amd64,darwin/arm64,windows/amd64,windows/arm64
|
||||||
|
-o THIRD-PARTY-NOTICES
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: amld
|
||||||
|
main: ./cmd/amld
|
||||||
|
binary: amld
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
tags:
|
||||||
|
- embedui
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: amld
|
||||||
|
builds: [amld]
|
||||||
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
files:
|
||||||
|
- LICENSE*
|
||||||
|
- README*
|
||||||
|
- THIRD-PARTY-NOTICES
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: checksums.txt
|
||||||
|
|
||||||
|
sboms:
|
||||||
|
- artifacts: archive
|
||||||
|
|
||||||
|
snapshot:
|
||||||
|
version_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
use: github
|
||||||
|
sort: asc
|
||||||
|
|
||||||
|
release:
|
||||||
|
draft: false
|
||||||
|
prerelease: auto
|
||||||
|
mode: append
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Stage 1: Build UI (Vite + React + @hanzo/gui)
|
||||||
|
# pnpm must match pnpm-lock.yaml's lockfileVersion (9.0), and --frozen-lockfile
|
||||||
|
# is mandatory so lockfile drift fails the build instead of resolving fresh.
|
||||||
|
FROM node:22-alpine AS ui
|
||||||
|
RUN corepack enable && corepack prepare [email protected] --activate
|
||||||
|
WORKDIR /ui
|
||||||
|
COPY ui/package.json ui/pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY ui/ .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 2: Build Go binary with embedded UI
|
||||||
|
# Every dependency resolves through the default module proxy and is checked
|
||||||
|
# against the default checksum database, so a tag that moves after the fact
|
||||||
|
# cannot change what this image contains: proxy.golang.org serves the zip it
|
||||||
|
# first recorded, and sum.golang.org is an append-only witness of that zip's
|
||||||
|
# hash. go.sum carries the witnessed hashes — see LLM.md, "Dependency pinning".
|
||||||
|
# Nothing here is fetched from the git host, so the build needs no credential.
|
||||||
|
FROM golang:1.26.4-alpine AS build
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
COPY --from=ui /ui/dist ./ui/dist/
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -tags embedui \
|
||||||
|
-ldflags="-s -w -X main.version=${VERSION}" -o /app/amld ./cmd/amld/
|
||||||
|
|
||||||
|
# Third-party license and notice texts for the binary just built, derived from
|
||||||
|
# its module graph while the module cache is still present.
|
||||||
|
RUN CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld \
|
||||||
|
-o /app/THIRD-PARTY-NOTICES
|
||||||
|
|
||||||
|
# Stage 3: Runtime
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
COPY --from=build /app/amld /app/amld
|
||||||
|
COPY --from=build /app/THIRD-PARTY-NOTICES /app/THIRD-PARTY-NOTICES
|
||||||
|
COPY LICENSE /app/LICENSE
|
||||||
|
WORKDIR /data
|
||||||
|
EXPOSE 8090
|
||||||
|
ENTRYPOINT ["/app/amld"]
|
||||||
|
CMD ["serve", "--http=0.0.0.0:8090"]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
Lux Ecosystem License
|
||||||
|
Version 1.2, December 2025
|
||||||
|
|
||||||
|
Copyright (c) 2020-2025 Lux Industries Inc.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
TECHNOLOGY PORTFOLIO - PATENT APPLICATIONS PLANNED
|
||||||
|
Contact: [email protected]
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
1. DEFINITIONS
|
||||||
|
|
||||||
|
"Lux Primary Network" means the official Lux blockchain with Network ID=1
|
||||||
|
and EVM Chain ID=96369.
|
||||||
|
|
||||||
|
"Authorized Network" means the Lux Primary Network, official testnets/devnets,
|
||||||
|
and any L1/L2/L3 chain descending from the Lux Primary Network.
|
||||||
|
|
||||||
|
"Descending Chain" means an L1/L2/L3 chain built on, anchored to, or deriving
|
||||||
|
security from the Lux Primary Network or its authorized testnets.
|
||||||
|
|
||||||
|
"Research Use" means non-commercial academic research, education, personal
|
||||||
|
study, or evaluation purposes.
|
||||||
|
|
||||||
|
"Commercial Use" means any use in connection with a product or service
|
||||||
|
offered for sale or fee, internal use by a for-profit entity, or any use
|
||||||
|
to generate revenue.
|
||||||
|
|
||||||
|
2. GRANT OF LICENSE
|
||||||
|
|
||||||
|
Subject to these terms, Lux Industries Inc grants you a non-exclusive,
|
||||||
|
royalty-free license to:
|
||||||
|
|
||||||
|
(a) Use for Research Use without restriction;
|
||||||
|
|
||||||
|
(b) Operate on the Lux Primary Network (Network ID=1, EVM Chain ID=96369);
|
||||||
|
|
||||||
|
(c) Operate on official Lux testnets and devnets;
|
||||||
|
|
||||||
|
(d) Operate L1/L2/L3 chains descending from the Lux Primary Network;
|
||||||
|
|
||||||
|
(e) Build applications within the Lux ecosystem;
|
||||||
|
|
||||||
|
(f) Contribute improvements back to the original repositories.
|
||||||
|
|
||||||
|
3. RESTRICTIONS
|
||||||
|
|
||||||
|
Without a commercial license from Lux Industries Inc, you may NOT:
|
||||||
|
|
||||||
|
(a) Fork the Lux Network or any Lux software;
|
||||||
|
|
||||||
|
(b) Create competing networks not descending from Lux Primary Network;
|
||||||
|
|
||||||
|
(c) Use for Commercial Use outside the Lux ecosystem;
|
||||||
|
|
||||||
|
(d) Sublicense or transfer rights outside the Lux ecosystem;
|
||||||
|
|
||||||
|
(e) Use to create competing blockchain networks, exchanges, custody
|
||||||
|
services, or cryptographic systems outside the Lux ecosystem.
|
||||||
|
|
||||||
|
4. NO FORKS POLICY
|
||||||
|
|
||||||
|
Lux Industries Inc maintains ZERO TOLERANCE for unauthorized forks.
|
||||||
|
Any fork or deployment on an unauthorized network constitutes:
|
||||||
|
|
||||||
|
(a) Breach of this license;
|
||||||
|
(b) Grounds for immediate legal action.
|
||||||
|
|
||||||
|
5. RIGHTS RESERVATION
|
||||||
|
|
||||||
|
All rights not explicitly granted are reserved by Lux Industries Inc.
|
||||||
|
|
||||||
|
We plan to apply for patent protection for the technology in this
|
||||||
|
repository. Any implementation outside the Lux ecosystem may require
|
||||||
|
a separate commercial license.
|
||||||
|
|
||||||
|
6. DISCLAIMER OF WARRANTY
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
|
||||||
|
7. LIMITATION OF LIABILITY
|
||||||
|
|
||||||
|
IN NO EVENT SHALL LUX INDUSTRIES INC BE LIABLE FOR ANY CLAIM, DAMAGES
|
||||||
|
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE.
|
||||||
|
|
||||||
|
8. TERMINATION
|
||||||
|
|
||||||
|
This license terminates immediately upon any breach, including but not
|
||||||
|
limited to deployment on unauthorized networks or creation of forks.
|
||||||
|
|
||||||
|
9. GOVERNING LAW
|
||||||
|
|
||||||
|
This License shall be governed by the laws of the State of Delaware.
|
||||||
|
|
||||||
|
10. COMMERCIAL LICENSING
|
||||||
|
|
||||||
|
For commercial use outside the Lux ecosystem:
|
||||||
|
|
||||||
|
Lux Industries Inc.
|
||||||
|
Email: [email protected]
|
||||||
|
Subject: Commercial License Request
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
TL;DR
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
- Research/academic use = OK
|
||||||
|
- Lux Primary Network (Network ID=1, Chain ID=96369) = OK
|
||||||
|
- L1/L2/L3 chains descending from Lux Primary Network = OK
|
||||||
|
- Commercial products outside Lux ecosystem = Contact [email protected]
|
||||||
|
- Forks = Absolutely not
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
See LP-0012 for full licensing documentation:
|
||||||
|
https://github.com/luxfi/lps/blob/main/LPs/lp-0012-ecosystem-licensing.md
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Licensing
|
||||||
|
|
||||||
|
This repository is licensed under the **Lux Ecosystem License v1.2** (see [LICENSE](LICENSE)). It belongs to the **patent-protected** tier of the Lux three-tier IP strategy.
|
||||||
|
|
||||||
|
- Free for **Authorized Networks** (Lux Primary NetID=1, EVM 96369; official testnets/devnets; Descending Chains).
|
||||||
|
- Free for **Research Use** (academic, education, evaluation).
|
||||||
|
- **Commercial use outside Authorized Networks requires a paid commercial license.**
|
||||||
|
|
||||||
|
For the canonical Lux IP and licensing strategy and the precise definitions of "Authorized Network", "Descending Chain", and "Research Use", see:
|
||||||
|
<https://github.com/luxfi/.github/blob/main/profile/README.md>
|
||||||
|
|
||||||
|
For commercial licensing inquiries, contact `[email protected]`.
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# luxfi/aml
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Go module: `github.com/luxfi/aml`
|
||||||
|
|
||||||
|
Real-time AML/CFT transaction monitoring engine. Pure Go, single binary, embedded SQLite via Hanzo Base. Configurable rules DSL (expr-lang), sanctions screening (OFAC/UN/EU/HMT), case management, scored alerting.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Language**: Go 1.26.4
|
||||||
|
- **Database**: Hanzo Base (embedded SQLite per-org)
|
||||||
|
- **Rules DSL**: github.com/expr-lang/expr v1.17
|
||||||
|
- **Scoring**: Weight-of-evidence (pure Go math)
|
||||||
|
- **Sanctions**: Jaro-Winkler + token-based fuzzy name matching
|
||||||
|
- **HTTP**: Hanzo Base router (net/http)
|
||||||
|
- **Auth**: X-Org-Id header (set by gateway from JWT owner claim)
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # UI build + Go build -tags embedui -> ./amld (the product)
|
||||||
|
make test # go test -race -count=1 ./...
|
||||||
|
make vet # go vet ./...
|
||||||
|
make ui # pnpm install --frozen-lockfile && pnpm build -> ui/dist
|
||||||
|
make run # Build + run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`make build` is the only command that produces the shippable binary: the admin
|
||||||
|
dashboard is embedded, so it always builds `ui/dist` first and links with
|
||||||
|
`-tags embedui`.
|
||||||
|
|
||||||
|
Bare `go build ./...`, `go vet ./...`, `go test ./...` and `go install` work on a
|
||||||
|
fresh clone with no Node toolchain — without the `embedui` tag, `ui/embed.go`
|
||||||
|
supplies a placeholder tree instead of embedding `ui/dist`. That is what keeps
|
||||||
|
the module installable, since `ui/dist` is a build artifact and is not committed.
|
||||||
|
|
||||||
|
## Module Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/amld/main.go -- Single binary: serve, version, embedded admin UI
|
||||||
|
migrations/0001_core.sql -- 9 SQLite collections
|
||||||
|
ui/ -- Embedded admin dashboard (Vite + React + @hanzo/gui)
|
||||||
|
embed.go -- !embedui: placeholder DistDirFS(), no dist/ needed
|
||||||
|
embed_prod.go -- embedui: go:embed all:dist -> DistDirFS()
|
||||||
|
package.json -- @luxfi/aml-ui (private, pnpm)
|
||||||
|
vite.config.ts -- base: /_/aml/, proxy /v1/aml to :8090
|
||||||
|
src/
|
||||||
|
main.tsx -- React 19 entry
|
||||||
|
App.tsx -- Hash router (wouter), sidebar nav, 5 routes
|
||||||
|
api.ts -- Typed fetch wrappers for /v1/aml/* endpoints
|
||||||
|
pages/
|
||||||
|
Dashboard.tsx -- Stats cards, recent cases, health check
|
||||||
|
Cases.tsx -- DataTable with status filter tabs, expandable rows
|
||||||
|
Rules.tsx -- DataTable with DSL display, test rule modal
|
||||||
|
Alerts.tsx -- DataTable with severity filter chips, score breakdown
|
||||||
|
Sanctions.tsx -- Name search form, results table with scores
|
||||||
|
dist/ -- Built output (gitignored, ~220KB)
|
||||||
|
pkg/
|
||||||
|
types/types.go -- Canonical domain types (Transaction, Entity, Rule, Alert, Case)
|
||||||
|
engine/
|
||||||
|
engine.go -- Core engine: evaluate tx -> alerts + score + action
|
||||||
|
evaluator.go -- expr-lang compiler + evaluator with helper functions
|
||||||
|
scoring.go -- Weight-of-evidence scoring + ScorerPlugin interface
|
||||||
|
rules/library.go -- 20 starter AML rules (CTR, structuring, velocity, sanctions, etc.)
|
||||||
|
sanctions/
|
||||||
|
match.go -- Jaro-Winkler + token-based fuzzy name matching
|
||||||
|
lists.go -- Default list configurations (OFAC, UN, EU, HMT)
|
||||||
|
ingest.go -- OFAC SDN XML parser + HTTP fetcher
|
||||||
|
cases/
|
||||||
|
case.go -- Case store (create, update status, assign, resolve, events)
|
||||||
|
errors.go -- Sentinel errors
|
||||||
|
webhook/webhook.go -- Signed delivery (HMAC-SHA256) with retry + dead-letter
|
||||||
|
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | /v1/aml/transactions | Ingest transaction, sync rule eval, returns action |
|
||||||
|
| GET | /v1/aml/transactions/{id}/alerts | Alerts for a transaction |
|
||||||
|
| GET | /v1/aml/cases | List cases (filter by status) |
|
||||||
|
| POST | /v1/aml/cases/{id}/events | Add case event (note, status change) |
|
||||||
|
| GET | /v1/aml/rules | List all rules |
|
||||||
|
| POST | /v1/aml/rules/test | Dry-run a DSL expression |
|
||||||
|
| POST | /v1/aml/sanctions/search | Search sanctions lists by name |
|
||||||
|
| GET | /v1/aml/health | Health check |
|
||||||
|
|
||||||
|
All endpoints require `X-Org-Id` header.
|
||||||
|
|
||||||
|
## Embedded Admin UI
|
||||||
|
|
||||||
|
Served at `/_/aml/` via `go:embed`. Hash router (`/#/cases`, `/#/rules`, etc.) for SPA routing without server rewrites.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build # builds ui/dist, then links it with -tags embedui
|
||||||
|
./amld serve --dev # UI at http://localhost:8090/_/aml/
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `-tags embedui` the binary serves a placeholder page at `/_/aml/`; that
|
||||||
|
is the expected result of a bare `go build`/`go install`.
|
||||||
|
|
||||||
|
Dev mode: `cd ui && pnpm dev` (port 3000, proxies API to :8090).
|
||||||
|
|
||||||
|
Tech: React 19, wouter (hash router), @hanzo/gui, Vite 6. Dark theme.
|
||||||
|
|
||||||
|
## Transaction Ingest Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/aml/transactions
|
||||||
|
-> Parse + validate
|
||||||
|
-> Resolve entity from user_id
|
||||||
|
-> EvalAll(rules, {tx, entity})
|
||||||
|
-> Filter by jurisdiction, asset_class, enabled
|
||||||
|
-> Compile + run each rule DSL via expr-lang
|
||||||
|
-> Score(hits) -> weight-of-evidence [0,1]
|
||||||
|
-> resolveAction(alerts) -> highest severity action wins
|
||||||
|
-> If block/report: auto-create Case
|
||||||
|
-> Return {action, score, alert_ids, case_id?}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starter Rules (20)
|
||||||
|
|
||||||
|
| # | Rule | DSL Pattern | Action |
|
||||||
|
|---|------|-------------|--------|
|
||||||
|
| 1 | CTR Threshold | notional > 10000 && USD | report |
|
||||||
|
| 2 | Structuring | notional in [9000, 10000) | flag |
|
||||||
|
| 3 | Velocity Daily | sum_last_24h > 50000 | review |
|
||||||
|
| 4 | Velocity Monthly | sum_last_30d > 500000 | review |
|
||||||
|
| 5 | First-Time Large | first_tx && notional > 25000 | review |
|
||||||
|
| 6 | Round-Trip | is_round_trip 24h | flag |
|
||||||
|
| 7 | Sanctioned Jurisdiction | jurisdiction in sanctioned list | block |
|
||||||
|
| 8 | PEP Large | PEP && notional > 10000 | review |
|
||||||
|
| 9 | Unusual Hour | hour in [2,3,4] | flag |
|
||||||
|
| 10 | New Counterparty Large | first_counterparty && notional > 5000 | review |
|
||||||
|
| 11 | Dormant Reactivation | last_tx > 180d && notional > 10000 | review |
|
||||||
|
| 12 | Crypto Mixer | is_mixer_address | block |
|
||||||
|
| 13 | Darknet Market | is_darknet_market | block |
|
||||||
|
| 14 | IP/Geo Mismatch | !geo_match | flag |
|
||||||
|
| 15 | Layering | layering_score > 0.8 | review |
|
||||||
|
| 16 | Smurfing | smurfing_detected 7d | review |
|
||||||
|
| 17 | Wash Trading | wash_trade_detected 1h | flag |
|
||||||
|
| 18 | Sanctions Direct | sanctions_hit(entity) | block |
|
||||||
|
| 19 | Sanctions Counterparty | sanctions_hit(counterparty) | block |
|
||||||
|
| 20 | Travel Rule | notional > travel_rule_threshold | report |
|
||||||
|
|
||||||
|
## Compliance Client Compatibility
|
||||||
|
|
||||||
|
`luxfi/compliance` AML client connects via HTTP:
|
||||||
|
|
||||||
|
| Client Method | Endpoint |
|
||||||
|
|---|---|
|
||||||
|
| ScreenTransaction | POST /v1/aml/transactions |
|
||||||
|
| CheckSanctions | POST /v1/aml/sanctions/search |
|
||||||
|
| CreateCase | POST /v1/aml/cases/{id}/events |
|
||||||
|
| GetCases | GET /v1/aml/cases |
|
||||||
|
|
||||||
|
Webhook events: `aml.flagged`, `aml.cleared`, `kyc.approved`, `trade.executed`.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- ML scoring via Hanzo Zen gateway (ScorerPlugin interface ready)
|
||||||
|
- Hanzo Tasks durable workflows (sanctions-refresh, case-automation, backtest)
|
||||||
|
- Base realtime subscription for live transaction monitoring
|
||||||
|
- Backtest/batch evaluation against historical data
|
||||||
|
- SAR draft generation
|
||||||
|
- Full OFAC/UN/EU/HMT list refresh automation
|
||||||
|
- Base collection persistence (current: in-memory stores for v1 engine)
|
||||||
|
|
||||||
|
## Dependency pinning
|
||||||
|
|
||||||
|
Every dependency resolves through `proxy.golang.org` and is verified against
|
||||||
|
`sum.golang.org`. Both are append-only: the proxy serves the zip it first
|
||||||
|
recorded for a version, and the checksum database is a transparency log of that
|
||||||
|
zip's hash. A tag that moves afterwards therefore cannot change what this repo
|
||||||
|
compiles — which is the whole point, because tags in this fleet do move.
|
||||||
|
|
||||||
|
The builds pin that policy themselves rather than inheriting it. A machine may
|
||||||
|
carry a `go env` file whose `GOPRIVATE`/`GONOPROXY`/`GONOSUMDB` route
|
||||||
|
`luxfi/*` and `hanzoai/*` around both, so `ci.yml` and `release.yml` set
|
||||||
|
`GOENV: off`, the one documented switch that ignores the file. `GOPRIVATE=` in
|
||||||
|
the environment does **not** override it: Go reads an empty environment variable
|
||||||
|
as unset and falls back to the file.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. **Never write a hash `sum.golang.org` does not corroborate.** The log is the
|
||||||
|
authority; if `go.sum` disagrees with it, `go.sum` is wrong. Two commits in
|
||||||
|
this history (`9821aff`, `77173ad`) wrote hashes taken from whatever the git
|
||||||
|
host was serving, which is the failure this rule exists to prevent.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://sum.golang.org/lookup/github.com/luxfi/[email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`go mod verify` does not check `go.sum`.** It compares the extracted module
|
||||||
|
against the hash recorded in the local module cache, so it reports
|
||||||
|
"all modules verified" while a `go.sum` entry that matches nothing sits right
|
||||||
|
next to it. Only a download can catch that — on a *clean* `GOMODCACHE`, since
|
||||||
|
a cache entry fetched from the git host keeps serving those bits.
|
||||||
|
3. **Prefer the lowest unmoved patch successor** (`x.y.z` -> `x.y.z+1`). Never
|
||||||
|
jump a major; never go above `v1.x.x`.
|
||||||
|
4. If a module has no version whose host content, proxy content, and log record
|
||||||
|
all agree, that repo's owner must cut one. Do not work around it here.
|
||||||
|
|
||||||
|
Tags known to have been force-moved, and how each was resolved:
|
||||||
|
|
||||||
|
| Module | Moved tag | Resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| `github.com/luxfi/age` | v1.5.0 | pinned v1.5.1 — same commit `b13d4cb0`, unmoved |
|
||||||
|
| `github.com/luxfi/threshold` | v1.9.4 | pinned v1.9.5 — same commit `0080201`, unmoved |
|
||||||
|
| `github.com/luxfi/pq` | v1.0.3 | resolved in place: move was content-neutral, `go.sum` carries the log hash |
|
||||||
|
| `github.com/hanzoai/base` | v1.1.0 | `go.sum` carries the log hash; **owner action** for a version host and log agree on |
|
||||||
|
| `github.com/hanzoai/kms/sdk/go` | v1.0.0 | graph-only, not in `go.sum` |
|
||||||
|
| `github.com/luxfi/mpc` | v1.14.13 | graph-only, not in `go.sum`; host and log disagree |
|
||||||
|
| `github.com/luxfi/precompile` | v0.5.37 | graph-only, not in `go.sum`; host and log disagree |
|
||||||
|
|
||||||
|
`luxfi/age` and `luxfi/threshold` are the pattern to copy: a successor tag cut at
|
||||||
|
the *identical commit*, so pinning it changes no code and restores corroboration.
|
||||||
|
|
||||||
|
Two entries needed more than a successor tag:
|
||||||
|
|
||||||
|
- **`luxfi/pq` v1.0.3** moved from `e16b004d` to `90d2223e`, but the two commits
|
||||||
|
have byte-identical trees, so the zip hash never changed. The proxy, the log,
|
||||||
|
and the git host all report `h1:pFlQm1+5...`; `go.sum` had recorded
|
||||||
|
`h1:ksw1dmfT...`, which corresponds to nothing obtainable and made a
|
||||||
|
cold-cache build fail outright. `go.sum` now carries the log hash. No
|
||||||
|
successor tag is needed.
|
||||||
|
- **`hanzoai/base` v1.1.0** moved from `fe516fdc` to `a0b4a9f3`, and the content
|
||||||
|
did change — in exactly three files, `README.md`, `docs/NETWORK.md`, and
|
||||||
|
`docs/NETWORK_RED_REVIEW.md`. **No Go source and no `go.mod` differs**, so the
|
||||||
|
two trees compile identically. `go.sum` carries the log hash
|
||||||
|
(`h1:Mi4yRgR1...`, the `fe516fdc` tree). A machine whose module cache already
|
||||||
|
holds the `a0b4a9f3` bits will report a mismatch until that entry is evicted;
|
||||||
|
a clean cache resolves it from the proxy. The durable fix is for the owner to
|
||||||
|
cut `v1.1.1` at `a0b4a9f3` so host, proxy, and log agree on one version.
|
||||||
|
|
||||||
|
## Third-party notices
|
||||||
|
|
||||||
|
The distributed artifacts carry `THIRD-PARTY-NOTICES`: the license, notice, and
|
||||||
|
patent-grant texts of every module the linker puts in `amld`, reproduced
|
||||||
|
verbatim. Apache-2.0 §4(d) requires the attribution notices of Apache-licensed
|
||||||
|
dependencies to travel with each distributed copy, and 10 of the linked modules
|
||||||
|
ship a `NOTICE` file; MIT/BSD/ISC require their copyright and permission notices
|
||||||
|
to travel the same way.
|
||||||
|
|
||||||
|
`go run ./internal/notice` generates it from the build's own module graph, so it
|
||||||
|
cannot describe a dependency set the artifact does not have and cannot go stale
|
||||||
|
when a dependency changes. Output is deterministic — modules sorted by path, no
|
||||||
|
timestamps — so it does not perturb a reproducible build.
|
||||||
|
|
||||||
|
It is generated, not committed (`.gitignore`), and produced on all three
|
||||||
|
distribution paths: `make build` (via the `notice` target), the goreleaser
|
||||||
|
archive (a `before` hook plus an `archives.files` entry), and the container image
|
||||||
|
(build stage, copied to `/app/THIRD-PARTY-NOTICES` beside `/app/LICENSE`).
|
||||||
|
Regenerate with `make notice`; verify by inspecting a built archive or the image
|
||||||
|
filesystem, never by reading the config.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Single binary: `amld serve --http=0.0.0.0:8090`
|
||||||
|
- Docker: `ghcr.io/luxfi/aml:{env}`
|
||||||
|
- K8s: Deployment, replicas=1 (SQLite single-writer), PVC for /data
|
||||||
|
- Replication: hanzoai/replicate sidecar for age-encrypted S3 WAL streaming
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
133 tests across 8 packages. All pass with `-race`.
|
||||||
|
|
||||||
|
- engine: 38 tests (evaluator, scoring, integration)
|
||||||
|
- cases: 28 tests (CRUD, status, events, assignment, resolution)
|
||||||
|
- sanctions: 24 tests (Jaro-Winkler, normalize, token match)
|
||||||
|
- rules: 21 tests (all 20 starter rules compile + individual eval)
|
||||||
|
- chain: 7 tests (Chainalysis client: entities, transfers, webhooks)
|
||||||
|
- workflows: 7 tests
|
||||||
|
- webhook: 5 tests (sign, verify, deliver, failure)
|
||||||
|
- api: 3 tests
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.PHONY: build ui notice test vet run clean
|
||||||
|
|
||||||
|
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||||
|
LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
|
||||||
|
NOTICE := THIRD-PARTY-NOTICES
|
||||||
|
|
||||||
|
# The product binary embeds the admin dashboard, so it always builds the UI
|
||||||
|
# first. Bare `go build`/`go vet`/`go test`/`go install` need no UI: without
|
||||||
|
# the embedui tag ui/embed.go supplies a placeholder tree.
|
||||||
|
build: ui notice
|
||||||
|
CGO_ENABLED=0 go build -trimpath -tags embedui $(LDFLAGS) -o amld ./cmd/amld/
|
||||||
|
|
||||||
|
ui:
|
||||||
|
pnpm --dir ui install --frozen-lockfile
|
||||||
|
pnpm --dir ui build
|
||||||
|
|
||||||
|
# Third-party license and notice texts for the binary, derived from its module
|
||||||
|
# graph. Generated with the same tags and CGO setting the product ships with, so
|
||||||
|
# it describes the artifact that is distributed and not some other graph — which
|
||||||
|
# also means dist/ has to exist first for the embed directive to resolve. No
|
||||||
|
# -platforms: this target builds one binary, for this host.
|
||||||
|
notice: ui
|
||||||
|
CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld -o $(NOTICE)
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -race -count=1 ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./amld serve --dev
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f amld $(NOTICE)
|
||||||
|
rm -rf data/ ui/dist/
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// amld is the AML engine daemon. It provides real-time transaction monitoring,
|
||||||
|
// sanctions screening, case management, and webhook delivery.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// amld serve Start the HTTP server
|
||||||
|
// amld version Print version
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/hanzoai/base"
|
||||||
|
"github.com/hanzoai/base/apis"
|
||||||
|
"github.com/hanzoai/base/core"
|
||||||
|
"github.com/hanzoai/base/tools/hook"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/api"
|
||||||
|
"github.com/luxfi/aml/pkg/cases"
|
||||||
|
"github.com/luxfi/aml/pkg/engine"
|
||||||
|
"github.com/luxfi/aml/pkg/rules"
|
||||||
|
"github.com/luxfi/aml/pkg/sanctions"
|
||||||
|
uiaml "github.com/luxfi/aml/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "(dev)"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := base.New()
|
||||||
|
|
||||||
|
var serveCmd *cobra.Command
|
||||||
|
for _, c := range app.RootCmd.Commands() {
|
||||||
|
if c.Use == "serve" {
|
||||||
|
serveCmd = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire AML engine into Base's serve lifecycle.
|
||||||
|
defaultOrg := os.Getenv("AML_DEFAULT_ORG")
|
||||||
|
if defaultOrg == "" {
|
||||||
|
defaultOrg = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
starterRules := rules.StarterRules(defaultOrg)
|
||||||
|
eng := engine.New(starterRules)
|
||||||
|
caseStore := cases.NewStore()
|
||||||
|
alertStore := api.NewAlertStore()
|
||||||
|
sanctionsStore := api.NewSanctionsStore()
|
||||||
|
|
||||||
|
handler := &api.Handler{
|
||||||
|
Engine: eng,
|
||||||
|
Cases: caseStore,
|
||||||
|
Alerts: alertStore,
|
||||||
|
Sanctions: sanctionsStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||||
|
Func: func(se *core.ServeEvent) error {
|
||||||
|
// Wire TransactionStore (Base-backed) into engine helpers.
|
||||||
|
txStore := engine.NewBaseStore(app)
|
||||||
|
engine.RegisterStoreHelpers(eng, txStore)
|
||||||
|
|
||||||
|
// Wire SanctionsStore (Base-backed) and register refresh cron.
|
||||||
|
baseSanctionsStore := sanctions.NewBaseSanctionsStore(app)
|
||||||
|
sanctions.RefreshCron(app, baseSanctionsStore)
|
||||||
|
|
||||||
|
handler.Register(se)
|
||||||
|
|
||||||
|
// Mount embedded admin UI at /_/aml/
|
||||||
|
se.Router.GET("/_/aml/{path...}", apis.Static(uiaml.DistDirFS(), true))
|
||||||
|
|
||||||
|
return se.Next()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add version subcommand.
|
||||||
|
app.RootCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println("amld", version)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = serveCmd // Base already provides "serve" — we just hook into it.
|
||||||
|
|
||||||
|
if err := app.Start(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
amld:
|
||||||
|
build: .
|
||||||
|
container_name: amld
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
volumes:
|
||||||
|
- aml-data:/data
|
||||||
|
environment:
|
||||||
|
AML_DEFAULT_ORG: ${AML_DEFAULT_ORG:-default}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
aml-data:
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# amld — Integration Patterns
|
||||||
|
|
||||||
|
This document describes how `amld` integrates with the rest of the
|
||||||
|
stack. It is deliberately generic: it specifies the *interfaces*,
|
||||||
|
*data shapes*, and *control flow*, not any particular commercial
|
||||||
|
data provider. Operators choose their own watchlist, PEP, and
|
||||||
|
adverse-media sources and adapt them to the schemas below.
|
||||||
|
|
||||||
|
`amld` is the AML / FinCrime evaluation service. It exposes a
|
||||||
|
versioned HTTP API on `/v1/aml/*`, persists rules / alerts / cases
|
||||||
|
through a Hanzo Base–backed store, evaluates per-transaction risk
|
||||||
|
synchronously, emits signed webhooks for downstream review, and
|
||||||
|
ships with a starter rule library covering the standard typologies.
|
||||||
|
|
||||||
|
The integration surface has six concerns:
|
||||||
|
|
||||||
|
1. Sanctions / watchlist screening
|
||||||
|
2. Politically Exposed Person (PEP) screening
|
||||||
|
3. Adverse-media screening
|
||||||
|
4. Velocity and amount-threshold limits
|
||||||
|
5. Customer risk tiers
|
||||||
|
6. Pre-transaction hooks for caller services (`bankd`, exchange,
|
||||||
|
payments)
|
||||||
|
7. Webhook + case-management surface for human review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Sanctions / Watchlist Screening
|
||||||
|
|
||||||
|
`amld` treats sanctions data as a *normalised, append-only catalog*
|
||||||
|
that any number of upstream lists can feed. The canonical model is in
|
||||||
|
`pkg/types/types.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SanctionsList struct {
|
||||||
|
ID string // internal id
|
||||||
|
Source string // free-form source identifier
|
||||||
|
URL string // origin URL
|
||||||
|
Format string // xml | csv | json | ...
|
||||||
|
FetchedAt time.Time
|
||||||
|
SHA256 string // integrity hash of the raw payload
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SanctionsEntry struct {
|
||||||
|
ID string
|
||||||
|
ListID string // back-ref to SanctionsList.ID
|
||||||
|
RefID string // upstream-stable identifier
|
||||||
|
Name string
|
||||||
|
Aliases []string
|
||||||
|
DOB string // best-effort, often partial
|
||||||
|
Nationality string
|
||||||
|
Address string
|
||||||
|
Type string // individual | entity | vessel | aircraft
|
||||||
|
Raw json.RawMessage // original record preserved verbatim
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The shipped list-source constants (`ofac_sdn`, `un`, `eu`, `hmt`,
|
||||||
|
`interpol`) are *category tags*, not bindings to any particular
|
||||||
|
publisher. Operators can ingest:
|
||||||
|
|
||||||
|
- Government-published consolidated lists (OFAC SDN / Consolidated,
|
||||||
|
UN Security Council, EU Consolidated Financial Sanctions, UK HMT
|
||||||
|
OFSI, regional regulators).
|
||||||
|
- Internal block lists (denylists from internal investigations,
|
||||||
|
closed customer relationships, etc.).
|
||||||
|
- Third-party aggregated watchlist feeds — the same ingest path
|
||||||
|
applies; only the parser differs.
|
||||||
|
|
||||||
|
### Ingest contract
|
||||||
|
|
||||||
|
A list ingester is a function that:
|
||||||
|
|
||||||
|
1. Fetches the upstream payload.
|
||||||
|
2. Computes its SHA-256 (recorded on `SanctionsList`).
|
||||||
|
3. Parses it into `[]SanctionsEntry` rows tagged with the parent
|
||||||
|
`ListID`.
|
||||||
|
4. Persists rows transactionally — partial ingests must not leave
|
||||||
|
half-loaded lists active.
|
||||||
|
|
||||||
|
The matcher (`pkg/sanctions/match.go`) is source-agnostic. It scores
|
||||||
|
incoming names against `Name` and every `Alias` using token-based
|
||||||
|
similarity and returns hits above a configurable threshold.
|
||||||
|
|
||||||
|
### Refresh cadence
|
||||||
|
|
||||||
|
`pkg/sanctions/refresh.go` defines the periodic refresh loop. Cadence
|
||||||
|
is policy: regulated counterparties typically require daily refresh
|
||||||
|
of consolidated government lists; internal lists update on commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PEP Screening
|
||||||
|
|
||||||
|
PEP status is a *property of the entity*, not of the transaction. The
|
||||||
|
canonical flag lives on `Entity`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Entity struct {
|
||||||
|
ID string
|
||||||
|
OrgID string
|
||||||
|
EntityType string // user | account | counterparty | bank_account
|
||||||
|
Name string
|
||||||
|
Jurisdiction string
|
||||||
|
KYCLevel int
|
||||||
|
PEP bool // <-- politically exposed
|
||||||
|
SanctionsFlag bool
|
||||||
|
RiskScore float64
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PEP` is a boolean on the resolved entity. The recommended pattern:
|
||||||
|
|
||||||
|
- Upstream KYC pipeline (in `bankd` or a dedicated onboarding
|
||||||
|
service) determines PEP status at customer-onboarding and on
|
||||||
|
periodic re-screen.
|
||||||
|
- The result is written to the AML `Entity` record. `amld` is the
|
||||||
|
consumer, not the resolver — it does not embed a PEP database.
|
||||||
|
- Rules consume `entity.PEP` directly via the expr-lang DSL:
|
||||||
|
`entity.PEP && tx.Notional > 1000`.
|
||||||
|
|
||||||
|
The same shape supports relatives-and-close-associates (RCA) and
|
||||||
|
PEP-by-association — encode them either as additional boolean
|
||||||
|
attributes on `Entity` (via a future-compatible extension column) or
|
||||||
|
as separate watchlist categories ingested through the sanctions path
|
||||||
|
above. The data model intentionally does not over-specify PEP
|
||||||
|
sub-categories so that operators can match the granularity their
|
||||||
|
regulator requires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Adverse-Media Screening
|
||||||
|
|
||||||
|
Adverse-media findings are modelled as *evidence attached to a
|
||||||
|
case*, not as a primary signal. The integration shape:
|
||||||
|
|
||||||
|
- An external adverse-media screen (operator's choice) produces a
|
||||||
|
finding: subject identifier, severity, source URL, headline,
|
||||||
|
excerpt, date.
|
||||||
|
- The finding is posted as a `CaseEvent` of kind `note` (or `file`
|
||||||
|
if a snapshot PDF is attached) on a case opened for the subject:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/aml/cases/{id}/events
|
||||||
|
{
|
||||||
|
"kind": "note",
|
||||||
|
"body": "Adverse media match — <category> — <source> — <date>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- For *automated* adverse-media gating before a transaction settles,
|
||||||
|
the upstream screen pushes an adverse-media risk score into the
|
||||||
|
`Entity.RiskScore` field (or a derived attribute) so the rule
|
||||||
|
engine can read it via expr-lang: `entity.RiskScore > 0.7`.
|
||||||
|
|
||||||
|
This keeps `amld` neutral about the adverse-media provider while
|
||||||
|
giving operators a clean place to materialise findings into the case
|
||||||
|
record for examiner review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Velocity & Amount-Threshold Limits
|
||||||
|
|
||||||
|
Velocity and threshold rules are expressed in the rule DSL. The
|
||||||
|
starter library (`pkg/rules/library.go`) ships representative
|
||||||
|
examples:
|
||||||
|
|
||||||
|
| Rule | DSL |
|
||||||
|
|-------------------|---------------------------------------------|
|
||||||
|
| CTR threshold | `notional_usd(tx) > 10000` |
|
||||||
|
| Structuring | `tx.Notional >= 9000 && tx.Notional < 10000` |
|
||||||
|
| Velocity (24h) | `sum_last_24h(tx.UserID) > 50000` |
|
||||||
|
| Velocity (30d) | `sum_last_30d(tx.UserID) > 500000` |
|
||||||
|
| First-time large | `first_tx(tx.UserID) && tx.Notional > 25000` |
|
||||||
|
| Round-trip | `is_round_trip(tx.UserID, "24h")` |
|
||||||
|
|
||||||
|
Helper functions (`pkg/engine/helpers.go`) provide the temporal
|
||||||
|
aggregates: `sum_last_24h`, `sum_last_30d`, `count_last_24h`,
|
||||||
|
`first_tx`, `is_round_trip`, `notional_usd`. Operators add their
|
||||||
|
own helpers by registering them on the evaluator.
|
||||||
|
|
||||||
|
Each rule carries:
|
||||||
|
|
||||||
|
- `Severity`: low | medium | high | critical
|
||||||
|
- `Weight`: contribution to the aggregate risk score (0..1)
|
||||||
|
- `Action`: allow | flag | review | block | report
|
||||||
|
- `JurisdictionFilter` / `AssetClassFilter`: scope guards
|
||||||
|
- `Priority`: deterministic evaluation order
|
||||||
|
|
||||||
|
Negative weights are rejected at write time (RED-19 hardening) — a
|
||||||
|
rule cannot subtract from the score.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Customer Risk Tiers
|
||||||
|
|
||||||
|
There is no separate "risk-tier" table. Tiering is modelled by the
|
||||||
|
combination of two `Entity` fields:
|
||||||
|
|
||||||
|
- `KYCLevel` (int) — the verification depth completed.
|
||||||
|
- `RiskScore` (float, 0..1) — the rolling risk score from accepted
|
||||||
|
alerts.
|
||||||
|
|
||||||
|
A rule expresses a tier in DSL:
|
||||||
|
|
||||||
|
```
|
||||||
|
entity.KYCLevel < 2 && tx.Notional > 1000
|
||||||
|
entity.RiskScore > 0.6 && tx.AssetClass == "crypto"
|
||||||
|
```
|
||||||
|
|
||||||
|
The aggregate score returned by `engine.Evaluate` is the
|
||||||
|
weight-sum of matching rules, clipped to `[0, 1]`. The action taken
|
||||||
|
is the *most severe* of the matching rules' actions (`report` >
|
||||||
|
`block` > `review` > `flag` > `allow` for ordering purposes), with
|
||||||
|
ties broken by `Priority`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pre-Transaction Hooks (`bankd` and other callers)
|
||||||
|
|
||||||
|
Caller services call `amld` synchronously on the hot path. The
|
||||||
|
contract is one POST per transaction:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/aml/transactions
|
||||||
|
Headers:
|
||||||
|
X-Org-Id: <tenant>
|
||||||
|
Authorization: Bearer <iam-jwt>
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"id": "<caller-tx-id>", // optional; generated if absent
|
||||||
|
"user_id": "<entity-id>",
|
||||||
|
"account_id": "<account-id>",
|
||||||
|
"symbol": "<asset>",
|
||||||
|
"asset_class": "fiat|crypto|equity|...",
|
||||||
|
"side": "buy|sell|in|out",
|
||||||
|
"qty": 123.0,
|
||||||
|
"notional": 12300.00,
|
||||||
|
"currency": "USD",
|
||||||
|
"counterparty":"<counterparty-id>",
|
||||||
|
"ip_address": "...",
|
||||||
|
"device_fingerprint":"...",
|
||||||
|
"timestamp": "2026-05-23T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The response is the synchronous decision:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"action": "allow|flag|review|block|report",
|
||||||
|
"score": 0.42,
|
||||||
|
"alert_ids": ["..."],
|
||||||
|
"case_id": "..." // present when action ∈ {review,block,report}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caller contract (e.g., `bankd`)
|
||||||
|
|
||||||
|
Pseudocode at the caller:
|
||||||
|
|
||||||
|
```
|
||||||
|
res := amld.Evaluate(tx)
|
||||||
|
switch res.Action {
|
||||||
|
case "allow":
|
||||||
|
proceed()
|
||||||
|
case "flag":
|
||||||
|
proceed() // async review; do not block settlement
|
||||||
|
case "review":
|
||||||
|
park(tx, res.CaseID) // hold until case resolved
|
||||||
|
case "block":
|
||||||
|
reject(tx, res.AlertIDs) // hard stop
|
||||||
|
case "report":
|
||||||
|
proceed() // settle, but a regulatory report
|
||||||
|
// (e.g., CTR) is now owed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller MUST treat any non-200 response as **fail-closed** for
|
||||||
|
regulated transactions (i.e., reject), and as **fail-open** only
|
||||||
|
for explicitly designated low-risk lanes. The default posture is
|
||||||
|
fail-closed.
|
||||||
|
|
||||||
|
The hook is a single round-trip; `amld` does not call back into
|
||||||
|
the caller during evaluation. Asynchronous review happens via the
|
||||||
|
case + webhook surface described next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Webhooks & Case Management
|
||||||
|
|
||||||
|
When a transaction transitions out of `allow`, two things happen:
|
||||||
|
|
||||||
|
1. A `Case` is opened (or extended) with the firing `Alert` rows
|
||||||
|
attached.
|
||||||
|
2. A signed webhook is dispatched to every subscriber registered
|
||||||
|
for the relevant event.
|
||||||
|
|
||||||
|
### Webhook contract
|
||||||
|
|
||||||
|
Subscriber config:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Webhook struct {
|
||||||
|
ID string
|
||||||
|
OrgID string
|
||||||
|
URL string
|
||||||
|
Secret string
|
||||||
|
Events []string // aml.flagged | aml.cleared | case.opened | ...
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Delivery is HTTP POST with:
|
||||||
|
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `X-Webhook-Event: <event-name>`
|
||||||
|
- `X-Webhook-Signature: <hex(hmac-sha256(body, secret))>`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"event": "case.opened",
|
||||||
|
"timestamp": "2026-05-23T12:34:56Z",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Delivery is at-least-once with exponential backoff up to
|
||||||
|
`webhook.MaxRetries` attempts; persistent failures dead-letter.
|
||||||
|
Outbound URLs are validated against SSRF (RED-18 hardening) — no
|
||||||
|
loopback, no link-local, no metadata endpoints.
|
||||||
|
|
||||||
|
### Case API
|
||||||
|
|
||||||
|
The case-management surface is HTTP-only — there is no embedded
|
||||||
|
reviewer UI in `amld` proper (the dashboard is mounted by Base at
|
||||||
|
`/_/aml/`).
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|----------------------------------------|-----------------------------------|
|
||||||
|
| POST | `/v1/aml/transactions` | Submit tx, get decision |
|
||||||
|
| GET | `/v1/aml/transactions/{id}/alerts` | Alerts for a tx |
|
||||||
|
| GET | `/v1/aml/cases` | List cases (filterable) |
|
||||||
|
| POST | `/v1/aml/cases/{id}/events` | Append note / file / status change |
|
||||||
|
| GET | `/v1/aml/rules` | List active rules |
|
||||||
|
| POST | `/v1/aml/rules/test` | Dry-run a rule against a sample tx |
|
||||||
|
| POST | `/v1/aml/sanctions/search` | Name search against loaded lists |
|
||||||
|
| GET | `/v1/aml/health` | Liveness |
|
||||||
|
|
||||||
|
All routes require `X-Org-Id` and an authenticated IAM session.
|
||||||
|
Data is scoped per-org; cross-org reads are not possible.
|
||||||
|
|
||||||
|
A typical reviewer flow:
|
||||||
|
|
||||||
|
1. Case opens → `case.opened` webhook fires → reviewer's
|
||||||
|
case-management UI ingests it.
|
||||||
|
2. Reviewer investigates; appends notes / files / a status change
|
||||||
|
via `POST /v1/aml/cases/{id}/events`.
|
||||||
|
3. Reviewer resolves the case (`cleared` | `sar_filed` |
|
||||||
|
`account_frozen` | `false_positive`); this drives the
|
||||||
|
`case.closed` webhook and updates the rolling `Entity.RiskScore`
|
||||||
|
for future evaluations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational invariants
|
||||||
|
|
||||||
|
- **Synchronous, single-round-trip evaluation.** Callers do not wait
|
||||||
|
on review; they wait only on rule evaluation.
|
||||||
|
- **Per-org isolation.** All persisted rows carry `OrgID`; queries
|
||||||
|
scope by `X-Org-Id` enforced server-side.
|
||||||
|
- **Fail-closed on rule error.** A rule that errors counts as
|
||||||
|
*unevaluated* with `EvalErr` populated; the operator-configured
|
||||||
|
default action applies (default: `review`).
|
||||||
|
- **Append-only sanctions catalog.** Lists are content-addressed by
|
||||||
|
SHA-256; refreshes never mutate prior entries in place.
|
||||||
|
- **Signed, retried webhooks.** HMAC-SHA256 over the body with the
|
||||||
|
subscriber's secret; outbound URLs SSRF-validated.
|
||||||
|
- **No vendor lock-in.** Every external data category — sanctions,
|
||||||
|
PEP, adverse-media — is consumed via a generic schema. Swapping
|
||||||
|
providers means swapping ingesters, not rewriting the engine.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
module github.com/luxfi/aml
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/expr-lang/expr v1.17.6
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hanzoai/base v1.1.0
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
golang.org/x/text v0.37.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cel.dev/expr v0.25.1 // indirect
|
||||||
|
cloud.google.com/go v0.123.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.20.0 // indirect
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
|
cloud.google.com/go/iam v1.7.0 // indirect
|
||||||
|
cloud.google.com/go/monitoring v1.25.0 // indirect
|
||||||
|
cloud.google.com/go/storage v1.56.0 // indirect
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
filippo.io/hpke v0.4.0 // indirect
|
||||||
|
github.com/ALTree/bigfloat v0.2.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
|
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||||
|
github.com/consensys/gnark-crypto v0.20.1 // indirect
|
||||||
|
github.com/cronokirby/saferith v0.33.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||||
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||||
|
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||||
|
github.com/gorilla/rpc v1.2.1 // indirect
|
||||||
|
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||||
|
github.com/gtank/merlin v0.1.1 // indirect
|
||||||
|
github.com/gtank/ristretto255 v0.2.0 // indirect
|
||||||
|
github.com/hanzoai/dbx v1.15.0 // indirect
|
||||||
|
github.com/hanzoai/pubsub-go v1.0.0 // indirect
|
||||||
|
github.com/hanzoai/tasks v1.40.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/luxfi/accel v1.2.4 // indirect
|
||||||
|
github.com/luxfi/age v1.5.1 // indirect
|
||||||
|
github.com/luxfi/cache v1.2.1 // indirect
|
||||||
|
github.com/luxfi/compress v0.0.5 // indirect
|
||||||
|
github.com/luxfi/concurrent v0.0.3 // indirect
|
||||||
|
github.com/luxfi/consensus v1.25.17 // indirect
|
||||||
|
github.com/luxfi/corona v0.7.6 // indirect
|
||||||
|
github.com/luxfi/crypto v1.19.20 // indirect
|
||||||
|
github.com/luxfi/crypto/ipa v1.2.4 // indirect
|
||||||
|
github.com/luxfi/database v1.19.0 // indirect
|
||||||
|
github.com/luxfi/fhe v1.8.8 // indirect
|
||||||
|
github.com/luxfi/ids v1.2.15 // indirect
|
||||||
|
github.com/luxfi/lattice/v7 v7.1.4 // indirect
|
||||||
|
github.com/luxfi/lens v0.1.4 // indirect
|
||||||
|
github.com/luxfi/log v1.4.3 // indirect
|
||||||
|
github.com/luxfi/magnetar v1.2.0 // indirect
|
||||||
|
github.com/luxfi/math v1.4.1 // indirect
|
||||||
|
github.com/luxfi/math/big v0.1.0 // indirect
|
||||||
|
github.com/luxfi/mdns v0.1.1 // indirect
|
||||||
|
github.com/luxfi/metric v1.5.8 // indirect
|
||||||
|
github.com/luxfi/mock v0.1.1 // indirect
|
||||||
|
github.com/luxfi/pq v1.0.3 // indirect
|
||||||
|
github.com/luxfi/pulsar v1.1.1 // indirect
|
||||||
|
github.com/luxfi/threshold v1.9.5 // indirect
|
||||||
|
github.com/luxfi/zap v0.7.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
|
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
|
||||||
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
|
github.com/minio/minio-go/v7 v7.0.100 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.9.0 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
|
github.com/supranational/blst v0.3.16 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
|
||||||
|
golang.org/x/image v0.38.0 // indirect
|
||||||
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
|
google.golang.org/api v0.275.0 // indirect
|
||||||
|
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
|
google.golang.org/grpc v1.80.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.48.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||||
|
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||||
|
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||||
|
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||||
|
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||||
|
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||||
|
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||||
|
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
|
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
|
||||||
|
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
|
||||||
|
cloud.google.com/go/logging v1.14.0 h1:xpPpY8cVT6n9DgIRgrWyE+YEsGlO/994pWnbc7o5Eh4=
|
||||||
|
cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM=
|
||||||
|
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
|
||||||
|
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
|
||||||
|
cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4=
|
||||||
|
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
|
||||||
|
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||||
|
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||||
|
cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ=
|
||||||
|
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
|
||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||||
|
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||||
|
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
|
||||||
|
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
|
||||||
|
github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk=
|
||||||
|
github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||||
|
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||||
|
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||||
|
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||||
|
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
|
||||||
|
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
|
||||||
|
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d h1:49RLWk1j44Xu4fjHb6JFYmeUnDORVwHNkDxaQ0ctCVU=
|
||||||
|
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d/go.mod h1:tSxLoYXyBmiFeKpvmq4dzayMdCjCnu8uqmCysIGBT2Y=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
|
||||||
|
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||||
|
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||||
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||||
|
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
|
||||||
|
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
|
||||||
|
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||||
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||||
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||||
|
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||||
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||||
|
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
|
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||||
|
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
|
||||||
|
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
|
||||||
|
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
|
||||||
|
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
|
||||||
|
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
|
||||||
|
github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s=
|
||||||
|
github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
|
||||||
|
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
|
||||||
|
github.com/hanzoai/base v1.1.0 h1:Mi4yRgR1AFuzB0ajzJ/gEkcxno0gqrbhG2xV6gQ6IR8=
|
||||||
|
github.com/hanzoai/base v1.1.0/go.mod h1:Sw7XlHnaP+k+bLyZrXh/q/JDFnLuy3mlJIDdyO6Agbk=
|
||||||
|
github.com/hanzoai/dbx v1.15.0 h1:IjXzivPcPwYCrl1sj+UVIva2nW5QIw2XoOh1IxhMhvM=
|
||||||
|
github.com/hanzoai/dbx v1.15.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
|
||||||
|
github.com/hanzoai/pubsub-go v1.0.0 h1:nUZ4jK+MFlFQ5qTMri/Yffnqs/R5avI99SfZ9e3t6Cs=
|
||||||
|
github.com/hanzoai/pubsub-go v1.0.0/go.mod h1:ZHWDgSLsYHK4TiitzNwGc7Cn+oApKyMp0uK8cU7jwpg=
|
||||||
|
github.com/hanzoai/tasks v1.40.0 h1:1Y0uXHmdGsU3QSv5So7NH55DhWqw/UPV3kgFeBaaWls=
|
||||||
|
github.com/hanzoai/tasks v1.40.0/go.mod h1:VwQ9ljfO0zYW694NPr4eIdc7WBPpPIs4zC8dbrNALZ0=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||||
|
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
|
||||||
|
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
|
||||||
|
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
|
||||||
|
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
|
||||||
|
github.com/luxfi/age v1.5.1 h1:Gj8iHMMi0lGkKT/mlXV2HVBr2m3vt2v0eKVsTMTtAQM=
|
||||||
|
github.com/luxfi/age v1.5.1/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
|
||||||
|
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
|
||||||
|
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
|
||||||
|
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
|
||||||
|
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
|
||||||
|
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
|
||||||
|
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
|
||||||
|
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
|
||||||
|
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
|
||||||
|
github.com/luxfi/consensus v1.25.17 h1:MjTp+7qd5ArYli+Im+Ba3v5C0iJuDcZbLiI83uT1/Bo=
|
||||||
|
github.com/luxfi/consensus v1.25.17/go.mod h1:cNhLp215lFwiOPxdj8I+6JBLz3/vDRMV2vqLigulv04=
|
||||||
|
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
|
||||||
|
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
|
||||||
|
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
|
||||||
|
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
|
||||||
|
github.com/luxfi/corona v0.7.6 h1:CJP6smygD55dL0HHkKkWryL9H24a+wXvs+L+WchK7Nc=
|
||||||
|
github.com/luxfi/corona v0.7.6/go.mod h1:4aD7+ZqnlZ2aVuU/DBQ5aspIagv5ux45LW2sJ4+siY8=
|
||||||
|
github.com/luxfi/crypto v1.19.20 h1:PjM/d3tSSWdhsMezaDg5JnFHFJfSGknBJJ+STlsmJ+0=
|
||||||
|
github.com/luxfi/crypto v1.19.20/go.mod h1:0tfz+EbAjsW1QBWB0cte9kdjB5XhhYFmCr8BkZRux48=
|
||||||
|
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
|
||||||
|
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
|
||||||
|
github.com/luxfi/database v1.19.0 h1:sySH5eS8ihJtyc+yb0LgTignGwJ9H4W9bv+nboCuIm0=
|
||||||
|
github.com/luxfi/database v1.19.0/go.mod h1:o1ccjgVKMYbVBfi+6+sDvx4swGFgGnCGFQ4gL/oJ+kY=
|
||||||
|
github.com/luxfi/fhe v1.8.8 h1:3DuWY1OvnhbMPhjQT5RPmNXUGnu8x6KaJAohVQcwUXw=
|
||||||
|
github.com/luxfi/fhe v1.8.8/go.mod h1:3EsrY0COtGmQGpab3Nb/bA67ZUeWMS6/nwepUeacjpY=
|
||||||
|
github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
|
||||||
|
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
|
||||||
|
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||||
|
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
||||||
|
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
|
||||||
|
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
|
||||||
|
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
|
||||||
|
github.com/luxfi/lens v0.1.4/go.mod h1:mL+G8IK+9L41d78/2FYRgfhEzAjcr5+VEXB8SGuHbus=
|
||||||
|
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
|
||||||
|
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
|
||||||
|
github.com/luxfi/magnetar v1.2.0 h1:bsxHmBnJiswc/A6ElQ0pWz5g6ogqewIEKKqR26VgizA=
|
||||||
|
github.com/luxfi/magnetar v1.2.0/go.mod h1:7J9YP9jByWbwCjssMFJNUkTU8tcPlSUoVSSiYShtvFs=
|
||||||
|
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
|
||||||
|
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
|
||||||
|
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
|
||||||
|
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
|
||||||
|
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
|
||||||
|
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
|
||||||
|
github.com/luxfi/metric v1.5.8 h1:axPwfq+erOlIue7IJp5g+hMcMtVhYHja9gJAaT3+KNA=
|
||||||
|
github.com/luxfi/metric v1.5.8/go.mod h1:fO2giazkg4NDtr72JM/QXJBYebplAMeWC1JoZyNDvKw=
|
||||||
|
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
|
||||||
|
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
|
||||||
|
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
|
||||||
|
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
|
||||||
|
github.com/luxfi/pulsar v1.1.1 h1:jo1jEgUsGiVxpT17Eg7Gw4Ax+07pbKFzq4NhHMLmDpI=
|
||||||
|
github.com/luxfi/pulsar v1.1.1/go.mod h1:U7tPleeAHJ9dZ61ymtstzLKKoZjxM2zFeGZ+RSjHyRw=
|
||||||
|
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
||||||
|
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
|
||||||
|
github.com/luxfi/threshold v1.9.5 h1:fTGiT+qCyskAjJbDXw1C3x+ZGKYJiyc3WXeawarYHng=
|
||||||
|
github.com/luxfi/threshold v1.9.5/go.mod h1:yFEqzc07h+olG8CpJ+i2FjSLxCznk/GcISRfxMuDBvE=
|
||||||
|
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
|
||||||
|
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
|
||||||
|
github.com/luxfi/zap v0.7.2 h1:YecWTWNE5PPJXL56sLIkzS8b23bprUwZ5lPAQuLUtTE=
|
||||||
|
github.com/luxfi/zap v0.7.2/go.mod h1:1k+nwT+JW802YzuPAuf7CxMSGr/qxvbGgGwi5k6X9Ok=
|
||||||
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
|
github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM=
|
||||||
|
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk=
|
||||||
|
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU=
|
||||||
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||||
|
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||||
|
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
|
||||||
|
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
|
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||||
|
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
|
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
|
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||||
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||||
|
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||||
|
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||||
|
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||||
|
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||||
|
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||||
|
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||||
|
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||||
|
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||||
|
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||||
|
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
|
||||||
|
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
|
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
|
||||||
|
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||||
|
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
|
||||||
|
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||||
|
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||||
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||||
|
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
|
||||||
|
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||||
|
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Command notice writes the third-party notice file that ships beside a
|
||||||
|
// distributed artifact.
|
||||||
|
//
|
||||||
|
// It reads the module graph of the package being distributed — the same graph
|
||||||
|
// the linker walks — and reproduces, verbatim, each dependency module's
|
||||||
|
// license, notice, and patent-grant texts. Apache-2.0 §4(d) requires the
|
||||||
|
// attribution notices of Apache-licensed dependencies to travel with every
|
||||||
|
// distributed copy; MIT/BSD/ISC require their copyright and permission notices
|
||||||
|
// to travel the same way.
|
||||||
|
//
|
||||||
|
// The graph depends on the build configuration, so callers must describe the
|
||||||
|
// artifact they are shipping: -tags and CGO_ENABLED select which packages are
|
||||||
|
// reachable (blst is linked only under cgo), and -platforms unions over the
|
||||||
|
// GOOS/GOARCH set when one notice file serves several archives. Pointed at the
|
||||||
|
// wrong configuration this reports the wrong dependency set.
|
||||||
|
//
|
||||||
|
// Output is deterministic: modules sorted by path, files sorted by name, no
|
||||||
|
// timestamps. Byte-identical input produces a byte-identical file.
|
||||||
|
//
|
||||||
|
// CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld \
|
||||||
|
// -platforms linux/amd64,darwin/arm64 -o THIRD-PARTY-NOTICES
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// module is the subset of `go list -json` output this command needs.
|
||||||
|
type module struct {
|
||||||
|
Path string
|
||||||
|
Version string
|
||||||
|
Dir string
|
||||||
|
Main bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pkg struct {
|
||||||
|
Module *module
|
||||||
|
}
|
||||||
|
|
||||||
|
// kinds maps a filename stem to the section heading it is reproduced under.
|
||||||
|
// One rule covers every spelling and extension a module might use: match the
|
||||||
|
// stem case-insensitively, ignore the extension.
|
||||||
|
var kinds = map[string]string{
|
||||||
|
"license": "LICENSE",
|
||||||
|
"licence": "LICENSE",
|
||||||
|
"licenses": "LICENSE",
|
||||||
|
"copying": "LICENSE",
|
||||||
|
"unlicense": "LICENSE",
|
||||||
|
"notice": "NOTICE",
|
||||||
|
"patents": "PATENT GRANT",
|
||||||
|
}
|
||||||
|
|
||||||
|
// legal reports whether name is a legal text to reproduce, and under which
|
||||||
|
// heading. Names carry a distinguishing suffix through to the heading so a
|
||||||
|
// module shipping both LICENSE-MIT and LICENSE-APACHE keeps them apart.
|
||||||
|
func legal(name string) (heading string, ok bool) {
|
||||||
|
stem := strings.ToLower(name)
|
||||||
|
if i := strings.LastIndex(stem, "."); i > 0 {
|
||||||
|
stem = stem[:i]
|
||||||
|
}
|
||||||
|
if h, ok := kinds[stem]; ok {
|
||||||
|
return h, true
|
||||||
|
}
|
||||||
|
// LICENSE-MIT, LICENSE.APACHE2, COPYING.LESSER, license_apache.
|
||||||
|
for _, sep := range []string{"-", "_", "."} {
|
||||||
|
if i := strings.Index(stem, sep); i > 0 {
|
||||||
|
if h, ok := kinds[stem[:i]]; ok {
|
||||||
|
return h + " (" + name + ")", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// texts returns the legal texts in a module root, keyed by heading, sorted by
|
||||||
|
// filename so the result is stable across filesystems.
|
||||||
|
func texts(dir string) (map[string]string, []string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
names := make([]string, 0, 4)
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Type().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := legal(e.Name()); ok {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
out := make(map[string]string, len(names))
|
||||||
|
headings := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
heading, _ := legal(name)
|
||||||
|
if _, dup := out[heading]; dup {
|
||||||
|
heading += " (" + name + ")"
|
||||||
|
}
|
||||||
|
out[heading] = strings.TrimRight(string(b), "\n") + "\n"
|
||||||
|
headings = append(headings, heading)
|
||||||
|
}
|
||||||
|
return out, headings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect adds to seen every dependency module whose code is compiled into
|
||||||
|
// pkgPath for one GOOS/GOARCH. The main module is excluded: its own LICENSE
|
||||||
|
// ships separately. Standard-library packages have no module and are skipped.
|
||||||
|
func collect(seen map[string]module, tags, pkgPath, goos, goarch string) error {
|
||||||
|
args := []string{"list", "-deps", "-json"}
|
||||||
|
if tags != "" {
|
||||||
|
args = append(args, "-tags", tags)
|
||||||
|
}
|
||||||
|
args = append(args, pkgPath)
|
||||||
|
|
||||||
|
cmd := exec.Command(goBin(), args...)
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if goos != "" {
|
||||||
|
cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch)
|
||||||
|
}
|
||||||
|
out, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := json.NewDecoder(out)
|
||||||
|
for {
|
||||||
|
var p pkg
|
||||||
|
if err := dec.Decode(&p); err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("decoding go list output: %w", err)
|
||||||
|
}
|
||||||
|
if p.Module == nil || p.Module.Main || p.Module.Version == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p.Module.Path] = *p.Module
|
||||||
|
}
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return fmt.Errorf("go %s (GOOS=%s GOARCH=%s): %w", strings.Join(args, " "), goos, goarch, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modules returns the union of the dependency modules across every platform the
|
||||||
|
// artifact is released for, sorted by module path.
|
||||||
|
//
|
||||||
|
// The set is platform-dependent — darwin links ncruces/go-strftime and linux
|
||||||
|
// does not, linux links prometheus/procfs and darwin does not — and one notice
|
||||||
|
// file goes into every archive. A union over-attributes on some platforms,
|
||||||
|
// which costs nothing; describing only the host platform would omit a license
|
||||||
|
// from the archives built for the others, which is the whole failure being
|
||||||
|
// fixed here.
|
||||||
|
func modules(tags, pkgPath string, platforms []string) ([]module, error) {
|
||||||
|
seen := map[string]module{}
|
||||||
|
if len(platforms) == 0 {
|
||||||
|
if err := collect(seen, tags, pkgPath, "", ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range platforms {
|
||||||
|
goos, goarch, ok := strings.Cut(p, "/")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("platform %q is not GOOS/GOARCH", p)
|
||||||
|
}
|
||||||
|
if err := collect(seen, tags, pkgPath, goos, goarch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := make([]module, 0, len(seen))
|
||||||
|
for _, m := range seen {
|
||||||
|
mods = append(mods, m)
|
||||||
|
}
|
||||||
|
sort.Slice(mods, func(i, j int) bool { return mods[i].Path < mods[j].Path })
|
||||||
|
return mods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// goBin resolves the toolchain that is running this command, so the generated
|
||||||
|
// file describes the graph the build will actually use.
|
||||||
|
func goBin() string {
|
||||||
|
if exe := os.Getenv("GOEXE_PATH"); exe != "" {
|
||||||
|
return exe
|
||||||
|
}
|
||||||
|
if root := os.Getenv("GOROOT"); root != "" {
|
||||||
|
if exe := filepath.Join(root, "bin", "go"); fileExists(exe) {
|
||||||
|
return exe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "go"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(p string) bool {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
return err == nil && fi.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = "--------------------------------------------------------------------------------"
|
||||||
|
|
||||||
|
func render(w io.Writer, artifact string, mods []module, platforms []string) error {
|
||||||
|
var bare []module
|
||||||
|
body := &strings.Builder{}
|
||||||
|
|
||||||
|
for _, m := range mods {
|
||||||
|
if m.Dir == "" {
|
||||||
|
bare = append(bare, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byHeading, headings, err := texts(m.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s@%s: %w", m.Path, m.Version, err)
|
||||||
|
}
|
||||||
|
if len(headings) == 0 {
|
||||||
|
bare = append(bare, m)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(body, "\n%s\n%s %s\n%s\n", rule, m.Path, m.Version, rule)
|
||||||
|
for _, h := range headings {
|
||||||
|
fmt.Fprintf(body, "\n---- %s ----\n\n%s", h, byHeading[h])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "Third-party notices for %s\n", artifact)
|
||||||
|
fmt.Fprintf(w, "%s\n\n", strings.Repeat("=", 24+len(artifact)))
|
||||||
|
scope := "the platform it was generated on"
|
||||||
|
if len(platforms) > 0 {
|
||||||
|
scope = strings.Join(platforms, ", ")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `%s links the modules listed below. Their license, notice, and patent-grant
|
||||||
|
texts are reproduced verbatim in the sections that follow. This file is
|
||||||
|
generated from the build's own module graph by `+"`go run ./internal/notice`"+`;
|
||||||
|
it covers third-party code only. The license of %s itself is in LICENSE.
|
||||||
|
|
||||||
|
The module set is platform-dependent, so this is the union over every platform
|
||||||
|
%s is released for: %s. A module listed here may
|
||||||
|
therefore not be linked into the particular binary beside this file.
|
||||||
|
|
||||||
|
Modules: %d
|
||||||
|
|
||||||
|
`, artifact, artifact, artifact, scope, len(mods))
|
||||||
|
|
||||||
|
for _, m := range mods {
|
||||||
|
fmt.Fprintf(w, " %s %s\n", m.Path, m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.WriteString(w, body.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bare) > 0 {
|
||||||
|
fmt.Fprintf(w, "\n%s\nModules with no license text in the module root\n%s\n\n", rule, rule)
|
||||||
|
io.WriteString(w, "No license, notice, or patent file was found at the root of these\nmodules, so there is no text to reproduce for them:\n\n")
|
||||||
|
for _, m := range bare {
|
||||||
|
fmt.Fprintf(w, " %s %s\n", m.Path, m.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tags := flag.String("tags", "", "build tags for the distributed artifact")
|
||||||
|
pkgPath := flag.String("pkg", "./...", "package whose module graph is described")
|
||||||
|
out := flag.String("o", "THIRD-PARTY-NOTICES", "output file")
|
||||||
|
name := flag.String("name", "", "artifact name in the header (default: base of -pkg)")
|
||||||
|
plat := flag.String("platforms", "", "comma-separated GOOS/GOARCH list to union over (default: host)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
artifact := *name
|
||||||
|
if artifact == "" {
|
||||||
|
artifact = filepath.Base(*pkgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var platforms []string
|
||||||
|
if *plat != "" {
|
||||||
|
platforms = strings.Split(*plat, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
mods, err := modules(*tags, *pkgPath, platforms)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "notice:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if len(mods) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "notice: no dependency modules found — refusing to write an empty notice file")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := &strings.Builder{}
|
||||||
|
if err := render(buf, artifact, mods, platforms); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "notice:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*out, []byte(buf.String()), 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "notice:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "notice: %s — %d modules across %d platform(s)\n", *out, len(mods), max(len(platforms), 1))
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
-- AML core collections for Hanzo Base.
|
||||||
|
-- Base auto-manages id, created, updated on every record.
|
||||||
|
-- These are the 9 canonical collections.
|
||||||
|
|
||||||
|
-- 1. Transactions — incoming transaction events
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
tenant_id TEXT NOT NULL DEFAULT '',
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
account_id TEXT NOT NULL DEFAULT '',
|
||||||
|
symbol TEXT NOT NULL DEFAULT '',
|
||||||
|
asset_class TEXT NOT NULL DEFAULT '',
|
||||||
|
side TEXT NOT NULL DEFAULT '',
|
||||||
|
qty REAL NOT NULL DEFAULT 0,
|
||||||
|
notional REAL NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
counterparty TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
device_fingerprint TEXT NOT NULL DEFAULT '',
|
||||||
|
timestamp TEXT NOT NULL DEFAULT '',
|
||||||
|
raw JSON DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_org ON transactions(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_user ON transactions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tx_timestamp ON transactions(timestamp);
|
||||||
|
|
||||||
|
-- 2. Entities — normalized actors
|
||||||
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
entity_type TEXT NOT NULL DEFAULT 'user',
|
||||||
|
external_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
jurisdiction TEXT NOT NULL DEFAULT '',
|
||||||
|
kyc_level INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pep INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sanctions_flag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
risk_score REAL NOT NULL DEFAULT 0,
|
||||||
|
first_seen TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_org ON entities(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_external ON entities(external_id);
|
||||||
|
|
||||||
|
-- 3. Rules — evaluation rules
|
||||||
|
CREATE TABLE IF NOT EXISTS rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
dsl TEXT NOT NULL DEFAULT '',
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
weight REAL NOT NULL DEFAULT 0.1,
|
||||||
|
action TEXT NOT NULL DEFAULT 'flag',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
jurisdiction_filter JSON DEFAULT '[]',
|
||||||
|
asset_class_filter JSON DEFAULT '[]',
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rules_org ON rules(org_id);
|
||||||
|
|
||||||
|
-- 4. Alerts — rule hits
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
tx_id TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_id TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_name TEXT NOT NULL DEFAULT '',
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
score REAL NOT NULL DEFAULT 0,
|
||||||
|
score_breakdown JSON DEFAULT '{}',
|
||||||
|
action_taken TEXT NOT NULL DEFAULT 'flag',
|
||||||
|
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||||
|
reviewed_at TEXT NOT NULL DEFAULT '',
|
||||||
|
decision TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_org ON alerts(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_tx ON alerts(tx_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_rule ON alerts(rule_id);
|
||||||
|
|
||||||
|
-- 5. Cases — human review cases
|
||||||
|
CREATE TABLE IF NOT EXISTS cases (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
number INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
severity TEXT NOT NULL DEFAULT 'medium',
|
||||||
|
entity_ids JSON DEFAULT '[]',
|
||||||
|
alert_ids JSON DEFAULT '[]',
|
||||||
|
assignee_id TEXT NOT NULL DEFAULT '',
|
||||||
|
opened_at TEXT NOT NULL DEFAULT '',
|
||||||
|
closed_at TEXT NOT NULL DEFAULT '',
|
||||||
|
resolution TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cases_org ON cases(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status);
|
||||||
|
|
||||||
|
-- 6. Case events — case timeline
|
||||||
|
CREATE TABLE IF NOT EXISTS case_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
case_id TEXT NOT NULL DEFAULT '',
|
||||||
|
author_id TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL DEFAULT 'note',
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
file_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_case_events_case ON case_events(case_id);
|
||||||
|
|
||||||
|
-- 7. Sanctions lists — list metadata
|
||||||
|
CREATE TABLE IF NOT EXISTS sanctions_lists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
format TEXT NOT NULL DEFAULT 'xml',
|
||||||
|
fetched_at TEXT NOT NULL DEFAULT '',
|
||||||
|
sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Sanctions entries — flattened entries
|
||||||
|
CREATE TABLE IF NOT EXISTS sanctions_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
list_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ref_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
aliases JSON DEFAULT '[]',
|
||||||
|
dob TEXT NOT NULL DEFAULT '',
|
||||||
|
nationality TEXT NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
type TEXT NOT NULL DEFAULT 'individual',
|
||||||
|
raw JSON DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sanctions_list ON sanctions_entries(list_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sanctions_name ON sanctions_entries(name);
|
||||||
|
|
||||||
|
-- 9. Webhooks — subscribers
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
org_id TEXT NOT NULL DEFAULT '',
|
||||||
|
url TEXT NOT NULL DEFAULT '',
|
||||||
|
secret TEXT NOT NULL DEFAULT '',
|
||||||
|
events JSON DEFAULT '[]',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_delivery_at TEXT NOT NULL DEFAULT '',
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhooks_org ON webhooks(org_id);
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
// Package api registers AML HTTP routes on a Base app.
|
||||||
|
// All endpoints enforce X-Org-Id and require IAM auth.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/hanzoai/base/core"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/cases"
|
||||||
|
"github.com/luxfi/aml/pkg/engine"
|
||||||
|
"github.com/luxfi/aml/pkg/sanctions"
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler wires AML engine + case store + sanctions to HTTP routes.
|
||||||
|
type Handler struct {
|
||||||
|
Engine *engine.Engine
|
||||||
|
Cases *cases.Store
|
||||||
|
Alerts *AlertStore
|
||||||
|
Sanctions *SanctionsStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMaxAlerts is the default maximum number of transaction IDs in the alert store.
|
||||||
|
const DefaultMaxAlerts = 100_000
|
||||||
|
|
||||||
|
// AlertStore is a thread-safe in-memory alert store with LRU eviction.
|
||||||
|
// RED-02: sync.RWMutex for data-race safety.
|
||||||
|
// RED-08: LRU eviction prevents unbounded memory growth.
|
||||||
|
type AlertStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
alerts map[string][]types.Alert // keyed by tx_id
|
||||||
|
order []string // insertion order for LRU eviction
|
||||||
|
maxItems int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertStore creates an alert store with the default max capacity.
|
||||||
|
func NewAlertStore() *AlertStore {
|
||||||
|
return NewAlertStoreWithMax(DefaultMaxAlerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertStoreWithMax creates an alert store with the specified max capacity.
|
||||||
|
func NewAlertStoreWithMax(maxItems int) *AlertStore {
|
||||||
|
if maxItems <= 0 {
|
||||||
|
maxItems = DefaultMaxAlerts
|
||||||
|
}
|
||||||
|
return &AlertStore{
|
||||||
|
alerts: make(map[string][]types.Alert),
|
||||||
|
maxItems: maxItems,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores alerts for a transaction. Evicts oldest 10% when capacity is exceeded.
|
||||||
|
func (s *AlertStore) Add(txID string, alerts []types.Alert) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.alerts[txID]; !exists {
|
||||||
|
s.order = append(s.order, txID)
|
||||||
|
}
|
||||||
|
s.alerts[txID] = append(s.alerts[txID], alerts...)
|
||||||
|
|
||||||
|
if len(s.order) > s.maxItems {
|
||||||
|
evictCount := s.maxItems / 10
|
||||||
|
if evictCount < 1 {
|
||||||
|
evictCount = 1
|
||||||
|
}
|
||||||
|
for i := 0; i < evictCount && i < len(s.order); i++ {
|
||||||
|
delete(s.alerts, s.order[i])
|
||||||
|
}
|
||||||
|
s.order = s.order[evictCount:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByTx returns alerts for a transaction.
|
||||||
|
func (s *AlertStore) ByTx(txID string) []types.Alert {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.alerts[txID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of transaction IDs in the store.
|
||||||
|
func (s *AlertStore) Len() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return len(s.alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanctionsStore is a minimal in-memory sanctions entry store.
|
||||||
|
type SanctionsStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries []types.SanctionsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSanctionsStore creates an empty sanctions store.
|
||||||
|
func NewSanctionsStore() *SanctionsStore {
|
||||||
|
return &SanctionsStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load replaces all entries.
|
||||||
|
func (s *SanctionsStore) Load(entries []types.SanctionsEntry) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.entries = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search finds entries matching the given name above the threshold.
|
||||||
|
func (s *SanctionsStore) Search(name string, threshold float64) []sanctionSearchResult {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var results []sanctionSearchResult
|
||||||
|
for _, e := range s.entries {
|
||||||
|
score := sanctions.TokenMatch(name, e.Name)
|
||||||
|
if score >= threshold {
|
||||||
|
results = append(results, sanctionSearchResult{
|
||||||
|
Entry: e,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check aliases.
|
||||||
|
for _, alias := range e.Aliases {
|
||||||
|
aliasScore := sanctions.TokenMatch(name, alias)
|
||||||
|
if aliasScore >= threshold && aliasScore > score {
|
||||||
|
score = aliasScore
|
||||||
|
results = append(results, sanctionSearchResult{
|
||||||
|
Entry: e,
|
||||||
|
Score: score,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
type sanctionSearchResult struct {
|
||||||
|
Entry types.SanctionsEntry
|
||||||
|
Score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds AML routes to a ServeEvent router.
|
||||||
|
// Called from within an OnServe hook.
|
||||||
|
func (h *Handler) Register(se *core.ServeEvent) {
|
||||||
|
se.Router.POST("/v1/aml/transactions", h.ingestTransaction())
|
||||||
|
se.Router.GET("/v1/aml/transactions/{id}/alerts", h.getAlerts())
|
||||||
|
se.Router.GET("/v1/aml/cases", h.listCases())
|
||||||
|
se.Router.POST("/v1/aml/cases/{id}/events", h.addCaseEvent())
|
||||||
|
se.Router.GET("/v1/aml/rules", h.listRules())
|
||||||
|
se.Router.POST("/v1/aml/rules/test", h.testRule())
|
||||||
|
se.Router.POST("/v1/aml/sanctions/search", h.searchSanctions())
|
||||||
|
se.Router.GET("/v1/aml/health", h.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) health() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{
|
||||||
|
"status": "ok",
|
||||||
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ingestTransaction() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
orgID := e.Request.Header.Get("X-Org-Id")
|
||||||
|
if orgID == "" {
|
||||||
|
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "missing X-Org-Id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx types.Transaction
|
||||||
|
if err := json.NewDecoder(e.Request.Body).Decode(&tx); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
if tx.ID == "" {
|
||||||
|
tx.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
tx.OrgID = orgID
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tx.CreatedAt = now
|
||||||
|
tx.UpdatedAt = now
|
||||||
|
if tx.Timestamp.IsZero() {
|
||||||
|
tx.Timestamp = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve entity — for now use a minimal entity from the tx.
|
||||||
|
entity := types.Entity{
|
||||||
|
ID: tx.UserID,
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: tx.UserID,
|
||||||
|
EntityType: types.EntityUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts, score, action := h.Engine.Evaluate(tx, entity)
|
||||||
|
|
||||||
|
alertIDs := make([]string, len(alerts))
|
||||||
|
for i, a := range alerts {
|
||||||
|
alertIDs[i] = a.ID
|
||||||
|
}
|
||||||
|
h.Alerts.Add(tx.ID, alerts)
|
||||||
|
|
||||||
|
result := types.EvalResult{
|
||||||
|
Action: action,
|
||||||
|
Score: score,
|
||||||
|
AlertIDs: alertIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// On critical: auto-create case.
|
||||||
|
if action == types.ActionBlock || action == types.ActionReport {
|
||||||
|
c := h.Cases.Create(orgID, types.SeverityCritical, alertIDs, []string{tx.UserID})
|
||||||
|
result.CaseID = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getAlerts() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
txID := e.Request.PathValue("id")
|
||||||
|
alerts := h.Alerts.ByTx(txID)
|
||||||
|
if alerts == nil {
|
||||||
|
alerts = []types.Alert{}
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listCases() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
orgID := e.Request.Header.Get("X-Org-Id")
|
||||||
|
status := e.Request.URL.Query().Get("status")
|
||||||
|
result := h.Cases.List(orgID, status)
|
||||||
|
if result == nil {
|
||||||
|
result = []*types.Case{}
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) addCaseEvent() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
caseID := e.Request.PathValue("id")
|
||||||
|
|
||||||
|
var evt types.CaseEvent
|
||||||
|
if err := json.NewDecoder(e.Request.Body).Decode(&evt); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.Cases.AddEvent(caseID, evt); err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listRules() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, h.Engine.Rules())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) searchSanctions() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DOB string `json:"dob"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.Sanctions == nil {
|
||||||
|
return e.JSON(http.StatusOK, []interface{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
results := h.Sanctions.Search(req.Name, sanctions.MatchThreshold)
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ListID string `json:"list_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
DOB string `json:"dob,omitempty"`
|
||||||
|
Nationality string `json:"nationality,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]entry, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
out = append(out, entry{
|
||||||
|
ID: r.Entry.ID,
|
||||||
|
ListID: r.Entry.ListID,
|
||||||
|
Name: r.Entry.Name,
|
||||||
|
Aliases: r.Entry.Aliases,
|
||||||
|
DOB: r.Entry.DOB,
|
||||||
|
Nationality: r.Entry.Nationality,
|
||||||
|
Type: r.Entry.Type,
|
||||||
|
Score: r.Score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDSLLength is the maximum allowed DSL expression length (RED-15).
|
||||||
|
const maxDSLLength = 2048
|
||||||
|
|
||||||
|
func (h *Handler) testRule() func(e *core.RequestEvent) error {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
var req struct {
|
||||||
|
DSL string `json:"dsl"`
|
||||||
|
Tx types.Transaction `json:"tx"`
|
||||||
|
Entity types.Entity `json:"entity"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED-15: Reject oversized DSL to prevent DoS via computation bombs.
|
||||||
|
if len(req.DSL) > maxDSLLength {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "DSL expression exceeds maximum length of 2048 bytes",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
testRule := types.Rule{
|
||||||
|
ID: "test",
|
||||||
|
Name: "test",
|
||||||
|
DSL: req.DSL,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
match, err := h.Engine.Evaluator().Eval(testRule, types.EvalContext{
|
||||||
|
Tx: req.Tx,
|
||||||
|
Entity: req.Entity,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"match": match,
|
||||||
|
"dsl": req.DSL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAlertStoreEviction (RED-08) verifies that the alert store evicts oldest
|
||||||
|
// entries when capacity is exceeded.
|
||||||
|
func TestAlertStoreEviction(t *testing.T) {
|
||||||
|
store := NewAlertStoreWithMax(100)
|
||||||
|
|
||||||
|
// Add 200 items — store should evict down to ~90 (evicts 10% = 10 entries per trigger).
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
txID := fmt.Sprintf("tx-%d", i)
|
||||||
|
store.Add(txID, []types.Alert{{ID: fmt.Sprintf("alert-%d", i)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
size := store.Len()
|
||||||
|
if size > 100 {
|
||||||
|
t.Errorf("store size after eviction = %d, want <= 100", size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest entries should be evicted.
|
||||||
|
if alerts := store.ByTx("tx-0"); len(alerts) > 0 {
|
||||||
|
t.Error("tx-0 should have been evicted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent entries should still exist.
|
||||||
|
if alerts := store.ByTx("tx-199"); len(alerts) == 0 {
|
||||||
|
t.Error("tx-199 should still be present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlertStoreNoEvictionUnderCapacity (RED-08) verifies no eviction happens
|
||||||
|
// when store is under capacity.
|
||||||
|
func TestAlertStoreNoEvictionUnderCapacity(t *testing.T) {
|
||||||
|
store := NewAlertStoreWithMax(200)
|
||||||
|
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
store.Add(fmt.Sprintf("tx-%d", i), []types.Alert{{ID: fmt.Sprintf("a-%d", i)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
if store.Len() != 50 {
|
||||||
|
t.Errorf("store size = %d, want 50", store.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// All entries should be present.
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if alerts := store.ByTx(fmt.Sprintf("tx-%d", i)); len(alerts) == 0 {
|
||||||
|
t.Errorf("tx-%d should be present", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAlertStoreDuplicateTxID verifies that adding to an existing tx_id
|
||||||
|
// does not double-count in the order list.
|
||||||
|
func TestAlertStoreDuplicateTxID(t *testing.T) {
|
||||||
|
store := NewAlertStoreWithMax(100)
|
||||||
|
|
||||||
|
store.Add("tx-1", []types.Alert{{ID: "a1"}})
|
||||||
|
store.Add("tx-1", []types.Alert{{ID: "a2"}})
|
||||||
|
|
||||||
|
if store.Len() != 1 {
|
||||||
|
t.Errorf("store size = %d, want 1 (same tx_id)", store.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts := store.ByTx("tx-1")
|
||||||
|
if len(alerts) != 2 {
|
||||||
|
t.Errorf("alert count = %d, want 2", len(alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Package cases provides case management — creating, updating, and
|
||||||
|
// querying compliance review cases and their event timelines.
|
||||||
|
package cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultClosedCaseRetention is how long closed cases stay in memory.
|
||||||
|
const DefaultClosedCaseRetention = 90 * 24 * time.Hour // 90 days
|
||||||
|
|
||||||
|
// DefaultMaxCases is the maximum open+recent cases held in memory.
|
||||||
|
const DefaultMaxCases = 100_000
|
||||||
|
|
||||||
|
// Store is an in-memory case store with eviction of old closed cases.
|
||||||
|
// RED-08: Prevents unbounded memory growth by evicting closed cases
|
||||||
|
// older than the retention period, and hard-capping total count.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cases map[string]*types.Case
|
||||||
|
events map[string][]types.CaseEvent
|
||||||
|
seq atomic.Int64
|
||||||
|
maxCases int
|
||||||
|
retention time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates an empty case store with default limits.
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{
|
||||||
|
cases: make(map[string]*types.Case),
|
||||||
|
events: make(map[string][]types.CaseEvent),
|
||||||
|
maxCases: DefaultMaxCases,
|
||||||
|
retention: DefaultClosedCaseRetention,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStoreWithLimits creates a case store with explicit limits.
|
||||||
|
func NewStoreWithLimits(maxCases int, retention time.Duration) *Store {
|
||||||
|
if maxCases <= 0 {
|
||||||
|
maxCases = DefaultMaxCases
|
||||||
|
}
|
||||||
|
if retention <= 0 {
|
||||||
|
retention = DefaultClosedCaseRetention
|
||||||
|
}
|
||||||
|
return &Store{
|
||||||
|
cases: make(map[string]*types.Case),
|
||||||
|
events: make(map[string][]types.CaseEvent),
|
||||||
|
maxCases: maxCases,
|
||||||
|
retention: retention,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvictExpired removes closed cases older than the retention period.
|
||||||
|
// Called internally after Create; can also be called externally by a cron.
|
||||||
|
func (s *Store) EvictExpired() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.evictExpiredLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) evictExpiredLocked() int {
|
||||||
|
cutoff := time.Now().UTC().Add(-s.retention)
|
||||||
|
evicted := 0
|
||||||
|
for id, c := range s.cases {
|
||||||
|
if c.Status == types.CaseClosed && c.ClosedAt != nil && c.ClosedAt.Before(cutoff) {
|
||||||
|
delete(s.cases, id)
|
||||||
|
delete(s.events, id)
|
||||||
|
evicted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of cases in the store.
|
||||||
|
func (s *Store) Len() int {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return len(s.cases)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create opens a new case from a set of alerts.
|
||||||
|
func (s *Store) Create(orgID string, severity string, alertIDs []string, entityIDs []string) *types.Case {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := &types.Case{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
OrgID: orgID,
|
||||||
|
Number: s.seq.Add(1),
|
||||||
|
Status: types.CaseOpen,
|
||||||
|
Severity: severity,
|
||||||
|
AlertIDs: alertIDs,
|
||||||
|
EntityIDs: entityIDs,
|
||||||
|
OpenedAt: now,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c.ID] = c
|
||||||
|
// RED-08: Evict expired closed cases when approaching capacity.
|
||||||
|
if len(s.cases) > s.maxCases {
|
||||||
|
s.evictExpiredLocked()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a case by ID.
|
||||||
|
func (s *Store) Get(id string) *types.Case {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.cases[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns cases for an org, optionally filtered by status.
|
||||||
|
func (s *Store) List(orgID, status string) []*types.Case {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var out []*types.Case
|
||||||
|
for _, c := range s.cases {
|
||||||
|
if c.OrgID != orgID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status != "" && c.Status != status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus changes a case's status and records the event.
|
||||||
|
func (s *Store) UpdateStatus(caseID, status, authorID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
c, ok := s.cases[caseID]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStatus := c.Status
|
||||||
|
c.Status = status
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c.UpdatedAt = now
|
||||||
|
|
||||||
|
if status == types.CaseClosed {
|
||||||
|
c.ClosedAt = &now
|
||||||
|
}
|
||||||
|
|
||||||
|
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
CaseID: caseID,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Kind: types.EventStatusChange,
|
||||||
|
Body: oldStatus + " -> " + status,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEvent appends a note, file, or other event to a case timeline.
|
||||||
|
func (s *Store) AddEvent(caseID string, evt types.CaseEvent) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := s.cases[caseID]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
evt.ID = uuid.NewString()
|
||||||
|
evt.CaseID = caseID
|
||||||
|
evt.CreatedAt = time.Now().UTC()
|
||||||
|
s.events[caseID] = append(s.events[caseID], evt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events returns the timeline for a case.
|
||||||
|
func (s *Store) Events(caseID string) []types.CaseEvent {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.events[caseID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign sets the assignee for a case.
|
||||||
|
func (s *Store) Assign(caseID, assigneeID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
c, ok := s.cases[caseID]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
c.AssigneeID = assigneeID
|
||||||
|
c.UpdatedAt = time.Now().UTC()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve closes a case with a resolution.
|
||||||
|
func (s *Store) Resolve(caseID, resolution, authorID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
c, ok := s.cases[caseID]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c.Status = types.CaseClosed
|
||||||
|
c.Resolution = resolution
|
||||||
|
c.ClosedAt = &now
|
||||||
|
c.UpdatedAt = now
|
||||||
|
|
||||||
|
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
CaseID: caseID,
|
||||||
|
AuthorID: authorID,
|
||||||
|
Kind: types.EventStatusChange,
|
||||||
|
Body: "closed: " + resolution,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateCase(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityHigh, []string{"a1", "a2"}, []string{"e1"})
|
||||||
|
|
||||||
|
if c.ID == "" {
|
||||||
|
t.Fatal("case ID should not be empty")
|
||||||
|
}
|
||||||
|
if c.OrgID != "org1" {
|
||||||
|
t.Errorf("orgID = %s, want org1", c.OrgID)
|
||||||
|
}
|
||||||
|
if c.Status != types.CaseOpen {
|
||||||
|
t.Errorf("status = %s, want open", c.Status)
|
||||||
|
}
|
||||||
|
if c.Number != 1 {
|
||||||
|
t.Errorf("number = %d, want 1", c.Number)
|
||||||
|
}
|
||||||
|
if len(c.AlertIDs) != 2 {
|
||||||
|
t.Errorf("alertIDs len = %d, want 2", len(c.AlertIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCase(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("Get returned nil")
|
||||||
|
}
|
||||||
|
if got.ID != c.ID {
|
||||||
|
t.Errorf("ID mismatch: %s vs %s", got.ID, c.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Get("nonexistent") != nil {
|
||||||
|
t.Error("Get should return nil for missing case")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCases(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
s.Create("org1", types.SeverityHigh, nil, nil)
|
||||||
|
s.Create("org2", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
all := s.List("org1", "")
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Errorf("list org1 all: got %d, want 2", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
open := s.List("org1", types.CaseOpen)
|
||||||
|
if len(open) != 2 {
|
||||||
|
t.Errorf("list org1 open: got %d, want 2", len(open))
|
||||||
|
}
|
||||||
|
|
||||||
|
closed := s.List("org1", types.CaseClosed)
|
||||||
|
if len(closed) != 0 {
|
||||||
|
t.Errorf("list org1 closed: got %d, want 0", len(closed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatus(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityHigh, nil, nil)
|
||||||
|
|
||||||
|
err := s.UpdateStatus(c.ID, types.CaseInReview, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateStatus error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got.Status != types.CaseInReview {
|
||||||
|
t.Errorf("status = %s, want in_review", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := s.Events(c.ID)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("events len = %d, want 1", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Kind != types.EventStatusChange {
|
||||||
|
t.Errorf("event kind = %s, want status_change", events[0].Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateStatusNotFound(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
err := s.UpdateStatus("bad-id", types.CaseClosed, "user1")
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEvent(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
|
||||||
|
err := s.AddEvent(c.ID, types.CaseEvent{
|
||||||
|
AuthorID: "user1",
|
||||||
|
Kind: types.EventNote,
|
||||||
|
Body: "test note",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddEvent error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := s.Events(c.ID)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("events len = %d, want 1", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Body != "test note" {
|
||||||
|
t.Errorf("event body = %q, want 'test note'", events[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEventNotFound(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
err := s.AddEvent("bad-id", types.CaseEvent{})
|
||||||
|
if err != ErrNotFound {
|
||||||
|
t.Errorf("expected ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssign(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
err := s.Assign(c.ID, "reviewer1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Assign error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got.AssigneeID != "reviewer1" {
|
||||||
|
t.Errorf("assignee = %s, want reviewer1", got.AssigneeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityHigh, nil, nil)
|
||||||
|
|
||||||
|
err := s.Resolve(c.ID, types.ResolutionSARFiled, "user1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got.Status != types.CaseClosed {
|
||||||
|
t.Errorf("status = %s, want closed", got.Status)
|
||||||
|
}
|
||||||
|
if got.Resolution != types.ResolutionSARFiled {
|
||||||
|
t.Errorf("resolution = %s, want sar_filed", got.Resolution)
|
||||||
|
}
|
||||||
|
if got.ClosedAt == nil {
|
||||||
|
t.Error("closed_at should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceIncrement(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c1 := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
c2 := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
c3 := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
|
||||||
|
if c1.Number != 1 || c2.Number != 2 || c3.Number != 3 {
|
||||||
|
t.Errorf("sequence: %d, %d, %d — want 1, 2, 3", c1.Number, c2.Number, c3.Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaseStoreEviction (RED-08) verifies that closed cases older than the
|
||||||
|
// retention period are evicted when the store exceeds capacity.
|
||||||
|
func TestCaseStoreEviction(t *testing.T) {
|
||||||
|
retention := 1 * time.Millisecond // very short for test
|
||||||
|
s := NewStoreWithLimits(10, retention)
|
||||||
|
|
||||||
|
// Create and immediately close 15 cases.
|
||||||
|
for i := 0; i < 15; i++ {
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
s.Resolve(c.ID, types.ResolutionCleared, "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for closed cases to become stale.
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create one more to trigger eviction.
|
||||||
|
s.Create("org1", types.SeverityHigh, nil, nil)
|
||||||
|
|
||||||
|
if s.Len() > 10 {
|
||||||
|
t.Errorf("store size after eviction = %d, want <= 10", s.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCaseStoreEvictExpiredDirect (RED-08) tests the EvictExpired method directly.
|
||||||
|
func TestCaseStoreEvictExpiredDirect(t *testing.T) {
|
||||||
|
retention := 1 * time.Millisecond
|
||||||
|
s := NewStoreWithLimits(1000, retention)
|
||||||
|
|
||||||
|
// Create 5 cases, close 3 of them.
|
||||||
|
var openIDs []string
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
if i < 3 {
|
||||||
|
s.Resolve(c.ID, types.ResolutionCleared, "system")
|
||||||
|
} else {
|
||||||
|
openIDs = append(openIDs, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
evicted := s.EvictExpired()
|
||||||
|
|
||||||
|
if evicted != 3 {
|
||||||
|
t.Errorf("evicted = %d, want 3", evicted)
|
||||||
|
}
|
||||||
|
if s.Len() != 2 {
|
||||||
|
t.Errorf("store size = %d, want 2 (open cases)", s.Len())
|
||||||
|
}
|
||||||
|
for _, id := range openIDs {
|
||||||
|
if s.Get(id) == nil {
|
||||||
|
t.Errorf("open case %s should not be evicted", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package cases
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNotFound is returned when a case is not found.
|
||||||
|
var ErrNotFound = errors.New("case not found")
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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 cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FilingType constants for FinCEN SAR.
|
||||||
|
const (
|
||||||
|
FilingInitial = "initial"
|
||||||
|
FilingContinuing = "continuing"
|
||||||
|
FilingCorrection = "correction"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SARDraft is a structured SAR (Suspicious Activity Report) draft.
|
||||||
|
// An analyst reviews and submits this — it is never auto-filed.
|
||||||
|
type SARDraft struct {
|
||||||
|
// Filing metadata
|
||||||
|
FilingType string `json:"filing_type"`
|
||||||
|
FiledBy string `json:"filed_by,omitempty"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
|
||||||
|
// Subject information (from entity)
|
||||||
|
SubjectName string `json:"subject_name"`
|
||||||
|
SubjectType string `json:"subject_type"` // individual, entity
|
||||||
|
SubjectIDType string `json:"subject_id_type,omitempty"`
|
||||||
|
SubjectIDNumber string `json:"subject_id_number,omitempty"`
|
||||||
|
SubjectAddress string `json:"subject_address,omitempty"`
|
||||||
|
SubjectDOB string `json:"subject_dob,omitempty"`
|
||||||
|
SubjectSSN string `json:"subject_ssn,omitempty"` // last 4 only
|
||||||
|
SubjectOccupation string `json:"subject_occupation,omitempty"`
|
||||||
|
|
||||||
|
// Suspicious activity details
|
||||||
|
ActivityType []string `json:"activity_type"`
|
||||||
|
InstrumentType string `json:"instrument_type,omitempty"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
DateRangeStart time.Time `json:"date_range_start"`
|
||||||
|
DateRangeEnd time.Time `json:"date_range_end"`
|
||||||
|
TransactionCount int `json:"transaction_count"`
|
||||||
|
|
||||||
|
// Narrative (the core of the SAR — must be human-reviewed)
|
||||||
|
Narrative string `json:"narrative"`
|
||||||
|
|
||||||
|
// Case reference
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
AlertIDs []string `json:"alert_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSARDraft creates a SAR draft from a case, its alerts, and the entity.
|
||||||
|
func GenerateSARDraft(c *types.Case, alerts []types.Alert, entity types.Entity) *SARDraft {
|
||||||
|
draft := &SARDraft{
|
||||||
|
FilingType: FilingInitial,
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
CaseID: c.ID,
|
||||||
|
AlertIDs: c.AlertIDs,
|
||||||
|
|
||||||
|
SubjectName: entity.Name,
|
||||||
|
SubjectType: entity.EntityType,
|
||||||
|
Currency: "USD",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute amount and date range from alerts.
|
||||||
|
var totalAmount float64
|
||||||
|
var earliest, latest time.Time
|
||||||
|
activityTypes := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, a := range alerts {
|
||||||
|
totalAmount += a.Score * 10000 // Rough estimate from score
|
||||||
|
activityTypes[a.RuleName] = true
|
||||||
|
|
||||||
|
if earliest.IsZero() || a.CreatedAt.Before(earliest) {
|
||||||
|
earliest = a.CreatedAt
|
||||||
|
}
|
||||||
|
if a.CreatedAt.After(latest) {
|
||||||
|
latest = a.CreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.Amount = totalAmount
|
||||||
|
draft.DateRangeStart = earliest
|
||||||
|
draft.DateRangeEnd = latest
|
||||||
|
draft.TransactionCount = len(alerts)
|
||||||
|
|
||||||
|
types_ := make([]string, 0, len(activityTypes))
|
||||||
|
for t := range activityTypes {
|
||||||
|
types_ = append(types_, t)
|
||||||
|
}
|
||||||
|
draft.ActivityType = types_
|
||||||
|
|
||||||
|
draft.Narrative = generateNarrative(c, alerts, entity)
|
||||||
|
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateNarrative creates the SAR narrative text from case + alerts + entity.
|
||||||
|
func generateNarrative(c *types.Case, alerts []types.Alert, entity types.Entity) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("This report is filed regarding suspicious activity involving %s ", entity.Name))
|
||||||
|
b.WriteString(fmt.Sprintf("(entity type: %s", entity.EntityType))
|
||||||
|
if entity.Jurisdiction != "" {
|
||||||
|
b.WriteString(fmt.Sprintf(", jurisdiction: %s", entity.Jurisdiction))
|
||||||
|
}
|
||||||
|
b.WriteString(").\n\n")
|
||||||
|
|
||||||
|
b.WriteString(fmt.Sprintf("Case #%d was opened on %s with severity %s. ",
|
||||||
|
c.Number, c.OpenedAt.Format("2006-01-02"), c.Severity))
|
||||||
|
b.WriteString(fmt.Sprintf("%d alert(s) triggered:\n\n", len(alerts)))
|
||||||
|
|
||||||
|
for i, a := range alerts {
|
||||||
|
b.WriteString(fmt.Sprintf(" %d. %s (severity: %s, action: %s, score: %.2f)\n",
|
||||||
|
i+1, a.RuleName, a.Severity, a.ActionTaken, a.Score))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if entity.PEP {
|
||||||
|
b.WriteString("NOTE: Subject is flagged as a Politically Exposed Person (PEP).\n")
|
||||||
|
}
|
||||||
|
if entity.SanctionsFlag {
|
||||||
|
b.WriteString("NOTE: Subject has a sanctions list match.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n[ANALYST: Review and supplement this narrative before filing.]")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// 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 cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateSARDraft_Basic(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := &types.Case{
|
||||||
|
ID: "case-001",
|
||||||
|
OrgID: "org-test",
|
||||||
|
Number: 42,
|
||||||
|
Status: types.CaseOpen,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
AlertIDs: []string{"alert-1", "alert-2"},
|
||||||
|
OpenedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
alerts := []types.Alert{
|
||||||
|
{
|
||||||
|
ID: "alert-1",
|
||||||
|
RuleName: "CTR Threshold",
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
ActionTaken: types.ActionReport,
|
||||||
|
Score: 0.3,
|
||||||
|
CreatedAt: now.Add(-2 * time.Hour),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "alert-2",
|
||||||
|
RuleName: "Structuring",
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
ActionTaken: types.ActionFlag,
|
||||||
|
Score: 0.25,
|
||||||
|
CreatedAt: now.Add(-1 * time.Hour),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := types.Entity{
|
||||||
|
ID: "entity-1",
|
||||||
|
Name: "John Smith",
|
||||||
|
EntityType: types.EntityUser,
|
||||||
|
Jurisdiction: "US",
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := GenerateSARDraft(c, alerts, entity)
|
||||||
|
|
||||||
|
if draft.CaseID != "case-001" {
|
||||||
|
t.Fatalf("expected case-001, got %q", draft.CaseID)
|
||||||
|
}
|
||||||
|
if draft.FilingType != FilingInitial {
|
||||||
|
t.Fatalf("expected initial, got %q", draft.FilingType)
|
||||||
|
}
|
||||||
|
if draft.SubjectName != "John Smith" {
|
||||||
|
t.Fatalf("expected John Smith, got %q", draft.SubjectName)
|
||||||
|
}
|
||||||
|
if draft.SubjectType != types.EntityUser {
|
||||||
|
t.Fatalf("expected user, got %q", draft.SubjectType)
|
||||||
|
}
|
||||||
|
if draft.TransactionCount != 2 {
|
||||||
|
t.Fatalf("expected 2 transactions, got %d", draft.TransactionCount)
|
||||||
|
}
|
||||||
|
if len(draft.ActivityType) != 2 {
|
||||||
|
t.Fatalf("expected 2 activity types, got %d", len(draft.ActivityType))
|
||||||
|
}
|
||||||
|
if draft.Narrative == "" {
|
||||||
|
t.Fatal("expected non-empty narrative")
|
||||||
|
}
|
||||||
|
if !strings.Contains(draft.Narrative, "John Smith") {
|
||||||
|
t.Fatal("narrative should contain subject name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(draft.Narrative, "CTR Threshold") {
|
||||||
|
t.Fatal("narrative should contain alert rule name")
|
||||||
|
}
|
||||||
|
if !strings.Contains(draft.Narrative, "ANALYST") {
|
||||||
|
t.Fatal("narrative should contain analyst review notice")
|
||||||
|
}
|
||||||
|
if draft.DateRangeStart.After(draft.DateRangeEnd) {
|
||||||
|
t.Fatal("date range start should be before end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSARDraft_PEP(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := &types.Case{
|
||||||
|
ID: "case-pep",
|
||||||
|
Number: 1,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
AlertIDs: []string{"alert-pep"},
|
||||||
|
OpenedAt: now,
|
||||||
|
}
|
||||||
|
alerts := []types.Alert{
|
||||||
|
{
|
||||||
|
ID: "alert-pep", RuleName: "PEP Large Transaction",
|
||||||
|
Severity: types.SeverityHigh, ActionTaken: types.ActionReview,
|
||||||
|
Score: 0.3, CreatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entity := types.Entity{
|
||||||
|
Name: "Jane Minister",
|
||||||
|
EntityType: types.EntityUser,
|
||||||
|
PEP: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := GenerateSARDraft(c, alerts, entity)
|
||||||
|
if !strings.Contains(draft.Narrative, "Politically Exposed Person") {
|
||||||
|
t.Fatal("PEP entity should have PEP note in narrative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSARDraft_Sanctions(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
c := &types.Case{
|
||||||
|
ID: "case-sanc",
|
||||||
|
Number: 2,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
AlertIDs: []string{"alert-sanc"},
|
||||||
|
OpenedAt: now,
|
||||||
|
}
|
||||||
|
alerts := []types.Alert{
|
||||||
|
{
|
||||||
|
ID: "alert-sanc", RuleName: "Sanctions Direct",
|
||||||
|
Severity: types.SeverityCritical, ActionTaken: types.ActionBlock,
|
||||||
|
Score: 0.5, CreatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entity := types.Entity{
|
||||||
|
Name: "Blocked Entity",
|
||||||
|
EntityType: types.EntityAccount,
|
||||||
|
SanctionsFlag: true,
|
||||||
|
Jurisdiction: "IR",
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := GenerateSARDraft(c, alerts, entity)
|
||||||
|
if !strings.Contains(draft.Narrative, "sanctions list match") {
|
||||||
|
t.Fatal("sanctioned entity should have sanctions note in narrative")
|
||||||
|
}
|
||||||
|
if !strings.Contains(draft.Narrative, "IR") {
|
||||||
|
t.Fatal("narrative should mention jurisdiction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSARDraft_EmptyAlerts(t *testing.T) {
|
||||||
|
c := &types.Case{
|
||||||
|
ID: "case-empty",
|
||||||
|
Number: 3,
|
||||||
|
Severity: types.SeverityLow,
|
||||||
|
OpenedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
entity := types.Entity{
|
||||||
|
Name: "Nobody",
|
||||||
|
EntityType: types.EntityUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := GenerateSARDraft(c, nil, entity)
|
||||||
|
if draft.TransactionCount != 0 {
|
||||||
|
t.Fatalf("expected 0 transactions, got %d", draft.TransactionCount)
|
||||||
|
}
|
||||||
|
if draft.Amount != 0 {
|
||||||
|
t.Fatalf("expected 0 amount, got %f", draft.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSARDraftConstants(t *testing.T) {
|
||||||
|
if FilingInitial != "initial" {
|
||||||
|
t.Fatalf("FilingInitial = %q", FilingInitial)
|
||||||
|
}
|
||||||
|
if FilingContinuing != "continuing" {
|
||||||
|
t.Fatalf("FilingContinuing = %q", FilingContinuing)
|
||||||
|
}
|
||||||
|
if FilingCorrection != "correction" {
|
||||||
|
t.Fatalf("FilingCorrection = %q", FilingCorrection)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriageConfig controls auto-assignment, escalation SLAs, and auto-close behavior.
|
||||||
|
type TriageConfig struct {
|
||||||
|
AutoAssignEnabled bool
|
||||||
|
EscalationSLA map[string]time.Duration // severity -> max time before escalation
|
||||||
|
AutoCloseAfter time.Duration // auto-close low-severity if no activity
|
||||||
|
AssignmentMode string // "round_robin", "least_loaded", "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTriageConfig returns production-safe SLA defaults.
|
||||||
|
func DefaultTriageConfig() TriageConfig {
|
||||||
|
return TriageConfig{
|
||||||
|
AutoAssignEnabled: true,
|
||||||
|
EscalationSLA: map[string]time.Duration{
|
||||||
|
types.SeverityCritical: 1 * time.Hour,
|
||||||
|
types.SeverityHigh: 4 * time.Hour,
|
||||||
|
types.SeverityMedium: 24 * time.Hour,
|
||||||
|
types.SeverityLow: 72 * time.Hour,
|
||||||
|
},
|
||||||
|
AutoCloseAfter: 30 * 24 * time.Hour, // 30 days
|
||||||
|
AssignmentMode: "round_robin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rrIndex tracks round-robin state for assignment.
|
||||||
|
var rrIndex atomic.Int64
|
||||||
|
|
||||||
|
// AutoAssign assigns an unassigned case to an analyst.
|
||||||
|
// Returns the assigned analyst ID, or empty if no analysts available.
|
||||||
|
func (s *Store) AutoAssign(c *types.Case, analysts []string, mode string) string {
|
||||||
|
if len(analysts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignee string
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "round_robin":
|
||||||
|
idx := rrIndex.Add(1) - 1
|
||||||
|
assignee = analysts[idx%int64(len(analysts))]
|
||||||
|
|
||||||
|
case "least_loaded":
|
||||||
|
// Count open cases per analyst.
|
||||||
|
s.mu.RLock()
|
||||||
|
loads := make(map[string]int)
|
||||||
|
for _, existing := range s.cases {
|
||||||
|
if existing.Status != types.CaseClosed && existing.AssigneeID != "" {
|
||||||
|
loads[existing.AssigneeID]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
minLoad := -1
|
||||||
|
for _, a := range analysts {
|
||||||
|
load := loads[a]
|
||||||
|
if minLoad == -1 || load < minLoad {
|
||||||
|
minLoad = load
|
||||||
|
assignee = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default: // "manual" or unknown: no auto-assign
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if assignee != "" {
|
||||||
|
_ = s.Assign(c.ID, assignee)
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignee
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckEscalation returns true and escalates the case if the SLA is breached.
|
||||||
|
func (s *Store) CheckEscalation(c *types.Case, config TriageConfig) bool {
|
||||||
|
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sla, ok := config.EscalationSLA[c.Severity]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := c.OpenedAt.Add(sla)
|
||||||
|
if time.Now().UTC().Before(deadline) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SLA breached: escalate.
|
||||||
|
s.mu.Lock()
|
||||||
|
existing, ok := s.cases[c.ID]
|
||||||
|
if !ok {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
existing.Status = types.CaseEscalated
|
||||||
|
existing.UpdatedAt = now
|
||||||
|
|
||||||
|
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
|
||||||
|
Kind: types.EventStatusChange,
|
||||||
|
CaseID: c.ID,
|
||||||
|
AuthorID: "system",
|
||||||
|
Body: "auto-escalated: SLA breached",
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoClose closes low-severity cases with no activity after the configured threshold.
|
||||||
|
// Returns the number of cases closed.
|
||||||
|
func (s *Store) AutoClose(config TriageConfig) int {
|
||||||
|
if config.AutoCloseAfter <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
threshold := now.Add(-config.AutoCloseAfter)
|
||||||
|
closed := 0
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, c := range s.cases {
|
||||||
|
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Severity != types.SeverityLow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check last activity: use UpdatedAt as proxy.
|
||||||
|
if c.UpdatedAt.Before(threshold) {
|
||||||
|
c.Status = types.CaseClosed
|
||||||
|
c.Resolution = "auto_closed_inactive"
|
||||||
|
c.ClosedAt = &now
|
||||||
|
c.UpdatedAt = now
|
||||||
|
|
||||||
|
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
|
||||||
|
Kind: types.EventStatusChange,
|
||||||
|
CaseID: c.ID,
|
||||||
|
AuthorID: "system",
|
||||||
|
Body: "auto-closed: no activity for " + config.AutoCloseAfter.String(),
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
closed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriageCheck runs the full triage cycle: auto-assign unassigned, escalate overdue, auto-close stale.
|
||||||
|
// Intended to run every 5 minutes.
|
||||||
|
func (s *Store) TriageCheck(orgID string, analysts []string, config TriageConfig) (assigned, escalated, autoClosed int) {
|
||||||
|
// Auto-close stale low-severity FIRST — before assign refreshes UpdatedAt.
|
||||||
|
autoClosed = s.AutoClose(config)
|
||||||
|
|
||||||
|
cases := s.List(orgID, "")
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.Status == types.CaseClosed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assign unassigned cases.
|
||||||
|
if config.AutoAssignEnabled && c.AssigneeID == "" {
|
||||||
|
if a := s.AutoAssign(c, analysts, config.AssignmentMode); a != "" {
|
||||||
|
assigned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check escalation.
|
||||||
|
if s.CheckEscalation(c, config) {
|
||||||
|
escalated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assigned, escalated, autoClosed
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package cases
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAutoAssign_RoundRobin(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c1 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
analysts := []string{"analyst-a", "analyst-b"}
|
||||||
|
|
||||||
|
a1 := s.AutoAssign(c1, analysts, "round_robin")
|
||||||
|
a2 := s.AutoAssign(c2, analysts, "round_robin")
|
||||||
|
|
||||||
|
if a1 == "" || a2 == "" {
|
||||||
|
t.Fatal("expected assignments, got empty")
|
||||||
|
}
|
||||||
|
if a1 == a2 {
|
||||||
|
t.Errorf("round-robin should alternate: got %q and %q", a1, a2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_LeastLoaded(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
|
||||||
|
// Give analyst-a two open cases.
|
||||||
|
c1 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
_ = s.Assign(c1.ID, "analyst-a")
|
||||||
|
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
_ = s.Assign(c2.ID, "analyst-a")
|
||||||
|
|
||||||
|
// New unassigned case.
|
||||||
|
c3 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
assignee := s.AutoAssign(c3, []string{"analyst-a", "analyst-b"}, "least_loaded")
|
||||||
|
if assignee != "analyst-b" {
|
||||||
|
t.Errorf("assignee = %q, want analyst-b (least loaded)", assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_Manual(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
assignee := s.AutoAssign(c, []string{"analyst-a"}, "manual")
|
||||||
|
if assignee != "" {
|
||||||
|
t.Errorf("manual mode should return empty, got %q", assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoAssign_NoAnalysts(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
assignee := s.AutoAssign(c, nil, "round_robin")
|
||||||
|
if assignee != "" {
|
||||||
|
t.Errorf("expected empty with no analysts, got %q", assignee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckEscalation_SLABreached(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityCritical, nil, nil)
|
||||||
|
|
||||||
|
// Backdate the case to trigger SLA breach.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c.ID].OpenedAt = time.Now().UTC().Add(-2 * time.Hour)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
escalated := s.CheckEscalation(c, config)
|
||||||
|
if !escalated {
|
||||||
|
t.Error("expected escalation for 2h-old critical case (1h SLA)")
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got.Status != types.CaseEscalated {
|
||||||
|
t.Errorf("status = %q, want %q", got.Status, types.CaseEscalated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckEscalation_NotBreached(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
escalated := s.CheckEscalation(c, config)
|
||||||
|
if escalated {
|
||||||
|
t.Error("should not escalate fresh low-severity case")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckEscalation_AlreadyClosed(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityCritical, nil, nil)
|
||||||
|
_ = s.Resolve(c.ID, "cleared", "analyst")
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
escalated := s.CheckEscalation(c, config)
|
||||||
|
if escalated {
|
||||||
|
t.Error("should not escalate closed case")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoClose(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
|
||||||
|
// Low-severity case with old activity.
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour) // 60 days old
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Medium-severity case that should not be auto-closed.
|
||||||
|
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c2.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
closed := s.AutoClose(config)
|
||||||
|
if closed != 1 {
|
||||||
|
t.Errorf("closed = %d, want 1 (only low-severity)", closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := s.Get(c.ID)
|
||||||
|
if got.Status != types.CaseClosed {
|
||||||
|
t.Errorf("low-severity status = %q, want closed", got.Status)
|
||||||
|
}
|
||||||
|
if got.Resolution != "auto_closed_inactive" {
|
||||||
|
t.Errorf("resolution = %q, want auto_closed_inactive", got.Resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
got2 := s.Get(c2.ID)
|
||||||
|
if got2.Status == types.CaseClosed {
|
||||||
|
t.Error("medium-severity case should not be auto-closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoClose_RecentActivity(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
c := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
// UpdatedAt is now (fresh), so auto-close should skip it.
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
closed := s.AutoClose(config)
|
||||||
|
if closed != 0 {
|
||||||
|
t.Errorf("closed = %d, want 0 (recent activity)", closed)
|
||||||
|
}
|
||||||
|
_ = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTriageCheck_FullCycle(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
|
||||||
|
// Unassigned case.
|
||||||
|
s.Create("org1", types.SeverityMedium, nil, nil)
|
||||||
|
|
||||||
|
// Critical case past SLA.
|
||||||
|
c2 := s.Create("org1", types.SeverityCritical, nil, nil)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c2.ID].OpenedAt = time.Now().UTC().Add(-3 * time.Hour)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Stale low-severity case.
|
||||||
|
c3 := s.Create("org1", types.SeverityLow, nil, nil)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cases[c3.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
config := DefaultTriageConfig()
|
||||||
|
assigned, escalated, autoClosed := s.TriageCheck("org1", []string{"analyst-a"}, config)
|
||||||
|
|
||||||
|
// All 3 unassigned cases get assigned (including the stale one before it's closed).
|
||||||
|
if assigned < 2 {
|
||||||
|
t.Errorf("assigned = %d, want at least 2", assigned)
|
||||||
|
}
|
||||||
|
if escalated != 1 {
|
||||||
|
t.Errorf("escalated = %d, want 1", escalated)
|
||||||
|
}
|
||||||
|
if autoClosed != 1 {
|
||||||
|
t.Errorf("autoClosed = %d, want 1", autoClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultTriageConfig(t *testing.T) {
|
||||||
|
cfg := DefaultTriageConfig()
|
||||||
|
if !cfg.AutoAssignEnabled {
|
||||||
|
t.Error("auto-assign should be enabled by default")
|
||||||
|
}
|
||||||
|
if cfg.EscalationSLA[types.SeverityCritical] != 1*time.Hour {
|
||||||
|
t.Error("critical SLA should be 1 hour")
|
||||||
|
}
|
||||||
|
if cfg.EscalationSLA[types.SeverityHigh] != 4*time.Hour {
|
||||||
|
t.Error("high SLA should be 4 hours")
|
||||||
|
}
|
||||||
|
if cfg.EscalationSLA[types.SeverityMedium] != 24*time.Hour {
|
||||||
|
t.Error("medium SLA should be 24 hours")
|
||||||
|
}
|
||||||
|
if cfg.EscalationSLA[types.SeverityLow] != 72*time.Hour {
|
||||||
|
t.Error("low SLA should be 72 hours")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Package chain provides blockchain analytics integration for AML screening.
|
||||||
|
// Supports address risk assessment and transaction exposure analysis.
|
||||||
|
package chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a Chainalysis KYT (Know Your Transaction) API client.
|
||||||
|
// API key must be stored in KMS and injected at startup.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxResponseSize limits API response body reads (1 MB).
|
||||||
|
const maxResponseSize = 1024 * 1024
|
||||||
|
|
||||||
|
// NewClient creates a Chainalysis client. apiKey must come from KMS.
|
||||||
|
func NewClient(baseURL, apiKey string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressRisk is the risk assessment for a blockchain address.
|
||||||
|
type AddressRisk struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
RiskScore float64 `json:"risk_score"` // 0.0 (clean) to 1.0 (high risk)
|
||||||
|
Category string `json:"category"` // exchange, mixer, darknet, sanctions, unknown
|
||||||
|
Cluster string `json:"cluster_name,omitempty"`
|
||||||
|
Exposure []ExposureEntry `json:"exposure,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExposureEntry describes exposure to a particular category.
|
||||||
|
type ExposureEntry struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Percentage float64 `json:"percentage"` // 0-100
|
||||||
|
Amount float64 `json:"amount,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TxRisk is the risk assessment for a blockchain transaction.
|
||||||
|
type TxRisk struct {
|
||||||
|
TxHash string `json:"tx_hash"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
RiskScore float64 `json:"risk_score"`
|
||||||
|
DirectExposure []ExposureEntry `json:"direct_exposure,omitempty"`
|
||||||
|
IndirectExposure []ExposureEntry `json:"indirect_exposure,omitempty"`
|
||||||
|
Alerts []string `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenAddress checks a blockchain address against Chainalysis risk data.
|
||||||
|
func (c *Client) ScreenAddress(ctx context.Context, address, chain string) (*AddressRisk, error) {
|
||||||
|
body := map[string]string{
|
||||||
|
"address": address,
|
||||||
|
"chain": chain,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.post(ctx, "/v2/entities", body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("screen address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result AddressRisk
|
||||||
|
if err := json.Unmarshal(resp, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse address risk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Address = address
|
||||||
|
result.Chain = chain
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenTransaction checks a transaction against Chainalysis.
|
||||||
|
func (c *Client) ScreenTransaction(ctx context.Context, txHash, chain string) (*TxRisk, error) {
|
||||||
|
path := fmt.Sprintf("/v2/transfers/%s?chain=%s", txHash, chain)
|
||||||
|
|
||||||
|
resp, err := c.get(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("screen transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result TxRisk
|
||||||
|
if err := json.Unmarshal(resp, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse tx risk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TxHash = txHash
|
||||||
|
result.Chain = chain
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWebhook subscribes to Chainalysis alert events.
|
||||||
|
func (c *Client) RegisterWebhook(ctx context.Context, callbackURL string, events []string) error {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"url": callbackURL,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.post(ctx, "/v2/webhooks", body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("register webhook: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHighRisk returns true if the address is categorized as high risk.
|
||||||
|
func (ar *AddressRisk) IsHighRisk() bool {
|
||||||
|
if ar.RiskScore >= 0.7 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch ar.Category {
|
||||||
|
case "mixer", "darknet", "sanctions", "scam", "ransomware", "stolen_funds":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlocked returns true if the address is associated with sanctioned entities.
|
||||||
|
func (ar *AddressRisk) IsBlocked() bool {
|
||||||
|
return ar.Category == "sanctions"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) post(ctx context.Context, path string, body interface{}) ([]byte, error) {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Token", c.apiKey)
|
||||||
|
|
||||||
|
return c.doRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Token", c.apiKey)
|
||||||
|
|
||||||
|
return c.doRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package chain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScreenAddress(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/entities" {
|
||||||
|
t.Errorf("path = %s, want /v2/entities", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Token") == "" {
|
||||||
|
t.Error("missing Token header")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"risk_score": 0.85,
|
||||||
|
"category": "mixer",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key")
|
||||||
|
result, err := client.ScreenAddress(context.Background(), "0xabc123", "ethereum")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScreenAddress error: %v", err)
|
||||||
|
}
|
||||||
|
if result.RiskScore != 0.85 {
|
||||||
|
t.Errorf("risk_score = %.2f, want 0.85", result.RiskScore)
|
||||||
|
}
|
||||||
|
if result.Category != "mixer" {
|
||||||
|
t.Errorf("category = %q, want mixer", result.Category)
|
||||||
|
}
|
||||||
|
if result.Address != "0xabc123" {
|
||||||
|
t.Errorf("address = %q, want 0xabc123", result.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenTransaction(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %s, want GET", r.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"risk_score": 0.3,
|
||||||
|
"alerts": []string{},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key")
|
||||||
|
result, err := client.ScreenTransaction(context.Background(), "0xdef456", "ethereum")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScreenTransaction error: %v", err)
|
||||||
|
}
|
||||||
|
if result.RiskScore != 0.3 {
|
||||||
|
t.Errorf("risk_score = %.2f, want 0.3", result.RiskScore)
|
||||||
|
}
|
||||||
|
if result.TxHash != "0xdef456" {
|
||||||
|
t.Errorf("tx_hash = %q, want 0xdef456", result.TxHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenAddress_APIError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"error":"invalid api key"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "bad-key")
|
||||||
|
_, err := client.ScreenAddress(context.Background(), "0xabc", "ethereum")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 403 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterWebhook(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v2/webhooks" {
|
||||||
|
t.Errorf("path = %s, want /v2/webhooks", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body["url"] != "https://example.com/webhook" {
|
||||||
|
t.Errorf("url = %v, want https://example.com/webhook", body["url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Write([]byte(`{"id":"wh-001"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-key")
|
||||||
|
err := client.RegisterWebhook(context.Background(), "https://example.com/webhook", []string{"alert.new"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegisterWebhook error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressRisk_IsHighRisk(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
risk AddressRisk
|
||||||
|
wantHigh bool
|
||||||
|
}{
|
||||||
|
{"high score", AddressRisk{RiskScore: 0.8}, true},
|
||||||
|
{"mixer category", AddressRisk{RiskScore: 0.3, Category: "mixer"}, true},
|
||||||
|
{"darknet", AddressRisk{RiskScore: 0.5, Category: "darknet"}, true},
|
||||||
|
{"sanctions", AddressRisk{RiskScore: 0.4, Category: "sanctions"}, true},
|
||||||
|
{"clean", AddressRisk{RiskScore: 0.2, Category: "exchange"}, false},
|
||||||
|
{"low score unknown", AddressRisk{RiskScore: 0.1, Category: "unknown"}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.risk.IsHighRisk(); got != tt.wantHigh {
|
||||||
|
t.Errorf("IsHighRisk() = %v, want %v", got, tt.wantHigh)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddressRisk_IsBlocked(t *testing.T) {
|
||||||
|
sanctioned := AddressRisk{Category: "sanctions"}
|
||||||
|
if !sanctioned.IsBlocked() {
|
||||||
|
t.Error("sanctions category should be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
mixer := AddressRisk{Category: "mixer"}
|
||||||
|
if mixer.IsBlocked() {
|
||||||
|
t.Error("mixer should not be blocked (flagged, not blocked)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClient(t *testing.T) {
|
||||||
|
c := NewClient("https://api.chainalysis.com", "test-key")
|
||||||
|
if c.baseURL != "https://api.chainalysis.com" {
|
||||||
|
t.Errorf("baseURL = %q", c.baseURL)
|
||||||
|
}
|
||||||
|
if c.client.Timeout != 30*1e9 {
|
||||||
|
t.Error("expected 30s timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Package engine implements the real-time AML rule evaluation engine.
|
||||||
|
// It evaluates transactions against rules using expr-lang, scores hits
|
||||||
|
// using weight-of-evidence, and determines the enforcement action.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine is the core AML evaluation engine.
|
||||||
|
type Engine struct {
|
||||||
|
evaluator *Evaluator
|
||||||
|
mu sync.RWMutex
|
||||||
|
rules []types.Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an Engine with the given rules.
|
||||||
|
func New(rules []types.Rule) *Engine {
|
||||||
|
return &Engine{
|
||||||
|
evaluator: NewEvaluator(),
|
||||||
|
rules: rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRules replaces the rule set atomically. Returns an error if any rule
|
||||||
|
// has a negative weight (RED-19: prevents score suppression attacks).
|
||||||
|
func (e *Engine) SetRules(rules []types.Rule) error {
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Weight < 0 {
|
||||||
|
return fmt.Errorf("rule %q has negative weight %f: negative weights are not allowed", r.ID, r.Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.rules = rules
|
||||||
|
e.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules returns a copy of the current rule set.
|
||||||
|
func (e *Engine) Rules() []types.Rule {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
out := make([]types.Rule, len(e.rules))
|
||||||
|
copy(out, e.rules)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluator returns the underlying evaluator for function registration.
|
||||||
|
func (e *Engine) Evaluator() *Evaluator {
|
||||||
|
return e.evaluator
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate runs all rules against a transaction and its primary entity.
|
||||||
|
// Returns the computed alerts, the aggregate score, and the enforcement action.
|
||||||
|
func (e *Engine) Evaluate(tx types.Transaction, entity types.Entity) ([]types.Alert, float64, string) {
|
||||||
|
e.mu.RLock()
|
||||||
|
rules := e.rules
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: tx,
|
||||||
|
Entity: entity,
|
||||||
|
}
|
||||||
|
|
||||||
|
hits := e.evaluator.EvalAll(rules, ctx)
|
||||||
|
if len(hits) == 0 {
|
||||||
|
return nil, 0, types.ActionAllow
|
||||||
|
}
|
||||||
|
|
||||||
|
score, breakdown := Score(hits)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
alerts := make([]types.Alert, 0, len(hits))
|
||||||
|
for _, h := range hits {
|
||||||
|
alerts = append(alerts, types.Alert{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
OrgID: tx.OrgID,
|
||||||
|
TxID: tx.ID,
|
||||||
|
RuleID: h.Rule.ID,
|
||||||
|
RuleName: h.Rule.Name,
|
||||||
|
Severity: h.Rule.Severity,
|
||||||
|
Score: breakdown[h.Rule.ID],
|
||||||
|
ScoreBreakdown: breakdown,
|
||||||
|
ActionTaken: h.Rule.Action,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
action := resolveAction(alerts)
|
||||||
|
return alerts, score, action
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAction picks the most severe action from a set of alerts.
|
||||||
|
// Priority: block > report > review > flag > allow.
|
||||||
|
func resolveAction(alerts []types.Alert) string {
|
||||||
|
priority := map[string]int{
|
||||||
|
types.ActionBlock: 4,
|
||||||
|
types.ActionReport: 3,
|
||||||
|
types.ActionReview: 2,
|
||||||
|
types.ActionFlag: 1,
|
||||||
|
types.ActionAllow: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
best := types.ActionAllow
|
||||||
|
bestP := 0
|
||||||
|
for _, a := range alerts {
|
||||||
|
if p, ok := priority[a.ActionTaken]; ok && p > bestP {
|
||||||
|
best = a.ActionTaken
|
||||||
|
bestP = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/expr-lang/expr"
|
||||||
|
"github.com/expr-lang/expr/vm"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExprFunc is the function signature required by expr-lang.
|
||||||
|
type ExprFunc = func(params ...any) (any, error)
|
||||||
|
|
||||||
|
// DefaultMaxCacheSize is the default max number of compiled programs in the cache.
|
||||||
|
const DefaultMaxCacheSize = 10_000
|
||||||
|
|
||||||
|
// Evaluator compiles and evaluates expr-lang rule DSL against transaction contexts.
|
||||||
|
// Compiled programs are cached per rule ID + DSL hash for reuse.
|
||||||
|
// RED-15: Cache has a max size with LRU eviction to prevent memory exhaustion.
|
||||||
|
type Evaluator struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]*vm.Program // key = rule.ID + ":" + rule.DSL
|
||||||
|
cacheOrder []string // insertion order for LRU eviction
|
||||||
|
maxCacheSize int
|
||||||
|
funcs map[string]ExprFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEvaluator creates an Evaluator with the default helper functions.
|
||||||
|
func NewEvaluator() *Evaluator {
|
||||||
|
return NewEvaluatorWithMaxCache(DefaultMaxCacheSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEvaluatorWithMaxCache creates an Evaluator with a custom cache limit.
|
||||||
|
func NewEvaluatorWithMaxCache(maxCache int) *Evaluator {
|
||||||
|
if maxCache <= 0 {
|
||||||
|
maxCache = DefaultMaxCacheSize
|
||||||
|
}
|
||||||
|
e := &Evaluator{
|
||||||
|
cache: make(map[string]*vm.Program),
|
||||||
|
maxCacheSize: maxCache,
|
||||||
|
funcs: make(map[string]ExprFunc),
|
||||||
|
}
|
||||||
|
e.registerDefaults()
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheLen returns the number of compiled programs in the cache.
|
||||||
|
func (e *Evaluator) CacheLen() int {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return len(e.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterFunc adds a named function available in the expr-lang environment.
|
||||||
|
func (e *Evaluator) RegisterFunc(name string, fn ExprFunc) {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.funcs[name] = fn
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDefaults registers stub helper functions for the DSL.
|
||||||
|
// These return conservative defaults. Production wires real implementations.
|
||||||
|
func (e *Evaluator) registerDefaults() {
|
||||||
|
e.funcs["count_last_24h"] = func(params ...any) (any, error) { return 0, nil }
|
||||||
|
e.funcs["sum_last_24h"] = func(params ...any) (any, error) { return 0.0, nil }
|
||||||
|
e.funcs["sum_last_30d"] = func(params ...any) (any, error) { return 0.0, nil }
|
||||||
|
e.funcs["first_tx"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["first_counterparty"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["last_tx_age"] = func(params ...any) (any, error) { return 0, nil }
|
||||||
|
e.funcs["is_round_trip"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["geo_match"] = func(params ...any) (any, error) { return true, nil }
|
||||||
|
e.funcs["sanctions_hit"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["sanctioned_jurisdictions"] = func(params ...any) (any, error) {
|
||||||
|
return []string{"IR", "KP", "SY", "CU", "AF", "BY", "CF", "CD", "LY", "ML", "SO", "SS", "SD", "YE"}, nil
|
||||||
|
}
|
||||||
|
e.funcs["hour"] = func(params ...any) (any, error) { return 12, nil }
|
||||||
|
e.funcs["is_mixer_address"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["is_darknet_market"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["layering_score"] = func(params ...any) (any, error) { return 0.0, nil }
|
||||||
|
e.funcs["smurfing_detected"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["wash_trade_detected"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
e.funcs["travel_rule_threshold"] = func(params ...any) (any, error) { return 3000.0, nil }
|
||||||
|
e.funcs["travel_rule_complete"] = func(params ...any) (any, error) { return true, nil }
|
||||||
|
e.funcs["chain_risk_score"] = func(params ...any) (any, error) { return 0.0, nil }
|
||||||
|
e.funcs["chain_is_sanctioned"] = func(params ...any) (any, error) { return false, nil }
|
||||||
|
|
||||||
|
// RED-13: notional_usd converts tx.Notional to USD using approximate FX rates.
|
||||||
|
// This ensures CTR rules fire correctly for non-USD currencies.
|
||||||
|
e.funcs["notional_usd"] = func(params ...any) (any, error) {
|
||||||
|
if len(params) < 1 {
|
||||||
|
return 0.0, fmt.Errorf("notional_usd requires a transaction")
|
||||||
|
}
|
||||||
|
// expr-lang passes the struct directly.
|
||||||
|
switch v := params[0].(type) {
|
||||||
|
case types.Transaction:
|
||||||
|
return notionalUSD(v.Notional, v.Currency), nil
|
||||||
|
case *types.Transaction:
|
||||||
|
return notionalUSD(v.Notional, v.Currency), nil
|
||||||
|
case map[string]interface{}:
|
||||||
|
notional, _ := v["Notional"].(float64)
|
||||||
|
currency, _ := v["Currency"].(string)
|
||||||
|
return notionalUSD(notional, currency), nil
|
||||||
|
default:
|
||||||
|
return 0.0, fmt.Errorf("notional_usd: unexpected type %T", params[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RED-13: fxRates maps currency codes to their approximate USD exchange rate.
|
||||||
|
// Updated by the sanctions refresh cron in production; these are conservative defaults.
|
||||||
|
var fxRates = map[string]float64{
|
||||||
|
"USD": 1.0, "EUR": 1.08, "GBP": 1.27, "CHF": 1.12,
|
||||||
|
"JPY": 0.0067, "INR": 0.012, "BRL": 0.20, "AUD": 0.66,
|
||||||
|
"CAD": 0.74, "SGD": 0.75, "AED": 0.27, "KRW": 0.00075,
|
||||||
|
"CNY": 0.14, "MXN": 0.058, "ZAR": 0.055, "TRY": 0.031,
|
||||||
|
"RUB": 0.011, "HKD": 0.13, "NZD": 0.61, "SEK": 0.096,
|
||||||
|
"NOK": 0.094, "DKK": 0.14, "PLN": 0.25, "THB": 0.028,
|
||||||
|
}
|
||||||
|
|
||||||
|
// notionalUSD converts a notional amount to USD using approximate FX rates.
|
||||||
|
// Unknown currencies return the raw notional value (conservative — assumes USD).
|
||||||
|
func notionalUSD(notional float64, currency string) float64 {
|
||||||
|
if currency == "" || currency == "USD" {
|
||||||
|
return notional
|
||||||
|
}
|
||||||
|
rate, ok := fxRates[strings.ToUpper(currency)]
|
||||||
|
if !ok {
|
||||||
|
return notional // unknown currency → assume raw = conservative
|
||||||
|
}
|
||||||
|
return notional * rate
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheKey returns the cache key for a compiled rule program.
|
||||||
|
func cacheKey(r types.Rule) string {
|
||||||
|
return r.ID + ":" + r.DSL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile compiles a rule DSL into a cached vm.Program.
|
||||||
|
func (e *Evaluator) Compile(r types.Rule) (*vm.Program, error) {
|
||||||
|
key := cacheKey(r)
|
||||||
|
|
||||||
|
e.mu.RLock()
|
||||||
|
if prog, ok := e.cache[key]; ok {
|
||||||
|
e.mu.RUnlock()
|
||||||
|
return prog, nil
|
||||||
|
}
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
opts := []expr.Option{
|
||||||
|
expr.Env(map[string]interface{}{
|
||||||
|
"tx": types.Transaction{},
|
||||||
|
"entity": types.Entity{},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
for name, fn := range e.funcs {
|
||||||
|
opts = append(opts, expr.Function(name, fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
prog, err := expr.Compile(r.DSL, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compile rule %q: %w", r.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.mu.Lock()
|
||||||
|
// RED-15: Evict oldest entries when cache exceeds max size.
|
||||||
|
if len(e.cache) >= e.maxCacheSize {
|
||||||
|
evictCount := e.maxCacheSize / 10
|
||||||
|
if evictCount < 1 {
|
||||||
|
evictCount = 1
|
||||||
|
}
|
||||||
|
for i := 0; i < evictCount && i < len(e.cacheOrder); i++ {
|
||||||
|
delete(e.cache, e.cacheOrder[i])
|
||||||
|
}
|
||||||
|
if evictCount <= len(e.cacheOrder) {
|
||||||
|
e.cacheOrder = e.cacheOrder[evictCount:]
|
||||||
|
} else {
|
||||||
|
e.cacheOrder = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.cache[key] = prog
|
||||||
|
e.cacheOrder = append(e.cacheOrder, key)
|
||||||
|
e.mu.Unlock()
|
||||||
|
|
||||||
|
return prog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eval evaluates a single rule against a transaction + entity context.
|
||||||
|
func (e *Evaluator) Eval(r types.Rule, ctx types.EvalContext) (bool, error) {
|
||||||
|
prog, err := e.Compile(r)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
env := map[string]interface{}{
|
||||||
|
"tx": ctx.Tx,
|
||||||
|
"entity": ctx.Entity,
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := expr.Run(prog, env)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("eval rule %q: %w", r.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := out.(type) {
|
||||||
|
case bool:
|
||||||
|
return v, nil
|
||||||
|
case float64:
|
||||||
|
return v > 0, nil
|
||||||
|
case int:
|
||||||
|
return v > 0, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("rule %q returned non-bool type %T", r.Name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalAll evaluates all rules against a context and returns matching hits.
|
||||||
|
// Rules are filtered by jurisdiction and asset class before evaluation.
|
||||||
|
func (e *Evaluator) EvalAll(rules []types.Rule, ctx types.EvalContext) []types.RuleHit {
|
||||||
|
var hits []types.RuleHit
|
||||||
|
for _, r := range rules {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesFilter(r.JurisdictionFilter, ctx.Entity.Jurisdiction) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesFilter(r.AssetClassFilter, ctx.Tx.AssetClass) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
match, err := e.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
// RED-03: Fail closed on eval errors. A broken rule should trigger
|
||||||
|
// review, not silently allow the transaction through. The error is
|
||||||
|
// captured in the hit so analysts can see WHY the rule flagged.
|
||||||
|
hits = append(hits, types.RuleHit{
|
||||||
|
Rule: r,
|
||||||
|
Match: true,
|
||||||
|
EvalErr: err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if match {
|
||||||
|
hits = append(hits, types.RuleHit{
|
||||||
|
Rule: r,
|
||||||
|
Match: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesFilter returns true if the filter is empty (matches all)
|
||||||
|
// or if val is in the filter list.
|
||||||
|
func matchesFilter(filter []string, val string) bool {
|
||||||
|
if len(filter) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, f := range filter {
|
||||||
|
if strings.EqualFold(f, val) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,53 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "github.com/luxfi/aml/pkg/types"
|
||||||
|
|
||||||
|
// severityMultiplier returns a weight multiplier for a severity level.
|
||||||
|
func severityMultiplier(severity string) float64 {
|
||||||
|
switch severity {
|
||||||
|
case types.SeverityCritical:
|
||||||
|
return 2.0
|
||||||
|
case types.SeverityHigh:
|
||||||
|
return 1.5
|
||||||
|
case types.SeverityMedium:
|
||||||
|
return 1.0
|
||||||
|
case types.SeverityLow:
|
||||||
|
return 0.5
|
||||||
|
default:
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamp01 clamps v to [0, 1].
|
||||||
|
func clamp01(v float64) float64 {
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if v > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score computes a weight-of-evidence risk score from rule hits.
|
||||||
|
// Returns a score in [0,1] and a breakdown per rule ID.
|
||||||
|
func Score(hits []types.RuleHit) (float64, map[string]float64) {
|
||||||
|
breakdown := make(map[string]float64, len(hits))
|
||||||
|
var total float64
|
||||||
|
for _, h := range hits {
|
||||||
|
if !h.Match {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := h.Rule.Weight * severityMultiplier(h.Rule.Severity)
|
||||||
|
breakdown[h.Rule.ID] = s
|
||||||
|
total += s
|
||||||
|
}
|
||||||
|
return clamp01(total), breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScorerPlugin is the interface for pluggable ML scoring.
|
||||||
|
// v1 uses weight-of-evidence only. This interface is the hook point
|
||||||
|
// for later Hanzo Zen gateway integration.
|
||||||
|
type ScorerPlugin interface {
|
||||||
|
Score(tx types.Transaction, entity types.Entity, hits []types.RuleHit) (float64, map[string]float64, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// 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,291 @@
|
|||||||
|
// Package rules provides the AML rule DSL layer and the starter rule library.
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import "github.com/luxfi/aml/pkg/types"
|
||||||
|
|
||||||
|
// StarterRules returns the 20 default AML rules covering standard typologies.
|
||||||
|
// These are seeded on first run for every org. Admins can disable or modify.
|
||||||
|
func StarterRules(orgID string) []types.Rule {
|
||||||
|
return []types.Rule{
|
||||||
|
{
|
||||||
|
ID: "rule-ctr-threshold",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "CTR Threshold",
|
||||||
|
Description: "Currency Transaction Report: transactions over $10,000 USD equivalent",
|
||||||
|
DSL: `notional_usd(tx) > 10000`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.3,
|
||||||
|
Action: types.ActionReport,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-structuring",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Structuring",
|
||||||
|
Description: "Transactions just below CTR threshold — possible structuring",
|
||||||
|
DSL: `tx.Notional >= 9000 && tx.Notional < 10000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.25,
|
||||||
|
Action: types.ActionFlag,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-velocity-daily",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Velocity Daily",
|
||||||
|
Description: "Daily transaction volume exceeds $50,000",
|
||||||
|
DSL: `sum_last_24h(tx.UserID) > 50000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.2,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-velocity-monthly",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Velocity Monthly",
|
||||||
|
Description: "Monthly transaction volume exceeds $500,000",
|
||||||
|
DSL: `sum_last_30d(tx.UserID) > 500000`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.25,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-first-time-large",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "First-Time Large",
|
||||||
|
Description: "First transaction from user exceeds $25,000",
|
||||||
|
DSL: `first_tx(tx.UserID) && tx.Notional > 25000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.2,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-round-trip",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Round-Trip",
|
||||||
|
Description: "Funds sent and received within 24h — layering indicator",
|
||||||
|
DSL: `is_round_trip(tx.UserID, "24h")`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.2,
|
||||||
|
Action: types.ActionFlag,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-sanctioned-jurisdiction",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Sanctioned Jurisdiction",
|
||||||
|
Description: "Entity located in a sanctioned jurisdiction",
|
||||||
|
DSL: `entity.Jurisdiction in sanctioned_jurisdictions()`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-pep-large",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "PEP Large Transaction",
|
||||||
|
Description: "Politically exposed person transacting over $10,000",
|
||||||
|
DSL: `entity.PEP == true && tx.Notional > 10000`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.3,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-unusual-hour",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Unusual Hour",
|
||||||
|
Description: "Transaction during unusual hours (2-4 AM local time)",
|
||||||
|
DSL: `hour(tx.Timestamp, entity.Jurisdiction) in [2, 3, 4]`,
|
||||||
|
Severity: types.SeverityLow,
|
||||||
|
Weight: 0.1,
|
||||||
|
Action: types.ActionFlag,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-new-counterparty-large",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "New Counterparty Large",
|
||||||
|
Description: "First transaction with counterparty exceeds $5,000",
|
||||||
|
DSL: `first_counterparty(tx.UserID, tx.Counterparty) && tx.Notional > 5000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.15,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-dormant-reactivation",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Dormant Reactivation",
|
||||||
|
Description: "Account dormant >180 days reactivated with large transaction",
|
||||||
|
DSL: `last_tx_age(tx.UserID) > 180 && tx.Notional > 10000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.2,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 11,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-crypto-mixer",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Crypto Mixer Address",
|
||||||
|
Description: "Counterparty is a known mixer address",
|
||||||
|
DSL: `is_mixer_address(tx.Counterparty)`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 12,
|
||||||
|
AssetClassFilter: []string{"us_crypto", "crypto"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-darknet-market",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Darknet Market",
|
||||||
|
Description: "Counterparty is a known darknet marketplace",
|
||||||
|
DSL: `is_darknet_market(tx.Counterparty)`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 13,
|
||||||
|
AssetClassFilter: []string{"us_crypto", "crypto"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-ip-geo-mismatch",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "IP/Geo Mismatch",
|
||||||
|
Description: "Transaction IP does not match entity jurisdiction",
|
||||||
|
DSL: `!geo_match(tx.IPAddress, entity.Jurisdiction)`,
|
||||||
|
Severity: types.SeverityLow,
|
||||||
|
Weight: 0.1,
|
||||||
|
Action: types.ActionFlag,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-layering",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Layering Pattern",
|
||||||
|
Description: "Layering score exceeds 0.8 — complex transaction chains",
|
||||||
|
DSL: `layering_score(tx.UserID) > 0.8`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.3,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-smurfing",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Smurfing",
|
||||||
|
Description: "Multiple small deposits across accounts — smurfing pattern",
|
||||||
|
DSL: `smurfing_detected(tx.UserID, "7d")`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.3,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-wash-trading",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Wash Trading",
|
||||||
|
Description: "Self-trading detected within 1h window",
|
||||||
|
DSL: `wash_trade_detected(tx.Symbol, "1h")`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.2,
|
||||||
|
Action: types.ActionFlag,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-sanctions-direct",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Sanctions Direct",
|
||||||
|
Description: "Entity name matches sanctions list",
|
||||||
|
DSL: `sanctions_hit(entity.Name, entity.Jurisdiction)`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-sanctions-counterparty",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Sanctions Counterparty",
|
||||||
|
Description: "Counterparty name matches sanctions list",
|
||||||
|
DSL: `sanctions_hit(tx.Counterparty, "")`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-travel-rule",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Travel Rule",
|
||||||
|
Description: "Transaction exceeds FATF Travel Rule threshold ($3,000 USD equivalent)",
|
||||||
|
DSL: `notional_usd(tx) > 3000`,
|
||||||
|
Severity: types.SeverityMedium,
|
||||||
|
Weight: 0.15,
|
||||||
|
Action: types.ActionReport,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-travel-rule-incomplete",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Travel Rule Incomplete",
|
||||||
|
Description: "Travel rule triggered but originator/beneficiary info missing",
|
||||||
|
DSL: `tx.Notional > travel_rule_threshold(tx.Currency) && !travel_rule_complete(tx.ID)`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.25,
|
||||||
|
Action: types.ActionReview,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 21,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-chain-risk",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Chain Risk Check",
|
||||||
|
Description: "Counterparty address flagged by blockchain analytics",
|
||||||
|
DSL: `chain_risk_score(tx.Counterparty) > 0.7`,
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
Weight: 0.35,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 22,
|
||||||
|
AssetClassFilter: []string{"us_crypto", "crypto"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rule-chain-sanctions",
|
||||||
|
OrgID: orgID,
|
||||||
|
Name: "Chain Sanctions",
|
||||||
|
Description: "Counterparty address is sanctions-listed on-chain",
|
||||||
|
DSL: `chain_is_sanctioned(tx.Counterparty)`,
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
Weight: 0.5,
|
||||||
|
Action: types.ActionBlock,
|
||||||
|
Enabled: true,
|
||||||
|
Priority: 23,
|
||||||
|
AssetClassFilter: []string{"us_crypto", "crypto"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/engine"
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStarterRulesCount(t *testing.T) {
|
||||||
|
rules := StarterRules("test")
|
||||||
|
if len(rules) != 23 {
|
||||||
|
t.Errorf("expected 23 starter rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesAllEnabled(t *testing.T) {
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if !r.Enabled {
|
||||||
|
t.Errorf("rule %q should be enabled", r.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesUniqueIDs(t *testing.T) {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if seen[r.ID] {
|
||||||
|
t.Errorf("duplicate rule ID: %s", r.ID)
|
||||||
|
}
|
||||||
|
seen[r.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesUniquePriorities(t *testing.T) {
|
||||||
|
seen := make(map[int]bool)
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if seen[r.Priority] {
|
||||||
|
t.Errorf("duplicate priority %d for rule %q", r.Priority, r.Name)
|
||||||
|
}
|
||||||
|
seen[r.Priority] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesAllCompile(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
_, err := eval.Compile(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("rule %q failed to compile: %v", r.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesValidSeverity(t *testing.T) {
|
||||||
|
valid := map[string]bool{
|
||||||
|
types.SeverityLow: true,
|
||||||
|
types.SeverityMedium: true,
|
||||||
|
types.SeverityHigh: true,
|
||||||
|
types.SeverityCritical: true,
|
||||||
|
}
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if !valid[r.Severity] {
|
||||||
|
t.Errorf("rule %q has invalid severity: %s", r.Name, r.Severity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesValidAction(t *testing.T) {
|
||||||
|
valid := map[string]bool{
|
||||||
|
types.ActionFlag: true,
|
||||||
|
types.ActionReview: true,
|
||||||
|
types.ActionBlock: true,
|
||||||
|
types.ActionReport: true,
|
||||||
|
}
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if !valid[r.Action] {
|
||||||
|
t.Errorf("rule %q has invalid action: %s", r.Name, r.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesAllHaveWeight(t *testing.T) {
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if r.Weight <= 0 {
|
||||||
|
t.Errorf("rule %q has non-positive weight: %f", r.Name, r.Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarterRulesOrgID(t *testing.T) {
|
||||||
|
for _, r := range StarterRules("my-org") {
|
||||||
|
if r.OrgID != "my-org" {
|
||||||
|
t.Errorf("rule %q has org_id %s, want my-org", r.Name, r.OrgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual rule evaluation tests.
|
||||||
|
|
||||||
|
func TestRuleCTRFires(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-ctr-threshold")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 15000, Currency: "USD"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("CTR should fire for $15,000 USD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCTRFiresForEUR (RED-13) verifies that CTR fires for non-USD currencies
|
||||||
|
// when the USD equivalent exceeds $10,000. EUR 9999 * 1.08 = $10,798.
|
||||||
|
func TestCTRFiresForEUR(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-ctr-threshold")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 9999, Currency: "EUR"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("RED-13: CTR should fire for EUR 9999 ($10,799 at 1.08)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCTRNoFireJPYSmall (RED-13) verifies CTR does NOT fire for small JPY amounts.
|
||||||
|
func TestCTRNoFireJPYSmall(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-ctr-threshold")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 10001, Currency: "JPY"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
t.Error("RED-13: CTR should NOT fire for JPY 10001 ($67 at 0.0067)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCTRFiresJPYLarge (RED-13) verifies CTR fires for large JPY amounts.
|
||||||
|
func TestCTRFiresJPYLarge(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-ctr-threshold")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 1500000, Currency: "JPY"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("RED-13: CTR should fire for JPY 1500000 ($10,050 at 0.0067)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleStructuringFires(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-structuring")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 9500},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("Structuring should fire for $9,500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleStructuringNoFire(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-structuring")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 10000},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
t.Error("Structuring should not fire at exactly $10,000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulePEPFires(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-pep-large")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 20000},
|
||||||
|
Entity: types.Entity{PEP: true},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("PEP rule should fire for PEP with $20,000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulePEPNoFireLow(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-pep-large")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 5000},
|
||||||
|
Entity: types.Entity{PEP: true},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
t.Error("PEP rule should not fire for $5,000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleSanctionedJurisdiction(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-sanctioned-jurisdiction")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Entity: types.Entity{Jurisdiction: "IR"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("sanctioned jurisdiction should fire for IR")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleSanctionedJurisdictionSafe(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-sanctioned-jurisdiction")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Entity: types.Entity{Jurisdiction: "US"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
t.Error("sanctioned jurisdiction should not fire for US")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleTravelRule(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-travel-rule")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 5000, Currency: "USD"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
t.Error("travel rule should fire for $5,000 (threshold $3,000)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleTravelRuleBelowThreshold(t *testing.T) {
|
||||||
|
eval := engine.NewEvaluator()
|
||||||
|
r := findRule("rule-travel-rule")
|
||||||
|
ctx := types.EvalContext{
|
||||||
|
Tx: types.Transaction{Notional: 2000, Currency: "USD"},
|
||||||
|
}
|
||||||
|
match, err := eval.Eval(r, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
t.Error("travel rule should not fire for $2,000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRule(id string) types.Rule {
|
||||||
|
for _, r := range StarterRules("test") {
|
||||||
|
if r.ID == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("rule not found: " + id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// Package sanctions implements name-matching and list ingestion for
|
||||||
|
// OFAC SDN, UN, EU, and HMT sanctions lists.
|
||||||
|
package sanctions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JaroWinkler computes the Jaro-Winkler distance between two strings.
|
||||||
|
// Returns a value in [0, 1] where 1 is an exact match.
|
||||||
|
func JaroWinkler(s1, s2 string) float64 {
|
||||||
|
s1 = normalize(s1)
|
||||||
|
s2 = normalize(s2)
|
||||||
|
|
||||||
|
if s1 == s2 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
if len(s1) == 0 || len(s2) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
jaro := jaroDistance(s1, s2)
|
||||||
|
|
||||||
|
// Winkler prefix bonus: up to 4 common prefix chars.
|
||||||
|
prefix := 0
|
||||||
|
for i := 0; i < min(len(s1), len(s2)) && i < 4; i++ {
|
||||||
|
if s1[i] != s2[i] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
prefix++
|
||||||
|
}
|
||||||
|
|
||||||
|
return jaro + float64(prefix)*0.1*(1.0-jaro)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jaroDistance computes the Jaro distance between two strings.
|
||||||
|
func jaroDistance(s1, s2 string) float64 {
|
||||||
|
if s1 == s2 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
l1, l2 := len(s1), len(s2)
|
||||||
|
matchWindow := max(l1, l2)/2 - 1
|
||||||
|
if matchWindow < 0 {
|
||||||
|
matchWindow = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
s1Matches := make([]bool, l1)
|
||||||
|
s2Matches := make([]bool, l2)
|
||||||
|
|
||||||
|
var matches float64
|
||||||
|
var transpositions float64
|
||||||
|
|
||||||
|
for i := 0; i < l1; i++ {
|
||||||
|
lo := max(0, i-matchWindow)
|
||||||
|
hi := min(l2-1, i+matchWindow)
|
||||||
|
|
||||||
|
for j := lo; j <= hi; j++ {
|
||||||
|
if s2Matches[j] || s1[i] != s2[j] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s1Matches[i] = true
|
||||||
|
s2Matches[j] = true
|
||||||
|
matches++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
k := 0
|
||||||
|
for i := 0; i < l1; i++ {
|
||||||
|
if !s1Matches[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for !s2Matches[k] {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if s1[i] != s2[k] {
|
||||||
|
transpositions++
|
||||||
|
}
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
|
||||||
|
return (matches/float64(l1) + matches/float64(l2) + (matches-transpositions/2)/matches) / 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// cyrillicToLatin maps Cyrillic homoglyphs to their Latin equivalents.
|
||||||
|
// RED-10: Prevents homoglyph evasion (e.g., Cyrillic 'а' vs Latin 'a').
|
||||||
|
var cyrillicToLatin = map[rune]rune{
|
||||||
|
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e',
|
||||||
|
'ё': 'e', 'ж': 'z', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k',
|
||||||
|
'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r',
|
||||||
|
'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c',
|
||||||
|
'ч': 'c', 'ш': 's', 'щ': 's', 'ъ': ' ', 'ы': 'y', 'ь': ' ',
|
||||||
|
'э': 'e', 'ю': 'y', 'я': 'y',
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize performs NFKD Unicode normalization, Cyrillic→Latin transliteration,
|
||||||
|
// lowercasing, and non-alphanumeric stripping.
|
||||||
|
// RED-10: NFKD normalization decomposes compatibility characters, and the
|
||||||
|
// transliteration map catches Cyrillic homoglyphs used to evade sanctions.
|
||||||
|
func normalize(s string) string {
|
||||||
|
// NFKD decomposition — normalizes ligatures, fullwidth chars, etc.
|
||||||
|
s = norm.NFKD.String(s)
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
for _, r := range s {
|
||||||
|
// Cyrillic transliteration.
|
||||||
|
if lat, ok := cyrillicToLatin[r]; ok {
|
||||||
|
if lat != ' ' {
|
||||||
|
b.WriteRune(lat)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenMatchScore computes the average best Jaro-Winkler per input token.
|
||||||
|
func tokenMatchScore(inputTokens, candidateTokens []string) float64 {
|
||||||
|
if len(inputTokens) == 0 || len(candidateTokens) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
var totalBest float64
|
||||||
|
for _, it := range inputTokens {
|
||||||
|
best := 0.0
|
||||||
|
for _, ct := range candidateTokens {
|
||||||
|
score := JaroWinkler(it, ct)
|
||||||
|
best = math.Max(best, score)
|
||||||
|
}
|
||||||
|
totalBest += best
|
||||||
|
}
|
||||||
|
return totalBest / float64(len(inputTokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverseTokens returns the tokens in reverse order.
|
||||||
|
func reverseTokens(tokens []string) []string {
|
||||||
|
n := len(tokens)
|
||||||
|
rev := make([]string, n)
|
||||||
|
for i, t := range tokens {
|
||||||
|
rev[n-1-i] = t
|
||||||
|
}
|
||||||
|
return rev
|
||||||
|
}
|
||||||
|
|
||||||
|
// soundex computes a simplified Soundex code for a string.
|
||||||
|
func soundex(s string) string {
|
||||||
|
s = strings.ToUpper(normalize(s))
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
codes := map[byte]byte{
|
||||||
|
'B': '1', 'F': '1', 'P': '1', 'V': '1',
|
||||||
|
'C': '2', 'G': '2', 'J': '2', 'K': '2', 'Q': '2', 'S': '2', 'X': '2', 'Z': '2',
|
||||||
|
'D': '3', 'T': '3',
|
||||||
|
'L': '4',
|
||||||
|
'M': '5', 'N': '5',
|
||||||
|
'R': '6',
|
||||||
|
}
|
||||||
|
|
||||||
|
result := []byte{s[0]}
|
||||||
|
lastCode := codes[s[0]]
|
||||||
|
for i := 1; i < len(s) && len(result) < 4; i++ {
|
||||||
|
code, ok := codes[s[i]]
|
||||||
|
if ok && code != lastCode {
|
||||||
|
result = append(result, code)
|
||||||
|
lastCode = code
|
||||||
|
} else if !ok {
|
||||||
|
lastCode = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(result) < 4 {
|
||||||
|
result = append(result, '0')
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenMatch computes token-based matching with name reversal and phonetic fallback.
|
||||||
|
// RED-10: Runs both forward and reversed token orderings to catch "Jong Un Kim"
|
||||||
|
// matching "Kim Jong Un". Adds Soundex as a phonetic tie-breaker.
|
||||||
|
func TokenMatch(input, candidate string) float64 {
|
||||||
|
inputTokens := tokenize(input)
|
||||||
|
candidateTokens := tokenize(candidate)
|
||||||
|
if len(inputTokens) == 0 || len(candidateTokens) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward match.
|
||||||
|
forward := tokenMatchScore(inputTokens, candidateTokens)
|
||||||
|
|
||||||
|
// Reversed name-order match.
|
||||||
|
reversed := tokenMatchScore(reverseTokens(inputTokens), candidateTokens)
|
||||||
|
|
||||||
|
best := math.Max(forward, reversed)
|
||||||
|
|
||||||
|
// Phonetic fallback: if token match is marginal, check Soundex similarity.
|
||||||
|
if best < MatchThreshold && best > 0.5 {
|
||||||
|
phonForward := tokenMatchScore(soundexTokens(inputTokens), soundexTokens(candidateTokens))
|
||||||
|
phonReversed := tokenMatchScore(soundexTokens(reverseTokens(inputTokens)), soundexTokens(candidateTokens))
|
||||||
|
phonBest := math.Max(phonForward, phonReversed)
|
||||||
|
// Blend: 70% string match + 30% phonetic.
|
||||||
|
blended := best*0.7 + phonBest*0.3
|
||||||
|
best = math.Max(best, blended)
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// soundexTokens converts string tokens to their Soundex codes.
|
||||||
|
func soundexTokens(tokens []string) []string {
|
||||||
|
out := make([]string, len(tokens))
|
||||||
|
for i, t := range tokens {
|
||||||
|
out[i] = soundex(t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize splits a string on whitespace and returns non-empty tokens.
|
||||||
|
func tokenize(s string) []string {
|
||||||
|
parts := strings.Fields(strings.ToLower(s))
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchThreshold is the default Jaro-Winkler threshold for a sanctions match.
|
||||||
|
const MatchThreshold = 0.85
|
||||||
|
|
||||||
|
// Match checks if a name matches any entry in the list above the threshold.
|
||||||
|
// Returns matching entries with their scores.
|
||||||
|
type MatchResult struct {
|
||||||
|
EntryID string `json:"entry_id"`
|
||||||
|
ListID string `json:"list_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
MatchType string `json:"match_type"` // exact, fuzzy, token
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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,144 @@
|
|||||||
|
// 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 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,78 @@
|
|||||||
|
// 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,261 @@
|
|||||||
|
// Package types defines the canonical domain types for the AML engine.
|
||||||
|
// These types are the single source of truth for all AML operations.
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity levels for rules and alerts.
|
||||||
|
const (
|
||||||
|
SeverityLow = "low"
|
||||||
|
SeverityMedium = "medium"
|
||||||
|
SeverityHigh = "high"
|
||||||
|
SeverityCritical = "critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Action constants for rule outcomes and transaction responses.
|
||||||
|
const (
|
||||||
|
ActionAllow = "allow"
|
||||||
|
ActionFlag = "flag"
|
||||||
|
ActionReview = "review"
|
||||||
|
ActionBlock = "block"
|
||||||
|
ActionReport = "report"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case statuses.
|
||||||
|
const (
|
||||||
|
CaseOpen = "open"
|
||||||
|
CaseInReview = "in_review"
|
||||||
|
CaseEscalated = "escalated"
|
||||||
|
CaseClosed = "closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case resolutions.
|
||||||
|
const (
|
||||||
|
ResolutionCleared = "cleared"
|
||||||
|
ResolutionSARFiled = "sar_filed"
|
||||||
|
ResolutionAccountFrozen = "account_frozen"
|
||||||
|
ResolutionFalsePositive = "false_positive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alert decisions.
|
||||||
|
const (
|
||||||
|
DecisionApproved = "approved"
|
||||||
|
DecisionRejected = "rejected"
|
||||||
|
DecisionEscalated = "escalated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity types.
|
||||||
|
const (
|
||||||
|
EntityUser = "user"
|
||||||
|
EntityAccount = "account"
|
||||||
|
EntityCounterparty = "counterparty"
|
||||||
|
EntityBankAccount = "bank_account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case event kinds.
|
||||||
|
const (
|
||||||
|
EventNote = "note"
|
||||||
|
EventStatusChange = "status_change"
|
||||||
|
EventFile = "file"
|
||||||
|
EventWebhook = "webhook_event"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanctions list sources.
|
||||||
|
const (
|
||||||
|
ListOFACSDN = "ofac_sdn"
|
||||||
|
ListUN = "un"
|
||||||
|
ListEU = "eu"
|
||||||
|
ListHMT = "hmt"
|
||||||
|
ListInterpol = "interpol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanctions entry types.
|
||||||
|
const (
|
||||||
|
SanctionIndividual = "individual"
|
||||||
|
SanctionEntity = "entity"
|
||||||
|
SanctionVessel = "vessel"
|
||||||
|
SanctionAircraft = "aircraft"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Webhook event types.
|
||||||
|
const (
|
||||||
|
WebhookAMLFlagged = "aml.flagged"
|
||||||
|
WebhookAMLCleared = "aml.cleared"
|
||||||
|
WebhookCaseOpened = "case.opened"
|
||||||
|
WebhookCaseClosed = "case.closed"
|
||||||
|
WebhookKYCApproved = "kyc.approved"
|
||||||
|
WebhookTradeExecuted = "trade.executed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transaction is an incoming transaction event for AML evaluation.
|
||||||
|
type Transaction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AccountID string `json:"account_id,omitempty"`
|
||||||
|
Symbol string `json:"symbol,omitempty"`
|
||||||
|
AssetClass string `json:"asset_class,omitempty"`
|
||||||
|
Side string `json:"side,omitempty"`
|
||||||
|
Qty float64 `json:"qty"`
|
||||||
|
Notional float64 `json:"notional"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Counterparty string `json:"counterparty,omitempty"`
|
||||||
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
|
DeviceFingerprint string `json:"device_fingerprint,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity is a normalized actor (user, account, counterparty).
|
||||||
|
type Entity struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Jurisdiction string `json:"jurisdiction,omitempty"`
|
||||||
|
KYCLevel int `json:"kyc_level"`
|
||||||
|
PEP bool `json:"pep"`
|
||||||
|
SanctionsFlag bool `json:"sanctions_flag"`
|
||||||
|
RiskScore float64 `json:"risk_score"`
|
||||||
|
FirstSeen time.Time `json:"first_seen"`
|
||||||
|
LastSeen time.Time `json:"last_seen"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule is an AML evaluation rule using expr-lang DSL.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
ScoreBreakdown map[string]float64 `json:"score_breakdown,omitempty"`
|
||||||
|
ActionTaken string `json:"action_taken"`
|
||||||
|
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||||
|
Decision string `json:"decision,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case is a human review case.
|
||||||
|
type Case struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
EntityIDs []string `json:"entity_ids,omitempty"`
|
||||||
|
AlertIDs []string `json:"alert_ids,omitempty"`
|
||||||
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
|
OpenedAt time.Time `json:"opened_at"`
|
||||||
|
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||||
|
Resolution string `json:"resolution,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaseEvent is a case timeline entry.
|
||||||
|
type CaseEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CaseID string `json:"case_id"`
|
||||||
|
AuthorID string `json:"author_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanctionsList is list metadata.
|
||||||
|
type SanctionsList struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanctionsEntry is a flattened sanctions list entry.
|
||||||
|
type SanctionsEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ListID string `json:"list_id"`
|
||||||
|
RefID string `json:"ref_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
DOB string `json:"dob,omitempty"`
|
||||||
|
Nationality string `json:"nationality,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook is a subscriber configuration.
|
||||||
|
type Webhook struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OrgID string `json:"org_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
Events []string `json:"events"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
|
||||||
|
FailureCount int `json:"failure_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleHit is the result of evaluating a single rule against a transaction.
|
||||||
|
type RuleHit struct {
|
||||||
|
Rule Rule
|
||||||
|
Match bool
|
||||||
|
Score float64
|
||||||
|
EvalErr string // non-empty if the rule evaluation failed (fail-closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalContext is the evaluation context passed to expr-lang rules.
|
||||||
|
type EvalContext struct {
|
||||||
|
Tx Transaction `json:"tx"`
|
||||||
|
Entity Entity `json:"entity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalResult is the synchronous response to a transaction ingestion.
|
||||||
|
type EvalResult struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
AlertIDs []string `json:"alert_ids"`
|
||||||
|
CaseID string `json:"case_id,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Package webhook provides signed outbound webhook delivery with
|
||||||
|
// exponential backoff and dead-letter semantics.
|
||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxRetries is the maximum delivery attempts before dead-lettering.
|
||||||
|
const MaxRetries = 5
|
||||||
|
|
||||||
|
// maxResponseBody is the max response body to read from webhook targets.
|
||||||
|
const maxResponseBody = 1024
|
||||||
|
|
||||||
|
// Payload is the webhook delivery body.
|
||||||
|
type Payload struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver sends a signed webhook to the subscriber URL.
|
||||||
|
// The signature is HMAC-SHA256 of the JSON body using the webhook secret.
|
||||||
|
// Returns nil on 2xx, error otherwise.
|
||||||
|
func Deliver(ctx context.Context, wh types.Webhook, event string, data interface{}) error {
|
||||||
|
payload := Payload{
|
||||||
|
Event: event,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("webhook marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := Sign(body, wh.Secret)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("webhook request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Webhook-Signature", sig)
|
||||||
|
req.Header.Set("X-Webhook-Event", event)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("webhook deliver: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("webhook %s returned %d", wh.URL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign computes HMAC-SHA256 of body using secret and returns hex-encoded.
|
||||||
|
func Sign(body []byte, secret string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks that a signature matches the expected HMAC-SHA256.
|
||||||
|
func Verify(body []byte, secret, signature string) bool {
|
||||||
|
expected := Sign(body, secret)
|
||||||
|
return hmac.Equal([]byte(expected), []byte(signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
// privateNetworks lists CIDR ranges that must be rejected for webhook URLs.
|
||||||
|
// RED-18: Prevents SSRF by blocking delivery to internal/private networks.
|
||||||
|
var privateNetworks = []string{
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"169.254.0.0/16", // link-local + cloud metadata
|
||||||
|
"::1/128",
|
||||||
|
"fc00::/7", // unique local
|
||||||
|
"fe80::/10", // link-local IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
// internalHostSuffixes are DNS suffixes that resolve to internal services.
|
||||||
|
var internalHostSuffixes = []string{
|
||||||
|
".svc.cluster.local",
|
||||||
|
".internal",
|
||||||
|
".local",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateURL checks that a webhook URL is safe to deliver to.
|
||||||
|
// Rejects private IPs, localhost, cloud metadata, and K8s internal hostnames.
|
||||||
|
func ValidateURL(rawURL string) error {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Scheme != "https" {
|
||||||
|
return fmt.Errorf("webhook URL must use HTTPS")
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := parsed.Hostname()
|
||||||
|
|
||||||
|
// Check internal hostname suffixes.
|
||||||
|
lower := strings.ToLower(hostname)
|
||||||
|
for _, suffix := range internalHostSuffixes {
|
||||||
|
if strings.HasSuffix(lower, suffix) {
|
||||||
|
return fmt.Errorf("webhook URL hostname %q is internal", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block well-known metadata hostnames.
|
||||||
|
if lower == "metadata.google.internal" || lower == "metadata" {
|
||||||
|
return fmt.Errorf("webhook URL hostname %q is a cloud metadata endpoint", hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hostname and check all IPs against private ranges.
|
||||||
|
ips, err := net.LookupHost(hostname)
|
||||||
|
if err != nil {
|
||||||
|
// If resolution fails, check if hostname is already an IP.
|
||||||
|
ip := net.ParseIP(hostname)
|
||||||
|
if ip != nil {
|
||||||
|
ips = []string{hostname}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("cannot resolve hostname %q: %w", hostname, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ipStr := range ips {
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, cidr := range privateNetworks {
|
||||||
|
_, network, _ := net.ParseCIDR(cidr)
|
||||||
|
if network != nil && network.Contains(ip) {
|
||||||
|
return fmt.Errorf("webhook URL resolves to private IP %s", ipStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliverWithRetry attempts delivery with exponential backoff.
|
||||||
|
// Sleeps 1s, 2s, 4s, 8s, 16s between retries.
|
||||||
|
func DeliverWithRetry(ctx context.Context, wh types.Webhook, event string, data interface{}) error {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < MaxRetries; attempt++ {
|
||||||
|
if err := Deliver(ctx, wh, event, data); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("webhook dead-letter after %d attempts: %w", MaxRetries, lastErr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package webhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSign(t *testing.T) {
|
||||||
|
sig := Sign([]byte("hello"), "secret")
|
||||||
|
if sig == "" {
|
||||||
|
t.Fatal("signature should not be empty")
|
||||||
|
}
|
||||||
|
// Deterministic.
|
||||||
|
sig2 := Sign([]byte("hello"), "secret")
|
||||||
|
if sig != sig2 {
|
||||||
|
t.Error("same input should produce same signature")
|
||||||
|
}
|
||||||
|
// Different input.
|
||||||
|
sig3 := Sign([]byte("world"), "secret")
|
||||||
|
if sig == sig3 {
|
||||||
|
t.Error("different input should produce different signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify(t *testing.T) {
|
||||||
|
body := []byte(`{"event":"test"}`)
|
||||||
|
secret := "my-secret-key"
|
||||||
|
sig := Sign(body, secret)
|
||||||
|
|
||||||
|
if !Verify(body, secret, sig) {
|
||||||
|
t.Error("valid signature should verify")
|
||||||
|
}
|
||||||
|
if Verify(body, "wrong-secret", sig) {
|
||||||
|
t.Error("wrong secret should not verify")
|
||||||
|
}
|
||||||
|
if Verify([]byte("tampered"), secret, sig) {
|
||||||
|
t.Error("tampered body should not verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliver(t *testing.T) {
|
||||||
|
var received Payload
|
||||||
|
var gotSig, gotEvent string
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotSig = r.Header.Get("X-Webhook-Signature")
|
||||||
|
gotEvent = r.Header.Get("X-Webhook-Event")
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(body, &received)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wh := types.Webhook{
|
||||||
|
URL: srv.URL,
|
||||||
|
Secret: "test-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Deliver(context.Background(), wh, "aml.flagged", map[string]string{"tx_id": "tx1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Deliver error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if received.Event != "aml.flagged" {
|
||||||
|
t.Errorf("event = %s, want aml.flagged", received.Event)
|
||||||
|
}
|
||||||
|
if gotEvent != "aml.flagged" {
|
||||||
|
t.Errorf("X-Webhook-Event = %s, want aml.flagged", gotEvent)
|
||||||
|
}
|
||||||
|
if gotSig == "" {
|
||||||
|
t.Error("X-Webhook-Signature should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookURLValidation (RED-18) verifies SSRF protection on webhook URLs.
|
||||||
|
func TestWebhookURLValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"private_10", "https://10.0.0.1/hook", true},
|
||||||
|
{"private_172", "https://172.16.0.1/hook", true},
|
||||||
|
{"private_192", "https://192.168.1.1/hook", true},
|
||||||
|
{"localhost_ip", "https://127.0.0.1/hook", true},
|
||||||
|
{"localhost_ipv6", "https://[::1]/hook", true},
|
||||||
|
{"metadata_gcp", "https://metadata.google.internal/computeMetadata/v1/", true},
|
||||||
|
{"link_local", "https://169.254.169.254/latest/meta-data/", true},
|
||||||
|
{"k8s_internal", "https://my-svc.default.svc.cluster.local/hook", true},
|
||||||
|
{"dot_internal", "https://something.internal/hook", true},
|
||||||
|
{"http_scheme", "http://example.com/hook", true},
|
||||||
|
{"public_ok", "https://example.com/hook", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateURL(tt.url)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Errorf("RED-18: ValidateURL(%q) should reject but returned nil", tt.url)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("RED-18: ValidateURL(%q) should accept but got: %v", tt.url, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverFailure(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
wh := types.Webhook{
|
||||||
|
URL: srv.URL,
|
||||||
|
Secret: "secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Deliver(context.Background(), wh, "test", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 500 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Package workflows provides cron-driven AML workflows: continuous KYC
|
||||||
|
// re-screening, high-risk entity monitoring, and entity change detection.
|
||||||
|
package workflows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/sanctions"
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntitySource fetches active entities for re-screening.
|
||||||
|
// Production wires IAM or database queries; tests use in-memory stubs.
|
||||||
|
type EntitySource interface {
|
||||||
|
// ActiveEntities returns all entities with approved KYC.
|
||||||
|
ActiveEntities(ctx context.Context, orgID string) ([]types.Entity, error)
|
||||||
|
// HighRiskEntities returns entities with risk_score > threshold or PEP=true.
|
||||||
|
HighRiskEntities(ctx context.Context, orgID string, threshold float64) ([]types.Entity, error)
|
||||||
|
// UpdateLastScreened marks an entity as screened at the given time.
|
||||||
|
UpdateLastScreened(ctx context.Context, entityID string, at time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertSink receives new alerts and cases from screening.
|
||||||
|
type AlertSink interface {
|
||||||
|
CreateAlert(ctx context.Context, alert types.Alert) error
|
||||||
|
CreateCase(ctx context.Context, orgID, severity string, alertIDs, entityIDs []string) (*types.Case, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenResult captures the outcome of a single entity re-screen.
|
||||||
|
type ScreenResult struct {
|
||||||
|
EntityID string
|
||||||
|
Matched bool
|
||||||
|
Score float64
|
||||||
|
ListID string
|
||||||
|
EntryName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContinuousKYCConfig controls re-screening behavior.
|
||||||
|
type ContinuousKYCConfig struct {
|
||||||
|
OrgID string
|
||||||
|
HighRiskThreshold float64 // entities above this risk_score get hourly rescreen
|
||||||
|
MatchThreshold float64 // Jaro-Winkler threshold for sanctions match
|
||||||
|
BatchSize int // max entities per batch
|
||||||
|
Timeout time.Duration // per-entity timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns conservative defaults.
|
||||||
|
func DefaultConfig(orgID string) ContinuousKYCConfig {
|
||||||
|
return ContinuousKYCConfig{
|
||||||
|
OrgID: orgID,
|
||||||
|
HighRiskThreshold: 0.7,
|
||||||
|
MatchThreshold: sanctions.MatchThreshold,
|
||||||
|
BatchSize: 500,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContinuousKYCScreen re-screens all active users against current sanctions lists.
|
||||||
|
// Intended to run daily at 02:00 UTC.
|
||||||
|
func ContinuousKYCScreen(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg ContinuousKYCConfig,
|
||||||
|
source EntitySource,
|
||||||
|
sink AlertSink,
|
||||||
|
entries []types.SanctionsEntry,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) (screened int, matches int, cases int) {
|
||||||
|
entities, err := source.ActiveEntities(ctx, cfg.OrgID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to fetch active entities", "err", err)
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return screened, matches, cases
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, result := screenEntity(entity, entries, cfg.MatchThreshold)
|
||||||
|
screened++
|
||||||
|
|
||||||
|
if err := source.UpdateLastScreened(ctx, entity.ID, time.Now().UTC()); err != nil {
|
||||||
|
logger.Error("failed to update last_screened", "entity_id", entity.ID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches++
|
||||||
|
|
||||||
|
alert := types.Alert{
|
||||||
|
OrgID: cfg.OrgID,
|
||||||
|
RuleID: "continuous_kyc_rescreen",
|
||||||
|
RuleName: "Continuous KYC Rescreen",
|
||||||
|
Severity: types.SeverityHigh,
|
||||||
|
ActionTaken: types.ActionReview,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sink.CreateAlert(ctx, alert); err != nil {
|
||||||
|
logger.Error("failed to create alert", "entity_id", entity.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := sink.CreateCase(ctx, cfg.OrgID, types.SeverityHigh,
|
||||||
|
[]string{alert.ID}, []string{entity.ID})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to create case", "entity_id", entity.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cases++
|
||||||
|
|
||||||
|
logger.Warn("sanctions match on rescreen",
|
||||||
|
"entity_id", entity.ID,
|
||||||
|
"entity_name", entity.Name,
|
||||||
|
"match_score", result.Score,
|
||||||
|
"match_entry", result.EntryName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("continuous KYC screen complete",
|
||||||
|
"screened", screened,
|
||||||
|
"matches", matches,
|
||||||
|
"cases", cases,
|
||||||
|
)
|
||||||
|
|
||||||
|
return screened, matches, cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// HighRiskRescreen re-screens entities above the risk threshold or flagged as PEP.
|
||||||
|
// Intended to run hourly.
|
||||||
|
func HighRiskRescreen(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg ContinuousKYCConfig,
|
||||||
|
source EntitySource,
|
||||||
|
sink AlertSink,
|
||||||
|
entries []types.SanctionsEntry,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) (screened int, matches int, cases int) {
|
||||||
|
entities, err := source.HighRiskEntities(ctx, cfg.OrgID, cfg.HighRiskThreshold)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to fetch high-risk entities", "err", err)
|
||||||
|
return 0, 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entity := range entities {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return screened, matches, cases
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, result := screenEntity(entity, entries, cfg.MatchThreshold)
|
||||||
|
screened++
|
||||||
|
|
||||||
|
if err := source.UpdateLastScreened(ctx, entity.ID, time.Now().UTC()); err != nil {
|
||||||
|
logger.Error("failed to update last_screened", "entity_id", entity.ID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matched {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches++
|
||||||
|
|
||||||
|
alert := types.Alert{
|
||||||
|
OrgID: cfg.OrgID,
|
||||||
|
RuleID: "high_risk_rescreen",
|
||||||
|
RuleName: "High-Risk Rescreen",
|
||||||
|
Severity: types.SeverityCritical,
|
||||||
|
ActionTaken: types.ActionBlock,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sink.CreateAlert(ctx, alert); err != nil {
|
||||||
|
logger.Error("failed to create alert", "entity_id", entity.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := sink.CreateCase(ctx, cfg.OrgID, types.SeverityCritical,
|
||||||
|
[]string{alert.ID}, []string{entity.ID})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to create case", "entity_id", entity.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cases++
|
||||||
|
|
||||||
|
logger.Warn("high-risk sanctions match",
|
||||||
|
"entity_id", entity.ID,
|
||||||
|
"entity_name", entity.Name,
|
||||||
|
"risk_score", entity.RiskScore,
|
||||||
|
"match_score", result.Score,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("high-risk rescreen complete",
|
||||||
|
"screened", screened,
|
||||||
|
"matches", matches,
|
||||||
|
"cases", cases,
|
||||||
|
)
|
||||||
|
|
||||||
|
return screened, matches, cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenEntity checks one entity against all sanctions entries.
|
||||||
|
func screenEntity(entity types.Entity, entries []types.SanctionsEntry, threshold float64) (bool, ScreenResult) {
|
||||||
|
for _, entry := range entries {
|
||||||
|
// Full name match.
|
||||||
|
score := sanctions.JaroWinkler(entity.Name, entry.Name)
|
||||||
|
if score >= threshold {
|
||||||
|
return true, ScreenResult{
|
||||||
|
EntityID: entity.ID,
|
||||||
|
Matched: true,
|
||||||
|
Score: score,
|
||||||
|
ListID: entry.ListID,
|
||||||
|
EntryName: entry.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token match for partial name coverage.
|
||||||
|
tokenScore := sanctions.TokenMatch(entity.Name, entry.Name)
|
||||||
|
if tokenScore >= threshold {
|
||||||
|
return true, ScreenResult{
|
||||||
|
EntityID: entity.ID,
|
||||||
|
Matched: true,
|
||||||
|
Score: tokenScore,
|
||||||
|
ListID: entry.ListID,
|
||||||
|
EntryName: entry.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check aliases.
|
||||||
|
for _, alias := range entry.Aliases {
|
||||||
|
aliasScore := sanctions.JaroWinkler(entity.Name, alias)
|
||||||
|
if aliasScore >= threshold {
|
||||||
|
return true, ScreenResult{
|
||||||
|
EntityID: entity.ID,
|
||||||
|
Matched: true,
|
||||||
|
Score: aliasScore,
|
||||||
|
ListID: entry.ListID,
|
||||||
|
EntryName: alias,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, ScreenResult{EntityID: entity.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- In-memory stubs for testing ---
|
||||||
|
|
||||||
|
// MemEntitySource is an in-memory EntitySource for testing.
|
||||||
|
type MemEntitySource struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entities []types.Entity
|
||||||
|
screened map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemEntitySource creates a MemEntitySource with the given entities.
|
||||||
|
func NewMemEntitySource(entities []types.Entity) *MemEntitySource {
|
||||||
|
return &MemEntitySource{
|
||||||
|
entities: entities,
|
||||||
|
screened: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemEntitySource) ActiveEntities(_ context.Context, orgID string) ([]types.Entity, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var out []types.Entity
|
||||||
|
for _, e := range m.entities {
|
||||||
|
if e.OrgID == orgID {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemEntitySource) HighRiskEntities(_ context.Context, orgID string, threshold float64) ([]types.Entity, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
var out []types.Entity
|
||||||
|
for _, e := range m.entities {
|
||||||
|
if e.OrgID == orgID && (e.RiskScore > threshold || e.PEP) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemEntitySource) UpdateLastScreened(_ context.Context, entityID string, at time.Time) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.screened[entityID] = at
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemEntitySource) LastScreened(entityID string) (time.Time, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
t, ok := m.screened[entityID]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemAlertSink is an in-memory AlertSink for testing.
|
||||||
|
type MemAlertSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Alerts []types.Alert
|
||||||
|
Cases []types.Case
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemAlertSink) CreateAlert(_ context.Context, a types.Alert) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.Alerts = append(m.Alerts, a)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemAlertSink) CreateCase(_ context.Context, orgID, severity string, alertIDs, entityIDs []string) (*types.Case, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
c := types.Case{
|
||||||
|
ID: "case-" + orgID,
|
||||||
|
OrgID: orgID,
|
||||||
|
Severity: severity,
|
||||||
|
AlertIDs: alertIDs,
|
||||||
|
EntityIDs: entityIDs,
|
||||||
|
Status: types.CaseOpen,
|
||||||
|
OpenedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
m.Cases = append(m.Cases, c)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package workflows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/luxfi/aml/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuousKYCScreen_NoMatch(t *testing.T) {
|
||||||
|
entities := []types.Entity{
|
||||||
|
{ID: "e1", OrgID: "org1", Name: "Alice Smith"},
|
||||||
|
{ID: "e2", OrgID: "org1", Name: "Bob Jones"},
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
// Sanctions list with no matching names.
|
||||||
|
entries := []types.SanctionsEntry{
|
||||||
|
{Name: "Vladimir Petrov", ListID: "ofac"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
screened, matches, cases := ContinuousKYCScreen(
|
||||||
|
context.Background(), cfg, source, sink, entries, testLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if screened != 2 {
|
||||||
|
t.Errorf("screened = %d, want 2", screened)
|
||||||
|
}
|
||||||
|
if matches != 0 {
|
||||||
|
t.Errorf("matches = %d, want 0", matches)
|
||||||
|
}
|
||||||
|
if cases != 0 {
|
||||||
|
t.Errorf("cases = %d, want 0", cases)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all entities were marked as screened.
|
||||||
|
for _, e := range entities {
|
||||||
|
if _, ok := source.LastScreened(e.ID); !ok {
|
||||||
|
t.Errorf("entity %s not marked as screened", e.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuousKYCScreen_WithMatch(t *testing.T) {
|
||||||
|
entities := []types.Entity{
|
||||||
|
{ID: "e1", OrgID: "org1", Name: "Vladimir Petrov"},
|
||||||
|
{ID: "e2", OrgID: "org1", Name: "Clean Name"},
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
entries := []types.SanctionsEntry{
|
||||||
|
{Name: "Vladimir Petrov", ListID: "ofac"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
screened, matches, cases := ContinuousKYCScreen(
|
||||||
|
context.Background(), cfg, source, sink, entries, testLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if screened != 2 {
|
||||||
|
t.Errorf("screened = %d, want 2", screened)
|
||||||
|
}
|
||||||
|
if matches != 1 {
|
||||||
|
t.Errorf("matches = %d, want 1", matches)
|
||||||
|
}
|
||||||
|
if cases != 1 {
|
||||||
|
t.Errorf("cases = %d, want 1", cases)
|
||||||
|
}
|
||||||
|
if len(sink.Alerts) != 1 {
|
||||||
|
t.Errorf("alerts = %d, want 1", len(sink.Alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuousKYCScreen_OrgFilter(t *testing.T) {
|
||||||
|
entities := []types.Entity{
|
||||||
|
{ID: "e1", OrgID: "org1", Name: "Test User"},
|
||||||
|
{ID: "e2", OrgID: "org2", Name: "Other Org"},
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
screened, _, _ := ContinuousKYCScreen(
|
||||||
|
context.Background(), cfg, source, sink, nil, testLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if screened != 1 {
|
||||||
|
t.Errorf("screened = %d, want 1 (org-filtered)", screened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHighRiskRescreen(t *testing.T) {
|
||||||
|
entities := []types.Entity{
|
||||||
|
{ID: "e1", OrgID: "org1", Name: "Normal User", RiskScore: 0.3},
|
||||||
|
{ID: "e2", OrgID: "org1", Name: "Risky User", RiskScore: 0.9},
|
||||||
|
{ID: "e3", OrgID: "org1", Name: "PEP User", RiskScore: 0.4, PEP: true},
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
screened, _, _ := HighRiskRescreen(
|
||||||
|
context.Background(), cfg, source, sink, nil, testLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only e2 (risk 0.9 > 0.7) and e3 (PEP) should be screened.
|
||||||
|
if screened != 2 {
|
||||||
|
t.Errorf("screened = %d, want 2 (high-risk + PEP only)", screened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHighRiskRescreen_MatchCreatesCriticalCase(t *testing.T) {
|
||||||
|
entities := []types.Entity{
|
||||||
|
{ID: "e1", OrgID: "org1", Name: "Sanctioned Person", RiskScore: 0.9},
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
entries := []types.SanctionsEntry{
|
||||||
|
{Name: "Sanctioned Person", ListID: "ofac"},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
_, matches, cases := HighRiskRescreen(
|
||||||
|
context.Background(), cfg, source, sink, entries, testLogger(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if matches != 1 {
|
||||||
|
t.Errorf("matches = %d, want 1", matches)
|
||||||
|
}
|
||||||
|
if cases != 1 {
|
||||||
|
t.Errorf("cases = %d, want 1", cases)
|
||||||
|
}
|
||||||
|
if len(sink.Cases) > 0 && sink.Cases[0].Severity != types.SeverityCritical {
|
||||||
|
t.Errorf("case severity = %q, want %q", sink.Cases[0].Severity, types.SeverityCritical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContinuousKYCScreen_Cancellation(t *testing.T) {
|
||||||
|
entities := make([]types.Entity, 100)
|
||||||
|
for i := range entities {
|
||||||
|
entities[i] = types.Entity{ID: "e-many", OrgID: "org1", Name: "Test"}
|
||||||
|
}
|
||||||
|
source := NewMemEntitySource(entities)
|
||||||
|
sink := &MemAlertSink{}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately.
|
||||||
|
|
||||||
|
cfg := DefaultConfig("org1")
|
||||||
|
screened, _, _ := ContinuousKYCScreen(ctx, cfg, source, sink, nil, testLogger())
|
||||||
|
|
||||||
|
// Should stop quickly due to cancellation.
|
||||||
|
if screened > 1 {
|
||||||
|
t.Errorf("screened = %d, expected early exit due to context cancellation", screened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenEntity_AliasMatch(t *testing.T) {
|
||||||
|
entity := types.Entity{ID: "e1", Name: "Vlad Petrov"}
|
||||||
|
entries := []types.SanctionsEntry{
|
||||||
|
{Name: "Vladimir Petrovich Petrov", Aliases: []string{"Vlad Petrov"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, result := screenEntity(entity, entries, 0.85)
|
||||||
|
if !matched {
|
||||||
|
t.Error("expected alias match")
|
||||||
|
}
|
||||||
|
if result.Score < 0.85 {
|
||||||
|
t.Errorf("score = %.2f, expected >= 0.85", result.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
//go:build !embedui
|
||||||
|
|
||||||
|
// Package ui provides a placeholder dashboard filesystem when the server is
|
||||||
|
// built without the `embedui` tag, so the bare Go toolchain (go build, go vet,
|
||||||
|
// go test, go install) never depends on a Vite build having run first.
|
||||||
|
//
|
||||||
|
// The product build embeds the real dashboard: `make build` runs the UI build
|
||||||
|
// and passes -tags embedui. See ui/embed_prod.go.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var emptyFS = fstest.MapFS{
|
||||||
|
"index.html": &fstest.MapFile{Data: []byte(
|
||||||
|
`<!doctype html><title>AML</title><p>UI not embedded. Build with -tags embedui.</p>`,
|
||||||
|
)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistDirFS returns a placeholder dashboard tree.
|
||||||
|
func DistDirFS() fs.FS { return emptyFS }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build embedui
|
||||||
|
|
||||||
|
// Package ui embeds the AML admin dashboard built with Vite + React.
|
||||||
|
//
|
||||||
|
// The Go HTTP handler serves the admin UI at the /_/aml/ path. `make ui`
|
||||||
|
// produces dist/; the //go:embed directive below picks up the result.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
|
|
||||||
|
// DistDirFS returns a rooted fs.FS pointing at dist/ so it can be passed
|
||||||
|
// directly to the Base router's Static handler.
|
||||||
|
func DistDirFS() fs.FS {
|
||||||
|
sub, err := fs.Sub(distFS, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic("aml-ui: dist/ missing — run `make ui` first: " + err.Error())
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AML Admin</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body, #root { height: 100%; width: 100%; background: #000; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@luxfi/aml-ui",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hanzo/gui": "^4.8.0",
|
||||||
|
"@hanzogui/config-default": "^4.4.0",
|
||||||
|
"@hanzogui/core": "^4.4.0",
|
||||||
|
"@hanzogui/theme": "^4.4.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"wouter": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7571
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
import { Router, Route, Switch, Link, useLocation } from 'wouter'
|
||||||
|
import { useHashLocation } from 'wouter/use-hash-location'
|
||||||
|
import { Dashboard } from './pages/Dashboard'
|
||||||
|
import { Cases } from './pages/Cases'
|
||||||
|
import { Rules } from './pages/Rules'
|
||||||
|
import { Alerts } from './pages/Alerts'
|
||||||
|
import { Sanctions } from './pages/Sanctions'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Dashboard' },
|
||||||
|
{ href: '/cases', label: 'Cases' },
|
||||||
|
{ href: '/rules', label: 'Rules' },
|
||||||
|
{ href: '/alerts', label: 'Alerts' },
|
||||||
|
{ href: '/sanctions', label: 'Sanctions' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function NavLink({ href, label }: { href: string; label: string }) {
|
||||||
|
const [location] = useLocation()
|
||||||
|
const active = href === '/' ? location === '/' : location.startsWith(href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: active ? '#fff' : '#a3a3a3',
|
||||||
|
background: active ? '#262626' : 'transparent',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<Router hook={useHashLocation}>
|
||||||
|
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
|
||||||
|
<aside
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
borderRight: '1px solid #262626',
|
||||||
|
padding: 16,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
|
||||||
|
AML Admin
|
||||||
|
</div>
|
||||||
|
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{navItems.map((n) => (
|
||||||
|
<NavLink key={n.href} href={n.href} label={n.label} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
|
||||||
|
<Switch>
|
||||||
|
<Route path="/" component={Dashboard} />
|
||||||
|
<Route path="/cases" component={Cases} />
|
||||||
|
<Route path="/rules" component={Rules} />
|
||||||
|
<Route path="/alerts" component={Alerts} />
|
||||||
|
<Route path="/sanctions" component={Sanctions} />
|
||||||
|
<Route>
|
||||||
|
<div style={{ color: '#737373' }}>Not found</div>
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
// api.ts -- typed fetch wrappers for /v1/aml/* endpoints.
|
||||||
|
// All calls use relative URLs so they work on any host.
|
||||||
|
|
||||||
|
export interface Case {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
number: number
|
||||||
|
status: string
|
||||||
|
severity: string
|
||||||
|
entity_ids?: string[]
|
||||||
|
alert_ids?: string[]
|
||||||
|
assignee_id?: string
|
||||||
|
opened_at: string
|
||||||
|
closed_at?: string
|
||||||
|
resolution?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rule {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
dsl: string
|
||||||
|
severity: string
|
||||||
|
weight: number
|
||||||
|
action: string
|
||||||
|
enabled: boolean
|
||||||
|
jurisdiction_filter?: string[]
|
||||||
|
asset_class_filter?: string[]
|
||||||
|
priority: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
tx_id: string
|
||||||
|
rule_id: string
|
||||||
|
rule_name: string
|
||||||
|
severity: string
|
||||||
|
score: number
|
||||||
|
score_breakdown?: Record<string, number>
|
||||||
|
action_taken: string
|
||||||
|
reviewed_by?: string
|
||||||
|
reviewed_at?: string
|
||||||
|
decision?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SanctionsResult {
|
||||||
|
id: string
|
||||||
|
list_id: string
|
||||||
|
name: string
|
||||||
|
aliases?: string[]
|
||||||
|
dob?: string
|
||||||
|
nationality?: string
|
||||||
|
type: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleTestResult {
|
||||||
|
match: boolean
|
||||||
|
dsl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new Error(`${res.status}: ${body}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCases(status?: string): Promise<Case[]> {
|
||||||
|
const q = status && status !== 'all' ? `?status=${status}` : ''
|
||||||
|
return json(`/v1/aml/cases${q}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRules(): Promise<Rule[]> {
|
||||||
|
return json('/v1/aml/rules')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testRule(dsl: string): Promise<RuleTestResult> {
|
||||||
|
return json('/v1/aml/rules/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
dsl,
|
||||||
|
tx: { id: 'test', notional: 15000, currency: 'USD', qty: 1 },
|
||||||
|
entity: { id: 'test', name: 'Test User', entity_type: 'user' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAlerts(txId: string): Promise<Alert[]> {
|
||||||
|
return json(`/v1/aml/transactions/${txId}/alerts`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSanctions(name: string, dob?: string): Promise<SanctionsResult[]> {
|
||||||
|
return json('/v1/aml/sanctions/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, dob }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function health(): Promise<{ status: string; time: string }> {
|
||||||
|
return json('/v1/aml/health')
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import React from 'react'
|
||||||
|
import { App } from './App'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (!root) throw new Error('root element missing')
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listCases, listAlerts, type Case, type Alert } from '../api'
|
||||||
|
|
||||||
|
export function Alerts() {
|
||||||
|
const [cases, setCases] = useState<Case[]>([])
|
||||||
|
const [alerts, setAlerts] = useState<Alert[]>([])
|
||||||
|
const [sevFilter, setSevFilter] = useState<string>('all')
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
// Derive alerts from cases' alert_ids. The API returns alerts per-transaction,
|
||||||
|
// so we collect unique tx_ids from case alert references and fetch each.
|
||||||
|
useEffect(() => {
|
||||||
|
listCases()
|
||||||
|
.then(async (cs) => {
|
||||||
|
setCases(cs)
|
||||||
|
// Collect all unique alert fetches -- alerts are keyed by tx,
|
||||||
|
// but we only have case.alert_ids. For the embedded UI, we show
|
||||||
|
// case-level alert metadata as placeholder rows when the
|
||||||
|
// per-transaction fetch is not available.
|
||||||
|
const allAlerts: Alert[] = []
|
||||||
|
// Derive minimal alert rows from case data.
|
||||||
|
for (const c of cs) {
|
||||||
|
for (const aid of c.alert_ids ?? []) {
|
||||||
|
allAlerts.push({
|
||||||
|
id: aid,
|
||||||
|
org_id: c.org_id,
|
||||||
|
tx_id: '',
|
||||||
|
rule_id: '',
|
||||||
|
rule_name: '',
|
||||||
|
severity: c.severity,
|
||||||
|
score: 0,
|
||||||
|
action_taken: '',
|
||||||
|
created_at: c.opened_at,
|
||||||
|
updated_at: c.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAlerts(allAlerts)
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
sevFilter === 'all' ? alerts : alerts.filter((a) => a.severity === sevFilter)
|
||||||
|
|
||||||
|
const severities = ['all', 'low', 'medium', 'high', 'critical']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Alerts</h1>
|
||||||
|
|
||||||
|
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{severities.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setSevFilter(s)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid #262626',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
background: sevFilter === s ? '#262626' : 'transparent',
|
||||||
|
color: sevFilter === s ? '#fff' : '#737373',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||||
|
<th style={th}>Time</th>
|
||||||
|
<th style={th}>Alert ID</th>
|
||||||
|
<th style={th}>Severity</th>
|
||||||
|
<th style={th}>Score</th>
|
||||||
|
<th style={th}>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((a) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
|
||||||
|
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td style={{ ...td, color: '#737373' }}>
|
||||||
|
{new Date(a.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{a.id.slice(0, 8)}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<SeverityBadge severity={a.severity} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>{a.score.toFixed(2)}</td>
|
||||||
|
<td style={td}>{a.action_taken || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
{expanded === a.id && (
|
||||||
|
<tr key={`${a.id}-detail`}>
|
||||||
|
<td colSpan={5} style={{ padding: 12, background: '#0a0a0a' }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||||
|
<div>Full ID: {a.id}</div>
|
||||||
|
<div>Transaction: {a.tx_id || '-'}</div>
|
||||||
|
<div>Rule: {a.rule_name || a.rule_id || '-'}</div>
|
||||||
|
<div>Decision: {a.decision || '-'}</div>
|
||||||
|
{a.score_breakdown && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ marginBottom: 4, color: '#737373' }}>Score Breakdown:</div>
|
||||||
|
{Object.entries(a.score_breakdown).map(([k, v]) => (
|
||||||
|
<div key={k} style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<span style={{ color: '#525252' }}>{k}:</span>
|
||||||
|
<span>{v.toFixed(3)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||||
|
No alerts
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||||
|
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
low: '#3b82f6',
|
||||||
|
medium: '#f59e0b',
|
||||||
|
high: '#f97316',
|
||||||
|
critical: '#ef4444',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: `${colors[severity] ?? '#262626'}20`,
|
||||||
|
color: colors[severity] ?? '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listCases, type Case } from '../api'
|
||||||
|
|
||||||
|
const statuses = ['all', 'open', 'in_review', 'escalated', 'closed'] as const
|
||||||
|
|
||||||
|
export function Cases() {
|
||||||
|
const [cases, setCases] = useState<Case[]>([])
|
||||||
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listCases(filter)
|
||||||
|
.then(setCases)
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Cases</h1>
|
||||||
|
|
||||||
|
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilter(s)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
background: filter === s ? '#262626' : 'transparent',
|
||||||
|
color: filter === s ? '#fff' : '#737373',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||||
|
<th style={th}>#</th>
|
||||||
|
<th style={th}>Status</th>
|
||||||
|
<th style={th}>Severity</th>
|
||||||
|
<th style={th}>Assignee</th>
|
||||||
|
<th style={th}>Opened</th>
|
||||||
|
<th style={th}>Resolution</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
|
||||||
|
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td style={td}>{c.number}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<StatusBadge status={c.status} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<SeverityBadge severity={c.severity} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>{c.assignee_id || '-'}</td>
|
||||||
|
<td style={{ ...td, color: '#737373' }}>
|
||||||
|
{new Date(c.opened_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td style={td}>{c.resolution || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
{expanded === c.id && (
|
||||||
|
<tr key={`${c.id}-detail`}>
|
||||||
|
<td colSpan={6} style={{ padding: '12px', background: '#0a0a0a' }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||||
|
<div>Case ID: {c.id}</div>
|
||||||
|
<div>Alerts: {c.alert_ids?.join(', ') || 'none'}</div>
|
||||||
|
<div>Entities: {c.entity_ids?.join(', ') || 'none'}</div>
|
||||||
|
{c.closed_at && <div>Closed: {new Date(c.closed_at).toLocaleString()}</div>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{cases.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||||
|
No cases
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||||
|
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
open: '#3b82f6',
|
||||||
|
in_review: '#f59e0b',
|
||||||
|
escalated: '#f97316',
|
||||||
|
closed: '#525252',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: `${colors[status] ?? '#262626'}20`,
|
||||||
|
color: colors[status] ?? '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
low: '#3b82f6',
|
||||||
|
medium: '#f59e0b',
|
||||||
|
high: '#f97316',
|
||||||
|
critical: '#ef4444',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: `${colors[severity] ?? '#262626'}20`,
|
||||||
|
color: colors[severity] ?? '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listCases, health, type Case } from '../api'
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#0a0a0a',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
minWidth: 160,
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#737373',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStyle: React.CSSProperties = {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
marginTop: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [cases, setCases] = useState<Case[]>([])
|
||||||
|
const [status, setStatus] = useState<string>('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listCases()
|
||||||
|
.then(setCases)
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
health()
|
||||||
|
.then((h) => setStatus(h.status))
|
||||||
|
.catch(() => setStatus('unreachable'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const open = cases.filter((c) => c.status === 'open').length
|
||||||
|
const inReview = cases.filter((c) => c.status === 'in_review').length
|
||||||
|
const escalated = cases.filter((c) => c.status === 'escalated').length
|
||||||
|
const closed = cases.filter((c) => c.status === 'closed').length
|
||||||
|
const recent = cases.slice(0, 5)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Dashboard</h1>
|
||||||
|
<span style={{ fontSize: 12, color: status === 'ok' ? '#22c55e' : '#ef4444' }}>
|
||||||
|
Engine: {status || 'loading'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={labelStyle}>Open Cases</div>
|
||||||
|
<div style={valueStyle}>{open}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={labelStyle}>In Review</div>
|
||||||
|
<div style={valueStyle}>{inReview}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={labelStyle}>Escalated</div>
|
||||||
|
<div style={valueStyle}>{escalated}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={labelStyle}>Closed</div>
|
||||||
|
<div style={valueStyle}>{closed}</div>
|
||||||
|
</div>
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div style={labelStyle}>Total Cases</div>
|
||||||
|
<div style={valueStyle}>{cases.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div style={labelStyle}>Recent Cases</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8, fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '8px 12px' }}>#</th>
|
||||||
|
<th style={{ padding: '8px 12px' }}>Status</th>
|
||||||
|
<th style={{ padding: '8px 12px' }}>Severity</th>
|
||||||
|
<th style={{ padding: '8px 12px' }}>Opened</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.map((c) => (
|
||||||
|
<tr key={c.id} style={{ borderBottom: '1px solid #171717' }}>
|
||||||
|
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
|
||||||
|
{c.number}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 12px' }}>
|
||||||
|
<Badge text={c.status} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 12px' }}>
|
||||||
|
<SeverityBadge severity={c.severity} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 12px', color: '#737373' }}>
|
||||||
|
{new Date(c.opened_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{recent.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||||
|
No cases yet
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: '#262626',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
low: '#3b82f6',
|
||||||
|
medium: '#f59e0b',
|
||||||
|
high: '#f97316',
|
||||||
|
critical: '#ef4444',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: `${colors[severity] ?? '#525252'}20`,
|
||||||
|
color: colors[severity] ?? '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listRules, testRule, type Rule, type RuleTestResult } from '../api'
|
||||||
|
|
||||||
|
export function Rules() {
|
||||||
|
const [rules, setRules] = useState<Rule[]>([])
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [editId, setEditId] = useState<string | null>(null)
|
||||||
|
const [testDsl, setTestDsl] = useState('')
|
||||||
|
const [testResult, setTestResult] = useState<RuleTestResult | null>(null)
|
||||||
|
const [testErr, setTestErr] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRules()
|
||||||
|
.then(setRules)
|
||||||
|
.catch((e) => setErr(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestErr('')
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const r = await testRule(testDsl)
|
||||||
|
setTestResult(r)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setTestErr(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Rules</h1>
|
||||||
|
|
||||||
|
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||||
|
<th style={th}>Name</th>
|
||||||
|
<th style={th}>DSL</th>
|
||||||
|
<th style={th}>Severity</th>
|
||||||
|
<th style={th}>Action</th>
|
||||||
|
<th style={th}>Enabled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map((r) => (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => {
|
||||||
|
setEditId(editId === r.id ? null : r.id)
|
||||||
|
setTestDsl(r.dsl)
|
||||||
|
setTestResult(null)
|
||||||
|
setTestErr('')
|
||||||
|
}}
|
||||||
|
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td style={td}>{r.name}</td>
|
||||||
|
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{r.dsl}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<SeverityBadge severity={r.severity} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>{r.action}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span style={{ color: r.enabled ? '#22c55e' : '#525252' }}>
|
||||||
|
{r.enabled ? 'on' : 'off'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{editId === r.id && (
|
||||||
|
<tr key={`${r.id}-detail`}>
|
||||||
|
<td colSpan={5} style={{ padding: 16, background: '#0a0a0a' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
|
||||||
|
<div>ID: {r.id}</div>
|
||||||
|
<div>Description: {r.description || '-'}</div>
|
||||||
|
<div>Weight: {r.weight}</div>
|
||||||
|
<div>Priority: {r.priority}</div>
|
||||||
|
{r.jurisdiction_filter?.length ? (
|
||||||
|
<div>Jurisdictions: {r.jurisdiction_filter.join(', ')}</div>
|
||||||
|
) : null}
|
||||||
|
{r.asset_class_filter?.length ? (
|
||||||
|
<div>Asset classes: {r.asset_class_filter.join(', ')}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, color: '#737373', display: 'block', marginBottom: 4 }}>
|
||||||
|
Test DSL
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={testDsl}
|
||||||
|
onChange={(e) => setTestDsl(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 60,
|
||||||
|
background: '#171717',
|
||||||
|
border: '1px solid #262626',
|
||||||
|
borderRadius: 6,
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
padding: 8,
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<button onClick={handleTest} style={btnStyle}>
|
||||||
|
Test Rule
|
||||||
|
</button>
|
||||||
|
{testResult && (
|
||||||
|
<span style={{ fontSize: 13, color: testResult.match ? '#22c55e' : '#737373' }}>
|
||||||
|
{testResult.match ? 'Matched' : 'No match'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{testErr && (
|
||||||
|
<span style={{ fontSize: 13, color: '#ef4444' }}>{testErr}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
{rules.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||||
|
No rules loaded
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||||
|
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
padding: '6px 16px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: '#262626',
|
||||||
|
color: '#fff',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityBadge({ severity }: { severity: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
low: '#3b82f6',
|
||||||
|
medium: '#f59e0b',
|
||||||
|
high: '#f97316',
|
||||||
|
critical: '#ef4444',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
background: `${colors[severity] ?? '#262626'}20`,
|
||||||
|
color: colors[severity] ?? '#a3a3a3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{severity}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { searchSanctions, type SanctionsResult } from '../api'
|
||||||
|
|
||||||
|
export function Sanctions() {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [dob, setDob] = useState('')
|
||||||
|
const [results, setResults] = useState<SanctionsResult[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) return
|
||||||
|
setErr('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await searchSanctions(name.trim(), dob.trim() || undefined)
|
||||||
|
setResults(r)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Sanctions Search</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#737373' }}>Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<label style={{ fontSize: 12, color: '#737373' }}>Date of Birth</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dob}
|
||||||
|
onChange={(e) => setDob(e.target.value)}
|
||||||
|
placeholder="YYYY-MM-DD (optional)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading || !name.trim()} style={btnStyle}>
|
||||||
|
{loading ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
|
||||||
|
|
||||||
|
{results !== null && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
|
||||||
|
<th style={th}>Name</th>
|
||||||
|
<th style={th}>Aliases</th>
|
||||||
|
<th style={th}>Source</th>
|
||||||
|
<th style={th}>Nationality</th>
|
||||||
|
<th style={th}>Type</th>
|
||||||
|
<th style={th}>Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<tr key={`${r.id}-${i}`} style={{ borderBottom: '1px solid #171717' }}>
|
||||||
|
<td style={td}>{r.name}</td>
|
||||||
|
<td style={{ ...td, fontSize: 12, color: '#a3a3a3' }}>
|
||||||
|
{r.aliases?.join(', ') || '-'}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
background: '#262626',
|
||||||
|
color: '#d4d4d4',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.list_id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={td}>{r.nationality || '-'}</td>
|
||||||
|
<td style={td}>{r.type}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<span style={{ color: r.score >= 0.9 ? '#ef4444' : r.score >= 0.7 ? '#f59e0b' : '#a3a3a3' }}>
|
||||||
|
{r.score.toFixed(3)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{results.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
|
||||||
|
No matches found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
|
||||||
|
const td: React.CSSProperties = { padding: '8px 12px' }
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #262626',
|
||||||
|
background: '#0a0a0a',
|
||||||
|
color: '#e5e5e5',
|
||||||
|
fontSize: 14,
|
||||||
|
outline: 'none',
|
||||||
|
minWidth: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: '#262626',
|
||||||
|
color: '#fff',
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/_/aml/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: { manualChunks: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/v1/aml': 'http://localhost:8090',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user