Skip to content

fix(W-18094367): remove support for .netrc files #170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"herokurc",
"yubikey"
]
}
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"debug": "^4.4.0",
"fs-extra": "^9.1.0",
"heroku-client": "^3.1.0",
"netrc-parser": "^3.1.6",
"open": "^8.4.2",
"uuid": "^8.3.0",
"yargs-parser": "^18.1.3",
Expand All @@ -22,6 +21,7 @@
"@heroku-cli/schema": "^1.0.25",
"@types/ansi-styles": "^3.2.1",
"@types/chai": "^4.3.16",
"@types/debug": "^4.1.12",
"@types/fs-extra": "^9.0.13",
"@types/mocha": "^10.0.6",
"@types/node": "20.14.8",
Expand Down Expand Up @@ -55,7 +55,8 @@
"node": ">= 20"
},
"files": [
"lib"
"lib",
"scripts/cleanup-netrc.cjs"
],
"homepage": "https://github.com/heroku/heroku-cli-command",
"keywords": [
Expand All @@ -77,10 +78,12 @@
},
"scripts": {
"build": "rm -rf lib && tsc",
"build:dev": "rm -rf lib && tsc --sourceMap",
"lint": "tsc -p test --noEmit && eslint . --ext .ts",
"posttest": "yarn run lint",
"prepublishOnly": "yarn run build",
"test": "mocha --forbid-only \"test/**/*.test.ts\""
"test": "mocha --forbid-only \"test/**/*.test.ts\"",
"postinstall": "node scripts/cleanup-netrc.cjs"
},
"types": "./lib/index.d.ts"
}
118 changes: 118 additions & 0 deletions scripts/cleanup-netrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const fs = require('fs')
const os = require('os')
const path = require('path')
/**
* List of Heroku machines to remove from the .netrc file
* as they are no longer used and pose a security risk.
*
* Add any additional Heroku machines to this list that you
* want to remove from the .netrc file.
*/
const machinesToRemove = ['api.heroku.com', 'git.heroku.com', 'api.staging.herokudev.com']
/**
* Removes the unencrypted Heroku entries from the .netrc file
* This is a mitigation for a critical security vulnerability
* where unencrypted Heroku API tokens could be leaked
* if the .netrc file is compromised. This function removes
* any entries related to Heroku from the .netrc file as Heroku
* has discontinued it's use.
*
* BE ADVISED: a defect exists in the original implementation
* where orphaned credentials (passwords without machine blocks)
* are created when attempting to delete machine entries using the
* netrc-parser library.
*
* This implementation corrects that issue by removing orphaned
* credentials as well.
*
* @returns {void}
*/
function removeUnencryptedNetrcMachines() {
try {
const netrcPath = getNetrcFileLocation()

if (!fs.existsSync(netrcPath)) {
console.log('.netrc file not found, nothing to clean up')
return
}

const content = fs.readFileSync(netrcPath, 'utf8')
const lines = content.split('\n')
const filteredLines = []
let skipLines = false

// Iterate through lines, handling machine blocks and orphaned credentials
for (const line of lines) {
const trimmedLine = line.trim().toLowerCase()

// Check if we're starting a Heroku machine block
if (trimmedLine.startsWith('machine') &&
(machinesToRemove.some(machine => trimmedLine.includes(machine)))) {
skipLines = true
continue
}

// Check if we're starting a new machine block (non-Heroku)
if (trimmedLine.startsWith('machine') && !skipLines) {
skipLines = false
}

// Check for orphaned Heroku passwords (HKRU-) and their associated usernames
if (/(HRKUSTG-|HKRU-)/.test(line)) {
// Remove the previous line if it exists (username)
if (filteredLines.length > 0) {
filteredLines.pop()
}

continue
}

// Only keep lines if we're not in a Heroku block
if (!skipLines) {
filteredLines.push(line)
}
}

// Remove any trailing empty lines
while (filteredLines.length > 0 && !filteredLines[filteredLines.length - 1].trim()) {
filteredLines.pop()
}

// Add a newline at the end if we have content
const outputContent = filteredLines.length > 0 ?
filteredLines.join('\n') + '\n' :
''

fs.writeFileSync(netrcPath, outputContent)
} catch (error) {
throw new Error(`Error cleaning up .netrc: ${error.message}`)
}
}

/**
* Finds the absolute path to the .netrc file
* on disk based on the operating system. This
* code was copied directly from `netrc-parser`
* and optimized for use here.
*
* @see [netrc-parser](https://github.com/jdx/node-netrc-parser/blob/master/src/netrc.ts#L177)
*
* @returns {string} the file path of the .netrc on disk.
*/
function getNetrcFileLocation() {
let home = ''
if (os.platform() === 'win32') {
home =
process.env.HOME ??
(process.env.HOMEDRIVE && process.env.HOMEPATH && path.join(process.env.HOMEDRIVE, process.env.HOMEPATH)) ??
process.env.USERPROFILE
}

if (!home) {
home = os.homedir() ?? os.tmpdir()
}

return path.join(home, os.platform() === 'win32' ? '_netrc' : '.netrc')
}

