Initial commit
This commit is contained in:
commit
66cf1fc9aa
30 changed files with 2735 additions and 0 deletions
187
checker/safebrowsing.go
Normal file
187
checker/safebrowsing.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue