SBOM Baselining Workflow with AI in n8n

Build an n8n workflow that enriches and deduplicates SBOMs using NVD and Claude.

Introduction

30 Second Summary

Every piece of software you use is built from hundreds of other components. When a vulnerability is discovered in one of those components, the first question is always the same: do we use it? The answer lives in a Software Bill of Materials (SBOM). But raw SBOMs from scanners are messy, incomplete, and full of duplicates.

In this project, you will build an n8n workflow that takes raw SBOMs, enriches them with standardized identifiers from the NVD API, uses Claude to resolve ambiguous components and flag false positives, and outputs a clean baseline with a full audit trail.

What You'll Build

You will submit a raw SBOM via webhook, watch it get parsed and enriched with CPE identifiers, see the AI reason about which components are real and which are noise, and receive a clean, deduplicated baseline alongside a decision log explaining every change.

By the end of this project, you'll have:

  • A webhook-triggered pipeline that accepts any CycloneDX or SPDX SBOM and returns an enriched, deduplicated baseline with full identifier coverage.
  • An AI-powered analysis stage that reasons about ambiguous components, picks the correct match across ecosystems, and flags false positives with documented justification.
  • A structured decision log that creates a full audit trail for every enrichment, deduplication, and removal so you can explain every change to auditors or teammates.
  • Secret Mission: An interactive chat interface that surfaces unresolved components for real-time human review decisions.

Are there any prerequisites?

You need Docker running locally with an n8n container operational, and basic familiarity with JSON. Step 1 walks you through verifying your setup and configuring API access.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Configure n8n, Anthropic, and NVD API Access

Your SBOM baselining workflow depends on three services working together: n8n orchestrates the automation, the NVD CPE Dictionary API provides standardized product identifiers, and Claude handles the AI-powered reasoning for ambiguous components.

Without an API key, the NVD throttles you to 5 requests per rolling 30-second window. With a key, that jumps to 50 requests per 30 seconds. For a real SBOM with dozens of components, the unauthenticated rate makes the workflow impractical. This step gets all three services configured and confirmed working before you build anything.

In this step, get ready to:
  • Confirm n8n is running and accessible in Docker.
  • Register for an NVD API key and set up Anthropic credentials in n8n.
  • Test both API connections with a live request.
Verify n8n is running and accessible

Your n8n instance needs to be running in Docker and reachable in the browser before you can configure anything inside it.

  • Open a terminal window.
  • Check if n8n is already running by opening http://localhost:5678 in your browser.

✔️ I see the n8n editor

Your n8n instance is running. Skip ahead to the next substep.

ⓧ The page won't load

Your n8n container is not running. Start it by running these commands in your terminal:

docker volume create n8n_data
docker run -it --rm --name n8n -p 5678:5678 -e GENERIC_TIMEZONE="UTC" -e TZ="UTC" -e N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true -e N8N_RUNNERS_ENABLED=true -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n

What does this command do?

This creates a Docker volume called n8n_data to persist your workflows, then starts the n8n container with that volume mounted. Port 5678 is mapped so you can access the editor in your browser.

  • Wait for the terminal to show n8n's startup message.
  • Open http://localhost:5678 in your browser. You should see the n8n workflow editor.

Still can't access n8n?

  • Make sure Docker Desktop is running. If the docker command is not found, Docker is not installed or not in your PATH.
  • Check if port 5678 is in use by another process. Stop any conflicting service first.
  • If you see a permission error, ensure Docker has file sharing enabled for the volume path.

Can't get n8n running? Help me troubleshoot my n8n Docker container

  • Confirm you can create a new workflow by clicking the + button or Create Workflow in the n8n editor. You should see a blank canvas with a start node.
Register for an NVD API key and configure Anthropic credentials

You need two API keys: one from the National Vulnerability Database for CPE lookups, and one from Anthropic for Claude's AI analysis. Both get stored in n8n's credential system.

  • Open https://nvd.nist.gov/developers/request-an-api-key in a new browser tab.
  • Fill in the three fields on the request form (name, organization, email).
  • Scroll to the bottom of the Terms of Use and check the box marked I agree to the Terms of Use.
  • Submit the form.
  • Check your email inbox for a message from nvd-noreply@nist.gov.
  • Click the single-use activation link in the email. Copy the API key displayed on the page and store it somewhere secure. This page cannot be revisited after you close it.

Why a single-use link?

The NVD shows your API key exactly once on the activation page. If you lose it, you must request a new key (which invalidates the old one). Paste it into a password manager or a local text file right away.

You must activate the key within 7 days or it expires.

Now set up the Anthropic credential in n8n.

  • In a new browser tab, go to console.anthropic.com and sign in.
  • Navigate to Settings then API Keys.
  • Click + Create Key.
  • Name the key n8n-sbom-baseline and copy the generated key value.
  • Switch back to your n8n editor at http://localhost:5678.
  • Click the ... menu in the left sidebar, then click Credentials.
  • Click Add Credential.
  • Search for and select Anthropic.
  • Paste your Anthropic API key into the API Key field.
  • Click Save. n8n automatically tests the credential on save. You should see a success confirmation.

Credential test failed?

  • Double-check that you copied the full API key string without any leading or trailing spaces.
  • Ensure your Anthropic account has API access enabled and billing configured at console.anthropic.com.
  • If you see a connection timeout, confirm your machine has internet access and no firewall is blocking outbound HTTPS.

Still stuck? Help me debug my Anthropic credential in n8n

Test both API connections in a workflow

Before building the full SBOM pipeline, you need to confirm both APIs respond correctly inside n8n. You will create a small test workflow for this.

  • In the n8n editor, create a new workflow by clicking the + button.
  • Click the + node on the canvas to add a new node.
  • Search for HTTP Request and select it.

Configure the HTTP Request node to call the NVD CPE API.

  • Set Method to GET.
  • Set URL to:
https://services.nvd.nist.gov/rest/json/cpes/2.0?keywordSearch=apache&resultsPerPage=1
  • Under Send Headers, toggle it on.
  • Add a header with Name set to apiKey and Value set to your NVD API key.

Why a header instead of a URL parameter?

The NVD API 2.0 requires the API key to be passed in the request header as apiKey (case sensitive). Older versions accepted it in the URL query string, but 2.0 uses the header for security.

  • Click Test step (or press the play button on the node) to execute it.

You should see a JSON response in the output panel. Look for a totalResults field with a value greater than 0.

Getting an empty response or error?

  • If you see a 403 or 404, double-check the URL is exactly as shown above with no extra spaces.
  • If totalResults is 0, try changing keywordSearch=apache to keywordSearch=nginx. A result of 0 for a common product name suggests the key is not being passed correctly.
  • If you just activated your key, wait 30 seconds and retry. Activation can take a moment to propagate.

