check / check (push) Failing after 1m14s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
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_WAF_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_WAF_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_WAF_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_WAF_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.LimitHitMinuteRefusals
|
|
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.rateLimitPercent.logged()
|
|
case ratelimit.KindBytes:
|
|
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesLimitPercent.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_WAF_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_WAF_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)
|
|
}
|