← Back to Blog

Why Your SBOM Fails PURL Validation for Scoped npm Packages

· Verimu
PURLCycloneDXNTIASBOMnpm

If you're generating CycloneDX SBOMs for Node.js projects, you may have seen your SBOM pass schema validation but fail NTIA minimum element checks with this error:

Component requires at least one valid identifier (PURL or CPE). Both are currently missing or invalid. Path: components.pkg:npm/@types/node@20.11.5.identifiers Rule: Component.identifiers

The component is there. The version is there. The PURL looks right: pkg:npm/@types/node@20.11.5. So what's wrong?

The problem: two @ signs

A Package URL (PURL) uses the @ character as a version separator. The format is:

pkg:<type>/<namespace>/<name>@<version>

When you write pkg:npm/@types/node@20.11.5, there are two @ characters in the string. A PURL parser needs to know which one separates the version. Is it @types/node at version 20.11.5, or is it @types/node@20.11.5 with no version at all?

This ambiguity causes PURL parsers to reject the identifier entirely. Your component effectively has no valid PURL, which means it fails the NTIA "unique identifier" requirement.

The spec is explicit

The PURL specification for npm packages addresses this directly:

"The npm scope @ sign prefix is always percent encoded, as it was in the early days of npm scope."

The correct encoding for scoped npm packages is:

Package ❌ Incorrect PURL ✅ Correct PURL
@types/node@20.11.5 pkg:npm/@types/node@20.11.5 pkg:npm/%40types/node@20.11.5
@angular/core@17.1.0 pkg:npm/@angular/core@17.1.0 pkg:npm/%40angular/core@17.1.0
@vue/reactivity@3.4.1 pkg:npm/@vue/reactivity@3.4.1 pkg:npm/%40vue/reactivity@3.4.1

The @ in the scope becomes %40, leaving exactly one literal @ in the entire PURL — unambiguously the version separator.

Unscoped packages like express@4.18.2 are unaffected: pkg:npm/express@4.18.2 is already correct.

Why this mistake is so common

It's a natural mistake. When you look at @types/node, the @ prefix is part of how npm displays and references the package. Every developer types npm install @types/node — with the literal @. It feels wrong to encode it.

Adding to the confusion, some PURL examples floating around the internet (and even in some CycloneDX sample files) show the @ sign unencoded. The canonical spec examples on GitHub show pkg:npm/@angular/animation@12.3.1 — but the normative text in that same document says the @ must be encoded. The spec text wins.

The fix

If you're building SBOM tooling, the fix is straightforward. When constructing a PURL for a scoped npm package, encode the leading @ as %40:

function buildPurl(name: string, version: string): string {
  if (name.startsWith('@')) {
    return `pkg:npm/%40${name.slice(1)}@${version}`;
  }
  return `pkg:npm/${name}@${version}`;
}

A common mistake is to use a blanket name.replace('/', '%2F') or encodeURIComponent on the whole name. Don't do that either — the / between namespace and name is a structural PURL separator and must remain unencoded.

A quick way to verify

After fixing your PURL generation, you can validate your SBOM using:

Both will catch invalid PURLs before they become a compliance problem.

After the fix

With the @ scope prefix properly percent-encoded, your scoped npm packages will carry valid PURLs and the NTIA identifier check passes cleanly:

✓ 12/12 NTIA minimum element checks passed

How Verimu handles this

Verimu's SBOM generator encodes scoped npm PURLs correctly per the PURL specification — %40 for the scope prefix, unencoded / for the namespace separator. Every SBOM we generate passes NTIA minimum element validation out of the box.

If you're working toward CRA compliance for EU market access, or you just want SBOMs that don't fail validation in production, we've already solved these edge cases so you don't have to.

Get started free → or book a demo to see Verimu in action.