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.
196 lines
5.7 KiB
Go
196 lines
5.7 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
func init() { Register(&safeBrowsingSource{endpoint: safeBrowsingEndpoint}) }
|
|
|
|
const safeBrowsingEndpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=%s"
|
|
|
|
// safeBrowsingSource calls Google Safe Browsing v4. The endpoint is
|
|
// kept on the struct so tests can swap it for httptest.
|
|
type safeBrowsingSource struct {
|
|
endpoint string
|
|
}
|
|
|
|
func (*safeBrowsingSource) ID() string { return "google_safe_browsing" }
|
|
func (*safeBrowsingSource) Name() string { return "Google Safe Browsing" }
|
|
|
|
func (s *safeBrowsingSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
|
|
if stringOpt(opts, "google_safe_browsing_api_key") == "" {
|
|
return fmt.Errorf("Google Safe Browsing API key is not configured")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (*safeBrowsingSource) Options() SourceOptions {
|
|
return SourceOptions{
|
|
Admin: []sdk.CheckerOptionField{
|
|
{
|
|
Id: "google_safe_browsing_api_key",
|
|
Type: "string",
|
|
Label: "Google Safe Browsing API key",
|
|
Description: "Google Cloud API key with the Safe Browsing API enabled. Leave empty to skip Safe Browsing lookups.",
|
|
Secret: true,
|
|
},
|
|
{
|
|
Id: "google_safe_browsing_client_id",
|
|
Type: "string",
|
|
Label: "Safe Browsing client ID",
|
|
Default: "happydomain",
|
|
},
|
|
{
|
|
Id: "google_safe_browsing_client_version",
|
|
Type: "string",
|
|
Label: "Safe Browsing client version",
|
|
Default: "1.0",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *safeBrowsingSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
|
apiKey := stringOpt(opts, "google_safe_browsing_api_key")
|
|
if apiKey == "" {
|
|
return disabledResult(s.ID(), s.Name())
|
|
}
|
|
if registered == "" {
|
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
|
|
}
|
|
clientID := stringOptDefault(opts, "google_safe_browsing_client_id", "happydomain")
|
|
clientVersion := stringOptDefault(opts, "google_safe_browsing_client_version", "1.0")
|
|
|
|
res := SourceResult{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}
|
|
|
|
body := map[string]any{
|
|
"client": map[string]string{"clientId": clientID, "clientVersion": clientVersion},
|
|
"threatInfo": map[string]any{
|
|
"threatTypes": []string{
|
|
"MALWARE", "SOCIAL_ENGINEERING",
|
|
"UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION",
|
|
},
|
|
"platformTypes": []string{"ANY_PLATFORM"},
|
|
"threatEntryTypes": []string{"URL"},
|
|
"threatEntries": []map[string]string{
|
|
{"url": "http://" + registered + "/"},
|
|
{"url": "https://" + registered + "/"},
|
|
{"url": registered},
|
|
},
|
|
},
|
|
}
|
|
buf, err := json.Marshal(body)
|
|
if err != nil {
|
|
res.Error = err.Error()
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf(s.endpoint, apiKey)
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(buf))
|
|
if err != nil {
|
|
res.Error = redactSecret(err.Error(), apiKey)
|
|
return []SourceResult{res}
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
raw, status, err := httpDo(req, 1<<20)
|
|
if err != nil {
|
|
res.Error = redactSecret(err.Error(), apiKey)
|
|
return []SourceResult{res}
|
|
}
|
|
if status != http.StatusOK {
|
|
res.Error = fmt.Sprintf("HTTP %d: %s", status, redactSecret(truncate(string(raw), 200), apiKey))
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
var parsed struct {
|
|
Matches []struct {
|
|
ThreatType string `json:"threatType"`
|
|
PlatformType string `json:"platformType"`
|
|
Threat struct {
|
|
URL string `json:"url"`
|
|
} `json:"threat"`
|
|
} `json:"matches"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
res.Error = "decode: " + err.Error()
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
if len(parsed.Matches) == 0 {
|
|
return []SourceResult{res}
|
|
}
|
|
res.Reference = "https://transparencyreport.google.com/safe-browsing/search?url=" + registered
|
|
seenType := map[string]bool{}
|
|
for _, m := range parsed.Matches {
|
|
if !seenType[m.ThreatType] {
|
|
seenType[m.ThreatType] = true
|
|
res.Reasons = append(res.Reasons, m.ThreatType)
|
|
}
|
|
res.Evidence = append(res.Evidence, Evidence{
|
|
Label: "URL",
|
|
Value: m.Threat.URL,
|
|
Status: m.ThreatType,
|
|
Extra: map[string]string{"platform": m.PlatformType},
|
|
})
|
|
}
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
func (*safeBrowsingSource) Evaluate(r SourceResult) (bool, string) {
|
|
return evidenceEval(r, SeverityCrit)
|
|
}
|
|
|
|
func (*safeBrowsingSource) Diagnose(res SourceResult) Diagnosis {
|
|
return Diagnosis{
|
|
Severity: SeverityCrit,
|
|
Title: "Flagged by Google Safe Browsing",
|
|
Detail: fmt.Sprintf(
|
|
"Threat type(s): %s. Visitors using Chrome, Firefox, Safari and most major browsers see a red interstitial when opening any URL on this domain. Investigate compromised pages, clean them, then request a review through Google Search Console: listings typically clear within 24h after a successful review.",
|
|
joinNonEmpty(res.Reasons, ", "),
|
|
),
|
|
Fix: "https://search.google.com/search-console/security-issues",
|
|
FixIsURL: true,
|
|
}
|
|
}
|
|
|
|
// redactSecret removes occurrences of secret from s. Used to scrub API
|
|
// keys out of transport errors before they reach the report payload:
|
|
// *url.Error renders the full request URL, which for Safe Browsing
|
|
// includes ?key=… as a query parameter.
|
|
func redactSecret(s, secret string) string {
|
|
if secret == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, secret, "REDACTED")
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func stringOpt(opts sdk.CheckerOptions, key string) string {
|
|
v, _ := sdk.GetOption[string](opts, key)
|
|
return v
|
|
}
|
|
|
|
func stringOptDefault(opts sdk.CheckerOptions, key, def string) string {
|
|
if v := stringOpt(opts, key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|