Simplify scan results with readable summary and matched-event CSV

This commit is contained in:
StasonJatham committed 2026-10-05 06:54:18 +02:00
1 parent 711c42b270
commit 8c3a90225f
8 files changed
+235 -46

No files matched your search

+29 -13
View File
@@ -57,21 +57,35 @@ directories; review the preparation manifest for unusual export layouts.
## Read the results
Start with these three files:
```text
triage-results-.../
SUMMARY.txt Start here
FINDINGS.csv Review matched events
IOCS.csv Search candidates for your SIEM
details/ Full evidence and machine-readable exports
inputs/ Prepared input copies, when preparation was needed
```
| File | Purpose |
| --- | --- |
| `summary.json` | Processing status, counts and incomplete coverage |
| `findings.jsonl` | Events, source lines, matching rules and decoding explanations |
| `IOCS.csv` | Exact SIEM search values and separate defanged display values |
| `updates.jsonl` | Installation and software-version evidence |
| `correlations.jsonl` | Related events on the same appliance; temporal candidates, not causal proof |
| `coverage.json` | Per-file coverage and errors |
| `input_manifest.json` | Original relative paths and SHA-256 checksums |
| `preparation_manifest.json` | Archive preparation and appliance grouping |
| `SUMMARY.txt` | Readable overview: coverage, counts, limitations and what to open next |
| `FINDINGS.csv` | Only rule/indicator matches, with explanations and original source references |
| `IOCS.csv` | SIEM search candidates; not an automatic blocklist |
The output also includes an SQLite event index, effective configuration and
indicator/hash hit files. JSONL contains one JSON object per line. URL/Base64
decoding retains original values, transformation steps, checksums and errors.
An installer command alone does not establish successful patch completion.
All technical exports are under `details/`: full events in `findings.jsonl`,
URL/Base64 decoding, updates, correlations, checksums, coverage and the SQLite
index. Prepared input copies, if needed, remain in `inputs/`.
`details/findings.jsonl` includes context and processing errors as well as matches;
use `FINDINGS.csv` to browse matched events. A match is not proof of execution.
The CSV keeps authorized-test and reference statuses visible. Spreadsheet-sensitive
cells are prefixed with an apostrophe; exact original values remain in JSON.
This layout applies to CLI scans. Direct Python calls to `engine.scan` retain
flat machine-readable exports. Failed or interrupted scans can also retain the
flat layout; inspect their `summary.json` before using partial results.
## Add indicators and Sigma rules
@@ -112,7 +126,9 @@ repository. Examples under [examples/](examples/) use synthetic data.
## Limits and interpretation
- Exit `0`: configured processing completed; **not** a clean-system verdict.
- Exit `2`: an error or incomplete processing; inspect `summary.json`.
- Exit `2`: an error or incomplete processing; inspect `SUMMARY.txt` and
`details/summary.json`. If the scan failed before presentation, inspect
`summary.json` at the result root instead.
- Missing years/timezones are not guessed. See [README.txt](README.txt) for time configuration.
- Binary appliance logs may need an external decoder. Decode failures can also come from ordinary text.
- Damaged inputs and regex timeouts are reported. Archive path traversal, unsafe links and exhausted quotas stop preparation.
+16 -25
View File
@@ -52,32 +52,21 @@ and AES-ZIP are supported. Passwords are not saved in results.
WHAT TO READ FIRST
summary.json tells you what was processed and whether anything was incomplete.
findings.jsonl contains selected events, original source/line references, matching
rule details, extracted indicators and safe decoding steps with explanations.
Each line is a separate JSON object. Explanations describe what command text
could do; they do not establish that it ran successfully.
SUMMARY.txt is the readable overview: status, counts, limitations and next steps.
FINDINGS.csv contains only rule/indicator matches, explanations, source references
and indicator statuses. A match is not proof of execution or compromise.
IOCS.csv contains raw search candidates and defanged display values, not a blocklist.
IOCS.csv contains exact values for SIEM searches and a separate defanged display
value. Observed values are candidates, not an automatic blocklist.
updates.jsonl contains update/version evidence. An installer command is not by
itself proof of a completed patch. correlations.jsonl contains temporal candidates
between log files on the same appliance, not proof of causation.
Technical exports are grouped under details/: findings.jsonl (all selected events,
including context/errors), summary.json, updates.jsonl, correlations.jsonl,
coverage.json, input_manifest.json, configuration.json, indicator_hits.csv,
file_hash_hits.json, preparation_manifest.json and evidence.sqlite.
Prepared input copies remain in inputs/. Preparation-manifest paths are relative
to the result root. Decoding details retain originals, transformations and errors.
Other outputs support verification:
evidence.sqlite On-disk event index
coverage.json Per-file coverage and read/matching errors
input_manifest.json Original relative paths and SHA-256 checksums
configuration.json Effective rules, references and time assumptions
indicator_hits.csv Matches against configured reference indicators
file_hash_hits.json Reference matches against original file SHA-256
preparation_manifest.json Preparation actions, groups and exclusions
inputs/ Prepared inputs when automatic preparation runs
URL/Base64 transformations retain the original value, transformation, parent
step, decoded result or error, and checksum. Binary decoded content is displayed
as hex and is not executed. Finding rule_details retain matching rule descriptions
and source metadata; custom packs can provide their own explanations.
FINDINGS.csv prefixes spreadsheet-sensitive text with an apostrophe; exact values
remain in JSON. Authorized-test/context/reference hits are explicitly labelled.
Direct engine.scan calls and failed/interrupted CLI scans retain flat exports.
ADD YOUR OWN RULES OR IOCS
@@ -125,7 +114,9 @@ year overrides can be supplied using --time-config; see examples/time.json.
LIMITS AND INCOMPLETE RESULTS
Exit 0 means the configured processing completed, not that the appliance is clean.
Exit 2 means an error or incomplete processing; inspect summary.json.
Exit 2 means an error or incomplete processing; inspect SUMMARY.txt and
details/summary.json. If the scan failed before presentation, inspect
summary.json at the result root instead.
Invalid decoding candidates may be ordinary text, not malicious payloads.
Unknown text formats remain searchable, even if no timestamp is parsed.
Proprietary binary logs require an appropriate external decoder.
+12 -3
View File
@@ -5,9 +5,9 @@ validation environment. Synthetic path checks do not replace target-OS tests.
CODE AUDIT
134 tests passed with Python warnings treated as errors in 36.475 seconds.
Strict mypy passed for 35 source files. Ruff lint and format checks passed for
36 files. The enabled
136 tests passed with Python warnings treated as errors in 37.031 seconds.
Strict mypy passed for 37 source files. Ruff lint and format checks passed for
38 files. The enabled
lint families are E, F, I, UP, B, SIM, RUF and PERF; this is not a claim that every
optional rule from every analyzer is enabled.
@@ -131,3 +131,12 @@ A subsequent Unicode literal-lookup guard has synthetic regression coverage,
but the entire private fixture set was not rerun after that guard. These checks
validate known examples and important negative cases, not zero false positives
or complete detection of all future attacks. Private evidence is not shipped.
CLI RESULT PRESENTATION
CLI scans produce SUMMARY.txt, matched-event FINDINGS.csv and IOCS.csv at the
result root. Complete machine-readable evidence is retained under details/;
prepared inputs remain under inputs/. Tests cover separation of context from
matches, preservation of JSON evidence, authorized-test labels, spreadsheet
formula protection and no-matches wording. Direct engine.scan callers retain
the flat layout. A synthetic CLI smoke check confirmed the new directory layout.
+8 -2
View File
@@ -24,6 +24,7 @@ from .engine import dump, scan, validate_output
from .indicators import extract_indicators
from .ingest import extract_archive, inventory
from .preparation import PreparationEntry, prepare_inputs
from .presentation import present_results
class Parser(argparse.ArgumentParser):
@@ -55,7 +56,8 @@ def display_result(result: object, json_output: bool = False) -> None:
table.add_row(key.replace("_", " ").title(), str(value))
console.print(table)
console.print(
"Use --json for all fields. Review summary.json for coverage and warnings.", style="dim"
"Use --json for all fields. Start with SUMMARY.txt; evidence is under details/.",
style="dim",
)
else:
console.print_json(data=result, ensure_ascii=False)
@@ -371,12 +373,16 @@ def main(argv: list[str] | None = None) -> int:
Console(stderr=True).print(
Text(
f"Preparation incomplete: {len(preparation_errors)} input error(s). "
"Readable inputs were analyzed; review preparation_manifest.json.",
"Readable inputs were analyzed; review details/preparation_manifest.json.",
style="yellow",
)
)
present_results(args.output, result)
display_result(result, args.json)
Console(stderr=True).print(Text(f"Results saved to: {os.path.relpath(args.output)}"))
Console(stderr=True).print(
"Start with SUMMARY.txt, then FINDINGS.csv. Evidence: details/."
)
return 0 if result["status"] == "complete" else 2
if args.command == "extract":
if args.destination is None:
+116
View File
@@ -0,0 +1,116 @@
"""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']}",
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.",
"",
"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)
+4 -2
View File
@@ -89,7 +89,9 @@ class CliTests(unittest.TestCase):
self.assertEqual(len(destinations), 2)
self.assertEqual((logs / "sample.log").read_text(), original)
for destination in destinations:
config = json.loads((destination / "configuration.json").read_text())
config = json.loads((destination / "details/configuration.json").read_text())
self.assertTrue((destination / "SUMMARY.txt").is_file())
self.assertTrue((destination / "FINDINGS.csv").is_file())
self.assertIsNone(config["year"])
self.assertEqual(config["timezones"], {})
@@ -105,7 +107,7 @@ class CliTests(unittest.TestCase):
self.assertEqual(
main(["scan", "--input", f"ADC={logs}", "--output", str(root / "out")]), 0
)
config = json.loads((root / "out/configuration.json").read_text())
config = json.loads((root / "out/details/configuration.json").read_text())
self.assertEqual(config["appliances"], ["ADC"])
+49
View File
@@ -0,0 +1,49 @@
import csv
import json
import tempfile
import unittest
from pathlib import Path
from netscaler_triage.presentation import present_results
class PresentationTests(unittest.TestCase):
def test_matches_are_separate_from_context_and_evidence_is_preserved(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
events = [
{"id": 1, "rules": [], "categories": ["update"]},
{"id": 2, "rules": [], "analysis_errors": ["timeout"]},
{
"id": 3,
"rules": ["probe"],
"source": "=unsafe",
"rule_details": [{"description": "A command probe, not execution."}],
"indicator_matches": [{"status": "authorized_test"}],
},
]
original = "".join(json.dumps(event) + "\n" for event in events)
(output / "findings.jsonl").write_text(original)
(output / "summary.json").write_text('{"status":"partial"}')
(output / "IOCS.csv").write_text("type,value\n")
(output / "inputs").mkdir()
present_results(output, {"status": "partial", "counts": {"records": 3}})
with (output / "FINDINGS.csv").open() as handle:
rows = list(csv.DictReader(handle))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["event_id"], "3")
self.assertEqual(rows[0]["source"], "'=unsafe")
self.assertEqual(rows[0]["indicator_status"], "authorized_test")
self.assertEqual((output / "details/findings.jsonl").read_text(), original)
self.assertIn("Processing status: partial", (output / "SUMMARY.txt").read_text())
self.assertEqual(
{path.name for path in output.iterdir()},
{"SUMMARY.txt", "FINDINGS.csv", "IOCS.csv", "details", "inputs"},
)
def test_no_matches_is_not_a_clean_verdict(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
(output / "findings.jsonl").write_text("")
present_results(output, {"status": "complete"})
self.assertIn("No matches does not prove", (output / "SUMMARY.txt").read_text())
+1 -1
View File
@@ -66,7 +66,7 @@ class SigmaIntegrationTests(unittest.TestCase):
]
)
self.assertEqual(code, 0)
rows = findings(output)
rows = findings(output / "details")
matches = [row for row in rows if row.get("rules")]
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["line"], 1)