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 (*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 []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}} } 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.Listed = true res.Severity = SeverityCrit 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) 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 }