Skip to content

Commit 87831ea

Browse files
test(ec2): ensure that private key is not written to telemetry (#5779)
## Problem We want to enforce that the private key generated by the toolkit is not accidentally slipped into a telemetry metric. ## Solution Similar to `assertTelemetry`, we implement a `assertNoTelemetryMatch` test utility that scans all metrics to see if a specified keyword is included in its keys or values. With this utility, it is relatively straightforward to assert that this private key doesn't appear in the telemetry when we generate it and perform other operations with it. --- <!--- REMINDER: Ensure that your PR meets the guidelines in CONTRIBUTING.md --> License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Justin M. Keyes <[email protected]>
1 parent ed323d7 commit 87831ea

File tree

5 files changed

+62
-14
lines changed

5 files changed

+62
-14
lines changed

packages/core/src/awsService/ec2/sshKeyPair.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import { fs } from '../../shared'
5+
import os from 'os'
6+
import { fs, globals } from '../../shared'
67
import { ToolkitError } from '../../shared/errors'
78
import { tryRun } from '../../shared/utilities/pathFind'
89
import { Timeout } from '../../shared/utilities/timeoutUtils'
@@ -32,12 +33,24 @@ export class SshKeyPair {
3233
return new SshKeyPair(keyPath, lifetime)
3334
}
3435

35-
public static async generateSshKeyPair(keyPath: string): Promise<void> {
36-
const keyGenerated = await this.tryKeyTypes(keyPath, ['ed25519', 'rsa'])
36+
private static async assertGenerated(keyPath: string, keyGenerated: boolean): Promise<never | void> {
3737
if (!keyGenerated) {
38-
throw new ToolkitError('ec2: Unable to generate ssh key pair')
38+
throw new ToolkitError('ec2: Unable to generate ssh key pair with either ed25519 or rsa')
39+
}
40+
41+
if (!(await fs.exists(keyPath))) {
42+
throw new ToolkitError(`ec2: Failed to generate keys, resulting key not found at ${keyPath}`)
43+
}
44+
}
45+
46+
public static async generateSshKeyPair(keyPath: string): Promise<void> {
47+
const keyGenerated = await SshKeyPair.tryKeyTypes(keyPath, ['ed25519', 'rsa'])
48+
await SshKeyPair.assertGenerated(keyPath, keyGenerated)
49+
// Should already be the case, but just in case we assert permissions.
50+
// skip on Windows since it only allows write permission to be changed.
51+
if (!globals.isWeb && os.platform() !== 'win32') {
52+
await fs.chmod(keyPath, 0o600)
3953
}
40-
await fs.chmod(keyPath, 0o600)
4154
}
4255
/**
4356
* Attempts to generate an ssh key pair. Returns true if successful, false otherwise.
@@ -72,8 +85,8 @@ export class SshKeyPair {
7285
}
7386

7487
public async delete(): Promise<void> {
75-
await fs.delete(this.keyPath)
76-
await fs.delete(this.publicKeyPath)
88+
await fs.delete(this.keyPath, { force: true })
89+
await fs.delete(this.publicKeyPath, { force: true })
7790

7891
if (!this.lifeTimeout.completed) {
7992
this.lifeTimeout.cancel()

packages/core/src/shared/telemetry/telemetryLogger.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,11 @@ export class TelemetryLogger {
118118
public queryFull(query: MetricQuery): MetricDatum[] {
119119
return this._metrics.filter((m) => m.MetricName === query.metricName)
120120
}
121+
122+
/**
123+
* Queries telemetry for metrics with metadata key or value matching the given regex.
124+
*/
125+
public queryRegex(re: RegExp | string): MetricDatum[] {
126+
return this._metrics.filter((m) => m.Metadata?.some((md) => md.Value?.match(re) || md.Key?.match(re)))
127+
}
121128
}

packages/core/src/test/awsService/ec2/model.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import { ToolkitError } from '../../../shared/errors'
1313
import { IAM } from 'aws-sdk'
1414
import { SshKeyPair } from '../../../awsService/ec2/sshKeyPair'
1515
import { DefaultIamClient } from '../../../shared/clients/iamClient'
16+
import { assertNoTelemetryMatch, createTestWorkspaceFolder } from '../../testUtil'
17+
import { fs } from '../../../shared'
18+
import path from 'path'
1619

1720
describe('Ec2ConnectClient', function () {
1821
let client: Ec2ConnectionManager
@@ -144,6 +147,25 @@ describe('Ec2ConnectClient', function () {
144147
sinon.assert.calledWith(sendCommandStub, testSelection.instanceId, 'AWS-RunShellScript')
145148
sinon.restore()
146149
})
150+
151+
it('avoids writing the keys to any telemetry metrics', async function () {
152+
sinon.stub(SsmClient.prototype, 'sendCommandAndWait')
153+
154+
const testSelection = {
155+
instanceId: 'test-id',
156+
region: 'test-region',
157+
}
158+
const testWorkspaceFolder = await createTestWorkspaceFolder()
159+
const keyPath = path.join(testWorkspaceFolder.uri.fsPath, 'key')
160+
const keys = await SshKeyPair.getSshKeyPair(keyPath, 60000)
161+
await client.sendSshKeyToInstance(testSelection, keys, 'test-user')
162+
const privKey = await fs.readFileText(keyPath)
163+
assertNoTelemetryMatch(privKey)
164+
sinon.restore()
165+
166+
await keys.delete()
167+
await fs.delete(testWorkspaceFolder.uri, { force: true })
168+
})
147169
})
148170

149171
describe('getRemoteUser', async function () {

packages/core/src/test/awsService/ec2/sshKeyPair.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import * as vscode from 'vscode'
65
import assert from 'assert'
76
import nodefs from 'fs' // eslint-disable-line no-restricted-imports
87
import * as sinon from 'sinon'
@@ -14,7 +13,7 @@ import { InstalledClock } from '@sinonjs/fake-timers'
1413
import { ChildProcess } from '../../../shared/utilities/processUtils'
1514
import { fs, globals } from '../../../shared'
1615

17-
describe('SshKeyUtility', async function () {
16+
describe('SshKeyPair', async function () {
1817
let temporaryDirectory: string
1918
let keyPath: string
2019
let keyPair: SshKeyPair
@@ -41,14 +40,14 @@ describe('SshKeyUtility', async function () {
4140
})
4241

4342
it('generates key in target file', async function () {
44-
const contents = await fs.readFileBytes(vscode.Uri.file(keyPath))
43+
const contents = await fs.readFileBytes(keyPath)
4544
assert.notStrictEqual(contents.length, 0)
4645
})
4746

4847
it('generates unique key each time', async function () {
49-
const beforeContent = await fs.readFileBytes(vscode.Uri.file(keyPath))
48+
const beforeContent = await fs.readFileBytes(keyPath)
5049
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
51-
const afterContent = await fs.readFileBytes(vscode.Uri.file(keyPath))
50+
const afterContent = await fs.readFileBytes(keyPath)
5251
assert.notStrictEqual(beforeContent, afterContent)
5352
})
5453

@@ -90,10 +89,10 @@ describe('SshKeyUtility', async function () {
9089

9190
it('does overwrite existing keys on get call', async function () {
9291
const generateStub = sinon.spy(SshKeyPair, 'generateSshKeyPair')
93-
const keyBefore = await fs.readFileBytes(vscode.Uri.file(keyPath))
92+
const keyBefore = await fs.readFileBytes(keyPath)
9493
keyPair = await SshKeyPair.getSshKeyPair(keyPath, 30000)
9594

96-
const keyAfter = await fs.readFileBytes(vscode.Uri.file(keyPath))
95+
const keyAfter = await fs.readFileBytes(keyPath)
9796
sinon.assert.calledOnce(generateStub)
9897

9998
assert.notStrictEqual(keyBefore, keyAfter)

packages/core/src/test/testUtil.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,13 @@ export function partialDeepCompare<T>(actual: unknown, expected: T, message?: st
295295
const partial = selectFrom(actual, ...keys(expected as object))
296296
assert.deepStrictEqual(partial, expected, message)
297297
}
298+
/**
299+
* Asserts that no metrics metadata (key OR value) matches the given regex.
300+
* @param keyword target substring to search for
301+
*/
302+
export function assertNoTelemetryMatch(re: RegExp | string): void | never {
303+
return assert.ok(globals.telemetry.logger.queryRegex(re).length === 0)
304+
}
298305

299306
/**
300307
* Finds the emitted telemetry metrics with the given `name`, then checks if the metadata fields

0 commit comments

Comments
 (0)