Need help? Help me troubleshoot my NVD API request in n8n

Now test the Anthropic connection.

  • Click the + button on the canvas to add another node.
  • Search for AI Agent and select it.
  • In the AI Agent node settings, set Prompt to Define below.
  • In the Text field, type: Say hello.
  • On the AI Agent node, click the + connector under Model.
  • Search for Anthropic Chat Model and select it.
  • In the Anthropic Chat Model sub-node, select your saved Anthropic credential from the Credential to connect with dropdown.
  • Click Test step on the AI Agent node.

Why does the AI Agent require a tool sub-node?

All AI Agent nodes in n8n operate as a Tools Agent. The node needs at least one connected tool sub-node to function. For this test, just the Anthropic Chat Model connection is sufficient to confirm connectivity.

Before you run this, what do you think Claude will respond with?

You should see a text response from Claude in the output panel. Any coherent reply confirms your Anthropic credential is working correctly.

AI Agent node returns an error?

  • Ensure the Anthropic Chat Model sub-node is connected to the AI Agent node via the Model connector, not the main output.
  • Check that you selected the correct credential in the Anthropic Chat Model sub-node.
  • If you see a billing error, verify your Anthropic account has active payment at console.anthropic.com.

Still stuck? Help me connect Claude to my n8n AI Agent node

Both APIs are connected and confirmed working. You have the foundation for every API call the workflow will make. Next up, you will build the SBOM parser that accepts raw CycloneDX or SPDX files and normalizes them into a unified component list.

Build the SBOM Ingest and Normalization Parser

In the previous step, you confirmed your n8n instance can talk to the NVD and Anthropic APIs. Now it is time to build the actual SBOM baselining workflow.

Raw SBOMs arrive in two different formats with completely different JSON structures. CycloneDX stores components in a components[] array, while SPDX uses packages[] with different field names for everything. Before you can enrich or deduplicate anything, you need a single unified component list.

In this step, get ready to:
  • Create a Webhook trigger that accepts raw SBOM JSON payloads.
  • Detect whether the incoming file is CycloneDX or SPDX and extract its components.
  • Normalize both formats into a unified component structure with identifier status tracking.
Create the Webhook trigger and format detection node

Every SBOM needs a way into the workflow. A Webhook node gives you a URL that accepts POST requests containing the raw SBOM JSON.

  • In the n8n editor at http://localhost:5678, click the + button in the top right to create a new workflow.
  • Name the workflow SBOM Baselining by clicking the workflow name at the top.
  • Click the + node button on the canvas, search for Webhook, and select it.
  • In the Webhook node parameters, set HTTP Method to POST.
  • In the Path field, replace the auto-generated value with sbom-baseline.

What is the Webhook path?

The path you set determines the URL where you send SBOMs. n8n generates two URLs: a Test URL at http://localhost:5678/webhook-test/sbom-baseline for development, and a Production URL at http://localhost:5678/webhook/sbom-baseline for published workflows.

You will use the Test URL throughout this project. It stays active for 120 seconds after you click Listen for test event.

Now add the format detection logic. This Code node inspects the incoming JSON to determine whether it is CycloneDX or SPDX.

  • Close the Webhook node panel.
  • Click the + connector on the right side of the Webhook node to add a new node after it.
  • Search for Code and select it.
  • In the Code node, set Mode to Run Once for All Items and Language to JavaScript.
  • Rename the node to Detect Format by double-clicking its title.
  • Paste the following code into the code editor:
// Detect SBOM format and extract raw components
const inputData = $input.all();
const payload = inputData[0].json;

let format = 'unknown';
let components = [];

// CycloneDX always has bomFormat field
if (payload.bomFormat === 'CycloneDX') {
  format = 'CycloneDX';
  components = payload.components || [];
}
// SPDX always has spdxVersion field
else if (payload.spdxVersion) {
  format = 'SPDX';
  components = payload.packages || [];
}

return [{
  json: {
    detectedFormat: format,
    rawComponents: components,
    totalRawComponents: components.length
  }
}];

What does this code do?

  • $input.all() retrieves the data from the previous node (the Webhook payload).
  • It checks for bomFormat === 'CycloneDX' or the presence of spdxVersion to identify the format. These are 100% reliable discriminators.
  • The output passes the detected format and raw component array downstream.

Let's verify the Webhook and format detection are working before adding more nodes.

  • Click the Webhook node to open it.
  • Click Listen for test event.
  • Open a terminal and send a sample CycloneDX SBOM by running this command:
curl -X POST http://localhost:5678/webhook-test/sbom-baseline \
  -H "Content-Type: application/json" \
  -d '{"bomFormat":"CycloneDX","specVersion":"1.4","components":[{"type":"library","name":"express","version":"4.18.2","purl":"pkg:npm/express@4.18.2"},{"type":"library","name":"lodash","version":"4.17.21"}]}'
  • Switch back to the n8n editor. You should see the workflow execute and the Detect Format node show output with detectedFormat: "CycloneDX" and totalRawComponents: 2.

Not seeing any execution data?

  • Make sure you clicked Listen for test event before sending the curl request. The test webhook only stays active for 120 seconds.
  • Confirm you used the webhook-test path (not webhook) in your curl URL.
  • Check that the Webhook node's Path field is set to sbom-baseline (no leading slash).

Still stuck? Help me troubleshoot my n8n webhook not receiving data.

Normalize components into a unified structure

The format detection gives you a raw component array, but its field names differ between formats. This next Code node maps both formats into a single structure with consistent fields.

  • Click the + connector on the right side of the Detect Format node.
  • Add another Code node, set Mode to Run Once for All Items and Language to JavaScript.
  • Rename the node to Normalize Components.
  • Paste the CycloneDX normalization function into the code editor:
// Normalize components into unified structure
const input = $input.all();
const { detectedFormat, rawComponents } = input[0].json;

// CycloneDX normalization
function normalizeCycloneDX(comp) {
  const purl = comp.purl || '';
  const cpe = comp.cpe || '';
  let identifierStatus = 'missing';
  if (purl && cpe) identifierStatus = 'complete';
  else if (purl || cpe) identifierStatus = 'partial';
  return {
    name: comp.name || '',
    version: comp.version || '',
    group: comp.group || '',
    purl, cpe,
    type: comp.type || '',
    'bom-ref': comp['bom-ref'] || '',
    identifierStatus
  };
}

