Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ObjectFreeze,
StringPrototypeCharCodeAt,
StringPrototypeSlice,
StringPrototypeToLowerCase,
Expand Down Expand Up @@ -46,4 +47,37 @@ class PurlError extends Error {
}
}

export { formatPurlErrorMessage, PurlError }
/**
* Specialized error for injection character detection.
* Developers can catch this specifically to distinguish injection rejections
* from other PURL validation errors and handle them at an elevated level
* (e.g., logging, alerting, blocking).
*
* Properties:
* - `component` — which PURL component was rejected ("name", "namespace")
* - `charCode` — the character code of the injection character found
* - `purlType` — the package type (e.g., "npm", "maven")
*/
class PurlInjectionError extends PurlError {
readonly charCode: number
readonly component: string
readonly purlType: string

constructor(
purlType: string,
component: string,
charCode: number,
charLabel: string,
) {
super(
`${purlType} "${component}" component contains injection character ${charLabel}`,
)
this.charCode = charCode
this.component = component
this.purlType = purlType
ObjectFreeze(this)
}
}
ObjectFreeze(PurlInjectionError.prototype)

export { formatPurlErrorMessage, PurlError, PurlInjectionError }
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export {
UrlConverter,
} from './package-url.js'
export { PurlBuilder } from './package-url-builder.js'
export { PurlError } from './error.js'
export { PurlError, PurlInjectionError } from './error.js'
// ============================================================================
// Modular Utilities
// ============================================================================
Expand All @@ -96,7 +96,11 @@ export { parseNpmSpecifier } from './purl-types/npm.js'
// separate entry point: import { npmExists } from '@socketregistry/packageurl-js/exists'
// This keeps the core bundle lean (~200 KB vs 3.3 MB with HTTP deps).
export type { ExistsOptions, ExistsResult } from './purl-types/npm.js'
export { containsInjectionCharacters } from './strings.js'
export {
containsInjectionCharacters,
findInjectionCharCode,
formatInjectionChar,
} from './strings.js'
export { stringify, stringifySpec } from './stringify.js'
export { Vers } from './vers.js'
export type { VersComparator, VersConstraint, VersWildcard } from './vers.js'
Expand Down
3 changes: 2 additions & 1 deletion src/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { isObject } from './objects.js'
import {
ObjectCreate,
ObjectEntries,
ObjectFreeze,
ReflectApply,
StringPrototypeCharCodeAt,
Expand Down Expand Up @@ -152,7 +153,7 @@ function qualifiersToEntries(
? (ReflectApply(entriesProperty, rawQualifiersObj, []) as Iterable<
[string, string]
>)
: (Object.entries(rawQualifiers as Record<string, string>) as Iterable<
: (ObjectEntries(rawQualifiers as Record<string, string>) as Iterable<
[string, string]
>)
}
Expand Down
8 changes: 8 additions & 0 deletions src/primordials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,15 @@ const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor
const ReflectOwnKeys = Reflect.ownKeys
const ReflectSetPrototypeOf = Reflect.setPrototypeOf

// ─── Number ───────────────────────────────────────────────────────────
const NumberPrototypeToString = uncurryThis(Number.prototype.toString)

// ─── RegExp ────────────────────────────────────────────────────────────
const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec)
const RegExpPrototypeTest = uncurryThis(RegExp.prototype.test)

// ─── String ────────────────────────────────────────────────────────────
const StringFromCharCode = String.fromCharCode
const StringPrototypeCharCodeAt = uncurryThis(String.prototype.charCodeAt)
const StringPrototypeEndsWith = uncurryThis(String.prototype.endsWith)
const StringPrototypeIncludes = uncurryThis(String.prototype.includes)
Expand All @@ -98,6 +102,7 @@ const StringPrototypeReplaceAll = uncurryThis(
replaceValue: string,
) => string,
)
const StringPrototypePadStart = uncurryThis(String.prototype.padStart)
const StringPrototypeSlice = uncurryThis(String.prototype.slice)
const StringPrototypeSplit = uncurryThis(String.prototype.split)
const StringPrototypeStartsWith = uncurryThis(String.prototype.startsWith)
Expand Down Expand Up @@ -125,6 +130,7 @@ export {
MapCtor,
ObjectCreate,
ObjectEntries,
NumberPrototypeToString,
ObjectFreeze,
ObjectIsFrozen,
ObjectKeys,
Expand All @@ -137,13 +143,15 @@ export {
RegExpPrototypeExec,
RegExpPrototypeTest,
SetCtor,
StringFromCharCode,
StringPrototypeCharCodeAt,
StringPrototypeEndsWith,
StringPrototypeIncludes,
StringPrototypeIndexOf,
StringPrototypeLastIndexOf,
StringPrototypeReplace,
StringPrototypeReplaceAll,
StringPrototypePadStart,
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
Expand Down
72 changes: 65 additions & 7 deletions src/purl-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@
* module in the purl-types/ directory with specific rules for namespace, name, version
* normalization and validation.
*/
import { PurlInjectionError } from './error.js'
import { createHelpersNamespaceObject } from './helpers.js'
import { findInjectionCharCode, formatInjectionChar } from './strings.js'
import { normalize as alpmNormalize } from './purl-types/alpm.js'
import { normalize as apkNormalize } from './purl-types/apk.js'
import {
normalize as bazelNormalize,
validate as bazelValidate,
} from './purl-types/bazel.js'
import { normalize as bitbucketNormalize } from './purl-types/bitbucket.js'
import {
normalize as bitbucketNormalize,
validate as bitbucketValidate,
} from './purl-types/bitbucket.js'
import { normalize as bitnamiNormalize } from './purl-types/bitnami.js'
import { validate as cargoValidate } from './purl-types/cargo.js'
import { validate as cocoaodsValidate } from './purl-types/cocoapods.js'
Expand All @@ -26,13 +31,25 @@ import {
import { validate as cpanValidate } from './purl-types/cpan.js'
import { validate as cranValidate } from './purl-types/cran.js'
import { normalize as debNormalize } from './purl-types/deb.js'
import { normalize as dockerNormalize } from './purl-types/docker.js'
import {
normalize as dockerNormalize,
validate as dockerValidate,
} from './purl-types/docker.js'
import { validate as gemValidate } from './purl-types/gem.js'
import { normalize as genericNormalize } from './purl-types/generic.js'
import { normalize as githubNormalize } from './purl-types/github.js'
import { normalize as gitlabNormalize } from './purl-types/gitlab.js'
import {
normalize as githubNormalize,
validate as githubValidate,
} from './purl-types/github.js'
import {
normalize as gitlabNormalize,
validate as gitlabValidate,
} from './purl-types/gitlab.js'
import { validate as golangValidate } from './purl-types/golang.js'
import { normalize as hexNormalize } from './purl-types/hex.js'
import {
normalize as hexNormalize,
validate as hexValidate,
} from './purl-types/hex.js'
import { normalize as huggingfaceNormalize } from './purl-types/huggingface.js'
import {
normalize as juliaNormalize,
Expand Down Expand Up @@ -62,7 +79,10 @@ import {
normalize as pubNormalize,
validate as pubValidate,
} from './purl-types/pub.js'
import { normalize as pypiNormalize } from './purl-types/pypi.js'
import {
normalize as pypiNormalize,
validate as pypiValidate,
} from './purl-types/pypi.js'
import { normalize as qpkgNormalize } from './purl-types/qpkg.js'
import { normalize as rpmNormalize } from './purl-types/rpm.js'
import { normalize as socketNormalize } from './purl-types/socket.js'
Expand Down Expand Up @@ -94,8 +114,40 @@ const PurlTypNormalizer = (purl: PurlObject): PurlObject => purl

/**
* Default validator for PURL types without specific validation rules.
* Rejects injection characters in name and namespace components.
* This ensures all types (including newly added ones) get injection
* protection by default — security is opt-out, not opt-in.
*/
const PurlTypeValidator = (_purl: PurlObject, _throws: boolean): boolean => true
function PurlTypeValidator(purl: PurlObject, throws: boolean): boolean {
const type = purl.type ?? 'unknown'
if (typeof purl.namespace === 'string') {
const nsCode = findInjectionCharCode(purl.namespace)
if (nsCode !== -1) {
if (throws) {
throw new PurlInjectionError(
type,
'namespace',
nsCode,
formatInjectionChar(nsCode),
)
}
return false
}
}
const nameCode = findInjectionCharCode(purl.name)
if (nameCode !== -1) {
if (throws) {
throw new PurlInjectionError(
type,
'name',
nameCode,
formatInjectionChar(nameCode),
)
}
return false
}
return true
}

// PURL types:
// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst
Expand Down Expand Up @@ -133,14 +185,19 @@ const PurlType = createHelpersNamespaceObject(
},
validate: {
bazel: bazelValidate,
bitbucket: bitbucketValidate,
cargo: cargoValidate,
cocoapods: cocoaodsValidate,
conda: condaValidate,
conan: conanValidate,
cpan: cpanValidate,
cran: cranValidate,
docker: dockerValidate,
gem: gemValidate,
github: githubValidate,
gitlab: gitlabValidate,
golang: golangValidate,
hex: hexValidate,
julia: juliaValidate,
maven: mavenValidate,
mlflow: mlflowValidate,
Expand All @@ -150,6 +207,7 @@ const PurlType = createHelpersNamespaceObject(
opam: opamValidate,
otp: otpValidate,
pub: pubValidate,
pypi: pypiValidate,
swift: swiftValidate,
swid: swidValidate,
'vscode-extension': vscodeExtensionValidate,
Expand Down
7 changes: 6 additions & 1 deletion src/purl-types/bazel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { PurlError } from '../error.js'
import { lowerName } from '../strings.js'
import { validateNoInjectionByType } from '../validate.js'

interface PurlObject {
name: string
Expand All @@ -29,7 +30,8 @@ export function normalize(purl: PurlObject): PurlObject {

/**
* Validate Bazel package URL.
* Bazel packages must have a version (for reproducible builds).
* Bazel packages must have a version (for reproducible builds). Name must not
* contain injection characters.
*/
export function validate(purl: PurlObject, throws: boolean): boolean {
if (!purl.version || purl.version.length === 0) {
Expand All @@ -38,5 +40,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean {
}
return false
}
if (!validateNoInjectionByType('bazel', 'name', purl.name, throws)) {
return false
}
return true
}
19 changes: 18 additions & 1 deletion src/purl-types/bitbucket.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* @fileoverview Bitbucket PURL normalization.
* @fileoverview Bitbucket PURL normalization and validation.
* https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket
*/

import { lowerName, lowerNamespace } from '../strings.js'
import { validateNoInjectionByType } from '../validate.js'

interface PurlObject {
name: string
Expand All @@ -23,3 +24,19 @@ export function normalize(purl: PurlObject): PurlObject {
lowerName(purl)
return purl
}

/**
* Validate Bitbucket package URL.
* Name and namespace must not contain injection characters.
*/
export function validate(purl: PurlObject, throws: boolean): boolean {
if (
!validateNoInjectionByType('bitbucket', 'namespace', purl.namespace, throws)
) {
return false
}
if (!validateNoInjectionByType('bitbucket', 'name', purl.name, throws)) {
return false
}
return true
}
18 changes: 13 additions & 5 deletions src/purl-types/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { httpJson } from '@socketsecurity/lib/http-request'

import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js'
import { validateEmptyByType } from '../validate.js'
import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'

import type { ExistsResult, ExistsOptions } from './npm.js'

Expand Down Expand Up @@ -137,10 +137,18 @@ export async function cargoExists(

/**
* Validate Cargo package URL.
* Cargo packages must not have a namespace.
* Cargo packages must not have a namespace. Name must not contain injection characters.
*/
export function validate(purl: PurlObject, throws: boolean): boolean {
return validateEmptyByType('cargo', 'namespace', purl.namespace, {
throws,
})
if (
!validateEmptyByType('cargo', 'namespace', purl.namespace, {
throws,
})
) {
return false
}
if (!validateNoInjectionByType('cargo', 'name', purl.name, throws)) {
return false
}
return true
}
14 changes: 5 additions & 9 deletions src/purl-types/cocoapods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { httpJson } from '@socketsecurity/lib/http-request'

import { PurlError } from '../error.js'
import {
RegExpPrototypeTest,
StringPrototypeCharCodeAt,
StringPrototypeIncludes,
ArrayPrototypeSome,
} from '../primordials.js'
import { validateNoInjectionByType } from '../validate.js'

import type { ExistsResult, ExistsOptions } from './npm.js'

Expand Down Expand Up @@ -125,17 +125,13 @@ export async function cocoapodsExists(

/**
* Validate CocoaPods package URL.
* Name cannot contain whitespace, plus (+) character, or begin with a period (.).
* Name cannot contain injection or whitespace characters, plus (+) character,
* or begin with a period (.).
*/
export function validate(purl: PurlObject, throws: boolean): boolean {
const { name } = purl
// Name cannot contain whitespace
if (RegExpPrototypeTest(/\s/, name)) {
if (throws) {
throw new PurlError(
'cocoapods "name" component cannot contain whitespace',
)
}
// Name must not contain injection characters
if (!validateNoInjectionByType('cocoapods', 'name', name, throws)) {
return false
}
// Name cannot contain a plus (+) character
Expand Down
Loading
Loading