Skip to content

Commit cfbb9e3

Browse files
committed
Updates to analysis logic for improved detection.
1 parent 4e7924e commit cfbb9e3

File tree

1 file changed

+199
-0
lines changed

1 file changed

+199
-0
lines changed

lib/analyze.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ export type ActorScorecard = {
103103
actionSequenceRepeatScore: number;
104104
topActionNgram: string;
105105
topActionNgramCount: number;
106+
botnetScore: number;
107+
botnetGroupSize: number;
108+
botnetSignature: string;
109+
cadenceScore: number;
110+
cadenceGroupSize: number;
111+
medianActionGapSeconds: number;
112+
amountFingerprintScore: number;
113+
roundAmountRate: number;
114+
topAmountBucket: string;
115+
topAmountBucketCount: number;
116+
sharedAmountBucketActors: number;
106117
avgSessionMinutes: number;
107118
avgSessionGapMinutes: number;
108119
maxSessionGapMinutes: number;
@@ -465,6 +476,9 @@ export function analyzeLogs(input: { logs: LogEntry[]; settings: AnalysisSetting
465476
);
466477
const circadianByActor = computeCircadianByActor(logs);
467478
const ngramByActor = computeActionNgramByActor(logs, Math.min(Math.max(2, settings.actionNgramSize), 5));
479+
const botnetByActor = computeBotnetPatternByActor(ngramByActor);
480+
const cadenceByActor = computeCadenceByActor(logs);
481+
const amountFpByActor = detectAmountFingerprints(logs);
468482

469483
// Cross-actor similarity (same targets == coordinated ops / multi-account)
470484
const maxJaccardByActor = new Map<string, { score: number; peer: string }>();
@@ -646,6 +660,15 @@ export function analyzeLogs(input: { logs: LogEntry[]; settings: AnalysisSetting
646660
const velocity = velocityByActor.get(stats.actor) ?? { maxInWindow: 0, maxPerSecond: 0, velocityScore: 0 };
647661
const circadian = circadianByActor.get(stats.actor) ?? { hourEntropy: 0, activeHours: 0, circadianScore: 0 };
648662
const ngram = ngramByActor.get(stats.actor) ?? { repeatScore: 0, topNgram: '', topCount: 0 };
663+
const botnet = botnetByActor.get(stats.actor) ?? { botnetScore: 0, groupSize: 0, signature: '' };
664+
const cadence = cadenceByActor.get(stats.actor) ?? { cadenceScore: 0, groupSize: 0, medianGapSeconds: 0 };
665+
const amountFp = amountFpByActor.get(stats.actor) ?? {
666+
amountFingerprintScore: 0,
667+
roundAmountRate: 0,
668+
topAmountBucket: '',
669+
topAmountBucketCount: 0,
670+
sharedAmountBucketActors: 0,
671+
};
649672

650673
const session = sessionMetrics.get(stats.actor) ?? {
651674
sessionCount: 0,
@@ -672,6 +695,9 @@ export function analyzeLogs(input: { logs: LogEntry[]; settings: AnalysisSetting
672695
0.05 * (stats.totalActions >= settings.entropyMinTotalActions ? lowEntropyScore : 0) +
673696
0.05 * velocity.velocityScore +
674697
0.03 * ngram.repeatScore +
698+
0.04 * botnet.botnetScore +
699+
0.03 * cadence.cadenceScore +
700+
0.04 * amountFp.amountFingerprintScore +
675701
0.03 * circadian.circadianScore +
676702
0.05 * (jacc.score >= 0.85 && stats.uniqueTargets.size >= 3 ? Math.min((jacc.score - 0.85) / 0.15, 1) : 0) +
677703
seedInfluence * seedProximityScore +
@@ -701,6 +727,11 @@ export function analyzeLogs(input: { logs: LogEntry[]; settings: AnalysisSetting
701727
if (maxApm >= settings.rapidActionsPerMinuteThreshold) reasons.push(`Rapid actions (${maxApm}/min)`);
702728
if (velocity.velocityScore >= 0.7) reasons.push(`High velocity (${velocity.maxInWindow} in ${Math.max(1, settings.velocityWindowSeconds)}s)`);
703729
if (ngram.repeatScore >= 0.7 && ngram.topNgram) reasons.push(`Script-like sequence (${ngram.topNgram})`);
730+
if (botnet.botnetScore >= 0.5 && botnet.signature) reasons.push(`Botnet-like shared script (${botnet.signature}) across ${botnet.groupSize} actors`);
731+
if (cadence.cadenceScore >= 0.6 && cadence.groupSize >= 5)
732+
reasons.push(`Synchronized cadence (median gap ~${Math.round(cadence.medianGapSeconds)}s) across ${cadence.groupSize} actors`);
733+
if (amountFp.amountFingerprintScore >= 0.6 && amountFp.topAmountBucket)
734+
reasons.push(`Shared amount fingerprint (${amountFp.topAmountBucket}) across ${amountFp.sharedAmountBucketActors} actors`);
704735
if (circadian.circadianScore >= 0.8) reasons.push(`Unnatural circadian pattern (active hours ${circadian.activeHours})`);
705736
if (jacc.score >= 0.85 && stats.uniqueTargets.size >= 3 && jacc.peer) reasons.push(`Very similar targets to ${jacc.peer} (Jaccard ${jacc.score.toFixed(2)})`);
706737
if (seedInfluence > 0 && seedProximityScore >= 0.5) reasons.push(`Near confirmed sybil(s) (seed proximity ${seedProximityScore.toFixed(2)})`);
@@ -746,6 +777,17 @@ export function analyzeLogs(input: { logs: LogEntry[]; settings: AnalysisSetting
746777
actionSequenceRepeatScore: ngram.repeatScore,
747778
topActionNgram: ngram.topNgram,
748779
topActionNgramCount: ngram.topCount,
780+
botnetScore: botnet.botnetScore,
781+
botnetGroupSize: botnet.groupSize,
782+
botnetSignature: botnet.signature,
783+
cadenceScore: cadence.cadenceScore,
784+
cadenceGroupSize: cadence.groupSize,
785+
medianActionGapSeconds: cadence.medianGapSeconds,
786+
amountFingerprintScore: amountFp.amountFingerprintScore,
787+
roundAmountRate: amountFp.roundAmountRate,
788+
topAmountBucket: amountFp.topAmountBucket,
789+
topAmountBucketCount: amountFp.topAmountBucketCount,
790+
sharedAmountBucketActors: amountFp.sharedAmountBucketActors,
749791
avgSessionMinutes: session.avgSessionMinutes,
750792
avgSessionGapMinutes: session.avgGapMinutes,
751793
maxSessionGapMinutes: session.maxGapMinutes,
@@ -1002,6 +1044,69 @@ function computeActionNgramByActor(logs: LogEntry[], n: number): Map<string, { r
10021044
return out;
10031045
}
10041046

1047+
function computeBotnetPatternByActor(
1048+
ngramByActor: Map<string, { repeatScore: number; topNgram: string; topCount: number }>,
1049+
): Map<string, { botnetScore: number; groupSize: number; signature: string }> {
1050+
const bySignature = new Map<string, string[]>();
1051+
ngramByActor.forEach((ng, actor) => {
1052+
if (!ng.topNgram) return;
1053+
if (ng.repeatScore < 0.7) return;
1054+
if (ng.topCount < 5) return;
1055+
if (!bySignature.has(ng.topNgram)) bySignature.set(ng.topNgram, []);
1056+
bySignature.get(ng.topNgram)!.push(actor);
1057+
});
1058+
1059+
const out = new Map<string, { botnetScore: number; groupSize: number; signature: string }>();
1060+
bySignature.forEach((actors, signature) => {
1061+
if (actors.length < 3) return;
1062+
const score = Math.min((actors.length - 2) / 8, 1);
1063+
for (const actor of actors) out.set(actor, { botnetScore: score, groupSize: actors.length, signature });
1064+
});
1065+
return out;
1066+
}
1067+
1068+
function computeCadenceByActor(logs: LogEntry[]): Map<string, { cadenceScore: number; groupSize: number; medianGapSeconds: number }> {
1069+
const actorTimes = new Map<string, number[]>();
1070+
logs.forEach((log) => {
1071+
const ts = new Date(log.timestamp).getTime();
1072+
if (!Number.isFinite(ts)) return;
1073+
if (!actorTimes.has(log.actor)) actorTimes.set(log.actor, []);
1074+
actorTimes.get(log.actor)!.push(ts);
1075+
});
1076+
1077+
const medianGapByActor = new Map<string, number>();
1078+
actorTimes.forEach((times, actor) => {
1079+
if (times.length < 12) return;
1080+
times.sort((a, b) => a - b);
1081+
const gaps: number[] = [];
1082+
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
1083+
gaps.sort((a, b) => a - b);
1084+
const mid = Math.floor(gaps.length / 2);
1085+
const median = gaps.length % 2 === 0 ? (gaps[mid - 1] + gaps[mid]) / 2 : gaps[mid];
1086+
if (!Number.isFinite(median) || median <= 0) return;
1087+
medianGapByActor.set(actor, median / 1000);
1088+
});
1089+
1090+
// Group by a coarse bucket: nearest 5 seconds, capped to avoid huge keys.
1091+
const bucketToActors = new Map<string, string[]>();
1092+
medianGapByActor.forEach((gapS, actor) => {
1093+
const b = Math.min(60 * 60, Math.max(1, Math.round(gapS / 5) * 5));
1094+
const key = String(b);
1095+
if (!bucketToActors.has(key)) bucketToActors.set(key, []);
1096+
bucketToActors.get(key)!.push(actor);
1097+
});
1098+
1099+
const out = new Map<string, { cadenceScore: number; groupSize: number; medianGapSeconds: number }>();
1100+
bucketToActors.forEach((actors, bucket) => {
1101+
if (actors.length < 5) return;
1102+
const groupSize = actors.length;
1103+
const score = Math.min((groupSize - 4) / 12, 1);
1104+
const medianGapSeconds = Number.parseInt(bucket, 10) || 0;
1105+
for (const actor of actors) out.set(actor, { cadenceScore: score, groupSize, medianGapSeconds });
1106+
});
1107+
return out;
1108+
}
1109+
10051110
function detectWindowBursts(input: {
10061111
logs: LogEntry[];
10071112
windowMs: number;
@@ -1121,6 +1226,100 @@ export function detectFraudulentTransactions(logs: LogEntry[]): Map<string, numb
11211226
return fraudScores;
11221227
}
11231228

1229+
export function detectAmountFingerprints(
1230+
logs: LogEntry[],
1231+
): Map<
1232+
string,
1233+
{
1234+
amountFingerprintScore: number;
1235+
roundAmountRate: number;
1236+
topAmountBucket: string;
1237+
topAmountBucketCount: number;
1238+
sharedAmountBucketActors: number;
1239+
}
1240+
> {
1241+
const perActor: Map<string, number[]> = new Map();
1242+
for (const log of logs) {
1243+
const amount = log.amount;
1244+
if (amount === undefined) continue;
1245+
if (!Number.isFinite(amount)) continue;
1246+
if (!perActor.has(log.actor)) perActor.set(log.actor, []);
1247+
perActor.get(log.actor)!.push(amount);
1248+
}
1249+
1250+
const bucket = (a: number): string => {
1251+
const rounded = Math.round(a * 1_000_000) / 1_000_000;
1252+
// Keep buckets stable for UI/evidence, but avoid huge strings.
1253+
return Number.isFinite(rounded) ? String(rounded) : '';
1254+
};
1255+
1256+
const isRoundish = (a: number): boolean => {
1257+
const abs = Math.abs(a);
1258+
if (abs === 0) return true;
1259+
// Round to 2 decimals / 3 decimals / integer.
1260+
const near = (step: number) => {
1261+
const v = abs / step;
1262+
return Math.abs(v - Math.round(v)) < 1e-9;
1263+
};
1264+
return near(1) || near(0.1) || near(0.01) || near(0.001);
1265+
};
1266+
1267+
const bucketActors = new Map<string, Set<string>>();
1268+
const topBucketByActor = new Map<string, { bucket: string; count: number; roundRate: number; score: number; sharedActors: number }>();
1269+
1270+
// First pass: per-actor bucket counts
1271+
perActor.forEach((amounts, actor) => {
1272+
const counts = new Map<string, number>();
1273+
let round = 0;
1274+
for (const a of amounts) {
1275+
const b = bucket(a);
1276+
if (!b) continue;
1277+
counts.set(b, (counts.get(b) || 0) + 1);
1278+
if (isRoundish(a)) round++;
1279+
}
1280+
1281+
let top = { bucket: '', count: 0 };
1282+
counts.forEach((c, b) => {
1283+
if (c > top.count) top = { bucket: b, count: c };
1284+
});
1285+
1286+
const roundRate = amounts.length > 0 ? round / amounts.length : 0;
1287+
const repeatRate = amounts.length > 0 ? top.count / amounts.length : 0;
1288+
// Shared actors will be populated after we build bucketActors.
1289+
topBucketByActor.set(actor, { bucket: top.bucket, count: top.count, roundRate, score: Math.min(repeatRate, 1), sharedActors: 0 });
1290+
});
1291+
1292+
// Build bucket -> actors map (using each actor's top bucket only to keep it sparse)
1293+
topBucketByActor.forEach((t, actor) => {
1294+
if (!t.bucket) return;
1295+
if (!bucketActors.has(t.bucket)) bucketActors.set(t.bucket, new Set());
1296+
bucketActors.get(t.bucket)!.add(actor);
1297+
});
1298+
1299+
// Final score combines (a) repeated amounts, (b) shared top amount across many actors, (c) roundish amount rate.
1300+
const out = new Map<
1301+
string,
1302+
{ amountFingerprintScore: number; roundAmountRate: number; topAmountBucket: string; topAmountBucketCount: number; sharedAmountBucketActors: number }
1303+
>();
1304+
1305+
topBucketByActor.forEach((t, actor) => {
1306+
const sharedActors = t.bucket ? (bucketActors.get(t.bucket)?.size ?? 0) : 0;
1307+
const sharedScore = sharedActors >= 5 ? Math.min((sharedActors - 4) / 12, 1) : 0;
1308+
const repeatScore = Math.min(t.score, 1);
1309+
const roundScore = Math.min(t.roundRate / 0.8, 1) * 0.5;
1310+
const amountFingerprintScore = Math.min(Math.max(0.6 * sharedScore + 0.4 * repeatScore + roundScore, 0), 1);
1311+
out.set(actor, {
1312+
amountFingerprintScore,
1313+
roundAmountRate: t.roundRate,
1314+
topAmountBucket: t.bucket,
1315+
topAmountBucketCount: t.count,
1316+
sharedAmountBucketActors: sharedActors,
1317+
});
1318+
});
1319+
1320+
return out;
1321+
}
1322+
11241323
function computePageRank(nodes: string[], out: Map<string, string[]>, incoming: Map<string, string[]>): Map<string, number> {
11251324
const N = Math.max(nodes.length, 1);
11261325
const damping = 0.85;

0 commit comments

Comments
 (0)