What does the CycloneDX normalizer do?

  • It reads name, version, group, purl, cpe, type, and bom-ref directly from the CycloneDX component object.
  • It assigns identifierStatus: "complete" if both PURL and CPE exist, "partial" if only one exists, "missing" if neither exists.
  • Below the CycloneDX function in the same Code node editor, paste the SPDX normalization and mapping logic:
// SPDX normalization
function normalizeSPDX(comp) {
  const refs = comp.externalRefs || [];
  const purlRef = refs.find(
    r => r.referenceCategory === 'PACKAGE_MANAGER'
  );
  const cpeRef = refs.find(
    r => r.referenceCategory === 'SECURITY'
  );
  const purl = purlRef ? purlRef.referenceLocator : '';
  const cpe = cpeRef ? cpeRef.referenceLocator : '';
  let identifierStatus = 'missing';
  if (purl && cpe) identifierStatus = 'complete';
  else if (purl || cpe) identifierStatus = 'partial';
  return {
    name: comp.name || '',
    version: comp.versionInfo || '',
    group: '',
    purl, cpe,
    type: 'library',
    supplier: comp.supplier || '',
    identifierStatus
  };
}

// Map each component through the correct normalizer
const normalized = rawComponents.map(comp =>
  detectedFormat === 'CycloneDX'
    ? normalizeCycloneDX(comp)
    : normalizeSPDX(comp)
);

return [{ json: { detectedFormat, normalizedComponents: normalized } }];

What does the SPDX normalizer do?

  • SPDX stores identifiers in an externalRefs[] array rather than as top-level fields. The code filters by referenceCategory to find PURL (PACKAGE_MANAGER) and CPE (SECURITY) entries.
  • SPDX uses versionInfo instead of version, so the normalizer maps it to the unified version field.
  • The final map() call routes each component through the correct normalizer based on the detected format.
  • Verify both parts are in the same Code node editor, with Part 2 directly below Part 1.
  • Click Listen for test event on the Webhook node again.
  • Re-run the same curl command from your terminal.
  • Click the Normalize Components node to inspect its output. You should see a normalizedComponents array where express shows identifierStatus: "partial" (it has purl but no cpe) and lodash shows identifierStatus: "missing" (it has neither).

Seeing a Code node error?

  • Check that both code parts are in a single Code node, with the functions defined before the rawComponents.map() call at the bottom.
  • Make sure Mode is set to Run Once for All Items, not Run Once for Each Item.

Help me debug my n8n Code node normalization error.

✔️ Awesome, I've got everything!

Great. Make sure you saved the workflow before moving on.

ⓧ I'd like to double check the full code

Your Normalize Components Code node should contain these two sections in order:

  • Part 1: The variable declarations (input, detectedFormat, rawComponents) and the normalizeCycloneDX function.
  • Part 2: The normalizeSPDX function, the rawComponents.map() call, and the return statement.

Scroll up and confirm both chunks are present in sequence in a single Code node.

Output the component inventory with a status summary

The normalization node outputs the full component array, but you also need a quick summary showing how many components need enrichment. This summary becomes the input for all downstream processing.

  • Click the + connector on the right side of the Normalize Components node.
  • Add another Code node, set Mode to Run Once for All Items and Language to JavaScript.
  • Rename the node to Component Summary.
  • Paste the following summary code:
// Produce status summary and output component array
const input = $input.all();
const { detectedFormat, normalizedComponents } = input[0].json;

// Count components by identifier status
const countByStatus = { complete: 0, partial: 0, missing: 0 };
normalizedComponents.forEach(comp => {
  countByStatus[comp.identifierStatus]++;
});

return [{
  json: {
    summary: {
      detectedFormat,
      totalComponents: normalizedComponents.length,
      countByStatus
    },
    components: normalizedComponents
  }
}];

What does this code do?

  • It iterates over the normalized components and counts how many have complete, partial, or missing identifier status.
  • The output contains both the summary object (for quick reference) and the full component array (for downstream enrichment in the next step).
  • Save the workflow by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Time for the full end-to-end test. Before you run it, think about what you expect to see. Given two components where one has a purl but no cpe, and the other has neither, what should the countByStatus summary show?

  • Click the Webhook node and click Listen for test event.
  • In your terminal, send this richer sample SBOM:
curl -X POST http://localhost:5678/webhook-test/sbom-baseline \
  -H "Content-Type: application/json" \
  -d '{"bomFormat":"CycloneDX","specVersion":"1.4","components":[{"type":"library","name":"express","version":"4.18.2","purl":"pkg:npm/express@4.18.2","cpe":"cpe:2.3:a:openjsf:express:4.18.2:*:*:*:*:node.js:*:*"},{"type":"library","name":"lodash","version":"4.17.21"},{"type":"library","name":"axios","version":"1.6.0","purl":"pkg:npm/axios@1.6.0"},{"type":"library","name":"debug","version":"4.3.4"}]}'
  • Click the Component Summary node to view its output.

You should see a JSON response with totalComponents: 4 and countByStatus showing complete: 1 (express has both purl and cpe), partial: 1 (axios has only purl), and missing: 2 (lodash and debug have neither).

Counts don't match what you expected?

  • Double check the sample SBOM JSON in your curl command. Make sure express has both a purl and cpe field.
  • Verify the normalization code checks for empty strings correctly. An empty string "" is falsy in JavaScript, so comp.purl || '' correctly produces a falsy result when the field is missing.

Help me figure out why my component identifier status counts are wrong.

Your SBOM parser can now accept raw SBOMs in either format and produce a clean, unified component inventory. Next up, you will query the NVD CPE Dictionary to fill in those missing identifiers.

Enrich Components with CPE via NVD API

Your workflow can now detect SBOM formats and produce a unified component inventory. The problem is that most components in a raw SBOM are missing standardized identifiers.

The NVD CPE Dictionary contains over 1.7 million product entries and is the authoritative source for mapping software names to standardized CPE identifiers. In this step, you will query it to fill in missing CPEs, construct PURLs where possible, and score the confidence of each match.

In this step, get ready to:
  • Filter components by identifier status and batch them for rate-limited API calls.
  • Query the NVD CPE API and assess match confidence for each component.
  • Construct PURLs with ecosystem detection and merge enriched components back together.
Filter components needing enrichment and batch for API calls

Not every component needs enrichment. Components that already have both a PURL and a CPE (status "complete") can skip straight through to the final merge. Only components with "missing" or "partial" status need to hit the NVD API.

  • In your n8n workflow editor, click the + button after the Summary Code node on the canvas.
  • Search for IF and add the IF node.
  • In the IF node parameters, click Add condition.
  • Select data type String and comparison is not equal to.
  • In the left Value field, click the expression toggle and enter {{ $json.identifierStatus }}.
  • In the right Value field, type complete.

