Skip to content

Commit fda0d6f

Browse files
feat: add remaining v2 stretch goals — Consul discovery, circuit breakers, Vault, WAF analytics
Implement all five remaining stretch goals: 1. Consul Service Discovery — behaviour + HTTP resolver, discovery source validation, secret-aware token handling 2. Circuit Breaker Health View — per-node state tracking via heartbeats, summary queries, dashboard stats, upstream group detail UI 3. Vault Integration — encrypted config, token/approle/k8s auth methods, transparent secret backend in resolve_references, API controller 4. WAF Event Logging Dashboard — bulk ingestion, filterable queries, top blocked IPs/paths, LiveView with auto-refresh, prune worker 5. WAF Anomaly Detection — statistical baselines (hourly mean/stddev), z-score spike/new-vector/IP-burst/rate-change detection, Oban workers, acknowledge/resolve/false-positive workflow, anomalies LiveView 30 new files, 14 modified files, 4 migrations, 63 new tests (1226 total).
1 parent f8ec6c6 commit fda0d6f

44 files changed

Lines changed: 3909 additions & 42 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/test.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ config :sentinel_cp, :dns_resolver, SentinelCp.Services.DnsResolver.Mock
5757
# Use mock K8s resolver in tests
5858
config :sentinel_cp, :k8s_resolver, SentinelCp.Services.K8sResolver.Mock
5959

60+
# Use mock Consul resolver in tests
61+
config :sentinel_cp, :consul_resolver, SentinelCp.Services.ConsulResolver.Mock
62+
63+
# Use mock Vault client in tests
64+
config :sentinel_cp, :vault_client, SentinelCp.Secrets.VaultClient.Mock
65+
6066
# Use mock ACME client in tests
6167
config :sentinel_cp, :acme_client, SentinelCp.Services.Acme.Client.Mock
6268

lib/sentinel_cp/analytics.ex

Lines changed: 302 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)