PURL (Package URL) Generation

Package URLs follow the purl-spec to identify software packages across ecosystems. PURLs are generated dynamically at API response time from CVEAffected fields and persisted for caching.

Architecture

flowchart TB subgraph Input["Input Fields (CVEAffected)"] PkgName["packageName"] Vendor["vendor"] Product["product"] CollURL["collectionURL"] Eco["ecosystem (derived)"] end subgraph TypeDerivation["PURL Type Derivation"] EcoMap["Ecosystem → PURL Type
50+ mappings"] URLPattern["Registry URL Pattern
20+ regex patterns"] HashFallback["Hash Present → github"] GenericFallback["Default → generic"] end subgraph NamespaceDerivation["Namespace Derivation"] Maven["maven: vendor or group:artifact split"] Golang["golang: path segments (all but last)"] Docker["docker: org from image path"] GitHub["github/bitbucket: owner from repo URL"] Composer["composer: vendor/package split"] DistPkg["deb/rpm/apk: vendor"] NoNS["npm/pypi/cargo/etc: no namespace"] end subgraph Build["PURL Assembly"] Scheme["pkg:{type}"] NS["/{namespace} (optional)"] Name["/{name}"] Version["@{version} (optional)"] Quals["?{qualifiers} (optional)"] Subpath["#{subpath} (optional)"] end subgraph Validate["Version Validation"] Wildcards["Reject wildcards: x, X, *"] Ranges["Reject ranges: <, >, =, ^, ~, !"] Boolean["Reject boolean: ||, &&, -"] Multi["Reject multi-constraint"] end PkgName --> Build Product -->|"fallback if packageName empty"| Build CollURL --> URLPattern Eco --> EcoMap EcoMap -->|"matched"| TypeDerivation URLPattern -->|"matched"| TypeDerivation HashFallback -->|"matched"| TypeDerivation GenericFallback -->|"no match"| TypeDerivation TypeDerivation --> NamespaceDerivation NamespaceDerivation --> Build Validate --> Build Build --> Scheme --> NS --> Name --> Version --> Quals --> Subpath

PURL Format

pkg:<type>[/<namespace>]/<name>[@<version>][?<qualifiers>][#<subpath>]
ComponentRequiredExample
pkg:yesFixed scheme prefix
typeyesnpm, pypi, maven, cargo, etc.
namespaceno@angular (npm), org.apache (maven)
nameyesexpress, django, log4j-core
versionno4.17.21, 3.2.1
qualifiersnoarch=x86_64, repository_id=rhel-8-baseos
subpathnolib/net45

Type Derivation

Type is derived through a priority chain:

flowchart TD Start["BuildPurl(config)"] CheckEco{"config.Ecosystem
non-empty?"} EcoLookup["ecosystemToPurlType map
normalise lowercase → lookup"] ValidType{"Valid PURL type?
[a-z][a-z0-9+.-]*"} UseEcoType["Use mapped/raw type"] CheckURL{"config.RepoURL
non-empty?"} URLMatch["registryURLPatterns
20+ regex matchers"] UseURLType["Use pattern-matched type"] CheckHash{"config.Hash
non-empty?"} UseGithub["type = 'github'"] UseGeneric["type = 'generic'"] Start --> CheckEco CheckEco -->|"yes"| EcoLookup EcoLookup -->|"found"| UseEcoType EcoLookup -->|"not found"| ValidType ValidType -->|"valid"| UseEcoType ValidType -->|"invalid"| CheckURL CheckEco -->|"no"| CheckURL CheckURL -->|"yes"| URLMatch URLMatch -->|"matched"| UseURLType URLMatch -->|"no match"| CheckHash CheckURL -->|"no"| CheckHash CheckHash -->|"yes"| UseGithub CheckHash -->|"no"| UseGeneric

Ecosystem-to-Type Mappings (50+ entries)

Ecosystem(s)PURL TypeExample PURL
npmnpmpkg:npm/express@4.18.2
pypi, pippypipkg:pypi/django@4.2
mavenmavenpkg:maven/org.apache.commons/commons-lang3@3.12
cargo, rustcargopkg:cargo/tokio@1.28
rubygems, gem, rubygempkg:gem/rails@7.0
nuget, dotnetnugetpkg:nuget/Newtonsoft.Json@13.0
composer, phpcomposerpkg:composer/symfony/http-foundation@6.3
go, golanggolangpkg:golang/github.com/gin-gonic/gin@1.9
hex, elixir, erlanghexpkg:hex/phoenix@1.7
pub, dart, flutterpubpkg:pub/flutter_bloc@8.1
hackage, haskellhackagepkg:hackage/aeson@2.1
cran, rcranpkg:cran/ggplot2@3.4
githubgithubpkg:github/vuetifyjs/vuetify@3.3
docker, ocidockerpkg:docker/nginx@1.24
deb, debian, ubuntudebpkg:deb/debian/openssl@3.0
rpm, fedora, rhel, centos, suse, slesrpmpkg:rpm/redhat/openssl@1.1.1k
apk, alpineapkpkg:apk/alpine/curl@8.1
cocoapods, ioscocoapodspkg:cocoapods/Alamofire@5.7
swift, swiftpmswiftpkg:swift/apple/swift-nio@2.55
conda, anacondacondapkg:conda/numpy@1.25
conanconanpkg:conan/boost@1.82
huggingfacehuggingfacepkg:huggingface/meta-llama/Llama-2-7b
mlflowmlflowpkg:mlflow/my-model@1.0
juliajuliapkg:julia/JSON@0.21
luarocks, lualuarockspkg:luarocks/luasocket@3.1
opam, ocamlopampkg:opam/core@0.16
cpan, perlcpanpkg:cpan/Mojolicious@9.33
yoctoyoctopkg:yocto/busybox@1.36
bitnamibitnamipkg:bitnami/redis@7.2
bazelbazelpkg:bazel/rules_go@0.41
qpkgqpkgpkg:qpkg/Entware@1.0
vscodevscode-extensionpkg:vscode-extension/ms-python/python@2023.14
alpm, archlinuxalpmpkg:alpm/core/linux@6.4
bitbucketbitbucketpkg:bitbucket/atlassian/aui@9.3
generic, unknowngenericpkg:generic/my-package@1.0

Registry URL Pattern Matching

When ecosystem is not provided, the collectionURL or RepoURL is matched against these regex patterns:

PatternPURL Type
npmjs.(org|com), registry.npmjsnpm
pypi.(org|python.org)pypi
maven.(apache.org|central), mvnrepository.commaven
crates.iocargo
rubygems.orggem
nuget.orgnuget
packagist.orgcomposer
pkg.go.dev, golang.orggolang
hex.pmhex
pub.devpub
hackage.haskell.orghackage
cran.r-project.orgcran
github.comgithub
hub.docker.com, gcr.io, ghcr.iodocker
bitbucket.(org|com)bitbucket
conda-forge.org, anaconda.orgconda
conan.ioconan
huggingface.cohuggingface
luarocks.orgluarocks
opam.ocaml.orgopam
metacpan.orgcpan
marketplace.visualstudio.comvscode-extension

Namespace Derivation

flowchart TD Type["PURL Type determined"] Maven{"maven?"} MavenNS["Package contains ':'?
group:artifact → namespace = group
else namespace = vendor"] Golang{"golang?"} GolangNS["Package contains '/'?
github.com/org/pkg → ns = github.com/org
else no namespace"] Docker{"docker?"} DockerNS["Package contains '/'?
org/image → namespace = org
else no namespace"] GH{"github/bitbucket?"} GHNS["RepoURL available?
parse path → namespace = owner
else namespace = vendor"] Composer{"composer?"} ComposerNS["Package contains '/'?
vendor/package → namespace = vendor
else namespace = vendor"] Dist{"deb/rpm/apk?"} DistNS["namespace = vendor
(redhat, debian, alpine, etc.)"] Other{"npm/pypi/cargo/gem/
nuget/hex/pub/hackage/
cran/cocoapods/swift/
conda/conan/etc."} NoNS["No namespace"] Type --> Maven Type --> Golang Type --> Docker Type --> GH Type --> Composer Type --> Dist Type --> Other Maven -->|"yes"| MavenNS Golang -->|"yes"| GolangNS Docker -->|"yes"| DockerNS GH -->|"yes"| GHNS Composer -->|"yes"| ComposerNS Dist -->|"yes"| DistNS Other -->|"yes"| NoNS

NPM Scoped Package Handling

NPM packages starting with @ use percent-encoded scope:

Input:  @angular/core
Output: pkg:npm/%40angular/core

Input:  @babel/parser@7.22.5
Output: pkg:npm/%40babel/parser@7.22.5

The @ is encoded as %40 per PURL spec, and the scope becomes the namespace with the / separator.

Version Validation

Versions must be concrete — ranges, wildcards, and operators are rejected:

flowchart TD Raw["Raw version string"] Unknown{"'unknown'?"} Wild{"Contains x, X, *?"} Range{"Starts with < > = ^ ~ !?"} Bool{"Contains || && -?"} Multi{"Multiple constraints?"} Strip["Strip operator prefix
(fallback from VersionWithOperator)"] Valid["Valid PURL version"] Invalid["Version omitted from PURL"] Raw --> Unknown Unknown -->|"yes"| Invalid Unknown -->|"no"| Wild Wild -->|"yes"| Invalid Wild -->|"no"| Range Range -->|"yes"| Strip Strip --> Valid Range -->|"no"| Bool Bool -->|"yes"| Invalid Bool -->|"no"| Multi Multi -->|"yes"| Invalid Multi -->|"no"| Valid
InputResultReason
4.17.21@4.17.21Valid concrete version
>=2.0.0stripped → 2.0.0Operator stripped via VersionWithOperator path
2.xomittedWildcard
^1.0.0omittedRange operator
>=1.0 <2.0omittedMulti-constraint
1.0 || 2.0omittedBoolean range
unknownomittedLiteral “unknown”

Red Hat PURL Specifics

Per Red Hat Security Data Guidelines:

  • RPM packages use redhat namespace: pkg:rpm/redhat/openssl@1.1.1k-7.el8_6
  • Add repository_id qualifier: ?repository_id=rhel-8-for-x86_64-baseos-rpms
  • Add arch qualifier from platforms data: ?arch=x86_64
  • OCI images use unique tags (not “latest”): pkg:oci/ubi8@sha256:abc...
  • RPM modules use rpmmod qualifier: ?rpmmod=Stream:Version:Context

Cloud Service PURLs

When cloud services are detected from titles/descriptions and no package ecosystem exists:

pkg:generic/AWS/CloudWatch
pkg:generic/AWS/EC2
pkg:generic/Azure/Key%20Vault
pkg:generic/GCP/Cloud%20SQL

These use the generic type with the cloud provider as namespace and service name as package name.

Generation Contexts

ContextHandlerInput Fields
/v2/vuln/{id}/affectedv2_affected.gopackageName, vendor, collectionURL
/v1/vuln/{id}vuln.gopackageName, vendor, product, ecosystem, collectionURL
/v1/packages/searchpackage_search.gopackageName, vendor, product
/v2/cloud-locatorsv2_cloud_locators.govendor, product (from query params)
Synthesized entriesv2_affected.goservice name from title extraction

Fallback Chain

flowchart TD Start["BuildPurl(config)"] PkgName{"config.PackageName
non-empty?"} UsePkg["name = PackageName"] Product{"config.Product
non-empty?"} UseProd["name = Product"] Empty["return '' (empty)"] GenericFallback["fmt.Sprintf('pkg:generic/%s',
url.PathEscape(pkgId))"] Start --> PkgName PkgName -->|"yes"| UsePkg PkgName -->|"no"| Product Product -->|"yes"| UseProd Product -->|"no"| Empty Empty -->|"in handler"| GenericFallback

When BuildPurl() returns an empty string, the handler falls back to a pkg:generic/{name} PURL to ensure every affected entry has some form of package identifier.

Key Files

FileProjectPurpose
internal/shared/purl.govdb-apiBuildPurl, ecosystemToPurlType, registryURLPatterns, namespace derivation
internal/shared/purl_parser.govdb-apiPURL string parsing
internal/handler/v2_affected.govdb-apiPURL generation per affected entry
internal/handler/vuln.govdb-apix_purls array in VVD ADP container
internal/purl/purl.gocliCLI-side PURL parser with 37 ecosystem mappings
src/composables/useVdbApi.tswebsiteClient-side PURL parsing for dispatch
src/components/vdb-console/PurlBadge.vuewebsitePURL display component
src/shared/packages.tsvdb-managerEcosystem definitions (28 languages, 50+ package managers)
src/shared/ecosystem-helpers.tsvdb-managerEcosystem detection from CVE data, collectionURL, references