Build an SBOM-to-Nuclei Pipeline
Automate CVE prioritization and Nuclei template generation from a CycloneDX SBOM.
Introduction
30 Second Summary
Most teams know what software they ship, but have no systematic way to figure out which vulnerabilities in that software actually need to be tested. The result is a pile of CVE alerts with no clear next step.
In this project, you will build a local automation pipeline that takes your product's software bill of materials, classifies each component by how exposed it is in your architecture, pulls real vulnerability data, and generates ready-to-run exploit-testing templates for the threats that matter most.
What You'll Build
When you trigger the finished workflow, you upload your product's SBOM and watch it transform into a ranked set of Nuclei YAML templates you can hand to a penetration tester or run yourself.
By the end of this project, you'll have:
- A working n8n workflow that ingests your CycloneDX SBOM and displays every component with its name, version, and package URL.
- An AI-powered classification step where your local LLM labels each component as frontend, API, or backend based on your product's OpenAPI spec.
- A prioritized vulnerability list that ranks CVE data from the NVD API by how exposed each component is in your architecture.
- Downloadable Nuclei YAML templates generated by Ollama targeting your product's highest-risk vulnerabilities.
- Secret Mission: Integrate EPSS exploit probability scoring to weight prioritization by real-world exploit likelihood.
Are there any prerequisites?
This project assumes you already have n8n self-hosted in Docker and Ollama running natively on Windows with the Llama 3.1 8B model pulled. You also need a CycloneDX SBOM JSON file and an OpenAPI JSON spec for your product.
If you do not have these set up yet, complete the prerequisite projects in this series first.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters for your security workflow.
Set Up n8n-to-Ollama Connection
Your n8n workflow needs to talk to your local LLM. Every AI-powered node in the pipeline depends on this connection.
Without verified connectivity between n8n (running in Docker) and Ollama (running natively on Windows), every downstream AI step will fail silently. In this step, you will create Ollama credentials in n8n and confirm the LLM responds to a test prompt.
In this step, get ready to:
- Configure Ollama credentials in n8n with the correct Docker-to-host networking URL.
- Build a test workflow with a Manual Trigger and Basic LLM Chain node.
- Verify the LLM responds and save the workflow for future steps.
Configure Ollama credentials in n8n
Your n8n instance runs inside a Docker container. Ollama runs natively on your Windows host. These two processes have different network namespaces, so n8n cannot reach Ollama at localhost. Docker Desktop provides a special hostname that lets containers talk back to the host machine.
- Open your browser and navigate to http://localhost:5678 to access n8n.
- Click the Create button in the upper-left corner of the side menu.
- Select Credential.
- Search for and select Ollama from the list of services.
- In the Base URL field, enter:
http://host.docker.internal:11434
- Leave the API Key field empty (you are not using an authenticated proxy).
- Click Save. n8n will test the credential to confirm it can reach Ollama.
Why host.docker.internal instead of localhost?
Each Docker container has its own localhost that only points to itself. The special hostname host.docker.internal resolves to your Windows host machine's IP, letting n8n's container reach Ollama on port 11434.
Docker Desktop on Windows configures this automatically. No extra flags are needed.
Credential test failed?
Make sure Ollama is actually running on your host. Open a new terminal and run ollama list to confirm it responds.
If you see an IPv6 error like ECONNREFUSED ::1:11434, try changing the Base URL to http://host.docker.internal:11434 (which should already be set) or verify Docker Desktop is running.
Help me troubleshoot my n8n-to-Ollama credential connection failing
Test the connection with a Basic LLM Chain
Credentials are stored, but you need proof that n8n can actually send a prompt and get a response from Llama 3.1 8B.
- Click the Create button in the upper-left corner again and select Workflow.
- A blank canvas opens with a trigger placeholder. Click Add first step and search for Manual Trigger. Select it.
- Click the + connector on the Manual Trigger node to add a new node after it.
- Search for Basic LLM Chain and select it.
- In the Basic LLM Chain node settings, set Prompt to Define below.
- In the Prompt (User Message) text field, type:
Respond with OK
- Below the Basic LLM Chain node on the canvas, you will see a connector labelled Model. Click it to attach a model sub-node.
- Search for Ollama Chat Model and select it.
- In the Ollama Chat Model sub-node, set the Credential to connect with dropdown to the Ollama credential you just created.
- In the Model field, type or select llama3.1:8b.
What is the Basic LLM Chain?
The Basic LLM Chain node sends a prompt to a connected language model and returns the response. It does not store conversation memory, which makes it perfect for single-shot tasks like the classifications you will build later in this project.
Before you execute, what do you expect to see in the output panel after running this workflow?
- Click Test workflow (or press the play button on the canvas). Wait a few seconds for Ollama to generate a response.
You should see the Basic LLM Chain node output a response from Llama 3.1 8B. It will likely say something like "OK" or a short acknowledgement. The key confirmation is that the output panel shows a successful result and not an error.
Seeing a connection error or timeout?
Check that Ollama is running with the llama3.1:8b model pulled. In a terminal, run ollama list and confirm you see llama3.1:8b in the output.
If the model is missing, pull it by running ollama pull llama3.1:8b in your terminal (this is a 4.7GB download).
Help me debug why my Ollama Chat Model node is timing out in n8n
Save the workflow as SBOM-to-Nuclei Pipeline
The test worked. Now give the workflow a meaningful name so you can build on it in the remaining steps.
- Click the workflow name at the top of the canvas (it likely says "My workflow" or "Untitled workflow").
- Replace the name with:
SBOM-to-Nuclei Pipeline
- Press Enter to confirm the name.
- Press Ctrl+S to save the workflow.
You should see the name "SBOM-to-Nuclei Pipeline" displayed at the top of your canvas. The workflow is saved and ready for the next step.
What did you just set up?
You now have a verified communication channel between n8n (Docker) and Ollama (Windows host). Your workflow contains a Manual Trigger connected to a Basic LLM Chain with an Ollama Chat Model sub-node using llama3.1:8b. Every AI node you add from here forward will use this same credential.
Your n8n-to-Ollama link is live. Next up, you will build the SBOM ingestion pipeline that feeds your product's component list into this workflow.
Ingest and Parse the CycloneDX SBOM
Your n8n-to-Ollama connection is live. Now you need a way to feed your product's SBOM into the workflow so downstream nodes can process the data.
Before the LLM can classify anything, the workflow needs to receive your SBOM file and extract the component list into structured data. In this step, you will replace the test trigger with a proper intake mechanism and write the parsing logic.
In this step, get ready to:
- Replace the Manual Trigger with a Webhook node that accepts POST requests.
- Write a Code node that parses CycloneDX JSON into individual component items.
- Test the pipeline end-to-end with a curl command.
Replace the Manual Trigger with a Webhook node
The Manual Trigger from Step 1 was useful for testing Ollama, but a real pipeline needs to receive data programmatically. A Webhook node exposes an HTTP endpoint that your tooling (or a curl command) can POST an SBOM file to.
- In your open workflow, click the Manual Trigger node to select it.
- Press Delete (or Backspace) to remove it.
- Also delete the Basic LLM Chain node and its Ollama Chat Model sub-node. These were for testing the connection only.
- Click the + button on the empty canvas to add a new node.
- Search for Webhook and select it.
- In the Webhook node parameters, set HTTP Method to POST.
- In the Path field, replace the default value with sbom-ingest. This gives your endpoint a readable URL.
What is a Webhook node?
A Webhook node creates an HTTP endpoint inside n8n. When something sends an HTTP request to that URL, the payload arrives as the first item in the workflow. You are configuring it to accept POST requests so you can send your SBOM JSON in the request body.
Add the Code node to parse SBOM components
The Webhook receives raw JSON, but downstream nodes need each component as a separate item. A Code node lets you write JavaScript to transform the incoming payload into the exact shape you need.
- Click the + connector on the right side of the Webhook node to add a new node after it.
- Search for Code and select it.
- Make sure the Mode is set to Run Once for All Items.
- Paste the following JavaScript into the code editor:
// Get the SBOM JSON from the incoming webhook body
const sbomData = $input.first().json.body;
// Extract the components array from the CycloneDX structure
const components = sbomData.components || [];
// Map each component to a clean item with the fields we need
return components.map(component => ({
json: {
name: component.name || '',
version: component.version || '',
purl: component.purl || '',
type: component.type || '',
group: component.group || ''
}
}));
What does this code do?
The Code node receives the raw webhook payload and digs into the CycloneDX structure to find the components[] array.
- `$input.first().json.body` accesses the POST body that arrived at the Webhook node. In n8n, webhook data is structured as { headers, params, query, body }.
- For each component, it extracts five fields: name, version, purl, type, and group.
- It returns each component wrapped in { json: { ... } } format, which is how n8n passes data between nodes.
- Close the Code node panel by clicking outside it or pressing Escape.
- Save the workflow by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Time to confirm data flows through your Webhook and Code node. You will send your CycloneDX SBOM to the Webhook test URL and observe the parsed output.
- Click the Webhook node to open its panel.
- Click Test URL at the top of the panel to display the test webhook URL.
- Copy the displayed URL. It looks like http://localhost:5678/webhook-test/sbom-ingest.
- Click Listen for test event. The webhook stays active for 120 seconds.
- Open a terminal on your machine and send your SBOM file by running this command:
curl -X POST http://localhost:5678/webhook-test/sbom-ingest -H "Content-Type: application/json" -d @[[SBOM_PATH="path/to/your/cyclonedx-sbom.json"]]
What does this curl command do?
- -X POST tells curl to make a POST request, matching your Webhook node method.
- -H "Content-Type: application/json" tells n8n the body is JSON so it parses it correctly.
- -d @ followed by a file path sends the contents of that file as the request body.
- Switch back to your n8n browser tab.
- Click the Code node and check its output panel on the right. You should see a list of items, each showing name, version, purl, type, and group fields populated with your SBOM component data.
Not seeing any data in the output?
- Make sure you clicked Listen for test event before running the curl command. The test webhook only stays active for 120 seconds.
- If your terminal shows a connection refused error, confirm n8n is still running in Docker at http://localhost:5678.
- If the Code node output shows empty items, check that your SBOM file is valid CycloneDX JSON with a components array at the top level.
Help me debug why my n8n Webhook is not receiving data from curl.
Split components into individual items
The Code node outputs all components as a single batch. Downstream nodes (like the LLM classifier in the next step) need to process each component one at a time. A Split In Batches node handles this by sending one item through the workflow per iteration.
- Click the + connector on the right side of the Code node.
- Search for Split In Batches and select it.
- Leave the default Batch Size at 1. This ensures each component flows individually through the rest of the workflow.
- Save the workflow by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Why split into batches of 1?
In later steps, each component gets its own LLM classification call and its own NVD API lookup. Processing one component at a time keeps the logic simple and avoids hitting API rate limits with parallel requests.
Before you re-test, predict: when you send the same SBOM again, what do you expect to see differently now that the Split In Batches node is in the chain?
- Click the Webhook node, then click Listen for test event again.
- In your terminal, re-run the same curl command:
curl -X POST http://localhost:5678/webhook-test/sbom-ingest -H "Content-Type: application/json" -d @[[SBOM_PATH="path/to/your/cyclonedx-sbom.json"]]
- Switch back to n8n and click the Split In Batches node. Each component now flows as its own individual item through the workflow.
Split In Batches shows no output?
- Verify the Code node output still shows your parsed components. If that is empty, the webhook did not receive the file (re-check the 120-second window).
- Make sure the Split In Batches node is connected to the Code node output, not directly to the Webhook.
Help me troubleshoot why my Split In Batches node has no output in n8n.
Your pipeline now has a front door. Any tool that can make an HTTP POST request can feed an SBOM into your workflow. Next up, you will teach the LLM to classify each component by where it lives in your product architecture.
Classify Components by Architectural Layer
Your workflow can now ingest a CycloneDX SBOM and split it into individual component items. The next question is: which of these components actually matter most from a security perspective?
A CVE in a frontend-facing library is exploitable directly from the internet. The same severity CVE in an internal backend utility requires an attacker to already have access to your infrastructure. Classification by architectural layer is what turns a flat component list into a prioritized attack surface map.
In this step, get ready to:
- Build a Code node that extracts a condensed API summary from your OpenAPI spec.
- Connect a Basic LLM Chain node that classifies each component as frontend, api, or backend.
- Add a Set node that normalizes the LLM response into a clean architectureLayer field.
Add a Code node to build the API summary
Your local LLM needs context about your product's architecture to classify components accurately. The OpenAPI spec describes every endpoint your product exposes. By condensing this into a short summary, you give the LLM enough signal to determine which components serve the frontend, which are API middleware, and which are backend-only.
- In your workflow canvas, click the + button on the output of the Split In Batches node.
- Search for Code and select it to add a new Code node.
- Rename the node to Build API Summary by double-clicking its title.
- Set the Language to JavaScript.
- Paste the following code into the Code node editor:
// Paste your full OpenAPI JSON spec as a string between the backticks
const openApiRaw = `PASTE_YOUR_OPENAPI_JSON_HERE`;
// Parse the spec and extract endpoint paths with tags
const spec = JSON.parse(openApiRaw);
const paths = spec.paths || {};
let summary = 'API Endpoints:\n';
for (const [path, methods] of Object.entries(paths)) {
for (const [method, details] of Object.entries(methods)) {
if (typeof details === 'object' && details.tags) {
summary += `${method.toUpperCase()} ${path} [${details.tags.join(', ')}]\n`;
} else if (typeof details === 'object') {
summary += `${method.toUpperCase()} ${path}\n`;
}
}
}
// Truncate to stay under 2000 characters for LLM context
if (summary.length > 2000) {
summary = summary.substring(0, 1997) + '...';
}
// Attach the API summary to each flowing component item
const items = $input.all();
return items.map(item => ({
json: {
...item.json,
apiSummary: summary
}
}));
- Replace PASTE_YOUR_OPENAPI_JSON_HERE with your product's full OpenAPI JSON spec content (everything between the backticks).
What does this code do?
It parses your OpenAPI JSON and walks through every path and HTTP method, building a one-line-per-endpoint summary string.
The summary is truncated to 2000 characters so it fits comfortably in the LLM's prompt without consuming too much of the context window. Each flowing component item gets this apiSummary field attached so the next node can use it.
- Click Test step in the Code node to execute it.
- In the Output panel, confirm you see each component item now carries an apiSummary field containing your endpoint list.
Seeing a JSON parse error?
Make sure your OpenAPI JSON is pasted as valid JSON between the backticks. Trailing commas or unescaped quotes will break JSON.parse(). If your spec is large, try pasting a smaller subset (just the paths object) to verify the node works first.
help me debug my Code node JSON parse error in n8n
Connect the Basic LLM Chain for classification
Each component now carries the API summary as context. The LLM reads this context alongside the component's metadata and decides whether it belongs to the frontend, api, or backend layer.
- Click the + button on the output of the Build API Summary node.
- Search for Basic LLM Chain and add it.
- In the Basic LLM Chain node, set Prompt to Define below.
- In the Prompt (User Message) field, switch to Expression mode by clicking the small toggle next to the field.
- Paste this expression into the prompt field:
Component: {{ $json.name }} v{{ $json.version }} (type: {{ $json.type }}, purl: {{ $json.purl }})
API Specification Summary:
{{ $json.apiSummary }}
Classify this component as exactly one of: frontend, api, backend
What does this prompt do?
Each time n8n processes a component, it fills in that component's name, version, type, and purl from the flowing item data. The API summary provides the architectural context the LLM needs to make its classification decision.
- Open the Chat Messages section of the Basic LLM Chain node (under Options).
- Add a System Message and paste this system prompt:
You are a software architecture classifier. Given a software component (name, version, type, purl) and an API specification summary, classify the component as exactly one of: frontend, api, backend. Respond with ONLY the classification word.
- Click the Model attachment point below the Basic LLM Chain node.
- Select Ollama Chat Model.
- In the Ollama Chat Model sub-node, select your Ollama credentials.
- Set the Model field to llama3.1:8b.
- Close the sub-node and click Test step on the Basic LLM Chain node.
In the Output panel, you should see a text field containing a single word: frontend, api, or backend.
Why a single-word response?
Constraining the LLM to respond with only one word makes downstream parsing trivial. The Set node can simply check whether the response matches one of the three valid values without needing complex text extraction.
Getting a longer response or gibberish?
Check that your system prompt is in the System Message field, not the User Message. The system prompt is what constrains the model's output format. Also verify the Ollama Chat Model sub-node has llama3.1:8b selected and your Ollama credentials are connected.
help me fix my Basic LLM Chain node that returns too much text
Add a Set node to normalize the classification
LLMs can be unpredictable. The response might come back as Frontend with a capital letter, backend. with a trailing period, or occasionally a full sentence. The Set node normalizes this into a reliable architectureLayer field that downstream nodes can depend on.
- Click the + button on the output of the Basic LLM Chain node.
- Search for Edit Fields (Set) and add it.
- In the Set node, make sure Include in Output is set to All Input Fields.
- Click Add Field and choose String.
- Set the field name to architectureLayer.
- Switch the value to Expression mode and paste this expression:
{{ ['frontend', 'api', 'backend'].includes($json.text.trim().toLowerCase()) ? $json.text.trim().toLowerCase() : 'backend' }}
What does this expression do?
It reads the LLM's response from the text field, strips whitespace, and converts it to lowercase. If the cleaned value is one of the three valid classifications, it uses that value. Otherwise it defaults to backend as the safest assumption for unknown components.
- Click Test step on the Set node.
- In the Output panel, confirm you see the original component fields (name, version, purl, type, group) plus the new architectureLayer field.
Missing component fields in the output?
Make sure Include in Output is set to All Input Fields in the Set node. If it is set to No Input Fields, only the new architectureLayer field appears and you lose the component metadata.
help me configure my n8n Set node to keep all input fields
Before you run the full classification, think about this: if you execute the entire workflow from the webhook trigger, do you expect every component to get the same classification, or different ones?
- Execute the full workflow from the Webhook node by sending your SBOM via the curl command from the previous step.
- Click the Set node and review the output items.
Each component item now has an architectureLayer field set to frontend, api, or backend. Spot-check 3-4 components against your knowledge of the product to confirm the classifications make sense.
Why spot-check manually?
LLM classification is probabilistic. A component like react should always come back as frontend, and express should come back as api. If obvious components are misclassified, your system prompt or API summary may need adjusting.
- Save the workflow by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Every component now carries its architectural context. Next up, you will query the NVD API to find which of these classified components have known vulnerabilities waiting to be exploited.
Enrich Components with CVE Data
Your workflow now classifies each component by its architectural role. That classification will drive prioritization later, but only if you know which components actually have known vulnerabilities.
The National Vulnerability Database (NVD) maintains a free API that returns CVE records for any software component. In this step, you will wire up an HTTP Request node to query the NVD for each component, parse the response into a clean structure, and filter out components with no known vulnerabilities.
In this step, get ready to:
- Query the NVD API for each component's known CVEs.
- Parse CVE responses into a structured array on each component item.
- Filter out components with zero vulnerabilities.
Add the HTTP Request node for NVD queries
Each component needs its own NVD lookup. The NVD API accepts a keyword search and returns matching CVE records. Without an API key, the rate limit is approximately 5 requests per 30 seconds, so you need a delay between requests.
- In your SBOM-to-Nuclei Pipeline workflow, click the + button on the output of the Set node (the one that adds architectureLayer).
- Search for HTTP Request and add it to the canvas.
- Set Method to GET.
- Set the URL field to the following expression (click the expression toggle to enter expression mode):
https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch={{ encodeURIComponent($json.name) }}
- Click Add Option at the bottom of the node panel.
- Select Batching.
- Set Items per Batch to 1.
- Set Batch Interval (ms) to 1000.
What does this configuration do?
The URL uses the NVD 2.0 CVE endpoint with keywordSearch set to the component's name. The encodeURIComponent function ensures spaces and special characters are properly URL-encoded.
The Batching option sends one request at a time with a 1-second pause between each. This keeps you within the NVD's rate limit of approximately 5 requests per 30 seconds for keyless access.
Why keyword search instead of CPE matching?
CPE-based queries are more precise, but they require the SBOM to contain valid CPE identifiers. Many SBOMs lack these. Keyword search by component name works universally, though it may return some false positives. You can refine to CPE matching in a follow-on project.
Parse the NVD response into a CVE array
The NVD API returns a JSON object with a vulnerabilities array. Each entry contains a cve object with the CVE ID, descriptions, and optional CVSS v3.1 metrics. You need a Code node to extract only the fields your pipeline needs.
- Add a Code node after the HTTP Request node by clicking + on its output.
- Set Language to JavaScript.
- Paste the following code into the Code field:
// Get the NVD response and the original component data
const response = $input.first().json;
const vulnerabilities = response.vulnerabilities || [];
// Extract CVE ID, CVSS score, severity, and description
const cves = vulnerabilities.map(vuln => {
const cve = vuln.cve;
const metrics = cve.metrics?.cvssMetricV31?.[0]?.cvssData || {};
const description = cve.descriptions?.find(d => d.lang === 'en');
return {
id: cve.id,
cvssScore: metrics.baseScore || 0,
severity: metrics.baseSeverity || 'UNKNOWN',
description: description?.value || ''
};
});
// Return the component item with cves array attached
return [{ json: { ...$input.first().json, cves } }];
What does this code do?
- It reads the vulnerabilities array from the NVD response. If the API returned no results, it defaults to an empty array.
- For each vulnerability, it digs into cve.metrics.cvssMetricV31[0].cvssData to pull the CVSS v3.1 base score and severity. Older CVEs without v3.1 metrics default to 0 and UNKNOWN.
- It finds the English description from the descriptions array.
- The output preserves all existing component fields and adds a cves array containing objects with id, cvssScore, severity, and description.
- Save the workflow by pressing Ctrl+S.
Before moving on, test the two new nodes together to confirm data flows correctly.
- Click the HTTP Request node to select it.
- Click Test step in the top right of the node panel to execute just this node with sample component data.
- Then click the Code node and run Test step on it as well.
In the output panel of the Code node, you should see your component's fields plus a cves array. If the component has known CVEs, each entry shows an id like CVE-2023-XXXXX, a numeric cvssScore, and a severity string.
Seeing an empty response or error?
- If the HTTP Request node returns a 403 or 429 error, you hit the NVD rate limit. Confirm Batch Interval (ms) is set to 1000 and try again after 30 seconds.
- If you see an empty vulnerabilities array, the component may genuinely have no CVEs. Try testing with a well-known library name like lodash or openssl to confirm the API is responding.
- If the Code node fails with a TypeError, make sure you used the optional chaining (?.) exactly as shown. The cvssMetricV31 field is optional and may not exist for every CVE.
Help me debug my NVD API response parsing in n8n
Filter out components with no CVEs
Not every component in your SBOM has known vulnerabilities. Passing empty-CVE items downstream wastes processing time and clutters the output. A Filter node removes them so only components with real vulnerability data continue.
- Add a Filter node after the Code node by clicking + on its output.
- In the Filter node, set the Condition to:
- Value 1 (expression): {{ $json.cves.length }}.
- Operation: is greater than.
- Value 2: 0.
- Save the workflow by pressing Ctrl+S.
What does the Filter node do?
It checks whether the cves array on each component has at least one entry. Components with zero CVEs are dropped from the workflow. Only components with known vulnerabilities proceed to the prioritization step.
Now run the full workflow to see the enrichment pipeline in action.
Before you trigger it, what do you expect to see at the Filter node's output? Think about how many of your SBOM's components are likely to have known CVEs.
- Send your CycloneDX SBOM to the webhook URL using the same curl command from Step 2.
- Wait for execution to complete. This will take longer than before because of the 1-second delays between NVD requests.
- Click the Filter node and check the output panel.
You should see component items that now carry a cves array populated with CVE IDs, CVSS scores, and descriptions. Components with no vulnerabilities have been removed. The exact number depends on your SBOM, but you should see at least a few components with CVE data.
No components surviving the filter?
- If every component is filtered out, your SBOM may contain very niche packages. Try adding a well-known library (like express or log4j) to your test SBOM's components array and re-run.
- Check the HTTP Request node's output for earlier items. If you see 403 errors partway through, the rate limit kicked in. Increase the Batch Interval (ms) to 6000 (6 seconds) for more conservative pacing.
Help me troubleshoot why no components have CVE data after NVD enrichment
Take a look at the number of CVEs across your surviving components. You will likely see dozens of vulnerabilities spread across multiple components, with no ranking or prioritization. Which ones should you test first? That overwhelming unranked list is exactly the problem the next step solves.
Your pipeline now knows what each component is, where it sits in your architecture, and which vulnerabilities affect it. Next, you will rank those CVEs by combining CVSS severity with architectural exposure and generate Nuclei templates for the top threats.
Prioritize CVEs and Generate Nuclei Templates
Your workflow now carries real CVE data for every component that survived the filter. But look at the output from the previous step. There could be dozens of CVEs across all your components, with no signal telling you which ones deserve immediate attention.
This step applies a key insight: a high-CVSS vulnerability in a frontend-facing library is more urgent to test than the same severity score buried in an internal backend utility. You'll calculate a weighted priority score, pick the top threats, and have your local LLM generate ready-to-use Nuclei YAML templates for each one.
In this step, get ready to:
- Calculate a weighted priority score for each CVE based on CVSS severity and architecture exposure.
- Generate valid Nuclei YAML templates for the top 5 vulnerabilities using Ollama.
- Collect all templates into a single downloadable output.
Calculate priority scores for each CVE
Not all CVEs are equal. A critical vulnerability in a JavaScript UI framework your users load in their browser is exploitable from the internet. The same score on an internal logging utility requires an attacker to already have backend access.
- In n8n, click the + button after the Filter node to add a new node.
- Search for Code and select it.
- Name the node Priority Score.
- Set the Language to JavaScript.
- Paste the following code into the Code field:
// Get all component items from the Filter node
const items = $input.all();
// Define exposure weights by architecture layer
const exposureWeights = {
frontend: 1.5,
api: 1.2,
backend: 1.0
};
// Flatten all CVEs with component context
const allCves = [];
for (const item of items) {
const layer = item.json.architectureLayer || 'backend';
const weight = exposureWeights[layer] || 1.0;
for (const cve of item.json.cves) {
allCves.push({
cveId: cve.id,
cvssScore: cve.cvssScore,
severity: cve.severity,
description: cve.description,
componentName: item.json.name,
componentVersion: item.json.version,
architectureLayer: layer,
priority: cve.cvssScore * weight
});
}
}
What does this code do?
- It loops through every component that has CVEs and flattens the nested cves arrays into one big list.
- For each CVE, it multiplies the cvssScore by an exposureWeights value: 1.5 for frontend components, 1.2 for API components, and 1.0 for backend.
- A frontend CVE with a CVSS of 9.0 gets a priority of 13.5, while a backend CVE with the same score only gets 9.0. This reflects real-world exploitability.
The code above collects and scores all CVEs. Now add the sorting and selection logic immediately below it in the same Code node:
// Sort by priority descending and take top 5
allCves.sort((a, b) => b.priority - a.priority);
const top5 = allCves.slice(0, 5);
// Return each top CVE as its own item
return top5.map(cve => ({ json: cve }));
Why take only the top 5?
Generating a Nuclei template for every CVE would overwhelm both the LLM and the security team reviewing results. The top 5 represent your product's highest-risk attack surface. You can always re-run with a larger slice later.
- Click Execute Node on the Priority Score node to test it.
You should see up to 5 items in the output panel, each with a cveId, priority score, and architectureLayer field. The items are sorted with the highest priority first.
Seeing zero items in the output?
- Make sure the Filter node upstream has data. Execute the full workflow from the Webhook first so data flows through every node.
- Check that item.json.cves is the correct path. Open the previous Code node's output and confirm the field is named cves (not vulnerabilities or another name).
- help me debug why my Priority Score code node returns empty output
Generate Nuclei templates with Ollama
Each top-5 CVE now needs a matching Nuclei template so your security team can immediately test for it. You'll have Llama 3.1 8B generate valid YAML following the official Nuclei template structure.
- Click the + button after the Priority Score node to add a new node.
- Search for Basic LLM Chain and select it.
- Attach an Ollama Chat Model sub-node. Select your existing Ollama credentials and set the model to llama3.1:8b.
- In the Prompt field, paste the following text:
You are a security engineer generating Nuclei vulnerability templates.
Given the following CVE details:
- CVE ID: {{ $json.cveId }}
- Description: {{ $json.description }}
- Affected Component: {{ $json.componentName }} {{ $json.componentVersion }}
- CVSS Score: {{ $json.cvssScore }}
- Severity: {{ $json.severity }}
- Architecture Layer: {{ $json.architectureLayer }}
Generate a valid Nuclei YAML template that tests for this vulnerability.
What are the expressions doing?
The {{ $json.cveId }} expressions are n8n's way of referencing the current item's fields. Since the Basic LLM Chain processes each item individually, these expressions pull the specific CVE data for each of the top 5 items.
The prompt above gives the LLM context, but it needs a structural example to produce valid output. Add this reference template below the existing prompt text (still in the same Prompt field):
Follow this exact structure:
id: CVE-ID-here
info:
name: Short vulnerability name
author: automated-pipeline
severity: critical
description: Brief description of the vulnerability
reference:
- https://nvd.nist.gov/vuln/detail/CVE-ID-here
http:
- method: GET
path:
- "{{BaseURL}}/vulnerable-path"
matchers:
- type: word
words:
- "expected-response-indicator"
Output ONLY the YAML template. No explanation or markdown fencing.
Why include a structural example in the prompt?
Without a reference, the LLM may produce invalid YAML or miss required fields. The example anchors the output format. It shows the id, info block (with name, author, severity, description, reference), and the http request block with matchers. These are the minimum required fields for a valid Nuclei template.
- Click Execute Node on the Basic LLM Chain node.
You should see 5 output items, each containing a YAML string that starts with id: and includes info: and http: blocks.
Getting malformed YAML or markdown fences in the output?
- If the LLM wraps output in ```yaml fences, add a line to the end of your prompt: "Do not include markdown code fences or backticks."
- If the output is truncated, check that your Ollama Chat Model sub-node does not have a low token limit set. Remove any max-token restriction or set it to at least 2048.
- help me get clean Nuclei YAML output from my LLM node
Collect all templates into a single output
Right now each template is a separate workflow item. The Aggregate node combines them into one output so you can view or download all templates together.
- Click the + button after the Basic LLM Chain node.
- Search for Aggregate and select it.
- Set the Aggregate dropdown to All Item Data.
- In the Put Output in Field parameter, type templates.
- Save the workflow by pressing Ctrl+S.
What does the Aggregate node do?
The Aggregate node takes multiple input items (your 5 generated templates) and combines them into a single output item. The All Item Data mode puts everything into one field called templates. This makes it easy to view all templates at once in the output panel.
Before you execute the full workflow, predict what you expect to see. Do you think the output will contain valid YAML with the correct Nuclei fields, or will the LLM produce something unexpected?
- Execute the full workflow by triggering the Webhook with your CycloneDX SBOM (the same curl command from Step 2).
- Click on the Aggregate node to view its output.
You should see a single item with a templates field containing an array of 3-5 generated Nuclei YAML templates.
- Copy one of the generated templates from the output panel.
- Paste it into a new file with a .yaml extension on your machine.
- Verify it contains the required Nuclei fields: id (lowercase, hyphens), info (with name, author, severity), and an http block with a request and matchers.
What makes a valid Nuclei template?
At minimum, a valid Nuclei template needs three things: an id (lowercase, no spaces, hyphens allowed), an info block with name, author, and severity fields, and a protocol block (in this case http) with requests and matchers.
Severity must be one of: info, low, medium, high, or critical. The id should use the CVE ID format for CVE templates (e.g., CVE-2021-12345).
Templates missing fields or containing invalid YAML?
- LLM output quality varies between runs. If a template is missing the http block entirely, re-execute the LLM node. The 8B model occasionally drops sections on longer outputs.
- If indentation is wrong (YAML is whitespace-sensitive), check that the LLM is not mixing tabs and spaces. Most templates will be usable with minor manual fixes.
- help me validate my AI-generated Nuclei templates
Secret mission
Add EPSS Exploit Probability Scoring
Your pipeline prioritizes CVEs by CVSS score and architecture exposure. But CVSS only tells you how bad a vulnerability could be in theory. What if you could also factor in how likely it is to be exploited in the real world? In this secret mission, integrate the EPSS (Exploit Prediction Scoring System) API to weight your prioritization by actual exploit probability.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- n8n workflow "SBOM-to-Nuclei Pipeline" (stored in your local n8n instance).
- Ollama credentials (stored in n8n).
- n8n Docker container (running locally).
Keep everything running
No action needed. Choose this if you plan to reuse the pipeline whenever you update your SBOM or want to experiment further.
- Your n8n instance continues to run at http://localhost:5678.
- Ollama continues to serve the llama3.1:8b model on port 11434.
- The workflow is ready to trigger anytime you send a new SBOM to its webhook endpoint.
Pause - I'll come back to this later
Shut down running processes to free up memory. You can restart everything later without losing any work.
- Stop your n8n Docker container by running:
docker ps
Find your n8n container name in the output, then stop it:
docker stop <your-n8n-container-name>
- When you are ready to return, restart it with:
docker start <your-n8n-container-name>
- Your workflow and credentials are preserved inside the container. Nothing is lost.
Delete - I don't want to use this again
Remove all project resources and start fresh. Ollama and its models remain on your machine since they were installed before this project.
- Open n8n at http://localhost:5678 and navigate to the Workflows page.
- Click the three-dot menu on the SBOM-to-Nuclei Pipeline workflow and select Delete.
- Go to Credentials in the left sidebar.
- Find the Ollama credential, click its three-dot menu, and select Delete.
If you also want to remove the n8n Docker container entirely, run:
docker stop <your-n8n-container-name>
docker rm <your-n8n-container-name>
What stays on your machine?
Ollama and the llama3.1:8b model remain installed. These were prerequisites you set up before this project. If you want to remove them, follow Ollama's own uninstall documentation separately.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully local security automation pipeline that transforms a raw CycloneDX SBOM into prioritized, architecture-aware Nuclei exploit-testing templates.
You've learned how to:
- Built a complete SBOM-to-Nuclei automation pipeline in n8n that ingests a CycloneDX SBOM, classifies components by architectural layer, enriches them with CVE data from the NVD API, and outputs actionable templates.
- Used a local Llama 3.1 8B model through Ollama to classify software components as frontend, API, or backend using your OpenAPI spec as context, and to generate valid Nuclei YAML templates for high-priority CVEs.
- Implemented a weighted prioritization model that combines CVSS severity scores with architecture exposure weights to surface the most urgent vulnerabilities first.
- Secret Mission: Integrated EPSS exploit probability scoring to weight prioritization by real-world exploitation likelihood, creating a composite model that matches how red teams actually pick targets.
Ready to quiz yourself?