threatpulse-cli 1.0.0__py3-none-any.whl
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.
- threat_pulse/__init__.py +17 -0
- threat_pulse/__main__.py +7 -0
- threat_pulse/cache.py +119 -0
- threat_pulse/cli.py +282 -0
- threat_pulse/detector.py +136 -0
- threat_pulse/enrichers/__init__.py +13 -0
- threat_pulse/enrichers/abuseipdb.py +69 -0
- threat_pulse/enrichers/base.py +23 -0
- threat_pulse/enrichers/malware.py +80 -0
- threat_pulse/enrichers/network.py +81 -0
- threat_pulse/formatters.py +140 -0
- threat_pulse/heuristics.py +167 -0
- threat_pulse/models.py +67 -0
- threat_pulse/scorer.py +170 -0
- threatpulse_cli-1.0.0.dist-info/METADATA +223 -0
- threatpulse_cli-1.0.0.dist-info/RECORD +20 -0
- threatpulse_cli-1.0.0.dist-info/WHEEL +5 -0
- threatpulse_cli-1.0.0.dist-info/entry_points.txt +3 -0
- threatpulse_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- threatpulse_cli-1.0.0.dist-info/top_level.txt +1 -0
threat_pulse/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ThreatPulse - Fast, modular Threat Intelligence & IOC enrichment engine for SOC Analysts."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
__author__ = "Aleksei Zharkou"
|
|
5
|
+
__license__ = "MIT"
|
|
6
|
+
|
|
7
|
+
from threat_pulse.models import IOCType, RiskLevel, EnrichmentResult
|
|
8
|
+
from threat_pulse.detector import detect_ioc_type
|
|
9
|
+
from threat_pulse.scorer import calculate_risk_score
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"IOCType",
|
|
13
|
+
"RiskLevel",
|
|
14
|
+
"EnrichmentResult",
|
|
15
|
+
"detect_ioc_type",
|
|
16
|
+
"calculate_risk_score",
|
|
17
|
+
]
|
threat_pulse/__main__.py
ADDED
threat_pulse/cache.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Local SQLite caching layer for threat intelligence queries."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
from threat_pulse.models import EnrichmentResult, IOCType, RiskLevel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ThreatCache:
|
|
12
|
+
"""Persistent local cache for IOC enrichment results to respect API rate limits."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, db_path: Optional[str] = None, ttl_seconds: int = 86400):
|
|
15
|
+
if db_path:
|
|
16
|
+
self.db_path = Path(db_path)
|
|
17
|
+
else:
|
|
18
|
+
self.db_path = Path.home() / ".threatpulse_cache.db"
|
|
19
|
+
self.ttl = ttl_seconds
|
|
20
|
+
self._init_db()
|
|
21
|
+
|
|
22
|
+
def _init_db(self) -> None:
|
|
23
|
+
"""Create cache table if not already existing."""
|
|
24
|
+
try:
|
|
25
|
+
conn = sqlite3.connect(self.db_path)
|
|
26
|
+
try:
|
|
27
|
+
cursor = conn.cursor()
|
|
28
|
+
cursor.execute(
|
|
29
|
+
"""
|
|
30
|
+
CREATE TABLE IF NOT EXISTS ioc_cache (
|
|
31
|
+
ioc TEXT PRIMARY KEY,
|
|
32
|
+
ioc_type TEXT,
|
|
33
|
+
data_json TEXT,
|
|
34
|
+
cached_at INTEGER
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
)
|
|
38
|
+
conn.commit()
|
|
39
|
+
finally:
|
|
40
|
+
conn.close()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def get(self, ioc: str) -> Optional[EnrichmentResult]:
|
|
45
|
+
"""Retrieve cached result if within TTL."""
|
|
46
|
+
try:
|
|
47
|
+
conn = sqlite3.connect(self.db_path)
|
|
48
|
+
try:
|
|
49
|
+
cursor = conn.cursor()
|
|
50
|
+
cursor.execute(
|
|
51
|
+
"SELECT ioc_type, data_json, cached_at FROM ioc_cache WHERE ioc = ?",
|
|
52
|
+
(ioc.lower().strip(),)
|
|
53
|
+
)
|
|
54
|
+
row = cursor.fetchone()
|
|
55
|
+
if not row:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
ioc_type_str, data_json, cached_at = row
|
|
59
|
+
if time.time() - cached_at > self.ttl:
|
|
60
|
+
return None # Expired
|
|
61
|
+
|
|
62
|
+
payload = json.loads(data_json)
|
|
63
|
+
payload["cached"] = True
|
|
64
|
+
return EnrichmentResult(
|
|
65
|
+
ioc=payload["ioc"],
|
|
66
|
+
ioc_type=IOCType(payload["ioc_type"]),
|
|
67
|
+
defanged=payload["defanged"],
|
|
68
|
+
risk_score=payload["risk_score"],
|
|
69
|
+
risk_level=RiskLevel(payload["risk_level"]),
|
|
70
|
+
verdict=payload["verdict"],
|
|
71
|
+
is_private=payload.get("is_private", False),
|
|
72
|
+
is_bogon=payload.get("is_bogon", False),
|
|
73
|
+
tags=payload.get("tags", []),
|
|
74
|
+
mitre_attack=payload.get("mitre_attack", []),
|
|
75
|
+
details=payload.get("details", {}),
|
|
76
|
+
recommendation=payload.get("recommendation", ""),
|
|
77
|
+
cached=True,
|
|
78
|
+
timestamp=payload.get("timestamp", "")
|
|
79
|
+
)
|
|
80
|
+
finally:
|
|
81
|
+
conn.close()
|
|
82
|
+
except Exception:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def set(self, result: EnrichmentResult) -> None:
|
|
86
|
+
"""Save enrichment result into local SQLite cache."""
|
|
87
|
+
try:
|
|
88
|
+
payload_json = json.dumps(result.to_dict())
|
|
89
|
+
conn = sqlite3.connect(self.db_path)
|
|
90
|
+
try:
|
|
91
|
+
cursor = conn.cursor()
|
|
92
|
+
cursor.execute(
|
|
93
|
+
"""
|
|
94
|
+
INSERT OR REPLACE INTO ioc_cache (ioc, ioc_type, data_json, cached_at)
|
|
95
|
+
VALUES (?, ?, ?, ?)
|
|
96
|
+
""",
|
|
97
|
+
(result.ioc.lower().strip(), result.ioc_type.value, payload_json, int(time.time()))
|
|
98
|
+
)
|
|
99
|
+
conn.commit()
|
|
100
|
+
finally:
|
|
101
|
+
conn.close()
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def clear(self) -> int:
|
|
106
|
+
"""Clear all entries from cache and return count of deleted records."""
|
|
107
|
+
try:
|
|
108
|
+
conn = sqlite3.connect(self.db_path)
|
|
109
|
+
try:
|
|
110
|
+
cursor = conn.cursor()
|
|
111
|
+
cursor.execute("SELECT count(*) FROM ioc_cache")
|
|
112
|
+
count = cursor.fetchone()[0]
|
|
113
|
+
cursor.execute("DELETE FROM ioc_cache")
|
|
114
|
+
conn.commit()
|
|
115
|
+
return count
|
|
116
|
+
finally:
|
|
117
|
+
conn.close()
|
|
118
|
+
except Exception:
|
|
119
|
+
return 0
|
threat_pulse/cli.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) entry point for ThreatPulse."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from threat_pulse import __version__
|
|
10
|
+
from threat_pulse.cache import ThreatCache
|
|
11
|
+
from threat_pulse.detector import detect_ioc_type, defang, refang, extract_iocs_from_text
|
|
12
|
+
from threat_pulse.enrichers.abuseipdb import AbuseIPDBEnricher
|
|
13
|
+
from threat_pulse.enrichers.malware import MalwareHashEnricher
|
|
14
|
+
from threat_pulse.enrichers.network import NetworkEnricher
|
|
15
|
+
from threat_pulse.formatters import (
|
|
16
|
+
render_terminal_card,
|
|
17
|
+
render_terminal_summary_table,
|
|
18
|
+
render_json,
|
|
19
|
+
render_markdown,
|
|
20
|
+
render_wazuh_cdb_list,
|
|
21
|
+
)
|
|
22
|
+
from threat_pulse.heuristics import (
|
|
23
|
+
analyze_ip_heuristics,
|
|
24
|
+
analyze_domain_heuristics,
|
|
25
|
+
analyze_url_heuristics,
|
|
26
|
+
)
|
|
27
|
+
from threat_pulse.models import EnrichmentResult, IOCType, RiskLevel
|
|
28
|
+
from threat_pulse.scorer import calculate_risk_score
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def enrich_single_ioc(
|
|
32
|
+
raw_ioc: str,
|
|
33
|
+
cache: Optional[ThreatCache] = None,
|
|
34
|
+
abuse_api_key: Optional[str] = None,
|
|
35
|
+
use_cache: bool = True
|
|
36
|
+
) -> EnrichmentResult:
|
|
37
|
+
"""Enrich a single IOC across all heuristic, network, and intelligence engines."""
|
|
38
|
+
clean_target = refang(raw_ioc).strip()
|
|
39
|
+
ioc_type = detect_ioc_type(clean_target)
|
|
40
|
+
|
|
41
|
+
# 1. Check Cache
|
|
42
|
+
if use_cache and cache:
|
|
43
|
+
cached_result = cache.get(clean_target)
|
|
44
|
+
if cached_result:
|
|
45
|
+
return cached_result
|
|
46
|
+
|
|
47
|
+
# 2. Run Heuristics
|
|
48
|
+
heuristics = {}
|
|
49
|
+
if ioc_type in (IOCType.IPV4, IOCType.IPV6):
|
|
50
|
+
heuristics = analyze_ip_heuristics(clean_target)
|
|
51
|
+
elif ioc_type == IOCType.DOMAIN:
|
|
52
|
+
heuristics = analyze_domain_heuristics(clean_target)
|
|
53
|
+
elif ioc_type == IOCType.URL:
|
|
54
|
+
heuristics = analyze_url_heuristics(clean_target)
|
|
55
|
+
|
|
56
|
+
# 3. Run Enrichers
|
|
57
|
+
network_enricher = NetworkEnricher()
|
|
58
|
+
abuse_enricher = AbuseIPDBEnricher(api_key=abuse_api_key)
|
|
59
|
+
malware_enricher = MalwareHashEnricher()
|
|
60
|
+
|
|
61
|
+
net_data = network_enricher.enrich(clean_target, ioc_type) if network_enricher.can_enrich(ioc_type) else {}
|
|
62
|
+
abuse_data = abuse_enricher.enrich(clean_target, ioc_type) if abuse_enricher.can_enrich(ioc_type) else {}
|
|
63
|
+
malware_data = malware_enricher.enrich(clean_target, ioc_type) if malware_enricher.can_enrich(ioc_type) else {}
|
|
64
|
+
|
|
65
|
+
# 4. Score and Classify
|
|
66
|
+
score, level, verdict, tags, mitre, recom = calculate_risk_score(
|
|
67
|
+
clean_target, ioc_type, heuristics, net_data, abuse_data, malware_data
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Combine all details
|
|
71
|
+
combined_details = {**heuristics, **net_data, **abuse_data, **malware_data}
|
|
72
|
+
|
|
73
|
+
result = EnrichmentResult(
|
|
74
|
+
ioc=clean_target,
|
|
75
|
+
ioc_type=ioc_type,
|
|
76
|
+
defanged=defang(clean_target),
|
|
77
|
+
risk_score=score,
|
|
78
|
+
risk_level=level,
|
|
79
|
+
verdict=verdict,
|
|
80
|
+
is_private=heuristics.get("is_rfc1918", False),
|
|
81
|
+
is_bogon=heuristics.get("is_bogon", False),
|
|
82
|
+
tags=tags,
|
|
83
|
+
mitre_attack=mitre,
|
|
84
|
+
details=combined_details,
|
|
85
|
+
recommendation=recom,
|
|
86
|
+
cached=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Save to Cache
|
|
90
|
+
if use_cache and cache:
|
|
91
|
+
cache.set(result)
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
97
|
+
"""Build the CLI argument parser with comprehensive options."""
|
|
98
|
+
parser = argparse.ArgumentParser(
|
|
99
|
+
prog="threat-pulse",
|
|
100
|
+
description="ThreatPulse - Fast Threat Intelligence & IOC Enrichment CLI for SOC Analysts.",
|
|
101
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
102
|
+
epilog="""
|
|
103
|
+
Examples:
|
|
104
|
+
threat-pulse 185.220.101.5
|
|
105
|
+
threat-pulse 84c82835a5d21bbcf75a61706d8ab549
|
|
106
|
+
threat-pulse evil-dga-domain192837.top --format json
|
|
107
|
+
threat-pulse --file access.log --format table
|
|
108
|
+
threat-pulse --file suspicious_iocs.txt --format wazuh-cdb --out threat_pulse.cdb
|
|
109
|
+
threat-pulse --defang http://malicious.com/payload.exe
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"ioc",
|
|
114
|
+
nargs="?",
|
|
115
|
+
help="Target IOC to inspect (IPv4, IPv6, Domain, URL, MD5, SHA1, SHA256)"
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"-f", "--file",
|
|
119
|
+
help="Path to file (log, alert export, text) containing IOCs to extract and analyze"
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--format",
|
|
123
|
+
choices=["card", "table", "json", "markdown", "wazuh-cdb"],
|
|
124
|
+
default="card",
|
|
125
|
+
help="Output visualization format (default: card for single, table for batch)"
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"-o", "--out",
|
|
129
|
+
help="Write report to an output file instead of stdout"
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--api-key",
|
|
133
|
+
help="AbuseIPDB API key (defaults to ABUSEIPDB_API_KEY environment variable)"
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--no-cache",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Bypass local SQLite cache and force fresh enrichment"
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--clear-cache",
|
|
142
|
+
action="store_true",
|
|
143
|
+
help="Wipe the local ThreatPulse SQLite cache and exit"
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--defang",
|
|
147
|
+
help="Quickly defang an indicator and print the result"
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--refang",
|
|
151
|
+
help="Quickly refang an indicator and print the canonical form"
|
|
152
|
+
)
|
|
153
|
+
parser.add_argument(
|
|
154
|
+
"-v", "--version",
|
|
155
|
+
action="version",
|
|
156
|
+
version=f"ThreatPulse v{__version__}"
|
|
157
|
+
)
|
|
158
|
+
return parser
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main() -> int:
|
|
162
|
+
# Ensure Windows console supports UTF-8 Unicode characters (borders, bullets, emoji)
|
|
163
|
+
if sys.platform == "win32":
|
|
164
|
+
try:
|
|
165
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
166
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
parser = build_parser()
|
|
171
|
+
args = parser.parse_args()
|
|
172
|
+
|
|
173
|
+
cache = ThreatCache()
|
|
174
|
+
|
|
175
|
+
# Clear cache action
|
|
176
|
+
if args.clear_cache:
|
|
177
|
+
deleted = cache.clear()
|
|
178
|
+
print(f"[*] ThreatPulse cache cleared successfully ({deleted} entries removed).")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
# Quick defang / refang helpers
|
|
182
|
+
if args.defang:
|
|
183
|
+
print(defang(args.defang))
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
if args.refang:
|
|
187
|
+
print(refang(args.refang))
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
use_cache = not args.no_cache
|
|
191
|
+
iocs_to_check: List[str] = []
|
|
192
|
+
|
|
193
|
+
# Single IOC from positional argument
|
|
194
|
+
if args.ioc:
|
|
195
|
+
iocs_to_check.append(args.ioc)
|
|
196
|
+
|
|
197
|
+
# Batch IOCs from file
|
|
198
|
+
if args.file:
|
|
199
|
+
file_path = Path(args.file)
|
|
200
|
+
if not file_path.is_file():
|
|
201
|
+
print(f"[!] Error: File '{args.file}' not found.", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
try:
|
|
204
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
205
|
+
extracted = extract_iocs_from_text(content)
|
|
206
|
+
for val, _ in extracted:
|
|
207
|
+
if val not in iocs_to_check:
|
|
208
|
+
iocs_to_check.append(val)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f"[!] Error reading file: {e}", file=sys.stderr)
|
|
211
|
+
return 1
|
|
212
|
+
|
|
213
|
+
if not iocs_to_check:
|
|
214
|
+
parser.print_help()
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
# Enrich all targets (parallelized for batch files)
|
|
218
|
+
results: List[EnrichmentResult] = []
|
|
219
|
+
if len(iocs_to_check) > 1:
|
|
220
|
+
import concurrent.futures
|
|
221
|
+
future_map = {}
|
|
222
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|
223
|
+
for target in iocs_to_check:
|
|
224
|
+
fut = executor.submit(
|
|
225
|
+
enrich_single_ioc,
|
|
226
|
+
target,
|
|
227
|
+
cache=cache,
|
|
228
|
+
abuse_api_key=args.api_key,
|
|
229
|
+
use_cache=use_cache
|
|
230
|
+
)
|
|
231
|
+
future_map[fut] = target
|
|
232
|
+
|
|
233
|
+
completed_map = {}
|
|
234
|
+
for fut in concurrent.futures.as_completed(future_map):
|
|
235
|
+
t = future_map[fut]
|
|
236
|
+
completed_map[t] = fut.result()
|
|
237
|
+
|
|
238
|
+
# Maintain original order of inputs
|
|
239
|
+
results = [completed_map[t] for t in iocs_to_check if t in completed_map]
|
|
240
|
+
else:
|
|
241
|
+
results = [
|
|
242
|
+
enrich_single_ioc(
|
|
243
|
+
iocs_to_check[0],
|
|
244
|
+
cache=cache,
|
|
245
|
+
abuse_api_key=args.api_key,
|
|
246
|
+
use_cache=use_cache
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
# Determine default format
|
|
251
|
+
selected_format = args.format
|
|
252
|
+
if len(results) > 1 and selected_format == "card" and not args.out:
|
|
253
|
+
selected_format = "table"
|
|
254
|
+
|
|
255
|
+
output_text = ""
|
|
256
|
+
if selected_format == "card":
|
|
257
|
+
output_text = "\n\n".join(render_terminal_card(r) for r in results)
|
|
258
|
+
elif selected_format == "table":
|
|
259
|
+
output_text = render_terminal_summary_table(results)
|
|
260
|
+
elif selected_format == "json":
|
|
261
|
+
output_text = render_json(results, pretty=True)
|
|
262
|
+
elif selected_format == "markdown":
|
|
263
|
+
output_text = render_markdown(results)
|
|
264
|
+
elif selected_format == "wazuh-cdb":
|
|
265
|
+
output_text = render_wazuh_cdb_list(results)
|
|
266
|
+
|
|
267
|
+
# Write output or print to console
|
|
268
|
+
if args.out:
|
|
269
|
+
try:
|
|
270
|
+
Path(args.out).write_text(output_text, encoding="utf-8")
|
|
271
|
+
print(f"[+] Threat report successfully written to {args.out}")
|
|
272
|
+
except Exception as e:
|
|
273
|
+
print(f"[!] Error writing to {args.out}: {e}", file=sys.stderr)
|
|
274
|
+
return 1
|
|
275
|
+
else:
|
|
276
|
+
print(output_text)
|
|
277
|
+
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
sys.exit(main())
|
threat_pulse/detector.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Indicator of Compromise (IOC) type detection and extraction engine."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
from threat_pulse.models import IOCType
|
|
7
|
+
|
|
8
|
+
# Regular expressions for IOC detection
|
|
9
|
+
IPV4_REGEX = re.compile(
|
|
10
|
+
r"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}"
|
|
11
|
+
r"(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
MD5_REGEX = re.compile(r"\b[a-fA-F0-9]{32}\b")
|
|
15
|
+
SHA1_REGEX = re.compile(r"\b[a-fA-F0-9]{40}\b")
|
|
16
|
+
SHA256_REGEX = re.compile(r"\b[a-fA-F0-9]{64}\b")
|
|
17
|
+
|
|
18
|
+
# Matches standard domains (2+ labels, valid TLD length)
|
|
19
|
+
DOMAIN_REGEX = re.compile(
|
|
20
|
+
r"\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,24}\b"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Matches URLs (http, https, ftp, or defanged hxxp/hxxps)
|
|
24
|
+
URL_REGEX = re.compile(
|
|
25
|
+
r"\b(?:https?|hxxps?|ftp)://[^\s/$.?#].[^\s]*", re.IGNORECASE
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def refang(ioc: str) -> str:
|
|
30
|
+
"""Normalize defanged indicators back into canonical format."""
|
|
31
|
+
cleaned = ioc.strip()
|
|
32
|
+
# Replace bracketed dots: [.] or (.) or {.} -> .
|
|
33
|
+
cleaned = re.sub(r"[\[\(\{]\.[\]\)\}]", ".", cleaned)
|
|
34
|
+
# Replace hxxp/hxxps -> http/https
|
|
35
|
+
cleaned = re.sub(r"^hxxp(s?):", r"http\1:", cleaned, flags=re.IGNORECASE)
|
|
36
|
+
# Replace [:] -> :
|
|
37
|
+
cleaned = re.sub(r"\[:\]", ":", cleaned)
|
|
38
|
+
return cleaned
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def defang(ioc: str) -> str:
|
|
42
|
+
"""Defang an indicator to prevent accidental clicks or resolutions."""
|
|
43
|
+
cleaned = ioc.strip()
|
|
44
|
+
# Defang http/https
|
|
45
|
+
cleaned = re.sub(r"^http:", "hxxp:", cleaned, flags=re.IGNORECASE)
|
|
46
|
+
cleaned = re.sub(r"^https:", "hxxps:", cleaned, flags=re.IGNORECASE)
|
|
47
|
+
# Defang dots
|
|
48
|
+
return cleaned.replace(".", "[.]")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_ioc_type(raw_ioc: str) -> IOCType:
|
|
52
|
+
"""Classify an IOC string into a specific IOCType."""
|
|
53
|
+
target = refang(raw_ioc).strip()
|
|
54
|
+
|
|
55
|
+
# 1. URL Check
|
|
56
|
+
if URL_REGEX.match(target) or target.startswith(("http://", "https://", "ftp://")):
|
|
57
|
+
return IOCType.URL
|
|
58
|
+
|
|
59
|
+
# 2. IP Checks
|
|
60
|
+
try:
|
|
61
|
+
ip = ipaddress.ip_address(target)
|
|
62
|
+
if ip.version == 4:
|
|
63
|
+
return IOCType.IPV4
|
|
64
|
+
elif ip.version == 6:
|
|
65
|
+
return IOCType.IPV6
|
|
66
|
+
except ValueError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
# 3. Hash Checks
|
|
70
|
+
if SHA256_REGEX.fullmatch(target):
|
|
71
|
+
return IOCType.SHA256
|
|
72
|
+
if SHA1_REGEX.fullmatch(target):
|
|
73
|
+
return IOCType.SHA1
|
|
74
|
+
if MD5_REGEX.fullmatch(target):
|
|
75
|
+
return IOCType.MD5
|
|
76
|
+
|
|
77
|
+
# 4. Domain Check
|
|
78
|
+
if DOMAIN_REGEX.fullmatch(target):
|
|
79
|
+
# Exclude common file extensions that match domain regex by accident (e.g. script.sh, tool.py)
|
|
80
|
+
excluded_suffixes = {
|
|
81
|
+
"py", "sh", "exe", "dll", "bat", "ps1", "json", "xml", "csv", "log",
|
|
82
|
+
"txt", "md", "yml", "yaml", "tar", "gz", "zip", "rar", "7z", "conf"
|
|
83
|
+
}
|
|
84
|
+
parts = target.lower().split(".")
|
|
85
|
+
if len(parts) == 2 and parts[-1] in excluded_suffixes:
|
|
86
|
+
return IOCType.UNKNOWN
|
|
87
|
+
return IOCType.DOMAIN
|
|
88
|
+
|
|
89
|
+
return IOCType.UNKNOWN
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_iocs_from_text(text: str) -> List[Tuple[str, IOCType]]:
|
|
93
|
+
"""Scan raw text/logs and extract all recognized indicators."""
|
|
94
|
+
results: List[Tuple[str, IOCType]] = []
|
|
95
|
+
seen = set()
|
|
96
|
+
|
|
97
|
+
# 1. URLs
|
|
98
|
+
for match in URL_REGEX.finditer(text):
|
|
99
|
+
val = refang(match.group(0))
|
|
100
|
+
if val not in seen:
|
|
101
|
+
seen.add(val)
|
|
102
|
+
results.append((val, IOCType.URL))
|
|
103
|
+
|
|
104
|
+
# 2. IP Addresses (v4)
|
|
105
|
+
for match in IPV4_REGEX.finditer(text):
|
|
106
|
+
val = match.group(0)
|
|
107
|
+
try:
|
|
108
|
+
ipaddress.IPv4Address(val)
|
|
109
|
+
if val not in seen:
|
|
110
|
+
seen.add(val)
|
|
111
|
+
results.append((val, IOCType.IPV4))
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# 3. Hashes
|
|
116
|
+
for match in SHA256_REGEX.finditer(text):
|
|
117
|
+
val = match.group(0)
|
|
118
|
+
if val not in seen:
|
|
119
|
+
seen.add(val)
|
|
120
|
+
results.append((val, IOCType.SHA256))
|
|
121
|
+
|
|
122
|
+
for match in MD5_REGEX.finditer(text):
|
|
123
|
+
val = match.group(0)
|
|
124
|
+
if val not in seen:
|
|
125
|
+
seen.add(val)
|
|
126
|
+
results.append((val, IOCType.MD5))
|
|
127
|
+
|
|
128
|
+
# 4. Domains (avoid matching substrings of already extracted URLs or IPs)
|
|
129
|
+
for match in DOMAIN_REGEX.finditer(text):
|
|
130
|
+
val = match.group(0)
|
|
131
|
+
if detect_ioc_type(val) == IOCType.DOMAIN:
|
|
132
|
+
if val not in seen:
|
|
133
|
+
seen.add(val)
|
|
134
|
+
results.append((val, IOCType.DOMAIN))
|
|
135
|
+
|
|
136
|
+
return results
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Enrichers package initialization."""
|
|
2
|
+
|
|
3
|
+
from threat_pulse.enrichers.base import BaseEnricher
|
|
4
|
+
from threat_pulse.enrichers.network import NetworkEnricher
|
|
5
|
+
from threat_pulse.enrichers.abuseipdb import AbuseIPDBEnricher
|
|
6
|
+
from threat_pulse.enrichers.malware import MalwareHashEnricher
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseEnricher",
|
|
10
|
+
"NetworkEnricher",
|
|
11
|
+
"AbuseIPDBEnricher",
|
|
12
|
+
"MalwareHashEnricher",
|
|
13
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""AbuseIPDB Threat Intelligence API client with graceful offline fallback."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.request
|
|
6
|
+
import urllib.error
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
from threat_pulse.enrichers.base import BaseEnricher
|
|
9
|
+
from threat_pulse.models import IOCType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AbuseIPDBEnricher(BaseEnricher):
|
|
13
|
+
"""Enriches IP addresses using AbuseIPDB APIv2 or fallback heuristics."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, api_key: Optional[str] = None, timeout: float = 3.0):
|
|
16
|
+
super().__init__(name="abuseipdb")
|
|
17
|
+
self.api_key = api_key or os.getenv("ABUSEIPDB_API_KEY")
|
|
18
|
+
self.timeout = timeout
|
|
19
|
+
|
|
20
|
+
def can_enrich(self, ioc_type: IOCType) -> bool:
|
|
21
|
+
return ioc_type in (IOCType.IPV4, IOCType.IPV6)
|
|
22
|
+
|
|
23
|
+
def enrich(self, ioc: str, ioc_type: IOCType) -> Dict[str, Any]:
|
|
24
|
+
data: Dict[str, Any] = {
|
|
25
|
+
"queried": False,
|
|
26
|
+
"abuse_score": 0,
|
|
27
|
+
"total_reports": 0,
|
|
28
|
+
"country_code": "UNKNOWN",
|
|
29
|
+
"usage_type": "Unknown",
|
|
30
|
+
"isp": "Unknown",
|
|
31
|
+
"is_whitelisted": False,
|
|
32
|
+
"last_reported_at": None,
|
|
33
|
+
"categories": [],
|
|
34
|
+
"error": None,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if not self.api_key:
|
|
38
|
+
data["error"] = "No API key configured (using local heuristics). Set ABUSEIPDB_API_KEY to enable live checks."
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
url = f"https://api.abuseipdb.com/api/v2/check?ipAddress={ioc}&maxAgeInDays=90&verbose"
|
|
42
|
+
headers = {
|
|
43
|
+
"Key": self.api_key,
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
"User-Agent": "ThreatPulse-SOC-CLI/1.0"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
50
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
51
|
+
if resp.status == 200:
|
|
52
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
53
|
+
report = payload.get("data", {})
|
|
54
|
+
data["queried"] = True
|
|
55
|
+
data["abuse_score"] = report.get("abuseConfidenceScore", 0)
|
|
56
|
+
data["total_reports"] = report.get("totalReports", 0)
|
|
57
|
+
data["country_code"] = report.get("countryCode", "UNKNOWN")
|
|
58
|
+
data["usage_type"] = report.get("usageType", "Unknown")
|
|
59
|
+
data["isp"] = report.get("isp", "Unknown")
|
|
60
|
+
data["is_whitelisted"] = report.get("isWhitelisted", False)
|
|
61
|
+
data["last_reported_at"] = report.get("lastReportedAt")
|
|
62
|
+
except urllib.error.HTTPError as e:
|
|
63
|
+
data["error"] = f"HTTP {e.code}: {e.reason}"
|
|
64
|
+
except urllib.error.URLError as e:
|
|
65
|
+
data["error"] = f"Network error: {e.reason}"
|
|
66
|
+
except Exception as e:
|
|
67
|
+
data["error"] = str(e)
|
|
68
|
+
|
|
69
|
+
return data
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Base classes for threat intelligence enrichers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, Any, Optional
|
|
5
|
+
from threat_pulse.models import IOCType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseEnricher(ABC):
|
|
9
|
+
"""Abstract interface for all threat intelligence and metadata enrichers."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str, enabled: bool = True):
|
|
12
|
+
self.name = name
|
|
13
|
+
self.enabled = enabled
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def can_enrich(self, ioc_type: IOCType) -> bool:
|
|
17
|
+
"""Determine if this enricher supports the given IOC type."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def enrich(self, ioc: str, ioc_type: IOCType) -> Dict[str, Any]:
|
|
22
|
+
"""Perform enrichment and return structured metadata dictionary."""
|
|
23
|
+
pass
|