Include go-grok and write enrichment functions for log normalisation

This commit is contained in:
2026-03-05 22:28:45 +01:00
parent c820521bca
commit 4863827945
5 changed files with 547 additions and 0 deletions

2
go.mod
View File

@@ -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
View File

@@ -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=

View 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)
}
}

View 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}`,
},
}

View 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
}