Skip to content

Commit f0cd020

Browse files
authored
feat: Refactor parser sync to use correct SIEM paths and add auto-sync support (#68)
- Changed parser paths from `/parsers/` to `/logParsers/` with `.json` extension to match SIEM config tree structure - Added `LOCAL_PARSER_ALIASES` mapping to handle marketplace parser names that don't match local directory names (e.g., marketplace-paloaltonetworksfirewall-latest -> paloalto_firewall-latest) - Updated Palo Alto firewall sourcetype mapping from `paloalto_logs-latest` to `paloalto_firewall-latest` - Ref
1 parent c1e6678 commit f0cd020

10 files changed

Lines changed: 3273 additions & 720 deletions

File tree

Backend/api/app/routers/parser_sync.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,15 @@ async def sync_single_parser(
135135
service = ParserSyncService(config_api_url=request.config_api_url)
136136

137137
logger.info(f"Syncing single parser for sourcetype: {request.sourcetype}")
138-
139-
# Use the sourcetype as the source name for lookup
140-
results = service.ensure_parsers_for_sources(
141-
sources=[request.sourcetype],
138+
139+
result = service.ensure_parser_for_sourcetype(
140+
sourcetype=request.sourcetype,
142141
config_write_token=request.config_write_token,
143142
github_repo_urls=request.github_repo_urls,
144-
github_token=request.github_token
143+
github_token=request.github_token,
145144
)
146-
147-
# Get result for the sourcetype
148-
result = results.get(request.sourcetype, {})
149-
status = result.get('status', 'no_parser')
150-
message = result.get('message', 'Unknown status')
145+
status = result.get("status", "no_parser")
146+
message = result.get("message", "Unknown status")
151147

152148
logger.info(f"Single parser sync for {request.sourcetype}: {status} - {message}")
153149

Backend/api/app/services/parser_sync_service.py

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@
1818

1919
logger = logging.getLogger(__name__)
2020

21+
22+
# Some generator sourcetypes correspond to marketplace parser names that don't exist
23+
# as local parser directories in this repo. Provide aliases so we can still upload
24+
# a reasonable local parser when asked to sync those names.
25+
LOCAL_PARSER_ALIASES: Dict[str, str] = {
26+
# Palo Alto Networks Firewall marketplace parser name -> local parser folder name
27+
"marketplace-paloaltonetworksfirewall-latest": "paloalto_firewall-latest",
28+
}
29+
2130
# Mapping from generator/source names to parser sourcetypes
2231
# This maps scenario sources to their corresponding parser directory names
2332
SCENARIO_SOURCE_TO_PARSER = {
@@ -49,7 +58,7 @@
4958

5059
# Network Security
5160
"darktrace": "darktrace_darktrace_logs-latest",
52-
"paloalto_firewall": "paloalto_logs-latest",
61+
"paloalto_firewall": "paloalto_firewall-latest",
5362
"f5_networks": "f5_networks_logs-latest",
5463
"fortinet_fortigate": "fortinet_fortigate_candidate_logs-latest",
5564
"zscaler": "zscaler_logs-latest",
@@ -125,9 +134,36 @@ def get_parser_path_in_siem(self, sourcetype: str) -> str:
125134
sourcetype: The parser sourcetype (e.g., 'okta_authentication-latest')
126135
127136
Returns:
128-
The parser path in SIEM (e.g., '/parsers/okta_authentication-latest')
137+
The parser path in SIEM (e.g., '/logParsers/okta_authentication-latest')
129138
"""
130-
return f"/parsers/{sourcetype}"
139+
# In the Scalyr/SentinelOne config tree, log parsers are stored as JSON files
140+
# under /logParsers.
141+
leaf = sourcetype
142+
if not leaf.endswith(".json"):
143+
leaf = f"{leaf}.json"
144+
return f"/logParsers/{leaf}"
145+
146+
def _local_parser_directories_for_sourcetype(self, sourcetype: str) -> List[Path]:
147+
local_name = LOCAL_PARSER_ALIASES.get(sourcetype, sourcetype)
148+
149+
# Handle prefixed sourcetypes produced by generator tooling (e.g., community-foo-latest)
150+
if local_name.startswith("community-"):
151+
leaf = local_name[len("community-"):]
152+
return [
153+
self.parsers_dir / "community" / leaf,
154+
self.parsers_dir / "community_new" / leaf,
155+
]
156+
if local_name.startswith("marketplace-"):
157+
leaf = local_name[len("marketplace-"):]
158+
return [
159+
self.parsers_dir / "marketplace" / leaf,
160+
]
161+
162+
return [
163+
self.parsers_dir / "community" / local_name,
164+
self.parsers_dir / "community_new" / local_name,
165+
self.parsers_dir / "sentinelone" / local_name,
166+
]
131167

132168
def load_local_parser(self, sourcetype: str) -> Optional[str]:
133169
"""
@@ -139,12 +175,7 @@ def load_local_parser(self, sourcetype: str) -> Optional[str]:
139175
Returns:
140176
The parser JSON content as string, or None if not found
141177
"""
142-
# Try community directory first
143-
parser_dirs = [
144-
self.parsers_dir / "community" / sourcetype,
145-
self.parsers_dir / "community_new" / sourcetype,
146-
self.parsers_dir / "sentinelone" / sourcetype,
147-
]
178+
parser_dirs = self._local_parser_directories_for_sourcetype(sourcetype)
148179

149180
for parser_dir in parser_dirs:
150181
if parser_dir.exists():
@@ -168,6 +199,54 @@ def load_local_parser(self, sourcetype: str) -> Optional[str]:
168199

169200
logger.warning(f"Parser not found locally: {sourcetype}")
170201
return None
202+
203+
def ensure_parser_for_sourcetype(
204+
self,
205+
sourcetype: str,
206+
config_write_token: str,
207+
github_repo_urls: Optional[List[str]] = None,
208+
github_token: Optional[str] = None,
209+
selected_parser: Optional[Dict] = None,
210+
) -> Dict[str, str]:
211+
parser_path = self.get_parser_path_in_siem(sourcetype)
212+
213+
exists, _ = self.check_parser_exists(config_write_token, parser_path)
214+
if exists:
215+
return {
216+
"status": "exists",
217+
"message": f"Parser already exists: {parser_path}",
218+
}
219+
220+
parser_content = self.load_local_parser(sourcetype)
221+
from_github = False
222+
223+
if not parser_content and github_repo_urls:
224+
parser_content = self.load_parser_from_github(
225+
sourcetype=sourcetype,
226+
repo_urls=github_repo_urls,
227+
selected_parser=selected_parser,
228+
github_token=github_token,
229+
)
230+
from_github = parser_content is not None
231+
232+
if not parser_content:
233+
return {
234+
"status": "no_parser",
235+
"message": f"Parser not found locally or in GitHub repos: {sourcetype}",
236+
}
237+
238+
success = self.upload_parser(config_write_token, parser_path, parser_content)
239+
ok, detail = success
240+
if not ok:
241+
return {
242+
"status": "failed",
243+
"message": f"Failed to upload parser: {parser_path} ({detail})",
244+
}
245+
246+
return {
247+
"status": "uploaded_from_github" if from_github else "uploaded",
248+
"message": f"Parser uploaded successfully: {parser_path}",
249+
}
171250

172251
def load_parser_from_github(
173252
self,
@@ -298,7 +377,7 @@ def upload_parser(
298377
parser_path: str,
299378
content: str,
300379
timeout: int = 30
301-
) -> bool:
380+
) -> Tuple[bool, str]:
302381
"""
303382
Upload a parser to the destination SIEM using putFile API
304383
@@ -309,7 +388,7 @@ def upload_parser(
309388
timeout: Request timeout in seconds
310389
311390
Returns:
312-
True if upload succeeded, False otherwise
391+
Tuple of (success, message)
313392
"""
314393
try:
315394
url = f"{self.api_base_url}/putFile"
@@ -332,25 +411,22 @@ def upload_parser(
332411
result = response.json()
333412
if result.get("status") == "success":
334413
logger.info(f"Parser uploaded successfully: {parser_path}")
335-
return True
414+
return True, "success"
336415
else:
337-
logger.error(
338-
f"Failed to upload parser {parser_path}: {result.get('message', 'Unknown error')}"
339-
)
340-
return False
416+
msg = result.get('message', 'Unknown error')
417+
logger.error(f"Failed to upload parser {parser_path}: {msg}")
418+
return False, msg
341419
else:
342-
logger.error(
343-
f"Failed to upload parser {parser_path}: "
344-
f"{response.status_code} - {response.text}"
345-
)
346-
return False
420+
msg = f"{response.status_code} - {response.text}"
421+
logger.error(f"Failed to upload parser {parser_path}: {msg}")
422+
return False, msg
347423

348424
except requests.exceptions.Timeout:
349425
logger.error(f"Timeout uploading parser: {parser_path}")
350-
return False
426+
return False, "timeout"
351427
except Exception as e:
352428
logger.error(f"Error uploading parser {parser_path}: {e}")
353-
return False
429+
return False, str(e)
354430

355431
def ensure_parsers_for_sources(
356432
self,
@@ -457,9 +533,9 @@ def ensure_parsers_for_sources(
457533
continue
458534

459535
# Upload the parser
460-
success = self.upload_parser(config_write_token, parser_path, parser_content)
536+
ok, detail = self.upload_parser(config_write_token, parser_path, parser_content)
461537

462-
if success:
538+
if ok:
463539
status = "uploaded_from_github" if from_github else "uploaded"
464540
source_label = "GitHub" if from_github else "local"
465541
results[source] = {
@@ -471,7 +547,7 @@ def ensure_parsers_for_sources(
471547
results[source] = {
472548
"status": "failed",
473549
"sourcetype": actual_sourcetype,
474-
"message": f"Failed to upload parser: {parser_path}"
550+
"message": f"Failed to upload parser: {parser_path} ({detail})"
475551
}
476552

477553
return results

Backend/event_generators/network_security/paloalto_firewall.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
from datetime import datetime, timezone, timedelta
66
import time
77

8+
import os
9+
import sys
10+
11+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'shared'))
12+
from randomization import Randomizer
13+
814
# Palo Alto log types
915
LOG_TYPES = ["TRAFFIC", "THREAT", "SYSTEM", "CONFIG", "HIP-MATCH", "GLOBALPROTECT", "USERID", "URL"]
1016

@@ -19,14 +25,18 @@
1925

2026
def get_random_ip(internal_probability=0.5):
2127
"""Generate a random IP address."""
22-
if random.random() < internal_probability:
23-
return random.choice([
24-
f"10.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}",
25-
f"172.{random.randint(16, 31)}.{random.randint(0, 255)}.{random.randint(1, 254)}",
26-
f"192.168.{random.randint(0, 255)}.{random.randint(1, 254)}"
27-
])
28-
else:
29-
return f"{random.randint(1, 223)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 254)}"
28+
internal = random.random() < internal_probability
29+
return _R.ip(internal=internal)
30+
31+
32+
def get_random_username(domain_probability: float = 0.7, empty_probability: float = 0.2) -> str:
33+
if random.random() < empty_probability:
34+
return ""
35+
36+
username = _R.person(domain="corp.local").username
37+
if random.random() < domain_probability:
38+
return f"corp\\{username}"
39+
return username
3040

3141
def generate_serial_number():
3242
"""Generate a firewall serial number."""
@@ -36,6 +46,9 @@ def generate_session_id():
3646
"""Generate a session ID."""
3747
return str(random.randint(10000, 999999))
3848

49+
50+
_R = Randomizer()
51+
3952
def generate_traffic_log():
4053
"""Generate a TRAFFIC log entry."""
4154
now = datetime.now(timezone.utc)
@@ -89,7 +102,7 @@ def generate_traffic_log():
89102
src_ip, # natsrc
90103
dst_ip, # natdst
91104
f"allow-{app}" if action == "allow" else f"block-{random.choice(['threats', 'malware', 'default'])}", # rule
92-
random.choice([f"domain\\user{random.randint(1, 100)}", ""]), # srcuser
105+
get_random_username(), # srcuser
93106
"", # dstuser
94107
app, # app
95108
"vsys1", # vsys
@@ -125,7 +138,14 @@ def generate_traffic_log():
125138
str(int(packets * 0.4)), # pkts_received
126139
random.choice(["aged-out", "tcp-fin", "tcp-rst", "policy-deny", ""]) if action != "allow" else "aged-out", # session_end_reason
127140
]
128-
141+
142+
# The marketplace Palo Alto firewall parser expects a fixed number of CSV columns.
143+
# If we stop emitting fields early, the line will not match even if the earlier
144+
# fields are correct (because required delimiters/columns are missing).
145+
expected_fields = 115
146+
if len(fields) < expected_fields:
147+
fields.extend([""] * (expected_fields - len(fields)))
148+
129149
return ",".join(fields)
130150

131151
def generate_threat_log():
@@ -155,7 +175,7 @@ def generate_threat_log():
155175
src_ip, # natsrc
156176
dst_ip, # natdst
157177
"block-threats", # rule
158-
"", # srcuser
178+
get_random_username(), # srcuser
159179
"", # dstuser
160180
random.choice(["web-browsing", "ssl", "ftp", "smtp"]), # app
161181
"vsys1", # vsys
@@ -197,7 +217,11 @@ def generate_threat_log():
197217
"", # recipient
198218
"", # reportid
199219
]
200-
220+
221+
expected_fields = 120
222+
if len(fields) < expected_fields:
223+
fields.extend([""] * (expected_fields - len(fields)))
224+
201225
return ",".join(fields)
202226

203227
def paloalto_firewall_log(overrides: dict | None = None) -> str:

0 commit comments

Comments
 (0)