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

mozilla / blurts-server / #11959

pending completion
#11959

push

circleci

web-flow
Merge pull request #2766 from mozilla/MNTOR-978

MNTOR-978: Migrate breach resolution (part 2)

282 of 1182 branches covered (23.86%)

Branch coverage included in aggregate %.

40 of 40 new or added lines in 1 file covered. (100.0%)

959 of 3157 relevant lines covered (30.38%)

2.47 hits per line

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

0.0
/src/utils/hibp.js
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
 * License, v. 2.0. If a copy of the MPL was not distributed with this
3
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4

5
import mozlog from './log.js'
6
import AppConstants from '../app-constants.js'
7
import { fluentError } from './fluent.js'
8
import { getAllBreaches, upsertBreaches } from '../db/tables/breaches.js'
9
const { HIBP_THROTTLE_MAX_TRIES, HIBP_THROTTLE_DELAY, HIBP_API_ROOT, HIBP_KANON_API_ROOT, HIBP_KANON_API_TOKEN } = AppConstants
×
10

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

21
function _addStandardOptions (options = {}) {
×
22
  const hibpOptions = {
×
23
    headers: {
24
      'User-Agent': HIBP_USER_AGENT
25
    }
26
  }
27
  return Object.assign(options, hibpOptions)
×
28
}
29

30
async function _throttledFetch (url, reqOptions, tryCount = 1) {
×
31
  try {
×
32
    const response = await fetch(url, reqOptions)
×
33
    if (response.ok) return await response.json()
×
34

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

57
async function req (path, options = {}) {
×
58
  const url = `${HIBP_API_ROOT}${path}`
×
59
  const reqOptions = _addStandardOptions(options)
×
60
  return await _throttledFetch(url, reqOptions)
×
61
}
62

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

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

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

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

122
async function loadBreachesIntoApp (app) {
123
  try {
×
124
    // attempt to fetch breaches from the "breaches" database table
125
    const breaches = await getAllBreachesFromDb()
×
126
    log.debug('loadBreachesIntoApp', `loaded breaches from database: ${breaches.length}`)
×
127

128
    // if "breaches" table does not return results, fall back to HIBP request
129
    if (breaches?.length < 1) {
×
130
      const breachesResponse = await req('/breaches')
×
131
      log.debug('loadBreachesIntoApp', `loaded breaches from HIBP: ${breachesResponse.length}`)
×
132

133
      for (const breach of breachesResponse) {
×
134
        breach.DataClasses = formatDataClassesArray(breach.DataClasses)
×
135
        breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)[0]
×
136
        breaches.push(breach)
×
137
      }
138

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

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

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

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

187
      break
×
188
    }
189
  }
190

191
  if (includeSensitive) {
×
192
    return foundBreaches
×
193
  }
194
  return foundBreaches.filter(
×
195
    breach => !breach.IsSensitive
×
196
  )
197
}
198

199
function getBreachByName (allBreaches, breachName) {
200
  breachName = breachName.toLowerCase()
×
201
  if (RENAMED_BREACHES.includes(breachName)) {
×
202
    breachName = RENAMED_BREACHES_MAP[breachName]
×
203
  }
204
  const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
×
205
  return foundBreach
×
206
}
207

208
function filterBreaches (breaches) {
209
  return breaches.filter(
×
210
    breach => !breach.IsRetired &&
×
211
                !breach.IsSpamList &&
212
                !breach.IsFabricated &&
213
                breach.IsVerified &&
214
                breach.Domain !== ''
215
  )
216
}
217

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

238
  return await kAnonReq(path, options)
×
239
}
240

241
/**
242
 * A range subscription can be deleted with the following request:
243
 * DELETE /range/[hash prefix]
244

245
 * There is one possible response code that can be returned:
246
 * HTTP 200: Range subscription successfully deleted
247

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

258
  return await kAnonReq(path, options)
×
259
}
260

261
export {
262
  req,
263
  kAnonReq,
264
  formatDataClassesArray,
265
  loadBreachesIntoApp,
266
  getBreachesForEmail,
267
  getBreachByName,
268
  getAllBreachesFromDb,
269
  filterBreaches,
270
  subscribeHash,
271
  deleteSubscribedHash
272
}
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