201 lines
6.2 KiB
Go
201 lines
6.2 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
const urlhausHostEndpoint = "https://urlhaus-api.abuse.ch/v1/host/"
|
|
|
|
func init() { Register(&urlhausSource{endpoint: urlhausHostEndpoint}) }
|
|
|
|
type urlhausSource struct {
|
|
endpoint string
|
|
}
|
|
|
|
func (*urlhausSource) ID() string { return "urlhaus" }
|
|
func (*urlhausSource) Name() string { return "abuse.ch URLhaus" }
|
|
|
|
func (*urlhausSource) Options() SourceOptions {
|
|
return SourceOptions{
|
|
User: []sdk.CheckerOptionField{
|
|
{
|
|
Id: "enable_urlhaus",
|
|
Type: "bool",
|
|
Label: "Use abuse.ch URLhaus",
|
|
Description: "Query the URLhaus host endpoint for active malware-distribution URLs hosted on the domain.",
|
|
Default: true,
|
|
},
|
|
},
|
|
Admin: []sdk.CheckerOptionField{
|
|
{
|
|
Id: "urlhaus_auth_key",
|
|
Type: "string",
|
|
Label: "URLhaus Auth-Key",
|
|
Description: "abuse.ch URLhaus Auth-Key (free, requires an abuse.ch account). Required: the URLhaus API rejects anonymous requests with HTTP 401. Without this key the source is disabled.",
|
|
Secret: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// urlhausDetails is the source-specific extras kept in
|
|
// SourceResult.Details so the rich detail renderer can show a per-URL
|
|
// table with online/offline state, threat type, tags and date added.
|
|
type urlhausDetails struct {
|
|
URLs []urlhausURL `json:"urls"`
|
|
}
|
|
|
|
type urlhausURL struct {
|
|
URL string `json:"url"`
|
|
Status string `json:"status"`
|
|
Threat string `json:"threat"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
DateAdded string `json:"date_added,omitempty"`
|
|
Reference string `json:"reference,omitempty"`
|
|
}
|
|
|
|
func (s *urlhausSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
|
authKey := stringOpt(opts, "urlhaus_auth_key")
|
|
if !sdk.GetBoolOption(opts, "enable_urlhaus", true) || registered == "" || authKey == "" {
|
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
|
|
}
|
|
|
|
res := SourceResult{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
form := url.Values{"host": {registered}}
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, s.endpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
res.Error = err.Error()
|
|
return []SourceResult{res}
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0")
|
|
if authKey != "" {
|
|
req.Header.Set("Auth-Key", authKey)
|
|
}
|
|
|
|
body, status, err := httpDo(req, 4<<20)
|
|
if err != nil {
|
|
res.Error = err.Error()
|
|
return []SourceResult{res}
|
|
}
|
|
if status != http.StatusOK {
|
|
res.Error = fmt.Sprintf("HTTP %d: %s", status, truncate(string(body), 200))
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
var parsed struct {
|
|
QueryStatus string `json:"query_status"`
|
|
Reference string `json:"urlhaus_reference"`
|
|
URLs []struct {
|
|
URL string `json:"url"`
|
|
Status string `json:"url_status"`
|
|
Threat string `json:"threat"`
|
|
Tags []string `json:"tags"`
|
|
DateAdded string `json:"date_added"`
|
|
Reference string `json:"urlhaus_reference"`
|
|
} `json:"urls"`
|
|
}
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
res.Error = "decode: " + err.Error()
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
res.Reference = parsed.Reference
|
|
switch parsed.QueryStatus {
|
|
case "ok":
|
|
if len(parsed.URLs) == 0 {
|
|
return []SourceResult{res}
|
|
}
|
|
res.Listed = true
|
|
res.Severity = SeverityCrit
|
|
threats := map[string]bool{}
|
|
details := urlhausDetails{}
|
|
for _, u := range parsed.URLs {
|
|
if u.Threat != "" && !threats[u.Threat] {
|
|
threats[u.Threat] = true
|
|
res.Reasons = append(res.Reasons, u.Threat)
|
|
}
|
|
res.Evidence = append(res.Evidence, Evidence{
|
|
Label: "URL", Value: u.URL, Status: u.Status,
|
|
})
|
|
details.URLs = append(details.URLs, urlhausURL{
|
|
URL: u.URL, Status: u.Status, Threat: u.Threat,
|
|
Tags: u.Tags, DateAdded: u.DateAdded, Reference: u.Reference,
|
|
})
|
|
}
|
|
res.Details = mustJSON(details)
|
|
case "no_results":
|
|
// Clean.
|
|
case "invalid_host", "http_post_expected":
|
|
res.Error = "rejected query: " + parsed.QueryStatus
|
|
default:
|
|
res.Error = "query_status=" + parsed.QueryStatus
|
|
}
|
|
return []SourceResult{res}
|
|
}
|
|
|
|
func (*urlhausSource) Diagnose(res SourceResult) Diagnosis {
|
|
online := 0
|
|
for _, e := range res.Evidence {
|
|
if e.Status == "online" {
|
|
online++
|
|
}
|
|
}
|
|
return Diagnosis{
|
|
Severity: SeverityCrit,
|
|
Title: "Listed in abuse.ch URLhaus (active malware distribution)",
|
|
Detail: fmt.Sprintf(
|
|
"%d URL(s) tracked, %d still online; threat type(s): %s. URLhaus indexes URLs that actively serve malware payloads. Treat the host as compromised: take the offending pages offline, audit the web stack (CMS plugins, recently-uploaded files, cron jobs), then submit a takedown notification through the URLhaus reference page.",
|
|
len(res.Evidence), online, joinNonEmpty(res.Reasons, ", "),
|
|
),
|
|
Fix: res.Reference,
|
|
FixIsURL: res.Reference != "",
|
|
}
|
|
}
|
|
|
|
// RenderDetail renders the URLhaus URL table. Implementing
|
|
// DetailRenderer keeps the rich per-source view alongside the source
|
|
// implementation rather than scattered in the report code.
|
|
func (*urlhausSource) RenderDetail(res SourceResult) (template.HTML, error) {
|
|
var d urlhausDetails
|
|
if len(res.Details) > 0 {
|
|
if err := json.Unmarshal(res.Details, &d); err != nil {
|
|
return "", fmt.Errorf("urlhaus: decode details: %w", err)
|
|
}
|
|
}
|
|
if len(d.URLs) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
var b bytes.Buffer
|
|
if err := urlhausDetailTpl.Execute(&b, d); err != nil {
|
|
return "", err
|
|
}
|
|
return template.HTML(b.String()), nil
|
|
}
|
|
|
|
var urlhausDetailTpl = template.Must(template.New("urlhaus_detail").Parse(`
|
|
<table>
|
|
<thead><tr><th>URL</th><th>Status</th><th>Threat</th><th>Tags</th><th>Added</th></tr></thead>
|
|
<tbody>{{range .URLs}}<tr class="row-crit">
|
|
<td><code>{{.URL}}</code>{{with .Reference}} <a href="{{.}}" target="_blank" rel="noreferrer">↗</a>{{end}}</td>
|
|
<td>{{.Status}}</td>
|
|
<td>{{.Threat}}</td>
|
|
<td>{{range .Tags}}<span>{{.}} </span>{{end}}</td>
|
|
<td><small>{{.DateAdded}}</small></td>
|
|
</tr>{{end}}</tbody>
|
|
</table>`))
|