diff --git a/projects/10-soc-triage/README.md b/projects/10-soc-triage/README.md index 924c19a..a9ada4f 100644 --- a/projects/10-soc-triage/README.md +++ b/projects/10-soc-triage/README.md @@ -7,6 +7,14 @@ Standard library only; run from a clone of this repository. No home PC, Splunk, network, endpoint agent, third-party package or live account access is required. The process-pattern checks reuse `../02-windows-process-triage/process_triage.py`. +## Authentication Export → Analyst Case Report + +[Explore the executed case and report](artifacts/authentication-export/README.md): +an authentication CSV becomes a UTC timeline, a review candidate and traceable +source records. Malformed records are retained with reasons, not silently dropped. +The demonstration uses synthetic data; its methodology and reproducible commands +are included with the results. + ## Run From the repository root, choose a **new** output directory: @@ -32,7 +40,7 @@ rows/file. This is a bounded utility, not an EVTX parser or universal vendor ada | Field | Accepted names | Meaning | | --- | --- | --- | -| Timestamp | `timestamp`, `time`, `TimeCreated` | ISO 8601 with explicit timezone; normalized to UTC | +| Timestamp | `timestamp`, `time`, `TimeCreated`, `_time` | ISO 8601 with explicit timezone; normalized to UTC | | User | `username`, `user`, `User` | Required; case preserved; no domain alias inference | | Host | `host`, `computer`, `Computer` | Required; trimmed and lowercased | | Event type | `event_type`, `action` | `login_failure`, `login_success`, `process_start`; `event_id=4688` can identify a process event | diff --git a/projects/10-soc-triage/artifacts/authentication-export/README.md b/projects/10-soc-triage/artifacts/authentication-export/README.md new file mode 100644 index 0000000..490e95f --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/README.md @@ -0,0 +1,80 @@ +# Authentication Export → Analyst Case Report + +A working Python workflow turns an authentication CSV into a readable analyst +handoff: a UTC timeline, review candidates, original source records and explicit +rejection reasons. It reuses the existing investigation logic and HTML report +generator rather than adding a separate conversion pipeline. + +## What the execution demonstrates + +| Input | Accepted events | Rejected records | Review candidates | CLI exit | +| --- | ---: | ---: | ---: | ---: | +| Eight-event authentication export | 8 | 0 | 1 | 0 — complete | +| Same export plus six malformed records | 7 | 7 | 0 | 2 — partial | + +The candidate is five failures followed by a success within the inclusive +600-second window, for the same user, host and source IP. A separate +below-threshold sequence does not produce a candidate. Follow source references +`S01:R000001` through `S01:R000006` in the valid report to inspect the evidence. + +In the mixed run, an invalid record shares an identity with a valid event. Both +copies are quarantined, leaving four burst failures. The resulting zero-candidate +output is **partial evidence, not an all-clear**. All 14 source records remain +available, including the extra malformed CSV value. + +Analyst follow-up: confirm account ownership, MFA context and whether the activity +was approved. Mistyped credentials, an authorized test or a credential change +are benign alternatives to investigate. The tool does not assign incident +severity, establish compromise or take response actions. + +## Retained outputs + +- Valid input: [case.json](valid/case.json) · [report.html](valid/report.html). +- Mixed input: [case.json](mixed-rejected/case.json) · [report.html](mixed-rejected/report.html). +- [CLI execution record](execution.txt) · [Input contract and fixtures](../../fixtures/splunk_csv_v1/README.md). + +Download an HTML report and open it in a browser; GitHub displays its source. + +## Methodology and scope + +Executed on 2026-11-09 using synthetic authentication events, clearly named lab +hosts and documentation IP addresses. The inputs were shaped to the declared +CSV contract; they are not captured incident telemetry or genuine Splunk exports. +The real CLI produced these files. No report findings were hand-authored, and no +live Splunk connection, endpoint collection or response action was used. + +The importer now accepts `_time` as an explicit-timezone timestamp alias. +Existing validation, alias conflicts, identity quarantine, source hashes and +HTML escaping remain active. The shared JSON-array path also validates `_time`. +This is not a claim of universal vendor-format support or production efficacy. + +Nine new contract tests failed against the original importer and passed after +the change. Four nearby existing checks passed. The original 20-row demo was +executed once to refresh its source-bound artifacts; its declared counts, rule +IDs and source references were unchanged. No full-suite run was performed. + +## Reproduce + +From the repository root, use new output directories (existing directories are +never overwritten): + +```sh +python -B -m unittest discover -s projects/10-soc-triage -p test_splunk_csv.py -v +python projects/10-soc-triage/triage.py projects/10-soc-triage/fixtures/splunk_csv_v1/valid.csv --output-dir auth-export-valid +python projects/10-soc-triage/triage.py projects/10-soc-triage/fixtures/splunk_csv_v1/valid.csv projects/10-soc-triage/fixtures/splunk_csv_v1/malformed.csv --output-dir auth-export-mixed +``` + +The mixed command intentionally returns exit 2. Both case files record the input +file hashes and these executed source hashes: + +| Source | SHA-256 | +| --- | --- | +| `triage.py` | `c10abcf1c3b79227d36a1825e480dd8568b55c5b699effd5f5241f494a54b976` | +| `process_triage.py` | `cd8784969935356a724d55a2a5f1e7885f6338fe5b1d5d4137e0578398412064` | + +| Retained output | SHA-256 | +| --- | --- | +| Valid case JSON | `494ee0650988e1f3bfdab43ad568266d99cc15f6e47445c77a43df634b20c010` | +| Valid HTML report | `a0a9d06b96b859ff51e79830d8c36b6adee5c224149235ed4ad75a9c497d3d5b` | +| Mixed case JSON | `d2ec2f87ea9efa300070b87415f519126d33be99f8b299333484def94d48c403` | +| Mixed HTML report | `699f24143601fd00873f7ca66cf4d3e284e7e599d09c9586633cc53f523e6860` | diff --git a/projects/10-soc-triage/artifacts/authentication-export/execution.txt b/projects/10-soc-triage/artifacts/authentication-export/execution.txt new file mode 100644 index 0000000..cdeea4b --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/execution.txt @@ -0,0 +1,10 @@ +CLI execution record — 2026-11-09 +Synthetic authentication-export demonstration; actual captured stdout. + +Valid input; exit 0 +COMPLETE: input_rows=8, accepted_events=8, duplicate_rows=0, rejected_rows=0, review_candidates=1 +Wrote case.json and report.html; findings require analyst review. + +Mixed input; exit 2 +PARTIAL: input_rows=14, accepted_events=7, duplicate_rows=0, rejected_rows=7, review_candidates=0 +Wrote case.json and report.html; findings require analyst review. diff --git a/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/case.json b/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/case.json new file mode 100644 index 0000000..a77bc10 --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/case.json @@ -0,0 +1,339 @@ +{ + "schema": "portfolio.soc-triage.v1", + "status": "PARTIAL", + "scope": "Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.", + "source_sha256": { + "triage.py": "c10abcf1c3b79227d36a1825e480dd8568b55c5b699effd5f5241f494a54b976", + "process_triage.py": "cd8784969935356a724d55a2a5f1e7885f6338fe5b1d5d4137e0578398412064" + }, + "summary": { + "input_rows": 14, + "accepted_events": 7, + "duplicate_rows": 0, + "rejected_rows": 7, + "review_candidates": 0 + }, + "inputs": [ + { + "id": "S01", + "name": "valid.csv", + "format": "csv", + "sha256": "b76966b8cdef9dfc0e14e26f18a16384815d8797c2973dfc22df70c391265514", + "rows": 8 + }, + { + "id": "S02", + "name": "malformed.csv", + "format": "csv", + "sha256": "ea92256f387b8cb2aaeca4a18a7ab0b81b3fd100a13fb2d42934d738b8ed01ce", + "rows": 6 + } + ], + "notes": [ + "Same-time failures and successes do not establish order: tied failures are excluded and every success resets the earlier failure history." + ], + "findings": [], + "timeline": [ + { + "timestamp": "2026-11-08T10:01:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-02", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000002" + ] + }, + { + "timestamp": "2026-11-08T10:02:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-03", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000003" + ] + }, + { + "timestamp": "2026-11-08T10:03:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-04", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000004" + ] + }, + { + "timestamp": "2026-11-08T10:04:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-05", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000005" + ] + }, + { + "timestamp": "2026-11-08T10:10:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_success", + "event_uid": "burst-06", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000006" + ] + }, + { + "timestamp": "2026-11-08T11:00:00Z", + "username": "lab_benign", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "benign-01", + "source_ip": "198.51.100.23", + "source_refs": [ + "S01:R000007" + ] + }, + { + "timestamp": "2026-11-08T11:01:00Z", + "username": "lab_benign", + "host": "synthetic-export-01", + "event_type": "login_success", + "event_uid": "benign-02", + "source_ip": "198.51.100.23", + "source_refs": [ + "S01:R000008" + ] + } + ], + "source_records": [ + { + "id": "S01:R000001", + "input_id": "S01", + "data_record": 1, + "parsed_record_sha256": "e777cf11961d3ab872cf839db36d7ab21b1d9c676ba4eddc714fd399862ffcac", + "raw": { + "_time": "2026-11-08T12:00:00+02:00", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-01" + }, + "status": "rejected", + "reason": "Conflicting or invalid copy of event_uid within host; all identifiable matching IDs quarantined" + }, + { + "id": "S01:R000002", + "input_id": "S01", + "data_record": 2, + "parsed_record_sha256": "c0de3255fbb0be9165d44df81959ed89dd597f6a1f819ab6f74696cdd7d4c481", + "raw": { + "_time": "2026-11-08T10:01:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-02" + }, + "status": "accepted" + }, + { + "id": "S01:R000003", + "input_id": "S01", + "data_record": 3, + "parsed_record_sha256": "0add2b60034146e579b68142b60900094c872790b1f527b1e791b3d70efb3df1", + "raw": { + "_time": "2026-11-08T10:02:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-03" + }, + "status": "accepted" + }, + { + "id": "S01:R000004", + "input_id": "S01", + "data_record": 4, + "parsed_record_sha256": "0fa7b058a983d025f7745ba2aed0a5345b1136e58019570265a4e0f4befe565c", + "raw": { + "_time": "2026-11-08T10:03:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-04" + }, + "status": "accepted" + }, + { + "id": "S01:R000005", + "input_id": "S01", + "data_record": 5, + "parsed_record_sha256": "794d0fde77a059c53eb932a82ec576d807324f0d3767c7e6bba97461d38d8ca1", + "raw": { + "_time": "2026-11-08T10:04:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-05" + }, + "status": "accepted" + }, + { + "id": "S01:R000006", + "input_id": "S01", + "data_record": 6, + "parsed_record_sha256": "5fbb92d72982a3f4b3746282ccd8d64660b848c843106b10f3421a799a925ed2", + "raw": { + "_time": "2026-11-08T10:10:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_success", + "event_uid": "burst-06" + }, + "status": "accepted" + }, + { + "id": "S01:R000007", + "input_id": "S01", + "data_record": 7, + "parsed_record_sha256": "c7b6010a0f573a9fa421174f893583e9bbafa20ea2f44f668439cdc1328c1333", + "raw": { + "_time": "2026-11-08T11:00:00Z", + "username": "lab_benign", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "198.51.100.23", + "event_type": "login_failure", + "event_uid": "benign-01" + }, + "status": "accepted" + }, + { + "id": "S01:R000008", + "input_id": "S01", + "data_record": 8, + "parsed_record_sha256": "a7006fe02392f7e5459d91bcb7599a91b42db2c88911dbabfc22b7f1c745d93c", + "raw": { + "_time": "2026-11-08T11:01:00Z", + "username": "lab_benign", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "198.51.100.23", + "event_type": "login_success", + "event_uid": "benign-02" + }, + "status": "accepted" + }, + { + "id": "S02:R000001", + "input_id": "S02", + "data_record": 1, + "parsed_record_sha256": "71ff5c94f4c9d9922d3e62cba16b162f4de6ffca0f8ebe2551b381209b52a682", + "raw": { + "_time": "not-a-time", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-01" + }, + "status": "rejected", + "reason": "Invalid isoformat string: 'not-a-time'" + }, + { + "id": "S02:R000002", + "input_id": "S02", + "data_record": 2, + "parsed_record_sha256": "816730de261d00c3e247031f7ca20a18ffe357b1d74074b79704b9fd2c41ba31", + "raw": { + "_time": "2026-11-08T12:01:00", + "username": "lab_bad", + "host": "SYNTHETIC-EXPORT-02", + "source_ip": "192.0.2.78", + "event_type": "login_failure", + "event_uid": "bad-timezone" + }, + "status": "rejected", + "reason": "Timestamp must include an explicit timezone" + }, + { + "id": "S02:R000003", + "input_id": "S02", + "data_record": 3, + "parsed_record_sha256": "a98b0bbab253a3019316b92621690e18108d1c5ff9fb9024fd5986496c7e6f65", + "raw": { + "_time": "2026-11-08T12:02:00Z", + "username": "lab_bad", + "host": "SYNTHETIC-EXPORT-02", + "source_ip": "999.0.0.1", + "event_type": "login_failure", + "event_uid": "bad-ip" + }, + "status": "rejected", + "reason": "'999.0.0.1' does not appear to be an IPv4 or IPv6 address" + }, + { + "id": "S02:R000004", + "input_id": "S02", + "data_record": 4, + "parsed_record_sha256": "cb6cd9f21a9eda2bb10a43a2a821ee32993a9793aef63599a971778b7f896cbb", + "raw": { + "_time": "2026-11-08T12:03:00Z", + "username": "lab_bad", + "host": "", + "source_ip": "192.0.2.78", + "event_type": "login_failure", + "event_uid": "bad-host" + }, + "status": "rejected", + "reason": "Missing required field: host" + }, + { + "id": "S02:R000005", + "input_id": "S02", + "data_record": 5, + "parsed_record_sha256": "299b0a04ff2f304f258b06ddd701194dd3d922a1cfa810f644892c37580bf03f", + "raw": { + "_time": "2026-11-08T12:04:00Z", + "username": "lab_bad", + "host": "SYNTHETIC-EXPORT-02", + "source_ip": "192.0.2.78", + "event_type": "success", + "event_uid": "bad-kind" + }, + "status": "rejected", + "reason": "Unsupported event_type" + }, + { + "id": "S02:R000006", + "input_id": "S02", + "data_record": 6, + "parsed_record_sha256": "8a740cb396bd4770f24fd5642cc488ae0d47b7ab65cac07108ff8a84d82ad42d", + "raw": { + "csv_fields": { + "_time": "2026-11-08T12:05:00Z", + "username": "lab_bad", + "host": "SYNTHETIC-EXPORT-02", + "source_ip": "192.0.2.78", + "event_type": "login_failure", + "event_uid": "bad-columns" + }, + "extra_values": [ + "unexpected" + ] + }, + "status": "rejected", + "reason": "Expected an object with a valid CSV field count" + } + ] +} diff --git a/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/report.html b/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/report.html new file mode 100644 index 0000000..1af2226 --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/mixed-rejected/report.html @@ -0,0 +1,107 @@ +SOC triage handoff
+

