Skip to content

Commit 61729c5

Browse files
committed
fix(W-18094367): remove support for .netrc files
1 parent 11e3c36 commit 61729c5

12 files changed

+1018
-547
lines changed

.vscode/settings.json

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"cSpell.words": [
3+
"herokurc",
4+
"yubikey"
5+
]
6+
}

package.json

+6-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
"debug": "^4.4.0",
1313
"fs-extra": "^9.1.0",
1414
"heroku-client": "^3.1.0",
15-
"netrc-parser": "^3.1.6",
1615
"open": "^8.4.2",
1716
"uuid": "^8.3.0",
1817
"yargs-parser": "^18.1.3",
@@ -22,6 +21,7 @@
2221
"@heroku-cli/schema": "^1.0.25",
2322
"@types/ansi-styles": "^3.2.1",
2423
"@types/chai": "^4.3.16",
24+
"@types/debug": "^4.1.12",
2525
"@types/fs-extra": "^9.0.13",
2626
"@types/mocha": "^10.0.6",
2727
"@types/node": "20.14.8",
@@ -55,7 +55,8 @@
5555
"node": ">= 20"
5656
},
5757
"files": [
58-
"lib"
58+
"lib",
59+
"scripts/cleanup-netrc.cjs"
5960
],
6061
"homepage": "https://github.com/heroku/heroku-cli-command",
6162
"keywords": [
@@ -77,10 +78,12 @@
7778
},
7879
"scripts": {
7980
"build": "rm -rf lib && tsc",
81+
"build:dev": "rm -rf lib && tsc --sourceMap",
8082
"lint": "tsc -p test --noEmit && eslint . --ext .ts",
8183
"posttest": "yarn run lint",
8284
"prepublishOnly": "yarn run build",
83-
"test": "mocha --forbid-only \"test/**/*.test.ts\""
85+
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
86+
"postinstall": "node scripts/cleanup-netrc.cjs"
8487
},
8588
"types": "./lib/index.d.ts"
8689
}

