AI-Powered SBOM Vulnerability Prioritization
Build an n8n workflow that turns a CycloneDX SBOM into a ranked vulnerability report.
Introduction
30 Second Summary
Every security scanner dumps hundreds of vulnerabilities on your desk. Most teams sort by CVSS score and start patching from the top. The problem is that a CVSS 9.8 nobody has ever exploited in the wild gets the same urgency as a CVSS 7.5 that ransomware gangs are actively using right now.
In this project, you will build an automated n8n workflow that ingests a CycloneDX SBOM, discovers known vulnerabilities, checks each one against real-world exploitation evidence, and produces a prioritized remediation brief powered by a local AI model.
What You'll Build
You upload a Black Duck SBOM into your n8n workflow, and within seconds you get back a ranked vulnerability report where confirmed-exploited threats sit at the top, complete with a plain-English summary your team can act on immediately.
By the end of this project, you'll have:
- A working vulnerability scanner that takes any CycloneDX SBOM and instantly reveals which components have confirmed exploits in the wild versus theoretical risks, using free APIs from OSV.dev, CISA KEV, and FIRST EPSS.
- A transparent scoring formula that ranks findings by real exploitation evidence. You can show exactly why one CVE jumped ahead of another, instead of blindly trusting a single CVSS number.
- A local AI-generated remediation brief from your Ollama Llama 3.1 model that turns raw vulnerability data into a plain-English report you can hand directly to developers.
- Secret Mission: Add Anthropic Claude as an alternative AI backend for deeper exploit-context analysis alongside your local Llama model.
Are there any prerequisites?
This project assumes you already have a self-hosted n8n instance running in Docker on Windows 11, and Ollama installed with the Llama 3.1 8B model pulled.
If you do not have these set up yet, complete the n8n Docker installation and Ollama setup first, then come back here.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up n8n-to-Ollama Connectivity
Your n8n instance lives inside a Docker container, but Ollama runs directly on your Windows host machine. These two environments are isolated from each other by default.
Without a network bridge and a saved credential, n8n has no way to send prompts to your local Llama 3.1 model. In this step, you will wire them together so the AI summarization stage of your workflow can function.
In this step, get ready to:
- Confirm Ollama is reachable on your Windows host.
- Create and test an Ollama credential in n8n.
- Verify end-to-end AI Agent connectivity with a quick test workflow.
Confirm Ollama is running on your host
Before configuring n8n, you need to verify that Ollama is actually accepting connections on your Windows machine. On Windows, Ollama starts automatically as a background service after installation.
- Open your web browser on Windows.
- Navigate to http://localhost:11434 in the address bar.
You should see the text Ollama is running displayed in the browser. This confirms the Ollama API server is listening on port 11434.
Why host.docker.internal?
Your n8n container cannot use localhost to reach Ollama because localhost inside Docker refers to the container itself.
Docker Desktop on Windows automatically provides host.docker.internal as a hostname that resolves to the Windows host. No extra Docker flags are needed.
Don't see "Ollama is running"?
Check the Windows system tray (bottom-right corner) for the Ollama icon. If it is not there, search for Ollama in the Start menu and launch it. Wait a few seconds, then refresh the browser page.
help me troubleshoot Ollama not responding on localhost:11434
Create the Ollama credential in n8n
Credentials in n8n store connection details for external services. You will create one that tells n8n how to reach your local Ollama server.
- Switch to your n8n browser tab.
- Click the menu icon in the left sidebar, then select Settings.
- Select Credentials from the Settings menu.
- Click Add Credential in the top right.
- In the search field, type Ollama and select the Ollama credential type from the results.
- In the Base URL field, replace the default value with:
http://host.docker.internal:11434
- Leave the API Key field empty. Local Ollama requires no authentication.
What does this Base URL do?
The Base URL tells n8n where to send all Ollama API requests. Since n8n runs inside Docker, it uses host.docker.internal to break out of the container and reach port 11434 on your Windows machine where Ollama is listening.
Before you click the test button, what do you expect will happen if n8n can successfully reach Ollama?
- Click Save to store the credential.
- Click Test to verify connectivity.
You should see a green success confirmation appear, indicating that n8n can reach your local Ollama server through Docker's network bridge.
Credential test failed?
Double-check that you entered http://host.docker.internal:11434 exactly, with no trailing slash and no typos.
Confirm Ollama is still running by refreshing http://localhost:11434 in your browser. If it stopped, relaunch it from the Start menu.
help me fix my n8n Ollama credential connection failure
Verify end-to-end with a test workflow
A passing credential test confirms network connectivity. Now you will verify that n8n's AI Agent node can actually send a prompt to Llama 3.1 and get a response back.
- Click Workflows in the n8n left sidebar.
- Click Add Workflow to create a new blank workflow.
- Click the + button on the canvas to add your first node.
- Search for Manual Trigger and select it. This lets you run the workflow on demand.
- Click the + connector on the right side of the Manual Trigger node to add the next node.
- Search for AI Agent and select it.
The AI Agent node requires a chat model and at least one tool to function.
- On the AI Agent node, look for the Model connector underneath the node. Click it to attach a model sub-node.
- Search for Ollama Chat Model and select it.
- In the Ollama Chat Model configuration panel, select your saved Ollama credential from the Credential dropdown.
- In the Model field, type llama3.1 to select your pulled model.
- Close the model configuration panel.
Why Ollama Chat Model and not Ollama Model?
The plain Ollama Model node lacks tools support, so it will not work with the AI Agent node. The Ollama Chat Model node is specifically designed to connect to the AI Agent and supports tool calling.
- On the AI Agent node, look for the Tool connector. Click it to attach a tool sub-node.
- Search for HTTP Request and select it as a tool.
- In the HTTP Request Tool configuration, set the URL to https://httpbin.org/get. This is a simple test endpoint that returns request data.
- Close the HTTP Request Tool configuration panel.
Your test workflow is now: Manual Trigger > AI Agent (with Ollama Chat Model and HTTP Request Tool).
- On the AI Agent node, set the Prompt to Define below.
- In the Prompt (User Message) text area, type: Say hello and tell me what tools you have available.
What do you think will happen when you execute this workflow? Will the AI Agent respond with a greeting, or will it error out?
- Click Test Workflow in the top right corner of the canvas.
After a few seconds (Llama 3.1 may take a moment to load into memory on first run), you should see a green checkmark on the AI Agent node. Click the AI Agent node to view the output. The model should respond with a greeting and mention the HTTP Request tool.
AI Agent execution failed?
If you see a connection error, confirm your Ollama credential still passes the test in Settings > Credentials.
If you see an error about no tools connected, make sure the HTTP Request Tool sub-node is properly wired to the AI Agent's Tool connector (not the main flow connector).
The first execution can take 30-60 seconds as Ollama loads the model into memory. Subsequent runs will be faster.
help me debug my n8n AI Agent failing to connect to Ollama
- Now that you have confirmed it works, delete this test workflow. Click the three-dot menu in the top right and select Delete.
- Confirm the deletion when prompted.
Why delete the test workflow?
This was only to prove the connection works. Your real SBOM workflow in the next step will use the same credential and model configuration. Keeping test workflows around clutters your workspace.
Your n8n instance can now reach Ollama across the Docker boundary. Next up, you will build the real workflow that ingests a CycloneDX SBOM and extracts its components for vulnerability scanning.
Ingest the SBOM and Extract Components
Your n8n instance can now talk to Ollama. The next challenge is feeding it real security data to analyze.
Your Black Duck SBOM is a structured JSON file containing hundreds of components. Before you can query vulnerability databases, n8n needs each component broken out as an individual item with a clean Package URL ready for lookup.
In this step, get ready to:
- Create a new workflow with a Manual Trigger and sample SBOM data.
- Parse the CycloneDX JSON and extract each component's Package URL.
- Run the workflow and confirm individual components appear in the output.
Create the workflow with Manual Trigger and sample SBOM
Every n8n automation starts with a trigger that kicks off the workflow, and a data source to process.
- In the n8n UI, click the + button (or Create new workflow on the home page) to start a fresh workflow.
- Double-click the workflow name at the top and rename it to SBOM Vulnerability Prioritizer.
- Click the + node button on the canvas and search for Manual Trigger. Select it to add it to the canvas.
- Click the + on the right side of the Manual Trigger node to add the next node.
- Search for Edit Fields and select Edit Fields (Set).
- In the Edit Fields node settings, change Mode to JSON Output.
- Set Include in Output to No Input Fields.
Now paste the following sample CycloneDX SBOM into the JSON Output field:
{
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
"components": [
{
"type": "library",
"group": "org.apache.logging.log4j",
"name": "log4j-core",
"version": "2.14.1",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"bom-ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
},
{
"type": "library",
"group": "com.fasterxml.jackson.core",
"name": "jackson-databind",
"version": "2.13.0",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.0",
"bom-ref": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.13.0"
},
{
"type": "library",
"group": "org.springframework",
"name": "spring-core",
"version": "5.3.9",
"purl": "pkg:maven/org.springframework/spring-core@5.3.9",
"bom-ref": "pkg:maven/org.springframework/spring-core@5.3.9"
}
]
}
What is this sample SBOM?
This is a minimal CycloneDX SBOM with three well-known Java libraries. The purl field (Package URL) is a standardized identifier for software packages that vulnerability databases understand.
In a real workflow, you would replace this with your actual Black Duck SBOM export. The structure is the same regardless of how many components it contains.
- Close the Edit Fields node panel by clicking Back to canvas or pressing Escape.
You should see two nodes on your canvas connected by a line: Manual Trigger on the left, Edit Fields (Set) on the right.
Don't see the Edit Fields node connected?
If the node appeared but is not connected to the Manual Trigger, drag from the small circle on the right edge of the Manual Trigger node to the left edge of the Edit Fields node.
If you added it using the + on the Manual Trigger node, it connects automatically.
Help me connect my nodes in n8n.
Parse CycloneDX with a Code node
The Edit Fields node outputs the raw SBOM as a single JSON object. You need a Code node to iterate through its components array and produce one n8n item per component.
- Click the + on the right side of the Edit Fields (Set) node.
- Search for Code and select the Code node.
- Rename the Code node to Parse CycloneDX SBOM by double-clicking its title on the canvas.
- Open the Code node and make sure Language is set to JavaScript.
- Delete any placeholder code in the editor and paste the following:
// Parse CycloneDX SBOM and extract components with PURLs
const sbomData = $input.first().json;
// Handle both top-level and nested SBOM structures
const components = sbomData.components || [];
const extractedComponents = components
.filter(comp => comp.purl) // Only components with Package URLs
.map(comp => ({
purl: comp.purl,
name: comp.name || '',
version: comp.version || '',
group: comp.group || '',
type: comp.type || 'library',
bomRef: comp['bom-ref'] || ''
}));
return extractedComponents.map(comp => ({ json: comp }));
What does this code do?
- `$input.first().json` grabs the SBOM object that flows in from the Edit Fields node.
- `components.filter(comp => comp.purl)` removes any entries that lack a Package URL, since those cannot be queried against vulnerability databases.
- The .map() extracts only the fields you need: purl, name, version, group, type, and bomRef.
- Wrapping each component in { json: comp } tells n8n to treat it as a separate item in the pipeline, so downstream nodes process each component individually.
Run and verify the extraction
Before you run this, think about what you expect the Code node to output. You pasted a SBOM with 3 components, each with a purl field. How many output items do you expect?
- Click Test Workflow in the top-right corner of the canvas (or press Ctrl+Enter).
- Click on the Parse CycloneDX SBOM node to open its output panel.
You should see 3 items in the output. Each item contains fields like purl: "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1", name, version, and group.
Seeing 0 items or an error?
- If you see an error about invalid JSON in the Edit Fields node, confirm you pasted valid JSON in the JSON Output field. Check for missing commas or mismatched braces.
- If the Code node outputs 0 items, verify the Edit Fields node mode is set to JSON Output (not Manual Mapping) and that Include in Output is No Input Fields.
- If the output shows 1 item instead of 3, make sure your Code node code returns extractedComponents.map(comp => ({ json: comp })) as an array, not a single object.
Help me debug my Parse CycloneDX SBOM node output.
✔️ Awesome, I've got everything!
Great. Double check that each output item has a purl field starting with pkg: and then save your workflow with Ctrl+S.
ⓧ I'd like to double check the full code
Here is the complete code for the Parse CycloneDX SBOM Code node:
// Parse CycloneDX SBOM and extract components with PURLs
const sbomData = $input.first().json;
// Handle both top-level and nested SBOM structures
const components = sbomData.components || [];
const extractedComponents = components
.filter(comp => comp.purl) // Only components with Package URLs
.map(comp => ({
purl: comp.purl,
name: comp.name || '',
version: comp.version || '',
group: comp.group || '',
type: comp.type || 'library',
bomRef: comp['bom-ref'] || ''
}));
return extractedComponents.map(comp => ({ json: comp }));
Your SBOM is parsed and each component is ready as an individual item. Next up, you will query all of these Package URLs against a real vulnerability database to find out which components have known security flaws.
Query Vulnerabilities from OSV.dev
Your workflow now extracts individual components from a CycloneDX SBOM, each with a clean PURL. The next question is obvious: which of these components have known security vulnerabilities?
OSV.dev aggregates vulnerability data from dozens of advisory sources and exposes a free batch query API. It requires no authentication and currently has no rate limits. In this step, you will send all your component PURLs to OSV.dev in a single request and map the results back to each component.
In this step, get ready to:
- Build a batch query payload from your extracted component PURLs.
- Send the batch request to the OSV.dev vulnerability API.
- Map vulnerabilities back to components and filter to only vulnerable ones.
Build the batch query payload
The OSV.dev querybatch endpoint accepts a JSON body with a queries array. Each entry contains a package.purl field. You need a Code node that reshapes your component items into this exact structure.
- Select the Add node connector on the right side of your Parse CycloneDX SBOM node.
- Search for Code and select it to add a Code node to the canvas.
- Rename the node to Build OSV Batch Query by double-clicking the node title.
- Leave the mode as Run Once for All Items (the default).
- Replace the placeholder code with the following:
// Build the OSV.dev querybatch payload from component PURLs
const items = $input.all();
const queries = items.map(item => ({
package: {
purl: item.json.purl
}
}));
return [{ json: { queries } }];
What does this code do?
- $input.all() grabs every component item from the previous node (one per component extracted from your SBOM).
- The .map() reshapes each item into the exact structure OSV.dev expects: an object with a package.purl field.
- The return merges everything into a single item containing the full queries array, ready to send as one HTTP request body.
- Close the Code node to return to the canvas.
- Click Execute Workflow to run the workflow.
- Click the Build OSV Batch Query node to inspect its output. You should see a single item containing a queries array with one entry per component.
Seeing an empty queries array?
- Make sure the Build OSV Batch Query node is connected to the output of Parse CycloneDX SBOM. Drag from the grey dot on the right of the previous node to the grey rectangle on the left of this node.
- Confirm that the previous node produced items with a purl field by clicking Parse CycloneDX SBOM and checking its output panel.
Help me debug my Build OSV Batch Query code node
✔️ Awesome, I've got everything!
Great. Make sure you have saved your workflow before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for the Build OSV Batch Query Code node:
// Build the OSV.dev querybatch payload from component PURLs
const items = $input.all();
const queries = items.map(item => ({
package: {
purl: item.json.purl
}
}));
return [{ json: { queries } }];
Send the batch request to OSV.dev
Now you have a properly formatted payload. The next node sends it to the OSV.dev querybatch endpoint and receives vulnerability matches for every component in one call.
- Select the Add node connector on the right side of the Build OSV Batch Query node.
- Search for HTTP Request and select it to add the node.
- Set Method to POST.
- In the URL field, enter:
https://api.osv.dev/v1/querybatch
- Toggle Send Body on.
- Set Body Content Type to JSON.
- Under Specify Body, select Using JSON.
- In the JSON body field, click the expression editor (the small toggle next to the field) to switch to Expression mode.
- Enter the following expression to pass the payload from the previous node:
{{ $json }}
What does this expression do?
The expression {{ $json }} passes the entire JSON output of the previous node (your queries object) as the HTTP request body. Since the Build OSV Batch Query node outputs exactly the structure OSV.dev expects, no additional transformation is needed.
- Leave Authentication set to None. OSV.dev requires no API key.
- Close the HTTP Request node to return to the canvas.
- Click Execute Workflow to run the full workflow.
- Click the HTTP Request node to inspect its output.
You should see a JSON response containing a results array. Each entry corresponds to one of your queried components. Entries with known vulnerabilities contain a vulns array listing CVE or GHSA identifiers.
Seeing a 400 error from OSV.dev?
- Make sure Specify Body is set to Using JSON (not Using Fields Below).
- Confirm the body field is in Expression mode and contains {{ $json }} exactly.
- Check that the Build OSV Batch Query node output has a queries key containing an array. If it is empty, your SBOM may not have components with PURL values.
Help me fix my HTTP Request to OSV.dev returning a 400 error
Map vulnerabilities back to components
The OSV.dev response preserves input order. The first entry in results corresponds to the first component you queried. A Code node can use this property to pair each result back to its original component and filter out anything with zero vulnerabilities.
- Select the Add node connector on the right side of the HTTP Request node.
- Search for Code and select it to add a new Code node.
- Rename the node to Map Vulnerabilities to Components.
- Leave the mode as Run Once for All Items.
- Replace the placeholder code with the following:
// Pair OSV results back to components and extract vulnerability IDs
const osvResponse = $input.first().json;
const components = $('Parse CycloneDX SBOM').all();
const results = osvResponse.results || [];
const vulnerableComponents = [];
for (let i = 0; i < results.length; i++) {
const vulns = results[i].vulns || [];
if (vulns.length === 0) continue;
const component = components[i]?.json || {};
const vulnIds = vulns.map(v => v.id);
vulnerableComponents.push({
purl: component.purl,
name: component.name,
version: component.version,
group: component.group,
vulnerabilityCount: vulns.length,
vulnerabilityIds: vulnIds
});
}
return vulnerableComponents.map(comp => ({ json: comp }));
What does this code do?
- $input.first().json reads the HTTP response body (the OSV.dev results).
- $('Parse CycloneDX SBOM').all() reaches back to a previous node by name and retrieves the original component items. This is how you cross-reference data from different parts of the workflow.
- The loop pairs each OSV result to its component by index (OSV preserves query order), extracts vulnerability IDs, and skips components with zero vulnerabilities.
- The output is a filtered list of only vulnerable components, each carrying a vulnerabilityIds array and a vulnerabilityCount.
- Close the Code node to return to the canvas.
Before you run this, what do you expect to see in the output? Will every component appear, or only some of them?
- Click Execute Workflow to run the full workflow end-to-end.
- Click the Map Vulnerabilities to Components node to inspect its output.
You should see a filtered list of items. Each item contains purl, name, version, group, vulnerabilityCount, and vulnerabilityIds (an array of CVE/GHSA identifiers). Components with no known vulnerabilities are gone.
Seeing zero output items?
- Check the HTTP Request node output. If the results array entries all have empty vulns arrays, your sample SBOM components may have no known vulnerabilities. Try adding a component with a known CVE (e.g., pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1) to your sample data.
- Verify that the node name in the code matches exactly: $('Parse CycloneDX SBOM'). Node names in n8n are case-sensitive.
Help me debug why my Map Vulnerabilities node returns zero items
✔️ Awesome, I've got everything!
Great. Save your workflow before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for the Map Vulnerabilities to Components Code node:
// Pair OSV results back to components and extract vulnerability IDs
const osvResponse = $input.first().json;
const components = $('Parse CycloneDX SBOM').all();
const results = osvResponse.results || [];
const vulnerableComponents = [];
for (let i = 0; i < results.length; i++) {
const vulns = results[i].vulns || [];
if (vulns.length === 0) continue;
const component = components[i]?.json || {};
const vulnIds = vulns.map(v => v.id);
vulnerableComponents.push({
purl: component.purl,
name: component.name,
version: component.version,
group: component.group,
vulnerabilityCount: vulns.length,
vulnerabilityIds: vulnIds
});
}
return vulnerableComponents.map(comp => ({ json: comp }));
You now know which components are vulnerable and which CVEs affect them. But every CVE in this list is treated equally. A critical remote code execution that is actively exploited by ransomware gangs sits next to a low-severity information disclosure that nobody has ever weaponized.
Next up, you will enrich each CVE with real-world exploitation evidence from CISA KEV and FIRST EPSS to separate the truly dangerous vulnerabilities from the theoretical ones.
Enrich with Exploitation Intelligence
Your workflow now surfaces which components have known vulnerabilities. But the list treats every CVE as equally dangerous.
In reality, a CVE with a CVSS score of 7.5 that is confirmed exploited in the wild is empirically more dangerous than one scored 9.8 that nobody has ever weaponized. Without real-world exploitation signals, your team wastes time patching theoretical risks while attackers walk through confirmed holes.
In this step, get ready to:
- Fetch the full CISA KEV catalog of confirmed-exploited vulnerabilities.
- Batch-query FIRST EPSS for 30-day exploitation probability scores.
- Merge exploitation intelligence onto each CVE in your component list.
Fetch the CISA KEV catalog
The CISA KEV (Known Exploited Vulnerabilities) catalog lists every CVE that has been confirmed exploited in the wild. If a vulnerability appears here, it is not theoretical. Someone has already used it to break into a real system.
- In your workflow canvas, click the + button after the Map Vulnerabilities to Components node.
- Search for HTTP Request and add it.
- Rename this node to Fetch CISA KEV by double-clicking its title.
- Set Method to GET.
- Set URL to:
https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
- Leave Authentication set to None.
- Click the + button after Fetch CISA KEV to add a new node.
- Search for Code and add it.
- Rename it to Build KEV Lookup Set.
- Paste the following code into the Code node editor:
// Extract all CVE IDs from KEV catalog into a fast lookup object
const kevCatalog = $input.first().json;
const kevVulns = kevCatalog.vulnerabilities || [];
const kevLookup = {};
for (const vuln of kevVulns) {
kevLookup[vuln.cveID] = {
dateAdded: vuln.dateAdded,
ransomware: vuln.knownRansomwareCampaignUse,
dueDate: vuln.dueDate,
requiredAction: vuln.requiredAction
};
}
return [{ json: { kevLookup, kevCount: Object.keys(kevLookup).length } }];
What does this code do?
The CISA KEV response is a single JSON object with a vulnerabilities array. Each entry contains a cveID plus metadata like dateAdded, knownRansomwareCampaignUse, and requiredAction.
The code builds a kevLookup object keyed by CVE ID. Later, checking whether a CVE is in KEV is a single property lookup instead of scanning the entire array each time.
- Click Test step on the Build KEV Lookup Set node (run the workflow up to this point first if needed).
- In the output panel, you should see a kevCount field showing a number in the range of 1100+. This confirms the full KEV catalog was fetched and indexed.
Seeing a connection error or empty response?
- Make sure your Docker container running n8n has internet access. Test by adding a temporary HTTP Request node to any public URL.
- Verify the URL is exactly https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json with no extra spaces.
- If the response is very large and times out, increase the node timeout under Settings > Timeout to 30000 ms.
Why is my KEV fetch failing in n8n?
Batch-query FIRST EPSS scores
The EPSS (Exploit Prediction Scoring System) gives each CVE a probability score between 0 and 1 representing the likelihood of exploitation in the next 30 days. A CVE with an EPSS of 0.95 has a 95% chance of being exploited soon.
- Click the + button after the Build KEV Lookup Set node.
- Add another HTTP Request node and rename it to Batch Query EPSS.
- Set Method to GET.
- In the URL field, click the Expression toggle (the curly braces icon) to switch to expression mode.
- Paste the following expression into the URL field:
https://api.first.org/data/v1/epss?cve={{ $('Map Vulnerabilities to Components').all().map(item => item.json.vulnerabilityIds).flat().filter(id => id.startsWith('CVE-')).join(',') }}
What does this expression do?
This n8n expression dynamically builds the EPSS API URL at runtime. It reaches back to the Map Vulnerabilities to Components node, collects all vulnerability IDs across every component, filters to only CVE-prefixed IDs (since EPSS only scores CVEs), and joins them into a comma-separated query parameter.
The FIRST EPSS API accepts batch requests with multiple CVE IDs in a single GET call. No authentication is needed.
- Leave Authentication set to None.
- Execute the workflow up to the Batch Query EPSS node by clicking Test step.
- In the output panel, look for a data array. Each entry contains cve, epss, and percentile fields. This confirms the EPSS API returned scores for your CVEs.
URL length limits
The EPSS API URL supports approximately 2000 characters for the cve parameter. For SBOMs with hundreds of unique CVEs, you may need to split into multiple HTTP Request calls. For this project, a typical SBOM stays well within this limit.
Expression showing an error or empty result?
- Make sure you ran all previous nodes first. The expression references Map Vulnerabilities to Components by name, and it must have executed data available.
- Check that the node name matches exactly. In n8n, node name references are case-sensitive.
- If you renamed Map Vulnerabilities to Components to something different earlier, update the expression to match your actual node name.
Why is my EPSS expression returning an error in n8n?
Merge enrichment data onto each vulnerability
You now have three data sources ready: the vulnerable components from Step 3, the KEV lookup, and the EPSS scores. The final node in this step joins them together so each CVE carries its real-world exploitation context.
- Click the + button after Batch Query EPSS and add a Code node.
- Rename it to Merge Enrichment Data.
- Paste the following code into the Code node editor. This first section pulls in data from the three upstream nodes and builds the EPSS lookup:
// Enrich each CVE with KEV status and EPSS score
const vulnerableComponents = $('Map Vulnerabilities to Components').all();
const kevData = $('Build KEV Lookup Set').first().json.kevLookup;
const epssResponse = $('Batch Query EPSS').first().json;
// Build EPSS lookup from API response
const epssLookup = {};
const epssData = epssResponse.data || [];
for (const entry of epssData) {
epssLookup[entry.cve] = {
epss: parseFloat(entry.epss),
percentile: parseFloat(entry.percentile)
};
}
const enrichedComponents = [];
What does this section do?
The $('NodeName') syntax lets an n8n Code node reach back to any previous node's output by name. This pulls the component list, KEV lookup object, and raw EPSS API response into local variables.
The EPSS response is converted into a epssLookup object keyed by CVE ID for fast lookups, just like we did with KEV.
- Directly below the code you just pasted (in the same Code node), add the enrichment loop:
for (const item of vulnerableComponents) {
const comp = item.json;
const enrichedVulns = comp.vulnerabilityIds.map(vulnId => {
const isKev = kevData[vulnId] ? true : false;
const epssInfo = epssLookup[vulnId] || { epss: 0, percentile: 0 };
return {
id: vulnId,
kev: isKev,
ransomware: kevData[vulnId]?.ransomware || 'N/A',
epss: epssInfo.epss,
percentile: epssInfo.percentile
};
});
enrichedComponents.push({
purl: comp.purl,
name: comp.name,
version: comp.version,
group: comp.group,
vulnerabilities: enrichedVulns
});
}
return enrichedComponents.map(comp => ({ json: comp }));
What does the enrichment loop do?
For each vulnerable component, the loop iterates through its vulnerability IDs and decorates each one. It checks whether the CVE exists in the kevData lookup (setting kev: true if confirmed exploited), pulls the ransomware field from KEV data, and grabs the epss probability and percentile from the EPSS lookup.
CVEs without EPSS data default to 0. CVEs not in KEV get kev: false and ransomware N/A. The output is one item per component, each carrying a fully enriched vulnerabilities array.
Before you run the full workflow, predict what you expect to see. Will every CVE have kev: true? Or will most be false?
- Execute the full workflow by clicking Test Workflow from the Manual Trigger.
- Click on the Merge Enrichment Data node and check its output panel.
You should see component items where each vulnerabilities array entry now contains kev: true/false, epss (a decimal like 0.03 or 0.97), percentile, and ransomware. Most CVEs will have kev: false because the KEV catalog contains only around 1100 confirmed-exploited vulnerabilities out of hundreds of thousands tracked. The few with kev: true are the ones your team needs to drop everything and fix.
Merge Enrichment Data showing empty or errors?
- Verify that all three upstream node names in your code match exactly: Map Vulnerabilities to Components, Build KEV Lookup Set, and Batch Query EPSS.
- Run the full workflow from the Manual Trigger, not just the individual node. The Code node needs data from all three referenced nodes to be present in memory.
- If you see Referenced node is unexecuted, it means one of the upstream nodes did not run. Execute the full workflow end-to-end.
Why is my Merge Enrichment Data node failing with a reference error?
✔️ Awesome, I've got everything!
Great. Double check you have four new nodes on your canvas: Fetch CISA KEV, Build KEV Lookup Set, Batch Query EPSS, and Merge Enrichment Data. Save your workflow.
ⓧ I'd like to double check the full code
Here is the complete code for each Code node added in this step. Scroll up and confirm yours matches.
Build KEV Lookup Set:
// Extract all CVE IDs from KEV catalog into a fast lookup object
const kevCatalog = $input.first().json;
const kevVulns = kevCatalog.vulnerabilities || [];
const kevLookup = {};
for (const vuln of kevVulns) {
kevLookup[vuln.cveID] = {
dateAdded: vuln.dateAdded,
ransomware: vuln.knownRansomwareCampaignUse,
dueDate: vuln.dueDate,
requiredAction: vuln.requiredAction
};
}
return [{ json: { kevLookup, kevCount: Object.keys(kevLookup).length } }];
Merge Enrichment Data:
// Enrich each CVE with KEV status and EPSS score
const vulnerableComponents = $('Map Vulnerabilities to Components').all();
const kevData = $('Build KEV Lookup Set').first().json.kevLookup;
const epssResponse = $('Batch Query EPSS').first().json;
// Build EPSS lookup from API response
const epssLookup = {};
const epssData = epssResponse.data || [];
for (const entry of epssData) {
epssLookup[entry.cve] = {
epss: parseFloat(entry.epss),
percentile: parseFloat(entry.percentile)
};
}
const enrichedComponents = [];
for (const item of vulnerableComponents) {
const comp = item.json;
const enrichedVulns = comp.vulnerabilityIds.map(vulnId => {
const isKev = kevData[vulnId] ? true : false;
const epssInfo = epssLookup[vulnId] || { epss: 0, percentile: 0 };
return {
id: vulnId,
kev: isKev,
ransomware: kevData[vulnId]?.ransomware || 'N/A',
epss: epssInfo.epss,
percentile: epssInfo.percentile
};
});
enrichedComponents.push({
purl: comp.purl,
name: comp.name,
version: comp.version,
group: comp.group,
vulnerabilities: enrichedVulns
});
}
return enrichedComponents.map(comp => ({ json: comp }));
Remember: these two blocks go into a single Code node. Paste them together as one continuous script.
Your CVEs now carry real-world exploitation context. But they are still an unsorted list of JSON objects. Next up, you will apply a scoring formula that ranks these findings into clear priority tiers and hand them to your local AI for a human-readable remediation brief.
Prioritize and Generate AI Summary
Your workflow now has enriched vulnerability data with CISA KEV and EPSS signals attached to every CVE.
The problem is that these enriched items are still an unsorted list of JSON objects. Your security team needs two things: a ranked report with clear priority tiers, and a plain-English explanation of what to fix first and why.
In this step, get ready to:
- Apply an exploitation-weighted scoring formula that ranks CVEs by real-world danger.
- Connect your local Llama 3.1 model to generate a human-readable remediation brief.
- Execute the full workflow end-to-end and see both a ranked JSON output and an AI-generated summary.
Apply the scoring formula
A CVE with a CVSS score of 7.5 that is confirmed exploited in the wild is empirically more dangerous than a CVE with a CVSS score of 9.8 that nobody has ever exploited. Your scoring formula encodes this reality by weighting KEV status and EPSS probability on top of the base CVSS score.
- In your workflow canvas, click the + button after the Merge Enrichment Data node.
- Select Code from the node list.
- Rename the node to Score and Rank.
- Paste the following code into the Code node editor. This is the first half of the scoring script:
// Apply exploitation-weighted scoring formula and assign priority tiers
const enrichedComponents = $input.all();
const scoredFindings = [];
for (const item of enrichedComponents) {
const comp = item.json;
for (const vuln of comp.vulnerabilities) {
const cvss = vuln.cvss || 7.0; // Default if CVSS not available from OSV
const kevWeight = vuln.kev ? 1 : 0;
const epssWeight = vuln.epss || 0;
let score = cvss * (1 + 1.5 * kevWeight + 1.0 * epssWeight);
score = Math.min(score, 25); // Clamp to max 25
let tier;
if (vuln.kev) {
tier = 'Critical';
} else if (score >= 12) {
tier = 'High';
} else if (score >= 6) {
tier = 'Medium';
} else {
tier = 'Low';
}
What does this formula do?
The formula score = cvss * (1 + 1.5 * kev + 1.0 * epss) multiplies the base CVSS score by a factor that grows when real exploitation evidence exists.
- If a CVE is in the CISA KEV catalog (confirmed exploited), it gets a 1.5x multiplier on top of the base.
- The EPSS probability (0 to 1) adds a further weighted boost reflecting 30-day exploitation likelihood.
- The score is clamped to a maximum of 25 to keep the tier thresholds meaningful.
- Any CVE in KEV is automatically Critical regardless of score, because confirmed exploitation is the strongest signal available.
- Directly below the code you just pasted (still in the same Code node editor), add the rest of the script:
scoredFindings.push({
priority: tier,
score: Math.round(score * 100) / 100,
cve: vuln.id,
component: `${comp.group ? comp.group + '/' : ''}${comp.name}@${comp.version}`,
purl: comp.purl,
kev: vuln.kev,
ransomware: vuln.ransomware,
epss: vuln.epss,
percentile: vuln.percentile
});
}
}
// Sort by score descending
scoredFindings.sort((a, b) => b.score - a.score);
return [{ json: { findings: scoredFindings, totalFindings: scoredFindings.length } }];
What does this output section do?
Each vulnerability gets pushed into the scoredFindings array with its tier, score, component name, and all enrichment fields.
The final sort orders everything by score descending, so the most dangerous CVEs appear first. The node outputs a single item containing findings (the ranked array) and totalFindings (a count).
- Click Execute Node on the Score and Rank node to test it.
- In the output panel, expand the findings array. You should see CVEs sorted by score, with any KEV entries at the top marked as Critical.
Seeing an empty findings array?
- Make sure the Merge Enrichment Data node ran first. Click Execute Workflow from the Manual Trigger to re-run the full chain.
- Check that the code includes both parts pasted together as a single continuous script in the Code node editor.
- help me debug my Score and Rank Code node in n8n
✔️ Awesome, I've got everything!
Great. Double check you have both parts pasted together as one continuous script in the Score and Rank Code node, then save your workflow.
ⓧ I'd like to double check the full code
Scroll up and confirm that your Score and Rank Code node contains both code sections above in order:
- Part 1: The scoring formula section starting with const enrichedComponents = $input.all() through the tier assignment logic.
- Part 2: The scoredFindings.push() section through the return statement.
Both parts must appear as one continuous block of code with no gap between them.
Generate the AI summary with Ollama
You now have a scored and ranked JSON payload. The final piece is handing those top-priority findings to your local Llama 3.1 model so it can produce a remediation brief your team can read and act on immediately.
- On the workflow canvas, click the + button after the Score and Rank node.
- Search for AI Agent and add it to the workflow.
- With the AI Agent node open, click the + Chat Model connector at the bottom.
- Select Ollama Chat Model from the list.
- In the Ollama Chat Model sub-node, select the credential you created earlier.
- In the Model field, type llama3.1.
Why the AI Agent node?
The AI Agent node provides a system prompt field that lets you define the model's role and output format. This turns your local LLM into a specialized vulnerability analyst that produces structured, actionable briefs rather than generic text.
- Back in the AI Agent node settings, set the Prompt dropdown to Define below.
- In the System Message field (under node options), paste the following system prompt:
You are a vulnerability analyst. Given the following prioritized vulnerability data, produce a concise remediation brief. List the top 5 most critical findings, explain why they are critical (KEV status, EPSS probability), and recommend immediate actions. Format with clear headers.
- In the Text field (the user message / prompt), enter an expression that references the Score and Rank output. Click the expression editor icon and enter:
{{ JSON.stringify($('Score and Rank').first().json.findings.slice(0, 10), null, 2) }}
What does this expression do?
This n8n expression pulls the findings array from the Score and Rank node, takes the top 10 entries, and converts them to formatted JSON. The model receives only the highest-priority CVEs, keeping the prompt within Llama 3.1's context window.
- Close the AI Agent node settings.
- Save your workflow by pressing Ctrl+S.
Seeing a 'must be connected' error?
- If n8n shows "A Chat Model sub-node must be connected", click the + Chat Model button at the bottom of the AI Agent node and re-attach the Ollama Chat Model.
- If you see a prompt-related error, make sure the Prompt dropdown is set to Define below (not "Connected Chat Trigger Node").
- help me connect my Ollama Chat Model to the AI Agent node in n8n
Run the full workflow and verify the output
Everything is wired up. Time to execute the entire pipeline from SBOM ingestion through to the AI-generated remediation brief.
- Click the Manual Trigger node in your workflow.
- Click Test Workflow to execute the full pipeline end-to-end.
Before you check the output, predict: what do you expect the AI Agent's response to contain?
- Click on the Score and Rank node and inspect its output. You should see a findings array with entries sorted by score, each entry showing priority, score, cve, component, and kev fields.
- Click on the AI Agent node and inspect its output. You should see a natural-language remediation brief with headers, the top critical findings listed, KEV/EPSS explanations for each, and recommended immediate actions.
What should I see?
The Score and Rank node produces structured JSON data with priority tiers (Critical, High, Medium, Low) and numeric scores. The AI Agent node produces a plain-English summary that a non-technical stakeholder could read.
Together, these two outputs give your team both machine-readable data for tooling integration and a human-readable report for immediate action.
AI Agent returning an error or blank output?
- Make sure Ollama is still running on your host. Open http://localhost:11434 in your browser to verify.
- If the output is blank, check the expression in the Text field is referencing the correct node name: $('Score and Rank'). Node names are case-sensitive in n8n expressions.
- The first execution with Llama 3.1 may take 30-60 seconds as the model loads into memory. Wait for it to complete.
- help me troubleshoot my n8n AI Agent node not generating a response with Ollama
Secret mission
Add Claude for Deeper Analysis
Your local Llama 3.1 8B model produces solid remediation summaries, but a larger cloud model can deliver richer exploit-context reasoning. In this secret mission, you add Anthropic Claude as a second AI path and build a Switch node that lets you choose between quick local analysis and deep cloud-powered analysis with a single parameter change.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free APIs, so there are no ongoing costs while the workflow is idle.
Resources you used:
- SBOM Vulnerability Prioritizer workflow in n8n.
- Ollama credential in n8n.
- Anthropic credential in n8n (Secret Mission only).
- Anthropic API key at console.anthropic.com (Secret Mission only).
Keep everything running
No action needed. Choose this if you're still actively building or plan to reuse the workflow for future SBOMs.
- Your n8n workflow only runs when you manually trigger it. It consumes no resources while idle.
- Ollama uses GPU/CPU memory only while processing a request. Between runs it sits idle on your host machine.
- The free APIs (OSV.dev, CISA KEV, FIRST EPSS) require no accounts and store no data on your behalf.
- Your Anthropic API key (if created) does not incur charges unless you actively send requests.
Pause - I'll come back to this later
Stop your n8n Docker container to free up memory. Your workflow and credentials are saved in n8n's internal database and will be there when you restart.
- Open Docker Desktop on Windows.
- In the Containers tab, find your n8n container.
- Click the Stop button (square icon) to stop the container.
- When you're ready to resume, click the Start button (play icon) on the same container.
Your workflow, credentials, and execution history will all be intact when you restart.
Delete - I don't want to use this again
Remove all project resources permanently. This deletes your workflow and credentials from n8n.
- In n8n, open the SBOM Vulnerability Prioritizer workflow.
- Press Ctrl+K to open the Command Bar, type delete, and select the delete workflow action.
- Go to Settings > Credentials.
- Find and delete the Ollama credential.
- If you completed the Secret Mission, also delete the Anthropic credential from the same page.
- If you completed the Secret Mission and no longer need the Anthropic API key, go to console.anthropic.com > Settings > API Keys and disable or delete the key you created for this project.
Nice Work!
Nice Work!
You just built a fully automated vulnerability prioritization engine powered by free intelligence APIs and a local AI model. From a raw SBOM to a ranked, exploitation-weighted remediation brief, your workflow does in seconds what manual triage takes hours to accomplish.
Here's what you learned:
- Built an n8n workflow that parses CycloneDX SBOMs, extracts component Package URLs, and batch-queries OSV.dev to discover CVEs across all components simultaneously.
- Enriched every CVE with real-world exploitation signals from CISA KEV and FIRST EPSS, then applied a transparent exploitation-weighted scoring formula that surfaces genuinely dangerous vulnerabilities over theoretical ones.
- Connected a local Ollama Llama 3.1 8B model to generate actionable remediation summaries from the prioritized findings.
- Secret Mission: Added Anthropic Claude as an alternative AI backend for deeper exploit-context analysis alongside your local Llama model.
Ready to quiz yourself?