checker-blacklist/checker/safebrowsing.go

187 lines
5.5 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 (*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
}