UNPKG

@iocium/ioc-diff

Version:

A full-featured, ESM-compatible IOC diffing and normalization library + CLI for InfoSec tooling.

211 lines (206 loc) 6.59 kB
#!/usr/bin/env node // bin/ioc-diff.ts import fs from "node:fs"; import path from "node:path"; import { hideBin } from "yargs/helpers"; import yargs from "yargs/yargs"; import yaml from "js-yaml"; import { parse as parseCSV } from "csv-parse/sync"; // src/utils.ts import validator from "validator"; function normalizeIOC(ioc) { return { ...ioc, value: ioc.value.trim().toLowerCase(), type: ioc.type?.toLowerCase() }; } function iocKey(ioc, matchBy) { const norm = normalizeIOC(ioc); return matchBy === "value+type" ? `${norm.value}|${norm.type}` : norm.value; } function isDifferent(a, b, compareTags, compareSeverity) { if (compareTags && JSON.stringify((a.tags || []).sort()) !== JSON.stringify((b.tags || []).sort())) return true; if (compareSeverity && a.severity !== b.severity) return true; return false; } function isValidDomain(value) { return validator.isFQDN(value); } function parsePlainIOCs(lines) { const seen = /* @__PURE__ */ new Set(); const iocs = []; for (const raw of lines) { const value = raw.trim(); if (!value || value.startsWith("#")) continue; const lower = value.toLowerCase(); let type; if (validator.isIP(lower)) type = "ip"; else if (validator.isHash(lower, "sha256")) type = "sha256"; else if (validator.isHash(lower, "md5")) type = "md5"; else if (validator.isURL(lower, { require_protocol: true })) type = "url"; else if (validator.isEmail(lower)) type = "email"; else if (isValidDomain(lower)) type = "domain"; else continue; const key = `${lower}|${type}`; if (seen.has(key)) continue; seen.add(key); iocs.push({ value, type }); } return iocs; } // src/diffIOCs.ts import stringSimilarity from "string-similarity"; function diffIOCs(oldIOCs2, newIOCs2, options = {}) { const matchBy = options.matchBy || "value"; const compareTags = options.compareTags || false; const compareSeverity = options.compareSeverity || false; const fuzzyMatch = options.fuzzyMatch || false; const fuzzyThreshold = options.fuzzyThreshold ?? 0.85; const oldMap = /* @__PURE__ */ new Map(); const newMap = /* @__PURE__ */ new Map(); for (const ioc of oldIOCs2) { oldMap.set(iocKey(ioc, matchBy), ioc); } for (const ioc of newIOCs2) { newMap.set(iocKey(ioc, matchBy), ioc); } const added = []; const removed = []; const changed = []; for (const [key, newIOC] of newMap.entries()) { if (!oldMap.has(key)) { let matched = false; if (fuzzyMatch) { for (const [oldKey, oldIOC] of oldMap.entries()) { const similarity = stringSimilarity.compareTwoStrings( iocKey(newIOC, matchBy), iocKey(oldIOC, matchBy) ); if (similarity >= fuzzyThreshold) { changed.push({ before: oldIOC, after: newIOC }); matched = true; break; } } } if (!matched) { added.push(newIOC); } } else { const oldIOC = oldMap.get(key); if (isDifferent(oldIOC, newIOC, compareTags, compareSeverity)) { changed.push({ before: oldIOC, after: newIOC }); } } } for (const [key, oldIOC] of oldMap.entries()) { if (!newMap.has(key)) { removed.push(oldIOC); } } return { added, removed, changed }; } // bin/ioc-diff.ts var argv = yargs(hideBin(process.argv)).option("old", { type: "string", describe: "Path to old IOC file", demandOption: true }).option("new", { type: "string", describe: "Path to new IOC file", demandOption: true }).option("old-format", { type: "string", describe: "Format of old IOC file", choices: ["plaintext", "json", "misp", "csv", "yara", "sigma"] }).option("new-format", { type: "string", describe: "Format of new IOC file", choices: ["plaintext", "json", "misp", "csv", "yara", "sigma"] }).option("fuzzy", { type: "boolean", describe: "Enable fuzzy matching", default: false }).option("threshold", { type: "number", describe: "Fuzzy match similarity threshold (0\u20131)", default: 0.85 }).help().alias("h", "help").parseSync(); function detectFormat(filename) { const ext = path.extname(filename).toLowerCase(); if (ext === ".json") return filename.endsWith(".misp.json") ? "misp" : "json"; if (ext === ".csv") return "csv"; if (ext === ".yara") return "yara"; if (ext === ".yml" || ext === ".yaml") return "sigma"; return "plaintext"; } function parseIOCFile(file, overrideFormat) { const format = overrideFormat || detectFormat(file); const raw = fs.readFileSync(file, "utf8"); switch (format) { case "json": { const json = JSON.parse(raw); return Array.isArray(json) ? json : json.iocs || []; } case "misp": { const json = JSON.parse(raw); const attributes = json?.Event?.Attribute ?? []; return attributes.map((attr) => ({ value: attr.value, type: attr.type, tags: attr.tags?.map((t) => t.name), source: "misp" })); } case "csv": { const records = parseCSV(raw, { columns: true }); return records.map((row) => ({ value: row.value || row.indicator || row.indicator_value, type: row.type || row.indicator_type, tags: row.tags?.split(",").map((t) => t.trim()), severity: row.severity, source: row.source })).filter((ioc) => !!ioc.value); } case "yara": { const iocs = []; const lines = raw.split(/\r?\n/); for (const line of lines) { const match = line.match(/\$[\w_]+\s*=\s*"([^"]+)"/); if (match) { iocs.push({ value: match[1], type: "string", source: "yara" }); } } return iocs; } case "sigma": { const doc = yaml.load(raw); const detection = doc?.detection ?? {}; const iocs = []; for (const val of Object.values(detection)) { if (typeof val === "object" && !Array.isArray(val)) { for (const sub of Object.values(val)) { if (typeof sub === "string") { iocs.push({ value: sub, type: "string", source: "sigma" }); } } } } return iocs; } case "plaintext": default: return parsePlainIOCs(raw.split(/\r?\n/)); } } var oldIOCs = parseIOCFile(argv.old, argv.oldFormat); var newIOCs = parseIOCFile(argv.new, argv.newFormat); var diff = diffIOCs(oldIOCs, newIOCs, { fuzzyMatch: argv.fuzzy, fuzzyThreshold: argv.threshold, matchBy: "value+type", compareSeverity: true, compareTags: true }); console.log(JSON.stringify(diff, null, 2));