Every month the scanner finishes and the same problem lands on someone’s desk: thousands of detections, a few hundred hosts, and a remediation team that needs tickets it can actually work. The traditional answer is a spreadsheet, a CVSS sort, and a long afternoon of reading vendor advisories before writing tickets by hand.

I wanted that afternoon back. The result is a pipeline in n8n that pulls findings from Qualys VMDR, ranks them against a written remediation policy, enriches them with Microsoft Defender telemetry, has a self-hosted LLM judge each one, and files tickets in Jira only after a human types “yes”.

This post covers how it fits together and, more importantly, where the AI belongs and where it doesn’t.

The design rule

The single most useful decision in this build was splitting the work three ways:

  • Code decides how urgent something is. Risk rank, due dates and compliance windows come from policy. They have to be exact, repeatable and auditable, so a model never touches them.
  • The AI decides what to do about it and explains why. Reading scanner output, weighing evidence, choosing between patching, removing or reconfiguring, and writing the ticket. This is the judgment an analyst spends 10 to 20 minutes on per item.
  • A human decides whether it gets filed. Every ticket is approved by a person, and that approval is enforced in code, not in a prompt.

Everything else in this post follows from that split.

The pipeline

Scanner pull + score  ->  EDR enrichment  ->  LLM triage  ->  chat review  ->  tickets

Seven n8n workflows: four modules that do the work and three small tools behind a chat assistant. It runs once a month after the scan cycle, triggered by hand.

1. Pull and score

The first module pulls every open detection from the Qualys API (read-only account), plus the KnowledgeBase entry for every QID it sees. That KnowledgeBase call is where most of the value hides. With details=All you get Qualys’s own vulnerability description (including how it was detected), the impact, CVSS vectors, CISA KEV and active-exploitation flags, and known malware. Ask the detection API for QDS factors and you get EPSS too.

Scoring is deterministic and follows the remediation policy:

// Critical = known exploited, regardless of CVSS. Everything else by score.
let tier = score >= HIGH_MIN ? 'High' : score >= MEDIUM_MIN ? 'Medium' : 'Low';
if ((k.kev || k.active_attacks) && zone !== 'EUD') tier = 'Critical';

const SLA_DAYS = { Critical: 3, High: 14, Medium: 30, Low: 90 };

The score itself multiplies asset criticality (from zone tags), threat (known exploited, public exploit, or neither), internet exposure and the scanner’s own detection score. Systems in compliance scope get an extra check, such as PCI DSS 6.3.3’s one-month window from patch release, which can only pull a due date earlier.

This is where CVSS-only prioritization falls apart. A CVSS 5.5 local issue with a 44% EPSS score that sits on the KEV list is not a medium. Under the policy it’s Critical, and the ticket says why.

2. Group by fix, not by CVE

The early version produced one ticket per product, and the remediation team rightly pushed back. A monthly cumulative update and an unrelated registry hardening fix are different work, even when both say “Microsoft”.

Grouping now follows the action that fixes it:

  • Microsoft updates group by KB.
  • Third-party software groups by product, since one upgrade fixes every CVE for it.
  • Configuration and non-patchable issues get one group per CVE.
  • Everything splits by platform and by compliance scope.

One patch, one ticket. That alone cut the noise more than any AI did.

3. Ask the EDR whether it matters

The scanner tells you what is installed. It can’t tell you whether anyone uses it, whether the box is reachable, or whether “requires local access” is a real constraint. The EDR can.

The enrichment module runs a handful of batched Advanced Hunting queries over every affected host and attaches the results to each group:

  • Is the vulnerable app actually used?
  • Is the host reachable from the internet (accepted inbound connections from public IPs, and which ports)?
  • Do people log on interactively? This decides whether local-only exploits are realistic.
  • Any alerts on the host?
  • Does the EDR independently see the same CVE?

The usage check looks like this. It deliberately looks beyond process-creation events, because those are not always recorded for every execution:

let procs = dynamic(["chrome.exe","winscp.exe","wireshark.exe"]);
union
  (DeviceProcessEvents | where Timestamp > ago(30d) | where FileName in~ (procs)
     | project DeviceName, P = tolower(FileName)),
  (DeviceNetworkEvents | where Timestamp > ago(30d) | where InitiatingProcessFileName in~ (procs)
     | project DeviceName, P = tolower(InitiatingProcessFileName)),
  (DeviceFileEvents    | where Timestamp > ago(30d) | where InitiatingProcessFileName in~ (procs)
     | project DeviceName, P = tolower(InitiatingProcessFileName))
| summarize Events = count() by DeviceName, P

Two rules keep this honest. EDR evidence can change the verdict and the fix, but never the policy rank or due date. And only counts, ports, process names and dates go forward. No account names, no IP addresses.

