checker-blacklist/checker/source.go

146 lines
5.3 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
}
// 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
}