Sources that always work (botvrij, disconnect, oisd, openphish, phishtank, quad9) drop their user-facing enable_* option; the rule's enabled/disabled state is now solely controlled by the SDK rule toggle. Sources that require credentials (criminalip, malwarebazaar, otx, pulsedive, safebrowsing, threatfox, urlhaus, virustotal) instead implement the new SourcePrecheck interface so the host UI can surface "not configured" before attempting a query.
181 lines
4.6 KiB
Go
181 lines
4.6 KiB
Go
package checker
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
const (
|
|
botvrijFeedURL = "https://www.botvrij.eu/data/ioclist.domain.simple"
|
|
botvrijDefaultTTL = 6 * time.Hour
|
|
botvrijFailBackoff = 1 * time.Minute
|
|
)
|
|
|
|
func init() {
|
|
Register(&botvrijSource{
|
|
cache: newBotvrijCache(botvrijFeedURL),
|
|
})
|
|
}
|
|
|
|
type botvrijSource struct {
|
|
cache *botvrijCache
|
|
}
|
|
|
|
func (*botvrijSource) ID() string { return "botvrij" }
|
|
func (*botvrijSource) Name() string { return "Botvrij.eu domain blocklist" }
|
|
|
|
func (*botvrijSource) Options() SourceOptions {
|
|
return SourceOptions{}
|
|
}
|
|
|
|
func (s *botvrijSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
|
if registered == "" {
|
|
return disabledResult(s.ID(), s.Name())
|
|
}
|
|
|
|
matched, size, fetched, err := s.cache.lookup(ctx, registered)
|
|
res := SourceResult{
|
|
SourceID: s.ID(), SourceName: s.Name(), Enabled: true,
|
|
Reference: "https://botvrij.eu/",
|
|
Details: mustJSON(map[string]any{"feed_size": size, "fetched_at": fetched}),
|
|
}
|
|
if err != nil {
|
|
res.Error = err.Error()
|
|
}
|
|
if len(matched) > 0 {
|
|
res.Reasons = []string{"Malicious domain"}
|
|
for _, d := range matched {
|
|
res.Evidence = append(res.Evidence, Evidence{Label: "Domain", Value: d})
|
|
}
|
|
}
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
func (*botvrijSource) Evaluate(r SourceResult) (bool, string) {
|
|
return evidenceEval(r, SeverityCrit)
|
|
}
|
|
|
|
func (*botvrijSource) Diagnose(res SourceResult) Diagnosis {
|
|
domains := make([]string, 0, len(res.Evidence))
|
|
for _, e := range res.Evidence {
|
|
domains = append(domains, e.Value)
|
|
}
|
|
previewN := min(len(domains), 5)
|
|
return Diagnosis{
|
|
Severity: SeverityCrit,
|
|
Title: "Listed in the Botvrij.eu domain blocklist",
|
|
Detail: fmt.Sprintf(
|
|
"%d domain(s) matching this registered domain appear in the Botvrij.eu IOC list; examples: %s. Botvrij.eu tracks domains associated with malware C2, exploit kits, and other malicious infrastructure. Investigate recent DNS changes and hosted content.",
|
|
len(domains), joinNonEmpty(domains[:previewN], ", "),
|
|
),
|
|
Fix: "https://botvrij.eu/",
|
|
FixIsURL: true,
|
|
}
|
|
}
|
|
|
|
// ---------- cache ----------
|
|
|
|
type botvrijCache struct {
|
|
mu sync.Mutex
|
|
domains []string // all domains in feed
|
|
byDomain map[string]struct{} // set for O(1) exact-match test
|
|
fetchedAt time.Time
|
|
lastAttemptAt time.Time
|
|
refreshing bool
|
|
ttl time.Duration
|
|
failBackoff time.Duration
|
|
feedURL string
|
|
}
|
|
|
|
func newBotvrijCache(feedURL string) *botvrijCache {
|
|
return &botvrijCache{
|
|
ttl: botvrijDefaultTTL,
|
|
failBackoff: botvrijFailBackoff,
|
|
feedURL: feedURL,
|
|
}
|
|
}
|
|
|
|
func (c *botvrijCache) lookup(ctx context.Context, registered string) (matched []string, size int, fetchedAt time.Time, err error) {
|
|
registered = strings.ToLower(strings.TrimSuffix(registered, "."))
|
|
|
|
c.mu.Lock()
|
|
stale := c.byDomain == nil || time.Since(c.fetchedAt) > c.ttl
|
|
doRefresh := stale && !c.refreshing && time.Since(c.lastAttemptAt) > c.failBackoff
|
|
if doRefresh {
|
|
c.refreshing = true
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
if doRefresh {
|
|
newDomains, newByDomain, ferr := c.fetch(ctx)
|
|
c.mu.Lock()
|
|
c.refreshing = false
|
|
c.lastAttemptAt = time.Now()
|
|
if ferr == nil {
|
|
c.domains = newDomains
|
|
c.byDomain = newByDomain
|
|
c.fetchedAt = c.lastAttemptAt
|
|
} else {
|
|
err = ferr
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
c.mu.Lock()
|
|
suffix := "." + registered
|
|
for d := range c.byDomain {
|
|
if d == registered || strings.HasSuffix(d, suffix) {
|
|
matched = append(matched, d)
|
|
}
|
|
}
|
|
size = len(c.domains)
|
|
fetchedAt = c.fetchedAt
|
|
c.mu.Unlock()
|
|
return matched, size, fetchedAt, err
|
|
}
|
|
|
|
func (c *botvrijCache) fetch(ctx context.Context) ([]string, map[string]struct{}, error) {
|
|
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.feedURL, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0")
|
|
|
|
resp, err := sharedHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, nil, fmt.Errorf("botvrij HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
domains := make([]string, 0, 4096)
|
|
byDomain := make(map[string]struct{}, 4096)
|
|
scanner := bufio.NewScanner(io.LimitReader(resp.Body, 16<<20))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
d := strings.ToLower(line)
|
|
domains = append(domains, d)
|
|
byDomain[d] = struct{}{}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return domains, byDomain, nil
|
|
}
|