sneak ruled to remove it. Gone: the heuristic crowdsec, the ban cause crowdsec and its earlier_bans count, the tag crowdsec, the settings SWW_WAF_CROWDSEC_LAPI_URL and SWW_WAF_CROWDSEC_LAPI_KEY, the decision list fetch and parse in internal/reputation, their tests, and every mention in the README, SPEC.md and docs/. EVALUATION.md keeps its comparison of tools. The ban ledger's internal ban function loses its expires parameter, which only CrowdSec bans used. Model: opus-5-5
This commit is contained in:
1 parent
50c50bfcae
commit
478c3affad
27 files changed
+125
-1851
No files matched your search
@@ -1,8 +1,7 @@
|
||||
// Package reputation fetches the lists the settings name by URL: the
|
||||
// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_WAF_TAG_LISTS, and
|
||||
// the decision list of the CrowdSec engine SWW_WAF_CROWDSEC_LAPI_URL names. It
|
||||
// keeps the last good copy of
|
||||
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, and the address lists of
|
||||
// SWW_WAF_TAG_LISTS. It keeps the last good copy of
|
||||
// each, whole, comment lines included, which is used while a fetch fails,
|
||||
// and when each was last tried. It also asks the DNSBL zones of
|
||||
// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||
@@ -13,7 +12,6 @@ package reputation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -35,10 +33,6 @@ const (
|
||||
maxListBytes = 16 << 20
|
||||
// fetchTimeout bounds one fetch of a list.
|
||||
fetchTimeout = time.Minute
|
||||
// crowdSecRefresh is how long after the CrowdSec decision list was last
|
||||
// fetched or tried it is fetched again: the engine is the operator's
|
||||
// own, and makes and ends decisions all the time.
|
||||
crowdSecRefresh = time.Minute
|
||||
// mappedBits is the length of ::ffff:0.0.0.0/96, the netblock of every
|
||||
// IPv4-mapped address.
|
||||
mappedBits = 96
|
||||
@@ -52,9 +46,6 @@ var (
|
||||
)
|
||||
errNotASNPercent = errors.New(
|
||||
"is not an AS number, : and a percentage, such as AS64496:50")
|
||||
errNotDecision = errors.New(
|
||||
"does not give an address or a netblock and a duration, such as 4h0m0s",
|
||||
)
|
||||
)
|
||||
|
||||
// List is a list as lists.json holds it: the URL it is fetched from,
|
||||
@@ -77,14 +68,8 @@ type Params struct {
|
||||
ASNLimitPercentURL string
|
||||
// TagLists are the address lists that add a tag (SWW_WAF_TAG_LISTS).
|
||||
TagLists []config.TagList
|
||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||
// SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||
// (SWW_WAF_CROWDSEC_LAPI_KEY).
|
||||
CrowdSecDecisionsURL string
|
||||
CrowdSecKey string
|
||||
// Refresh is how long after a list was last fetched or tried it is
|
||||
// fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||
// list, which is fetched again crowdSecRefresh after.
|
||||
// fetched again (SWW_WAF_BLOCKLIST_REFRESH).
|
||||
Refresh time.Duration
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
Now func() time.Time
|
||||
@@ -99,10 +84,6 @@ type Params struct {
|
||||
type Lists struct {
|
||||
params Params
|
||||
httpClient *http.Client
|
||||
// crowdSecClient fetches the CrowdSec decision list. It follows no
|
||||
// redirect, so that the key goes to the engine alone: a redirect is a
|
||||
// failure.
|
||||
crowdSecClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
// lists are by URL, one for each URL Params names.
|
||||
@@ -119,22 +100,12 @@ type list struct {
|
||||
}
|
||||
|
||||
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
||||
// netblocks it names, with the lengths among them, for the file of AS:percent
|
||||
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
||||
// list, the decision on each netblock that ends last, with the lengths among
|
||||
// them.
|
||||
// netblocks it names, with the lengths among them, and for the file of
|
||||
// AS:percent lines, the percentage it gives each AS number.
|
||||
type entries struct {
|
||||
netblocks map[netip.Prefix]bool
|
||||
lengths []int
|
||||
percents map[string]int64
|
||||
decisions map[netip.Prefix]Decision
|
||||
}
|
||||
|
||||
// Decision is a decision of the CrowdSec engine to ban a netblock: when
|
||||
// it ends, and the scenario that made it, such as crowdsecurity/ssh-bf.
|
||||
type Decision struct {
|
||||
Expires time.Time
|
||||
Scenario string
|
||||
}
|
||||
|
||||
// New returns the lists, without a copy of any yet.
|
||||
@@ -142,12 +113,7 @@ func New(params Params) *Lists {
|
||||
l := &Lists{
|
||||
params: params,
|
||||
httpClient: &http.Client{},
|
||||
crowdSecClient: &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
lists: map[string]*list{},
|
||||
lists: map[string]*list{},
|
||||
}
|
||||
|
||||
for _, listURL := range l.URLs() {
|
||||
@@ -159,7 +125,7 @@ func New(params Params) *Lists {
|
||||
|
||||
// URLs returns the URL of every list: the blocklists' in the order
|
||||
// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then the
|
||||
// tag lists', then the CrowdSec decision list's.
|
||||
// tag lists'.
|
||||
func (l *Lists) URLs() []string {
|
||||
urls := slices.Clone(l.params.BlocklistURLs)
|
||||
if l.params.ASNLimitPercentURL != "" {
|
||||
@@ -170,10 +136,6 @@ func (l *Lists) URLs() []string {
|
||||
urls = append(urls, list.URL)
|
||||
}
|
||||
|
||||
if l.params.CrowdSecDecisionsURL != "" {
|
||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
@@ -225,40 +187,6 @@ func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||
return percent, listed
|
||||
}
|
||||
|
||||
// CrowdSecDecision returns the decision of the copy of the CrowdSec
|
||||
// decision list on a netblock that holds addr and that ends last, and
|
||||
// whether it is still in force at now. A decision that has ended no
|
||||
// longer bans, even before the next fetch drops it.
|
||||
func (l *Lists) CrowdSecDecision(
|
||||
addr netip.Addr,
|
||||
now time.Time,
|
||||
) (Decision, bool) {
|
||||
if l.params.CrowdSecDecisionsURL == "" {
|
||||
return Decision{}, false
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
kept := l.lists[l.params.CrowdSecDecisionsURL].entries
|
||||
|
||||
var last Decision
|
||||
|
||||
for _, length := range kept.lengths {
|
||||
netblock, err := addr.Prefix(length)
|
||||
if err != nil {
|
||||
continue // an IPv6 netblock's length, past an IPv4 address's 32 bits
|
||||
}
|
||||
|
||||
decision := kept.decisions[netblock]
|
||||
if decision.Expires.After(last.Expires) {
|
||||
last = decision
|
||||
}
|
||||
}
|
||||
|
||||
return last, now.Before(last.Expires)
|
||||
}
|
||||
|
||||
// Fetched returns when the copy in use of the list at listURL was
|
||||
// fetched, or zero while there is none.
|
||||
func (l *Lists) Fetched(listURL string) time.Time {
|
||||
@@ -331,7 +259,7 @@ func (l *Lists) Load(lists []List) error {
|
||||
continue // dropped, or a list tried but never fetched, without a copy
|
||||
}
|
||||
|
||||
read, err := l.parse(kept.URL, kept.Lines, kept.Fetched)
|
||||
read, err := l.parse(kept.URL, kept.Lines)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the copy of %s: %w", kept.URL, err)
|
||||
}
|
||||
@@ -377,8 +305,7 @@ func (l *Lists) fetchDue(ctx context.Context) time.Time {
|
||||
}
|
||||
|
||||
// due returns when the list at listURL is to be fetched: Refresh after it
|
||||
// was last fetched or tried, the later of the two, or crowdSecRefresh
|
||||
// after for the CrowdSec decision list.
|
||||
// was last fetched or tried, the later of the two.
|
||||
func (l *Lists) due(listURL string) time.Time {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -390,10 +317,6 @@ func (l *Lists) due(listURL string) time.Time {
|
||||
last = held.kept.Tried
|
||||
}
|
||||
|
||||
if listURL == l.params.CrowdSecDecisionsURL {
|
||||
return last.Add(crowdSecRefresh)
|
||||
}
|
||||
|
||||
return last.Add(l.params.Refresh)
|
||||
}
|
||||
|
||||
@@ -408,7 +331,7 @@ func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||
|
||||
var found entries
|
||||
if err == nil {
|
||||
found, err = l.parse(listURL, lines, now)
|
||||
found, err = l.parse(listURL, lines)
|
||||
}
|
||||
|
||||
cutOff := err != nil && ctx.Err() != nil
|
||||
@@ -468,11 +391,8 @@ func raiseFailure(queue *alerts.Queue, reason, source string, err error) {
|
||||
})
|
||||
}
|
||||
|
||||
// get fetches the list at listURL, and returns its lines. The CrowdSec
|
||||
// decision list is fetched with CrowdSecKey in the header X-Api-Key, where
|
||||
// the engine looks for it, by crowdSecClient, which follows no redirect.
|
||||
// An answer other than 200, or a list longer than maxListBytes, is a
|
||||
// failure.
|
||||
// get fetches the list at listURL, and returns its lines. An answer other
|
||||
// than 200, or a list longer than maxListBytes, is a failure.
|
||||
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
@@ -487,14 +407,7 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
return nil, fmt.Errorf("make the request: %w", err)
|
||||
}
|
||||
|
||||
client := l.httpClient
|
||||
|
||||
if listURL == l.params.CrowdSecDecisionsURL {
|
||||
req.Header.Set("X-Api-Key", l.params.CrowdSecKey)
|
||||
client = l.crowdSecClient
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
res, err := l.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// Do's error names the URL, which the log line and the alert name
|
||||
// already: only what went wrong is kept.
|
||||
@@ -526,22 +439,16 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
||||
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
||||
// a # on a line is left out, and so is a line left blank. Any other line that
|
||||
// parse reads the lines of the list at listURL: those of a blocklist or a
|
||||
// tag list, or of the file of AS:percent lines. Anything after a ; or a #
|
||||
// on a line is left out, and so is a line left blank. Any other line that
|
||||
// does not read is an error naming it by its number.
|
||||
func (l *Lists) parse(
|
||||
listURL string, lines []string, fetched time.Time,
|
||||
) (entries, error) {
|
||||
switch listURL {
|
||||
case l.params.ASNLimitPercentURL:
|
||||
func (l *Lists) parse(listURL string, lines []string) (entries, error) {
|
||||
if listURL == l.params.ASNLimitPercentURL {
|
||||
return parsePercents(lines)
|
||||
case l.params.CrowdSecDecisionsURL:
|
||||
return parseDecisions(lines, fetched)
|
||||
default:
|
||||
return parseNetblocks(lines)
|
||||
}
|
||||
|
||||
return parseNetblocks(lines)
|
||||
}
|
||||
|
||||
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
||||
@@ -628,64 +535,6 @@ func parsePercents(lines []string) (entries, error) {
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// parseDecisions reads the lines of the CrowdSec decision list fetched at
|
||||
// fetched: the engine's answer, a JSON list of its decisions in force,
|
||||
// null while it has none. A decision of the type ban whose scope is Ip or
|
||||
// Range, as CrowdSec names them, bans its value, an address or a netblock
|
||||
// as parseNetblock reads it, until its duration, the time it had left as
|
||||
// the engine answered, has passed since fetched. Any other decision, such
|
||||
// as one to show a captcha or one on a country, is left out. A decision
|
||||
// to ban whose value or duration does not read is an error naming it by
|
||||
// its number.
|
||||
func parseDecisions(lines []string, fetched time.Time) (entries, error) {
|
||||
var answer []struct {
|
||||
Duration string `json:"duration"`
|
||||
Scenario string `json:"scenario"`
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &answer)
|
||||
if err != nil {
|
||||
return entries{}, fmt.Errorf("read the answer: %w", err)
|
||||
}
|
||||
|
||||
found := entries{decisions: map[netip.Prefix]Decision{}}
|
||||
|
||||
for i, decision := range answer {
|
||||
if decision.Type != "ban" ||
|
||||
(decision.Scope != "Ip" && decision.Scope != "Range") {
|
||||
continue
|
||||
}
|
||||
|
||||
netblock, ok := parseNetblock(decision.Value)
|
||||
|
||||
duration, err := time.ParseDuration(decision.Duration)
|
||||
if !ok || err != nil {
|
||||
return entries{}, fmt.Errorf(
|
||||
"decision %d %w",
|
||||
i+1,
|
||||
errNotDecision,
|
||||
)
|
||||
}
|
||||
|
||||
expires := fetched.Add(duration)
|
||||
if expires.After(found.decisions[netblock].Expires) {
|
||||
found.decisions[netblock] = Decision{
|
||||
Expires: expires,
|
||||
Scenario: decision.Scenario,
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(found.lengths, netblock.Bits()) {
|
||||
found.lengths = append(found.lengths, netblock.Bits())
|
||||
}
|
||||
}
|
||||
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// withoutComment returns line without anything after a ; or a #, and
|
||||
// without the spaces around what is left.
|
||||
func withoutComment(line string) string {
|
||||
|
||||
Reference in new issue
Block a user