@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
JavaScript
#!/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));