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,227 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "time"
|
|
7
|
+
require "active_sanction/error"
|
|
8
|
+
require "active_sanction/doctor/diagnosis"
|
|
9
|
+
require "active_sanction/doctor/finding"
|
|
10
|
+
|
|
11
|
+
module ActiveSanction
|
|
12
|
+
class Doctor
|
|
13
|
+
# What a whole diagnostic run found, one Diagnosis per source.
|
|
14
|
+
#
|
|
15
|
+
# report = ActiveSanction.doctor
|
|
16
|
+
#
|
|
17
|
+
# report.ok? # => false
|
|
18
|
+
# report.findings # => [Finding, ...]
|
|
19
|
+
# report[:ofac_sdn] # => Diagnosis
|
|
20
|
+
# exit report.exit_code
|
|
21
|
+
# puts report
|
|
22
|
+
#
|
|
23
|
+
# 2 sources in 18.42s: 1 with findings
|
|
24
|
+
# ofac_sdn WARN 3 findings
|
|
25
|
+
# warn remarks coverage 71.4% (was 97.3%): "Passport No." x 1,880 unrecognized
|
|
26
|
+
# warn individuals with a date of birth 12% (was 61%) of 11,704
|
|
27
|
+
# info unknown SDN_Type "syndicate"; treated as an organization (41 rows)
|
|
28
|
+
# un_consolidated OK
|
|
29
|
+
#
|
|
30
|
+
# ### It is an object, not console output
|
|
31
|
+
#
|
|
32
|
+
# The same split Sync::Report makes, for the same reason. The human form is
|
|
33
|
+
# what a CLI verb (#36) prints; the serialized form is what a host
|
|
34
|
+
# application alerts on, what a nightly job keeps so that next week's run
|
|
35
|
+
# has a warning class to compare against, and what the instrumentation
|
|
36
|
+
# hooks (#59) emit. A diagnostic that only existed as printed text would
|
|
37
|
+
# mean every host that wants to notice a drifting source has to scrape a
|
|
38
|
+
# log, which is precisely the state this exists to end.
|
|
39
|
+
#
|
|
40
|
+
# ### The exit code is a policy, and it is the caller's
|
|
41
|
+
#
|
|
42
|
+
# `exit_code` is 1 when anything failed at `error`, because a list that
|
|
43
|
+
# cannot be read is not a matter of taste. Whether a `warn` should also
|
|
44
|
+
# stop a deployment is, so it is a parameter: `exit_code(on: :warn)` is
|
|
45
|
+
# what a team that treats drift as a build failure passes.
|
|
46
|
+
#
|
|
47
|
+
# Instances are frozen on construction and compare by value.
|
|
48
|
+
class Report
|
|
49
|
+
extend T::Sig
|
|
50
|
+
extend T::Generic
|
|
51
|
+
include Enumerable
|
|
52
|
+
|
|
53
|
+
# @api private
|
|
54
|
+
Elem = type_member { { fixed: Diagnosis } }
|
|
55
|
+
|
|
56
|
+
# @api private
|
|
57
|
+
MEMBERS = T.let(%i[diagnoses started_at duration].freeze, T::Array[Symbol])
|
|
58
|
+
|
|
59
|
+
sig { returns(T::Array[Diagnosis]) }
|
|
60
|
+
attr_reader :diagnoses
|
|
61
|
+
|
|
62
|
+
# When the run began, UTC.
|
|
63
|
+
sig { returns(Time) }
|
|
64
|
+
attr_reader :started_at
|
|
65
|
+
|
|
66
|
+
# Wall-clock seconds for the whole run.
|
|
67
|
+
sig { returns(Float) }
|
|
68
|
+
attr_reader :duration
|
|
69
|
+
|
|
70
|
+
sig { params(hash: T.untyped).returns(T.attached_class) }
|
|
71
|
+
def self.from_h(hash)
|
|
72
|
+
attributes = hash.to_h.transform_keys(&:to_sym)
|
|
73
|
+
unknown = attributes.keys - MEMBERS
|
|
74
|
+
raise InvalidArgument, "unknown Doctor::Report attribute(s): #{unknown.join(", ")}" if unknown.any?
|
|
75
|
+
|
|
76
|
+
T.unsafe(self).new(**attributes)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { params(diagnoses: T.untyped, started_at: T.untyped, duration: T.untyped).void }
|
|
80
|
+
def initialize(diagnoses:, started_at: nil, duration: 0.0)
|
|
81
|
+
@diagnoses = T.let(diagnoses!(diagnoses), T::Array[Diagnosis])
|
|
82
|
+
@started_at = T.let(time!(started_at), Time)
|
|
83
|
+
@duration = T.let(duration.to_f, Float)
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { override.params(block: T.nilable(T.proc.params(diagnosis: Diagnosis).void)).returns(T.untyped) }
|
|
88
|
+
def each(&block)
|
|
89
|
+
return enum_for(:each) unless block
|
|
90
|
+
|
|
91
|
+
diagnoses.each(&block)
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One source's diagnosis, or nil if the run did not cover it.
|
|
96
|
+
sig { params(source: T.untyped).returns(T.nilable(Diagnosis)) }
|
|
97
|
+
def [](source)
|
|
98
|
+
key = source.to_sym
|
|
99
|
+
diagnoses.find { |diagnosis| diagnosis.source == key }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
sig { returns(T::Array[Symbol]) }
|
|
103
|
+
def sources = diagnoses.map(&:source)
|
|
104
|
+
|
|
105
|
+
# Every finding across every source, most serious first, and within a
|
|
106
|
+
# severity in the order the sources were diagnosed.
|
|
107
|
+
sig { returns(T::Array[Finding]) }
|
|
108
|
+
def findings
|
|
109
|
+
diagnoses.flat_map(&:findings)
|
|
110
|
+
.sort_by.with_index { |finding, at| [-Finding::SEVERITIES.index(finding.severity).to_i, at] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
sig { returns(T::Array[Finding]) }
|
|
114
|
+
def errors = findings.select(&:error?)
|
|
115
|
+
|
|
116
|
+
sig { returns(T::Array[Finding]) }
|
|
117
|
+
def warnings = findings.select(&:warn?)
|
|
118
|
+
|
|
119
|
+
sig { returns(T::Array[Finding]) }
|
|
120
|
+
def infos = findings.select(&:info?)
|
|
121
|
+
|
|
122
|
+
# Nothing above `info`, anywhere. What a nightly job alerts on when it
|
|
123
|
+
# only wants one question answered.
|
|
124
|
+
sig { returns(T::Boolean) }
|
|
125
|
+
def ok? = diagnoses.all?(&:ok?)
|
|
126
|
+
|
|
127
|
+
# The sources with something worth reading about them.
|
|
128
|
+
sig { returns(T::Array[Diagnosis]) }
|
|
129
|
+
def unhealthy = diagnoses.reject(&:ok?)
|
|
130
|
+
|
|
131
|
+
# The sources that could not be diagnosed at all -- a publisher that is
|
|
132
|
+
# down, a payload that is not the format it should be. Louder than a
|
|
133
|
+
# finding, and a different question: nothing here knows whether those
|
|
134
|
+
# lists have drifted.
|
|
135
|
+
sig { returns(T::Array[Diagnosis]) }
|
|
136
|
+
def failed = diagnoses.select(&:failed?)
|
|
137
|
+
|
|
138
|
+
sig { returns(T::Boolean) }
|
|
139
|
+
def failed? = diagnoses.any?(&:failed?)
|
|
140
|
+
|
|
141
|
+
# The most serious severity anywhere in the run, or nil for a clean one.
|
|
142
|
+
sig { returns(T.nilable(Symbol)) }
|
|
143
|
+
def severity
|
|
144
|
+
Finding::SEVERITIES.reverse.find { |level| diagnoses.any? { |one| one.severity == level } }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
sig { returns(Integer) }
|
|
148
|
+
def size = diagnoses.size
|
|
149
|
+
|
|
150
|
+
sig { returns(T::Boolean) }
|
|
151
|
+
def empty? = diagnoses.empty?
|
|
152
|
+
|
|
153
|
+
# What a scheduled job should exit with. 1 on any `error` by default, and
|
|
154
|
+
# `on: :warn` for a caller that wants drift to stop a build too. See the
|
|
155
|
+
# class comment.
|
|
156
|
+
sig { params(on: T.untyped).returns(Integer) }
|
|
157
|
+
def exit_code(on: :error)
|
|
158
|
+
level = on.to_sym
|
|
159
|
+
findings.any? { |finding| finding.at_least?(level) } ? 1 : 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
163
|
+
def to_h
|
|
164
|
+
{ diagnoses: diagnoses.map(&:to_h), started_at: started_at.iso8601, duration: duration }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The profile of each source, keyed by source -- what a nightly job keeps
|
|
168
|
+
# so that the next run has last night's warning classes and free-text
|
|
169
|
+
# coverage to compare against, which a stored snapshot cannot supply.
|
|
170
|
+
# See Doctor#baseline.
|
|
171
|
+
sig { returns(T::Hash[Symbol, Profile]) }
|
|
172
|
+
def profiles
|
|
173
|
+
diagnoses.each_with_object({}) do |diagnosis, all|
|
|
174
|
+
profile = diagnosis.profile
|
|
175
|
+
all[diagnosis.source] = profile unless profile.nil?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
sig { returns(String) }
|
|
180
|
+
def summary
|
|
181
|
+
counts = { "with findings" => unhealthy.size, "unreadable" => failed.size }
|
|
182
|
+
.reject { |_label, count| count.zero? }
|
|
183
|
+
.map { |label, count| "#{count} #{label}" }
|
|
184
|
+
"#{size} #{size == 1 ? "source" : "sources"} in #{format("%.2f", duration)}s" \
|
|
185
|
+
"#{": #{counts.empty? ? "all healthy" : counts.join(", ")}"}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sig { returns(String) }
|
|
189
|
+
def to_s = ([summary] + diagnoses.flat_map { |diagnosis| diagnosis.lines(width) }).join("\n")
|
|
190
|
+
|
|
191
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
|
192
|
+
def ==(other)
|
|
193
|
+
return false unless other.instance_of?(self.class)
|
|
194
|
+
|
|
195
|
+
to_h == other.to_h
|
|
196
|
+
end
|
|
197
|
+
alias eql? ==
|
|
198
|
+
|
|
199
|
+
sig { returns(Integer) }
|
|
200
|
+
def hash = [self.class, to_h].hash
|
|
201
|
+
|
|
202
|
+
sig { returns(String) }
|
|
203
|
+
def inspect = "#<#{self.class} #{summary}>"
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
sig { returns(Integer) }
|
|
208
|
+
def width = diagnoses.map { |diagnosis| diagnosis.source.to_s.length }.max.to_i
|
|
209
|
+
|
|
210
|
+
sig { params(value: T.untyped).returns(T::Array[Diagnosis]) }
|
|
211
|
+
def diagnoses!(value)
|
|
212
|
+
Array(value).map { |one| one.is_a?(Diagnosis) ? one : Diagnosis.from_h(one) }.freeze
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
sig { params(value: T.untyped).returns(Time) }
|
|
216
|
+
def time!(value)
|
|
217
|
+
time = case value
|
|
218
|
+
when nil then Time.now
|
|
219
|
+
when Time then value
|
|
220
|
+
when String then Time.parse(value)
|
|
221
|
+
else raise InvalidArgument, "started_at is not a time: #{value.inspect}"
|
|
222
|
+
end
|
|
223
|
+
Time.at(time.to_i).utc
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "active_sanction/error"
|
|
7
|
+
require "active_sanction/fetcher"
|
|
8
|
+
require "active_sanction/sources"
|
|
9
|
+
require "active_sanction/storage"
|
|
10
|
+
require "active_sanction/validator_store"
|
|
11
|
+
require "active_sanction/doctor/checkup"
|
|
12
|
+
require "active_sanction/doctor/diagnosis"
|
|
13
|
+
require "active_sanction/doctor/finding"
|
|
14
|
+
require "active_sanction/doctor/profile"
|
|
15
|
+
require "active_sanction/doctor/report"
|
|
16
|
+
|
|
17
|
+
module ActiveSanction
|
|
18
|
+
# Diagnoses whether a source's format has drifted: fetches each list, parses
|
|
19
|
+
# it, measures it, and compares the measurements against the last version
|
|
20
|
+
# that was stored.
|
|
21
|
+
#
|
|
22
|
+
# report = ActiveSanction.doctor # every configured source
|
|
23
|
+
# report = ActiveSanction.doctor(:ofac_sdn) # one
|
|
24
|
+
#
|
|
25
|
+
# report.ok? # => false
|
|
26
|
+
# report.findings # => [Finding(source:, severity:, check:, message:, observed:, baseline:)]
|
|
27
|
+
# exit report.exit_code
|
|
28
|
+
#
|
|
29
|
+
# ### The failure this exists to catch
|
|
30
|
+
#
|
|
31
|
+
# Sanctions lists change format on three clocks. A whole-format migration is
|
|
32
|
+
# announced years ahead and fails loudly. A column added or an element
|
|
33
|
+
# renamed happens quietly, in months. A new document label or a new
|
|
34
|
+
# designation vocabulary happens continuously, weekly.
|
|
35
|
+
#
|
|
36
|
+
# Only the first of those fails loudly. The dangerous ones are the changes
|
|
37
|
+
# where the file still parses cleanly and means something different: 19,321
|
|
38
|
+
# entities carrying zero passports looks exactly as healthy as 19,321
|
|
39
|
+
# carrying 23,429 if all anyone counts is records. Nothing in a sync would
|
|
40
|
+
# notice that for months, and a screening run against it returns a clean
|
|
41
|
+
# result for a customer whose passport is on the list.
|
|
42
|
+
#
|
|
43
|
+
# So this measures what a sync does not: the share of records carrying each
|
|
44
|
+
# field, the vocabulary the parser recognized, the shape of the values in a
|
|
45
|
+
# positional column, the classes of warning the parse produced. See Profile
|
|
46
|
+
# for what is measured and Checkup for what is made of it.
|
|
47
|
+
#
|
|
48
|
+
# ### It never writes anything
|
|
49
|
+
#
|
|
50
|
+
# Not the snapshot, not the payload cache, not the conditional-GET
|
|
51
|
+
# validators. Each adapter the doctor builds gets a fetcher over an in-memory
|
|
52
|
+
# validator store and no payload cache, which has two consequences worth
|
|
53
|
+
# stating:
|
|
54
|
+
#
|
|
55
|
+
# - every run downloads every list in full, because a diagnosis of a list the
|
|
56
|
+
# publisher answered 304 for is a diagnosis of nothing; and
|
|
57
|
+
# - a doctor run before a sync cannot make that sync skip a changed list.
|
|
58
|
+
# Sharing the validators would do exactly that -- the doctor's fetch would
|
|
59
|
+
# learn the new ETag, the sync that followed would be answered 304, and the
|
|
60
|
+
# list it decided was unchanged would be the one the doctor had just seen
|
|
61
|
+
# change. Diagnosing a source must not be able to stop it being updated.
|
|
62
|
+
#
|
|
63
|
+
# Nothing is repaired either. Deciding that a 40% drop in record count is a
|
|
64
|
+
# delisting wave rather than a broken parse is a judgment call, and making it
|
|
65
|
+
# automatically is how a compliance tool ends up quietly screening against
|
|
66
|
+
# nothing.
|
|
67
|
+
#
|
|
68
|
+
# It is not a cheap run, and it is not meant to be: every list is downloaded,
|
|
69
|
+
# parsed, and parsed again where a positional file's columns are asserted,
|
|
70
|
+
# and the stored snapshot is read in full so its fill rates can be recomputed
|
|
71
|
+
# as the baseline. That is the price of comparing two parses rather than two
|
|
72
|
+
# file sizes, and it is charged once a night rather than once a sync.
|
|
73
|
+
#
|
|
74
|
+
# ### One source failing does not stop the others
|
|
75
|
+
#
|
|
76
|
+
# The same rule sync orchestration runs under, and for the same reason:
|
|
77
|
+
# government endpoints go down, and a UN outage must not stop OFAC being
|
|
78
|
+
# diagnosed. Each source runs inside its own rescue and a failure becomes an
|
|
79
|
+
# `error` finding on that source alone.
|
|
80
|
+
#
|
|
81
|
+
# ### Where this is meant to run
|
|
82
|
+
#
|
|
83
|
+
# In a nightly job, not in a terminal. A `doctor` invoked by hand only
|
|
84
|
+
# confirms a regression that was already suspected; the whole value here is
|
|
85
|
+
# noticing one nobody suspected, which means something has to run it when
|
|
86
|
+
# nobody is looking and alert when it says something. `exit_code` is for the
|
|
87
|
+
# cron job, `to_h` is for the metrics pipeline, and `to_s` is for the CLI
|
|
88
|
+
# verb (#36) that will print it.
|
|
89
|
+
class Doctor
|
|
90
|
+
extend T::Sig
|
|
91
|
+
|
|
92
|
+
# The adapters this run covers.
|
|
93
|
+
sig { returns(T::Array[T.untyped]) }
|
|
94
|
+
attr_reader :sources
|
|
95
|
+
|
|
96
|
+
sig { returns(T::Array[Symbol]) }
|
|
97
|
+
attr_reader :keys
|
|
98
|
+
|
|
99
|
+
sig { returns(T.untyped) }
|
|
100
|
+
attr_reader :store
|
|
101
|
+
|
|
102
|
+
# How far a measurement may move from its baseline before it is worth a
|
|
103
|
+
# finding, as a share of what it was.
|
|
104
|
+
sig { returns(Float) }
|
|
105
|
+
attr_reader :tolerance
|
|
106
|
+
|
|
107
|
+
sig { returns(T.untyped) }
|
|
108
|
+
attr_reader :logger
|
|
109
|
+
|
|
110
|
+
sig { params(options: T.untyped, block: T.untyped).returns(Report) }
|
|
111
|
+
def self.call(**options, &block) = T.unsafe(self).new(**options).call(&block)
|
|
112
|
+
|
|
113
|
+
# `sources:` takes source keys, adapter classes, adapter instances, or nil
|
|
114
|
+
# for whatever `config.sources` names.
|
|
115
|
+
#
|
|
116
|
+
# `baseline:` is what a previous run measured -- a Doctor::Report, or a
|
|
117
|
+
# Hash of source to Profile -- for the checks a stored snapshot cannot
|
|
118
|
+
# supply. Everything derived from the entities is recomputed from what is
|
|
119
|
+
# in storage and needs nothing passed here; the warning classes and the
|
|
120
|
+
# free-text coverage exist only during a parse, so a host that wants those
|
|
121
|
+
# compared week to week keeps the last report and hands it back:
|
|
122
|
+
#
|
|
123
|
+
# yesterday = JSON.parse(File.read("doctor.json"))
|
|
124
|
+
# report = ActiveSanction.doctor(baseline: Doctor::Report.from_h(yesterday))
|
|
125
|
+
# File.write("doctor.json", JSON.generate(report.to_h))
|
|
126
|
+
#
|
|
127
|
+
# A supplied profile is used only where it describes the same list version
|
|
128
|
+
# that is in storage; where it does not, storage wins, because a profile
|
|
129
|
+
# from three syncs ago would report drift that has already been reviewed.
|
|
130
|
+
sig do
|
|
131
|
+
params(sources: T.untyped, store: T.untyped, baseline: T.untyped, tolerance: T.untyped,
|
|
132
|
+
logger: T.untyped).void
|
|
133
|
+
end
|
|
134
|
+
def initialize(sources: nil, store: nil, baseline: nil, tolerance: nil, logger: ActiveSanction.config.logger)
|
|
135
|
+
@sources = T.let(resolve(sources), T::Array[T.untyped])
|
|
136
|
+
@keys = T.let(@sources.map { |source| Sources::Definition.key!(source.key) }, T::Array[Symbol])
|
|
137
|
+
@store = T.let(store || ActiveSanction.storage, T.untyped)
|
|
138
|
+
@recorded = T.let(baselines!(baseline), T::Hash[Symbol, Profile])
|
|
139
|
+
@tolerance = T.let(
|
|
140
|
+
Configuration.doctor_tolerance!(tolerance || ActiveSanction.config.doctor_tolerance), Float
|
|
141
|
+
)
|
|
142
|
+
@logger = T.let(logger, T.untyped)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Runs the diagnosis and returns the Report. Never raises for a source that
|
|
146
|
+
# could not be read -- that is what an `error` finding is for.
|
|
147
|
+
#
|
|
148
|
+
# The optional block is the progress hook: it is called with each Diagnosis
|
|
149
|
+
# as that source finishes. Sources are diagnosed one at a time, because
|
|
150
|
+
# this is a job nobody is waiting on and downloading four government lists
|
|
151
|
+
# at once to save four minutes of it is not a trade worth making.
|
|
152
|
+
sig { params(block: T.nilable(T.proc.params(diagnosis: Diagnosis).void)).returns(Report) }
|
|
153
|
+
def call(&block)
|
|
154
|
+
started_at = Time.now.utc
|
|
155
|
+
began = monotonic
|
|
156
|
+
log(:info, "diagnosing #{keys.size} source(s): #{keys.join(", ")}")
|
|
157
|
+
diagnoses = keys.each_with_index.map do |key, at|
|
|
158
|
+
diagnose(key, sources.fetch(at)).tap do |diagnosis|
|
|
159
|
+
log_diagnosis(diagnosis)
|
|
160
|
+
block&.call(diagnosis)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
report = Report.new(diagnoses: diagnoses, started_at: started_at, duration: elapsed(began))
|
|
164
|
+
log(report.ok? ? :info : :warn, "diagnosed #{report.summary}")
|
|
165
|
+
report
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
sig { returns(String) }
|
|
169
|
+
def inspect = "#<#{self.class} #{keys.join(", ")} tolerance=#{tolerance}>"
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# One source, start to finish, inside its own rescue. Nothing in here may
|
|
174
|
+
# raise past this method, and nothing in here may write anything.
|
|
175
|
+
sig { params(key: Symbol, source: T.untyped).returns(Diagnosis) }
|
|
176
|
+
def diagnose(key, source)
|
|
177
|
+
started = monotonic
|
|
178
|
+
baseline = T.let(nil, T.nilable(Profile))
|
|
179
|
+
begin
|
|
180
|
+
adapter = isolate(source)
|
|
181
|
+
baseline, findings = baseline_for(key)
|
|
182
|
+
observed = measure(key, adapter)
|
|
183
|
+
findings += Checkup.new(source: key, observed: observed, baseline: baseline, floors: floors(adapter),
|
|
184
|
+
tolerance: tolerance).findings
|
|
185
|
+
Diagnosis.new(source: key, status: :checked, findings: findings, profile: observed,
|
|
186
|
+
baseline: baseline, duration: elapsed(started))
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
failure(key, stamp(key, e), baseline, started)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Fetch, parse, measure. `force: true` because the diagnosis is of the
|
|
193
|
+
# bytes the publisher is serving now, and a 304 would have this reporting
|
|
194
|
+
# on a parse that did not happen.
|
|
195
|
+
sig { params(key: Symbol, adapter: T.untyped).returns(Profile) }
|
|
196
|
+
def measure(key, adapter)
|
|
197
|
+
payloads = adapter.retrieve(force: true)
|
|
198
|
+
raise Sources::MissingPayload, "#{key} answered nothing to an unconditional request" if payloads.nil?
|
|
199
|
+
|
|
200
|
+
snapshot = adapter.snapshot(payloads)
|
|
201
|
+
Profile.measure(snapshot, adapter: adapter, columns: tallies(key, adapter, payloads))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The positional-column assertions, for an adapter that declares any. A
|
|
205
|
+
# source is still diagnosed when they cannot be run: the rest of the
|
|
206
|
+
# checkup is worth having, and the reason they could not be run is a bug
|
|
207
|
+
# here rather than a fact about the publisher's file.
|
|
208
|
+
sig { params(key: Symbol, adapter: T.untyped, payloads: T.untyped).returns(T::Array[T.untyped]) }
|
|
209
|
+
def tallies(key, adapter, payloads)
|
|
210
|
+
return [] unless adapter.respond_to?(:column_tallies)
|
|
211
|
+
|
|
212
|
+
adapter.column_tallies(payloads)
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
log(:warn, "#{key} column shapes could not be checked (#{e.class}: #{e.message})")
|
|
215
|
+
[]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# What this source was last measured to be, and any findings raised by
|
|
219
|
+
# trying to find out. A stored snapshot that no longer hashes to its
|
|
220
|
+
# checksum is an `error` in its own right -- the list being screened
|
|
221
|
+
# against cannot prove what it contains -- and leaves the run with no
|
|
222
|
+
# baseline, which is a different thing from a clean comparison.
|
|
223
|
+
sig { params(key: Symbol).returns([T.nilable(Profile), T::Array[Finding]]) }
|
|
224
|
+
def baseline_for(key)
|
|
225
|
+
stored = Profile.measure(store.fetch_snapshot(key))
|
|
226
|
+
recorded = @recorded[key]
|
|
227
|
+
[recorded && recorded.checksum == stored.checksum ? recorded : stored, []]
|
|
228
|
+
rescue Storage::MissingSnapshot
|
|
229
|
+
[@recorded[key], []]
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
[nil, [Finding.new(source: key, severity: :error, check: :baseline,
|
|
232
|
+
message: "the stored snapshot could not be read, so nothing was compared: " \
|
|
233
|
+
"#{e.class}: #{e.message}")]]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
sig do
|
|
237
|
+
params(key: Symbol, error: StandardError, baseline: T.nilable(Profile), started: Float).returns(Diagnosis)
|
|
238
|
+
end
|
|
239
|
+
def failure(key, error, baseline, started)
|
|
240
|
+
finding = Finding.new(source: key, severity: :error, check: :parse,
|
|
241
|
+
message: "could not be read: #{error.class}: #{error.message}")
|
|
242
|
+
Diagnosis.new(source: key, status: :failed, findings: [finding], baseline: baseline,
|
|
243
|
+
duration: elapsed(started), error: error)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# An adapter built to leave no trace: no payload cache, and validators that
|
|
247
|
+
# live and die with this run. See the class comment on why sharing them
|
|
248
|
+
# would be unsafe. An instance a caller built themselves is used as it
|
|
249
|
+
# stands, and fetches through whatever it was built with.
|
|
250
|
+
sig { params(source: T.untyped).returns(T.untyped) }
|
|
251
|
+
def isolate(source)
|
|
252
|
+
return source unless source.is_a?(Class)
|
|
253
|
+
return T.unsafe(source).new unless isolatable?(source)
|
|
254
|
+
|
|
255
|
+
T.unsafe(source).new(fetcher: Fetcher.new(store: ValidatorStore::Memory.new, logger: logger),
|
|
256
|
+
cache: nil, logger: logger)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Whether a source class takes the keywords Sources::Base does. A third
|
|
260
|
+
# party's adapter that does not is built plainly rather than not at all.
|
|
261
|
+
sig { params(klass: T.untyped).returns(T::Boolean) }
|
|
262
|
+
def isolatable?(klass)
|
|
263
|
+
parameters = klass.instance_method(:initialize).parameters
|
|
264
|
+
return true if parameters.any? { |(kind, _name)| kind == :keyrest }
|
|
265
|
+
|
|
266
|
+
named = parameters.select { |(kind, _name)| %i[key keyreq].include?(kind) }.map(&:last)
|
|
267
|
+
(%i[fetcher cache logger] - named).empty?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The lower bounds this adapter committed to, for the checks that have no
|
|
271
|
+
# baseline to compare against. See Sources::Definition#floor.
|
|
272
|
+
sig { params(adapter: T.untyped).returns(T::Hash[Symbol, Numeric]) }
|
|
273
|
+
def floors(adapter) = adapter.respond_to?(:floors) ? adapter.floors : {}
|
|
274
|
+
|
|
275
|
+
sig { params(requested: T.untyped).returns(T::Array[T.untyped]) }
|
|
276
|
+
def resolve(requested)
|
|
277
|
+
listed = Array(requested).flatten.compact
|
|
278
|
+
return Sources.enabled if listed.empty?
|
|
279
|
+
|
|
280
|
+
listed.map { |source| adapter!(source) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
sig { params(source: T.untyped).returns(T.untyped) }
|
|
284
|
+
def adapter!(source)
|
|
285
|
+
return Sources[source] if source.is_a?(Symbol) || source.is_a?(String)
|
|
286
|
+
return source if source.respond_to?(:key) && source.respond_to?(source.is_a?(Class) ? :new : :retrieve)
|
|
287
|
+
|
|
288
|
+
raise InvalidArgument, "a source must be a registered key, or answer .key and .new, got #{source.inspect}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Takes a Report, a Hash of source to Profile, or the `#to_h` of either,
|
|
292
|
+
# so that a job which round-tripped last night's report through JSON does
|
|
293
|
+
# not have to rebuild it itself.
|
|
294
|
+
sig { params(value: T.untyped).returns(T::Hash[Symbol, Profile]) }
|
|
295
|
+
def baselines!(value)
|
|
296
|
+
case value
|
|
297
|
+
when nil then {}
|
|
298
|
+
when Report then value.profiles
|
|
299
|
+
when Hash then report?(value) ? Report.from_h(value).profiles : profiles!(value)
|
|
300
|
+
else raise InvalidArgument, "baseline must be a Doctor::Report or a Hash of source => Profile"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A serialized Report rather than a Hash of profiles. String keys as well
|
|
305
|
+
# as symbols, since a report that has been through JSON has string keys all
|
|
306
|
+
# the way down.
|
|
307
|
+
sig { params(value: T::Hash[T.untyped, T.untyped]).returns(T::Boolean) }
|
|
308
|
+
def report?(value) = value.key?(:diagnoses) || value.key?("diagnoses")
|
|
309
|
+
|
|
310
|
+
sig { params(value: T::Hash[T.untyped, T.untyped]).returns(T::Hash[Symbol, Profile]) }
|
|
311
|
+
def profiles!(value)
|
|
312
|
+
value.to_h do |source, profile|
|
|
313
|
+
[source.to_sym, profile.is_a?(Profile) ? profile : Profile.from_h(profile)]
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A failure captured for a source names that source, even when it was
|
|
318
|
+
# raised somewhere that could not know. See Error#in_source.
|
|
319
|
+
sig { params(key: Symbol, error: StandardError).returns(StandardError) }
|
|
320
|
+
def stamp(key, error) = error.is_a?(ActiveSanction::Error) ? error.in_source(key) : error
|
|
321
|
+
|
|
322
|
+
sig { returns(Float) }
|
|
323
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
324
|
+
|
|
325
|
+
sig { params(started: Float).returns(Float) }
|
|
326
|
+
def elapsed(started) = (monotonic - started).round(3).to_f
|
|
327
|
+
|
|
328
|
+
sig { params(diagnosis: Diagnosis).void }
|
|
329
|
+
def log_diagnosis(diagnosis)
|
|
330
|
+
level = diagnosis.ok? ? :info : :warn
|
|
331
|
+
log(level, "#{diagnosis.source} #{diagnosis.label}: #{diagnosis.findings.size} finding(s) " \
|
|
332
|
+
"in #{format("%.2f", diagnosis.duration)}s")
|
|
333
|
+
diagnosis.findings.each { |finding| log(finding.at_least?(:warn) ? :warn : :info, finding.to_s) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Configuration only promises a logger that answers #info, so a finding is
|
|
337
|
+
# logged at warn where the logger has one and at info where it does not --
|
|
338
|
+
# rather than not at all.
|
|
339
|
+
sig { params(level: Symbol, message: String).void }
|
|
340
|
+
def log(level, message)
|
|
341
|
+
return unless logger
|
|
342
|
+
|
|
343
|
+
line = "[active_sanction] #{message}"
|
|
344
|
+
logger.respond_to?(level) ? logger.public_send(level, line) : logger.info(line)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|