2026-10-05 06:54:18 +02:00
|
|
|
"""A small reader-facing result set with full evidence retained under details/."""
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
import json
|
|
|
|
|
from collections import Counter
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from .indicators import defang
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cell(value: object) -> str:
|
|
|
|
|
"""Prevent spreadsheet formula interpretation of untrusted log text."""
|
|
|
|
|
text = str(value if value is not None else "")
|
|
|
|
|
return "'" + text if text.lstrip().startswith(("=", "+", "-", "@")) else text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def present_results(output: Path, summary: dict[str, Any]) -> None:
|
|
|
|
|
counts: Counter[str] = Counter()
|
|
|
|
|
with (
|
|
|
|
|
(output / "findings.jsonl").open(encoding="utf-8") as events,
|
|
|
|
|
(output / "FINDINGS.csv").open("x", encoding="utf-8", newline="") as target,
|
|
|
|
|
):
|
|
|
|
|
writer = csv.writer(target)
|
|
|
|
|
writer.writerow(
|
|
|
|
|
[
|
|
|
|
|
"event_id",
|
|
|
|
|
"appliance",
|
|
|
|
|
"timestamp",
|
|
|
|
|
"source",
|
|
|
|
|
"line",
|
|
|
|
|
"rules",
|
|
|
|
|
"explanation",
|
|
|
|
|
"indicator_status",
|
|
|
|
|
"matched_indicators",
|
|
|
|
|
"matching_incomplete",
|
|
|
|
|
"evidence",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
for line in events:
|
|
|
|
|
event = json.loads(line)
|
|
|
|
|
rules = event.get("rules", [])
|
|
|
|
|
hits = event.get("indicator_matches", [])
|
|
|
|
|
if not rules and not hits:
|
|
|
|
|
continue
|
|
|
|
|
counts["matches"] += 1
|
|
|
|
|
statuses = sorted({str(hit.get("status", "unknown")) for hit in hits})
|
|
|
|
|
if "authorized_test" in statuses:
|
|
|
|
|
counts["authorized_test"] += 1
|
|
|
|
|
explanation = "; ".join(
|
|
|
|
|
str(rule.get("description", rule.get("title", "")))
|
|
|
|
|
for rule in event.get("rule_details", [])
|
|
|
|
|
)
|
|
|
|
|
writer.writerow(
|
|
|
|
|
cell(value)
|
|
|
|
|
for value in [
|
|
|
|
|
event.get("id"),
|
|
|
|
|
event.get("appliance"),
|
|
|
|
|
event.get("timestamp"),
|
|
|
|
|
event.get("source"),
|
|
|
|
|
event.get("line"),
|
|
|
|
|
"; ".join(rules),
|
|
|
|
|
explanation,
|
|
|
|
|
"; ".join(statuses),
|
|
|
|
|
"; ".join(
|
|
|
|
|
defang(str(hit.get("value", "")), str(hit.get("type", ""))) for hit in hits
|
|
|
|
|
),
|
|
|
|
|
bool(event.get("analysis_errors")),
|
|
|
|
|
f"details/findings.jsonl: event id {event.get('id')}",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
stats = summary.get("counts", {})
|
|
|
|
|
lines = [
|
|
|
|
|
"NETSCALER TRIAGE — START HERE",
|
|
|
|
|
"",
|
|
|
|
|
f"Processing status: {summary.get('status', 'unknown')}",
|
|
|
|
|
f"Records read: {stats.get('records', 0)}",
|
|
|
|
|
f"Events matching a rule or indicator: {counts['matches']}",
|
2026-10-05 07:01:34 +02:00
|
|
|
f"Original-file SHA-256 indicator matches: {stats.get('file_hash_hits', 0)}",
|
2026-10-05 06:54:18 +02:00
|
|
|
f"Matching events including an authorized-test indicator: {counts['authorized_test']}",
|
|
|
|
|
f"Update/version records: {stats.get('update_records', 0)}",
|
|
|
|
|
"",
|
|
|
|
|
"WHAT TO OPEN",
|
|
|
|
|
"",
|
|
|
|
|
"1. FINDINGS.csv — rule/indicator matches with explanations and original references.",
|
|
|
|
|
" One row per matching event. Sort by appliance, rules and indicator_status.",
|
|
|
|
|
"2. IOCS.csv — search candidates for SIEM; not an automatic blocklist.",
|
|
|
|
|
"3. details/ — full JSON events, decoding, update records, correlations and checksums.",
|
|
|
|
|
" details/findings.jsonl also retains context and processing-error events.",
|
2026-10-05 07:01:34 +02:00
|
|
|
" File-hash matches are in details/file_hash_hits.json, separate from event matches.",
|
2026-10-05 06:54:18 +02:00
|
|
|
"",
|
|
|
|
|
"HOW TO INTERPRET THIS",
|
|
|
|
|
"",
|
|
|
|
|
"A match is a lead, not proof that a command ran or that the appliance was compromised.",
|
|
|
|
|
"authorized_test/context_only/reference_only must not be treated as confirmed attackers.",
|
|
|
|
|
"An authorized-test indicator does not authorize every action in that event.",
|
|
|
|
|
"No matches does not prove the system is clean.",
|
|
|
|
|
"",
|
|
|
|
|
"COVERAGE",
|
|
|
|
|
"",
|
|
|
|
|
f"Read errors: {stats.get('read_errors', 0)}",
|
|
|
|
|
f"Matching errors: {stats.get('matching_errors', 0)}",
|
|
|
|
|
f"Decoding failures: {stats.get('decoding_errors', 0)}",
|
|
|
|
|
f"Inventory errors: {stats.get('inventory_errors', 0)}",
|
|
|
|
|
f"Preparation errors: {stats.get('preparation_errors', 0)}",
|
|
|
|
|
f"Original integrity: {summary.get('integrity', 'unknown')}",
|
|
|
|
|
"partial means some processing was incomplete; inspect details/summary.json",
|
|
|
|
|
"and details/coverage.json.",
|
|
|
|
|
"A decoding failure can come from ordinary text; it is not itself an attack.",
|
|
|
|
|
"Prepared input copies, if present, are under inputs/. Preparation paths are relative",
|
|
|
|
|
"to this result directory, including those in details/preparation_manifest.json.",
|
|
|
|
|
]
|
|
|
|
|
(output / "SUMMARY.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
details = output / "details"
|
|
|
|
|
details.mkdir()
|
|
|
|
|
for path in sorted(output.iterdir()):
|
|
|
|
|
if path.is_file() and path.name not in {"SUMMARY.txt", "FINDINGS.csv", "IOCS.csv"}:
|
|
|
|
|
path.rename(details / path.name)
|