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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A comprehensive TypeScript library for capturing, storing, and appending UTM tra

- **Capture** UTM parameters from URLs
- **Sanitize** parameter values to prevent XSS and injection
- **PII filtering** to detect and reject/redact email addresses, phone numbers, and other PII
- **Store** in sessionStorage for the browser session
- **Append** UTM parameters to share URLs
- **Configurable** key format (snake_case or camelCase)
Expand Down Expand Up @@ -248,6 +249,46 @@ const params = captureUtmParameters(url, {
});
```

### PII Filtering

Detect and filter personally identifiable information (email addresses, phone numbers) from UTM parameter values. Prevents PII from leaking into analytics via misconfigured tracking links. Disabled by default.

```typescript
import { captureUtmParameters } from '@jackmisner/utm-toolkit';

// Reject mode (default) — discard values containing PII
const params = captureUtmParameters('https://example.com?utm_source=john@example.com&utm_medium=cpc', {
piiFiltering: { enabled: true },
});
// { utm_medium: 'cpc' } — utm_source was rejected

// Redact mode — replace PII values with [REDACTED]
const params = captureUtmParameters('https://example.com?utm_source=john@example.com&utm_medium=cpc', {
piiFiltering: { enabled: true, mode: 'redact' },
});
// { utm_source: '[REDACTED]', utm_medium: 'cpc' }

// Strict allowlist — only accept values matching a pattern
const params = captureUtmParameters(url, {
piiFiltering: {
enabled: true,
allowlistPattern: /^[a-z0-9_-]+$/, // Only lowercase alphanumeric, hyphens, underscores
},
});

// Callback for logging PII detections
const params = captureUtmParameters(url, {
piiFiltering: {
enabled: true,
onPiiDetected: (param, value, patternName) => {
console.warn(`PII detected in ${param}: matched ${patternName}`);
},
},
});
```

Built-in PII patterns detect: email addresses, international phone numbers, UK phone numbers, and US phone numbers.

### Configuration

```typescript
Expand Down Expand Up @@ -332,6 +373,7 @@ installDebugHelpers();
| `shareContextParams` | `object` | `{}` | Platform-specific params |
| `excludeFromShares` | `string[]` | `[]` | Params to exclude from shares |
| `sanitize` | `SanitizeConfig` | `{ enabled: false }` | Value sanitization settings |
| `piiFiltering` | `PiiFilterConfig` | `{ enabled: false }` | PII detection and filtering |

## TypeScript Types

Expand All @@ -340,6 +382,8 @@ import type {
UtmParameters,
UtmConfig,
SanitizeConfig,
PiiFilterConfig,
PiiPattern,
SharePlatform,
UseUtmTrackingReturn,
} from '@jackmisner/utm-toolkit';
Expand Down
103 changes: 103 additions & 0 deletions __tests__/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,109 @@ describe('sanitize config', () => {
})
})

