checker-blacklist/checker/source.go
Pierre-Olivier Mercier 6b1d2e2540 Extract disabledResult and evidenceEval helpers to reduce boilerplate
Add two shared helpers to source.go and apply them across all sources:
- disabledResult(id, name) replaces the repeated inline SourceResult literal
- evidenceEval(r, severity) replaces the identical Evaluate body in 6 sources
2026-05-15 21:36:24 +08:00

182 lines
6.6 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)
}
// 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, ""
}