mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
feat: add fuzz testing harness and fix repost builder
- Created generic fuzzing library `scripts/agent/fuzz-lib.mjs`. - Implemented fuzz targets for `nostrEventSchemas.js`, `dmDecryptor.js`, and `magnetUtils.js`. - Fixed `buildRepostEvent` to allow missing relay URLs for robustness. - Added crash reproducers to `examples/reproducers/`.
This commit is contained in:
1 parent
5726997475
commit
5c2e9cd7f8
26 files changed
+1041
-3
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import { Fuzzer } from "./fuzz-lib.mjs";
|
||||
import { decryptDM } from "../../js/dmDecryptor.js";
|
||||
|
||||
// Mock global dependencies
|
||||
if (typeof global.window === "undefined") {
|
||||
global.window = {
|
||||
location: { protocol: "https:" },
|
||||
crypto: {
|
||||
getRandomValues: (arr) => crypto.randomFillSync(arr),
|
||||
subtle: {
|
||||
digest: async (algo, data) => {
|
||||
const hash = crypto.createHash("sha256").update(data).digest();
|
||||
return hash.buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof global.localStorage === "undefined") {
|
||||
global.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {},
|
||||
removeItem: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
const fuzzer = new Fuzzer("dm-decryptor");
|
||||
|
||||
async function test(fuzzer, state) {
|
||||
// Generate random event
|
||||
const event = {
|
||||
kind: fuzzer.randBool() ? fuzzer.pick([4, 1059]) : fuzzer.randInt(0, 20000), // 4=DM, 1059=GiftWrap
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: fuzzer.randArray(() => {
|
||||
const tagType = fuzzer.pick(["p", "e", "encrypted"]);
|
||||
if (tagType === "p") return ["p", fuzzer.randString(64, "0123456789abcdef")];
|
||||
if (tagType === "encrypted") return ["encrypted", fuzzer.pick(["nip04", "nip44", "nip44_v2"])];
|
||||
return [fuzzer.randString(5), fuzzer.randString(10)];
|
||||
}, 0, 5),
|
||||
content: fuzzer.randUnicodeString(fuzzer.randInt(0, 200)),
|
||||
pubkey: fuzzer.randString(64, "0123456789abcdef"),
|
||||
id: fuzzer.randString(64, "0123456789abcdef")
|
||||
};
|
||||
|
||||
if (fuzzer.randBool()) event.content = fuzzer.randJSON(); // Garbage content
|
||||
|
||||
// Mock decryptors
|
||||
const decryptors = fuzzer.randArray(() => {
|
||||
return {
|
||||
decrypt: async (pubkey, ciphertext) => {
|
||||
if (fuzzer.randBool()) throw new Error("Decrypt failed");
|
||||
// Return random valid JSON or garbage
|
||||
if (fuzzer.randBool()) return JSON.stringify(fuzzer.randJSON());
|
||||
return fuzzer.randUnicodeString(50);
|
||||
},
|
||||
scheme: fuzzer.pick(["nip04", "nip44", "nip44_v2", "unknown"]),
|
||||
priority: fuzzer.randInt(0, 10),
|
||||
supportsGiftWrap: fuzzer.randBool()
|
||||
};
|
||||
}, 0, 3);
|
||||
|
||||
const context = {
|
||||
actorPubkey: fuzzer.randString(64, "0123456789abcdef"),
|
||||
decryptors: decryptors
|
||||
};
|
||||
|
||||
state.input = { event, context };
|
||||
|
||||
await decryptDM(event, context);
|
||||
}
|
||||
|
||||
fuzzer.runFuzzLoop(2000, test);
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
|
||||
export class Fuzzer {
|
||||
constructor(targetName) {
|
||||
this.targetName = targetName;
|
||||
this.artifactsDir = path.join(process.cwd(), "artifacts");
|
||||
this.reproducersDir = path.join(process.cwd(), "examples", "reproducers", targetName);
|
||||
this.reportPath = path.join(this.artifactsDir, `fuzz-report-${targetName}.json`);
|
||||
this.issues = [];
|
||||
|
||||
if (!fs.existsSync(this.artifactsDir)) {
|
||||
fs.mkdirSync(this.artifactsDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.reproducersDir)) {
|
||||
fs.mkdirSync(this.reproducersDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Random Primitives
|
||||
|
||||
randInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
randBool() {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
randByte() {
|
||||
return this.randInt(0, 255);
|
||||
}
|
||||
|
||||
randBytes(length) {
|
||||
return crypto.randomBytes(length);
|
||||
}
|
||||
|
||||
randString(length, charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") {
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset.charAt(this.randInt(0, charset.length - 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randUnicodeString(length) {
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
// Basic multilingual plane + some surrogate pairs
|
||||
if (Math.random() < 0.1) {
|
||||
// High surrogate
|
||||
result += String.fromCharCode(this.randInt(0xD800, 0xDBFF));
|
||||
} else if (Math.random() < 0.1) {
|
||||
// Low surrogate
|
||||
result += String.fromCharCode(this.randInt(0xDC00, 0xDFFF));
|
||||
} else {
|
||||
result += String.fromCharCode(this.randInt(0, 0xFFFF));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randBuffer(length) {
|
||||
return this.randBytes(length);
|
||||
}
|
||||
|
||||
// Structured Data
|
||||
|
||||
randArray(generator, minLen = 0, maxLen = 10) {
|
||||
const len = this.randInt(minLen, maxLen);
|
||||
const arr = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
arr.push(generator());
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
randObject(schema) {
|
||||
const obj = {};
|
||||
for (const [key, generator] of Object.entries(schema)) {
|
||||
if (Math.random() > 0.1) { // 10% chance to miss field
|
||||
obj[key] = generator();
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
randJSON() {
|
||||
const types = ["string", "number", "object", "array", "boolean", "null"];
|
||||
const type = types[this.randInt(0, types.length - 1)];
|
||||
|
||||
switch (type) {
|
||||
case "string": return this.randUnicodeString(this.randInt(0, 100));
|
||||
case "number": return Math.random() * 10000;
|
||||
case "boolean": return this.randBool();
|
||||
case "null": return null;
|
||||
case "array": return this.randArray(() => this.randJSON(), 0, 3); // keep depth low
|
||||
case "object": {
|
||||
const obj = {};
|
||||
const keys = this.randInt(0, 5);
|
||||
for(let i=0; i<keys; i++) {
|
||||
obj[this.randString(5)] = this.randJSON();
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateMalformedJSON() {
|
||||
const json = JSON.stringify(this.randJSON());
|
||||
// Mutate
|
||||
const mutationType = this.randInt(0, 2);
|
||||
if (mutationType === 0) {
|
||||
// Truncate
|
||||
return json.slice(0, this.randInt(0, json.length));
|
||||
} else if (mutationType === 1) {
|
||||
// Insert garbage
|
||||
const pos = this.randInt(0, json.length);
|
||||
return json.slice(0, pos) + this.randString(5) + json.slice(pos);
|
||||
} else {
|
||||
// Delete chunk
|
||||
const start = this.randInt(0, json.length);
|
||||
const end = this.randInt(start, json.length);
|
||||
return json.slice(0, start) + json.slice(end);
|
||||
}
|
||||
}
|
||||
|
||||
pick(arr) {
|
||||
if (arr.length === 0) return undefined;
|
||||
return arr[this.randInt(0, arr.length - 1)];
|
||||
}
|
||||
|
||||
// Execution
|
||||
|
||||
async runFuzzLoop(iterations, testFn) {
|
||||
console.log(`Starting fuzzing for ${this.targetName} with ${iterations} iterations...`);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const state = {};
|
||||
try {
|
||||
await testFn(this, state);
|
||||
} catch (err) {
|
||||
this.reportIssue(err, state.input);
|
||||
}
|
||||
|
||||
if ((i + 1) % 100 === 0) {
|
||||
process.stdout.write(".");
|
||||
}
|
||||
}
|
||||
console.log("\nFuzzing complete.");
|
||||
this.writeReport();
|
||||
}
|
||||
|
||||
reportIssue(error, input) {
|
||||
const hash = crypto.createHash("sha256").update(JSON.stringify(input) + error.stack).digest("hex").slice(0, 16);
|
||||
const issue = {
|
||||
hash,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
input: input,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Check for duplicate
|
||||
if (this.issues.some(i => i.hash === hash)) return;
|
||||
|
||||
this.issues.push(issue);
|
||||
console.error(`\n[Found Issue] ${error.message} (Hash: ${hash})`);
|
||||
|
||||
// Write reproducer
|
||||
const reproPath = path.join(this.reproducersDir, `${hash}.json`);
|
||||
fs.writeFileSync(reproPath, JSON.stringify(issue, null, 2));
|
||||
}
|
||||
|
||||
writeReport() {
|
||||
const report = {
|
||||
target: this.targetName,
|
||||
timestamp: new Date().toISOString(),
|
||||
issues: this.issues
|
||||
};
|
||||
fs.writeFileSync(this.reportPath, JSON.stringify(report, null, 2));
|
||||
console.log(`Report written to ${this.reportPath}`);
|
||||
if (this.issues.length > 0) {
|
||||
console.log(`Found ${this.issues.length} unique issues.`);
|
||||
} else {
|
||||
console.log("No issues found.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Fuzzer } from "./fuzz-lib.mjs";
|
||||
import { normalizeAndAugmentMagnet } from "../../js/magnetUtils.js";
|
||||
|
||||
// Mock global dependencies if needed
|
||||
if (typeof global.window === "undefined") {
|
||||
global.window = {
|
||||
location: { protocol: "https:" }
|
||||
};
|
||||
}
|
||||
|
||||
const fuzzer = new Fuzzer("magnet-utils");
|
||||
|
||||
async function test(fuzzer, state) {
|
||||
const rawValue = fuzzer.randBool() ?
|
||||
`magnet:?xt=urn:btih:${fuzzer.randString(40, "0123456789abcdef")}&dn=${fuzzer.randString(10)}` :
|
||||
fuzzer.randUnicodeString(100);
|
||||
|
||||
const options = {
|
||||
webSeed: fuzzer.randBool() ? fuzzer.randString(50) : fuzzer.randArray(() => fuzzer.randString(50), 0, 3),
|
||||
torrentUrl: fuzzer.randBool() ? fuzzer.randString(50) : undefined,
|
||||
xs: fuzzer.randBool() ? fuzzer.randString(50) : undefined,
|
||||
extraTrackers: fuzzer.randArray(() => fuzzer.randString(30), 0, 5),
|
||||
appProtocol: fuzzer.pick(["http:", "https:", "magnet:", "unknown:"]),
|
||||
logger: () => {} // No-op logger
|
||||
};
|
||||
|
||||
// Sometimes pass garbage options
|
||||
if (fuzzer.randBool()) {
|
||||
options.webSeed = fuzzer.randJSON();
|
||||
}
|
||||
|
||||
state.input = { rawValue, options };
|
||||
normalizeAndAugmentMagnet(rawValue, options);
|
||||
}
|
||||
|
||||
fuzzer.runFuzzLoop(5000, test);
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Fuzzer } from "./fuzz-lib.mjs";
|
||||
import * as schemas from "../../js/nostrEventSchemas.js";
|
||||
|
||||
// Mock global dependencies if needed
|
||||
if (typeof global.window === "undefined") {
|
||||
global.window = {};
|
||||
}
|
||||
|
||||
const fuzzer = new Fuzzer("nostr-schemas");
|
||||
|
||||
// Builders take a params object.
|
||||
const builders = [
|
||||
"buildVideoPostEvent",
|
||||
"buildVideoMirrorEvent",
|
||||
"buildRepostEvent",
|
||||
"buildRelayListEvent",
|
||||
"buildDmRelayListEvent",
|
||||
"buildProfileMetadataEvent",
|
||||
"buildMuteListEvent",
|
||||
"buildDmAttachmentEvent",
|
||||
"buildDmReadReceiptEvent",
|
||||
"buildDmTypingIndicatorEvent",
|
||||
"buildViewEvent",
|
||||
"buildZapRequestEvent",
|
||||
"buildReactionEvent",
|
||||
"buildCommentEvent",
|
||||
"buildWatchHistoryEvent",
|
||||
"buildSubscriptionListEvent",
|
||||
"buildBlockListEvent",
|
||||
"buildHashtagPreferenceEvent",
|
||||
];
|
||||
|
||||
async function test(fuzzer, state) {
|
||||
const action = fuzzer.randInt(0, 3);
|
||||
let input = {};
|
||||
|
||||
if (action === 0) {
|
||||
// Fuzz sanitizeAdditionalTags
|
||||
const tags = fuzzer.randArray(() => {
|
||||
if (fuzzer.randBool()) {
|
||||
return fuzzer.randArray(() => fuzzer.randString(10), 0, 5);
|
||||
} else {
|
||||
return fuzzer.randJSON(); // Garbage
|
||||
}
|
||||
}, 0, 20);
|
||||
input = { target: "sanitizeAdditionalTags", tags };
|
||||
state.input = input;
|
||||
schemas.sanitizeAdditionalTags(tags);
|
||||
|
||||
} else if (action === 1) {
|
||||
// Fuzz validateEventAgainstSchema
|
||||
const types = Object.keys(schemas.NOTE_TYPES);
|
||||
const type = fuzzer.pick(types);
|
||||
const event = {
|
||||
kind: fuzzer.randInt(0, 40000),
|
||||
content: fuzzer.randUnicodeString(100),
|
||||
tags: fuzzer.randArray(() => fuzzer.randArray(() => fuzzer.randString(10), 0, 5), 0, 10),
|
||||
pubkey: fuzzer.randString(64, "0123456789abcdef"),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
// Mutate event
|
||||
if (fuzzer.randBool()) event.content = fuzzer.randJSON();
|
||||
if (fuzzer.randBool()) event.tags = "not an array";
|
||||
|
||||
input = { target: "validateEventAgainstSchema", type, event };
|
||||
state.input = input;
|
||||
schemas.validateEventAgainstSchema(type, event);
|
||||
|
||||
} else if (action === 2) {
|
||||
// Fuzz Builders
|
||||
const builderName = fuzzer.pick(builders);
|
||||
const builderFn = schemas[builderName];
|
||||
|
||||
// Generate random params
|
||||
const params = fuzzer.randObject({
|
||||
pubkey: () => fuzzer.randString(64, "0123456789abcdef"),
|
||||
created_at: () => Math.floor(Date.now() / 1000),
|
||||
content: () => fuzzer.randUnicodeString(50),
|
||||
additionalTags: () => fuzzer.randArray(() => [fuzzer.randString(5), fuzzer.randString(10)], 0, 5),
|
||||
dTagValue: () => fuzzer.randString(10),
|
||||
eventId: () => fuzzer.randString(64, "0123456789abcdef"),
|
||||
// Add more random keys that might be expected or unexpected
|
||||
[fuzzer.randString(5)]: () => fuzzer.randJSON()
|
||||
});
|
||||
|
||||
input = { target: builderName, params };
|
||||
state.input = input;
|
||||
|
||||
if (typeof builderFn === "function") {
|
||||
builderFn(params);
|
||||
}
|
||||
} else if (action === 3) {
|
||||
// buildAdminListEvent special case
|
||||
const listKey = fuzzer.pick(["moderation", "editors", "whitelist", "blacklist", fuzzer.randString(5)]);
|
||||
const params = {
|
||||
pubkey: fuzzer.randString(64, "0123456789abcdef"),
|
||||
hexPubkeys: fuzzer.randArray(() => fuzzer.randString(64, "0123456789abcdef"), 0, 5)
|
||||
};
|
||||
input = { target: "buildAdminListEvent", listKey, params };
|
||||
state.input = input;
|
||||
schemas.buildAdminListEvent(listKey, params);
|
||||
}
|
||||
}
|
||||
|
||||
fuzzer.runFuzzLoop(2000, test);
|
||||
Reference in new issue
Block a user