@cliwant/mcp-sam-gov 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/dist/ofac.js
ADDED
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cliwant/mcp-sam-gov/ofac — OFAC denied-party sanctions screening (keyless).
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (ADR-0034)
|
|
5
|
+
* --------------------------
|
|
6
|
+
* OFAC denied-party screening is legally REQUIRED on every federal
|
|
7
|
+
* transaction/award (31 CFR ch. V; strict-liability civil penalties). The
|
|
8
|
+
* server already wires SAM exclusions (`sam_check_exclusions`), but SAM
|
|
9
|
+
* exclusions do NOT cover OFAC/BIS sanctions — a different list with a different
|
|
10
|
+
* legal basis. This module closes that gap with ONE screening tool
|
|
11
|
+
* (`ofac_screen_entity`) over OFAC's keyless bulk-download lists:
|
|
12
|
+
* - SDN.CSV (primary Specially-Designated-Nationals names)
|
|
13
|
+
* - ALT.CSV (SDN AKA / alternate names, joined to SDN by ent_num)
|
|
14
|
+
* - CONS_PRIM.CSV (Consolidated non-SDN program primary names)
|
|
15
|
+
* - CONS_ALT.CSV (Consolidated AKA names)
|
|
16
|
+
*
|
|
17
|
+
* ★ THE load-bearing SAFETY invariant — NEVER FAKE A "CLEAR"
|
|
18
|
+
* ----------------------------------------------------------
|
|
19
|
+
* A FALSE "no match / clear" could authorize an ILLEGAL transaction. Therefore:
|
|
20
|
+
* - `result` is `"potential_matches" | "no_name_match"` — NEVER "clear"
|
|
21
|
+
* (screening ≠ legal clearance; mirrors integrity.ts's never-"clear" gate).
|
|
22
|
+
* - A `no_name_match` is reachable ONLY when every REQUIRED list loaded,
|
|
23
|
+
* parsed, and passed its plausibility floor AND zero name matches at ANY
|
|
24
|
+
* quality were found. ANY fetch failure / SSRF reject / parse drift /
|
|
25
|
+
* floor-fail / empty-final-host THROWS a classified error — it is NEVER
|
|
26
|
+
* rendered as `no_name_match`.
|
|
27
|
+
*
|
|
28
|
+
* Doctrine reuse (do NOT edit those modules):
|
|
29
|
+
* - integrity.ts — the normalized-name-match gate + never-"clear".
|
|
30
|
+
* - attachments.ts — the redirect-final-host SSRF revalidation + size guard.
|
|
31
|
+
* - gsa-csv.ts — the exported RFC-4180 `parseRecordFields` (the private
|
|
32
|
+
* record-assembler is REPLICATED here per M6; NO gsa-csv edit).
|
|
33
|
+
* - datasource.ts — `throughGate` self-throttle + `driftError`.
|
|
34
|
+
* - coerce.ts convention (`-0-`/""/"null" ⇒ null, never 0/"").
|
|
35
|
+
*
|
|
36
|
+
* SSRF (M4/M5): the legacy treasury.gov paths 2-hop-redirect
|
|
37
|
+
* (treasury.gov → sanctionslistservice.ofac.treas.gov → GovCloud S3). We follow
|
|
38
|
+
* the redirect (default cap) and fail-closed-allowlist ONLY the FINAL host — the
|
|
39
|
+
* region-pinned GovCloud bucket. A commercial-partition squat of the same bucket
|
|
40
|
+
* name (`…s3.amazonaws.com`) is REJECTED (no `us-gov` marker); an empty/hidden
|
|
41
|
+
* final host is UNCONDITIONALLY rejected (OFAC always redirects — no
|
|
42
|
+
* `!res.redirected` escape hatch). On any host reject we THROW `invalid_input`
|
|
43
|
+
* and NEVER read the body.
|
|
44
|
+
*/
|
|
45
|
+
import { parseRecordFields } from "./gsa-csv.js";
|
|
46
|
+
import { throughGate, driftError } from "./datasource.js";
|
|
47
|
+
import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
|
|
48
|
+
import { withMeta } from "./meta.js";
|
|
49
|
+
// ─── Endpoints (compile-time CONSTANTS — no caller value ever on the wire) ──
|
|
50
|
+
/** The 4 keyless OFAC bulk-download lists (legacy treasury.gov paths). */
|
|
51
|
+
export const OFAC_URLS = {
|
|
52
|
+
sdn: "https://www.treasury.gov/ofac/downloads/sdn.csv",
|
|
53
|
+
alt: "https://www.treasury.gov/ofac/downloads/alt.csv",
|
|
54
|
+
cons_prim: "https://www.treasury.gov/ofac/downloads/consolidated/cons_prim.csv",
|
|
55
|
+
cons_alt: "https://www.treasury.gov/ofac/downloads/consolidated/cons_alt.csv",
|
|
56
|
+
};
|
|
57
|
+
/** Browser-ish UA — OFAC serves the keyless bulk files to a normal client. */
|
|
58
|
+
const OFAC_UA = "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
|
|
59
|
+
/** Fetch timeout for a (multi-MB) list download. */
|
|
60
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
61
|
+
/** Self-throttle: space consecutive OFAC fetches (one shared budget, 4 files). */
|
|
62
|
+
const OFAC_GATE_MIN_INTERVAL_MS = 250;
|
|
63
|
+
/**
|
|
64
|
+
* Hard per-list read cap. SDN is ~5.6 MB today; this bounds memory and catches a
|
|
65
|
+
* drifted giant (the 125 MB SDN_ADVANCED.XML is off-path but the guard is cheap)
|
|
66
|
+
* while leaving SDN generous growth headroom. Applied to BOTH the declared
|
|
67
|
+
* content-length (pre-check) AND the streamed read (abort past this bound), not
|
|
68
|
+
* content-length alone (S3 §S3).
|
|
69
|
+
*/
|
|
70
|
+
const MAX_LIST_BYTES = 16 * 1024 * 1024;
|
|
71
|
+
/** Cache TTL — the lists update ~daily; a 6 h TTL serves a warm cache instantly. */
|
|
72
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
73
|
+
/**
|
|
74
|
+
* ★ M4 — the ONE allowed final host: the region-pinned OFAC GovCloud bucket.
|
|
75
|
+
* The bucket NAME (`wc2h-sls-prod-public-published`) is registrable in the
|
|
76
|
+
* COMMERCIAL AWS partition, so a bucket-prefix + `.amazonaws.com` check ALONE
|
|
77
|
+
* would admit a `…s3.amazonaws.com` squat serving an attacker CSV (that omits
|
|
78
|
+
* the target ⇒ false CLEAR). We therefore ALSO require the `.s3.us-gov-`
|
|
79
|
+
* GovCloud region marker.
|
|
80
|
+
*/
|
|
81
|
+
export const OFAC_S3_HOST = "wc2h-sls-prod-public-published.s3.us-gov-west-1.amazonaws.com";
|
|
82
|
+
/** Fail-closed final-host allowlist (M4). Empty host ⇒ false (M5, in caller). */
|
|
83
|
+
export function isOfacS3Host(host) {
|
|
84
|
+
if (!host)
|
|
85
|
+
return false;
|
|
86
|
+
if (host === OFAC_S3_HOST)
|
|
87
|
+
return true;
|
|
88
|
+
// At minimum: the pinned bucket prefix + the GovCloud region marker + the AWS
|
|
89
|
+
// suffix (admits region/dualstack GovCloud host-forms; REJECTS the commercial
|
|
90
|
+
// squat which lacks `.s3.us-gov-`).
|
|
91
|
+
return (host.startsWith("wc2h-sls-prod-public-published.") &&
|
|
92
|
+
host.includes(".s3.us-gov-") &&
|
|
93
|
+
host.endsWith(".amazonaws.com"));
|
|
94
|
+
}
|
|
95
|
+
/** Per-list plausibility floors (M1) — two orders of magnitude apart. */
|
|
96
|
+
export const FLOORS = {
|
|
97
|
+
sdn: 10_000, // live ~19,143
|
|
98
|
+
alt: 10_000, // live ~20,318
|
|
99
|
+
cons_prim: 150, // live ~442 — MUST NOT share SDN's floor
|
|
100
|
+
cons_alt: 300, // live ~1,072
|
|
101
|
+
};
|
|
102
|
+
/** Which schema each list uses. */
|
|
103
|
+
const LIST_KIND = {
|
|
104
|
+
sdn: "primary",
|
|
105
|
+
alt: "alt",
|
|
106
|
+
cons_prim: "primary",
|
|
107
|
+
cons_alt: "alt",
|
|
108
|
+
};
|
|
109
|
+
/** Human list label surfaced on each match. */
|
|
110
|
+
const LIST_LABEL = {
|
|
111
|
+
sdn: "SDN",
|
|
112
|
+
alt: "SDN",
|
|
113
|
+
cons_prim: "Consolidated",
|
|
114
|
+
cons_alt: "Consolidated",
|
|
115
|
+
};
|
|
116
|
+
/** Column counts: primary (SDN/CONS_PRIM) = 12; alt (ALT/CONS_ALT) = 5. */
|
|
117
|
+
const PRIMARY_COLS = 12;
|
|
118
|
+
const ALT_COLS = 5;
|
|
119
|
+
/** ent_num (column 0) is a non-negative integer by OFAC's spec — the shape anchor. */
|
|
120
|
+
const ENT_NUM_RE = /^\d+$/;
|
|
121
|
+
// ─── ★ H1 — the mandatory never-"clear" caveat (verbatim constant) ──────────
|
|
122
|
+
/**
|
|
123
|
+
* Rides in `_meta.notes` on EVERY response (potential_matches AND no_name_match).
|
|
124
|
+
* Kept verbatim so the fault suite can assert it. A screen is NOT a determination.
|
|
125
|
+
*/
|
|
126
|
+
export const OFAC_NOT_DETERMINATION_NOTE = "This screens OFAC's published SDN + Consolidated lists as-of the snapshot date below; it is NOT OFAC's official Sanctions List Search, NOT a compliance/blocking determination, and NOT legal advice. A potential_match requires human adjudication against OFAC's Sanctions List Search + counsel; a no_name_match means no name match was found IN THIS SNAPSHOT — it is NOT a clearance and does NOT prove a party is not sanctioned (transliterations, variants, and typos can miss a real hit).";
|
|
127
|
+
// ─── Value coercion (the `-0- ` sentinel ⇒ null, never ""/0) ────────────────
|
|
128
|
+
/** A parsed cell: trim; the `-0-`/`-0- ` sentinel and ""/whitespace ⇒ null. */
|
|
129
|
+
function cell(raw) {
|
|
130
|
+
if (raw === undefined || raw === null)
|
|
131
|
+
return null;
|
|
132
|
+
const t = raw.trim();
|
|
133
|
+
if (t === "" || t === "-0-")
|
|
134
|
+
return null;
|
|
135
|
+
return t;
|
|
136
|
+
}
|
|
137
|
+
// ─── Normalized name match (mirror integrity.ts's normName gate) ────────────
|
|
138
|
+
/**
|
|
139
|
+
* Normalize a name for matching: uppercase → strip punctuation → drop trailing
|
|
140
|
+
* entity suffixes (LLC/INC/CORP/…) → collapse whitespace. Byte-for-byte the
|
|
141
|
+
* integrity.ts doctrine, applied to the query AND every primary + AKA name.
|
|
142
|
+
*/
|
|
143
|
+
export function normName(s) {
|
|
144
|
+
return (s ?? "")
|
|
145
|
+
.toUpperCase()
|
|
146
|
+
.replace(/[.,/#!$%^&*;:{}=\-_`~()'"]/g, " ")
|
|
147
|
+
.replace(/\b(LLC|L L C|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LIMITED|LP|LLP|PLLC|PC)\b/g, " ")
|
|
148
|
+
.replace(/\s+/g, " ")
|
|
149
|
+
.trim();
|
|
150
|
+
}
|
|
151
|
+
/** Whole-token list of a normalized (single-space-collapsed) name. */
|
|
152
|
+
function tokensOf(norm) {
|
|
153
|
+
if (norm === "")
|
|
154
|
+
return [];
|
|
155
|
+
return norm.split(" ").filter((t) => t.length > 0);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Grade the match between a normalized query and a normalized index name.
|
|
159
|
+
* - exact: the normalized strings are equal.
|
|
160
|
+
* - strong: one is a whole-token superset of the other, OR one normalized
|
|
161
|
+
* string contains the other (word-order / suffix noise).
|
|
162
|
+
* - weak: ≥1 shared token of length ≥ 3 (the transliteration / common-name
|
|
163
|
+
* false-positive zone — a REVIEW candidate, never an assertion).
|
|
164
|
+
* - null: no relationship.
|
|
165
|
+
*/
|
|
166
|
+
export function classifyMatch(queryNorm, entryNorm) {
|
|
167
|
+
if (queryNorm === "" || entryNorm === "")
|
|
168
|
+
return null;
|
|
169
|
+
if (queryNorm === entryNorm)
|
|
170
|
+
return "exact";
|
|
171
|
+
const qt = tokensOf(queryNorm);
|
|
172
|
+
const et = tokensOf(entryNorm);
|
|
173
|
+
const eSet = new Set(et);
|
|
174
|
+
const qSet = new Set(qt);
|
|
175
|
+
const allQinE = qt.length > 0 && qt.every((t) => eSet.has(t));
|
|
176
|
+
const allEinQ = et.length > 0 && et.every((t) => qSet.has(t));
|
|
177
|
+
if (allQinE || allEinQ)
|
|
178
|
+
return "strong";
|
|
179
|
+
if (entryNorm.includes(queryNorm) || queryNorm.includes(entryNorm)) {
|
|
180
|
+
return "strong";
|
|
181
|
+
}
|
|
182
|
+
for (const t of qt) {
|
|
183
|
+
if (t.length >= 3 && eSet.has(t))
|
|
184
|
+
return "weak";
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const QUALITY_RANK = { weak: 0, strong: 1, exact: 2 };
|
|
189
|
+
// ─── B1 + M6 — record assembler (RFC-4180, replicated from gsa-csv.ts) ───────
|
|
190
|
+
/**
|
|
191
|
+
* ★ B1 — strip a trailing `0x1A` (SUB / EOF sentinel) byte and any terminal
|
|
192
|
+
* newlines BEFORE record assembly, so the last genuine content row is not
|
|
193
|
+
* followed by a bogus empty/`\r\n\x1a`-only record (which would trip the strict
|
|
194
|
+
* column-count drift check → schema_drift on 100% of live cold loads).
|
|
195
|
+
*/
|
|
196
|
+
function stripTrailingSub(body) {
|
|
197
|
+
return body.replace(/[\r\n\x1a]+$/g, "");
|
|
198
|
+
}
|
|
199
|
+
/** Is a parsed record an empty (whitespace/sentinel-only) non-content row? */
|
|
200
|
+
function isEmptyRecord(fields) {
|
|
201
|
+
return fields.every((f) => f.trim() === "");
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* ★ M6 — assemble physical lines into LOGICAL CSV records, correctly re-joining
|
|
205
|
+
* a record whose quoted field (Remarks) contains a newline. Replicates
|
|
206
|
+
* gsa-csv.ts's PRIVATE `makeRecordAssembler` using `parseRecordFields`' returned
|
|
207
|
+
* `inQuotes` flag (NO gsa-csv edit). Empty content rows are skipped (B1).
|
|
208
|
+
*
|
|
209
|
+
* ★ SYMMETRIC-DRIFT — we pass `maxCol = cols` (12 primary / 5 alt), NOT `cols-1`.
|
|
210
|
+
* `parseRecordFields` STOPS STORING fields past `maxCol` (while still scanning for
|
|
211
|
+
* quote parity), so `cols-1` would CAP a too-many-columns row at exactly `cols`
|
|
212
|
+
* stored fields — silently PASSING the downstream `rec.length !== cols` check on a
|
|
213
|
+
* column-ADDITION drift (e.g. OFAC prepends a `record_id`: every row → cols+1
|
|
214
|
+
* fields, SDN_Name shifts, and every real party screens as no_name_match = a
|
|
215
|
+
* blanket false CLEAR). With `maxCol = cols`, a genuine `cols`-field row still
|
|
216
|
+
* materializes exactly `cols` fields, but a >`cols`-field row materializes
|
|
217
|
+
* `cols+1` fields → trips the drift check → schema_drift. The guard is now
|
|
218
|
+
* symmetric (too-few AND too-many both THROW), honoring the "exactly N columns or
|
|
219
|
+
* THROW" contract in BOTH directions.
|
|
220
|
+
*/
|
|
221
|
+
function assembleRecords(body, cols) {
|
|
222
|
+
const maxCol = cols;
|
|
223
|
+
const text = stripTrailingSub(body);
|
|
224
|
+
const lines = text.split("\n");
|
|
225
|
+
const records = [];
|
|
226
|
+
let pending = null;
|
|
227
|
+
for (const line of lines) {
|
|
228
|
+
const candidate = pending === null ? line : pending + "\n" + line;
|
|
229
|
+
const res = parseRecordFields(candidate, maxCol);
|
|
230
|
+
if (res.inQuotes) {
|
|
231
|
+
// A newline fell inside a quoted field → keep accumulating this record.
|
|
232
|
+
pending = candidate;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
pending = null;
|
|
236
|
+
if (isEmptyRecord(res.fields))
|
|
237
|
+
continue; // B1 — not a genuine content row
|
|
238
|
+
records.push(res.fields);
|
|
239
|
+
}
|
|
240
|
+
if (pending !== null) {
|
|
241
|
+
const res = parseRecordFields(pending, maxCol);
|
|
242
|
+
if (!isEmptyRecord(res.fields))
|
|
243
|
+
records.push(res.fields);
|
|
244
|
+
}
|
|
245
|
+
return records;
|
|
246
|
+
}
|
|
247
|
+
// ─── Field-level parsers ────────────────────────────────────────────────────
|
|
248
|
+
/** SDN_Type mapping. Blank (`-0-`) ⇒ "entity" (inferred, disclosed). */
|
|
249
|
+
function mapType(raw) {
|
|
250
|
+
if (raw === null)
|
|
251
|
+
return { type: "entity", inferred: true };
|
|
252
|
+
const t = raw.toLowerCase();
|
|
253
|
+
if (t === "individual")
|
|
254
|
+
return { type: "individual", inferred: false };
|
|
255
|
+
if (t === "vessel")
|
|
256
|
+
return { type: "vessel", inferred: false };
|
|
257
|
+
if (t === "aircraft")
|
|
258
|
+
return { type: "aircraft", inferred: false };
|
|
259
|
+
if (t === "entity")
|
|
260
|
+
return { type: "entity", inferred: false };
|
|
261
|
+
return { type: null, inferred: false };
|
|
262
|
+
}
|
|
263
|
+
/** Bracket-split a Program cell (`CUBA] [SDNTK` ⇒ ["CUBA","SDNTK"]). [] never faked. */
|
|
264
|
+
function parsePrograms(raw) {
|
|
265
|
+
if (raw === null)
|
|
266
|
+
return [];
|
|
267
|
+
return raw
|
|
268
|
+
.split(/\]\s*\[|\[|\]/)
|
|
269
|
+
.map((s) => s.trim())
|
|
270
|
+
.filter((s) => s.length > 0);
|
|
271
|
+
}
|
|
272
|
+
/** ALT alt_type ("aka"/"fka"/"nka") → normalized, else null. */
|
|
273
|
+
function mapAltType(raw) {
|
|
274
|
+
if (raw === null)
|
|
275
|
+
return null;
|
|
276
|
+
const t = raw.toLowerCase();
|
|
277
|
+
if (t === "aka" || t === "a.k.a." || t === "a.k.a")
|
|
278
|
+
return "aka";
|
|
279
|
+
if (t === "fka" || t === "f.k.a." || t === "f.k.a")
|
|
280
|
+
return "fka";
|
|
281
|
+
if (t === "nka" || t === "n.k.a." || t === "n.k.a")
|
|
282
|
+
return "nka";
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* ★ B2 — mine a primary row's free-text Remarks for embedded aliases: the
|
|
287
|
+
* `a.k.a./f.k.a./n.k.a. '<name>'` (or `"<name>"`) markers. The flagship "BNC"
|
|
288
|
+
* (a documented alias of BANCO NACIONAL DE CUBA) lives ONLY in SDN Remarks, NOT
|
|
289
|
+
* in ALT.CSV — indexing these is the false-CLEAR blocker for such aliases.
|
|
290
|
+
*/
|
|
291
|
+
const REMARKS_AKA_RE = /\b([afn])\.k\.a\.\s*['"]([^'"]+)['"]/gi;
|
|
292
|
+
function parseRemarksAkas(remarks) {
|
|
293
|
+
if (remarks === null)
|
|
294
|
+
return [];
|
|
295
|
+
const out = [];
|
|
296
|
+
REMARKS_AKA_RE.lastIndex = 0;
|
|
297
|
+
let m;
|
|
298
|
+
while ((m = REMARKS_AKA_RE.exec(remarks)) !== null) {
|
|
299
|
+
const marker = (m[1] ?? "").toLowerCase();
|
|
300
|
+
const akaType = marker === "a" ? "aka" : marker === "f" ? "fka" : "nka";
|
|
301
|
+
const name = (m[2] ?? "").trim();
|
|
302
|
+
if (name.length > 0)
|
|
303
|
+
out.push({ name, akaType });
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
// ─── List body parsers (strict column validation; no floor here) ────────────
|
|
308
|
+
/**
|
|
309
|
+
* Parse a primary (SDN / CONS_PRIM) list body into records. Every genuine
|
|
310
|
+
* content row MUST have exactly 12 columns — a short/drifted row THROWS
|
|
311
|
+
* `schema_drift` (a truncated download or a drifted file must NEVER read as a
|
|
312
|
+
* near-empty "clear"). HEADERLESS (S1): every record is indexed (no header-skip).
|
|
313
|
+
*/
|
|
314
|
+
export function parsePrimaryList(body, list, label) {
|
|
315
|
+
const records = assembleRecords(body, PRIMARY_COLS);
|
|
316
|
+
const out = [];
|
|
317
|
+
let rowNo = 0;
|
|
318
|
+
for (const rec of records) {
|
|
319
|
+
rowNo++;
|
|
320
|
+
if (rec.length !== PRIMARY_COLS) {
|
|
321
|
+
throw driftError(label, `OFAC ${label} content row ${rowNo} has ${rec.length} column(s), expected exactly ${PRIMARY_COLS} — the download was truncated (too few) or the file schema drifted (a column added/removed, too many). Refusing to screen a drifted list (a truncated OR column-shifted parse must never read as a clear).`);
|
|
322
|
+
}
|
|
323
|
+
// ★ Belt-and-suspenders shape assert: ent_num (column 0) is a non-negative
|
|
324
|
+
// integer by OFAC's spec. A non-numeric column 0 on a genuine content row
|
|
325
|
+
// means a leading-column INSERTION/REPLACEMENT shifted every field (which can
|
|
326
|
+
// otherwise keep the field COUNT at `cols`) → schema_drift, never a false clear.
|
|
327
|
+
const entRaw = (rec[0] ?? "").trim();
|
|
328
|
+
if (!ENT_NUM_RE.test(entRaw)) {
|
|
329
|
+
throw driftError(label, `OFAC ${label} content row ${rowNo} has a non-numeric ent_num ${JSON.stringify(entRaw)} in column 0 — the file schema drifted (a leading-column insertion/replacement shifts SDN_Name off index 1). Refusing to screen (a shifted parse must never read as a clear).`);
|
|
330
|
+
}
|
|
331
|
+
const entNum = entRaw;
|
|
332
|
+
const name = cell(rec[1]);
|
|
333
|
+
if (name === null)
|
|
334
|
+
continue; // a nameless row is not screenable
|
|
335
|
+
const { type, inferred } = mapType(cell(rec[2]));
|
|
336
|
+
const remarks = cell(rec[11]);
|
|
337
|
+
out.push({
|
|
338
|
+
entNum,
|
|
339
|
+
name,
|
|
340
|
+
normName: normName(name),
|
|
341
|
+
type,
|
|
342
|
+
typeInferred: inferred,
|
|
343
|
+
programs: parsePrograms(cell(rec[3])),
|
|
344
|
+
title: cell(rec[4]),
|
|
345
|
+
remarks,
|
|
346
|
+
list,
|
|
347
|
+
remarksAkas: parseRemarksAkas(remarks),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Parse an alt (ALT / CONS_ALT) list body into records. Every genuine content
|
|
354
|
+
* row MUST have exactly 5 columns — a drifted row THROWS `schema_drift`.
|
|
355
|
+
* HEADERLESS (S1).
|
|
356
|
+
*/
|
|
357
|
+
export function parseAltList(body, list, label) {
|
|
358
|
+
const records = assembleRecords(body, ALT_COLS);
|
|
359
|
+
const out = [];
|
|
360
|
+
let rowNo = 0;
|
|
361
|
+
for (const rec of records) {
|
|
362
|
+
rowNo++;
|
|
363
|
+
if (rec.length !== ALT_COLS) {
|
|
364
|
+
throw driftError(label, `OFAC ${label} content row ${rowNo} has ${rec.length} column(s), expected exactly ${ALT_COLS} — the download was truncated (too few) or the file schema drifted (a column added/removed, too many). Refusing to screen a drifted alias list (a truncated OR column-shifted parse would miss an AKA = a false clear).`);
|
|
365
|
+
}
|
|
366
|
+
// ★ Shape assert (see parsePrimaryList): ent_num (column 0) is a non-negative
|
|
367
|
+
// integer; a non-numeric column 0 means a leading-column shift → schema_drift.
|
|
368
|
+
const entRaw = (rec[0] ?? "").trim();
|
|
369
|
+
if (!ENT_NUM_RE.test(entRaw)) {
|
|
370
|
+
throw driftError(label, `OFAC ${label} content row ${rowNo} has a non-numeric ent_num ${JSON.stringify(entRaw)} in column 0 — the alias file schema drifted (a leading-column shift). Refusing to screen (a shifted parse must never read as a clear).`);
|
|
371
|
+
}
|
|
372
|
+
const name = cell(rec[3]);
|
|
373
|
+
if (name === null)
|
|
374
|
+
continue;
|
|
375
|
+
out.push({
|
|
376
|
+
entNum: entRaw,
|
|
377
|
+
altNum: cell(rec[1]),
|
|
378
|
+
akaType: mapAltType(cell(rec[2])),
|
|
379
|
+
name,
|
|
380
|
+
remarks: cell(rec[4]),
|
|
381
|
+
list,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
/** A minimal base for an alt whose ent_num has no matching primary (recall-safe). */
|
|
387
|
+
function syntheticBase(alt) {
|
|
388
|
+
return {
|
|
389
|
+
entNum: alt.entNum,
|
|
390
|
+
name: alt.name,
|
|
391
|
+
normName: normName(alt.name),
|
|
392
|
+
type: null,
|
|
393
|
+
typeInferred: false,
|
|
394
|
+
programs: [],
|
|
395
|
+
title: null,
|
|
396
|
+
remarks: alt.remarks,
|
|
397
|
+
list: alt.list,
|
|
398
|
+
remarksAkas: [],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/** Build the primary + AKA name entries for ONE scope (primaries + its alts). */
|
|
402
|
+
export function buildScopeEntries(primaries, alts) {
|
|
403
|
+
const byEnt = new Map();
|
|
404
|
+
for (const p of primaries) {
|
|
405
|
+
if (p.entNum !== "" && !byEnt.has(p.entNum))
|
|
406
|
+
byEnt.set(p.entNum, p);
|
|
407
|
+
}
|
|
408
|
+
const entries = [];
|
|
409
|
+
for (const p of primaries) {
|
|
410
|
+
entries.push({
|
|
411
|
+
name: p.name,
|
|
412
|
+
normName: p.normName,
|
|
413
|
+
matchedVia: "primary",
|
|
414
|
+
akaType: null,
|
|
415
|
+
base: p,
|
|
416
|
+
});
|
|
417
|
+
for (const a of p.remarksAkas) {
|
|
418
|
+
entries.push({
|
|
419
|
+
name: a.name,
|
|
420
|
+
normName: normName(a.name),
|
|
421
|
+
matchedVia: "aka(remarks)",
|
|
422
|
+
akaType: a.akaType,
|
|
423
|
+
base: p,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
for (const alt of alts) {
|
|
428
|
+
const base = byEnt.get(alt.entNum) ?? syntheticBase(alt);
|
|
429
|
+
entries.push({
|
|
430
|
+
name: alt.name,
|
|
431
|
+
normName: normName(alt.name),
|
|
432
|
+
matchedVia: "aka(alt)",
|
|
433
|
+
akaType: alt.akaType,
|
|
434
|
+
base,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
return entries;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* ★ M2 — existence-first screening over a prebuilt name index.
|
|
441
|
+
*
|
|
442
|
+
* Matches are computed at the LOWEST (weak) quality FIRST, so `result` is
|
|
443
|
+
* `potential_matches` whenever ≥1 name match exists at ANY quality — INDEPENDENT
|
|
444
|
+
* of `minMatchQuality` and of the `type`/`program` post-filters. Those only trim
|
|
445
|
+
* the RETURNED `matches[]`; when they suppress a match, `suppressedCount` and the
|
|
446
|
+
* highest suppressed quality are disclosed. `result` is NEVER `no_name_match`
|
|
447
|
+
* while a name match exists.
|
|
448
|
+
*/
|
|
449
|
+
export function computeScreen(entries, args) {
|
|
450
|
+
const queryNorm = normName(args.name);
|
|
451
|
+
const minQ = args.minMatchQuality ?? "weak";
|
|
452
|
+
const minRank = QUALITY_RANK[minQ];
|
|
453
|
+
const limit = Math.min(200, Math.max(1, Math.floor(args.limit ?? 50)));
|
|
454
|
+
const typeFilter = args.type;
|
|
455
|
+
const programFilter = args.program ? args.program.trim().toUpperCase() : null;
|
|
456
|
+
const all = [];
|
|
457
|
+
for (const entry of entries) {
|
|
458
|
+
const q = classifyMatch(queryNorm, entry.normName);
|
|
459
|
+
if (q !== null)
|
|
460
|
+
all.push({ entry, quality: q });
|
|
461
|
+
}
|
|
462
|
+
// Existence-first: matchCount is the TOTAL name matches at any quality, before
|
|
463
|
+
// ANY of type / program / minMatchQuality trimming.
|
|
464
|
+
const matchCount = all.length;
|
|
465
|
+
// Trim the RETURNED set by type / program / minMatchQuality (disclosed).
|
|
466
|
+
const passesType = (s) => typeFilter === undefined || s.entry.base.type === typeFilter;
|
|
467
|
+
const passesProgram = (s) => programFilter === null ||
|
|
468
|
+
s.entry.base.programs.some((p) => p.toUpperCase() === programFilter);
|
|
469
|
+
const passesMinQ = (s) => QUALITY_RANK[s.quality] >= minRank;
|
|
470
|
+
const returnedPool = all.filter((s) => passesType(s) && passesProgram(s) && passesMinQ(s));
|
|
471
|
+
returnedPool.sort((a, b) => {
|
|
472
|
+
if (QUALITY_RANK[b.quality] !== QUALITY_RANK[a.quality]) {
|
|
473
|
+
return QUALITY_RANK[b.quality] - QUALITY_RANK[a.quality];
|
|
474
|
+
}
|
|
475
|
+
return a.entry.name.localeCompare(b.entry.name);
|
|
476
|
+
});
|
|
477
|
+
const returned = returnedPool.slice(0, limit);
|
|
478
|
+
// The highest quality among the matches we did NOT return (min-quality OR
|
|
479
|
+
// filter OR limit suppression) — so an AI knows what it is not seeing.
|
|
480
|
+
const returnedSet = new Set(returned);
|
|
481
|
+
let highestSuppressedQuality = null;
|
|
482
|
+
for (const s of all) {
|
|
483
|
+
if (returnedSet.has(s))
|
|
484
|
+
continue;
|
|
485
|
+
if (highestSuppressedQuality === null ||
|
|
486
|
+
QUALITY_RANK[s.quality] > QUALITY_RANK[highestSuppressedQuality]) {
|
|
487
|
+
highestSuppressedQuality = s.quality;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const matches = returned.map((s) => {
|
|
491
|
+
const b = s.entry.base;
|
|
492
|
+
return {
|
|
493
|
+
name: s.entry.name,
|
|
494
|
+
matchedVia: s.entry.matchedVia,
|
|
495
|
+
akaType: s.entry.akaType,
|
|
496
|
+
matchQuality: s.quality,
|
|
497
|
+
list: b.list,
|
|
498
|
+
programs: b.programs,
|
|
499
|
+
type: b.type,
|
|
500
|
+
entNum: b.entNum === "" ? null : b.entNum,
|
|
501
|
+
title: b.title,
|
|
502
|
+
remarks: b.remarks,
|
|
503
|
+
typeInferred: b.typeInferred,
|
|
504
|
+
ofacSearchUrl: b.entNum !== ""
|
|
505
|
+
? `https://sanctionssearch.ofac.treas.gov/Details.aspx?id=${encodeURIComponent(b.entNum)}`
|
|
506
|
+
: "https://sanctionssearch.ofac.treas.gov/",
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
result: matchCount > 0 ? "potential_matches" : "no_name_match",
|
|
511
|
+
matchCount,
|
|
512
|
+
returnedCount: matches.length,
|
|
513
|
+
suppressedCount: matchCount - matches.length,
|
|
514
|
+
highestSuppressedQuality,
|
|
515
|
+
matches,
|
|
516
|
+
anyTypeInferred: matches.some((m) => m.typeInferred),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/** Pull the `/YYYY-MM-DD/` snapshot-date segment out of the signed S3 path. */
|
|
520
|
+
function pathDateOf(url) {
|
|
521
|
+
const m = /\/(\d{4}-\d{2}-\d{2})\//.exec(url);
|
|
522
|
+
return m ? (m[1] ?? null) : null;
|
|
523
|
+
}
|
|
524
|
+
/** Concatenate streamed chunks into one Uint8Array. */
|
|
525
|
+
function concatChunks(chunks, total) {
|
|
526
|
+
const out = new Uint8Array(total);
|
|
527
|
+
let off = 0;
|
|
528
|
+
for (const c of chunks) {
|
|
529
|
+
out.set(c, off);
|
|
530
|
+
off += c.byteLength;
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Read the response body with a HARD byte cap — abort past `maxBytes` rather
|
|
536
|
+
* than trusting content-length alone (S3 §S3). Streams via `res.body` when
|
|
537
|
+
* available (real S3), else falls back to `arrayBuffer()` + a post-read cap
|
|
538
|
+
* (the offline fetch-mock, which exposes no stream). NEVER called until the
|
|
539
|
+
* FINAL host passed the SSRF allowlist.
|
|
540
|
+
*/
|
|
541
|
+
async function readCappedBody(res, maxBytes, label) {
|
|
542
|
+
const tooBig = () => new ToolErrorCarrier({
|
|
543
|
+
kind: "invalid_input",
|
|
544
|
+
message: `OFAC ${label} body exceeded the ${Math.round(maxBytes / 1048576)} MB read cap — refusing to buffer it (a drifted giant, not the ~7 MB bulk lists). Verify the pinned endpoint.`,
|
|
545
|
+
retryable: false,
|
|
546
|
+
upstreamEndpoint: label,
|
|
547
|
+
});
|
|
548
|
+
const body = res.body;
|
|
549
|
+
if (body && typeof body.getReader === "function") {
|
|
550
|
+
const reader = body.getReader();
|
|
551
|
+
const chunks = [];
|
|
552
|
+
let total = 0;
|
|
553
|
+
for (;;) {
|
|
554
|
+
const { done, value } = await reader.read();
|
|
555
|
+
if (done)
|
|
556
|
+
break;
|
|
557
|
+
if (value) {
|
|
558
|
+
total += value.byteLength;
|
|
559
|
+
if (total > maxBytes) {
|
|
560
|
+
try {
|
|
561
|
+
await reader.cancel();
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
/* ignore */
|
|
565
|
+
}
|
|
566
|
+
throw tooBig();
|
|
567
|
+
}
|
|
568
|
+
chunks.push(value);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return concatChunks(chunks, total);
|
|
572
|
+
}
|
|
573
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
574
|
+
if (buf.byteLength > maxBytes)
|
|
575
|
+
throw tooBig();
|
|
576
|
+
return buf;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Fetch ONE OFAC list. Order (S3): fetch(follow) → check status → validate the
|
|
580
|
+
* FINAL host → size-check → THEN read (capped). Every failure THROWS a
|
|
581
|
+
* classified error (never a fake empty).
|
|
582
|
+
*/
|
|
583
|
+
async function fetchListBody(url, label) {
|
|
584
|
+
let res;
|
|
585
|
+
try {
|
|
586
|
+
res = await fetch(url, {
|
|
587
|
+
headers: { "User-Agent": OFAC_UA, Accept: "*/*" },
|
|
588
|
+
redirect: "follow",
|
|
589
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
// Network / timeout / abort ⇒ retryable OUTAGE (THROW — never no_name_match).
|
|
594
|
+
throw new ToolErrorCarrier({
|
|
595
|
+
kind: "upstream_unavailable",
|
|
596
|
+
message: `Network error fetching OFAC ${label}: ${e.message}. The service is unavailable, NOT an empty list — retry (never read a fetch failure as a clear).`,
|
|
597
|
+
retryable: true,
|
|
598
|
+
retryAfterSeconds: 30,
|
|
599
|
+
upstreamEndpoint: label,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (!res.ok) {
|
|
603
|
+
// 404/429/5xx/4xx ⇒ the errors.ts taxonomy. A DOWN endpoint NEVER reads empty.
|
|
604
|
+
throw new ToolErrorCarrier(errorFromResponse(res, label));
|
|
605
|
+
}
|
|
606
|
+
// ★ M4/M5 — re-validate the FINAL host (fail-closed). Compute it in try/catch
|
|
607
|
+
// (→ "" on throw); accept ONLY when isOfacS3Host. An empty final host is
|
|
608
|
+
// UNCONDITIONALLY rejected (OFAC always redirects — no `!res.redirected`
|
|
609
|
+
// escape hatch). On rejection THROW invalid_input and NEVER read the body.
|
|
610
|
+
const finalHost = (() => {
|
|
611
|
+
try {
|
|
612
|
+
return new URL(res.url).hostname.toLowerCase();
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return "";
|
|
616
|
+
}
|
|
617
|
+
})();
|
|
618
|
+
if (!isOfacS3Host(finalHost)) {
|
|
619
|
+
throw new ToolErrorCarrier({
|
|
620
|
+
kind: "invalid_input",
|
|
621
|
+
message: `OFAC ${label} redirected to an unexpected final host ${JSON.stringify(finalHost)} — refusing to read the body (SSRF: the final host must be OFAC's region-pinned GovCloud bucket ${OFAC_S3_HOST}). A bucket rotation manifests here as a loud, SAFE failure (never a fake clear) requiring a pinned-constant update.`,
|
|
622
|
+
retryable: false,
|
|
623
|
+
upstreamEndpoint: label,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
// Size guard (content-length) BEFORE buffering — belt to the streamed cap.
|
|
627
|
+
const declaredLen = Number(res.headers.get("content-length") ?? "");
|
|
628
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_LIST_BYTES) {
|
|
629
|
+
throw new ToolErrorCarrier({
|
|
630
|
+
kind: "invalid_input",
|
|
631
|
+
message: `OFAC ${label} declares ${Math.round(declaredLen / 1048576)} MB, over this tool's ${Math.round(MAX_LIST_BYTES / 1048576)} MB per-list cap — refusing (a drifted giant, not the ~7 MB bulk lists).`,
|
|
632
|
+
retryable: false,
|
|
633
|
+
upstreamEndpoint: label,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
const bytes = await readCappedBody(res, MAX_LIST_BYTES, label);
|
|
637
|
+
const body = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
638
|
+
return {
|
|
639
|
+
body,
|
|
640
|
+
lastModified: res.headers.get("last-modified"),
|
|
641
|
+
pathDate: pathDateOf(res.url),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
// ─── Module cache (per-list, TTL, single in-flight promise per list) ────────
|
|
645
|
+
const primaryCache = new Map();
|
|
646
|
+
const altCache = new Map();
|
|
647
|
+
const inFlight = new Map();
|
|
648
|
+
/** TEST-ONLY: drop the per-list caches so a fault case can re-point the mock. */
|
|
649
|
+
export function _resetOfacCacheForTests() {
|
|
650
|
+
primaryCache.clear();
|
|
651
|
+
altCache.clear();
|
|
652
|
+
inFlight.clear();
|
|
653
|
+
indexMemo.scope = null;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Ensure ONE list is loaded, parsed, and above its floor. A warm cache within
|
|
657
|
+
* TTL is served instantly; a past-TTL cache is refreshed but, if the refresh
|
|
658
|
+
* FAILS while a prior (real) copy exists, the stale copy is kept (real data, not
|
|
659
|
+
* a fabricated clear — disclosed via age). A COLD miss whose fetch fails THROWS.
|
|
660
|
+
* A below-floor parse THROWS `schema_drift` (M1). Fetches route through the
|
|
661
|
+
* shared `ofac` self-throttle gate.
|
|
662
|
+
*/
|
|
663
|
+
async function ensureList(key) {
|
|
664
|
+
const kind = LIST_KIND[key];
|
|
665
|
+
const cached = kind === "primary" ? primaryCache.get(key) : altCache.get(key);
|
|
666
|
+
if (cached && Date.now() - cached.fetchedAt <= CACHE_TTL_MS)
|
|
667
|
+
return;
|
|
668
|
+
const existing = inFlight.get(key);
|
|
669
|
+
if (existing)
|
|
670
|
+
return existing;
|
|
671
|
+
const load = (async () => {
|
|
672
|
+
let fetched;
|
|
673
|
+
try {
|
|
674
|
+
fetched = await throughGate("ofac", OFAC_GATE_MIN_INTERVAL_MS, () => fetchListBody(OFAC_URLS[key], `ofac:${key}`));
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
// A refresh failure with a prior REAL copy → keep the stale copy (honest,
|
|
678
|
+
// disclosed by age). A COLD miss → propagate the THROW (never a clear).
|
|
679
|
+
if (cached)
|
|
680
|
+
return;
|
|
681
|
+
throw e;
|
|
682
|
+
}
|
|
683
|
+
const label = `ofac:${key}`;
|
|
684
|
+
const floor = FLOORS[key];
|
|
685
|
+
if (kind === "primary") {
|
|
686
|
+
const records = parsePrimaryList(fetched.body, LIST_LABEL[key], label);
|
|
687
|
+
if (records.length < floor) {
|
|
688
|
+
throw driftError(label, `OFAC ${label} parsed ${records.length} row(s), below the plausibility floor of ${floor.toLocaleString()} — the download is truncated or drifted. Refusing to screen (a near-empty list must never read as a clear).`);
|
|
689
|
+
}
|
|
690
|
+
primaryCache.set(key, {
|
|
691
|
+
records,
|
|
692
|
+
fetchedAt: Date.now(),
|
|
693
|
+
lastModified: fetched.lastModified,
|
|
694
|
+
pathDate: fetched.pathDate,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
const records = parseAltList(fetched.body, LIST_LABEL[key], label);
|
|
699
|
+
if (records.length < floor) {
|
|
700
|
+
throw driftError(label, `OFAC ${label} parsed ${records.length} alias row(s), below the plausibility floor of ${floor.toLocaleString()} — the download is truncated or drifted. Refusing to screen (missing aliases would be a false clear).`);
|
|
701
|
+
}
|
|
702
|
+
altCache.set(key, {
|
|
703
|
+
records,
|
|
704
|
+
fetchedAt: Date.now(),
|
|
705
|
+
lastModified: fetched.lastModified,
|
|
706
|
+
pathDate: fetched.pathDate,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
indexMemo.scope = null; // the index must be rebuilt from the fresh list
|
|
710
|
+
})();
|
|
711
|
+
inFlight.set(key, load);
|
|
712
|
+
try {
|
|
713
|
+
await load;
|
|
714
|
+
}
|
|
715
|
+
finally {
|
|
716
|
+
inFlight.delete(key);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/** The lists REQUIRED to screen a given `list` scope (M3 — all-or-throw). */
|
|
720
|
+
function requiredLists(list) {
|
|
721
|
+
if (list === "sdn")
|
|
722
|
+
return ["sdn", "alt"];
|
|
723
|
+
if (list === "consolidated")
|
|
724
|
+
return ["cons_prim", "cons_alt"];
|
|
725
|
+
return ["sdn", "alt", "cons_prim", "cons_alt"];
|
|
726
|
+
}
|
|
727
|
+
// One memoized built index per scope-signature (rebuilt when a list refreshes).
|
|
728
|
+
const indexMemo = { scope: null, signature: "", entries: [] };
|
|
729
|
+
function scopeSignature(list) {
|
|
730
|
+
return requiredLists(list)
|
|
731
|
+
.map((k) => {
|
|
732
|
+
const c = LIST_KIND[k] === "primary" ? primaryCache.get(k) : altCache.get(k);
|
|
733
|
+
return `${k}:${c?.fetchedAt ?? 0}`;
|
|
734
|
+
})
|
|
735
|
+
.join("|");
|
|
736
|
+
}
|
|
737
|
+
/** Build (or reuse) the screenable index for a scope from the loaded lists. */
|
|
738
|
+
function indexForScope(list) {
|
|
739
|
+
const sig = scopeSignature(list);
|
|
740
|
+
if (indexMemo.scope === list && indexMemo.signature === sig) {
|
|
741
|
+
return indexMemo.entries;
|
|
742
|
+
}
|
|
743
|
+
const entries = [];
|
|
744
|
+
if (list === "sdn" || list === "all") {
|
|
745
|
+
entries.push(...buildScopeEntries(primaryCache.get("sdn")?.records ?? [], altCache.get("alt")?.records ?? []));
|
|
746
|
+
}
|
|
747
|
+
if (list === "consolidated" || list === "all") {
|
|
748
|
+
entries.push(...buildScopeEntries(primaryCache.get("cons_prim")?.records ?? [], altCache.get("cons_alt")?.records ?? []));
|
|
749
|
+
}
|
|
750
|
+
indexMemo.scope = list;
|
|
751
|
+
indexMemo.signature = sig;
|
|
752
|
+
indexMemo.entries = entries;
|
|
753
|
+
return entries;
|
|
754
|
+
}
|
|
755
|
+
// ─── Freshness helpers ──────────────────────────────────────────────────────
|
|
756
|
+
function publishedDateOf(list) {
|
|
757
|
+
if (!list)
|
|
758
|
+
return null;
|
|
759
|
+
if (list.pathDate)
|
|
760
|
+
return list.pathDate;
|
|
761
|
+
if (list.lastModified) {
|
|
762
|
+
const t = Date.parse(list.lastModified);
|
|
763
|
+
if (Number.isFinite(t))
|
|
764
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
// ─── The tool ───────────────────────────────────────────────────────────────
|
|
769
|
+
/** Runtime enum re-checks (defense-in-depth over the server's Zod enums). */
|
|
770
|
+
const VALID_TYPES = new Set(["individual", "entity", "vessel", "aircraft"]);
|
|
771
|
+
const VALID_LISTS = new Set(["sdn", "consolidated", "all"]);
|
|
772
|
+
const VALID_QUALITIES = new Set(["exact", "strong", "weak"]);
|
|
773
|
+
/**
|
|
774
|
+
* ★ `ofac_screen_entity` — keyless OFAC denied-party name screening.
|
|
775
|
+
*
|
|
776
|
+
* NEVER FAKE A CLEAR: every fetch failure / SSRF reject / parse drift /
|
|
777
|
+
* floor-fail THROWS; a `no_name_match` is reached ONLY when all required lists
|
|
778
|
+
* loaded + passed floors AND zero matches at any quality. `result` is never
|
|
779
|
+
* "clear".
|
|
780
|
+
*/
|
|
781
|
+
export async function screenEntity(args) {
|
|
782
|
+
const name = (args.name ?? "").trim();
|
|
783
|
+
if (name === "") {
|
|
784
|
+
throw new ToolErrorCarrier({
|
|
785
|
+
kind: "invalid_input",
|
|
786
|
+
message: "ofac_screen_entity requires a non-empty `name` to screen (an empty screen is never a no-op clear).",
|
|
787
|
+
retryable: false,
|
|
788
|
+
upstreamEndpoint: "ofac",
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
// Runtime enum re-checks — an off-enum value must be a loud invalid_input, not
|
|
792
|
+
// a silently-accepted empty screen (the integrity.ts silent-accept trap).
|
|
793
|
+
if (args.type !== undefined && !VALID_TYPES.has(args.type)) {
|
|
794
|
+
throw new ToolErrorCarrier({
|
|
795
|
+
kind: "invalid_input",
|
|
796
|
+
message: `Unknown type '${args.type}'. Use one of: individual, entity, vessel, aircraft (or omit to screen all types).`,
|
|
797
|
+
retryable: false,
|
|
798
|
+
upstreamEndpoint: "ofac",
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const list = (args.list ?? "all");
|
|
802
|
+
if (!VALID_LISTS.has(list)) {
|
|
803
|
+
throw new ToolErrorCarrier({
|
|
804
|
+
kind: "invalid_input",
|
|
805
|
+
message: `Unknown list '${args.list}'. Use one of: sdn, consolidated, all (default all).`,
|
|
806
|
+
retryable: false,
|
|
807
|
+
upstreamEndpoint: "ofac",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
const minMatchQuality = (args.minMatchQuality ?? "weak");
|
|
811
|
+
if (!VALID_QUALITIES.has(minMatchQuality)) {
|
|
812
|
+
throw new ToolErrorCarrier({
|
|
813
|
+
kind: "invalid_input",
|
|
814
|
+
message: `Unknown minMatchQuality '${args.minMatchQuality}'. Use one of: exact, strong, weak (default weak).`,
|
|
815
|
+
retryable: false,
|
|
816
|
+
upstreamEndpoint: "ofac",
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
// ★ M3 — every REQUIRED list loads-or-throws (fail-closed). A partial set / an
|
|
820
|
+
// AKA list that failed to load is NEVER screened (an AKA-only hit would miss =
|
|
821
|
+
// false clear). Loaded sequentially through the shared throttle gate.
|
|
822
|
+
const needed = requiredLists(list);
|
|
823
|
+
for (const key of needed) {
|
|
824
|
+
await ensureList(key);
|
|
825
|
+
}
|
|
826
|
+
const entries = indexForScope(list);
|
|
827
|
+
const comp = computeScreen(entries, {
|
|
828
|
+
name,
|
|
829
|
+
type: args.type ?? undefined,
|
|
830
|
+
program: args.program,
|
|
831
|
+
minMatchQuality,
|
|
832
|
+
limit: args.limit,
|
|
833
|
+
});
|
|
834
|
+
// Freshness — the S3 last-modified / signed-path date, plus the cache age.
|
|
835
|
+
const loadedCounts = {};
|
|
836
|
+
let oldestFetchedAt = Infinity;
|
|
837
|
+
let publishedDate = null;
|
|
838
|
+
const listLabels = [];
|
|
839
|
+
for (const key of needed) {
|
|
840
|
+
const c = LIST_KIND[key] === "primary" ? primaryCache.get(key) : altCache.get(key);
|
|
841
|
+
loadedCounts[key] = c?.records.length ?? 0;
|
|
842
|
+
if (c) {
|
|
843
|
+
oldestFetchedAt = Math.min(oldestFetchedAt, c.fetchedAt);
|
|
844
|
+
publishedDate = publishedDate ?? publishedDateOf(c);
|
|
845
|
+
}
|
|
846
|
+
const lbl = LIST_LABEL[key];
|
|
847
|
+
if (!listLabels.includes(lbl))
|
|
848
|
+
listLabels.push(lbl);
|
|
849
|
+
}
|
|
850
|
+
const fetchedAtMs = Number.isFinite(oldestFetchedAt)
|
|
851
|
+
? oldestFetchedAt
|
|
852
|
+
: Date.now();
|
|
853
|
+
const ageHours = Math.round(((Date.now() - fetchedAtMs) / 3_600_000) * 10) / 10;
|
|
854
|
+
const cacheStale = Date.now() - fetchedAtMs > CACHE_TTL_MS;
|
|
855
|
+
const snapshot = {
|
|
856
|
+
publishedDate,
|
|
857
|
+
fetchedAt: new Date(fetchedAtMs).toISOString(),
|
|
858
|
+
ageHours,
|
|
859
|
+
lists: listLabels,
|
|
860
|
+
counts: {
|
|
861
|
+
sdn: loadedCounts.sdn ?? null,
|
|
862
|
+
alt: loadedCounts.alt ?? null,
|
|
863
|
+
consolidatedPrimary: loadedCounts.cons_prim ?? null,
|
|
864
|
+
consolidatedAlt: loadedCounts.cons_alt ?? null,
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
const data = {
|
|
868
|
+
query: {
|
|
869
|
+
name,
|
|
870
|
+
normalizedName: normName(name),
|
|
871
|
+
type: args.type ?? null,
|
|
872
|
+
program: args.program ?? null,
|
|
873
|
+
list,
|
|
874
|
+
minMatchQuality,
|
|
875
|
+
},
|
|
876
|
+
result: comp.result,
|
|
877
|
+
matchCount: comp.matchCount,
|
|
878
|
+
returnedCount: comp.returnedCount,
|
|
879
|
+
suppressedCount: comp.suppressedCount,
|
|
880
|
+
matches: comp.matches,
|
|
881
|
+
snapshot,
|
|
882
|
+
};
|
|
883
|
+
// ── Honest _meta (the mandatory caveat rides on EVERY response) ──
|
|
884
|
+
const notes = [OFAC_NOT_DETERMINATION_NOTE];
|
|
885
|
+
notes.push(`Snapshot published ${publishedDate ?? "unknown"}; loaded ${ageHours}h ago (cache TTL 6h). Lists screened: ${listLabels.join(" + ")} — counts SDN ${snapshot.counts.sdn ?? "n/a"}, ALT ${snapshot.counts.alt ?? "n/a"}, Consolidated-primary ${snapshot.counts.consolidatedPrimary ?? "n/a"}, Consolidated-alt ${snapshot.counts.consolidatedAlt ?? "n/a"}.`);
|
|
886
|
+
if (cacheStale) {
|
|
887
|
+
notes.push(`This snapshot is STALE (loaded ${ageHours}h ago, past the 6h TTL) — a refresh could not complete, so a previously-loaded copy was served. Re-run to refresh; do not treat a stale no_name_match as current.`);
|
|
888
|
+
}
|
|
889
|
+
if (comp.result === "potential_matches") {
|
|
890
|
+
notes.push(`${comp.matchCount} potential name match(es) found at some quality (exact/strong/weak). A weak/strong hit is a REVIEW CANDIDATE, not a confirmed match — OFAC name matching is inexact (transliterations, common names). Adjudicate every match against OFAC's Sanctions List Search (ofacSearchUrl) before acting.`);
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
notes.push("no_name_match: no name matched across the required list(s) IN THIS SNAPSHOT. This is NOT a clearance — OFAC publishes transliterations and low-quality variants this normalized screen cannot fully catch, and a very-new designation may post after the snapshot. It does not prove a party is not sanctioned.");
|
|
894
|
+
}
|
|
895
|
+
if (comp.suppressedCount > 0) {
|
|
896
|
+
notes.push(`${comp.suppressedCount} match(es) were found but NOT returned (trimmed by minMatchQuality='${minMatchQuality}', the type/program filter, or the limit). The highest suppressed match quality was '${comp.highestSuppressedQuality}'. Lower minMatchQuality, drop the type/program filter, or raise limit to see them — result already reflects that matches EXIST.`);
|
|
897
|
+
}
|
|
898
|
+
if (comp.anyTypeInferred) {
|
|
899
|
+
notes.push("One or more matches have type='entity' INFERRED from a blank OFAC type field (OFAC leaves the type blank for organizations); it is not an explicitly-tagged type.");
|
|
900
|
+
}
|
|
901
|
+
return withMeta(data, {
|
|
902
|
+
source: `OFAC published SDN + Consolidated bulk lists (keyless) as-of ${publishedDate ?? "unknown"}`,
|
|
903
|
+
keylessMode: true,
|
|
904
|
+
complete: comp.suppressedCount === 0,
|
|
905
|
+
truncated: comp.suppressedCount > 0,
|
|
906
|
+
returned: comp.returnedCount,
|
|
907
|
+
totalAvailable: comp.matchCount,
|
|
908
|
+
filtersApplied: [
|
|
909
|
+
`list(${list})`,
|
|
910
|
+
`minMatchQuality(${minMatchQuality})`,
|
|
911
|
+
...(args.type ? [`type(${args.type})`] : []),
|
|
912
|
+
...(args.program ? [`program(${args.program})`] : []),
|
|
913
|
+
],
|
|
914
|
+
filtersDropped: [],
|
|
915
|
+
fieldsUnavailable: [],
|
|
916
|
+
notes,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
//# sourceMappingURL=ofac.js.map
|