checker-blacklist/checker/source.go
Pierre-Olivier Mercier c3cda1f104
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0
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.
2026-05-20 14:26:42 +08:00

193 lines
7 KiB
Go

package checker
import (
"context"
"encoding/json"
"html/template"
"sync"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Severity strings shared between sources, rules, and the HTML report.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
SeverityOK = "ok"
)
// Source is the contract every reputation source implements. The
// registry collects one Source per backend (DNSBL family, Safe
// Browsing, URLhaus, VirusTotal, OpenPhish, …); Collect fans out over
// the registry concurrently and folds the per-source results into the
// observation payload. Adding a new source is a single file plus a
// `Register(...)` call in init().
//
// A Source returns *one or more* SourceResult values. Most sources
// return exactly one (`{Listed, Reasons, …}`); the DNSBL family returns
// one result per zone. Returning many results from one Source keeps the
// definition tidy (one ID, one set of options, one rule entry) while
// still surfacing per-zone detail in the report.
type Source interface {
ID() string
Name() string
// Options contributes the option fields the source needs. They are
// merged into the global CheckerDefinition at startup.
Options() SourceOptions
// Query runs the source against `registered` (the eTLD+1 of the
// target domain) and returns one result per logical sub-target. The
// implementation should never return nil: when the source is
// disabled, return a single SourceResult with Enabled=false.
Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult
// Diagnose produces the action-required card for a *listed* result.
// Implementations should focus on the operator's next step; the
// generic report wraps it with the title bar and severity styling.
// Called only when SourceResult.Listed is true.
Diagnose(res SourceResult) Diagnosis
// Evaluate inspects an already-collected SourceResult and returns
// whether the domain is considered listed and at what severity.
// Implementations must read observation fields only (Evidence,
// Reasons, Error, Enabled, BlockedQuery, Details) and must never
// consult r.Listed or r.Severity.
Evaluate(r SourceResult) (listed bool, severity string)
}
// SourcePrecheck is an optional interface a Source can implement to
// declare whether the current options are sufficient for it to run.
// Used to surface "rule unavailable because the operator hasn't
// configured the credentials yet" in the host UI via the SDK's
// RulePrecheck contract. Returning nil means "ready to run"; any error
// is shown verbatim to the operator.
type SourcePrecheck interface {
Source
Precheck(ctx context.Context, opts sdk.CheckerOptions) error
}
// DetailRenderer is an optional interface a Source can implement when
// the generic SourceResult shape (Reasons + Evidence + URLs) cannot
// fully express its output. Examples: VirusTotal's per-vendor verdict
// table, URLhaus' URL list with online/offline status. The returned
// HTML fragment is dropped into the source's section verbatim and is
// expected to be safe (use html/template or template.HTMLEscape).
type DetailRenderer interface {
Source
RenderDetail(res SourceResult) (template.HTML, error)
}
// SourceOptions describes the option fields a source contributes to the
// CheckerDefinition. Audiences map directly to the SDK's
// CheckerOptionsDocumentation buckets.
type SourceOptions struct {
Admin []sdk.CheckerOptionField
User []sdk.CheckerOptionField
}
// SourceResult is the unified envelope every source produces. Source-
// specific structured data lives in Details (json.RawMessage), so the
// generic code (rules, headline, base diagnosis card, summary table)
// can operate on the envelope without source-specific switches; the
// rich report sections fish Details back through DetailRenderer.
type SourceResult struct {
SourceID string `json:"source_id"`
SourceName string `json:"source_name"`
Subject string `json:"subject,omitempty"` // e.g. zone label for DNSBL
Enabled bool `json:"enabled"`
Listed bool `json:"listed"`
BlockedQuery bool `json:"blocked_query,omitempty"` // resolver blocked, not a real listing
Severity string `json:"severity,omitempty"` // when Listed
Reasons []string `json:"reasons,omitempty"`
Evidence []Evidence `json:"evidence,omitempty"`
LookupURL string `json:"lookup_url,omitempty"`
RemovalURL string `json:"removal_url,omitempty"`
Reference string `json:"reference,omitempty"`
Error string `json:"error,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
}
// Evidence is a single observation that supports a verdict. Keeping it
// loosely typed (Label/Value/Status + free-form Extra) covers DNSBL
// return codes, OpenPhish URLs, URLhaus URLs, VT engine verdicts, …
// without growing the schema for each source.
type Evidence struct {
Label string `json:"label"`
Value string `json:"value"`
Status string `json:"status,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// Diagnosis is the action-required card surfaced at the top of the
// report. Sources build it in their Diagnose method.
type Diagnosis struct {
Severity string
Title string
Detail string
Fix string
FixIsURL bool
LookupURL string
RemovalURL string
}
// ---------- registry ----------
var (
registryMu sync.RWMutex
registry []Source
)
// Register adds a Source to the global registry. Intended to be called
// from init(). Panics on duplicate IDs so misconfigurations fail loudly
// at startup rather than producing silently-overlapping rules/options.
func Register(s Source) {
registryMu.Lock()
defer registryMu.Unlock()
for _, existing := range registry {
if existing.ID() == s.ID() {
panic("checker-blacklist: duplicate source ID " + s.ID())
}
}
registry = append(registry, s)
}
// Sources returns a snapshot of the registered sources, in registration
// order. Callers must not mutate the slice.
func Sources() []Source {
registryMu.RLock()
defer registryMu.RUnlock()
out := make([]Source, len(registry))
copy(out, registry)
return out
}
// disabledResult returns the standard "source is disabled" sentinel slice.
func disabledResult(id, name string) []SourceResult {
return []SourceResult{{SourceID: id, SourceName: name, Enabled: false}}
}
// evidenceEval is the common Evaluate body: listed when there is at least
// one Evidence entry and no error.
func evidenceEval(r SourceResult, severity string) (bool, string) {
if r.Enabled && r.Error == "" && len(r.Evidence) > 0 {
return true, severity
}
return false, ""
}
// EvaluateResult looks up the source that produced r from the registry
// and delegates to its Evaluate method. Returns (false, "") when the
// source is not found — a safe default that never promotes a stale
// Listed=true value.
func EvaluateResult(r SourceResult) (bool, string) {
registryMu.RLock()
defer registryMu.RUnlock()
for _, s := range registry {
if s.ID() == r.SourceID {
return s.Evaluate(r)
}
}
return false, ""
}