active_sanction 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +538 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +312 -0
- data/LICENSE.txt +21 -0
- data/README.md +616 -0
- data/SECURITY.md +97 -0
- data/active_sanction.gemspec +98 -0
- data/docs/adding_a_source.md +1083 -0
- data/docs/api_stability.md +357 -0
- data/docs/bundle_format.md +407 -0
- data/lib/active_sanction/address.rb +170 -0
- data/lib/active_sanction/client.rb +341 -0
- data/lib/active_sanction/configuration.rb +721 -0
- data/lib/active_sanction/countries.txt +292 -0
- data/lib/active_sanction/country.rb +164 -0
- data/lib/active_sanction/deprecation.rb +168 -0
- data/lib/active_sanction/diff/change.rb +208 -0
- data/lib/active_sanction/diff.rb +348 -0
- data/lib/active_sanction/doctor/checkup.rb +356 -0
- data/lib/active_sanction/doctor/diagnosis.rb +245 -0
- data/lib/active_sanction/doctor/finding.rb +183 -0
- data/lib/active_sanction/doctor/profile.rb +384 -0
- data/lib/active_sanction/doctor/report.rb +227 -0
- data/lib/active_sanction/doctor.rb +347 -0
- data/lib/active_sanction/entity.rb +293 -0
- data/lib/active_sanction/error.rb +332 -0
- data/lib/active_sanction/fetcher/result.rb +116 -0
- data/lib/active_sanction/fetcher.rb +242 -0
- data/lib/active_sanction/http_client/errors.rb +94 -0
- data/lib/active_sanction/http_client/response.rb +129 -0
- data/lib/active_sanction/http_client.rb +324 -0
- data/lib/active_sanction/identifier.rb +212 -0
- data/lib/active_sanction/index/builder.rb +89 -0
- data/lib/active_sanction/index/candidate.rb +63 -0
- data/lib/active_sanction/index/entry.rb +66 -0
- data/lib/active_sanction/index/features.rb +112 -0
- data/lib/active_sanction/index.rb +381 -0
- data/lib/active_sanction/match_result.rb +345 -0
- data/lib/active_sanction/matcher.rb +388 -0
- data/lib/active_sanction/name.rb +183 -0
- data/lib/active_sanction/normalizer/cache.rb +82 -0
- data/lib/active_sanction/normalizer/dictionaries/honorifics.txt +30 -0
- data/lib/active_sanction/normalizer/dictionaries/legal_forms.txt +72 -0
- data/lib/active_sanction/normalizer/dictionaries/organization_stopwords.txt +20 -0
- data/lib/active_sanction/normalizer/dictionaries/particles.txt +35 -0
- data/lib/active_sanction/normalizer/dictionary/stoplist.rb +121 -0
- data/lib/active_sanction/normalizer/dictionary.rb +243 -0
- data/lib/active_sanction/normalizer/form.rb +279 -0
- data/lib/active_sanction/normalizer.rb +137 -0
- data/lib/active_sanction/parsers/column_shape.rb +274 -0
- data/lib/active_sanction/parsers/delimited_table/reader.rb +194 -0
- data/lib/active_sanction/parsers/delimited_table/row.rb +81 -0
- data/lib/active_sanction/parsers/delimited_table.rb +151 -0
- data/lib/active_sanction/parsers/format.rb +107 -0
- data/lib/active_sanction/parsers/join.rb +149 -0
- data/lib/active_sanction/parsers/spreadsheet/archive.rb +258 -0
- data/lib/active_sanction/parsers/spreadsheet/reader.rb +213 -0
- data/lib/active_sanction/parsers/spreadsheet/row.rb +88 -0
- data/lib/active_sanction/parsers/spreadsheet/workbook.rb +314 -0
- data/lib/active_sanction/parsers/spreadsheet.rb +168 -0
- data/lib/active_sanction/parsers/xml_records/backends/nokogiri.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/backends/rexml.rb +157 -0
- data/lib/active_sanction/parsers/xml_records/backends.rb +103 -0
- data/lib/active_sanction/parsers/xml_records/builder.rb +90 -0
- data/lib/active_sanction/parsers/xml_records/reader.rb +134 -0
- data/lib/active_sanction/parsers/xml_records/record.rb +174 -0
- data/lib/active_sanction/parsers/xml_records.rb +140 -0
- data/lib/active_sanction/parsers.rb +94 -0
- data/lib/active_sanction/partial_date/parser.rb +124 -0
- data/lib/active_sanction/partial_date.rb +303 -0
- data/lib/active_sanction/payload_cache/checksum.rb +66 -0
- data/lib/active_sanction/payload_cache/entry.rb +304 -0
- data/lib/active_sanction/payload_cache.rb +366 -0
- data/lib/active_sanction/phonetics/double_metaphone.rb +662 -0
- data/lib/active_sanction/phonetics.rb +40 -0
- data/lib/active_sanction/query.rb +290 -0
- data/lib/active_sanction/rescreen/alert.rb +314 -0
- data/lib/active_sanction/rescreen.rb +332 -0
- data/lib/active_sanction/scorer/adjustments.rb +241 -0
- data/lib/active_sanction/scorer/name_score.rb +279 -0
- data/lib/active_sanction/scorer/reason.rb +136 -0
- data/lib/active_sanction/scorer/result.rb +129 -0
- data/lib/active_sanction/scorer/subject.rb +235 -0
- data/lib/active_sanction/scorer/weights.rb +306 -0
- data/lib/active_sanction/scorer.rb +303 -0
- data/lib/active_sanction/similarity/jaro_winkler.rb +210 -0
- data/lib/active_sanction/similarity/levenshtein.rb +179 -0
- data/lib/active_sanction/similarity/token_set.rb +159 -0
- data/lib/active_sanction/similarity/token_sort.rb +120 -0
- data/lib/active_sanction/similarity.rb +183 -0
- data/lib/active_sanction/snapshot/bundle/header.rb +302 -0
- data/lib/active_sanction/snapshot/bundle/payload.rb +185 -0
- data/lib/active_sanction/snapshot/bundle/signature.rb +201 -0
- data/lib/active_sanction/snapshot/bundle.rb +381 -0
- data/lib/active_sanction/snapshot.rb +305 -0
- data/lib/active_sanction/sources/australia_dfat/published_date.rb +166 -0
- data/lib/active_sanction/sources/australia_dfat/record.rb +319 -0
- data/lib/active_sanction/sources/australia_dfat.rb +291 -0
- data/lib/active_sanction/sources/base.rb +295 -0
- data/lib/active_sanction/sources/canada_sema/record.rb +282 -0
- data/lib/active_sanction/sources/canada_sema/source_ref.rb +89 -0
- data/lib/active_sanction/sources/canada_sema.rb +153 -0
- data/lib/active_sanction/sources/definition.rb +360 -0
- data/lib/active_sanction/sources/eu_fsf/record.rb +467 -0
- data/lib/active_sanction/sources/eu_fsf.rb +203 -0
- data/lib/active_sanction/sources/ofac/record.rb +251 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/coverage.rb +120 -0
- data/lib/active_sanction/sources/ofac/remarks_parser/vocabulary.rb +121 -0
- data/lib/active_sanction/sources/ofac/remarks_parser.rb +312 -0
- data/lib/active_sanction/sources/ofac.rb +248 -0
- data/lib/active_sanction/sources/ofac_consolidated/record.rb +37 -0
- data/lib/active_sanction/sources/ofac_consolidated.rb +203 -0
- data/lib/active_sanction/sources/ofac_sdn.rb +43 -0
- data/lib/active_sanction/sources/remarks.rb +84 -0
- data/lib/active_sanction/sources/uk_sanctions_list/published_date.rb +104 -0
- data/lib/active_sanction/sources/uk_sanctions_list/record.rb +412 -0
- data/lib/active_sanction/sources/uk_sanctions_list.rb +214 -0
- data/lib/active_sanction/sources/un_consolidated/record.rb +279 -0
- data/lib/active_sanction/sources/un_consolidated.rb +140 -0
- data/lib/active_sanction/sources.rb +196 -0
- data/lib/active_sanction/storage/active_record/reader.rb +124 -0
- data/lib/active_sanction/storage/active_record/row.rb +175 -0
- data/lib/active_sanction/storage/active_record/writer.rb +141 -0
- data/lib/active_sanction/storage/active_record.rb +277 -0
- data/lib/active_sanction/storage/base.rb +216 -0
- data/lib/active_sanction/storage/file_system.rb +373 -0
- data/lib/active_sanction/storage/memory.rb +67 -0
- data/lib/active_sanction/storage/meta.rb +188 -0
- data/lib/active_sanction/storage.rb +74 -0
- data/lib/active_sanction/subject.rb +271 -0
- data/lib/active_sanction/sync/report.rb +245 -0
- data/lib/active_sanction/sync/result.rb +296 -0
- data/lib/active_sanction/sync.rb +390 -0
- data/lib/active_sanction/validator_store/file_system.rb +103 -0
- data/lib/active_sanction/validator_store/memory.rb +41 -0
- data/lib/active_sanction/validator_store.rb +119 -0
- data/lib/active_sanction/validators.rb +195 -0
- data/lib/active_sanction/version.rb +23 -0
- data/lib/active_sanction.rb +331 -0
- data/lib/generators/active_sanction/install/install_generator.rb +60 -0
- data/lib/generators/active_sanction/install/templates/create_active_sanction_tables.rb.tt +142 -0
- metadata +236 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/sources"
|
|
7
|
+
require "active_sanction/sources/ofac"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
module Sources
|
|
11
|
+
# OFAC's Consolidated (non-SDN) list: everything the US sanctions that is
|
|
12
|
+
# not a Specially Designated National.
|
|
13
|
+
#
|
|
14
|
+
# snapshot = ActiveSanction::Sources[:ofac_consolidated].new.sync
|
|
15
|
+
#
|
|
16
|
+
# It is small -- 481 entities against the SDN list's 19,321 -- and it is
|
|
17
|
+
# screened anyway, because being on it still carries real legal weight. A
|
|
18
|
+
# Chinese semiconductor firm on the CMIC list cannot be invested in; a
|
|
19
|
+
# Russian bank on the SSI list can be transacted with but not lent to
|
|
20
|
+
# beyond a tenor the directive sets. Neither is a blocking sanction, and
|
|
21
|
+
# both are prohibitions a compliance team has to act on.
|
|
22
|
+
#
|
|
23
|
+
# ### One file, several lists
|
|
24
|
+
#
|
|
25
|
+
# OFAC ships the non-SDN lists as one set of three CSVs in the same shape
|
|
26
|
+
# as the SDN files -- the reading is Ofac's, and this adapter adds no
|
|
27
|
+
# parsing of its own -- but the rows in it belong to six different lists:
|
|
28
|
+
#
|
|
29
|
+
# Sectoral Sanctions Identifications (SSI) the Russia/Ukraine directives
|
|
30
|
+
# Non-SDN CMIC Chinese military-industrial firms
|
|
31
|
+
# Non-SDN Palestinian Legislative Council NS-PLC
|
|
32
|
+
# Non-SDN Menu-Based Sanctions (NS-MBS) HKAA, CAATSA, EO 14024 directives
|
|
33
|
+
# CAPTA foreign financial institutions
|
|
34
|
+
# FSE foreign sanctions evaders
|
|
35
|
+
#
|
|
36
|
+
# A hit on CMIC and a hit on NS-PLC are different findings with different
|
|
37
|
+
# consequences, so which one a record is on has to survive the parse.
|
|
38
|
+
# #lists answers it for any entity this adapter produced:
|
|
39
|
+
#
|
|
40
|
+
# ActiveSanction::Sources::OfacConsolidated.lists(entity) # => [:cmic]
|
|
41
|
+
# ActiveSanction::Sources::OfacConsolidated.names(entity) # => ["Non-SDN CMIC List"]
|
|
42
|
+
#
|
|
43
|
+
# It is a function of `entity.programs`, which is a canonical Entity member
|
|
44
|
+
# -- so it keeps working on a record that has been stored, serialized and
|
|
45
|
+
# read back, with no per-source column anywhere downstream. The names are
|
|
46
|
+
# also appended to `remarks` behind the `[source fields]` marker, which is
|
|
47
|
+
# where a compliance report reads them from.
|
|
48
|
+
#
|
|
49
|
+
# ### Why the programs and not a column
|
|
50
|
+
#
|
|
51
|
+
# There is no list column. CONS_PRIM.CSV has the same twelve columns as
|
|
52
|
+
# SDN.CSV, and the only thing in it that names a list is the program code
|
|
53
|
+
# -- `CMIC-EO13959`, `NS-PLC`, `HKAA`. OFAC states the membership
|
|
54
|
+
# explicitly in CONS_ADVANCED.XML, a 4.5 MB re-publication of the same 481
|
|
55
|
+
# records, and downloading the list twice to read one attribute off the
|
|
56
|
+
# second copy is a poor trade. So the program code is what LISTS maps.
|
|
57
|
+
#
|
|
58
|
+
# Checked against that XML's own attribution, the mapping is exact for 478
|
|
59
|
+
# of the 481 published records. The three it is not are all the same
|
|
60
|
+
# ambiguity, and SHARED is where it lives.
|
|
61
|
+
class OfacConsolidated < Ofac
|
|
62
|
+
extend T::Sig
|
|
63
|
+
|
|
64
|
+
key :ofac_consolidated
|
|
65
|
+
|
|
66
|
+
url :prim, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_PRIM.CSV"
|
|
67
|
+
url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_ALT.CSV"
|
|
68
|
+
url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/CONS_ADD.CSV"
|
|
69
|
+
|
|
70
|
+
# Ofac declares 0.90, calibrated on the SDN file, where RemarksParser
|
|
71
|
+
# reads about 97% of the segments. This list reads at 80.3%, and it is
|
|
72
|
+
# not a parse that has gone wrong: the CMIC rows publish a vocabulary the
|
|
73
|
+
# SDN file has no equivalent of -- `Effective Date (CMIC)`,
|
|
74
|
+
# `Listing Date (CMIC)`, `Purchase/Sales For Divestment`, `HKAA
|
|
75
|
+
# Section 5`, an equity ticker -- none of which is a name, a date of
|
|
76
|
+
# birth, a nationality or a document number, so none of it is anything
|
|
77
|
+
# this parser has a home for.
|
|
78
|
+
#
|
|
79
|
+
# Inheriting the SDN's floor made every first `ActiveSanction.doctor` run
|
|
80
|
+
# against a deployment with no stored snapshot warn about this list
|
|
81
|
+
# forever, with no fix available to whoever read the warning. 0.75 sits
|
|
82
|
+
# the same distance below what the file does as 0.90 does for the SDN,
|
|
83
|
+
# and it is a floor rather than a target: it exists for the run with
|
|
84
|
+
# nothing to compare against, and is not consulted once there is a
|
|
85
|
+
# snapshot or a committed baseline. Found by the canary (#69) on its
|
|
86
|
+
# first run against the published lists.
|
|
87
|
+
floor :remarks_coverage, 0.75
|
|
88
|
+
|
|
89
|
+
# The sub-lists, spelled the way OFAC's own `/sanctions-lists` endpoint
|
|
90
|
+
# spells them -- which is what a report has to print beside a hit.
|
|
91
|
+
#
|
|
92
|
+
# @api private
|
|
93
|
+
NAMES = T.let({
|
|
94
|
+
ssi: "Sectoral Sanctions Identifications List",
|
|
95
|
+
cmic: "Non-SDN CMIC List",
|
|
96
|
+
ns_plc: "Non-SDN Palestinian Legislative Council List",
|
|
97
|
+
ns_mbs: "Non-SDN Menu-Based Sanctions List",
|
|
98
|
+
capta: "CAPTA List",
|
|
99
|
+
fse: "FSE List"
|
|
100
|
+
}.freeze, T::Hash[Symbol, String])
|
|
101
|
+
|
|
102
|
+
# Program code to sub-list, for every program that names exactly one.
|
|
103
|
+
#
|
|
104
|
+
# A program absent from here is not an error: `SDGT` appears on three
|
|
105
|
+
# consolidated rows because those people are on the SDN list as well, and
|
|
106
|
+
# it says nothing about which non-SDN list they are on. FSE-IR and FSE-SY
|
|
107
|
+
# are carried because the FSE list is one OFAC still publishes and can
|
|
108
|
+
# refill, though nothing is on it today.
|
|
109
|
+
#
|
|
110
|
+
# @api private
|
|
111
|
+
LISTS = T.let({
|
|
112
|
+
"UKRAINE-EO13662" => :ssi,
|
|
113
|
+
"UKRAINE-EO13685" => :ssi,
|
|
114
|
+
"VENEZUELA-EO13850" => :ssi,
|
|
115
|
+
"IRAN-CON-ARMS-EO" => :ssi,
|
|
116
|
+
"CMIC-EO13959" => :cmic,
|
|
117
|
+
"NS-PLC" => :ns_plc,
|
|
118
|
+
"HKAA" => :ns_mbs,
|
|
119
|
+
"CAATSA - RUSSIA" => :ns_mbs,
|
|
120
|
+
"BURMA-EO14014" => :ns_mbs,
|
|
121
|
+
"ILLICIT-DRUGS-EO14059" => :ns_mbs,
|
|
122
|
+
"561-Related" => :capta,
|
|
123
|
+
"CAPTA" => :capta,
|
|
124
|
+
"FSE-IR" => :fse,
|
|
125
|
+
"FSE-SY" => :fse
|
|
126
|
+
}.freeze, T::Hash[String, Symbol])
|
|
127
|
+
|
|
128
|
+
# The one program OFAC uses for two lists, and the rule that reads it.
|
|
129
|
+
#
|
|
130
|
+
# EO 14024 is the Russia authority behind both the SSI directives and
|
|
131
|
+
# several menu-based determinations, so `RUSSIA-EO14024` alone means
|
|
132
|
+
# NS-MBS -- the Central Bank of Russia, the Ministry of Finance -- while
|
|
133
|
+
# `RUSSIA-EO14024` beside a program that is unambiguously SSI means the
|
|
134
|
+
# entity is on SSI under both authorities. That reads 92 of the 95 rows
|
|
135
|
+
# carrying it the way OFAC's own XML does.
|
|
136
|
+
#
|
|
137
|
+
# The three it does not are Gazprom, Transneft and Rosselkhozbank, which
|
|
138
|
+
# are on SSI *and* NS-MBS and come out marked SSI only. It is the safer
|
|
139
|
+
# direction of the two errors -- the record is still returned, still
|
|
140
|
+
# matched, still flagged as a non-SDN sanctions hit, and the program code
|
|
141
|
+
# OFAC published is on the entity verbatim for anyone who needs to look
|
|
142
|
+
# closer -- but it is an error, and nothing in the CSVs distinguishes
|
|
143
|
+
# those three from the 89 rows carrying the identical program pair.
|
|
144
|
+
#
|
|
145
|
+
# @api private
|
|
146
|
+
SHARED = T.let(
|
|
147
|
+
{ "RUSSIA-EO14024" => { with: :ssi, alone: :ns_mbs } }.freeze,
|
|
148
|
+
T::Hash[String, T::Hash[Symbol, Symbol]]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Which sub-lists a record is on, as an Array of the keys NAMES uses.
|
|
152
|
+
# Takes an Entity, or the programs themselves.
|
|
153
|
+
#
|
|
154
|
+
# OfacConsolidated.lists(entity) # => [:ssi]
|
|
155
|
+
# OfacConsolidated.lists(%w[UKRAINE-EO13662 RUSSIA-EO14024]) # => [:ssi]
|
|
156
|
+
# OfacConsolidated.lists(%w[RUSSIA-EO14024]) # => [:ns_mbs]
|
|
157
|
+
#
|
|
158
|
+
# Empty for a record whose programs name no list this adapter knows,
|
|
159
|
+
# which is what #parse warns about.
|
|
160
|
+
sig { params(programs: T.untyped).returns(T::Array[Symbol]) }
|
|
161
|
+
def self.lists(programs)
|
|
162
|
+
codes = programs.respond_to?(:programs) ? programs.programs : Array(programs)
|
|
163
|
+
certain = codes.filter_map { |code| LISTS[code] }.uniq
|
|
164
|
+
shared = codes.filter_map { |code| SHARED[code] }
|
|
165
|
+
.map { |rule| certain.include?(rule[:with]) ? rule[:with] : rule[:alone] }
|
|
166
|
+
(certain + shared).uniq
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The same answer as OFAC spells it, which is what goes in a report.
|
|
170
|
+
sig { params(programs: T.untyped).returns(T::Array[String]) }
|
|
171
|
+
def self.names(programs) = lists(programs).map { |list| NAMES.fetch(list) }
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
sig { override.returns(T.untyped) }
|
|
176
|
+
def record_class = Record
|
|
177
|
+
|
|
178
|
+
# A row whose programs name no sub-list is the signal that OFAC has
|
|
179
|
+
# added an authority: the entity is still returned, with its programs
|
|
180
|
+
# intact, but nothing downstream can say which list it puts it on until
|
|
181
|
+
# LISTS learns the code. Every other adapter's drift shows up as a
|
|
182
|
+
# parse warning, and so does this.
|
|
183
|
+
sig { override.params(record: T.untyped).void }
|
|
184
|
+
def note(record)
|
|
185
|
+
super
|
|
186
|
+
note_unattributed(record) if record.lists.empty?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
sig { params(record: T.untyped).void }
|
|
190
|
+
def note_unattributed(record)
|
|
191
|
+
@unmapped << Parsers::Warning.new(
|
|
192
|
+
line: record.row.line,
|
|
193
|
+
message: "row #{record.row[:ent_num].inspect} carries no program naming a consolidated sub-list " \
|
|
194
|
+
"(#{record.programs.join(", ")}); it is on none this adapter knows"
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
require "active_sanction/sources/ofac_consolidated/record"
|
|
202
|
+
|
|
203
|
+
ActiveSanction::Sources.register(ActiveSanction::Sources::OfacConsolidated)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/sources"
|
|
7
|
+
require "active_sanction/sources/ofac"
|
|
8
|
+
|
|
9
|
+
module ActiveSanction
|
|
10
|
+
module Sources
|
|
11
|
+
# The US Specially Designated Nationals list: the largest and most
|
|
12
|
+
# frequently screened sanctions list there is, and the one whose format
|
|
13
|
+
# dictates most of what the parsing toolkits have to handle.
|
|
14
|
+
#
|
|
15
|
+
# snapshot = ActiveSanction::Sources[:ofac_sdn].new.sync
|
|
16
|
+
#
|
|
17
|
+
# ### Three files, one list
|
|
18
|
+
#
|
|
19
|
+
# OFAC publishes the SDN list as three headerless CSVs joined on `ent_num`:
|
|
20
|
+
#
|
|
21
|
+
# SDN.CSV 19,321 rows primary names, type, programs, remarks
|
|
22
|
+
# ALT.CSV 20,147 rows aliases -- more of them than there are entities
|
|
23
|
+
# ADD.CSV 25,078 rows addresses
|
|
24
|
+
#
|
|
25
|
+
# Each is fetched and cached independently by Base, because they change
|
|
26
|
+
# independently; the join happens in Ofac#parse, which the consolidated
|
|
27
|
+
# list uses too -- the two files are the same twelve columns and are read
|
|
28
|
+
# by the same code.
|
|
29
|
+
#
|
|
30
|
+
# What is left here is the declaration: which list this is, and where its
|
|
31
|
+
# three files live. Everything else, including the free-text remarks
|
|
32
|
+
# parsing that gives the list its secondary identifiers, is in Ofac.
|
|
33
|
+
class OfacSdn < Ofac
|
|
34
|
+
key :ofac_sdn
|
|
35
|
+
|
|
36
|
+
url :sdn, "https://sanctionslistservice.ofac.treas.gov/api/download/SDN.CSV"
|
|
37
|
+
url :alt, "https://sanctionslistservice.ofac.treas.gov/api/download/ALT.CSV"
|
|
38
|
+
url :add, "https://sanctionslistservice.ofac.treas.gov/api/download/ADD.CSV"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
ActiveSanction::Sources.register(ActiveSanction::Sources::OfacSdn)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module ActiveSanction
|
|
7
|
+
module Sources
|
|
8
|
+
# The publisher's own free text, plus the fields the canonical model has no
|
|
9
|
+
# home for, kept together in one string.
|
|
10
|
+
#
|
|
11
|
+
# Remarks.build(row[:remarks], [["Vessel flag", "Panama"], ["Tonnage", "8000"]])
|
|
12
|
+
# # => "Registered in Panama [source fields] Vessel flag: Panama; Tonnage: 8000"
|
|
13
|
+
#
|
|
14
|
+
# Remarks.published(entity.remarks) # => "Registered in Panama"
|
|
15
|
+
#
|
|
16
|
+
# ### Why an adapter appends rather than drops
|
|
17
|
+
#
|
|
18
|
+
# Every list publishes something the canonical Entity has nowhere to put --
|
|
19
|
+
# OFAC a vessel's flag and owner, the UN a place of birth and a gender.
|
|
20
|
+
# Dropping it keeps the schema tidy at the cost of real screening signal,
|
|
21
|
+
# which is the wrong trade; putting it in remarks keeps it, at the cost of
|
|
22
|
+
# mixing it with the publisher's own prose.
|
|
23
|
+
#
|
|
24
|
+
# The marker is what pays that cost back. Anything reading the remark for
|
|
25
|
+
# what the publisher actually wrote -- OFAC's remarks parser (#19) is the
|
|
26
|
+
# first, and it must never see a vessel flag and read it as a nationality
|
|
27
|
+
# -- takes the text before the marker, which is what .published does.
|
|
28
|
+
#
|
|
29
|
+
# ### One marker, not one per source
|
|
30
|
+
#
|
|
31
|
+
# Both launch adapters invented this independently and gave it different
|
|
32
|
+
# spellings, which would have meant every consumer of a remark learning
|
|
33
|
+
# which source it came from before it could strip anything. There is one
|
|
34
|
+
# marker now, and one format, and adding a third source does not add a
|
|
35
|
+
# third convention.
|
|
36
|
+
module Remarks
|
|
37
|
+
extend T::Sig
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
MARKER = T.let("[source fields]", String)
|
|
41
|
+
|
|
42
|
+
# @api private
|
|
43
|
+
SEPARATOR = T.let("; ", String)
|
|
44
|
+
|
|
45
|
+
# `fields` is a list of label/value pairs. A value may be an Array -- the
|
|
46
|
+
# UN files three designations under one element -- and a label whose
|
|
47
|
+
# value is missing or blank is left out entirely rather than printed
|
|
48
|
+
# against an empty string.
|
|
49
|
+
sig { params(published: T.untyped, fields: T.untyped).returns(T.nilable(String)) }
|
|
50
|
+
def self.build(published, fields = [])
|
|
51
|
+
appended = Array(fields).filter_map { |label, value| entry(label, value) }
|
|
52
|
+
text = string_or_nil(published)
|
|
53
|
+
return text if appended.empty?
|
|
54
|
+
|
|
55
|
+
[text, MARKER, appended.join(SEPARATOR)].compact.join(" ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The publisher's own text, with anything an adapter appended stripped
|
|
59
|
+
# back off. nil when the publisher wrote nothing and every word in the
|
|
60
|
+
# remark was put there by us, which is the honest answer: a consumer
|
|
61
|
+
# asking what the publisher said should not be handed a vessel's tonnage.
|
|
62
|
+
sig { params(remarks: T.untyped).returns(T.nilable(String)) }
|
|
63
|
+
def self.published(remarks)
|
|
64
|
+
string_or_nil(remarks.to_s.split(MARKER, 2).first)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { params(label: T.untyped, value: T.untyped).returns(T.nilable(String)) }
|
|
68
|
+
def self.entry(label, value)
|
|
69
|
+
values = Array(value).filter_map { |item| string_or_nil(item) }
|
|
70
|
+
"#{label}: #{values.join(", ")}" if values.any?
|
|
71
|
+
end
|
|
72
|
+
private_class_method :entry
|
|
73
|
+
|
|
74
|
+
sig { params(value: T.untyped).returns(T.nilable(String)) }
|
|
75
|
+
def self.string_or_nil(value)
|
|
76
|
+
return nil if value.nil?
|
|
77
|
+
|
|
78
|
+
string = value.to_s.strip
|
|
79
|
+
string.empty? ? nil : string
|
|
80
|
+
end
|
|
81
|
+
private_class_method :string_or_nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/partial_date"
|
|
7
|
+
|
|
8
|
+
module ActiveSanction
|
|
9
|
+
module Sources
|
|
10
|
+
class UkSanctionsList < Base
|
|
11
|
+
# The UK Sanctions List's own date convention, which no general date
|
|
12
|
+
# parser reads and which is the single most valuable thing this adapter
|
|
13
|
+
# gets right.
|
|
14
|
+
#
|
|
15
|
+
# PublishedDate.call("04/08/2026") # => 2026-08-04, day precision
|
|
16
|
+
# PublishedDate.call("dd/mm/1945") # => 1945, year precision
|
|
17
|
+
# PublishedDate.call("dd/06/1945") # => 1945-06, month precision
|
|
18
|
+
# PublishedDate.call("00/00/1975") # => 1975, year precision
|
|
19
|
+
# PublishedDate.call("1945") # => 1945, year precision
|
|
20
|
+
# PublishedDate.call("15/08/19yy") # => nil
|
|
21
|
+
#
|
|
22
|
+
# ### The placeholders are the point
|
|
23
|
+
#
|
|
24
|
+
# The FCDO writes every date `DD/MM/YYYY`, and where it does not know a
|
|
25
|
+
# component it writes the component's own letters in its place: 800 of
|
|
26
|
+
# the 3,788 published birth dates read `dd/mm/1962`, and 23 read
|
|
27
|
+
# `dd/07/1978`. One record uses `00` for the same purpose --
|
|
28
|
+
# `00/00/1975`. That is a publisher stating precision, and it maps onto
|
|
29
|
+
# PartialDate exactly.
|
|
30
|
+
#
|
|
31
|
+
# Read with an ordinary parser those become nil, and 824 of 3,788 birth
|
|
32
|
+
# dates -- 22% of everything the list says about when a person was born
|
|
33
|
+
# -- silently disappear. Read *credulously*, as a zeroth day of a zeroth
|
|
34
|
+
# month, they become invalid dates or, worse, dates the scorer would
|
|
35
|
+
# compare as though the FCDO had been precise.
|
|
36
|
+
#
|
|
37
|
+
# ### `19yy` gets no date, on purpose
|
|
38
|
+
#
|
|
39
|
+
# One record publishes `15/08/19yy`: a day and a month, and a century
|
|
40
|
+
# where the year should be. PartialDate has no shape for a date with no
|
|
41
|
+
# year, and inventing one -- a 1900-1999 span, which would throw the day
|
|
42
|
+
# and month away, or a year of 1900, which would be a fact nobody
|
|
43
|
+
# published -- is exactly the false precision PartialDate exists to
|
|
44
|
+
# prevent. It reads as nil and the adapter keeps the published string in
|
|
45
|
+
# `remarks` instead.
|
|
46
|
+
#
|
|
47
|
+
# @api private
|
|
48
|
+
module PublishedDate
|
|
49
|
+
extend T::Sig
|
|
50
|
+
|
|
51
|
+
SEPARATOR = T.let("/", String)
|
|
52
|
+
|
|
53
|
+
# A component the FCDO filled in. Anything else -- `dd`, `mm`, `yy`,
|
|
54
|
+
# `00`, an empty field -- is the publisher saying it does not know,
|
|
55
|
+
# which is not the same as a zero and must never be read as one.
|
|
56
|
+
KNOWN = T.let(/\A0*[1-9]\d*\z/, Regexp)
|
|
57
|
+
|
|
58
|
+
YEAR_ONLY = T.let(/\A\d{4}\z/, Regexp)
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# The PartialDate a UKSL date string states, at the precision it
|
|
63
|
+
# states it, or nil for one that states no year at all.
|
|
64
|
+
#
|
|
65
|
+
# Returns nil rather than raising, for the reason PartialDate::Parser
|
|
66
|
+
# does: a birth date is one field on a record, and a string this does
|
|
67
|
+
# not read should not abort the import of the entity around it. The
|
|
68
|
+
# adapter records what it could not read.
|
|
69
|
+
sig { params(text: T.untyped).returns(T.nilable(PartialDate)) }
|
|
70
|
+
def call(text)
|
|
71
|
+
string = text.to_s.strip
|
|
72
|
+
return nil if string.empty?
|
|
73
|
+
return build(year: string) if string.match?(YEAR_ONLY)
|
|
74
|
+
|
|
75
|
+
day, month, year = string.split(SEPARATOR, 3).map { |part| component(part) }
|
|
76
|
+
return nil if year.nil?
|
|
77
|
+
|
|
78
|
+
# A day the FCDO gave under a month it did not is a day of an unknown
|
|
79
|
+
# month, which PartialDate rightly refuses. The month is the part
|
|
80
|
+
# that is missing, so the day goes with it.
|
|
81
|
+
build(year: year, month: month, day: (day if month))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# nil for a placeholder, so that "the FCDO did not say" and "the FCDO
|
|
85
|
+
# said zero" cannot be confused downstream.
|
|
86
|
+
sig { params(part: T.untyped).returns(T.nilable(Integer)) }
|
|
87
|
+
def component(part)
|
|
88
|
+
string = part.to_s.strip
|
|
89
|
+
string.match?(KNOWN) ? string.to_i : nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A shape these regexes accept can still be an impossible date -- the
|
|
93
|
+
# list publishes no 31 February today, but a screening tool should not
|
|
94
|
+
# be the thing that breaks on the day it does.
|
|
95
|
+
sig { params(attributes: T.untyped).returns(T.nilable(PartialDate)) }
|
|
96
|
+
def build(**attributes)
|
|
97
|
+
PartialDate.new(**attributes)
|
|
98
|
+
rescue ArgumentError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|