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