dshield-parser 0.1.7__tar.gz → 0.2.0__tar.gz

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.
Files changed (55) hide show
  1. dshield_parser-0.2.0/CHANGELOG.md +55 -0
  2. dshield_parser-0.2.0/MANIFEST.in +3 -0
  3. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/PKG-INFO +5 -1
  4. dshield_parser-0.2.0/README.md +84 -0
  5. dshield_parser-0.2.0/docs/IP-ENRICHMENT.md +404 -0
  6. dshield_parser-0.2.0/dshield_parser/__init__.py +3 -0
  7. dshield_parser-0.2.0/dshield_parser/_version.py +3 -0
  8. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_api.py +303 -0
  9. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_cache.py +112 -0
  10. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_dns.py +321 -0
  11. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_http.py +447 -0
  12. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_local.py +381 -0
  13. dshield_parser-0.2.0/dshield_parser/test/test_enrichment_workflow.py +490 -0
  14. dshield_parser-0.2.0/dshield_parser/utils/_enrichment_cache.py +137 -0
  15. dshield_parser-0.2.0/dshield_parser/utils/_enrichment_dns.py +299 -0
  16. dshield_parser-0.2.0/dshield_parser/utils/_enrichment_http.py +675 -0
  17. dshield_parser-0.2.0/dshield_parser/utils/_enrichment_local.py +367 -0
  18. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/enrichment.py +173 -159
  19. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser.egg-info/PKG-INFO +5 -1
  20. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser.egg-info/SOURCES.txt +22 -1
  21. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser.egg-info/requires.txt +4 -0
  22. dshield_parser-0.2.0/examples/README.md +53 -0
  23. dshield_parser-0.2.0/examples/clear_research_ips.py +469 -0
  24. dshield_parser-0.2.0/examples/dshield_honeypot_comparisons_dbs_exclusions.py +142 -0
  25. dshield_parser-0.2.0/examples/enrich_summary_hashes.py +60 -0
  26. dshield_parser-0.2.0/examples/url-command-clustering.py +319 -0
  27. dshield_parser-0.2.0/requirements-enrichment.txt +3 -0
  28. dshield_parser-0.2.0/requirements.txt +9 -0
  29. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/setup.py +11 -1
  30. dshield_parser-0.1.7/README.md +0 -14
  31. dshield_parser-0.1.7/dshield_parser/__init__.py +0 -1
  32. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/__main__.py +0 -0
  33. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/cowrie_processor/__init__.py +0 -0
  34. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/cowrie_processor/__main__.py +0 -0
  35. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/cowrie_processor/reports.py +0 -0
  36. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/firewall_processor/__init__.py +0 -0
  37. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/firewall_processor/__main__.py +0 -0
  38. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/firewall_processor/reports.py +0 -0
  39. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/test/__init__.py +0 -0
  40. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/test/test_json.py +0 -0
  41. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/__init__.py +0 -0
  42. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/__main__.py +0 -0
  43. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/file_io.py +0 -0
  44. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/graphs.py +0 -0
  45. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/json.py +0 -0
  46. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/ml.py +0 -0
  47. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/sql.py +0 -0
  48. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/utils/ssh.py +0 -0
  49. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/web_processor/__init__.py +0 -0
  50. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/web_processor/__main__.py +0 -0
  51. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser/web_processor/reports.py +0 -0
  52. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser.egg-info/dependency_links.txt +0 -0
  53. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/dshield_parser.egg-info/top_level.txt +0 -0
  54. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/pyproject.toml +0 -0
  55. {dshield_parser-0.1.7 → dshield_parser-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-11-04
4
+
5
+ ### Added
6
+
7
+ - Local GeoLite2 ASN and GeoLite2/GeoIP2 Country/City lookup functions, with an
8
+ optional `geoip` installation extra and reusable reader support.
9
+ - Validated, reusable IPv4/IPv6 IPtoASN TSV/gzip indexes and binary-search lookups.
10
+ - Selective authoritative RDAP lookups with IANA bootstrap, bounded referrals,
11
+ returned contact extraction, and exact-query caching. The former
12
+ `get_whois_data` placeholder now delegates to RDAP.
13
+ - Thread-safe SQLite enrichment cache with independent provider namespaces,
14
+ record expiration, persistent cooldowns, and context-manager cleanup.
15
+ - Structured PTR results and bounded DNS batch iteration, including authoritative
16
+ negative TTLs, short transient-failure cooldowns, and resolver-specific cache keys.
17
+ - Atomic, size-limited ISC `daily_sources` and `threatintel` feed downloads.
18
+ - Offline enrichment regression tests, Linux/Windows CI, usage documentation,
19
+ and release tag/version verification before PyPI publishing.
20
+
21
+ ### Corrected
22
+
23
+ - ISC IP lookups now use explicit timeouts and bounded retries. Rate limits raise
24
+ `EnrichmentRateLimitError` with retry timing instead of sleeping indefinitely.
25
+ Missing optional fields and malformed 429 responses are handled safely.
26
+ - Related ISC/scanner HTTP helpers no longer retry forever; contact User-Agent
27
+ interpolation is corrected for the ISC intel helper, and user-agent lookup
28
+ paths correctly encode spaces and reserved URL characters.
29
+ - Legacy `get_reverse_dns` retains its string result while using bounded DNS
30
+ resolution and correct exception handling.
31
+ - Example PTR processing covers all three log sources and uses public result
32
+ interfaces. Completed DNS results are persistently cached.
33
+ - Example IP metadata is unique by IP, with validated joins. Multiple scanner
34
+ labels are aggregated before joining, preserving raw label tables.
35
+ - Onyphe records are labeled `Onyphe`; actual LeakIX records remain `LeakIX`.
36
+ - Example credentials are read from environment variables; absent optional
37
+ Shodan configuration skips its lookups safely.
38
+ - Optional scanner/researcher feed failures no longer stop local ASN enrichment.
39
+ Source availability and retry timing are recorded separately in the
40
+ `enrichment_sources` table, preserving unknown labels and UA evidence.
41
+
42
+ ### Migration
43
+
44
+ - New ASN providers are opt-in through function calls or example configuration.
45
+ MaxMind database files and IPtoASN downloads are supplied by the caller.
46
+ - Catch `EnrichmentRateLimitError` and defer work until `retry_at`. Live provider
47
+ failures are visible instead of causing an unbounded wait.
48
+ - The updated example uses the new SQLite cache and does not automatically import
49
+ `ip_lookups.txt`, whose entries lack reliable fetch/expiration timestamps.
50
+ - Geolocation, registry country, ASN country, lookup range, and ASN size remain
51
+ separate concepts. Fields unavailable from a provider are left empty.
52
+ - The package version is maintained in `dshield_parser/_version.py` and exposed as
53
+ `dshield_parser.__version__`; `setup.py` reads that same value.
54
+
55
+ See [IP enrichment and migration examples](docs/IP-ENRICHMENT.md).
@@ -0,0 +1,3 @@
1
+ include README.md CHANGELOG.md requirements.txt requirements-enrichment.txt
2
+ recursive-include docs *.md
3
+ recursive-include examples *.py *.md
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dshield_parser
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: This program has utilities to download, parse and enrich DShield honeypot data
5
5
  Home-page: https://github.com/jslagrew/dshield-parser
6
6
  Author: Jesse La Grew
7
7
  Author-email: jlagrew.github@proton.me
8
8
  Requires-Python: >=3.12
9
+ Requires-Dist: dnspython>=2.6.1
9
10
  Requires-Dist: matplotlib>=3.6.3
10
11
  Requires-Dist: numpy>=1.26.0
11
12
  Requires-Dist: pandas>=2.0.3
@@ -13,9 +14,12 @@ Requires-Dist: requests>=2.32.3
13
14
  Requires-Dist: scikit-learn>=1.2.2
14
15
  Requires-Dist: shodan>=1.28.0
15
16
  Requires-Dist: SQLAlchemy>=2.0.31
17
+ Provides-Extra: geoip
18
+ Requires-Dist: geoip2>=4.8.0; extra == "geoip"
16
19
  Dynamic: author
17
20
  Dynamic: author-email
18
21
  Dynamic: home-page
22
+ Dynamic: provides-extra
19
23
  Dynamic: requires-dist
20
24
  Dynamic: requires-python
21
25
  Dynamic: summary
@@ -0,0 +1,84 @@
1
+ # dshield-parser
2
+
3
+ Python utilities for reading and summarizing DShield Cowrie, web honeypot, and firewall logs. The package exposes importable readers and report helpers. The scripts in [`examples/`](examples/README.md) show a research workflow that compares multiple honeypots, writes SQLite extracts, enriches selected records, and clusters URLs or commands.
4
+
5
+ ## New in 0.2.0: local and cached IP enrichment
6
+
7
+ `dshield_parser.utils.enrichment` now provides local GeoLite2 ASN/Country and
8
+ IPtoASN lookups, selective RDAP, persistent TTL-aware reverse DNS, bounded ISC
9
+ requests, and atomic ISC static-feed downloads. The example workflow fixes
10
+ Onyphe labeling and validates enrichment joins so repeated metadata cannot
11
+ multiply event counts.
12
+
13
+ See the [complete enrichment guide](docs/IP-ENRICHMENT.md) for installation,
14
+ database downloads, function examples, result fields, cache behavior, and
15
+ migration from 0.1.x. The [changelog](CHANGELOG.md) lists release changes.
16
+
17
+ From a checkout, install the optional MaxMind reader with
18
+ `python -m pip install -e ".[geoip]"`. Database files remain caller-managed.
19
+
20
+ ## Installing
21
+
22
+ Python 3.12 or later is required by this repository's `setup.py`. A virtual environment is recommended:
23
+
24
+ ```sh
25
+ python3 -m venv .venv
26
+ source .venv/bin/activate
27
+ python -m pip install --upgrade pip
28
+ python -m pip install dshield-parser
29
+ ```
30
+
31
+ On Windows, activate with `.venv\Scripts\activate` instead. To work from a checkout instead of the published package, run `python -m pip install -e .` in the repository root. The package declares dependencies for data frames, plotting, SQLite access, and optional enrichment services; the pinned development versions are in `requirements.txt`.
32
+
33
+ ## What it does
34
+
35
+ | Log family | Main helper module | Typical fields |
36
+ | --- | --- | --- |
37
+ | Cowrie | `dshield_parser.cowrie_processor.reports` | Source IP, username, password, command input, file hash |
38
+ | Web honeypot | `dshield_parser.web_processor.reports` | Source IP, URL, headers, method, user agent |
39
+ | Firewall | `dshield_parser.firewall_processor.reports` | Source and destination IP, port, protocol, flags |
40
+
41
+ `dshield_parser.utils.file_io` reads JSON files and JSON lines. `dshield_parser.utils.json` extracts fields and builds summaries. `dshield_parser.utils.sql`, `graphs`, `ml`, and `enrichment` support the example workflows. The repository does not define a supported command-line entry point; use the importable functions or adapt an example script.
42
+
43
+ ```mermaid
44
+ flowchart LR
45
+ A[Cowrie JSON lines] --> D[File readers]
46
+ B[Web honeypot logs] --> D
47
+ C[Firewall logs] --> D
48
+ D --> E[Field extraction and summaries]
49
+ E --> F[Data frames or SQLite extracts]
50
+ F --> G[Optional enrichment and clustering]
51
+ G --> H[Comparisons and plots]
52
+ ```
53
+
54
+ ## Using the library
55
+
56
+ Count readable Cowrie JSON events without loading the whole file into a list:
57
+
58
+ ```python
59
+ from dshield_parser.utils.file_io import read_file_json_generator
60
+
61
+ count = sum(1 for _ in read_file_json_generator("cowrie.json.2024-05-01"))
62
+ print(f"Parsed events: {count}")
63
+ ```
64
+
65
+ Summarize source IPs from dated Cowrie log files:
66
+
67
+ ```python
68
+ from pathlib import Path
69
+ from dshield_parser.cowrie_processor import reports
70
+
71
+ logs = [Path("cowrie.json.2024-05-01"), Path("cowrie.json.2024-05-02")]
72
+ window = {"start_date": "2024-05-01", "end_date": "2024-05-02"}
73
+ counts = reports.get_summary_src_ip(logs, datespan=window)
74
+ print(counts["src_ip"])
75
+ ```
76
+
77
+ This summary helper uses dates found in Cowrie **filenames** to select files. Keep dated filenames and check the actual events when you need precise event-time boundaries. It accepts an `exclusions` dictionary for exact field-value exclusions, for example `exclusions={"src_ip": "192.0.2.10"}`. Use the corresponding web or firewall report module for those log families; their field names differ.
78
+
79
+ For a multi-honeypot comparison, see [the example sequence](examples/README.md). Its scripts contain sample paths, dates, and sensor names that must be adapted to your own input layout. They are examples of a research process, not a single automatic CLI pipeline.
80
+
81
+ ## Data handling
82
+
83
+ The package reads local log paths supplied by your code. Examples may create SQLite files, text, plots, or enrichment results in their working directory. Review the script's input and output paths before running it on production data. Keep logs, databases, API keys, and generated output outside the source repository. Enrichment functions that call external services require their own service configuration and may have rate or availability limits.
84
+
@@ -0,0 +1,404 @@
1
+ # IP enrichment in 0.2.0
2
+
3
+ Routine ASN attribution can now run against a local database. DNS, ISC and
4
+ registration detail can be collected separately with explicit deadlines,
5
+ resumable caching, and visible failure states. All public entry points remain in
6
+ `dshield_parser.utils.enrichment`; the private implementation modules separate
7
+ the local, DNS, HTTP, and SQLite responsibilities.
8
+
9
+ ## Installation
10
+
11
+ From this checkout:
12
+
13
+ ```sh
14
+ python -m pip install -e ".[geoip]"
15
+ python -c "import dshield_parser; print(dshield_parser.__version__)"
16
+ ```
17
+
18
+ The version is `0.2.0`. The `geoip` extra installs the optional MaxMind Python
19
+ reader. Use `python -m pip install -e .` if only using IPtoASN, DNS, RDAP, or ISC.
20
+ Dnspython is a core dependency because the legacy DNS function now uses it.
21
+ After 0.2.0 is published, the equivalent package installation is
22
+ `python -m pip install "dshield-parser[geoip]==0.2.0"`.
23
+
24
+ Creating the changes in GitHub does not publish the package to PyPI. The existing
25
+ release workflow still requires a matching version tag; its test and version
26
+ checks now run before publication. The single version source is
27
+ `dshield_parser/_version.py`, read by both the package and `setup.py`.
28
+
29
+ ## Public API
30
+
31
+ | Entry point | Purpose | External lookup |
32
+ | --- | --- | --- |
33
+ | `get_geoip_asn(ip, database_path=None, *, reader=None)` | ASN and associated organization from a local GeoLite2 ASN MMDB | None |
34
+ | `get_geoip_country(ip, database_path=None, *, reader=None)` | Separate geolocation and registered-country fields from a local Country/City MMDB | None |
35
+ | `load_iptoasn_database(path)` | Validate and index a plain/gzip IPtoASN TSV once | None |
36
+ | `get_iptoasn_data(ip, database)` | Binary-search the reusable IPtoASN index | None |
37
+ | `get_rdap_data(ip, *, email, ...)` | Selective registration lookup via IANA bootstrap and authoritative RDAP | HTTPS on an uncached lookup |
38
+ | `get_whois_data(ip, email, **kwargs)` | Alias for `get_rdap_data`; implements the former placeholder | Same as RDAP |
39
+ | `get_reverse_dns_info(ip, ...)` | PTR names, status, and expiration | DNS on a cache miss |
40
+ | `iter_reverse_dns(ips, *, max_workers=16, ...)` | Stream completed, deduplicated PTR results with bounded work | DNS on cache misses |
41
+ | `get_reverse_dns(ip, **kwargs)` | Compatibility wrapper returning one hostname or `""` | Same as DNS |
42
+ | `isc_ipinfo(ip, email, ...)` | Legacy ISC field dictionary with bounded requests and an optional exact-IP cache | HTTPS on a cache miss |
43
+ | `download_isc_feed(feed, destination, *, email, ...)` | Atomically download `daily_sources` or `threatintel` | HTTPS when a refresh is needed |
44
+ | `EnrichmentCache(path)` | Explicit, thread-safe SQLite cache shared across script runs | None |
45
+
46
+ New structured results include `ip`, `source`, `status`, `fetched_at`, and
47
+ `cache_hit`. DNS and RDAP add `expires_at`; local datasets instead report dataset
48
+ provenance. Times in these fields are **UTC Unix seconds**, not the time of the
49
+ original honeypot event. `isc_ipinfo` retains its `{ip: fields}` shape.
50
+
51
+ ```mermaid
52
+ flowchart TD
53
+ A["Unique IPs from all logs"] --> B["Local ASN database"]
54
+ A --> C["PTR cache and bounded DNS workers"]
55
+ A -->|"Selected detail lookups"| D["RDAP or ISC with provider cooldown"]
56
+ E["Downloaded ISC feeds"] --> F["Enrichment records with source and time"]
57
+ B --> F
58
+ C --> F
59
+ D --> F
60
+ F --> G["Validated joins into analysis tables"]
61
+ ```
62
+
63
+ ## Local ASN and country databases
64
+
65
+ ### GeoLite2 ASN
66
+
67
+ Download and unpack the MMDB using your MaxMind account, following the official
68
+ [GeoLite integration guide](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data/).
69
+ For regular updates, MaxMind recommends
70
+ [GeoIP Update](https://dev.maxmind.com/geoip/updating-databases/).
71
+ Keep download credentials outside the repository and follow the provider's
72
+ current attribution, update, and retention requirements.
73
+
74
+ Reuse one reader for a batch:
75
+
76
+ ```python
77
+ import geoip2.database
78
+ from dshield_parser.utils import enrichment
79
+
80
+ ips = ["1.1.1.1", "8.8.8.8"]
81
+ with geoip2.database.Reader("GeoLite2-ASN.mmdb") as reader:
82
+ records = [enrichment.get_geoip_asn(ip, reader=reader) for ip in ips]
83
+
84
+ for record in records:
85
+ print(record["ip"], record["asn"], record["asn_organization"], record["status"])
86
+ ```
87
+
88
+ For a single lookup, supply `database_path="GeoLite2-ASN.mmdb"` instead. Exactly
89
+ one of `reader` and `database_path` is required. A caller-owned reader is never
90
+ closed by the helper; a reader created for a single lookup is closed afterwards.
91
+ The supported ASN database is GeoLite2-ASN. Paid ISP databases are not handled by
92
+ this ASN method.
93
+
94
+ ASN result fields are `asn`, `asn_organization`, and `lookup_network`, plus
95
+ `dataset_build_epoch`, `dataset_type`, and `dataset_path` where available. A
96
+ caller-supplied reader has no known path, so `dataset_path` is `None`. An address
97
+ absent from the database returns `status="not_found"`. A corrupt database,
98
+ incompatible database, or invalid address raises an error instead of being
99
+ misreported as absent.
100
+
101
+ ### Country or City MMDB
102
+
103
+ ```python
104
+ from dshield_parser.utils.enrichment import get_geoip_country
105
+
106
+ record = get_geoip_country("1.1.1.1", "GeoLite2-Country.mmdb")
107
+ print(record["geo_country"], record["registered_country"])
108
+ ```
109
+
110
+ GeoLite2/GeoIP2 Country and City databases are supported. `geo_country` is the
111
+ source's IP geolocation country; `registered_country` is a distinct registration
112
+ attribute. Neither is automatically the ISC `ascountry` field or evidence of an
113
+ attacker's physical location.
114
+
115
+ ### IPtoASN
116
+
117
+ Download an ASN dataset from [IPtoASN](https://www.iptoasn.com/), keep it outside
118
+ the repository, then load it once:
119
+
120
+ ```python
121
+ from dshield_parser.utils import enrichment
122
+
123
+ database = enrichment.load_iptoasn_database("ip2asn-combined.tsv.gz")
124
+ records = [
125
+ enrichment.get_iptoasn_data(ip, database)
126
+ for ip in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"]
127
+ ]
128
+ ```
129
+
130
+ The five TSV columns are `range_start`, `range_end`, `AS_number`, `country_code`,
131
+ and `AS_description`. Plain and gzip files, integer IPv4 endpoints, IPv6, and
132
+ combined files are supported. The loader rejects malformed, overlapping,
133
+ unsorted, empty, and truncated datasets. Lookups use binary search; they do not
134
+ reopen or scan the file for each IP. Reload the index after replacing the file.
135
+
136
+ Results include `asn`, `asn_organization`, `source_country`, `range_start`, and
137
+ `range_end`. Inclusive source ranges can span multiple CIDRs, so no BGP prefix is
138
+ invented. AS0/unallocated ranges and missing ranges return `not_found`.
139
+ `dataset_mtime`, `dataset_mtime_ns`, and `dataset_size_bytes` identify the local
140
+ file at load time; its modification time is not claimed to be the upstream build
141
+ date. The IPtoASN public API is discontinued; these functions use downloads.
142
+
143
+ ### Field meanings when migrating
144
+
145
+ | Old ISC field | Local replacement |
146
+ | --- | --- |
147
+ | `as` | `asn`, with provider recorded |
148
+ | `asname` | `asn_organization`; names can differ across providers |
149
+ | `network` | `lookup_network` for MaxMind, or explicit start/end ranges for IPtoASN |
150
+ | `ascountry` | No silent substitution; retain separate country fields |
151
+ | `assize` | No direct replacement in these basic lookups |
152
+ | `updated` | Preserve source meaning; use separate fetch/build timestamps |
153
+
154
+ A matched lookup prefix, routing announcement, and registered address range are
155
+ different concepts. The size of one prefix is not the size of an entire ASN.
156
+ Local ASN records do not contain complete WHOIS contacts or ISC activity counts.
157
+
158
+ ## Persistent cache and reverse DNS
159
+
160
+ Use one cache file at a stable location across runs. The path is explicit for
161
+ library calls. The cache stores JSON records with unique `(namespace, key)` pairs
162
+ and per-record expiration. It may be shared by threads; independent instances can
163
+ open the same local SQLite file. Close it after its workers have finished.
164
+
165
+ ```python
166
+ from pathlib import Path
167
+ from dshield_parser.utils import enrichment
168
+
169
+ cache_file = Path.home() / "dshield-parser-cache" / "enrichment.sqlite"
170
+ ips = ["1.1.1.1", "8.8.8.8", "1.1.1.1"]
171
+ with enrichment.EnrichmentCache(cache_file) as cache:
172
+ for record in enrichment.iter_reverse_dns(
173
+ ips, cache=cache, max_workers=16, timeout=1.0, lifetime=3.0,
174
+ ):
175
+ print(record["ip"], record["reverse_dns"], record["status"])
176
+ ```
177
+
178
+ The generator yields **completion order**. Join using `record["ip"]`, never list
179
+ position. Inputs are canonicalized and deduplicated, with at most `max_workers`
180
+ lookups in flight. The deduplication set still grows with distinct IPs. Each result
181
+ is saved before it is yielded, so an interrupted run can reuse completed fresh
182
+ records. Closing the generator cancels unstarted work and waits for active DNS
183
+ lookups to complete within their resolver lifetime.
184
+
185
+ | DNS status | Meaning | Cache behavior |
186
+ | --- | --- | --- |
187
+ | `ok` | At least one PTR was returned | Uses the answer's original expiration |
188
+ | `nxdomain` | DNS returned name nonexistence | Uses the negative SOA lifetime, including alias limits |
189
+ | `no_ptr` | A DNS answer contains no PTR | Uses its negative lifetime when supplied |
190
+ | `timeout` | Total DNS lookup lifetime expired | Separate short failure cooldown |
191
+ | `resolver_error` | Nameservers/transport could not resolve the question | Separate short failure cooldown |
192
+
193
+ `ptr_names` retains all returned names. `reverse_dns` provides the first name or
194
+ an empty string. A negative response without a defensible SOA lifetime is not
195
+ persisted as fresh. A timeout is never treated as proof of no PTR. Temporary
196
+ failures default to a 60-second cooldown; set `failure_ttl=0` to disable that.
197
+
198
+ The default resolver uses the host's configured DNS service and an explicit
199
+ dnspython in-memory cache. `timeout` is the per-server wait; `lifetime` bounds the
200
+ DNS question. A supplied dnspython resolver is copied before changing timing
201
+ settings, preserving the caller's configuration. Configure a resolver before
202
+ sharing it between workers. A custom resolver must honor the supplied lifetime;
203
+ it can provide `enrichment_cache_identity` for a stable cache view. Normal
204
+ resolver cache keys include resolver/transport identity to separate DNS views.
205
+
206
+ For compatibility, `get_reverse_dns(ip, cache=cache)` still returns a string.
207
+ Use the structured function when the distinction between absence and failure
208
+ matters. Putting a timeout on a thread Future alone does not stop the underlying
209
+ OS resolver; this implementation places the deadline inside the DNS resolver.
210
+
211
+ ## Selective RDAP registration detail
212
+
213
+ ```python
214
+ import os
215
+ import requests
216
+ from dshield_parser.utils import enrichment
217
+
218
+ with enrichment.EnrichmentCache("enrichment.sqlite") as cache, requests.Session() as session:
219
+ try:
220
+ record = enrichment.get_rdap_data(
221
+ "1.1.1.1", email=os.environ["DSHIELD_CONTACT_EMAIL"],
222
+ cache=cache, session=session,
223
+ )
224
+ except enrichment.EnrichmentRateLimitError as exc:
225
+ print("Save this lookup as pending until", exc.retry_at)
226
+ except enrichment.EnrichmentError as exc:
227
+ print("Provider lookup failed:", exc)
228
+ else:
229
+ print(record["status"], record["network"], record["abuse_contacts"])
230
+ ```
231
+
232
+ The client caches IANA IPv4/IPv6 bootstrap data, selects the most-specific matching
233
+ service, and retrieves the authoritative IP network record over HTTPS. You can
234
+ instead pass `bootstrap` as an IANA-style dictionary, a prefix-to-HTTPS-base map,
235
+ or a path to either JSON form. HTTPS redirects and exact-IP RDAP referrals are
236
+ bounded. The returned range must contain the requested IP.
237
+
238
+ Results include `network` (handle, name, start/end addresses, country and related
239
+ registration fields), `entities`, `abuse_contacts`, and `raw`. Contacts are
240
+ extracted only from entities already in the response. There is no recursive
241
+ entity retrieval, extra ASN query, or port-43 WHOIS request. Data omitted by the
242
+ registry remains absent. A non-global address returns `not_global`; HTTP 404
243
+ becomes a short-lived `not_found` result. Other failures raise `EnrichmentError`.
244
+
245
+ The default successful RDAP cache lifetime is seven days and is configurable with
246
+ `cache_ttl`. Cache identity includes the exact query URL; records are not applied
247
+ to an entire parent subnet or ASN. Registration data remains separate from ASN,
248
+ geolocation, per-IP threat observations, and PTR data.
249
+
250
+ ## ISC requests and static feeds
251
+
252
+ The legacy call remains usable:
253
+
254
+ ```python
255
+ from dshield_parser.utils import enrichment
256
+
257
+ with enrichment.EnrichmentCache("enrichment.sqlite") as cache:
258
+ data = enrichment.isc_ipinfo("1.1.1.1", "analyst@example.org", cache=cache)
259
+ print(data["1.1.1.1"]["asname"])
260
+ ```
261
+
262
+ Its default exact-IP cache lifetime is 24 hours. The inner field dictionary also
263
+ includes `fetched_at`, `expires_at`, and `cache_hit`, so callers can distinguish
264
+ the original observation time from cache reuse. Optional missing XML fields are
265
+ `None`; HTML or malformed success responses are errors. Contact information is
266
+ required for HTTP User-Agent identification. Use the rate-limit handling shown
267
+ above around this call in long-running jobs.
268
+
269
+ HTTP functions default to `(3.05, 10)` seconds for connect/read timeouts and three
270
+ attempts. These are connection/read-inactivity limits, **not a total wall-clock
271
+ deadline for a complete download**. Temporary transport errors and selected 5xx
272
+ responses receive short capped backoff. Permanent HTTP failures do not retry
273
+ forever. HTTP 429, and 503 with Retry-After, immediately raise
274
+ `EnrichmentRateLimitError`, exposing `origin`, `retry_after`, and `retry_at`.
275
+ Retry-After seconds and HTTP dates are supported; ISC's HTML delay is a fallback,
276
+ then a conservative 300-second fallback. No worker sleeps for that five-minute
277
+ interval. Known origin-wide cooldowns prevent subsequent requests, including
278
+ lookups for different IPs. Passing the persistent cache retains cooldowns across
279
+ processes. Cached successful data can still be used while a provider is cooling
280
+ down. Respect `retry_at`; resume pending work later rather than increasing load.
281
+
282
+ Static ISC feeds can be downloaded separately:
283
+
284
+ ```python
285
+ from dshield_parser.utils import enrichment
286
+
287
+ with enrichment.EnrichmentCache("enrichment.sqlite") as cache:
288
+ result = enrichment.download_isc_feed(
289
+ "threatintel", "feeds/threatintel.txt",
290
+ email="analyst@example.org", cache=cache,
291
+ )
292
+ print(result["path"], result["status"])
293
+ ```
294
+
295
+ The other supported feed name is `daily_sources`. The helper keeps the original
296
+ feed text; it does not guess a schema or claim that a daily feed replaces every
297
+ field of `/api/ip`. Ingest only fields and time windows appropriate for your
298
+ analysis. Absence from a feed does not mean benign.
299
+
300
+ Reuse a stable destination. An unchanged file downloaded within the last hour is
301
+ reused for that feed/path; cache metadata makes that behavior survive restarts.
302
+ The default maximum download size is 256 MiB, configurable with `max_bytes`.
303
+ Downloads stream to a temporary file in the destination directory and replace the
304
+ destination only after a successful nonempty download. HTML/error responses,
305
+ oversized downloads, and interrupted transfers leave the last good file intact.
306
+ Refresh jobs should follow [ISC's feed guidance](https://isc.sans.edu/feeds_doc.html)
307
+ and avoid fetching more frequently than hourly.
308
+
309
+ ## Updating the example workflow
310
+
311
+ `examples/clear_research_ips.py` uses a shared cache path and the bounded DNS batch
312
+ helper. It supports these environment settings (set them in the same shell that
313
+ starts the script):
314
+
315
+ | Variable | Purpose |
316
+ | --- | --- |
317
+ | `DSHIELD_CONTACT_EMAIL` | Contact address for ISC and other HTTP requests |
318
+ | `SHODAN_API_KEY` | Optional Shodan key; an unset key skips Shodan-only lookups |
319
+ | `DSHIELD_ENRICHMENT_CACHE` | Cache file; defaults to `~/dshield-parser-cache/enrichment.sqlite` |
320
+ | `DSHIELD_ASN_MMDB` | GeoLite2 ASN MMDB path for local ASN attribution |
321
+ | `DSHIELD_IPTOASN_FILE` | IPtoASN TSV/gzip path as an alternative local provider |
322
+
323
+ Set at most one local ASN path. Without either, the example continues to use ISC
324
+ for ASN/network context, with bounded requests and the new exact-IP cache. The
325
+ example still uses other external scanner/researcher feeds even when ASN lookups
326
+ are local; the standalone local functions themselves perform no network calls.
327
+ Optional feed failures do not stop local ASN processing. The separate
328
+ `enrichment_sources` output table records `source`, `status`, `checked_at`,
329
+ `retry_at`, and a sanitized `error` category. Its statuses are `ok`, `deferred`,
330
+ `error`, `unavailable`, or `skipped`. `checked_at` is the source availability
331
+ assessment time, not the source record's observation time. Check this table
332
+ before interpreting missing labels; missing evidence never proves an IP benign.
333
+
334
+ PowerShell configuration example:
335
+
336
+ ```powershell
337
+ $env:DSHIELD_CONTACT_EMAIL = "analyst@example.org"
338
+ $env:DSHIELD_ASN_MMDB = "C:\Enrichment\GeoLite2-ASN.mmdb"
339
+ $env:DSHIELD_ENRICHMENT_CACHE = "C:\Enrichment\enrichment.sqlite"
340
+ python examples/clear_research_ips.py
341
+ ```
342
+
343
+ Edit the example's input database and date settings for your run. Add the Shodan
344
+ environment variable only if you want those lookups. Keep actual keys outside
345
+ tracked files.
346
+
347
+ The example reports `enrichment_status` and `enrichment_retry_at` for pending
348
+ ISC data. It does not pretend a partially enriched output is complete. Rerun
349
+ after the provider cooldown to resume using fresh cached successes. Local
350
+ results retain `enrichment_source`, a local lookup timestamp, and separate
351
+ IPtoASN range/country fields. `ascountry`, `assize`, and ISC `updated` are left
352
+ empty when the local provider cannot supply their original meanings.
353
+
354
+ The old `ip_lookups.txt` is no longer automatically consumed by this example.
355
+ Its entries have no reliable local fetch/expiration time and previously reused
356
+ arbitrary parent networks. Keep it as a historical artifact if needed. A first
357
+ run with the new cache needs fresh ISC results or a configured local dataset;
358
+ subsequent runs reuse fresh cache entries. This change prevents cached subnet
359
+ copies from multiplying event rows.
360
+
361
+ Scanner/research labels are aggregated for one-row-per-IP joins while raw label
362
+ records remain in their separate output tables. Joins validate many-to-one
363
+ cardinality. Onyphe and LeakIX now have separate correct labels. PTR results cover
364
+ the union of web, Cowrie, and firewall IPs. Logging and provider status distinguish
365
+ lookup failures from confirmed absence.
366
+
367
+ ## Testing and release checks
368
+
369
+ ```sh
370
+ python -m pip install ".[geoip]" build setuptools wheel
371
+ python -m unittest discover -s dshield_parser/test -p "test_enrichment*.py" -v
372
+ python -m build
373
+ ```
374
+
375
+ Tests use synthetic addresses, fake provider responses, temporary SQLite files,
376
+ and DNS records. They do not send real DNS/ISC/RDAP requests. Coverage includes
377
+ range boundaries and IPv6, optional reader behavior, cache persistence/expiry,
378
+ negative TTLs, bounded DNS work, retry/cooldown behavior, response validation,
379
+ atomic feed updates, public API compatibility, and workflow merge cardinality.
380
+
381
+ The older `test_json.py` suite references honeypot fixture files absent from this
382
+ repository; it is not represented as passing by these enrichment checks. CI runs
383
+ the self-contained enrichment suite and builds the package on Linux and Windows.
384
+ The release workflow verifies that the pushed tag matches the package version
385
+ and runs the same enrichment suite before publishing.
386
+
387
+ ## Source references and remaining scope
388
+
389
+ - [MaxMind ASN fields](https://dev.maxmind.com/geoip/docs/databases/asn/binary/)
390
+ - [MaxMind Python reader](https://github.com/maxmind/GeoIP2-python)
391
+ - [IPtoASN downloadable datasets](https://www.iptoasn.com/)
392
+ - [ISC API usage and throttling](https://isc.sans.edu/api/)
393
+ - [ISC static feeds](https://isc.sans.edu/feeds_doc.html)
394
+ - [Authoritative RDAP discovery, RFC 9224](https://www.rfc-editor.org/rfc/rfc9224.html)
395
+ - [RDAP rate limits, RFC 7480](https://www.rfc-editor.org/rfc/rfc7480.html#section-5.5)
396
+ - [Dnspython resolver timing](https://dnspython.readthedocs.io/en/stable/resolver-class.html)
397
+ - [Dnspython cache behavior](https://dnspython.readthedocs.io/en/stable/resolver-caching.html)
398
+
399
+ Bulk RIR mirrors and licensed Internet-wide PTR snapshots require separate data
400
+ acquisition, terms, and import design. This release does not download or mirror
401
+ them. It supplies local ASN/country lookups, selective RDAP, TTL-aware PTR results,
402
+ and safe ISC feed downloads for the pipeline's immediate needs. For historical
403
+ research, record event time, enrichment observation time, and dataset provenance
404
+ separately; current attribution is not proof of ownership at an earlier date.
@@ -0,0 +1,3 @@
1
+ """Utilities for processing and enriching DShield honeypot logs."""
2
+
3
+ from ._version import __version__
@@ -0,0 +1,3 @@
1
+ """Package version, also read by setup.py without importing dependencies."""
2
+
3
+ __version__ = "0.2.0"