SOC triage handoff

PARTIAL / analyst review required
input rows: 14 / accepted events: 7 / duplicate rows: 0 / rejected rows: 7 / review candidates: 0

Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.

Rejected records remain below. A zero-candidate result does not establish that the input is complete or safe.

+ +

Review candidates

    Normalized UTC timeline

    1. 2026-11-08T10:01:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000002
    2. 2026-11-08T10:02:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000003
    3. 2026-11-08T10:03:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000004
    4. 2026-11-08T10:04:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000005
    5. 2026-11-08T10:10:00Z

      login_success / lab_burst / synthetic-export-01

      S01:R000006
    6. 2026-11-08T11:00:00Z

      login_failure / lab_benign / synthetic-export-01

      S01:R000007
    7. 2026-11-08T11:01:00Z

      login_success / lab_benign / synthetic-export-01

      S01:R000008

    Source files and scope

    Same-time failures and successes do not establish order: tied failures are excluded and every success resets the earlier failure history.

    Record references count CSV data records after the header or JSON array positions, starting at one. Record hashes cover canonical parsed JSON; file hashes cover exact input bytes. Only declared host/event_uid pairs are coalesced. Shared IDs must be stable and unique within a host across the supplied exports.

    Preserved input records

    S01:R000001 / rejected

    Conflicting or invalid copy of event_uid within host; all identifiable matching IDs quarantined

    {
    +  "_time": "2026-11-08T12:00:00+02:00",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-01"
    +}

    Parsed-record SHA-256: e777cf11961d3ab872cf839db36d7ab21b1d9c676ba4eddc714fd399862ffcac

    S01:R000002 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:01:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-02"
    +}

    Parsed-record SHA-256: c0de3255fbb0be9165d44df81959ed89dd597f6a1f819ab6f74696cdd7d4c481

    S01:R000003 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:02:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-03"
    +}

    Parsed-record SHA-256: 0add2b60034146e579b68142b60900094c872790b1f527b1e791b3d70efb3df1

    S01:R000004 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:03:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-04"
    +}

    Parsed-record SHA-256: 0fa7b058a983d025f7745ba2aed0a5345b1136e58019570265a4e0f4befe565c

    S01:R000005 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:04:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-05"
    +}

    Parsed-record SHA-256: 794d0fde77a059c53eb932a82ec576d807324f0d3767c7e6bba97461d38d8ca1

    S01:R000006 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:10:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_success",
    +  "event_uid": "burst-06"
    +}

    Parsed-record SHA-256: 5fbb92d72982a3f4b3746282ccd8d64660b848c843106b10f3421a799a925ed2

    S01:R000007 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T11:00:00Z",
    +  "username": "lab_benign",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "198.51.100.23",
    +  "event_type": "login_failure",
    +  "event_uid": "benign-01"
    +}

    Parsed-record SHA-256: c7b6010a0f573a9fa421174f893583e9bbafa20ea2f44f668439cdc1328c1333

    S01:R000008 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T11:01:00Z",
    +  "username": "lab_benign",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "198.51.100.23",
    +  "event_type": "login_success",
    +  "event_uid": "benign-02"
    +}

    Parsed-record SHA-256: a7006fe02392f7e5459d91bcb7599a91b42db2c88911dbabfc22b7f1c745d93c

    S02:R000001 / rejected

    Invalid isoformat string: 'not-a-time'

    {
    +  "_time": "not-a-time",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-01"
    +}

    Parsed-record SHA-256: 71ff5c94f4c9d9922d3e62cba16b162f4de6ffca0f8ebe2551b381209b52a682

    S02:R000002 / rejected

    Timestamp must include an explicit timezone

    {
    +  "_time": "2026-11-08T12:01:00",
    +  "username": "lab_bad",
    +  "host": "SYNTHETIC-EXPORT-02",
    +  "source_ip": "192.0.2.78",
    +  "event_type": "login_failure",
    +  "event_uid": "bad-timezone"
    +}

    Parsed-record SHA-256: 816730de261d00c3e247031f7ca20a18ffe357b1d74074b79704b9fd2c41ba31

    S02:R000003 / rejected

    '999.0.0.1' does not appear to be an IPv4 or IPv6 address

    {
    +  "_time": "2026-11-08T12:02:00Z",
    +  "username": "lab_bad",
    +  "host": "SYNTHETIC-EXPORT-02",
    +  "source_ip": "999.0.0.1",
    +  "event_type": "login_failure",
    +  "event_uid": "bad-ip"
    +}

    Parsed-record SHA-256: a98b0bbab253a3019316b92621690e18108d1c5ff9fb9024fd5986496c7e6f65

    S02:R000004 / rejected

    Missing required field: host

    {
    +  "_time": "2026-11-08T12:03:00Z",
    +  "username": "lab_bad",
    +  "host": "",
    +  "source_ip": "192.0.2.78",
    +  "event_type": "login_failure",
    +  "event_uid": "bad-host"
    +}

    Parsed-record SHA-256: cb6cd9f21a9eda2bb10a43a2a821ee32993a9793aef63599a971778b7f896cbb

    S02:R000005 / rejected

    Unsupported event_type

    {
    +  "_time": "2026-11-08T12:04:00Z",
    +  "username": "lab_bad",
    +  "host": "SYNTHETIC-EXPORT-02",
    +  "source_ip": "192.0.2.78",
    +  "event_type": "success",
    +  "event_uid": "bad-kind"
    +}

    Parsed-record SHA-256: 299b0a04ff2f304f258b06ddd701194dd3d922a1cfa810f644892c37580bf03f

    S02:R000006 / rejected

    Expected an object with a valid CSV field count

    {
    +  "csv_fields": {
    +    "_time": "2026-11-08T12:05:00Z",
    +    "username": "lab_bad",
    +    "host": "SYNTHETIC-EXPORT-02",
    +    "source_ip": "192.0.2.78",
    +    "event_type": "login_failure",
    +    "event_uid": "bad-columns"
    +  },
    +  "extra_values": [
    +    "unexpected"
    +  ]
    +}

    Parsed-record SHA-256: 8a740cb396bd4770f24fd5642cc488ae0d47b7ab65cac07108ff8a84d82ad42d

    \ No newline at end of file diff --git a/projects/10-soc-triage/artifacts/authentication-export/valid/case.json b/projects/10-soc-triage/artifacts/authentication-export/valid/case.json new file mode 100644 index 0000000..021a6be --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/valid/case.json @@ -0,0 +1,259 @@ +{ + "schema": "portfolio.soc-triage.v1", + "status": "COMPLETE", + "scope": "Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.", + "source_sha256": { + "triage.py": "c10abcf1c3b79227d36a1825e480dd8568b55c5b699effd5f5241f494a54b976", + "process_triage.py": "cd8784969935356a724d55a2a5f1e7885f6338fe5b1d5d4137e0578398412064" + }, + "summary": { + "input_rows": 8, + "accepted_events": 8, + "duplicate_rows": 0, + "rejected_rows": 0, + "review_candidates": 1 + }, + "inputs": [ + { + "id": "S01", + "name": "valid.csv", + "format": "csv", + "sha256": "b76966b8cdef9dfc0e14e26f18a16384815d8797c2973dfc22df70c391265514", + "rows": 8 + } + ], + "notes": [ + "Same-time failures and successes do not establish order: tied failures are excluded and every success resets the earlier failure history." + ], + "findings": [ + { + "rule_id": "AUTH_FAILURES_THEN_SUCCESS", + "timestamp": "2026-11-08T10:10:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "source_ip": "192.0.2.77", + "reason": "5 earlier failures within inclusive 600 seconds, then success on the same user/host/IP.", + "source_refs": [ + "S01:R000001", + "S01:R000002", + "S01:R000003", + "S01:R000004", + "S01:R000005", + "S01:R000006" + ], + "next_step": "Confirm account ownership, MFA context and approved activity before assigning incident severity." + } + ], + "timeline": [ + { + "timestamp": "2026-11-08T10:00:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-01", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000001" + ] + }, + { + "timestamp": "2026-11-08T10:01:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-02", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000002" + ] + }, + { + "timestamp": "2026-11-08T10:02:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-03", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000003" + ] + }, + { + "timestamp": "2026-11-08T10:03:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-04", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000004" + ] + }, + { + "timestamp": "2026-11-08T10:04:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "burst-05", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000005" + ] + }, + { + "timestamp": "2026-11-08T10:10:00Z", + "username": "lab_burst", + "host": "synthetic-export-01", + "event_type": "login_success", + "event_uid": "burst-06", + "source_ip": "192.0.2.77", + "source_refs": [ + "S01:R000006" + ] + }, + { + "timestamp": "2026-11-08T11:00:00Z", + "username": "lab_benign", + "host": "synthetic-export-01", + "event_type": "login_failure", + "event_uid": "benign-01", + "source_ip": "198.51.100.23", + "source_refs": [ + "S01:R000007" + ] + }, + { + "timestamp": "2026-11-08T11:01:00Z", + "username": "lab_benign", + "host": "synthetic-export-01", + "event_type": "login_success", + "event_uid": "benign-02", + "source_ip": "198.51.100.23", + "source_refs": [ + "S01:R000008" + ] + } + ], + "source_records": [ + { + "id": "S01:R000001", + "input_id": "S01", + "data_record": 1, + "parsed_record_sha256": "e777cf11961d3ab872cf839db36d7ab21b1d9c676ba4eddc714fd399862ffcac", + "raw": { + "_time": "2026-11-08T12:00:00+02:00", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-01" + }, + "status": "accepted" + }, + { + "id": "S01:R000002", + "input_id": "S01", + "data_record": 2, + "parsed_record_sha256": "c0de3255fbb0be9165d44df81959ed89dd597f6a1f819ab6f74696cdd7d4c481", + "raw": { + "_time": "2026-11-08T10:01:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-02" + }, + "status": "accepted" + }, + { + "id": "S01:R000003", + "input_id": "S01", + "data_record": 3, + "parsed_record_sha256": "0add2b60034146e579b68142b60900094c872790b1f527b1e791b3d70efb3df1", + "raw": { + "_time": "2026-11-08T10:02:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-03" + }, + "status": "accepted" + }, + { + "id": "S01:R000004", + "input_id": "S01", + "data_record": 4, + "parsed_record_sha256": "0fa7b058a983d025f7745ba2aed0a5345b1136e58019570265a4e0f4befe565c", + "raw": { + "_time": "2026-11-08T10:03:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-04" + }, + "status": "accepted" + }, + { + "id": "S01:R000005", + "input_id": "S01", + "data_record": 5, + "parsed_record_sha256": "794d0fde77a059c53eb932a82ec576d807324f0d3767c7e6bba97461d38d8ca1", + "raw": { + "_time": "2026-11-08T10:04:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_failure", + "event_uid": "burst-05" + }, + "status": "accepted" + }, + { + "id": "S01:R000006", + "input_id": "S01", + "data_record": 6, + "parsed_record_sha256": "5fbb92d72982a3f4b3746282ccd8d64660b848c843106b10f3421a799a925ed2", + "raw": { + "_time": "2026-11-08T10:10:00Z", + "username": "lab_burst", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "192.0.2.77", + "event_type": "login_success", + "event_uid": "burst-06" + }, + "status": "accepted" + }, + { + "id": "S01:R000007", + "input_id": "S01", + "data_record": 7, + "parsed_record_sha256": "c7b6010a0f573a9fa421174f893583e9bbafa20ea2f44f668439cdc1328c1333", + "raw": { + "_time": "2026-11-08T11:00:00Z", + "username": "lab_benign", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "198.51.100.23", + "event_type": "login_failure", + "event_uid": "benign-01" + }, + "status": "accepted" + }, + { + "id": "S01:R000008", + "input_id": "S01", + "data_record": 8, + "parsed_record_sha256": "a7006fe02392f7e5459d91bcb7599a91b42db2c88911dbabfc22b7f1c745d93c", + "raw": { + "_time": "2026-11-08T11:01:00Z", + "username": "lab_benign", + "host": "SYNTHETIC-EXPORT-01", + "source_ip": "198.51.100.23", + "event_type": "login_success", + "event_uid": "benign-02" + }, + "status": "accepted" + } + ] +} diff --git a/projects/10-soc-triage/artifacts/authentication-export/valid/report.html b/projects/10-soc-triage/artifacts/authentication-export/valid/report.html new file mode 100644 index 0000000..fb8a908 --- /dev/null +++ b/projects/10-soc-triage/artifacts/authentication-export/valid/report.html @@ -0,0 +1,60 @@ +SOC triage handoff
    +

    SOC triage handoff

    COMPLETE / analyst review required
    input rows: 8 / accepted events: 8 / duplicate rows: 0 / rejected rows: 0 / review candidates: 1

    Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.

    Rejected records remain below. A zero-candidate result does not establish that the input is complete or safe.

    + +

    Review candidates

    1. AUTH_FAILURES_THEN_SUCCESS

      2026-11-08T10:10:00Z / lab_burst / synthetic-export-01

      5 earlier failures within inclusive 600 seconds, then success on the same user/host/IP.

      Follow-up: Confirm account ownership, MFA context and approved activity before assigning incident severity.

      S01:R000001 S01:R000002 S01:R000003 S01:R000004 S01:R000005 S01:R000006

    Normalized UTC timeline

    1. 2026-11-08T10:00:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000001
    2. 2026-11-08T10:01:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000002
    3. 2026-11-08T10:02:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000003
    4. 2026-11-08T10:03:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000004
    5. 2026-11-08T10:04:00Z

      login_failure / lab_burst / synthetic-export-01

      S01:R000005
    6. 2026-11-08T10:10:00Z

      login_success / lab_burst / synthetic-export-01

      S01:R000006
    7. 2026-11-08T11:00:00Z

      login_failure / lab_benign / synthetic-export-01

      S01:R000007
    8. 2026-11-08T11:01:00Z

      login_success / lab_benign / synthetic-export-01

      S01:R000008

    Source files and scope

    Same-time failures and successes do not establish order: tied failures are excluded and every success resets the earlier failure history.

    Record references count CSV data records after the header or JSON array positions, starting at one. Record hashes cover canonical parsed JSON; file hashes cover exact input bytes. Only declared host/event_uid pairs are coalesced. Shared IDs must be stable and unique within a host across the supplied exports.

    Preserved input records

    S01:R000001 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T12:00:00+02:00",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-01"
    +}

    Parsed-record SHA-256: e777cf11961d3ab872cf839db36d7ab21b1d9c676ba4eddc714fd399862ffcac

    S01:R000002 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:01:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-02"
    +}

    Parsed-record SHA-256: c0de3255fbb0be9165d44df81959ed89dd597f6a1f819ab6f74696cdd7d4c481

    S01:R000003 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:02:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-03"
    +}

    Parsed-record SHA-256: 0add2b60034146e579b68142b60900094c872790b1f527b1e791b3d70efb3df1

    S01:R000004 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:03:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-04"
    +}

    Parsed-record SHA-256: 0fa7b058a983d025f7745ba2aed0a5345b1136e58019570265a4e0f4befe565c

    S01:R000005 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:04:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_failure",
    +  "event_uid": "burst-05"
    +}

    Parsed-record SHA-256: 794d0fde77a059c53eb932a82ec576d807324f0d3767c7e6bba97461d38d8ca1

    S01:R000006 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T10:10:00Z",
    +  "username": "lab_burst",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "192.0.2.77",
    +  "event_type": "login_success",
    +  "event_uid": "burst-06"
    +}

    Parsed-record SHA-256: 5fbb92d72982a3f4b3746282ccd8d64660b848c843106b10f3421a799a925ed2

    S01:R000007 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T11:00:00Z",
    +  "username": "lab_benign",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "198.51.100.23",
    +  "event_type": "login_failure",
    +  "event_uid": "benign-01"
    +}

    Parsed-record SHA-256: c7b6010a0f573a9fa421174f893583e9bbafa20ea2f44f668439cdc1328c1333

    S01:R000008 / accepted

    Accepted source record

    {
    +  "_time": "2026-11-08T11:01:00Z",
    +  "username": "lab_benign",
    +  "host": "SYNTHETIC-EXPORT-01",
    +  "source_ip": "198.51.100.23",
    +  "event_type": "login_success",
    +  "event_uid": "benign-02"
    +}

    Parsed-record SHA-256: a7006fe02392f7e5459d91bcb7599a91b42db2c88911dbabfc22b7f1c745d93c

    \ No newline at end of file diff --git a/projects/10-soc-triage/artifacts/case.json b/projects/10-soc-triage/artifacts/case.json index b5bb33a..f5e31e1 100644 --- a/projects/10-soc-triage/artifacts/case.json +++ b/projects/10-soc-triage/artifacts/case.json @@ -1,9 +1,9 @@ { "schema": "portfolio.soc-triage.v1", "status": "PARTIAL", - "scope": "Offline exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.", + "scope": "Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.", "source_sha256": { - "triage.py": "f74800fa490303e2a62d061b53ca2d0ffdc6ebbbcc5b83800495a84ae1787fd7", + "triage.py": "c10abcf1c3b79227d36a1825e480dd8568b55c5b699effd5f5241f494a54b976", "process_triage.py": "cd8784969935356a724d55a2a5f1e7885f6338fe5b1d5d4137e0578398412064" }, "summary": { diff --git a/projects/10-soc-triage/artifacts/report.html b/projects/10-soc-triage/artifacts/report.html index e3a40ef..e169277 100644 --- a/projects/10-soc-triage/artifacts/report.html +++ b/projects/10-soc-triage/artifacts/report.html @@ -1,5 +1,5 @@ SOC triage handoff
    -

    SOC triage handoff

    PARTIAL / analyst review required
    input rows: 20 / accepted events: 13 / duplicate rows: 1 / rejected rows: 6 / review candidates: 3

    Offline exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.

    Rejected records remain below. A zero-candidate result does not establish that the input is complete or safe.

    +

    SOC triage handoff

    PARTIAL / analyst review required
    input rows: 20 / accepted events: 13 / duplicate rows: 1 / rejected rows: 6 / review candidates: 3

    Exported-log processing; review candidates, not confirmed incidents. No endpoint collection, network calls or response actions.

    Rejected records remain below. A zero-candidate result does not establish that the input is complete or safe.

    Review candidates

    1. AUTH_FAILURES_THEN_SUCCESS

      2026-10-26T10:10:00Z / lab_analyst / lab-wks-01

      5 earlier failures within inclusive 600 seconds, then success on the same user/host/IP.

      Follow-up: Confirm account ownership, MFA context and approved activity before assigning incident severity.

      S01:R000001 S01:R000002 S01:R000003 S01:R000007 S01:R000004 S01:R000005 S01:R000006

    2. CERTUTIL_URLCACHE_FETCH

      2026-10-26T10:12:00Z / lab_analyst / lab-wks-01

      certutil URL cache and force flags

      Follow-up: Validate command content, parent process and authorization; encoding alone does not establish malicious intent.

      S02:R000002

    3. PS_ENCODED_COMMAND

      2026-10-26T10:12:00Z / lab_analyst / lab-wks-01

      PowerShell encoded-command argument

      Follow-up: Validate command content, parent process and authorization; encoding alone does not establish malicious intent.

      S02:R000001

    Normalized UTC timeline

    1. 2026-10-26T10:00:00Z

      login_failure / lab_analyst / lab-wks-01

      S01:R000001
    2. 2026-10-26T10:00:00Z

      login_failure / lab_control / lab-wks-02

      S01:R000008
    3. 2026-10-26T10:01:00Z

      login_failure / lab_analyst / lab-wks-01

      S01:R000002
    4. 2026-10-26T10:01:00Z

      login_failure / lab_control / lab-wks-02

      S01:R000009
    5. 2026-10-26T10:02:00Z

      login_failure / lab_analyst / lab-wks-01

      S01:R000003 S01:R000007
    6. 2026-10-26T10:02:00Z

      login_success / lab_control / lab-wks-02

      S01:R000010
    7. 2026-10-26T10:03:00Z

      login_failure / lab_analyst / lab-wks-01

      S01:R000004
    8. 2026-10-26T10:04:00Z

      login_failure / lab_analyst / lab-wks-01

      S01:R000005
    9. 2026-10-26T10:10:00Z

      login_success / lab_analyst / lab-wks-01

      S01:R000006
    10. 2026-10-26T10:12:00Z

      process_start / lab_analyst / lab-wks-01

      S02:R000001
    11. 2026-10-26T10:12:00Z

      process_start / lab_analyst / lab-wks-01

      S02:R000002
    12. 2026-10-26T10:12:00Z

      process_start / lab_analyst / lab-wks-01

      S02:R000003
    13. 2026-10-26T10:12:00Z

      process_start / lab_analyst / lab-wks-01

      S02:R000004

    Source files and scope

    Same-time failures and successes do not establish order: tied failures are excluded and every success resets the earlier failure history.

    Record references count CSV data records after the header or JSON array positions, starting at one. Record hashes cover canonical parsed JSON; file hashes cover exact input bytes. Only declared host/event_uid pairs are coalesced. Shared IDs must be stable and unique within a host across the supplied exports.

    Preserved input records

    S01:R000001 / accepted

    Accepted source record

    {
       "event_uid": "a01",
    diff --git a/projects/10-soc-triage/fixtures/splunk_csv_v1/README.md b/projects/10-soc-triage/fixtures/splunk_csv_v1/README.md
    new file mode 100644
    index 0000000..86c6e7c
    --- /dev/null
    +++ b/projects/10-soc-triage/fixtures/splunk_csv_v1/README.md
    @@ -0,0 +1,96 @@
    +# Authentication export CSV contract — v1
    +
    +Status: **importer implemented, with focused checks and retained CLI reports**.
    +See [Authentication Export → Analyst Case Report](../../artifacts/authentication-export/README.md).
    +These fixtures are synthetic, not actual Splunk exports. They do not establish
    +live connectivity or compatibility with an unmodified native Splunk export.
    +The importer recognizes `_time` under the explicit format below.
    +
    +## Input format
    +
    +Use UTF-8 CSV with one authentication event per record and this header:
    +
    +```csv
    +_time,username,host,source_ip,event_type,event_uid
    +```
    +
    +| Column | Existing normalized field | Contract |
    +| --- | --- | --- |
    +| `_time` | `timestamp` | ISO 8601 with `T` and an explicit timezone (`Z` or numeric offset); normalized to UTC. No epoch values or inferred local timezone. |
    +| `username` | `username` | Required, nonempty user name. |
    +| `host` | `host` | Required host; existing normalization case-folds it. |
    +| `source_ip` | `source_ip` | Required valid IP address; existing canonicalization applies. |
    +| `event_type` | `event_type` | Exactly `login_failure` or `login_success`. The producer must provide these values; no vendor-field inference. |
    +| `event_uid` | `event_uid` | Optional stable identity within a host across supplied files. Blank or absent IDs remain distinct; do not invent IDs to deduplicate events. |
    +
    +This is an explicitly shaped event export, not an aggregate alert-count input.
    +Unrecognized named fields are preserved but not interpreted; recognized aliases
    +retain existing validation and conflict checks. Existing restrictions remain:
    +at most eight files, 5 MiB and 5,000 records per file, and unique nonempty
    +headers. Incorrect field counts are rejected; extra values remain in the source
    +record. Do not silently repair malformed records.
    +
    +## Evidence and rejection behavior
    +
    +Reuse the existing reader, normalizer, investigation logic and report generator.
    +Retain each input file's SHA-256, parsed raw fields, record hash, source reference
    +and rejection reason. Parsed records are not a byte-for-byte representation of
    +CSV quoting: retain the original CSV file alongside its hash.
    +
    +Preserve existing alias-conflict behavior: stripped strings are compared before
    +timestamp parsing. If `_time` and another timestamp alias disagree, reject the
    +record even if the two strings represent the same instant. Keep host-aware
    +grouping, same-time ordering rules and invalid/conflicting-identity quarantine.
    +
    +## Fixture pair and previously observed diagnostic
    +
    +The completed diagnostic used the unchanged source at commit
    +`0a3049fdf864c55867ee198b038821404769837b`, then temporarily appended `_time` to
    +`ALIASES['timestamp']` in memory and restored the tuple afterward. It did not edit
    +the importer. It exited 0; the results below summarize that earlier diagnostic,
    +which was not rerun during the earlier save-only preparation slice. No generated
    +case JSON or HTML report was retained from that diagnostic. These are historical
    +diagnostic observations, distinct from the subsequently retained CLI evidence
    +linked above and not genuine-export validation.
    +
    +| Input and mode | Input records | Accepted | Rejected | Review candidates |
    +| --- | ---: | ---: | ---: | ---: |
    +| Both fixtures, unchanged importer | 14 | 0 | 14 | 0 |
    +| `valid.csv`, temporary alias | 8 | 8 | 0 | 1 |
    +| `malformed.csv`, temporary alias | 6 | 0 | 6 | 0 |
    +| Both fixtures, temporary alias | 14 | 7 | 7 | 0 |
    +
    +- `valid.csv`: five failures followed by success at the inclusive 600-second
    +  boundary; a separate one-failure/success negative control. The first timestamp
    +  normalizes from `12:00:00+02:00` to `10:00:00Z`. Candidate references cover rows
    +  1–6; original `_time` remains in the parsed raw record.
    +- `malformed.csv`: invalid timestamp, absent timezone, invalid IP, absent host,
    +  unsupported event type and an extra CSV field. The extra value is retained.
    +- Combined: the invalid first record in `malformed.csv` intentionally shares
    +  host and UID with the first valid record. Both copies are quarantined, leaving
    +  four burst failures and no candidate. All 14 parsed source records are retained.
    +
    +The diagnostic also checked source references in the in-memory HTML report.
    +
    +| Artifact | SHA-256 |
    +| --- | --- |
    +| `valid.csv` | `b76966b8cdef9dfc0e14e26f18a16384815d8797c2973dfc22df70c391265514` |
    +| `malformed.csv` | `ea92256f387b8cb2aaeca4a18a7ab0b81b3fd100a13fb2d42934d738b8ed01ce` |
    +| `triage.py` baseline | `f74800fa490303e2a62d061b53ca2d0ffdc6ebbbcc5b83800495a84ae1787fd7` |
    +| `process_triage.py` baseline | `cd8784969935356a724d55a2a5f1e7885f6338fe5b1d5d4137e0578398412064` |
    +
    +## Implemented changes and limits
    +
    +1. `_time` was appended to the existing timestamp alias tuple in `triage.py`.
    +   No separate converter, new detection rule or intermediate normalized file.
    +2. Nine focused tests cover the fixture outcomes, conflicting timestamp aliases,
    +   naive/epoch timestamps, fractional time, blank/absent IDs, HTML escaping and
    +   the shared JSON process-event path. Four nearby existing checks also passed.
    +3. Actual CLI case/report files are retained for the valid and mixed inputs with
    +   hashes bound to the executed source. The full suite and live lab were not run.
    +
    +The alias table is also used by existing JSON-array input, including process
    +events. `_time` is now validated rather than ignored: conflicting aliases reject
    +the row. This does not add native Splunk JSON-envelope or streaming support.
    +Validation used synthetic event exports. No live Splunk connection, PC access,
    +Commander, credentials, notifications or containment was involved.
    diff --git a/projects/10-soc-triage/fixtures/splunk_csv_v1/malformed.csv b/projects/10-soc-triage/fixtures/splunk_csv_v1/malformed.csv
    new file mode 100644
    index 0000000..726c999
    --- /dev/null
    +++ b/projects/10-soc-triage/fixtures/splunk_csv_v1/malformed.csv
    @@ -0,0 +1,7 @@
    +_time,username,host,source_ip,event_type,event_uid
    +not-a-time,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-01
    +2026-11-08T12:01:00,lab_bad,SYNTHETIC-EXPORT-02,192.0.2.78,login_failure,bad-timezone
    +2026-11-08T12:02:00Z,lab_bad,SYNTHETIC-EXPORT-02,999.0.0.1,login_failure,bad-ip
    +2026-11-08T12:03:00Z,lab_bad,,192.0.2.78,login_failure,bad-host
    +2026-11-08T12:04:00Z,lab_bad,SYNTHETIC-EXPORT-02,192.0.2.78,success,bad-kind
    +2026-11-08T12:05:00Z,lab_bad,SYNTHETIC-EXPORT-02,192.0.2.78,login_failure,bad-columns,unexpected
    diff --git a/projects/10-soc-triage/fixtures/splunk_csv_v1/valid.csv b/projects/10-soc-triage/fixtures/splunk_csv_v1/valid.csv
    new file mode 100644
    index 0000000..75035ba
    --- /dev/null
    +++ b/projects/10-soc-triage/fixtures/splunk_csv_v1/valid.csv
    @@ -0,0 +1,9 @@
    +_time,username,host,source_ip,event_type,event_uid
    +2026-11-08T12:00:00+02:00,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-01
    +2026-11-08T10:01:00Z,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-02
    +2026-11-08T10:02:00Z,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-03
    +2026-11-08T10:03:00Z,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-04
    +2026-11-08T10:04:00Z,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_failure,burst-05
    +2026-11-08T10:10:00Z,lab_burst,SYNTHETIC-EXPORT-01,192.0.2.77,login_success,burst-06
    +2026-11-08T11:00:00Z,lab_benign,SYNTHETIC-EXPORT-01,198.51.100.23,login_failure,benign-01
    +2026-11-08T11:01:00Z,lab_benign,SYNTHETIC-EXPORT-01,198.51.100.23,login_success,benign-02
    diff --git a/projects/10-soc-triage/test_splunk_csv.py b/projects/10-soc-triage/test_splunk_csv.py
    new file mode 100644
    index 0000000..53a2437
    --- /dev/null
    +++ b/projects/10-soc-triage/test_splunk_csv.py
    @@ -0,0 +1,127 @@
    +"""Focused authentication-export contract checks; no live service or mocks."""
    +import csv
    +import importlib.util
    +import json
    +from pathlib import Path
    +import tempfile
    +import unittest
    +
    +HERE = Path(__file__).resolve().parent
    +FIXTURES = HERE / 'fixtures/splunk_csv_v1'
    +
    +
    +class ExportContractTests(unittest.TestCase):
    +    def setUp(self):
    +        spec = importlib.util.spec_from_file_location('triage', HERE / 'triage.py')
    +        self.tool = importlib.util.module_from_spec(spec)
    +        spec.loader.exec_module(self.tool)
    +        self.temp = tempfile.TemporaryDirectory()
    +        self.addCleanup(self.temp.cleanup)
    +        self.folder = Path(self.temp.name)
    +
    +    def row(self, **changes):
    +        return dict(_time='2026-11-08T10:00:00Z', username='lab_user',
    +                    host='SYNTHETIC-01', source_ip='192.0.2.1',
    +                    event_type='login_failure', event_uid='', **changes)
    +
    +    def analyze_csv(self, rows):
    +        path = self.folder / 'events.csv'
    +        with path.open('w', newline='', encoding='utf-8') as stream:
    +            writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
    +            writer.writeheader()
    +            writer.writerows(rows)
    +        return self.tool.analyze([path])
    +
    +    def test_valid_export_normalizes_time_and_preserves_candidate_sources(self):
    +        report = self.tool.analyze([FIXTURES / 'valid.csv'])
    +        self.assertEqual(report['summary'], dict(input_rows=8, accepted_events=8,
    +                         duplicate_rows=0, rejected_rows=0, review_candidates=1))
    +        self.assertEqual(report['status'], 'COMPLETE')
    +        self.assertEqual(report['findings'][0]['source_refs'],
    +                         [f'S01:R{i:06d}' for i in range(1, 7)])
    +        self.assertEqual(report['timeline'][0]['timestamp'], '2026-11-08T10:00:00Z')
    +        self.assertEqual(report['source_records'][0]['raw']['_time'],
    +                         '2026-11-08T12:00:00+02:00')
    +        self.assertEqual(report['inputs'][0]['sha256'],
    +                         'b76966b8cdef9dfc0e14e26f18a16384815d8797c2973dfc22df70c391265514')
    +        self.assertEqual(len(report['source_records']), 8)
    +
    +    def test_malformed_export_keeps_reasons_and_extra_values(self):
    +        report = self.tool.analyze([FIXTURES / 'malformed.csv'])
    +        self.assertEqual(report['summary'], dict(input_rows=6, accepted_events=0,
    +                         duplicate_rows=0, rejected_rows=6, review_candidates=0))
    +        self.assertIn('explicit timezone', report['source_records'][1]['reason'])
    +        self.assertIn('Unsupported event_type', report['source_records'][4]['reason'])
    +        self.assertEqual(report['source_records'][5]['raw']['extra_values'], ['unexpected'])
    +        self.assertEqual(report['source_records'][5]['id'], 'S01:R000006')
    +
    +    def test_invalid_identified_copy_quarantines_valid_match(self):
    +        report = self.tool.analyze([FIXTURES / 'valid.csv', FIXTURES / 'malformed.csv'])
    +        self.assertEqual(report['summary'], dict(input_rows=14, accepted_events=7,
    +                         duplicate_rows=0, rejected_rows=7, review_candidates=0))
    +        self.assertEqual(report['status'], 'PARTIAL')
    +        self.assertEqual(report['source_records'][0]['status'], 'rejected')
    +        self.assertEqual(report['source_records'][8]['status'], 'rejected')
    +        self.assertEqual(len(report['source_records']), 14)
    +
    +    def test_timestamp_aliases_compare_strings_before_parsing(self):
    +        row = self.row(timestamp='2026-11-08T10:00:00Z')
    +        self.assertEqual(self.analyze_csv([row])['summary']['accepted_events'], 1)
    +        for other in ('2026-11-08T11:00:00Z', '2026-11-08T12:00:00+02:00'):
    +            with self.subTest(other=other):
    +                row['timestamp'] = other
    +                report = self.analyze_csv([row])
    +                self.assertEqual(report['summary']['rejected_rows'], 1)
    +                self.assertIn('Conflicting aliases: timestamp', report['source_records'][0]['reason'])
    +
    +    def test_requires_timezone_and_rejects_epoch_text(self):
    +        row = self.row()
    +        row['_time'] = '2026-11-08T10:00:00'
    +        with self.assertRaisesRegex(ValueError, 'explicit timezone'):
    +            self.tool.normalize(row)
    +        row['_time'] = '1794132000'
    +        with self.assertRaisesRegex(ValueError, 'Invalid isoformat'):
    +            self.tool.normalize(row)
    +
    +    def test_fractional_timestamp_is_preserved(self):
    +        row = self.row()
    +        row['_time'] = '2026-11-08T12:00:00.123456+02:00'
    +        report = self.analyze_csv([row])
    +        self.assertEqual(report['summary']['accepted_events'], 1)
    +        self.assertEqual(report['timeline'][0]['timestamp'], '2026-11-08T10:00:00.123456Z')
    +
    +    def test_blank_or_absent_uid_does_not_coalesce_records(self):
    +        for absent in (False, True):
    +            with self.subTest(absent=absent):
    +                row = self.row()
    +                if absent:
    +                    del row['event_uid']
    +                report = self.analyze_csv([row, row.copy()])
    +                self.assertEqual(report['summary']['accepted_events'], 2)
    +                self.assertEqual(report['summary']['duplicate_rows'], 0)
    +                self.assertEqual(len(report['source_records']), 2)
    +
    +    def test_unknown_fields_are_retained_and_html_escaped(self):
    +        report = self.analyze_csv([self.row(note='')])
    +        self.assertEqual(report['summary']['accepted_events'], 1)
    +        self.assertEqual(report['source_records'][0]['raw']['note'], '')
    +        html = self.tool.render_html(report)
    +        self.assertNotIn('