diff --git a/SECURITY.md b/SECURITY.md index 6dd05c7..ec82078 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,9 @@ Only the latest deployment of samlguy.com is actively maintained. Please do not open public GitHub issues for security vulnerabilities. -Report via email to: **samlguy@kellenmurphy.com** +**Preferred:** Use GitHub's private vulnerability reporting — [Report a vulnerability](https://github.com/kellenmurphy/samlguy/security/advisories/new). Reports submitted this way are visible only to the maintainer until a Security Advisory is published. + +**Alternate:** Email **me@kellenmurphy.com** if you prefer not to use GitHub. Include: @@ -116,6 +118,10 @@ A [Scorecard](https://securityscorecards.dev) workflow runs weekly and on every The CI workflow enforces test coverage via Vitest and uploads results to Codecov. All library modules (`src/lib/`) maintain 100% statement, branch, function, and line coverage. A drop in coverage fails the build. +### Fuzzing + +Property-based fuzz tests (`src/lib/fuzz.test.ts`) run against every parser on every CI build using [fast-check](https://github.com/dubzzz/fast-check). The core invariant tested: for any arbitrary input — random strings, arbitrary byte sequences encoded as base64, or malformed base64 — each parser either returns a valid result or throws an `Error` instance. A non-Error throw (string, plain object, `undefined`) is treated as a test failure. This exercises the SAML decoder, JWT decoder, X.509 DER/ASN.1 parser, and generic fallback decoder against inputs they would never receive in normal use. + --- ## License diff --git a/package-lock.json b/package-lock.json index ecb1d84..4cc07a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.3.0", "eslint-plugin-svelte": "^3.17.1", + "fast-check": "^4.8.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", "jsdom": "^29.1.1", @@ -3513,6 +3514,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4737,6 +4761,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/package.json b/package.json index 770bd6a..190db7b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@vitest/coverage-v8": "^4.1.5", "eslint": "^10.3.0", "eslint-plugin-svelte": "^3.17.1", + "fast-check": "^4.8.0", "globals": "^17.6.0", "happy-dom": "^20.9.0", "jsdom": "^29.1.1", diff --git a/src/lib/fuzz.test.ts b/src/lib/fuzz.test.ts new file mode 100644 index 0000000..59be69c --- /dev/null +++ b/src/lib/fuzz.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'vitest'; +import * as fc from 'fast-check'; +import { decodeSaml, decodeAllSaml } from '$lib/saml'; +import { decodeJwt } from '$lib/jwt'; +import { decodeCert } from '$lib/cert'; +import { decodeAllGeneric } from '$lib/generic'; + +// Invariant: any thrown value must be an Error instance. +// A non-Error throw (string, plain object, undefined, etc.) is a parser bug. +function neverThrowsNonError(fn: () => unknown): boolean { + try { + fn(); + return true; + } catch (e) { + return e instanceof Error; + } +} + +function bytesToBase64(bytes: Uint8Array): string { + return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join('')); +} + +describe('fuzz: decodeSaml', () => { + it('never throws a non-Error for arbitrary string input', () => { + fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeSaml(s)))); + }); +}); + +describe('fuzz: decodeAllSaml', () => { + it('never throws a non-Error for arbitrary string input', () => { + fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeAllSaml(s)))); + }); +}); + +describe('fuzz: decodeJwt', () => { + it('never throws a non-Error for arbitrary string input', () => { + fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeJwt(s)))); + }); +}); + +describe('fuzz: decodeCert — arbitrary base64 strings', () => { + it('never throws a non-Error', () => { + fc.assert( + fc.property(fc.base64String(), (s) => neverThrowsNonError(() => decodeCert(s))) + ); + }); +}); + +describe('fuzz: decodeCert — valid base64 of arbitrary byte sequences', () => { + it('never throws a non-Error', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 4096 }), (bytes) => + neverThrowsNonError(() => decodeCert(bytesToBase64(bytes))) + ) + ); + }); +}); + +describe('fuzz: decodeAllGeneric', () => { + it('never throws a non-Error for arbitrary string input', () => { + fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeAllGeneric(s)))); + }); +});