Skip to content

Chapter 21: Financial Crime and AML Investigations

Learning Objectives

By the end of this chapter, you will be able to: - Apply OSINT methodology to anti-money laundering (AML) and financial crime investigations - Navigate the public records and data sources specific to financial intelligence - Trace beneficial ownership through corporate structures using open sources - Analyze cryptocurrency transactions using public blockchain data - Conduct sanctions screening and politically exposed person (PEP) research - Understand the regulatory context that mandates OSINT in financial compliance


21.1 OSINT in Financial Crime Investigation

Financial crime investigation — encompassing money laundering, fraud, sanctions evasion, and corruption — is one of the most data-intensive OSINT applications. Financial criminals actively construct complexity: shell companies, nominee directors, layered transactions, and opaque ownership structures are the tools of the trade.

OSINT penetrates this complexity by combining: - Corporate registry data to map entity structures - Property records to trace asset flows - Beneficial ownership registers (where available) - Cryptocurrency blockchain analysis - Sanctions and PEP lists - Court records, regulatory actions, and news coverage - AIS and ADS-B data for sanctions evasion tracking

Regulatory mandates drive institutional OSINT demand. AML regulations — the Bank Secrecy Act in the U.S., the EU's Anti-Money Laundering Directives, FATF recommendations globally — require financial institutions to understand their customers and their customers' customers. This creates massive demand for scalable OSINT-based due diligence.


21.2 Regulatory Framework

Key AML/Financial Crime Regulations

Bank Secrecy Act (BSA) / FinCEN regulations: U.S. financial institutions must file Suspicious Activity Reports (SARs), Currency Transaction Reports (CTRs), and maintain Customer Due Diligence (CDD) records including beneficial ownership information.

EU Anti-Money Laundering Directives (AMLD): Progressive strengthening of AML requirements across EU member states, including beneficial ownership registers.

FATF Recommendations: The Financial Action Task Force provides the global standard for AML/CFT (counter-financing of terrorism) frameworks.

OFAC Sanctions: The Office of Foreign Assets Control administers U.S. economic sanctions programs. Sanctions screening is a compliance requirement for most financial institutions and many corporates.

Beneficial Ownership Requirements

The Corporate Transparency Act (U.S., enacted 2021, with reporting requirements phased in from 2024) requires most U.S. corporations to report beneficial owners — individuals who own 25%+ or who exercise substantial control — to FinCEN. Implementation has faced ongoing legal challenges; verify current enforcement status before relying on BOI data availability.

European AML directives have progressively required public beneficial ownership registers. EU5AMLD made these public; some member states have implemented fully searchable databases.


21.3 Corporate Beneficial Ownership Analysis

Mapping Corporate Structures

import requests
import json
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, field
import networkx as nx

@dataclass
class CorporateEntity:
    """Corporate entity for ownership analysis"""
    name: str
    entity_type: str  # 'corporation', 'LLC', 'trust', 'individual', 'foundation'
    jurisdiction: str
    registration_number: Optional[str] = None
    registered_address: Optional[str] = None
    incorporation_date: Optional[str] = None
    status: str = 'unknown'
    officers: List[Dict] = field(default_factory=list)
    registered_agent: Optional[str] = None
    risk_flags: List[str] = field(default_factory=list)
    source: str = ""

