diff --git a/breach.go b/breach.go new file mode 100644 index 0000000..240b179 --- /dev/null +++ b/breach.go @@ -0,0 +1,137 @@ +package hibp + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// BreachApi is a HIBP breaches API client +type BreachApi struct { + hibp *Client + + // Options + domain string // Filter for a specific breach domain +} + +// Breach represents a JSON response structure of the breaches API +type Breach struct { + // Name is a pascal-cased name representing the breach which is unique across all other breaches. + // This value never changes and may be used to name dependent assets (such as images) but should not + // be shown directly to end users (see the "Title" attribute instead) + Name string `json:"Name"` + + // Title is a descriptive title for the breach suitable for displaying to end users. It's unique across + // all breaches but individual values may change in the future (i.e. if another breach occurs against + // an organisation already in the system). If a stable value is required to reference the breach, + // refer to the "Name" attribute instead + Title string `json:"Title"` + + // Domain of the primary website the breach occurred on. This may be used for identifying other + // assets external systems may have for the site + Domain string `json:"Domain"` + + // BreachDate is the date (with no time) the breach originally occurred on in ISO 8601 format. This is not + // always accurate — frequently breaches are discovered and reported long after the original incident. Use + // this attribute as a guide only + BreachDate *ApiDate `json:"BreachDate,omitempty"` + + // AddedDate represents the date and time (precision to the minute) the breach was added to the system + // in ISO 8601 format + AddedDate time.Time `json:"AddedDate"` + + // ModifiedDate is the date and time (precision to the minute) the breach was modified in ISO 8601 format. + // This will only differ from the AddedDate attribute if other attributes represented here are changed or + // data in the breach itself is changed (i.e. additional data is identified and loaded). It is always + // either equal to or greater then the AddedDate attribute, never less than + ModifiedDate time.Time `json:"ModifiedDate"` + + // PwnCount is the total number of accounts loaded into the system. This is usually less than the total + // number reported by the media due to duplication or other data integrity issues in the source data + PwnCount int `json:"PwnCount"` + + // Description contains an overview of the breach represented in HTML markup. The description may include + // markup such as emphasis and strong tags as well as hyperlinks + Description string `json:"Description"` + + // DataClasses describes the nature of the data compromised in the breach and contains an alphabetically ordered + // string array of impacted data classes + DataClasses []string `json:"DataClasses"` +} + +// 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 + +// Breaches returns a list of all breaches in the HIBP system +func (b *BreachApi) Breaches(options ...BreachOption) ([]*Breach, *http.Response, error) { + for _, opt := range options { + opt(b) + } + + apiUrl := fmt.Sprintf("%s/breaches", BaseUrl) + if b.domain != "" { + apiUrl = fmt.Sprintf("%s?domain=%s", apiUrl, b.domain) + } + + hreq, err := b.hibp.HttpReq(http.MethodGet, apiUrl) + if err != nil { + return nil, nil, err + } + hr, err := b.hibp.hc.Do(hreq) + if err != nil { + return nil, hr, err + } + if hr.StatusCode != 200 { + return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s", hr.Status) + } + defer func() { + _ = hr.Body.Close() + }() + + hb, err := io.ReadAll(hr.Body) + if err != nil { + return nil, hr, err + } + + var breachList []*Breach + if err := json.Unmarshal(hb, &breachList); err != nil { + return nil, hr, err + } + + return breachList, hr, nil +} + +// WithDomain sets the domain filter for the breaches API +func WithDomain(d string) BreachOption { + return func(b *BreachApi) { + b.domain = d + } +} + +// 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" { + return nil + } + + pd, err := time.Parse("2006-01-02", ds) + if err != nil { + return fmt.Errorf("failed to convert API date string to time.Time type: %s", err) + } + + *(*time.Time)(d) = pd + return nil +} + +// Time adds a Time() method to the ApiDate converted time.Time type +func (d ApiDate) Time() time.Time { + return time.Time(d) +} diff --git a/breach_test.go b/breach_test.go new file mode 100644 index 0000000..479d021 --- /dev/null +++ b/breach_test.go @@ -0,0 +1,26 @@ +package hibp + +import ( + "fmt" + "os" + "testing" +) + +// TestNew tests the New() function +func TestBreach(t *testing.T) { + hc := New(WithApiKey(os.Getenv("HIBP_API_KE"))) + if hc == nil { + t.Errorf("hibp client creation failed") + } + foo, _, err := hc.BreachApi.Breaches(WithDomain("adobe.com")) + if err != nil { + t.Error(err) + } + for _, b := range foo { + fmt.Printf("%+v", *b) + if b.BreachDate != nil { + fmt.Printf("%+v", b.BreachDate.Time().String()) + } + break + } +} diff --git a/hibp.go b/hibp.go index c122f42..bc43dae 100644 --- a/hibp.go +++ b/hibp.go @@ -11,13 +11,19 @@ import ( // Version represents the version of this package const Version = "0.1.1" +// BaseUrl is the base URL for the majority of API calls +const BaseUrl = "https://haveibeenpwned.com/api/v3" + // Client is the HIBP client object type Client struct { hc *http.Client // HTTP client to perform the API requests to time.Duration // HTTP client timeout ak string // HIBP API key - PwnedPassword *PwnedPassword // Reference to the PwnedPassword API + PwnedPassApi *PwnedPassApi // Reference to the PwnedPassApi API + PwnedPassApiOpts *PwnedPasswordOptions // Additional options for the PwnedPassApi API + + BreachApi *BreachApi // Reference to the BreachApi API } // Option is a function that is used for grouping of Client options. @@ -29,6 +35,7 @@ func New(options ...Option) *Client { // Set defaults c.to = time.Second * 5 + c.PwnedPassApiOpts = &PwnedPasswordOptions{} // Set additional options for _, opt := range options { @@ -39,7 +46,8 @@ func New(options ...Option) *Client { c.hc = httpClient(c.to) // Associate the different HIBP service APIs with the Client - c.PwnedPassword = &PwnedPassword{hc: c} + c.PwnedPassApi = &PwnedPassApi{hibp: c} + c.BreachApi = &BreachApi{hibp: c} return c } @@ -58,6 +66,13 @@ func WithApiKey(k string) Option { } } +// WithPwnedPadding enables padding-mode for the PwnedPasswords API client +func WithPwnedPadding() Option { + return func(c *Client) { + c.PwnedPassApiOpts.WithPadding = true + } +} + // HttpReq performs an HTTP request to the corresponding API func (c *Client) HttpReq(m, p string) (*http.Request, error) { u, err := url.Parse(p) @@ -73,7 +88,11 @@ func (c *Client) HttpReq(m, p string) (*http.Request, error) { hr.Header.Set("User-Agent", fmt.Sprintf("go-hibp v%s - https://github.com/wneessen/go-hibp", Version)) if c.ak != "" { - hr.Header["hibp-api-key"] = []string{c.ak} + hr.Header.Set("hibp-api-key", c.ak) + } + + if c.PwnedPassApiOpts.WithPadding { + hr.Header.Set("Add-Padding", "true") } return hr, nil diff --git a/hibp_test.go b/hibp_test.go index 5ee1756..698178e 100644 --- a/hibp_test.go +++ b/hibp_test.go @@ -13,7 +13,7 @@ func TestNew(t *testing.T) { } } -// TestNewWithHttpTimeout tests the New() function +// TestNewWithHttpTimeout tests the New() function with the http timeout option func TestNewWithHttpTimeout(t *testing.T) { hc := New(WithHttpTimeout(time.Second * 10)) if hc == nil { @@ -25,3 +25,16 @@ func TestNewWithHttpTimeout(t *testing.T) { time.Second*10, hc.to) } } + +// TestNewWithPwnedPadding tests the New() function with the PwnedPadding option +func TestNewWithPwnedPadding(t *testing.T) { + hc := New(WithPwnedPadding()) + if hc == nil { + t.Errorf("hibp client creation failed") + return + } + if !hc.PwnedPassApiOpts.WithPadding { + t.Errorf("hibp client pwned padding option was not set properly. Expected %v, got: %v", + true, hc.PwnedPassApiOpts.WithPadding) + } +} diff --git a/password.go b/password.go index a875daa..03a101d 100644 --- a/password.go +++ b/password.go @@ -9,9 +9,9 @@ import ( "strings" ) -// PwnedPassword is a HIBP Pwned Passwords API client -type PwnedPassword struct { - hc *Client +// PwnedPassApi is a HIBP Pwned Passwords API client +type PwnedPassApi struct { + hibp *Client } // Match represents a match in the Pwned Passwords API @@ -20,14 +20,19 @@ type Match struct { Count int64 } +// PwnedPasswordOptions is a struct of additional options for the PP API +type PwnedPasswordOptions struct { + WithPadding bool +} + // CheckPassword checks the Pwned Passwords database against a given password string -func (p *PwnedPassword) CheckPassword(pw string) (*Match, *http.Response, error) { +func (p *PwnedPassApi) CheckPassword(pw string) (*Match, *http.Response, error) { shaSum := fmt.Sprintf("%x", sha1.Sum([]byte(pw))) return p.CheckSHA1(shaSum) } // CheckSHA1 checks the Pwned Passwords database against a given SHA1 checksum of a password -func (p *PwnedPassword) CheckSHA1(h string) (*Match, *http.Response, error) { +func (p *PwnedPassApi) CheckSHA1(h string) (*Match, *http.Response, error) { pwMatches, hr, err := p.apiCall(h) if err != nil { return &Match{}, hr, err @@ -44,13 +49,13 @@ func (p *PwnedPassword) CheckSHA1(h string) (*Match, *http.Response, error) { // apiCall performs the API call to the Pwned Password API endpoint and returns // the http.Response -func (p *PwnedPassword) apiCall(h string) ([]Match, *http.Response, error) { +func (p *PwnedPassApi) apiCall(h string) ([]Match, *http.Response, error) { sh := h[:5] - hreq, err := p.hc.HttpReq(http.MethodGet, fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", sh)) + hreq, err := p.hibp.HttpReq(http.MethodGet, fmt.Sprintf("https://api.pwnedpasswords.com/range/%s", sh)) if err != nil { return nil, nil, err } - hr, err := p.hc.hc.Do(hreq) + hr, err := p.hibp.hc.Do(hreq) if err != nil { return nil, nil, err } diff --git a/password_test.go b/password_test.go index ab76b73..45407cd 100644 --- a/password_test.go +++ b/password_test.go @@ -19,7 +19,7 @@ func TestPwnedPasswordString(t *testing.T) { for _, tc := range testTable { t.Run(tc.testName, func(t *testing.T) { - m, _, err := hc.PwnedPassword.CheckPassword(tc.pwString) + m, _, err := hc.PwnedPassApi.CheckPassword(tc.pwString) if err != nil { t.Error(err) } @@ -50,7 +50,7 @@ func TestPwnedPasswordHash(t *testing.T) { for _, tc := range testTable { t.Run(tc.testName, func(t *testing.T) { - m, _, err := hc.PwnedPassword.CheckSHA1(tc.pwHash) + m, _, err := hc.PwnedPassApi.CheckSHA1(tc.pwHash) if err != nil { t.Error(err) }