What does this IF node do?

Items where identifierStatus is NOT "complete" flow to the True output (these need enrichment). Items where status equals "complete" flow to the False output (these bypass enrichment and go straight to the Merge node later).

  • From the True output of the IF node, click + and search for Loop Over Items. Add it.
  • In the Loop Over Items parameters, set Batch Size to 5.

Why batch size 5?

The NVD API allows higher throughput with an API key, but sending requests too fast can still trigger rate limits. Batching in groups of 5 with a 1-second pause between batches keeps you well within safe limits while still processing efficiently.

  • Execute the workflow by clicking Test workflow with a sample SBOM sent via the webhook (the same approach you used in the previous step).
  • Click on the IF node output to confirm items are splitting correctly. Components with "missing" or "partial" status should appear on the True branch.

All items going to False?

If every component routes to the False output, your sample SBOM may already have complete identifiers. Use a sample with components that lack purl or cpe fields to see the split in action.

Also verify your expression is exactly {{ $json.identifierStatus }} with no typos.

help me debug my IF node condition for filtering components by identifierStatus

Query the NVD CPE API and assess match confidence

Now you will call the NVD CPE API for each component that needs enrichment. The API uses keyword search, which means it can return multiple possible matches or none at all for oddly-named packages.

  • From the second output of the Loop Over Items node (the "loop" output), click + and search for HTTP Request. Add it.
  • Set Method to GET.
  • Set URL to https://services.nvd.nist.gov/rest/json/cpes/2.0.
  • Under Query Parameters, click Add Parameter and add two parameters:
  • Under Headers, click Add Header. Set Name to apiKey and Value to your NVD API key.

Why include group in the keyword search?

The NVD keyword search treats multiple words as AND conditions. Including the group or supplier name (like "apache" or "org.springframework") narrows results and increases the chance of finding the right CPE on the first try.

  • After the HTTP Request node, click + and add a Code node. Name it CPE Confidence.
  • Set the Code node mode to Run Once for Each Item.
  • Paste the following JavaScript into the Code node:
// Assess CPE match confidence from NVD response
const component = $input.item.json.component;
const nvdResponse = $input.item.json;
const totalResults = nvdResponse.totalResults || 0;

const logEntry = {
  timestamp: new Date().toISOString(),
  component: component.name,
  version: component.version,
  action: 'cpe_lookup'
};

if (totalResults === 0) {
  component.cpe_lookup_failed = true;
  component.cpeMatchConfidence = null;
  logEntry.result = 'no_match';
  logEntry.reasoning = 'NVD returned zero results';
} else {
  const topMatch = nvdResponse.products[0].cpe.cpeName;
  component.cpe = topMatch;
  const cpeLower = topMatch.toLowerCase();
  const nameMatch = cpeLower.includes(component.name.toLowerCase());
  const versionMatch = component.version &&
    cpeLower.includes(component.version);
  component.cpeMatchConfidence = (nameMatch && versionMatch)
    ? 'high' : nameMatch ? 'medium' : 'low';
  logEntry.result = 'matched';
  logEntry.confidence = component.cpeMatchConfidence;
  logEntry.cpe = topMatch;
}

return { json: { component, decisionLogEntry: logEntry } };

What does this code do?

  • If the NVD returned zero results, it marks the component with cpe_lookup_failed: true so you know it needs AI help in the next step.
  • If results exist, it grabs the top match from products[0].cpe.cpeName and checks whether the CPE string contains both the component name and version ("high"), just the name ("medium"), or only a partial match ("low").
  • Every lookup is recorded in a decisionLogEntry for the audit trail.
  • After the CPE Confidence Code node, add a Wait node. Set the Resume option to After Time Interval and set it to 1 second.
  • Connect the Wait node output back to the Loop Over Items node input to complete the loop.
  • Execute the workflow with your sample SBOM.
  • Click on the CPE Confidence node output. You should see components with cpeMatchConfidence values of "high", "medium", or "low", and some marked with cpe_lookup_failed: true.

Getting 403 or empty responses from NVD?

Double-check that your header name is exactly apiKey (case sensitive) and the value matches the key you activated via email. If you recently registered, allow a few minutes for the key to become active.

help me debug my NVD CPE API request in n8n

Construct PURLs and merge results

CPE identifiers map components to the NVD vulnerability database. PURLs serve a different purpose: they provide a universal, ecosystem-aware address for each package. The canonical form is pkg:type/namespace/name@version.

  • After the Loop Over Items node completes (its first output, the "done" branch), click + and add a new Code node. Name it PURL Construction.
  • Set the mode to Run Once for All Items.
  • Paste the following JavaScript:
// Construct PURLs with ecosystem-aware type detection
const items = $input.all();
const results = [];

for (const item of items) {
  const component = item.json.component;
  let purlType = 'generic';
  const name = (component.name || '').toLowerCase();
  const group = (component.group || '').toLowerCase();

  if (component.purl && component.purl.startsWith('pkg:')) {
    // PURL already exists from original SBOM, keep it
  } else {
    // Detect ecosystem from metadata patterns
    if (group.includes('org.') || group.includes('com.')) purlType = 'maven';
    else if (group.includes('npm') || name.endsWith('.js')) purlType = 'npm';
    else if (group.includes('pypi') || name.includes('python')) purlType = 'pypi';
    else if (group.includes('golang') || name.includes('go-')) purlType = 'golang';
    else if (group.includes('nuget')) purlType = 'nuget';
    else if (group.includes('rubygems')) purlType = 'gem';
    else if (group.includes('crates')) purlType = 'cargo';

    const namespace = component.group ? `/${component.group}` : '';
    const version = component.version ? `@${component.version}` : '';
    component.purl = `pkg:${purlType}${namespace}/${component.name}${version}`;
  }

  // Update identifier status based on new data
  if (component.purl && component.cpe) {
    component.identifierStatus = 'complete';
  } else if (component.purl || component.cpe) {
    component.identifierStatus = 'partial';
  }

  results.push({ json: { component, decisionLogEntry: item.json.decisionLogEntry } });
}

return results;

What does this code do?

  • It checks if a PURL already exists from the original SBOM. If so, it leaves it untouched.
  • For components without a PURL, it detects the ecosystem from group/name patterns (maven for Java-style groups like org.apache, npm for JavaScript packages, pypi for Python, and so on).
  • If no ecosystem can be determined, it falls back to pkg:generic/name@version as the standard PURL fallback.
  • Finally, it updates identifierStatus based on whether the component now has both identifiers, one, or neither.
  • Now add the Merge node. From the IF node's False output (the "complete" bypass path), click + and search for Merge. Add it.
  • Connect the PURL Construction node output to the second input of the Merge node.
  • In the Merge node parameters, set the mode to Append so it combines both input arrays into a single list.

