diff --git a/breach.go b/breach.go index 962b030..9783043 100644 --- a/breach.go +++ b/breach.go @@ -96,81 +96,81 @@ type APIDate time.Time // Breaches returns a list of all breaches in the HIBP system func (b *BreachAPI) Breaches(options ...BreachOption) ([]*Breach, *http.Response, error) { - queryParams := b.setBreachOpts(options...) - apiURL := fmt.Sprintf("%s/breaches", BaseURL) + qp := b.setBreachOpts(options...) + au := fmt.Sprintf("%s/breaches", BaseURL) - hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, apiURL, queryParams) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, qp) if err != nil { - return nil, nil, err - } - - var breachList []*Breach - if err := json.Unmarshal(hb, &breachList); err != nil { return nil, hr, err } - return breachList, hr, nil + var bl []*Breach + if err := json.Unmarshal(hb, &bl); err != nil { + return nil, hr, err + } + + return bl, hr, nil } // BreachByName returns a single breached site based on its name func (b *BreachAPI) BreachByName(n string, options ...BreachOption) (*Breach, *http.Response, error) { - queryParams := b.setBreachOpts(options...) + qp := b.setBreachOpts(options...) if n == "" { - return nil, nil, fmt.Errorf("no breach name given") + return nil, nil, ErrNoName } - apiURL := fmt.Sprintf("%s/breach/%s", BaseURL, n) - hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, apiURL, queryParams) + au := fmt.Sprintf("%s/breach/%s", BaseURL, n) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, qp) if err != nil { - return nil, nil, err - } - - var breachDetails *Breach - if err := json.Unmarshal(hb, &breachDetails); err != nil { return nil, hr, err } - return breachDetails, hr, nil + var bd *Breach + if err := json.Unmarshal(hb, &bd); err != nil { + return nil, hr, err + } + + return bd, hr, nil } // DataClasses are attribute of a record compromised in a breach. This method returns a list of strings // with all registered data classes known to HIBP func (b *BreachAPI) DataClasses() ([]string, *http.Response, error) { - apiURL := fmt.Sprintf("%s/dataclasses", BaseURL) - hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, apiURL, nil) + au := fmt.Sprintf("%s/dataclasses", BaseURL) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, nil) if err != nil { - return nil, nil, err - } - - var dataClasses []string - if err := json.Unmarshal(hb, &dataClasses); err != nil { return nil, hr, err } - return dataClasses, hr, nil + var dc []string + if err := json.Unmarshal(hb, &dc); err != nil { + return nil, hr, err + } + + return dc, hr, nil } // BreachedAccount returns a single breached site based on its name func (b *BreachAPI) BreachedAccount(a string, options ...BreachOption) ([]*Breach, *http.Response, error) { - queryParams := b.setBreachOpts(options...) + qp := b.setBreachOpts(options...) if a == "" { - return nil, nil, fmt.Errorf("no account id given") + return nil, nil, ErrNoAccountID } - apiURL := fmt.Sprintf("%s/breachedaccount/%s", BaseURL, a) - hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, apiURL, queryParams) + au := fmt.Sprintf("%s/breachedaccount/%s", BaseURL, a) + hb, hr, err := b.hibp.HTTPResBody(http.MethodGet, au, qp) if err != nil { - return nil, nil, err - } - - var breachDetails []*Breach - if err := json.Unmarshal(hb, &breachDetails); err != nil { return nil, hr, err } - return breachDetails, hr, nil + var bd []*Breach + 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 @@ -205,7 +205,7 @@ func (d *APIDate) UnmarshalJSON(s []byte) error { pd, err := time.Parse("2006-01-02", ds) if err != nil { - return fmt.Errorf("failed to convert API date string to time.Time type: %w", err) + return fmt.Errorf("convert API date string to time.Time type: %w", err) } *(*time.Time)(d) = pd @@ -220,7 +220,7 @@ func (d *APIDate) Time() time.Time { // setBreachOpts returns a map of default settings and overridden values from different BreachOption func (b *BreachAPI) setBreachOpts(options ...BreachOption) map[string]string { - queryParams := map[string]string{ + qp := map[string]string{ "truncateResponse": "true", "includeUnverified": "true", } @@ -233,16 +233,16 @@ func (b *BreachAPI) setBreachOpts(options ...BreachOption) map[string]string { } if b.domain != "" { - queryParams["domain"] = b.domain + qp["domain"] = b.domain } if b.disableTrunc { - queryParams["truncateResponse"] = "false" + qp["truncateResponse"] = "false" } if b.noUnverified { - queryParams["includeUnverified"] = "false" + qp["includeUnverified"] = "false" } - return queryParams + return qp } diff --git a/breach_test.go b/breach_test.go index 0cc2783..1129e63 100644 --- a/breach_test.go +++ b/breach_test.go @@ -2,6 +2,7 @@ package hibp import ( "encoding/json" + "errors" "fmt" "os" "testing" @@ -14,8 +15,8 @@ const ( invalidDateJSON = `{"date": "202299-10-01"}` ) -// TestBreaches tests the Breaches() method of the breaches API -func TestBreaches(t *testing.T) { +// TestBreachAPI_Breaches tests the Breaches() method of the breaches API +func TestBreachAPI_Breaches(t *testing.T) { hc := New() breachList, _, err := hc.BreachAPI.Breaches() if err != nil { @@ -26,20 +27,21 @@ func TestBreaches(t *testing.T) { } } -// TestBreachesWithNil tests the Breaches() method of the breaches API with a nil option -func TestBreachesWithNil(t *testing.T) { +// TestBreachAPI_Breaches_WithNIL tests the Breaches() method of the breaches API with a nil option +func TestBreachAPI_Breaches_WithNIL(t *testing.T) { hc := New() breachList, _, err := hc.BreachAPI.Breaches(nil) if err != nil { t.Error(err) + return } if breachList != nil && len(breachList) <= 0 { t.Error("breaches list returned 0 results") } } -// TestBreachesWithDomain tests the Breaches() method of the breaches API for a specific domain -func TestBreachesWithDomain(t *testing.T) { +// TestBreachAPI_Breaches_WithDomain tests the Breaches() method of the breaches API for a specific domain +func TestBreachAPI_Breaches_WithDomain(t *testing.T) { testTable := []struct { testName string domain string @@ -55,6 +57,7 @@ func TestBreachesWithDomain(t *testing.T) { breachList, _, err := hc.BreachAPI.Breaches(WithDomain(tc.domain)) if err != nil { t.Error(err) + return } if breachList == nil && tc.isBreached { @@ -75,8 +78,8 @@ func TestBreachesWithDomain(t *testing.T) { } } -// TestBreachesWithoutUnverified tests the Breaches() method of the breaches API with the unverified parameter -func TestBreachesWithoutUnverified(t *testing.T) { +// TestBreachAPI_Breaches_WithoutUnverified tests the Breaches() method of the breaches API with the unverified parameter +func TestBreachAPI_Breaches_WithoutUnverified(t *testing.T) { testTable := []struct { testName string domain string @@ -94,6 +97,7 @@ func TestBreachesWithoutUnverified(t *testing.T) { breachList, _, err := hc.BreachAPI.Breaches(WithDomain(tc.domain), WithoutUnverified()) if err != nil { t.Error(err) + return } if breachList == nil && tc.isVerified && tc.isBreached { @@ -104,8 +108,8 @@ func TestBreachesWithoutUnverified(t *testing.T) { } } -// TestBreachByName tests the BreachByName() method of the breaches API for a specific domain -func TestBreachByName(t *testing.T) { +// TestBreachAPI_BreachByName tests the BreachByName() method of the breaches API for a specific domain +func TestBreachAPI_BreachByName(t *testing.T) { testTable := []struct { testName string breachName string @@ -122,6 +126,7 @@ func TestBreachByName(t *testing.T) { breachDetails, _, err := hc.BreachAPI.BreachByName(tc.breachName) if err != nil && !tc.shouldFail { t.Error(err) + return } if breachDetails == nil && tc.isBreached { @@ -136,20 +141,42 @@ func TestBreachByName(t *testing.T) { } } -// TestDataClasses tests the DataClasses() method of the breaches API -func TestDataClasses(t *testing.T) { +// TestBreachAPI_BreachByName_FailedHTTP tests the BreachByName() method with a failing HTTP request +func TestBreachAPI_BreachByName_FailedHTTP(t *testing.T) { + hc := New(WithRateLimitSleep()) + _, res, err := hc.BreachAPI.BreachByName("fäiled_invalid") + if err == nil { + t.Errorf("HTTP request was supposed to fail but didn't") + } + if res == nil { + t.Errorf("expected HTTP response but got nil") + } +} + +// TestBreachAPI_BreachByName_Errors tests the errors for the BreachByName() method +func TestBreachAPI_BreachByName_Errors(t *testing.T) { + hc := New(WithRateLimitSleep()) + _, _, err := hc.BreachAPI.BreachByName("") + if !errors.Is(err, ErrNoName) { + t.Errorf("expected to receive ErrNoName error but didn't") + } +} + +// TestBreachAPI_DataClasses tests the DataClasses() method of the breaches API +func TestBreachAPI_DataClasses(t *testing.T) { hc := New() classList, _, err := hc.BreachAPI.DataClasses() if err != nil { t.Error(err) + return } if classList != nil && len(classList) <= 0 { t.Error("breaches list returned 0 results") } } -// TestBreachedAccount tests the BreachedAccount() method of the breaches API -func TestBreachedAccount(t *testing.T) { +// TestBreachAPI_BreachedAccount tests the BreachedAccount() method of the breaches API +func TestBreachAPI_BreachedAccount(t *testing.T) { testTable := []struct { testName string accountName string @@ -200,9 +227,35 @@ func TestBreachedAccount(t *testing.T) { } } -// TestBreachedAccountWithoutTruncate tests the BreachedAccount() method of the breaches API with the +// TestBreachAPI_BreachedAccount_FailedHTTP tests the BreachedAccount() method of the breaches API with a failing +// HTTP request +func TestBreachAPI_BreachedAccount_FailedHTTP(t *testing.T) { + apiKey := os.Getenv("HIBP_API_KEY") + if apiKey == "" { + t.SkipNow() + } + hc := New(WithAPIKey(apiKey), WithRateLimitSleep()) + _, res, err := hc.BreachAPI.BreachedAccount("bröken@invalid_domain.tld") + if err == nil { + t.Error("HTTP request was supposed to fail, but didn't") + } + if res == nil { + t.Errorf("expected HTTP response but got nil") + } +} + +// TestBreachAPI_BreachedAccount_Errors tests the errors for the BreachedAccount() method +func TestBreachAPI_BreachedAccount_Errors(t *testing.T) { + hc := New(WithRateLimitSleep()) + _, _, err := hc.BreachAPI.BreachedAccount("") + if !errors.Is(err, ErrNoAccountID) { + t.Errorf("expected to receive ErrNoAccountID error but didn't") + } +} + +// TestBreachAPI_BreachedAccount_WithoutTruncate tests the BreachedAccount() method of the breaches API with the // truncateResponse option set to false -func TestBreachedAccountWithoutTruncate(t *testing.T) { +func TestBreachAPI_BreachedAccount_WithoutTruncate(t *testing.T) { testTable := []struct { testName string accountName string @@ -211,14 +264,17 @@ func TestBreachedAccountWithoutTruncate(t *testing.T) { shouldFail bool }{ { - "account-exists is breached once", "account-exists@hibp-integration-tests.com", "Adobe", - "adobe.com", false, + "account-exists is breached once", "account-exists@hibp-integration-tests.com", + "Adobe", "adobe.com", false, }, { - "multiple-breaches is breached multiple times", "multiple-breaches@hibp-integration-tests.com", "Adobe", - "adobe.com", false, + "multiple-breaches is breached multiple times", "multiple-breaches@hibp-integration-tests.com", + "Adobe", "adobe.com", false, + }, + { + "opt-out is not breached", "opt-out@hibp-integration-tests.com", "", + "", true, }, - {"opt-out is not breached", "opt-out@hibp-integration-tests.com", "", "", true}, {"empty string should fail", "", "", "", true}, } diff --git a/hibp.go b/hibp.go index 0e6626f..5dd01b9 100644 --- a/hibp.go +++ b/hibp.go @@ -4,6 +4,7 @@ package hibp import ( "bytes" "crypto/tls" + "errors" "fmt" "io" "log" @@ -13,15 +14,44 @@ import ( ) // Version represents the version of this package -const Version = "1.0.2" +const Version = "1.0.5" -// BaseURL is the base URL for the majority of API calls +// BaseURL is the base URL for the majority of API endpoints const BaseURL = "https://haveibeenpwned.com/api/v3" +// PasswdBaseURL is the base URL for the pwned passwords API endpoints +const PasswdBaseURL = "https://api.pwnedpasswords.com" + // DefaultUserAgent defines the default UA string for the HTTP client // Currently the URL in the UA string is comment out, as there is a bug in the HIBP API // not allowing multiple slashes -const DefaultUserAgent = `go-hibp v` + Version + ` (https://github.com/wneessen/go-hibp)` +const DefaultUserAgent = `go-hibp/` + Version + ` (+https://github.com/wneessen/go-hibp)` + +// DefaultTimeout is the default timeout value for the HTTP client +const DefaultTimeout = time.Second * 5 + +// List of common errors +var ( + // ErrNoAccountID is returned if no account ID is given to the corresponding API method + ErrNoAccountID = errors.New("no account ID given") + + // ErrNoName is returned if no name is given to the corresponding API method + ErrNoName = errors.New("no name given") + + // ErrNonPositiveResponse should be returned if a HTTP request failed with a non HTTP-200 status + ErrNonPositiveResponse = errors.New("non HTTP-200 response for HTTP request") + + // ErrPrefixLengthMismatch should be used if a given prefix does not match the + // expected length + ErrPrefixLengthMismatch = errors.New("password hash prefix must be 5 characters long") + + // ErrSHA1LengthMismatch should be used if a given SHA1 checksum does not match the + // expected length + ErrSHA1LengthMismatch = errors.New("SHA1 hash size needs to be 160 bits") + + // ErrSHA1Invalid should be used if a given string does not represent a valid SHA1 hash + ErrSHA1Invalid = errors.New("not a valid SHA1 hash") +) // Client is the HIBP client object type Client struct { @@ -49,7 +79,7 @@ func New(options ...Option) Client { c := Client{} // Set defaults - c.to = time.Second * 5 + c.to = DefaultTimeout c.PwnedPassAPIOpts = &PwnedPasswordOptions{} c.ua = DefaultUserAgent @@ -183,7 +213,7 @@ func (c *Client) HTTPResBody(m string, p string, q map[string]string) ([]byte, * } if hr.StatusCode != 200 { - return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s - %s", hr.Status, hb) + return nil, hr, fmt.Errorf("HTTP %s: %w", hr.Status, ErrNonPositiveResponse) } return hb, hr, nil @@ -191,18 +221,18 @@ func (c *Client) HTTPResBody(m string, p string, q map[string]string) ([]byte, * // httpClient returns a custom http client for the HIBP Client object func httpClient(to time.Duration) *http.Client { - tlsConfig := &tls.Config{ + tc := &tls.Config{ MaxVersion: tls.VersionTLS13, MinVersion: tls.VersionTLS12, } - httpTransport := &http.Transport{TLSClientConfig: tlsConfig} - httpClient := &http.Client{ - Transport: httpTransport, - Timeout: 5 * time.Second, + ht := &http.Transport{TLSClientConfig: tc} + hc := &http.Client{ + Transport: ht, + Timeout: DefaultTimeout, } if to.Nanoseconds() > 0 { - httpClient.Timeout = to + hc.Timeout = to } - return httpClient + return hc } diff --git a/password.go b/password.go index 9a6f7b2..cac5f8e 100644 --- a/password.go +++ b/password.go @@ -3,22 +3,13 @@ package hibp import ( "bufio" "crypto/sha1" + "encoding/hex" "fmt" "net/http" "strconv" "strings" ) -const ( - // ErrPrefixLengthMismatch should be used if a given prefix does not match the - // expected length - ErrPrefixLengthMismatch = "password hash prefix must be 5 characters long" - - // ErrSHA1LengthMismatch should be used if a given SHA1 checksum does not match the - // expected length - ErrSHA1LengthMismatch = "SHA1 hash size needs to be 160 bits" -) - // PwnedPassAPI is a HIBP Pwned Passwords API client type PwnedPassAPI struct { hibp *Client // References back to the parent HIBP client @@ -46,7 +37,7 @@ func (p *PwnedPassAPI) CheckPassword(pw string) (*Match, *http.Response, error) // CheckSHA1 checks the Pwned Passwords database against a given SHA1 checksum of a password string func (p *PwnedPassAPI) CheckSHA1(h string) (*Match, *http.Response, error) { if len(h) != 40 { - return nil, nil, fmt.Errorf(ErrSHA1LengthMismatch) + return nil, nil, ErrSHA1LengthMismatch } pwMatches, hr, err := p.ListHashesPrefix(h[:5]) @@ -79,7 +70,11 @@ func (p *PwnedPassAPI) ListHashesPassword(pw string) ([]Match, *http.Response, e // contain junk data func (p *PwnedPassAPI) ListHashesSHA1(h string) ([]Match, *http.Response, error) { if len(h) != 40 { - return nil, nil, fmt.Errorf(ErrSHA1LengthMismatch) + return nil, nil, ErrSHA1LengthMismatch + } + dst := make([]byte, hex.DecodedLen(len(h))) + if _, err := hex.Decode(dst, []byte(h)); err != nil { + return nil, nil, ErrSHA1Invalid } return p.ListHashesPrefix(h[:5]) } @@ -91,28 +86,29 @@ func (p *PwnedPassAPI) ListHashesSHA1(h string) ([]Match, *http.Response, error) // contain junk data func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, error) { if len(pf) != 5 { - return nil, nil, fmt.Errorf(ErrPrefixLengthMismatch) + return nil, nil, ErrPrefixLengthMismatch } - hreq, err := p.hibp.HTTPReq(http.MethodGet, fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", pf), - nil) + + au := fmt.Sprintf("%s/range/%s", PasswdBaseURL, pf) + hreq, err := p.hibp.HTTPReq(http.MethodGet, au, nil) if err != nil { return nil, nil, err } hr, err := p.hibp.hc.Do(hreq) if err != nil { - return nil, nil, err - } - if hr.StatusCode != 200 { - return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s", hr.Status) + return nil, hr, err } defer func() { _ = hr.Body.Close() }() + if hr.StatusCode != 200 { + return nil, hr, fmt.Errorf("HTTP %s: %w", hr.Status, ErrNonPositiveResponse) + } - var pwMatches []Match - scanObj := bufio.NewScanner(hr.Body) - for scanObj.Scan() { - hp := strings.SplitN(scanObj.Text(), ":", 2) + var pm []Match + so := bufio.NewScanner(hr.Body) + for so.Scan() { + hp := strings.SplitN(so.Text(), ":", 2) fh := fmt.Sprintf("%s%s", strings.ToLower(pf), strings.ToLower(hp[0])) hc, err := strconv.ParseInt(hp[1], 10, 64) if err != nil { @@ -121,15 +117,15 @@ func (p *PwnedPassAPI) ListHashesPrefix(pf string) ([]Match, *http.Response, err if hc == 0 { continue } - pwMatches = append(pwMatches, Match{ + pm = append(pm, Match{ Hash: fh, Count: hc, }) } - if err := scanObj.Err(); err != nil { - return nil, nil, err + if err := so.Err(); err != nil { + return nil, hr, err } - return pwMatches, hr, nil + return pm, hr, nil } diff --git a/password_test.go b/password_test.go index cb1bd60..b67d3f6 100644 --- a/password_test.go +++ b/password_test.go @@ -1,6 +1,7 @@ package hibp import ( + "errors" "fmt" "testing" ) @@ -119,6 +120,74 @@ func TestPwnedPassAPI_ListHashesPrefix(t *testing.T) { } } +// TestPwnedPassAPI_ListHashesPrefix_Errors tests the ListHashesPrefix method's errors +func TestPwnedPassAPI_ListHashesPrefix_Errors(t *testing.T) { + hc := New() + + // Empty prefix + t.Run("empty prefix", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesPrefix("") + if err == nil { + t.Errorf("ListHashesPrefix with empty prefix should fail but didn't") + return + } + if !errors.Is(err, ErrPrefixLengthMismatch) { + t.Errorf("ListHashesPrefix with empty prefix should return ErrPrefixLengthMismatch error but didn't") + return + } + }) + + // Too long prefix + t.Run("too long prefix", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesPrefix("abcdefg12345") + if err == nil { + t.Errorf("ListHashesPrefix with too long prefix should fail but didn't") + return + } + if !errors.Is(err, ErrPrefixLengthMismatch) { + t.Errorf("ListHashesPrefix with too long prefix should return ErrPrefixLengthMismatch error but didn't") + } + }) +} + +// TestPwnedPassAPI_ListHashesSHA1_Errors tests the ListHashesSHA1 method's errors +func TestPwnedPassAPI_ListHashesSHA1_Errors(t *testing.T) { + hc := New() + + // Empty hash + t.Run("empty hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesSHA1("") + if err == nil { + t.Errorf("ListHashesSHA1 with empty hash should fail but didn't") + } + if !errors.Is(err, ErrSHA1LengthMismatch) { + t.Errorf("ListHashesSHA1 with empty hash should return ErrSHA1LengthMismatch error but didn't") + } + }) + + // Too long hash + t.Run("too long hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesSHA1("FF36DC7D3284A39991ADA90CAF20D1E3C0DADEFAB") + if err == nil { + t.Errorf("ListHashesSHA1 with too long hash should fail but didn't") + } + if !errors.Is(err, ErrSHA1LengthMismatch) { + t.Errorf("ListHashesSHA1 with too long hash should return ErrSHA1LengthMismatch error but didn't") + } + }) + + // Invalid hash + t.Run("invalid hash", func(t *testing.T) { + _, _, err := hc.PwnedPassAPI.ListHashesSHA1("FF36DC7D3284A39991ADA90CAF20D1E3C0DADEFZ") + if err == nil { + t.Errorf("ListHashesSHA1 with invalid hash should fail but didn't") + } + if !errors.Is(err, ErrSHA1Invalid) { + t.Errorf("ListHashesSHA1 with invalid hash should return ErrSHA1Invalid error but didn't") + } + }) +} + // TestPwnedPassApi_ListHashesSHA1 tests the PwnedPassAPI.ListHashesSHA1 metethod func TestPwnedPassAPI_ListHashesSHA1(t *testing.T) { hc := New() diff --git a/paste.go b/paste.go index 49efcbc..3f55f3d 100644 --- a/paste.go +++ b/paste.go @@ -2,15 +2,11 @@ package hibp import ( "encoding/json" - "errors" "fmt" "net/http" "time" ) -// ErrNoAccountID is returned if no account ID is given to the PastedAccount method -var ErrNoAccountID = errors.New("no account ID given") - // PasteAPI is a HIBP pastes API client type PasteAPI struct { hibp *Client // References back to the parent HIBP client