Skip to content

Commit 10bb6f8

Browse files
feature: Add check on waf ip blocklist to white list gc owned ip addresses (#726)
Co-authored-by: Pat Heard <[email protected]>
1 parent c8749ff commit 10bb6f8

File tree

3 files changed

+280
-2
lines changed

3 files changed

+280
-2
lines changed

waf_ip_blocklist/lambda/blocklist.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import os
88
import time
99
import boto3
10+
import requests
11+
from requests.adapters import HTTPAdapter
12+
from urllib3.util.retry import Retry
1013

1114
# Required
1215
ATHENA_OUTPUT_BUCKET = os.environ["ATHENA_OUTPUT_BUCKET"]
@@ -129,6 +132,9 @@ def update_waf_ip_set(ip_addresses, waf_ip_set_name, waf_ip_set_id, waf_scope):
129132
print(f"Reducing {len(ip_addresses)} address to 10,000 addresses.")
130133
ip_addresses = ip_addresses[:10000]
131134

135+
# Remove any ip addresses known to be owned by the Government of Canada
136+
ip_addresses = [ip for ip in ip_addresses if not gc_ip(ip)]
137+
132138
# Check if new addresses have been added to the list of existing addresses.
133139
# This is useful to monitor the number of new IPs added to the blocklist
134140
# and to set up alarms if the number of new IPs added is too high
@@ -155,3 +161,61 @@ def update_waf_ip_set(ip_addresses, waf_ip_set_name, waf_ip_set_id, waf_scope):
155161
print(
156162
f"Updated WAF IP set with {new_ips} new IPs for a total of {len(ip_addresses)} blocked IPs."
157163
)
164+
165+
166+
def recursive_entity_search(data):
167+
"""Recursively search through a list of entities to see if one matches GoC"""
168+
if isinstance(data, list):
169+
registrants = [d for d in data if "registrant" in d.get("roles", [])]
170+
top_level_result = any(
171+
entity.get("handle") == "SSC-299" for entity in registrants
172+
)
173+
if not top_level_result:
174+
# Go deeper and see if there are any other entities associated with this record
175+
for entity in registrants:
176+
top_level_result = top_level_result or recursive_entity_search(entity)
177+
# short circuit once we hit a positive identification
178+
if top_level_result:
179+
return top_level_result
180+
return top_level_result
181+
if isinstance(data, dict) and "entities" in data:
182+
return recursive_entity_search(data["entities"])
183+
return False
184+
185+
186+
def create_retrying_request(
187+
total_retries=3,
188+
backoff_factor=0.5,
189+
status_forcelist=(500, 502, 503, 504),
190+
allowed_methods=("GET"),
191+
):
192+
"""
193+
Creates a requests session with retry logic.
194+
"""
195+
retry_strategy = Retry(
196+
total=total_retries,
197+
backoff_factor=backoff_factor,
198+
status_forcelist=status_forcelist,
199+
allowed_methods=allowed_methods,
200+
)
201+
adapter = HTTPAdapter(max_retries=retry_strategy)
202+
session = requests.Session()
203+
session.mount("https://", adapter)
204+
return session
205+
206+
207+
def gc_ip(ip):
208+
"""Check if IP is owned by Government of Canada"""
209+
session = create_retrying_request(total_retries=5, backoff_factor=1)
210+
try:
211+
api_url = f"https://rdap.arin.net/registry/ip/{ip}"
212+
is_gc_ip = False
213+
response = session.get(api_url, timeout=5)
214+
if response.ok:
215+
record = response.json().get("entities")
216+
if record is not None:
217+
is_gc_ip = recursive_entity_search(record)
218+
return is_gc_ip
219+
except requests.exceptions.RequestException:
220+
print(f"Could not successfully retrieve information about IP {ip}")
221+
return False

waf_ip_blocklist/lambda/blocklist_test.py

Lines changed: 214 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import tempfile
22
import os
3+
import json
34

4-
from unittest.mock import call, patch
5+
from unittest.mock import call, patch, Mock, MagicMock
56

67
os.environ["AWS_DEFAULT_REGION"] = "ca-central-1"
78
os.environ["ATHENA_OUTPUT_BUCKET"] = "test_bucket"
@@ -31,6 +32,7 @@ def test_handler_with_ips_to_block(mock_waf_client, mock_athena_client, capsys):
3132
{"Data": [{"VarCharValue": "header"}]},
3233
{"Data": [{"VarCharValue": "192.168.1.1"}]},
3334
{"Data": [{"VarCharValue": "192.168.1.2"}]},
35+
{"Data": [{"VarCharValue": "198.103.1.2"}]},
3436
]
3537
}
3638
},
@@ -40,6 +42,7 @@ def test_handler_with_ips_to_block(mock_waf_client, mock_athena_client, capsys):
4042
{"Data": [{"VarCharValue": "header"}]},
4143
{"Data": [{"VarCharValue": "192.168.1.1"}]},
4244
{"Data": [{"VarCharValue": "192.168.1.3"}]},
45+
{"Data": [{"VarCharValue": "205.193.1.2"}]},
4346
]
4447
}
4548
},
@@ -282,3 +285,213 @@ def test_get_query_from_file_with_single_rule_id():
282285