class BeneficialOwnershipMapper:
    """Map beneficial ownership through corporate structures"""

    def __init__(self):
        self.entities = {}
        self.ownership_graph = nx.DiGraph()
        self.visited = set()

        # High-risk jurisdictions for shell companies
        self.high_risk_jurisdictions = {
            'British Virgin Islands', 'BVI', 'Cayman Islands', 'Panama',
            'Seychelles', 'Marshall Islands', 'Belize', 'Anguilla',
            'Cyprus', 'Malta', 'Vanuatu', 'Isle of Man', 'Jersey', 'Guernsey',
            'Liechtenstein', 'San Marino', 'Andorra'
        }

    def add_entity(self, entity: CorporateEntity, owned_by: str = None,
                   ownership_percentage: float = None):
        """Add an entity and optionally its owner"""
        entity_key = f"{entity.name}_{entity.jurisdiction}"
        self.entities[entity_key] = entity
        self.ownership_graph.add_node(entity_key, **{
            'name': entity.name,
            'type': entity.entity_type,
            'jurisdiction': entity.jurisdiction,
            'risk_flags': entity.risk_flags
        })

        if owned_by and owned_by in self.ownership_graph:
            self.ownership_graph.add_edge(
                owned_by, entity_key,
                weight=ownership_percentage or 0,
                relationship='owns'
            )

        # Flag high-risk jurisdictions
        if entity.jurisdiction in self.high_risk_jurisdictions:
            entity.risk_flags.append(f'HIGH_RISK_JURISDICTION: {entity.jurisdiction}')

        return entity_key

    def trace_ultimate_owners(self, entity_key: str, max_depth: int = 10) -> dict:
        """Trace the ownership chain to find ultimate beneficial owners"""
        result = {
            'starting_entity': entity_key,
            'ownership_chain': [],
            'ultimate_beneficial_owners': [],
            'shell_companies_detected': 0,
            'high_risk_jurisdictions': [],
            'total_depth': 0,
        }

        def traverse(current_entity, depth, path):
            if depth > max_depth or current_entity in self.visited:
                return
            self.visited.add(current_entity)

            entity_data = self.entities.get(current_entity, {})
            current_path = path + [current_entity]

            # Find who owns this entity
            predecessors = list(self.ownership_graph.predecessors(current_entity))

            if not predecessors:
                # No further owners — this may be an ultimate owner or a gap
                entity = self.entities.get(current_entity)
                if entity and entity.entity_type == 'individual':
                    result['ultimate_beneficial_owners'].append({
                        'entity': current_entity,
                        'chain_depth': depth,
                        'path': current_path,
                    })
                result['total_depth'] = max(result['total_depth'], depth)
                return

            for owner in predecessors:
                owner_entity = self.entities.get(owner)
                if owner_entity:
                    if owner_entity.jurisdiction in self.high_risk_jurisdictions:
                        result['high_risk_jurisdictions'].append(owner_entity.jurisdiction)

                    # Check for shell company characteristics
                    if self._is_shell_indicator(owner_entity):
                        result['shell_companies_detected'] += 1

                traverse(owner, depth + 1, current_path)

        traverse(entity_key, 0, [])
        self.visited.clear()
        return result

    def _is_shell_indicator(self, entity: CorporateEntity) -> bool:
        """Assess whether an entity has shell company characteristics"""
        indicators = [
            entity.jurisdiction in self.high_risk_jurisdictions,
            entity.entity_type in ('trust', 'foundation'),
            len(entity.officers) == 0,
            entity.registered_agent is not None and len(entity.officers) == 0,
            'nominee' in entity.name.lower(),
            entity.name.endswith('Holdings') or entity.name.endswith('Investments'),
        ]
        return sum(indicators) >= 2  # Two or more indicators suggest shell

    def search_opencorporates_officers(self, person_name: str) -> list:
        """Find all corporate positions held by a person across jurisdictions"""
        response = requests.get(
            'https://api.opencorporates.com/v0.4/officers/search',
            params={'q': person_name, 'format': 'json'}
        )

        positions = []
        if response.status_code == 200:
            data = response.json()
            officers = data.get('results', {}).get('officers', [])
            for officer_data in officers:
                officer = officer_data.get('officer', {})
                positions.append({
                    'person': officer.get('name', ''),
                    'position': officer.get('position', ''),
                    'company': officer.get('company', {}).get('name', ''),
                    'jurisdiction': officer.get('company', {}).get('jurisdiction_code', ''),
                    'start_date': officer.get('start_date', ''),
                    'end_date': officer.get('end_date', ''),
                    'is_current': not bool(officer.get('end_date')),
                })

        return positions

    def analyze_officer_network(self, person_name: str) -> dict:
        """Analyze the corporate network of a person through their officer positions"""
        positions = self.search_opencorporates_officers(person_name)

        companies = {}
        jurisdictions = {}
        co_officers = {}

        for position in positions:
            company = position['company']
            jurisdiction = position['jurisdiction']

            companies[company] = companies.get(company, 0) + 1
            jurisdictions[jurisdiction] = jurisdictions.get(jurisdiction, 0) + 1

        return {
            'person': person_name,
            'total_positions': len(positions),
            'current_positions': sum(1 for p in positions if p['is_current']),
            'companies': list(companies.keys())[:20],
            'jurisdictions': dict(sorted(jurisdictions.items(), key=lambda x: x[1], reverse=True)),
            'positions': positions[:20]
        }

21.4 Sanctions and PEP Screening

