Skip to content
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
7 changes: 7 additions & 0 deletions .changeset/login-key-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"nansen-cli": patch
---

`nansen login` hygiene: the API key is redacted from relayed verification
errors, the invalid-key resolution points at the key management view, and the
help text leads with the paths that keep the key out of shell history.
59 changes: 59 additions & 0 deletions src/__tests__/cli.internal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1847,6 +1847,65 @@ describe('buildCommands', () => {
}
});

it('redacts the API key from relayed verification errors (API-320)', async () => {
const key = 'nsn_super_secret_key_123';
const mockApi = { getAccount: vi.fn().mockRejectedValue(
Object.assign(new Error(`upstream said: header NANSEN-API-KEY: ${key} is malformed`), { code: 'SOMETHING_ELSE' })
) };
mockDeps.NansenAPIClass.mockImplementation(function() { return mockApi; });

let thrown;
try {
await commands.login([], null, {}, { 'api-key': key });
} catch (e) { thrown = e; }
expect(thrown).toBeDefined();
expect(thrown.message).toContain('[redacted]');
expect(thrown.message).not.toContain(key);
expect(JSON.stringify(thrown.data ?? {})).not.toContain(key);
});

it('redacts a key that upstream echoes in trimmed form', async () => {
const key = ' nsn_padded_key_456 ';
const mockApi = { getAccount: vi.fn().mockRejectedValue(
Object.assign(new Error(`bad credential nsn_padded_key_456 rejected`), { code: 'SOMETHING_ELSE' })
) };
mockDeps.NansenAPIClass.mockImplementation(function() { return mockApi; });

let thrown;
try {
await commands.login([], null, {}, { 'api-key': key });
} catch (e) { thrown = e; }
expect(thrown).toBeDefined();
expect(thrown.message).not.toContain(key.trim());
});

it('invalid-key resolution points at the key management view, never agent-setup (API-390)', async () => {
const mockApi = { getAccount: vi.fn().mockRejectedValue(
Object.assign(new Error('unauthorized'), { code: ErrorCode.UNAUTHORIZED })
) };
mockDeps.NansenAPIClass.mockImplementation(function() { return mockApi; });

let thrown;
try {
await commands.login([], null, {}, { 'api-key': 'some-invalid-key' });
} catch (e) { thrown = e; }
expect(thrown).toBeDefined();
const resolution = JSON.stringify(thrown.data?.resolution ?? []);
expect(resolution).toContain('https://app.nansen.ai/api?tab=api');
expect(resolution).not.toContain('/auth/agent-setup');
});

it('login help warns that literal keys land in shell history', async () => {
const logs = [];
const localCommands = buildCommands({ ...mockDeps, log: (m) => logs.push(m) });
await localCommands.login([], null, { help: true }, {});
const out = logs.join('\n');
expect(out).toContain('--human');
expect(out).toMatch(/recorded in shell history/i);
// the safe path is listed before the history-recording one
expect(out.indexOf('--human')).toBeLessThan(out.indexOf('--api-key <key>'));
});

it('should save config with --api-key option after verification', async () => {
const mockApi = { getAccount: vi.fn().mockResolvedValue({ plan: 'pro', credits_remaining: 9800 }) };
mockDeps.NansenAPIClass.mockImplementation(function() { return mockApi; });
Expand Down
27 changes: 20 additions & 7 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,16 @@ export async function prompt(question, hidden = false) {
}

// Build command handlers (returns object with handler functions)
/**
* Replace every occurrence of a secret in text with [redacted].
* Upstream error messages can echo request headers or bodies containing the
* API key; anything user-visible must pass through this first.
*/
export function redactSecret(text, secret) {
if (typeof text !== 'string' || typeof secret !== 'string' || secret.length === 0) return text;
return text.split(secret).join('[redacted]');
}

export function buildCommands(deps = {}) {
// Allow dependency injection for testing
const {
Expand Down Expand Up @@ -993,13 +1003,15 @@ export function buildCommands(deps = {}) {
if (flags.help || flags.h) {
log('nansen login - Save your Nansen API key\n');
log('USAGE:');
log(' nansen login --api-key <key>');
log(' NANSEN_API_KEY=<key> nansen login');
log(' nansen login --human (interactive prompt)\n');
log(' nansen login --human (interactive prompt; key never enters shell history)');
log(' NANSEN_API_KEY=$(security find-generic-password -s nansen-api-key -w) nansen login');
log(' (command substitution; key output not recorded in history)');
log(' nansen login --api-key <key> (literal key IS recorded in shell history)\n');
log('OPTIONS:');
log(' --api-key <key> Your Nansen API key');
log(' --api-key <key> Your Nansen API key (recorded in shell history — prefer --human)');
log(' --human Enable interactive prompt');
log(' --help Show this help\n');
log('Inline NANSEN_API_KEY=<key> assignments are also recorded in shell history.');
log('Get your API key at: https://app.nansen.ai/auth/agent-setup');
return;
}
Expand Down Expand Up @@ -1049,12 +1061,13 @@ export function buildCommands(deps = {}) {
throw new CommandError('The API key is not valid.', 'INVALID_API_KEY', {
error: 'INVALID_API_KEY',
message: 'The API key is not valid.',
resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'],
resolution: ['Check or rotate your key at https://app.nansen.ai/api?tab=api'],
});
}
throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', {
const safeMessage = redactSecret(redactSecret(error.message, apiKey), apiKey.trim());
throw new CommandError(`Could not verify API key: ${safeMessage}`, 'VERIFICATION_FAILED', {
error: 'VERIFICATION_FAILED',
message: `Could not verify API key: ${error.message}`,
message: `Could not verify API key: ${safeMessage}`,
resolution: ['Check your internet connection', 'Try again'],
});
}
Expand Down
Loading