Merge pull request #1985 from PR0M3TH3AN/ai/fuzz-suite-20260130-14030343412094288093

Add fuzz testing suite for Nostr schemas, DM decryptor, and Magnet utils
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-01-30 11:14:31 -05:00
commit 8a3e337e97
9 files changed
+4 -736

No files matched your search

-153
View File
@@ -1,153 +0,0 @@
import './fuzz-setup.mjs';
import * as dm from '../../js/dmDecryptor.js';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const REPRODUCER_DIR = 'examples/reproducers';
const REPORT_FILE = 'artifacts/fuzz-report-dm.json';
const ITERATIONS = 5000;
// Verify export
if (typeof dm.decryptDM !== 'function') {
console.error("Error: decryptDM is not exported from js/dmDecryptor.js");
process.exit(1);
}
// Random generators
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
function getRandomString(length = 100) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function getRandomHex(length = 64) {
const chars = '0123456789abcdef';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function getRandomTags() {
const numTags = getRandomInt(5);
const tags = [];
for(let i=0; i<numTags; i++) {
const tagLen = getRandomInt(4) + 1;
const tag = [];
tag.push(Math.random() > 0.5 ? 'p' : (Math.random() > 0.5 ? 'encrypted' : getRandomString(5)));
for(let j=1; j<tagLen; j++) {
tag.push(Math.random() > 0.8 ? getRandomHex(64) : getRandomString(20));
}
tags.push(tag);
}
// inject malformed tags
if (Math.random() < 0.1) tags.push("not-an-array");
if (Math.random() < 0.1) tags.push([]);
return tags;
}
async function run() {
console.log(`Starting fuzzing DM decryptor for ${ITERATIONS} iterations...`);
const report = {
target: 'js/dmDecryptor.js',
timestamp: new Date().toISOString(),
iterations: ITERATIONS,
crashes: [],
};
const uniqueCrashes = new Set();
for (let i = 0; i < ITERATIONS; i++) {
const event = {
kind: Math.random() > 0.5 ? 4 : 1059,
pubkey: getRandomHex(64),
created_at: Math.floor(Date.now() / 1000),
tags: getRandomTags(),
content: getRandomString(50),
};
// Malform event
if (Math.random() < 0.1) event.kind = "string";
if (Math.random() < 0.1) event.tags = null;
if (Math.random() < 0.1) event.content = null;
const context = {
actorPubkey: getRandomHex(64),
decryptors: [
{
scheme: 'nip44',
decrypt: async (pk, ciphertext) => {
if (Math.random() < 0.1) throw new Error("Decrypt failed");
if (Math.random() < 0.1) return null;
return "decrypted-content";
},
priority: 1
},
{
scheme: 'nip04',
decrypt: async () => "legacy-decrypted",
priority: 0
}
]
};
// Malform context
if (Math.random() < 0.1) context.decryptors = null;
if (Math.random() < 0.1) context.decryptors = [null, {}];
try {
await dm.decryptDM(event, context);
} catch (err) {
const crashKey = `decryptDM:${err.message}`;
if (uniqueCrashes.has(crashKey)) {
continue;
}
uniqueCrashes.add(crashKey);
let safeInput = "Could not stringify input";
try {
safeInput = JSON.stringify({ event, context: { ...context, decryptors: 'mocked' } });
} catch (e) {
safeInput = "[Input causes JSON.stringify to throw]";
}
const crashInfo = {
function: 'decryptDM',
input: safeInput,
error: err.toString(),
stack: err.stack,
};
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
const filename = `crash-dm-${hash}.json`;
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
report.crashes.push({
file: filename,
function: 'decryptDM',
error: err.message
});
console.error(`[CRASH] decryptDM failed! Saved to ${filename}`);
}
}
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
}
run();
+1
View File
@@ -1,3 +1,4 @@
// Fuzz harness for js/dmDecryptor.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import { decryptDM } from "../../js/dmDecryptor.js";
+1
View File
@@ -1,3 +1,4 @@
// Fuzzing library for agent scripts
import fs from "fs";
import path from "path";
import crypto from "crypto";
-120
View File
@@ -1,120 +0,0 @@
import './fuzz-setup.mjs';
import * as magnetUtils from '../../js/magnetUtils.js';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const REPRODUCER_DIR = 'examples/reproducers';
const REPORT_FILE = 'artifacts/fuzz-report-magnet.json';
const ITERATIONS = 5000;
function getRandomString(length = 100) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./% ';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function getRandomMagnet() {
const hash = getRandomString(40).replace(/[^0-9a-f]/gi, 'a');
return `magnet:?xt=urn:btih:${hash}&dn=${getRandomString(10)}&tr=${encodeURIComponent(getRandomString(20))}`;
}
async function run() {
console.log(`Starting fuzzing magnet utils for ${ITERATIONS} iterations...`);
const report = {
target: 'js/magnetUtils.js',
timestamp: new Date().toISOString(),
iterations: ITERATIONS,
crashes: [],
};
const uniqueCrashes = new Set();
const targets = [
{ name: 'normalizeAndAugmentMagnet', fn: magnetUtils.normalizeAndAugmentMagnet, args: 2 },
{ name: 'safeDecodeMagnet', fn: magnetUtils.safeDecodeMagnet, args: 1 },
];
for (const { name, fn, args } of targets) {
for (let i = 0; i < ITERATIONS; i++) {
let input, options;
if (Math.random() < 0.3) {
input = getRandomMagnet();
} else if (Math.random() < 0.3) {
input = getRandomString(getRandomInt(200));
} else {
input = null; // or undefined, or object
if (Math.random() < 0.5) input = {};
}
if (args > 1) {
options = {
webSeed: Math.random() < 0.5 ? getRandomString(20) : [getRandomString(20)],
torrentUrl: getRandomString(20),
extraTrackers: [getRandomString(20)],
appProtocol: 'https:',
};
if (Math.random() < 0.1) options = null;
if (Math.random() < 0.1) options = "string";
}
try {
if (args === 1) {
fn(input);
} else {
fn(input, options);
}
} catch (err) {
const crashKey = `${name}:${err.message}`;
if (uniqueCrashes.has(crashKey)) {
continue;
}
uniqueCrashes.add(crashKey);
let safeInput = "Could not stringify input";
try {
safeInput = JSON.stringify(args === 1 ? input : { input, options });
} catch (e) {
safeInput = "[Input causes JSON.stringify to throw]";
}
const crashInfo = {
function: name,
input: safeInput,
error: err.toString(),
stack: err.stack,
};
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
const filename = `crash-magnet-${name}-${hash}.json`;
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
report.crashes.push({
file: filename,
function: name,
error: err.message
});
console.error(`[CRASH] ${name} failed! Saved to ${filename}`);
}
}
}
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
}
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
run();
+1
View File
@@ -1,3 +1,4 @@
// Fuzz harness for js/magnetUtils.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import { normalizeAndAugmentMagnet } from "../../js/magnetUtils.js";
+1
View File
@@ -1,3 +1,4 @@
// Fuzz harness for js/nostrEventSchemas.js
import "./setup-test-env.js";
import { Fuzzer } from "./fuzz-lib.mjs";
import * as schemas from "../../js/nostrEventSchemas.js";
-217
View File
@@ -1,217 +0,0 @@
import './fuzz-setup.mjs';
import * as schemas from '../../js/nostrEventSchemas.js';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const REPRODUCER_DIR = 'examples/reproducers';
const REPORT_FILE = 'artifacts/fuzz-report-schemas.json';
const ITERATIONS = 5000;
// Fuzzing inputs generator
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
function getRandomString(length = 100) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}|:"<>?~`-=[]\;\',./';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function getRandomUnicodeString(length = 100) {
let result = '';
// Include surrogates and other edge cases
for (let i = 0; i < length; i++) {
if (Math.random() < 0.1) {
// Surrogate pair halves
result += String.fromCharCode(0xD800 + Math.random() * 0x800);
} else {
result += String.fromCharCode(Math.random() * 0xFFFF);
}
}
return result;
}
function getRandomValue(depth = 0) {
if (depth > 3) return null;
const type = Math.random();
if (type < 0.1) return null;
if (type < 0.2) return undefined;
if (type < 0.3) return getRandomInt(100000);
if (type < 0.5) return getRandomString(getRandomInt(50));
if (type < 0.6) return getRandomUnicodeString(getRandomInt(50));
if (type < 0.7) return true;
if (type < 0.8) return false;
if (type < 0.9) {
const arr = [];
const len = getRandomInt(5);
for(let i=0; i<len; i++) arr.push(getRandomValue(depth + 1));
return arr;
}
const obj = {};
const len = getRandomInt(5);
for(let i=0; i<len; i++) {
obj[getRandomString(5)] = getRandomValue(depth + 1);
}
return obj;
}
const interestingValues = [
null,
undefined,
0,
1,
-1,
Infinity,
-Infinity,
NaN,
"",
" ",
"\n",
"\t",
"{}",
"[]",
"true",
"false",
"null",
"undefined",
{ toString: () => { throw new Error('toString throw'); } },
{ toJSON: () => { throw new Error('toJSON throw'); } },
new Array(1000).fill('a').join(''), // large string
"javascript:alert(1)",
"__proto__",
"constructor",
];
function generateFuzzInput(paramsSchema) {
// If we knew the schema of params, we could be smarter.
// For now, generate a random object with random keys, plus keys that likely exist.
const input = {};
const commonKeys = [
'pubkey', 'created_at', 'content', 'tags', 'additionalTags',
'id', 'kind', 'dTagValue', 'eventId', 'eventRelay', 'address',
'addressRelay', 'authorPubkey', 'repostKind', 'targetKind',
'targetEvent', 'serializedEvent', 'video', 'relays', 'metadata',
'pTags', 'eventIds', 'addresses', 'reason', 'recipientPubkey',
'ciphertext', 'attachment', 'messageKind', 'expiresAt',
'pointerValue', 'pointerTag', 'pointerTags', 'dedupeTag',
'includeSessionTag', 'amountSats', 'lnurl', 'coordinate',
'targetPointer', 'targetAuthorPubkey', 'videoEventId',
'videoEventRelay', 'videoDefinitionAddress', 'rootIdentifier',
'parentCommentId', 'threadParticipantPubkey', 'rootKind',
'monthIdentifier', 'encryption', 'hexPubkeys', 'listKey'
];
// Pick some common keys and assign random or interesting values
const numKeys = getRandomInt(10);
for (let i = 0; i < numKeys; i++) {
const key = commonKeys[getRandomInt(commonKeys.length)];
const useInteresting = Math.random() < 0.2;
input[key] = useInteresting ? interestingValues[getRandomInt(interestingValues.length)] : getRandomValue();
}
// Also add completely random keys
const numRandomKeys = getRandomInt(3);
for (let i = 0; i < numRandomKeys; i++) {
input[getRandomString(5)] = getRandomValue();
}
return input;
}
async function run() {
console.log(`Starting fuzzing schemas for ${ITERATIONS} iterations...`);
const functionsToFuzz = [
{ name: 'buildVideoPostEvent', fn: schemas.buildVideoPostEvent },
{ name: 'buildVideoMirrorEvent', fn: schemas.buildVideoMirrorEvent },
{ name: 'buildRepostEvent', fn: schemas.buildRepostEvent },
{ name: 'buildShareEvent', fn: schemas.buildShareEvent },
{ name: 'buildRelayListEvent', fn: schemas.buildRelayListEvent },
{ name: 'buildDmRelayListEvent', fn: schemas.buildDmRelayListEvent },
{ name: 'buildProfileMetadataEvent', fn: schemas.buildProfileMetadataEvent },
{ name: 'buildMuteListEvent', fn: schemas.buildMuteListEvent },
{ name: 'buildDeletionEvent', fn: schemas.buildDeletionEvent },
{ name: 'buildLegacyDirectMessageEvent', fn: schemas.buildLegacyDirectMessageEvent },
{ name: 'buildDmAttachmentEvent', fn: schemas.buildDmAttachmentEvent },
{ name: 'buildDmReadReceiptEvent', fn: schemas.buildDmReadReceiptEvent },
{ name: 'buildDmTypingIndicatorEvent', fn: schemas.buildDmTypingIndicatorEvent },
{ name: 'buildViewEvent', fn: schemas.buildViewEvent },
{ name: 'buildZapRequestEvent', fn: schemas.buildZapRequestEvent },
{ name: 'buildReactionEvent', fn: schemas.buildReactionEvent },
{ name: 'buildCommentEvent', fn: schemas.buildCommentEvent },
{ name: 'buildWatchHistoryEvent', fn: schemas.buildWatchHistoryEvent },
{ name: 'buildSubscriptionListEvent', fn: schemas.buildSubscriptionListEvent },
{ name: 'buildBlockListEvent', fn: schemas.buildBlockListEvent },
{ name: 'buildHashtagPreferenceEvent', fn: schemas.buildHashtagPreferenceEvent },
{ name: 'buildAdminListEvent', fn: (params) => schemas.buildAdminListEvent(params.listKey, params) }, // Wrapper since it takes 2 args
];
const report = {
target: 'js/nostrEventSchemas.js',
timestamp: new Date().toISOString(),
iterations: ITERATIONS,
crashes: [],
};
const uniqueCrashes = new Set();
for (const { name, fn } of functionsToFuzz) {
for (let i = 0; i < ITERATIONS; i++) {
const input = generateFuzzInput();
try {
const result = fn(input);
// Optional: validate output structure
// if (result) {
// schemas.validateEventAgainstSchema(result.kind, result);
// }
} catch (err) {
const crashKey = `${name}:${err.message}`;
if (uniqueCrashes.has(crashKey)) {
continue;
}
uniqueCrashes.add(crashKey);
let safeInput = "Could not stringify input";
try {
safeInput = JSON.stringify(input, (k, v) => v === undefined ? '__undefined__' : v);
} catch (e) {
safeInput = "[Input causes JSON.stringify to throw]";
}
const crashInfo = {
function: name,
input: safeInput,
error: err.toString(),
stack: err.stack,
};
// Save reproducer
const hash = crypto.createHash('sha256').update(JSON.stringify(crashInfo)).digest('hex').slice(0, 8);
const filename = `crash-schemas-${name}-${hash}.json`;
fs.writeFileSync(path.join(REPRODUCER_DIR, filename), JSON.stringify(crashInfo, null, 2));
report.crashes.push({
file: filename,
function: name,
error: err.message
});
console.error(`[CRASH] ${name} failed! Saved to ${filename}`);
}
}
}
if (!fs.existsSync(path.dirname(REPORT_FILE))) {
fs.mkdirSync(path.dirname(REPORT_FILE), { recursive: true });
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
console.log(`Fuzzing complete. Report saved to ${REPORT_FILE}. Crashes: ${report.crashes.length}`);
}
run();
-55
View File
@@ -1,55 +0,0 @@
import crypto from 'node:crypto';
// Mock localStorage
const localStorageStore = new Map();
const localStorageMock = {
getItem: (key) => localStorageStore.get(String(key)) ?? null,
setItem: (key, value) => localStorageStore.set(String(key), String(value)),
removeItem: (key) => localStorageStore.delete(String(key)),
clear: () => localStorageStore.clear(),
key: (index) => Array.from(localStorageStore.keys())[index] ?? null,
get length() {
return localStorageStore.size;
},
};
// Mock window
const windowMock = {
localStorage: localStorageMock,
crypto: {
getRandomValues: (buffer) => crypto.getRandomValues(buffer),
subtle: crypto.webcrypto.subtle,
},
location: {
protocol: 'https:',
hostname: 'bitvid.network',
href: 'https://bitvid.network/',
},
};
// Polyfill globals
if (typeof globalThis.window === 'undefined') {
globalThis.window = windowMock;
}
if (typeof globalThis.localStorage === 'undefined') {
globalThis.localStorage = localStorageMock;
}
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = windowMock.crypto;
}
if (typeof globalThis.WebSocket === 'undefined') {
globalThis.WebSocket = class WebSocket {
constructor(url) {
this.url = url;
this.readyState = 0;
}
send() {}
close() {}
};
}
// Mock bitvid-specific globals if needed (though config.js sets them on window)
globalThis.bitvidNostrEventOverrides = {};
-191
View File
@@ -1,191 +0,0 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPRODUCER_DIR = path.join(process.cwd(), "examples/reproducers");
const ARTIFACTS_DIR = path.join(process.cwd(), "artifacts");
// Ensure directories exist
if (!fs.existsSync(REPRODUCER_DIR)) {
fs.mkdirSync(REPRODUCER_DIR, { recursive: true });
}
if (!fs.existsSync(ARTIFACTS_DIR)) {
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
}
export const rng = {
bool: () => Math.random() > 0.5,
int: (min = 0, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min,
float: (min = 0, max = 100) => Math.random() * (max - min) + min,
oneOf: (arr) => arr[Math.floor(Math.random() * arr.length)],
string: (maxLength = 100) => {
const len = Math.floor(Math.random() * maxLength);
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < len; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
},
nastyString: () => {
const candidates = [
"",
"null",
"undefined",
"NaN",
"\x00",
"\n",
"\r",
"\t",
" ",
" ",
"https://example.com",
"wss://relay.example.com",
"<script>alert(1)</script>",
"' OR 1=1 --",
"javascript:void(0)",
"data:text/plain;base64,SGVsbG8=",
"😀",
"ñ",
"\uD83D\uDCA9", // Pile of poo
"\uD800", // Lone surrogate (high)
"\uDFFF", // Lone surrogate (low)
"A".repeat(1000), // Long string
"A".repeat(10000), // Very long string
JSON.stringify({ foo: "bar" }),
"{}",
"[]"
];
return candidates[Math.floor(Math.random() * candidates.length)];
},
mixedString: (maxLength = 100) => {
if (Math.random() < 0.3) return rng.nastyString();
return rng.string(maxLength);
},
array: (generator, maxLen = 10) => {
const len = rng.int(0, maxLen);
const result = [];
for (let i = 0; i < len; i++) {
result.push(generator());
}
return result;
},
object: (keyGen, valGen, maxKeys = 10) => {
const len = rng.int(0, maxKeys);
const result = {};
for (let i = 0; i < len; i++) {
result[keyGen()] = valGen();
}
return result;
},
recursiveObject: (depth = 3) => {
if (depth <= 0) return rng.mixedString();
const type = rng.int(0, 4);
if (type === 0) return rng.int(-1000, 1000);
if (type === 1) return rng.mixedString();
if (type === 2) return rng.bool();
if (type === 3) return null;
if (type === 4) {
// Array
const len = rng.int(0, 5);
const arr = [];
for(let i=0; i<len; i++) arr.push(rng.recursiveObject(depth - 1));
return arr;
}
// Object
const len = rng.int(0, 5);
const obj = {};
for(let i=0; i<len; i++) {
obj[rng.string(10)] = rng.recursiveObject(depth - 1);
}
return obj;
}
};
export function saveReproducer(targetName, input, error) {
const timestamp = Date.now();
const filename = `fuzz-${targetName}-${timestamp}.json`;
const filepath = path.join(REPRODUCER_DIR, filename);
const content = {
target: targetName,
timestamp: new Date().toISOString(),
error: {
message: error.message,
stack: error.stack,
code: error.code,
name: error.name
},
input
};
fs.writeFileSync(filepath, JSON.stringify(content, null, 2));
console.error(`[FUZZ] Saved reproducer to ${filepath}`);
return filepath;
}
export function saveReport(targetName, stats) {
const filepath = path.join(ARTIFACTS_DIR, `fuzz-report-${targetName}.json`);
fs.writeFileSync(filepath, JSON.stringify(stats, null, 2));
console.log(`[FUZZ] Saved report to ${filepath}`);
}
export async function runFuzzer(targetName, iterations, testFn) {
console.log(`[FUZZ] Starting fuzzer for ${targetName} with ${iterations} iterations...`);
const stats = {
target: targetName,
iterations: 0,
passed: 0,
failed: 0,
crashes: [], // List of { input, error, reproducerPath }
startTime: Date.now(),
endTime: null
};
for (let i = 0; i < iterations; i++) {
stats.iterations++;
let input = null;
try {
input = await testFn(i);
stats.passed++;
} catch (err) {
stats.failed++;
const reproducerPath = saveReproducer(targetName, input, err);
stats.crashes.push({
iteration: i,
error: err.message,
reproducerPath
});
// Optionally stop on first crash or continue
// For now, we continue but log it.
}
if (i % 100 === 0 && i > 0) {
process.stdout.write(".");
}
}
process.stdout.write("\n");
stats.endTime = Date.now();
stats.duration = stats.endTime - stats.startTime;
console.log(`[FUZZ] Finished. ${stats.passed} passed, ${stats.failed} failed.`);
saveReport(targetName, stats);
return stats;
}