From a68273e21f722018a0550d1ae482a331d582225f Mon Sep 17 00:00:00 2001 From: Shannon Wynter Date: Wed, 3 Apr 2024 15:31:21 +1000 Subject: [PATCH 1/2] Add missing API --- breach.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/breach.go b/breach.go index 9783043..8a77f49 100644 --- a/breach.go +++ b/breach.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "time" ) @@ -88,12 +87,40 @@ type Breach struct { LogoPath string `json:"LogoPath"` } +type SubscribedDomains struct { + // DomainName is the full domain name that has been successfully verified. + DomainName string `json:"DomainName"` + + // PwnCount is the total number of breached email addresses found on the domain at last search + // (will be null if no searches yet performed). + PwnCount *int `json:"PwnCount"` + + // PwnCountExcludingSpamLists is the number of breached email addresses found on the domain + // at last search, excluding any breaches flagged as a spam list (will be null if no + // searches yet performed). + PwnCountExcludingSpamLists *int `json:"PwnCountExcludingSpamLists"` + + // The total number of breached email addresses found on the domain when the current + // subscription was taken out (will be null if no searches yet performed). This number + // ensures the domain remains searchable throughout the subscription period even if the + // volume of breached accounts grows beyond the subscription's scope. + PwnCountExcludingSpamListsAtLastSubscriptionRenewal *int `json:"PwnCountExcludingSpamListsAtLastSubscriptionRenewal"` + + // The date and time the current subscription ends in ISO 8601 format. The + // PwnCountExcludingSpamListsAtLastSubscriptionRenewal value is locked in until this time (will + // be null if there have been no subscriptions). + NextSubscriptionRenewal RenewalTime `json:"NextSubscriptionRenewal"` +} + // BreachOption is an additional option the can be set for the BreachApiClient type BreachOption func(*BreachAPI) // APIDate is a date string without time returned by the API represented as time.Time type type APIDate time.Time +// RenewalTime is a timestamp returned by the API that doesn't have timezone information +type RenewalTime time.Time + // Breaches returns a list of all breaches in the HIBP system func (b *BreachAPI) Breaches(options ...BreachOption) ([]*Breach, *http.Response, error) { qp := b.setBreachOpts(options...) @@ -173,6 +200,42 @@ func (b *BreachAPI) BreachedAccount(a string, options ...BreachOption) ([]*Breac return bd, hr, nil } +// SubscribedDomains returns domains that have been successfully added to the domain +// search dashboard after verifying control are returned via this API. This is an +// authenticated API requiring an HIBP API key which will then return all domains associated with that key. +func (b *BreachAPI) SubscribedDomains() ([]SubscribedDomains, *http.Response, error) { + au := fmt.Sprintf("%s/subscribeddomains", BaseURL) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, nil) + if err != nil { + return nil, hr, err + } + + var bd []SubscribedDomains + if err := json.Unmarshal(hb, &bd); err != nil { + return nil, hr, err + } + + return bd, hr, nil +} + +// BreachedDomain returns all email addresses on a given domain and the breaches they've appeared +// in can be returned via the domain search API. Only domains that have been successfully added +// to the domain search dashboard after verifying control can be searched. +func (b *BreachAPI) BreachedDomain(domain string) (map[string][]string, *http.Response, error) { + au := fmt.Sprintf("%s/breacheddomain/%s", BaseURL, domain) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, nil) + if err != nil { + return nil, hr, err + } + + var bd map[string][]string + if err := json.Unmarshal(hb, &bd); err != nil { + return nil, hr, err + } + + return bd, hr, nil +} + // WithDomain sets the domain filter for the breaches API func WithDomain(d string) BreachOption { return func(b *BreachAPI) { @@ -197,9 +260,8 @@ func WithoutUnverified() BreachOption { // UnmarshalJSON for the APIDate type converts a give date string into a time.Time type func (d *APIDate) UnmarshalJSON(s []byte) error { - ds := string(s) - ds = strings.ReplaceAll(ds, `"`, ``) - if ds == "null" { + ds := string(s[1 : len(s)-1]) + if ds == "null" || ds == "" { return nil } @@ -218,6 +280,28 @@ func (d *APIDate) Time() time.Time { return time.Time(dp) } +// UnmarshalJSON for the RenewalTime type converts a give date string into a time.Time type +func (d *RenewalTime) UnmarshalJSON(s []byte) error { + ds := string(s[1 : len(s)-1]) + if ds == "null" || ds == "" { + return nil + } + + pd, err := time.Parse("2006-01-02T15:04:05", ds) + if err != nil { + return fmt.Errorf("convert API date string to time.Time type: %w", err) + } + + *(*time.Time)(d) = pd + return nil +} + +// Time adds a Time() method to the RenewalTime converted time.Time type +func (d *RenewalTime) Time() time.Time { + dp := *d + return time.Time(dp) +} + // setBreachOpts returns a map of default settings and overridden values from different BreachOption func (b *BreachAPI) setBreachOpts(options ...BreachOption) map[string]string { qp := map[string]string{ From 78e78f5569cea7ceebd1a28327467a8d4dc29283 Mon Sep 17 00:00:00 2001 From: Shannon Wynter Date: Thu, 11 Apr 2024 08:54:28 +1000 Subject: [PATCH 2/2] Add some testing Testing is limited by the lack of predictable response from upstream. If you vote on this idea, you'll receive a notification when it's implemented: https://haveibeenpwned.uservoice.com/forums/275398-general/suggestions/48189713-implement-test-api-key-for-automated-domain-search --- breach_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/breach_test.go b/breach_test.go index 1129e63..864d51f 100644 --- a/breach_test.go +++ b/breach_test.go @@ -310,6 +310,76 @@ func TestBreachAPI_BreachedAccount_WithoutTruncate(t *testing.T) { } } +// TestBreachAPI_SubscribedDomains tests the SubscribedDomains() method of the breaches API +func TestBreachAPI_SubscribedDomains(t *testing.T) { + apiKey := os.Getenv("HIBP_API_KEY") + if apiKey == "" { + t.SkipNow() + } + hc := New(WithAPIKey(apiKey), WithRateLimitSleep()) + + domains, _, err := hc.BreachAPI.SubscribedDomains() + if err != nil { + t.Error(err) + } + + if len(domains) < 1 { + t.Log("no subscribed domains found with provided api key") + t.SkipNow() + } + + for i, domain := range domains { + t.Run(fmt.Sprintf("checking domain %d", i), func(t *testing.T) { + if domain.DomainName == "" { + t.Error("domain name is missing") + } + + if domain.NextSubscriptionRenewal.Time().IsZero() { + t.Error("next subscription renewal is missing") + } + }) + } +} + +// TestBreachAPI_BreachedDomain tests the BreachedDomain() method of the breaches API +func TestBreachAPI_BreachedDomain(t *testing.T) { + apiKey := os.Getenv("HIBP_API_KEY") + if apiKey == "" { + t.SkipNow() + } + hc := New(WithAPIKey(apiKey), WithRateLimitSleep()) + + domains, _, err := hc.BreachAPI.SubscribedDomains() + if err != nil { + t.Error(err) + } + + if len(domains) < 1 { + t.Log("no subscribed domains found with provided api key") + t.SkipNow() + } + + for i, domain := range domains { + t.Run(fmt.Sprintf("checking domain %d", i), func(t *testing.T) { + breaches, _, err := hc.BreachAPI.BreachedDomain(domain.DomainName) + if err != nil { + t.Error(err) + } + + if len(breaches) < 1 { + t.Logf("domain %s contains no breaches", domain.DomainName) + t.SkipNow() + } + + for alias, list := range breaches { + if l := len(list); l == 0 { + t.Errorf("alias %s contains %d breaches, there should be at least 1", alias, l) + } + } + }) + } +} + // TestAPIDate_UnmarshalJSON_Time tests the APIDate type JSON unmarshalling func TestAPIDate_UnmarshalJSON_Time(t *testing.T) { type testData struct {