scripts/cleanup-netrc.cjs

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
const fs = require('fs')
2+
const os = require('os')
3+
const path = require('path')
4+
/**
5+
* List of Heroku machines to remove from the .netrc file
6+
* as they are no longer used and pose a security risk.
7+
*
8+
* Add any additional Heroku machines to this list that you
9+
* want to remove from the .netrc file.
10+
*/
11+
const machinesToRemove = ['api.heroku.com', 'git.heroku.com', 'api.staging.herokudev.com']
12+
/**
13+
* Removes the unencrypted Heroku entries from the .netrc file
14+
* This is a mitigation for a critical security vulnerability
15+
* where unencrypted Heroku API tokens could be leaked
16+
* if the .netrc file is compromised. This function removes
17+
* any entries related to Heroku from the .netrc file as Heroku
18+
* has discontinued it's use.
19+
*
20+
* BE ADVISED: a defect exists in the original implementation
21+
* where orphaned credentials (passwords without machine blocks)
22+
* are created when attempting to delete machine entries using the
23+
* netrc-parser library.
24+
*
25+
* This implementation corrects that issue by removing orphaned
26+
* credentials as well.
27+
*
28+
* @returns {void}
29+
*/
30+
function removeUnencryptedNetrcMachines() {
31+
try {
32+
const netrcPath = getNetrcFileLocation()
33+
34+
if (!fs.existsSync(netrcPath)) {
35+
console.log('.netrc file not found, nothing to clean up')
36+
return
37+
}
38+
39+
const content = fs.readFileSync(netrcPath, 'utf8')
40+
const lines = content.split('\n')
41+
const filteredLines = []
42+
let skipLines = false
43+
44+
// Iterate through lines, handling machine blocks and orphaned credentials
45+
for (const line of lines) {
46+
const trimmedLine = line.trim().toLowerCase()
47+
48+
// Check if we're starting a Heroku machine block
49+
if (trimmedLine.startsWith('machine') &&
50+
(machinesToRemove.some(machine => trimmedLine.includes(machine)))) {
51+
skipLines = true
52+
continue
53+
}
54+
55+
// Check if we're starting a new machine block (non-Heroku)
56+
if (trimmedLine.startsWith('machine') && !skipLines) {
57+
skipLines = false
58+
}
59+
60+
// Check for orphaned Heroku passwords (HKRU-) and their associated usernames
61+
if (/(HRKUSTG-|HKRU-)/.test(line)) {
62+
// Remove the previous line if it exists (username)
63+
if (filteredLines.length > 0) {
64+
filteredLines.pop()
65+
}
66+
67+
continue
68+
}
69+
70+
// Only keep lines if we're not in a Heroku block
71+
if (!skipLines) {
72+
filteredLines.push(line)
73+
}
74+
}
75+
76+
// Remove any trailing empty lines
77+
while (filteredLines.length > 0 && !filteredLines[filteredLines.length - 1].trim()) {
78+
filteredLines.pop()
79+
}
80+
81+
// Add a newline at the end if we have content
82+
const outputContent = filteredLines.length > 0 ?
83+
filteredLines.join('\n') + '\n' :
84+
''
85+
86+
fs.writeFileSync(netrcPath, outputContent)
87+
} catch (error) {
88+
throw new Error(`Error cleaning up .netrc: ${error.message}`)
89+
}
90+
}
91+
92+
/**
93+
* Finds the absolute path to the .netrc file
94+
* on disk based on the operating system. This
95+
* code was copied directly from `netrc-parser`
96+
* and optimized for use here.
97+
*
98+
* @see [netrc-parser](https://github.com/jdx/node-netrc-parser/blob/master/src/netrc.ts#L177)
99+
*
100+
* @returns {string} the file path of the .netrc on disk.
101+
*/
102+
function getNetrcFileLocation() {
103+
let home = ''
104+
if (os.platform() === 'win32') {
105+
home =
106+
process.env.HOME ??
107+
(process.env.HOMEDRIVE && process.env.HOMEPATH && path.join(process.env.HOMEDRIVE, process.env.HOMEPATH)) ??
108+
process.env.USERPROFILE
109+
}
110+
111+
if (!home) {
112+
home = os.homedir() ?? os.tmpdir()
113+
}
114+
115+
return path.join(home, os.platform() === 'win32' ? '_netrc' : '.netrc')
116+
}
117+
118+
removeUnencryptedNetrcMachines()

src/api-client.ts

+16-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {Interfaces} from '@oclif/core'
22
import {CLIError, warn} from '@oclif/core/lib/errors'
33
import {HTTP, HTTPError, HTTPRequestOptions} from '@heroku/http-call'
4-
import Netrc from 'netrc-parser'
54
import * as url from 'url'
65

76
import deps from './deps'
@@ -11,7 +10,11 @@ import {RequestId, requestIdHeader} from './request-id'
1110
import {vars} from './vars'
1211
import {ParticleboardClient, IDelinquencyInfo, IDelinquencyConfig} from './particleboard-client'
1312

14-
const debug = require('debug')
13+
import debug from 'debug'
14+
import {removeToken, retrieveToken} from './token-storage'
15+
16+
// intentional side effect
17+
import '../scripts/cleanup-netrc.cjs'
1518

