From d10f52a223c5570c64c612e87a146eb0e1d11a1c Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:02:11 +0500 Subject: [PATCH 01/28] Add README for OpenCTI WhoisFreaks connector This README provides detailed information about the OpenCTI WhoisFreaks enrichment connector, including its features, configuration variables, and deployment instructions via Docker Compose. --- internal-enrichment/whoisfreaks/README.md | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 internal-enrichment/whoisfreaks/README.md diff --git a/internal-enrichment/whoisfreaks/README.md b/internal-enrichment/whoisfreaks/README.md new file mode 100644 index 00000000000..2b12c5daf17 --- /dev/null +++ b/internal-enrichment/whoisfreaks/README.md @@ -0,0 +1,60 @@ +# OpenCTI WhoisFreaks Enrichment Connector + +An official internal enrichment connector for the **OpenCTI** (Open Cyber Threat Intelligence) platform powered by the **WhoisFreaks API**. + +This connector enables security analysts to enrich `Domain-Name`, `IPv4-Addr`, and `IPv6-Addr` observables on demand or via automated OpenCTI playbooks. + +--- + +## Features + +- **WHOIS Registration Intelligence**: Fetches live WHOIS records and converts Registrars and Registrants into STIX 2.1 `Identity` SDOs linked via `registered-by` and `owned-by` relationships. +- **DNS Record Mapping**: Parses live DNS responses (A, AAAA, MX, NS, CNAME, TXT) and maps resolutions into the OpenCTI graph using `resolves-to` and `related-to` relationships. +- **Subdomain Discovery**: Maps parent-child domain hierarchies and populates subdomains as related `Domain-Name` observables. +- **SSL/TLS Certificate Metadata**: Extracts X.509 certificate metadata and attaches `X509-Certificate` observables to target domains. +- **IP Geolocation & Reverse DNS**: Translates IPv4/IPv6 observables into STIX `Location` SDOs (`located-at`) and maps reverse DNS domain associations. +- **IP Reputation Context**: Evaluates threat scores and attaches contextual security notes to IP entities. + +--- + +## Configuration Variables + +The connector is configured using environment variables (or `.env` / `config.yml`): + +| Variable | Required | Default | Description | +|---|---|---|---| +| `OPENCTI_URL` | Yes | - | Base URL of your OpenCTI platform instance (e.g. `http://opencti:4000`). | +| `OPENCTI_TOKEN` | Yes | - | OpenCTI User / Admin API Token for authentication. | +| `CONNECTOR_ID` | Yes | - | A unique UUIDv4 string generated specifically for this connector. | +| `CONNECTOR_NAME` | No | `WhoisFreaks` | Display name of the connector inside the OpenCTI dashboard. | +| `CONNECTOR_TYPE` | No | `INTERNAL_ENRICHMENT` | Connector operational category in OpenCTI. | +| `CONNECTOR_SCOPE` | No | `Domain-Name,IPv4-Addr,IPv6-Addr` | Supported STIX 2.1 entity types. | +| `CONNECTOR_AUTO` | No | `false` | Enable automatic enrichment on observable creation (`true`/`false`). | +| `CONNECTOR_CONFIDENCE_LEVEL` | No | `100` | Confidence score (0-100) assigned to created STIX objects. | +| `CONNECTOR_LOG_LEVEL` | No | `INFO` | Log verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | +| `WHOISFREAKS_API_KEY` | Yes | - | Your active WhoisFreaks API key. | + +--- + +## Deployment via Docker Compose + +Add the service block below to your OpenCTI stack `docker-compose.yml`: + +```yaml + connector-whoisfreaks: + image: opencti-connector-whoisfreaks:latest + container_name: opencti-connector-whoisfreaks + environment: + - OPENCTI_URL=http://opencti:4000 + - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} + - CONNECTOR_ID=${CONNECTOR_KEY} + - CONNECTOR_TYPE=INTERNAL_ENRICHMENT + - CONNECTOR_NAME=WhoisFreaks + - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr + - CONNECTOR_AUTO=false + - CONNECTOR_CONFIDENCE_LEVEL=100 + - CONNECTOR_LOG_LEVEL=INFO + - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} + depends_on: + - opencti + restart: always From 4fe4a3a8ff19527df3b40460255ac54bcd00904f Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:03:31 +0500 Subject: [PATCH 02/28] Add files via upload --- internal-enrichment/whoisfreaks/Dockerfile | 24 +++++ internal-enrichment/whoisfreaks/config.yml | 15 ++++ .../whoisfreaks/docker-compose.yml | 87 +++++++++++++++++++ internal-enrichment/whoisfreaks/entrypoint.sh | 7 ++ .../whoisfreaks/requirements.txt | 5 ++ 5 files changed, 138 insertions(+) create mode 100644 internal-enrichment/whoisfreaks/Dockerfile create mode 100644 internal-enrichment/whoisfreaks/config.yml create mode 100644 internal-enrichment/whoisfreaks/docker-compose.yml create mode 100644 internal-enrichment/whoisfreaks/entrypoint.sh create mode 100644 internal-enrichment/whoisfreaks/requirements.txt diff --git a/internal-enrichment/whoisfreaks/Dockerfile b/internal-enrichment/whoisfreaks/Dockerfile new file mode 100644 index 00000000000..1f12cb792a2 --- /dev/null +++ b/internal-enrichment/whoisfreaks/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +# Set working directory inside container +WORKDIR /opt/opencti-connector-whoisfreaks + +# Install system runtime dependencies needed by pycti and stix2 +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +# Install Python package dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code and entrypoint +COPY src ./src +COPY entrypoint.sh . + +# Ensure entrypoint is executable +RUN chmod +x entrypoint.sh + +# Run startup script +ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/config.yml b/internal-enrichment/whoisfreaks/config.yml new file mode 100644 index 00000000000..e02c553e32b --- /dev/null +++ b/internal-enrichment/whoisfreaks/config.yml @@ -0,0 +1,15 @@ +opencti: + url: "http://localhost:8080" + token: "ChangeMe" + +connector: + id: "ChangeMe" # Must be a valid UUIDv4 (e.g. generated via `uuidgen`) + type: "INTERNAL_ENRICHMENT" + name: "WhoisFreaks" + scope: "Domain-Name,IPv4-Addr" + auto: false # Manual trigger recommended to manage API quota + confidence_level: 100 + log_level: "INFO" + +whoisfreaks: + api_key: "9c0189f5a7a8457d88fdf22c91b4d9f1" diff --git a/internal-enrichment/whoisfreaks/docker-compose.yml b/internal-enrichment/whoisfreaks/docker-compose.yml new file mode 100644 index 00000000000..243d6a4f5f9 --- /dev/null +++ b/internal-enrichment/whoisfreaks/docker-compose.yml @@ -0,0 +1,87 @@ +version: "3" + +services: + redis: + image: redis:7.2-alpine + restart: always + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + restart: always + + minio: + image: minio/minio:RELEASE.2024-02-17T01-15-57Z + command: server /data + environment: + - MINIO_ROOT_USER=opencti + - MINIO_ROOT_PASSWORD=opencti_minio_password + restart: always + + rabbitmq: + image: rabbitmq:3.12-management-alpine + restart: always + + opencti: + image: opencti/platform:6.0.5 + environment: + - NODE_OPTIONS=--max-old-space-size=4096 + - APP__ADMIN__EMAIL=${OPENCTI_ADMIN_EMAIL} + - APP__ADMIN__PASSWORD=${OPENCTI_ADMIN_PASSWORD} + - APP__ADMIN__TOKEN=${OPENCTI_ADMIN_TOKEN} + - APP__BASE_URL=${OPENCTI_BASE_URL} + - REDIS__HOSTNAME=redis + - REDIS__PORT=6379 + - ELASTICSEARCH__URL=http://elasticsearch:9200 + - MINIO__ENDPOINT=minio + - MINIO__PORT=9000 + - MINIO__USE_SSL=false + - MINIO__ACCESS_KEY=opencti + - MINIO__SECRET_KEY=opencti_minio_password + - RABBITMQ__HOSTNAME=rabbitmq + - RABBITMQ__PORT=5672 + - RABBITMQ__PORT_MANAGEMENT=15672 + - RABBITMQ__USER=guest + - RABBITMQ__PASSWORD=guest + ports: + - "8080:4000" + depends_on: + - redis + - elasticsearch + - minio + - rabbitmq + restart: always + + worker: + image: opencti/worker:6.0.5 + environment: + - OPENCTI_URL=http://opencti:4000 + - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} + - WORKER_LOG_LEVEL=INFO + depends_on: + - opencti + restart: always + + connector-whoisfreaks: + build: + context: . + dockerfile: Dockerfile + image: opencti-connector-whoisfreaks:latest + container_name: opencti-connector-whoisfreaks + environment: + - OPENCTI_URL=http://opencti:4000 + - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} + - CONNECTOR_ID=${CONNECTOR_KEY} + - CONNECTOR_TYPE=INTERNAL_ENRICHMENT + - CONNECTOR_NAME=WhoisFreaks + - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr + - CONNECTOR_AUTO=false + - CONNECTOR_CONFIDENCE_LEVEL=100 + - CONNECTOR_LOG_LEVEL=INFO + - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} + depends_on: + - opencti + restart: always \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/entrypoint.sh b/internal-enrichment/whoisfreaks/entrypoint.sh new file mode 100644 index 00000000000..2744f930110 --- /dev/null +++ b/internal-enrichment/whoisfreaks/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# Navigate to app directory +cd /opt/opencti-connector-whoisfreaks + +# Start the OpenCTI WhoisFreaks connector main script +exec python3 src/main.py \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/requirements.txt b/internal-enrichment/whoisfreaks/requirements.txt new file mode 100644 index 00000000000..f25151a062e --- /dev/null +++ b/internal-enrichment/whoisfreaks/requirements.txt @@ -0,0 +1,5 @@ +pycti==6.0.5 +stix2>=3.0.1 +requests>=2.31.0 +pyyaml>=6.0.1 +python-dotenv>=1.0.0 \ No newline at end of file From a99d60ada6cea72ea696d3f6ac605fb05f7e3248 Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:05:51 +0500 Subject: [PATCH 03/28] Create connector_manifest.json --- .../__metadata__/connector_manifest.json | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json new file mode 100644 index 00000000000..ab0aaad8e62 --- /dev/null +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json @@ -0,0 +1,21 @@ +{ + "title": "WhoisFreaks", + "slug": "whoisfreaks", + "description": "An official internal enrichment connector for OpenCTI powered by WhoisFreaks API. Enriches Domain-Name, IPv4-Addr, and IPv6-Addr observables with live WHOIS registration data, DNS records, SSL/TLS certificate metadata, IP geolocation, and threat context.", + "short_description": "Enrich domain, IP, and SSL observables with live WHOIS, DNS, Geolocation, and threat intelligence from WhoisFreaks.", + "logo": "https://whoisfreaks.com/images/logo.png", + "use_cases": [ + "Enrichment & Analysis" + ], + "verified": false, + "last_verified_date": null, + "playbook_supported": true, + "max_confidence_level": 100, + "support_version": ">=6.0.0", + "subscription_link": "https://whoisfreaks.com", + "source_code": "https://github.com/OpenCTI-Platform/connectors/tree/master/internal-enrichment/whoisfreaks", + "manager_supported": true, + "container_version": "rolling", + "container_image": "opencti/connector-whoisfreaks", + "container_type": "INTERNAL_ENRICHMENT" +} From 8ad11ace9feedc3ebc8902d7336aff0bfe4f00f7 Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:07:26 +0500 Subject: [PATCH 04/28] Add compiled bytecode for config_variables module This file is a compiled Python bytecode file for the config_variables module, which contains configuration settings for the WhoisFreaks OpenCTI Connector. --- .../__pycache__/config_variables.cpython-312.pyc | Bin 0 -> 4151 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5f22d2b4dcc2cf6168f69ecd9efe4b9de23f809 GIT binary patch literal 4151 zcmb7HZBHE66~41?>=g#cRESt5g4C2nY=RD`! zbI+Z7&RJRx1__?(@Bi|FuvXpt6timi31eB0r$*47X;AcQA`yv8lcf3Pk{sr)v@7FI zx;c~QNqWF@r@a|p(#H`OnIe*heo7>7<)zECmTW0<5g*-TvqXfe{`S6SUes3&s%u5o zh2>l}rK||IMO6_$PRq-tnmN{N5V|t}20W4p^CIbzxTIThVI?@=WlMUJ-l99=gZcKD zVem%Ho>uxzkjGq>S-<5Oxb!<%l86*FlbM&#^mMa^Y1a#rW9ooJ@=GFP`FA80q*$`1V! zo5PNDTdZ5VC6$*o=9+aRbtWXP2Q$f43K4RTQ^-Bn2DjEy2dI&imo-9tM-N!y4Nh`P zp2~Ba4|4<0d2-LK!<6}~wOW+(-f-Lg+~qVEN7$M;cKq3=%n`la(vW;oi{xK%ZMf`3 zHi+Q2#C5;`fWbzf?Q0z{B(;7YXnfAhY8|i*!1hLq_U`I{#{leT1lpUg19k$~)d+01 z^l<>+Xau%3+4Ttk-)sch(W_g!8^E4M;ISqad!?|{*9h#efE#3O*rJ`c{dsVtNCwGi zqH%X{wo8P=AN^~!{P?bWiQM5L{Rz6sKF3nf`Q&lUIJM+0=&8|*#!dFi+2x#s+W|>v zZa$vCEfPLjxQb~@4wq_KS3tT8Y8tPg1z4P$jVHkH>bZ3}3!*hPmq^58i*pOY%p~Rk zENNauOk9eDvCV-jF3rcm@#w|8d{mN{xMoPQVunfD9i8hjEDn}eTZUgCjyp0?S7s*R zI|Cdub}PYkSxC#bW#)L@+7llw&Vl0*3wjRkF2~B%xhdgl{6?JZJ&?|=*xFq;r{`v_ zUs;Gx%w88J=4XW2_!2OoJ1haEj0A*fiA$*S13Y)_@5~;e72(@^yZJ|h{6-m_Ph==V~lhY}@&68R&gOo>Y0ncYF&%Q8z^cIE_EK~;myx6SyU|HmTi0{>=$)Lp9yL~s*1}@FM{V~;>pY5T>T=YyZ&xjC zEMLT8I4UKp=z>t{jvcK+W0r(v1dZOujiUEafd3IbS^2_GzZtLo=?~%4)$(T;HlO^6 z5$8Z`Fd|khZ~43FK}Q$e^Hs}FLt%hJgAlRdQE0eY{^*01>BDD|NZ%Z zaKUqxgCWR!`ssfL=*xl1zNcFLyl1pp-t9RHXea&3cev{Yj&A!%Uz8rS_RtqI)$(2e z2E(J3{o8cM?dl8CJt#hc8{2*|a2l{MeW6tL-vp|kx%@7J4Op;V2J3$ZuwMG&R%IV9 zZ1qI}JHcT67OaOYdh!flee}hzEBjXf8(?nTV6c-GEc^pt{8?D*hyS6<{!N%Wc^>AB zJ@A&fK4xxY9Jn6?cMG61lNg5Ll>-AfVgXP6fOUs1z#h(RZx-u?VQ%pK>gEH(-jHRF zXZCF1H2~jx1rTTFCxIY+-bSCbRmU-V z;#IB2AKBOTHCaKwfMNi>#z$>$&X>6LpAe#Upu?!O3-y{33Vf|m;Rp;gX;uuwP57@d z>~tK4Vb(P@Jci*W{MQ)v9QD|4N*wEk;U@go7)C?ogo0DYotBEVLu+=!;4HMk*(&rK zLcgsKW}UX*M%DLNYjzvp7?c8a&|->79wjn`L2ofp$-UX?#7D5@;gT3D+k zMs6+08jt$I=T%ull^J6hEw(CVSLADQsmH3?DC>NbucgW{6qHiGB{B+nozN&RrLa>R zXC@)Fv+9^4l_E{5A~1FNcC1zbO6PCI7Z&Cge#Ku?G!4B5YrG{nB^J_p^Sz+Gzr>eZ zKjlk5w^ZlTvZ%>?AF5Q1$!uVRPiEH ze9uxm#S~9f_L-+6sBNE(#3~2O(UA|2s-YS@YMrY5Gt`L!*4jZ2I;SfC_I$S$sFweR z|E+~3l(Ok0s414jkNuKvyIsLHmSlra+3LK`3@kF`cKUppKAWareMrBUMGg%SL}Uk{ zBoP-oPF2!yzhdl?3U{|6F_#nu1- literal 0 HcmV?d00001 From a30abe79e1e103d88d23c5ec3406206ca0e64add Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:08:40 +0500 Subject: [PATCH 05/28] Add files via upload --- .../src/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 178 bytes .../src/__pycache__/builder.cpython-312.pyc | Bin 0 -> 18078 bytes .../src/__pycache__/client.cpython-312.pyc | Bin 0 -> 12290 bytes .../__pycache__/configVariables.cpython-312.pyc | Bin 0 -> 3732 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 0 -> 9556 bytes 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/__init__.cpython-312.pyc create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/configVariables.cpython-312.pyc create mode 100644 internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/__init__.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f23267e81a2c31bfae35d4e5698f79067b4ba17a GIT binary patch literal 178 zcmX@j%ge<81P?vqvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!3e?ZY&rQ`YsL*#w zEzT~Q>us8A!4-54&CEtG?a$++#2DhyS@7!+wu4{H4g>tVZBw z3L`KAk6@>{U;H!Cl0p|~Tnjkovr=9LNY&NE6Zy+r-sz z_vr0;6`_7daavDkfKK~dbUL6j5PG09JQtl2=!}F3=t`c8&J1)Wq7>*X&qZeix)QcM z+jG&`fzHgf_k46^Kvx>F5Dw_6Dr7ycAe`?g?&OF6v<=>r3vXPaH})~Nzmh3Cc_9|2 z4w9k4i_}0oe0ASwI6{QT@sm0)Ts_`%T;6*=MtXx#Djtpwc`pw}!i0C=MBgcITT?5P zi4u_zygf(zykv;F6pKZbOHm#^I zOJ&aO&pBUtVpKbHNyU%#t3Zsth#d@eni<^{#AE?tFh0nr$WVNgj0VwE^B0nt?9~4R zK4J<>$S8aaOJKwNd(rO@J|R!w!(!_ETb|{Im7?P7-c2at0&5IwHkL~$7I0pgPAC)d zVZB5dC0_!Eo(`i)Tz3&NzQk3WlA}`4YgN32FNP#^LUjP+dt9Uz1z!u#v=i!t`UXzc z#Y_2`5^BE8W+MVLX3=+Bcey=)ND$%x7q4@%9vP-jERZ= z22w^Sqc~9_xqgH%{$upuIXJAa3iZ-!ETQMW?@zo6%?Z8a8hP3(l%yie&oM?4tFVYB zC>;{k+5zka{uaoc2}8m_;qR$M+-TrObTeK6nk~;uvzbOsC@#JLhyj1^h-jTjXx4AC zO_7c^7U3>IqWp=u%aSmDCGL`>A}n8zyKdm`c@&svp0*XFD1Vob6V?SAFR=$OzrFu9 z&L{!#kA@g$9>=lJjtX1=12RU2gJ3)gU>)i~x}_cdR_7pj3BwVh!`?awJc}1VeFBdQ zu%2&?>;PmZf!+v==WNnftVBT@N!B2sxJX9q^L-0Z5 zAjz17TNc0e-g>%l9$HairM4;cq&nrSo8Olw7(h z9urO?O-dqoOBx{YD@X)SMJ9=fwe~_p;M`b%44of0a$^!ALqy}bF*G7nY?KU!&`YM2 z3`JN&iMkNJ6o`*q3NgC)APFOcUf))7l{cXlT2RuAk_Jem8RvWOMqnHeFHdEh?Zwza zU>sYq5(UP|v&K$f4V+~S8DrrYAsP<^W6}6vFy2q1RYTT7BA#-x9v*zX$?ii{c0ux# z$?nTE9u`e@!~D6dxivq*$cX*k?!7ppEA*V5R7M?)ja(W8?~g7VCBk5bjt)k~HL)vE zc0L&cGH(M^h`byksV{*V<`dq;V<&*e)v`EXw3JZV4Zzb)}9I{ry zbY+zACPb3RHeqy}Dcp}tX4T-OOQ9%1B3=e7I&>A54p5K?Zjn}$AmfAO5XNv^#7B$` z8L0bXWJvs!+>T0DL&Dg=ro2RkFAoOC;5QbBE{4D^-p9Hl{3fytl|%@hbfW|fEn^5q zLW9u&x8wyY71`U28dhn!B{p5f^q*d%M4@zQNIOy;X3w6g_&JBtK(tS*4c|Q7pLD!s&kI5Sw}7Hs7*WS zvW^zo(UNwwB@JNMXUnSTvg-LaGi6;#eNI#IzUf`ljkhxz-$P5qRMTWrYRj&)rTc;9 z$bHv+LsI=jg}JI8$&mPQZ+)VK-xVEZbiCjHZhy*MpEfkC8r3dc()8GZ8C)L+=bF-0 zt=Xy<=&Bb!t=hRUQRQtyy0O?dw=kW6nLR zn8H~GU*oMuHA6dDPYdm7$yImed^NXr-Q0z!o>+CImM3z9(e$JWb916t=}9cFOPb!2_rw6FW` z2kv{)yN`Y9JHA}yn~lyyH;h^!UEY{2Z>P)K)8(BoQWe#6vERfq?(SSgeYT>Ru4rD= zW-7WKDY5bzaL-iLtr*#H)xfw~(71Zic7N91Oxv5&_ExCCxpl7jHys(rw{p&!th1hW z*3b84oI9WbyALXGRX-FBywBn>C0|*o<_CUn#!&yP1K*MJwPk&sw69Zg;G43ZR@&2= zt8S4T_?0TmT8UV5u4>VewsfpwX00W;k0~krFgU&SgQ2OB$&rs@3%>btcWQpsc)RgV zbGB|TUAH$~*K_~PbltIKmnZA0qg{2su`V9}wc}pp-O68kvaJW{)`RKRLm5}!4Ry}! zm@-Y8rmtkoHTnBxTY1)2P1~xM-QKLblXiDz-95CsCs)&U>)Oq0xu&jMP1CLMo8$0F z75i{trV1A@)3#=o*L-!ix^8ym>UQL6>TkV$^KC=|tYk0y54oY#vZ94Ac(M&M+uu*T zo0#ie+LkHVo6_thk)xb3rzw0*xg?8xnL zq$b%bp^hV^#9jjGwI|es3YC^_sbKq|Hdy=bhfp)}9!d2~@CZWBEYN@HQW`?bw?B-U zUU%`H$q8){dcp4it&|pRMX>R~gQF#72_4vpXA@e&a1r1WS@V?H*KKCU7Px3aPZ%c* z(eqHRX+ynI9Qb_oc0s)*FsGfO-b!)brnO4hB>Wh!`4TgH3I5*WBHSyCCu`M8@VBUs zQl3k}UpZX0Zo(luyk+Av@IY>rcxnKjiNy1TQjzCU;=MFg!cfGjm8fCCve9!1R_%|# zsx3&JXR~U*#Jfl|3V;DYwC157ql3(G4hAx|f_-~HcuL9;jT(xbKvs@|wRPaZAVI)R z7eyimD1-Nm>me75TnMBcNO`s`v(2e68<0dU;0=*Cc~79r+Bo2hr_C3BubfSY{Lvw9~5`1sqP2 z`_N++cJD`b0`?|fgsk7lf###=)iIQ?pjpp>X3hzq&)a47L!3wopX4*z4txv6(~0XnHsUdAw#*TwEyl6R42tf|=q_ zv2z_NF58+dtEJ0o(`9wpvKG3mC0*8*)C1Nrn6vsyT3H{3hbiS=XM zT*rbf<7~=RZJleK8A8y0zF|q0uG)1&|FCS!D(2Cek_U6{Ewf!SU31~IyXArV@Z!OH zhwmO1y2sZIBaY8RrJ;F9B2%!8Ws#Dm| z57=QkHtDTPiIx|Pk^sQF1^`F^2XJef7D{-?3vO+5KRE!O!$E)& zSl;_XWITKxoZ0aZ#h8PI97Il>k1^UXMU95Y01+KGy&An31;?4Uk9`D=&{5Vs2JaXn zc+kRwkwA2G~^>1N)Y6bOAo<&6wFOq{OIfoKf$+P;s z2eM4bsm_+2qW*LG|Ctnh6jc{PiP$^FNj>r+s@#l3pJ+HJMuBpS7NzXike3nt6;xQo zeSZEW>-XARxueRTNx2sa{uUadehM27(#qXYIhB}9d~hx6sHYwEX-8wy@K9fp*Xxmt zzMj+T4Yajdtk(^nnmY@6{mqQMYlB{|$hh_-Ujp)qZL{4o-KpB0X?NEH_o1b$OT#IR zGiTejia9|UAILc?W;HXK)Ry+Nv*UrYdr6V(%h{`@-kN-CZdcmg{J_3H)xIB;c9{ox zE#{7}u%Ku0&1CPg-8o%8TRBrX_Z`sj*W!Bk9I~$iBuIU{gyuIvnPxt=J zkgMAEM6D<{CHs&wj&p?V%ow&mtJd6+^EKS+zS%9QHEU-RGl^U^Xx6n(FivZh!*@RE zD55lV1+>bThe9#5tAT>3HY!jgtUz^Qs~>&?3Uoptcp!v5q+zt-vF;N43BUtUC@ZmR zDK7gp;(^3I1*B?Z$dZ?ggb*t_WLHa<5|kYH7oX$h*2okUm@aV9ld4)y|f~bh|GW}nf%Owf|rcqNeJqeRhIg7 zYyer&ct|O3!BZo(gFB426xLm!^dx#pfbT=<`GQ`gEG6iqsi3xJA0q=lM*JM%;%}CK z0H7v~3nr18ED@_o4Zo6M7bP|tjgw_V}GN{iP2ZP(*L5`+^4a*s@ zZq9&(TEtQ?ukPA5wPht~{tMcmt_zyayUwh&R%Wd}+UiSNwrflg}x^(NjGgI0IvR`Wk^`SMhJ3v=58qY(kFKex#tu^zyw6*<# zwKw%bZ&H)9xTYE=8>X+NElm$B{fo8t8tyhMT}!w1!!}ci19`Bg52j0eNyWeFEk7le zE4Iw`&-CYVf(Io-FLh1>oV;xefZ*Z?OYe_YRcG}Q^#I~Zg%dzcjWGo`|eD~ z%k0Ztw5vH|Ye@~96mL9Ob(N;%%PVEPqT8J@_@Aoi1oykICF^UaeeG*iUCr$H%y`bz zko7dvo@NPU$L;u36w!04{3c=vpgQh$GZ(%08&GkZxe@Oue^IJt#m z3cM(R&_A`L!V^>@g#Hz*FG1lTI}e=3?O=}~ps?<`lg~qm1ER1NDMh|1{bu7T3O4%c ziblKa4l62BkP@A-(k!nG;QPF@~*0d(rNW$d| z>PEew;dunCGHpL+kRg*QzoZzga4&5!nMIlbIZQcYNx~1x1`UwNVtR@bG_MyXQOTd8 z1nC9xAWC{s@*zq-f`m2b0^r*O2hr%c;)=k*DFWd`1czqRrb=K#`FfE3RF|LtJnIyI zgz2;CR=<5))?Q27Yv(H#$+W#Ismqo7RxvwltMx~7biMsSOo z>pn*o$_Hmak{WHWb!~P`zc9OdX7|tcvOA>pw7Y(>=3e97#$PvQ+j{A?-gH}E#(nsP zhTmMhnlaZtb#t{D?Uu5etL!!_G$!6$Z9v=d!hU{{%~dOW$CIWaq8marE{(>ESS3i6 zvsMX;Q2V-Wz5k`#BPI5}17mFNhOD z%a)W89Gwp3z}2JzYE?1nA|g+sC2)1|_Qx}vGZ2l1U{wH@<*%opZYmTUB@w1J#Mwhd z0Sfk4LJ)R^e#YI$1y=U19@29DjJ6Y*x}m8oqua8PI43pWh9w7f7E)Ryats&Y=b?XyUp*tnl)C@#;Unf zpBkI~aWva|itas?3Jhg>FMzNGK(1N~$hT&#)!?t18&6x?9#~&oi~$~Zc#xlKu0QQ) zf8aQ>c=cZ5ZsLA#`h_FN-{nZ@eap_uS>22d1rT*jc4e)8+Un0$?Odu|a;5eUq)wbo z4Gg9#&gHh$-g4b^%^4u1&<3Zv=WXFONAZT9rc>FFcl(5zA|>yfhYKplCcK8GV@8$(>ygt8#a#tPkrZ$Rj7 zhUflRIGH!O^8pgl2gL;RiyT7V{k{mpN(jZTATkxK$W*dIueit*4rYOHMdDEg*bj+X za0hKBYM(<`7MwNeBF-8C@q>$^fR?3&r4C#tz$NGc%xK7CMzd%!v!apwITRT89N+AeQitdW0#2)2tPfOjJ?F9Nk)I-cw%kV%NSqG0qFP+n5XX*emz0#8B8fis;C zS%omL*a$1u*l#2M8Y-gD%HahZC*P_#VZ{Mx5bpxi$;&wLq1BBtt{7Pi$+`cA#-!z( z%{6so^2i7MSz9e_t4-VLl3EZwoJV5bpRsm;%*w}^Ud?E{56zye*+-jwsck#b<`*8A zPcH4v?&_y^^?!0Dz3XIBnbVt7`U+_wwtFeO^u{OJ)bW$4W2aITujjV-Z@F)}=ZtG* zSPy>ReqWs`Ka_LrT=Flu?;rkT^pl~K^R-;%_SrXP-k5&*s|hYPpn6T@x&nrUm0lfi z^FsiQLvXnNFL80)dJ;R26S8+S3A}Ldla~>|#qxf?no;MYnHg;%oEgF-2+*n!*sL9l z#-d{*u~902pmu0L3BCz`(q?KN)#FuulG93m|Tqf z9?|*|bDvZ&x5SZ%j}RLo8}th{O#Xr8*kbvOopXv?x|_OR7&6ud+H&j<6q=ClS9owg z&d4F?-7n*U;4OT(xX??A4dOq`g`AKFfNZ?}IXd_w)El&K`UM=%P-v=jDUA!lY|YlR zXn-F58u8s~~ap)JoD>{)}CHhAak zXzBiRge-@0Sz`4E=$p8vCWTO@0?|gN1@KdQq$XGIYG1 zJ-!fNbb-J~j2MlC0s+Pl2z(n3Ipp7{0}%8d3K|Yz-oYNpWJ=8=jYg@P+rNsz&x$RMEB_47x#4ghTMC7{=RfT7TyQyzplrIh}YHsi09;~H7Wmm@WCPY$Jz9MIDw)^LO^VEFp zO$fk857v^PCo5+;5?5uOq_uM~xYWLQ;VyKxD2b16g}o7f4SKM)74&4~FkXpBP%G?B zOUL5Cl5g?UT@&|UEeR^JvZowTpig0M#EqZ_Ybnr^l^vMMvLe$dkK=RgD;WBn?|97q zuF8d1D^B)Stt_7&Si#_D?!;sC!xniYgF9||(aib{j5-n<8iId|!5w{3K|~RfrriET7y*Wznt}D=@#*baULihx{ja2CRcoZIHmq w!13QM|hQhvJz~lESoC+<=+`Ndv5bwh)aWK;co}OlS z!!*ku;u(SCS-~in>VS2I<+j{ zZ{3f3Ebs` zyGj6lGK$^CQw@D8_dK3`z+DC0hl_Nr0jV!+Van1xq zr!No|M2|P@3(Ptfec?H0ULWW9(8Q#3;LYY-~mlodR9Z&NH zp83?Eh7Foprd;e;bpOSW5Ez_1*PUm=?!jOnAb7(;vA3`1aJ1YL3i*9rVxr!8DHw1W zqSnijAa)PTg6U-AfY&R8!Y-4D;}b1FWV0xI7!jnfB%3A#Ndn`_CP}T#2_9Y$CD|AW z@t&}N5lRyLGr8^(8WE;^yHparyPK_K2Q$97<_=5M;S%e zC*T{OP`@`?xT($ z<+(QE*n7@P7Xi_>OPwYlB~oX@8H)LW;-K!$2be63eX|!BW4{L& z1DP=fKn%eYE`Unzr69(=9K?3$%@PQ#+hw713S8%w_%gMFJR^};lP3?oyy)VyyEhEO zJ4iC`Ns=XhOuM|*8e;*DOZ49dEjDP$LU3-#IzXL;XFX7!*N4+Wpw_cWDH&gx2B>py ztIv%}tr4I$?xMM!NVdg{IzVmw$Vh-1$~>Sh9~1FqE`x$i2uork5Z})MQ=;u!KphJL z=6Zu74;gACz&kx*r|%x}zNO{89PmWKb3xG;1vu|^4$cYQ>&~f>i{~bW$A<)9+jGg%zX+IV3NZA~Q2SFJ{e7uPE@ou+$T+(RM#OH0czZ zP^V@jEl8X|qB;?!AW~pQSDCmUJ0L`eBt|f^LRdtUA)!HRgPP3x0+6b=RQCh@!gOrt81u!c zN}#{3Xc+2ae%r@ET{iN;1&=Qv8^KW)JcU~++!T>Cx(6yGjfOcbzIYm91Z-?RHGIyf zXI~R!TT|Y9qrc~{vfbrJrH*+ul{)X&T`7uyngVBG1+l3%~0iaFDOH~ zH4QP}Dk9JoY$(z9i4_h*Wj8Cp_Kx{Hdj<%0;KrgQd3KPkb3O?+b+DE5L-vX05OMNk zj;JLb!w0y2#5aKys_)2kEi{5lYlIzG1r&+^P+*F>RC60Fw7*JLji)(c4F;{jWUL{Z zV6(jlsQSvy0y|s9J!F$8+<@&U>_ZisoJ14gt}AB|Xd*~On$U2C)~U`&C^XGMt!ec5 z7EqkNT^#WrB&*)hw~OM(Qh{Bviw2!|XLsQZF@ulhnHcS(!C^89mf|qgio<*m@n0ui zK^Bj)bV_ndM_S9oMan$J@S!f`g1*Bn$fHVokt6mem6HVC;q^cVq z%p|JXQGCGUMPT&C~ zs0gxO>mfxfAEEjh=V(+G%}}!HysmSwAfD;>j9Y1g)f9Vu1+*y8^KLazfpCChE|dNSE<%L70TGLiG~@vo&UbHj!KcQ(fXw{*h)$7Tsb9!j? ziX)i#BXj_g+z_Y8`h$2DM~B{(#heypYDpHVt9_qV`(3*H3%*1XuR8V=uksIoIgmr3 z2lqfbuOA5I7{rUR-^$(_M{=G4aVaQ?Bz4I`GAx;FLL=dORT!=&+#g0fVf?zHdVx+V zqsRAes63v$!O^?D3Ul2=UHAHOyd!b!{L1oE`-O!0f;P#`9yRSHHW7?vi!m00GcE;5 z@)LxZ9L^6(GUuHm+YD$P_bk42DOiC=Rk~3-`MK7AM^!cPrZE$5>Y7sZEjz{=eUJZ6 z8=Y;4E~xpe5cCI?Pc-UI$KVU)R8^C+Y!oS62>T!a=7~@(pc8y`8GnbyXe09un{IWN z)3SanUZ3n7)!XIBwp_@C$nmN?Uz~+O$c!%}K~jpjDTxG-tP*Fxj>F7%_Ik3IbybS$ z0N>8sU!r6@Pf^1tWT}bz?)A$Xqlu&ER-#Ys=M(1hitDq$re4`7x%>VO6Aakj6|HpG z=Mu+{e4wDRq#{4ArJ^)Uya>;V5{C*Y+f$u%ovd8Lw26{5K;Mi&Z;Z3asv%vVe^s-* ztuA-L3*Rro%iHB*k^mA+YQ!Lr0;TFyuheSxnijxzWd0A7tMTBj_4bX%MBgxa@Y#g< zEKv})K2qR2VHhTPI)^oOCCLM+-HUj@0tmK*x{@HCo7T+!GTT%LVdc=haC><`t9~)h ze-~>_LRQztktYX}N8ZJ09Z#6YwIM6J(cT5LcThOvzrIjbU*f@q`iedrTtU8K2gf89 zQhiq^TPS(RhEoai)GOD}k)NA(=Ufw}7IV^~sny9zYcpA9uB)nZ zmv&lr;@v{!HF2qZ;|eI>b44#MdGk*h(e@+Jj#7=|Q#MP}1m>hXHvYeCpexS}mu)e0E?jNL1No&I=a#w$*#pHIw z4}9Ei^1VW4q_8MrUJVICAS_m4jpgB|ks?mA$Rfeaog4>?Kfv1CNCtq&tRK!!#Sy$8 zMREzr6(rY?yoUsT>mz!Q2uS9Ugpi0x@FYuIL=r_3LxRUU;!lwL4Ujd8*nymfh?p7> zg6Xk@KP|dVZ5bHOoVHTs-M2=zSPN%;$iQBlv#!pqOKS@+u$pe94jfFiw5A+&TP5{e z?eofgX%0%r%PQ&~RNpI0oAAy|*&TPocLHe(-dQQ&LhiJUvR2-0yDL2yyt{O-Cu!OL zq7)w=r$*^9dW&h}YT-m6P0IG}RsPZYN_}XAl+P^{g`GL*kiJAyM~`nY9A{0{)TK$; z`r7K?qqj+2+Xooa<)n)-(*;Yl3)Hn;;9Ckorzt-WmX{ zOxUO4!Qj2+G^uy>uOC_KCUtE;U`|(mm(h*W6Y#E*j7nIlDq(%#+>_SFO=)b`z7rn8 zyk4QFK!6RH>wyu=j^1_V(M_d3^v)CSaO-I|_BkO}}8O=|he{$sU zE7$zH8M%_?M(NeTG==5*$i`&6H7>V7LOEY+*<-@12c$7ItAH)G+sSXJ1>KsE|D*+y+ z%V}vmcbXjE+{`?3|D3qTZ8R0YnZ*o9R zaNYtRgh$EGlterqCLg91VEuc%#m{X7Y`4uPln8QC{3$%c2qX0ZNi#G}|A8v|17-gs mbudXCd~T^7(25q?W9|CgdA`bQK+X;n#aSlMzM+wo5ur2<9$KsF`IiVVpuwrlN*TA6=& zyOd2vqGD9}r&We7VhYRm8rCS^@n%x0F9 zq_8StMZBJp7i$u=ry-7BcES1~lFRVQKROus43YDpUR4ojQOQ3=C3Yi6?GKL;tiFb%E_77>;wg8> zl{HPtBvpP1XVZLb!-Iu%iU%cfW>vx2Oj^!pyy=5l6JBI2zPMm+hyRxqaOxq_>O9SK z5^Wl^6*KM@afu1RO0pHs6;?~eg)SNford9?OP%k=(ROTjp6Z43HzX!}Gj$8J5bd!nx zB=MYnz(sH4f)EB59eHi(_&K=kZaK4VYNa|wo((rmw=iP z(=w!px}qyFFhv~dBG@M#4LZz5Cq`g@ap7WoE`E_5(EX8Mld_PKSLM{~a|!&6+K5|G z6Z!OY855TzFNyh-W;Hwf;p`M)#k`g!!G`*aQ)7l+Pb!p_ z6IVuO;=_|ygyHE4VKP2Xd^c&bTGEphafvxaxFHv!hcLa;#4QL*N=g<4;>w8_GLcGL zimKBHOT$apf|yPbk18&K50C*d{k(|J2)&?3-dq?VuLoJ6F>b7C(QT}2ac!)PfHnv) zTT(3Hvntc0V^=jy9Qj-h0vT+`HNq3LI4>B!6AzS(lp=|m9CZ*@l~YT`NhNE+$U%)E zPThEturxlzkHwqdz7chmURzoq_}7P3ym+Dptyho1T!98w$tap26x(AztSy5khvo-64bEAI9^H|jcB<-(7YjU(mA$e5*qz>2(6)kUh8_P7fnOgyAS;hdh69j*ITdY4duGt zeduq{>#)Ru!9$I1g!U3E^_ttTv&NVbpQGPdqG*wHn+R)fvT~rI!<_&6-^Abp&rWJeAw8I7yXh3H}+3o+cU1U+BWtz`*+V@Jms1Br{fFdv1{c6 zzl0>`Z-E@%DALtOABctqO_CziE5&LrR`4iqF>Z=uqA z1tFc4@_N<;($ z-gh0Ly=A7=(O!d7P}GIv@S=@{wwzL~>kS9~DJ3~_1!&v!PD0m*6F_YkdhPysZG%Jj mGB9XssM7vs*Um7^H^}=fYX1iLzD517oRbXG{ym~kUHre0XPEN< literal 0 HcmV?d00001 diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfd109d0966ee78006debe08681c24767b01e5e2 GIT binary patch literal 9556 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 Ky1)0L_J05{@|Pt5 literal 0 HcmV?d00001 From b1fe0eefa670f2f7947e107f1f1c8219f24362b8 Mon Sep 17 00:00:00 2001 From: Usama015 <136613780+Usama015@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:12:46 +0500 Subject: [PATCH 06/28] Add files via upload --- .../whoisfreaks/src/__init__.py | 0 .../whoisfreaks/src/builder.py | 415 ++++++++++++++++++ internal-enrichment/whoisfreaks/src/client.py | 252 +++++++++++ .../whoisfreaks/src/configVariables.py | 82 ++++ internal-enrichment/whoisfreaks/src/main.py | 180 ++++++++ 5 files changed, 929 insertions(+) create mode 100644 internal-enrichment/whoisfreaks/src/__init__.py create mode 100644 internal-enrichment/whoisfreaks/src/builder.py create mode 100644 internal-enrichment/whoisfreaks/src/client.py create mode 100644 internal-enrichment/whoisfreaks/src/configVariables.py create mode 100644 internal-enrichment/whoisfreaks/src/main.py diff --git a/internal-enrichment/whoisfreaks/src/__init__.py b/internal-enrichment/whoisfreaks/src/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/internal-enrichment/whoisfreaks/src/builder.py b/internal-enrichment/whoisfreaks/src/builder.py new file mode 100644 index 00000000000..9a3bf358168 --- /dev/null +++ b/internal-enrichment/whoisfreaks/src/builder.py @@ -0,0 +1,415 @@ +import logging +import stix2 +from typing import Any, Dict, List, Optional +import pycti + +logger = logging.getLogger(__name__) + + +class WhoisFreaksStixBuilder: + """ + A builder for creating valid STIX 2.1 bundles from WhoisFreaks API responses. + """ + + def __init__(self, author_name: str = "WhoisFreaks"): + self.author = stix2.Identity( + id=pycti.Identity.generate_id( + name=author_name, identity_class="organization" + ), + name=author_name, + identity_class="organization", + description="WhoisFreaks Domain & IP Threat Intelligence Provider.", + ) + + def build_whois_bundle( + self, domain_name: str, whois_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not whois_data: + return None + + objects: List[Any] = [self.author] + clean_domain = domain_name.strip().lower() + domain_stix = stix2.DomainName(value=clean_domain) + objects.append(domain_stix) + + records = whois_data.get("whois_domains_historical") or [whois_data] + + # Registrar + registrar_name = next( + ( + rec.get("domain_registrar", {}).get("registrar_name") + for rec in records + if isinstance(rec, dict) and rec.get("domain_registrar") + ), + whois_data.get("registrar_name"), + ) + + if registrar_name and str(registrar_name).strip(): + registrar_stix = stix2.Identity( + id=pycti.Identity.generate_id( + name=str(registrar_name).strip(), identity_class="organization" + ), + name=str(registrar_name).strip(), + identity_class="organization", + created_by_ref=self.author.id, + ) + objects.append(registrar_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=domain_stix.id, + relationship_type="registered-by", + target_ref=registrar_stix.id, + ), + source_ref=domain_stix.id, + target_ref=registrar_stix.id, + relationship_type="registered-by", + created_by_ref=self.author.id, + ) + ) + + # Registrant + registrant_data = next( + ( + rec.get("registrant_contact", {}) + for rec in records + if isinstance(rec, dict) and rec.get("registrant_contact") + ), + whois_data.get("registrant", {}), + ) + + registrant_name = ( + registrant_data.get("name") + if isinstance(registrant_data, dict) + else str(registrant_data) + ) + if registrant_name and not self.is_privacy_protected(str(registrant_name)): + registrant_stix = stix2.Identity( + id=pycti.Identity.generate_id( + name=str(registrant_name).strip(), + identity_class=( + "organization" + if isinstance(registrant_data, dict) + and registrant_data.get("company") + else "individual" + ), + ), + name=str(registrant_name).strip(), + identity_class=( + "organization" + if isinstance(registrant_data, dict) + and registrant_data.get("company") + else "individual" + ), + created_by_ref=self.author.id, + ) + objects.append(registrant_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=domain_stix.id, + relationship_type="owned-by", + target_ref=registrant_stix.id, + ), + source_ref=domain_stix.id, + target_ref=registrant_stix.id, + relationship_type="owned-by", + created_by_ref=self.author.id, + ) + ) + + # Name Servers (Domain -> Domain uses 'related-to') + for ns in whois_data.get("name_servers", []): + if ns and str(ns).strip() and str(ns).strip().lower() != clean_domain: + ns_stix = stix2.DomainName(value=str(ns).strip().lower()) + objects.append(ns_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=domain_stix.id, + relationship_type="related-to", + target_ref=ns_stix.id, + ), + source_ref=domain_stix.id, + target_ref=ns_stix.id, + relationship_type="related-to", + created_by_ref=self.author.id, + ) + ) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_dns_bundle( + self, domain_or_ip: str, dns_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not dns_data: + return None + + objects: List[Any] = [self.author] + clean_value = domain_or_ip.strip().lower() + + # Unified source observable handler + if self.is_ip_address(clean_value): + source_stix = ( + stix2.IPv6Address(value=clean_value) + if ":" in clean_value + else stix2.IPv4Address(value=clean_value) + ) + else: + source_stix = stix2.DomainName(value=clean_value) + objects.append(source_stix) + + dns_records = dns_data.get("dns_records") or dns_data.get("dnsRecords") or [] + if not dns_records and "historicalDnsRecords" in dns_data: + hist = dns_data["historicalDnsRecords"] + if hist and isinstance(hist, list): + dns_records = hist[0].get("dnsRecords", []) + + for record in dns_records: + record_type = (record.get("dnsType") or record.get("type", "")).upper() + record_value = record.get("address") or record.get("value") + if not record_type or not record_value: + continue + + if record_type == "A": + target_stix = stix2.IPv4Address(value=record_value.strip()) + rel_type = "resolves-to" + elif record_type == "AAAA": + target_stix = stix2.IPv6Address(value=record_value.strip()) + rel_type = "resolves-to" + elif record_type in ["CNAME", "MX", "NS"]: + target_stix = stix2.DomainName(value=record_value.strip().lower()) + rel_type = "related-to" + else: + continue + + objects.append(target_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=source_stix.id, + relationship_type=rel_type, + target_ref=target_stix.id, + ), + source_ref=source_stix.id, + target_ref=target_stix.id, + relationship_type=rel_type, + created_by_ref=self.author.id, + ) + ) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_ssl_bundle( + self, target: str, ssl_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not ssl_data: + return None + + objects: List[Any] = [self.author] + clean_target = target.strip().lower() + + if self.is_ip_address(clean_target): + target_stix = ( + stix2.IPv6Address(value=clean_target) + if ":" in clean_target + else stix2.IPv4Address(value=clean_target) + ) + else: + target_stix = stix2.DomainName(value=clean_target) + ssl_certificates = ssl_data.get("sslCertificates") or [ssl_data] + for cert in ssl_certificates: + cert_info = cert.get("certificate_info") or cert + issuer = cert_info.get("issuer_dn") or "Unknown Issuer" + subject = cert_info.get("subject_dn") or clean_target + serial = str(cert_info.get("serial_number", "")) + + cert_stix = stix2.X509Certificate( + issuer=str(issuer), + subject=str(subject), + serial_number=serial, + ) + objects.append(cert_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=target_stix.id, + relationship_type="related-to", + target_ref=cert_stix.id, + ), + source_ref=target_stix.id, + target_ref=cert_stix.id, + relationship_type="related-to", + created_by_ref=self.author.id, + ) + ) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_ip_geolocation_bundle( + self, ip_address: str, geolocation_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not geolocation_data: + return None + + objects: List[Any] = [self.author] + clean_ip = ip_address.strip() + ip_stix = ( + stix2.IPv6Address(value=clean_ip) + if ":" in clean_ip + else stix2.IPv4Address(value=clean_ip) + ) + objects.append(ip_stix) + + loc_data = geolocation_data.get("location") or geolocation_data + country = loc_data.get("country_name") or loc_data.get("country_code") + city = loc_data.get("city") + + if country or city: + location_stix = stix2.Location( + country=country, + city=city, + latitude=( + float(loc_data["latitude"]) if loc_data.get("latitude") else None + ), + longitude=( + float(loc_data["longitude"]) if loc_data.get("longitude") else None + ), + created_by_ref=self.author.id, + ) + objects.append(location_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=ip_stix.id, + relationship_type="located-at", + target_ref=location_stix.id, + ), + source_ref=ip_stix.id, + target_ref=location_stix.id, + relationship_type="located-at", + created_by_ref=self.author.id, + ) + ) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_subdomains_bundle( + self, domain: str, subdomains_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not subdomains_data: + return None + + objects: List[Any] = [self.author] + clean_domain = domain.strip().lower() + domain_stix = stix2.DomainName(value=clean_domain) + objects.append(domain_stix) + + subdomains = subdomains_data.get("subdomains", []) + for subdomain in subdomains: + sub_val = ( + subdomain.get("subdomain") + if isinstance(subdomain, dict) + else str(subdomain) + ) + if sub_val and sub_val.strip(): + sub_stix = stix2.DomainName(value=sub_val.strip().lower()) + objects.append(sub_stix) + objects.append( + stix2.Relationship( + id=pycti.StixCoreRelationship.generate_id( + source_ref=sub_stix.id, + relationship_type="related-to", + target_ref=domain_stix.id, + ), + source_ref=sub_stix.id, + target_ref=domain_stix.id, + relationship_type="related-to", + created_by_ref=self.author.id, + ) + ) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_ip_reputation_bundle( + self, ip_address: str, reputation_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not reputation_data: + return None + + objects: List[Any] = [self.author] + clean_ip = ip_address.strip() + ip_stix = ( + stix2.IPv6Address(value=clean_ip) + if ":" in clean_ip + else stix2.IPv4Address(value=clean_ip) + ) + objects.append(ip_stix) + + sec_data = reputation_data.get("security") or reputation_data + threat_score = sec_data.get("threat_score") or sec_data.get("score") + + if threat_score is not None: + note_stix = stix2.Note( + id=pycti.Note.generate_id( + content=f"Reputation Analysis for IP {clean_ip}: Threat Score: {threat_score}", + object_refs=[ip_stix.id], + ), + abstract=f"WhoisFreaks Threat Score: {threat_score}", + content=f"Reputation Analysis for IP {clean_ip}:\nThreat Score: {threat_score}\nDetails: {sec_data}", + object_refs=[ip_stix.id], + created_by_ref=self.author.id, + ) + objects.append(note_stix) + + return stix2.Bundle(objects=objects, allow_custom=True) + + def build_domain_reputation_bundle( + self, domain: str, reputation_data: Dict[str, Any] + ) -> Optional[stix2.Bundle]: + if not reputation_data: + return None + + objects: List[Any] = [self.author] + clean_dom = domain.strip().lower() + dom_stix = stix2.DomainName(value=clean_dom) + objects.append(dom_stix) + + score = reputation_data.get("reputation_score") or reputation_data.get("score") + if score is not None: + note_stix = stix2.Note( + id=pycti.Note.generate_id( + content=f"Domain Reputation Analysis for {clean_dom}: Score: {score}", + object_refs=[dom_stix.id], + ), + abstract=f"WhoisFreaks Domain Reputation Score: {score}", + content=f"Domain Reputation Analysis for {clean_dom}:\nScore: {score}", + object_refs=[dom_stix.id], + created_by_ref=self.author.id, + ) + objects.append(note_stix) + + return stix2.Bundle(objects=objects, allow_custom=True) + + @staticmethod + def is_privacy_protected(name: str) -> bool: + privacy_indicators = [ + "privacy", + "protected", + "redacted", + "anonymous", + "whoisguard", + "contact privacy", + "data protected", + ] + return any(indicator in name.lower() for indicator in privacy_indicators) + + @staticmethod + def is_ip_address(value: str) -> bool: + import ipaddress + + try: + ipaddress.ip_address(value.strip()) + return True + except ValueError: + return False diff --git a/internal-enrichment/whoisfreaks/src/client.py b/internal-enrichment/whoisfreaks/src/client.py new file mode 100644 index 00000000000..7977b17193d --- /dev/null +++ b/internal-enrichment/whoisfreaks/src/client.py @@ -0,0 +1,252 @@ +import logging +import requests +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class WhoisFreaksClient: + """ + Client for interacting with WhoisFreaks REST APIs. + Docs: https://whoisfreaks.com/documentation + """ + + BASE_URL = "https://api.whoisfreaks.com" + + def __init__(self, api_key: str, timeout: Optional[int] = 30): + self.api_key = api_key + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": "OpenCTI-WhoisFreaks-Connector/1.0", + "Accept": "application/json", + } + ) + + def _post( + self, + endpoint: str, + params: Optional[dict] = None, + body: Optional[dict] = None, + timeout: int = 30, + ) -> Optional[Dict[str, Any]]: + url = f"{self.BASE_URL}{endpoint}" + if params is None: + params = {} + params["apiKey"] = self.api_key + + try: + response = self.session.post(url, params=params, json=body, timeout=timeout) + if response.status_code == 200: + return response.json() + else: + logger.error( + f"[WhoisFreaks API] POST {url} failed HTTP {response.status_code}: {response.text}" + ) + return None + except requests.exceptions.Timeout: + logger.error(f"[WhoisFreaks API] Timeout reaching {url}.") + return None + except requests.RequestException as e: + logger.error(f"[WhoisFreaks API] Error during POST {url}: {e}") + return None + + def _get( + self, endpoint: str, params: Optional[dict] = None, timeout: int = 30 + ) -> Optional[Dict[str, Any]]: + url = f"{self.BASE_URL}{endpoint}" + if params is None: + params = {} + params["apiKey"] = self.api_key + + try: + response = self.session.get(url, params=params, timeout=timeout) + + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + logger.info(f"[WhoisFreaks API] No record found at {endpoint}") + return None + elif response.status_code == 401: + logger.error( + "[WhoisFreaks API] Unauthorized - Check WHOISFREAKS_API_KEY." + ) + return None + elif response.status_code == 429: + logger.error( + "[WhoisFreaks API] Rate limit reached or insufficient credits." + ) + return None + else: + logger.error( + f"[WhoisFreaks API] GET {url} failed HTTP {response.status_code}: {response.text}" + ) + return None + except requests.exceptions.Timeout: + logger.error(f"[WhoisFreaks API] Timeout reaching {url}.") + return None + except requests.RequestException as e: + logger.error(f"[WhoisFreaks API] Error during GET {url}: {e}") + return None + + # --- API ENDPOINT WRAPPERS --- + def live_whois_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v2.0/whois/live", params={"format": format, "domainName": domain} + ) + + def historical_whois_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v2.0/whois", + params={"format": format, "domainName": domain, "whois": "historical"}, + ) + + def reverse_whois_lookup( + self, keyword: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/whois", + params={"format": format, "keyword": keyword, "whois": "reverse"}, + ) + + def bulk_whois_lookup( + self, domains: list[str], format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._post( + "/v2.0/bulkwhois/live", + params={"format": format}, + body={"domainNames": domains}, + ) + + def live_dns_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v2.0/dns/live", + params={"format": format, "domainName": domain, "type": "all"}, + ) + + def historical_dns_lookup( + self, domain: str, format: Optional[str] = "json", page: Optional[int] = 1 + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v2.0/dns/historical", + params={ + "format": format, + "domainName": domain, + "type": "all", + "page": page, + }, + ) + + def reverse_dns_lookup( + self, ip_address: str, format: Optional[str] = "json", page: Optional[int] = 1 + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v2.0/dns/reverse", + params={ + "format": format, + "value": ip_address, + "page": page, + "type": "a", + "exact": True, + }, + ) + + def bulk_dns_lookup( + self, domains: list[str], ipAddresses: list[str], format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._post( + "/v2.0/dns/bulk/live", + params={"format": format, "type": "all"}, + body={"domainNames": domains, "ipAddresses": ipAddresses}, + ) + + def domain_availability_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/domain/availability", + params={"format": format, "domainName": domain, "sug": False}, + ) + + def bulk_domain_availability_lookup( + self, domains: list[str], format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._post( + "/v1.0/domain/availability", + params={"format": format}, + body={"domainNames": domains}, + ) + + def typosquatting_lookup(self, keyword: str) -> Optional[Dict[str, Any]]: + return self._get("/v3.0/domain/typos", params={"keyword": keyword}) + + def ssl_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/ssl/live", + params={ + "format": format, + "domainName": domain, + "chain": True, + "sslRaw": False, + }, + ) + + def ip_geolocation_lookup( + self, ip_address: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/geolocation", params={"format": format, "ip": ip_address} + ) + + def bulk_ip_geolocation_lookup( + self, ip_addresses: list[str] + ) -> Optional[Dict[str, Any]]: + return self._post("/v1.0/geolocation", body={"ips": ip_addresses}) + + def subdomains_lookup( + self, domain: str, format: Optional[str] = "json", page: Optional[int] = 1 + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/subdomains", + params={"format": format, "domain": domain, "page": page}, + ) + + def ip_reputation_lookup(self, ip_address: str) -> Optional[Dict[str, Any]]: + return self._get("/v1.0/security", params={"ip": ip_address}) + + def bulk_ip_reputation_lookup( + self, ip_addresses: list[str] + ) -> Optional[Dict[str, Any]]: + return self._post("/v1.0/security", body={"ips": ip_addresses}) + + def asn_whois_lookup( + self, asn: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get("/v2.0/asn-whois", params={"asn": asn, "format": format}) + + def ip_whois_lookup( + self, ip_address: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get("/v1.0/ip-whois", params={"ip": ip_address, "format": format}) + + def domain_reputation_lookup( + self, domain: str, format: Optional[str] = "json" + ) -> Optional[Dict[str, Any]]: + return self._get( + "/v1.0/domain-reputation", params={"domainName": domain, "format": format} + ) + + def account_usage(self) -> Optional[Dict[str, Any]]: + return self._get("/v1.0/whoisapi/usage") + + def rotate_api_key(self) -> Optional[Dict[str, Any]]: + return self._post("/v1.0/api-key/rotate") diff --git a/internal-enrichment/whoisfreaks/src/configVariables.py b/internal-enrichment/whoisfreaks/src/configVariables.py new file mode 100644 index 00000000000..218683bb80b --- /dev/null +++ b/internal-enrichment/whoisfreaks/src/configVariables.py @@ -0,0 +1,82 @@ +import sys +from pathlib import Path +import yaml +from pycti import get_config_variable + + +class ConfigVariables: + """ + Configuration loader for WhoisFreaks OpenCTI Connector. + Reads settings from config.yml or environment variables. + """ + + def __init__(self): + + config_file_path = Path(__file__).parents[1] / "config.yml" + config = {} + + if config_file_path.is_file(): + with open(config_file_path, "r", encoding="utf-8") as file: + config = yaml.safe_load(file) or {} + + self.opencti_url = get_config_variable( + "OPENCTI_URL", ["opencti", "url"], config + ) + + self.opencti_token = get_config_variable( + "OPENCTI_TOKEN", ["opencti", "token"], config + ) + + self.connector_id = get_config_variable( + "CONNECTOR_ID", ["connector", "id"], config + ) + self.connector_type = get_config_variable( + "CONNECTOR_TYPE", ["connector", "type"], config + ) + self.connector_name = get_config_variable( + "CONNECTOR_NAME", ["connector", "name"], config + ) + self.connector_scope = get_config_variable( + "CONNECTOR_SCOPE", ["connector", "scope"], config + ) + self.connector_confidence_level = get_config_variable( + "CONNECTOR_CONFIDENCE_LEVEL", + ["connector", "confidence_level"], + config, + isNumber=True, + default=100, + ) + self.connector_auto = get_config_variable( + "CONNECTOR_AUTO", ["connector", "auto"], config + ) + # Force log level to uppercase string (e.g. 'INFO') + raw_log_level = get_config_variable( + "CONNECTOR_LOG_LEVEL", ["connector", "log_level"], config, default="INFO" + ) + self.connector_log_level = ( + str(raw_log_level).upper() if raw_log_level else "INFO" + ) + self.whoisfreaks_api_key = get_config_variable( + "WHOISFREAKS_API_KEY", ["whoisfreaks", "api_key"], config + ) + + self._validate() + + def _validate(self) -> None: + """Validates that all required configuration values are present.""" + missing = [] + if not self.opencti_url or self.opencti_url == "ChangeMe": + missing.append("OPENCTI_URL / opencti.url") + if not self.opencti_token or self.opencti_token == "ChangeMe": + missing.append("OPENCTI_TOKEN / opencti.token") + if not self.connector_id or self.connector_id == "ChangeMe": + missing.append("CONNECTOR_ID / connector.id") + if not self.whoisfreaks_api_key or self.whoisfreaks_api_key == "ChangeMe": + missing.append("WHOISFREAKS_API_KEY / whoisfreaks.api_key") + + if missing: + print( + f"[ERROR] Missing or default configuration values for: {', '.join(missing)}" + ) + print("[ERROR] Please update config.yml or pass environment variables.") + sys.exit(1) diff --git a/internal-enrichment/whoisfreaks/src/main.py b/internal-enrichment/whoisfreaks/src/main.py new file mode 100644 index 00000000000..e913f216c6e --- /dev/null +++ b/internal-enrichment/whoisfreaks/src/main.py @@ -0,0 +1,180 @@ +import sys +import logging +from typing import Any, Dict, List, Optional, Tuple +from pycti import OpenCTIConnectorHelper + +from configVariables import ConfigVariables +from client import WhoisFreaksClient +from builder import WhoisFreaksStixBuilder + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + + +class WhoisFreaksConnector: + """ + OpenCTI Internal Enrichment Connector for WhoisFreaks. + Enriches Domain-Name and IPv4/IPv6 Observables with WHOIS, DNS, SSL, and Geolocation data. + """ + + def __init__(self): + logger.info("[WhoisFreaks Connector] Initializing configuration...") + self.config = ConfigVariables() + + logging.getLogger().setLevel(self.config.connector_log_level) + + self.helper = OpenCTIConnectorHelper( + { + "opencti": { + "url": self.config.opencti_url, + "token": self.config.opencti_token, + }, + "connector": { + "name": self.config.connector_name, + "id": self.config.connector_id, + "type": self.config.connector_type, + "scope": self.config.connector_scope, + "auto": self.config.connector_auto, + "log_level": self.config.connector_log_level, + "confidence_level": self.config.connector_confidence_level, + }, + }, + playbook_compatible=True, + ) + + self.client = WhoisFreaksClient(api_key=self.config.whoisfreaks_api_key) + self.builder = WhoisFreaksStixBuilder(author_name="WhoisFreaks") + + def _enrich_domain(self, domain_name: str) -> List[Any]: + """Executes all WhoisFreaks lookups for Domain-Name entities.""" + bundles = [] + + lookups = [ + (self.client.live_whois_lookup, self.builder.build_whois_bundle), + (self.client.live_dns_lookup, self.builder.build_dns_bundle), + (self.client.ssl_lookup, self.builder.build_ssl_bundle), + (self.client.subdomains_lookup, self.builder.build_subdomains_bundle), + ] + + for fetch_fn, build_fn in lookups: + resp = fetch_fn(domain_name) + if resp: + bundle = build_fn(domain_name, resp) + if bundle: + bundles.append(bundle) + + return bundles + + def _enrich_ip(self, ip_address: str) -> List[Any]: + """Executes all WhoisFreaks lookups for IP entities.""" + bundles = [] + + lookups = [ + ( + self.client.ip_geolocation_lookup, + self.builder.build_ip_geolocation_bundle, + ), + (self.client.ip_reputation_lookup, self.builder.build_ip_reputation_bundle), + (self.client.reverse_dns_lookup, self.builder.build_dns_bundle), + ] + + for fetch_fn, build_fn in lookups: + resp = fetch_fn(ip_address) + if resp: + bundle = build_fn(ip_address, resp) + if bundle: + bundles.append(bundle) + + return bundles + + def _get_entity_info(self, entity_id: str) -> Tuple[Optional[str], Optional[str]]: + """Reads entity from OpenCTI and returns entity type and observable value.""" + opencti_entity = self.helper.api.stix_cyber_observable.read(id=entity_id) + if not opencti_entity: + opencti_entity = self.helper.api.stix_domain_object.read(id=entity_id) + + if not opencti_entity: + return None, None + + obs_type = opencti_entity.get("entity_type") + obs_val = ( + opencti_entity.get("observable_value") + or opencti_entity.get("value") + or opencti_entity.get("name") + ) + return obs_type, obs_val + + def process_message(self, msg: Dict[str, Any]) -> str: + """Callback executed whenever an enrichment task is received from RabbitMQ.""" + entity_id = msg.get("entity_id") + observable_type, observable_value = self._get_entity_info(entity_id) + + if not observable_type or not observable_value: + logger.error( + f"[WhoisFreaks Connector] Invalid or missing entity for ID: {entity_id}" + ) + return "Entity or value missing" + + logger.info( + f"[WhoisFreaks Connector] Processing enrichment for {observable_type}: '{observable_value}'" + ) + + work_id = self.helper.api.work.initiate_work( + connector_id=self.config.connector_id, + friendly_name=f"WhoisFreaks enrichment for {observable_value}", + ) + + try: + if observable_type == "Domain-Name": + bundles = self._enrich_domain(observable_value) + elif observable_type in ["IPv4-Addr", "IPv6-Addr"]: + bundles = self._enrich_ip(observable_value) + else: + logger.warning( + f"[WhoisFreaks Connector] Unsupported type: {observable_type}" + ) + self.helper.api.work.to_processed( + work_id, "Unsupported observable type" + ) + return "Unsupported observable type" + + if bundles: + for bundle in bundles: + self.helper.send_stix2_bundle( + bundle=bundle.serialize(), + work_id=work_id, + ) + + message = f"Successfully enriched {observable_value} with {len(bundles)} STIX bundles." + logger.info(f"[WhoisFreaks Connector] {message}") + self.helper.api.work.to_processed(work_id, message) + return message + + message = f"No threat intelligence data found on WhoisFreaks for {observable_value}." + logger.info(f"[WhoisFreaks Connector] {message}") + self.helper.api.work.to_processed(work_id, message) + return message + + except Exception as e: + error_msg = f"Error during processing of {observable_value}: {str(e)}" + logger.exception(f"[WhoisFreaks Connector] {error_msg}") + self.helper.api.work.to_processed(work_id, error_msg, in_error=True) + return error_msg + + def start(self): + """Starts the connector worker and listens to RabbitMQ queue.""" + logger.info("[WhoisFreaks Connector] Starting connector listener loop...") + self.helper.listen(self.process_message) + + +if __name__ == "__main__": + try: + connector = WhoisFreaksConnector() + connector.start() + except Exception as e: + logger.fatal(f"[WhoisFreaks Connector] Unhandled startup failure: {str(e)}") + sys.exit(1) From 4d0bcb3666672678d8afd2a0aeb2b5ee9dfed6ab Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Thu, 23 Jul 2026 14:23:43 +0500 Subject: [PATCH 07/28] fix: fixed PR comments --- internal-enrichment/whoisfreaks/Dockerfile | 16 +++++++++------- internal-enrichment/whoisfreaks/README.md | 2 +- internal-enrichment/whoisfreaks/config.yml | 4 ++-- .../whoisfreaks/docker-compose.yml | 4 ++-- .../whoisfreaks/requirements.txt | 3 +-- .../src/__pycache__/__init__.cpython-312.pyc | Bin 178 -> 186 bytes .../src/__pycache__/builder.cpython-312.pyc | Bin 18078 -> 18446 bytes .../whoisfreaks/src/builder.py | 6 ++++++ 8 files changed, 21 insertions(+), 14 deletions(-) diff --git a/internal-enrichment/whoisfreaks/Dockerfile b/internal-enrichment/whoisfreaks/Dockerfile index 1f12cb792a2..ef0c4281892 100644 --- a/internal-enrichment/whoisfreaks/Dockerfile +++ b/internal-enrichment/whoisfreaks/Dockerfile @@ -1,17 +1,19 @@ -FROM python:3.11-slim +FROM python:3.12-alpine +ENV CONNECTOR_TYPE=INTERNAL_ENRICHMENT # Set working directory inside container WORKDIR /opt/opencti-connector-whoisfreaks # Install system runtime dependencies needed by pycti and stix2 -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - libmagic1 \ - && rm -rf /var/lib/apt/lists/* +# Install system runtime/build dependencies needed by pycti and stix2 +# hadolint ignore=DL3003 +RUN apk update && apk upgrade && \ + apk --no-cache add git build-base libmagic libffi-dev # Install Python package dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt && \ + apk del git build-base # Copy source code and entrypoint COPY src ./src @@ -21,4 +23,4 @@ COPY entrypoint.sh . RUN chmod +x entrypoint.sh # Run startup script -ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/README.md b/internal-enrichment/whoisfreaks/README.md index 2b12c5daf17..0db9bae1034 100644 --- a/internal-enrichment/whoisfreaks/README.md +++ b/internal-enrichment/whoisfreaks/README.md @@ -9,7 +9,7 @@ This connector enables security analysts to enrich `Domain-Name`, `IPv4-Addr`, a ## Features - **WHOIS Registration Intelligence**: Fetches live WHOIS records and converts Registrars and Registrants into STIX 2.1 `Identity` SDOs linked via `registered-by` and `owned-by` relationships. -- **DNS Record Mapping**: Parses live DNS responses (A, AAAA, MX, NS, CNAME, TXT) and maps resolutions into the OpenCTI graph using `resolves-to` and `related-to` relationships. +- **DNS Record Mapping**: Parses live DNS responses (A, AAAA, MX, NS, CNAME) and maps resolutions into the OpenCTI graph using `resolves-to` and `related-to` relationships. - **Subdomain Discovery**: Maps parent-child domain hierarchies and populates subdomains as related `Domain-Name` observables. - **SSL/TLS Certificate Metadata**: Extracts X.509 certificate metadata and attaches `X509-Certificate` observables to target domains. - **IP Geolocation & Reverse DNS**: Translates IPv4/IPv6 observables into STIX `Location` SDOs (`located-at`) and maps reverse DNS domain associations. diff --git a/internal-enrichment/whoisfreaks/config.yml b/internal-enrichment/whoisfreaks/config.yml index e02c553e32b..0d7188288ee 100644 --- a/internal-enrichment/whoisfreaks/config.yml +++ b/internal-enrichment/whoisfreaks/config.yml @@ -6,10 +6,10 @@ connector: id: "ChangeMe" # Must be a valid UUIDv4 (e.g. generated via `uuidgen`) type: "INTERNAL_ENRICHMENT" name: "WhoisFreaks" - scope: "Domain-Name,IPv4-Addr" + scope: "Domain-Name,IPv4-Addr,IPv6-Addr" auto: false # Manual trigger recommended to manage API quota confidence_level: 100 log_level: "INFO" whoisfreaks: - api_key: "9c0189f5a7a8457d88fdf22c91b4d9f1" + api_key: "ChangeMe" diff --git a/internal-enrichment/whoisfreaks/docker-compose.yml b/internal-enrichment/whoisfreaks/docker-compose.yml index 243d6a4f5f9..4c51147d978 100644 --- a/internal-enrichment/whoisfreaks/docker-compose.yml +++ b/internal-enrichment/whoisfreaks/docker-compose.yml @@ -17,8 +17,8 @@ services: image: minio/minio:RELEASE.2024-02-17T01-15-57Z command: server /data environment: - - MINIO_ROOT_USER=opencti - - MINIO_ROOT_PASSWORD=opencti_minio_password + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-opencti} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD} restart: always rabbitmq: diff --git a/internal-enrichment/whoisfreaks/requirements.txt b/internal-enrichment/whoisfreaks/requirements.txt index f25151a062e..aca3b476cbc 100644 --- a/internal-enrichment/whoisfreaks/requirements.txt +++ b/internal-enrichment/whoisfreaks/requirements.txt @@ -1,5 +1,4 @@ pycti==6.0.5 stix2>=3.0.1 requests>=2.31.0 -pyyaml>=6.0.1 -python-dotenv>=1.0.0 \ No newline at end of file +pyyaml>=6.0.1 \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/__init__.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/__init__.cpython-312.pyc index f23267e81a2c31bfae35d4e5698f79067b4ba17a..87a2bad50e3393d68c8bd4dda83da47b902df174 100644 GIT binary patch delta 86 zcmdnQxQmhdG%qg~0}vcMmN=2y*gjG}BR@A)zo0_jCAB!aB)>qvJR?7|SUjREC^I=DH#M(Be`2XE0Odv=?*IS* delta 78 zcmdnRxQUVbG%qg~0}wp)jGxGDY#ykek)NBYUr?d%l3JWyl3$=-o?n!$U!IYlS*)L5 akeZiVk_n@Alk@ZPQj<&ai*zT}+5!OD${Ria diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc index 4d25c152362fb33fc20ffe09cad477abed37bc5d..16b631068a86ae6e7fbb825a8b26eddbba0e632e 100644 GIT binary patch delta 1388 zcmZvbT})eL7{~jzrJR19!VX_$Kw*bbTiYEbpaV;h?~9GjWF}kGtu#GnP}ZK)oO6gU zI!ugNU}2-=NygZ8xJ?v8leOs$H|i1-FWe2&8EZ3=T^M83i-ze97sm6pb^)_DIlnyb z|NNil{l87m?W^co9FY&rW`jyuzkC;%uz6GDMeXr3%(xKYnCKKUz)2TmAT4lzN2=eeLPh!PXxWlrP+VK>K%q2PFgrHP@4KBe^=2 zxnj{b@4M0c*yMndO-<=V#;dh0q8J7{Mz+EiJaAig|RVC6bYRFTMYcQiq#5s2~R{|C&QdqdW@P5ku$a8creku!3lyuUdy~kJ@PmfO@pudq*u`uu4o7!Gx1rIkvnI>OPAz<2Yfq;C9a#2E>&!?vACQ=P%ESKxD` zV=AN@rLJUu`58@(X0Cqbgq+ERF99Gf^ z%nU)aZjULBGFxySRgZ+UI^yqW^LYg?e--BH4$|AD+lZ9IOQ#-=uGd;;YMkSRDf{_d z)7s)mZShdc8?EheblTH0>1lb@*7e%6H=~d-xfxmz@duKy5$|1@79;k&y2K-d=z`d$1Jc Date: Thu, 23 Jul 2026 14:43:10 +0500 Subject: [PATCH 08/28] fix: fixed PR comments --- .../src/__pycache__/builder.cpython-312.pyc | Bin 18446 -> 18394 bytes .../src/__pycache__/client.cpython-312.pyc | Bin 12290 -> 12364 bytes .../configVariables.cpython-312.pyc | Bin 3732 -> 3806 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 9556 -> 9630 bytes .../whoisfreaks/src/builder.py | 4 +- .../whoisfreaks/tests/conftest.py | 7 + .../whoisfreaks/tests/test_builder.py | 250 ++++++++++++++++++ .../whoisfreaks/tests/test_client.py | 186 +++++++++++++ .../whoisfreaks/tests/test_connector.py | 167 ++++++++++++ 9 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/tests/conftest.py create mode 100644 internal-enrichment/whoisfreaks/tests/test_builder.py create mode 100644 internal-enrichment/whoisfreaks/tests/test_client.py create mode 100644 internal-enrichment/whoisfreaks/tests/test_connector.py diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc index 16b631068a86ae6e7fbb825a8b26eddbba0e632e..56a37692778cacf978c177bf3b8336e569146617 100644 GIT binary patch delta 308 zcmeC1z<8^lk@qw&FBbz4)Zb6cV%x~8ZO#&c=r3OD2|cWCU8y5JmqAtd2KaN-4-q{-Sgb=;gloyV{0a;l+L*MnuQ9ZaDHA%YF^1L?);>z)Z~)*qSUnF$(&X`oJ)a57A*r3 zlhdqTGa5`jZz?%C+j_FJoaGLU9a0yZgD-?6TnJ9QAd_@MQDeE(La7BjlZ9>SxH*7Y zn1T4S4%6ffnog4ycx5L4vYE}?T*5wCPuq_pg$3*w*2w~Pa+|-|7BVufoSb3r!MJ+z zM*BjhKMa#Em`Y5xambdEv)z%oBl-mAg`n^Yfe{yEB5x>aE|*>?y?}S~Zig6V#=V;* uT$mZz)_~l(akHYU7nAS-Ao~_uPJViNYEco$Aw|a~*SSkDo}N72-5CHpKVuvK diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc index d36f3a1c10ad2ffc8db3d634d8fae9212cbbc60c..2ee65832452c939ac31a150f338d9ff510b49dfc 100644 GIT binary patch delta 101 zcmZolIFrDAnwOW00SFErOWerq!tNcepOK%Ns$Wo{?~+=aU6Nm*U!IYlS*)L5keZiV zlBu7ZpO=@KT#{c@te=@zl3J9Pn4_DTSCpBYk(-)V0#Te+l$w}btiL&ceUk Date: Mon, 27 Jul 2026 12:31:57 +0500 Subject: [PATCH 09/28] fix: pr comments --- .../__metadata__/connector_config_schema.json | 19 ++ .../__metadata__/connector_manifest.json | 7 +- .../src/__pycache__/builder.cpython-312.pyc | Bin 18394 -> 18394 bytes .../src/__pycache__/client.cpython-312.pyc | Bin 12364 -> 12364 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 9630 -> 9630 bytes .../whoisfreaks/src/builder.py | 3 +- internal-enrichment/whoisfreaks/src/client.py | 3 +- .../whoisfreaks/src/configVariables.py | 1 + internal-enrichment/whoisfreaks/src/main.py | 8 +- .../whoisfreaks/tests/test_client.py | 199 +++++++++++++++--- .../whoisfreaks/tests/test_connector.py | 98 ++++++--- 11 files changed, 264 insertions(+), 74 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json new file mode 100644 index 00000000000..aa0e06d599b --- /dev/null +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WhoisFreaks connector configuration schema", + "type": "object", + "properties": { + "whoisfreaks_api_key": { + "type": "string", + "description": "API key for accessing the WhoisFreaks service", + "minLength": 1 + }, + "whoisfreaks_url": { + "type": "string", + "description": "Optional base URL for the WhoisFreaks API", + "format": "uri" + } + }, + "required": ["whoisfreaks_api_key"], + "additionalProperties": false +} diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json index ab0aaad8e62..8fa60171a7d 100644 --- a/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json @@ -5,8 +5,13 @@ "short_description": "Enrich domain, IP, and SSL observables with live WHOIS, DNS, Geolocation, and threat intelligence from WhoisFreaks.", "logo": "https://whoisfreaks.com/images/logo.png", "use_cases": [ - "Enrichment & Analysis" + "Infrastructure & Attack Surface Visibility" ], + "solution_categories": [ + "Enrichment & Reputation" + ], + "contact": null, + "license_type": "Commercial", "verified": false, "last_verified_date": null, "playbook_supported": true, diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc index 56a37692778cacf978c177bf3b8336e569146617..aec51136306cd78dd837e211cda6c1c2c9225c11 100644 GIT binary patch delta 188 zcmV;t07L)Uj{(|`0S(Iy4GI7N005K*XKDwL4JsW1YyoWoS^{kYS_5qaS_EwcS_J_B zWB_CVYzA5eu~fJO0S~hc1&s>;VQ!0001UI+LIfBD1d&3<3cev%C})3jvR_ z(jWc@0nD?}C&mW>`?HuYF$MuJv&uDs1p!sFO*_y80d}(3bR>7-~p39Mbb1&s>;VQz0001UI+LIfBD1d&3<3cdv%C})3jvO^ z(jWc@0n4+|C&mW>`m>lXF$MuIv&uDs1p!pEO*_y80d=z;K!5@Po0FeHjRBCeEkh*% t0iCl`L3A0&6-~k145dZ)HlQ~BrlV3+n3EKb+-vBJr04$TVMXZ39{ZZ3J2b0RUtGWC3gi zS_P3%MG6B!Zh6`SL}_Dmv7asn0THv#2L%KH9JAC5_XPnsv&<2W0s&u>K_L~Ba1`|c zW|Mvv8k2w({Q+*Xeij`80d|v|7ybc%vzr(w0Re}z#2GdL0hF`*8ZrR^qO&d>8UX>T zvq>ES0RgjH)vAG$0HC0nDR8Aprr?lR_fz0oao=Bk}>@lO-hH0ppVd zCG`R5llmp$0qv9ACj0^NlR+UBlkO+F0r``uD7^vvlV>TJ1_S^A0CEG91}c{U3A3Im e9R-uXFBbxG43pF^ObOorCeZ*U;{XqnDKI0ycV1Zl delta 299 zcmX?;a3+EGG%qg~0}vcMmY8*OBCnPtV-8~!V+unGQw~!ua};wfOB72kYZNOZ!^8kr zE*6)}ANjh_|m_ zKQApaJuI;(GchSAwRo}=lho!iraD$euE}57?HRczn{rHM6m Date: Mon, 27 Jul 2026 12:32:11 +0500 Subject: [PATCH 10/28] fix: pr comments --- .../comlaude/src/comlaude/__init__.py | 6 ++-- .../tests/test_converter_to_stix.py | 18 +++++++---- .../src/import-external-reference.py | 12 +++++--- .../tests/test_e2e_keydoor.py | 14 ++++++--- .../tests/test_e2e_network_iocs.py | 14 ++++++--- .../tests/test_e2e_opencti.py | 20 +++++++++---- .../tests/test_e2e_sandbox.py | 7 +++-- .../src/connector/connector.py | 6 ++-- .../reversinglabs-spectra-analyze/src/main.py | 30 ++++++++++++------- .../src/connector/connector.py | 18 +++++++---- .../src/import_doc_ai/client_api.py | 11 +++++-- .../test_linter_stix_generator.py | 24 ++++++++++----- 12 files changed, 124 insertions(+), 56 deletions(-) diff --git a/external-import/comlaude/src/comlaude/__init__.py b/external-import/comlaude/src/comlaude/__init__.py index b9b58517178..a1679091b1e 100644 --- a/external-import/comlaude/src/comlaude/__init__.py +++ b/external-import/comlaude/src/comlaude/__init__.py @@ -243,6 +243,8 @@ def _response_error(message, response): else: error_message = json.loads(response.text) - raise Exception(f"""Message:{message}. + raise Exception( + f"""Message:{message}. Response code returned:{response.status_code}. - Error message returned:{error_message}.""") + Error message returned:{error_message}.""" + ) diff --git a/external-import/tenable-vuln-management/tests/test_converter_to_stix.py b/external-import/tenable-vuln-management/tests/test_converter_to_stix.py index 01a9b2db43e..a8004cdd7e7 100644 --- a/external-import/tenable-vuln-management/tests/test_converter_to_stix.py +++ b/external-import/tenable-vuln-management/tests/test_converter_to_stix.py @@ -43,7 +43,8 @@ def mock_config(): @pytest.fixture def fake_asset(): - return Asset.model_validate_json(""" + return Asset.model_validate_json( + """ { "device_type": "general-purpose", "fqdn": "sharepoint2016.target.example.com", @@ -59,12 +60,14 @@ def fake_asset(): "tracked": true, "last_scan_target": "192.0.0.1" } - """) + """ + ) @pytest.fixture def fake_plugin(): - return Plugin.model_validate_json(""" + return Plugin.model_validate_json( + """ { "bid": [ 156641 @@ -190,12 +193,14 @@ def fake_plugin(): ], "type": "local" } - """) + """ + ) @pytest.fixture def fake_vuln_finding(): - return VulnerabilityFinding.model_validate_json("""{ + return VulnerabilityFinding.model_validate_json( + """{ "asset": { "device_type": "hypervisor", "fqdn": "vcsa8.target.example.com", @@ -270,7 +275,8 @@ def fake_vuln_finding(): "indexed": "2023-05-04T09:44:55.673359Z", "source": "NESSUS" } - """) + """ + ) def test_tlp_marking_definition_handler_should_fails_with_unsupported_TLP(): diff --git a/internal-enrichment/import-external-reference/src/import-external-reference.py b/internal-enrichment/import-external-reference/src/import-external-reference.py index 42e7df78aa9..0256b04ca81 100644 --- a/internal-enrichment/import-external-reference/src/import-external-reference.py +++ b/internal-enrichment/import-external-reference/src/import-external-reference.py @@ -275,7 +275,8 @@ async def _dismiss_cookies(self, page: Page) -> bool: # Fallback: try to hide/remove banners/popups via JS if nothing was clicked if not found: try: - await page.evaluate(""" + await page.evaluate( + """ const keywords = ["cookie", "consent", "banner", "popup", "gdpr", "privacy", "notice", "modal", "alert", "dialog"]; let removed = false; for (const kw of keywords) { @@ -290,7 +291,8 @@ async def _dismiss_cookies(self, page: Page) -> bool: } } return removed; - """) + """ + ) self.helper.log_debug("Ran JS fallback to hide banners/popups.") except Exception as e: self.helper.log_debug(f"JS fallback for hiding banners failed: {e}") @@ -310,13 +312,15 @@ async def _browser_worker(self): viewport={"width": 1280, "height": 800}, extra_http_headers=self.headers, ) - await ctx.add_init_script(""" + await ctx.add_init_script( + """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); Object.defineProperty(navigator, 'platform', { get: () => 'Win32' }); Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); window.chrome = { runtime: {}, loadTimes: () => { return null; }, csi: () => { return {}; } }; - """) + """ + ) page = await ctx.new_page() status_code = None diff --git a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_keydoor.py b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_keydoor.py index 4e6346ffd38..66deddaf993 100644 --- a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_keydoor.py +++ b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_keydoor.py @@ -120,7 +120,8 @@ def delete_observable(sha256: str): # Query helpers # --------------------------------------------------------------------------- def get_observable_by_hash(sha256: str) -> dict | None: - query = """ + query = ( + """ { stixCyberObservables( filters: { @@ -141,14 +142,17 @@ def get_observable_by_hash(sha256: str) -> dict | None: } } } - """ % sha256 + """ + % sha256 + ) result = graphql(query) edges = result["stixCyberObservables"]["edges"] return edges[0]["node"] if edges else None def get_notes_for_observable(observable_id: str) -> list[dict]: - query = """ + query = ( + """ { notes( filters: { @@ -160,7 +164,9 @@ def get_notes_for_observable(observable_id: str) -> list[dict]: edges { node { id attribute_abstract content } } } } - """ % observable_id + """ + % observable_id + ) result = graphql(query) return [e["node"] for e in result["notes"]["edges"]] diff --git a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_network_iocs.py b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_network_iocs.py index bd51a1a3d29..3f1a264b087 100644 --- a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_network_iocs.py +++ b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_network_iocs.py @@ -132,7 +132,8 @@ def create_observable_via_pycti(sha256: str, description: str = "") -> dict: # --------------------------------------------------------------------------- def get_observable_by_hash(sha256: str) -> dict | None: """Look up a StixFile observable by SHA-256 hash.""" - query = """ + query = ( + """ { stixCyberObservables( filters: { @@ -153,7 +154,9 @@ def get_observable_by_hash(sha256: str) -> dict | None: } } } - """ % sha256 + """ + % sha256 + ) result = graphql(query) edges = result["stixCyberObservables"]["edges"] return edges[0]["node"] if edges else None @@ -161,7 +164,8 @@ def get_observable_by_hash(sha256: str) -> dict | None: def get_notes_for_observable(observable_id: str) -> list[dict]: """Get Notes linked to an observable.""" - query = """ + query = ( + """ { notes( filters: { @@ -173,7 +177,9 @@ def get_notes_for_observable(observable_id: str) -> list[dict]: edges { node { id attribute_abstract content } } } } - """ % observable_id + """ + % observable_id + ) result = graphql(query) return [e["node"] for e in result["notes"]["edges"]] diff --git a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_opencti.py b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_opencti.py index b355fb2ad64..2ec5f4b50ea 100644 --- a/internal-enrichment/polyswarm-enrichment/tests/test_e2e_opencti.py +++ b/internal-enrichment/polyswarm-enrichment/tests/test_e2e_opencti.py @@ -118,7 +118,8 @@ def delete_observable(internal_id: str): # --------------------------------------------------------------------------- def get_observable_by_hash(sha256: str) -> dict | None: """Look up a StixFile observable by SHA-256 hash.""" - query = """ + query = ( + """ { stixCyberObservables( filters: { @@ -143,7 +144,9 @@ def get_observable_by_hash(sha256: str) -> dict | None: } } } - """ % sha256 + """ + % sha256 + ) result = graphql(query) edges = result["stixCyberObservables"]["edges"] return edges[0]["node"] if edges else None @@ -151,7 +154,8 @@ def get_observable_by_hash(sha256: str) -> dict | None: def get_notes_for_observable(observable_id: str) -> list[dict]: """Get Notes linked to an observable.""" - query = """ + query = ( + """ { notes( filters: { @@ -169,7 +173,9 @@ def get_notes_for_observable(observable_id: str) -> list[dict]: } } } - """ % observable_id + """ + % observable_id + ) result = graphql(query) return [e["node"] for e in result["notes"]["edges"]] @@ -370,11 +376,13 @@ def feeder_enriched(): """Wait for any feeder-created observable to be enriched.""" deadline = time.time() + ENRICHMENT_TIMEOUT while time.time() < deadline: - result = graphql(""" + result = graphql( + """ { stixCyberObservables(first: 20, orderBy: created_at, orderMode: desc) { edges { node { id observable_value x_opencti_score } } } } - """) + """ + ) for edge in result["stixCyberObservables"]["edges"]: node = edge["node"] if node["x_opencti_score"] and node["x_opencti_score"] > 50: diff --git a/internal-enrichment/polyswarm-sandbox/tests/test_e2e_sandbox.py b/internal-enrichment/polyswarm-sandbox/tests/test_e2e_sandbox.py index b327d55d074..74f935b1265 100644 --- a/internal-enrichment/polyswarm-sandbox/tests/test_e2e_sandbox.py +++ b/internal-enrichment/polyswarm-sandbox/tests/test_e2e_sandbox.py @@ -159,7 +159,8 @@ def get_artifact_by_id(artifact_id: str) -> dict | None: def get_notes_for_observable(observable_id: str) -> list[dict]: - query = """ + query = ( + """ { notes( filters: { @@ -177,7 +178,9 @@ def get_notes_for_observable(observable_id: str) -> list[dict]: } } } - """ % observable_id + """ + % observable_id + ) result = graphql(query) return [e["node"] for e in result["notes"]["edges"]] diff --git a/internal-enrichment/reversinglabs-malware-presence/src/connector/connector.py b/internal-enrichment/reversinglabs-malware-presence/src/connector/connector.py index d41fdae2c8a..a288281ddda 100644 --- a/internal-enrichment/reversinglabs-malware-presence/src/connector/connector.py +++ b/internal-enrichment/reversinglabs-malware-presence/src/connector/connector.py @@ -157,7 +157,8 @@ def _generate_stix_note(self, data): scanner_percent = data["rl"]["malware_presence"]["scanner_percent"] abstract = "ReversingLabs Spectra Intelligence Results" - content = textwrap.dedent(f""" + content = textwrap.dedent( + f""" ## ReversingLabs Spectra Intelligence Results Detected hash {hash} is **{status}** | Scanner | | @@ -165,7 +166,8 @@ def _generate_stix_note(self, data): | Count | {scanner_count} | | Match | {scanner_match} | | (%) | {scanner_percent} | - """) + """ + ) # Create Note stix_note = stix2.Note( diff --git a/internal-enrichment/reversinglabs-spectra-analyze/src/main.py b/internal-enrichment/reversinglabs-spectra-analyze/src/main.py index bfdcc8210fb..4ec41bcb755 100644 --- a/internal-enrichment/reversinglabs-spectra-analyze/src/main.py +++ b/internal-enrichment/reversinglabs-spectra-analyze/src/main.py @@ -678,7 +678,8 @@ def _ip_report(self): dl_files_statistics = resp_json.get("downloaded_files_statistics", {}) abstract = "ReversingLabs Spectra Analyze IP address report" - content = textwrap.dedent(f""" + content = textwrap.dedent( + f""" ## ReversingLabs Spectra Analyze IP address report for {self.ip_sample} Third party statistics | Status | Amount | @@ -697,7 +698,8 @@ def _ip_report(self): | SUSPICIOUS | {dl_files_statistics.get("suspicious")} | | UNKNOWN | {dl_files_statistics.get("unknown")} | | TOTAL | {dl_files_statistics.get("total")} | - """) + """ + ) note = stix2.Note( id=Note.generate_id(None, content), @@ -842,11 +844,13 @@ def _domain_reports(self, domain_list): if selected_domains: abstract = "ReversingLabs Spectra Analyze domain statistics" - accumulated_content = textwrap.dedent(""" + accumulated_content = textwrap.dedent( + """ ## ReversingLabs Spectra Analyze domain statistics | Domain | Third party statistics Malicious/Total | Downloaded files statistics Malicious/Total | | ------------- | --------------- | --------------- | - """) + """ + ) for one_domain in selected_domains: domain_name = one_domain.get("requested_domain") @@ -981,11 +985,13 @@ def _url_reports(self, url_list): if selected_urls: abstract = "ReversingLabs Spectra Analyze URL statistics" - accumulated_content = textwrap.dedent(""" + accumulated_content = textwrap.dedent( + """ ## ReversingLabs Spectra Analyze URL statistics | URL | Third party statistics Malicious/Total | Analysis statistics Malicious/Total | | ------------- | --------------- | --------------- | - """) + """ + ) for one_url in selected_urls: url_name = one_url.get("requested_url") @@ -1107,7 +1113,8 @@ def _domain_report(self): ) dl_files_statistics = resp_json.get("downloaded_files_statistics", {}) - content = textwrap.dedent(f""" + content = textwrap.dedent( + f""" ## ReversingLabs Spectra Analyze domain report for `{self.domain_sample}` Third party statistics | Status | Amount | @@ -1126,7 +1133,8 @@ def _domain_report(self): | SUSPICIOUS | {dl_files_statistics.get("suspicious")} | | UNKNOWN | {dl_files_statistics.get("unknown")} | | TOTAL | {dl_files_statistics.get("total")} | - """) + """ + ) note = stix2.Note( id=Note.generate_id(None, content), @@ -1226,7 +1234,8 @@ def _url_report(self): analysis_stats = resp_json.get("analysis", {}).get("statistics", {}) tp_stats = resp_json.get("third_party_reputations", {}).get("statistics", {}) - content = textwrap.dedent(f""" + content = textwrap.dedent( + f""" ## ReversingLabs Spectra Analyze URL report for `{self.url_sample}` Third party statistics @@ -1246,7 +1255,8 @@ def _url_report(self): | SUSPICIOUS | {analysis_stats.get("suspicious")} | | UNKNOWN | {analysis_stats.get("unknown")} | | TOTAL | {analysis_stats.get("total")} | - """) + """ + ) note = stix2.Note( id=Note.generate_id(None, content), diff --git a/internal-enrichment/reversinglabs-spectra-intel-submission/src/connector/connector.py b/internal-enrichment/reversinglabs-spectra-intel-submission/src/connector/connector.py index bc1d9dac6fb..92d8f28d1e0 100644 --- a/internal-enrichment/reversinglabs-spectra-intel-submission/src/connector/connector.py +++ b/internal-enrichment/reversinglabs-spectra-intel-submission/src/connector/connector.py @@ -192,7 +192,8 @@ def _generate_stix_note(self, results): analysis_duration = results["analysis_duration"] abstract = "ReversingLabs Spectra Sandbox Results" - analysis_content = textwrap.dedent(f""" + analysis_content = textwrap.dedent( + f""" # ReversingLabs Spectra Sandbox Analysis metadata Classification: **{classification}** @@ -207,7 +208,8 @@ def _generate_stix_note(self, results): Analysis is executed on **{platform}** operating system with following configuration: **{configuration}** - """) + """ + ) signature_text_header = """ @@ -215,10 +217,12 @@ def _generate_stix_note(self, results): """ - signature_text_content = textwrap.dedent("""\ + signature_text_content = textwrap.dedent( + """\ | Description | Risk Factor | |-----------------|-------------------| - """) + """ + ) signatures_list = results["signatures"] sorted_signatures = sorted( signatures_list, key=lambda x: x["risk_factor"], reverse=True @@ -227,9 +231,11 @@ def _generate_stix_note(self, results): for sig in sorted_signatures: sig_description = sig["description"] sig_risk_factor = sig["risk_factor"] - signature_text_content += textwrap.dedent(f"""\ + signature_text_content += textwrap.dedent( + f"""\ | {sig_description} | {sig_risk_factor} | - """) + """ + ) signature_text_header = textwrap.dedent(signature_text_header) signature_text = signature_text_header + signature_text_content diff --git a/internal-import-file/import-document-ai/src/import_doc_ai/client_api.py b/internal-import-file/import-document-ai/src/import_doc_ai/client_api.py index ac7724917ac..531ef9677d9 100644 --- a/internal-import-file/import-document-ai/src/import_doc_ai/client_api.py +++ b/internal-import-file/import-document-ai/src/import_doc_ai/client_api.py @@ -17,13 +17,20 @@ def __init__(self, helper, config: ConfigConnector): self.helper = helper self.config = config - self._opencti_instance_id = self.helper.api.query(""" + self._opencti_instance_id = ( + self.helper.api.query( + """ query SettingsQuery { settings { id } } - """).get("data", {}).get("settings", {}).get("id", "") + """ + ) + .get("data", {}) + .get("settings", {}) + .get("id", "") + ) # Define headers in session for legacy direct mode headers = {} diff --git a/shared/pylint_plugins/tests/test_check_stix_plugin/test_linter_stix_generator.py b/shared/pylint_plugins/tests/test_check_stix_plugin/test_linter_stix_generator.py index 3503c08c5b8..a92f1bf4841 100644 --- a/shared/pylint_plugins/tests/test_check_stix_plugin/test_linter_stix_generator.py +++ b/shared/pylint_plugins/tests/test_check_stix_plugin/test_linter_stix_generator.py @@ -93,7 +93,8 @@ def test_find_constructor_calls_should_detect_constructor_call(example_code): def test_find_constructor_calls_should_detect_multiple_constructor_calls(): # Given a Python script several calls to a STIX2 Domain Object constructors - module = parse(""" + module = parse( + """ import stix2 loc = stix2.Location( id=generate_id("name"), @@ -102,7 +103,8 @@ def test_find_constructor_calls_should_detect_multiple_constructor_calls(): author = stix2.Identity( name="author" ) - """) + """ + ) # When find_constructor_calls is called calls = list(find_constructor_calls(module, ["Location", "Identity"], "stix2")) @@ -150,10 +152,12 @@ def test_StixIdGeneratorChecker_should_raise_message_when_no_given_id_in_stix2_d self, ): # Given: A STIX2 domain object constructor call without an 'id' argument - call_node = extract_node(""" + call_node = extract_node( + """ from stix2 import Location loc = Location(name="example") #@ - """) + """ + ) # When: the checker visits the constructor call node with self.assertAddsMessages( @@ -172,12 +176,14 @@ def test_StixIdGeneratorChecker_should_not_raise_message_when_no_stix2_domain_ob self, ): # Given: A Python script with no STIX2 domain object constructor call - call_node = extract_node(""" + call_node = extract_node( + """ class Location: def __init__(self): pass loc = Location() #@ - """) + """ + ) # When: the checker visits the method without constructor calls with self.assertNoMessages(): @@ -187,10 +193,12 @@ def test_StixIdGeneratorChecker_should_not_raise_message_when_given_in_stix2_dom self, ): # Given: A STIX2 domain object constructor call with an 'id' argument - call_node = extract_node(""" + call_node = extract_node( + """ from stix2 import Location loc = Location(id=generate_id("example"), name="example") #@ - """) + """ + ) # When: the checker visits the constructor call node with an id argument # Then: No message is added From 3a19598248bc6056991fff46855feb901e592a7b Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 12:44:39 +0500 Subject: [PATCH 11/28] fix(whoisfreaks): update metadata, add config schema, and fix tests --- internal-enrichment/whoisfreaks/tests/test_connector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-enrichment/whoisfreaks/tests/test_connector.py b/internal-enrichment/whoisfreaks/tests/test_connector.py index ad06295e221..6d8458d989d 100644 --- a/internal-enrichment/whoisfreaks/tests/test_connector.py +++ b/internal-enrichment/whoisfreaks/tests/test_connector.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import main import pytest From d3d9cbb36fb23fa2f83004976c995ce548b54dd9 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 13:03:11 +0500 Subject: [PATCH 12/28] fix(whoisfreaks): address PR review comments for imports, timeout handling, and test deps --- internal-enrichment/whoisfreaks/src/client.py | 8 ++++++-- .../whoisfreaks/tests/test-requirements.txt | 3 +++ internal-enrichment/whoisfreaks/tests/test_builder.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/tests/test-requirements.txt diff --git a/internal-enrichment/whoisfreaks/src/client.py b/internal-enrichment/whoisfreaks/src/client.py index d0cf79c167f..e1807b9da5c 100644 --- a/internal-enrichment/whoisfreaks/src/client.py +++ b/internal-enrichment/whoisfreaks/src/client.py @@ -30,12 +30,14 @@ def _post( endpoint: str, params: Optional[dict] = None, body: Optional[dict] = None, - timeout: int = 30, + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: url = f"{self.BASE_URL}{endpoint}" if params is None: params = {} params["apiKey"] = self.api_key + if timeout is None: + timeout = self.timeout try: response = self.session.post(url, params=params, json=body, timeout=timeout) @@ -54,12 +56,14 @@ def _post( return None def _get( - self, endpoint: str, params: Optional[dict] = None, timeout: int = 30 + self, endpoint: str, params: Optional[dict] = None, timeout: Optional[int] = None ) -> Optional[Dict[str, Any]]: url = f"{self.BASE_URL}{endpoint}" if params is None: params = {} params["apiKey"] = self.api_key + if timeout is None: + timeout = self.timeout try: response = self.session.get(url, params=params, timeout=timeout) diff --git a/internal-enrichment/whoisfreaks/tests/test-requirements.txt b/internal-enrichment/whoisfreaks/tests/test-requirements.txt new file mode 100644 index 00000000000..19740d26d95 --- /dev/null +++ b/internal-enrichment/whoisfreaks/tests/test-requirements.txt @@ -0,0 +1,3 @@ +-r ../requirements.txt +pytest==9.0.3 +requests-mock diff --git a/internal-enrichment/whoisfreaks/tests/test_builder.py b/internal-enrichment/whoisfreaks/tests/test_builder.py index 4240d8f206e..69a6dab7395 100644 --- a/internal-enrichment/whoisfreaks/tests/test_builder.py +++ b/internal-enrichment/whoisfreaks/tests/test_builder.py @@ -1,6 +1,6 @@ import pytest import stix2 -from whoisfreaks.src.builder import WhoisFreaksStixBuilder +from builder import WhoisFreaksStixBuilder @pytest.fixture From f8fe4f6e6bb47ee1ea71f3aaa14a7cff4b08a0e1 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 13:17:24 +0500 Subject: [PATCH 13/28] fix: inlcuded missing files --- .gitignore | 2 ++ .../src/__pycache__/client.cpython-312.pyc | Bin 12364 -> 12470 bytes .../configVariables.cpython-312.pyc | Bin 3806 -> 3806 bytes 3 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 85953837321..00f6da35382 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ Pipfile* venv* venv_* .temp_venv +.temp_venv_run +.temp_venv_review black *.key *.crt diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc index a3fffed8848f74379f5b085bbe50c2cd87d5f03c..49176d5c990322433d9e9cc34b1026df1776ec6e 100644 GIT binary patch delta 1031 zcmaJ;TS!xJ82^7eXUCnhopbc;Y|gn$(@k9$$svm(6-^DpMs#}+%ECdhBy3ukER%Z3 z=*95`4Gb%SD0+xEP>=;)L8u^~(hyN7f{2=iJ)}nGY+5MQ|Ka!jzU%+HygK&exbTwa zIikMY%oCB8ytwcfy5j@;P@)}J01j2Ulm=L2U^6;-FLoFTgE6|x6eAH@%Ub&`&@53s z%v!6aMOf@N_%X_u@j1^@OoYcV-l1y~uupJeIm>A-rk8}77=xVH2x-SOYr|V&9+i;_ zbm4aHwIsYVR8=0RX*{&QK59@)M_Du*Mh& zGFTE&amX92HdDh6NyFmYYJnON^eS(nvC<&x1PWdJo?wjn=X}(E=DWlfuFPiyZ92E6 zabfyYk=kR<%A_8_s+r|5lQLV|gwE`#0MvBLr`YZqWf`eJ^-Lv>E?oI1spOo>?EI%!(o4 z#i&OuxO$ImS=X5zs@{4I$FtTSMbSwWP^#>#wvfTCg(?o&yc(8iIGh(yal{g=rKwR~ z($Jk-Q$dYZ=xb>_YyJRDSn07~i@X34?6lgk%d!knrPJyJ=)jxVKjE^{Ybye{ri|nS z0TRlhJpgb|DR$Ta9w~KB9l%qpcP+sH*112yuu{6g1n}l=r3c_GR^@$#DGcP#!+Z33 zr{N>gz6JPp~%=&rbsw@b1Qviyq&c z(V@4^NS35tl}UzV zFCJ$--+0YjP7Xr@%uxk;+k`DQnUcOO_rrINrg_cYv8KTzDL%ty$!ja<)vV+}rE$|V QEw)n-m?s)WW%WwG05H7%!TR#+p zUdXaU=o;hCCs)fxl}SjCK5qvz+BhffgFqqEdf+F|6f<~AuE6U;h!F`+3N9vT#tk71 z0)paa`D76`@jj)43|RD$`bt{qk-f;2=5CQ5F3l$e>=V6EOx1yRM4PAcA(W zLk*Ht0CtQ(Uh;s7!`6TfR9-dze+PC+FJ*aEXm09Abe=rXni8W>q4o^Wvv8)Y}Mt(!dlkHHl=WEd;~!yey3(j2uxZP`s1 zs|ZPXcRXcKaTzUkkv9m}fCW!0CAgpz7wY&$5x}Tf#S9d_0LT^U+TadbCCc@QPR>H_ zQvZC`u|fx~Sv|N*Drflut<@K|fuN+y=oo4mZDV{rP5UokjT+G|sSU7y;$YlNX39fU zXQiO7b-K`r%|^IH4By*7LZ_B>NC4-wC8ryp7nhtqjd8O8 zmy!4Uf+1RPYCf+W;D%P~ivf&jXZ=xtd)j!w2awUvaiL@rG@RO%ho`9R{tVBw+ocwO z#rv5ufVcRt{3|RQ;AF)+SjU^eHQ2!3%J1+Ad-i;QO*|6H!#1{5y@DTT4^K0Bf^WiM zIdzTN3^Q#;e7x5LK#;4B47t5qMLy26t~zrZjX9p!yyK2hC1=)m009O)l#85ta{eJz%KllAfe(h6;`#RfUgvd5n@Vr9*Pp1WrXdE%%M*G1dk gW}L1z^Ayju13s#YYWeDn$vVJ7berg!a?n@*21c{zFaQ7m diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/configVariables.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/configVariables.cpython-312.pyc index 5c85b02ab6b49b2e050d8d672eb9eba4e77bb225..8eea43dad4ac38f14e4513c67f0c454a36938e6e 100644 GIT binary patch delta 66 zcmca7dry}4G%qg~0}xDMOV8T6k++VWk#BPkdowGe(`Er)Q$|LP$!>g&;*S})?=q-< RXY*!cl=#E|B8xnLngCQ~5N7}Y delta 66 zcmca7dry}4G%qg~0}vcMmYB6=BX1o$Bk$%O_GVT_$ISw~ri_g2lim0l#cwlk-(^t! S&gRX?DDjB_L>74fH30yEFA+ij From 053069bf91f08422befd76b63003105d814f5f8e Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 14:49:25 +0500 Subject: [PATCH 14/28] fix: resolved PR comments --- .../__metadata__/connector_config_schema.json | 79 +++++++++++++++--- .../__metadata__/connector_manifest.json | 2 +- .../whoisfreaks/__metadata__/logo.png | Bin 0 -> 35344 bytes .../whoisfreaks/docker-compose.yml | 2 +- .../src/__pycache__/builder.cpython-312.pyc | Bin 18394 -> 18394 bytes .../src/__pycache__/client.cpython-312.pyc | Bin 12470 -> 12465 bytes .../configVariables.cpython-312.pyc | Bin 3806 -> 3813 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 9630 -> 9770 bytes internal-enrichment/whoisfreaks/src/client.py | 4 +- .../whoisfreaks/src/configVariables.py | 2 +- internal-enrichment/whoisfreaks/src/main.py | 4 +- .../whoisfreaks/tests/test_client.py | 8 +- 12 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 internal-enrichment/whoisfreaks/__metadata__/logo.png diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json index aa0e06d599b..a92e9e3cebd 100644 --- a/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json @@ -1,19 +1,76 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "WhoisFreaks connector configuration schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.filigran.io/connectors/whoisfreaks_config.schema.json", "type": "object", "properties": { - "whoisfreaks_api_key": { - "type": "string", - "description": "API key for accessing the WhoisFreaks service", - "minLength": 1 + "OPENCTI_URL": { + "description": "The base URL of the OpenCTI instance.", + "format": "uri", + "maxLength": 2083, + "minLength": 1, + "type": "string" + }, + "OPENCTI_TOKEN": { + "description": "The API token to connect to OpenCTI.", + "type": "string" + }, + "CONNECTOR_NAME": { + "default": "WhoisFreaks", + "description": "The name of the connector.", + "type": "string" + }, + "CONNECTOR_SCOPE": { + "default": [ + "Domain-Name", + "IPv4-Addr", + "IPv6-Addr" + ], + "description": "The scope of the connector.", + "items": { + "type": "string" + }, + "type": "array" }, - "whoisfreaks_url": { + "CONNECTOR_LOG_LEVEL": { + "default": "info", + "description": "The minimum level of logs to display.", + "enum": [ + "debug", + "info", + "warn", + "warning", + "error" + ], + "type": "string" + }, + "CONNECTOR_TYPE": { + "const": "INTERNAL_ENRICHMENT", + "default": "INTERNAL_ENRICHMENT", + "type": "string" + }, + "CONNECTOR_AUTO": { + "default": false, + "description": "Whether the connector should run automatically when an entity is created or updated.", + "type": "boolean" + }, + "CONNECTOR_CONFIDENCE_LEVEL": { + "default": 100, + "description": "Default confidence level (0-100) attached to STIX objects created by the connector.", + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + "WHOISFREAKS_API_KEY": { + "description": "API key for accessing the WhoisFreaks service", + "format": "password", "type": "string", - "description": "Optional base URL for the WhoisFreaks API", - "format": "uri" + "writeOnly": true } }, - "required": ["whoisfreaks_api_key"], - "additionalProperties": false + "required": [ + "OPENCTI_URL", + "OPENCTI_TOKEN", + "WHOISFREAKS_API_KEY" + ], + "additionalProperties": true } diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json index 8fa60171a7d..08f9fe57182 100644 --- a/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_manifest.json @@ -3,7 +3,7 @@ "slug": "whoisfreaks", "description": "An official internal enrichment connector for OpenCTI powered by WhoisFreaks API. Enriches Domain-Name, IPv4-Addr, and IPv6-Addr observables with live WHOIS registration data, DNS records, SSL/TLS certificate metadata, IP geolocation, and threat context.", "short_description": "Enrich domain, IP, and SSL observables with live WHOIS, DNS, Geolocation, and threat intelligence from WhoisFreaks.", - "logo": "https://whoisfreaks.com/images/logo.png", + "logo": "internal-enrichment/whoisfreaks/__metadata__/logo.png", "use_cases": [ "Infrastructure & Attack Surface Visibility" ], diff --git a/internal-enrichment/whoisfreaks/__metadata__/logo.png b/internal-enrichment/whoisfreaks/__metadata__/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d1d5bf395af4e304d2f1eade6fc3a62e318ecc1b GIT binary patch literal 35344 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 HP6h!k?S-sFBbz4Oc&Y6b-^6~Ofv>c delta 21 bcmccB&v>h!k?S-sFBbz4Okvx|b-^6~OWOuI diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/client.cpython-312.pyc index 49176d5c990322433d9e9cc34b1026df1776ec6e..ad5d9a0d57531871865715308c3b8cb44c44aa36 100644 GIT binary patch delta 493 zcmXw#&o2W(6vw@tS**2TLzT8#w53H$;^(D8nlup+f<)2?DpM>6wLSIZLgKo47bhnN z5{Jqr5*LY^rY9$jga1HM2?sYbUFPt~_xs*_-KNPG52HejD$PphibjH+SvA zu}*BEF{O)pmq=q-N;nCO6KBE@?ht2J7>lHl#Cb++cqjG5lhsitUX`-mQ=0bu&xbxt z(0~%;H9z~iqfr*(J|MQRu$Z9s^Q}EK?@TOGD)-i2aRgzApeB3eD9;2@=PFdFkB-`l z@_L8dY~;2wcCy4x>Yd`~JLEV|yeNv^q4FsT$6-jM!UT(j4@}H@L?fBWv_{yA=6k{ z+0bdu*wbmKtRxox4K@}g7VZLW@yonjO?4lOc@Ejue1s>-7Kz8WMe&$axKZB}hDYHyXb43H_6HR%J zX+NLFo{%3-V44gQl6XQAquvAY5R1+54WGnKLS+p#qAMZ|Uy3E;KcBgIaZ!%4-U1!n zQAHYKdXaCTah{+ov#m=5JxG)jOos-qID;6hpepIoDw~O*Ce6x8Rz)w|D{Ic4bTA_- zwMGFu`nA+NV1I-g#+9hQY1^0z*iIrC0626C3-@H=W4zWlwkd6vTdi^ndyyTXS;0)$ zfKz8L+y|&S@n~1@S6NCk%dyUps%@Uc`j3%u#pa3Z#*EAdLcdHt%2CHI&wNy#)m3w|7v~gK0GkRXqW}N^ delta 154 zcmaDVdry}4G%qg~0}xDMOV8T6kvEl*@yh06#-EHjtC>N<3=B04@%#`L149X%l_H(O zlOmJORKt`ayGCyE9A-yG*~#~q#aZQ36jBr?|6=y#WUXM1WGH9URN5TCBFQ+pkX4Oc tld(t^Xx8TGtp6Apmu;TLZp=72pM!hyHI5Q?S>~g%tgh;t139O#0sy2RB#Hn4 diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc index 4d719c80fc54d6af43ed44c2c5d647a115c43072..edee3f3a381106f62513d7cdb9a20e65e07b63df 100644 GIT binary patch delta 679 zcmbQ|y~>C0G%qg~0}w1$OwaO{-pF@Slu3wn@=Z~7ex_ov5+;xo2xPHNekdx;$F!Oe z!e?ZtWUiTPs3ShuGLUeYgsuNYM4{FC+myrGV)B071uEwz7#%~ zCZPNHCW}i%+Vdksdh}TsQUt&j3YI8C{K3Fb!v^yc&>=!|S!+4aoysg+!vQotd9sG2 zfVc=)zvx_MJnBwJDzS@!)rn93C@IazG?`aQmQi@Jwp6;V#2U%f%&;)5;YwzTWJr;! z<*efjhSKGXPpuMk1&bvz!2(;)HYidzZeo;{(kiA)2R-4f-2Nf>&@XwEMsShPh0u86kp<43*3!Fcl10oRc4l3iGqnFwbU4VXa{Sl5BHXCnrjIPUaA|lVk^p z%w<}`v6=~@yOx!cp@unyb8@`6E+g0EUU5xsgjv!cvzWPSm?y_dCGn;3z%&7^ zjO7d!%#jS`jGB^@-%ABBT1|G4me{;d`Z1$GB2Z(IB9OSnnp#wpU$l9OtTv-|Bru>F z7#@g-Pj{c_KEwO6h(-tJ4IP8)I*u2091mz-*73Q>q1nNIK~nSK=D%|0OuC0O98KNr z7>>v}@dK%2dX6CKxE-gb9`gxyM;T8|=99{dK+Z``R!_aniHZq~j4L)DQc_}+HfNPx uV6uXFMequag?1kpfZPqqpFz~;qRs!6moV}~GBFx{WB`#LDkiU2tp))2mxzG? diff --git a/internal-enrichment/whoisfreaks/src/client.py b/internal-enrichment/whoisfreaks/src/client.py index e1807b9da5c..6985d11991b 100644 --- a/internal-enrichment/whoisfreaks/src/client.py +++ b/internal-enrichment/whoisfreaks/src/client.py @@ -164,12 +164,12 @@ def reverse_dns_lookup( ) def bulk_dns_lookup( - self, domains: list[str], ipAddresses: list[str], format: Optional[str] = "json" + self, domains: list[str], ip_addresses: list[str], format: Optional[str] = "json" ) -> Optional[Dict[str, Any]]: return self._post( "/v2.0/dns/bulk/live", params={"format": format, "type": "all"}, - body={"domainNames": domains, "ipAddresses": ipAddresses}, + body={"domainNames": domains, "ipAddresses": ip_addresses}, ) def domain_availability_lookup( diff --git a/internal-enrichment/whoisfreaks/src/configVariables.py b/internal-enrichment/whoisfreaks/src/configVariables.py index 2b9bc852d60..3c602b15991 100644 --- a/internal-enrichment/whoisfreaks/src/configVariables.py +++ b/internal-enrichment/whoisfreaks/src/configVariables.py @@ -48,7 +48,7 @@ def __init__(self): default=100, ) self.connector_auto = get_config_variable( - "CONNECTOR_AUTO", ["connector", "auto"], config + "CONNECTOR_AUTO", ["connector", "auto"], config, default=False ) # Force log level to uppercase string (e.g. 'INFO') raw_log_level = get_config_variable( diff --git a/internal-enrichment/whoisfreaks/src/main.py b/internal-enrichment/whoisfreaks/src/main.py index 9bda0ebbc83..5ae73bc5971 100644 --- a/internal-enrichment/whoisfreaks/src/main.py +++ b/internal-enrichment/whoisfreaks/src/main.py @@ -111,8 +111,10 @@ def _get_entity_info(self, entity_id: str) -> Tuple[Optional[str], Optional[str] def process_message(self, msg: Dict[str, Any]) -> str: """Callback executed whenever an enrichment task is received from RabbitMQ.""" entity_id = msg.get("entity_id") + if not entity_id: + logger.error("[WhoisFreaks Connector] Missing entity_id in message") + return "Entity ID missing" observable_type, observable_value = self._get_entity_info(entity_id) - if not observable_type or not observable_value: logger.error( f"[WhoisFreaks Connector] Invalid or missing entity for ID: {entity_id}" diff --git a/internal-enrichment/whoisfreaks/tests/test_client.py b/internal-enrichment/whoisfreaks/tests/test_client.py index 760703f15e5..e01087ca45e 100644 --- a/internal-enrichment/whoisfreaks/tests/test_client.py +++ b/internal-enrichment/whoisfreaks/tests/test_client.py @@ -182,7 +182,7 @@ def test_bulk_dns_lookup(client): "https://api.whoisfreaks.com/v2.0/dns/bulk/live?apiKey=test-api-key&format=json&type=all", json={"data": "bulk"}, ) - assert client.bulk_dns_lookup(["example.com"], ["1.2.3.4"]) == {"data": "bulk"} + assert client.bulk_dns_lookup(domains=["example.com"], ip_addresses=["1.2.3.4"]) == {"data": "bulk"} def test_domain_availability_lookup(client): @@ -207,12 +207,6 @@ def test_bulk_domain_availability_lookup(client): def test_typosquatting_lookup(client): with requests_mock.Mocker() as m: - m.get( - "https://api.whoisfreaks.com/v3.0/domain/tyops?apiKey=test-api-key&keyword=example", - json={"data": "typo"}, - ) - # Note: in client.py line 188: self._get("/v3.0/domain/typos", params={"keyword": keyword}) - # Wait, the path is typos, let's fix URL mock: m.get( "https://api.whoisfreaks.com/v3.0/domain/typos?apiKey=test-api-key&keyword=example", json={"data": "typo"}, From abde9f4df805f0b4634633835c0fa6a5db6a7b14 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 14:52:19 +0500 Subject: [PATCH 15/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/Dockerfile b/internal-enrichment/whoisfreaks/Dockerfile index ef0c4281892..96d784929d6 100644 --- a/internal-enrichment/whoisfreaks/Dockerfile +++ b/internal-enrichment/whoisfreaks/Dockerfile @@ -17,10 +17,10 @@ RUN pip3 install --no-cache-dir -r requirements.txt && \ # Copy source code and entrypoint COPY src ./src -COPY entrypoint.sh . +COPY entrypoint.sh /entrypoint.sh # Ensure entrypoint is executable -RUN chmod +x entrypoint.sh +RUN chmod +x /entrypoint.sh # Run startup script ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file From c50ce0b70076e97dff100bb9ec634409c5d1a5db Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 15:38:31 +0500 Subject: [PATCH 16/28] fix: resolved PR comments --- .../config_variables.cpython-312.pyc | Bin 4151 -> 3814 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 9770 -> 9771 bytes ...configVariables.py => config_variables.py} | 0 internal-enrichment/whoisfreaks/src/main.py | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) rename internal-enrichment/whoisfreaks/src/{configVariables.py => config_variables.py} (100%) diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc index a5f22d2b4dcc2cf6168f69ecd9efe4b9de23f809..c87bc9270770f01f9a91c25496c482d77dc949de 100644 GIT binary patch literal 3814 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 diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/main.cpython-312.pyc index edee3f3a381106f62513d7cdb9a20e65e07b63df..78211e6f15592517f5ac64f7610b808696f5ccf6 100644 GIT binary patch delta 42 wcmZ4Gv)YIEG%qg~0}z}rNzV${$lIpEDUh6>mzJ3xUp9HR%3emD$&soT0TnL|@c;k- delta 41 vcmZ4Ov&x6}G%qg~0}w1$OwaP)$lIpE!JnL;mzJ3xHhGT9UPkT7QK}aK02~cC diff --git a/internal-enrichment/whoisfreaks/src/configVariables.py b/internal-enrichment/whoisfreaks/src/config_variables.py similarity index 100% rename from internal-enrichment/whoisfreaks/src/configVariables.py rename to internal-enrichment/whoisfreaks/src/config_variables.py diff --git a/internal-enrichment/whoisfreaks/src/main.py b/internal-enrichment/whoisfreaks/src/main.py index 5ae73bc5971..e831c13a4c0 100644 --- a/internal-enrichment/whoisfreaks/src/main.py +++ b/internal-enrichment/whoisfreaks/src/main.py @@ -4,7 +4,7 @@ from builder import WhoisFreaksStixBuilder from client import WhoisFreaksClient -from configVariables import ConfigVariables +from config_variables import ConfigVariables from pycti import OpenCTIConnectorHelper logging.basicConfig( From c2b24a41c1c54fd220af48de3e47382168e6f68e Mon Sep 17 00:00:00 2001 From: qayyum-jfreaks Date: Mon, 27 Jul 2026 15:45:39 +0500 Subject: [PATCH 17/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal-enrichment/whoisfreaks/src/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/src/main.py b/internal-enrichment/whoisfreaks/src/main.py index e831c13a4c0..4275b8c470b 100644 --- a/internal-enrichment/whoisfreaks/src/main.py +++ b/internal-enrichment/whoisfreaks/src/main.py @@ -112,8 +112,8 @@ def process_message(self, msg: Dict[str, Any]) -> str: """Callback executed whenever an enrichment task is received from RabbitMQ.""" entity_id = msg.get("entity_id") if not entity_id: - logger.error("[WhoisFreaks Connector] Missing entity_id in message") - return "Entity ID missing" + logger.error("[WhoisFreaks Connector] Missing entity_id in message") + return "Entity ID missing" observable_type, observable_value = self._get_entity_info(entity_id) if not observable_type or not observable_value: logger.error( From bc802fdf74ef79657e41219b460393e45461fe95 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 15:49:27 +0500 Subject: [PATCH 18/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/src/builder.py | 4 ++++ internal-enrichment/whoisfreaks/tests/test_builder.py | 2 +- internal-enrichment/whoisfreaks/tests/test_connector.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/src/builder.py b/internal-enrichment/whoisfreaks/src/builder.py index 143893395a4..a497fcd11e4 100644 --- a/internal-enrichment/whoisfreaks/src/builder.py +++ b/internal-enrichment/whoisfreaks/src/builder.py @@ -184,6 +184,10 @@ def build_dns_bundle( else: continue + # Skip self-resolving records to avoid self-referential relationships + if target_stix.id == source_stix.id: + continue + objects.append(target_stix) objects.append( stix2.Relationship( diff --git a/internal-enrichment/whoisfreaks/tests/test_builder.py b/internal-enrichment/whoisfreaks/tests/test_builder.py index 69a6dab7395..2db4b39bdff 100644 --- a/internal-enrichment/whoisfreaks/tests/test_builder.py +++ b/internal-enrichment/whoisfreaks/tests/test_builder.py @@ -103,7 +103,7 @@ def test_build_dns_bundle_ipv4_source(builder): # Check source observable ipv4s = [obj for obj in bundle.objects if obj["type"] == "ipv4-addr"] - assert len(ipv4s) == 2 # The source "1.2.3.4" and the record "1.2.3.4" + assert len(ipv4s) == 1 # Only the source "1.2.3.4" (self-resolve records are skipped) ipv6s = [obj for obj in bundle.objects if obj["type"] == "ipv6-addr"] assert len(ipv6s) == 1 diff --git a/internal-enrichment/whoisfreaks/tests/test_connector.py b/internal-enrichment/whoisfreaks/tests/test_connector.py index 6d8458d989d..01c1f27b9cb 100644 --- a/internal-enrichment/whoisfreaks/tests/test_connector.py +++ b/internal-enrichment/whoisfreaks/tests/test_connector.py @@ -125,7 +125,7 @@ def test_process_message_domain_enrichment(mock_dependencies): result = connector.process_message({"entity_id": "entity-123"}) assert "Successfully enriched example.com with" in result - assert helper.send_stix2_bundle.call_count == 4 + assert helper.send_stix2_bundle.call_count == 3 helper.api.work.to_processed.assert_called_once() From a2350846a70244bc1a952ca22b78ce9056f63994 Mon Sep 17 00:00:00 2001 From: qayyum-jfreaks Date: Mon, 27 Jul 2026 16:07:16 +0500 Subject: [PATCH 19/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal-enrichment/whoisfreaks/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/Dockerfile b/internal-enrichment/whoisfreaks/Dockerfile index 96d784929d6..a29705ba8a8 100644 --- a/internal-enrichment/whoisfreaks/Dockerfile +++ b/internal-enrichment/whoisfreaks/Dockerfile @@ -15,10 +15,10 @@ COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt && \ apk del git build-base -# Copy source code and entrypoint +# Copy source code, config, and entrypoint COPY src ./src +COPY config.yml ./config.yml COPY entrypoint.sh /entrypoint.sh - # Ensure entrypoint is executable RUN chmod +x /entrypoint.sh From 385baaaf24366427233e22324a37853d4793d574 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 16:15:33 +0500 Subject: [PATCH 20/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/src/builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal-enrichment/whoisfreaks/src/builder.py b/internal-enrichment/whoisfreaks/src/builder.py index a497fcd11e4..46ae9340e9c 100644 --- a/internal-enrichment/whoisfreaks/src/builder.py +++ b/internal-enrichment/whoisfreaks/src/builder.py @@ -223,7 +223,9 @@ def build_ssl_bundle( else: target_stix = stix2.DomainName(value=clean_target) objects.append(target_stix) - ssl_certificates = ssl_data.get("sslCertificates") or [ssl_data] + ssl_certificates = ssl_data.get("sslCertificates") + if not ssl_certificates: + return None for cert in ssl_certificates: cert_info = cert.get("certificate_info") or cert issuer = cert_info.get("issuer_dn") or "Unknown Issuer" From 90391fe30a813b3ae4c20b6686ed09f28e478c52 Mon Sep 17 00:00:00 2001 From: qayyum-jfreaks Date: Mon, 27 Jul 2026 16:21:49 +0500 Subject: [PATCH 21/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal-enrichment/whoisfreaks/src/builder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/src/builder.py b/internal-enrichment/whoisfreaks/src/builder.py index 46ae9340e9c..22f58a756fc 100644 --- a/internal-enrichment/whoisfreaks/src/builder.py +++ b/internal-enrichment/whoisfreaks/src/builder.py @@ -360,8 +360,9 @@ def build_ip_reputation_bundle( objects.append(ip_stix) sec_data = reputation_data.get("security") or reputation_data - threat_score = sec_data.get("threat_score") or sec_data.get("score") - + threat_score = sec_data.get("threat_score") + if threat_score is None: + threat_score = sec_data.get("score") if threat_score is not None: note_stix = stix2.Note( id=pycti.Note.generate_id( From 596014e4721efcc8584e6f4bcfcd41fa031e8275 Mon Sep 17 00:00:00 2001 From: qayyum-jfreaks Date: Mon, 27 Jul 2026 16:44:58 +0500 Subject: [PATCH 22/28] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- internal-enrichment/whoisfreaks/tests/test_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal-enrichment/whoisfreaks/tests/test_client.py b/internal-enrichment/whoisfreaks/tests/test_client.py index e01087ca45e..f70aa819e5a 100644 --- a/internal-enrichment/whoisfreaks/tests/test_client.py +++ b/internal-enrichment/whoisfreaks/tests/test_client.py @@ -182,7 +182,10 @@ def test_bulk_dns_lookup(client): "https://api.whoisfreaks.com/v2.0/dns/bulk/live?apiKey=test-api-key&format=json&type=all", json={"data": "bulk"}, ) - assert client.bulk_dns_lookup(domains=["example.com"], ip_addresses=["1.2.3.4"]) == {"data": "bulk"} + assert ( + client.bulk_dns_lookup(domains=["example.com"], ip_addresses=["1.2.3.4"]) + == {"data": "bulk"} + ) def test_domain_availability_lookup(client): From e5eb19795d475d9eabe454622538f32fbf2655ac Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 16:45:28 +0500 Subject: [PATCH 23/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/README.md | 59 ++++++++++--------- .../whoisfreaks/src/builder.py | 12 ++-- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/internal-enrichment/whoisfreaks/README.md b/internal-enrichment/whoisfreaks/README.md index 0db9bae1034..8cb9c25dabc 100644 --- a/internal-enrichment/whoisfreaks/README.md +++ b/internal-enrichment/whoisfreaks/README.md @@ -21,18 +21,18 @@ This connector enables security analysts to enrich `Domain-Name`, `IPv4-Addr`, a The connector is configured using environment variables (or `.env` / `config.yml`): -| Variable | Required | Default | Description | -|---|---|---|---| -| `OPENCTI_URL` | Yes | - | Base URL of your OpenCTI platform instance (e.g. `http://opencti:4000`). | -| `OPENCTI_TOKEN` | Yes | - | OpenCTI User / Admin API Token for authentication. | -| `CONNECTOR_ID` | Yes | - | A unique UUIDv4 string generated specifically for this connector. | -| `CONNECTOR_NAME` | No | `WhoisFreaks` | Display name of the connector inside the OpenCTI dashboard. | -| `CONNECTOR_TYPE` | No | `INTERNAL_ENRICHMENT` | Connector operational category in OpenCTI. | -| `CONNECTOR_SCOPE` | No | `Domain-Name,IPv4-Addr,IPv6-Addr` | Supported STIX 2.1 entity types. | -| `CONNECTOR_AUTO` | No | `false` | Enable automatic enrichment on observable creation (`true`/`false`). | -| `CONNECTOR_CONFIDENCE_LEVEL` | No | `100` | Confidence score (0-100) assigned to created STIX objects. | -| `CONNECTOR_LOG_LEVEL` | No | `INFO` | Log verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | -| `WHOISFREAKS_API_KEY` | Yes | - | Your active WhoisFreaks API key. | +| Variable | Required | Default | Description | +| ---------------------------- | -------- | --------------------------------- | ------------------------------------------------------------------------ | +| `OPENCTI_URL` | Yes | - | Base URL of your OpenCTI platform instance (e.g. `http://opencti:4000`). | +| `OPENCTI_TOKEN` | Yes | - | OpenCTI User / Admin API Token for authentication. | +| `CONNECTOR_ID` | Yes | - | A unique UUIDv4 string generated specifically for this connector. | +| `CONNECTOR_NAME` | No | `WhoisFreaks` | Display name of the connector inside the OpenCTI dashboard. | +| `CONNECTOR_TYPE` | No | `INTERNAL_ENRICHMENT` | Connector operational category in OpenCTI. | +| `CONNECTOR_SCOPE` | No | `Domain-Name,IPv4-Addr,IPv6-Addr` | Supported STIX 2.1 entity types. | +| `CONNECTOR_AUTO` | No | `false` | Enable automatic enrichment on observable creation (`true`/`false`). | +| `CONNECTOR_CONFIDENCE_LEVEL` | No | `100` | Confidence score (0-100) assigned to created STIX objects. | +| `CONNECTOR_LOG_LEVEL` | No | `INFO` | Log verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | +| `WHOISFREAKS_API_KEY` | Yes | - | Your active WhoisFreaks API key. | --- @@ -41,20 +41,21 @@ The connector is configured using environment variables (or `.env` / `config.yml Add the service block below to your OpenCTI stack `docker-compose.yml`: ```yaml - connector-whoisfreaks: - image: opencti-connector-whoisfreaks:latest - container_name: opencti-connector-whoisfreaks - environment: - - OPENCTI_URL=http://opencti:4000 - - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} - - CONNECTOR_ID=${CONNECTOR_KEY} - - CONNECTOR_TYPE=INTERNAL_ENRICHMENT - - CONNECTOR_NAME=WhoisFreaks - - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr - - CONNECTOR_AUTO=false - - CONNECTOR_CONFIDENCE_LEVEL=100 - - CONNECTOR_LOG_LEVEL=INFO - - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} - depends_on: - - opencti - restart: always +connector-whoisfreaks: + image: opencti-connector-whoisfreaks:latest + container_name: opencti-connector-whoisfreaks + environment: + - OPENCTI_URL=http://opencti:4000 + - OPENCTI_TOKEN=${OPENCTI_ADMIN_TOKEN} + - CONNECTOR_ID=${CONNECTOR_KEY} + - CONNECTOR_TYPE=INTERNAL_ENRICHMENT + - CONNECTOR_NAME=WhoisFreaks + - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr + - CONNECTOR_AUTO=false + - CONNECTOR_CONFIDENCE_LEVEL=100 + - CONNECTOR_LOG_LEVEL=INFO + - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} + depends_on: + - opencti + restart: always +``` diff --git a/internal-enrichment/whoisfreaks/src/builder.py b/internal-enrichment/whoisfreaks/src/builder.py index 46ae9340e9c..23a9bafe2cf 100644 --- a/internal-enrichment/whoisfreaks/src/builder.py +++ b/internal-enrichment/whoisfreaks/src/builder.py @@ -230,13 +230,11 @@ def build_ssl_bundle( cert_info = cert.get("certificate_info") or cert issuer = cert_info.get("issuer_dn") or "Unknown Issuer" subject = cert_info.get("subject_dn") or clean_target - serial = str(cert_info.get("serial_number", "")) - - cert_stix = stix2.X509Certificate( - issuer=str(issuer), - subject=str(subject), - serial_number=serial, - ) + serial = cert_info.get("serial_number") + cert_kwargs = {"issuer": str(issuer), "subject": str(subject)} + if serial: + cert_kwargs["serial_number"] = str(serial) + cert_stix = stix2.X509Certificate(**cert_kwargs) objects.append(cert_stix) objects.append( stix2.Relationship( From 1bea73a3f92fb0ae78eae9045cba7c678c28e1ca Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 16:58:14 +0500 Subject: [PATCH 24/28] fix: resolved PR comments --- .../__metadata__/connector_config_schema.json | 7 ++++++- .../whoisfreaks/src/config_variables.py | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json index a92e9e3cebd..e9c48a92ef2 100644 --- a/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json +++ b/internal-enrichment/whoisfreaks/__metadata__/connector_config_schema.json @@ -14,6 +14,10 @@ "description": "The API token to connect to OpenCTI.", "type": "string" }, + "CONNECTOR_ID": { + "description": "A stable UUID for this connector instance.", + "type": "string" + }, "CONNECTOR_NAME": { "default": "WhoisFreaks", "description": "The name of the connector.", @@ -70,7 +74,8 @@ "required": [ "OPENCTI_URL", "OPENCTI_TOKEN", - "WHOISFREAKS_API_KEY" + "WHOISFREAKS_API_KEY", + "CONNECTOR_ID" ], "additionalProperties": true } diff --git a/internal-enrichment/whoisfreaks/src/config_variables.py b/internal-enrichment/whoisfreaks/src/config_variables.py index 3c602b15991..46c55f4b818 100644 --- a/internal-enrichment/whoisfreaks/src/config_variables.py +++ b/internal-enrichment/whoisfreaks/src/config_variables.py @@ -32,13 +32,22 @@ def __init__(self): "CONNECTOR_ID", ["connector", "id"], config ) self.connector_type = get_config_variable( - "CONNECTOR_TYPE", ["connector", "type"], config + "CONNECTOR_TYPE", + ["connector", "type"], + config, + default="INTERNAL_ENRICHMENT", ) self.connector_name = get_config_variable( - "CONNECTOR_NAME", ["connector", "name"], config + "CONNECTOR_NAME", + ["connector", "name"], + config, + default="WhoisFreaks", ) self.connector_scope = get_config_variable( - "CONNECTOR_SCOPE", ["connector", "scope"], config + "CONNECTOR_SCOPE", + ["connector", "scope"], + config, + default=["Domain-Name", "IPv4-Addr", "IPv6-Addr"], ) self.connector_confidence_level = get_config_variable( "CONNECTOR_CONFIDENCE_LEVEL", From 4310311ab258f44b4b0b5a356a89ea0c91700f5b Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Mon, 27 Jul 2026 17:40:09 +0500 Subject: [PATCH 25/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/README.md | 2 +- internal-enrichment/whoisfreaks/src/config_variables.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal-enrichment/whoisfreaks/README.md b/internal-enrichment/whoisfreaks/README.md index 8cb9c25dabc..075e6516ebf 100644 --- a/internal-enrichment/whoisfreaks/README.md +++ b/internal-enrichment/whoisfreaks/README.md @@ -31,7 +31,7 @@ The connector is configured using environment variables (or `.env` / `config.yml | `CONNECTOR_SCOPE` | No | `Domain-Name,IPv4-Addr,IPv6-Addr` | Supported STIX 2.1 entity types. | | `CONNECTOR_AUTO` | No | `false` | Enable automatic enrichment on observable creation (`true`/`false`). | | `CONNECTOR_CONFIDENCE_LEVEL` | No | `100` | Confidence score (0-100) assigned to created STIX objects. | -| `CONNECTOR_LOG_LEVEL` | No | `INFO` | Log verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`). | +| `CONNECTOR_LOG_LEVEL` | No | `info` | Log verbosity (`debug`, `info`, `warning`, `error`). | | `WHOISFREAKS_API_KEY` | Yes | - | Your active WhoisFreaks API key. | --- diff --git a/internal-enrichment/whoisfreaks/src/config_variables.py b/internal-enrichment/whoisfreaks/src/config_variables.py index 46c55f4b818..6f4bb74b93d 100644 --- a/internal-enrichment/whoisfreaks/src/config_variables.py +++ b/internal-enrichment/whoisfreaks/src/config_variables.py @@ -61,7 +61,7 @@ def __init__(self): ) # Force log level to uppercase string (e.g. 'INFO') raw_log_level = get_config_variable( - "CONNECTOR_LOG_LEVEL", ["connector", "log_level"], config, default="INFO" + "CONNECTOR_LOG_LEVEL", ["connector", "log_level"], config, default="info" ) self.connector_log_level = ( str(raw_log_level).upper() if raw_log_level else "INFO" From b54e67d962b4b365fb0db1d5a88e6c865b75a8d1 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Tue, 28 Jul 2026 14:58:06 +0500 Subject: [PATCH 26/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/README.md | 2 +- internal-enrichment/whoisfreaks/config.yml | 2 +- internal-enrichment/whoisfreaks/docker-compose.yml | 2 +- internal-enrichment/whoisfreaks/src/config_variables.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal-enrichment/whoisfreaks/README.md b/internal-enrichment/whoisfreaks/README.md index 075e6516ebf..75ade030931 100644 --- a/internal-enrichment/whoisfreaks/README.md +++ b/internal-enrichment/whoisfreaks/README.md @@ -53,7 +53,7 @@ connector-whoisfreaks: - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr - CONNECTOR_AUTO=false - CONNECTOR_CONFIDENCE_LEVEL=100 - - CONNECTOR_LOG_LEVEL=INFO + - CONNECTOR_LOG_LEVEL=info - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} depends_on: - opencti diff --git a/internal-enrichment/whoisfreaks/config.yml b/internal-enrichment/whoisfreaks/config.yml index 0d7188288ee..e0aef8679c0 100644 --- a/internal-enrichment/whoisfreaks/config.yml +++ b/internal-enrichment/whoisfreaks/config.yml @@ -9,7 +9,7 @@ connector: scope: "Domain-Name,IPv4-Addr,IPv6-Addr" auto: false # Manual trigger recommended to manage API quota confidence_level: 100 - log_level: "INFO" + log_level: "info" whoisfreaks: api_key: "ChangeMe" diff --git a/internal-enrichment/whoisfreaks/docker-compose.yml b/internal-enrichment/whoisfreaks/docker-compose.yml index 0acba4e8963..e451f7f3038 100644 --- a/internal-enrichment/whoisfreaks/docker-compose.yml +++ b/internal-enrichment/whoisfreaks/docker-compose.yml @@ -80,7 +80,7 @@ services: - CONNECTOR_SCOPE=Domain-Name,IPv4-Addr,IPv6-Addr - CONNECTOR_AUTO=false - CONNECTOR_CONFIDENCE_LEVEL=100 - - CONNECTOR_LOG_LEVEL=INFO + - CONNECTOR_LOG_LEVEL=info - WHOISFREAKS_API_KEY=${WHOISFREAKS_API_KEY} depends_on: - opencti diff --git a/internal-enrichment/whoisfreaks/src/config_variables.py b/internal-enrichment/whoisfreaks/src/config_variables.py index 6f4bb74b93d..0cb4ebd6c2b 100644 --- a/internal-enrichment/whoisfreaks/src/config_variables.py +++ b/internal-enrichment/whoisfreaks/src/config_variables.py @@ -47,7 +47,7 @@ def __init__(self): "CONNECTOR_SCOPE", ["connector", "scope"], config, - default=["Domain-Name", "IPv4-Addr", "IPv6-Addr"], + default="Domain-Name,IPv4-Addr,IPv6-Addr", ) self.connector_confidence_level = get_config_variable( "CONNECTOR_CONFIDENCE_LEVEL", From 9f6c5be3295d46b21c23f7600701db6b199d2325 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Tue, 28 Jul 2026 15:05:05 +0500 Subject: [PATCH 27/28] fix: resolved PR comments --- internal-enrichment/whoisfreaks/tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-enrichment/whoisfreaks/tests/test_client.py b/internal-enrichment/whoisfreaks/tests/test_client.py index f70aa819e5a..90cc41da6da 100644 --- a/internal-enrichment/whoisfreaks/tests/test_client.py +++ b/internal-enrichment/whoisfreaks/tests/test_client.py @@ -16,7 +16,7 @@ def test_client_init(client): assert client.session.headers["Accept"] == "application/json" -def test_client_get_success(client, requests_mock_fixture=None): +def test_client_get_success(client): with requests_mock.Mocker() as m: m.get( "https://api.whoisfreaks.com/v2.0/whois/live?apiKey=test-api-key&format=json&domainName=example.com", From ea1cc353cbf8d5c820f9c8220a31a911f9a63158 Mon Sep 17 00:00:00 2001 From: ABDULQAYYUM Date: Wed, 29 Jul 2026 13:05:00 +0500 Subject: [PATCH 28/28] fix: resolved PR comments --- .../src/__pycache__/builder.cpython-312.pyc | Bin 18394 -> 18507 bytes .../config_variables.cpython-312.pyc | Bin 3814 -> 3914 bytes .../src/__pycache__/main.cpython-312.pyc | Bin 9771 -> 9771 bytes internal-enrichment/whoisfreaks/src/main.py | 2 +- .../whoisfreaks/tests/test_builder.py | 24 +++++++ .../tests/test_config_variables.py | 61 ++++++++++++++++++ .../whoisfreaks/tests/test_connector.py | 11 ++++ 7 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 internal-enrichment/whoisfreaks/tests/test_config_variables.py diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/builder.cpython-312.pyc index 9e7c886b1056d0aad78034bb68603bfbd19a4c9f..38c14390836a318ce06c65480783f10db1ef4680 100644 GIT binary patch delta 1106 zcmY+CUrbwd6vyxHmV4WB+gq^oAN>dP53Ryl7%a5lE`b8NE!z^z;-Z8UXxPe(dfP<| z7esupa~l*+GGLj3y0OtgT$|3ssfi?eb3u!nQkt6V!Ndn&s+)<44|;zk(|B|9Ip?19 zJ?Ea^pZpwp5J&6-rLvq5pPvGw;pe$Ddsj~mQIK+XL5Jx&PlmW8aGJDlu zAjH5bQ$-3CHtd*-Ab2v4E-3{`xDtMARzuR%06v+Tj9MV=Fp*&!j-rwryk-g#j2d1t zE3qYoQZtw`8{h|%8g83))LI!4l|zyBz#ry6kqX#WJvt`AZeQsm!)H58whWKxJ(A#v%8!l@@wRzLST3{s@YEvgupx#35pR}hZu&S-BK~4 zI!g$O8)z0y{<{N63ETD$4dsF|T=T5_KfB4m8xE^P%t6|wM&&Tyq=kD94RAg=>^hW` ztF!^LW)pnms|2kxHx;e8^Lu;~mV7IgIk z&oRRxJ~}w|PLLmsFhaJbgB*I)ape@pk}>pRs)Vn_E>nu4b}b!cNE&MckSsc0zZ&Aa-> z9sG8r7!k3Ta|-p`@e9Y7e2)|k;B+pqx!Y-tRy-ZAcsRPug!{8HCWfcKh;a$p4*)%b z-4Ec=0Vh3+-PnR>5&fN6@~lwSWgNJQ;m7F3=)<^%@iClgji8pyH?7koJt-BW)0LT% zZ4`;t;go;KUTU1c!cy@9_CQ2l76}axjs&AY{st8MZ_%g4dl2!$cdZ5(Yd@olDRPW1 z%UE+p+e*iB$GY^P(HlFK%lzE_D~Z-1d)SL^0^$y#B(!lJvne( zS8XmzN{+WB)&IFI^=g{k5B$FlONs&5=-pD^7e~m`=iVG04e`7T&od5FeR{MFi+wGs OAdL*a6PUa~%>4uXv`Z%d delta 1047 zcmZvZe@qi+7{~9qqen}B;L_5&mb+e|rBF*pb-(QJ!ZqKk&{qaqCnn z-}^q#^FGhL@A?$v&Vy;)Xsn>rHDeiwm$xsQuJLrE1|RHt3-8xHM5}#{d7q+CaNDrk zZOHy&zYdsoS@Aca5zT(HGa-;-vf_@?{w(7)xVF<`kJmnHmUPmP)W>UaNR+t$i;~!7 z(T4{-X3UG6=3E(-&|$&Ugg(bVc&f#Lozgl4(dhgOB>da?18&s}v-OhB^mq>*stv;i z-rvpfkf>gib6S3`lJDOcsOA2qdI{uepu*!Jzny2=oQWUl5tV< z{Mn$+G3mrMkBc!aRbsDaM3j%JDPAVW4l2rMOwN@nqrLH1UqT*LeGIf%@n6qLFye{& zy?EWj52q zfkFjkwBVcGA=bvL*W8Tn@?7>0@3&?8aka7h0l_(3coEqYk!P{3$-_<(IgTfrX5h)y zKw2Zs5M!1gNYF(PCHRbB4p&_xB`v!GvOd)&dy*mue0O@k!XhE|6ABD0K|M+u6yE6b%>Axc5Vt z#<{(BAccbw0akH55`=lY8Tr2I49WkLV4mP>f~$CbUkzNrYy09ag)|+oB}fIPD11#B zCUZEWz0zJX^12tM>Sh|J8}I6*M;ek+I0g86*M;2@sk4g(rYHi-QQe!j0M|6icq$xvHt+-2P(|~ diff --git a/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc b/internal-enrichment/whoisfreaks/src/__pycache__/config_variables.cpython-312.pyc index c87bc9270770f01f9a91c25496c482d77dc949de..411c3603fac01680ecfdbfb5bbcff704c391b769 100644 GIT binary patch delta 672 zcmY*UJ8aWX6!r5bpJF?tl+?}7Ng%Bv5tUD2pb^3ZSB*-XkOoj$BGZ>Pruktfe5xQ; z76$Z?%D}*ingvA&Mi`I~EFBmk4@`&|P!=-ro;yI_aNfD+oO|DWKO&!_;uk@fpzujN ztlfUbzZM|?-v<_01ul5F@84z7Cpv-z#_NJpPWh9L_>Mvz1~%y)4W}JVVj>|4Ycq}( zgrRx7yWh`EskF8_l$aJ5J<^cNfwMgeGC<9A*cz1tfBr zuo=Mr`2Qv4TDf4edbiVBLU*_XYcw}ngQpxvgSTkM{pRLSke<)(@$n%)Ih;z3yqrAo znEmPrjd)5vJ>&?LCJfak><(1F1_8ZHlcYE7v?Df=h1sU8p;sU-7qZ6Xl`0Gu3|A$A^sxz z!$-l&KoSCA@{*%qctx2jge&NxD6cJYGox+b=cIkng`Je!T*jB}ZtI;|-MD2kB%US` q*M6|xF-(>SBx+tIus;s7u_x1L!EU{J!7QV9qUl_e_@WG%qg~0}#|Hre_`D-pH52$arIOF=H?z(`x3)XP9KU1yXoY1k;&nK>Qy} zvgSfSzA%Ij(#OD1!w}C4VKFe2@WWUsB0zPbAayBXDdKA+CI>Lf^GX86q`+d*Yh)(Z zFw0NQXXas)nY@D8QC1#kju1>Yk~s<~iYZEy*;srz*(;bM8Oj+ol_$rr^y>0C`}_I1 zI*0fN#rrw>y53^ROUzBZ#g7mTcJ>c&y~SFboL`VSIfd0>@_!alA;-`V|644Hr6u`p znv6xVK>H>iU{%`uf|Z$xamC~}>|4EORIhNmAZNTG>@zcyJXZ(H6%OeyYz*>7H+W>Q zODP!N;E`Rxy(0XAyy=EApe_ZlE}1WElYet8kd9MaZ(nV4BQK5-ebN`A>;6l9fvC0o delta 43 ycmZ4Ov)YIEG%qg~0}z}rNzV${$a_kTpI5j)rZeV(RKOM9z#Ag!o1e+KvI77o<_<3a diff --git a/internal-enrichment/whoisfreaks/src/main.py b/internal-enrichment/whoisfreaks/src/main.py index 4275b8c470b..4c8deef67ac 100644 --- a/internal-enrichment/whoisfreaks/src/main.py +++ b/internal-enrichment/whoisfreaks/src/main.py @@ -173,7 +173,7 @@ def start(self): self.helper.listen(self.process_message) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover try: connector = WhoisFreaksConnector() connector.start() diff --git a/internal-enrichment/whoisfreaks/tests/test_builder.py b/internal-enrichment/whoisfreaks/tests/test_builder.py index 2db4b39bdff..4f729335710 100644 --- a/internal-enrichment/whoisfreaks/tests/test_builder.py +++ b/internal-enrichment/whoisfreaks/tests/test_builder.py @@ -248,3 +248,27 @@ def test_build_domain_reputation_bundle_success(builder): assert len(notes) == 1 assert "Score: 45" in notes[0].abstract assert "Score: 45" in notes[0].content + +def test_build_dns_bundle_unsupported_record_type(builder): + """Triggers line 173 in builder.py by passing an unhandled DNS record type (e.g., TXT).""" + dns_data = { + "dns_records": [ + {"type": "TXT", "value": "v=spf1 include:_spf.google.com ~all"} + ] + } + bundle = builder.build_dns_bundle("example.com", dns_data) + assert bundle is not None + # Only Identity and Domain-Name objects are present; the TXT record was safely skipped + assert len(bundle.objects) == 2 + + +def test_build_ssl_bundle_empty_certs(builder): + """Triggers line 218 in builder.py by passing an empty sslCertificates list.""" + bundle = builder.build_ssl_bundle("example.com", {"sslCertificates": []}) + assert bundle is None + + +def test_is_ip_address_invalid_string(builder): + """Triggers line 363 in builder.py by passing a non-IP string to is_ip_address.""" + assert builder.is_ip_address("example.com") is False + assert builder.is_ip_address("not-an-ip-address") is False \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/tests/test_config_variables.py b/internal-enrichment/whoisfreaks/tests/test_config_variables.py new file mode 100644 index 00000000000..d3081969dff --- /dev/null +++ b/internal-enrichment/whoisfreaks/tests/test_config_variables.py @@ -0,0 +1,61 @@ +import pytest +from config_variables import ConfigVariables + + +@pytest.fixture +def valid_env(monkeypatch): + """Provides a complete set of valid environment variables.""" + env = { + "OPENCTI_URL": "http://localhost:8080", + "OPENCTI_TOKEN": "test-token-uuid-1234", + "CONNECTOR_ID": "whoisfreaks-connector-id-1234", + "CONNECTOR_NAME": "WhoisFreaks", + "CONNECTOR_SCOPE": "Domain-Name,IPv4-Addr,IPv6-Addr", + "CONNECTOR_LOG_LEVEL": "info", + "WHOISFREAKS_API_KEY": "test_whoisfreaks_api_key_xyz", + } + for key, value in env.items(): + monkeypatch.setenv(key, value) + return env + + +def test_config_variables_success(valid_env): + """Tests successful instantiation when all environment variables are present.""" + config = ConfigVariables() + assert config.opencti_url == "http://localhost:8080" + assert config.opencti_token == "test-token-uuid-1234" + assert config.whoisfreaks_api_key == "test_whoisfreaks_api_key_xyz" + + +def test_config_variables_missing_opencti_url(valid_env, monkeypatch): + """Executes validation failure lines when OPENCTI_URL is set to default/invalid value.""" + monkeypatch.setenv("OPENCTI_URL", "ChangeMe") + with pytest.raises(SystemExit): + ConfigVariables() + + +def test_config_variables_missing_opencti_token(valid_env, monkeypatch): + """Executes validation failure lines when OPENCTI_TOKEN is set to default/invalid value.""" + monkeypatch.setenv("OPENCTI_TOKEN", "ChangeMe") + with pytest.raises(SystemExit): + ConfigVariables() + + +def test_config_variables_missing_api_key(valid_env, monkeypatch): + """Executes validation failure lines when WHOISFREAKS_API_KEY is set to default/invalid value.""" + monkeypatch.setenv("WHOISFREAKS_API_KEY", "ChangeMe") + with pytest.raises(SystemExit): + ConfigVariables() + + +def test_config_variables_empty_values(valid_env, monkeypatch): + """Executes validation checks when required variables are empty strings.""" + monkeypatch.setenv("WHOISFREAKS_API_KEY", "") + with pytest.raises(SystemExit): + ConfigVariables() + +def test_config_variables_missing_connector_id(valid_env, monkeypatch): + """Executes validation failure lines when CONNECTOR_ID is set to ChangeMe.""" + monkeypatch.setenv("CONNECTOR_ID", "ChangeMe") + with pytest.raises(SystemExit): + ConfigVariables() \ No newline at end of file diff --git a/internal-enrichment/whoisfreaks/tests/test_connector.py b/internal-enrichment/whoisfreaks/tests/test_connector.py index 01c1f27b9cb..0964bcba5df 100644 --- a/internal-enrichment/whoisfreaks/tests/test_connector.py +++ b/internal-enrichment/whoisfreaks/tests/test_connector.py @@ -195,3 +195,14 @@ def test_connector_start(mock_dependencies): connector.start() helper.listen.assert_called_once_with(connector.process_message) + +def test_process_message_unsupported_observable_type(mock_dependencies, monkeypatch): + """Executes lines 115-116 in main.py by processing an unsupported observable type with a valid work_id.""" + from main import WhoisFreaksConnector + + connector = WhoisFreaksConnector() + monkeypatch.setattr( + connector, "_get_entity_info", lambda entity_id: ("StixFile", "sample.exe") + ) + result = connector.process_message({"entity_id": "file--1234", "work_id": "work-1234"}) + assert result == "Unsupported observable type" \ No newline at end of file