Sanctions compliance requires screening against multiple lists maintained by different regulatory bodies.

Key Sanctions Lists

U.S. OFAC SDN List: Specially Designated Nationals and Blocked Persons List. The primary U.S. sanctions list.

EU Sanctions: European Union consolidated list of sanctioned persons and entities.

UN Security Council Sanctions: Global sanctions imposed by the UN Security Council.

UK OFSI: HM Treasury Office of Financial Sanctions Implementation.

National sanctions lists: Many countries maintain their own sanctions lists.

import requests
from datetime import datetime

class SanctionsScreener:
    """Screen entities against major sanctions databases"""

    def __init__(self):
        self.ofac_data = None
        self.eu_data = None

    def load_ofac_sdn_list(self):
        """Load the OFAC SDN list (XML or JSON format)"""
        # OFAC provides the SDN list via their website
        # The XML version is downloadable
        ofac_url = "https://www.treasury.gov/ofac/downloads/sdn.xml"

        # In practice, use a commercial sanctions screening API
        # or download and parse the XML locally

        print("Loading OFAC SDN list...")
        print("Note: For production, use commercial APIs (Dow Jones, LexisNexis, Refinitiv)")
        print("SDN list available at: https://home.treasury.gov/policy-issues/financial-sanctions/sdn-list")
        return []

    def screen_entity(self, entity_name: str, entity_type: str = 'individual') -> dict:
        """
        Screen an entity name against sanctions lists
        In production, this would use commercial APIs with fuzzy matching
        """

        results = {
            'entity': entity_name,
            'entity_type': entity_type,
            'screened_at': datetime.now().isoformat(),
            'matches': [],
            'screening_lists': []
        }

        # Commercial sanctions screening APIs to use in production:
        # - Dow Jones Factiva/Risk & Compliance
        # - LexisNexis WorldCompliance
        # - Refinitiv World-Check
        # - Accuity Bankers Almanac
        # - ComplyAdvantage

        # Free/Open alternatives (lower quality):
        # OFAC XML download and local parsing
        # EU API: https://webgate.ec.europa.eu/fsd/fsf/public/files/xmlFullSanctionsList_1_1/content

        return results

    def get_ofac_sanctions_by_program(self, program: str) -> list:
        """Get all OFAC sanctioned entities in a specific sanctions program"""
        # OFAC organizes sanctions by program (e.g., IRAN, RUSSIA, NORTH_KOREA)
        # API: https://ofac-api.treasury.gov/documentation
        api_url = "https://ofac-api.treasury.gov/sanctions/v1/sdn"

        params = {
            'program': program,
            'type': 'Entity',
            'api_key': 'YOUR_OFAC_API_KEY'
        }

        try:
            response = requests.get(api_url, params=params, timeout=30)
            if response.status_code == 200:
                return response.json().get('sdnList', {}).get('sdnEntry', [])
        except Exception as e:
            print(f"OFAC API error: {e}")

        return []

class PEPScreener:
    """Screen individuals against Politically Exposed Person databases"""

    # OpenSanctions provides free PEP and sanctions data
    OPENSANCTIONS_API = "https://api.opensanctions.org/v3"

    def search_opensanctions(self, name: str, schema: str = 'Person') -> dict:
        """
        Search OpenSanctions database (free tier available)
        OpenSanctions aggregates PEP, sanctions, and crime data
        """
        try:
            response = requests.get(
                f"{self.OPENSANCTIONS_API}/search",
                params={
                    'q': name,
                    'schema': schema,
                    'limit': 20,
                },
                headers={'Authorization': 'ApiKey YOUR_OPENSANCTIONS_KEY'}
            )

            if response.status_code == 200:
                data = response.json()
                return {
                    'query': name,
                    'total': data.get('total', {}).get('value', 0),
                    'results': [
                        {
                            'id': entity.get('id'),
                            'caption': entity.get('caption'),
                            'schema': entity.get('schema'),
                            'datasets': entity.get('datasets', []),
                            'properties': entity.get('properties', {}),
                            'score': entity.get('score', 0),
                        }
                        for entity in data.get('results', [])
                    ]
                }
        except Exception as e:
            return {'error': str(e)}

        return {}

    def check_wikidata_for_pep(self, person_name: str) -> dict:
        """
        Use Wikidata SPARQL to check if a person holds political office
        Wikidata is a free, public knowledge graph
        """
        # Wikidata SPARQL query to find political positions
        query = f"""
        SELECT ?item ?itemLabel ?position ?positionLabel ?start ?end WHERE {{
          ?item rdfs:label "{person_name}"@en .
          ?item p:P39 ?positionStatement .
          ?positionStatement ps:P39 ?position .
          OPTIONAL {{ ?positionStatement pq:P580 ?start }}
          OPTIONAL {{ ?positionStatement pq:P582 ?end }}
          SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en" }}
        }}
        """

        try:
            response = requests.get(
                'https://query.wikidata.org/sparql',
                params={'query': query, 'format': 'json'},
                headers={'User-Agent': 'OSINT Research Bot/1.0'},
                timeout=30
            )

            if response.status_code == 200:
                data = response.json()
                results = data.get('results', {}).get('bindings', [])

                positions = []
                for result in results:
                    positions.append({
                        'position': result.get('positionLabel', {}).get('value', ''),
                        'start': result.get('start', {}).get('value', ''),
                        'end': result.get('end', {}).get('value', ''),
                        'is_current': not bool(result.get('end', {}).get('value'))
                    })

                return {
                    'person': person_name,
                    'is_pep': len(positions) > 0,
                    'positions': positions
                }
        except Exception as e:
            return {'error': str(e)}

        return {}