1619
export namespace APIClient {
1720
export interface Options extends HTTPRequestOptions {
@@ -192,7 +195,7 @@ export class APIClient {
192195
}
193196

194197
if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
195-
opts.headers.authorization = `Bearer ${self.auth}`
198+
opts.headers.authorization = `Bearer ${await self.auth}`
196199
}
197200

198201
this.configDelinquency(url, opts)
@@ -204,7 +207,7 @@ export class APIClient {
204207
const particleboardClient: ParticleboardClient = self.particleboard
205208

206209
if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
207-
self._particleboard.auth = self.auth
210+
self._particleboard.auth = await self.auth
208211
const settledResponses = await Promise.allSettled([
209212
super.request<T>(url, opts),
210213
particleboardClient.get<IDelinquencyInfo>(delinquencyConfig.fetch_url as string),
@@ -240,7 +243,7 @@ export class APIClient {
240243

241244
if (!self.authPromise) self.authPromise = self.login()
242245
await self.authPromise
243-
opts.headers.authorization = `Bearer ${self.auth}`
246+
opts.headers.authorization = `Bearer ${await self.auth}`
244247
return this.request<T>(url, opts, retries)
245248
}
246249

@@ -273,9 +276,13 @@ export class APIClient {
273276
if (!this._auth) {
274277
if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY) deps.cli.warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY')
275278
this._auth = process.env.HEROKU_API_KEY
276-
if (!this._auth) {
277-
deps.netrc.loadSync()
278-
this._auth = deps.netrc.machines[vars.apiHost] && deps.netrc.machines[vars.apiHost].password
279+
}
280+
281+
if (!this._auth) {
282+
try {
283+
this._auth = retrieveToken()
284+
} catch {
285+
// noop
279286
}
280287
}
281288

@@ -346,9 +353,7 @@ export class APIClient {
346353
if (error instanceof CLIError) warn(error)
347354
}
348355

349-
delete Netrc.machines['api.heroku.com']
350-
delete Netrc.machines['git.heroku.com']
351-
await Netrc.save()
356+
removeToken()
352357
}
353358

354359
get defaults(): typeof HTTP.defaults {

src/deps.ts

+14-45
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
// remote
1+
// This file isn't necessary and should be removed.
2+
// I reorganized the code to make it easier to understand
3+
// but it is entirely unnecessary to have a file like this.
4+
// I'm leaving it here for now, but it should be removed
5+
// in the future.
26
import oclif = require('@oclif/core')
37
import HTTP = require('@heroku/http-call')
4-
import netrc = require('netrc-parser')
58

69
import apiClient = require('./api-client')
710
import particleboardClient = require('./particleboard-client')
@@ -14,49 +17,15 @@ import yubikey = require('./yubikey')
1417
const {ux} = oclif
1518

1619
export const deps = {
17-
// remote
18-
get cli(): typeof ux {
19-
return fetch('@oclif/core').ux
20-
},
21-
get HTTP(): typeof HTTP {
22-
return fetch('@heroku/http-call')
23-
},
24-
get netrc(): typeof netrc.default {
25-
return fetch('netrc-parser').default
26-
},
27-
28-
// local
29-
get Mutex(): typeof mutex.Mutex {
30-
return fetch('./mutex').Mutex
31-
},
32-
get yubikey(): typeof yubikey.yubikey {
33-
return fetch('./yubikey').yubikey
34-
},
35-
get APIClient(): typeof apiClient.APIClient {
36-
return fetch('./api-client').APIClient
37-
},
38-
get ParticleboardClient(): typeof particleboardClient.ParticleboardClient {
39-
return fetch('./particleboard-client').ParticleboardClient
40-
},
41-
get file(): typeof file {
42-
return fetch('./file')
43-
},
44-
get flags(): typeof flags {
45-
return fetch('./flags')
46-
},
47-
get Git(): typeof git.Git {
48-
return fetch('./git').Git
49-
},
50-
}
51-
52-
const cache: any = {}
53-
54-
function fetch(s: string) {
55-
if (!cache[s]) {
56-
cache[s] = require(s)
57-
}
58-
59-
return cache[s]
20+
cli: ux,
21+
HTTP,
22+
Mutex: mutex.Mutex,
23+
yubikey: yubikey.yubikey,
24+
APIClient: apiClient.APIClient,
25+
ParticleboardClient: particleboardClient.ParticleboardClient,
26+
file,
27+
flags,
28+
Git: git.Git,
6029
}
6130

6231
export default deps

0 commit comments

Comments
 (0)