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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Malware hash intelligence enricher with offline threat signature database."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
from threat_pulse.enrichers.base import BaseEnricher
|
|
5
|
+
from threat_pulse.models import IOCType
|
|
6
|
+
|
|
7
|
+
# Curated offline database of critical malware hashes (Ransomware, Mimikatz, C2 Beacons, WebShells)
|
|
8
|
+
KNOWN_MALWARE_HASHES: Dict[str, Dict[str, str]] = {
|
|
9
|
+
# WannaCry Ransomware
|
|
10
|
+
"ed01ebf83334a19373140c0df0ddb638ec6d7e99f8788f5b8978cb8c4cb5ffce": {
|
|
11
|
+
"family": "WannaCry Ransomware",
|
|
12
|
+
"type": "Ransomware / Cryptoworm",
|
|
13
|
+
"severity": "CRITICAL",
|
|
14
|
+
"description": "EternalBlue-based ransomware targeting SMBv1 (MS17-010)"
|
|
15
|
+
},
|
|
16
|
+
"84c82835a5d21bbcf75a61706d8ab549": {
|
|
17
|
+
"family": "WannaCry Dropper",
|
|
18
|
+
"type": "Dropper",
|
|
19
|
+
"severity": "CRITICAL",
|
|
20
|
+
"description": "WannaCry primary dropper binary"
|
|
21
|
+
},
|
|
22
|
+
# Mimikatz (Credential Dumping)
|
|
23
|
+
"a60c52bb53a5c0b89cf53dc5bb7c2c129219b16e4ee7d9c6e3b5e43a9b1c9d2f": {
|
|
24
|
+
"family": "Mimikatz",
|
|
25
|
+
"type": "Credential Dumper",
|
|
26
|
+
"severity": "CRITICAL",
|
|
27
|
+
"description": "LSASS memory credential extraction utility (T1003.001)"
|
|
28
|
+
},
|
|
29
|
+
# Cobalt Strike Beacon
|
|
30
|
+
"4d01b1bcf03ebdd8cb0667b9381665a58d6ff8cb194fa8cbdf24dd3ee3228a9b": {
|
|
31
|
+
"family": "Cobalt Strike",
|
|
32
|
+
"type": "Adversary Simulation / C2 Beacon",
|
|
33
|
+
"severity": "CRITICAL",
|
|
34
|
+
"description": "Malleable C2 Beacon executable (T1071)"
|
|
35
|
+
},
|
|
36
|
+
# Emotet Trojan
|
|
37
|
+
"49b788647055a40bbd21b79313ea5fa2e6ff3f1b": {
|
|
38
|
+
"family": "Emotet",
|
|
39
|
+
"type": "Banking Trojan / Botnet",
|
|
40
|
+
"severity": "HIGH",
|
|
41
|
+
"description": "Polymorphic modular botnet loader"
|
|
42
|
+
},
|
|
43
|
+
# China Chopper / Classic WebShell
|
|
44
|
+
"8bc6e6f96602330a6c0c2bb01c56ad44": {
|
|
45
|
+
"family": "China Chopper WebShell",
|
|
46
|
+
"type": "Web Shell",
|
|
47
|
+
"severity": "HIGH",
|
|
48
|
+
"description": "Minimalist server-side web backdoor (T1505.003)"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MalwareHashEnricher(BaseEnricher):
|
|
54
|
+
"""Enriches file hashes against known malware signatures and IOC threat feeds."""
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
super().__init__(name="malware_hash_enricher")
|
|
58
|
+
|
|
59
|
+
def can_enrich(self, ioc_type: IOCType) -> bool:
|
|
60
|
+
return ioc_type in (IOCType.MD5, IOCType.SHA1, IOCType.SHA256)
|
|
61
|
+
|
|
62
|
+
def enrich(self, ioc: str, ioc_type: IOCType) -> Dict[str, Any]:
|
|
63
|
+
normalized_hash = ioc.lower().strip()
|
|
64
|
+
data: Dict[str, Any] = {
|
|
65
|
+
"is_known_malware": False,
|
|
66
|
+
"family": None,
|
|
67
|
+
"type": None,
|
|
68
|
+
"severity": None,
|
|
69
|
+
"description": None,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if normalized_hash in KNOWN_MALWARE_HASHES:
|
|
73
|
+
intel = KNOWN_MALWARE_HASHES[normalized_hash]
|
|
74
|
+
data["is_known_malware"] = True
|
|
75
|
+
data["family"] = intel["family"]
|
|
76
|
+
data["type"] = intel["type"]
|
|
77
|
+
data["severity"] = intel["severity"]
|
|
78
|
+
data["description"] = intel["description"]
|
|
79
|
+
|
|
80
|
+
return data
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Network enricher: DNS PTR resolution, Tor exit node detection, and scanner identification."""
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
from typing import Dict, Any, Set
|
|
5
|
+
from threat_pulse.enrichers.base import BaseEnricher
|
|
6
|
+
from threat_pulse.models import IOCType
|
|
7
|
+
|
|
8
|
+
# Known active Tor exit nodes (sample curated offline seed list for instant high-confidence matching)
|
|
9
|
+
KNOWN_TOR_EXIT_NODES: Set[str] = {
|
|
10
|
+
"185.220.101.5", "185.220.101.6", "185.220.101.7",
|
|
11
|
+
"185.220.100.241", "185.220.100.242", "185.220.100.252",
|
|
12
|
+
"195.176.3.23", "51.15.43.205", "185.107.56.210",
|
|
13
|
+
"109.70.100.25", "185.14.29.178", "198.96.155.3"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Known security research / port scanner subnets & IPs
|
|
17
|
+
KNOWN_SCANNERS: Dict[str, str] = {
|
|
18
|
+
"198.20.69.74": "Shadowserver Foundation Scanner",
|
|
19
|
+
"198.20.69.98": "Shadowserver Foundation Scanner",
|
|
20
|
+
"162.142.125.0": "Censys Research Scanner",
|
|
21
|
+
"167.248.133.0": "Censys Research Scanner",
|
|
22
|
+
"71.6.232.0": "Shodan Internet Scanner",
|
|
23
|
+
"71.6.165.200": "Shodan Internet Scanner",
|
|
24
|
+
"185.180.143.0": "LeakIX Security Scanner",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NetworkEnricher(BaseEnricher):
|
|
29
|
+
"""Enriches network indicators with reverse DNS, Tor status, and scanner attribution."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, timeout: float = 2.0):
|
|
32
|
+
super().__init__(name="network_enricher")
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
|
|
35
|
+
def can_enrich(self, ioc_type: IOCType) -> bool:
|
|
36
|
+
return ioc_type in (IOCType.IPV4, IOCType.IPV6, IOCType.DOMAIN)
|
|
37
|
+
|
|
38
|
+
def enrich(self, ioc: str, ioc_type: IOCType) -> Dict[str, Any]:
|
|
39
|
+
data: Dict[str, Any] = {
|
|
40
|
+
"ptr": None,
|
|
41
|
+
"resolved_ips": [],
|
|
42
|
+
"is_tor_exit": False,
|
|
43
|
+
"is_known_scanner": False,
|
|
44
|
+
"scanner_name": None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# IP Specific Network Checks
|
|
48
|
+
if ioc_type in (IOCType.IPV4, IOCType.IPV6):
|
|
49
|
+
# Check Tor
|
|
50
|
+
if ioc in KNOWN_TOR_EXIT_NODES:
|
|
51
|
+
data["is_tor_exit"] = True
|
|
52
|
+
|
|
53
|
+
# Check Known Scanners
|
|
54
|
+
for scanner_ip, scanner_name in KNOWN_SCANNERS.items():
|
|
55
|
+
if ioc == scanner_ip or ioc.startswith(scanner_ip.rsplit(".", 1)[0]):
|
|
56
|
+
data["is_known_scanner"] = True
|
|
57
|
+
data["scanner_name"] = scanner_name
|
|
58
|
+
break
|
|
59
|
+
|
|
60
|
+
# Reverse DNS (PTR)
|
|
61
|
+
try:
|
|
62
|
+
socket.setdefaulttimeout(self.timeout)
|
|
63
|
+
host_info = socket.gethostbyaddr(ioc)
|
|
64
|
+
data["ptr"] = host_info[0]
|
|
65
|
+
except (socket.herror, socket.gaierror, socket.timeout, OSError):
|
|
66
|
+
data["ptr"] = None
|
|
67
|
+
|
|
68
|
+
# Domain Specific Network Checks
|
|
69
|
+
elif ioc_type == IOCType.DOMAIN:
|
|
70
|
+
try:
|
|
71
|
+
socket.setdefaulttimeout(self.timeout)
|
|
72
|
+
_, _, ip_list = socket.gethostbyname_ex(ioc)
|
|
73
|
+
data["resolved_ips"] = ip_list
|
|
74
|
+
# Check if domain resolves to a Tor node or scanner
|
|
75
|
+
for ip in ip_list:
|
|
76
|
+
if ip in KNOWN_TOR_EXIT_NODES:
|
|
77
|
+
data["is_tor_exit"] = True
|
|
78
|
+
except (socket.gaierror, socket.timeout, OSError):
|
|
79
|
+
data["resolved_ips"] = []
|
|
80
|
+
|
|
81
|
+
return data
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Terminal card, table, JSON, Markdown, and Wazuh CDB list output formatters."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import List
|
|
5
|
+
from threat_pulse.models import EnrichmentResult, RiskLevel
|
|
6
|
+
|
|
7
|
+
# ANSI Color codes for clean, zero-dependency colored terminal rendering
|
|
8
|
+
RESET = "\033[0m"
|
|
9
|
+
BOLD = "\033[1m"
|
|
10
|
+
DIM = "\033[2m"
|
|
11
|
+
RED = "\033[91m"
|
|
12
|
+
GREEN = "\033[92m"
|
|
13
|
+
YELLOW = "\033[93m"
|
|
14
|
+
BLUE = "\033[94m"
|
|
15
|
+
MAGENTA = "\033[95m"
|
|
16
|
+
CYAN = "\033[96m"
|
|
17
|
+
WHITE = "\033[97m"
|
|
18
|
+
BG_RED = "\033[41m"
|
|
19
|
+
BG_YELLOW = "\033[43m"
|
|
20
|
+
BG_GREEN = "\033[42m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _risk_color(level: RiskLevel) -> str:
|
|
24
|
+
if level == RiskLevel.CRITICAL:
|
|
25
|
+
return f"{BG_RED}{WHITE}{BOLD}"
|
|
26
|
+
elif level == RiskLevel.HIGH:
|
|
27
|
+
return f"{RED}{BOLD}"
|
|
28
|
+
elif level == RiskLevel.MEDIUM:
|
|
29
|
+
return f"{YELLOW}{BOLD}"
|
|
30
|
+
elif level == RiskLevel.LOW:
|
|
31
|
+
return f"{BLUE}"
|
|
32
|
+
else:
|
|
33
|
+
return f"{GREEN}{BOLD}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_terminal_card(res: EnrichmentResult) -> str:
|
|
37
|
+
"""Render a standalone, highly detailed threat intelligence card for a single IOC."""
|
|
38
|
+
color = _risk_color(res.risk_level)
|
|
39
|
+
lines = []
|
|
40
|
+
lines.append(f"{CYAN}┌────────────────────────────────────────────────────────────────────────┐{RESET}")
|
|
41
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}ThreatPulse IOC Report{RESET} {DIM}[{res.ioc_type.value.upper()}]{RESET}")
|
|
42
|
+
lines.append(f"{CYAN}├────────────────────────────────────────────────────────────────────────┤{RESET}")
|
|
43
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}Indicator:{RESET} {res.defanged} {DIM}(raw: {res.ioc}){RESET}")
|
|
44
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}Risk Level:{RESET} {color} {res.risk_level.value} ({res.risk_score}/100) {RESET}")
|
|
45
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}Verdict:{RESET} {res.verdict}")
|
|
46
|
+
|
|
47
|
+
if res.tags:
|
|
48
|
+
tag_str = " ".join([f"{MAGENTA}#{t}{RESET}" for t in res.tags])
|
|
49
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}Tags:{RESET} {tag_str}")
|
|
50
|
+
|
|
51
|
+
if res.mitre_attack:
|
|
52
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}MITRE ATT&CK:{RESET}")
|
|
53
|
+
for m in res.mitre_attack:
|
|
54
|
+
lines.append(f"{CYAN}│{RESET} • {YELLOW}{m}{RESET}")
|
|
55
|
+
|
|
56
|
+
# Specific details section
|
|
57
|
+
details = res.details
|
|
58
|
+
if details:
|
|
59
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}Intelligence Details:{RESET}")
|
|
60
|
+
if "ptr" in details and details["ptr"]:
|
|
61
|
+
lines.append(f"{CYAN}│{RESET} • Reverse DNS: {details['ptr']}")
|
|
62
|
+
if "country_code" in details and details["country_code"] != "UNKNOWN":
|
|
63
|
+
lines.append(f"{CYAN}│{RESET} • Country / ISP: {details['country_code']} ({details.get('isp', 'N/A')})")
|
|
64
|
+
if "abuse_score" in details and details["abuse_score"] > 0:
|
|
65
|
+
lines.append(f"{CYAN}│{RESET} • AbuseIPDB Score: {details['abuse_score']}% ({details.get('total_reports', 0)} reports)")
|
|
66
|
+
if "family" in details and details["family"]:
|
|
67
|
+
lines.append(f"{CYAN}│{RESET} • Malware Family: {details['family']} ({details.get('description', '')})")
|
|
68
|
+
if "sld_entropy" in details:
|
|
69
|
+
lines.append(f"{CYAN}│{RESET} • Domain Entropy: {details['sld_entropy']} (DGA Candidate: {details.get('is_dga_candidate')})")
|
|
70
|
+
if "is_known_scanner" in details and details["is_known_scanner"]:
|
|
71
|
+
lines.append(f"{CYAN}│{RESET} • Scanner: {details.get('scanner_name')}")
|
|
72
|
+
|
|
73
|
+
lines.append(f"{CYAN}│{RESET} {BOLD}SOC Action:{RESET} {WHITE}{res.recommendation}{RESET}")
|
|
74
|
+
cache_badge = f"{DIM}(from local cache){RESET}" if res.cached else f"{DIM}(live analysis){RESET}"
|
|
75
|
+
lines.append(f"{CYAN}│{RESET} {DIM}Timestamp: {res.timestamp} {cache_badge}{RESET}")
|
|
76
|
+
lines.append(f"{CYAN}└────────────────────────────────────────────────────────────────────────┘{RESET}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_terminal_summary_table(results: List[EnrichmentResult]) -> str:
|
|
81
|
+
"""Render a clean summary table for batch IOC enrichment."""
|
|
82
|
+
if not results:
|
|
83
|
+
return "No IOCs analyzed."
|
|
84
|
+
|
|
85
|
+
headers = f"{'INDICATOR':<35} {'TYPE':<8} {'RISK':<12} {'SCORE':<7} {'VERDICT':<30}"
|
|
86
|
+
separator = "-" * 95
|
|
87
|
+
lines = [f"{BOLD}{headers}{RESET}", separator]
|
|
88
|
+
|
|
89
|
+
for r in results:
|
|
90
|
+
color = _risk_color(r.risk_level)
|
|
91
|
+
short_verdict = (r.verdict[:27] + "...") if len(r.verdict) > 30 else r.verdict
|
|
92
|
+
line = (
|
|
93
|
+
f"{r.defanged[:33]:<35} "
|
|
94
|
+
f"{r.ioc_type.value:<8} "
|
|
95
|
+
f"{color}{r.risk_level.value:<12}{RESET} "
|
|
96
|
+
f"{r.risk_score:<7} "
|
|
97
|
+
f"{short_verdict:<30}"
|
|
98
|
+
)
|
|
99
|
+
lines.append(line)
|
|
100
|
+
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def render_json(results: List[EnrichmentResult], pretty: bool = True) -> str:
|
|
105
|
+
"""Serialize results list to JSON."""
|
|
106
|
+
data = [r.to_dict() for r in results]
|
|
107
|
+
indent = 2 if pretty else None
|
|
108
|
+
return json.dumps({"count": len(results), "indicators": data}, indent=indent)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_markdown(results: List[EnrichmentResult]) -> str:
|
|
112
|
+
"""Format results into a Markdown table for ticketing / GitHub / reporting."""
|
|
113
|
+
lines = [
|
|
114
|
+
"# ThreatPulse Threat Intelligence Report",
|
|
115
|
+
"",
|
|
116
|
+
f"**Total Indicators Analyzed:** {len(results)}",
|
|
117
|
+
"",
|
|
118
|
+
"| Indicator (Defanged) | Type | Risk Level | Score | Verdict | Tags |",
|
|
119
|
+
"| --------------------- | ---- | ---------- | ----- | ------- | ---- |"
|
|
120
|
+
]
|
|
121
|
+
for r in results:
|
|
122
|
+
tags_str = ", ".join(r.tags) if r.tags else "-"
|
|
123
|
+
lines.append(
|
|
124
|
+
f"| `{r.defanged}` | {r.ioc_type.value} | **{r.risk_level.value}** | {r.risk_score}/100 | {r.verdict} | {tags_str} |"
|
|
125
|
+
)
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_wazuh_cdb_list(results: List[EnrichmentResult]) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Format malicious/suspicious indicators into Wazuh CDB list syntax.
|
|
132
|
+
Syntax: <key>:<value>
|
|
133
|
+
Usage: save to /var/ossec/etc/lists/threat_pulse.cdb
|
|
134
|
+
"""
|
|
135
|
+
lines = ["# ThreatPulse exported CDB List for Wazuh SIEM"]
|
|
136
|
+
for r in results:
|
|
137
|
+
if r.risk_score >= 40: # Medium, High, or Critical
|
|
138
|
+
tag_summary = ",".join(r.tags[:2]) if r.tags else "malicious"
|
|
139
|
+
lines.append(f"{r.ioc}:{r.risk_level.value}_{r.risk_score}:{tag_summary}")
|
|
140
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Offline heuristic analyzers for IP addresses, domains, and URLs."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from typing import Dict, Any, Optional, Tuple
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
# Suspicious TLDs frequently abused in malware campaigns / phishing
|
|
10
|
+
HIGH_ABUSE_TLDS = {
|
|
11
|
+
"top", "xyz", "tk", "ml", "ga", "cf", "gq", "buzz", "icu", "work",
|
|
12
|
+
"click", "link", "rest", "surf", "monster", "casa", "country", "kim"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Bogon / Reserved network ranges (IPv4)
|
|
16
|
+
BOGON_NETWORKS = [
|
|
17
|
+
ipaddress.ip_network("0.0.0.0/8"),
|
|
18
|
+
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918
|
|
19
|
+
ipaddress.ip_network("100.64.0.0/10"), # Carrier-grade NAT
|
|
20
|
+
ipaddress.ip_network("127.0.0.0/8"), # Loopback
|
|
21
|
+
ipaddress.ip_network("169.254.0.0/16"), # Link-local / APIPA
|
|
22
|
+
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918
|
|
23
|
+
ipaddress.ip_network("192.0.0.0/24"), # IETF Protocol
|
|
24
|
+
ipaddress.ip_network("192.0.2.0/24"), # TEST-NET-1
|
|
25
|
+
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918
|
|
26
|
+
ipaddress.ip_network("198.18.0.0/15"), # Network Interconnect Benchmarking
|
|
27
|
+
ipaddress.ip_network("198.51.100.0/24"), # TEST-NET-2
|
|
28
|
+
ipaddress.ip_network("203.0.113.0/24"), # TEST-NET-3
|
|
29
|
+
ipaddress.ip_network("224.0.0.0/4"), # Multicast
|
|
30
|
+
ipaddress.ip_network("240.0.0.0/4"), # Reserved / Future use
|
|
31
|
+
ipaddress.ip_network("255.255.255.255/32"),# Broadcast
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def calculate_shannon_entropy(text: str) -> float:
|
|
36
|
+
"""Calculate Shannon entropy of a string to evaluate randomness (DGA detection)."""
|
|
37
|
+
if not text:
|
|
38
|
+
return 0.0
|
|
39
|
+
entropy = 0.0
|
|
40
|
+
text_len = len(text)
|
|
41
|
+
freq: Dict[str, int] = {}
|
|
42
|
+
for char in text:
|
|
43
|
+
freq[char] = freq.get(char, 0) + 1
|
|
44
|
+
for count in freq.values():
|
|
45
|
+
p = count / text_len
|
|
46
|
+
entropy -= p * math.log2(p)
|
|
47
|
+
return round(entropy, 3)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analyze_ip_heuristics(ip_str: str) -> Dict[str, Any]:
|
|
51
|
+
"""Inspect IP against RFC1918, Bogon, Loopback, and Reserved spaces."""
|
|
52
|
+
try:
|
|
53
|
+
ip = ipaddress.ip_address(ip_str)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return {"valid": False}
|
|
56
|
+
|
|
57
|
+
is_private = ip.is_private
|
|
58
|
+
is_loopback = ip.is_loopback
|
|
59
|
+
is_link_local = ip.is_link_local
|
|
60
|
+
is_multicast = ip.is_multicast
|
|
61
|
+
is_reserved = ip.is_reserved
|
|
62
|
+
|
|
63
|
+
is_bogon = False
|
|
64
|
+
bogon_reason = ""
|
|
65
|
+
if ip.version == 4:
|
|
66
|
+
for net in BOGON_NETWORKS:
|
|
67
|
+
if ip in net:
|
|
68
|
+
is_bogon = True
|
|
69
|
+
bogon_reason = str(net)
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
rfc1918 = False
|
|
73
|
+
if ip.version == 4:
|
|
74
|
+
rfc_nets = [
|
|
75
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
76
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
77
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
78
|
+
]
|
|
79
|
+
rfc1918 = any(ip in net for net in rfc_nets)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"valid": True,
|
|
83
|
+
"version": ip.version,
|
|
84
|
+
"is_private": is_private,
|
|
85
|
+
"is_rfc1918": rfc1918,
|
|
86
|
+
"is_bogon": is_bogon,
|
|
87
|
+
"bogon_network": bogon_reason,
|
|
88
|
+
"is_loopback": is_loopback,
|
|
89
|
+
"is_link_local": is_link_local,
|
|
90
|
+
"is_multicast": is_multicast,
|
|
91
|
+
"is_reserved": is_reserved,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def analyze_domain_heuristics(domain: str) -> Dict[str, Any]:
|
|
96
|
+
"""Inspect domain for DGA entropy, suspicious TLDs, and IDN homograph punycode."""
|
|
97
|
+
clean_domain = domain.lower().strip()
|
|
98
|
+
parts = clean_domain.split(".")
|
|
99
|
+
|
|
100
|
+
tld = parts[-1] if len(parts) > 1 else ""
|
|
101
|
+
sld = parts[-2] if len(parts) > 1 else parts[0]
|
|
102
|
+
|
|
103
|
+
# Calculate entropy on Second-Level Domain (the main name)
|
|
104
|
+
sld_entropy = calculate_shannon_entropy(sld)
|
|
105
|
+
|
|
106
|
+
is_high_entropy = (sld_entropy >= 3.6 and len(sld) >= 8) or (sld_entropy >= 3.3 and len(sld) >= 14)
|
|
107
|
+
has_suspicious_tld = tld in HIGH_ABUSE_TLDS
|
|
108
|
+
is_punycode = "xn--" in clean_domain
|
|
109
|
+
has_subdomain_abuse = len(parts) >= 4
|
|
110
|
+
|
|
111
|
+
# Count digits in main domain name (common in botnets / DGA)
|
|
112
|
+
digit_count = sum(c.isdigit() for c in sld)
|
|
113
|
+
digit_ratio = digit_count / len(sld) if sld else 0.0
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"domain": clean_domain,
|
|
117
|
+
"sld": sld,
|
|
118
|
+
"tld": tld,
|
|
119
|
+
"sld_entropy": sld_entropy,
|
|
120
|
+
"is_dga_candidate": is_high_entropy or (digit_ratio >= 0.4 and len(sld) >= 8),
|
|
121
|
+
"suspicious_tld": has_suspicious_tld,
|
|
122
|
+
"is_punycode": is_punycode,
|
|
123
|
+
"subdomain_depth": len(parts),
|
|
124
|
+
"excessive_subdomains": has_subdomain_abuse,
|
|
125
|
+
"digit_ratio": round(digit_ratio, 2),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def analyze_url_heuristics(url_str: str) -> Dict[str, Any]:
|
|
130
|
+
"""Inspect URL structure for raw IP hosts, suspicious paths, or credentials in URI."""
|
|
131
|
+
try:
|
|
132
|
+
parsed = urlparse(url_str)
|
|
133
|
+
except Exception:
|
|
134
|
+
return {"valid": False}
|
|
135
|
+
|
|
136
|
+
hostname = parsed.hostname or ""
|
|
137
|
+
path = parsed.path or ""
|
|
138
|
+
query = parsed.query or ""
|
|
139
|
+
|
|
140
|
+
is_raw_ip = False
|
|
141
|
+
try:
|
|
142
|
+
if hostname:
|
|
143
|
+
ipaddress.ip_address(hostname)
|
|
144
|
+
is_raw_ip = True
|
|
145
|
+
except ValueError:
|
|
146
|
+
is_raw_ip = False
|
|
147
|
+
|
|
148
|
+
has_credentials = bool(parsed.username or parsed.password)
|
|
149
|
+
has_executable_extension = any(
|
|
150
|
+
path.lower().endswith(ext)
|
|
151
|
+
for ext in [".exe", ".dll", ".ps1", ".vbs", ".bat", ".scr", ".iso", ".hta", ".apk"]
|
|
152
|
+
)
|
|
153
|
+
uncommon_port = parsed.port not in [None, 80, 443, 8080]
|
|
154
|
+
|
|
155
|
+
domain_data = analyze_domain_heuristics(hostname) if not is_raw_ip else {}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"valid": True,
|
|
159
|
+
"scheme": parsed.scheme,
|
|
160
|
+
"hostname": hostname,
|
|
161
|
+
"port": parsed.port,
|
|
162
|
+
"uncommon_port": uncommon_port,
|
|
163
|
+
"is_raw_ip_host": is_raw_ip,
|
|
164
|
+
"has_credentials": has_credentials,
|
|
165
|
+
"has_executable_extension": has_executable_extension,
|
|
166
|
+
"domain_heuristics": domain_data,
|
|
167
|
+
}
|
threat_pulse/models.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Data models and enums for ThreatPulse."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IOCType(str, Enum):
|
|
10
|
+
"""Supported indicator of compromise types."""
|
|
11
|
+
IPV4 = "ipv4"
|
|
12
|
+
IPV6 = "ipv6"
|
|
13
|
+
DOMAIN = "domain"
|
|
14
|
+
URL = "url"
|
|
15
|
+
MD5 = "md5"
|
|
16
|
+
SHA1 = "sha1"
|
|
17
|
+
SHA256 = "sha256"
|
|
18
|
+
UNKNOWN = "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RiskLevel(str, Enum):
|
|
22
|
+
"""Categorized threat risk level based on aggregated score."""
|
|
23
|
+
CLEAN = "CLEAN" # 0 - 15
|
|
24
|
+
LOW = "LOW" # 16 - 39
|
|
25
|
+
MEDIUM = "MEDIUM" # 40 - 69
|
|
26
|
+
HIGH = "HIGH" # 70 - 89
|
|
27
|
+
CRITICAL = "CRITICAL" # 90 - 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class EnrichmentResult:
|
|
32
|
+
"""Consolidated threat intelligence report for a single IOC."""
|
|
33
|
+
ioc: str
|
|
34
|
+
ioc_type: IOCType
|
|
35
|
+
defanged: str
|
|
36
|
+
risk_score: int
|
|
37
|
+
risk_level: RiskLevel
|
|
38
|
+
verdict: str
|
|
39
|
+
is_private: bool = False
|
|
40
|
+
is_bogon: bool = False
|
|
41
|
+
tags: List[str] = field(default_factory=list)
|
|
42
|
+
mitre_attack: List[str] = field(default_factory=list)
|
|
43
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
recommendation: str = ""
|
|
45
|
+
cached: bool = False
|
|
46
|
+
timestamp: str = field(
|
|
47
|
+
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
51
|
+
"""Convert result to a serializable dictionary."""
|
|
52
|
+
return {
|
|
53
|
+
"ioc": self.ioc,
|
|
54
|
+
"ioc_type": self.ioc_type.value,
|
|
55
|
+
"defanged": self.defanged,
|
|
56
|
+
"risk_score": self.risk_score,
|
|
57
|
+
"risk_level": self.risk_level.value,
|
|
58
|
+
"verdict": self.verdict,
|
|
59
|
+
"is_private": self.is_private,
|
|
60
|
+
"is_bogon": self.is_bogon,
|
|
61
|
+
"tags": self.tags,
|
|
62
|
+
"mitre_attack": self.mitre_attack,
|
|
63
|
+
"details": self.details,
|
|
64
|
+
"recommendation": self.recommendation,
|
|
65
|
+
"cached": self.cached,
|
|
66
|
+
"timestamp": self.timestamp,
|
|
67
|
+
}
|