Skip to content

Commit 356de02

Browse files
CLI-246 UserPromptSubmit secrets scanner callback
1 parent 9191141 commit 356de02

8 files changed

Lines changed: 320 additions & 7 deletions

File tree

src/cli/command-tree.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { MAX_PAGE_SIZE } from '../sonarqube/projects';
4040
import { apiCommand, apiExtraHelpText, type ApiCommandOptions } from './commands/api/api';
4141
import { GENERIC_HTTP_METHODS } from '../sonarqube/client';
4242
import { claudePreToolUse } from './commands/hook/claude-pre-tool-use';
43+
import { agentPromptSubmit } from './commands/hook/agent-prompt-submit';
4344

4445
const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;
4546

@@ -247,9 +248,7 @@ hookCommand
247248
hookCommand
248249
.command('claude-prompt-submit')
249250
.description('UserPromptSubmit handler: scan prompts for secrets before sending')
250-
.anonymousAction(() => {
251-
return;
252-
});
251+
.anonymousAction(() => agentPromptSubmit());
253252

254253
hookCommand
255254
.command('claude-post-tool-use')

src/cli/commands/analyze/secrets.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ export async function runSecretsBinary(
6464
});
6565
}
6666

67+
/**
68+
* Run sonar-secrets binary on arbitrary text via stdin (--input mode). Returns the full spawn result.
69+
*/
70+
export async function runSecretsBinaryOnText(
71+
binaryPath: string,
72+
text: string,
73+
auth: ResolvedAuth,
74+
): Promise<SpawnResult> {
75+
return spawnWithTimeout(binaryPath, ['--input'], {
76+
stdin: 'pipe',
77+
stdinData: text,
78+
stdout: 'pipe',
79+
stderr: 'pipe',
80+
env: buildAuthEnv(auth),
81+
});
82+
}
83+
6784
function buildAuthEnv(auth: ResolvedAuth): Record<string, string> {
6885
return { [BINARY_AUTH_URL_ENV]: auth.serverUrl, [BINARY_AUTH_TOKEN_ENV]: auth.token };
6986
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// UserPromptSubmit callback handler — scans prompt text for secrets before it is sent.
22+
// Replaces the bash/PowerShell logic that was previously embedded in the hook script.
23+
24+
import { resolveAuth } from '../../../lib/auth-resolver';
25+
import logger from '../../../lib/logger';
26+
import { readStdinJson } from './stdin';
27+
import { resolveSecretsBinaryPath } from '../_common/install/secrets';
28+
import { EXIT_CODE_SECRETS_FOUND, runSecretsBinaryOnText } from '../analyze/secrets';
29+
30+
interface PromptSubmitPayload {
31+
prompt?: string;
32+
}
33+
34+
export async function agentPromptSubmit(): Promise<void> {
35+
let payload: PromptSubmitPayload;
36+
try {
37+
payload = await readStdinJson<PromptSubmitPayload>();
38+
} catch {
39+
return; // unparseable stdin — allow
40+
}
41+
42+
const prompt = payload.prompt;
43+
if (!prompt) return;
44+
45+
const auth = await resolveAuth().catch(() => null);
46+
if (!auth) return; // not authenticated — allow gracefully
47+
48+
const binaryPath = resolveSecretsBinaryPath();
49+
if (!binaryPath) return; // binary not installed — allow gracefully
50+
51+
try {
52+
const result = await runSecretsBinaryOnText(binaryPath, prompt, auth);
53+
const exitCode = result.exitCode ?? 1;
54+
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
55+
process.stdout.write(
56+
JSON.stringify({ decision: 'block', reason: 'Sonar detected secrets in prompt' }) + '\n',
57+
);
58+
}
59+
} catch (err) {
60+
logger.debug(`UserPromptSubmit secrets scan failed: ${(err as Error).message}`);
61+
}
62+
}

src/cli/commands/hook/claude-pre-tool-use.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* SonarQube CLI
3-
* Copyright (C) 2026 SonarSource Sàrl
3+
* Copyright (C) SonarSource Sàrl
44
* mailto:info AT sonarsource DOT com
55
*
66
* This program is free software; you can redistribute it and/or

src/cli/commands/hook/stdin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*
22
* SonarQube CLI
3-
* Copyright (C) 2026 SonarSource Sàrl
3+
* Copyright (C) SonarSource Sàrl
44
* mailto:info AT sonarsource DOT com
55
*
66
* This program is free software; you can redistribute it and/or