21.5 Cryptocurrency Blockchain Analysis

Cryptocurrency transactions are public by design on most blockchain networks, providing a permanent, auditable record of fund flows.

class BlockchainAnalyzer:
    """Analyze cryptocurrency transactions using public blockchain data"""

    def get_bitcoin_address_info(self, address: str) -> dict:
        """Get information about a Bitcoin address"""
        # Using blockchain.info API (free)
        try:
            response = requests.get(
                f"https://blockchain.info/rawaddr/{address}",
                timeout=30
            )

            if response.status_code == 200:
                data = response.json()
                return {
                    'address': address,
                    'total_received': data.get('total_received', 0) / 1e8,
                    'total_sent': data.get('total_sent', 0) / 1e8,
                    'final_balance': data.get('final_balance', 0) / 1e8,
                    'transaction_count': data.get('n_tx', 0),
                    'first_tx': data.get('txs', [{}])[-1].get('time', 0) if data.get('txs') else None,
                    'last_tx': data.get('txs', [{}])[0].get('time', 0) if data.get('txs') else None,
                }
        except Exception as e:
            return {'error': str(e)}
        return {}

    def get_ethereum_address_info(self, address: str, etherscan_api_key: str) -> dict:
        """Get information about an Ethereum address"""
        try:
            balance_response = requests.get(
                'https://api.etherscan.io/api',
                params={
                    'module': 'account',
                    'action': 'balance',
                    'address': address,
                    'tag': 'latest',
                    'apikey': etherscan_api_key
                }
            )

            tx_response = requests.get(
                'https://api.etherscan.io/api',
                params={
                    'module': 'account',
                    'action': 'txlist',
                    'address': address,
                    'sort': 'desc',
                    'apikey': etherscan_api_key
                }
            )

            balance_data = balance_response.json() if balance_response.status_code == 200 else {}
            tx_data = tx_response.json() if tx_response.status_code == 200 else {}

            return {
                'address': address,
                'balance_eth': int(balance_data.get('result', 0)) / 1e18,
                'transactions': len(tx_data.get('result', [])),
                'recent_transactions': [
                    {
                        'hash': tx.get('hash'),
                        'from': tx.get('from'),
                        'to': tx.get('to'),
                        'value_eth': int(tx.get('value', 0)) / 1e18,
                        'timestamp': tx.get('timeStamp'),
                    }
                    for tx in tx_data.get('result', [])[:10]
                ]
            }
        except Exception as e:
            return {'error': str(e)}

    def analyze_transaction_clusters(self, transactions: list) -> dict:
        """
        Basic clustering analysis for cryptocurrency transactions
        In production, use Chainalysis, Elliptic, or TRM Labs
        """
        # Count counterparties
        from collections import Counter
        senders = Counter()
        receivers = Counter()

        for tx in transactions:
            if tx.get('from'):
                senders[tx['from']] += 1
            if tx.get('to'):
                receivers[tx['to']] += 1

        # Identify significant counterparties
        significant = {
            'top_senders': senders.most_common(5),
            'top_receivers': receivers.most_common(5),
            'total_counterparties': len(set(list(senders.keys()) + list(receivers.keys())))
        }

        return significant

    def check_address_against_sanctions(self, crypto_address: str) -> dict:
        """
        Check a cryptocurrency address against OFAC crypto sanctions
        OFAC maintains a list of sanctioned cryptocurrency addresses
        """
        # OFAC publishes sanctioned crypto addresses at:
        # https://home.treasury.gov/policy-issues/financial-sanctions/recent-actions/20181128

        # Some platforms aggregate this:
        # Chainalysis (commercial), TRM Labs (commercial)
        # Free: search OFAC SDN list for virtual currency indicators

        return {
            'address': crypto_address,
            'note': 'Check OFAC SDN list for crypto addresses and commercial services like Chainalysis for comprehensive screening'
        }

