@@ -5,7 +5,7 @@ defmodule SentinelCp.Analytics do
55
66 import Ecto.Query , warn: false
77 alias SentinelCp.Repo
8- alias SentinelCp.Analytics . { ServiceMetric , RequestLog }
8+ alias SentinelCp.Analytics . { ServiceMetric , RequestLog , WafEvent , WafBaseline , WafAnomaly }
99
1010 ## Ingestion
1111
@@ -198,6 +198,307 @@ defmodule SentinelCp.Analytics do
198198 { :ok , count }
199199 end
200200
201+ ## WAF Events
202+
203+ @ doc """
204+ Bulk inserts WAF event records from a node push.
205+ """
206+ def ingest_waf_events ( events_list ) when is_list ( events_list ) do
207+ now = DateTime . utc_now ( ) |> DateTime . truncate ( :second )
208+
209+ entries =
210+ Enum . map ( events_list , fn attrs ->
211+ % {
212+ id: Ecto.UUID . generate ( ) ,
213+ project_id: attrs [ "project_id" ] ,
214+ service_id: attrs [ "service_id" ] ,
215+ node_id: attrs [ "node_id" ] ,
216+ timestamp: parse_datetime_usec ( attrs [ "timestamp" ] ) ,
217+ rule_type: attrs [ "rule_type" ] ,
218+ rule_id: attrs [ "rule_id" ] ,
219+ action: attrs [ "action" ] ,
220+ severity: attrs [ "severity" ] ,
221+ client_ip: attrs [ "client_ip" ] ,
222+ method: attrs [ "method" ] ,
223+ path: attrs [ "path" ] ,
224+ matched_data: attrs [ "matched_data" ] ,
225+ user_agent: attrs [ "user_agent" ] ,
226+ geo_country: attrs [ "geo_country" ] ,
227+ request_headers: attrs [ "request_headers" ] || % { } ,
228+ metadata: attrs [ "metadata" ] || % { } ,
229+ inserted_at: now
230+ }
231+ end )
232+
233+ { count , _ } = Repo . insert_all ( WafEvent , entries )
234+
235+ # Broadcast & emit events
236+ project_ids = entries |> Enum . map ( & & 1 . project_id ) |> Enum . uniq ( )
237+
238+ for pid <- project_ids do
239+ Phoenix.PubSub . broadcast ( SentinelCp.PubSub , "waf:#{ pid } " , { :waf_event , pid } )
240+ end
241+
242+ blocked_count = Enum . count ( entries , & ( & 1 . action == "blocked" ) )
243+
244+ if blocked_count > 0 do
245+ for pid <- project_ids do
246+ SentinelCp.Events . emit ( "security.waf_blocked" , % { count: blocked_count } ,
247+ project_id: pid
248+ )
249+ end
250+ end
251+
252+ { :ok , count }
253+ end
254+
255+ @ doc """
256+ Lists WAF events for a project with optional filters and pagination.
257+ """
258+ def list_waf_events ( project_id , opts \\ [ ] ) do
259+ limit = Keyword . get ( opts , :limit , 50 )
260+ offset = Keyword . get ( opts , :offset , 0 )
261+ time_range = Keyword . get ( opts , :time_range , 24 )
262+
263+ { start_time , end_time } = resolve_time_range ( time_range )
264+
265+ query =
266+ from ( e in WafEvent ,
267+ where: e . project_id == ^ project_id ,
268+ where: e . timestamp >= ^ start_time ,
269+ where: e . timestamp <= ^ end_time ,
270+ order_by: [ desc: e . timestamp ] ,
271+ limit: ^ limit ,
272+ offset: ^ offset
273+ )
274+
275+ query =
276+ Enum . reduce ( opts , query , fn
277+ { :rule_type , rt } , q when is_binary ( rt ) -> where ( q , [ e ] , e . rule_type == ^ rt )
278+ { :action , a } , q when is_binary ( a ) -> where ( q , [ e ] , e . action == ^ a )
279+ { :severity , s } , q when is_binary ( s ) -> where ( q , [ e ] , e . severity == ^ s )
280+ { :client_ip , ip } , q when is_binary ( ip ) -> where ( q , [ e ] , e . client_ip == ^ ip )
281+ _ , q -> q
282+ end )
283+
284+ Repo . all ( query )
285+ end
286+
287+ @ doc """
288+ Returns WAF event statistics for a project within a time range.
289+ """
290+ def get_waf_event_stats ( project_id , time_range_hours \\ 24 ) do
291+ { start_time , end_time } = resolve_time_range ( time_range_hours )
292+
293+ stats =
294+ from ( e in WafEvent ,
295+ where: e . project_id == ^ project_id ,
296+ where: e . timestamp >= ^ start_time ,
297+ where: e . timestamp <= ^ end_time ,
298+ select: % {
299+ total: count ( e . id ) ,
300+ blocked: count ( fragment ( "CASE WHEN ? = 'blocked' THEN 1 END" , e . action ) ) ,
301+ logged: count ( fragment ( "CASE WHEN ? = 'logged' THEN 1 END" , e . action ) ) ,
302+ challenged: count ( fragment ( "CASE WHEN ? = 'challenged' THEN 1 END" , e . action ) ) ,
303+ unique_ips: count ( e . client_ip , :distinct )
304+ }
305+ )
306+ |> Repo . one ( )
307+
308+ stats || % { total: 0 , blocked: 0 , logged: 0 , challenged: 0 , unique_ips: 0 }
309+ end
310+
311+ @ doc """
312+ Returns the top blocked client IPs for a project.
313+ """
314+ def get_top_blocked_ips ( project_id , time_range_hours \\ 24 , limit \\ 10 ) do
315+ { start_time , end_time } = resolve_time_range ( time_range_hours )
316+
317+ from ( e in WafEvent ,
318+ where: e . project_id == ^ project_id ,
319+ where: e . action == "blocked" ,
320+ where: e . timestamp >= ^ start_time ,
321+ where: e . timestamp <= ^ end_time ,
322+ where: not is_nil ( e . client_ip ) ,
323+ group_by: e . client_ip ,
324+ select: { e . client_ip , count ( e . id ) } ,
325+ order_by: [ desc: count ( e . id ) ] ,
326+ limit: ^ limit
327+ )
328+ |> Repo . all ( )
329+ end
330+
331+ @ doc """
332+ Returns the top blocked paths for a project.
333+ """
334+ def get_top_blocked_paths ( project_id , time_range_hours \\ 24 , limit \\ 10 ) do
335+ { start_time , end_time } = resolve_time_range ( time_range_hours )
336+
337+ from ( e in WafEvent ,
338+ where: e . project_id == ^ project_id ,
339+ where: e . action == "blocked" ,
340+ where: e . timestamp >= ^ start_time ,
341+ where: e . timestamp <= ^ end_time ,
342+ where: not is_nil ( e . path ) ,
343+ group_by: e . path ,
344+ select: { e . path , count ( e . id ) } ,
345+ order_by: [ desc: count ( e . id ) ] ,
346+ limit: ^ limit
347+ )
348+ |> Repo . all ( )
349+ end
350+
351+ @ doc """
352+ Returns time-series WAF event counts grouped by rule_type.
353+ """
354+ def get_waf_time_series ( project_id , time_range_hours \\ 24 , bucket_minutes \\ 60 ) do
355+ { start_time , end_time } = resolve_time_range ( time_range_hours )
356+ bucket_seconds = bucket_minutes * 60
357+
358+ from ( e in WafEvent ,
359+ where: e . project_id == ^ project_id ,
360+ where: e . timestamp >= ^ start_time ,
361+ where: e . timestamp <= ^ end_time ,
362+ group_by: [
363+ fragment ( "(strftime('%s', ?) / ? * ?)" , e . timestamp , ^ bucket_seconds , ^ bucket_seconds ) ,
364+ e . rule_type
365+ ] ,
366+ select: % {
367+ bucket: fragment ( "datetime((strftime('%s', ?) / ? * ?), 'unixepoch')" , e . timestamp , ^ bucket_seconds , ^ bucket_seconds ) ,
368+ rule_type: e . rule_type ,
369+ count: count ( e . id )
370+ } ,
371+ order_by: [ asc: fragment ( "(strftime('%s', ?) / ? * ?)" , e . timestamp , ^ bucket_seconds , ^ bucket_seconds ) ]
372+ )
373+ |> Repo . all ( )
374+ end
375+
376+ @ doc """
377+ Deletes WAF events older than the retention period.
378+ Returns the number of deleted records.
379+ """
380+ def prune_old_waf_events ( retention_days \\ 30 ) do
381+ cutoff = DateTime . utc_now ( ) |> DateTime . add ( - retention_days * 86_400 , :second )
382+
383+ { count , _ } =
384+ from ( e in WafEvent , where: e . timestamp < ^ cutoff )
385+ |> Repo . delete_all ( )
386+
387+ { :ok , count }
388+ end
389+
390+ ## WAF Baselines
391+
392+ @ doc """
393+ Gets WAF baselines for a project.
394+ """
395+ def get_waf_baselines ( project_id ) do
396+ from ( b in WafBaseline ,
397+ where: b . project_id == ^ project_id ,
398+ order_by: [ asc: b . metric_type ]
399+ )
400+ |> Repo . all ( )
401+ end
402+
403+ @ doc """
404+ Upserts a WAF baseline.
405+ """
406+ def upsert_waf_baseline ( attrs ) do
407+ % WafBaseline { }
408+ |> WafBaseline . changeset ( attrs )
409+ |> Repo . insert (
410+ on_conflict: { :replace , [ :mean , :stddev , :sample_count , :last_computed_at , :updated_at ] } ,
411+ conflict_target: [ :project_id , :service_id , :metric_type , :period ]
412+ )
413+ end
414+
415+ ## WAF Anomalies
416+
417+ @ doc """
418+ Lists WAF anomalies for a project with optional status filter.
419+ """
420+ def list_waf_anomalies ( project_id , opts \\ [ ] ) do
421+ query =
422+ from ( a in WafAnomaly ,
423+ where: a . project_id == ^ project_id ,
424+ order_by: [ desc: a . detected_at ] ,
425+ limit: ^ Keyword . get ( opts , :limit , 100 )
426+ )
427+
428+ query =
429+ case Keyword . get ( opts , :status ) do
430+ nil -> query
431+ status -> where ( query , [ a ] , a . status == ^ status )
432+ end
433+
434+ Repo . all ( query )
435+ end
436+
437+ @ doc """
438+ Gets a single WAF anomaly by ID.
439+ """
440+ def get_waf_anomaly ( id ) , do: Repo . get ( WafAnomaly , id )
441+
442+ @ doc """
443+ Creates a WAF anomaly.
444+ """
445+ def create_waf_anomaly ( attrs ) do
446+ % WafAnomaly { }
447+ |> WafAnomaly . create_changeset ( attrs )
448+ |> Repo . insert ( )
449+ end
450+
451+ @ doc """
452+ Acknowledges a WAF anomaly.
453+ """
454+ def acknowledge_anomaly ( id , user_id ) do
455+ case get_waf_anomaly ( id ) do
456+ nil -> { :error , :not_found }
457+ anomaly -> anomaly |> WafAnomaly . acknowledge_changeset ( user_id ) |> Repo . update ( )
458+ end
459+ end
460+
461+ @ doc """
462+ Resolves a WAF anomaly.
463+ """
464+ def resolve_anomaly ( id ) do
465+ case get_waf_anomaly ( id ) do
466+ nil -> { :error , :not_found }
467+ anomaly -> anomaly |> WafAnomaly . resolve_changeset ( ) |> Repo . update ( )
468+ end
469+ end
470+
471+ @ doc """
472+ Marks a WAF anomaly as a false positive.
473+ """
474+ def mark_false_positive ( id , user_id ) do
475+ case get_waf_anomaly ( id ) do
476+ nil -> { :error , :not_found }
477+ anomaly -> anomaly |> WafAnomaly . false_positive_changeset ( user_id ) |> Repo . update ( )
478+ end
479+ end
480+
481+ @ doc """
482+ Returns anomaly statistics for a project.
483+ """
484+ def get_anomaly_stats ( project_id ) do
485+ stats =
486+ from ( a in WafAnomaly ,
487+ where: a . project_id == ^ project_id ,
488+ group_by: a . status ,
489+ select: { a . status , count ( a . id ) }
490+ )
491+ |> Repo . all ( )
492+ |> Map . new ( )
493+
494+ % {
495+ active: Map . get ( stats , "active" , 0 ) ,
496+ acknowledged: Map . get ( stats , "acknowledged" , 0 ) ,
497+ resolved: Map . get ( stats , "resolved" , 0 ) ,
498+ false_positive: Map . get ( stats , "false_positive" , 0 )
499+ }
500+ end
501+
201502 ## Private
202503
203504 defp resolve_time_range ( hours ) when is_integer ( hours ) do
0 commit comments