Files
MattandGitHub 624066a3e8 Add RouteViews enrichment and sourced ASN organization attribution (#2528)
* Add RouteViews network enrichment

* Add authenticated RouteViews API access

* Restrict RouteViews to explicit network pivots

* Restore discovered ASN RouteViews enrichment

* Retain sourced ASN organization attribution

* Avoid dangling and repeated ASN attributions

* Keep automatic RouteViews evidence target-relevant

* Complete target-relevant RouteViews enrichment

* Clarify automatic RouteViews IP pivots
2026-08-13 10:46:54 -04:00

603 lines
20 KiB
Python

from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
import theHarvester.lib.routeviews as routeviews_module
from theHarvester.lib.core import FetcherResponse
from theHarvester.lib.network_evidence import (
BgpRouteObservation,
PrefixOriginObservation,
RpkiValidationObservation,
)
from theHarvester.lib.routeviews import RouteViewsCancelled, enrich_routeviews
def install_runtime(monkeypatch, responses: list[FetcherResponse | BaseException]):
calls: list[tuple[str, dict[str, Any]]] = []
elapsed = [0.0]
collected_at = datetime(2026, 8, 11, 12, tzinfo=UTC)
async def fetch_json(url: str, **kwargs: Any) -> FetcherResponse:
calls.append((url, kwargs))
response = responses.pop(0)
if isinstance(response, BaseException):
raise response
return response
async def sleep(seconds: float) -> None:
elapsed[0] += seconds
monkeypatch.setattr(routeviews_module, '_fetch_json', fetch_json)
monkeypatch.setattr(routeviews_module, '_sleep', sleep)
monkeypatch.setattr(routeviews_module, '_monotonic', lambda: elapsed[0])
monkeypatch.setattr(routeviews_module, '_now', lambda: collected_at + timedelta(seconds=elapsed[0]))
return calls, elapsed
def response(body: object, status: int = 200, headers: dict[str, str] | None = None) -> FetcherResponse:
return FetcherResponse(body=body, status=status, headers=headers or {})
@pytest.mark.asyncio
async def test_routeviews_collects_asn_prefixes_and_rpki_without_prefix_fanout(monkeypatch) -> None:
calls, elapsed = install_runtime(
monkeypatch,
[
response(['192.0.2.0/24', '2001:db8::/32', '192.0.2.0/24']),
response(
{
'64500': {
'prefix': [
{'192.0.2.0/24': 'valid'},
{'2001:db8::/32': 'notfound'},
],
'timestamp': '2026-08-11T12:00:00+00:00',
}
}
),
],
)
result = await enrich_routeviews(['64500'], [])
assert result.status == 'completed'
assert result.stop_reason is None
assert result.request_count == 2
assert result.prefixes == ('192.0.2.0/24', '2001:db8::/32')
assert result.origin_asns == ('AS64500',)
assert sum(isinstance(item, PrefixOriginObservation) for item in result.observations) == 2
assert {(item.prefix, item.state) for item in result.observations if isinstance(item, RpkiValidationObservation)} == {
('192.0.2.0/24', 'valid'),
('2001:db8::/32', 'not-found'),
}
assert [url for url, _kwargs in calls] == [
'https://api.routeviews.org/guest/asn/64500',
'https://api.routeviews.org/guest/rpki',
]
assert calls[1][1]['params'] == {'asn': '64500'}
assert elapsed[0] == 1.0
@pytest.mark.asyncio
async def test_routeviews_uses_configured_key_for_authenticated_access(monkeypatch) -> None:
calls, elapsed = install_runtime(
monkeypatch,
[
response(['192.0.2.0/24']),
response({'64500': None}),
],
)
result = await enrich_routeviews(['AS64500'], [], api_key='routeviews-key')
assert result.status == 'completed'
assert [url for url, _kwargs in calls] == [
'https://api.routeviews.org/asn/64500',
'https://api.routeviews.org/rpki',
]
assert [kwargs['headers'] for _url, kwargs in calls] == [
{'Api-Key': 'routeviews-key'},
{'Api-Key': 'routeviews-key'},
]
assert elapsed[0] == 0.1
@pytest.mark.asyncio
async def test_routeviews_invalid_configured_key_fails_without_guest_downgrade(monkeypatch) -> None:
calls, _elapsed = install_runtime(monkeypatch, [response(None, status=401)])
result = await enrich_routeviews(['AS64500'], [], api_key='invalid-key')
assert result.status == 'failed'
assert result.stop_reason == 'http-401'
assert calls == [
(
'https://api.routeviews.org/asn/64500',
{
'params': '',
'headers': {'Api-Key': 'invalid-key'},
'request_timeout': 30,
},
)
]
@pytest.mark.asyncio
async def test_routeviews_uses_authenticated_access_for_prefix_seeds(monkeypatch) -> None:
calls, elapsed = install_runtime(monkeypatch, [response([]), response([])])
result = await enrich_routeviews(
[],
['192.0.2.0/24', '198.51.100.0/24'],
api_key='routeviews-key',
)
assert result.status == 'completed'
assert calls == [
(
'https://api.routeviews.org/prefix/192.0.2.0%2F24',
{
'params': {'strict-match': 'yes'},
'headers': {'Api-Key': 'routeviews-key'},
'request_timeout': 30,
},
),
(
'https://api.routeviews.org/prefix/198.51.100.0%2F24',
{
'params': {'strict-match': 'yes'},
'headers': {'Api-Key': 'routeviews-key'},
'request_timeout': 30,
},
),
]
assert elapsed[0] == 0.1
@pytest.mark.asyncio
async def test_routeviews_collects_moas_routes_and_strict_prefix_evidence(monkeypatch) -> None:
calls, _elapsed = install_runtime(
monkeypatch,
[
response(
[
{
'prefix': '192.0.2.0/24',
'origin_asn': 64500,
'rpki_state': 'valid',
'rpki_roas': None,
'reporting_peers': [
{
'peer_asn': 64496,
'peer_addr': '198.51.100.1',
'collector': 'route-views.example',
'as_path': ' 64496 64500 ',
'communities': '',
'timestamp': '2026-08-11T11:59:00Z',
}
],
},
{
'prefix': '192.0.2.0/24',
'origin_asn': 64501,
'rpki_state': 'not-found',
'rpki_roas': None,
'reporting_peers': [],
},
]
)
],
)
result = await enrich_routeviews([], ['192.0.2.7/24'])
assert result.status == 'completed'
assert result.prefixes == ('192.0.2.0/24',)
assert result.origin_asns == ('AS64500', 'AS64501')
route = next(item for item in result.observations if isinstance(item, BgpRouteObservation))
assert route.as_path == ' 64496 64500 '
assert route.communities == ''
assert {item.state for item in result.observations if isinstance(item, RpkiValidationObservation)} == {'valid', 'not-found'}
assert calls == [
(
'https://api.routeviews.org/guest/prefix/192.0.2.0%2F24',
{
'params': {'strict-match': 'yes'},
'request_timeout': 30,
},
)
]
@pytest.mark.asyncio
async def test_routeviews_uses_longest_match_for_known_ip(monkeypatch) -> None:
calls, _elapsed = install_runtime(
monkeypatch,
[
response(
[
{
'prefix': '0.0.0.0/0',
'origin_asn': 64500,
'rpki_state': 'not-found',
'reporting_peers': [],
},
{
'prefix': '192.0.0.0/16',
'origin_asn': 64501,
'rpki_state': 'valid',
'reporting_peers': [],
},
{
'prefix': '192.0.2.0/24',
'origin_asn': 64502,
'rpki_state': 'valid',
'reporting_peers': [],
},
{
'prefix': '192.0.2.0/24',
'origin_asn': 64503,
'rpki_state': 'not-found',
'reporting_peers': [],
},
]
)
],
)
result = await enrich_routeviews([], ['192.0.2.7'])
assert result.status == 'completed'
assert result.stop_reason is None
assert result.prefixes == ('192.0.2.0/24',)
assert result.origin_asns == ('AS64502', 'AS64503')
assert calls[0][0] == 'https://api.routeviews.org/guest/prefix/192.0.2.7%2F32'
assert calls[0][1]['params'] == ''
@pytest.mark.asyncio
async def test_routeviews_deduplicates_shared_prefix_evidence_across_ip_seeds(monkeypatch) -> None:
shared_prefix = [
{
'prefix': '192.0.2.0/24',
'origin_asn': 64500,
'rpki_state': 'valid',
'reporting_peers': [
{
'peer_asn': 64496,
'peer_addr': '198.51.100.1',
'collector': 'route-views.example',
'as_path': '64496 64500',
'communities': '64500:1',
'timestamp': '2026-08-11T11:59:00Z',
}
],
}
]
install_runtime(monkeypatch, [response(shared_prefix), response(shared_prefix)])
result = await enrich_routeviews([], ['192.0.2.7', '192.0.2.8'])
assert result.request_count == 2
assert result.prefixes == ('192.0.2.0/24',)
assert sum(isinstance(item, PrefixOriginObservation) for item in result.observations) == 1
assert sum(isinstance(item, BgpRouteObservation) for item in result.observations) == 1
assert sum(isinstance(item, RpkiValidationObservation) for item in result.observations) == 1
@pytest.mark.asyncio
async def test_routeviews_records_each_response_collection_time(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response([]),
response(
[
{
'prefix': '192.0.2.0/24',
'origin_asn': 64500,
'rpki_state': 'valid',
'reporting_peers': [
{
'peer_asn': 64496,
'peer_addr': '198.51.100.1',
'collector': 'route-views.example',
'as_path': '64496 64500',
'communities': '',
'timestamp': '2026-08-11T12:00:00.500Z',
}
],
}
]
),
],
)
result = await enrich_routeviews([], ['192.0.2.7', '192.0.2.8'])
route = next(item for item in result.observations if isinstance(item, BgpRouteObservation))
assert route.collected_at == datetime(2026, 8, 11, 12, 0, 1, tzinfo=UTC)
@pytest.mark.asyncio
async def test_routeviews_accepts_null_rpki_as_no_data(monkeypatch) -> None:
install_runtime(monkeypatch, [response([]), response({'64500': None})])
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'completed'
assert result.stop_reason == 'no-results'
assert result.observations == ()
@pytest.mark.asyncio
async def test_routeviews_reports_rate_limit_without_parsing_error_body(monkeypatch) -> None:
install_runtime(monkeypatch, [response(None, status=429, headers={'retry-after': '60'})])
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'rate-limited'
assert result.error_type == 'HTTPStatusError'
assert result.stop_reason == 'http-429'
assert result.request_count == 1
@pytest.mark.asyncio
async def test_routeviews_reports_terminal_rate_limit_after_recoverable_error(monkeypatch) -> None:
install_runtime(monkeypatch, [response(None, status=503), response(None, status=429)])
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'rate-limited'
assert result.error_count == 2
assert result.error_type == 'HTTPStatusError'
assert result.stop_reason == 'http-429'
@pytest.mark.asyncio
async def test_routeviews_preserves_valid_prefixes_before_malformed_response(monkeypatch) -> None:
install_runtime(monkeypatch, [response(['192.0.2.0/24']), response({'64500': {'prefix': 'bad'}})])
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert result.error_type == 'ValueError'
assert result.stop_reason == 'invalid-response'
assert result.error_count == 1
@pytest.mark.asyncio
async def test_routeviews_continues_after_one_seed_fails(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response(None, status=503),
response({'64500': None}),
response(['198.51.100.0/24']),
response({'64501': None}),
],
)
result = await enrich_routeviews(['AS64500', 'AS64501'], [])
assert result.status == 'partial'
assert result.prefixes == ('198.51.100.0/24',)
assert result.origin_asns == ('AS64501',)
assert result.request_count == 4
assert result.error_count == 1
assert result.error_type == 'HTTPStatusError'
assert result.stop_reason == 'http-503'
@pytest.mark.asyncio
async def test_routeviews_rejects_prefix_evidence_unrelated_to_requested_seed(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response(
[
{
'prefix': '203.0.113.0/24',
'origin_asn': 64500,
'rpki_state': 'not-found',
'rpki_roas': None,
'reporting_peers': [],
},
{
'prefix': '192.0.2.0/24',
'origin_asn': 64501,
'rpki_state': 'valid',
'rpki_roas': None,
'reporting_peers': [],
},
]
)
],
)
result = await enrich_routeviews([], ['192.0.2.7'])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert result.origin_asns == ('AS64501',)
assert result.error_count == 1
assert result.stop_reason == 'invalid-response'
@pytest.mark.asyncio
async def test_routeviews_keeps_routes_when_rpki_or_a_sibling_peer_is_invalid(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response(
[
{
'prefix': '192.0.2.0/24',
'origin_asn': 64500,
'rpki_state': None,
'rpki_roas': None,
'reporting_peers': [
{'peer_asn': 'bad'},
{
'peer_asn': 64496,
'peer_addr': '198.51.100.1',
'collector': 'route-views.example',
'as_path': '64496 64500',
'communities': '',
'timestamp': '2026-08-11T11:59:00Z',
},
],
}
]
)
],
)
result = await enrich_routeviews([], ['192.0.2.0/24'])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert sum(isinstance(item, BgpRouteObservation) for item in result.observations) == 1
assert not any(isinstance(item, RpkiValidationObservation) for item in result.observations)
assert result.error_count == 2
assert result.stop_reason == 'invalid-response'
@pytest.mark.asyncio
async def test_routeviews_cancellation_carries_completed_prefix_evidence(monkeypatch) -> None:
cancelled = asyncio.CancelledError()
install_runtime(monkeypatch, [response(['192.0.2.0/24']), cancelled])
with pytest.raises(RouteViewsCancelled) as raised:
await enrich_routeviews(['AS64500'], [])
assert raised.value.result.status == 'partial'
assert raised.value.result.prefixes == ('192.0.2.0/24',)
assert raised.value.result.error_type == 'CancelledError'
assert raised.value.result.stop_reason == 'cancelled'
@pytest.mark.asyncio
async def test_routeviews_cancellation_overrides_an_earlier_recoverable_error(monkeypatch) -> None:
install_runtime(monkeypatch, [response(None, status=503), asyncio.CancelledError()])
with pytest.raises(RouteViewsCancelled) as raised:
await enrich_routeviews(['AS64500'], [])
assert raised.value.result.error_count == 2
assert raised.value.result.error_type == 'CancelledError'
assert raised.value.result.stop_reason == 'cancelled'
@pytest.mark.asyncio
async def test_routeviews_fixed_request_budget_preserves_prefix(monkeypatch) -> None:
install_runtime(monkeypatch, [response(['192.0.2.0/24'])])
monkeypatch.setattr(routeviews_module, 'MAX_ROUTEVIEWS_REQUESTS', 1)
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert result.error_type == 'RouteViewsLimitError'
assert result.stop_reason == 'request-limit'
@pytest.mark.asyncio
async def test_routeviews_runtime_budget_preserves_prefix(monkeypatch) -> None:
install_runtime(monkeypatch, [response(['192.0.2.0/24'])])
monkeypatch.setattr(routeviews_module, 'MAX_ROUTEVIEWS_RUNTIME_SECONDS', 0.5)
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert result.stop_reason == 'runtime-limit'
@pytest.mark.asyncio
async def test_routeviews_per_prefix_evidence_limit_returns_partial(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response(
[
{
'prefix': '192.0.2.0/24',
'origin_asn': 64500,
'rpki_state': 'valid',
'rpki_roas': None,
'reporting_peers': [],
}
]
)
],
)
monkeypatch.setattr(routeviews_module, 'MAX_NETWORK_OBSERVATIONS_PER_PREFIX', 1)
result = await enrich_routeviews([], ['192.0.2.0/24'])
assert result.status == 'partial'
assert result.prefixes == ('192.0.2.0/24',)
assert len(result.observations) == 1
assert result.stop_reason == 'result-limit'
@pytest.mark.asyncio
async def test_routeviews_cumulative_provider_budget_stops_before_next_request(monkeypatch) -> None:
calls, _elapsed = install_runtime(monkeypatch, [response(['192.0.2.0/24'])])
monkeypatch.setattr(routeviews_module, 'MAX_ROUTEVIEWS_RUN_JSON_BYTES', 1)
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'failed'
assert result.stop_reason == 'result-limit'
assert result.request_count == 1
assert len(calls) == 1
@pytest.mark.asyncio
async def test_routeviews_rejects_timestamp_that_overflows_utc_conversion(monkeypatch) -> None:
install_runtime(
monkeypatch,
[
response([]),
response({'64500': {'prefix': [], 'timestamp': '0001-01-01T00:00:00+01:00'}}),
],
)
result = await enrich_routeviews(['AS64500'], [])
assert result.status == 'failed'
assert result.error_count == 1
assert result.stop_reason == 'invalid-response'
@pytest.mark.asyncio
async def test_routeviews_skips_when_inputs_are_empty_or_invalid(monkeypatch) -> None:
calls, _elapsed = install_runtime(monkeypatch, [])
result = await enrich_routeviews(['not-an-asn'], ['not-an-ip'])
assert result.status == 'skipped'
assert result.stop_reason == 'no-input'
assert result.request_count == 0
assert calls == []
@pytest.mark.asyncio
async def test_routeviews_bounds_input_before_scheduling_requests(monkeypatch) -> None:
calls, _elapsed = install_runtime(monkeypatch, [response([]), response({'64500': None})])
monkeypatch.setattr(routeviews_module, 'MAX_ROUTEVIEWS_INPUT_ITEMS', 1)
result = await enrich_routeviews(['AS64500', 'AS64501'], [])
assert result.status == 'failed'
assert result.error_count == 1
assert result.stop_reason == 'input-limit'
assert len(calls) == 2