CVE reachability with an LLM and a code graph A developer built vex-engine, a GitLab CI pipeline that uses an LLM on Amazon Bedrock to read each CVE and OWASP dep-scan's code property graph to locate the vulnerable symbol in source, then commits a CycloneDX VEX verdict so Dependency-Track surfaces only reachable vulnerabilities. On one project, roughly seven or eight of ten critical CVEs were found unreachable from the code, though the developer cautions the ratio is anecdotal. The design replaces regex-based code scanning with graph queries to avoid false negatives that could hide a real vulnerability behind a confident not_affected verdict. Asking a language model whether a CVE affects your code is easy. Deciding when not to believe the answer is the hard part. On one real project we started with a hundred critical CVEs and went through them by hand. Seven or eight out of ten could not be reached from our code. That ratio comes from one codebase, so treat it as an anecdote. Anyone who runs a scanner knows the effect anyway: when everything is red, nobody opens the dashboard, and each CVE costs a developer an afternoon of reading advisories and grepping code. vex-engine is the pipeline I built to give that afternoon back. It runs on GitLab CI, reads each CVE with an LLM on Amazon Bedrock, looks for it in the code with the graph that OWASP dep-scan builds, and commits a CycloneDX VEX verdict to the repository. Dependency-Track then shows only the CVEs that need a person. There are two ways to get this wrong. A false positive costs a developer some time. A false negative hides a real vulnerability behind a confident not affected , and preventing that is the reason for most of the design. So I follow one example through the pipeline, a Pillow CVE in an image decoder, and at every stage I ask the same question: could this stage hide a real vulnerability, and what stops it? The code, the CI template and the lab app are on GitLab https://gitlab.com/argomauro/vex-engine . Every snippet below comes from that repository. On every push, Trivy reads requirements.txt and writes a CycloneDX SBOM. Dependency-Track matches each component against NVD, GitHub and OSV and returns the CVEs. In parallel, OWASP dep-scan 6.3 reads the source and builds a code property graph with its atom engine. The analysis then takes one CVE at a time through three modules: The verdict goes into vex.json . Exploitable CVEs open one GitLab issue per component, and people close issues with labels that flow back into the VEX. The first version also had three phases, but the middle one was a Scanner. The Strategist wrote search patterns, the Scanner ran them as regular expressions over the files that imported the library, and the Auditor received thirty lines above and below every match. Noise went down. Then it met this: config = yaml.safe load open "settings.yml" safe data = yaml.load request.body vulnerable A text search finds both lines. The Auditor gets two blocks of code with no idea where request.body comes from or who calls the surrounding function. It has to guess, and on a bad day it guesses not affected . I replaced the Scanner with a Locator that queries a code graph. The Scanner stayed in the codebase with two smaller jobs, covered below. The division of labour stayed the same, and the rest of the post builds on it: the LLM reads the CVE at the start and judges facts at the end. The graph does the looking. A CVE is written for people. "Heap overflow in the TIFF decoder when processing crafted images" does not tell you which function in your code triggers it, and no database does either. NVD used to add structured data such as scores and affected configurations to every entry; in April NIST announced it would stop enriching every CVE. Reading an advisory and mapping it to code used to need someone with security experience, every time. The Strategist does that reading. It receives the CVE description and the package name and returns a plan as strict JSON. For CVE-2017-18342 PyYAML before 5.1, yaml.load can execute arbitrary code on untrusted data the plan looks like this, with illustrative values: { "trigger type": "api call", "vulnerable symbols": "yaml.load", "yaml.load all" , "conditions": "the argument comes from untrusted input and no SafeLoader is passed", "safe alternatives": "yaml.safe load", "yaml.safe load all" , "import aliases": "yaml" , "attack vector": "a YAML document with a python/object tag executes code when loaded" } Each field has a consumer downstream. The Locator uses vulnerable symbols and import aliases Pillow is imported as PIL , PyYAML as yaml . The Auditor uses conditions , attack vector and safe alternatives . This is the first place a real vulnerability can disappear. If the Strategist names the wrong function, the graph looks for the wrong thing. This part of the prompt guards against it: - vulnerable symbols: ALL the PUBLIC functions/classes an application may call that lead to the vulnerable code, fully qualified from the TOP-LEVEL import name. If the bug is inside an internal decoder or C module e.g. TiffDecode.c , name the public entry points that reach it e.g. "Image.open" for image decoders . If it is in a class used internally by convenience functions, list those too e.g. a bug in requests.Session redirects: "requests.get", "requests.post", ... . Missing an entry point hides a real vulnerability, so when unsure list more, not fewer. In the Pillow example the bug lives in a C decoder that no application calls directly. Applications call Image.open , which reaches the decoder when it opens a TIFF. The prompt asks the Strategist to make that jump, from where the bug is to where your code touches it. The plan also says how the bug fires: api call : the application must call a specific function. framework implicit : the library acts on its own. A CVE in Django's multipart parser fires on every upload before your view runs, and your code never names the parser. config : a setting turns the bug on. The Strategist picks the type from the CVE text, and the type decides which question the Locator asks. Nothing uses the answer before validation. The JSON must parse, the trigger type must be one of the three values, and symbol names are normalised. If Bedrock is unreachable, a keyword heuristic produces a fallback plan and the pipeline marks it as heuristic, because a guessed plan must never clear a CVE. Plans are cached per run, so each CVE/package pair goes to the model once. A wrong symbol list is the cheapest way to hide a vulnerability. Ask for more entry points, never fewer, and record where each plan came from. The Locator uses no model. It reads the graph that OWASP dep-scan https://github.com/owasp-dep-scan/dep-scan builds with atom, which combines three views of the same code. The syntax tree knows that Image in your file is Pillow's module. The call graph knows who calls whom. The data flow knows where a value travels. dep-scan exports the graph as two JSON slices. usages lists every call with its arguments and line. reachables lists flows that start from external input, such as an HTTP request. The CI job pins the image, because the slice format changes between atom versions, and writes a marker file only when the slices are complete: vex reachability: stage: analyze image: name: ghcr.io/owasp-dep-scan/dep-scan:v6.3.0 entrypoint: "" allow failure: true script: - | if depscan -i "${VEX APP SRC}" -o "${CI PROJECT DIR}/depscan-out" -t python \ --reachability-analyzer SemanticReachability \ --explanation-mode EndpointsAndReachables \ --vdb-scope app --no-banner \ && -s depscan-out/python-usages.slices.json ; then touch depscan-out/.complete fi dep-scan exits 0 even when it produces no slices. Partial slices would report "library not used" for every file that failed to parse, and the analysis would close CVEs it never looked at. Without .complete , the Locator treats the graph as missing. Matching is one small function. atom writes resolved calls such as PIL.py: