mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-03-21 17:13:26 +01:00
Include go-grok and write enrichment functions for log normalisation
This commit is contained in:
2
go.mod
2
go.mod
@@ -21,6 +21,7 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-grok v0.3.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -32,6 +33,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magefile/mage v1.15.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -13,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elastic/go-grok v0.3.1 h1:WEhUxe2KrwycMnlvMimJXvzRa7DoByJB4PVUIE1ZD/U=
|
||||
github.com/elastic/go-grok v0.3.1/go.mod h1:n38ls8ZgOboZRgKcjMY8eFeZFMmcL9n2lP0iHhIDk64=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -54,6 +56,8 @@ github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP
|
||||
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
|
||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
||||
221
internal/enrichment/logparse.go
Normal file
221
internal/enrichment/logparse.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||
//
|
||||
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
|
||||
//
|
||||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||
// You may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package enrichment
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
grok "github.com/elastic/go-grok"
|
||||
)
|
||||
|
||||
// =========================================================================
|
||||
// Types / Variables
|
||||
// =========================================================================
|
||||
|
||||
type compiledPattern struct {
|
||||
def PatternDef
|
||||
grok *grok.Grok
|
||||
}
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
httpCompiled []compiledPattern
|
||||
sshCompiled []compiledPattern
|
||||
mailCompiled []compiledPattern
|
||||
fallbackCompiled []compiledPattern
|
||||
)
|
||||
|
||||
// Ensure the patterns are initialised.
|
||||
func ensureInit() {
|
||||
initOnce.Do(func() {
|
||||
httpCompiled = compileAll(HTTPPatterns)
|
||||
sshCompiled = compileAll(SSHPatterns)
|
||||
mailCompiled = compileAll(MailPatterns)
|
||||
fallbackCompiled = compileAll(FallbackPatterns)
|
||||
})
|
||||
}
|
||||
|
||||
func compileAll(defs []PatternDef) []compiledPattern {
|
||||
var out []compiledPattern
|
||||
for _, d := range defs {
|
||||
g := grok.New()
|
||||
if err := g.AddPatterns(SubPatterns); err != nil {
|
||||
log.Printf("⚠️ enrichment failed to add sub-patterns for %s: %v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
if err := g.Compile(d.Pattern, true); err != nil {
|
||||
log.Printf("⚠️ enrichment failed to compile pattern %s: %v", d.Name, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, compiledPattern{def: d, grok: g})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Parses the raw log text from a Fail2ban event and returns a map of structured fields.
|
||||
func ParseLogLines(logs, jail string) map[string]interface{} {
|
||||
ensureInit()
|
||||
|
||||
if strings.TrimSpace(logs) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := splitAndClean(logs)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ordered := orderedPatterns(jail)
|
||||
|
||||
var parsedEntries []map[string]interface{}
|
||||
var bestResult map[string]interface{}
|
||||
var bestDef PatternDef
|
||||
bestFieldCount := 0
|
||||
|
||||
for _, line := range lines {
|
||||
result, def := parseLine(line, ordered)
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
result["log.original"] = line
|
||||
|
||||
parsedEntries = append(parsedEntries, result)
|
||||
|
||||
if len(result) > bestFieldCount {
|
||||
bestFieldCount = len(result)
|
||||
bestResult = result
|
||||
bestDef = def
|
||||
}
|
||||
}
|
||||
|
||||
if bestResult == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enriched := make(map[string]interface{}, len(bestResult)+4)
|
||||
for k, v := range bestResult {
|
||||
if k == "log.original" {
|
||||
continue
|
||||
}
|
||||
enriched[k] = v
|
||||
}
|
||||
|
||||
if bestDef.Action != "" {
|
||||
enriched["event.action"] = bestDef.Action
|
||||
}
|
||||
if _, ok := enriched["process.name"]; !ok && bestDef.Process != "" {
|
||||
enriched["process.name"] = bestDef.Process
|
||||
}
|
||||
|
||||
postProcessFields(enriched)
|
||||
|
||||
if len(parsedEntries) > 1 {
|
||||
enriched["fail2ban.parsed_logs"] = parsedEntries
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper functions
|
||||
// =========================================================================
|
||||
|
||||
// Splits the raw log text
|
||||
func splitAndClean(logs string) []string {
|
||||
raw := strings.Split(logs, "\n")
|
||||
var out []string
|
||||
for _, l := range raw {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Returns compiled pattern slices in a priority order derived from the jail name.
|
||||
// For example, an "sshd" jail tries SSH patterns first.
|
||||
func orderedPatterns(jail string) [][]compiledPattern {
|
||||
jl := strings.ToLower(jail)
|
||||
|
||||
switch {
|
||||
case containsAny(jl, "ssh"):
|
||||
return [][]compiledPattern{sshCompiled, httpCompiled, mailCompiled, fallbackCompiled}
|
||||
case containsAny(jl, "apache", "nginx", "http", "npm", "proxy", "web"):
|
||||
return [][]compiledPattern{httpCompiled, sshCompiled, mailCompiled, fallbackCompiled}
|
||||
case containsAny(jl, "postfix", "dovecot", "mail", "smtp", "imap", "pop3"):
|
||||
return [][]compiledPattern{mailCompiled, sshCompiled, httpCompiled, fallbackCompiled}
|
||||
default:
|
||||
return [][]compiledPattern{httpCompiled, sshCompiled, mailCompiled, fallbackCompiled}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Tries every compiled pattern in priority order and returns the first successful match.
|
||||
func parseLine(line string, ordered [][]compiledPattern) (map[string]interface{}, PatternDef) {
|
||||
for _, group := range ordered {
|
||||
for _, cp := range group {
|
||||
result, err := cp.grok.ParseTypedString(line)
|
||||
if err != nil || len(result) == 0 {
|
||||
continue
|
||||
}
|
||||
cleanEmpty(result)
|
||||
if len(result) == 0 {
|
||||
continue
|
||||
}
|
||||
return result, cp.def
|
||||
}
|
||||
}
|
||||
return nil, PatternDef{}
|
||||
}
|
||||
|
||||
// Removes keys whose value is empty or a dash placeholder.
|
||||
func cleanEmpty(m map[string]interface{}) {
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok && (s == "" || s == "-") {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Performs secondary enrichment on the parsed fields.
|
||||
func postProcessFields(m map[string]interface{}) {
|
||||
// Splits url.original into url.path and url.query
|
||||
if raw, ok := m["url.original"].(string); ok && raw != "" {
|
||||
if idx := strings.IndexByte(raw, '?'); idx >= 0 {
|
||||
m["url.path"] = raw[:idx]
|
||||
m["url.query"] = raw[idx+1:]
|
||||
} else {
|
||||
m["url.path"] = raw
|
||||
}
|
||||
}
|
||||
|
||||
// Normalises common log.level values to lowercase
|
||||
if lv, ok := m["log.level"].(string); ok {
|
||||
m["log.level"] = strings.ToLower(lv)
|
||||
}
|
||||
}
|
||||
194
internal/enrichment/patterns.go
Normal file
194
internal/enrichment/patterns.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||
//
|
||||
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
|
||||
//
|
||||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||
// You may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package enrichment
|
||||
|
||||
// =========================================================================
|
||||
// Types / Variables
|
||||
// =========================================================================
|
||||
|
||||
// Describes a single log format pattern.
|
||||
type PatternDef struct {
|
||||
Name string
|
||||
Pattern string
|
||||
Category string
|
||||
Action string
|
||||
Process string
|
||||
}
|
||||
|
||||
// Custom grok sub-patterns registered alongside the built-in defaults from elastic/go-grok.
|
||||
// They are referenced by the top-level log format patterns below.
|
||||
var SubPatterns = map[string]string{
|
||||
"F2B_HTTPDUSER": `[a-zA-Z0-9._@%+-]+`,
|
||||
"F2B_NGINX_TS": `\d{4}/\d{2}/\d{2} %{TIME}`,
|
||||
"F2B_APACHE_ERROR_TS": `%{DAY} %{MONTH} %{MONTHDAY} %{TIME}(?:\.\d+)? %{YEAR}`,
|
||||
"F2B_SYSLOG_PREFIX": `%{SYSLOGTIMESTAMP:log.timestamp} %{SYSLOGHOST:log.syslog.hostname} %{PROG:process.name}(?:\[%{POSINT:process.pid:int}\])?:`,
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTTP Access / Error Log Patterns (highest priority first)
|
||||
// =========================================================================
|
||||
|
||||
var HTTPPatterns = []PatternDef{
|
||||
{
|
||||
// Apache/Nginx combined log format with vhost/server-name prefix
|
||||
// www.example.ch 1.1.1.1 - - [23/Feb/2026:14:37:29 +0100] "GET /.git/config HTTP/1.1" 301 248 "-" "Mozilla/5.0"
|
||||
Name: "http_combined_vhost",
|
||||
Category: "http",
|
||||
Action: "http_request",
|
||||
Process: "httpd",
|
||||
Pattern: `%{IPORHOST:server.address} %{IPORHOST:source.address} (?:-|%{F2B_HTTPDUSER}) (?:-|%{F2B_HTTPDUSER:source.user.name}) \[%{HTTPDATE:log.timestamp}\] "(?:%{WORD:http.request.method} %{NOTSPACE:url.original}(?: HTTP/%{NUMBER:http.version})?|%{DATA})" (?:-|%{INT:http.response.status_code:int}) (?:-|%{INT:http.response.body.bytes:int}) "(?:-|%{DATA:http.request.referrer})" "(?:-|%{DATA:user_agent.original})"`,
|
||||
},
|
||||
{
|
||||
// Apache/Nginx combined log format
|
||||
// 1.1.1.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.0" 200 2326 "http://www.example.com/" "Mozilla/4.08"
|
||||
Name: "http_combined",
|
||||
Category: "http",
|
||||
Action: "http_request",
|
||||
Process: "httpd",
|
||||
Pattern: `%{IPORHOST:source.address} (?:-|%{F2B_HTTPDUSER}) (?:-|%{F2B_HTTPDUSER:source.user.name}) \[%{HTTPDATE:log.timestamp}\] "(?:%{WORD:http.request.method} %{NOTSPACE:url.original}(?: HTTP/%{NUMBER:http.version})?|%{DATA})" (?:-|%{INT:http.response.status_code:int}) (?:-|%{INT:http.response.body.bytes:int}) "(?:-|%{DATA:http.request.referrer})" "(?:-|%{DATA:user_agent.original})"`,
|
||||
},
|
||||
{
|
||||
// Apache/Nginx common log format
|
||||
// 1.1.1.1 - frank [10/Oct/2000:13:55:36 -0700] "GET /page HTTP/1.0" 200 2326
|
||||
Name: "http_common",
|
||||
Category: "http",
|
||||
Action: "http_request",
|
||||
Process: "httpd",
|
||||
Pattern: `%{IPORHOST:source.address} (?:-|%{F2B_HTTPDUSER}) (?:-|%{F2B_HTTPDUSER:source.user.name}) \[%{HTTPDATE:log.timestamp}\] "(?:%{WORD:http.request.method} %{NOTSPACE:url.original}(?: HTTP/%{NUMBER:http.version})?|%{DATA})" (?:-|%{INT:http.response.status_code:int}) (?:-|%{INT:http.response.body.bytes:int})`,
|
||||
},
|
||||
{
|
||||
// Apache 2.4 error log format (with module, PID, optional TID)
|
||||
// [Thu Feb 23 14:37:29.123456 2026] [core:error] [pid 12345:tid 678] [client 1.1.1.1:54321] AH00126: error message
|
||||
Name: "apache_error_24",
|
||||
Category: "http",
|
||||
Action: "http_error",
|
||||
Process: "apache",
|
||||
Pattern: `\[%{F2B_APACHE_ERROR_TS:log.timestamp}\] \[(?:%{WORD:apache.module}:)?%{LOGLEVEL:log.level}\] \[pid %{POSINT:process.pid:int}(?::tid %{INT})?\]%{DATA}\[client %{IP:source.address}(?::%{POSINT:source.port:int})?\] %{GREEDYDATA:message}`,
|
||||
},
|
||||
{
|
||||
// Apache 2.0 error log format (no PID block)
|
||||
// [Thu Feb 23 14:37:29 2026] [error] [client 1.1.1.1] error message
|
||||
Name: "apache_error_20",
|
||||
Category: "http",
|
||||
Action: "http_error",
|
||||
Process: "apache",
|
||||
Pattern: `\[%{F2B_APACHE_ERROR_TS:log.timestamp}\] \[%{LOGLEVEL:log.level}\]%{DATA}\[client %{IP:source.address}(?::%{POSINT:source.port:int})?\] %{GREEDYDATA:message}`,
|
||||
},
|
||||
{
|
||||
// Nginx error log format
|
||||
// 2026/02/23 14:37:29 [error] 1234#0: *5678 access forbidden, client: 1.1.1.1, server: example.com, request: "GET /.git/config HTTP/1.1"
|
||||
Name: "nginx_error",
|
||||
Category: "http",
|
||||
Action: "http_error",
|
||||
Process: "nginx",
|
||||
Pattern: `%{F2B_NGINX_TS:log.timestamp} \[%{LOGLEVEL:log.level}\] %{POSINT:process.pid:int}#%{NONNEGINT}: \*%{NONNEGINT} %{DATA:message}, client: %{IP:source.address}(?:, server: %{NOTSPACE:server.address})?(?:, request: "(?:%{WORD:http.request.method} %{NOTSPACE:url.original}(?: HTTP/%{NUMBER:http.version})?)?")?%{GREEDYDATA}`,
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SSH Authentication Patterns
|
||||
// =========================================================================
|
||||
|
||||
var SSHPatterns = []PatternDef{
|
||||
{
|
||||
// sshd failed password
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: Failed password for root from 1.1.1.1 port 54321 ssh2
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: Failed password for invalid user admin from 1.1.1.1 port 54321 ssh2
|
||||
Name: "sshd_failed_password",
|
||||
Category: "ssh",
|
||||
Action: "failed_password",
|
||||
Process: "sshd",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} [Ff]ailed password for (?:invalid user )?%{USERNAME:source.user.name} from %{IP:source.address} port %{INT:source.port:int}%{GREEDYDATA}`,
|
||||
},
|
||||
{
|
||||
// sshd invalid user
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: Invalid user admin from 1.1.1.1 port 54321
|
||||
Name: "sshd_invalid_user",
|
||||
Category: "ssh",
|
||||
Action: "invalid_user",
|
||||
Process: "sshd",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} [Ii]nvalid user %{USERNAME:source.user.name} from %{IP:source.address} port %{INT:source.port:int}%{GREEDYDATA}`,
|
||||
},
|
||||
{
|
||||
// sshd disconnect / connection closed (preauth)
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: Disconnected from authenticating user root 1.1.1.1 port 54321 [preauth]
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: Connection closed by authenticating user root 1.1.1.1 port 54321 [preauth]
|
||||
Name: "sshd_disconnect",
|
||||
Category: "ssh",
|
||||
Action: "disconnect",
|
||||
Process: "sshd",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} (?:[Dd]isconnected from|[Cc]onnection closed by)(?: (?:authenticating|invalid))? user %{USERNAME:source.user.name} %{IP:source.address} port %{INT:source.port:int}%{GREEDYDATA}`,
|
||||
},
|
||||
{
|
||||
// PAM authentication failure
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=1.1.1.1
|
||||
// Feb 23 14:37:29 myhost sshd[12345]: pam_unix(sshd:auth): authentication failure; ... rhost=1.1.1.1 user=root
|
||||
Name: "sshd_pam_failure",
|
||||
Category: "ssh",
|
||||
Action: "pam_auth_failure",
|
||||
Process: "sshd",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} pam_unix\(%{DATA}\): authentication failure;%{DATA}rhost=%{IP:source.address}(?:%{DATA}user=%{USERNAME:source.user.name})?%{GREEDYDATA}`,
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Mail Server Patterns
|
||||
// =========================================================================
|
||||
|
||||
var MailPatterns = []PatternDef{
|
||||
{
|
||||
// Postfix reject
|
||||
// Feb 23 14:37:29 myhost postfix/smtpd[12345]: NOQUEUE: reject: RCPT from unknown[1.1.1.1]: 554 5.7.1 Relay access denied
|
||||
Name: "postfix_reject",
|
||||
Category: "mail",
|
||||
Action: "mail_reject",
|
||||
Process: "postfix",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} NOQUEUE: reject: %{WORD:postfix.action} from %{DATA}\[%{IP:source.address}\]:%{GREEDYDATA:message}`,
|
||||
},
|
||||
{
|
||||
// Postfix auth failure
|
||||
// Feb 23 14:37:29 myhost postfix/smtpd[12345]: warning: unknown[1.1.1.1]: SASL LOGIN authentication failed: ...
|
||||
Name: "postfix_auth",
|
||||
Category: "mail",
|
||||
Action: "mail_auth_failure",
|
||||
Process: "postfix",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} warning: %{DATA}\[%{IP:source.address}\]: SASL %{WORD} authentication failed%{GREEDYDATA:message}`,
|
||||
},
|
||||
{
|
||||
// Dovecot auth failure
|
||||
// Feb 23 14:37:29 myhost dovecot: imap-login: Disconnected (auth failed, 3 attempts): user=<testuser>, method=PLAIN, rip=1.1.1.1, lip=192.168.1.1
|
||||
Name: "dovecot_auth",
|
||||
Category: "mail",
|
||||
Action: "auth_failure",
|
||||
Process: "dovecot",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} %{DATA}: %{DATA:message}: user=<?%{DATA:source.user.name}>?,%{DATA}rip=%{IP:source.address}%{GREEDYDATA}`,
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fallback Patterns (if no other pattern matches)
|
||||
// =========================================================================
|
||||
|
||||
var FallbackPatterns = []PatternDef{
|
||||
{
|
||||
// Generic syslog line
|
||||
Name: "generic_syslog",
|
||||
Category: "syslog",
|
||||
Action: "syslog_event",
|
||||
Pattern: `%{F2B_SYSLOG_PREFIX} %{GREEDYDATA:message}`,
|
||||
},
|
||||
}
|
||||
126
internal/enrichment/whoisparse.go
Normal file
126
internal/enrichment/whoisparse.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||
//
|
||||
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
|
||||
//
|
||||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||
// You may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package enrichment
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// =========================================================================
|
||||
// Types / Variables
|
||||
// =========================================================================
|
||||
|
||||
// Maps raw whois keys (both ARIN and RIPE formats) to the normalised ECS-style output fields.
|
||||
// When the same output field appears more than once the LAST match wins, which naturally prefers the more specific RIPE/regional record over the ARIN referral header.
|
||||
var whoisKeyMap = map[string]string{
|
||||
// ARIN
|
||||
"netrange": "whois.net_range",
|
||||
"cidr": "whois.cidr",
|
||||
"netname": "whois.net_name",
|
||||
"orgname": "whois.org_name",
|
||||
"orgid": "whois.org_id",
|
||||
"country": "whois.country",
|
||||
"orgabuseemail": "whois.abuse_email",
|
||||
"orgabusephone": "whois.abuse_phone",
|
||||
"originas": "whois.asn",
|
||||
"regdate": "whois.registration_date",
|
||||
"updated": "whois.updated_date",
|
||||
|
||||
// RIPE / APNIC
|
||||
"inetnum": "whois.net_range",
|
||||
"inet6num": "whois.net_range",
|
||||
"org-name": "whois.org_name",
|
||||
"organisation": "whois.org_id",
|
||||
"org": "whois.org_id",
|
||||
"origin": "whois.asn",
|
||||
"created": "whois.registration_date",
|
||||
"last-modified": "whois.updated_date",
|
||||
"route": "whois.cidr",
|
||||
"route6": "whois.cidr",
|
||||
}
|
||||
|
||||
// Extracts the abuse contact email from the RIPE comment line:
|
||||
// % Abuse contact for '...' is 'abuse@example.com'
|
||||
var ripeAbuseRe = regexp.MustCompile(`(?i)abuse contact for .+ is '([^']+)'`)
|
||||
|
||||
// Matches "Key: value" lines in whois output (supports both CamelCase ARIN keys and lower-case-hyphenated RIPE keys).
|
||||
var kvLineRe = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_-]*):\s+(.+)$`)
|
||||
|
||||
// =========================================================================
|
||||
// Functions
|
||||
// =========================================================================
|
||||
|
||||
// Parses WHOIS text into structured fields.
|
||||
// Handles ARIN, RIPE, APNIC, LACNIC and AfriNIC formats.
|
||||
// Fields that cannot be extracted are omitted.
|
||||
func ParseWhois(whois string) map[string]interface{} {
|
||||
if strings.TrimSpace(whois) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
seenKeys := make(map[string]bool)
|
||||
|
||||
for _, line := range strings.Split(whois, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Checks for RIPE-style abuse contact comment
|
||||
if strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
|
||||
if m := ripeAbuseRe.FindStringSubmatch(line); len(m) == 2 {
|
||||
result["whois.abuse_email"] = strings.TrimSpace(m[1])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m := kvLineRe.FindStringSubmatch(line)
|
||||
if len(m) != 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
rawKey := strings.TrimSpace(m[1])
|
||||
rawVal := strings.TrimSpace(m[2])
|
||||
|
||||
lookupKey := strings.ToLower(rawKey)
|
||||
|
||||
outField, known := whoisKeyMap[lookupKey]
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
if outField == "whois.abuse_email" && seenKeys[outField] {
|
||||
continue
|
||||
}
|
||||
result[outField] = rawVal
|
||||
seenKeys[outField] = true
|
||||
}
|
||||
|
||||
// Normalises the ASN: strips "AS" prefix if present (e.g. "AS200373" → "200373")
|
||||
if asn, ok := result["whois.asn"].(string); ok {
|
||||
asn = strings.TrimSpace(asn)
|
||||
if strings.HasPrefix(strings.ToUpper(asn), "AS") {
|
||||
result["whois.asn"] = asn[2:]
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user