4. Let the model do the reading

For each Critical, High and Medium group, a self-hosted LLM gets the group facts, the scanner’s description and detection logic for each QID, what the scanner actually saw on each host (version strings, registry paths, banners), and the EDR evidence. It returns structured JSON: verdict, confidence, reasoning, recommended action, fix, verification steps and ticket bullets.

This is where the model earns its place. Some real verdicts from the first runs, generalized:

  • A browser on a server had a stack of known-exploited CVEs. The EDR showed it had not been launched once in 30 days. The model recommended removal instead of patching: a permanent fix and a smaller attack surface.
  • A local privilege escalation would normally rank lower because it needs local access. The EDR showed several different people logging on interactively to the affected servers, so the model kept it at exploitable and said exactly why.
  • An old SSH daemon was flagged from version banners on Linux hosts, and the EDR detected none of the CVEs. The model recommended checking for vendor backports before anyone touched a package.

None of those decisions are hard. They just take reading, cross-referencing and judgment, multiplied by every group, every month.

The guardrails matter as much as the prompt:

  • No invented versions. Any fixed version the model cites must appear in the scanner’s own solution text. Otherwise the code rewrites it to “latest vendor-supported release” and adds a check-the-advisory bullet.
  • Describe from evidence, not titles. The model has to explain the issue from the vulnerability description and the findings, never from the QID name alone.
  • Banner-only detections are capped at medium confidence.
  • No IP addresses in prompts. LLM gateways log prompts, so treat every prompt as something that will be stored.

5. A chat assistant with hard gates

Review happens in a chat page hosted by n8n behind its normal login. You can ask for status, critical details, hosts dmz, get 12, then approve 12 and create tickets.

The interesting part is what happened when I tested it. The local model:

  • approved a group without asking for confirmation,
  • created a ticket because I answered “yes” to a question it wasn’t supposed to ask,
  • invented a ticket number it never received,
  • quietly rewrote a table and changed which hosts were in a zone.

Every one of those is a prompt-following failure, and prompts did not fix them. Code did:

  • Hard confirmation gates. The approve and create-ticket tools receive the user’s actual typed message, not the model’s interpretation of it, and refuse unless it is a plain “yes”. The model can’t fake that input.
const said = String($input.first().json.confirm ?? '').trim().toLowerCase();
if (!/^(yes|y|confirm(ed)?)[.!]?$/.test(said))
  throw new Error('NO TICKETS CREATED: confirmation required.');
  • A reply guard. A final node compares the model’s reply with what the tools actually returned. Claimed an approval that didn’t succeed, or cited a ticket key no tool returned? The reply is replaced with a warning.
  • Pass-through for read-only answers. Lists, counts and host views are computed in code, and the chat shows that output verbatim. The model chooses the query; it doesn’t get to retype the answer.

If you take one thing from this post: anything an LLM must not get wrong belongs in code, not in the prompt.

6. Tickets the remediation team can work

Approved groups become tickets with priority and due date from policy, labels for filtering, the remediation timeline (including any compliance requirement), the model’s reasoning and fix steps, a facts section straight from the scanner (CVSS, EPSS, KEV, description per QID), and the EDR evidence.

A fix-key label makes it idempotent. Next month, if the ticket is still open, the pipeline adds a status comment instead of filing a duplicate.

What it surfaced that nobody asked for

The useful findings weren’t always vulnerabilities:

  • Scope drift between tools. The scanner and the EDR disagreed about which servers were in compliance scope. That is precisely the kind of gap an auditor finds before you do.
  • Internet exposure nobody had tagged. A server accepting traffic from dozens of public sources wasn’t marked internet-facing in the scanner, so it was under-scored.
  • Unused software everywhere. Packet capture tools, file transfer clients and browsers sitting on servers, unpatched and unlaunched for a month. Uninstall beats patch.

What I’d tell you before you build one

  1. Write the policy first. If your SLAs aren’t written down, the model will make them up for you, and it will be inconsistent.
  2. Group by fix. Ask the people closing the tickets what one unit of work looks like.
  3. Keep the model out of anything that has to be exact. Ranks, dates, counts and approvals belong in code.
  4. Give the model evidence, not titles. The scanner’s description and raw findings make a huge difference to verdict quality.
  5. Assume the model will ignore instructions eventually. Build the gate, not the prompt.
  6. Keep a human on the button. Faster triage is the win. Autonomous ticket filing is not.

Next on the list: closing the loop by auto-verifying fixes against the next scan, SLA nudges in a daily digest, and factoring compensating controls into exposure scoring.


Posts on this blog are sanitized, generalized, and never tied to any specific employer or environment. No internal tooling names, no real alert data, no org-specific configurations. Giving attackers a free OSINT read on your security posture isn’t a write-up, it’s a liability.