towerops/lib/towerops/security/brute_force.ex
Graham McIntire 146f5745cf
fix: resolve compilation errors, test failures, and credo issues
- Escape HEEx template braces in GraphQL/API docs with raw(~S[...])
- Fix test assertions for updated marketing copy and UI text
- Extract helper functions in GraphQL resolvers to reduce nesting depth
- Create shared ErrorHelpers module for API controllers
- Fix ETS race condition in brute force whitelist cache for async tests
- Fix property test generators to use ASCII instead of printable unicode
- Add alert_severity helper to site_live/show
- Update accounts fixtures for explicit user confirmation
2026-02-14 12:23:10 -06:00

409 lines
10 KiB
Elixir

defmodule Towerops.Security.BruteForce do
@moduledoc """
Context module for brute force protection and IP blocking.
Handles detection of scanning behavior (5+ unique 404s within 1 minute),
progressive ban escalation, whitelist management, and integration with
Cloudflare WAF for permanent bans.
"""
import Ecto.Query, warn: false
alias Phoenix.PubSub
alias Towerops.Repo
alias Towerops.Security.FourOhFourTracker
alias Towerops.Security.IpBlock
alias Towerops.Security.IpWhitelist
alias Towerops.Workers.CloudflareBanWorker
require Logger
@forgiveness_period_days 30
## Whitelist Functions
@doc """
Checks if an IP address is whitelisted.
Supports both individual IPs and CIDR ranges.
Uses ETS cache for performance.
"""
def check_whitelist(ip_address) do
whitelist = get_cached_whitelist()
Enum.any?(whitelist, &ip_matches_entry?(ip_address, &1))
end
defp ip_matches_entry?(ip_address, entry) do
if String.contains?(entry.ip_or_cidr, "/") do
# CIDR range - need to parse both the range and IP address
with {:ok, range} <- InetCidr.parse_cidr(entry.ip_or_cidr),
{:ok, addr} <- :inet.parse_address(String.to_charlist(ip_address)) do
InetCidr.contains?(range, addr)
else
_ -> false
end
else
# Exact IP match
entry.ip_or_cidr == ip_address
end
end
@doc """
Lists all whitelisted IPs and CIDR ranges.
"""
def list_whitelist do
Repo.all(from w in IpWhitelist, preload: [:added_by], order_by: [desc: w.inserted_at])
end
@doc """
Adds an IP address or CIDR range to the whitelist.
"""
def add_to_whitelist(ip_or_cidr, description, user) do
attrs = %{
ip_or_cidr: ip_or_cidr,
description: description,
added_by_id: user.id
}
result =
%IpWhitelist{}
|> IpWhitelist.changeset(attrs)
|> Repo.insert()
case result do
{:ok, _} ->
clear_whitelist_cache()
PubSub.broadcast(Towerops.PubSub, "security:whitelist", :whitelist_updated)
{:error, _} ->
:ok
end
result
end
@doc """
Removes an IP or CIDR range from the whitelist.
"""
def remove_from_whitelist(id) do
case Repo.get(IpWhitelist, id) do
nil ->
{:error, :not_found}
entry ->
Repo.delete(entry)
clear_whitelist_cache()
PubSub.broadcast(Towerops.PubSub, "security:whitelist", :whitelist_updated)
:ok
end
end
## Ban Management Functions
@doc """
Checks if an IP address is currently banned.
Returns `:allowed` if not banned or ban expired.
Returns `{:blocked, banned_until}` if currently banned.
"""
def check_ban_status(ip_address) do
case get_by_ip(ip_address) do
nil ->
:allowed
%IpBlock{banned_until: nil, offense_count: count} when count >= 3 ->
# Permanent ban
{:blocked, nil}
%IpBlock{banned_until: banned_until} ->
if DateTime.after?(DateTime.utc_now(), banned_until) do
# Ban expired
:allowed
else
{:blocked, banned_until}
end
end
end
@doc """
Creates a new ban or escalates an existing ban.
Escalation schedule:
- 1st offense: 5 minutes
- 2nd offense (within 30 days): 1 hour
- 3rd+ offense (within 30 days): Permanent + Cloudflare push
Resets to 1st offense if 30 days have passed since last violation.
"""
def create_or_escalate_ban(ip_address, reason \\ "Automated scanning - 5 unique 404s") do
case get_by_ip(ip_address) do
nil ->
# First offense
create_ban(ip_address, 1, DateTime.add(DateTime.utc_now(), 5, :minute), reason)
block ->
if DateTime.diff(DateTime.utc_now(), block.last_violation_at, :day) > @forgiveness_period_days do
# Reset after forgiveness period
update_ban(block, 1, DateTime.add(DateTime.utc_now(), 5, :minute), reason)
else
# Escalate
escalate_ban(block, reason)
end
end
end
@doc """
Manually unblocks an IP address.
Returns `{:ok, ip_block}` if successful.
Returns `{:error, :not_found}` if IP is not blocked.
"""
def manually_unblock(ip_address) do
case get_by_ip(ip_address) do
nil ->
{:error, :not_found}
block ->
# Delete the block record entirely
Repo.delete(block)
Logger.info("Manually unblocked IP: #{ip_address}")
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
{:ok, block}
end
end
@doc """
Lists blocked IPs with optional filtering.
Filter options: `:all`, `:permanent`, `:temporary`
"""
def list_blocked_ips(filter \\ :all) do
query =
case filter do
:permanent ->
from b in IpBlock,
where: is_nil(b.banned_until) and b.offense_count >= 3,
order_by: [desc: b.last_violation_at]
:temporary ->
from b in IpBlock,
where: not is_nil(b.banned_until),
order_by: [desc: b.last_violation_at]
:all ->
from b in IpBlock, order_by: [desc: b.last_violation_at]
end
Repo.all(query)
end
## 404 Tracking Functions
@doc """
Records a 404 path for an IP address.
Tracks unique paths in Redis with 60-second TTL.
Triggers ban creation/escalation if threshold (5) is reached.
"""
def record_404_path(ip_address, path) do
FourOhFourTracker.record_404(ip_address, path)
end
@doc """
Gets the count of unique 404 paths for an IP address within the tracking window.
"""
def get_unique_404_count(ip_address) do
FourOhFourTracker.get_count(ip_address)
end
## Cleanup Functions
@doc """
Deletes expired temporary bans.
Called by ExpiredBanCleanupWorker hourly.
"""
def delete_expired_bans do
{count, _} =
Repo.delete_all(
from(b in IpBlock,
where: not is_nil(b.banned_until),
where: b.banned_until < ^DateTime.utc_now()
)
)
if count > 0 do
Logger.info("Deleted #{count} expired ban(s)")
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
end
:ok
end
@doc """
Deletes old violation records that are not permanent bans.
Removes records older than 90 days with offense_count < 3.
Called by StaleViolationCleanupWorker daily.
"""
def delete_stale_violations do
cutoff = DateTime.add(DateTime.utc_now(), -90, :day)
{count, _} = Repo.delete_all(from(b in IpBlock, where: b.last_violation_at < ^cutoff, where: b.offense_count < 3))
if count > 0 do
Logger.info("Deleted #{count} stale violation record(s)")
end
:ok
end
## Private Functions
defp get_by_ip(ip_address) do
Repo.get_by(IpBlock, ip_address: ip_address)
end
defp create_ban(ip_address, offense_count, banned_until, reason) do
attrs = %{
ip_address: ip_address,
offense_count: offense_count,
banned_until: banned_until,
last_violation_at: DateTime.utc_now(),
reason: reason
}
result =
%IpBlock{}
|> IpBlock.changeset(attrs)
|> Repo.insert()
case result do
{:ok, block} ->
Logger.warning("IP banned (offense #{offense_count}): #{ip_address} until #{banned_until}")
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
{:ok, block}
error ->
error
end
end
defp update_ban(block, offense_count, banned_until, reason) do
attrs = %{
offense_count: offense_count,
banned_until: banned_until,
last_violation_at: DateTime.utc_now(),
reason: reason
}
result =
block
|> IpBlock.changeset(attrs)
|> Repo.update()
case result do
{:ok, updated} ->
Logger.warning("IP ban reset (offense #{offense_count}): #{block.ip_address} until #{banned_until}")
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
{:ok, updated}
error ->
error
end
end
defp escalate_ban(block, reason) do
new_count = block.offense_count + 1
{banned_until, log_msg} =
case new_count do
2 ->
until = DateTime.add(DateTime.utc_now(), 1, :hour)
{"#{until}", "1 hour"}
_ ->
# Permanent ban
{nil, "permanent"}
end
attrs = %{
offense_count: new_count,
banned_until: banned_until,
last_violation_at: DateTime.utc_now(),
reason: reason
}
result =
block
|> IpBlock.changeset(attrs)
|> Repo.update()
case result do
{:ok, updated} ->
Logger.warning("IP ban escalated to #{log_msg} (offense #{new_count}): #{block.ip_address}")
PubSub.broadcast(Towerops.PubSub, "security:blocks", :blocks_updated)
# Push to Cloudflare for permanent bans
if new_count >= 3 do
CloudflareBanWorker.enqueue(block.ip_address)
end
{:ok, updated}
error ->
error
end
end
# Whitelist caching using ETS
@whitelist_cache_table :brute_force_whitelist_cache
defp get_cached_whitelist do
case :ets.whereis(@whitelist_cache_table) do
:undefined ->
create_cache_table()
load_whitelist()
_ ->
try do
case :ets.lookup(@whitelist_cache_table, :whitelist) do
[{:whitelist, entries}] -> entries
[] -> load_whitelist()
end
rescue
ArgumentError ->
create_cache_table()
load_whitelist()
end
end
end
defp create_cache_table do
:ets.new(@whitelist_cache_table, [:set, :public, :named_table, read_concurrency: true])
rescue
ArgumentError ->
:ets.whereis(@whitelist_cache_table)
end
defp load_whitelist do
entries = Repo.all(IpWhitelist)
try do
:ets.insert(@whitelist_cache_table, {:whitelist, entries})
rescue
ArgumentError -> :ok
end
entries
end
defp clear_whitelist_cache do
case :ets.whereis(@whitelist_cache_table) do
:undefined -> :ok
_ -> :ets.delete(@whitelist_cache_table, :whitelist)
end
rescue
ArgumentError -> :ok
end
end