From 6216076b74a1221775881ecc34d196c20ddaa57f Mon Sep 17 00:00:00 2001 From: Chris Cummer Date: Thu, 20 Jun 2019 09:28:48 -0400 Subject: [PATCH] Add a working Have I Been Pwned module --- maker/widget_maker.go | 4 ++ modules/hibp/client.go | 117 ++++++++++++++++++++++++++++++++++++ modules/hibp/hibp_breach.go | 21 +++++++ modules/hibp/hibp_status.go | 28 +++++++++ modules/hibp/settings.go | 68 +++++++++++++++++++++ modules/hibp/widget.go | 76 +++++++++++++++++++++++ 6 files changed, 314 insertions(+) create mode 100644 modules/hibp/client.go create mode 100644 modules/hibp/hibp_breach.go create mode 100644 modules/hibp/hibp_status.go create mode 100644 modules/hibp/settings.go create mode 100644 modules/hibp/widget.go diff --git a/maker/widget_maker.go b/maker/widget_maker.go index 0a12a059..d02a970a 100644 --- a/maker/widget_maker.go +++ b/maker/widget_maker.go @@ -20,6 +20,7 @@ import ( "github.com/wtfutil/wtf/modules/gitter" "github.com/wtfutil/wtf/modules/gspreadsheets" "github.com/wtfutil/wtf/modules/hackernews" + "github.com/wtfutil/wtf/modules/hibp" "github.com/wtfutil/wtf/modules/ipaddresses/ipapi" "github.com/wtfutil/wtf/modules/ipaddresses/ipinfo" "github.com/wtfutil/wtf/modules/jenkins" @@ -114,6 +115,9 @@ func MakeWidget( case "hackernews": settings := hackernews.NewSettingsFromYAML(widgetName, moduleConfig, globalConfig) widget = hackernews.NewWidget(app, pages, settings) + case "hibp": + settings := hibp.NewSettingsFromYAML(widgetName, moduleConfig, globalConfig) + widget = hibp.NewWidget(app, settings) case "ipapi": settings := ipapi.NewSettingsFromYAML(widgetName, moduleConfig, globalConfig) widget = ipapi.NewWidget(app, settings) diff --git a/modules/hibp/client.go b/modules/hibp/client.go new file mode 100644 index 00000000..70b7bff7 --- /dev/null +++ b/modules/hibp/client.go @@ -0,0 +1,117 @@ +package hibp + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" +) + +const ( + apiURL = "https://haveibeenpwned.com/api/breachedaccount/" + apiVersion = "application/vnd.haveibeenpwned.v2+json" + clientTimeoutSecs = 2 + userAgent = "WTFUtil" +) + +func (widget *Widget) fullURL(account string, truncated bool) string { + truncStr := "false" + if truncated == true { + truncStr = "true" + } + + return apiURL + account + fmt.Sprintf("?truncateResponse=%s", truncStr) +} + +func (widget *Widget) fetchForAccount(account string, since string) (*Status, error) { + if account == "" { + return nil, nil + } + + hibpClient := http.Client{ + Timeout: time.Second * clientTimeoutSecs, + } + + asTruncated := true + if since != "" { + asTruncated = false + } + + request, err := http.NewRequest(http.MethodGet, widget.fullURL(account, asTruncated), nil) + if err != nil { + return nil, err + } + + request.Header.Set("Accept", apiVersion) + request.Header.Set("User-Agent", userAgent) + + response, getErr := hibpClient.Do(request) + if getErr != nil { + return nil, err + } + + body, readErr := ioutil.ReadAll(response.Body) + if readErr != nil { + return nil, err + } + + stat, err := widget.parseResponseBody(account, body) + if err != nil { + return nil, err + } + + return stat, nil +} + +func (widget *Widget) parseResponseBody(account string, body []byte) (*Status, error) { + // If the body is empty then there's no breaches + if len(body) == 0 { + stat := NewStatus(account, []Breach{}) + return stat, nil + } + + // Else we have breaches for this account + breaches := make([]Breach, 0) + + jsonErr := json.Unmarshal(body, &breaches) + if jsonErr != nil { + return nil, jsonErr + } + + breaches = widget.filterBreaches(breaches) + + return NewStatus(account, breaches), nil +} + +func (widget *Widget) filterBreaches(breaches []Breach) []Breach { + // If there's no valid since value in the settings, there's no point in trying to filter + // the breaches on that value, they'll all pass + if !widget.settings.HasSince() { + return breaches + } + + sinceDate, err := widget.settings.SinceDate() + if err != nil { + return breaches + } + + latestBreaches := []Breach{} + + for _, breach := range breaches { + breachDate, err := breach.BreachDate() + if err != nil { + // Append the erring breach here because a failing breach date doesn't mean that + // the breach itself isn't applicable. The date could be missing or malformed, + // in which case we err on the side of caution and assume that the breach is valid + latestBreaches = append(latestBreaches, breach) + continue + } + + if breachDate.After(sinceDate) { + latestBreaches = append(latestBreaches, breach) + } + } + + return latestBreaches +} diff --git a/modules/hibp/hibp_breach.go b/modules/hibp/hibp_breach.go new file mode 100644 index 00000000..2db518ab --- /dev/null +++ b/modules/hibp/hibp_breach.go @@ -0,0 +1,21 @@ +package hibp + +import "time" + +// Breach represents a breach in the HIBP system +type Breach struct { + Date string `json:"BreachDate"` + Name string `json:"Name"` +} + +// BreachDate returns the date of the breach +func (br *Breach) BreachDate() (time.Time, error) { + dt, err := time.Parse("2006-01-02", br.Date) + if err != nil { + // I would much rather return (nil, err) err but that doesn't seem possible + // Not sure what a better value would be + return time.Now(), err + } + + return dt, nil +} diff --git a/modules/hibp/hibp_status.go b/modules/hibp/hibp_status.go new file mode 100644 index 00000000..6220b503 --- /dev/null +++ b/modules/hibp/hibp_status.go @@ -0,0 +1,28 @@ +package hibp + +// Status represents the status of an account in the HIBP system +type Status struct { + Account string + Breaches []Breach +} + +// NewStatus creates and returns an instance of Status +func NewStatus(acct string, breaches []Breach) *Status { + stat := Status{ + Account: acct, + Breaches: breaches, + } + + return &stat +} + +// HasBeenCompromised returns TRUE if the specified account has any breaches associated +// with it, FALSE if no breaches are associated with it +func (stat *Status) HasBeenCompromised() bool { + return stat.Len() > 0 +} + +// Len returns the number of breaches found for the specified account +func (stat *Status) Len() int { + return len(stat.Breaches) +} diff --git a/modules/hibp/settings.go b/modules/hibp/settings.go new file mode 100644 index 00000000..9f5238f9 --- /dev/null +++ b/modules/hibp/settings.go @@ -0,0 +1,68 @@ +package hibp + +import ( + "time" + + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/wtf" +) + +const ( + defaultTitle = "HIBP" + minRefreshSecs = 21600 // TODO: Finish implementing this +) + +type colors struct { + ok string + pwned string +} + +// Settings defines the configuration properties for this module +type Settings struct { + colors + common *cfg.Common + + accounts []string + since string +} + +// NewSettingsFromYAML creates a new settings instance from a YAML config block +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, ymlConfig, globalConfig), + + accounts: wtf.ToStrs(ymlConfig.UList("accounts")), + since: ymlConfig.UString("since", ""), + } + + settings.colors.ok = ymlConfig.UString("colors.ok", "green") + settings.colors.pwned = ymlConfig.UString("colors.pwned", "red") + + return &settings +} + +// HasSince returns TRUE if there's a valid "since" value setting, FALSE if there is not +func (sett *Settings) HasSince() bool { + if sett.since == "" { + return false + } + + _, err := sett.SinceDate() + if err != nil { + return false + } + + return true +} + +// SinceDate returns the "since" settings as a proper Time instance +func (sett *Settings) SinceDate() (time.Time, error) { + dt, err := time.Parse("2006-01-02", sett.since) + if err != nil { + return time.Now(), err + } + + return dt, nil +} diff --git a/modules/hibp/widget.go b/modules/hibp/widget.go new file mode 100644 index 00000000..4e1ac2cd --- /dev/null +++ b/modules/hibp/widget.go @@ -0,0 +1,76 @@ +package hibp + +import ( + "fmt" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/wtf" +) + +// Widget is the container for hibp data +type Widget struct { + wtf.TextWidget + + accounts []string + settings *Settings +} + +// NewWidget creates a new instance of a widget +func NewWidget(app *tview.Application, settings *Settings) *Widget { + widget := Widget{ + TextWidget: wtf.NewTextWidget(app, settings.common, false), + + settings: settings, + } + + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +// Fetch rettrieves HIBP data from the HIBP API +func (widget *Widget) Fetch(accounts []string) ([]*Status, error) { + data := []*Status{} + + for _, account := range accounts { + stat, err := widget.fetchForAccount(account, widget.settings.since) + if err != nil { + return nil, err + } + + data = append(data, stat) + } + + return data, nil +} + +// Refresh updates the data for this widget and displays it onscreen +func (widget *Widget) Refresh() { + data, err := widget.Fetch(widget.settings.accounts) + + var content string + if err != nil { + content = err.Error() + } else { + content = widget.contentFrom(data) + } + + widget.Redraw(widget.CommonSettings.Title, content, false) +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) contentFrom(data []*Status) string { + str := "" + + for _, stat := range data { + color := widget.settings.colors.ok + if stat.HasBeenCompromised() { + color = widget.settings.colors.pwned + } + + str = str + fmt.Sprintf(" [%s]%s[white]\n", color, stat.Account) + } + + return str +}