21.6 Financial Crime Pattern Analysis with AI

def analyze_financial_crime_indicators(entity_data: dict, corporate_structure: dict, financial_data: dict) -> str:
    """Use AI to analyze financial crime risk indicators"""

    import anthropic
    client = anthropic.Anthropic()

    prompt = f"""You are a certified anti-money laundering specialist conducting a risk assessment.

ENTITY INFORMATION:
{json.dumps(entity_data, indent=2)}

CORPORATE STRUCTURE:
{json.dumps(corporate_structure, indent=2)}

FINANCIAL INDICATORS:
{json.dumps(financial_data, indent=2)}

Analyze the above information for financial crime risk indicators including:

1. MONEY LAUNDERING RISK INDICATORS
   - Placement, layering, and integration patterns
   - Unusual corporate structure complexity
   - High-risk jurisdictions in ownership chain
   - Shell company indicators
   - Nominee director patterns

2. SANCTIONS RISK
   - Any indicators of sanctions nexus
   - Geographic risk factors

3. PEP EXPOSURE
   - Any indicators of politically exposed person involvement or association

4. BENEFICIAL OWNERSHIP CONCERNS
   - Opacity of ultimate beneficial ownership
   - Inconsistency between stated structure and operational reality

5. OVERALL RISK RATING
   [HIGH/MEDIUM/LOW] with justification

IMPORTANT:
- Base assessment strictly on provided information
- Clearly note what information was not available to complete the assessment
- Distinguish between red flags requiring investigation vs. confirmed risk
- Note that this is an OSINT assessment and not a conclusive finding of wrongdoing"""

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        messages=[{"role": "user", "content": prompt}]
    )

    return response.content[0].text

Summary

Financial crime and AML investigation is among the most systematically structured OSINT applications, driven by regulatory requirements that mandate specific due diligence processes. The core activities — beneficial ownership mapping, sanctions screening, PEP identification, and cryptocurrency transaction analysis — each have specialized data sources and methodologies.

Corporate structures used for money laundering have recognizable patterns: multi-jurisdictional layering through high-risk jurisdictions, nominee directors, shell companies without apparent business purpose, and circular ownership structures. Network analysis of corporate registries is particularly effective at revealing these patterns.

Cryptocurrency blockchain analysis leverages the inherent transparency of public blockchains. While sophisticated actors use mixing services and privacy coins to obscure trails, most criminal cryptocurrency activity leaves exploitable traces on public blockchain data.

Commercial tools (Chainalysis, Elliptic, World-Check) provide superior efficiency for production AML investigation, but open-source methods using free public databases provide significant capability for analysts without commercial tool access.


Common Mistakes and Pitfalls

  • Jurisdiction ignorance: Missing high-risk jurisdictions that are common money laundering channels
  • Nominal owner vs. beneficial owner confusion: The registered owner of a corporate entity is often not the beneficial owner
  • Cryptocurrency address reuse assumption: Cryptocurrency addresses are one-use; multiple transactions to the same address may not be related to the same counterparty
  • Screening list staleness: Using outdated sanctions lists; lists are updated frequently and outdated screening creates compliance exposure
  • PEP scope too narrow: PEPs include family members and close associates, not just the official themselves
  • Blockchain analysis tool over-reliance: Without understanding blockchain analysis methodology, commercial tool outputs cannot be critically evaluated

Further Reading

  • FATF guidance documents on beneficial ownership and virtual assets
  • FinCEN guidance on cryptocurrency AML obligations
  • Basel AML Index — country risk rankings
  • ICIJ Panama Papers and Pandora Papers methodology
  • Chainalysis, TRM Labs, and Elliptic research publications on cryptocurrency crime