Skip to content

Commit f1a3ac5

Browse files
authored
Phunter Analyzer (#2841)
* phunter * fixed DeepSource errors * fixed errors related to docker run method * fixed errors with sensitive data leaks * fixed wrong number issue * fixed some minor bugs in phunter * used shlex and removed repeated phonenumber check * fixed migration issue * cleaned * fixed migration file name * chore: trigger CI * updated requirements file
1 parent 5d42723 commit f1a3ac5

File tree

11 files changed

+363
-5
lines changed

11 files changed

+363
-5
lines changed

api_app/analyzers_manager/classes.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,11 @@ def _raise_container_not_running(self) -> None:
399399
)
400400

401401
def _docker_run(
402-
self, req_data: dict, req_files: dict = None, analyzer_name: str = None
402+
self,
403+
req_data: dict,
404+
req_files: dict = None,
405+
analyzer_name: str = None,
406+
avoid_polling: bool = False,
403407
) -> dict:
404408
"""
405409
Helper function that takes of care of requesting new analysis,
@@ -433,8 +437,8 @@ def _docker_run(
433437
self._raise_container_not_running()
434438

435439
# step #2: raise AnalyzerRunException in case of error
436-
# Modified to support synchronous analyzer BBOT that return results directly in the initial response, avoiding unnecessary polling.
437-
if analyzer_name == "BBOT_Analyzer":
440+
# Modified to support synchronous analyzers that return results directly in the initial response, avoiding unnecessary polling.
441+
if avoid_polling:
438442
report = resp1.json().get("report", None)
439443
err = resp1.json().get("error", None)
440444
else:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from django.db import migrations
2+
from django.db.models.fields.related_descriptors import (
3+
ForwardManyToOneDescriptor,
4+
ForwardOneToOneDescriptor,
5+
ManyToManyDescriptor,
6+
ReverseManyToOneDescriptor,
7+
ReverseOneToOneDescriptor,
8+
)
9+
10+
plugin = {
11+
"python_module": {
12+
"health_check_schedule": None,
13+
"update_schedule": None,
14+
"module": "phunter.PhunterAnalyzer",
15+
"base_path": "api_app.analyzers_manager.observable_analyzers",
16+
},
17+
"name": "Phunter",
18+
"description": "[Phunter Analyzer](https://github.com/N0rz3/Phunter) is an OSINT tool for finding information about a phone number.",
19+
"disabled": False,
20+
"soft_time_limit": 60,
21+
"routing_key": "default",
22+
"health_check_status": True,
23+
"type": "observable",
24+
"docker_based": True,
25+
"maximum_tlp": "RED",
26+
"observable_supported": ["generic"],
27+
"supported_filetypes": [],
28+
"run_hash": False,
29+
"run_hash_type": "",
30+
"not_supported_filetypes": [],
31+
"mapping_data_model": {},
32+
"model": "analyzers_manager.AnalyzerConfig",
33+
}
34+
35+
params = []
36+
37+
values = []
38+
39+
40+
def _get_real_obj(Model, field, value):
41+
def _get_obj(Model, other_model, value):
42+
if isinstance(value, dict):
43+
real_vals = {}
44+
for key, real_val in value.items():
45+
real_vals[key] = _get_real_obj(other_model, key, real_val)
46+
value = other_model.objects.get_or_create(**real_vals)[0]
47+
# it is just the primary key serialized
48+
else:
49+
if isinstance(value, int):
50+
if Model.__name__ == "PluginConfig":
51+
value = other_model.objects.get(name=plugin["name"])
52+
else:
53+
value = other_model.objects.get(pk=value)
54+
else:
55+
value = other_model.objects.get(name=value)
56+
return value
57+
58+
if (
59+
type(getattr(Model, field))
60+
in [
61+
ForwardManyToOneDescriptor,
62+
ReverseManyToOneDescriptor,
63+
ReverseOneToOneDescriptor,
64+
ForwardOneToOneDescriptor,
65+
]
66+
and value
67+
):
68+
other_model = getattr(Model, field).get_queryset().model
69+
value = _get_obj(Model, other_model, value)
70+
elif type(getattr(Model, field)) in [ManyToManyDescriptor] and value:
71+
other_model = getattr(Model, field).rel.model
72+
value = [_get_obj(Model, other_model, val) for val in value]
73+
return value
74+
75+
76+
def _create_object(Model, data):
77+
mtm, no_mtm = {}, {}
78+
for field, value in data.items():
79+
value = _get_real_obj(Model, field, value)
80+
if type(getattr(Model, field)) is ManyToManyDescriptor:
81+
mtm[field] = value
82+
else:
83+
no_mtm[field] = value
84+
try:
85+
o = Model.objects.get(**no_mtm)
86+
except Model.DoesNotExist:
87+
o = Model(**no_mtm)
88+
o.full_clean()
89+
o.save()
90+
for field, value in mtm.items():
91+
attribute = getattr(o, field)
92+
if value is not None:
93+
attribute.set(value)
94+
return False
95+
return True
96+
97+
98+
def migrate(apps, schema_editor):
99+
Parameter = apps.get_model("api_app", "Parameter")
100+
PluginConfig = apps.get_model("api_app", "PluginConfig")
101+
python_path = plugin.pop("model")
102+
Model = apps.get_model(*python_path.split("."))
103+
if not Model.objects.filter(name=plugin["name"]).exists():
104+
exists = _create_object(Model, plugin)
105+
if not exists:
106+
for param in params:
107+
_create_object(Parameter, param)
108+
for value in values:
109+
_create_object(PluginConfig, value)
110+
111+
112+
def reverse_migrate(apps, schema_editor):
113+
python_path = plugin.pop("model")
114+
Model = apps.get_model(*python_path.split("."))
115+
Model.objects.get(name=plugin["name"]).delete()
116+
117+
118+
class Migration(migrations.Migration):
119+
atomic = False
120+
dependencies = [
121+
("api_app", "0071_delete_last_elastic_report"),
122+
("analyzers_manager", "0156_alter_analyzer_config_required_api_key_abuse_ch"),
123+
]
124+
125+
operations = [migrations.RunPython(migrate, reverse_migrate)]

api_app/analyzers_manager/observable_analyzers/bbot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def run(self):
5454
logger.info(f"Sending {self.name} scan request: {req_data} to {self.url}")
5555

5656
try:
57-
report = self._docker_run(req_data, analyzer_name=self.name)
57+
report = self._docker_run(
58+
req_data, analyzer_name=self.name, avoid_polling=True
59+
)
5860
logger.info(f"BBOT scan completed successfully with report: {report}")
5961
return report
6062
except requests.RequestException as e:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import logging
2+
3+
import phonenumbers
4+
import requests
5+
6+
from api_app.analyzers_manager.classes import DockerBasedAnalyzer, ObservableAnalyzer
7+
from api_app.analyzers_manager.exceptions import AnalyzerRunException
8+
from tests.mock_utils import MockUpResponse
9+
10+
logging.basicConfig(level=logging.DEBUG)
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class PhunterAnalyzer(ObservableAnalyzer, DockerBasedAnalyzer):
15+
name: str = "Phunter"
16+
url: str = "http://phunter:5612/analyze"
17+
max_tries: int = 1
18+
poll_distance: int = 0
19+
20+
def run(self):
21+
try:
22+
parsed_number = phonenumbers.parse(self.observable_name)
23+
24+
formatted_number = phonenumbers.format_number(
25+
parsed_number, phonenumbers.PhoneNumberFormat.E164
26+
)
27+
except phonenumbers.phonenumberutil.NumberParseException:
28+
logger.error(f"Phone number parsing failed for: {self.observable_name}")
29+
return {"success": False, "error": "Invalid phone number"}
30+
31+
req_data = {"phone_number": formatted_number}
32+
logger.info(f"Sending {self.name} scan request: {req_data} to {self.url}")
33+
34+
try:
35+
response = self._docker_run(
36+
req_data, analyzer_name=self.name, avoid_polling=True
37+
)
38+
logger.info(f"[{self.name}] Scan successful by Phunter. Result: {response}")
39+
return response
40+
41+
except requests.exceptions.RequestException as e:
42+
raise AnalyzerRunException(
43+
f"[{self.name}] Request failed due to network issue: {e}"
44+
)
45+
46+
except ValueError as e:
47+
raise AnalyzerRunException(f"[{self.name}] Invalid response format: {e}")
48+
49+
except Exception as e:
50+
raise AnalyzerRunException(f"{self.name} An unexpected error occurred: {e}")
51+
52+
@classmethod
53+
def update(self):
54+
pass
55+
56+
@staticmethod
57+
def mocked_docker_analyzer_post(*args, **kwargs):
58+
mock_response = {
59+
"success": True,
60+
"report": {
61+
"valid": "yes",
62+
"views": "9",
63+
"carrier": "Vodafone",
64+
"location": "India",
65+
"operator": "Vodafone",
66+
"possible": "yes",
67+
"line_type": "FIXED LINE OR MOBILE",
68+
"local_time": "21:34:45",
69+
"spam_status": "Not spammer",
70+
"phone_number": "+911234567890",
71+
"national_format": "01234567890",
72+
"international_format": "+91 1234567890",
73+
},
74+
}
75+
return MockUpResponse(mock_response, 200)

integrations/phunter/Dockerfile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
FROM python:3.12-slim
2+
3+
# Install dependencies
4+
RUN apt-get update && apt-get install -y --no-install-recommends git
5+
6+
# Clone Phunter
7+
RUN git clone https://github.com/N0rz3/Phunter.git /app/Phunter
8+
9+
# Set working directory
10+
WORKDIR /app
11+
12+
# Copy requirements file and app.py to the working directory
13+
COPY requirements.txt app.py ./
14+
15+
# Upgrade pip and install Python packages
16+
RUN pip install --no-cache-dir --upgrade pip && \
17+
pip install --no-cache-dir -r requirements.txt && \
18+
pip install --no-cache-dir -r /app/Phunter/requirements.txt
19+
20+
# Expose port
21+
EXPOSE 5612
22+
23+
# Run the app
24+
CMD ["python", "app.py"]

integrations/phunter/app.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import logging
2+
import re
3+
import shlex
4+
import subprocess
5+
6+
from flask import Flask, jsonify, request
7+
8+
# Logging Configuration
9+
logging.basicConfig(level=logging.DEBUG)
10+
logger = logging.getLogger(__name__)
11+
12+
app = Flask(__name__)
13+
14+
15+
def strip_ansi_codes(text):
16+
"""Remove ANSI escape codes from terminal output"""
17+
return re.sub(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", text)
18+
19+
20+
def parse_phunter_output(output):
21+
"""Parse output from Phunter CLI and convert to structured JSON"""
22+
result = {}
23+
key_mapping = {
24+
"phone number:": "phone_number",
25+
"possible:": "possible",
26+
"valid:": "valid",
27+
"operator:": "operator",
28+
"possible location:": "location",
29+
"location:": "location",
30+
"carrier:": "carrier",
31+
"line type:": "line_type",
32+
"international:": "international_format",
33+
"national:": "national_format",
34+
"local time:": "local_time",
35+
"views count:": "views",
36+
}
37+
38+
lines = output.splitlines()
39+
40+
for line in lines:
41+
line = line.strip().lower()
42+
43+
if "not spammer" in line:
44+
result["spam_status"] = "Not spammer"
45+
continue
46+
47+
for keyword, key in key_mapping.items():
48+
if keyword in line:
49+
value = line.partition(":")[2].strip()
50+
if key in ("possible", "valid"):
51+
result[key] = "yes" if "✔" in value else "no"
52+
else:
53+
result[key] = value
54+
break
55+
56+
return result
57+
58+
59+
@app.route("/analyze", methods=["POST"])
60+
def analyze():
61+
data = request.get_json()
62+
phone_number = data.get("phone_number")
63+
64+
logger.info("Received analysis request")
65+
66+
if not phone_number:
67+
logger.warning("No phone number provided in request")
68+
return jsonify({"error": "No phone number provided"}), 400
69+
70+
try:
71+
logger.info("Executing Phunter CLI tool")
72+
command_str = f"python3 phunter.py -t {phone_number}"
73+
command = shlex.split(command_str)
74+
result = subprocess.run(
75+
command,
76+
capture_output=True,
77+
text=True,
78+
check=True,
79+
cwd="/app/Phunter",
80+
)
81+
82+
raw_output = result.stdout
83+
clean_output = strip_ansi_codes(raw_output)
84+
parsed_output = parse_phunter_output(clean_output)
85+
86+
logger.info("Phunter analysis completed")
87+
88+
return (
89+
jsonify(
90+
{
91+
"success": True,
92+
"report": parsed_output,
93+
}
94+
),
95+
200,
96+
)
97+
98+
except subprocess.CalledProcessError as e:
99+
return jsonify({"error": f"Phunter execution failed with error {e}"}), 500
100+
101+
102+
if __name__ == "__main__":
103+
logger.info("Starting Phunter Flask API...")
104+
app.run(host="0.0.0.0", port=5612)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
phunter:
3+
build:
4+
context: ../integrations/phunter
5+
dockerfile: Dockerfile
6+
image: intelowlproject/intelowl_phunter:test

integrations/phunter/compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
services:
2+
phunter:
3+
image: intelowlproject/intelowl_phunter:${REACT_APP_INTELOWL_VERSION}
4+
container_name: intelowl_phunter
5+
restart: unless-stopped
6+
expose:
7+
- "5612"
8+
volumes:
9+
- generic_logs:/var/log/intel_owl
10+
depends_on:
11+
- uwsgi

integrations/phunter/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
flask==3.1.1

requirements/project-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ pylnk3==0.4.2
8989
androguard==3.4.0a1 # version >=4.x of androguard raises a dependency conflict with quark-engine==25.1.1
9090
wad==0.4.6
9191
debloat==1.6.4
92+
phonenumbers==9.0.3
9293
die-python==0.4.0
9394

9495
# httpx required for HTTP/2 support (Mullvad DNS rejects HTTP/1.1 with protocol errors)

0 commit comments

Comments
 (0)