feat(monitor/backend): abuseipdb enrichment for bgp hijacks (country + abuse_score in payload)
This commit is contained in:
parent
e690b30e92
commit
e216331aeb
|
|
@ -22,6 +22,7 @@ NOTIFICATION_ENCRYPTION_KEY=
|
||||||
NVD_API_KEY=
|
NVD_API_KEY=
|
||||||
CF_RADAR_TOKEN=
|
CF_RADAR_TOKEN=
|
||||||
GREYNOISE_API_KEY=
|
GREYNOISE_API_KEY=
|
||||||
|
ABUSEIPDB_API_KEY=
|
||||||
|
|
||||||
CLOUDFLARE_TUNNEL_TOKEN=
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ Operator-grade real-time situational awareness dashboard. Single-binary Go backe
|
||||||
| Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none |
|
| Space weather | NOAA SWPC (5 endpoints) | 1m / 3h | none |
|
||||||
| World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none |
|
| World events | Wikipedia ITN + GDELT v2 API | 5m / 15m | none |
|
||||||
| ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none |
|
| ISS position | wheretheiss.at + CelesTrak | 10s / 24h | none |
|
||||||
| IP enrichment | GreyNoise Community | on-demand | `GREYNOISE_API_KEY` (optional, free tier) |
|
| IP enrichment (BGP hijacks) | AbuseIPDB | on-demand | `ABUSEIPDB_API_KEY` (optional, free tier 1k/day) |
|
||||||
|
|
||||||
## Quickstart (development)
|
## Quickstart (development)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import (
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/collectors/wikipedia"
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/config"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/config"
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb"
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/health"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/health"
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/middleware"
|
||||||
"github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring"
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/redisring"
|
||||||
|
|
@ -48,6 +49,22 @@ const (
|
||||||
drainDelay = 5 * time.Second
|
drainDelay = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type abuseipdbEnricher struct {
|
||||||
|
client *abuseipdb.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a abuseipdbEnricher) Lookup(ctx context.Context, ip string) (cfradar.Enrichment, error) {
|
||||||
|
v, err := a.client.Lookup(ctx, ip)
|
||||||
|
if err != nil {
|
||||||
|
return cfradar.Enrichment{}, err
|
||||||
|
}
|
||||||
|
return cfradar.Enrichment{
|
||||||
|
Country: v.CountryCode,
|
||||||
|
AbuseConfidence: v.AbuseConfidenceScore,
|
||||||
|
ISP: v.ISP,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.yaml", "path to config file")
|
configPath := flag.String("config", "config.yaml", "path to config file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
@ -178,6 +195,12 @@ func run(configPath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Collectors.CFRadar.Enabled {
|
if cfg.Collectors.CFRadar.Enabled {
|
||||||
|
var enricher cfradar.Enricher
|
||||||
|
if cfg.Collectors.AbuseIPDB.Enabled && cfg.Collectors.AbuseIPDB.APIKey != "" {
|
||||||
|
enricher = abuseipdbEnricher{
|
||||||
|
client: abuseipdb.NewClient(abuseipdb.ClientConfig{APIKey: cfg.Collectors.AbuseIPDB.APIKey}),
|
||||||
|
}
|
||||||
|
}
|
||||||
coll := cfradar.NewCollector(cfradar.CollectorConfig{
|
coll := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||||
Interval: cfg.Collectors.CFRadar.Interval,
|
Interval: cfg.Collectors.CFRadar.Interval,
|
||||||
MinConfidence: cfg.Collectors.CFRadar.MinConfidence,
|
MinConfidence: cfg.Collectors.CFRadar.MinConfidence,
|
||||||
|
|
@ -185,6 +208,7 @@ func run(configPath string) error {
|
||||||
Repo: cfradar.NewRepo(db.DB),
|
Repo: cfradar.NewRepo(db.DB),
|
||||||
Emitter: eventBus,
|
Emitter: eventBus,
|
||||||
State: collectorState,
|
State: collectorState,
|
||||||
|
Enricher: enricher,
|
||||||
Logger: logger.With("collector", "cfradar"),
|
Logger: logger.With("collector", "cfradar"),
|
||||||
})
|
})
|
||||||
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
collectorGroup.Go(func() error { return coll.Run(collectorCtx) })
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
|
@ -41,6 +42,22 @@ type StateRecorder interface {
|
||||||
RecordError(ctx context.Context, name, errMsg string) error
|
RecordError(ctx context.Context, name, errMsg string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Enricher interface {
|
||||||
|
Lookup(ctx context.Context, ip string) (Enrichment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Enrichment struct {
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
AbuseConfidence int `json:"abuse_confidence,omitempty"`
|
||||||
|
ISP string `json:"isp,omitempty"`
|
||||||
|
CheckedIP string `json:"checked_ip,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnrichedHijack struct {
|
||||||
|
HijackEvent
|
||||||
|
Enrichment *Enrichment `json:"enrichment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type CollectorConfig struct {
|
type CollectorConfig struct {
|
||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
MinConfidence int
|
MinConfidence int
|
||||||
|
|
@ -48,6 +65,7 @@ type CollectorConfig struct {
|
||||||
Repo Repository
|
Repo Repository
|
||||||
Emitter Emitter
|
Emitter Emitter
|
||||||
State StateRecorder
|
State StateRecorder
|
||||||
|
Enricher Enricher
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +201,20 @@ func (c *Collector) tickHijacks(ctx context.Context) (int64, error) {
|
||||||
if known[e.ID] {
|
if known[e.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rawBytes, _ := json.Marshal(e)
|
enriched := EnrichedHijack{HijackEvent: e}
|
||||||
|
if c.cfg.Enricher != nil && len(e.Prefixes) > 0 {
|
||||||
|
if ip, ok := representativeIP(e.Prefixes[0]); ok {
|
||||||
|
if v, lerr := c.cfg.Enricher.Lookup(ctx, ip); lerr == nil {
|
||||||
|
enriched.Enrichment = &Enrichment{
|
||||||
|
Country: v.Country,
|
||||||
|
AbuseConfidence: v.AbuseConfidence,
|
||||||
|
ISP: v.ISP,
|
||||||
|
CheckedIP: ip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawBytes, _ := json.Marshal(enriched)
|
||||||
raw := json.RawMessage(rawBytes)
|
raw := json.RawMessage(rawBytes)
|
||||||
row := HijackRow{
|
row := HijackRow{
|
||||||
ID: e.ID,
|
ID: e.ID,
|
||||||
|
|
@ -210,3 +241,11 @@ func (c *Collector) tickHijacks(ctx context.Context) (int64, error) {
|
||||||
}
|
}
|
||||||
return emitted, nil
|
return emitted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func representativeIP(cidr string) (string, bool) {
|
||||||
|
p, err := netip.ParsePrefix(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return p.Addr().String(), true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,22 @@ type noopState struct{}
|
||||||
func (noopState) RecordSuccess(context.Context, string, int64) error { return nil }
|
func (noopState) RecordSuccess(context.Context, string, int64) error { return nil }
|
||||||
func (noopState) RecordError(context.Context, string, string) error { return nil }
|
func (noopState) RecordError(context.Context, string, string) error { return nil }
|
||||||
|
|
||||||
|
type fakeEnricher struct {
|
||||||
|
calls int
|
||||||
|
verdict cfradar.Enrichment
|
||||||
|
lastIP string
|
||||||
|
returnFn func(ip string) (cfradar.Enrichment, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *fakeEnricher) Lookup(_ context.Context, ip string) (cfradar.Enrichment, error) {
|
||||||
|
e.calls++
|
||||||
|
e.lastIP = ip
|
||||||
|
if e.returnFn != nil {
|
||||||
|
return e.returnFn(ip)
|
||||||
|
}
|
||||||
|
return e.verdict, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollector_OnlyEmitsNetNew(t *testing.T) {
|
func TestCollector_OnlyEmitsNetNew(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
ftch := &fakeFetcher{
|
ftch := &fakeFetcher{
|
||||||
|
|
@ -146,6 +162,91 @@ func TestCollector_OnlyEmitsNetNew(t *testing.T) {
|
||||||
require.GreaterOrEqual(t, hijackEvents, 1)
|
require.GreaterOrEqual(t, hijackEvents, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollector_EnrichesHijackPayloadWhenEnricherProvided(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ftch := &fakeFetcher{
|
||||||
|
hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{
|
||||||
|
{
|
||||||
|
ID: 501,
|
||||||
|
DetectedAt: now,
|
||||||
|
StartedAt: now,
|
||||||
|
Confidence: 9,
|
||||||
|
HijackerASN: 4242,
|
||||||
|
Prefixes: []string{"203.0.113.0/24"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
repo := &fakeRepo{knownHijacks: map[int64]bool{}}
|
||||||
|
emt := &fakeEmitter{}
|
||||||
|
enr := &fakeEnricher{verdict: cfradar.Enrichment{
|
||||||
|
Country: "RU",
|
||||||
|
AbuseConfidence: 95,
|
||||||
|
ISP: "ExampleNet",
|
||||||
|
}}
|
||||||
|
|
||||||
|
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||||
|
Interval: 30 * time.Millisecond,
|
||||||
|
MinConfidence: 7,
|
||||||
|
Fetcher: ftch,
|
||||||
|
Repo: repo,
|
||||||
|
Emitter: emt,
|
||||||
|
State: noopState{},
|
||||||
|
Enricher: enr,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_ = c.Run(ctx)
|
||||||
|
|
||||||
|
require.GreaterOrEqual(t, enr.calls, 1)
|
||||||
|
require.Equal(t, "203.0.113.0", enr.lastIP)
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, ev := range emt.Events() {
|
||||||
|
if ev.Topic != events.TopicBGPHijack {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(ev.Payload)
|
||||||
|
require.Contains(t, string(body), `"country":"RU"`)
|
||||||
|
require.Contains(t, string(body), `"abuse_confidence":95`)
|
||||||
|
require.Contains(t, string(body), `"checked_ip":"203.0.113.0"`)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
require.True(t, found, "expected at least one hijack event with enrichment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollector_HijackEmitsRawWhenNoEnricher(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
ftch := &fakeFetcher{
|
||||||
|
hijacks: cfradar.HijackBody{Events: []cfradar.HijackEvent{
|
||||||
|
{ID: 777, DetectedAt: now, StartedAt: now, Confidence: 9, Prefixes: []string{"198.51.100.0/24"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
repo := &fakeRepo{knownHijacks: map[int64]bool{}}
|
||||||
|
emt := &fakeEmitter{}
|
||||||
|
|
||||||
|
c := cfradar.NewCollector(cfradar.CollectorConfig{
|
||||||
|
Interval: 30 * time.Millisecond,
|
||||||
|
MinConfidence: 7,
|
||||||
|
Fetcher: ftch,
|
||||||
|
Repo: repo,
|
||||||
|
Emitter: emt,
|
||||||
|
State: noopState{},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_ = c.Run(ctx)
|
||||||
|
|
||||||
|
for _, ev := range emt.Events() {
|
||||||
|
if ev.Topic != events.TopicBGPHijack {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(ev.Payload)
|
||||||
|
require.NotContains(t, string(body), `"enrichment"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
func TestCollector_RepeatedTickIsIdempotent(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
ftch := &fakeFetcher{
|
ftch := &fakeFetcher{
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ type CollectorsConfig struct {
|
||||||
KEV SourceConfig `koanf:"kev"`
|
KEV SourceConfig `koanf:"kev"`
|
||||||
Ransomware SourceConfig `koanf:"ransomware"`
|
Ransomware SourceConfig `koanf:"ransomware"`
|
||||||
GreyNoise GreyNoiseConfig `koanf:"greynoise"`
|
GreyNoise GreyNoiseConfig `koanf:"greynoise"`
|
||||||
|
AbuseIPDB AbuseIPDBConfig `koanf:"abuseipdb"`
|
||||||
Coinbase CoinbaseConfig `koanf:"coinbase"`
|
Coinbase CoinbaseConfig `koanf:"coinbase"`
|
||||||
USGS SourceConfig `koanf:"usgs"`
|
USGS SourceConfig `koanf:"usgs"`
|
||||||
SWPC SWPCConfig `koanf:"swpc"`
|
SWPC SWPCConfig `koanf:"swpc"`
|
||||||
|
|
@ -66,6 +67,11 @@ type GreyNoiseConfig struct {
|
||||||
APIKey string `koanf:"api_key"`
|
APIKey string `koanf:"api_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AbuseIPDBConfig struct {
|
||||||
|
Enabled bool `koanf:"enabled"`
|
||||||
|
APIKey string `koanf:"api_key"`
|
||||||
|
}
|
||||||
|
|
||||||
type CoinbaseConfig struct {
|
type CoinbaseConfig struct {
|
||||||
Enabled bool `koanf:"enabled"`
|
Enabled bool `koanf:"enabled"`
|
||||||
URL string `koanf:"url"`
|
URL string `koanf:"url"`
|
||||||
|
|
@ -275,6 +281,7 @@ func loadDefaults(k *koanf.Koanf) error {
|
||||||
"collectors.ransomware.enabled": true,
|
"collectors.ransomware.enabled": true,
|
||||||
"collectors.ransomware.interval": "15m",
|
"collectors.ransomware.interval": "15m",
|
||||||
"collectors.greynoise.enabled": true,
|
"collectors.greynoise.enabled": true,
|
||||||
|
"collectors.abuseipdb.enabled": true,
|
||||||
"collectors.coinbase.enabled": true,
|
"collectors.coinbase.enabled": true,
|
||||||
"collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com",
|
"collectors.coinbase.url": "wss://advanced-trade-ws.coinbase.com",
|
||||||
"collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"},
|
"collectors.coinbase.product_ids": []string{"BTC-USD", "ETH-USD"},
|
||||||
|
|
@ -328,6 +335,7 @@ var envKeyMap = map[string]string{
|
||||||
"NVD_API_KEY": "collectors.cve.nvd_api_key",
|
"NVD_API_KEY": "collectors.cve.nvd_api_key",
|
||||||
"CF_RADAR_TOKEN": "collectors.cfradar.bearer_token",
|
"CF_RADAR_TOKEN": "collectors.cfradar.bearer_token",
|
||||||
"GREYNOISE_API_KEY": "collectors.greynoise.api_key",
|
"GREYNOISE_API_KEY": "collectors.greynoise.api_key",
|
||||||
|
"ABUSEIPDB_API_KEY": "collectors.abuseipdb.api_key",
|
||||||
}
|
}
|
||||||
|
|
||||||
func envKeyReplacer(s string) string {
|
func envKeyReplacer(s string) string {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// client.go
|
||||||
|
|
||||||
|
package abuseipdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultAIDBBaseURL = "https://api.abuseipdb.com"
|
||||||
|
pathV2Check = "/api/v2/check"
|
||||||
|
abuseipdbAPIHeader = "Key"
|
||||||
|
defaultAIDBRate = 1 * time.Second
|
||||||
|
defaultAIDBBudget = 3
|
||||||
|
defaultAIDBBreakerWin = 5 * time.Minute
|
||||||
|
defaultAIDBRequestTO = 10 * time.Second
|
||||||
|
defaultAIDBMaxAgeDays = 90
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnknownIP = errors.New("abuseipdb: unknown ip")
|
||||||
|
|
||||||
|
type ClientConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
hx *httpx.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg ClientConfig) *Client {
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = defaultAIDBBaseURL
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
hx: httpx.New(httpx.Config{
|
||||||
|
Name: "abuseipdb",
|
||||||
|
BaseURL: cfg.BaseURL,
|
||||||
|
APIKey: cfg.APIKey,
|
||||||
|
APIKeyHeader: abuseipdbAPIHeader,
|
||||||
|
Rate: rate.Every(defaultAIDBRate),
|
||||||
|
Burst: 1,
|
||||||
|
ConsecutiveFailureBudget: defaultAIDBBudget,
|
||||||
|
BreakerTimeout: defaultAIDBBreakerWin,
|
||||||
|
RequestTimeout: defaultAIDBRequestTO,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkEnvelope struct {
|
||||||
|
Data Verdict `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Verdict struct {
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
IPVersion int `json:"ipVersion"`
|
||||||
|
IsWhitelisted bool `json:"isWhitelisted"`
|
||||||
|
AbuseConfidenceScore int `json:"abuseConfidenceScore"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
CountryName string `json:"countryName"`
|
||||||
|
UsageType string `json:"usageType"`
|
||||||
|
ISP string `json:"isp"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
IsTor bool `json:"isTor"`
|
||||||
|
TotalReports int `json:"totalReports"`
|
||||||
|
NumDistinctUsers int `json:"numDistinctUsers"`
|
||||||
|
LastReportedAt string `json:"lastReportedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Lookup(ctx context.Context, ip string) (Verdict, error) {
|
||||||
|
q := url.Values{
|
||||||
|
"ipAddress": []string{ip},
|
||||||
|
"maxAgeInDays": []string{strconv.Itoa(defaultAIDBMaxAgeDays)},
|
||||||
|
}
|
||||||
|
resp, err := c.hx.Get(ctx, pathV2Check, q)
|
||||||
|
if err != nil {
|
||||||
|
var se *httpx.StatusError
|
||||||
|
if errors.As(err, &se) && se.Code == http.StatusNotFound {
|
||||||
|
return Verdict{}, ErrUnknownIP
|
||||||
|
}
|
||||||
|
return Verdict{}, fmt.Errorf("abuseipdb lookup %s: %w", ip, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var env checkEnvelope
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||||
|
return Verdict{}, fmt.Errorf("abuseipdb decode: %w", err)
|
||||||
|
}
|
||||||
|
if env.Data.IPAddress == "" {
|
||||||
|
return Verdict{}, ErrUnknownIP
|
||||||
|
}
|
||||||
|
return env.Data, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// client_test.go
|
||||||
|
|
||||||
|
package abuseipdb_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/enrich/abuseipdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClient_LookupDecodesVerdict(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/api/v2/check", r.URL.Path)
|
||||||
|
require.Equal(t, "118.25.6.39", r.URL.Query().Get("ipAddress"))
|
||||||
|
require.Equal(t, "90", r.URL.Query().Get("maxAgeInDays"))
|
||||||
|
require.Equal(t, "test-key", r.Header.Get("Key"))
|
||||||
|
body, err := os.ReadFile("testdata/check_lookup.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "test-key"})
|
||||||
|
v, err := c.Lookup(context.Background(), "118.25.6.39")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "118.25.6.39", v.IPAddress)
|
||||||
|
require.Equal(t, "CN", v.CountryCode)
|
||||||
|
require.Equal(t, 100, v.AbuseConfidenceScore)
|
||||||
|
require.Equal(t, "Tencent Cloud Computing", v.ISP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_LookupReturnsErrUnknownIPOn404(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"})
|
||||||
|
_, err := c.Lookup(context.Background(), "10.0.0.1")
|
||||||
|
require.ErrorIs(t, err, abuseipdb.ErrUnknownIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_LookupReturnsErrUnknownIPOnEmptyData(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"data":{}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := abuseipdb.NewClient(abuseipdb.ClientConfig{BaseURL: srv.URL, APIKey: "k"})
|
||||||
|
_, err := c.Lookup(context.Background(), "10.0.0.1")
|
||||||
|
require.ErrorIs(t, err, abuseipdb.ErrUnknownIP)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"ipAddress": "118.25.6.39",
|
||||||
|
"isPublic": true,
|
||||||
|
"ipVersion": 4,
|
||||||
|
"isWhitelisted": false,
|
||||||
|
"abuseConfidenceScore": 100,
|
||||||
|
"countryCode": "CN",
|
||||||
|
"countryName": "China",
|
||||||
|
"usageType": "Data Center/Web Hosting/Transit",
|
||||||
|
"isp": "Tencent Cloud Computing",
|
||||||
|
"domain": "tencent.com",
|
||||||
|
"hostnames": [],
|
||||||
|
"isTor": false,
|
||||||
|
"totalReports": 1,
|
||||||
|
"numDistinctUsers": 1,
|
||||||
|
"lastReportedAt": "2026-04-30T12:34:56+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue