|
| 1 | +""" |
| 2 | +Flowtriq DDoS Intelligence Module for MISP |
| 3 | +
|
| 4 | +Enriches IP addresses with DDoS attack data from Flowtriq. |
| 5 | +When given an IP, queries the Flowtriq API to check if this IP |
| 6 | +has been observed as a DDoS attack source across Flowtriq's |
| 7 | +network of monitored infrastructure. |
| 8 | +
|
| 9 | +Returns attack context including attack families, severity, |
| 10 | +peak traffic rates, geographic origin, and threat intel matches. |
| 11 | +""" |
| 12 | + |
| 13 | +import json |
| 14 | +import requests |
| 15 | + |
| 16 | +misperrors = {'error': 'Error'} |
| 17 | +mispattributes = { |
| 18 | + 'input': ['ip-src', 'ip-dst'], |
| 19 | + 'format': 'misp_standard', |
| 20 | +} |
| 21 | +moduleinfo = { |
| 22 | + 'version': '1.0', |
| 23 | + 'author': 'Flowtriq', |
| 24 | + 'description': 'Query Flowtriq for DDoS attack intelligence on IP addresses', |
| 25 | + 'module-type': ['expansion', 'hover'], |
| 26 | + 'name': 'Flowtriq DDoS Intelligence', |
| 27 | + 'logo': 'flowtriq.png', |
| 28 | + 'requirements': ['Flowtriq API key and API URL'], |
| 29 | + 'features': ( |
| 30 | + 'Queries the Flowtriq IP threat lookup API to check whether an IP ' |
| 31 | + 'has been observed as a DDoS attack source. Returns structured ' |
| 32 | + 'enrichment data including attack families, severity breakdown, ' |
| 33 | + 'peak PPS, ASN, country, risk score, and related attacker IPs.' |
| 34 | + ), |
| 35 | + 'references': ['https://flowtriq.com'], |
| 36 | + 'input': 'An IP address attribute (ip-src or ip-dst).', |
| 37 | + 'output': ( |
| 38 | + 'MISP attributes and objects describing DDoS attack activity ' |
| 39 | + 'associated with the queried IP: attack types, timestamps, ' |
| 40 | + 'severity, peak traffic, related IPs, and threat intel matches.' |
| 41 | + ), |
| 42 | +} |
| 43 | +moduleconfig = ['api_key', 'api_url'] |
| 44 | + |
| 45 | +_DEFAULT_API_URL = 'https://flowtriq.com' |
| 46 | +_TIMEOUT = 15 |
| 47 | + |
| 48 | + |
| 49 | +def handler(q=False): |
| 50 | + if q is False: |
| 51 | + return False |
| 52 | + |
| 53 | + request = json.loads(q) |
| 54 | + |
| 55 | + # Validate config |
| 56 | + config = request.get('config', {}) |
| 57 | + api_key = config.get('api_key', '').strip() |
| 58 | + api_url = config.get('api_url', '').strip().rstrip('/') or _DEFAULT_API_URL |
| 59 | + |
| 60 | + if not api_key: |
| 61 | + misperrors['error'] = 'Flowtriq API key is required. Set it in the module configuration.' |
| 62 | + return misperrors |
| 63 | + |
| 64 | + # Extract IP from the request |
| 65 | + ip = None |
| 66 | + attribute = request.get('attribute', {}) |
| 67 | + for attr_type in ('ip-src', 'ip-dst'): |
| 68 | + if attr_type in request: |
| 69 | + ip = request[attr_type] |
| 70 | + break |
| 71 | + if not ip and attribute.get('value'): |
| 72 | + ip = attribute['value'] |
| 73 | + |
| 74 | + if not ip: |
| 75 | + misperrors['error'] = 'No IP address provided in the request.' |
| 76 | + return misperrors |
| 77 | + |
| 78 | + # Query Flowtriq IP threat lookup |
| 79 | + try: |
| 80 | + response = requests.post( |
| 81 | + f'{api_url}/api/ip-lookup.php', |
| 82 | + json={'ip': ip}, |
| 83 | + headers={ |
| 84 | + 'Authorization': f'Bearer {api_key}', |
| 85 | + 'Content-Type': 'application/json', |
| 86 | + 'Accept': 'application/json', |
| 87 | + 'User-Agent': 'MISP-Flowtriq/1.0', |
| 88 | + }, |
| 89 | + timeout=_TIMEOUT, |
| 90 | + verify=True, |
| 91 | + ) |
| 92 | + except requests.exceptions.ConnectionError: |
| 93 | + misperrors['error'] = f'Cannot connect to Flowtriq API at {api_url}' |
| 94 | + return misperrors |
| 95 | + except requests.exceptions.Timeout: |
| 96 | + misperrors['error'] = f'Flowtriq API request timed out after {_TIMEOUT}s' |
| 97 | + return misperrors |
| 98 | + except requests.exceptions.RequestException as e: |
| 99 | + misperrors['error'] = f'Flowtriq API request failed: {e}' |
| 100 | + return misperrors |
| 101 | + |
| 102 | + if response.status_code != 200: |
| 103 | + misperrors['error'] = f'Flowtriq API returned HTTP {response.status_code}' |
| 104 | + return misperrors |
| 105 | + |
| 106 | + try: |
| 107 | + data = response.json() |
| 108 | + except (ValueError, json.JSONDecodeError): |
| 109 | + misperrors['error'] = 'Flowtriq API returned invalid JSON' |
| 110 | + return misperrors |
| 111 | + |
| 112 | + if not data.get('ok'): |
| 113 | + misperrors['error'] = data.get('error', 'Flowtriq API returned an error') |
| 114 | + return misperrors |
| 115 | + |
| 116 | + if not data.get('found'): |
| 117 | + return {'results': []} |
| 118 | + |
| 119 | + # Build enrichment results |
| 120 | + results = _build_results(ip, data, attribute) |
| 121 | + return {'results': results} |
| 122 | + |
| 123 | + |
| 124 | +def _build_results(ip, data, attribute): |
| 125 | + """Build MISP-standard enrichment results from Flowtriq API response.""" |
| 126 | + results = [] |
| 127 | + |
| 128 | + risk_score = data.get('risk_score', 0) |
| 129 | + reputation = data.get('reputation') |
| 130 | + incidents = data.get('incidents', {}) |
| 131 | + threat_intel = data.get('threat_intel', []) |
| 132 | + related_ips = data.get('related_ips', {}) |
| 133 | + ioc_matches = data.get('ioc_matches', {}) |
| 134 | + |
| 135 | + # -- Summary comment on the original attribute -- |
| 136 | + summary_parts = [] |
| 137 | + summary_parts.append(f'Flowtriq risk score: {risk_score}/100') |
| 138 | + |
| 139 | + total_incidents = incidents.get('total', 0) |
| 140 | + if total_incidents: |
| 141 | + summary_parts.append(f'Seen in {total_incidents} DDoS incident(s)') |
| 142 | + |
| 143 | + if reputation: |
| 144 | + attack_count = reputation.get('attack_count', 0) |
| 145 | + tenants_seen = reputation.get('tenants_seen', 0) |
| 146 | + if attack_count: |
| 147 | + summary_parts.append(f'{attack_count} attacks across {tenants_seen} network(s)') |
| 148 | + if reputation.get('top_attack_family'): |
| 149 | + summary_parts.append(f'Primary vector: {reputation["top_attack_family"]}') |
| 150 | + if reputation.get('country'): |
| 151 | + summary_parts.append(f'Country: {reputation["country"]}') |
| 152 | + if reputation.get('asn'): |
| 153 | + summary_parts.append(f'ASN: {reputation["asn"]}') |
| 154 | + if reputation.get('peak_pps'): |
| 155 | + summary_parts.append(f'Peak PPS: {reputation["peak_pps"]:,}') |
| 156 | + |
| 157 | + families = incidents.get('attack_families', {}) |
| 158 | + if families: |
| 159 | + top_families = ', '.join(list(families.keys())[:5]) |
| 160 | + summary_parts.append(f'Attack families: {top_families}') |
| 161 | + |
| 162 | + severity = incidents.get('severity', {}) |
| 163 | + sev_parts = [] |
| 164 | + for level in ('critical', 'high', 'medium', 'low'): |
| 165 | + count = severity.get(level, 0) |
| 166 | + if count: |
| 167 | + sev_parts.append(f'{count} {level}') |
| 168 | + if sev_parts: |
| 169 | + summary_parts.append(f'Severity: {", ".join(sev_parts)}') |
| 170 | + |
| 171 | + if threat_intel: |
| 172 | + sources = set() |
| 173 | + for ti in threat_intel: |
| 174 | + sources.add(ti.get('source', 'unknown')) |
| 175 | + summary_parts.append(f'Threat intel sources: {", ".join(sorted(sources))}') |
| 176 | + |
| 177 | + if ioc_matches: |
| 178 | + summary_parts.append(f'IOC matches: {", ".join(list(ioc_matches.keys())[:5])}') |
| 179 | + |
| 180 | + summary_text = '. '.join(summary_parts) + '.' |
| 181 | + |
| 182 | + results.append({ |
| 183 | + 'types': ['text'], |
| 184 | + 'categories': ['External analysis'], |
| 185 | + 'values': [summary_text], |
| 186 | + 'comment': f'Flowtriq DDoS intelligence for {ip}', |
| 187 | + }) |
| 188 | + |
| 189 | + # -- Reputation text attribute -- |
| 190 | + if reputation: |
| 191 | + rep_lines = [f'Flowtriq IP Reputation for {ip}:'] |
| 192 | + rep_lines.append(f' Risk Score: {risk_score}/100') |
| 193 | + rep_lines.append(f' Attack Count: {reputation.get("attack_count", 0)}') |
| 194 | + rep_lines.append(f' Networks Seen: {reputation.get("tenants_seen", 0)}') |
| 195 | + rep_lines.append(f' First Seen: {reputation.get("first_seen", "N/A")}') |
| 196 | + rep_lines.append(f' Last Seen: {reputation.get("last_seen", "N/A")}') |
| 197 | + rep_lines.append(f' Top Attack Family: {reputation.get("top_attack_family", "N/A")}') |
| 198 | + rep_lines.append(f' Top Protocol: {reputation.get("top_protocol", "N/A")}') |
| 199 | + rep_lines.append(f' Country: {reputation.get("country", "N/A")}') |
| 200 | + rep_lines.append(f' ASN: {reputation.get("asn", "N/A")}') |
| 201 | + rep_lines.append(f' Peak PPS: {reputation.get("peak_pps", 0):,}') |
| 202 | + tags = reputation.get('tags', []) |
| 203 | + if tags: |
| 204 | + rep_lines.append(f' Tags: {", ".join(tags)}') |
| 205 | + |
| 206 | + results.append({ |
| 207 | + 'types': ['text'], |
| 208 | + 'categories': ['External analysis'], |
| 209 | + 'values': ['\n'.join(rep_lines)], |
| 210 | + 'comment': 'Flowtriq reputation data', |
| 211 | + }) |
| 212 | + |
| 213 | + # -- ASN attribute -- |
| 214 | + if reputation and reputation.get('asn'): |
| 215 | + results.append({ |
| 216 | + 'types': ['AS'], |
| 217 | + 'categories': ['Network activity'], |
| 218 | + 'values': [str(reputation['asn'])], |
| 219 | + 'comment': f'ASN of {ip} per Flowtriq', |
| 220 | + }) |
| 221 | + |
| 222 | + # -- First/last seen timestamps -- |
| 223 | + if reputation: |
| 224 | + if reputation.get('first_seen'): |
| 225 | + results.append({ |
| 226 | + 'types': ['datetime'], |
| 227 | + 'categories': ['Network activity'], |
| 228 | + 'values': [reputation['first_seen']], |
| 229 | + 'comment': f'Flowtriq first seen {ip}', |
| 230 | + }) |
| 231 | + if reputation.get('last_seen'): |
| 232 | + results.append({ |
| 233 | + 'types': ['datetime'], |
| 234 | + 'categories': ['Network activity'], |
| 235 | + 'values': [reputation['last_seen']], |
| 236 | + 'comment': f'Flowtriq last seen {ip}', |
| 237 | + }) |
| 238 | + |
| 239 | + # -- Incident records as text -- |
| 240 | + records = incidents.get('records', []) |
| 241 | + if records: |
| 242 | + inc_lines = [f'Flowtriq DDoS Incidents involving {ip} (last 90 days):'] |
| 243 | + for i, rec in enumerate(records[:10], 1): |
| 244 | + inc_lines.append(f' [{i}] {rec.get("date", "?")} - {rec.get("attack_family", "?")} ' |
| 245 | + f'({rec.get("severity", "?")}) - ' |
| 246 | + f'Peak: {rec.get("peak_pps", 0):,} pps / ' |
| 247 | + f'{rec.get("peak_bps", 0):,} bps - ' |
| 248 | + f'Duration: {rec.get("duration_sec", 0)}s - ' |
| 249 | + f'{rec.get("source_ip_count", 0)} source IPs') |
| 250 | + if rec.get('spoofing'): |
| 251 | + inc_lines.append(' Spoofing detected') |
| 252 | + if rec.get('botnet'): |
| 253 | + inc_lines.append(' Botnet indicators') |
| 254 | + |
| 255 | + results.append({ |
| 256 | + 'types': ['text'], |
| 257 | + 'categories': ['External analysis'], |
| 258 | + 'values': ['\n'.join(inc_lines)], |
| 259 | + 'comment': 'Flowtriq incident history', |
| 260 | + }) |
| 261 | + |
| 262 | + # -- Related attacker IPs -- |
| 263 | + if related_ips: |
| 264 | + for related_ip, co_occurrence in list(related_ips.items())[:10]: |
| 265 | + results.append({ |
| 266 | + 'types': ['ip-src'], |
| 267 | + 'categories': ['Network activity'], |
| 268 | + 'values': [related_ip], |
| 269 | + 'comment': f'Co-attacker with {ip} in {co_occurrence} Flowtriq incident(s)', |
| 270 | + 'tags': ['flowtriq:related-attacker'], |
| 271 | + }) |
| 272 | + |
| 273 | + # -- Threat intel feed matches -- |
| 274 | + if threat_intel: |
| 275 | + ti_lines = [f'Threat Intel Matches for {ip}:'] |
| 276 | + for ti in threat_intel: |
| 277 | + ti_lines.append( |
| 278 | + f' - {ti.get("source", "?")} ({ti.get("threat_type", "?")}) ' |
| 279 | + f'confidence={ti.get("confidence", "?")} ' |
| 280 | + f'seen={ti.get("times_seen", "?")}x ' |
| 281 | + f'[{ti.get("first_seen", "?")} to {ti.get("last_seen", "?")}]' |
| 282 | + ) |
| 283 | + if ti.get('description'): |
| 284 | + ti_lines.append(f' {ti["description"]}') |
| 285 | + |
| 286 | + results.append({ |
| 287 | + 'types': ['text'], |
| 288 | + 'categories': ['External analysis'], |
| 289 | + 'values': ['\n'.join(ti_lines)], |
| 290 | + 'comment': 'Flowtriq threat intel feed matches', |
| 291 | + }) |
| 292 | + |
| 293 | + return results |
| 294 | + |
| 295 | + |
| 296 | +def introspection(): |
| 297 | + return mispattributes |
| 298 | + |
| 299 | + |
| 300 | +def version(): |
| 301 | + moduleinfo['config'] = moduleconfig |
| 302 | + return moduleinfo |
0 commit comments