Before you run the full workflow, do you think every component will get a CPE match from the NVD? Or will some remain unresolved?

  • Execute the workflow with your sample SBOM.
  • Click on the Merge node output. You should see the full component array with cpeMatchConfidence fields populated where NVD returned matches.

Some components will still show cpe_lookup_failed: true or have "low" confidence scores. This is expected and intentional. The NVD keyword search is fuzzy and cannot reliably resolve ambiguous names (like "requests" which exists in both Python and Ruby ecosystems). That gap is exactly what AI analysis solves in the next step.

Merge node showing empty output?

Verify both inputs of the Merge node have connections. The first input comes from the IF node's False output (complete components). The second input comes from the PURL Construction node (enriched components).

If one path has zero items (for example, no components were already complete), the Merge still works. Check that at least one path has data.

help me fix my Merge node connections in n8n

Your enrichment pipeline is working. Components now carry CPE identifiers and constructed PURLs wherever the NVD had a match. Next up, you will use Claude to resolve the ambiguous cases and flag false positives that deterministic code cannot handle.

Add AI Analysis to Resolve Ambiguities and Flag False Positives

Your enrichment pipeline now fills in CPE identifiers from the NVD, but some problems resist simple keyword matching against a database.

A component named "requests" exists in both Python and Ruby ecosystems. Scanner artifacts like build-tool metadata appear as real components. Some components return multiple CPE matches with no clear winner. Claude can reason about ecosystem conventions, common false positive patterns, and context to make judgment calls that deterministic code cannot.

In this step, get ready to:
  • Group components by analysis category (ambiguous, suspect false positive, needs review).
  • Send grouped components to Claude via n8n's AI Agent node for structured analysis.
  • Apply AI decisions to remove false positives, correct identifiers, and flag items for manual review.
Prepare context and structured prompt for Claude

Before Claude can analyze your components, you need to sort them into meaningful categories. Components with low-confidence CPE matches need different treatment than components whose names match known false positive patterns.

  • In the n8n editor, click the + button after the Merge node to add a new node.
  • Search for Code and select it.
  • Name this node Group for AI Analysis.
  • Set Mode to Run Once for All Items.
  • Paste the following JavaScript into the code editor:
// Categorize components for AI analysis
const items = $input.all();
const components = items.map(item => item.json);

const ambiguous = [];
const suspectFalsePositive = [];
const needsReview = [];
const clean = [];

// Patterns indicating build tools or container noise
const fpPatterns = [
  /^webpack-/i, /^babel-/i, /^eslint-/i,
  /^@types\//i, /linux-headers/i, /^kernel-/i,
  /^glibc/i, /^libc6/i, /^base-files/i
];

What does this code do?

The $input.all() call retrieves every item flowing into this Code node from the Merge node. You then extract the json property from each item, which contains your component data.

The fpPatterns array defines regex patterns for common false positive names: webpack/babel/eslint plugins (build-time only), TypeScript type definitions, and container base image packages.

  • Below the pattern definitions, add the categorization loop:
// Sort each component into the correct category
for (const comp of components) {
  if (comp.cpe_lookup_failed) {
    needsReview.push(comp);
  } else if (comp.cpeMatchConfidence === 'low') {
    ambiguous.push(comp);
  } else if (fpPatterns.some(p => p.test(comp.name))) {
    suspectFalsePositive.push(comp);
  } else {
    clean.push(comp);
  }
}

How does the categorization work?

  • Components where the NVD lookup returned zero results (cpe_lookup_failed: true) go into needsReview because there is not enough data to judge them.
  • Components with cpeMatchConfidence of low go into ambiguous because the NVD returned results but none were a strong match.
  • Names matching the regex patterns for build-tool internals, container-layer packages, or kernel modules go into suspectFalsePositive.
  • Everything else (high or medium confidence matches) is clean and passes through untouched.
  • Below the categorization loop, add the prompt construction and return statement:
// Build system prompt with PURL type reference
const systemPrompt = `You are an SBOM quality analyst.
For each component provided, determine:
1. Is it a false positive (build tool, container noise)?
2. If ambiguous, what is the correct identifier?
3. Are there missing components implied by the pattern?

PURL type reference: npm, pypi, maven, golang, nuget,
gem, cargo, composer, swift, cocoapods, deb, rpm.

Common false positive patterns:
- webpack/babel/eslint plugins (build-time only)
- Container base image packages (libc6, base-files)
- Kernel modules from host scanning
- @types/ packages (TypeScript type definitions)

Output a JSON array with fields per component:
component, action, reasoning, correctedIdentifier, missingInfo`;
// Package grouped data for the AI Agent
return [{
  json: {
    systemPrompt,
    ambiguous,
    suspectFalsePositive,
    needsReview,
    clean,
    totalForAnalysis: ambiguous.length +
      suspectFalsePositive.length + needsReview.length
  }
}];

Why return a single item?

The return statement packages everything into a single n8n item with a json key. The AI Agent node receives this object as its input, letting you reference each field (like $json.ambiguous) in the prompt expression.

  • Click the node, then select Test step to execute this Code node with data from your previous test run.

You should see a single output item containing a systemPrompt string, three arrays of categorized components, and a totalForAnalysis count.

Seeing empty component arrays?

  • Make sure the Merge node output connects into this Code node. Check the connection line in the canvas.
  • If all components land in clean and none are in the analysis groups, your test SBOM may have high-confidence matches for everything. Add a component with a name like my-internal-lib that the NVD will not recognise.
  • help me debug why my Code node output is empty
Send grouped components to the AI Agent node

The AI Agent node in n8n connects a chat model with tools to form an autonomous agent. You will configure it to receive your grouped components and return structured decisions about each one.

  • Click the + button after the Group for AI Analysis node.
  • Search for AI Agent and select it.
  • In the AI Agent node settings, set Prompt to Define below.
  • In the prompt text field, enter this expression that pulls your categorized data:
Analyze these SBOM components:

AMBIGUOUS (low confidence CPE match):
{{ JSON.stringify($json.ambiguous, null, 2) }}

SUSPECT FALSE POSITIVES:
{{ JSON.stringify($json.suspectFalsePositive, null, 2) }}

NEEDS REVIEW (no CPE match found):
{{ JSON.stringify($json.needsReview, null, 2) }}

