• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / blurts-server / #11752

pending completion
#11752

push

circleci

web-flow
Merge pull request #2739 from mozilla/MNTOR-1026

Mntor 1026

278 of 1120 branches covered (24.82%)

Branch coverage included in aggregate %.

11 of 11 new or added lines in 3 files covered. (100.0%)

948 of 3010 relevant lines covered (31.5%)

2.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/utils/hibp.js
1
import mozlog from './log.js'
2
import AppConstants from '../app-constants.js'
3
import { fluentError } from './fluent.js'
4
import { getAllBreaches, upsertBreaches } from '../db/tables/breaches.js'
5
const { HIBP_THROTTLE_MAX_TRIES, HIBP_THROTTLE_DELAY, HIBP_API_ROOT, HIBP_KANON_API_ROOT, HIBP_KANON_API_TOKEN } = AppConstants
×
6

7
// TODO: fix hardcode
8
const HIBP_USER_AGENT = 'monitor/1.0.0'
×
9
// When HIBP "re-names" a breach, it keeps its old 'Name' value but gets a new 'Title'
10
// We use 'Name' in Firefox (via Remote Settings), so we have to maintain our own mapping of re-named breaches.
11
const RENAMED_BREACHES = ['covve']
×
12
const RENAMED_BREACHES_MAP = {
×
13
  covve: 'db8151dd'
14
}
15
const log = mozlog('hibp')
×
16

17
function _addStandardOptions (options = {}) {
×
18
  const hibpOptions = {
×
19
    headers: {
20
      'User-Agent': HIBP_USER_AGENT
21
    }
22
  }
23
  return Object.assign(options, hibpOptions)
×
24
}
25

26
async function _throttledFetch (url, reqOptions, tryCount = 1) {
×
27
  try {
×
28
    const response = await fetch(url, reqOptions)
×
29
    if (response.ok) return await response.json()
×
30

31
    switch (response.status) {
×
32
      case 404:
33
        // 404 can mean "no results", return undefined response
34
        return undefined
×
35
      case 429:
36
        log.info('_throttledFetch', { err: 'Error 429, tryCount: ' + tryCount })
×
37
        if (tryCount >= HIBP_THROTTLE_MAX_TRIES) {
×
38
          throw fluentError('error-hibp-throttled')
×
39
        } else {
40
          tryCount++
×
41
          await new Promise(resolve => setTimeout(resolve, HIBP_THROTTLE_DELAY * tryCount))
×
42
          return await _throttledFetch(url, reqOptions, tryCount)
×
43
        }
44
      default:
45
        throw new Error(`bad response: ${response.status}`)
×
46
    }
47
  } catch (err) {
48
    log.error('_throttledFetch', { err })
×
49
    throw fluentError('error-hibp-connect')
×
50
  }
51
}
52

53
async function req (path, options = {}) {
×
54
  const url = `${HIBP_API_ROOT}${path}`
×
55
  const reqOptions = _addStandardOptions(options)
×
56
  return await _throttledFetch(url, reqOptions)
×
57
}
58

59
async function kAnonReq (path, options = {}) {
×
60
  // Construct HIBP url and standard headers
61
  const url = `${HIBP_KANON_API_ROOT}${path}?code=${encodeURIComponent(HIBP_KANON_API_TOKEN)}`
×
62
  const reqOptions = _addStandardOptions(options)
×
63
  return await _throttledFetch(url, reqOptions)
×
64
}
65

66
/**
67
 * Sanitize data classes
68
 * ie. "Email Addresses" -> "email-addresses"
69
 * @param {Array} dataClasses
70
 * @returns Array sanitized data classes array
71
 */
72
function formatDataClassesArray (dataClasses) {
73
  return dataClasses.map(dataClass =>
×
74
    dataClass.toLowerCase()
×
75
      .replace(/[^-a-z0-9]/g, '-')
76
      .replace(/-{2,}/g, '-')
77
      .replace(/(^-|-$)/g, '')
78
  )
79
}
80

81
/**
82
 * Get all breaches from the database table "breaches",
83
 * sanitize it, and return a javascript array
84
 * @returns formatted all breaches array
85
 */
86
async function getAllBreachesFromDb () {
87
  let dbBreaches = []
×
88
  try {
×
89
    dbBreaches = await getAllBreaches()
×
90
  } catch (e) {
91
    log.error('getAllBreachesFromDb', 'No breaches exist in the database: ' + e)
×
92
    return dbBreaches
×
93
  }
94

95
  // TODO: we can do some filtering here for the most commonly used fields
96
  // TODO: change field names to camel case
97
  return dbBreaches.map(breach => ({
×
98
    Name: breach.name,
99
    Title: breach.title,
100
    Domain: breach.domain,
101
    BreachDate: breach.breach_date,
102
    AddedDate: breach.added_date,
103
    ModifiedDate: breach.modified_date,
104
    PwnCount: breach.pwn_count,
105
    Description: breach.description,
106
    LogoPath: breach.logo_path,
107
    DataClasses: breach.data_classes,
108
    IsVerified: breach.is_verified,
109
    IsFabricated: breach.is_fabricated,
110
    IsSensitive: breach.is_sensitive,
111
    IsRetired: breach.is_retired,
112
    IsSpamList: breach.is_spam_list,
113
    IsMalware: breach.is_malware
114
  }))
115
}
116