283286
# Cleanup
284287
os.remove(temp_file_path)
288+
289+
290+
def test_recursive_entity_search_with_list_and_goc_handle():
291+
"""Test recursive_entity_search with a list containing GoC handle"""
292+
test_data = [
293+
{"roles": ["registrant"], "handle": "SSC-299"},
294+
{"roles": ["admin"], "handle": "OTHER-123"},
295+
]
296+
297+
result = blocklist.recursive_entity_search(test_data)
298+
assert result is True
299+
300+
301+
def test_recursive_entity_search_with_list_without_goc_handle():
302+
"""Test recursive_entity_search with a list not containing GoC handle"""
303+
test_data = [
304+
{"roles": ["registrant"], "handle": "OTHER-123"},
305+
{"roles": ["admin"], "handle": "ANOTHER-456"},
306+
]
307+
308+
result = blocklist.recursive_entity_search(test_data)
309+
assert result is False
310+
311+
312+
def test_recursive_entity_search_with_nested_entities():
313+
"""Test recursive_entity_search with nested entities containing GoC handle"""
314+
test_data = [
315+
{
316+
"roles": ["registrant"],
317+
"handle": "OTHER-123",
318+
"entities": [{"roles": ["registrant"], "handle": "SSC-299"}],
319+
}
320+
]
321+
322+
result = blocklist.recursive_entity_search(test_data)
323+
assert result is True
324+
325+
326+
def test_recursive_entity_search_with_dict_containing_entities():
327+
"""Test recursive_entity_search with dict containing entities key"""
328+
test_data = {"entities": [{"roles": ["registrant"], "handle": "SSC-299"}]}
329+
330+
result = blocklist.recursive_entity_search(test_data)
331+
assert result is True
332+
333+
334+
def test_recursive_entity_search_with_empty_list():
335+
"""Test recursive_entity_search with empty list"""
336+
test_data = []
337+
338+
result = blocklist.recursive_entity_search(test_data)
339+
assert result is False
340+
341+
342+
def test_recursive_entity_search_with_none():
343+
"""Test recursive_entity_search with None input"""
344+
result = blocklist.recursive_entity_search(None)
345+
assert result is False
346+
347+
348+
def test_recursive_entity_search_with_no_registrants():
349+
"""Test recursive_entity_search with list containing no registrants"""
350+
test_data = [
351+
{"roles": ["admin"], "handle": "SSC-299"},
352+
{"roles": ["tech"], "handle": "OTHER-123"},
353+
]
354+
355+
result = blocklist.recursive_entity_search(test_data)
356+
assert result is False
357+
358+
359+
def test_recursive_entity_search_with_missing_roles():
360+
"""Test recursive_entity_search with entities missing roles field"""
361+
test_data = [
362+
{"handle": "SSC-299"}, # Missing roles field
363+
{"roles": ["admin"], "handle": "OTHER-123"},
364+
]
365+
366+
result = blocklist.recursive_entity_search(test_data)
367+
assert result is False
368+
369+
370+
@patch("blocklist.create_retrying_request")
371+
def test_gc_ip_success_with_goc_ip(mock_create_session):
372+
"""Test gc_ip with successful response indicating GoC IP"""
373+
# Setup mock response
374+
mock_response = Mock()
375+
mock_response.ok = True
376+
mock_response.json.return_value = {
377+
"entities": [{"roles": ["registrant"], "handle": "SSC-299"}]
378+
}
379+
380+
# Setup mock session
381+
mock_session = Mock()
382+
mock_session.get.return_value = mock_response
383+
mock_create_session.return_value = mock_session
384+
385+
# Test
386+
result = blocklist.gc_ip("192.168.1.1")
387+
388+
# Verify
389+
assert result is True
390+
mock_create_session.assert_called_once_with(total_retries=5, backoff_factor=1)
391+
mock_session.get.assert_called_once_with(
392+
"https://rdap.arin.net/registry/ip/192.168.1.1", timeout=5
393+
)
394+
395+
396+
@patch("blocklist.create_retrying_request")
397+
def test_gc_ip_success_with_non_goc_ip(mock_create_session):
398+
"""Test gc_ip with successful response indicating non-GoC IP"""
399+
# Setup mock response
400+
mock_response = Mock()
401+
mock_response.ok = True
402+
mock_response.json.return_value = {
403+
"entities": [{"roles": ["registrant"], "handle": "OTHER-123"}]
404+
}
405+
406+
# Setup mock session
407+
mock_session = Mock()
408+
mock_session.get.return_value = mock_response
409+
mock_create_session.return_value = mock_session
410+
411+
# Test
412+
result = blocklist.gc_ip("8.8.8.8")
413+
414+
# Verify
415+
assert result is False
416+
417+
418+
@patch("blocklist.create_retrying_request")
419+
def test_gc_ip_http_error(mock_create_session):
420+
"""Test gc_ip with HTTP error response"""
421+
# Setup mock response
422+
mock_response = Mock()
423+
mock_response.ok = False
424+
mock_response.status_code = 404
425+
426+
# Setup mock session
427+
mock_session = Mock()
428+
mock_session.get.return_value = mock_response
429+
mock_create_session.return_value = mock_session
430+
431+
# Test
432+
result = blocklist.gc_ip("192.168.1.1")
433+
434+
# Verify
435+
assert result is False
436+
437+
438+
@patch("blocklist.create_retrying_request")
439+
def test_gc_ip_request_exception(mock_create_session, capsys):
440+
"""Test gc_ip with request exception"""
441+
# Setup mock session to raise exception
442+
mock_session = Mock()
443+
mock_session.get.side_effect = blocklist.requests.exceptions.RequestException(
444+
"Connection error"
445+
)
446+
mock_create_session.return_value = mock_session
447+
448+
# Test
449+
result = blocklist.gc_ip("192.168.1.1")
450+
451+
# Verify
452+
assert result is False
453+
captured = capsys.readouterr()
454+
assert (
455+
"Could not successfully retrieve information about IP 192.168.1.1"
456+
in captured.out
457+
)
458+
459+
460+
@patch("blocklist.create_retrying_request")
461+
def test_gc_ip_no_entities_in_response(mock_create_session):
462+
"""Test gc_ip when response has no entities"""
463+
# Setup mock response
464+
mock_response = Mock()
465+
mock_response.ok = True
466+
mock_response.json.return_value = {"other_field": "value"}
467+
468+
# Setup mock session
469+
mock_session = Mock()
470+
mock_session.get.return_value = mock_response
471+
mock_create_session.return_value = mock_session
472+
473+
# Test
474+
result = blocklist.gc_ip("192.168.1.1")
475+
476+
# Verify
477+
assert result is False
478+
479+
480+
@patch("blocklist.create_retrying_request")
481+
def test_gc_ip_entities_is_none(mock_create_session):
482+
"""Test gc_ip when entities field is None"""
483+
# Setup mock response
484+
mock_response = Mock()
485+
mock_response.ok = True
486+
mock_response.json.return_value = {"entities": None}
487+
488+
# Setup mock session
489+
mock_session = Mock()
490+
mock_session.get.return_value = mock_response
491+
mock_create_session.return_value = mock_session
492+
493+
# Test
494+
result = blocklist.gc_ip("192.168.1.1")
495+
496+
# Verify
497+
assert result is False
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
black==24.10.0
22
boto3==1.40.11
33
pylint==3.3.8
4-
pytest==8.4.1
4+
pytest==8.4.1
5+
requests==2.32.5

0 commit comments

Comments
 (0)