Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View 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)

View 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"
]
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1 @@
export { SecurityCodeDetector } from "./code-detector";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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}`;
}
}
}
}

View File

@@ -0,0 +1 @@
export { SecurityCodeSanitizer } from "./code-sanitizer";

View File

@@ -0,0 +1,2 @@
export { SecurityCodeDetector } from "./code-detector";
export { SecurityCodeSanitizer } from "./code-sanitizer";

View File

@@ -0,0 +1,5 @@
export {
SECURITY_CODES_CODES,
type SecurityCodesCode,
SecurityCodesError,
} from "./security-codes-error";

View File

@@ -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.

View File

@@ -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;
}
}

View 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";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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`.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
export type {
DetectorOptions,
MaskingStrategy,
SanitizeResult,
SanitizerOptions,
SecurityCodeMatch,
SecurityCodeType,
} from "./security-codes.types";

View File

@@ -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.

View File

@@ -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.

View File

@@ -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[];
}

View File

@@ -0,0 +1 @@
export { luhnCheck } from "./luhn";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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"]);
});
});

View File

@@ -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([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -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);
});
});

View File

@@ -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"]
}

View File

@@ -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:/],
},
},
});

View File

@@ -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,
},
});