117
async function loadBreachesIntoApp (app) {
118
  try {
×
119
    // attempt to fetch breaches from the "breaches" database table
120
    const breaches = await getAllBreachesFromDb()
×
121
    log.debug('loadBreachesIntoApp', `loaded breaches from database: ${breaches.length}`)
×
122

123
    // if "breaches" table does not return results, fall back to HIBP request
124
    if (breaches?.length < 1) {
×
125
      const breachesResponse = await req('/breaches')
×
126
      log.debug('loadBreachesIntoApp', `loaded breaches from HIBP: ${breachesResponse.length}`)
×
127

128
      for (const breach of breachesResponse) {
×
129
        breach.DataClasses = formatDataClassesArray(breach.DataClasses)
×
130
        breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)[0]
×
131
        breaches.push(breach)
×
132
      }
133

134
      // sync the "breaches" table with the latest from HIBP
135
      await upsertBreaches(breaches)
×
136
    }
137
    app.locals.breaches = breaches
×
138
    app.locals.breachesLoadedDateTime = Date.now()
×
139
  } catch (error) {
140
    throw fluentError('error-hibp-load-breaches')
×
141
  }
142
  log.info('done-loading-breaches', 'great success 👍')
×
143
}
144
/**
145
A range of hashes can be searched by passing the hash prefix in a GET request:
146
GET /breachedaccount/range/[hash prefix]
147

148
 * @param {string} sha1 first 6 chars of email sha1
149
 * @param {*} allBreaches
150
 * @param {*} includeSensitive
151
 * @param {*} filterBreaches
152
 * @returns
153
 */
154
async function getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
×
155
  let foundBreaches = []
×
156
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
157
  const path = `/breachedaccount/range/${sha1Prefix}`
×
158

159
  const response = await kAnonReq(path)
×
160
  if (!response) {
×
161
    return []
×
162
  }
163
  // Parse response body, format:
164
  // [
165
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
166
  //   {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
167
  // ]
168
  for (const breachedAccount of response) {
×
169
    if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
×
170
      foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
×
171
      if (filterBreaches) {
×
172
        foundBreaches = filterBreaches(foundBreaches)
×
173
      }
174

175
      // NOTE: DO NOT CHANGE THIS SORT LOGIC
176
      // We store breach resolutions by recency indices,
177
      // so that our DB does not contain any part of any user's list of accounts
178
      foundBreaches.sort((a, b) => {
×
179
        return new Date(b.AddedDate) - new Date(a.AddedDate)
×
180
      })
181

182
      break
×
183
    }
184
  }
185

186
  if (includeSensitive) {
×
187
    return foundBreaches
×
188
  }
189
  return foundBreaches.filter(
×
190
    breach => !breach.IsSensitive
×
191
  )
192
}
193

194
function getBreachByName (allBreaches, breachName) {
195
  breachName = breachName.toLowerCase()
×
196
  if (RENAMED_BREACHES.includes(breachName)) {
×
197
    breachName = RENAMED_BREACHES_MAP[breachName]
×
198
  }
199
  const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
×
200
  return foundBreach
×
201
}
202

203
function filterBreaches (breaches) {
204
  return breaches.filter(
×
205
    breach => !breach.IsRetired &&
×
206
                !breach.IsSpamList &&
207
                !breach.IsFabricated &&
208
                breach.IsVerified &&
209
                breach.Domain !== ''
210
  )
211
}
212

213
/**
214
 * A range can be subscribed for callbacks with the following request:
215
 * POST /range/subscribe
216
 * {
217
 *   hashPrefix:"[hash prefix]"
218
 * }
219
 * There are two possible response codes that can be returned:
220
 * 1. HTTP 201: New range subscription has been created
221
 * 2. HTTP 200: Range subscription already exists
222
 * @param {string} sha1 sha1 of the email being subscribed
223
 * @returns 200 or 201 response codes
224
 */
225
async function subscribeHash (sha1) {
226
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
227
  const path = '/range/subscribe'
×
228
  const options = {
×
229
    method: 'POST',
230
    json: { hashPrefix: sha1Prefix }
231
  }
232

233
  return await kAnonReq(path, options)
×
234
}
235

236
/**
237
 * A range subscription can be deleted with the following request:
238
 * DELETE /range/[hash prefix]
239

240
 * There is one possible response code that can be returned:
241
 * HTTP 200: Range subscription successfully deleted
242

243
 * @param {string} sha1 sha1 of the email being subscribed
244
 * @returns 200 response codes
245
 */
246
async function deleteSubscribedHash (sha1) {
247
  const sha1Prefix = sha1.slice(0, 6).toUpperCase()
×
248
  const path = `/range${sha1Prefix}`
×
249
  const options = {
×
250
    method: 'DELETE'
251
  }
252

253
  return await kAnonReq(path, options)
×
254
}
255

256
export {
257
  req,
258
  kAnonReq,
259
  formatDataClassesArray,
260
  loadBreachesIntoApp,
261
  getBreachesForEmail,
262
  getBreachByName,
263
  getAllBreachesFromDb,
264
  filterBreaches,
265
  subscribeHash,
266
  deleteSubscribedHash
267
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc