Files
worldmonitor/scripts/seed-sanctions-pressure.mjs
Elie Habib babb9b6836 feat(sanctions): add OFAC sanctions pressure intelligence (#1739)
* feat(sanctions): add OFAC sanctions pressure intelligence

* fix(sanctions): strip _state from API response, fix code/name alignment, cap seed limit

- trimResponse now destructures _state before spreading to prevent seed
  internals leaking to API clients during the atomicPublish→afterPublish window
- buildLocationMap and extractPartyCountries now sort (code, name) as aligned
  pairs instead of calling uniqueSorted independently on each array; fixes
  code↔name mispairing for OFAC-specific codes like XC (Crimea) where
  alphabetic order of codes and names diverges
- DEFAULT_RECENT_LIMIT reduced from 120 to 60 to match MAX_ITEMS_LIMIT so
  seeded entries beyond the handler cap are not written unnecessarily
- Add tests/sanctions-pressure.test.mjs covering all three invariants

* fix(sanctions): register sanctions:pressure:v1 in health.js BOOTSTRAP_KEYS and SEED_META

Adds sanctionsPressure to health.js so the health endpoint monitors the
seeded key for emptiness (CRIT) and freshness via seed-meta:sanctions:pressure
(maxStaleMin: 720 matches 12h seed TTL). Without this, health was blind to
stale or missing sanctions data.
2026-03-17 11:52:32 +04:00

378 lines
13 KiB
JavaScript

#!/usr/bin/env node
import { XMLParser } from 'fast-xml-parser';
import { CHROME_UA, loadEnvFile, runSeed, verifySeedKey } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'sanctions:pressure:v1';
const STATE_KEY = 'sanctions:pressure:state:v1';
const CACHE_TTL = 12 * 60 * 60;
const DEFAULT_RECENT_LIMIT = 60;
const OFAC_TIMEOUT_MS = 45_000;
const PROGRAM_CODE_RE = /^[A-Z0-9][A-Z0-9-]{1,24}$/;
const OFAC_SOURCES = [
{ label: 'SDN', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/sdn_advanced.xml' },
{ label: 'CONSOLIDATED', url: 'https://sanctionslistservice.ofac.treas.gov/api/PublicationPreview/exports/cons_advanced.xml' },
];
const XML_PARSER = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
removeNSPrefix: true,
parseTagValue: false,
trimValues: true,
});
function listify(value) {
if (Array.isArray(value)) return value;
return value == null ? [] : [value];
}
function textValue(value) {
if (value == null) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') {
if (typeof value['#text'] === 'string') return value['#text'].trim();
if (typeof value.NamePartValue === 'string') return value.NamePartValue.trim();
}
return '';
}
function buildEpoch(parts) {
const year = Number(parts?.Year || 0);
if (!year) return 0;
const month = Math.max(1, Number(parts?.Month || 1));
const day = Math.max(1, Number(parts?.Day || 1));
return Date.UTC(year, month - 1, day);
}
function uniqueSorted(values) {
return [...new Set(values.filter(Boolean).map((value) => String(value).trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
function compactNote(value) {
const note = String(value || '').replace(/\s+/g, ' ').trim();
if (!note) return '';
return note.length > 240 ? `${note.slice(0, 237)}...` : note;
}
function extractDocumentedName(documentedName) {
const parts = listify(documentedName?.DocumentedNamePart)
.map((part) => textValue(part?.NamePartValue))
.filter(Boolean);
if (parts.length > 0) return parts.join(' ');
return textValue(documentedName);
}
function normalizeDateOfIssue(value) {
const epoch = buildEpoch(value);
return Number.isFinite(epoch) ? epoch : 0;
}
function buildReferenceMaps(doc) {
const refs = doc?.ReferenceValueSets ?? {};
const areaCodes = new Map();
for (const area of listify(refs?.AreaCodeValues?.AreaCode)) {
areaCodes.set(String(area.ID || ''), {
code: textValue(area),
name: String(area.Description || '').trim(),
});
}
const featureTypes = new Map();
for (const feature of listify(refs?.FeatureTypeValues?.FeatureType)) {
featureTypes.set(String(feature.ID || ''), textValue(feature));
}
const legalBasis = new Map();
for (const basis of listify(refs?.LegalBasisValues?.LegalBasis)) {
legalBasis.set(String(basis.ID || ''), String(basis.LegalBasisShortRef || textValue(basis) || '').trim());
}
return { areaCodes, featureTypes, legalBasis };
}
function buildLocationMap(doc, areaCodes) {
const locations = new Map();
for (const location of listify(doc?.Locations?.Location)) {
const ids = listify(location?.LocationAreaCode).map((item) => String(item.AreaCodeID || ''));
const mapped = ids.map((id) => areaCodes.get(id)).filter(Boolean);
// Sort code/name as pairs so codes[i] always corresponds to names[i]
const pairs = [...new Map(mapped.map((item) => [item.code, item.name])).entries()]
.filter(([code]) => code.length > 0)
.sort(([a], [b]) => a.localeCompare(b));
locations.set(String(location.ID || ''), {
codes: pairs.map(([code]) => code),
names: pairs.map(([, name]) => name),
});
}
return locations;
}
function extractPartyName(profile) {
const identities = listify(profile?.Identity);
const aliases = identities.flatMap((identity) => listify(identity?.Alias));
const primaryAlias = aliases.find((alias) => alias?.Primary === 'true')
|| aliases.find((alias) => alias?.AliasTypeID === '1403')
|| aliases[0];
return extractDocumentedName(primaryAlias?.DocumentedName);
}
function resolveEntityType(profile, featureTypes) {
const subtype = String(profile?.PartySubTypeID || '');
if (subtype === '1') return 'SANCTIONS_ENTITY_TYPE_VESSEL';
if (subtype === '2') return 'SANCTIONS_ENTITY_TYPE_AIRCRAFT';
const featureNames = listify(profile?.Feature)
.map((feature) => featureTypes.get(String(feature?.FeatureTypeID || '')) || '')
.filter(Boolean);
if (featureNames.some((name) => /birth|citizenship|nationality/i.test(name))) {
return 'SANCTIONS_ENTITY_TYPE_INDIVIDUAL';
}
return 'SANCTIONS_ENTITY_TYPE_ENTITY';
}
function extractPartyCountries(profile, featureTypes, locations) {
// Use a Map to deduplicate by code while preserving code→name alignment
const seen = new Map();
for (const feature of listify(profile?.Feature)) {
const featureType = featureTypes.get(String(feature?.FeatureTypeID || '')) || '';
if (!/location/i.test(featureType)) continue;
const versions = listify(feature?.FeatureVersion);
for (const version of versions) {
const locationIds = listify(version?.VersionLocation).map((item) => String(item?.LocationID || ''));
for (const locationId of locationIds) {
const location = locations.get(locationId);
if (!location) continue;
location.codes.forEach((code, i) => {
if (code && !seen.has(code)) seen.set(code, location.names[i] ?? '');
});
}
}
}
const sorted = [...seen.entries()].sort(([a], [b]) => a.localeCompare(b));
return {
countryCodes: sorted.map(([c]) => c),
countryNames: sorted.map(([, n]) => n),
};
}
function buildPartyMap(doc, featureTypes, locations) {
const parties = new Map();
for (const distinctParty of listify(doc?.DistinctParties?.DistinctParty)) {
const profile = distinctParty?.Profile;
const profileId = String(profile?.ID || distinctParty?.FixedRef || '');
if (!profileId) continue;
parties.set(profileId, {
name: extractPartyName(profile),
entityType: resolveEntityType(profile, featureTypes),
...extractPartyCountries(profile, featureTypes, locations),
});
}
return parties;
}
function extractPrograms(entry) {
const directPrograms = listify(entry?.SanctionsMeasure)
.map((measure) => textValue(measure?.Comment))
.filter((value) => PROGRAM_CODE_RE.test(value));
return uniqueSorted(directPrograms);
}
function extractEffectiveAt(entry) {
const dates = [];
for (const event of listify(entry?.EntryEvent)) {
const epoch = buildEpoch(event?.Date);
if (epoch > 0) dates.push(epoch);
}
for (const measure of listify(entry?.SanctionsMeasure)) {
const epoch = buildEpoch(measure?.DatePeriod?.Start?.From || measure?.DatePeriod?.Start);
if (epoch > 0) dates.push(epoch);
}
return dates.length > 0 ? Math.max(...dates) : 0;
}
function extractNote(entry, legalBasis) {
const comments = listify(entry?.SanctionsMeasure)
.map((measure) => textValue(measure?.Comment))
.filter((value) => value && !PROGRAM_CODE_RE.test(value));
if (comments.length > 0) return compactNote(comments[0]);
const legal = listify(entry?.EntryEvent)
.map((event) => legalBasis.get(String(event?.LegalBasisID || '')) || '')
.filter(Boolean);
return compactNote(legal[0] || '');
}
function buildEntriesForDocument(doc, sourceLabel) {
const { areaCodes, featureTypes, legalBasis } = buildReferenceMaps(doc);
const locations = buildLocationMap(doc, areaCodes);
const parties = buildPartyMap(doc, featureTypes, locations);
const datasetDate = normalizeDateOfIssue(doc?.DateOfIssue);
const entries = [];
for (const entry of listify(doc?.SanctionsEntries?.SanctionsEntry)) {
const profileId = String(entry?.ProfileID || '');
const party = parties.get(profileId);
const name = party?.name || 'Unnamed designation';
const programs = extractPrograms(entry);
entries.push({
id: `${sourceLabel}:${String(entry?.ID || profileId || name)}`,
name,
entityType: party?.entityType || 'SANCTIONS_ENTITY_TYPE_ENTITY',
countryCodes: party?.countryCodes ?? [],
countryNames: party?.countryNames ?? [],
programs: programs.length > 0 ? programs : [sourceLabel],
sourceLists: [sourceLabel],
effectiveAt: String(extractEffectiveAt(entry)),
isNew: false,
note: extractNote(entry, legalBasis),
});
}
return { entries, datasetDate };
}
function sortEntries(a, b) {
return (Number(b.isNew) - Number(a.isNew))
|| (Number(b.effectiveAt) - Number(a.effectiveAt))
|| a.name.localeCompare(b.name);
}
function buildCountryPressure(entries) {
const map = new Map();
for (const entry of entries) {
const codes = entry.countryCodes.length > 0 ? entry.countryCodes : ['XX'];
const names = entry.countryNames.length > 0 ? entry.countryNames : ['Unknown'];
codes.forEach((code, index) => {
const key = `${code}:${names[index] || names[0] || 'Unknown'}`;
const current = map.get(key) || {
countryCode: code,
countryName: names[index] || names[0] || 'Unknown',
entryCount: 0,
newEntryCount: 0,
vesselCount: 0,
aircraftCount: 0,
};
current.entryCount += 1;
if (entry.isNew) current.newEntryCount += 1;
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL') current.vesselCount += 1;
if (entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT') current.aircraftCount += 1;
map.set(key, current);
});
}
return [...map.values()]
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.countryName.localeCompare(b.countryName))
.slice(0, 12);
}
function buildProgramPressure(entries) {
const map = new Map();
for (const entry of entries) {
const programs = entry.programs.length > 0 ? entry.programs : ['UNSPECIFIED'];
for (const program of programs) {
const current = map.get(program) || { program, entryCount: 0, newEntryCount: 0 };
current.entryCount += 1;
if (entry.isNew) current.newEntryCount += 1;
map.set(program, current);
}
}
return [...map.values()]
.sort((a, b) => b.newEntryCount - a.newEntryCount || b.entryCount - a.entryCount || a.program.localeCompare(b.program))
.slice(0, 12);
}
async function fetchSource(source) {
const response = await fetch(source.url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(OFAC_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`OFAC ${source.label} HTTP ${response.status}`);
}
const xml = await response.text();
const parsed = XML_PARSER.parse(xml)?.Sanctions;
if (!parsed) throw new Error(`OFAC ${source.label} parse returned no Sanctions root`);
return buildEntriesForDocument(parsed, source.label);
}
async function fetchSanctionsPressure() {
const previousState = await verifySeedKey(STATE_KEY).catch(() => null);
const previousIds = new Set(Array.isArray(previousState?.entryIds) ? previousState.entryIds.map((id) => String(id)) : []);
const hasPrevious = previousIds.size > 0;
const results = await Promise.all(OFAC_SOURCES.map((source) => fetchSource(source)));
const entries = results.flatMap((result) => result.entries);
const datasetDate = results.reduce((max, result) => Math.max(max, result.datasetDate || 0), 0);
if (hasPrevious) {
for (const entry of entries) {
entry.isNew = !previousIds.has(entry.id);
}
}
const sortedEntries = [...entries].sort(sortEntries);
const totalCount = entries.length;
const newEntryCount = hasPrevious ? entries.filter((entry) => entry.isNew).length : 0;
const vesselCount = entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_VESSEL').length;
const aircraftCount = entries.filter((entry) => entry.entityType === 'SANCTIONS_ENTITY_TYPE_AIRCRAFT').length;
return {
fetchedAt: String(Date.now()),
datasetDate: String(datasetDate),
totalCount,
sdnCount: results[0]?.entries.length ?? 0,
consolidatedCount: results[1]?.entries.length ?? 0,
newEntryCount,
vesselCount,
aircraftCount,
countries: buildCountryPressure(entries),
programs: buildProgramPressure(entries),
entries: sortedEntries.slice(0, DEFAULT_RECENT_LIMIT),
_state: {
entryIds: entries.map((entry) => entry.id),
},
};
}
function validate(data) {
return (data?.totalCount ?? 0) > 0;
}
runSeed('sanctions', 'pressure', CANONICAL_KEY, fetchSanctionsPressure, {
ttlSeconds: CACHE_TTL,
validateFn: validate,
sourceVersion: 'ofac-sls-advanced-xml-v1',
recordCount: (data) => data.totalCount ?? 0,
extraKeys: [
{
key: STATE_KEY,
ttl: CACHE_TTL,
transform: (data) => data._state,
},
],
afterPublish: async (data, _ctx) => {
delete data._state;
},
});