loghunter-cli 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loghunter/__init__.py +3 -0
- loghunter/cli.py +1108 -0
- loghunter/cli_init.py +567 -0
- loghunter/common/__init__.py +1 -0
- loghunter/common/allowlist.py +436 -0
- loghunter/common/clustering.py +326 -0
- loghunter/common/config.py +221 -0
- loghunter/common/display.py +323 -0
- loghunter/common/errors.py +45 -0
- loghunter/common/finding.py +239 -0
- loghunter/common/loader/__init__.py +136 -0
- loghunter/common/loader/diagnostics.py +94 -0
- loghunter/common/loader/discovery.py +335 -0
- loghunter/common/loader/io.py +76 -0
- loghunter/common/loader/pipeline.py +1010 -0
- loghunter/common/loader/sniff.py +184 -0
- loghunter/common/loader/types.py +207 -0
- loghunter/common/loader/windowing.py +523 -0
- loghunter/common/output.py +93 -0
- loghunter/common/paths.py +105 -0
- loghunter/common/sources.py +392 -0
- loghunter/data/allowlist/connections.txt +50 -0
- loghunter/data/allowlist/domains_devices.txt +5 -0
- loghunter/data/allowlist/domains_homelab.txt +5 -0
- loghunter/data/allowlist/domains_universal.txt +125 -0
- loghunter/data/config_example.toml +144 -0
- loghunter/detectors/__init__.py +5 -0
- loghunter/detectors/auth.py +27 -0
- loghunter/detectors/aws.py +671 -0
- loghunter/detectors/beacon.py +258 -0
- loghunter/detectors/dns.py +778 -0
- loghunter/detectors/dnsblock.py +29 -0
- loghunter/detectors/duration.py +178 -0
- loghunter/detectors/protocol.py +26 -0
- loghunter/detectors/scan.py +735 -0
- loghunter/detectors/ssl.py +25 -0
- loghunter/detectors/syslog.py +266 -0
- loghunter/detectors/weird.py +27 -0
- loghunter/digest/__init__.py +43 -0
- loghunter/digest/_stats.py +182 -0
- loghunter/digest/blob.py +698 -0
- loghunter/digest/cloudtrail.py +341 -0
- loghunter/digest/conn.py +367 -0
- loghunter/digest/dns.py +364 -0
- loghunter/digest/syslog.py +269 -0
- loghunter/exporters/__init__.py +534 -0
- loghunter/exporters/cloudtrail.py +499 -0
- loghunter/exporters/splunk.py +222 -0
- loghunter/outputs/__init__.py +1 -0
- loghunter/outputs/allowlist.py +75 -0
- loghunter/outputs/csv.py +70 -0
- loghunter/outputs/email.py +44 -0
- loghunter/outputs/html.py +99 -0
- loghunter/outputs/json.py +77 -0
- loghunter/outputs/text.py +1422 -0
- loghunter/parsers/__init__.py +1 -0
- loghunter/parsers/cloudtrail.py +287 -0
- loghunter/parsers/dnsmasq.py +331 -0
- loghunter/parsers/syslog.py +150 -0
- loghunter/parsers/zeek.py +294 -0
- loghunter/parsers/zeek_tsv.py +310 -0
- loghunter/runner.py +1895 -0
- loghunter_cli-0.1.0.dev0.dist-info/METADATA +336 -0
- loghunter_cli-0.1.0.dev0.dist-info/RECORD +122 -0
- loghunter_cli-0.1.0.dev0.dist-info/WHEEL +5 -0
- loghunter_cli-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- loghunter_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- loghunter_cli-0.1.0.dev0.dist-info/top_level.txt +4 -0
- migrations/cloudtrail_parquet.py +59 -0
- migrations/conn_fft.py +550 -0
- migrations/conn_scan.py +1097 -0
- migrations/dns_dbscan.py +520 -0
- migrations/get_syslog.py +402 -0
- migrations/syslog_drain3.py +479 -0
- scratch/junk/parquet.py +59 -0
- tests/__init__.py +1 -0
- tests/_cloudtrail_fakes.py +116 -0
- tests/conftest.py +17 -0
- tests/test_allowlist_defaults_accessor.py +90 -0
- tests/test_architecture_spine.py +302 -0
- tests/test_aws_detector.py +504 -0
- tests/test_be_like_water.py +106 -0
- tests/test_cli_help.py +342 -0
- tests/test_cli_multi_positional.py +458 -0
- tests/test_cloudtrail_exporter.py +631 -0
- tests/test_cloudtrail_exporter_botocore.py +207 -0
- tests/test_cloudtrail_parser.py +393 -0
- tests/test_clustering.py +85 -0
- tests/test_clustering_interruptible.py +404 -0
- tests/test_config_cli.py +1006 -0
- tests/test_config_example_drift.py +164 -0
- tests/test_digest_blob.py +1237 -0
- tests/test_digest_cli.py +1040 -0
- tests/test_digest_cloudtrail.py +980 -0
- tests/test_digest_conn.py +1189 -0
- tests/test_digest_dns.py +770 -0
- tests/test_digest_stats.py +282 -0
- tests/test_digest_syslog.py +724 -0
- tests/test_display.py +370 -0
- tests/test_dns_detector.py +1010 -0
- tests/test_dnsmasq_parser.py +467 -0
- tests/test_duration_detector.py +491 -0
- tests/test_export_orchestrator_shape.py +153 -0
- tests/test_init_wizard.py +707 -0
- tests/test_loader.py +3639 -0
- tests/test_loader_package_surface.py +115 -0
- tests/test_loader_window_model.py +215 -0
- tests/test_output_path_cascade.py +575 -0
- tests/test_resolve_path.py +111 -0
- tests/test_root_provenance.py +212 -0
- tests/test_runner.py +2599 -0
- tests/test_scan_detector.py +455 -0
- tests/test_search_paths.py +50 -0
- tests/test_sniff_orchestrator.py +373 -0
- tests/test_sniff_recognizers.py +573 -0
- tests/test_source_resolution_seam.py +471 -0
- tests/test_sources.py +648 -0
- tests/test_splunk_exporter.py +351 -0
- tests/test_syslog_detector.py +458 -0
- tests/test_syslog_parser.py +582 -0
- tests/test_text_output.py +1225 -0
- tests/test_zeek_tsv_parser.py +580 -0
migrations/get_syslog.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
get_syslog.py — Pull raw syslog from Splunk and write to a flat text file.
|
|
4
|
+
|
|
5
|
+
This is a data acquisition utility, not part of the analysis pipeline.
|
|
6
|
+
Its output is a plain syslog text file that the analysis tools consume
|
|
7
|
+
directly — no Splunk dependency required downstream.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python get_syslog.py # pulls last 1 full day (yesterday)
|
|
11
|
+
python get_syslog.py --days 3
|
|
12
|
+
python get_syslog.py --days 3 --out /tmp/syslog.log
|
|
13
|
+
python get_syslog.py --days 7 --max 200000
|
|
14
|
+
|
|
15
|
+
--days N covers the N most recent complete calendar days in local time.
|
|
16
|
+
--days 1 = yesterday 00:00:00 → 23:59:59 local
|
|
17
|
+
--days 3 = three days ago 00:00:00 → yesterday 23:59:59 local
|
|
18
|
+
|
|
19
|
+
--days M-N (or N-M) covers the inclusive range of complete days between
|
|
20
|
+
M and N days ago. The larger offset is the earlier bound.
|
|
21
|
+
--days 3-5 = five days ago 00:00:00 → three days ago 23:59:59 local (3 days)
|
|
22
|
+
--days 5-3 = same as above (order doesn't matter)
|
|
23
|
+
--days 3-3 = same as --days 3 (single day, three days ago)
|
|
24
|
+
|
|
25
|
+
Environment variables (override defaults):
|
|
26
|
+
SPLUNK_HOST Splunk host IP or hostname (default: 192.0.2.20)
|
|
27
|
+
SPLUNK_PORT Splunk management port (default: 8089)
|
|
28
|
+
SPLUNK_USER Splunk username (default: prompt)
|
|
29
|
+
SPLUNK_PASS Splunk password (default: prompt)
|
|
30
|
+
|
|
31
|
+
Output format:
|
|
32
|
+
One raw syslog line per line, exactly as received from Splunk _raw field.
|
|
33
|
+
RFC 3164 PRI prefix (<N>) is stripped if present.
|
|
34
|
+
Lines are sorted by timestamp ascending.
|
|
35
|
+
|
|
36
|
+
Notes:
|
|
37
|
+
Data is pulled in hourly chunks to stay under Splunk's per-query result
|
|
38
|
+
cap (enforced at the binary level on developer/free licenses). For a 7-day
|
|
39
|
+
pull this means 168 queries; expect 10-15 minutes total runtime.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import getpass
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import sys
|
|
47
|
+
from collections import Counter
|
|
48
|
+
from datetime import datetime, timedelta, timezone
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
# ── Dependency check ──────────────────────────────────────────────────────────
|
|
52
|
+
try:
|
|
53
|
+
import splunklib.client as splunk_client
|
|
54
|
+
import splunklib.results as splunk_results
|
|
55
|
+
except ImportError:
|
|
56
|
+
print("ERROR: splunk-sdk not installed. Run: pip install splunk-sdk")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
# ── Compiled patterns ─────────────────────────────────────────────────────────
|
|
60
|
+
# RFC 3164 PRI field: <N> or <NN> or <NNN> at start of line
|
|
61
|
+
PRI_RE = re.compile(r'^<\d+>')
|
|
62
|
+
|
|
63
|
+
# ── Fleet configuration ───────────────────────────────────────────────────────
|
|
64
|
+
# Hosts to include in the pull. Edit this list to match your fleet.
|
|
65
|
+
# Workstations and high-noise hosts should be excluded here so the
|
|
66
|
+
# analysis pipeline operates on a clean server-only baseline.
|
|
67
|
+
|
|
68
|
+
INCLUDE_HOSTS = [
|
|
69
|
+
"server1.example.com",
|
|
70
|
+
"server2.example.com",
|
|
71
|
+
"server3.example.com",
|
|
72
|
+
"server4.example.com",
|
|
73
|
+
"server5.example.com",
|
|
74
|
+
# "server6.example.com",
|
|
75
|
+
# "server7.example.com",
|
|
76
|
+
# "server8.example.com",
|
|
77
|
+
"router.example.com",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# ── Splunk connection defaults ────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
SPLUNK_HOST = os.environ.get("SPLUNK_HOST", "192.0.2.20")
|
|
83
|
+
SPLUNK_PORT = int(os.environ.get("SPLUNK_PORT", 8089))
|
|
84
|
+
SPLUNK_USER = os.environ.get("SPLUNK_USER", "")
|
|
85
|
+
SPLUNK_PASS = os.environ.get("SPLUNK_PASS", "")
|
|
86
|
+
|
|
87
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
def get_credentials() -> tuple[str, str]:
|
|
90
|
+
"""Resolve credentials from env vars or interactive prompt."""
|
|
91
|
+
user = SPLUNK_USER or input("Splunk username: ").strip()
|
|
92
|
+
passwd = SPLUNK_PASS or getpass.getpass("Splunk password: ")
|
|
93
|
+
return user, passwd
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def connect(user: str, passwd: str):
|
|
97
|
+
"""Connect to Splunk and return a service handle."""
|
|
98
|
+
print(f"Connecting to {SPLUNK_HOST}:{SPLUNK_PORT} as {user}...")
|
|
99
|
+
service = splunk_client.connect(
|
|
100
|
+
host=SPLUNK_HOST,
|
|
101
|
+
port=SPLUNK_PORT,
|
|
102
|
+
username=user,
|
|
103
|
+
password=passwd,
|
|
104
|
+
)
|
|
105
|
+
print(f"Connected. Splunk version: {service.info['version']}")
|
|
106
|
+
return service
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_hour_windows(far: int, near: int) -> list[tuple[datetime, datetime]]:
|
|
110
|
+
"""Return a list of (chunk_start, chunk_end) pairs in local time.
|
|
111
|
+
|
|
112
|
+
far -- the larger day offset (earlier boundary), e.g. 5 for "5 days ago"
|
|
113
|
+
near -- the smaller day offset (later boundary), e.g. 3 for "3 days ago"
|
|
114
|
+
near=0 is special: upper bound is the top of the last completed hour.
|
|
115
|
+
|
|
116
|
+
Normal upper bound (near >= 1):
|
|
117
|
+
today_midnight - near_days + 1 day (i.e. end of that calendar day)
|
|
118
|
+
|
|
119
|
+
Partial-day upper bound (near == 0):
|
|
120
|
+
now truncated to the hour (never a partial hour chunk)
|
|
121
|
+
|
|
122
|
+
Examples (today = 2026-05-16 14:37 local):
|
|
123
|
+
far=1, near=1 -> 2026-05-15 00:00 -> 2026-05-16 00:00 (yesterday, 24h)
|
|
124
|
+
far=3, near=1 -> 2026-05-13 00:00 -> 2026-05-16 00:00 (3 days, 72h)
|
|
125
|
+
far=5, near=3 -> 2026-05-11 00:00 -> 2026-05-13 00:00 (48h)
|
|
126
|
+
far=0, near=0 -> 2026-05-16 00:00 -> 2026-05-16 14:00 (today so far, 14h)
|
|
127
|
+
far=3, near=0 -> 2026-05-13 00:00 -> 2026-05-16 14:00 (3 days + partial, 86h)
|
|
128
|
+
|
|
129
|
+
Each pair spans exactly one hour. The list is ordered chronologically
|
|
130
|
+
(oldest first) so progress output reads naturally.
|
|
131
|
+
"""
|
|
132
|
+
local_now = datetime.now().astimezone()
|
|
133
|
+
today_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
134
|
+
|
|
135
|
+
window_start = today_midnight - timedelta(days=far)
|
|
136
|
+
|
|
137
|
+
if near == 0:
|
|
138
|
+
# Truncate to the top of the last completed hour.
|
|
139
|
+
window_end = local_now.replace(minute=0, second=0, microsecond=0)
|
|
140
|
+
else:
|
|
141
|
+
window_end = today_midnight - timedelta(days=near) + timedelta(days=1)
|
|
142
|
+
|
|
143
|
+
total_hours = int((window_end - window_start).total_seconds() // 3600)
|
|
144
|
+
windows = []
|
|
145
|
+
for i in range(total_hours):
|
|
146
|
+
h_start = window_start + timedelta(hours=i)
|
|
147
|
+
h_end = window_start + timedelta(hours=i + 1)
|
|
148
|
+
windows.append((h_start, h_end))
|
|
149
|
+
|
|
150
|
+
return windows
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_chunked(service, far: int, near: int, hosts: list[str], max_results: int) -> list[dict]:
|
|
154
|
+
"""Pull data in hourly chunks to stay under Splunk per-query row limits.
|
|
155
|
+
|
|
156
|
+
Splunk developer/free licenses enforce a hard per-query result cap at the
|
|
157
|
+
binary level (~50k rows) that limits.conf cannot override. Hourly chunking
|
|
158
|
+
keeps each query well under this ceiling even on high-volume hosts.
|
|
159
|
+
|
|
160
|
+
Time bounds are passed as Unix timestamps so Splunk interprets them
|
|
161
|
+
unambiguously regardless of the server's own timezone setting.
|
|
162
|
+
"""
|
|
163
|
+
all_rows = []
|
|
164
|
+
host_filter = " OR ".join(f'host="{h}"' for h in hosts)
|
|
165
|
+
|
|
166
|
+
spl = f"""
|
|
167
|
+
search index=main sourcetype=syslog
|
|
168
|
+
NOT sourcetype="zeek:json"
|
|
169
|
+
NOT (process=dnsmasq)
|
|
170
|
+
NOT (process=unbound)
|
|
171
|
+
({host_filter})
|
|
172
|
+
| table _time, host, _raw
|
|
173
|
+
""".strip()
|
|
174
|
+
|
|
175
|
+
windows = build_hour_windows(far, near)
|
|
176
|
+
total_hours = len(windows)
|
|
177
|
+
|
|
178
|
+
for i, (chunk_start, chunk_end) in enumerate(windows):
|
|
179
|
+
earliest = str(int(chunk_start.timestamp()))
|
|
180
|
+
latest = str(int(chunk_end.timestamp()))
|
|
181
|
+
|
|
182
|
+
label = chunk_start.strftime("%Y-%m-%d %H:%M %Z")
|
|
183
|
+
print(f" [{i+1:>4}/{total_hours}] {label}", end=" ... ", flush=True)
|
|
184
|
+
|
|
185
|
+
job = service.jobs.oneshot(
|
|
186
|
+
spl,
|
|
187
|
+
count=0,
|
|
188
|
+
output_mode="json",
|
|
189
|
+
earliest_time=earliest,
|
|
190
|
+
latest_time=latest,
|
|
191
|
+
)
|
|
192
|
+
chunk = [r for r in splunk_results.JSONResultsReader(job)
|
|
193
|
+
if isinstance(r, dict)]
|
|
194
|
+
print(f"{len(chunk):,} rows")
|
|
195
|
+
all_rows.extend(chunk)
|
|
196
|
+
|
|
197
|
+
if max_results and len(all_rows) >= max_results:
|
|
198
|
+
print(f" Reached max_results cap ({max_results:,}), stopping.")
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
return all_rows
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def make_auto_name(window_start: datetime, window_end: datetime,
|
|
205
|
+
far: int, near: int, partial: bool, total_days: int) -> str:
|
|
206
|
+
"""Return the default output filename derived from the time window."""
|
|
207
|
+
start_str = window_start.strftime("%Y%m%d")
|
|
208
|
+
if partial:
|
|
209
|
+
end_str = window_end.strftime("%Y%m%d_%Hh")
|
|
210
|
+
return f"syslog_{start_str}_to_{end_str}.log"
|
|
211
|
+
elif far == near:
|
|
212
|
+
return f"syslog_{start_str}_1d.log"
|
|
213
|
+
elif near == 1:
|
|
214
|
+
return f"syslog_{start_str}_{total_days}d.log"
|
|
215
|
+
else:
|
|
216
|
+
end_str = (window_end - timedelta(days=1)).strftime("%Y%m%d")
|
|
217
|
+
return f"syslog_{start_str}_to_{end_str}.log"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def resolve_outpath(arg_out: Path | None, auto_name: str) -> Path:
|
|
221
|
+
"""Resolve the final output path from the --out argument.
|
|
222
|
+
|
|
223
|
+
Rules:
|
|
224
|
+
no --out -> auto_name in CWD
|
|
225
|
+
--out <dir> -> auto_name inside that directory
|
|
226
|
+
--out <new path> -> use as-is (parent dir must exist)
|
|
227
|
+
--out <file> -> prompt to overwrite; exit if declined
|
|
228
|
+
"""
|
|
229
|
+
if arg_out is None:
|
|
230
|
+
return Path(auto_name)
|
|
231
|
+
|
|
232
|
+
if arg_out.is_dir():
|
|
233
|
+
return arg_out / auto_name
|
|
234
|
+
|
|
235
|
+
if arg_out.exists():
|
|
236
|
+
answer = input(f"{arg_out} already exists. Overwrite? [y/N] ").strip().lower()
|
|
237
|
+
if answer != "y":
|
|
238
|
+
print("Aborted.")
|
|
239
|
+
sys.exit(0)
|
|
240
|
+
return arg_out
|
|
241
|
+
|
|
242
|
+
# Path doesn't exist — treat as an explicit file path.
|
|
243
|
+
if not arg_out.parent.exists():
|
|
244
|
+
print(f"ERROR: Parent directory does not exist: {arg_out.parent}")
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
return arg_out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def write_output(rows: list[dict], outpath: Path) -> None:
|
|
250
|
+
"""Write raw syslog lines to a flat text file, one line per event."""
|
|
251
|
+
rows_sorted = sorted(rows, key=lambda r: r.get("_time", ""))
|
|
252
|
+
|
|
253
|
+
with open(outpath, "w", encoding="utf-8") as f:
|
|
254
|
+
for row in rows_sorted:
|
|
255
|
+
raw = PRI_RE.sub("", row.get("_raw", "").strip())
|
|
256
|
+
if raw:
|
|
257
|
+
f.write(raw + "\n")
|
|
258
|
+
|
|
259
|
+
size_kb = outpath.stat().st_size / 1024
|
|
260
|
+
print(f"\nWritten: {outpath} ({len(rows_sorted):,} lines, {size_kb:.1f} KB)")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_days_arg(value: str) -> tuple[int, int]:
|
|
264
|
+
"""Parse the --days argument into a (far, near) pair of day offsets.
|
|
265
|
+
|
|
266
|
+
Accepted forms:
|
|
267
|
+
"N" -> (N, 1) -- N complete days ending at last midnight
|
|
268
|
+
"M-N" -> (max, min) where max/min are the two values; order is ignored
|
|
269
|
+
"N-N" -> (N, N) -- single day N days ago
|
|
270
|
+
"0" -> (0, 0) -- today so far, up to the last completed hour
|
|
271
|
+
"N-0" -> (N, 0) -- N days ago midnight through last completed hour
|
|
272
|
+
|
|
273
|
+
near=0 means the upper bound is the top of the most recently completed
|
|
274
|
+
hour (now truncated to the hour), not a calendar-day boundary.
|
|
275
|
+
far must be >= near; both must be >= 0.
|
|
276
|
+
"""
|
|
277
|
+
value = value.strip()
|
|
278
|
+
if "-" in value:
|
|
279
|
+
parts = value.split("-", 1)
|
|
280
|
+
try:
|
|
281
|
+
a, b = int(parts[0]), int(parts[1])
|
|
282
|
+
except ValueError:
|
|
283
|
+
raise argparse.ArgumentTypeError(
|
|
284
|
+
f"Invalid range '{value}': both values must be integers (e.g. '3-5')"
|
|
285
|
+
)
|
|
286
|
+
far, near = max(a, b), min(a, b)
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
n = int(value)
|
|
290
|
+
except ValueError:
|
|
291
|
+
raise argparse.ArgumentTypeError(
|
|
292
|
+
f"Invalid value '{value}': must be an integer or a range like '3-5'"
|
|
293
|
+
)
|
|
294
|
+
# Single "0" = today so far; single "N" = N trailing complete days.
|
|
295
|
+
far, near = n, (0 if n == 0 else 1)
|
|
296
|
+
|
|
297
|
+
if far < 0 or near < 0:
|
|
298
|
+
raise argparse.ArgumentTypeError(
|
|
299
|
+
"Day offsets must be >= 0 (0 = today so far, up to last completed hour)"
|
|
300
|
+
)
|
|
301
|
+
return far, near
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def main():
|
|
307
|
+
parser = argparse.ArgumentParser(
|
|
308
|
+
description="Pull syslog from Splunk and write to a flat text file.",
|
|
309
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
310
|
+
epilog=__doc__,
|
|
311
|
+
)
|
|
312
|
+
parser.add_argument(
|
|
313
|
+
"--days", "-d",
|
|
314
|
+
type=parse_days_arg,
|
|
315
|
+
default="1",
|
|
316
|
+
metavar="N or M-N",
|
|
317
|
+
help=(
|
|
318
|
+
"Days to pull. Single number N: the N most recent complete days "
|
|
319
|
+
"(e.g. --days 3 = three days ago through yesterday). "
|
|
320
|
+
"Range M-N: the inclusive span of days M through N days ago "
|
|
321
|
+
"(e.g. --days 3-5 = five days ago through three days ago). "
|
|
322
|
+
"Order doesn't matter; M-M is the single day M days ago. "
|
|
323
|
+
"Default: 1 (yesterday)."
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"--out", "-o",
|
|
328
|
+
type=Path,
|
|
329
|
+
default=None,
|
|
330
|
+
help=(
|
|
331
|
+
"Output destination. "
|
|
332
|
+
"If omitted: auto-named file in CWD. "
|
|
333
|
+
"If a directory: auto-named file placed inside it. "
|
|
334
|
+
"If a new path: used as the file path (parent dir must exist). "
|
|
335
|
+
"If an existing file: prompts before overwriting."
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--max",
|
|
340
|
+
type=int,
|
|
341
|
+
default=0,
|
|
342
|
+
help="Maximum number of events to pull (default: 0 = unlimited)",
|
|
343
|
+
)
|
|
344
|
+
parser.add_argument(
|
|
345
|
+
"--hosts",
|
|
346
|
+
nargs="+",
|
|
347
|
+
default=INCLUDE_HOSTS,
|
|
348
|
+
metavar="HOST",
|
|
349
|
+
help="Override the default host list",
|
|
350
|
+
)
|
|
351
|
+
args = parser.parse_args()
|
|
352
|
+
|
|
353
|
+
far, near = args.days # unpacked from parse_days_arg
|
|
354
|
+
|
|
355
|
+
# Compute the actual window boundaries for display and default filename.
|
|
356
|
+
# Mirror the logic in build_hour_windows so the summary is always accurate.
|
|
357
|
+
local_now = datetime.now().astimezone()
|
|
358
|
+
today_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
359
|
+
window_start = today_midnight - timedelta(days=far)
|
|
360
|
+
if near == 0:
|
|
361
|
+
window_end = local_now.replace(minute=0, second=0, microsecond=0)
|
|
362
|
+
else:
|
|
363
|
+
window_end = today_midnight - timedelta(days=near) + timedelta(days=1)
|
|
364
|
+
total_hours = int((window_end - window_start).total_seconds() // 3600)
|
|
365
|
+
# For display: partial-day windows show hours, whole-day windows show days.
|
|
366
|
+
partial = (near == 0)
|
|
367
|
+
total_days = total_hours // 24
|
|
368
|
+
|
|
369
|
+
auto_name = make_auto_name(window_start, window_end, far, near, partial, total_days)
|
|
370
|
+
outpath = resolve_outpath(args.out, auto_name)
|
|
371
|
+
|
|
372
|
+
span_str = f"{total_hours}h partial" if partial else f"{total_days} day(s)"
|
|
373
|
+
print(f"\nget_syslog.py")
|
|
374
|
+
print(f" Window : {window_start.strftime('%Y-%m-%d %H:%M %Z')} -> {window_end.strftime('%Y-%m-%d %H:%M %Z')} ({span_str})")
|
|
375
|
+
print(f" Chunks : {total_hours} hourly")
|
|
376
|
+
print(f" Hosts : {', '.join(args.hosts)}")
|
|
377
|
+
print(f" Max rows: {'unlimited' if not args.max else f'{args.max:,}'}")
|
|
378
|
+
print(f" Output : {outpath}\n")
|
|
379
|
+
|
|
380
|
+
user, passwd = get_credentials()
|
|
381
|
+
service = connect(user, passwd)
|
|
382
|
+
|
|
383
|
+
print(f"\nFetching {total_hours} hourly chunk(s)...")
|
|
384
|
+
rows = fetch_chunked(service, far, near, args.hosts, args.max)
|
|
385
|
+
|
|
386
|
+
if not rows:
|
|
387
|
+
print("No results returned. Check your Splunk connection and index.")
|
|
388
|
+
sys.exit(1)
|
|
389
|
+
|
|
390
|
+
# Report host breakdown before writing
|
|
391
|
+
host_counts = Counter(r.get("host", "unknown") for r in rows)
|
|
392
|
+
print(f"\nTotal rows: {len(rows):,}")
|
|
393
|
+
print("Host breakdown:")
|
|
394
|
+
for host, count in sorted(host_counts.items(), key=lambda x: -x[1]):
|
|
395
|
+
print(f" {host:<35} {count:>8,}")
|
|
396
|
+
|
|
397
|
+
write_output(rows, outpath)
|
|
398
|
+
print("Done.")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
main()
|