removeUnencryptedNetrcMachines()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's fine to include the code for deleting the old cleartext Netrc entries, but I do not agree that it should be automatically run on the initial release of the new storage mechanism. Let's put the activation/calls to removeUnencryptedNetrcMachines() in a subsequent PR that can be released in a patch version after the major release.

If initial release of the new storage mechanism breaks something for a customer, they need to be able to CLI update back to the previous CLI version to restore functionality.

27 changes: 16 additions & 11 deletions src/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {Interfaces} from '@oclif/core'
import {CLIError, warn} from '@oclif/core/lib/errors'
import {HTTP, HTTPError, HTTPRequestOptions} from '@heroku/http-call'
import Netrc from 'netrc-parser'
import * as url from 'url'

import deps from './deps'
Expand All @@ -11,7 +10,11 @@
import {vars} from './vars'
import {ParticleboardClient, IDelinquencyInfo, IDelinquencyConfig} from './particleboard-client'

const debug = require('debug')
import debug from 'debug'
import {removeToken, retrieveToken} from './token-storage'

// intentional side effect
import '../scripts/cleanup-netrc.cjs'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as prior, that activation/calls to the clean-up code should be in a separate subsequent CLI release, after the major release implementing the new storage mechanism.


export namespace APIClient {
export interface Options extends HTTPRequestOptions {
Expand Down Expand Up @@ -70,7 +73,7 @@
if (options.debug) debug.enable('http')
if (options.debug && options.debugHeaders) debug.enable('http,http:headers')
this.options = options
const apiUrl = url.URL ? new url.URL(vars.apiUrl) : url.parse(vars.apiUrl)

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 22.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 20.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 22.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (macos-latest, 20.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 20.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead

Check warning on line 76 in src/api-client.ts

View workflow job for this annotation

GitHub Actions / test (windows-latest, 22.x)

'url.parse' was deprecated since v11.0.0. Use 'url.URL' constructor instead
const envHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}')
this.preauthPromises = {}
const self = this as any
Expand Down Expand Up @@ -192,7 +195,7 @@
}

if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
opts.headers.authorization = `Bearer ${self.auth}`
opts.headers.authorization = `Bearer ${await self.auth}`
}

this.configDelinquency(url, opts)
Expand All @@ -204,7 +207,7 @@
const particleboardClient: ParticleboardClient = self.particleboard

if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
self._particleboard.auth = self.auth
self._particleboard.auth = await self.auth
const settledResponses = await Promise.allSettled([
super.request<T>(url, opts),
particleboardClient.get<IDelinquencyInfo>(delinquencyConfig.fetch_url as string),
Expand Down Expand Up @@ -240,7 +243,7 @@

if (!self.authPromise) self.authPromise = self.login()
await self.authPromise
opts.headers.authorization = `Bearer ${self.auth}`
opts.headers.authorization = `Bearer ${await self.auth}`
return this.request<T>(url, opts, retries)
}

Expand Down Expand Up @@ -273,9 +276,13 @@
if (!this._auth) {
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')
this._auth = process.env.HEROKU_API_KEY
if (!this._auth) {
deps.netrc.loadSync()
this._auth = deps.netrc.machines[vars.apiHost] && deps.netrc.machines[vars.apiHost].password
}

if (!this._auth) {
try {
this._auth = retrieveToken()
} catch {
// noop
}
}

Expand Down Expand Up @@ -346,9 +353,7 @@
if (error instanceof CLIError) warn(error)
}

delete Netrc.machines['api.heroku.com']
delete Netrc.machines['git.heroku.com']
await Netrc.save()
removeToken()
}

get defaults(): typeof HTTP.defaults {
Expand Down
59 changes: 14 additions & 45 deletions src/deps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// remote
// This file isn't necessary and should be removed.
// I reorganized the code to make it easier to understand
// but it is entirely unnecessary to have a file like this.
// I'm leaving it here for now, but it should be removed
// in the future.
import oclif = require('@oclif/core')
import HTTP = require('@heroku/http-call')
import netrc = require('netrc-parser')

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

export const deps = {
// remote
get cli(): typeof ux {
return fetch('@oclif/core').ux
},
get HTTP(): typeof HTTP {
return fetch('@heroku/http-call')
},
get netrc(): typeof netrc.default {
return fetch('netrc-parser').default
},

// local
get Mutex(): typeof mutex.Mutex {
return fetch('./mutex').Mutex
},
get yubikey(): typeof yubikey.yubikey {
return fetch('./yubikey').yubikey
},
get APIClient(): typeof apiClient.APIClient {
return fetch('./api-client').APIClient
},
get ParticleboardClient(): typeof particleboardClient.ParticleboardClient {
return fetch('./particleboard-client').ParticleboardClient
},
get file(): typeof file {
return fetch('./file')
},
get flags(): typeof flags {
return fetch('./flags')
},
get Git(): typeof git.Git {
return fetch('./git').Git
},
}

const cache: any = {}

function fetch(s: string) {
if (!cache[s]) {
cache[s] = require(s)
}

return cache[s]
cli: ux,
HTTP,
Mutex: mutex.Mutex,
yubikey: yubikey.yubikey,
APIClient: apiClient.APIClient,
ParticleboardClient: particleboardClient.ParticleboardClient,
file,
flags,
Git: git.Git,
}

export default deps
Loading
Loading