├── .npmrc ├── .eslintignore ├── versions.json ├── styles.css ├── .editorconfig ├── README.md ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── package.json ├── LICENSE ├── esbuild.config.mjs └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0" 3 | } 4 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unofficial Obsidian HackerOne plugin 2 | This is an unofficial plugin to link your reports into your obsidian vault. 3 | 4 | Install it from the store, set your hackerone username and API token in the settings, then CTRL+P "fetch HackerOne Reports" 5 | You neeed the **dataview** plugin if you want the summaries to work. 6 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hackerone", 3 | "name": "HackerOne", 4 | "version": "1.0.11", 5 | "minAppVersion": "0.15.0", 6 | "description": "Unofficial plugin to fetch your bug reports from HackerOne. (needs dataview plugin)", 7 | "author": "Neolex", 8 | "authorUrl": "https://blog.neolex.dev", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | vault/ 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.x" 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | --draft \ 34 | main.js manifest.json styles.css 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-sample-plugin", 3 | "version": "1.0.2", 4 | "description": "This is a sample plugin for Obsidian (https://obsidian.md)", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "latest", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | }, 24 | "dependencies": { 25 | "fs": "^0.0.1-security", 26 | "path": "^0.12.7" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kevin Hascoët 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Plugin, 4 | Setting, 5 | PluginSettingTab, 6 | requestUrl, 7 | Notice, 8 | TFile, 9 | TFolder, 10 | normalizePath 11 | } from 'obsidian'; 12 | import { emitWarning } from 'process'; 13 | 14 | interface ReportNote { 15 | id: string; 16 | content: string; 17 | filename: string; 18 | } 19 | 20 | 21 | interface H1ObsidianPluginSettings { 22 | h1Username: string; 23 | h1Token: string; 24 | directory: string; 25 | } 26 | const DEFAULT_SETTINGS: H1ObsidianPluginSettings = { 27 | h1Username: '', 28 | h1Token: '', 29 | directory: 'Bug Bounty' 30 | }; 31 | 32 | 33 | export class H1ObsidianPluginSettingTab extends PluginSettingTab { 34 | plugin: H1ObsidianPlugin; 35 | 36 | constructor(app: App, plugin: H1ObsidianPlugin) { 37 | super(app, plugin); 38 | this.plugin = plugin; 39 | 40 | } 41 | 42 | display(): void { 43 | const { 44 | containerEl 45 | } = this; 46 | 47 | containerEl.empty(); 48 | 49 | new Setting(containerEl) 50 | .setName('HackerOne Username') 51 | .setDesc('Enter your HackerOne username') 52 | .addText((text) => 53 | text 54 | .setPlaceholder('Enter your username...') 55 | .setValue(this.plugin.settings.h1Username) 56 | .onChange(async (value) => { 57 | this.plugin.settings.h1Username = value; 58 | await this.plugin.saveSettings(); 59 | }) 60 | ); 61 | 62 | new Setting(containerEl) 63 | .setName('HackerOne Token') 64 | .setDesc('Enter your HackerOne API token') 65 | .addText((text) => 66 | text 67 | .setPlaceholder('Enter your token...') 68 | .setValue(this.plugin.settings.h1Token) 69 | .onChange(async (value) => { 70 | this.plugin.settings.h1Token = value; 71 | await this.plugin.saveSettings(); 72 | }) 73 | ); 74 | 75 | // Directory input with autocomplete for existing vault folders 76 | const allFolders = this.app.vault.getAllLoadedFiles() 77 | .filter((f) => f instanceof TFolder) as TFolder[]; 78 | const listId = 'h1-directory-datalist'; 79 | const datalist = containerEl.createEl('datalist', { attr: { id: listId } }); 80 | for (const folder of allFolders) { 81 | const option = datalist.createEl('option'); 82 | option.value = folder.path; 83 | } 84 | new Setting(containerEl) 85 | .setName('Directory') 86 | .setDesc('Type a path; autocomplete suggests existing folders') 87 | .addText((text) => { 88 | text 89 | .setPlaceholder('Bug Bounty') 90 | .setValue(this.plugin.settings.directory) 91 | .onChange(async (value) => { 92 | this.plugin.settings.directory = value; 93 | await this.plugin.saveSettings(); 94 | }); 95 | (text.inputEl as HTMLInputElement).setAttr('list', listId); 96 | }); 97 | } 98 | } 99 | const contentBugSummaryAlltime = "# Bugs \n\ 100 | ```dataview\n\ 101 | TABLE program,state,bounty,severity,url,created_at\n\ 102 | WHERE Type=\"bug-bounty-vuln\"\n\ 103 | SORT created_at DESC\n\ 104 | ```\n\ 105 | # Total \n\ 106 | ```dataview\n\ 107 | TABLE sum(rows.bounty) as TotalBounty\n\ 108 | WHERE Type=\"bug-bounty-vuln\" \n\ 109 | Where bounty > 0\n\ 110 | GROUP BY TotalBounty\n\ 111 | ```\n\ 112 | # Best Programs\n\ 113 | ```dataview\n\ 114 | TABLE sum(rows.bounty) as TotalBounty\n\ 115 | WHERE type=\"bug-bounty-vuln\"and bounty > 0\n\ 116 | GROUP BY program\n\ 117 | SORT sum(rows.bounty) DESC\n\ 118 | ``` \n\ 119 | \n\ 120 | " 121 | 122 | const contentBugSummaryCurrentYear = "# " + new Date().getFullYear() + " bug reports\n\ 123 | \n\ 124 | # Bugs\n\ 125 | ```dataview\n\ 126 | TABLE program,state,bounty,severity,url,created_at\n\ 127 | WHERE Type=\"bug-bounty-vuln\" and contains(dateformat(created_at,\"yyyy\"),\""+ new Date().getFullYear() + "\")\n\ 128 | SORT created_at DESC\n\ 129 | ```\n\ 130 | # Total \n\ 131 | ```dataview\n\ 132 | TABLE sum(rows.bounty) as TotalBounty\n\ 133 | WHERE Type=\"bug-bounty-vuln\" \n\ 134 | Where bounty > 0 and contains(dateformat(bounty_awarded_at,\"yyyy\"),\""+ new Date().getFullYear() + "\") \n\ 135 | GROUP BY TotalBounty\n\ 136 | ```\n\ 137 | # Best Programs \n\ 138 | ```dataview\n\ 139 | TABLE sum(rows.bounty) as TotalBounty\n\ 140 | WHERE type=\"bug-bounty-vuln\" and contains(dateformat(created_at,\"yyyy\"),\""+ new Date().getFullYear() + "\") and bounty > 0\n\ 141 | GROUP BY program\n\ 142 | SORT sum(rows.bounty) DESC\n\ 143 | ``` \n\ 144 | \n\ 145 | " 146 | export default class H1ObsidianPlugin extends Plugin { 147 | settings: H1ObsidianPluginSettings; 148 | 149 | 150 | oninstall() { 151 | console.log("H1ObsidianPlugin installed"); 152 | } 153 | 154 | 155 | 156 | async onload() { 157 | await this.loadSettings(); 158 | 159 | this.addSettingTab(new H1ObsidianPluginSettingTab(this.app, this)); 160 | 161 | try { 162 | await this.app.vault.createFolder(normalizePath(this.settings.directory)) 163 | }catch(error){ 164 | if (error.message !== 'Folder already exists.') { 165 | console.log(error); 166 | new Notice('Error creating folder for reports: ' + (error.code || error.message || error)); 167 | } 168 | } 169 | 170 | try { 171 | await this.app.vault.create(normalizePath(`${this.settings.directory}/bugs-summary-all-time.md`), contentBugSummaryAlltime); 172 | } catch (error) { 173 | if (error.message !== 'File already exists.') { 174 | console.log(error); 175 | new Notice('Error creating folder for reports: ' + (error.code || error.message || error)); 176 | } 177 | } 178 | try { 179 | await this.app.vault.create(normalizePath(`${this.settings.directory}/bugs-summary-current-year.md`), contentBugSummaryCurrentYear); 180 | } catch (error) { 181 | if (error.message !== 'File already exists.') { 182 | console.log(error); 183 | new Notice('Error creating summary file: ' + (error.code || error.message || error)); 184 | } 185 | } 186 | 187 | this.registerInterval( 188 | window.setInterval(() => this.fetchH1Reports(), 10*60*1000) 189 | ); 190 | 191 | this.addCommand({ 192 | id: 'fetch-h1-reports', 193 | name: 'Fetch hackerone reports', 194 | callback: () => this.fetchH1Reports(), 195 | }); 196 | 197 | } 198 | 199 | async loadSettings() { 200 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 201 | } 202 | 203 | async saveSettings() { 204 | await this.saveData(this.settings); 205 | } 206 | 207 | 208 | async fetchH1Reports() { 209 | console.log("fetch hackerone reports...") 210 | if (this.settings.h1Username == '') { 211 | new Notice("You need to fill your hackerone username in the settings of the plugin") 212 | return 213 | } 214 | if (this.settings.h1Token == '') { 215 | new Notice("You need to fill your hackerone API Token in the settings of the plugin") 216 | return 217 | } 218 | let h1Earnings = [] 219 | let h1Reports = [] 220 | new Notice("Fetching your HackerOne reports...") 221 | try { 222 | h1Reports = await this.getH1Reports(); 223 | h1Earnings = await this.getH1Earnings(); 224 | } catch (error) { 225 | new Notice('Error fetching HackerOne reports: ' + error.message); 226 | } 227 | try{ 228 | // Create a folder for the reports if it does n't exist 229 | await this.createNotes(h1Reports, h1Earnings) 230 | }catch(error){ 231 | new Notice('Error creating notes: ' + error.message); 232 | } 233 | } 234 | 235 | async createNotes(h1Reports: any[], earnings: any[]) { 236 | var reportNotes: ReportNote[] = []; 237 | const vault = this.app.vault; 238 | 239 | const folderPath = normalizePath(`${this.settings.directory}/reports`); 240 | try{ 241 | await vault.createFolder(folderPath); 242 | }catch(error){ 243 | if (error.message !== 'Folder already exists.') { 244 | console.log(error.message); 245 | new Notice('Error creating folder for reports: ' + (error.code || error.message || error)); 246 | } 247 | } 248 | 249 | let severity = "undefined" 250 | for (const item of h1Reports) { 251 | try { 252 | severity = item.relationships.severity.data.attributes.rating 253 | } catch (error) { 254 | } 255 | let program = "undefined" 256 | try { 257 | program = item.relationships.program.data.attributes.handle 258 | } catch (error) { 259 | 260 | } 261 | const specialChars = /([\'\[\]\/])/g; 262 | const title = item.attributes.title.replace(":","").replace(specialChars, '\\$1') 263 | const noteContent = '---\nType: bug-bounty-vuln\ntitle: '+ title + '\nurl: https://hackerone.com/reports/'+item.id +'\n' + await this.serializeAttributes(item.attributes) + 'bounty: ' + await this.getBountyReport(item.id, earnings) + '\nseverity: ' + severity + '\nprogram: ' + program + '\n---\n' + item.attributes.vulnerability_information.replace("<%", "<"); 264 | 265 | 266 | let fileName = `${folderPath}/${item.attributes.title.replace(/[^a-z0-9_ -]/gi, '_')}-${item.id}.md` 267 | const newReportNote: ReportNote = { 268 | id: item.id, 269 | content: noteContent, 270 | filename: fileName, 271 | }; 272 | reportNotes.push(newReportNote); 273 | 274 | } 275 | await this.overwriteFiles(reportNotes); 276 | 277 | } 278 | 279 | async overwriteFiles(reportNotes: Array) { 280 | try { 281 | const folderPath = normalizePath(`${this.settings.directory}/reports`); 282 | let existingReportFiles = this.app.vault.getMarkdownFiles() 283 | existingReportFiles = existingReportFiles.filter(file => file.path.startsWith(folderPath)); 284 | for (const reportNote of reportNotes) { 285 | const foundExistingReport = existingReportFiles.find((reportFile: TFile) => reportFile.basename.split("-").pop() === `${reportNote.id}`); 286 | if(foundExistingReport){ 287 | let currentContent = await this.app.vault.cachedRead(foundExistingReport); 288 | if(currentContent!=reportNote["content"]){ 289 | await this.app.vault.modify(foundExistingReport, reportNote["content"]); 290 | } 291 | 292 | }else{ 293 | console.log("report "+reportNote["id"]+" not found create "+reportNote["filename"]) 294 | await this.app.vault.create(reportNote["filename"],reportNote["content"]) 295 | } 296 | } 297 | } catch (err) { 298 | new Notice('Error: Unable to overwrite the file:'+err); 299 | console.log('Error overwriting file:', err); 300 | } 301 | } 302 | 303 | async getBountyReport(reportId: number, earnings: any[]) { 304 | let ret = 0; 305 | for (const earning of earnings) { 306 | if (earning.type === 'earning-bounty-earned') { 307 | if ( 308 | earning.relationships.bounty.data.relationships.report.data.id === 309 | reportId 310 | ) { 311 | ret += parseInt(earning.attributes.amount); 312 | 313 | if (earning.attributes.bonus_amount !== undefined) { 314 | ret += parseInt(earning.attributes.bonus_amount); 315 | } 316 | } 317 | } else if (earning.type === 'earning-retest-completed') { 318 | if ( 319 | earning.relationships.report_retest_user.data.relationships.report_retest.data.relationships.report.data.id === 320 | reportId 321 | ) { 322 | ret += 50; 323 | } 324 | } else { 325 | new Notice(earning.type); 326 | } 327 | } 328 | return ret; 329 | } 330 | 331 | 332 | 333 | async serializeAttributes(attributes: any[]) { 334 | let yamlString = ''; 335 | for (const key in attributes) { 336 | if (key != "vulnerability_information" && key != "title") { 337 | let content = attributes[key] 338 | yamlString += `${key}: ${content}\n`; 339 | } 340 | } 341 | 342 | return yamlString; 343 | } 344 | 345 | async getH1Reports(): Promise { 346 | console.log("fetch reports...") 347 | // fetch reports from the HackerOne API 348 | const authString = btoa(`${this.settings.h1Username}:${this.settings.h1Token}`); 349 | 350 | let page = 0; 351 | let h1ReportsRet: any[] = []; 352 | 353 | while (true) { 354 | page += 1; 355 | const response = await requestUrl({ 356 | url: `https://api.hackerone.com/v1/hackers/me/reports?page[size]=100&page[number]=${page}`, 357 | method: "GET", 358 | headers: { 359 | 360 | Authorization: `Basic ${authString}`, 361 | Accept: 'application/json', 362 | } 363 | }); 364 | if (response.status != 200) { 365 | new Notice("Error fetching hackerone api"); 366 | 367 | } 368 | if (response.json.data.length == 0) { 369 | return h1ReportsRet 370 | } 371 | 372 | h1ReportsRet = h1ReportsRet.concat(response.json.data) 373 | } 374 | } 375 | 376 | async getH1Earnings(): Promise { 377 | // fetch reports from the HackerOne API 378 | const authString = btoa(`${this.settings.h1Username}:${this.settings.h1Token}`); 379 | 380 | let page = 0; 381 | let earnings: any[] = []; 382 | 383 | while (true) { 384 | page += 1; 385 | const response = await requestUrl({ 386 | url: `https://api.hackerone.com/v1/hackers/payments/earnings?page%5Bsize%5D=100&page%5Bnumber%5D=${page}`, 387 | method: "GET", 388 | headers: { 389 | 390 | Authorization: `Basic ${authString}`, 391 | Accept: 'application/json', 392 | } 393 | }); 394 | if (response.status != 200) { 395 | new Notice("Error fetching hackerone api"); 396 | 397 | } 398 | if (response.json.data.length == 0) { 399 | return earnings 400 | } 401 | earnings = earnings.concat(response.json.data) 402 | } 403 | return earnings 404 | } 405 | } 406 | --------------------------------------------------------------------------------