For each component, respond with action: keep, flag_false_positive, resolve_ambiguity, or flag_for_review.

Why use Define below instead of Take from previous node?

The "Take from previous node automatically" option expects an input field called chatInput. By choosing "Define below", you write an expression that pulls specific fields from your Code node output and formats them into a clear prompt for Claude.

  • Click the model attachment point on the AI Agent node (below the node).
  • Select Anthropic Chat Model.
  • In the Anthropic Chat Model sub-node, select your n8n-sbom-baseline credential from the dropdown.
  • Leave the model on the default Claude selection.

Now configure the system message that defines Claude's analyst role.

  • Back in the AI Agent node, expand Options and find System Message.
  • Paste the following into the System Message field:
You are an SBOM quality analyst. For each component provided, determine: (1) Is it a false positive? (2) If ambiguous, what is the correct identifier? (3) Are there missing components that should be present based on the dependency pattern? Output a valid JSON array with fields: component, action, reasoning, correctedIdentifier, missingInfo

What does the system message do?

The system message sets the context for every interaction with this agent. It tells Claude to behave as an SBOM quality analyst and specifies the exact JSON structure to respond with, reinforcing the structured output parser constraint.

  • The AI Agent node requires at least one tool sub-node connected. Click the tool attachment point and add a Code Tool node (this satisfies the requirement while the agent relies on its own reasoning for analysis).
  • Now enable structured output. In the AI Agent node, toggle on Require Specific Output Format.
  • Click the new output parser attachment point that appears.
  • Select Structured Output Parser.
  • In the Structured Output Parser, set Schema Type to Generate from JSON Example.
  • Paste this JSON example to define the expected output shape:
[{
  "component": "example-lib",
  "action": "flag_false_positive",
  "reasoning": "Build tool internal, not runtime",
  "correctedIdentifier": "",
  "missingInfo": ""
}]

What does the Structured Output Parser do?

The parser takes your JSON example and generates a schema from it. It then instructs the model to respond in exactly that shape, preventing malformed output from breaking downstream Code nodes.

The action field is the key decision point. Valid values are keep, flag_false_positive, resolve_ambiguity, and flag_for_review.

  • Execute the AI Agent node by clicking Test step.

You should see a JSON array in the output with one decision object per analyzed component. Each object has component, action, and reasoning fields populated by Claude.

AI Agent returning an error?

  • Verify your Anthropic credential is still valid. Open the credential in the Credentials section and test it.
  • If you see a "no tool connected" error, confirm the Code Tool sub-node is attached to the tool attachment point on the AI Agent.
  • If the output is not valid JSON, check that the Structured Output Parser is connected to the output parser attachment point.
  • help me fix my AI Agent node configuration
Apply AI decisions and build the decision log

Claude returns structured decisions, but they need to be acted on. You need a Code node that removes false positives, updates identifiers for resolved ambiguities, and records every decision in the audit trail.

  • Add a new Code node after the AI Agent node.
  • Name it Apply AI Decisions.
  • Set Mode to Run Once for All Items.
  • Paste the following JavaScript to parse the AI response and rebuild the component list:
// Parse AI decisions from the agent output
const aiOutput = $input.first().json;
const decisions = Array.isArray(aiOutput.output)
  ? aiOutput.output
  : JSON.parse(aiOutput.output);

// Retrieve the full component list from earlier node
const groupNode = $('Group for AI Analysis').first().json;
const allComponents = [
  ...groupNode.ambiguous,
  ...groupNode.suspectFalsePositive,
  ...groupNode.needsReview,
  ...groupNode.clean
];

// Initialize decision log and output arrays
const decisionLog = [];
const kept = [];
const now = new Date().toISOString();

What does this code do?

  • It parses the AI response, handling both cases where the output is already an array or a JSON string that needs parsing.
  • It pulls original grouped components from the earlier Code node using $('Group for AI Analysis') so all components are available for processing.
  • It initializes the decisionLog array that will serve as the full audit trail.
  • Below the initialization code, add the decision processing loop:
// Process each AI decision into the log
for (const decision of decisions) {
  const entry = {
    timestamp: now,
    component: decision.component,
    action: decision.action,
    reasoning: decision.reasoning,
    confidence: 'ai_assessed'
  };

  if (decision.action === 'flag_false_positive') {
    entry.result = 'removed';
  } else if (decision.action === 'resolve_ambiguity') {
    entry.correctedIdentifier = decision.correctedIdentifier;
    entry.result = 'updated';
  } else if (decision.action === 'flag_for_review') {
    entry.result = 'flagged_manual';
  } else {
    entry.result = 'kept';
  }
  decisionLog.push(entry);
}

What does this loop produce?

Each entry in decisionLog captures the timestamp, component reference, action taken, AI reasoning, and confidence level. This creates the audit trail that explains every change made to the SBOM.

  • Finally, add the component filtering and return logic:
// Build lookup of AI decisions by component name
const decisionMap = {};
for (const d of decisions) {
  decisionMap[d.component] = d;
}

// Filter and update components based on decisions
for (const comp of allComponents) {
  const d = decisionMap[comp.name];
  if (!d || d.action === 'keep') {
    kept.push(comp);
  } else if (d.action === 'resolve_ambiguity') {
    comp.cpe = d.correctedIdentifier || comp.cpe;
    comp.aiResolved = true;
    kept.push(comp);
  } else if (d.action === 'flag_for_review') {
    comp.needsManualReview = true;
    kept.push(comp);
  }
  // flag_false_positive components are excluded
}

return [{ json: { components: kept, decisionLog } }];

How does the filtering work?

  • Components flagged as flag_false_positive are silently dropped from the output. They never reach the final baseline.
  • Components with resolve_ambiguity get their CPE updated to the corrected identifier Claude provided.
  • Components marked flag_for_review stay in the output but carry a needsManualReview flag for downstream handling.
  • The final return produces a single item with both the cleaned component array and the full decision log.

Before you run this, what do you expect the output component count to be compared to the input? Think about which components get removed versus flagged.

  • Execute the full workflow from the Webhook node by triggering it with your test SBOM.
  • Click the Apply AI Decisions node to inspect its output.

You should see a JSON object with two keys: components (a filtered array with fewer items than the input) and decisionLog (an array where each entry has component, action, reasoning, and timestamp fields).

Component count not decreasing?

  • Check that your test SBOM contains at least one component with a name matching the false positive patterns (like webpack-cli or @types/node). Without these, Claude has nothing to flag.
  • Inspect the decisionLog entries. If every action is keep, the AI determined all components are legitimate. Add more suspicious entries to your test data.
  • help me understand why AI decisions are not filtering my SBOM components

