Skip to content

Commit 47e1730

Browse files
committed
Add Platt scaling, allowlist/blocklist, caching, dashboard, and 4 new attack types
- Platt scaling confidence calibration (99.45% leave-one-out accuracy) - IP/path allowlist/blocklist with CIDR support - Thread-safe LRU cache with TTL - ActiveSupport::Notifications instrumentation - Structured JSON/text logging - Hot reload via AiBouncer.reload! and SIGUSR2 signal - Mountable Rails dashboard engine at /ai_bouncer - 4 new attack types: CRLF injection, host header injection, HTTP smuggling, prototype pollution - 18 attack types total, 3,300 balanced training patterns - Fix spec edge cases for CI stability
1 parent 0876d94 commit 47e1730

File tree

20 files changed

+2175
-331
lines changed

20 files changed

+2175
-331
lines changed

README.md

Lines changed: 105 additions & 311 deletions
Large diffs are not rendered by default.

ai_bouncer.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
2121
spec.metadata["rubygems_mfa_required"] = "true"
2222

2323
spec.files = Dir.chdir(__dir__) do
24-
Dir["{lib}/**/*", "README.md", "LICENSE.txt", "CHANGELOG.md"].reject do |f|
24+
Dir["{app,config,lib}/**/*", "README.md", "LICENSE.txt", "CHANGELOG.md"].reject do |f|
2525
File.directory?(f)
2626
end
2727
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
module AiBouncer
4+
class DashboardController < ActionController::Base
5+
layout "ai_bouncer/application"
6+
7+
before_action :authenticate!
8+
9+
def index
10+
@stats = AiBouncer.event_store.stats
11+
@recent_attacks = AiBouncer.event_store.recent_attacks(25)
12+
@recent_requests = AiBouncer.event_store.recent(25)
13+
end
14+
15+
private
16+
17+
def authenticate!
18+
auth_proc = AiBouncer.configuration.dashboard_auth
19+
return unless auth_proc
20+
21+
instance_exec(&auth_proc)
22+
end
23+
end
24+
end
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<div class="stats-grid">
2+
<div class="stat-card">
3+
<div class="label">Total Requests</div>
4+
<div class="value blue"><%= @stats[:total_requests] %></div>
5+
</div>
6+
<div class="stat-card">
7+
<div class="label">Attacks Detected</div>
8+
<div class="value red"><%= @stats[:total_attacks] %></div>
9+
</div>
10+
<div class="stat-card">
11+
<div class="label">Attack Rate</div>
12+
<div class="value yellow"><%= @stats[:attack_rate] %>%</div>
13+
</div>
14+
<div class="stat-card">
15+
<div class="label">Events Stored</div>
16+
<div class="value green"><%= @stats[:events_stored] %></div>
17+
</div>
18+
</div>
19+
20+
<% if @stats[:label_distribution].any? %>
21+
<div class="section">
22+
<div class="section-header"><h2>Attack Distribution</h2></div>
23+
<div style="padding: 16px;">
24+
<% max_count = @stats[:label_distribution].values.max || 1 %>
25+
<% @stats[:label_distribution].each do |label, count| %>
26+
<div class="dist-bar">
27+
<span class="name"><%= label %></span>
28+
<div class="bar" style="width: <%= (count.to_f / max_count * 200).round %>px;"></div>
29+
<span class="count"><%= count %></span>
30+
</div>
31+
<% end %>
32+
</div>
33+
</div>
34+
<% end %>
35+
36+
<div class="section">
37+
<div class="section-header"><h2>Recent Attacks</h2></div>
38+
<% if @recent_attacks.any? %>
39+
<table>
40+
<thead>
41+
<tr>
42+
<th>Time</th>
43+
<th>Label</th>
44+
<th>Confidence</th>
45+
<th>Method</th>
46+
<th>Path</th>
47+
<th>IP</th>
48+
<th>Latency</th>
49+
</tr>
50+
</thead>
51+
<tbody>
52+
<% @recent_attacks.each do |event| %>
53+
<tr>
54+
<td><%= event.timestamp.strftime("%H:%M:%S") %></td>
55+
<td><span class="badge badge-attack"><%= event.label %></span></td>
56+
<td><%= (event.confidence * 100).round(1) %>%</td>
57+
<td><%= event.method %></td>
58+
<td><%= event.path %></td>
59+
<td><%= event.ip %></td>
60+
<td><%= event.latency_ms %>ms</td>
61+
</tr>
62+
<% end %>
63+
</tbody>
64+
</table>
65+
<% else %>
66+
<div class="empty">No attacks detected yet</div>
67+
<% end %>
68+
</div>
69+
70+
<div class="section">
71+
<div class="section-header"><h2>Recent Requests</h2></div>
72+
<% if @recent_requests.any? %>
73+
<table>
74+
<thead>
75+
<tr>
76+
<th>Time</th>
77+
<th>Label</th>
78+
<th>Confidence</th>
79+
<th>Method</th>
80+
<th>Path</th>
81+
<th>IP</th>
82+
<th>Cached</th>
83+
</tr>
84+
</thead>
85+
<tbody>
86+
<% @recent_requests.each do |event| %>
87+
<tr>
88+
<td><%= event.timestamp.strftime("%H:%M:%S") %></td>
89+
<td>
90+
<span class="badge <%= event.is_attack ? 'badge-attack' : 'badge-clean' %>">
91+
<%= event.label %>
92+
</span>
93+
</td>
94+
<td><%= (event.confidence * 100).round(1) %>%</td>
95+
<td><%= event.method %></td>
96+
<td><%= event.path %></td>
97+
<td><%= event.ip %></td>
98+
<td><%= event.cached ? 'Yes' : '' %></td>
99+
</tr>
100+
<% end %>
101+
</tbody>
102+
</table>
103+
<% else %>
104+
<div class="empty">No requests recorded yet</div>
105+
<% end %>
106+
</div>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>AiBouncer Dashboard</title>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<style>
8+
* { box-sizing: border-box; margin: 0; padding: 0; }
9+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f1117; color: #e1e4e8; line-height: 1.5; }
10+
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
11+
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 16px 0; margin-bottom: 24px; }
12+
header .container { display: flex; align-items: center; justify-content: space-between; }
13+
h1 { font-size: 20px; font-weight: 600; }
14+
h1 span { color: #58a6ff; }
15+
h2 { font-size: 16px; margin-bottom: 12px; color: #c9d1d9; }
16+
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
17+
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 16px; }
18+
.stat-card .label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
19+
.stat-card .value { font-size: 28px; font-weight: 700; margin-top: 4px; }
20+
.stat-card .value.green { color: #3fb950; }
21+
.stat-card .value.red { color: #f85149; }
22+
.stat-card .value.yellow { color: #d29922; }
23+
.stat-card .value.blue { color: #58a6ff; }
24+
.section { background: #161b22; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 24px; overflow: hidden; }
25+
.section-header { padding: 12px 16px; border-bottom: 1px solid #30363d; }
26+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
27+
th { text-align: left; padding: 8px 16px; color: #8b949e; font-weight: 500; background: #0d1117; border-bottom: 1px solid #30363d; }
28+
td { padding: 8px 16px; border-bottom: 1px solid #21262d; }
29+
tr:last-child td { border-bottom: none; }
30+
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
31+
.badge-attack { background: rgba(248,81,73,0.15); color: #f85149; }
32+
.badge-clean { background: rgba(63,185,80,0.15); color: #3fb950; }
33+
.dist-bar { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
34+
.dist-bar .bar { height: 8px; border-radius: 4px; background: #f85149; }
35+
.dist-bar .name { font-size: 12px; min-width: 160px; }
36+
.dist-bar .count { font-size: 12px; color: #8b949e; }
37+
.empty { padding: 24px; text-align: center; color: #8b949e; }
38+
</style>
39+
</head>
40+
<body>
41+
<header>
42+
<div class="container">
43+
<h1><span>AI</span>Bouncer Dashboard</h1>
44+
<span style="color: #8b949e; font-size: 13px;">Real-time threat monitoring</span>
45+
</div>
46+
</header>
47+
<div class="container">
48+
<%= yield %>
49+
</div>
50+
</body>
51+
</html>

config/routes.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
AiBouncer::Engine.routes.draw do
4+
root to: "dashboard#index"
5+
end

lib/ai_bouncer.rb

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
require_relative "ai_bouncer/model"
1010
require_relative "ai_bouncer/classifier"
1111
require_relative "ai_bouncer/middleware"
12+
require_relative "ai_bouncer/cache"
13+
require_relative "ai_bouncer/instrumentation"
14+
require_relative "ai_bouncer/structured_logger"
15+
require_relative "ai_bouncer/event_store"
1216

1317
# Rails-specific components loaded conditionally
1418
if defined?(ActiveSupport::Concern)
@@ -36,6 +40,8 @@ def configure
3640
if configuration.enabled && configuration.memory_storage? && configuration.model_path
3741
load_classifier if configuration.preload_model
3842
end
43+
44+
setup_signal_reload if configuration.signal_reload
3945
end
4046

4147
# The ONNX embedding model (shared between memory and database modes)
@@ -48,6 +54,27 @@ def classifier
4854
@classifier ||= load_classifier
4955
end
5056

57+
# Classification cache
58+
def cache
59+
@cache ||= Cache.new(
60+
max_size: configuration.cache_max_size,
61+
ttl: configuration.cache_ttl
62+
)
63+
end
64+
65+
# Structured logger
66+
def structured_logger
67+
@structured_logger ||= StructuredLogger.new(
68+
logger: configuration.logger || (defined?(Rails) ? Rails.logger : nil),
69+
format: configuration.log_format
70+
)
71+
end
72+
73+
# Event store for dashboard
74+
def event_store
75+
@event_store ||= EventStore.new(max_size: configuration.event_store_size)
76+
end
77+
5178
def enabled?
5279
return false unless configuration.enabled
5380

@@ -59,24 +86,67 @@ def enabled?
5986
end
6087

6188
# Classify a request text
62-
# Automatically uses the configured storage mode
89+
# Uses cache and instrumentation when enabled
6390
def classify(request_text, k: 5)
64-
if configuration.database_storage?
65-
classify_with_database(request_text, k: k)
66-
else
67-
classifier.classify(request_text, k: k)
91+
# Check cache first
92+
if configuration.cache_enabled
93+
cached = cache.get(request_text)
94+
return cached if cached
95+
end
96+
97+
# Perform classification
98+
result = if configuration.database_storage?
99+
classify_with_database(request_text, k: k)
100+
else
101+
classifier.classify(request_text, k: k)
102+
end
103+
104+
# Instrument
105+
if Instrumentation.notifications_available?
106+
Instrumentation.instrument_classify(request_text) { result }
68107
end
108+
109+
# Cache result
110+
cache.set(request_text, result) if configuration.cache_enabled
111+
112+
result
69113
end
70114

71115
# Helper to build request text from components
72116
def request_to_text(**args)
73117
Classifier.request_to_text(**args)
74118
end
75119

120+
# Thread-safe hot reload of model and classifier
121+
def reload!
122+
@reload_mutex ||= Mutex.new
123+
@reload_mutex.synchronize do
124+
old_classifier = @classifier
125+
old_model = @model
126+
127+
begin
128+
@classifier = nil
129+
@model = nil
130+
load_classifier if configuration.memory_storage?
131+
cache.clear if @cache
132+
structured_logger.log_blocked("model_reloaded") if @structured_logger
133+
rescue StandardError => e
134+
# Rollback on failure
135+
@classifier = old_classifier
136+
@model = old_model
137+
warn "[AiBouncer] Reload failed: #{e.message}"
138+
raise
139+
end
140+
end
141+
end
142+
76143
def reset!
77144
@configuration = nil
78145
@classifier = nil
79146
@model = nil
147+
@cache = nil
148+
@structured_logger = nil
149+
@event_store = nil
80150
end
81151

82152
private
@@ -106,6 +176,15 @@ def load_classifier
106176
@classifier = Classifier.new(model_path)
107177
end
108178

179+
def setup_signal_reload
180+
Signal.trap("USR2") do
181+
Thread.new { reload! }
182+
end
183+
rescue ArgumentError
184+
# Signal not available on this platform (e.g., Windows)
185+
nil
186+
end
187+
109188
def ensure_model_files!(model_path)
110189
# Check if model exists
111190
if Downloader.model_exists?(model_path)

0 commit comments

Comments
 (0)