Initial Commit
This commit is contained in:
116
packages/security-stack/security/security-codes/README.md
Normal file
116
packages/security-stack/security/security-codes/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# @suaveplan/security-codes - Detect and sanitise sensitive security-code leakage in strings: API keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks, AWS access keys, and more
|
||||
|
||||
## Summary
|
||||
|
||||
Detect and sanitise sensitive security-code leakage in strings. Handles API
|
||||
keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks,
|
||||
AWS access keys, OpenAI keys, Google API keys, and high-entropy heuristics.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [security-codes-error](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [aws](./src/patterns/aws.md) — Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs are 20-character alphanumeric identifiers…
|
||||
- [credit-card](./src/patterns/credit-card.md) — Detects credit card numbers embedded in arbitrary strings using a combination of regex matching and the Luhn checksum a…
|
||||
- [entropy](./src/patterns/entropy.md) — Detects high-entropy strings that are likely secret tokens, API keys, or passwords using Shannon entropy analysis. This…
|
||||
- [github](./src/patterns/github.md) — Detects GitHub Personal Access Tokens (PATs) — both classic tokens (`ghp_…`) and the newer fine-grained tokens (`github…
|
||||
- [google](./src/patterns/google.md) — Detects Google API keys embedded in arbitrary strings. Google API keys begin with the well-known prefix `AIza` and are…
|
||||
- [jwt](./src/patterns/jwt.md) — Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are bearer tokens with a well-defined three-part bas…
|
||||
- [openai](./src/patterns/openai.md) — Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the format `sk-<48 chars>` for standard keys and `…
|
||||
- [pem](./src/patterns/pem.md) — Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail) is the standard encoding for TLS certifica…
|
||||
- [ssh](./src/patterns/ssh.md) — Detects SSH private keys and public keys embedded in arbitrary strings. SSH private keys grant direct shell access to a…
|
||||
- [stripe](./src/patterns/stripe.md) — Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and publishable keys (`pk_live_` / `pk_test_`) — e…
|
||||
- [types](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [security-codes](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [luhn](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes-error](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [aws](./src/patterns/aws.md) — Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs are 20-character alphanumeric identifiers…
|
||||
- [credit-card](./src/patterns/credit-card.md) — Detects credit card numbers embedded in arbitrary strings using a combination of regex matching and the Luhn checksum a…
|
||||
- [entropy](./src/patterns/entropy.md) — Detects high-entropy strings that are likely secret tokens, API keys, or passwords using Shannon entropy analysis. This…
|
||||
- [github](./src/patterns/github.md) — Detects GitHub Personal Access Tokens (PATs) — both classic tokens (`ghp_…`) and the newer fine-grained tokens (`github…
|
||||
- [google](./src/patterns/google.md) — Detects Google API keys embedded in arbitrary strings. Google API keys begin with the well-known prefix `AIza` and are…
|
||||
- [jwt](./src/patterns/jwt.md) — Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are bearer tokens with a well-defined three-part bas…
|
||||
- [openai](./src/patterns/openai.md) — Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the format `sk-<48 chars>` for standard keys and `…
|
||||
- [pem](./src/patterns/pem.md) — Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail) is the standard encoding for TLS certifica…
|
||||
- [ssh](./src/patterns/ssh.md) — Detects SSH private keys and public keys embedded in arbitrary strings. SSH private keys grant direct shell access to a…
|
||||
- [stripe](./src/patterns/stripe.md) — Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and publishable keys (`pk_live_` / `pk_test_`) — e…
|
||||
- [types](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [luhn](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes-error](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [aws](./src/patterns/aws.md) — Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs are 20-character alphanumeric identifiers…
|
||||
- [credit-card](./src/patterns/credit-card.md) — Detects credit card numbers embedded in arbitrary strings using a combination of regex matching and the Luhn checksum a…
|
||||
- [entropy](./src/patterns/entropy.md) — Detects high-entropy strings that are likely secret tokens, API keys, or passwords using Shannon entropy analysis. This…
|
||||
- [github](./src/patterns/github.md) — Detects GitHub Personal Access Tokens (PATs) — both classic tokens (`ghp_…`) and the newer fine-grained tokens (`github…
|
||||
- [google](./src/patterns/google.md) — Detects Google API keys embedded in arbitrary strings. Google API keys begin with the well-known prefix `AIza` and are…
|
||||
- [jwt](./src/patterns/jwt.md) — Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are bearer tokens with a well-defined three-part bas…
|
||||
- [openai](./src/patterns/openai.md) — Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the format `sk-<48 chars>` for standard keys and `…
|
||||
- [pem](./src/patterns/pem.md) — Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail) is the standard encoding for TLS certifica…
|
||||
- [ssh](./src/patterns/ssh.md) — Detects SSH private keys and public keys embedded in arbitrary strings. SSH private keys grant direct shell access to a…
|
||||
- [stripe](./src/patterns/stripe.md) — Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and publishable keys (`pk_live_` / `pk_test_`) — e…
|
||||
- [types](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [luhn](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes-error](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [aws](./src/patterns/aws.md) — Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs are 20-character alphanumeric identifiers…
|
||||
- [credit-card](./src/patterns/credit-card.md) — Detects credit card numbers embedded in arbitrary strings using a combination of regex matching and the Luhn checksum a…
|
||||
- [entropy](./src/patterns/entropy.md) — Detects high-entropy strings that are likely secret tokens, API keys, or passwords using Shannon entropy analysis. This…
|
||||
- [github](./src/patterns/github.md) — Detects GitHub Personal Access Tokens (PATs) — both classic tokens (`ghp_…`) and the newer fine-grained tokens (`github…
|
||||
- [google](./src/patterns/google.md) — Detects Google API keys embedded in arbitrary strings. Google API keys begin with the well-known prefix `AIza` and are…
|
||||
- [jwt](./src/patterns/jwt.md) — Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are bearer tokens with a well-defined three-part bas…
|
||||
- [openai](./src/patterns/openai.md) — Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the format `sk-<48 chars>` for standard keys and `…
|
||||
- [pem](./src/patterns/pem.md) — Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail) is the standard encoding for TLS certifica…
|
||||
- [ssh](./src/patterns/ssh.md) — Detects SSH private keys and public keys embedded in arbitrary strings. SSH private keys grant direct shell access to a…
|
||||
- [stripe](./src/patterns/stripe.md) — Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and publishable keys (`pk_live_` / `pk_test_`) — e…
|
||||
- [types](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [security-codes](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [luhn](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-detector](./src/core/code-detector/code-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [code-sanitizer](./src/core/code-sanitizer/code-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [security-codes-error](./src/errors/security-codes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [aws](./src/patterns/aws.md) — Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs are 20-character alphanumeric identifiers…
|
||||
- [credit-card](./src/patterns/credit-card.md) — Detects credit card numbers embedded in arbitrary strings using a combination of regex matching and the Luhn checksum a…
|
||||
- [entropy](./src/patterns/entropy.md) — Detects high-entropy strings that are likely secret tokens, API keys, or passwords using Shannon entropy analysis. This…
|
||||
- [github](./src/patterns/github.md) — Detects GitHub Personal Access Tokens (PATs) — both classic tokens (`ghp_…`) and the newer fine-grained tokens (`github…
|
||||
- [google](./src/patterns/google.md) — Detects Google API keys embedded in arbitrary strings. Google API keys begin with the well-known prefix `AIza` and are…
|
||||
- [jwt](./src/patterns/jwt.md) — Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are bearer tokens with a well-defined three-part bas…
|
||||
- [openai](./src/patterns/openai.md) — Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the format `sk-<48 chars>` for standard keys and `…
|
||||
- [pem](./src/patterns/pem.md) — Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail) is the standard encoding for TLS certifica…
|
||||
- [ssh](./src/patterns/ssh.md) — Detects SSH private keys and public keys embedded in arbitrary strings. SSH private keys grant direct shell access to a…
|
||||
- [stripe](./src/patterns/stripe.md) — Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and publishable keys (`pk_live_` / `pk_test_`) — e…
|
||||
- [types](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [security-codes](./src/types/security-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_ - [luhn](./src/utils/luhn.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Features
|
||||
|
||||
- **10 built-in pattern detectors** covering the most common secret types
|
||||
- **Pluggable detector API** — register custom patterns at runtime
|
||||
- **4 masking strategies** — full-redact, partial, hash, tokenize
|
||||
- **Overlap deduplication** — higher-confidence matches win
|
||||
- **OTel metrics** — `security_codes.detect.total` and `security_codes.detect.duration_ms`
|
||||
- __Zero @suaveplan/_ runtime dependencies_* — leaf package, no circular risk
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/security-codes
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { SecurityCodeDetector, SecurityCodeSanitizer } from '@suaveplan/security-codes';
|
||||
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect('aws_key=AKIAIOSFODNN7EXAMPLE');
|
||||
|
||||
const sanitizer = new SecurityCodeSanitizer();
|
||||
const { output } = sanitizer.sanitize('aws_key=AKIAIOSFODNN7EXAMPLE');
|
||||
// output === 'aws_key=[REDACTED]'
|
||||
```
|
||||
|
||||
## Module Docs
|
||||
|
||||
- [Types](src/types/security-codes.md)
|
||||
- [Luhn utility](src/utils/luhn.md)
|
||||
- [AWS pattern](src/patterns/aws.md)
|
||||
- [JWT pattern](src/patterns/jwt.md)
|
||||
- [Stripe pattern](src/patterns/stripe.md)
|
||||
- [GitHub pattern](src/patterns/github.md)
|
||||
- [Google pattern](src/patterns/google.md)
|
||||
- [OpenAI pattern](src/patterns/openai.md)
|
||||
- [Credit Card pattern](src/patterns/credit-card.md)
|
||||
- [PEM pattern](src/patterns/pem.md)
|
||||
- [SSH pattern](src/patterns/ssh.md)
|
||||
- [Entropy pattern](src/patterns/entropy.md)
|
||||
- [SecurityCodeDetector](src/core/code-detector/code-detector.md)
|
||||
- [SecurityCodeSanitizer](src/core/code-sanitizer/code-sanitizer.md)
|
||||
92
packages/security-stack/security/security-codes/package.json
Normal file
92
packages/security-stack/security/security-codes/package.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@suaveplan/security-codes",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Detect and sanitise sensitive security-code leakage in strings: API keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks, AWS access keys, and more",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./patterns": {
|
||||
"import": {
|
||||
"types": "./dist/patterns/index.d.ts",
|
||||
"default": "./dist/patterns/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/patterns/index.d.ts",
|
||||
"default": "./dist/patterns/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/security-codes-error.d.ts",
|
||||
"default": "./dist/errors/security-codes-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/security-codes-error.d.ts",
|
||||
"default": "./dist/errors/security-codes-error.js"
|
||||
}
|
||||
},
|
||||
"./core/code-detector/code-detector": {
|
||||
"import": {
|
||||
"types": "./dist/core/code-detector/code-detector.d.ts",
|
||||
"default": "./dist/core/code-detector/code-detector.js"
|
||||
}
|
||||
},
|
||||
"./core/code-sanitizer/code-sanitizer": {
|
||||
"import": {
|
||||
"types": "./dist/core/code-sanitizer/code-sanitizer.d.ts",
|
||||
"default": "./dist/core/code-sanitizer/code-sanitizer.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# code-detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SecurityCodeDetector` is the central orchestrator for secret detection in
|
||||
`@suaveplan/security-codes`. It aggregates all built-in pattern modules,
|
||||
allows registration of custom pattern functions at runtime, deduplicates
|
||||
overlapping matches, and emits OpenTelemetry metrics for observability.
|
||||
|
||||
## Features
|
||||
|
||||
- All ten built-in detectors (AWS, JWT, Stripe, GitHub, Google, OpenAI,
|
||||
credit card, PEM, SSH, entropy) are registered automatically at construction
|
||||
time.
|
||||
- `registerPattern()` accepts custom detectors for any `SecurityCodeType`,
|
||||
enabling application-specific secret patterns to be added without forking
|
||||
the library.
|
||||
- Overlapping matches are resolved by retaining the match with the higher
|
||||
confidence score; ties keep the earlier-starting match.
|
||||
- `DetectorOptions.types` restricts which detectors run, reducing scan time
|
||||
for pipelines that only care about specific secret types.
|
||||
- `DetectorOptions.minConfidence` post-filters results below a caller-chosen
|
||||
threshold.
|
||||
- `DetectorOptions.maxMatches` caps the number of returned results.
|
||||
- OTel counter `security_codes.detect.total` and histogram
|
||||
`security_codes.detect.duration_ms` provide production observability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodeDetector } from '@suaveplan/security-codes';
|
||||
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect('aws_access_key=AKIAIOSFODNN7EXAMPLE');
|
||||
// matches[0].type === 'aws-access-key'
|
||||
// matches[0].confidence === 0.95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodeDetector } from '@suaveplan/security-codes';
|
||||
import type { SecurityCodeMatch } from '@suaveplan/security-codes';
|
||||
|
||||
const detector = new SecurityCodeDetector();
|
||||
|
||||
// Register a custom detector for internal API keys
|
||||
detector.registerPattern('high-entropy', (input: string): SecurityCodeMatch[] => {
|
||||
const re = /\bINT-[A-Z0-9]{32}\b/g;
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(input)) !== null) {
|
||||
matches.push({ type: 'high-entropy', value: m[0], start: m.index, end: m.index + m[0].length, confidence: 0.99 });
|
||||
}
|
||||
return matches;
|
||||
});
|
||||
|
||||
// Detect only JWTs and credit cards with high confidence
|
||||
const results = detector.detect(logLine, {
|
||||
types: ['jwt', 'credit-card'],
|
||||
minConfidence: 0.85,
|
||||
maxMatches: 10,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SecurityCodeDetector()`
|
||||
|
||||
Constructs a detector with all built-in patterns pre-registered.
|
||||
|
||||
### `registerPattern(type: SecurityCodeType, detector: (input: string) => SecurityCodeMatch[]): void`
|
||||
|
||||
Registers an additional detector function for the given type.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | `SecurityCodeType` | The type this detector produces |
|
||||
| `detector` | `DetectorFn` | Function that scans a string and returns matches |
|
||||
|
||||
Throws `SecurityCodesError(INVALID_PATTERN)` when `detector` is not a function.
|
||||
|
||||
### `detect(input: string, options?: DetectorOptions): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` and returns deduplicated, sorted matches.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | String to scan |
|
||||
| `options` | `DetectorOptions` | Optional filtering options |
|
||||
|
||||
Returns `SecurityCodeMatch[]` sorted by `start` index.
|
||||
Throws `SecurityCodesError(DETECTION_FAILED)` when a pattern detector throws.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The Stripe and GitHub pattern modules each detect multiple sub-types
|
||||
internally (e.g. `stripe-secret` vs `stripe-publishable`). The detector
|
||||
registers these modules once per function reference but post-filters by
|
||||
requested type when `options.types` is provided. This avoids double-scanning
|
||||
while still allowing consumers to restrict results to a single sub-type.
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SecurityCodesError } from "../../errors/security-codes-error.js";
|
||||
import type { SecurityCodeMatch } from "../../types/security-codes.types.js";
|
||||
import { SecurityCodeDetector } from "./code-detector.js";
|
||||
|
||||
const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
describe("SecurityCodeDetector", () => {
|
||||
it("detects an AWS key with default detectors", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect(AWS_KEY);
|
||||
expect(matches.some((m) => m.type === "aws-access-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers and runs a custom pattern", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern(
|
||||
"high-entropy",
|
||||
(input): SecurityCodeMatch[] => {
|
||||
if (input.includes("CUSTOM_SECRET")) {
|
||||
return [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "CUSTOM_SECRET",
|
||||
start: input.indexOf("CUSTOM_SECRET"),
|
||||
end: input.indexOf("CUSTOM_SECRET") + 13,
|
||||
confidence: 0.99,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
const matches = detector.detect("val=CUSTOM_SECRET");
|
||||
expect(matches.some((m) => m.value === "CUSTOM_SECRET")).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by types option", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect(AWS_KEY, { types: ["jwt"] });
|
||||
expect(matches.every((m) => m.type === "jwt")).toBe(true);
|
||||
expect(matches.some((m) => m.type === "aws-access-key")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by minConfidence option", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect(AWS_KEY, { minConfidence: 0.99 });
|
||||
// AWS confidence is 0.95, so it should be filtered out
|
||||
expect(matches.filter((m) => m.type === "aws-access-key")).toHaveLength(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("respects maxMatches option", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Add multiple things to detect
|
||||
const input = `${AWS_KEY} sk_live_ABCDEFGHIJKLMNOPQRSTUVWX`;
|
||||
const matches = detector.detect(input, { maxMatches: 1 });
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping matches, keeping higher confidence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Register two detectors that produce overlapping matches with different confidence
|
||||
detector.registerPattern(
|
||||
"high-entropy",
|
||||
(input): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "AKIAIOSFODNN7EXAMPLE",
|
||||
start: 0,
|
||||
end: 20,
|
||||
confidence: 0.5,
|
||||
},
|
||||
]
|
||||
);
|
||||
const matches = detector.detect(AWS_KEY, {
|
||||
types: ["aws-access-key", "high-entropy"],
|
||||
});
|
||||
// The aws-access-key match (0.95) should win over high-entropy (0.5)
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("aws-access-key");
|
||||
});
|
||||
|
||||
it("throws SecurityCodesError when registering a non-function detector", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
expect(() =>
|
||||
detector.registerPattern(
|
||||
"high-entropy",
|
||||
null as unknown as (input: string) => SecurityCodeMatch[]
|
||||
)
|
||||
).toThrow(SecurityCodesError);
|
||||
});
|
||||
|
||||
it("throws SecurityCodesError when a pattern detector throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern("high-entropy", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(() =>
|
||||
detector.detect("test", { types: ["high-entropy"] })
|
||||
).toThrow(SecurityCodesError);
|
||||
});
|
||||
|
||||
it("returns empty array for input with no matches", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
expect(detector.detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles types option with an unregistered type gracefully", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Use a cast to a non-built-in type to exercise the ?? [] null path
|
||||
const matches = detector.detect("no secrets here", {
|
||||
types: [
|
||||
"unknown-type" as unknown as import("../../types/security-codes.types.js").SecurityCodeType,
|
||||
],
|
||||
});
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wraps non-Error detector throws in SecurityCodesError using String(err)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Throw a plain string (not an Error instance) to exercise the String(err) branch
|
||||
detector.registerPattern("high-entropy", () => {
|
||||
throw "not an error object";
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
detector.detect("test", { types: ["high-entropy"] });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecurityCodesError);
|
||||
expect((caught as SecurityCodesError).message).toContain(
|
||||
"not an error object"
|
||||
);
|
||||
});
|
||||
|
||||
it("runs minConfidence=0 without filtering anything", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
const matches = detector.detect(AWS_KEY, { minConfidence: 0 });
|
||||
expect(matches.some((m) => m.type === "aws-access-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("registerPattern correctly appends to existing detector list", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Registering a second detector for the same type appends it
|
||||
const detector = new SecurityCodeDetector();
|
||||
let called = false;
|
||||
detector.registerPattern(
|
||||
"aws-access-key",
|
||||
(input): SecurityCodeMatch[] => {
|
||||
called = true;
|
||||
return [];
|
||||
}
|
||||
);
|
||||
detector.detect(AWS_KEY, { types: ["aws-access-key"] });
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping matches with equal confidence, keeping earlier", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Two detectors producing overlapping matches with SAME confidence
|
||||
detector.registerPattern("high-entropy", (): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "AKIAIOSFODNN7EXAMPLE",
|
||||
start: 0,
|
||||
end: 20,
|
||||
confidence: 0.6,
|
||||
},
|
||||
]);
|
||||
detector.registerPattern("aws-access-key", (): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "aws-access-key",
|
||||
value: "AKIAIOSFODNN7EXAM",
|
||||
start: 0,
|
||||
end: 17,
|
||||
confidence: 0.6,
|
||||
},
|
||||
]);
|
||||
// Both have confidence 0.6; the earlier-starting one (both start at 0, first in sorted order)
|
||||
const matches = detector.detect(AWS_KEY, {
|
||||
types: ["aws-access-key", "high-entropy"],
|
||||
});
|
||||
// Only one should survive after dedup
|
||||
expect(matches.length).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it("registerPattern creates new entry for an unregistered type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
// Cast to exercise the ?? [] null path in registerPattern
|
||||
const newType =
|
||||
"custom-secret" as unknown as import("../../types/security-codes.types.js").SecurityCodeType;
|
||||
detector.registerPattern(newType, (): SecurityCodeMatch[] => []);
|
||||
// Should not throw and should be callable
|
||||
const matches = detector.detect("test", { types: [newType] });
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
SECURITY_CODES_CODES,
|
||||
SecurityCodesError,
|
||||
} from "../../errors/security-codes-error.js";
|
||||
import { detect as detectAws } from "../../patterns/aws.js";
|
||||
import { detect as detectCreditCard } from "../../patterns/credit-card.js";
|
||||
import { detect as detectEntropy } from "../../patterns/entropy.js";
|
||||
import { detect as detectGithub } from "../../patterns/github.js";
|
||||
import { detect as detectGoogle } from "../../patterns/google.js";
|
||||
import { detect as detectJwt } from "../../patterns/jwt.js";
|
||||
import { detect as detectOpenai } from "../../patterns/openai.js";
|
||||
import { detect as detectPem } from "../../patterns/pem.js";
|
||||
import { detect as detectSsh } from "../../patterns/ssh.js";
|
||||
import { detect as detectStripe } from "../../patterns/stripe.js";
|
||||
import type {
|
||||
DetectorOptions,
|
||||
SecurityCodeMatch,
|
||||
SecurityCodeType,
|
||||
} from "../../types/security-codes.types.js";
|
||||
|
||||
type DetectorFn = (input: string) => SecurityCodeMatch[];
|
||||
|
||||
const BUILT_IN_DETECTORS: Map<SecurityCodeType, DetectorFn> = new Map([
|
||||
["aws-access-key", detectAws],
|
||||
["jwt", detectJwt],
|
||||
["stripe-secret", detectStripe as DetectorFn],
|
||||
["stripe-publishable", detectStripe as DetectorFn],
|
||||
["github-pat", detectGithub as DetectorFn],
|
||||
["github-fine-grained-pat", detectGithub as DetectorFn],
|
||||
["google-api-key", detectGoogle],
|
||||
["openai-key", detectOpenai],
|
||||
["credit-card", detectCreditCard],
|
||||
["pem-block", detectPem],
|
||||
["ssh-key", detectSsh],
|
||||
["high-entropy", detectEntropy],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true when match `a` overlaps match `b` (they share at least one
|
||||
* character position).
|
||||
*/
|
||||
function overlaps(a: SecurityCodeMatch, b: SecurityCodeMatch): boolean {
|
||||
return a.start < b.end && b.start < a.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplicates an array of matches by removing lower-confidence matches
|
||||
* that overlap with a higher-confidence one.
|
||||
* Stable sort is maintained: equal-confidence ties keep the earlier match.
|
||||
*/
|
||||
function deduplicateOverlaps(
|
||||
matches: SecurityCodeMatch[]
|
||||
): SecurityCodeMatch[] {
|
||||
// Sort by confidence descending, then start ascending for tie-breaking
|
||||
const sorted = [...matches].sort((a, b) =>
|
||||
b.confidence !== a.confidence
|
||||
? b.confidence - a.confidence
|
||||
: a.start - b.start
|
||||
);
|
||||
const kept: SecurityCodeMatch[] = [];
|
||||
for (const candidate of sorted) {
|
||||
if (!kept.some((k) => overlaps(k, candidate))) {
|
||||
kept.push(candidate);
|
||||
}
|
||||
}
|
||||
// Re-sort by start position for consistent output ordering
|
||||
return kept.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects sensitive security codes and secrets in arbitrary strings.
|
||||
*
|
||||
* All built-in pattern detectors are registered at construction time.
|
||||
* Additional patterns can be registered via {@link registerPattern}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const detector = new SecurityCodeDetector();
|
||||
* const matches = detector.detect('aws_key=AKIAIOSFODNN7EXAMPLE');
|
||||
* ```
|
||||
*/
|
||||
export class SecurityCodeDetector {
|
||||
protected readonly detectors: Map<SecurityCodeType, DetectorFn[]> =
|
||||
new Map();
|
||||
protected readonly meter = metrics.getMeter("@suaveplan/security-codes");
|
||||
protected readonly detectCounter = this.meter.createCounter(
|
||||
"security_codes.detect.total"
|
||||
);
|
||||
protected readonly detectHistogram = this.meter.createHistogram(
|
||||
"security_codes.detect.duration_ms"
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Register each built-in pattern under its type.
|
||||
// Stripe and GitHub modules handle multiple sub-types internally.
|
||||
for (const [type, fn] of BUILT_IN_DETECTORS) {
|
||||
this.detectors.set(type, [fn]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom pattern detector for a given type.
|
||||
* Multiple detectors may be registered for the same type; all are run and
|
||||
* their results are merged before deduplication.
|
||||
*
|
||||
* @param type - The {@link SecurityCodeType} this detector recognises.
|
||||
* @param detector - A function that scans a string and returns matches.
|
||||
* @throws {@link SecurityCodesError} with code `INVALID_PATTERN` when
|
||||
* `detector` is not a function.
|
||||
*/
|
||||
registerPattern(type: SecurityCodeType, detector: DetectorFn): void {
|
||||
if (typeof detector !== "function") {
|
||||
throw new SecurityCodesError(
|
||||
`Detector for type "${type}" must be a function`,
|
||||
SECURITY_CODES_CODES.INVALID_PATTERN
|
||||
);
|
||||
}
|
||||
const existing = this.detectors.get(type) ?? [];
|
||||
this.detectors.set(type, [...existing, detector]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans `input` for sensitive security codes using all registered
|
||||
* detectors (or a restricted subset when `options.types` is provided).
|
||||
*
|
||||
* Overlapping matches are de-duplicated, preferring the match with the
|
||||
* higher confidence score.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @param options - Optional filtering and pagination options.
|
||||
* @returns Sorted array of non-overlapping {@link SecurityCodeMatch} objects.
|
||||
* @throws {@link SecurityCodesError} with code `DETECTION_FAILED` on
|
||||
* unexpected errors from a pattern detector.
|
||||
*/
|
||||
detect(input: string, options?: DetectorOptions): SecurityCodeMatch[] {
|
||||
const t0 = Date.now();
|
||||
const { types, minConfidence = 0, maxMatches } = options ?? {};
|
||||
|
||||
// Determine which detector functions to invoke (deduplicated by reference)
|
||||
const fnsToRun = new Set<DetectorFn>();
|
||||
const typesToRun: SecurityCodeType[] =
|
||||
types ?? (Array.from(this.detectors.keys()) as SecurityCodeType[]);
|
||||
for (const type of typesToRun) {
|
||||
for (const fn of this.detectors.get(type) ?? []) {
|
||||
fnsToRun.add(fn);
|
||||
}
|
||||
}
|
||||
|
||||
let allMatches: SecurityCodeMatch[] = [];
|
||||
for (const fn of fnsToRun) {
|
||||
try {
|
||||
const results = fn(input);
|
||||
allMatches = allMatches.concat(results);
|
||||
} catch (err) {
|
||||
throw new SecurityCodesError(
|
||||
`Pattern detector threw: ${err instanceof Error ? err.message : String(err)}`,
|
||||
SECURITY_CODES_CODES.DETECTION_FAILED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by requested types (if provided, stripe/github detectors produce
|
||||
// matches for multiple types, so we must post-filter)
|
||||
if (types) {
|
||||
const typeSet = new Set<string>(types);
|
||||
allMatches = allMatches.filter((m) => typeSet.has(m.type));
|
||||
}
|
||||
|
||||
// Filter by minimum confidence
|
||||
if (minConfidence > 0) {
|
||||
allMatches = allMatches.filter(
|
||||
(m) => m.confidence >= minConfidence
|
||||
);
|
||||
}
|
||||
|
||||
const deduped = deduplicateOverlaps(allMatches);
|
||||
const result =
|
||||
maxMatches !== undefined ? deduped.slice(0, maxMatches) : deduped;
|
||||
|
||||
const duration = Date.now() - t0;
|
||||
this.detectCounter.add(result.length);
|
||||
this.detectHistogram.record(duration);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SecurityCodeDetector } from "./code-detector";
|
||||
@@ -0,0 +1,98 @@
|
||||
# code-sanitizer
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SecurityCodeSanitizer` takes arbitrary strings, detects all embedded
|
||||
secrets via `SecurityCodeDetector`, and replaces each match with a
|
||||
configurable mask. It supports four strategies — full redaction, partial
|
||||
masking, FNV-1a hash substitution, and token placeholders — making it
|
||||
suitable for log pipelines, audit trails, and reversible pseudonymisation.
|
||||
|
||||
## Features
|
||||
|
||||
- Delegates detection to a `SecurityCodeDetector` instance (injected or
|
||||
default) so custom pattern registrations are automatically respected.
|
||||
- Four masking strategies:
|
||||
- `full-redact`: replaces the entire match with `[REDACTED]`
|
||||
- `partial`: keeps the first and last four characters, replacing the
|
||||
middle with `***`
|
||||
- `hash`: replaces the match with an 8-character FNV-1a hex digest
|
||||
of the original value
|
||||
- `tokenize`: replaces the match with `TOKEN_<TYPE>_<N>` where N
|
||||
increments per sanitize call
|
||||
- `preserveLength` option pads or truncates the replacement to the same
|
||||
byte length as the original, useful for fixed-width log formats.
|
||||
- Replacements are applied right-to-left so that earlier indices remain
|
||||
valid throughout the substitution pass.
|
||||
- Throws `SecurityCodesError(SANITIZATION_FAILED)` on unexpected errors.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodeSanitizer } from '@suaveplan/security-codes';
|
||||
|
||||
const sanitizer = new SecurityCodeSanitizer();
|
||||
const { output, matches } = sanitizer.sanitize(
|
||||
'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4In0.sig'
|
||||
);
|
||||
// output === 'Authorization: Bearer [REDACTED]'
|
||||
// matches[0].type === 'jwt'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodeSanitizer, SecurityCodeDetector } from '@suaveplan/security-codes';
|
||||
|
||||
const detector = new SecurityCodeDetector();
|
||||
const sanitizer = new SecurityCodeSanitizer(detector);
|
||||
|
||||
// Tokenize for reversible pseudonymisation
|
||||
const { output } = sanitizer.sanitize(
|
||||
'key=AKIAIOSFODNN7EXAMPLE secret=sk_live_ABCDEFGHIJKLMNOPQRSTUVWX',
|
||||
{ strategy: 'tokenize', tokenPrefix: 'SCRUBBED' }
|
||||
);
|
||||
// output contains 'SCRUBBED_AWS_ACCESS_KEY_0' and 'SCRUBBED_STRIPE_SECRET_1'
|
||||
|
||||
// Partial masking for readable logs
|
||||
const partial = sanitizer.sanitize('card=4532015112830366', { strategy: 'partial' });
|
||||
// partial.output === 'card=4532***0366'
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new SecurityCodeSanitizer(detector?: SecurityCodeDetector)`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `detector` | `SecurityCodeDetector` | Optional custom detector; a default is created if omitted |
|
||||
|
||||
### `sanitize(input: string, options?: SanitizerOptions): SanitizeResult`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to sanitize |
|
||||
| `options` | `SanitizerOptions` | Optional masking configuration |
|
||||
|
||||
Returns `{ output: string; matches: SecurityCodeMatch[] }`.
|
||||
Throws `SecurityCodesError(SANITIZATION_FAILED)` on errors.
|
||||
|
||||
**`SanitizerOptions`**
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `strategy` | `'full-redact'` | Masking strategy |
|
||||
| `preserveLength` | `false` | Pad/truncate replacement to original length |
|
||||
| `tokenPrefix` | `'TOKEN'` | Prefix for `tokenize` strategy |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The FNV-1a hash (`fnv1aHex`) is a fast, dependency-free 32-bit hash
|
||||
producing an 8-character hex string. It is intentionally non-cryptographic
|
||||
— use the `full-redact` strategy when one-way irreversibility is required.
|
||||
Replacements are processed in descending start-index order to prevent index
|
||||
drift: substituting a later match never affects the `start`/`end` of an
|
||||
earlier one.
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SecurityCodesError } from "../../errors/security-codes-error.js";
|
||||
import type { SecurityCodeMatch } from "../../types/security-codes.types.js";
|
||||
import { SecurityCodeDetector } from "../code-detector/code-detector.js";
|
||||
import { SecurityCodeSanitizer } from "./code-sanitizer.js";
|
||||
|
||||
const AWS_KEY = "AKIAIOSFODNN7EXAMPLE";
|
||||
|
||||
describe("SecurityCodeSanitizer", () => {
|
||||
it("full-redact strategy replaces match with [REDACTED]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output, matches } = s.sanitize(`key=${AWS_KEY}`);
|
||||
expect(output).toBe("key=[REDACTED]");
|
||||
expect(matches.some((m) => m.type === "aws-access-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("partial strategy keeps first/last 4 chars", { timeout: 5000 }, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, {
|
||||
strategy: "partial",
|
||||
});
|
||||
expect(output).toContain("AKIA");
|
||||
expect(output).toContain("MPLE");
|
||||
expect(output).toContain("***");
|
||||
});
|
||||
|
||||
it("hash strategy replaces with 8-char hex", { timeout: 5000 }, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, { strategy: "hash" });
|
||||
const replacement = output.replace("key=", "");
|
||||
expect(/^[0-9a-f]{8}$/.test(replacement)).toBe(true);
|
||||
});
|
||||
|
||||
it("tokenize strategy replaces with TOKEN_<TYPE>_<N>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, {
|
||||
strategy: "tokenize",
|
||||
});
|
||||
expect(output).toMatch(/TOKEN_AWS_ACCESS_KEY_0/);
|
||||
});
|
||||
|
||||
it("tokenize strategy uses custom tokenPrefix", { timeout: 5000 }, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, {
|
||||
strategy: "tokenize",
|
||||
tokenPrefix: "SECRET",
|
||||
});
|
||||
expect(output).toMatch(/SECRET_AWS_ACCESS_KEY_0/);
|
||||
});
|
||||
|
||||
it("preserveLength pads full-redact to original match length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, {
|
||||
strategy: "full-redact",
|
||||
preserveLength: true,
|
||||
});
|
||||
const replacement = output.replace("key=", "");
|
||||
expect(replacement.length).toBe(AWS_KEY.length);
|
||||
});
|
||||
|
||||
it("returns original string when no matches found", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output, matches } = s.sanitize("hello world");
|
||||
expect(output).toBe("hello world");
|
||||
expect(matches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles multiple matches in one string", { timeout: 5000 }, () => {
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const input = `a=${AWS_KEY} b=sk_live_ABCDEFGHIJKLMNOPQRSTUVWX`;
|
||||
const { output, matches } = s.sanitize(input);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(2);
|
||||
expect(output).not.toContain(AWS_KEY);
|
||||
});
|
||||
|
||||
it("accepts an injected custom detector", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern(
|
||||
"high-entropy",
|
||||
(input): SecurityCodeMatch[] => {
|
||||
if (input.includes("CUSTOM")) {
|
||||
return [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "CUSTOM",
|
||||
start: input.indexOf("CUSTOM"),
|
||||
end: input.indexOf("CUSTOM") + 6,
|
||||
confidence: 0.99,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
const s = new SecurityCodeSanitizer(detector);
|
||||
const { output } = s.sanitize("val=CUSTOM", {
|
||||
strategy: "full-redact",
|
||||
});
|
||||
expect(output).toBe("val=[REDACTED]");
|
||||
});
|
||||
|
||||
it("partial strategy returns [REDACTED] for very short values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Use a detector that returns a short match
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern("high-entropy", (): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "SHORT",
|
||||
start: 0,
|
||||
end: 5,
|
||||
confidence: 0.99,
|
||||
},
|
||||
]);
|
||||
const s = new SecurityCodeSanitizer(detector);
|
||||
const { output } = s.sanitize("SHORT", { strategy: "partial" });
|
||||
expect(output).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("hash preserveLength truncates when value is shorter than 8 chars", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A 4-char match: hash is 8 chars, len <= hash.length so it truncates
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern("high-entropy", (): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "ABCD",
|
||||
start: 0,
|
||||
end: 4,
|
||||
confidence: 0.99,
|
||||
},
|
||||
]);
|
||||
const s = new SecurityCodeSanitizer(detector);
|
||||
const { output } = s.sanitize("ABCD", {
|
||||
strategy: "hash",
|
||||
preserveLength: true,
|
||||
});
|
||||
// Output should be exactly 4 chars (truncated hash)
|
||||
expect(output.length).toBe(4);
|
||||
expect(/^[0-9a-f]+$/.test(output)).toBe(true);
|
||||
});
|
||||
|
||||
it("hash preserveLength pads when value is longer than 8 chars", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// AWS_KEY is 20 chars, hash is 8 chars, so len > hash.length → padded
|
||||
const s = new SecurityCodeSanitizer();
|
||||
const { output } = s.sanitize(`key=${AWS_KEY}`, {
|
||||
strategy: "hash",
|
||||
preserveLength: true,
|
||||
});
|
||||
const replacement = output.replace("key=", "");
|
||||
expect(replacement.length).toBe(AWS_KEY.length);
|
||||
// Should start with 8 hex chars then zeros
|
||||
expect(/^[0-9a-f]{8}0+$/.test(replacement)).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps detector errors in SecurityCodesError", { timeout: 5000 }, () => {
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern("high-entropy", () => {
|
||||
throw new Error("detector boom");
|
||||
});
|
||||
const s = new SecurityCodeSanitizer(detector);
|
||||
expect(() => s.sanitize("test")).toThrow(SecurityCodesError);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws via String(err) in sanitize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Subclass SecurityCodeDetector to throw a plain string (not an Error instance)
|
||||
class ThrowingDetector extends SecurityCodeDetector {
|
||||
override detect() {
|
||||
throw "detect failed plain string";
|
||||
}
|
||||
}
|
||||
const s = new SecurityCodeSanitizer(new ThrowingDetector());
|
||||
let caught: unknown;
|
||||
try {
|
||||
s.sanitize("test");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecurityCodesError);
|
||||
expect((caught as SecurityCodesError).message).toContain(
|
||||
"detect failed plain string"
|
||||
);
|
||||
});
|
||||
|
||||
it("full-redact preserveLength truncates when value is shorter than [REDACTED]", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A 5-char match: [REDACTED] is 10 chars, len < base.length so it truncates
|
||||
const detector = new SecurityCodeDetector();
|
||||
detector.registerPattern("high-entropy", (): SecurityCodeMatch[] => [
|
||||
{
|
||||
type: "high-entropy",
|
||||
value: "ABCDE",
|
||||
start: 0,
|
||||
end: 5,
|
||||
confidence: 0.99,
|
||||
},
|
||||
]);
|
||||
const s = new SecurityCodeSanitizer(detector);
|
||||
const { output } = s.sanitize("ABCDE", {
|
||||
strategy: "full-redact",
|
||||
preserveLength: true,
|
||||
});
|
||||
expect(output.length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
SECURITY_CODES_CODES,
|
||||
SecurityCodesError,
|
||||
} from "../../errors/security-codes-error.js";
|
||||
import type {
|
||||
SanitizeResult,
|
||||
SanitizerOptions,
|
||||
SecurityCodeMatch,
|
||||
} from "../../types/security-codes.types.js";
|
||||
import { SecurityCodeDetector } from "../code-detector/code-detector.js";
|
||||
|
||||
/**
|
||||
* FNV-1a 32-bit hash returning an 8-character lowercase hex string.
|
||||
* Used for the `hash` masking strategy without requiring a crypto dependency.
|
||||
*/
|
||||
function fnv1aHex(str: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h.toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes strings by detecting and replacing sensitive security codes.
|
||||
*
|
||||
* Uses a {@link SecurityCodeDetector} internally. You may inject a custom
|
||||
* detector instance for advanced control over which patterns are active.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const sanitizer = new SecurityCodeSanitizer();
|
||||
* const { output } = sanitizer.sanitize('key=AKIAIOSFODNN7EXAMPLE');
|
||||
* // output === 'key=[REDACTED]'
|
||||
* ```
|
||||
*/
|
||||
export class SecurityCodeSanitizer {
|
||||
protected readonly detector: SecurityCodeDetector;
|
||||
|
||||
constructor(detector?: SecurityCodeDetector) {
|
||||
this.detector = detector ?? new SecurityCodeDetector();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans `input` for secrets and replaces each match according to the
|
||||
* configured masking strategy.
|
||||
*
|
||||
* Replacements are applied from right to left so that earlier start indices
|
||||
* remain valid after each substitution.
|
||||
*
|
||||
* @param input - The string to sanitize.
|
||||
* @param options - Masking and detector options.
|
||||
* @returns Object containing the sanitized `output` string and all
|
||||
* `matches` that were replaced.
|
||||
* @throws {@link SecurityCodesError} with code `SANITIZATION_FAILED` on
|
||||
* unexpected errors.
|
||||
*/
|
||||
sanitize(input: string, options?: SanitizerOptions): SanitizeResult {
|
||||
const {
|
||||
strategy = "full-redact",
|
||||
preserveLength = false,
|
||||
tokenPrefix = "TOKEN",
|
||||
} = options ?? {};
|
||||
|
||||
let matches: SecurityCodeMatch[];
|
||||
try {
|
||||
matches = this.detector.detect(input);
|
||||
} catch (err) {
|
||||
throw new SecurityCodesError(
|
||||
`Detection failed during sanitization: ${err instanceof Error ? err.message : String(err)}`,
|
||||
SECURITY_CODES_CODES.SANITIZATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { output: input, matches: [] };
|
||||
}
|
||||
|
||||
// Sort matches by start descending so right-to-left replacement works
|
||||
const sorted = [...matches].sort((a, b) => b.start - a.start);
|
||||
let output = input;
|
||||
let tokenIndex = 0;
|
||||
|
||||
for (const match of sorted) {
|
||||
const replacement = this.buildReplacement(
|
||||
match,
|
||||
strategy,
|
||||
preserveLength,
|
||||
tokenPrefix,
|
||||
tokenIndex
|
||||
);
|
||||
output =
|
||||
output.slice(0, match.start) +
|
||||
replacement +
|
||||
output.slice(match.end);
|
||||
tokenIndex++;
|
||||
}
|
||||
|
||||
// Return matches in original order (ascending start)
|
||||
return { output, matches };
|
||||
}
|
||||
|
||||
protected buildReplacement(
|
||||
match: SecurityCodeMatch,
|
||||
strategy: string,
|
||||
preserveLength: boolean,
|
||||
tokenPrefix: string,
|
||||
tokenIndex: number
|
||||
): string {
|
||||
switch (strategy) {
|
||||
case "full-redact": {
|
||||
const base = "[REDACTED]";
|
||||
if (preserveLength) {
|
||||
const len = match.value.length;
|
||||
if (len <= base.length) return base.slice(0, len);
|
||||
return base + "*".repeat(len - base.length);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
case "partial": {
|
||||
const v = match.value;
|
||||
if (v.length <= 8) return "[REDACTED]";
|
||||
return `${v.slice(0, 4)}***${v.slice(-4)}`;
|
||||
}
|
||||
case "hash": {
|
||||
const hash = fnv1aHex(match.value);
|
||||
if (preserveLength) {
|
||||
const len = match.value.length;
|
||||
if (len <= hash.length) return hash.slice(0, len);
|
||||
return hash + "0".repeat(len - hash.length);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
default: {
|
||||
const typeTag = match.type.toUpperCase().replace(/-/g, "_");
|
||||
return `${tokenPrefix}_${typeTag}_${tokenIndex}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SecurityCodeSanitizer } from "./code-sanitizer";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SecurityCodeDetector } from "./code-detector";
|
||||
export { SecurityCodeSanitizer } from "./code-sanitizer";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SECURITY_CODES_CODES,
|
||||
type SecurityCodesCode,
|
||||
SecurityCodesError,
|
||||
} from "./security-codes-error";
|
||||
@@ -0,0 +1,79 @@
|
||||
# security-codes-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the package-local error class and stable error-code registry for `@suaveplan/security-codes`. Because this package is a leaf with no `@suaveplan/error` dependency, it defines its own lightweight `SecurityCodesError` base class rather than extending `SuaveplanError`. The stable `SECURITY_CODES_CODES` constant object gives callers a programmatic handle for each failure mode without relying on fragile string matching against error messages.
|
||||
|
||||
## Features
|
||||
|
||||
- `SECURITY_CODES_CODES` constant object with three stable error codes: `DETECTION_FAILED`, `INVALID_PATTERN`, `SANITIZATION_FAILED`.
|
||||
- `SecurityCodesCode` union type derived from the constant so callers can reference code values without importing a string literal.
|
||||
- `SecurityCodesError` extends `Error` with an additional `readonly code: string` property for programmatic handling.
|
||||
- `name` is set to `"SecurityCodesError"` so that `instanceof` checks and serialisation can distinguish this class from generic `Error` objects.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodesError, SECURITY_CODES_CODES } from '@suaveplan/security-codes/errors/security-codes-error';
|
||||
|
||||
try {
|
||||
// some operation
|
||||
} catch (err) {
|
||||
if (err instanceof SecurityCodesError) {
|
||||
console.error(err.code, err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SecurityCodesError, SECURITY_CODES_CODES, SecurityCodesCode } from '@suaveplan/security-codes/errors/security-codes-error';
|
||||
|
||||
function handleCode(code: SecurityCodesCode): void {
|
||||
switch (code) {
|
||||
case SECURITY_CODES_CODES.DETECTION_FAILED:
|
||||
// retry with simpler input
|
||||
break;
|
||||
case SECURITY_CODES_CODES.INVALID_PATTERN:
|
||||
// remove the offending detector from the registry
|
||||
break;
|
||||
case SECURITY_CODES_CODES.SANITIZATION_FAILED:
|
||||
// fall back to full-redact strategy
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SECURITY_CODES_CODES`
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `DETECTION_FAILED` | `"SECURITY_CODES.DETECTION_FAILED"` |
|
||||
| `INVALID_PATTERN` | `"SECURITY_CODES.INVALID_PATTERN"` |
|
||||
| `SANITIZATION_FAILED` | `"SECURITY_CODES.SANITIZATION_FAILED"` |
|
||||
|
||||
### `SecurityCodesCode`
|
||||
|
||||
`"SECURITY_CODES.DETECTION_FAILED" | "SECURITY_CODES.INVALID_PATTERN" | "SECURITY_CODES.SANITIZATION_FAILED"`
|
||||
|
||||
### `SecurityCodesError`
|
||||
|
||||
Extends `Error`.
|
||||
|
||||
| Member | Type | Description |
|
||||
|---|---|---|
|
||||
| `code` | `string` | Stable error code from `SECURITY_CODES_CODES` |
|
||||
| `message` | `string` | Human-readable description |
|
||||
| `name` | `"SecurityCodesError"` | Fixed for `instanceof` / serialisation |
|
||||
|
||||
**Constructor:** `new SecurityCodesError(message: string, code: string)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package deliberately avoids depending on `@suaveplan/error` to keep `@suaveplan/security-codes` a true leaf package suitable for use in constrained environments. The error-code namespace prefix `SECURITY_CODES.` mirrors the convention used by `@suaveplan/error` consumers so that if the package ever graduates to a full `SuaveplanError` descendant the codes remain backward-compatible.
|
||||
@@ -0,0 +1,23 @@
|
||||
export const SECURITY_CODES_CODES = {
|
||||
DETECTION_FAILED: "SECURITY_CODES.DETECTION_FAILED",
|
||||
INVALID_PATTERN: "SECURITY_CODES.INVALID_PATTERN",
|
||||
SANITIZATION_FAILED: "SECURITY_CODES.SANITIZATION_FAILED",
|
||||
} as const;
|
||||
|
||||
export type SecurityCodesCode =
|
||||
(typeof SECURITY_CODES_CODES)[keyof typeof SECURITY_CODES_CODES];
|
||||
|
||||
/**
|
||||
* Base error class for all @suaveplan/security-codes errors.
|
||||
* Carries a stable `code` string from SECURITY_CODES_CODES for
|
||||
* programmatic handling without depending on @suaveplan/error.
|
||||
*/
|
||||
export class SecurityCodesError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(message: string, code: string) {
|
||||
super(message);
|
||||
this.name = "SecurityCodesError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
22
packages/security-stack/security/security-codes/src/index.ts
Normal file
22
packages/security-stack/security/security-codes/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Error types
|
||||
|
||||
// Core classes
|
||||
export { SecurityCodeDetector } from "./core/code-detector/code-detector.js";
|
||||
export { SecurityCodeSanitizer } from "./core/code-sanitizer/code-sanitizer.js";
|
||||
export {
|
||||
SECURITY_CODES_CODES,
|
||||
type SecurityCodesCode,
|
||||
SecurityCodesError,
|
||||
} from "./errors/security-codes-error.js";
|
||||
// Public types
|
||||
export type {
|
||||
DetectorOptions,
|
||||
MaskingStrategy,
|
||||
SanitizeResult,
|
||||
SanitizerOptions,
|
||||
SecurityCodeMatch,
|
||||
SecurityCodeType,
|
||||
} from "./types/security-codes.types.js";
|
||||
|
||||
// Utilities
|
||||
export { luhnCheck } from "./utils/luhn.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
# aws
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects AWS Access Key IDs within arbitrary string input. AWS Access Key IDs
|
||||
are 20-character alphanumeric identifiers that always begin with the prefix
|
||||
`AKIA`. Detecting these in log lines, source code, or configuration strings
|
||||
prevents inadvertent credential exposure.
|
||||
|
||||
## Features
|
||||
|
||||
- Regex-based detection using the canonical `AKIA[0-9A-Z]{16}` pattern.
|
||||
- Word-boundary anchors (`\b`) reduce false positives from longer strings.
|
||||
- Returns a confidence score of `0.95` — the fixed prefix makes false
|
||||
positives very unlikely.
|
||||
- Resets `lastIndex` before each scan so the exported function is safe for
|
||||
repeated calls on different inputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/aws';
|
||||
|
||||
const matches = detect('Key: AKIAIOSFODNN7EXAMPLE');
|
||||
// matches[0].type === 'aws-access-key'
|
||||
// matches[0].confidence === 0.95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/aws';
|
||||
|
||||
const log = 'aws_access_key_id=AKIAIOSFODNN7EXAMPLE aws_secret=abc123';
|
||||
const matches = detect(log);
|
||||
console.log(matches[0].start, matches[0].end);
|
||||
// 20, 40 (covers just the key ID)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for AWS Access Key IDs.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` objects. Each match records the
|
||||
matched value, its `[start, end)` position, and a `confidence` of `0.95`.
|
||||
Returns an empty array when no keys are found.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The pattern `/\bAKIA[0-9A-Z]{16}\b/g` is the standard detection heuristic
|
||||
recommended by AWS and used by tools such as truffleHog and git-secrets.
|
||||
Secret Access Keys (the companion credential) are not detected here because
|
||||
they have no fixed prefix and are better covered by the entropy detector.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./aws.js";
|
||||
|
||||
describe("aws pattern", () => {
|
||||
it("detects a standalone AWS access key", { timeout: 5000 }, () => {
|
||||
const matches = detect("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("aws-access-key");
|
||||
expect(matches[0].value).toBe("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(matches[0].confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it("detects an AWS access key embedded in a config line", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect("aws_access_key_id=AKIAJSIE27AGENPACT7Z");
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("aws-access-key");
|
||||
});
|
||||
|
||||
it("returns empty array for a non-AWS string", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a key shorter than the required format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// AKIA + 15 chars (one short)
|
||||
expect(detect("AKIAIOSFODNN7EXAMPL")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns correct start/end indices", { timeout: 5000 }, () => {
|
||||
const input = "key: AKIAIOSFODNN7EXAMPLE end";
|
||||
const matches = detect(input);
|
||||
expect(matches[0].start).toBe(5);
|
||||
expect(matches[0].end).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const AWS_ACCESS_KEY_RE = /\bAKIA[0-9A-Z]{16}\b/g;
|
||||
|
||||
/**
|
||||
* Detects AWS Access Key IDs in the input string.
|
||||
* AWS Access Key IDs always start with `AKIA` followed by 16 uppercase
|
||||
* alphanumeric characters.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected key.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
AWS_ACCESS_KEY_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = AWS_ACCESS_KEY_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "aws-access-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# credit-card
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects credit card numbers embedded in arbitrary strings using a combination
|
||||
of regex matching and the Luhn checksum algorithm. Credit card numbers (PANs —
|
||||
Primary Account Numbers) must never appear in logs, error messages, or
|
||||
application state because they are governed by PCI-DSS compliance requirements.
|
||||
|
||||
## Features
|
||||
|
||||
- Matches 13–19 digit sequences, accepting space- or dash-separated formats
|
||||
(e.g. `4532 0151 1283 0366` or `4532-0151-1283-0366`).
|
||||
- Applies the Luhn algorithm to all candidates: only Luhn-valid numbers are
|
||||
reported, significantly reducing false positives.
|
||||
- Returns confidence `0.85` — the Luhn check is strong but not infallible
|
||||
(random numbers can pass by coincidence).
|
||||
- Word-boundary anchors prevent matching partial card numbers embedded in
|
||||
longer digit strings.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/credit-card';
|
||||
|
||||
const matches = detect('card=4532015112830366');
|
||||
// matches[0].type === 'credit-card'
|
||||
// matches[0].confidence === 0.85
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/credit-card';
|
||||
|
||||
// Space-separated format
|
||||
const matches = detect('Card: 4532 0151 1283 0366');
|
||||
console.log(matches[0].value); // '4532 0151 1283 0366'
|
||||
|
||||
// Luhn-invalid number is not returned
|
||||
const noMatch = detect('card=1234567890123456');
|
||||
console.log(noMatch.length); // 0
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for credit card numbers.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'credit-card'` and
|
||||
`confidence: 0.85` for each Luhn-valid candidate. Non-Luhn-valid candidates
|
||||
are silently discarded.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex `\b(?:\d[ -]?){12,18}\d\b` matches the outer word boundary and then
|
||||
requires 13–19 total digits with optional single-character separators between
|
||||
each group. The Luhn validation in `../utils/luhn.ts` strips all non-digit
|
||||
characters before computing the checksum, so space- and dash-separated formats
|
||||
are validated correctly.
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./credit-card.js";
|
||||
|
||||
describe("credit-card pattern", () => {
|
||||
it("detects a Luhn-valid Visa card number", { timeout: 5000 }, () => {
|
||||
// 4532015112830366 passes Luhn
|
||||
const matches = detect("4532015112830366");
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("credit-card");
|
||||
expect(matches[0].confidence).toBe(0.85);
|
||||
});
|
||||
|
||||
it("detects a space-separated Luhn-valid card number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect("4532 0151 1283 0366");
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not detect a Luhn-invalid card number", { timeout: 5000 }, () => {
|
||||
// 1234567890123456 fails Luhn
|
||||
expect(detect("1234567890123456")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not detect a number that is too short", { timeout: 5000 }, () => {
|
||||
expect(detect("123456789012")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns correct start/end for embedded card number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const input = "card: 4532015112830366 end";
|
||||
const matches = detect(input);
|
||||
expect(matches[0].start).toBe(6);
|
||||
expect(matches[0].end).toBe(22);
|
||||
});
|
||||
|
||||
it("detects a 15-digit Amex number", { timeout: 5000 }, () => {
|
||||
// 378282246310005 is a standard Amex test number (Luhn-valid)
|
||||
const matches = detect("378282246310005");
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
import { luhnCheck } from "../utils/luhn.js";
|
||||
|
||||
// Matches 13-19 digit sequences, optionally separated by spaces or dashes
|
||||
const CC_RE = /\b(?:\d[ -]?){12,18}\d\b/g;
|
||||
|
||||
/**
|
||||
* Detects potential credit card numbers in the input string.
|
||||
* Uses the Luhn algorithm to validate each candidate; only Luhn-valid
|
||||
* numbers are returned.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected card number.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
CC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CC_RE.exec(input)) !== null) {
|
||||
const candidate = match[0];
|
||||
const digits = candidate.replace(/\D/g, "");
|
||||
if (!luhnCheck(digits)) continue;
|
||||
matches.push({
|
||||
type: "credit-card",
|
||||
value: candidate,
|
||||
start: match.index,
|
||||
end: match.index + candidate.length,
|
||||
confidence: 0.85,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# entropy
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects high-entropy strings that are likely secret tokens, API keys, or
|
||||
passwords using Shannon entropy analysis. This heuristic catches secrets that
|
||||
have no fixed prefix and would be missed by pattern-based detectors.
|
||||
While less precise than dedicated pattern detectors, it provides a useful
|
||||
catch-all for novel or unrecognized credential formats.
|
||||
|
||||
## Features
|
||||
|
||||
- Scans for base64/hex-alphabet runs of at least 20 characters.
|
||||
- Computes Shannon entropy (bits per character) for each candidate.
|
||||
- Returns only candidates with entropy ≥ 4.5 bits per character, a threshold
|
||||
calibrated to separate random tokens from natural-language substrings.
|
||||
- Returns a deliberately conservative confidence of `0.6` to reflect the
|
||||
heuristic nature of the detection.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/entropy';
|
||||
|
||||
// A random-looking base64 string
|
||||
const matches = detect('secret=aB3dEfGhIjKlMnOpQrStUvWxYz012345678901');
|
||||
// matches[0].type === 'high-entropy', confidence 0.6
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/entropy';
|
||||
|
||||
// Short or low-entropy strings are not flagged
|
||||
const noMatch = detect('hello world this is plain text');
|
||||
console.log(noMatch.length); // 0
|
||||
|
||||
// High-entropy token is flagged
|
||||
const match = detect('TOKEN=xK8mP2nQ9rS5tV7wY1aB4cD6eF0gH3iJ');
|
||||
console.log(match[0].confidence); // 0.6
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for high-entropy strings.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'high-entropy'` and
|
||||
`confidence: 0.6`. Returns an empty array when no high-entropy candidates
|
||||
are found.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Shannon entropy `H = -∑ p(x) log₂ p(x)` over a string's character frequency
|
||||
distribution. Natural English text scores around 3.5–4.0 bits per character;
|
||||
random tokens typically exceed 4.5. The threshold was chosen empirically to
|
||||
minimise false positives on common identifiers and UUIDs while still catching
|
||||
hex-encoded secrets and base64 tokens. The minimum length of 20 characters
|
||||
filters out short words that happen to have high entropy due to their brevity.
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./entropy.js";
|
||||
|
||||
describe("entropy pattern", () => {
|
||||
it("detects a high-entropy base64-looking token", { timeout: 5000 }, () => {
|
||||
// This is a random-looking base64 string > 20 chars with high entropy
|
||||
const matches = detect("token=xK8mP2nQ9rS5tV7wY1aB4cD6eF0gH3iJ");
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0].type).toBe("high-entropy");
|
||||
expect(matches[0].confidence).toBe(0.6);
|
||||
});
|
||||
|
||||
it("detects a high-entropy mixed-case token", { timeout: 5000 }, () => {
|
||||
// 32-char string with high character diversity — entropy > 4.5
|
||||
const matches = detect("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef");
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
expect(matches[0].type).toBe("high-entropy");
|
||||
});
|
||||
|
||||
it("does not flag short strings", { timeout: 5000 }, () => {
|
||||
expect(detect("short")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag low-entropy natural language", { timeout: 5000 }, () => {
|
||||
// A long word with repetitive chars has low entropy
|
||||
expect(detect("aaaaaaaaaaaaaaaaaaaaaaaa")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const MIN_ENTROPY = 4.5;
|
||||
|
||||
// Base64/hex-looking token candidates: runs of base64url or hex chars (min 20 chars)
|
||||
const CANDIDATE_RE = /[A-Za-z0-9+/=_-]{20,}/g;
|
||||
|
||||
/**
|
||||
* Computes the Shannon entropy (bits per character) for a string.
|
||||
*/
|
||||
function shannonEntropy(s: string): number {
|
||||
const freq = new Map<string, number>();
|
||||
for (const ch of s) {
|
||||
freq.set(ch, (freq.get(ch) ?? 0) + 1);
|
||||
}
|
||||
let entropy = 0;
|
||||
for (const count of freq.values()) {
|
||||
const p = count / s.length;
|
||||
entropy -= p * Math.log2(p);
|
||||
}
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic high-entropy string detector.
|
||||
* Returns matches for base64- or hex-looking strings of at least 20
|
||||
* characters whose Shannon entropy meets or exceeds the 4.5 bit threshold.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each high-entropy candidate.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
CANDIDATE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = CANDIDATE_RE.exec(input)) !== null) {
|
||||
const candidate = match[0];
|
||||
if (shannonEntropy(candidate) < MIN_ENTROPY) continue;
|
||||
matches.push({
|
||||
type: "high-entropy",
|
||||
value: candidate,
|
||||
start: match.index,
|
||||
end: match.index + candidate.length,
|
||||
confidence: 0.6,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# github
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects GitHub Personal Access Tokens (PATs) — both classic tokens
|
||||
(`ghp_…`) and the newer fine-grained tokens (`github_pat_…`) — in
|
||||
arbitrary strings. GitHub PATs grant repository and organization-level
|
||||
access; leaking them in logs, CI output, or configuration files can lead
|
||||
to repository takeover or data exfiltration.
|
||||
|
||||
## Features
|
||||
|
||||
- Separate regexes for classic PATs (`ghp_[A-Za-z0-9]{36}`) and
|
||||
fine-grained PATs (`github_pat_[A-Za-z0-9_]{82}`).
|
||||
- Fine-grained tokens receive a slightly higher confidence (`0.97`) due to
|
||||
their longer, more distinctive prefix.
|
||||
- Classic tokens score `0.95`.
|
||||
- Word-boundary anchors prevent partial matches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/github';
|
||||
|
||||
const matches = detect('GH_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij');
|
||||
// matches[0].type === 'github-pat'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/github';
|
||||
|
||||
const text = 'classic=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij fine=github_pat_' +
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ABCDE' +
|
||||
'FGHIJKLMN';
|
||||
const matches = detect(text);
|
||||
// matches[0].type === 'github-pat'
|
||||
// matches[1].type === 'github-fine-grained-pat'
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for GitHub PATs (both variants).
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch`. Classic PATs have
|
||||
`confidence: 0.95`; fine-grained PATs have `confidence: 0.97`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
GitHub's token format is documented in their REST API and security advisory
|
||||
publications. The precise suffix lengths (36 for classic, 82 for
|
||||
fine-grained) are derived from GitHub's own secret-scanning patterns. Both
|
||||
regex objects reset `lastIndex` before each call to ensure correct behaviour
|
||||
on repeated invocations.
|
||||
|
||||
Classic PATs are being phased out in favour of fine-grained tokens, which
|
||||
scope permissions to specific repositories and organisations. Supporting both
|
||||
formats future-proofs detection during the transition period. The higher
|
||||
confidence score assigned to fine-grained tokens reflects their longer,
|
||||
more distinctive prefix, which produces fewer coincidental matches in
|
||||
arbitrary text. The `\b` word-boundary anchors on both sides prevent partial
|
||||
matches where a longer base64-like string happens to start with the token
|
||||
prefix.
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./github.js";
|
||||
|
||||
// Exactly 36 alphanumeric chars after ghp_
|
||||
const CLASSIC_PAT = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
|
||||
// Exactly 82 alphanumeric/underscore chars after github_pat_
|
||||
const FINE_PAT = `github_pat_${"A".repeat(82)}`;
|
||||
|
||||
describe("github pattern", () => {
|
||||
it("detects a classic GitHub PAT", { timeout: 5000 }, () => {
|
||||
const matches = detect(CLASSIC_PAT);
|
||||
const m = matches.find((x) => x.type === "github-pat");
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it("detects a fine-grained GitHub PAT", { timeout: 5000 }, () => {
|
||||
const matches = detect(FINE_PAT);
|
||||
const m = matches.find((x) => x.type === "github-fine-grained-pat");
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.confidence).toBe(0.97);
|
||||
});
|
||||
|
||||
it("returns empty array for a non-GitHub string", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a classic PAT with wrong length", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 35 chars (one short)
|
||||
const short = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcde";
|
||||
const matches = detect(short);
|
||||
expect(matches.filter((m) => m.type === "github-pat")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const GITHUB_PAT_RE = /\bghp_[A-Za-z0-9]{36}\b/g;
|
||||
const GITHUB_FINE_RE = /\bgithub_pat_[A-Za-z0-9_]{82}\b/g;
|
||||
|
||||
/**
|
||||
* Detects GitHub Personal Access Tokens (classic and fine-grained) in the
|
||||
* input string.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected token.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
|
||||
GITHUB_PAT_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = GITHUB_PAT_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "github-pat",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
|
||||
GITHUB_FINE_RE.lastIndex = 0;
|
||||
while ((match = GITHUB_FINE_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "github-fine-grained-pat",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.97,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# google
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects Google API keys embedded in arbitrary strings. Google API keys begin
|
||||
with the well-known prefix `AIza` and are used to authenticate requests to
|
||||
Google Cloud APIs, Maps, Firebase, and other Google services. Leaking these
|
||||
keys in source code or logs can result in unexpected billing and unauthorized
|
||||
access to Google services.
|
||||
|
||||
## Features
|
||||
|
||||
- Regex anchored with `\b` on both ends to avoid matching substrings of
|
||||
longer tokens.
|
||||
- Fixed 35-character suffix using URL-safe base64 characters (`[0-9A-Za-z-_]`).
|
||||
- Returns confidence `0.9` — the fixed prefix is distinctive but not as unique
|
||||
as AWS or Stripe prefixes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/google';
|
||||
|
||||
const matches = detect('GOOGLE_API_KEY=AIzaSyDummyKey12345678901234567890123');
|
||||
// matches[0].type === 'google-api-key'
|
||||
// matches[0].confidence === 0.9
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/google';
|
||||
|
||||
const config = JSON.stringify({ apiKey: 'AIzaSyDummyKey12345678901234567890123' });
|
||||
const matches = detect(config);
|
||||
console.log(matches[0].start, matches[0].end);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for Google API keys.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'google-api-key'` and
|
||||
`confidence: 0.9`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `AIza` prefix is used by Google for API keys across many of their services.
|
||||
The trailing character set (`[0-9A-Za-z-_]`) is the URL-safe base64 alphabet,
|
||||
matching Google's actual key encoding. `lastIndex` is reset before each scan.
|
||||
|
||||
The confidence value of `0.9` is intentionally conservative relative to
|
||||
Stripe or GitHub patterns, because the `AIza` prefix is shorter and the
|
||||
character set is broad enough that coincidental matches in large documents are
|
||||
plausible. Callers that process Google-specific configuration files may wish to
|
||||
raise `minConfidence` to `0.95` and accept the small risk of missing an
|
||||
obfuscated key, while callers performing broad log sweeps should leave it at
|
||||
the default to maximise recall. The 35-character suffix length matches the
|
||||
actual key length as observed in Google Cloud Console-issued credentials.
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./google.js";
|
||||
|
||||
describe("google pattern", () => {
|
||||
it("detects a Google API key", { timeout: 5000 }, () => {
|
||||
// AIza + exactly 35 URL-safe chars
|
||||
const matches = detect("AIzaSyDummyKey1234567890123456789012345");
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("google-api-key");
|
||||
expect(matches[0].confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it("detects a Google API key embedded in JSON", { timeout: 5000 }, () => {
|
||||
const json = '{"apiKey":"AIzaSyDummyKey1234567890123456789012345"}';
|
||||
const matches = detect(json);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array for a non-Google string", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a key with wrong prefix", { timeout: 5000 }, () => {
|
||||
expect(detect("BIzaSyDummyKey12345678901234567890123")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const GOOGLE_API_KEY_RE = /\bAIza[0-9A-Za-z-_]{35}\b/g;
|
||||
|
||||
/**
|
||||
* Detects Google API keys in the input string.
|
||||
* Google API keys always begin with `AIza` followed by 35 URL-safe characters.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected key.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
GOOGLE_API_KEY_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = GOOGLE_API_KEY_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "google-api-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.9,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { detect as detectAws } from "./aws.js";
|
||||
export { detect as detectCreditCard } from "./credit-card.js";
|
||||
export { detect as detectEntropy } from "./entropy.js";
|
||||
export { detect as detectGithub } from "./github.js";
|
||||
export { detect as detectGoogle } from "./google.js";
|
||||
export { detect as detectJwt } from "./jwt.js";
|
||||
export { detect as detectOpenai } from "./openai.js";
|
||||
export { detect as detectPem } from "./pem.js";
|
||||
export { detect as detectSsh } from "./ssh.js";
|
||||
export { detect as detectStripe } from "./stripe.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# jwt
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects JSON Web Tokens (JWTs) embedded in arbitrary strings. JWTs are
|
||||
bearer tokens with a well-defined three-part base64url structure
|
||||
(`header.payload.signature`). Leaking JWTs in logs, error messages, or
|
||||
configuration files grants an attacker the same privileges as the original
|
||||
holder until the token expires.
|
||||
|
||||
## Features
|
||||
|
||||
- Regex matches the canonical `eyJ...` header prefix followed by two more
|
||||
base64url segments separated by dots.
|
||||
- Secondary validation: decodes and JSON-parses the header segment to
|
||||
confirm the match is a real JWT (not a coincidental dot-separated string).
|
||||
- Uses `atob` for base64url decoding — available in all modern runtimes
|
||||
including Node.js ≥16 and all browsers.
|
||||
- Returns confidence `0.9` — the structural validation makes false positives
|
||||
unlikely, while `1.0` is reserved for signatures that are cryptographically
|
||||
verified.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/jwt';
|
||||
|
||||
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||
const matches = detect(`Authorization: Bearer ${token}`);
|
||||
// matches[0].type === 'jwt'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/jwt';
|
||||
|
||||
// Multiple tokens in a single string
|
||||
const text = 'token1=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ4In0.abc token2=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4In0.def';
|
||||
const matches = detect(text);
|
||||
console.log(matches.length); // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for JWTs.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'jwt'` and
|
||||
`confidence: 0.9`. Returns an empty array when no valid JWTs are found.
|
||||
Matches that pass the regex but fail the header JSON-parse are silently
|
||||
dropped.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex anchors on `\b` to avoid matching base64 blobs embedded inside
|
||||
longer tokens. The header validation uses `atob` rather than a Node-only
|
||||
`Buffer` to keep the module runtime-agnostic. The signature segment is not
|
||||
cryptographically verified — that is outside the scope of detection.
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./jwt.js";
|
||||
|
||||
// A real JWT-like token with valid base64url header {"alg":"HS256","typ":"JWT"}
|
||||
const VALID_JWT =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
|
||||
describe("jwt pattern", () => {
|
||||
it("detects a valid JWT", { timeout: 5000 }, () => {
|
||||
const matches = detect(VALID_JWT);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("jwt");
|
||||
expect(matches[0].confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it("detects a JWT embedded in an Authorization header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect(`Authorization: Bearer ${VALID_JWT}`);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].value).toBe(VALID_JWT);
|
||||
});
|
||||
|
||||
it("returns empty array for plain text", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a malformed three-part string with invalid header", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Base64url parts that do not decode to JSON
|
||||
expect(detect("xxxxx.yyyyy.zzzzz")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns correct start/end indices", { timeout: 5000 }, () => {
|
||||
const prefix = "token=";
|
||||
const input = prefix + VALID_JWT;
|
||||
const matches = detect(input);
|
||||
expect(matches[0].start).toBe(prefix.length);
|
||||
expect(matches[0].end).toBe(input.length);
|
||||
});
|
||||
|
||||
it("does not match a token whose header decodes to invalid JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// eyJpbnZhbGlk decodes to '{"invalid' (not valid JSON)
|
||||
const invalidHeaderJwt = "eyJpbnZhbGlk.eyJzdWIiOiJ4In0.sig";
|
||||
expect(detect(invalidHeaderJwt)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects a JWT whose header requires base64 padding (mod4 != 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// eyJ0eXAiOiJKV1QifQ decodes to {"typ":"JWT"} — length 18, mod4=2
|
||||
const paddedJwt = "eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiJ0ZXN0In0.sig";
|
||||
const matches = detect(paddedJwt);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("jwt");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
||||
|
||||
function isValidBase64UrlJson(part: string): boolean {
|
||||
try {
|
||||
// Pad the base64url string to a multiple of 4
|
||||
const padded = part.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const mod4 = padded.length % 4;
|
||||
const padStr = mod4 === 0 ? padded : padded + "====".slice(mod4);
|
||||
const decoded = atob(padStr);
|
||||
JSON.parse(decoded);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects JSON Web Tokens in the input string.
|
||||
* Matches the three-part base64url structure and validates the header
|
||||
* by attempting to decode and JSON-parse it.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected JWT.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
JWT_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = JWT_RE.exec(input)) !== null) {
|
||||
const parts = match[0].split(".");
|
||||
if (
|
||||
parts.length === 3 &&
|
||||
parts[0] !== undefined &&
|
||||
isValidBase64UrlJson(parts[0])
|
||||
) {
|
||||
matches.push({
|
||||
type: "jwt",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.9,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# openai
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects OpenAI API keys in arbitrary strings. OpenAI API keys follow the
|
||||
format `sk-<48 chars>` for standard keys and `sk-proj-<48+ chars>` for
|
||||
project-scoped keys. Exposing these credentials allows unauthorized access
|
||||
to OpenAI's models, incurring cost and potential data leakage.
|
||||
|
||||
## Features
|
||||
|
||||
- Handles both standard (`sk-`) and project-scoped (`sk-proj-`) key formats.
|
||||
- Project keys are matched first; the shorter `sk-` pattern skips positions
|
||||
already claimed by a project key to avoid double-reporting.
|
||||
- Project keys receive a slightly higher confidence (`0.95`) than standard
|
||||
keys (`0.92`) due to the more distinctive prefix.
|
||||
- Word-boundary anchors on both ends.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/openai';
|
||||
|
||||
const matches = detect('OPENAI_API_KEY=sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv');
|
||||
// matches[0].type === 'openai-key'
|
||||
// matches[0].confidence === 0.92
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/openai';
|
||||
|
||||
const text = 'key=sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const matches = detect(text);
|
||||
// matches[0].type === 'openai-key'
|
||||
// matches[0].confidence === 0.95
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for OpenAI API keys.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'openai-key'`.
|
||||
Standard keys have `confidence: 0.92`; project keys have `confidence: 0.95`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The project-key regex (`sk-proj-`) is tested first and winning positions are
|
||||
recorded in a `Set` so the standard-key regex does not produce duplicate
|
||||
matches for the same token. Both regexes reset `lastIndex` before scanning.
|
||||
|
||||
OpenAI introduced project-scoped keys to allow tighter permission boundaries
|
||||
per API project. Detecting them separately lets security tooling distinguish
|
||||
between a leaked organisation-wide key (higher blast radius) and a
|
||||
project-scoped key (limited blast radius). The 48-character suffix length for
|
||||
standard keys and the extended suffix for project keys match OpenAI's published
|
||||
key format; the exact length boundary is enforced via `{48,}` to tolerate any
|
||||
future increases without requiring a regex update.
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./openai.js";
|
||||
|
||||
// Standard: sk- followed by exactly 48 alphanumeric chars
|
||||
const STD_KEY = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv";
|
||||
// Project key: sk-proj- followed by 48 alphanumeric chars
|
||||
const PROJ_KEY = "sk-proj-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuv";
|
||||
|
||||
describe("openai pattern", () => {
|
||||
it("detects a standard OpenAI key", { timeout: 5000 }, () => {
|
||||
const matches = detect(STD_KEY);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("openai-key");
|
||||
expect(matches[0].confidence).toBe(0.92);
|
||||
});
|
||||
|
||||
it("detects a project-scoped OpenAI key", { timeout: 5000 }, () => {
|
||||
const matches = detect(PROJ_KEY);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("openai-key");
|
||||
expect(matches[0].confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it("does not double-report a project key as a standard key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect(PROJ_KEY);
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty array for a non-OpenAI string", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a key shorter than 48 chars", { timeout: 5000 }, () => {
|
||||
expect(detect("sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
// Standard secret key: sk- followed by 48 alphanumeric characters
|
||||
const OPENAI_SK_RE = /\bsk-[A-Za-z0-9]{48}\b/g;
|
||||
// Project key: sk-proj- followed by 48+ alphanumeric characters
|
||||
const OPENAI_PROJ_RE = /\bsk-proj-[A-Za-z0-9]{48,}\b/g;
|
||||
|
||||
/**
|
||||
* Detects OpenAI API keys in the input string.
|
||||
* Handles both standard secret keys (`sk-…`) and project keys (`sk-proj-…`).
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected key.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
|
||||
// Project keys: higher confidence due to more distinctive prefix
|
||||
OPENAI_PROJ_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = OPENAI_PROJ_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "openai-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.95,
|
||||
});
|
||||
}
|
||||
|
||||
// Standard keys: the `-` in `sk-proj-` prevents double-matching
|
||||
OPENAI_SK_RE.lastIndex = 0;
|
||||
while ((match = OPENAI_SK_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "openai-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.92,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# pem
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects PEM-encoded blocks in arbitrary strings. PEM (Privacy Enhanced Mail)
|
||||
is the standard encoding for TLS certificates, RSA/EC private keys, certificate
|
||||
signing requests, and public keys. Leaking private-key PEM blocks in logs or
|
||||
error messages compromises the entire cryptographic identity of the affected
|
||||
service.
|
||||
|
||||
## Features
|
||||
|
||||
- Regex uses a non-greedy `[\s\S]*?` between the `-----BEGIN … -----` and
|
||||
`-----END … -----` markers to match the full block including newlines.
|
||||
- Matches any PEM type: `RSA PRIVATE KEY`, `EC PRIVATE KEY`,
|
||||
`CERTIFICATE`, `CERTIFICATE REQUEST`, `PUBLIC KEY`, etc.
|
||||
- Returns confidence `0.97` — the fixed delimiter format makes false positives
|
||||
extremely unlikely.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/pem';
|
||||
|
||||
const pem = '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3V...\n-----END RSA PRIVATE KEY-----';
|
||||
const matches = detect(pem);
|
||||
// matches[0].type === 'pem-block'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/pem';
|
||||
|
||||
// Embedded in a config file
|
||||
const config = `tls_cert: |\n -----BEGIN CERTIFICATE-----\n MIIBxTCC...\n -----END CERTIFICATE-----`;
|
||||
const matches = detect(config);
|
||||
console.log(matches[0].confidence); // 0.97
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for PEM blocks.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan (may contain newlines) |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` with `type: 'pem-block'` and
|
||||
`confidence: 0.97`. The `value` includes the full block including header,
|
||||
base64 content, and footer.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex uses the `g` flag with `lastIndex` reset before each call. The
|
||||
`[\s\S]*?` pattern enables cross-line matching without the `s` (dotAll) flag,
|
||||
ensuring compatibility with environments where dotAll behaviour may differ.
|
||||
|
||||
The non-greedy quantifier is critical here: a greedy `[\s\S]*` would consume
|
||||
everything between the first `-----BEGIN` and the last `-----END` in a
|
||||
multi-certificate chain, collapsing multiple blocks into a single oversized
|
||||
match. The non-greedy form correctly yields one match per PEM block. The
|
||||
confidence of `0.97` is among the highest in the package because the
|
||||
`-----BEGIN … -----` / `-----END … -----` framing is extremely unlikely to
|
||||
appear in non-PEM text, making false positives rare even in noisy inputs.
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./pem.js";
|
||||
|
||||
const RSA_PRIVATE =
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xHn/ygWep4\n-----END RSA PRIVATE KEY-----";
|
||||
const CERTIFICATE =
|
||||
"-----BEGIN CERTIFICATE-----\nMIIBxTCCAW2gAwIBAgIJAP8sldp1\n-----END CERTIFICATE-----";
|
||||
|
||||
describe("pem pattern", () => {
|
||||
it("detects an RSA private key PEM block", { timeout: 5000 }, () => {
|
||||
const matches = detect(RSA_PRIVATE);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("pem-block");
|
||||
expect(matches[0].confidence).toBe(0.97);
|
||||
});
|
||||
|
||||
it("detects a CERTIFICATE PEM block", { timeout: 5000 }, () => {
|
||||
const matches = detect(CERTIFICATE);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("pem-block");
|
||||
});
|
||||
|
||||
it("returns empty array for plain text", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match incomplete PEM (missing END)", { timeout: 5000 }, () => {
|
||||
expect(detect("-----BEGIN RSA PRIVATE KEY-----\ndata")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
// Matches PEM blocks including multi-line content
|
||||
const PEM_RE = /-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g;
|
||||
|
||||
/**
|
||||
* Detects PEM-encoded blocks (certificates, private keys, CSRs, etc.) in the
|
||||
* input string.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected PEM block.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
PEM_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PEM_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "pem-block",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.97,
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# ssh
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects SSH private keys and public keys embedded in arbitrary strings.
|
||||
SSH private keys grant direct shell access to any server that has the
|
||||
corresponding public key in its `authorized_keys`. Public keys in authorized
|
||||
format can reveal the owner's cryptographic identity and are also worth
|
||||
flagging in security audits.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects OpenSSH private key blocks (`-----BEGIN OPENSSH PRIVATE KEY-----`),
|
||||
RSA public keys (`ssh-rsa AAAA…`), ECDSA keys (`ecdsa-sha2-nistp256 AAAA…`),
|
||||
and Ed25519 keys (`ssh-ed25519 AAAA…`).
|
||||
- Private key blocks receive a higher confidence score (`0.98`) than public
|
||||
keys (`0.92`).
|
||||
- All four regex patterns reset `lastIndex` before each scan.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/ssh';
|
||||
|
||||
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----';
|
||||
const matches = detect(key);
|
||||
// matches[0].type === 'ssh-key', confidence 0.98
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/ssh';
|
||||
|
||||
const config = 'authorized_key=ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQ user@host';
|
||||
const matches = detect(config);
|
||||
// matches[0].type === 'ssh-key', confidence 0.92
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for SSH keys.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan (may contain newlines) |
|
||||
|
||||
Returns `SecurityCodeMatch[]` with `type: 'ssh-key'`. Private key blocks
|
||||
have `confidence: 0.98`; public key formats have `confidence: 0.92`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OpenSSH private keys use PEM-like delimiters but with a specific header string
|
||||
that distinguishes them from generic PEM blocks detected by the `pem` module.
|
||||
Both modules may return matches for the same region — the detector deduplication
|
||||
layer resolves overlaps by preferring higher-confidence matches.
|
||||
|
||||
Public key formats (`ssh-rsa`, `ecdsa-sha2-nistp256`, `ssh-ed25519`) are
|
||||
matched with a permissive `\S+` suffix to handle the variable-length base64
|
||||
blob and the optional trailing comment (e.g. `user@host`). The lower confidence
|
||||
score of `0.92` for public keys reflects the fact that a standalone `ssh-rsa
|
||||
AAAA…` string in a configuration file is common and not necessarily a secret
|
||||
leak, whereas an OpenSSH private key block is always sensitive and receives
|
||||
`0.98`.
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./ssh.js";
|
||||
|
||||
const OPENSSH_PRIVATE =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAAEbm9uZQAAABBAAAAA\n-----END OPENSSH PRIVATE KEY-----";
|
||||
const RSA_PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQCtBRK7EG user@host";
|
||||
const ECDSA_PUB =
|
||||
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAA user@host";
|
||||
|
||||
describe("ssh pattern", () => {
|
||||
it("detects an OpenSSH private key block", { timeout: 5000 }, () => {
|
||||
const matches = detect(OPENSSH_PRIVATE);
|
||||
const m = matches.find((x) => x.type === "ssh-key");
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.confidence).toBe(0.98);
|
||||
});
|
||||
|
||||
it("detects an RSA public key", { timeout: 5000 }, () => {
|
||||
const matches = detect(RSA_PUB);
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0].type).toBe("ssh-key");
|
||||
expect(matches[0].confidence).toBe(0.92);
|
||||
});
|
||||
|
||||
it("detects an ECDSA public key", { timeout: 5000 }, () => {
|
||||
const matches = detect(ECDSA_PUB);
|
||||
const m = matches.find((x) => x.type === "ssh-key");
|
||||
expect(m).toBeDefined();
|
||||
});
|
||||
|
||||
it("detects an Ed25519 public key", { timeout: 5000 }, () => {
|
||||
const ed25519Pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI user@host";
|
||||
const matches = detect(ed25519Pub);
|
||||
expect(matches.some((m) => m.type === "ssh-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty array for plain text", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
// OpenSSH private key block (multi-line)
|
||||
const OPENSSH_PRIVATE_RE =
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----/g;
|
||||
// RSA public key in authorized_keys format
|
||||
const SSH_RSA_RE = /\bssh-rsa AAAA[A-Za-z0-9+/]+=*(?:\s+\S+)?/g;
|
||||
// ECDSA public key
|
||||
const ECDSA_RE = /\becdsa-sha2-nistp256 AAAA[A-Za-z0-9+/]+=*/g;
|
||||
// Ed25519 public key
|
||||
const ED25519_RE = /\bssh-ed25519 AAAA[A-Za-z0-9+/]+=*/g;
|
||||
|
||||
/**
|
||||
* Detects SSH private keys and public keys in the input string.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected SSH key.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
|
||||
OPENSSH_PRIVATE_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = OPENSSH_PRIVATE_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "ssh-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.98,
|
||||
});
|
||||
}
|
||||
|
||||
SSH_RSA_RE.lastIndex = 0;
|
||||
while ((match = SSH_RSA_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "ssh-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.92,
|
||||
});
|
||||
}
|
||||
|
||||
ECDSA_RE.lastIndex = 0;
|
||||
while ((match = ECDSA_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "ssh-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.92,
|
||||
});
|
||||
}
|
||||
|
||||
ED25519_RE.lastIndex = 0;
|
||||
while ((match = ED25519_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "ssh-key",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.92,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# stripe
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects Stripe API keys — both secret keys (`sk_live_` / `sk_test_`) and
|
||||
publishable keys (`pk_live_` / `pk_test_`) — embedded in arbitrary strings.
|
||||
While publishable keys are technically public, detecting them prevents
|
||||
environment confusion. Secret keys provide full API access and must never
|
||||
appear in logs or source code.
|
||||
|
||||
## Features
|
||||
|
||||
- Separate regexes for secret and publishable prefixes to distinguish types.
|
||||
- Live secret keys receive a higher confidence score (`0.99`) than test
|
||||
variants (`0.9`) because live credentials are more sensitive.
|
||||
- Publishable keys score `0.9` regardless of environment.
|
||||
- Minimum 24-character suffix requirement filters out truncated or partial
|
||||
matches.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/stripe';
|
||||
|
||||
const matches = detect('STRIPE_SECRET=sk_live_ABCDEFGHIJKLMNOPQRSTUVWX');
|
||||
// matches[0].type === 'stripe-secret'
|
||||
// matches[0].confidence === 0.99
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detect } from '@suaveplan/security-codes/patterns/stripe';
|
||||
|
||||
const env = 'sk_test_TESTKEY1234567890123456 pk_live_PUBKEY1234567890123456';
|
||||
const matches = detect(env);
|
||||
// matches[0].type === 'stripe-secret', confidence 0.9 (test key)
|
||||
// matches[1].type === 'stripe-publishable', confidence 0.9
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detect(input: string): SecurityCodeMatch[]`
|
||||
|
||||
Scans `input` for Stripe secret and publishable keys.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `input` | `string` | The string to scan |
|
||||
|
||||
Returns an array of `SecurityCodeMatch` objects. Live secret keys have
|
||||
`confidence: 0.99`, all other variants have `confidence: 0.9`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Stripe's key format is documented in their API reference. The `\b` word
|
||||
boundary on both ends prevents matching a key that is part of a longer
|
||||
alphanumeric string. Both regex objects have `lastIndex` reset before each
|
||||
scan to handle repeated calls safely.
|
||||
|
||||
Distinguishing live from test keys matters because test keys can be committed
|
||||
to public repositories intentionally (for CI integration tests), while live
|
||||
keys must never appear outside of secrets management infrastructure. The
|
||||
confidence delta between `sk_live_` (`0.99`) and `sk_test_` (`0.9`) allows
|
||||
callers to apply a stricter response policy to live keys — for example,
|
||||
immediately revoking them via Stripe's API — while treating test-key
|
||||
detections as lower-priority warnings. Publishable keys are tracked at `0.9`
|
||||
to surface environment-confusion issues during code review.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detect } from "./stripe.js";
|
||||
|
||||
const LIVE_SECRET = "sk_live_ABCDEFGHIJKLMNOPQRSTUVWX";
|
||||
const TEST_SECRET = "sk_test_ABCDEFGHIJKLMNOPQRSTUVWX";
|
||||
const LIVE_PUB = "pk_live_ABCDEFGHIJKLMNOPQRSTUVWX";
|
||||
const TEST_PUB = "pk_test_ABCDEFGHIJKLMNOPQRSTUVWX";
|
||||
|
||||
describe("stripe pattern", () => {
|
||||
it("detects a live secret key with 0.99 confidence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect(LIVE_SECRET);
|
||||
const m = matches.find((x) => x.type === "stripe-secret");
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.confidence).toBe(0.99);
|
||||
});
|
||||
|
||||
it("detects a test secret key with 0.9 confidence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = detect(TEST_SECRET);
|
||||
const m = matches.find((x) => x.type === "stripe-secret");
|
||||
expect(m).toBeDefined();
|
||||
expect(m?.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it("detects a live publishable key", { timeout: 5000 }, () => {
|
||||
const matches = detect(LIVE_PUB);
|
||||
const m = matches.find((x) => x.type === "stripe-publishable");
|
||||
expect(m).toBeDefined();
|
||||
});
|
||||
|
||||
it("detects a test publishable key", { timeout: 5000 }, () => {
|
||||
const matches = detect(TEST_PUB);
|
||||
const m = matches.find((x) => x.type === "stripe-publishable");
|
||||
expect(m).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns empty array for a non-Stripe string", { timeout: 5000 }, () => {
|
||||
expect(detect("hello world")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not match a key shorter than 24 chars after prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(detect("sk_live_SHORT")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SecurityCodeMatch } from "../types/security-codes.types.js";
|
||||
|
||||
const STRIPE_SECRET_RE = /\bsk_(?:live|test)_[A-Za-z0-9]{24,}\b/g;
|
||||
const STRIPE_PUB_RE = /\bpk_(?:live|test)_[A-Za-z0-9]{24,}\b/g;
|
||||
|
||||
/**
|
||||
* Detects Stripe API keys (secret and publishable) in the input string.
|
||||
*
|
||||
* @param input - The string to scan.
|
||||
* @returns Array of {@link SecurityCodeMatch} for each detected Stripe key.
|
||||
*/
|
||||
export function detect(input: string): SecurityCodeMatch[] {
|
||||
const matches: SecurityCodeMatch[] = [];
|
||||
|
||||
STRIPE_SECRET_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = STRIPE_SECRET_RE.exec(input)) !== null) {
|
||||
const isLive = match[0].startsWith("sk_live_");
|
||||
matches.push({
|
||||
type: "stripe-secret",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: isLive ? 0.99 : 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
STRIPE_PUB_RE.lastIndex = 0;
|
||||
while ((match = STRIPE_PUB_RE.exec(input)) !== null) {
|
||||
matches.push({
|
||||
type: "stripe-publishable",
|
||||
value: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
confidence: 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
DetectorOptions,
|
||||
MaskingStrategy,
|
||||
SanitizeResult,
|
||||
SanitizerOptions,
|
||||
SecurityCodeMatch,
|
||||
SecurityCodeType,
|
||||
} from "./security-codes.types";
|
||||
@@ -0,0 +1,127 @@
|
||||
# security-codes.types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the shared TypeScript types used throughout `@suaveplan/security-codes`.
|
||||
These types form the public contract between the pattern library, the detector,
|
||||
the sanitizer, and any consumer. Having them in a dedicated file ensures that
|
||||
feature modules can import only the type contract without pulling in any
|
||||
runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `SecurityCodeType` — a discriminated union of all secret/token pattern names
|
||||
the library supports (AWS keys, JWTs, credit cards, GitHub tokens, Stripe
|
||||
keys, Google API keys, OpenAI keys, PEM blocks, SSH keys, and high-entropy
|
||||
heuristics).
|
||||
- `MaskingStrategy` — union of the four masking modes understood by the
|
||||
sanitizer: `full-redact`, `partial`, `hash`, and `tokenize`.
|
||||
- `SecurityCodeMatch` — the detection result record: type, original value,
|
||||
start/end indices, and a confidence score `[0, 1]`.
|
||||
- `DetectorOptions` — filtering knobs for the detector: restrict by type,
|
||||
set a minimum confidence threshold, and cap the number of results.
|
||||
- `SanitizerOptions` — sanitizer configuration: choose a masking strategy,
|
||||
control length-preservation, and override the token prefix.
|
||||
- `SanitizeResult` — the sanitizer return value bundling the masked output
|
||||
string together with all replacement records.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SecurityCodeMatch, MaskingStrategy } from '@suaveplan/security-codes';
|
||||
|
||||
const match: SecurityCodeMatch = {
|
||||
type: 'aws-access-key',
|
||||
value: 'AKIAIOSFODNN7EXAMPLE',
|
||||
start: 0,
|
||||
end: 20,
|
||||
confidence: 0.95,
|
||||
};
|
||||
|
||||
const strategy: MaskingStrategy = 'partial';
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DetectorOptions,
|
||||
SanitizerOptions,
|
||||
SanitizeResult,
|
||||
} from '@suaveplan/security-codes';
|
||||
|
||||
const detectOpts: DetectorOptions = {
|
||||
types: ['jwt', 'stripe-secret', 'credit-card'],
|
||||
minConfidence: 0.8,
|
||||
maxMatches: 20,
|
||||
};
|
||||
|
||||
const sanitizeOpts: SanitizerOptions = {
|
||||
strategy: 'tokenize',
|
||||
tokenPrefix: 'MASKED',
|
||||
};
|
||||
|
||||
// sanitize() returns SanitizeResult
|
||||
const result: SanitizeResult = {
|
||||
output: 'Bearer MASKED_JWT_0',
|
||||
matches: [],
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SecurityCodeType`
|
||||
|
||||
`type SecurityCodeType = 'aws-access-key' | 'jwt' | 'credit-card' | 'github-pat' | 'github-fine-grained-pat' | 'stripe-secret' | 'stripe-publishable' | 'google-api-key' | 'openai-key' | 'pem-block' | 'ssh-key' | 'high-entropy'`
|
||||
|
||||
String union of all supported detection pattern identifiers.
|
||||
|
||||
### `MaskingStrategy`
|
||||
|
||||
`type MaskingStrategy = 'full-redact' | 'partial' | 'hash' | 'tokenize'`
|
||||
|
||||
Enumeration of masking strategies accepted by the sanitizer.
|
||||
|
||||
### `SecurityCodeMatch`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | `SecurityCodeType` | Classified pattern type |
|
||||
| `value` | `string` | Exact matched substring |
|
||||
| `start` | `number` | Inclusive start index |
|
||||
| `end` | `number` | Exclusive end index |
|
||||
| `confidence` | `number` | Score in `[0, 1]` |
|
||||
|
||||
### `DetectorOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `types` | `SecurityCodeType[]` | all | Restrict to these types |
|
||||
| `minConfidence` | `number` | `0` | Suppress below this score |
|
||||
| `maxMatches` | `number` | unlimited | Return at most N results |
|
||||
|
||||
### `SanitizerOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `strategy` | `MaskingStrategy` | `'full-redact'` | Masking mode |
|
||||
| `preserveLength` | `boolean` | `false` | Pad/truncate to original length |
|
||||
| `tokenPrefix` | `string` | `'TOKEN'` | Prefix for `tokenize` strategy |
|
||||
|
||||
### `SanitizeResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `output` | `string` | Sanitized output string |
|
||||
| `matches` | `SecurityCodeMatch[]` | Detected and replaced secrets |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types in this file are pure TypeScript interfaces and type aliases — no
|
||||
runtime code is emitted. This means importing from `security-codes.types` has
|
||||
zero bundle-size cost. The `confidence` field follows a convention used
|
||||
throughout the pattern library: deterministic patterns with well-known prefixes
|
||||
score ≥ 0.90, while heuristic detectors (Shannon entropy) score around 0.60.
|
||||
@@ -0,0 +1,97 @@
|
||||
# security-codes.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines all shared TypeScript interfaces and union types for the `@suaveplan/security-codes` package. These types are the single source of truth consumed by every detector module, the `SecurityCodeSanitizer`, and any external caller that processes detection results. Keeping them in one file makes it straightforward to add new detector variants or masking strategies without touching multiple implementation files.
|
||||
|
||||
## Features
|
||||
|
||||
- `SecurityCodeType` union covering all twelve supported secret categories (AWS, JWT, credit-card, GitHub classic/fine-grained, Stripe secret/publishable, Google, OpenAI, PEM, SSH, high-entropy).
|
||||
- `MaskingStrategy` union with four named strategies: `full-redact`, `partial`, `hash`, `tokenize`.
|
||||
- `SecurityCodeMatch` result shape with position metadata (`start`, `end`) and a calibrated `confidence` score.
|
||||
- `DetectorOptions` for filtering which detectors run, setting a minimum confidence threshold, and capping result count.
|
||||
- `SanitizerOptions` for selecting a masking strategy, preserving byte length, and customising the tokenize prefix.
|
||||
- `SanitizeResult` wrapping the sanitised output string together with all matches that were replaced.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SecurityCodeMatch, MaskingStrategy } from '@suaveplan/security-codes';
|
||||
|
||||
function printMatch(m: SecurityCodeMatch): void {
|
||||
console.log(`${m.type} at [${m.start},${m.end}] confidence=${m.confidence}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DetectorOptions,
|
||||
SanitizerOptions,
|
||||
SanitizeResult,
|
||||
} from '@suaveplan/security-codes';
|
||||
|
||||
const detectOpts: DetectorOptions = {
|
||||
types: ['aws-access-key', 'github-pat'],
|
||||
minConfidence: 0.9,
|
||||
maxMatches: 10,
|
||||
};
|
||||
|
||||
const sanitizeOpts: SanitizerOptions = {
|
||||
strategy: 'tokenize',
|
||||
tokenPrefix: 'SECRET',
|
||||
};
|
||||
|
||||
function logResult(result: SanitizeResult): void {
|
||||
console.log('Sanitized:', result.output);
|
||||
console.log('Replaced:', result.matches.length, 'secrets');
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SecurityCodeType`
|
||||
|
||||
String literal union for all classifiable secret types. Values: `"aws-access-key"`, `"jwt"`, `"credit-card"`, `"github-pat"`, `"github-fine-grained-pat"`, `"stripe-secret"`, `"stripe-publishable"`, `"google-api-key"`, `"openai-key"`, `"pem-block"`, `"ssh-key"`, `"high-entropy"`.
|
||||
|
||||
### `MaskingStrategy`
|
||||
|
||||
`"full-redact" | "partial" | "hash" | "tokenize"`
|
||||
|
||||
### `SecurityCodeMatch`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | `SecurityCodeType` | Classified secret category |
|
||||
| `value` | `string` | Exact matched substring |
|
||||
| `start` | `number` | Inclusive start offset in input |
|
||||
| `end` | `number` | Exclusive end offset in input |
|
||||
| `confidence` | `number` | Score in `[0, 1]` |
|
||||
|
||||
### `DetectorOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `types` | `SecurityCodeType[]?` | all | Restrict to these detectors |
|
||||
| `minConfidence` | `number?` | `0` | Suppress low-confidence matches |
|
||||
| `maxMatches` | `number?` | unlimited | Cap result count |
|
||||
|
||||
### `SanitizerOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `strategy` | `MaskingStrategy?` | `"full-redact"` | Replacement strategy |
|
||||
| `preserveLength` | `boolean?` | `false` | Pad/truncate replacement to original length |
|
||||
| `tokenPrefix` | `string?` | `"TOKEN"` | Prefix for tokenize strategy |
|
||||
|
||||
### `SanitizeResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `output` | `string` | Input with secrets replaced |
|
||||
| `matches` | `SecurityCodeMatch[]` | All detected and replaced secrets |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Confidence scores are calibrated per-detector based on the specificity of the matched pattern. Known-prefix formats (Stripe `sk_live_`, GitHub `ghp_`) score in the range `0.90–0.99`. Heuristic detection (high-entropy strings) scores around `0.60` to reflect the higher false-positive rate. Callers should treat the `minConfidence` threshold as the primary tuning knob for precision/recall trade-offs.
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* All supported security-code/secret pattern types that the detector
|
||||
* can identify.
|
||||
*/
|
||||
export type SecurityCodeType =
|
||||
| "aws-access-key"
|
||||
| "jwt"
|
||||
| "credit-card"
|
||||
| "github-pat"
|
||||
| "github-fine-grained-pat"
|
||||
| "stripe-secret"
|
||||
| "stripe-publishable"
|
||||
| "google-api-key"
|
||||
| "openai-key"
|
||||
| "pem-block"
|
||||
| "ssh-key"
|
||||
| "high-entropy";
|
||||
|
||||
/** Masking strategies available to the sanitizer. */
|
||||
export type MaskingStrategy = "full-redact" | "partial" | "hash" | "tokenize";
|
||||
|
||||
/**
|
||||
* A single security-code detection hit within an input string.
|
||||
*/
|
||||
export interface SecurityCodeMatch {
|
||||
/** The classified type of the detected secret. */
|
||||
type: SecurityCodeType;
|
||||
/** The exact matched value as it appears in the input. */
|
||||
value: string;
|
||||
/** Inclusive start index inside the original input string. */
|
||||
start: number;
|
||||
/** Exclusive end index inside the original input string. */
|
||||
end: number;
|
||||
/**
|
||||
* Confidence score in the range [0, 1].
|
||||
* Higher values indicate a stronger signal (e.g. known-prefix patterns
|
||||
* have 0.90–0.99, heuristic entropy detection scores 0.60).
|
||||
*/
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/** Options that control which detectors are run and what they return. */
|
||||
export interface DetectorOptions {
|
||||
/**
|
||||
* Restrict detection to these types only.
|
||||
* When `undefined` all registered detectors are run.
|
||||
*/
|
||||
types?: SecurityCodeType[];
|
||||
/**
|
||||
* Suppress matches whose confidence falls below this threshold.
|
||||
* Defaults to 0 (return everything).
|
||||
*/
|
||||
minConfidence?: number;
|
||||
/**
|
||||
* Return at most this many matches, ordered by descending confidence
|
||||
* then ascending start offset.
|
||||
*/
|
||||
maxMatches?: number;
|
||||
}
|
||||
|
||||
/** Options that control how detected values are masked. */
|
||||
export interface SanitizerOptions {
|
||||
/**
|
||||
* Masking strategy to apply.
|
||||
* - `full-redact` (default): replace the entire match with `[REDACTED]`
|
||||
* - `partial`: keep first 4 + last 4 chars, replace middle with `***`
|
||||
* - `hash`: replace with FNV-1a hex of the value (8 chars)
|
||||
* - `tokenize`: replace with `TOKEN_<TYPE>_<N>` where N increments per call
|
||||
*/
|
||||
strategy?: MaskingStrategy;
|
||||
/**
|
||||
* When `true` the replacement token is padded / truncated to the same
|
||||
* byte length as the original match.
|
||||
* Only meaningful for `full-redact` and `hash` strategies.
|
||||
*/
|
||||
preserveLength?: boolean;
|
||||
/**
|
||||
* Prefix used when `strategy === 'tokenize'`.
|
||||
* Defaults to `'TOKEN'`.
|
||||
*/
|
||||
tokenPrefix?: string;
|
||||
}
|
||||
|
||||
/** Result returned by `SecurityCodeSanitizer.sanitize()`. */
|
||||
export interface SanitizeResult {
|
||||
/** The sanitized output string with secrets replaced. */
|
||||
output: string;
|
||||
/** All matches that were detected and replaced. */
|
||||
matches: SecurityCodeMatch[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { luhnCheck } from "./luhn";
|
||||
@@ -0,0 +1,69 @@
|
||||
# luhn
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the Luhn algorithm for credit-card number validation. The Luhn
|
||||
check is a widely-used checksum formula that can quickly determine whether a
|
||||
sequence of digits is plausibly a valid credit-card number, and is used here
|
||||
to reduce false-positive detections in the `credit-card` pattern module.
|
||||
|
||||
## Features
|
||||
|
||||
- Strips non-digit characters before validating, accepting space- or
|
||||
dash-separated card number formats.
|
||||
- O(n) implementation with no allocations beyond the cleaned digit string.
|
||||
- Returns `false` immediately for inputs shorter than 2 digits.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { luhnCheck } from '@suaveplan/security-codes/utils/luhn';
|
||||
|
||||
luhnCheck('4532015112830366'); // true — valid Visa test number
|
||||
luhnCheck('1234567890123456'); // false — fails Luhn
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { luhnCheck } from '@suaveplan/security-codes/utils/luhn';
|
||||
|
||||
// Space-separated card numbers are handled transparently
|
||||
luhnCheck('4532 0151 1283 0366'); // true
|
||||
|
||||
// Short or empty strings are safely rejected
|
||||
luhnCheck(''); // false
|
||||
luhnCheck('4'); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `luhnCheck(digits: string): boolean`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `digits` | `string` | Raw digit string; non-digit chars are stripped before validation |
|
||||
|
||||
Returns `true` when `digits` passes the Luhn checksum and contains at least
|
||||
two digit characters, `false` otherwise. Does not throw.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The algorithm doubles every second digit from the right (starting at index
|
||||
`length - 2`). When doubling produces a value greater than 9, 9 is subtracted.
|
||||
All digits are summed; a total divisible by 10 indicates a valid number.
|
||||
|
||||
This implementation intentionally avoids any dependency on built-in crypto or
|
||||
external libraries so that it can be used in both Node.js and browser
|
||||
environments without a polyfill.
|
||||
|
||||
The Luhn check is used by the `credit-card` pattern module as a secondary gate
|
||||
after the initial regex match. A 16-digit string matching the card-number
|
||||
pattern but failing the Luhn check is discarded rather than reported, which
|
||||
significantly reduces false positives in documents containing order IDs,
|
||||
tracking numbers, or other long numeric strings. Note that Luhn validation
|
||||
confirms mathematical plausibility only — it does not verify that the card
|
||||
number has ever been issued or is currently active.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { luhnCheck } from "./luhn.js";
|
||||
|
||||
describe("luhnCheck", () => {
|
||||
it("returns true for a valid Visa test number", { timeout: 5000 }, () => {
|
||||
expect(luhnCheck("4532015112830366")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a valid Mastercard test number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(luhnCheck("5425233430109903")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a valid Amex test number", { timeout: 5000 }, () => {
|
||||
// 378282246310005 is a standard Amex test number (Luhn-valid)
|
||||
expect(luhnCheck("378282246310005")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a space-separated valid card number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(luhnCheck("4532 0151 1283 0366")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for a dash-separated valid card number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(luhnCheck("4532-0151-1283-0366")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a sequential number that fails Luhn", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(luhnCheck("1234567890123456")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty string", { timeout: 5000 }, () => {
|
||||
expect(luhnCheck("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a single digit", { timeout: 5000 }, () => {
|
||||
expect(luhnCheck("4")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a number that is off-by-one", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Change last digit of valid Visa number
|
||||
expect(luhnCheck("4532015112830367")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a valid 16-digit Visa test number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 4111111111111111 is a standard Visa test number (Luhn-valid)
|
||||
expect(luhnCheck("4111111111111111")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Validates a credit-card number string using the Luhn algorithm.
|
||||
*
|
||||
* @param digits - The digit string to validate (non-digit characters are stripped).
|
||||
* @returns `true` when the number passes the Luhn check, `false` otherwise.
|
||||
*/
|
||||
export function luhnCheck(digits: string): boolean {
|
||||
const nums = digits.replace(/\D/g, "");
|
||||
if (nums.length < 2) return false;
|
||||
let sum = 0;
|
||||
let alternate = false;
|
||||
for (let i = nums.length - 1; i >= 0; i--) {
|
||||
let n = Number.parseInt(nums[i] as string, 10);
|
||||
if (alternate) {
|
||||
n *= 2;
|
||||
if (n > 9) n -= 9;
|
||||
}
|
||||
sum += n;
|
||||
alternate = !alternate;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are empty (leaf package)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([]);
|
||||
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(["@opentelemetry/api", "tslib", "zod"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/security-codes",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user