← Writing

The Spark, a parsing problem that wouldn't stay small

June 15, 2026

The ticket that made me leave the ticket

Every alert started the same way. A detection would land in our SOAR as a ticket, raised by whichever platform caught it, whether endpoint, cloud, or a SIEM rule. The ticket had the basics and a link back to the source detection. To actually understand what happened, I had to follow that link, log into the platform, and read the alert in its own console.

Now multiply that by a queue. Every detection meant leaving the case I was working, authenticating somewhere else, finding the alert, reading it, and coming back. The context I needed already existed in the original JSON. I just couldn’t see it without a detour.

I wanted one thing: the alert, parsed and readable, sitting in the work note. No second tab. No second login. Just the signal, where I was already looking.

/parse-incident

The first version was almost embarrassingly simple, and I mean that literally. About fifteen lines of instruction that said, in effect: here are the eight fields I care about, pull them out, label them, and put the command line in a code block. That last part was half the reason I built it. A long rundll32 or PowerShell invocation pasted raw into a ticket field wraps into a smear of half-lines you can’t read and can’t cleanly copy back out. In a code block it stays one line, monospaced, selectable. The first version didn’t handle a second detection in the array, or a missing parent process. It just made one common shape readable, and that was already enough to stop me opening a second tab.

One thing up front, because it matters more than the parsing. The model ran in our own cloud, so the alerts never left our walls. Table stakes for putting AI anywhere near security data.

The decision that mattered was making it a skill, not a prompt. I did not want a general model interpreting security data and handing me something a little different every time. A creative prompt drifts. Same alert, slightly different output, and you never know which run is the one that quietly lied to you. I wanted the same fields, in the same shape, on every run. Known input, known output. No creativity, no drift.

I learned that the hard way. Before it was a skill, I just pasted JSON into a prompt and asked for a summary. Most of the time it was fine. Then one run “cleaned up” a command line. It dropped a doubled backslash and a flag it had decided was noise. The summary read perfectly. It was also wrong, and if I had pasted it into a case I would have triaged off a command that never ran. A summary that is wrong one time in fifty is worse than no summary, because you stop checking. A skill with a fixed template cannot invent or drop a field. That constraint is the feature.

Here is the kind of thing it works on, with synthetic values throughout. A single detection lands as deeply nested JSON, the few fields that matter buried in the ones that don’t:

{
  "detection_id": "DET-7f3a91c4",
  "event_timestamp": 1718409600123,
  "severity": 70,
  "confidence": 90,
  "sensor_id": "b7c2e9d0-44a1-4f2e-9c3a-1d8e7a0f2b66",
  "host": {
    "hostname": "HOST-EXAMPLE-01",
    "internal_ip": "192.0.2.34",
    "external_ip": "198.51.100.22",
    "os": "Windows 10",
    "hardware_model": "EXAMPLE-MODEL",
    "ad_domain": "AD.EXAMPLE.COM"
  },
  "detections": [
    {
      "process": "rundll32.exe",
      "command_line": "rundll32.exe comsvcs.dll, MiniDump <PID> C:\\Users\\Public\\example.dmp full",
      "sha256": "deadbeef...deadbeef",
      "user": "example_user",
      "mitre_tactic": "Credential Access (TA0006)",
      "mitre_technique": "OS Credential Dumping (T1003.001)",
      "parent": { "process": "cmd.exe", "user": "example_user" },
      "disposition": "Process blocked, file quarantined"
    }
  ],
  "network": null,
  "quarantine_state": "pending"
}

Illustrative example, not a real detection. The values are synthetic and the schema is simplified.

That is trimmed, and it is the easy case. A real detection carried far more, across host, behaviors, network, and quarantine state. The skill flattened it into the same shape every time, ready to paste straight into the ticket:

=== EDR DETECTION ===

Detection ID: DET-7f3a91c4

Hostname: HOST-EXAMPLE-01
IP Address: 192.0.2.34 (External: 198.51.100.22)
OS: Windows 10 - EXAMPLE-MODEL
Domain: AD.EXAMPLE.COM

Detection Name: Credential Dumping: LSASS Memory Access
Description: rundll32.exe used comsvcs.dll MiniDump to read LSASS
memory, consistent with credential theft.

MITRE ATT&CK:
 - Tactic: Credential Access (TA0006)
 - Technique: OS Credential Dumping (T1003.001)

Process Details:
 - Filename: rundll32.exe
 - Command Line: rundll32.exe comsvcs.dll, MiniDump <PID> C:\Users\Public\example.dmp full
 - SHA256: deadbeef...deadbeef
 - User: example_user

Parent Process:
 - Filename: cmd.exe
 - User: example_user

Action Taken: Process blocked, file quarantined

The real template ran longer still. It carried process lineage up to the grandparent, prevention actions, a timeline, files accessed and written, DNS requests, network connections, threat-intel prevalence, and IOC context. URLs always defanged. The shape never changed, and that was the whole point.

The workflow itself was unglamorous, and I didn’t care. Copy the JSON, run the skill, paste the result back as a work note. The win was not the seconds it saved me. It was that whoever opened the case next read a mechanical projection of the source JSON, not a model’s interpretation of it. Same input, same shape, every time. Output you can trust precisely because no judgment went into producing it.

It wouldn’t stay small

One template became all of them, and the messiness was never random. It was per-source, and it was specific. One source handed me a field that was itself a JSON string, JSON inside a string inside the JSON, so I had to parse it twice. Another shipped its behaviors as a bare object when there was one and an array when there were two, so the naive parse broke on the single-event case, which is the common case. Timestamps came in three formats from three sources: epoch millis here, ISO 8601 there, and one that kept the offset in a separate field. None of it is exotic. It is just what you only find by feeding a parser a few hundred real alerts and watching where the template tears.

Recognition was dumber than it sounds. Each source had a tell, a top-level key only it used, or an ID with a fixed prefix shape. The skill fingerprinted the payload against those tells before it parsed anything, and if nothing matched, it refused to guess rather than force the wrong template. A parser that confidently mis-parses is worse than one that says I don’t know this one.

The tell that it had gotten good was boring. I stopped dreading the queue. Every detection used to mean the same detour: open the console, re-auth, find the alert, scroll. Now the work note already had it, in the same shape I read fifty times a day, and my eyes knew where to look before I finished the first line.

But the parser is not the part that wouldn’t stay small. The idea behind it was. I had reached for AI and deliberately given it nothing to interpret: known input, known output, no room to be clever. That constraint was the entire reason I trusted it on security data, and once I had felt it work, I couldn’t unsee it. Every place I had been tempted to let a model reason its way through an alert, I started asking the smaller question first: can I make this deterministic instead? Creativity belongs in the hunt. Never in the handoff.


More detection-engineering notes as I write them.