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