AISecurityJun 2025 – May 2026
A capstone vulnerability scanner that turns raw nmap output into ranked remediation work, using a local model so your own network topology never leaves the building.
Next.js · React · TypeScript · Tailwind CSS · d3-force · FastAPI · Python · LangGraph · XGBoost · Ollama · SQLite · Nmap

A scan of our university's 10.10.160.0/22 lab range came back with 31 devices carrying open services and megabytes of XML. Every <host> element is a bag of open ports, service banners, OS-match guesses with confidence percentages, traceroute hops, and NSE script output, and none of it is ordered by anything a person cares about. The honest description of the day-to-day problem isn't that the data is hard to get — nmap is free and takes one command — it's that what comes back is an inventory, not a decision, and escalating passes will run unattended for as long as you let them. Somebody still has to sit down and work out which of the 53 findings to fix on Monday morning.
The obvious move is to sort by CVSS, and that's the wrong sort order for two reasons. First, a CVSS base score is assigned once, at publication, and describes the vulnerability in the abstract — it has no view of your network. Sort by it and a 9.8 on a lab printer that nothing can route to outranks a 7.4 on the gateway every device behind it depends on. Second, and more mundanely: the CVE feed doesn't always return a score at all. A null sorts to the bottom of the list and silently disappears, which is the worst possible failure mode for a tool whose entire job is deciding what not to ignore.
It's worth correcting a framing we started with, because it shaped the architecture. We expected the hard part to be reconciling three different scanners. It isn't — the project drives one scanner. The disagreement is between three evidence sources about the same host, all of which come out of nmap: the OS-fingerprint CPEs, the service-level CPEs attached to individual open ports, and the raw product/version banners. A single machine routinely shows up as cpe:/o:linux:linux_kernel:4.4, cpe:2.3:a:apache:http_server:2.4.58:*:*, and the banner string "Apache httpd 2.4.58" — two CPE grammars and a free-text string naming overlapping software. On top of that, -Pn marks every address in the range as "up" whether anything answered or not, so a /22 sweep hands you hundreds of hosts that don't exist.
SURGE was built for the case we actually had in front of us: a small internal team assessing a network they own, on hardware they control, with no budget for a commercial scanner. That constraint drove the most consequential decision in the project — the reasoning model runs locally via Ollama, because a tool whose input is your own network topology, OS versions, and unpatched services is a tool you cannot point at a third-party API. It was a two-person capstone: my half is the Next.js dashboard and the FastAPI layer that bridges it to the agent graph; my teammate Brianna owns the agent internals and the scoring model.
The canonical unit is the finding — one (host, product, version, CVE) tuple — and everything upstream exists to produce a clean stream of them. Recon merges each nmap pass into a cumulative {ip: host} map so hosts dedupe by IP as the scan escalates. A query builder walks the three evidence sources in precision order (OS-fingerprint CPEs first, then service CPEs, then banners) and emits (vendor, product, version) lookup tuples. Each of those is resolved against the CIRCL CVE feed, and only then does a model touch the data: the LLM's job is to fold the returned CVE records into one fixed schema, one row per affected host, and it is explicitly forbidden from emitting a CVE ID that isn't present in the fetched data.
Deduplication happens in three places rather than one, because the duplicates arrive in three different shapes. Lookup queries are keyed on (vendor.lower(), product.lower()), so a host whose Apache is visible through an OS CPE, a service CPE, and a banner produces exactly one CVE fetch instead of three. Hosts dedupe by IP as recon iterates. And generic service names are blocklisted at the query stage — searching CIRCL for "http" or "ssh" returns a wall of CVEs for unrelated vendors, so the raw service name is only used as a fallback when it's specific enough to mean something ("mysql", "postgresql"), while an explicit product string from nmap is always trusted. A final pass drops the -Pn phantoms: any host with no services and no vulnerabilities never reaches the dashboard.
The line between agentic and deterministic is the decision we'd most want to be judged on, and it's the same split as Resuzen's ATS score. The model decides what to look at: which nmap flags and targets to use next, when to escalate from host discovery to a full vuln-script pass, how to read a fingerprint blob into prose, and how to write the final report. Code decides what is true and what is allowed. Whether a proposed scan runs at all is a deterministic sanitizer, not a judgment call. The number the queue sorts by is an XGBoost regression over a fixed 459-feature vector, not a model's opinion. The topology graph is built from traceroute hop data, or from subnet arithmetic when hops are absent. Severity buckets, the sort order, and the recon loop's stop condition are all plain code. A ranking nobody can audit is a ranking nobody will act on — so nothing a model says ends up in a ranked position without a deterministic function in between.
On scoring, it's worth being precise about what this is and isn't, because the easy version of this sentence would be a lie. The original scope called for EPSS alongside CVSS — exploit likelihood next to severity — and that was cut; it isn't in the shipped system. What we built instead is an XGBoost regressor trained on roughly 93,000 historical CVE records that predicts a CVSS-style score from a vulnerability's own published metrics and the text of its description. That earns its place for a genuinely useful but narrower reason than "contextual re-ranking": it produces a continuous, always-present number for every finding, including the ones the feed returns no score for, which is what makes a single ordered queue possible at all. It does not yet know anything about your host. Claiming otherwise would be exactly the kind of impressive-sounding number this whole site argues against.
The output is a queue, not a report you have to read. The Exploits page is a ranked list — CVE, affected device, score, severity — sorted by predicted score descending, with every CVE ID linking out to its NVD entry so a finding can be verified against the source in one click rather than trusted. Around it sit a live D3 topology graph, a device inventory, and an activity feed narrating what the agents are doing as they do it. Formal reports still exist, in four audiences (executive, technical, public, and a combined final), but they're generated on demand from a completed scan rather than being the primary artifact.
Architecture. Two repos, not a monorepo: Python and TypeScript can't share a runtime, and the tooling cost of stitching them together bought nothing on a one-month timeline. surge-ai holds the LangGraph agent pipeline; web-page is a Next.js 16 dashboard; a thin FastAPI layer bridges them over HTTP, which is a stable interface neither side can accidentally break. The frontend talks to it three ways, and the split is deliberate. REST handles everything request-shaped. A WebSocket at /scans/ws/{scan_id} carries agent progress events, because those are pushes with no predictable cadence. And live topology and vulnerability data are polled every five seconds — that data is written incrementally to dashboard_data.json by the scoring agent, and polling a REST endpoint was more honest than building file-watch plumbing to push it. Pages own data fetching and state; components take props and render. That separation is what let me rewire every panel from static mock data to the live API without touching rendering logic.
Parsing nmap XML. The format is well-specified and still full of traps. Chained scans concatenate multiple <nmaprun> documents into one file, each with its own XML declaration, which is not a valid XML document — the parser wraps the whole thing in a synthetic <scans> root, and if that still fails, splits on the declarations and parses each block individually, discarding the ones that don't survive. CPEs arrive in two incompatible grammars, cpe:/a:vendor:product:version and cpe:2.3:a:vendor:product:version:..., so both are normalized to the same dict before anything reads a field. OS fingerprints frequently produce a usable human-readable match name and no usable CPE at all, which is why there's an explicit fallback table mapping strings like "windows server 2019" or "mac os x" back to a queryable vendor/product pair. Traceroute is requested on every medium and high scan regardless of what the model asks for, because without hop data the topology graph has nothing real to draw and falls back to inference.
The graph and the scoring model. Eight LangGraph nodes over a shared AgentState: recon fans out to a recon analyzer and an OS fingerprinter in parallel, both fan back in at the OS analyzer, and from there it's a chain — vulnerability lookup, CVE normalization, CVSS scoring, reporter. Two LLM tiers sit behind it: a fast tier on local Ollama (gpt-oss:20b) for structured, tool-shaped decisions where determinism matters, and an analysis tier for the heavy synthesis work of reading a fingerprint blob or writing a report. The scoring model is an XGBoost regressor over a 459-dimension feature vector built by concatenating one-hot encodings of the CVSS categorical metrics (access vector, complexity, authentication, and the three impact fields), a TF-IDF encoding of the CWE name, and a 384-dimension Sentence-BERT embedding of the vulnerability summary. The encoders are pre-fitted and the feature list is pinned to a schema file, with an alignment step that adds missing columns as zero and drops unexpected ones — so a CVE record with fields the training set never saw produces a score instead of a stack trace.
The D3 view, and what a table can't show. Node positions come from a d3-force simulation — charge repulsion, links as springs, a weak centering force, collision radii, and per-tick clamping so nodes can't drift off-canvas — with link distance and charge strength scaled by node count so an eight-host lab and a fifty-host subnet both stay readable. What the table genuinely cannot express is shape: the /22 scan renders as a single dense star, every address one hop from the scanner itself, which is a flat Layer 2 network stated visually. That's a lateral-movement finding you read in about a second and would never notice scrolling a list. Node color is the other half. The severity field on the topology payload is stale by construction — it's written before scoring finishes — so the client ignores it and recomputes each node's color from the maximum CVE severity for that IP in the /vulnerabilities response. Clicking a node opens its CVEs inline, which is the move a table can't make: pick the worst-looking thing on the map, then read why it's bad.
Partial data, which is most of the runtime. A deep scan runs for hours and the interesting data lands late — nothing is scored until the CVSS node writes its payload — so "the dashboard is empty" is the normal state for most of a scan, and the UI has to be honest about it rather than showing zeros that look like results. Every panel has an explicit waiting state; the stat cards derive from live data rather than defaulting to 0; and during a live scan every topology node renders neutral grey instead of a severity color, because a green node before scoring is a claim the system hasn't earned yet. Colors snap in when the scan completes and the view auto-switches from live to latest, which the dashboard detects by watching the active-scan count fall from above zero back to zero. Two smaller fixes in the same spirit: the FastAPI lifespan marks any scan still flagged running at boot as failed, so a crash can't leave a permanent phantom in the UI, and the activity feed orders by row id rather than timestamp — the sync and async writers stored two different ISO formats, and SQLite string-sorts them into the wrong order.
Untrusted input, which is the genuinely interesting security problem here. There are two attacker-influenced surfaces and they need different answers. Scan output is the obvious one — banners and script results are strings a host on the network chose to send you, and they flow into an LLM prompt — which is why the vulnerability agent is grounded: it may only emit CVE IDs present in the fetched CIRCL data, so a hostile banner can pollute a summary but can't conjure a finding. The sharper surface is the other direction. The recon agent hands nmap flags to a subprocess, and those flags are generated by a language model, which makes an ordinary prompt-injection into a command-injection primitive aimed at your own network. So no proposal reaches subprocess unfiltered: flags are tokenized with shlex, rejected outright if they contain shell metacharacters, then filtered against a per-tier allowlist — discovery-only at the low tier, service detection at medium, full scripting at high — with port expressions parsed and range-checked against that tier's ceiling, and -oX - appended if the model "forgot" to request XML output. Anything not on the list is dropped silently rather than passed through. The result is that the worst a compromised or confused model can do is request a scan that's already been decided to be acceptable.
An LLM picking nmap flags is an injection sink pointed at your own network. The first version passed the model's proposed flags more or less straight through, which meant a prompt-injection in a service banner and a bad model day had the same blast radius. The fix was the tiered sanitizer — tokenize, reject metacharacters, allowlist per tier, validate port ranges, force XML output — and the framing that came with it: the model's output is a proposal, and the code that converts a proposal into an action has to be small enough to read in one sitting. Lesson: with agentic systems the interesting security boundary isn't the prompt, it's the last function before the syscall. Next: log every rejected token instead of dropping it silently, so there's a record of what the model actually tried to run.
The autonomous loop wouldn't stop. Given a deep scan, the recon agent escalated to -A --script=vuln and then re-ran essentially the same two-hour scan against the same unchanged hosts, because delta detection found nothing new but the convergence counter needed four consecutive no-change iterations to trip. Successive passes got slower — roughly 25 minutes, then 52, then an hour and 44 — for no new data. Three deterministic guards fixed it: hard caps on iterations, no-change count, and wall-clock budget; narrowing medium and high scans to already-discovered IPs so the model can't re-sweep a broad CIDR; and force-adding the aggressive flags a "deep" scan is supposed to have so it can't silently degrade into a light port scan either. Lesson: an autonomous loop needs a termination condition that doesn't depend on the model's judgment, because "have I learned anything new?" is exactly the question it's worst at. Next: give the planner the cost of its last scan so escalation is a trade-off rather than a reflex.
One host, three names for the same software. Deduplicating findings sounds like the hard part and isn't — picking the key is. Keying on the CVE alone collapsed genuinely distinct findings on different hosts; keying on the full tuple deduplicated nothing, because the same Apache arrives as a 2.2 CPE, a 2.3 CPE, and a banner string. Keying lookups on (vendor, product) before the fetch fixed it and cut redundant CIRCL calls at the same time. The neighboring bug was noisier: falling back to nmap's generic service name meant querying "http" and "ssh", which return CVEs for hundreds of unrelated vendors, so those names are blocklisted while explicit product strings are always trusted. Lesson: the dedup key is a modeling decision about what counts as the same thing, and it's worth more thought than the dedup mechanism. Next: make version a first-class part of matching, so a CVE fixed in 2.4.58 stops attaching itself to a host running 2.4.58.
The graph was showing colors it hadn't earned. Topology nodes were being painted from the severity field on the topology payload, which is written before scoring completes and is therefore stale — during a live scan the map showed confident severity colors for findings that hadn't been scored yet. Two changes: derive node color client-side from the maximum CVE severity per IP in the vulnerabilities response, and render every node neutral grey while a scan is live so the graph makes no claim until it can back one. Lesson: when a value exists in two places, the display should derive from the source of truth even when the convenient copy is right most of the time — "right most of the time" is indistinguishable from wrong to the person reading it. Next: compute node severity server-side so the client stops recomputing something the API already knows.
The scoring model doesn't know about your network — and saying so was the right call. The most tempting sentence in this whole project is that our model re-ranks CVEs by environmental context. It doesn't. Its 459 features are all intrinsic to the CVE: its own CVSS metrics, its CWE, and an embedding of its description. What it genuinely delivers is a continuous score for every finding, including the many where the feed returns nothing, which is what makes a single sortable queue possible. What it doesn't deliver is a reason to trust that a 7.4 on the gateway outranks a 9.8 on an unreachable printer — the exact problem in the opening paragraph. Lesson: knowing precisely which claim your system supports is the difference between a demo and a tool, and the honest version of the sentence is the one you can defend in a review. Next: add host-context features — open-port count, reachability from the scanner, internet-facing or not — and retrain, which is the change that would make the queue mean what the pitch says it means.
Eight projects, four with a model in the loop.