src/lib/process.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface SpawnOptions {
2828
cwd?: string;
2929
env?: Record<string, string>;
3030
stdin?: StdioMode;
31+
stdinData?: string;
3132
stdout?: StdioMode;
3233
stderr?: StdioMode;
3334
detached?: boolean;
@@ -56,6 +57,7 @@ export async function spawnProcess(
5657
stdio: [options.stdin || 'ignore', options.stdout || 'pipe', options.stderr || 'pipe'],
5758
detached: options.detached || false,
5859
});
60+
options.onSpawn?.(() => proc.kill());
5961

6062
let stdout = '';
6163
let stderr = '';
@@ -72,8 +74,9 @@ export async function spawnProcess(
7274
});
7375
}
7476

75-
if (options.onSpawn) {
76-
options.onSpawn(() => proc.kill());
77+
if (options.stdinData !== undefined && proc.stdin) {
78+
proc.stdin.write(options.stdinData);
79+
proc.stdin.end();
7780
}
7881

7982
proc.on('error', reject);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) 2026 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// Integration tests for `sonar hook claude-prompt-submit`.
22+
// Runs the actual binary with real stdin to exercise scanText (stdinData path) in process.ts.
23+
//
24+
// Note: hardcoded token below is an intentional test fixture for the secret scanner.
25+
// sonar-ignore-next-line S6769
26+
27+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
28+
import { TestHarness } from '../../harness';
29+
30+
// Hardcoded test token — intentional fixture for secret detection, not a real credential
31+
// sonar-ignore-next-line S6769
32+
const GITHUB_TEST_TOKEN = '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';
33+
34+
// Unreachable server — binary handles connection-refused gracefully and proceeds with scan
35+
const FAKE_SERVER = 'http://localhost:19999';
36+
37+
describe('sonar hook claude-prompt-submit', () => {
38+
let harness: TestHarness;
39+
40+
beforeEach(async () => {
41+
harness = await TestHarness.create();
42+
});
43+
44+
afterEach(async () => {
45+
await harness.dispose();
46+
});
47+
48+
it(
49+
'exits 0 and outputs block JSON when prompt contains a secret',
50+
async () => {
51+
harness.state().withSecretsBinaryInstalled();
52+
harness.withAuth(FAKE_SERVER, 'fake-token');
53+
54+
const result = await harness.run('hook claude-prompt-submit', {
55+
stdin: JSON.stringify({ prompt: `my token is ${GITHUB_TEST_TOKEN}` }),
56+
});
57+
58+
expect(result.exitCode).toBe(0);
59+
const blockLine = result.stdout
60+
.split('\n')
61+
.map((l) => l.trim())
62+
.find((l) => l.startsWith('{') && l.includes('"block"'));
63+
expect(blockLine).toBeDefined();
64+
const output = JSON.parse(blockLine ?? '{}');
65+
expect(output.decision).toBe('block');
66+
expect(output.reason).toContain('secrets');
67+
},
68+
{ timeout: 30000 },
69+
);
70+
71+
it(
72+
'exits 0 and outputs nothing when prompt contains no secrets',
73+
async () => {
74+
harness.state().withSecretsBinaryInstalled();
75+
harness.withAuth(FAKE_SERVER, 'fake-token');
76+
77+
const result = await harness.run('hook claude-prompt-submit', {
78+
stdin: JSON.stringify({ prompt: 'please help me refactor this function' }),
79+
});
80+
81+
expect(result.exitCode).toBe(0);
82+
expect(result.stdout).not.toContain('"block"');
83+
},
84+
{ timeout: 30000 },
85+
);
86+
87+
it(
88+
'exits 0 and outputs nothing when stdin is invalid JSON',
89+
async () => {
90+
harness.state().withSecretsBinaryInstalled();
91+
harness.withAuth(FAKE_SERVER, 'fake-token');
92+
93+
const result = await harness.run('hook claude-prompt-submit', {
94+
stdin: 'not valid json {{',
95+
});
96+
97+
expect(result.exitCode).toBe(0);
98+
expect(result.stdout).not.toContain('"block"');
99+
},
100+
{ timeout: 15000 },
101+
);
102+
103+
it(
104+
'exits 0 and outputs nothing when prompt field is absent',
105+
async () => {
106+
harness.state().withSecretsBinaryInstalled();
107+
harness.withAuth(FAKE_SERVER, 'fake-token');
108+
109+
const result = await harness.run('hook claude-prompt-submit', {
110+
stdin: JSON.stringify({ tool_name: 'Read' }),
111+
});
112+
113+
expect(result.exitCode).toBe(0);
114+
expect(result.stdout).not.toContain('"block"');
115+
},
116+
{ timeout: 15000 },
117+
);
118+
119+
it(
120+
'exits 0 and outputs nothing when not authenticated',
121+
async () => {
122+
harness.state().withSecretsBinaryInstalled();
123+
// no withAuth — no active connection
124+
125+
const result = await harness.run('hook claude-prompt-submit', {
126+
stdin: JSON.stringify({ prompt: `my token is ${GITHUB_TEST_TOKEN}` }),
127+
});
128+
129+
expect(result.exitCode).toBe(0);
130+
expect(result.stdout).not.toContain('"block"');
131+
},
132+
{ timeout: 15000 },
133+
);
134+
135+
it(
136+
'exits 0 and outputs nothing when secrets binary is not installed',
137+
async () => {
138+
harness.withAuth(FAKE_SERVER, 'fake-token');
139+
140+
const result = await harness.run('hook claude-prompt-submit', {
141+
stdin: JSON.stringify({ prompt: `my token is ${GITHUB_TEST_TOKEN}` }),
142+
});
143+
144+
expect(result.exitCode).toBe(0);
145+
expect(result.stdout).not.toContain('"block"');
146+
},
147+
{ timeout: 15000 },
148+
);
149+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* SonarQube CLI
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
// Unit tests for agentPromptSubmit — only paths that cannot be exercised via integration tests:
22+
// • catch block when runSecretsBinaryOnText throws (no way to make the real binary throw)
23+
// • exitCode null branch (real binary always returns an integer exit code)
24+
25+
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
26+
import * as authResolver from '../../src/lib/auth-resolver';
27+
import * as stdinModule from '../../src/cli/commands/hook/stdin';
28+
import * as installSecrets from '../../src/cli/commands/_common/install/secrets';
29+
import * as analyzeSecrets from '../../src/cli/commands/analyze/secrets';
30+
import { agentPromptSubmit } from '../../src/cli/commands/hook/agent-prompt-submit';
31+
32+
describe('agentPromptSubmit (unit — impractical-via-e2e paths)', () => {
33+
let stdoutSpy: ReturnType<typeof spyOn>;
34+
let resolveAuthSpy: ReturnType<typeof spyOn>;
35+
let readStdinJsonSpy: ReturnType<typeof spyOn>;
36+
let resolveSecretsBinaryPathSpy: ReturnType<typeof spyOn>;
37+
let runSecretsBinaryOnTextSpy: ReturnType<typeof spyOn>;
38+
39+
beforeEach(() => {
40+
stdoutSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
41+
resolveAuthSpy = spyOn(authResolver, 'resolveAuth').mockResolvedValue({
42+
token: 'tok',
43+
serverUrl: 'https://sonarcloud.io',
44+
connectionType: 'cloud',
45+
orgKey: 'myorg',
46+
});
47+
readStdinJsonSpy = spyOn(stdinModule, 'readStdinJson').mockResolvedValue({
48+
prompt: 'help me refactor this',
49+
});
50+
resolveSecretsBinaryPathSpy = spyOn(installSecrets, 'resolveSecretsBinaryPath').mockReturnValue(
51+
'/usr/bin/sonar-secrets',
52+
);
53+
runSecretsBinaryOnTextSpy = spyOn(analyzeSecrets, 'runSecretsBinaryOnText').mockResolvedValue({
54+
exitCode: 0,
55+
stdout: '',
56+
stderr: '',
57+
});
58+
});
59+
60+
afterEach(() => {
61+
stdoutSpy.mockRestore();
62+
resolveAuthSpy.mockRestore();
63+
readStdinJsonSpy.mockRestore();
64+
resolveSecretsBinaryPathSpy.mockRestore();
65+
runSecretsBinaryOnTextSpy.mockRestore();
66+
});
67+
68+
it('outputs nothing and does not throw when scan throws an error', async () => {
69+
runSecretsBinaryOnTextSpy.mockRejectedValue(new Error('scan process crashed'));
70+
71+
await agentPromptSubmit();
72+
73+
expect(stdoutSpy).not.toHaveBeenCalled();
74+
});
75+
76+
it('outputs nothing when exitCode is null', async () => {
77+
runSecretsBinaryOnTextSpy.mockResolvedValue({ exitCode: null, stdout: '', stderr: '' });
78+
79+
await agentPromptSubmit();
80+
81+
expect(stdoutSpy).not.toHaveBeenCalled();
82+
});
83+
});

0 commit comments

Comments
 (0)