check / check (push) In progress
Rename too_large, rate_limited, rule_blocked and denied, and split banned into temporarily_banned and permanently_banned by whether the ban runs out. A client under both a temporary and a permanent ban gets the permanent one. A request that makes a ban permanent gets permanently_banned. A ban rule, a trap path or a CrowdSec decision gets the value for the ban it makes, and in observe mode would_action names the ban enforce mode would have made. The offence kind rule_blocked follows, as a metric label and a key in clients.json. Model: opus-5-5
326 lines
9.8 KiB
Go
326 lines
9.8 KiB
Go
package discriminator
|
|
|
|
// The heuristic active_ban, and the code that makes bans, which several
|
|
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
|
|
|
import (
|
|
"context"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
|
// with action.
|
|
func (rq *request) banResponse(action string) *refusal {
|
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
|
}
|
|
|
|
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
|
// in, in force at now. It notes for the log line when that ban ends. A request
|
|
// that makes the ban permanent, or in observe mode would have, raises the alert
|
|
// for it. Its figures are the ban's netblock and when it ends.
|
|
type activeBan struct{}
|
|
|
|
func (activeBan) name() string { return "active_ban" }
|
|
|
|
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
check := rq.h.ledger.Check
|
|
if rq.h.config.Observe {
|
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
|
}
|
|
|
|
ban, banned, madePermanent := check(rq.client, now)
|
|
if banned {
|
|
rq.line.BanExpires = banExpires(ban)
|
|
}
|
|
|
|
if madePermanent {
|
|
ban.Expires = time.Time{} // the ban made permanent, which Find leaves as it is
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
if !banned {
|
|
return nil
|
|
}
|
|
|
|
return &verdict{
|
|
action: banAction(ban.Permanent()),
|
|
figures: map[string]any{
|
|
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
|
},
|
|
}
|
|
}
|
|
|
|
// banAction is the action of a request refused under a ban or for making one:
|
|
// permanent says the ban never runs out.
|
|
func banAction(permanent bool) string {
|
|
if permanent {
|
|
return requestlog.ActionPermanentlyBanned
|
|
}
|
|
|
|
return requestlog.ActionTemporarilyBanned
|
|
}
|
|
|
|
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
|
// window, the count and the limit.
|
|
func limitFigures(hit ratelimit.Hit) map[string]any {
|
|
return map[string]any{
|
|
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
|
}
|
|
}
|
|
|
|
// countedBytes returns the request's bytes, once it has ended, as the
|
|
// byte limits and the anomaly thresholds count them: the response's body
|
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
|
func (rq *request) countedBytes() int64 {
|
|
response, request := rq.out.bytes, rq.requestBytes
|
|
|
|
switch rq.h.config.BytesCount {
|
|
case "response":
|
|
return response
|
|
case "request":
|
|
return request
|
|
default: // both
|
|
return response + request
|
|
}
|
|
}
|
|
|
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
|
// one hit names, notes the offence for the log line and counts the hit in
|
|
// the metrics. status is what the client was sent, or is sent:
|
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
|
// error burst. The ban's notes give the client's limit percentage for a
|
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
|
// the client's counters back to zero. In observe mode it makes no ban and
|
|
// sets nothing back, and raises the alert for the ban it would have made,
|
|
// if that alert would be sent.
|
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|
switch hit.Kind {
|
|
case ratelimit.KindBytes:
|
|
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
|
case ratelimit.KindRefusals:
|
|
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
|
default:
|
|
rq.line.LimitHit = hit.Window
|
|
}
|
|
|
|
rq.line.Offence = requestlog.OffenceLimit
|
|
rq.h.metrics.LimitHit(hit)
|
|
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
|
return
|
|
}
|
|
|
|
notes := bans.Notes{
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
Kind: hit.Kind,
|
|
Limit: hit.Limit,
|
|
Window: hit.Window,
|
|
Count: hit.Count,
|
|
Reputation: rq.reputation,
|
|
Request: rq.noted(now, status),
|
|
Requests: rq.netblockRequests(netblock),
|
|
}
|
|
|
|
switch hit.Kind {
|
|
case ratelimit.KindRequests:
|
|
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
|
case ratelimit.KindBytes:
|
|
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
|
}
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
|
rq.h.limiter.Reset(rq.h.clientGroup(rq.client))
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
}
|
|
|
|
// banForAttack bans the client's netblock at now for a clear sign of
|
|
// attack, which notes name: the ban rule that matched, or the trap path
|
|
// asked for. It fills in the rest of the notes. In observe mode it makes
|
|
// no ban, and raises the alert for the ban it would have made, if that
|
|
// alert would be sent. It returns the action for that ban.
|
|
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
|
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
|
}
|
|
|
|
notes.ASN = rq.line.ASN
|
|
notes.ASName = rq.line.ASName
|
|
notes.Country = rq.line.Country
|
|
notes.Reputation = rq.reputation
|
|
notes.Request = rq.noted(now, rq.h.config.BanResponse)
|
|
notes.Requests = rq.netblockRequests(netblock)
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return banAction(ban.Permanent())
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return banAction(ban.Permanent())
|
|
}
|
|
|
|
// banForCrowdSec bans the client's netblock at now until decision,
|
|
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
|
// ban, and raises the alert for the ban it would have made, if that alert
|
|
// would be sent. It returns the action for that ban.
|
|
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
|
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
|
}
|
|
|
|
notes := bans.Notes{
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
Reputation: rq.reputation,
|
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
|
Requests: rq.netblockRequests(netblock),
|
|
}
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires,
|
|
decision.Scenario, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return banAction(ban.Permanent())
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
|
decision.Scenario, notes)
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return banAction(ban.Permanent())
|
|
}
|
|
|
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
|
// made at now would be sent. In observe mode the ban the request would
|
|
// have made is worked out only then, at most once per
|
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
|
// netblock's requests, which can mean going through every client.
|
|
func (rq *request) wouldAlertBan(
|
|
netblock netip.Prefix, now time.Time, cause string,
|
|
) bool {
|
|
event := alerts.EventBan
|
|
if rq.h.ledger.WouldBePermanent(netblock, now, cause) {
|
|
event = alerts.EventPermanentBan
|
|
}
|
|
|
|
return rq.h.alerts.WouldSend(event, netblock)
|
|
}
|
|
|
|
// alertBan raises the alert for ban, which the request made, or made
|
|
// permanent: permanent_ban for a permanent ban, ban for another. Its
|
|
// detail gives the ban's cause, when it ends, and its notes, and in
|
|
// observe mode, where ban is the ban that would have been made, or made
|
|
// permanent, mode, observe.
|
|
func (rq *request) alertBan(ban bans.Ban) {
|
|
event := alerts.EventBan
|
|
if ban.Permanent() {
|
|
event = alerts.EventPermanentBan
|
|
}
|
|
|
|
detail := map[string]any{
|
|
"cause": ban.Cause, "ban_expires": banExpires(ban), "notes": ban.Notes,
|
|
}
|
|
if rq.h.config.Observe {
|
|
detail["mode"] = "observe"
|
|
}
|
|
|
|
rq.h.alerts.Raise(alerts.Alert{
|
|
Event: event,
|
|
Host: rq.site,
|
|
Client: rq.client,
|
|
Netblock: ban.Netblock,
|
|
ASN: ban.Notes.ASN,
|
|
ASName: ban.Notes.ASName,
|
|
Country: ban.Notes.Country,
|
|
Reason: ban.Reason,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// noted is the request, at now, with status, what the client was sent, or
|
|
// in observe mode would have been, as the notes of the ban it makes keep
|
|
// it.
|
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
|
return bans.Request{
|
|
Time: now,
|
|
Method: rq.line.Method,
|
|
Host: rq.line.Host,
|
|
Path: rq.requestURI,
|
|
Status: status,
|
|
UserAgent: rq.line.UserAgent,
|
|
}
|
|
}
|
|
|
|
// netblockRequests is how many requests netblock has sent since it was
|
|
// first seen, this one included: the histories count it only once it has
|
|
// ended.
|
|
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|
return rq.h.limiter.Requests(netblock) + 1
|
|
}
|
|
|
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
|
// counts it in.
|
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
|
addr := client.Unmap()
|
|
if addr.Is4() {
|
|
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
|
}
|
|
|
|
return h.clientGroup(addr)
|
|
}
|
|
|
|
// banExpires is when ban ends, as the log line gives it: a time, or
|
|
// permanent.
|
|
func banExpires(ban bans.Ban) string {
|
|
if ban.Permanent() {
|
|
return permanent
|
|
}
|
|
|
|
return requestlog.FormatTime(ban.Expires)
|
|
}
|