Introducing the breaches API

So far only the "list all breaches" API is implemented, though
This commit is contained in:
Winni Neessen 2021-09-21 19:46:48 +02:00
parent 277b45ec8d
commit f7588a863c
5 changed files with 131 additions and 24 deletions

View file

@ -11,10 +11,11 @@ import (
// BreachApi is a HIBP breaches API client // BreachApi is a HIBP breaches API client
type BreachApi struct { type BreachApi struct {
hibp *Client hibp *Client // References back to the parent HIBP client
// Options
domain string // Filter for a specific breach domain domain string // Filter for a specific breach domain
disableTrunc bool // Controls the truncateResponse parameter for the breaches API (defaults to false)
noUnverified bool // Controls the includeUnverified parameter for the breaches API (defaults to false)
} }
// Breach represents a JSON response structure of the breaches API // Breach represents a JSON response structure of the breaches API
@ -60,6 +61,32 @@ type Breach struct {
// DataClasses describes the nature of the data compromised in the breach and contains an alphabetically ordered // DataClasses describes the nature of the data compromised in the breach and contains an alphabetically ordered
// string array of impacted data classes // string array of impacted data classes
DataClasses []string `json:"DataClasses"` DataClasses []string `json:"DataClasses"`
// IsVerified indicates that the breach is considered unverified. An unverified breach may not have
// been hacked from the indicated website. An unverified breach is still loaded into HIBP when there's
// sufficient confidence that a significant portion of the data is legitimate
IsVerified bool `json:"IsVerified"`
// IsFabricated indicates that the breach is considered fabricated. A fabricated breach is unlikely
// to have been hacked from the indicated website and usually contains a large amount of manufactured
// data. However, it still contains legitimate email addresses and asserts that the account owners
// were compromised in the alleged breach
IsFabricated bool `json:"IsFabricated"`
// IsSensitive indicates if the breach is considered sensitive. The public API will not return any
// accounts for a breach flagged as sensitive
IsSensitive bool `json:"IsSensitive"`
// IsRetired indicates if the breach has been retired. This data has been permanently removed and
// will not be returned by the API
IsRetired bool `json:"IsRetired"`
// IsSpamList indicates
IsSpamList bool `json:"IsSpamList"`
// LogoPath represents a URI that specifies where a logo for the breached service can be found.
// Logos are always in PNG format
LogoPath string `json:"LogoPath"`
} }
// BreachOption is an additional option the can be set for the BreachApiClient // BreachOption is an additional option the can be set for the BreachApiClient
@ -70,16 +97,29 @@ type ApiDate time.Time
// Breaches returns a list of all breaches in the HIBP system // Breaches returns a list of all breaches in the HIBP system
func (b *BreachApi) Breaches(options ...BreachOption) ([]*Breach, *http.Response, error) { func (b *BreachApi) Breaches(options ...BreachOption) ([]*Breach, *http.Response, error) {
queryParms := map[string]string{
"truncateResponse": "true",
"includeUnverified": "true",
}
apiUrl := fmt.Sprintf("%s/breaches", BaseUrl)
for _, opt := range options { for _, opt := range options {
opt(b) opt(b)
} }
apiUrl := fmt.Sprintf("%s/breaches", BaseUrl)
if b.domain != "" { if b.domain != "" {
apiUrl = fmt.Sprintf("%s?domain=%s", apiUrl, b.domain) queryParms["domain"] = b.domain
} }
hreq, err := b.hibp.HttpReq(http.MethodGet, apiUrl) if b.disableTrunc {
queryParms["truncateResponse"] = "false"
}
if b.noUnverified {
queryParms["includeUnverified"] = "false"
}
hreq, err := b.hibp.HttpReq(http.MethodGet, apiUrl, queryParms)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@ -114,6 +154,13 @@ func WithDomain(d string) BreachOption {
} }
} }
// WithoutTruncate disables the truncateResponse parameter in the breaches API
func WithoutTruncate() BreachOption {
return func(b *BreachApi) {
b.disableTrunc = true
}
}
// UnmarshalJSON for the ApiDate type converts a give date string into a time.Time type // UnmarshalJSON for the ApiDate type converts a give date string into a time.Time type
func (d *ApiDate) UnmarshalJSON(s []byte) error { func (d *ApiDate) UnmarshalJSON(s []byte) error {
ds := string(s) ds := string(s)

View file

@ -1,26 +1,57 @@
package hibp package hibp
import ( import (
"fmt"
"os"
"testing" "testing"
) )
// TestNew tests the New() function // TestBreaches tests the Breaches() method of the breaches API
func TestBreach(t *testing.T) { func TestBreaches(t *testing.T) {
hc := New(WithApiKey(os.Getenv("HIBP_API_KE"))) hc := New()
if hc == nil { if hc == nil {
t.Errorf("hibp client creation failed") t.Errorf("hibp client creation failed")
} }
foo, _, err := hc.BreachApi.Breaches(WithDomain("adobe.com"))
breachList, _, err := hc.BreachApi.Breaches()
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
for _, b := range foo { if len(breachList) <= 0 {
fmt.Printf("%+v", *b) t.Error("breaches list returned 0 results")
if b.BreachDate != nil { }
fmt.Printf("%+v", b.BreachDate.Time().String()) }
}
break // TestBreachesWithDomain tests the Breaches() method of the breaches API for a specific domain
func TestBreachesWithDomain(t *testing.T) {
testTable := []struct {
testName string
domain string
isBreached bool
}{
{"adobe.com is breached", "adobe.com", true},
{"example.com is not breached", "example.com", false},
}
hc := New()
if hc == nil {
t.Error("failed to create HIBP client")
return
}
for _, tc := range testTable {
t.Run(tc.testName, func(t *testing.T) {
breachList, _, err := hc.BreachApi.Breaches(WithDomain(tc.domain))
if err != nil {
t.Error(err)
}
breachLen := len(breachList)
if tc.isBreached && breachLen <= 0 {
t.Errorf("domain %s is expected to be breached, but returned 0 results.",
tc.domain)
}
if !tc.isBreached && breachLen != 0 {
t.Errorf("domain %s is expected to be not breached, but returned %d results.",
tc.domain, breachLen)
}
})
} }
} }

25
hibp.go
View file

@ -1,15 +1,17 @@
package hibp package hibp
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
) )
// Version represents the version of this package // Version represents the version of this package
const Version = "0.1.1" const Version = "0.1.2"
// BaseUrl is the base URL for the majority of API calls // BaseUrl is the base URL for the majority of API calls
const BaseUrl = "https://haveibeenpwned.com/api/v3" const BaseUrl = "https://haveibeenpwned.com/api/v3"
@ -74,16 +76,35 @@ func WithPwnedPadding() Option {
} }
// HttpReq performs an HTTP request to the corresponding API // HttpReq performs an HTTP request to the corresponding API
func (c *Client) HttpReq(m, p string) (*http.Request, error) { func (c *Client) HttpReq(m, p string, q map[string]string) (*http.Request, error) {
u, err := url.Parse(p) u, err := url.Parse(p)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if m == http.MethodGet {
uq := u.Query()
for k, v := range q {
uq.Add(k, v)
}
u.RawQuery = uq.Encode()
}
hr, err := http.NewRequest(m, u.String(), nil) hr, err := http.NewRequest(m, u.String(), nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if m == http.MethodPost {
pd := url.Values{}
for k, v := range q {
pd.Add(k, v)
}
rb := io.NopCloser(bytes.NewBufferString(pd.Encode()))
hr.Body = rb
}
hr.Header.Set("Accept", "application/json") hr.Header.Set("Accept", "application/json")
hr.Header.Set("User-Agent", fmt.Sprintf("go-hibp v%s - https://github.com/wneessen/go-hibp", Version)) hr.Header.Set("User-Agent", fmt.Sprintf("go-hibp v%s - https://github.com/wneessen/go-hibp", Version))

View file

@ -11,17 +11,19 @@ import (
// PwnedPassApi is a HIBP Pwned Passwords API client // PwnedPassApi is a HIBP Pwned Passwords API client
type PwnedPassApi struct { type PwnedPassApi struct {
hibp *Client hibp *Client // References back to the parent HIBP client
} }
// Match represents a match in the Pwned Passwords API // Match represents a match in the Pwned Passwords API
type Match struct { type Match struct {
Hash string Hash string // SHA1 hash of the matching password
Count int64 Count int64 // Represents the number of leaked accounts that hold/held this password
} }
// PwnedPasswordOptions is a struct of additional options for the PP API // PwnedPasswordOptions is a struct of additional options for the PP API
type PwnedPasswordOptions struct { type PwnedPasswordOptions struct {
// WithPadding controls if the PwnedPassword API returns with padding or not
// See: https://haveibeenpwned.com/API/v3#PwnedPasswordsPadding
WithPadding bool WithPadding bool
} }
@ -51,7 +53,8 @@ func (p *PwnedPassApi) CheckSHA1(h string) (*Match, *http.Response, error) {
// the http.Response // the http.Response
func (p *PwnedPassApi) apiCall(h string) ([]Match, *http.Response, error) { func (p *PwnedPassApi) apiCall(h string) ([]Match, *http.Response, error) {
sh := h[:5] sh := h[:5]
hreq, err := p.hibp.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),
nil)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }

View file

@ -47,12 +47,17 @@ func TestPwnedPasswordHash(t *testing.T) {
"90efc095c82eab44e882fda507cfab1a2cd31fc0", false}, "90efc095c82eab44e882fda507cfab1a2cd31fc0", false},
} }
hc := New() hc := New()
if hc == nil {
t.Error("failed to create HIBP client")
return
}
for _, tc := range testTable { for _, tc := range testTable {
t.Run(tc.testName, func(t *testing.T) { t.Run(tc.testName, func(t *testing.T) {
m, _, err := hc.PwnedPassApi.CheckSHA1(tc.pwHash) m, _, err := hc.PwnedPassApi.CheckSHA1(tc.pwHash)
if err != nil { if err != nil {
t.Error(err) t.Error(err)
return
} }
if m == nil && tc.isLeaked { if m == nil && tc.isLeaked {
t.Errorf("password is expected to be leaked but 0 leaks were returned in Pwned Passwords DB") t.Errorf("password is expected to be leaked but 0 leaks were returned in Pwned Passwords DB")