Your workflow now has intelligence. It can reason about which components belong, which are noise, and which need a human to decide. Next up, you will deduplicate the remaining components by canonical PURL and produce the final baselined SBOM output.

Deduplicate Components and Output the Baselined SBOM

Your components are enriched and cleaned, but the list still contains duplicates. The same package often appears multiple times in a raw SBOM because scanners pick it up at different dependency paths or across multiple scan passes.

Deduplicating by canonical PURL is the industry best practice. Name-plus-version alone loses ecosystem context and can incorrectly merge distinct packages across ecosystems. In this step, you will collapse duplicates, reconstruct the SBOM in its original format, and return a clean baseline with a full audit trail.

In this step, get ready to:
  • Deduplicate components by canonical PURL with a fallback composite key.
  • Reconstruct the baselined SBOM in the original detected format with a summary section.
  • Return the final JSON response through a Respond to Webhook node.
Deduplicate by canonical PURL

Components that resolve to the same canonical PURL are duplicates regardless of how many times the scanner found them. Your deduplication logic keeps the entry with the most complete metadata and logs every removal.

  • In your n8n workflow, add a new Code node after the AI Decision Apply node.
  • Set the mode to Run Once for All Items.
  • Rename it to Deduplication.
  • Paste the following code into the Code editor:
const items = $input.all();
const components = items[0].json.components || [];
const decisionLog = items[0].json.decisionLog || [];
const groups = {};

for (const comp of components) {
  // Canonical PURL (lowercase, no qualifiers) or fallback key
  const key = comp.purl
    ? comp.purl.split('?')[0].toLowerCase()
    : `${comp.name}@${comp.version || 'unknown'}+${comp.type || 'generic'}`.toLowerCase();
  if (!groups[key]) groups[key] = [];
  groups[key].push(comp);
}

// Keep most-complete entry per group, log removals
const deduplicated = [];
for (const [key, group] of Object.entries(groups)) {
  const scored = group.map(c => ({ comp: c,
    score: (c.purl ? 2 : 0) + (c.cpe ? 2 : 0) + (c.version ? 1 : 0) + (c.group ? 1 : 0)
      + (c.cpeMatchConfidence === 'high' ? 2 : c.cpeMatchConfidence === 'medium' ? 1 : 0)
  })).sort((a, b) => b.score - a.score);
  deduplicated.push(scored[0].comp);
  for (let i = 1; i < scored.length; i++) {
    decisionLog.push({ timestamp: new Date().toISOString(), component: scored[i].comp.name,
      action: 'deduplicated',
      reasoning: `Duplicate of ${scored[0].comp.purl || scored[0].comp.name}. Removed: less complete metadata.`,
      confidence: 'high' });
  }
}
return [{ json: { components: deduplicated, decisionLog, duplicatesRemoved: components.length - deduplicated.length } }];

What does this code do?

  • It normalizes each component's PURL to lowercase and strips query qualifiers, creating a canonical grouping key. Components without a PURL fall back to a name@version+ecosystem composite key.
  • For each group with multiple entries, it scores components by metadata completeness (PURL, CPE, version, group, confidence level) and keeps the highest-scoring entry.
  • Every removed duplicate gets a decision log entry recording what was kept and why the duplicate was removed.
  • Click Test step to execute the node.
  • In the output panel, confirm duplicatesRemoved is a number greater than or equal to 0. The components array should be shorter than or equal to the input array.

Output shows 0 duplicates removed?

If your test SBOM has no duplicate entries, this is expected. The deduplication logic only collapses entries that share the same canonical PURL or fallback key. Try adding a duplicate component to your test data to verify the logic works.

help me understand why my deduplication node shows zero removals

Reconstruct the SBOM in original format and add the summary

The deduplicated component list needs to be packaged back into a valid SBOM document matching the original format the caller submitted. You also need a summary section showing the pipeline's impact at a glance.

  • Add another Code node after the Deduplication node.
  • Set the mode to Run Once for All Items.
  • Rename it to SBOM Reconstruction.
  • Paste the first section of the code, which calculates the summary statistics:
const items = $input.all();
const deduplicated = items[0].json.components || [];
const decisionLog = items[0].json.decisionLog || [];
const duplicatesRemoved = items[0].json.duplicatesRemoved || 0;
// Reference prior nodes for original format and input count
const originalFormat = $('Format Detection').first().json.format;
const originalSbom = $('Format Detection').first().json.originalSbom;
const totalInput = $('Component Summary').first().json.totalComponents;

// Build summary statistics
const summary = {
  totalInputComponents: totalInput,
  totalOutputComponents: deduplicated.length,
  enrichedCount: deduplicated.filter(c => c.identifierStatus === 'complete').length,
  falsePositivesRemoved: decisionLog.filter(d => d.action === 'flag_false_positive').length,
  duplicatesRemoved: duplicatesRemoved,
  flaggedForReview: deduplicated.filter(c => c.flaggedForReview).length
};

What does this section do?

It pulls the original SBOM format and raw document from the Format Detection node using n8n's $('NodeName') expression syntax. It also grabs the original component count from the Component Summary node so the summary can show how much the pipeline reduced the inventory.

  • Continue pasting the format-specific reconstruction logic directly below the summary code in the same Code node:
// Rebuild SBOM in original detected format
let baselinedSbom;
if (originalFormat === 'CycloneDX') {
  baselinedSbom = {
    bomFormat: 'CycloneDX', specVersion: originalSbom.specVersion || '1.5',
    version: (originalSbom.version || 0) + 1,
    metadata: { ...originalSbom.metadata,
      tools: [...(originalSbom.metadata?.tools || []), { name: 'n8n-sbom-baseline', version: '1.0.0' }] },
    components: deduplicated.map(c => ({ type: c.type || 'library', name: c.name,
      version: c.version, group: c.group, purl: c.purl, cpe: c.cpe, 'bom-ref': c['bom-ref'] || c.name })),
    dependencies: originalSbom.dependencies || []
  };
} else {
  // SPDX format reconstruction
  baselinedSbom = {
    spdxVersion: originalSbom.spdxVersion, dataLicense: originalSbom.dataLicense,
    SPDXID: originalSbom.SPDXID, name: originalSbom.name,
    packages: deduplicated.map(c => ({ name: c.name, versionInfo: c.version,
      SPDXID: c.spdxId || `SPDXRef-${c.name}`,
      externalRefs: [
        ...(c.purl ? [{ referenceCategory: 'PACKAGE_MANAGER', referenceType: 'purl', referenceLocator: c.purl }] : []),
        ...(c.cpe ? [{ referenceCategory: 'SECURITY', referenceType: 'cpe23Type', referenceLocator: c.cpe }] : [])
      ] })),
    relationships: originalSbom.relationships || []
  };
}
return [{ json: { baselinedSbom, decisionLog, summary } }];

