@@ -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+
10051110function 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+
11241323function 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