describe('piiFiltering config', () => {
it('createConfig includes default piiFiltering', () => {
const config = createConfig()
expect(config.piiFiltering).toBeDefined()
expect(config.piiFiltering.enabled).toBe(false)
expect(config.piiFiltering.mode).toBe('reject')
expect(config.piiFiltering.patterns.length).toBeGreaterThan(0)
})

it('createConfig merges partial piiFiltering override', () => {
const config = createConfig({ piiFiltering: { enabled: true } })
expect(config.piiFiltering.enabled).toBe(true)
expect(config.piiFiltering.mode).toBe('reject')
})

it('createConfig preserves custom patterns', () => {
const customPatterns = [{ name: 'custom', pattern: /test/, enabled: true }]
const config = createConfig({ piiFiltering: { patterns: customPatterns } })
expect(config.piiFiltering.patterns).toEqual(customPatterns)
})

it('mergeConfig merges piiFiltering', () => {
const base = createConfig()
const merged = mergeConfig(base, { piiFiltering: { enabled: true, mode: 'redact' } })
expect(merged.piiFiltering.enabled).toBe(true)
expect(merged.piiFiltering.mode).toBe('redact')
expect(merged.piiFiltering.patterns).toEqual(base.piiFiltering.patterns)
})

it('validateConfig validates piiFiltering.enabled is boolean', () => {
const errors = validateConfig({ piiFiltering: { enabled: 'yes' } })
expect(errors).toContain('piiFiltering.enabled must be a boolean')
})

it('validateConfig validates piiFiltering.mode is valid', () => {
const errors = validateConfig({ piiFiltering: { mode: 'delete' } })
expect(errors).toContain('piiFiltering.mode must be "reject" or "redact"')
})

it('validateConfig validates piiFiltering.patterns is an array', () => {
const errors = validateConfig({ piiFiltering: { patterns: 'not an array' } })
expect(errors).toContain('piiFiltering.patterns must be an array')
})

it('validateConfig validates piiFiltering is an object', () => {
const errors = validateConfig({ piiFiltering: 'not an object' })
expect(errors).toContain('piiFiltering must be an object')
})

it('validateConfig validates individual pattern objects', () => {
const errors = validateConfig({
piiFiltering: {
patterns: [{ name: 123, pattern: 'not a regex', enabled: 'yes' }],
},
})
expect(errors).toContain('piiFiltering.patterns[0].name must be a string')
expect(errors).toContain('piiFiltering.patterns[0].pattern must be a RegExp')
expect(errors).toContain('piiFiltering.patterns[0].enabled must be a boolean')
})

it('validateConfig validates non-object pattern entries', () => {
const errors = validateConfig({
piiFiltering: { patterns: ['not an object'] },
})
expect(errors).toContain('piiFiltering.patterns[0] must be an object')
})

it('validateConfig validates allowlistPattern is a RegExp', () => {
const errors = validateConfig({
piiFiltering: { allowlistPattern: 'not a regex' },
})
expect(errors).toContain('piiFiltering.allowlistPattern must be a RegExp')
})

it('validateConfig accepts valid allowlistPattern RegExp', () => {
const errors = validateConfig({
piiFiltering: { allowlistPattern: /^[a-z]+$/ },
})
expect(errors).toEqual([])
})

it('validateConfig validates onPiiDetected is a function', () => {
const errors = validateConfig({
piiFiltering: { onPiiDetected: 'not a function' },
})
expect(errors).toContain('piiFiltering.onPiiDetected must be a function')
})

it('validateConfig accepts valid onPiiDetected function', () => {
const errors = validateConfig({
piiFiltering: { onPiiDetected: () => {} },
})
expect(errors).toEqual([])
})

it('validateConfig accepts valid piiFiltering config', () => {
const errors = validateConfig({
piiFiltering: { enabled: true, mode: 'redact' },
})
expect(errors).toEqual([])
})
})

describe('getDefaultConfig', () => {
it('returns a copy of default config', () => {
const config1 = getDefaultConfig()
Expand Down
56 changes: 56 additions & 0 deletions __tests__/core/capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,62 @@ describe('sanitization integration', () => {
})
})

describe('PII filtering integration', () => {
const piiFilterConfig = {
enabled: true,
mode: 'reject' as const,
patterns: [
{
name: 'email',
pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
enabled: true,
},
],
}

it('rejects PII values when piiFiltering is enabled', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=john@example.com&utm_medium=email',
{ piiFiltering: piiFilterConfig },
)
expect(result).not.toHaveProperty('utm_source')
expect(result.utm_medium).toBe('email')
})

it('does not filter when piiFiltering is not provided', () => {
const result = captureUtmParameters('https://example.com?utm_source=john@example.com')
expect(result.utm_source).toBe('john@example.com')
})

it('does not filter when piiFiltering.enabled is false', () => {
const result = captureUtmParameters('https://example.com?utm_source=john@example.com', {
piiFiltering: { ...piiFilterConfig, enabled: false },
})
expect(result.utm_source).toBe('john@example.com')
})

it('works with camelCase key format', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=john@example.com&utm_medium=cpc',
{ keyFormat: 'camelCase', piiFiltering: piiFilterConfig },
)
expect(result).not.toHaveProperty('utmSource')
expect(result.utmMedium).toBe('cpc')
})

it('applies PII filter after sanitization', () => {
const result = captureUtmParameters(
'https://example.com?utm_source=john@example.com&utm_campaign=spring-2025',
{
sanitize: { enabled: true },
piiFiltering: piiFilterConfig,
},
)
expect(result).not.toHaveProperty('utm_source')
expect(result.utm_campaign).toBe('spring-2025')
})
})

describe('captureFromCurrentUrl', () => {
beforeEach(() => {
vi.stubGlobal('location', {
Expand Down
Loading