Skip to content

Commit bfe5bc0

Browse files
authored
Merge pull request #1654 from netalertx/next_release
BE: SYNC API logging
2 parents 8e391f5 + 813a92d commit bfe5bc0

6 files changed

Lines changed: 203 additions & 55 deletions

File tree

docs/SECURITY.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This includes (but is not limited to):
88
- Running NetAlertX only on networks where you have legal authorization
99
- Keeping your deployment up to date with the latest patches
1010

11-
> NetAlertX is not responsible for misuse, misconfiguration, or unsecure deployments. Always test and secure your setup before exposing it to the outside world.
11+
> NetAlertX is not responsible for misuse, misconfiguration, or insecure deployments. Always test and secure your setup before exposing it to the outside world. Users interacting with the UI are treated as trusted actors within the deployment model.
1212
1313
# 🔐 Securing Your NetAlertX Instance
1414

@@ -36,7 +36,7 @@ NetAlertX is designed to be run on **private LANs**, not the open internet.
3636

3737
### ✅ Tailscale (Easy VPN Alternative)
3838

39-
Tailscale sets up a private mesh network between your devices. It's fast to configure and ideal for NetAlertX.
39+
Tailscale sets up a private mesh network between your devices. It's fast to configure and ideal for NetAlertX.
4040
👉 [Get started with Tailscale](https://tailscale.com/)
4141

4242
---
@@ -63,19 +63,19 @@ By default, NetAlertX does **not** require login. Before exposing the UI in any
6363

6464
## 🔥 Additional Security Measures
6565

66-
- **Firewall / Network Rules**
66+
- **Firewall / Network Rules**
6767
Restrict UI/API access to trusted IPs only.
6868

69-
- **Limit Docker Capabilities**
69+
- **Limit Docker Capabilities**
7070
Avoid `--privileged`. Use `--cap-add=NET_RAW` and others **only if required** by your scan method.
7171

72-
- **Keep NetAlertX Updated**
72+
- **Keep NetAlertX Updated**
7373
Regular updates contain bug fixes and security patches.
7474

75-
- **Plugin Permissions**
75+
- **Plugin Permissions**
7676
Disable unused plugins. Only install from trusted sources.
7777

78-
- **Use Read-Only API Keys**
78+
- **Use Read-Only API Keys**
7979
When integrating NetAlertX with other tools, scope keys tightly.
8080

8181
---

front/plugins/sync/sync.py

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,29 @@ def main():
182182
# are pipe-delimited — catch and skip them via the JSONDecodeError guard below.
183183
parts = file_name.split('.')
184184
if len(parts) > 2:
185-
# Extract node name:
186-
# decoded/encoded: last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
187-
# pull mode: last_result.NodeName.log → parts[1]
188-
if 'decoded' in file_name or 'encoded' in file_name:
189-
syncHubNodeName = parts[3]
185+
# PUSH artifacts:
186+
# last_result.PLUGIN.decoded.NodeName.N.log
187+
# last_result.PLUGIN.encoded.NodeName.N.log
188+
#
189+
# Require BOTH:
190+
# 1. decoded/encoded marker
191+
# 2. trailing ".<counter>.log" shape
192+
#
193+
# This prevents PULL filenames like:
194+
# last_result.office.encoded.lab.log
195+
# from being incorrectly parsed as PUSH artifacts.
196+
is_push_artifact = (
197+
('.decoded.' in file_name or '.encoded.' in file_name) and file_name.rsplit('.', 2)[1].isdigit()
198+
)
199+
200+
if is_push_artifact:
201+
_marker = '.decoded.' if '.decoded.' in file_name else '.encoded.'
202+
_, _after = file_name.split(_marker, 1)
203+
syncHubNodeName = _after.rsplit('.', 2)[0]
190204
else:
191-
syncHubNodeName = parts[1]
205+
# PULL artifact:
206+
# last_result.NodeName.log
207+
syncHubNodeName = file_name[len('last_result.'):-len('.log')]
192208

193209
file_path = f"{LOG_PATH}/{file_name}"
194210

@@ -284,7 +300,6 @@ def main():
284300
return 0
285301

286302

287-
# ------------------------------------------------------------------
288303
# Data retrieval methods
289304
api_endpoints = [
290305
"/sync", # New Python-based endpoint
@@ -293,39 +308,87 @@ def main():
293308

294309
# send data to the HUB
295310
def send_data(api_token, file_content, encryption_key, file_path, node_name, pref, hub_url):
296-
"""Send encrypted data to HUB, preferring /sync endpoint and falling back to PHP version."""
311+
"""
312+
Sends encrypted plugin output from NODE → HUB.
313+
314+
Flow:
315+
1. Encrypt plugin output locally
316+
2. Build payload (data + metadata)
317+
3. Try each configured HUB endpoint in order
318+
4. On success (200) → stop immediately
319+
5. On failure → log HUB response + continue fallback
320+
6. If all endpoints fail → alert user
321+
"""
322+
323+
# STEP 1: Encrypt raw plugin output before transmission
297324
encrypted_data = encrypt_data(file_content, encryption_key)
298-
mylog('verbose', [f'[{pluginName}] Sending encrypted_data: "{encrypted_data}"'])
299325

326+
mylog('verbose', [f"[{pluginName}] Encrypted payload prepared type={type(encrypted_data).__name__}"])
327+
328+
# STEP 2: Build request payload for HUB sync API
300329
data = {
301330
'data': encrypted_data,
302331
'file_path': file_path,
303332
'plugin': pref,
304333
'node_name': node_name
305334
}
306-
headers = {'Authorization': f'Bearer {api_token}'}
307335

336+
headers = {
337+
'Authorization': f'Bearer {api_token}'
338+
}
339+
340+
# STEP 3: Attempt delivery to each configured endpoint
308341
for endpoint in api_endpoints:
309342

310343
final_endpoint = hub_url + endpoint
311344

312345
try:
313-
response = requests.post(final_endpoint, json=data, headers=headers, timeout=5)
314-
mylog('verbose', [f'[{pluginName}] Tried endpoint: {final_endpoint}, status: {response.status_code}'])
315346

347+
# STEP 4: Send request to HUB sync endpoint
348+
response = requests.post(
349+
final_endpoint,
350+
json=data,
351+
headers=headers,
352+
timeout=5
353+
)
354+
355+
# STEP 5a: Success path (HUB accepted payload)
316356
if response.status_code == 200:
317-
message = f'[{pluginName}] Data for "{file_path}" sent successfully via {final_endpoint}'
357+
message = (f'[{pluginName}] Sync success for "{file_path}" via {final_endpoint}')
318358
mylog('verbose', [message])
319359
write_notification(message, 'info', timeNowUTC())
320360
return True
321361

362+
# STEP 5b: HUB returned error (e.g. 500, 400)
363+
try:
364+
response_json = response.json()
365+
except Exception:
366+
response_json = {}
367+
368+
# Extract best available error message
369+
error_msg = (
370+
response_json.get("error") or response_json.get("message") or response.text
371+
)
372+
373+
msg = (f'[{pluginName}] HUB error on {final_endpoint} [{response.status_code}]: {error_msg}')
374+
375+
mylog('none', [msg])
376+
write_notification(msg, 'alert', timeNowUTC())
377+
378+
mylog('verbose', [f'[{pluginName}] Endpoint attempted: {final_endpoint} status={response.status_code}'])
379+
322380
except requests.RequestException as e:
323-
mylog('verbose', [f'[{pluginName}] Error calling {final_endpoint}: {e}'])
381+
# STEP 5c: Network-level failure (timeout, DNS, etc.)
382+
mylog('verbose', [f'[{pluginName}] Request exception calling {final_endpoint} error={type(e).__name__}: {e}'])
324383

325-
# If all endpoints fail
326-
message = f'[{pluginName}] Failed to send data for "{file_path}" via all endpoints'
327-
mylog('verbose', [message])
384+
# STEP 6: All endpoints failed → final fallback alert
385+
message = (
386+
f'[{pluginName}] All HUB endpoints failed for "{file_path}"'
387+
)
388+
389+
mylog('none', [message])
328390
write_notification(message, 'alert', timeNowUTC())
391+
329392
return False
330393

331394

server/api_server/sync_endpoint.py

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import base64
34
from flask import jsonify, request
@@ -6,16 +7,14 @@
67
from utils.datetime_utils import timeNowUTC
78
from messaging.in_app import write_notification
89

9-
INSTALL_PATH = os.getenv("NETALERTX_APP", "/app")
10-
1110
# Make sure log level is initialized correctly
1211
lggr = Logger(get_setting_value('LOG_LEVEL'))
1312

1413

1514
def handle_sync_get():
1615
"""Handle GET requests for SYNC (NODE → HUB)."""
1716

18-
# get all dwevices from the api endpoint
17+
# get all devices from the api endpoint
1918
api_path = os.environ.get('NETALERTX_API', '/tmp/api')
2019

2120
file_path = f"/{api_path}/table_devices.json"
@@ -47,40 +46,100 @@ def handle_sync_get():
4746

4847
def handle_sync_post():
4948
"""Handle POST requests for SYNC (HUB receiving from NODE)."""
50-
body = request.get_json(silent=True) or {}
49+
50+
mylog("debug", [
51+
"[SYNC API] ENTER handle_sync_post",
52+
f"method={request.method}",
53+
f"content_type={request.content_type}",
54+
f"content_length={request.content_length}",
55+
f"remote_addr={request.remote_addr}"
56+
])
57+
58+
# ---- RAW BODY (critical for debugging encoding / encryption issues)
59+
try:
60+
raw = request.get_data(cache=False)
61+
mylog("debug", [
62+
f"[SYNC API] raw_bytes_len={len(raw)} raw_preview={raw[:200]}"
63+
])
64+
except Exception as e:
65+
mylog("none", [f"[SYNC API] FAILED reading raw body: {e}"])
66+
write_notification("[SYNC API] FAILED reading raw body - see app.log", 'alert', timeNowUTC())
67+
return jsonify({"error": "failed reading body"}), 400
68+
69+
# ---- JSON PARSE (from already-read raw bytes to avoid empty-stream re-read)
70+
try:
71+
body = json.loads(raw)
72+
mylog("debug", [f"[SYNC API] parsed_json={body}"])
73+
except Exception as e:
74+
msg = f"[SYNC API] JSON_PARSE_FAILED={e}"
75+
mylog("none", [msg])
76+
write_notification(msg, 'alert', timeNowUTC())
77+
return jsonify({"error": "invalid json"}), 400
78+
79+
# ---- EXTRACT FIELDS
5180
data = body.get("data", "")
5281
node_name = body.get("node_name", "")
5382
plugin = body.get("plugin", "")
5483

55-
storage_path = INSTALL_PATH + "/log/plugins"
56-
os.makedirs(storage_path, exist_ok=True)
57-
58-
encoded_files = [
59-
f
60-
for f in os.listdir(storage_path)
61-
if f.startswith(f"last_result.{plugin}.encoded.{node_name}")
62-
]
63-
decoded_files = [
64-
f
65-
for f in os.listdir(storage_path)
66-
if f.startswith(f"last_result.{plugin}.decoded.{node_name}")
67-
]
68-
file_count = len(encoded_files + decoded_files) + 1
84+
mylog("debug", [
85+
f"[SYNC API] node_name={repr(node_name)} plugin={repr(plugin)} data_type={type(data).__name__} data_len={len(data) if isinstance(data, str) else 'non-string'}"
86+
])
87+
88+
storage_path = os.getenv("NETALERTX_PLUGINS_LOG", "/tmp/log/plugins")
89+
90+
try:
91+
os.makedirs(storage_path, exist_ok=True)
92+
mylog("debug", [f"[SYNC API] storage_path_ready={storage_path}"])
93+
except Exception as e:
94+
msg = f"[SYNC API] MKDIR_FAILED={e}"
95+
mylog("none", [msg])
96+
write_notification(msg, 'alert', timeNowUTC())
97+
return jsonify({"error": "storage path error"}), 500
6998

99+
# ---- FILE COUNT LOGIC
100+
try:
101+
encoded_files = [
102+
f for f in os.listdir(storage_path)
103+
if f.startswith(f"last_result.{plugin}.encoded.{node_name}")
104+
]
105+
decoded_files = [
106+
f for f in os.listdir(storage_path)
107+
if f.startswith(f"last_result.{plugin}.decoded.{node_name}")
108+
]
109+
file_count = len(encoded_files + decoded_files) + 1
110+
111+
mylog("debug", [f"[SYNC API] encoded_files={len(encoded_files)} decoded_files={len(decoded_files)} file_count={file_count}"])
112+
except Exception as e:
113+
msg = f"[SYNC API] LISTDIR_FAILED={e}"
114+
mylog("none", [msg])
115+
write_notification(msg, 'alert', timeNowUTC())
116+
return jsonify({"error": "listdir failed"}), 500
117+
118+
# ---- FILE PATH
70119
file_path_new = os.path.join(
71-
storage_path, f"last_result.{plugin}.encoded.{node_name}.{file_count}.log"
120+
storage_path,
121+
f"last_result.{plugin}.encoded.{node_name}.{file_count}.log"
72122
)
73123

124+
mylog("verbose", [f"[SYNC API] file_path_new={file_path_new}"])
125+
74126
try:
127+
if not isinstance(data, str):
128+
data = str(data)
129+
75130
with open(file_path_new, "w") as f:
76131
f.write(data)
132+
77133
except Exception as e:
78-
msg = f"[Plugin: SYNC] Failed to store data: {e}"
79-
write_notification(msg, "alert", timeNowUTC())
80-
mylog("verbose", [msg])
81-
return jsonify({"error": msg}), 500
134+
135+
msg = f"[Plugin: SYNC] Data write failed ({file_path_new}): {e}"
136+
mylog("none", [msg])
137+
write_notification(msg, 'alert', timeNowUTC())
138+
return jsonify({"error": str(e)}), 500
82139

83140
msg = f"[Plugin: SYNC] Data received ({file_path_new})"
84-
write_notification(msg, "info", timeNowUTC())
141+
if lggr.isAbove('verbose'):
142+
write_notification(msg, 'info', timeNowUTC())
85143
mylog("verbose", [msg])
144+
86145
return jsonify({"message": "Data received and stored successfully"}), 200

server/plugin.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -824,8 +824,7 @@ def process_plugin_events(db, plugin, plugEventsArr):
824824
# find corresponding object for the event and merge
825825
if plugObj.idsHash == tmpObjFromEvent.idsHash:
826826
if (
827-
plugObj.status == "missing-in-last-scan"
828-
or tmpObjFromEvent.status == "watched-changed"
827+
plugObj.status == "missing-in-last-scan" or tmpObjFromEvent.status == "watched-changed"
829828
):
830829
changed_this_cycle.add(tmpObjFromEvent.idsHash)
831830
pluginObjects[index] = combine_plugin_objects(

server/utils/datetime_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import pytz
88
from typing import Union, Optional
9-
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
9+
from zoneinfo import ZoneInfo
1010
import email.utils
1111
import conf
1212
# from const import *

test/plugins/test_sync_protocol.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,18 @@ def _node_name_from_filename(file_name: str) -> str:
7171
"""Mirror of the node-name extraction in sync.main() (Mode 3).
7272
7373
Real file formats produced by the system:
74-
PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log → parts[3]
75-
PULL: last_result.NodeName.log → parts[1]
74+
PUSH (post-decode): last_result.PLUGIN.decoded.NodeName.N.log
75+
— split on '.decoded.' marker, strip .N.log with rsplit from the right
76+
PULL: last_result.NodeName.log
77+
— strip 'last_result.' prefix and '.log' suffix
78+
79+
Both forms handle dots anywhere in PLUGIN or NodeName.
7680
"""
77-
parts = file_name.split(".")
78-
return parts[3] if ("decoded" in file_name or "encoded" in file_name) else parts[1]
81+
if '.decoded.' in file_name or '.encoded.' in file_name:
82+
marker = '.decoded.' if '.decoded.' in file_name else '.encoded.'
83+
_, after = file_name.split(marker, 1)
84+
return after.rsplit('.', 2)[0]
85+
return file_name[len('last_result.'):-len('.log')]
7986

8087

8188
def _should_delete_after_process(filename: str) -> bool:
@@ -339,6 +346,26 @@ def test_push_decoded_different_plugins(self):
339346
assert _node_name_from_filename(fname) == "HubNode", \
340347
f"Expected 'HubNode' from {fname}"
341348

349+
# --- dot-in-identifier regression (fragile parts[3] fix) ---
350+
351+
def test_pull_node_name_with_dots(self):
352+
# PULL mode: node name set to e.g. "node.home" or an IP like "192.168.1.82"
353+
assert _node_name_from_filename("last_result.node.home.log") == "node.home"
354+
assert _node_name_from_filename("last_result.192.168.1.82.log") == "192.168.1.82"
355+
356+
def test_push_decoded_node_name_with_dots(self):
357+
# Node name "Node.Vlan01" must survive the filename round-trip intact
358+
assert _node_name_from_filename("last_result.ARPSCAN.decoded.Node.Vlan01.1.log") == "Node.Vlan01"
359+
360+
def test_push_decoded_plugin_name_with_dots(self):
361+
# Hypothetical plugin with a dot in its name must not shift the node index
362+
assert _node_name_from_filename("last_result.MY.PLUGIN.decoded.NodeA.1.log") == "NodeA"
363+
364+
def test_push_both_identifiers_with_dots(self):
365+
assert _node_name_from_filename(
366+
"last_result.A.B.decoded.x.y.z.1.log"
367+
) == "x.y.z"
368+
342369

343370
# ===========================================================================
344371
# CurrentScan candidates filter (Mode 3 – RECEIVE)

0 commit comments

Comments
 (0)