What does this reconstruction do?

For CycloneDX input, it rebuilds the components array with enriched PURL, CPE, and bom-ref fields. It preserves the original dependencies and metadata sections, and adds a tool entry identifying your baselining workflow.

For SPDX input, it rebuilds packages with updated externalRefs containing the new PURL and CPE entries, and preserves relationships.

  • Click Test step to execute the SBOM Reconstruction node.
  • Confirm the output contains three keys: baselinedSbom, decisionLog, and summary.

Seeing a reference error for Format Detection or Component Summary?

The $('NodeName') syntax references nodes by their display name on the canvas. Make sure your Format Detection Code node is named exactly Format Detection and your Summary Code node is named exactly Component Summary. Double-click the node title on the canvas to rename it.

help me fix a node reference error in my n8n Code node

Add the Respond to Webhook node and run end-to-end

The final piece connects your reconstruction output to the HTTP response the caller receives. You need to update your Webhook trigger to use the Respond to Webhook pattern so n8n waits for the full pipeline to finish before replying.

  • Click the + button after the SBOM Reconstruction node and search for Respond to Webhook.
  • Add the node and connect it after SBOM Reconstruction.
  • In the Respond to Webhook node, set Respond With to First Incoming Item.
  • Open the Webhook trigger node at the start of your workflow.
  • Set Respond to Using 'Respond to Webhook' Node.

Why change the Webhook respond setting?

By default, the Webhook node responds immediately with a generic message. Changing it to use the Respond to Webhook node tells n8n to hold the HTTP connection open until your entire pipeline finishes and the Respond to Webhook node fires. This way the caller gets the full baselined SBOM in the response body.

  • Save the workflow.
  • Click Test workflow in the n8n editor to activate the test webhook.

Before you send the test request, predict what you expect to see. Will the output have fewer components than the input? Will the decision log explain every removal?

  • Open a terminal and send your sample SBOM to the test webhook URL by running:
curl -X POST http://localhost:5678/webhook-test/sbom-baseline \
  -H "Content-Type: application/json" \
  -d @sample-sbom.json

You should see a JSON response with three top-level keys: baselinedSbom, decisionLog, and summary. The summary.totalOutputComponents value should be lower than summary.totalInputComponents. The decision log contains an entry for every removal and enrichment the pipeline performed.

Getting 'Workflow could not be started' or a timeout?

Make sure you clicked Test workflow in the n8n editor before sending the curl request. The test webhook stays active for 120 seconds. If it timed out, click Test workflow again and resend.

If the response comes back empty, check that your Webhook node's Respond setting is Using 'Respond to Webhook' Node and not Immediately.

help me debug my n8n webhook not returning the full response

Your SBOM baselining pipeline is complete. Every raw component that entered the webhook has been normalized, enriched with CPE identifiers, analyzed by AI, deduplicated, and packaged into a clean baseline with a full audit trail. Next up, you will extend this workflow with an interactive chat interface for components that need human judgment.

Secret mission

Add Interactive Chat for Unresolved Components

Some components genuinely cannot be auto-resolved by the NVD API or Claude. Rather than silently including them or making a best guess, build a human-in-the-loop branch that surfaces unresolved items to an operator via n8n's Chat Trigger for real-time interactive resolution decisions.

Clean Up Your Resources

Clean Up Your Resources

This project uses the Anthropic API, which charges per request. The NVD API is free with no ongoing costs. Your n8n workflow runs locally in Docker and only incurs Claude API costs when the webhook is triggered.

Cost warning

The Anthropic API charges approximately $0.01-$0.05 per SBOM processed, depending on component count. If you leave the workflow published, costs accrue each time the webhook receives a request.

Unpublish or delete the workflow to prevent accidental charges.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • n8n SBOM Baselining Workflow (persists in n8n_data Docker volume).
  • Anthropic API key named n8n-sbom-baseline (pay per use, managed in Anthropic Console).
  • n8n Anthropic credential (stored locally in your n8n instance).

Keep everything running

No action needed. Choose this if you plan to continue using the SBOM baselining workflow.

  • The workflow remains available on your n8n instance at http://localhost:5678.
  • Claude API costs only accrue when the webhook receives a request. No idle charges.
  • The NVD API key is free with no usage limits beyond rate throttling.

Pause - I'll come back to this later

Unpublish the workflow to prevent it from responding to webhook requests. No costs while inactive.

  • Open the SBOM Baselining Workflow in the n8n editor.
  • Click the dropdown arrow next to the Publish button in the canvas header.
  • Select Unpublish to deactivate the workflow.
  • Your workflow and all node configurations remain saved. Re-publish it any time to resume processing.

Delete - I don't want to use this again

Remove all project resources permanently. Follow each step below.

  • Open the n8n editor and navigate to your workflow list.
  • Open the Command Bar by pressing Cmd+K (macOS) or Ctrl+K (Windows) and select the delete workflow action for the SBOM Baselining Workflow.
  • Go to Credentials in the n8n side menu, find your Anthropic credential, and delete it.
  • In the Anthropic Console (console.anthropic.com), go to Settings then API Keys. Find the key named n8n-sbom-baseline and disable or archive it.
  • If you want to remove all n8n data (including any other workflows you have), run:
docker volume rm n8n_data

Removing the Docker volume deletes everything

The n8n_data volume stores all your n8n workflows, credentials, and execution history. Only remove it if you want to start completely fresh. If you only want to remove this project, delete just the workflow and credential from within n8n instead.

Nice Work!

Nice Work!

Nice work! You just built a production-ready SBOM baselining workflow in n8n that transforms messy, incomplete software inventories into clean, enriched baselines ready for vulnerability management.

You've learned how to:

  • Build a complete SBOM baselining workflow that detects CycloneDX and SPDX formats, normalizes components into a unified structure, and outputs enriched baselines via webhook.
  • Integrate the NVD CPE Dictionary API for identifier enrichment with confidence scoring, and use Claude to resolve ambiguous components, flag false positives, and identify gaps in the inventory.
  • Deduplicate components by canonical PURL and produce a structured decision log that creates a full audit trail for every enrichment, removal, and deduplication action.
  • Secret Mission: Added an interactive human-in-the-loop resolution interface via Chat Trigger so unresolvable components get surfaced to an operator for real-time manual review decisions.

Ready to quiz yourself?