Skip to content

Commit 00ae5aa

Browse files
authored
Merge pull request #75 from maxmind/greg/ip-risk-reasons
Add support for IP risk reasons
2 parents 24eeb81 + 96d67c2 commit 00ae5aa

6 files changed

Lines changed: 122 additions & 3 deletions

File tree

HISTORY.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ History
1616
address and sends an MD5 hash of it to the web service rather than the
1717
plain-text address. Note that the email domain will still be sent in plain
1818
text.
19+
* Added support for the IP address risk reasons in the minFraud Insights and
20+
Factors responses. This is available at ``.ip_address.risk_reasons``. It is
21+
an array of ``IPRiskReason`` objects.
22+
1923

2024
2.2.0 (2020-10-13)
2125
++++++++++++++++++

minfraud/models.py

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,63 @@ def new(cls, *args, **kwargs):
5959
return new_cls
6060

6161

62+
@_inflate_to_namedtuple
63+
class IPRiskReason:
64+
"""Reason for the IP risk.
65+
66+
This class provides both a machine-readable code and a human-readable
67+
explanation of the reason for the IP risk score.
68+
69+
Although more codes may be added in the future, the current codes are:
70+
71+
- ``ANONYMOUS_IP`` - The IP address belongs to an anonymous network. See
72+
the object at ``->ipAddress->traits`` for more details.
73+
- ``BILLING_POSTAL_VELOCITY`` - Many different billing postal codes have
74+
been seen on this IP address.
75+
- ``EMAIL_VELOCITY`` - Many different email addresses have been seen on this
76+
IP address.
77+
- ``HIGH_RISK_DEVICE`` - A high risk device was seen on this IP address.
78+
- ``HIGH_RISK_EMAIL`` - A high risk email address was seen on this IP
79+
address in your past transactions.
80+
- ``ISSUER_ID_NUMBER_VELOCITY`` - Many different issuer ID numbers have been
81+
seen on this IP address.
82+
- ``MINFRAUD_NETWORK_ACTIVITY`` - Suspicious activity has been seen on this
83+
IP address across minFraud customers.
84+
85+
.. attribute:: code
86+
87+
This value is a machine-readable code identifying the
88+
reason.
89+
90+
:type: str | None
91+
92+
.. attribute:: reason
93+
94+
This property provides a human-readable explanation of the
95+
reason. The text may change at any time and should not be matched
96+
against.
97+
98+
:type: str | None
99+
"""
100+
101+
code: Optional[str]
102+
reason: Optional[str]
103+
104+
__slots__ = ()
105+
_fields = {
106+
"code": None,
107+
"reason": None,
108+
}
109+
110+
111+
def _create_ip_risk_reasons(
112+
reasons: Optional[List[Dict[str, str]]]
113+
) -> Tuple[IPRiskReason, ...]:
114+
if not reasons:
115+
return ()
116+
return tuple([IPRiskReason(x) for x in reasons]) # type: ignore
117+
118+
62119
class GeoIP2Location(geoip2.records.Location):
63120
"""Location information for the IP address.
64121
@@ -126,6 +183,14 @@ class IPAddress(geoip2.models.Insights):
126183
127184
:type: float | None
128185
186+
.. attribute:: risk_reasons
187+
188+
This tuple contains :class:`.IPRiskReason` objects identifying the
189+
reasons why the IP address received the associated risk. This will be
190+
an empty tuple if there are no reasons.
191+
192+
:type: tuple[IPRiskReason]
193+
129194
.. attribute:: city
130195
131196
City object for the requested IP address.
@@ -189,6 +254,7 @@ class IPAddress(geoip2.models.Insights):
189254
country: GeoIP2Country
190255
location: GeoIP2Location
191256
risk: Optional[float]
257+
risk_reasons: Tuple[IPRiskReason, ...]
192258

193259
def __init__(self, ip_address: Dict[str, Any]) -> None:
194260
if ip_address is None:
@@ -200,6 +266,7 @@ def __init__(self, ip_address: Dict[str, Any]) -> None:
200266
self.country = GeoIP2Country(locales, **ip_address.get("country", {}))
201267
self.location = GeoIP2Location(**ip_address.get("location", {}))
202268
self.risk = ip_address.get("risk", None)
269+
self.risk_reasons = _create_ip_risk_reasons(ip_address.get("risk_reasons"))
203270
self._finalized = True
204271

205272
# Unfortunately the GeoIP2 models are not immutable, only the records. This
@@ -1058,7 +1125,7 @@ class Factors:
10581125
risk_score: float
10591126
shipping_address: ShippingAddress
10601127
subscores: Subscores
1061-
warnings: List[ServiceWarning]
1128+
warnings: Tuple[ServiceWarning, ...]
10621129

10631130
__slots__ = ()
10641131
_fields = {
@@ -1182,7 +1249,7 @@ class Insights:
11821249
queries_remaining: int
11831250
risk_score: float
11841251
shipping_address: ShippingAddress
1185-
warnings: List[ServiceWarning]
1252+
warnings: Tuple[ServiceWarning, ...]
11861253

11871254
__slots__ = ()
11881255
_fields = {
@@ -1266,7 +1333,7 @@ class Score:
12661333
ip_address: ScoreIPAddress
12671334
queries_remaining: int
12681335
risk_score: float
1269-
warnings: List[ServiceWarning]
1336+
warnings: Tuple[ServiceWarning, ...]
12701337

12711338
__slots__ = ()
12721339
_fields = {

tests/data/factors-response.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
},
1010
"ip_address": {
1111
"risk": 0.01,
12+
"risk_reasons": [
13+
{
14+
"code": "ANONYMOUS_IP",
15+
"reason": "The IP address belongs to an anonymous network. See /ip_address/traits for more details."
16+
},
17+
{
18+
"code": "MINFRAUD_NETWORK_ACTIVITY",
19+
"reason": "Suspicious activity has been seen on this IP address across minFraud customers."
20+
}
21+
],
1222
"city": {
1323
"confidence": 42,
1424
"geoname_id": 2643743,

tests/data/insights-response.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
},
1010
"ip_address": {
1111
"risk": 0.01,
12+
"risk_reasons": [
13+
{
14+
"code": "ANONYMOUS_IP",
15+
"reason": "The IP address belongs to an anonymous network. See /ip_address/traits for more details."
16+
},
17+
{
18+
"code": "MINFRAUD_NETWORK_ACTIVITY",
19+
"reason": "Suspicious activity has been seen on this IP address across minFraud customers."
20+
}
21+
],
1222
"city": {
1323
"confidence": 42,
1424
"geoname_id": 2643743,

tests/test_models.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def test_model_immutability(self):
88
"""This tests some level of _shallow_ immutability for these classes"""
99
T = namedtuple("T", ["obj", "attr"])
1010
models = [
11+
T(IPRiskReason(), "code"),
1112
T(Issuer(), "name"),
1213
T(CreditCard(), "country"),
1314
T(Device(), "id"),
@@ -152,6 +153,16 @@ def test_ip_address(self):
152153
"local_time": time,
153154
},
154155
"risk": 99,
156+
"risk_reasons": [
157+
{
158+
"code": "ANONYMOUS_IP",
159+
"reason": "The IP address belongs to an anonymous network. See /ip_address/traits for more details.",
160+
},
161+
{
162+
"code": "MINFRAUD_NETWORK_ACTIVITY",
163+
"reason": "Suspicious activity has been seen on this IP address across minFraud customers.",
164+
},
165+
],
155166
"traits": {
156167
"is_anonymous": True,
157168
"is_anonymous_proxy": True,
@@ -179,6 +190,22 @@ def test_ip_address(self):
179190
self.assertEqual(True, address.traits.is_tor_exit_node)
180191
self.assertEqual(True, address.country.is_high_risk)
181192

193+
self.assertEqual("ANONYMOUS_IP", address.risk_reasons[0].code)
194+
self.assertEqual(
195+
"The IP address belongs to an anonymous network. See /ip_address/traits for more details.",
196+
address.risk_reasons[0].reason,
197+
)
198+
199+
self.assertEqual("MINFRAUD_NETWORK_ACTIVITY", address.risk_reasons[1].code)
200+
self.assertEqual(
201+
"Suspicious activity has been seen on this IP address across minFraud customers.",
202+
address.risk_reasons[1].reason,
203+
)
204+
205+
def test_empty_address(self):
206+
address = IPAddress({})
207+
self.assertEqual((), address.risk_reasons)
208+
182209
def test_score_ip_address(self):
183210
address = ScoreIPAddress({"risk": 99})
184211
self.assertEqual(99, address.risk)

tests/test_webservice.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ def test_200(self):
193193
if self.has_ip_location():
194194
self.assertEqual("United Kingdom", model.ip_address.country.name)
195195
self.assertEqual(True, model.ip_address.traits.is_residential_proxy)
196+
self.assertEqual("ANONYMOUS_IP", model.ip_address.risk_reasons[0].code)
196197

197198
@httprettified
198199
def test_200_on_request_with_nones(self):

0 commit comments

Comments
 (0)