Skip to content

Commit 49381af

Browse files
authored
Merge pull request #18 from kellenmurphy/feat/fuzz-testing
Add property-based fuzz tests
2 parents afd11c0 + 723cfc5 commit 49381af

4 files changed

Lines changed: 112 additions & 1 deletion

File tree

SECURITY.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ Only the latest deployment of samlguy.com is actively maintained.
1010

1111
Please do not open public GitHub issues for security vulnerabilities.
1212

13-
Report via email to: **samlguy@kellenmurphy.com**
13+
**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.
14+
15+
**Alternate:** Email **me@kellenmurphy.com** if you prefer not to use GitHub.
1416

1517
Include:
1618

@@ -116,6 +118,10 @@ A [Scorecard](https://securityscorecards.dev) workflow runs weekly and on every
116118

117119
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.
118120

121+
### Fuzzing
122+
123+
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.
124+
119125
---
120126

121127
## License

package-lock.json

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@vitest/coverage-v8": "^4.1.5",
2727
"eslint": "^10.3.0",
2828
"eslint-plugin-svelte": "^3.17.1",
29+
"fast-check": "^4.8.0",
2930
"globals": "^17.6.0",
3031
"happy-dom": "^20.9.0",
3132
"jsdom": "^29.1.1",

src/lib/fuzz.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it } from 'vitest';
2+
import * as fc from 'fast-check';
3+
import { decodeSaml, decodeAllSaml } from '$lib/saml';
4+
import { decodeJwt } from '$lib/jwt';
5+
import { decodeCert } from '$lib/cert';
6+
import { decodeAllGeneric } from '$lib/generic';
7+
8+
// Invariant: any thrown value must be an Error instance.
9+
// A non-Error throw (string, plain object, undefined, etc.) is a parser bug.
10+
function neverThrowsNonError(fn: () => unknown): boolean {
11+
try {
12+
fn();
13+
return true;
14+
} catch (e) {
15+
return e instanceof Error;
16+
}
17+
}
18+
19+
function bytesToBase64(bytes: Uint8Array): string {
20+
return btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(''));
21+
}
22+
23+
describe('fuzz: decodeSaml', () => {
24+
it('never throws a non-Error for arbitrary string input', () => {
25+
fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeSaml(s))));
26+
});
27+
});
28+
29+
describe('fuzz: decodeAllSaml', () => {
30+
it('never throws a non-Error for arbitrary string input', () => {
31+
fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeAllSaml(s))));
32+
});
33+
});
34+
35+
describe('fuzz: decodeJwt', () => {
36+
it('never throws a non-Error for arbitrary string input', () => {
37+
fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeJwt(s))));
38+
});
39+
});
40+
41+
describe('fuzz: decodeCert — arbitrary base64 strings', () => {
42+
it('never throws a non-Error', () => {
43+
fc.assert(
44+
fc.property(fc.base64String(), (s) => neverThrowsNonError(() => decodeCert(s)))
45+
);
46+
});
47+
});
48+
49+
describe('fuzz: decodeCert — valid base64 of arbitrary byte sequences', () => {
50+
it('never throws a non-Error', () => {
51+
fc.assert(
52+
fc.property(fc.uint8Array({ maxLength: 4096 }), (bytes) =>
53+
neverThrowsNonError(() => decodeCert(bytesToBase64(bytes)))
54+
)
55+
);
56+
});
57+
});
58+
59+
describe('fuzz: decodeAllGeneric', () => {
60+
it('never throws a non-Error for arbitrary string input', () => {
61+
fc.assert(fc.property(fc.string(), (s) => neverThrowsNonError(() => decodeAllGeneric(s))));
62+
});
63+
});

0 commit comments

Comments
 (0)