From 4ef4b22e1686cd9a72d40a4e3f97500fb70a5ccf Mon Sep 17 00:00:00 2001 From: idebenone Date: Fri, 4 Jul 2025 15:08:26 +0530 Subject: [PATCH 1/4] feat: mask emails and hide sensitive user data --- .github/workflows/release.yml | 119 ----------------------- .npmignore | 2 +- CHANGELOG.md | 23 ++++- package.json | 2 +- src/commands/add.ts | 2 +- src/commands/auth.ts | 8 +- src/commands/clone.ts | 41 ++++++-- src/commands/init.ts | 35 ++++++- src/commands/list.ts | 4 +- src/commands/status.ts | 3 +- src/commands/use.ts | 4 +- src/types/index.ts | 2 + src/utils/cli.ts | 54 ++++++++++ src/utils/ssh.ts | 113 +++++++++++++++++++-- test/unit/utils/file-permissions.test.ts | 105 ++++++++++++++++++++ test/unit/utils/privacy.test.ts | 33 +++++++ test/unit/utils/ssh-fingerprint.test.ts | 71 ++++++++++++++ 17 files changed, 473 insertions(+), 148 deletions(-) delete mode 100644 .github/workflows/release.yml create mode 100644 test/unit/utils/file-permissions.test.ts create mode 100644 test/unit/utils/privacy.test.ts create mode 100644 test/unit/utils/ssh-fingerprint.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f6e18da..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Create Release PR - -on: - workflow_dispatch: - inputs: - version: - description: 'Version type (patch, minor, major)' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - pull-requests: write - -jobs: - create-release-pr: - name: Create Release PR - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - - name: Create release branch - run: | - git checkout -b release/v${{ github.run_number }} - - - name: Bump version - id: version - run: | - # Get current version - CURRENT_VERSION=$(node -p "require('./package.json').version") - echo "Current version: $CURRENT_VERSION" - - # Bump version - npm version ${{ github.event.inputs.version }} --no-git-tag-version - - # Get new version - NEW_VERSION=$(node -p "require('./package.json').version") - echo "New version: $NEW_VERSION" - echo "new-version=$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Update CHANGELOG - run: | - # Create or update CHANGELOG.md - if [ ! -f CHANGELOG.md ]; then - echo "# Changelog" > CHANGELOG.md - echo "" >> CHANGELOG.md - fi - - # Add new version entry at the top - TEMP_FILE=$(mktemp) - echo "# Changelog" > $TEMP_FILE - echo "" >> $TEMP_FILE - echo "## v${{ steps.version.outputs.new-version }} - $(date +%Y-%m-%d)" >> $TEMP_FILE - echo "" >> $TEMP_FILE - echo "### Changed" >> $TEMP_FILE - echo "- Version bump from workflow" >> $TEMP_FILE - echo "" >> $TEMP_FILE - tail -n +2 CHANGELOG.md >> $TEMP_FILE - mv $TEMP_FILE CHANGELOG.md - - - name: Commit changes - run: | - git add package.json package-lock.json CHANGELOG.md - git commit -m "chore: bump version to v${{ steps.version.outputs.new-version }}" - - - name: Push branch - run: | - git push -u origin release/v${{ github.run_number }} - - - name: Create Pull Request - uses: actions/github-script@v7 - with: - script: | - const { data: pr } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `Release v${{ steps.version.outputs.new-version }}`, - body: `## 🚀 Release v${{ steps.version.outputs.new-version }} - - This PR was automatically created to release version v${{ steps.version.outputs.new-version }}. - - ### Checklist - - [ ] Update CHANGELOG.md with actual changes - - [ ] Review version bump is correct - - [ ] All tests are passing - - ### What happens when merged - When this PR is merged to \`master\`: - 1. The npm publish workflow will automatically trigger - 2. The package will be published to npm - - --- - _Created by release workflow_`, - head: `release/v${{ github.run_number }}`, - base: 'master', - draft: false - }); - - console.log(`Pull request created: ${pr.html_url}`); \ No newline at end of file diff --git a/.npmignore b/.npmignore index 6dc690a..2fe28dc 100644 --- a/.npmignore +++ b/.npmignore @@ -9,7 +9,7 @@ tsup.config.prod.ts .prettierrc # Testing -tests/ +test/ coverage/ *.test.ts *.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 346a5d6..44ccab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,4 +40,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Code coverage dependencies and scripts (`vitest coverage`, `c8`, `jest`) -- Duplicate npm scripts (consolidated lint/format variants) \ No newline at end of file +- Duplicate npm scripts (consolidated lint/format variants) + +## [1.1.0] - UN-RELEASED + +### Added +- **Email Masking**: All email addresses are now masked by default in command outputs (e.g., `u***r@e****e.com`) +- **SSH Key Fingerprints**: `auth` command displays SSH key fingerprint (SHA256) instead of the full public key +- **Windows File Permissions**: Implemented ACL-based permissions for SSH config and key files on Windows using `icacls` + +### Changed +- **Profile Auto-detection**: Now requires user confirmation before applying detected profiles +- **Account Selection**: Removed email addresses from selection dropdowns in `clone` and `init` commands +- **SSH Key Info**: Removed comment field from SSH key information display + +### Fixed +- SSH key generation failing due to missing comment parameter in ssh-keygen command +- Windows SSH config files not receiving restrictive permissions + +### Security +- Enhanced privacy protection by masking email addresses throughout the application +- Reduced risk of accidental SSH key exposure by showing only fingerprints +- Improved Windows security with proper file permissions for SSH-related files \ No newline at end of file diff --git a/package.json b/package.json index 7db340b..54fb747 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@loopgrid/gitm", - "version": "1.0.2", + "version": "1.1.0", "description": "Seamlessly manage multiple git accounts on the same device", "main": "dist/cli.js", "bin": { diff --git a/src/commands/add.ts b/src/commands/add.ts index 8525bba..2ce77ec 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -109,7 +109,7 @@ export async function addAccount(profile: string, options?: { provider?: string if (answers.generateSSH) { try { - await generateSSHKey(sshKeyPath, answers.email, profile); + await generateSSHKey(sshKeyPath, answers.email, { comment: `${answers.email} (gitm: ${profile})` }); logSuccess('SSH key generated successfully'); } catch (error) { logError(`Failed to generate SSH key: ${(error as Error).message}`); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 45b21cf..1c9e3f4 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,5 +1,5 @@ import { getAccount } from '@/lib/config'; -import { readPublicKey, updateSSHConfig } from '@/utils/ssh'; +import { readPublicKey, updateSSHConfig, getSSHKeyInfo } from '@/utils/ssh'; import { logError, logSuccess, logInfo, log, LogLevel } from '@/utils/cli'; import { getAuthState, updateAuthState, clearAuthState, isAuthCompleted } from '@/utils/auth-state'; import { safeExec } from '@/utils/shell'; @@ -46,9 +46,11 @@ export async function authenticateAccount(profile: string): Promise { try { const publicKey = await readPublicKey(account.sshKeyPath); + const keyInfo = getSSHKeyInfo(publicKey); - console.log(chalk.bold('\nSSH Public Key for ' + profile + ':\n')); - console.log(chalk.gray(publicKey)); + console.log(chalk.bold('\nSSH Key Details for ' + profile + ':\n')); + console.log(chalk.cyan('Type: ') + keyInfo.type); + console.log(chalk.cyan('Fingerprint: ') + keyInfo.fingerprint); const providerUrls: Record = { github: 'https://github.com/settings/keys', diff --git a/src/commands/clone.ts b/src/commands/clone.ts index d0c742c..d8253df 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -5,7 +5,7 @@ import { detectAccountForRepo } from '@/utils/git'; import { getSSHRemoteUrl, updateSSHConfig, addSSHKeyToAgent } from '@/utils/ssh'; import { getAccount, getAccounts } from '@/lib/config'; import { CloneOptions } from '@/types'; -import { logError, logSuccess, logWarning, logInfo } from '@/utils/cli'; +import { logError, logSuccess, logWarning, logInfo, maskEmail } from '@/utils/cli'; /** * Clone a repository with the appropriate account @@ -35,9 +35,38 @@ export async function cloneRepo( let selectedAccount; if (detection.profile) { - selectedProfile = detection.profile; - selectedAccount = detection.account; - logSuccess(`Auto-detected account: ${selectedProfile}`); + logSuccess(`Auto-detected account: ${detection.profile}`); + + const { confirmDetection } = await inquirer.prompt<{ confirmDetection: boolean }>([ + { + type: 'confirm', + name: 'confirmDetection', + message: `Use auto-detected account '${detection.profile}'?`, + default: true, + }, + ]); + + if (confirmDetection) { + selectedProfile = detection.profile; + selectedAccount = detection.account; + } else { + // Show all available accounts for selection + const allAccounts = Object.entries(accounts); + const { profile } = await inquirer.prompt<{ profile: string }>([ + { + type: 'list', + name: 'profile', + message: 'Select account to use for this repository:', + choices: allAccounts.map(([profileName, account]) => ({ + name: `${profileName} (${account.name})`, + value: profileName, + })), + }, + ]); + + selectedProfile = profile; + selectedAccount = getAccount(profile); + } } else if (detection.candidates && detection.candidates.length > 0) { const { profile } = await inquirer.prompt<{ profile: string }>([ { @@ -45,7 +74,7 @@ export async function cloneRepo( name: 'profile', message: 'Select account to use for this repository:', choices: detection.candidates.map(([profileName, account]) => ({ - name: `${profileName} (${account.name} - ${account.email})`, + name: `${profileName} (${account.name})`, value: profileName, })), }, @@ -103,7 +132,7 @@ export async function cloneRepo( logSuccess(`Git config set for account '${selectedProfile}'`); logInfo(`Repository: ${path.resolve(targetDir)}`); - logInfo(`Account: ${selectedAccount.name} <${selectedAccount.email}>`); + logInfo(`Account: ${selectedAccount.name} <${maskEmail(selectedAccount.email)}>`); if (!useSSH && url.startsWith('https://')) { logInfo("Note: Cloned with HTTPS. Use 'gitm use ' to switch to SSH"); diff --git a/src/commands/init.ts b/src/commands/init.ts index 7e86836..b3d84f0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,6 +1,7 @@ import inquirer from 'inquirer'; import { getCurrentRepo, detectAccountForRepo, applyAccountToRepo } from '@/utils/git'; import { logError, logSuccess, logInfo, logWarning } from '@/utils/cli'; +import { getAccounts } from '@/lib/config'; /** * Initialize repository with auto-detected or selected account @@ -14,7 +15,6 @@ export async function initRepo(options: { ssh?: boolean } = { ssh: true }): Prom if (!detection) { // Let's check if accounts actually exist - const { getAccounts } = await import('@/lib/config'); const accounts = getAccounts(); const accountCount = Object.keys(accounts).length; @@ -32,7 +32,36 @@ export async function initRepo(options: { ssh?: boolean } = { ssh: true }): Prom if (detection.profile) { logSuccess(`Auto-detected account: ${detection.profile}`); - selectedProfile = detection.profile; + + const { confirmDetection } = await inquirer.prompt<{ confirmDetection: boolean }>([ + { + type: 'confirm', + name: 'confirmDetection', + message: `Use auto-detected account '${detection.profile}'?`, + default: true, + }, + ]); + + if (confirmDetection) { + selectedProfile = detection.profile; + } else { + // Show all available accounts for selection + const accounts = getAccounts(); + const allAccounts = Object.entries(accounts); + const { profile } = await inquirer.prompt<{ profile: string }>([ + { + type: 'list', + name: 'profile', + message: 'Select account to use for this repository:', + choices: allAccounts.map(([profileName, account]) => ({ + name: `${profileName} (${account.name})`, + value: profileName, + })), + }, + ]); + + selectedProfile = profile; + } } else if (detection.candidates && detection.candidates.length > 0) { const { profile } = await inquirer.prompt<{ profile: string }>([ { @@ -40,7 +69,7 @@ export async function initRepo(options: { ssh?: boolean } = { ssh: true }): Prom name: 'profile', message: 'Select account to use for this repository:', choices: detection.candidates.map(([profileName, account]) => ({ - name: `${profileName} (${account.name} - ${account.email})`, + name: `${profileName} (${account.name})`, value: profileName, })), }, diff --git a/src/commands/list.ts b/src/commands/list.ts index 24e5a6a..9590b32 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,5 +1,5 @@ import { getAccounts } from '@/lib/config'; -import { log, logWarning, logInfo, sectionHeader, formatKeyValue } from '@/utils/cli'; +import { log, logWarning, logInfo, sectionHeader, formatKeyValue, maskEmail } from '@/utils/cli'; import chalk from 'chalk'; /** @@ -21,7 +21,7 @@ export function listAccounts(): void { const account = accounts[profile]; log(chalk.cyan(` ${profile}`)); log(formatKeyValue('Name', account.name, 4)); - log(formatKeyValue('Email', account.email, 4)); + log(formatKeyValue('Email', maskEmail(account.email), 4)); log(formatKeyValue('Provider', account.provider, 4)); log(formatKeyValue('Username', account.username, 4)); console.log(); diff --git a/src/commands/status.ts b/src/commands/status.ts index a6426b9..917ce47 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -8,6 +8,7 @@ import { sectionHeader, formatKeyValue, logInfo, + maskEmail, } from '@/utils/cli'; import chalk from 'chalk'; @@ -38,7 +39,7 @@ export async function showStatus(): Promise { if (gitUser.name && gitUser.email) { console.log(chalk.green('Git User Configuration:')); console.log(formatKeyValue('Name', gitUser.name)); - console.log(formatKeyValue('Email', gitUser.email)); + console.log(formatKeyValue('Email', maskEmail(gitUser.email))); } else { logWarning('No git user configured for this repository'); } diff --git a/src/commands/use.ts b/src/commands/use.ts index 1da9b2e..382986d 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -3,7 +3,7 @@ import { setRepoConfig, getCurrentRepo, updateRemoteUrl } from '@/utils/git'; import { updateSSHConfig, addSSHKeyToAgent, getSSHRemoteUrl } from '@/utils/ssh'; import { detectProvider } from '@/utils/providers'; import { isValidProfileName } from '@/utils/shell'; -import { logError, logSuccess, logDebug, logWarning, logInfo, formatKeyValue } from '@/utils/cli'; +import { logError, logSuccess, logDebug, logWarning, logInfo, formatKeyValue, maskEmail } from '@/utils/cli'; /** * Set a specific account for the current repository @@ -60,7 +60,7 @@ export async function useAccount(profile: string): Promise { logSuccess(`Account '${profile}' is now active for this repository`); logDebug('\nRepository configuration:'); logDebug(formatKeyValue('Name', account.name)); - logDebug(formatKeyValue('Email', account.email)); + logDebug(formatKeyValue('Email', maskEmail(account.email))); logDebug(formatKeyValue('Remote', newUrl)); } catch (error) { const errorMessage = (error as Error).message; diff --git a/src/types/index.ts b/src/types/index.ts index 046b8d2..35a7c27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -122,6 +122,8 @@ export interface SSHKeyOptions { bits?: number; /** Passphrase for key */ passphrase?: string; + /** Comment for the key */ + comment?: string; } /** diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 006fd8e..b7d18e4 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -96,3 +96,57 @@ export function formatKeyValue(key: string, value: string, indent: number = 2): export function sectionHeader(title: string): string { return chalk.bold(`\n${title}:\n`); } + +/** + * Mask an email address for privacy + * @param email - Email address to mask + * @returns Masked email (e.g., u***r@e****e.com) + */ +export function maskEmail(email: string): string { + const parts = email.split('@'); + if (parts.length !== 2) return email; + + const [local, domain] = parts; + + // Check for empty local part + if (!local) return email; + + const domainParts = domain.split('.'); + + // Check if domain has at least one dot (valid email format) + if (domainParts.length < 2) return email; + + // Mask local part (keep first and last char if length > 2) + let maskedLocal: string; + if (local.length === 1) { + maskedLocal = local; + } else if (local.length === 2) { + maskedLocal = local[0] + '*'; + } else if (local.length <= 5) { + // For short names, use exact number of asterisks + maskedLocal = local[0] + '*'.repeat(local.length - 2) + local[local.length - 1]; + } else { + // For longer names, limit to 3 asterisks + maskedLocal = local[0] + '***' + local[local.length - 1]; + } + + // Mask domain (keep first and last char of main part if length > 2) + const mainDomain = domainParts[0]; + let maskedDomain: string; + if (mainDomain.length === 1) { + maskedDomain = mainDomain; + } else if (mainDomain.length === 2) { + maskedDomain = mainDomain[0] + '*'; + } else if (mainDomain.length <= 6) { + // For short domains, use exact number of asterisks + maskedDomain = mainDomain[0] + '*'.repeat(mainDomain.length - 2) + mainDomain[mainDomain.length - 1]; + } else { + // For longer domains, limit to 4 asterisks + maskedDomain = mainDomain[0] + '****' + mainDomain[mainDomain.length - 1]; + } + + // Reconstruct email with TLD intact + const tld = domainParts.slice(1).join('.'); + return `${maskedLocal}@${maskedDomain}.${tld}`; +} + diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts index ab76ae0..60dada3 100644 --- a/src/utils/ssh.ts +++ b/src/utils/ssh.ts @@ -1,8 +1,56 @@ import fs from 'fs/promises'; +import crypto from 'crypto'; import { SSH_CONFIG_PATH, DEFAULT_SSH_KEY_TYPE, SSH_KEY_DIR } from '@/config/constants'; import { SSHKeyOptions } from '@/types'; import { safeExec, isPathSafe } from '@/utils/shell'; +/** + * Set secure file permissions for SSH-related files + * @param filePath - Path to the file + * @throws Error if permission setting fails + */ +export async function setSecureFilePermissions(filePath: string): Promise { + try { + if (process.platform === 'win32') { + // Windows: Use icacls to set permissions + // Remove inheritance and grant only current user full control + const username = process.env.USERNAME || process.env.USER; + if (!username) { + throw new Error('Could not determine current user'); + } + + // Reset permissions, remove inheritance, grant only current user + const commands = [ + // Remove inheritance while copying current permissions + ['icacls', [filePath, '/inheritance:r']], + // Grant full control only to current user + ['icacls', [filePath, '/grant:r', `${username}:F`]], + // Remove all other users if they exist + ['icacls', [filePath, '/remove', 'Users']], + ['icacls', [filePath, '/remove', 'Everyone']], + ['icacls', [filePath, '/remove', 'Authenticated Users']], + ]; + + for (const [cmd, args] of commands) { + try { + await safeExec(cmd as string, args as string[]); + } catch (error) { + // Some remove commands might fail if the user/group doesn't exist, that's ok + if (!args.includes('/remove')) { + throw error; + } + } + } + } else { + // Unix-like systems: Use chmod + await fs.chmod(filePath, 0o600); + } + } catch (error) { + // Log warning but don't fail the operation + console.warn(`Warning: Could not set secure permissions on ${filePath}: ${(error as Error).message}`); + } +} + /** * Generate a new SSH key pair * @param keyPath - Path where the key should be saved @@ -14,7 +62,6 @@ import { safeExec, isPathSafe } from '@/utils/shell'; export async function generateSSHKey( keyPath: string, email: string, - comment: string, options: SSHKeyOptions = {} ): Promise { // Validate the key path is within SSH directory @@ -23,17 +70,25 @@ export async function generateSSHKey( } const keyType = options.type || DEFAULT_SSH_KEY_TYPE; - const keyComment = `${email} (gitm: ${comment})`; const passphrase = options.passphrase || ''; + const comment = options.comment || email; - const args = ['-t', keyType, '-f', keyPath, '-N', passphrase, '-C', keyComment]; + const args = ['-t', keyType, '-f', keyPath, '-N', passphrase, '-C', comment]; if (keyType === 'rsa' && options.bits) { - args.push('-b', options.bits.toString()); + args.splice(2, 0, '-b', options.bits.toString()); } try { await safeExec('ssh-keygen', args); + + // Set secure permissions on the private key + await setSecureFilePermissions(keyPath); + + // Public key should be readable but not writable by others + if (process.platform !== 'win32') { + await fs.chmod(`${keyPath}.pub`, 0o644); + } } catch (error) { throw new Error(`Failed to generate SSH key: ${(error as Error).message}`); } @@ -124,10 +179,8 @@ Host ${host} const newConfig = existingConfig.trim() + '\n' + configEntry; await fs.writeFile(SSH_CONFIG_PATH, newConfig); - // Set proper permissions (Unix-like systems only) - if (process.platform !== 'win32') { - await fs.chmod(SSH_CONFIG_PATH, 0o600); - } + // Set proper permissions + await setSecureFilePermissions(SSH_CONFIG_PATH); } catch (error) { throw new Error(`Failed to update SSH config: ${(error as Error).message}`); } @@ -223,3 +276,47 @@ export async function readPublicKey(keyPath: string): Promise { return await fs.readFile(`${keyPath}.pub`, 'utf-8'); } + +/** + * Get the fingerprint of an SSH public key + * @param publicKey - The SSH public key content + * @returns The SHA256 fingerprint of the key + */ +export function getSSHKeyFingerprint(publicKey: string): string { + // Extract the base64-encoded key data (second part of the key) + const keyParts = publicKey.trim().split(/\s+/); + if (keyParts.length < 2) { + throw new Error('Invalid SSH public key format'); + } + + const keyData = keyParts[1]; + const keyBuffer = Buffer.from(keyData, 'base64'); + + // Calculate SHA256 hash + const hash = crypto.createHash('sha256').update(keyBuffer).digest('base64'); + + // Format as standard SSH fingerprint (remove padding) + const fingerprint = hash.replace(/=+$/, ''); + + return `SHA256:${fingerprint}`; +} + +/** + * Get SSH key info including type and fingerprint + * @param publicKey - The SSH public key content + * @returns Object with key type and fingerprint + */ +export function getSSHKeyInfo(publicKey: string): { type: string; fingerprint: string } { + const keyParts = publicKey.trim().split(/\s+/); + if (keyParts.length < 2) { + throw new Error('Invalid SSH public key format'); + } + + const keyType = keyParts[0]; // e.g., ssh-rsa, ssh-ed25519 + const fingerprint = getSSHKeyFingerprint(publicKey); + + return { + type: keyType, + fingerprint + }; +} diff --git a/test/unit/utils/file-permissions.test.ts b/test/unit/utils/file-permissions.test.ts new file mode 100644 index 0000000..87a94a6 --- /dev/null +++ b/test/unit/utils/file-permissions.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import { setSecureFilePermissions } from '@/utils/ssh'; +import * as shell from '@/utils/shell'; + +describe('File Permissions', () => { + const mockSafeExec = vi.spyOn(shell, 'safeExec'); + const mockChmod = vi.spyOn(fs, 'chmod'); + const originalPlatform = process.platform; + const originalUsername = process.env.USERNAME; + const originalUser = process.env.USER; + + beforeEach(() => { + vi.clearAllMocks(); + mockSafeExec.mockResolvedValue({ stdout: '', stderr: '' }); + mockChmod.mockResolvedValue(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env.USERNAME = originalUsername; + process.env.USER = originalUser; + }); + + describe('Unix/Linux/macOS', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + it('should set 0o600 permissions on Unix systems', async () => { + const testPath = '/home/user/.ssh/config'; + + await setSecureFilePermissions(testPath); + + expect(mockChmod).toHaveBeenCalledWith(testPath, 0o600); + expect(mockSafeExec).not.toHaveBeenCalled(); + }); + }); + + describe('Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.env.USERNAME = 'TestUser'; + }); + + it('should use icacls to set permissions on Windows', async () => { + const testPath = 'C:\\Users\\TestUser\\.ssh\\config'; + + await setSecureFilePermissions(testPath); + + expect(mockChmod).not.toHaveBeenCalled(); + + // Check that inheritance is removed + expect(mockSafeExec).toHaveBeenCalledWith('icacls', [testPath, '/inheritance:r']); + + // Check that current user gets full control + expect(mockSafeExec).toHaveBeenCalledWith('icacls', [testPath, '/grant:r', 'TestUser:F']); + + // Check that other users are removed + expect(mockSafeExec).toHaveBeenCalledWith('icacls', [testPath, '/remove', 'Users']); + expect(mockSafeExec).toHaveBeenCalledWith('icacls', [testPath, '/remove', 'Everyone']); + }); + + it('should handle missing USERNAME env var', async () => { + delete process.env.USERNAME; + process.env.USER = 'TestUser2'; + + const testPath = 'C:\\Users\\TestUser2\\.ssh\\config'; + + await setSecureFilePermissions(testPath); + + expect(mockSafeExec).toHaveBeenCalledWith('icacls', [testPath, '/grant:r', 'TestUser2:F']); + }); + + it('should not fail when removing non-existent users', async () => { + const testPath = 'C:\\Users\\TestUser\\.ssh\\config'; + + // Simulate failure on remove commands + mockSafeExec.mockImplementation(async (cmd, args) => { + if (args.includes('/remove')) { + throw new Error('No mapping between account names and security IDs was done'); + } + return { stdout: '', stderr: '' }; + }); + + // Should not throw + await expect(setSecureFilePermissions(testPath)).resolves.not.toThrow(); + }); + }); + + describe('Error handling', () => { + it('should log warning but not throw on permission errors', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockChmod.mockRejectedValue(new Error('Permission denied')); + + await setSecureFilePermissions('/test/path'); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Warning: Could not set secure permissions') + ); + + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/utils/privacy.test.ts b/test/unit/utils/privacy.test.ts new file mode 100644 index 0000000..c417ba8 --- /dev/null +++ b/test/unit/utils/privacy.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { maskEmail } from '@/utils/cli'; + +describe('Privacy utilities', () => { + describe('maskEmail', () => { + it('should mask standard email addresses', () => { + expect(maskEmail('user@example.com')).toBe('u**r@e****e.com'); + expect(maskEmail('john.doe@company.org')).toBe('j***e@c****y.org'); + expect(maskEmail('alice@test.co.uk')).toBe('a***e@t**t.co.uk'); + }); + + it('should handle short email parts', () => { + expect(maskEmail('a@b.com')).toBe('a@b.com'); + expect(maskEmail('ab@cd.com')).toBe('a*@c*.com'); + expect(maskEmail('abc@de.com')).toBe('a*c@d*.com'); + }); + + it('should handle complex email addresses', () => { + expect(maskEmail('very.long.email@subdomain.example.com')).toBe('v***l@s****n.example.com'); + expect(maskEmail('test+tag@email.com')).toBe('t***g@e***l.com'); + }); + + it('should return invalid emails unchanged', () => { + expect(maskEmail('notanemail')).toBe('notanemail'); + expect(maskEmail('missing@domain')).toBe('missing@domain'); + expect(maskEmail('@nodomain.com')).toBe('@nodomain.com'); + }); + + it('should limit asterisks to reasonable length', () => { + expect(maskEmail('verylonglocalpart@verylongdomainname.com')).toBe('v***t@v****e.com'); + }); + }); +}); \ No newline at end of file diff --git a/test/unit/utils/ssh-fingerprint.test.ts b/test/unit/utils/ssh-fingerprint.test.ts new file mode 100644 index 0000000..40ba5bf --- /dev/null +++ b/test/unit/utils/ssh-fingerprint.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { getSSHKeyFingerprint, getSSHKeyInfo } from '@/utils/ssh'; + +describe('SSH Key Fingerprint utilities', () => { + const sampleRSAKey = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLrPA3W8ySM1OoW9gL8YPjP4nETrCWnZP8XgHbLxc5xSmi8Xx3p7Qvd3VUMx8B2JV3TAMryXC2gD5XCYDqVBi9tPCOLANPPXKZ0h1NRvmSZJ3JYQ5N0VTj1D5BgBe2XYjNlHwH7kAPQHhKBNmSKJV8P3VGT2HXHS5J3lzsG7TTqPcW0K1g0XQnwqC6B7VUC0m4pdS2IVQnjm5kHTnBpPBr user@example.com'; + + const sampleED25519Key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@example.com'; + + describe('getSSHKeyFingerprint', () => { + it('should calculate correct fingerprint for RSA key', () => { + const fingerprint = getSSHKeyFingerprint(sampleRSAKey); + expect(fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + expect(fingerprint.startsWith('SHA256:')).toBe(true); + expect(fingerprint.length).toBeGreaterThanOrEqual(47); + expect(fingerprint.length).toBeLessThanOrEqual(51); // Base64 can vary slightly + }); + + it('should calculate correct fingerprint for ED25519 key', () => { + const fingerprint = getSSHKeyFingerprint(sampleED25519Key); + expect(fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + expect(fingerprint.startsWith('SHA256:')).toBe(true); + expect(fingerprint.length).toBeGreaterThanOrEqual(47); + expect(fingerprint.length).toBeLessThanOrEqual(51); + }); + + it('should handle keys with extra whitespace', () => { + const keyWithSpaces = ' ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLrPA3W8ySM1OoW9gL8YPjP4nETrCWnZP8XgHbLxc5xSmi8Xx3p7Qvd3VUMx8B2JV3TAMryXC2gD5XCYDqVBi9tPCOLANPPXKZ0h1NRvmSZJ3JYQ5N0VTj1D5BgBe2XYjNlHwH7kAPQHhKBNmSKJV8P3VGT2HXHS5J3lzsG7TTqPcW0K1g0XQnwqC6B7VUC0m4pdS2IVQnjm5kHTnBpPBr user@example.com '; + const fingerprint = getSSHKeyFingerprint(keyWithSpaces); + const normalFingerprint = getSSHKeyFingerprint(sampleRSAKey); + expect(fingerprint).toBe(normalFingerprint); + }); + + it('should throw error for invalid key format', () => { + expect(() => getSSHKeyFingerprint('invalid-key')).toThrow('Invalid SSH public key format'); + expect(() => getSSHKeyFingerprint('ssh-rsa')).toThrow('Invalid SSH public key format'); + expect(() => getSSHKeyFingerprint('')).toThrow('Invalid SSH public key format'); + }); + }); + + describe('getSSHKeyInfo', () => { + it('should extract correct info from RSA key', () => { + const info = getSSHKeyInfo(sampleRSAKey); + expect(info.type).toBe('ssh-rsa'); + expect(info.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + }); + + it('should extract correct info from ED25519 key', () => { + const info = getSSHKeyInfo(sampleED25519Key); + expect(info.type).toBe('ssh-ed25519'); + expect(info.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + }); + + it('should handle keys without comment', () => { + const keyNoComment = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLrPA3W8ySM1OoW9gL8YPjP4nETrCWnZP8XgHbLxc5xSmi8Xx3p7Qvd3VUMx8B2JV3TAMryXC2gD5XCYDqVBi9tPCOLANPPXKZ0h1NRvmSZJ3JYQ5N0VTj1D5BgBe2XYjNlHwH7kAPQHhKBNmSKJV8P3VGT2HXHS5J3lzsG7TTqPcW0K1g0XQnwqC6B7VUC0m4pdS2IVQnjm5kHTnBpPBr'; + const info = getSSHKeyInfo(keyNoComment); + expect(info.type).toBe('ssh-rsa'); + expect(info.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + }); + + it('should handle keys with multi-word comment', () => { + const keyWithComment = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLrPA3W8ySM1OoW9gL8YPjP4nETrCWnZP8XgHbLxc5xSmi8Xx3p7Qvd3VUMx8B2JV3TAMryXC2gD5XCYDqVBi9tPCOLANPPXKZ0h1NRvmSZJ3JYQ5N0VTj1D5BgBe2XYjNlHwH7kAPQHhKBNmSKJV8P3VGT2HXHS5J3lzsG7TTqPcW0K1g0XQnwqC6B7VUC0m4pdS2IVQnjm5kHTnBpPBr my test key comment'; + const info = getSSHKeyInfo(keyWithComment); + expect(info.type).toBe('ssh-rsa'); + expect(info.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/); + }); + + it('should throw error for invalid key format', () => { + expect(() => getSSHKeyInfo('invalid-key')).toThrow('Invalid SSH public key format'); + }); + }); +}); \ No newline at end of file From 3db9601425854e207645cc9710b551d1976ad479 Mon Sep 17 00:00:00 2001 From: idebenone Date: Sun, 6 Jul 2025 16:58:55 +0530 Subject: [PATCH 2/4] chore: update README.md --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dfa8a31..06da3e3 100644 --- a/README.md +++ b/README.md @@ -407,17 +407,28 @@ ssh -T git@github.com-personal ### Windows: SSH Agent Not Running -If you see "Could not add key to ssh-agent" on Windows: +If you see "Could not add key to ssh-agent" on Windows, the SSH agent service is likely not running. -```bash -# Start ssh-agent service (run as Administrator) +**To fix this permanently:** + +```powershell +# 1. Open PowerShell as Administrator +# 2. Enable and start the SSH agent service Get-Service ssh-agent | Set-Service -StartupType Automatic Start-Service ssh-agent -# Or manually add your key +# 3. Verify it's running +Get-Service ssh-agent +``` + +**Alternative: Manually add your key** +```bash +# If the above doesn't work, manually add your key ssh-add C:\Users\YourName\.ssh\gitm_personal_github ``` +**Note:** This is a one-time setup. Once enabled, the SSH agent will start automatically with Windows. + ### Port 22 Blocked If your network blocks port 22: From 1fb72cb01a6ac164a244203d4e2d10654461c02b Mon Sep 17 00:00:00 2001 From: idebenone Date: Sun, 6 Jul 2025 17:04:40 +0530 Subject: [PATCH 3/4] fix: format and type error --- src/commands/add.ts | 4 +++- src/commands/clone.ts | 4 ++-- src/commands/init.ts | 4 ++-- src/commands/use.ts | 10 +++++++++- src/utils/cli.ts | 18 +++++++++--------- src/utils/ssh.ts | 22 ++++++++++++---------- test/unit/utils/file-permissions.test.ts | 2 +- 7 files changed, 38 insertions(+), 26 deletions(-) diff --git a/src/commands/add.ts b/src/commands/add.ts index 2ce77ec..5ef0698 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -109,7 +109,9 @@ export async function addAccount(profile: string, options?: { provider?: string if (answers.generateSSH) { try { - await generateSSHKey(sshKeyPath, answers.email, { comment: `${answers.email} (gitm: ${profile})` }); + await generateSSHKey(sshKeyPath, answers.email, { + comment: `${answers.email} (gitm: ${profile})`, + }); logSuccess('SSH key generated successfully'); } catch (error) { logError(`Failed to generate SSH key: ${(error as Error).message}`); diff --git a/src/commands/clone.ts b/src/commands/clone.ts index d8253df..16ce727 100644 --- a/src/commands/clone.ts +++ b/src/commands/clone.ts @@ -36,7 +36,7 @@ export async function cloneRepo( if (detection.profile) { logSuccess(`Auto-detected account: ${detection.profile}`); - + const { confirmDetection } = await inquirer.prompt<{ confirmDetection: boolean }>([ { type: 'confirm', @@ -63,7 +63,7 @@ export async function cloneRepo( })), }, ]); - + selectedProfile = profile; selectedAccount = getAccount(profile); } diff --git a/src/commands/init.ts b/src/commands/init.ts index b3d84f0..9b8efa0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -32,7 +32,7 @@ export async function initRepo(options: { ssh?: boolean } = { ssh: true }): Prom if (detection.profile) { logSuccess(`Auto-detected account: ${detection.profile}`); - + const { confirmDetection } = await inquirer.prompt<{ confirmDetection: boolean }>([ { type: 'confirm', @@ -59,7 +59,7 @@ export async function initRepo(options: { ssh?: boolean } = { ssh: true }): Prom })), }, ]); - + selectedProfile = profile; } } else if (detection.candidates && detection.candidates.length > 0) { diff --git a/src/commands/use.ts b/src/commands/use.ts index 382986d..6b9e275 100644 --- a/src/commands/use.ts +++ b/src/commands/use.ts @@ -3,7 +3,15 @@ import { setRepoConfig, getCurrentRepo, updateRemoteUrl } from '@/utils/git'; import { updateSSHConfig, addSSHKeyToAgent, getSSHRemoteUrl } from '@/utils/ssh'; import { detectProvider } from '@/utils/providers'; import { isValidProfileName } from '@/utils/shell'; -import { logError, logSuccess, logDebug, logWarning, logInfo, formatKeyValue, maskEmail } from '@/utils/cli'; +import { + logError, + logSuccess, + logDebug, + logWarning, + logInfo, + formatKeyValue, + maskEmail, +} from '@/utils/cli'; /** * Set a specific account for the current repository diff --git a/src/utils/cli.ts b/src/utils/cli.ts index b7d18e4..359a766 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -105,17 +105,17 @@ export function sectionHeader(title: string): string { export function maskEmail(email: string): string { const parts = email.split('@'); if (parts.length !== 2) return email; - + const [local, domain] = parts; - + // Check for empty local part if (!local) return email; - + const domainParts = domain.split('.'); - + // Check if domain has at least one dot (valid email format) if (domainParts.length < 2) return email; - + // Mask local part (keep first and last char if length > 2) let maskedLocal: string; if (local.length === 1) { @@ -129,7 +129,7 @@ export function maskEmail(email: string): string { // For longer names, limit to 3 asterisks maskedLocal = local[0] + '***' + local[local.length - 1]; } - + // Mask domain (keep first and last char of main part if length > 2) const mainDomain = domainParts[0]; let maskedDomain: string; @@ -139,14 +139,14 @@ export function maskEmail(email: string): string { maskedDomain = mainDomain[0] + '*'; } else if (mainDomain.length <= 6) { // For short domains, use exact number of asterisks - maskedDomain = mainDomain[0] + '*'.repeat(mainDomain.length - 2) + mainDomain[mainDomain.length - 1]; + maskedDomain = + mainDomain[0] + '*'.repeat(mainDomain.length - 2) + mainDomain[mainDomain.length - 1]; } else { // For longer domains, limit to 4 asterisks maskedDomain = mainDomain[0] + '****' + mainDomain[mainDomain.length - 1]; } - + // Reconstruct email with TLD intact const tld = domainParts.slice(1).join('.'); return `${maskedLocal}@${maskedDomain}.${tld}`; } - diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts index 60dada3..33f880a 100644 --- a/src/utils/ssh.ts +++ b/src/utils/ssh.ts @@ -47,7 +47,9 @@ export async function setSecureFilePermissions(filePath: string): Promise } } catch (error) { // Log warning but don't fail the operation - console.warn(`Warning: Could not set secure permissions on ${filePath}: ${(error as Error).message}`); + console.warn( + `Warning: Could not set secure permissions on ${filePath}: ${(error as Error).message}` + ); } } @@ -81,10 +83,10 @@ export async function generateSSHKey( try { await safeExec('ssh-keygen', args); - + // Set secure permissions on the private key await setSecureFilePermissions(keyPath); - + // Public key should be readable but not writable by others if (process.platform !== 'win32') { await fs.chmod(`${keyPath}.pub`, 0o644); @@ -288,16 +290,16 @@ export function getSSHKeyFingerprint(publicKey: string): string { if (keyParts.length < 2) { throw new Error('Invalid SSH public key format'); } - + const keyData = keyParts[1]; const keyBuffer = Buffer.from(keyData, 'base64'); - + // Calculate SHA256 hash const hash = crypto.createHash('sha256').update(keyBuffer).digest('base64'); - + // Format as standard SSH fingerprint (remove padding) const fingerprint = hash.replace(/=+$/, ''); - + return `SHA256:${fingerprint}`; } @@ -311,12 +313,12 @@ export function getSSHKeyInfo(publicKey: string): { type: string; fingerprint: s if (keyParts.length < 2) { throw new Error('Invalid SSH public key format'); } - + const keyType = keyParts[0]; // e.g., ssh-rsa, ssh-ed25519 const fingerprint = getSSHKeyFingerprint(publicKey); - + return { type: keyType, - fingerprint + fingerprint, }; } diff --git a/test/unit/utils/file-permissions.test.ts b/test/unit/utils/file-permissions.test.ts index 87a94a6..df2e7c6 100644 --- a/test/unit/utils/file-permissions.test.ts +++ b/test/unit/utils/file-permissions.test.ts @@ -77,7 +77,7 @@ describe('File Permissions', () => { // Simulate failure on remove commands mockSafeExec.mockImplementation(async (cmd, args) => { - if (args.includes('/remove')) { + if (args && args.includes('/remove')) { throw new Error('No mapping between account names and security IDs was done'); } return { stdout: '', stderr: '' }; From 49cdb5f871e4c1a019e0a6016a86a6d2e94367e7 Mon Sep 17 00:00:00 2001 From: idebenone Date: Sun, 6 Jul 2025 17:10:18 +0530 Subject: [PATCH 4/4] chore: update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ccab6..2c83f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Code coverage dependencies and scripts (`vitest coverage`, `c8`, `jest`) - Duplicate npm scripts (consolidated lint/format variants) -## [1.1.0] - UN-RELEASED +## [1.1.0] - 2025-07-06 ### Added - **Email Masking**: All email addresses are now masked by default in command outputs (e.g., `u***r@e****e.com`)