Skip to content

Commit f4cbc21

Browse files
PR feedback: rename symbols, remove codex stub, fix hook naming, update tests
- Rename scanFiles → scanFilesForSecrets in secrets-scan.ts - Rename callbackCommand → hookCommand in command-tree.ts - Remove codex-pre-tool-use command (out of scope) - Remove PR-specific comments from command-tree.ts - Rename agent-prompt-submit → claude-prompt-submit - Rename agent-post-tool-use → claude-post-tool-use - Consolidate duplicate hook.test.ts tests, assert description shown - Update README.md via gen:docs
1 parent bf10cc2 commit f4cbc21

12 files changed

Lines changed: 160 additions & 246 deletions

File tree

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ Revoke a user token
154154
sonar api post "/api/user_tokens/revoke" --data '{"name":"my-token"}'
155155
```
156156

157+
157158
---
158159

159160
### `sonar integrate`
@@ -180,7 +181,7 @@ Setup SonarQube integration for Claude Code. This will install secrets scanning
180181

181182
| Option | Type | Required | Description | Default |
182183
| ------------------- | ------- | -------- | --------------------------------------------------------------------------- | ------- |
183-
| `--project`, `-p` | string | No | Project key | - |
184+
| `--project`, `-p` | string | No | SonarCloud project key (overrides auto-detected project) | - |
184185
| `--non-interactive` | boolean | No | Non-interactive mode (no prompts) | - |
185186
| `--global`, `-g` | boolean | No | Install hooks and config globally to ~/.claude instead of project directory | - |
186187

@@ -391,6 +392,40 @@ Update sonar CLI to the latest version
391392

392393
---
393394

395+
### `sonar hook`
396+
397+
Internal callback handlers for agent and git hooks
398+
399+
#### `sonar hook claude-pre-tool-use`
400+
401+
PreToolUse handler: scan files for secrets before agent reads them
402+
403+
---
404+
405+
#### `sonar hook codex-pre-tool-use`
406+
407+
PreToolUse handler for Codex: scan files for secrets before agent reads them
408+
409+
---
410+
411+
#### `sonar hook agent-prompt-submit`
412+
413+
UserPromptSubmit handler: scan prompts for secrets before sending
414+
415+
---
416+
417+
#### `sonar hook agent-post-tool-use`
418+
419+
PostToolUse handler: run SQAA analysis on modified files
420+
421+
**Options:**
422+
423+
| Option | Type | Required | Description | Default |
424+
| ----------------- | ------ | -------- | ---------------------- | ------- |
425+
| `--project`, `-p` | string | No | SonarCloud project key | - |
426+
427+
---
428+
394429
## Option Types
395430

396431
- `string` — text value (e.g. `--server https://sonarcloud.io`)
@@ -425,7 +460,7 @@ Both are enabled by default and share the same opt-out toggle. To disable all da
425460
sonar config telemetry --disabled
426461
```
427462

428-
No personally identifiable information is transmitted. File paths in error reports are anonymized by replacing your home directory with `~`.
463+
No personally identifiable information is transmitted.
429464

430465
## Contributing
431466

src/cli/command-tree.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -232,36 +232,27 @@ COMMAND_TREE.command('self-update')
232232

233233
// Hidden callback command — internal handlers for agent and git hooks.
234234
// Shell hook scripts call `sonar hook <event>` to delegate all business logic to TypeScript.
235-
export const callbackCommand = COMMAND_TREE.command('hook', { hidden: true })
236-
.description('Internal callback handlers for agent and git hooks')
235+
export const hookCommand = COMMAND_TREE.command('hook', { hidden: true })
236+
.description('Internal hook handlers for agent and git hooks')
237237
.enablePositionalOptions()
238238
.anonymousAction(function (this: Command) {
239239
this.outputHelp();
240240
});
241241

242-
callbackCommand
242+
hookCommand
243243
.command('claude-pre-tool-use')
244244
.description('PreToolUse handler: scan files for secrets before agent reads them')
245245
.anonymousAction(() => claudePreToolUse());
246246

247-
// codex-pre-tool-use reuses the same handler (same hook event, different agent name)
248-
callbackCommand
249-
.command('codex-pre-tool-use')
250-
.description('PreToolUse handler for Codex: scan files for secrets before agent reads them')
251-
.anonymousAction(() => claudePreToolUse());
252-
253-
// agent-prompt-submit and agent-post-tool-use are installed by `sonar integrate claude`.
254-
// They are implemented in subsequent PRs; stubs here ensure scripts don't fail with
255-
// "unknown command" on a CLI that only has CLI-244/245.
256-
callbackCommand
257-
.command('agent-prompt-submit')
247+
hookCommand
248+
.command('claude-prompt-submit')
258249
.description('UserPromptSubmit handler: scan prompts for secrets before sending')
259250
.anonymousAction(() => {
260251
return;
261252
});
262253

263-
callbackCommand
264-
.command('agent-post-tool-use')
254+
hookCommand
255+
.command('claude-post-tool-use')
265256
.option('-p, --project <project>', 'SonarCloud project key')
266257
.description('PostToolUse handler: run SQAA analysis on modified files')
267258
.anonymousAction(() => {

src/cli/commands/_common/install/secrets.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,13 @@ function cleanupOldVersionBinaries(binDir: string, currentBinaryName: string): v
217217
export function buildLocalBinaryName(platformInfo: PlatformInfo): string {
218218
return `sonar-secrets-${SONAR_SECRETS_VERSION}${buildPlatformSuffix(platformInfo)}`;
219219
}
220+
221+
/**
222+
* Returns the path to the installed sonar-secrets binary, or null if not present.
223+
* Never downloads — use this where silent operation is required (e.g. hook handlers).
224+
*/
225+
export function resolveSecretsBinaryPath(): string | null {
226+
const platform = detectPlatform();
227+
const binaryPath = join(BIN_DIR, buildLocalBinaryName(platform));
228+
return existsSync(binaryPath) ? binaryPath : null;
229+
}

src/cli/commands/analyze/secrets.ts

Lines changed: 72 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
2020
import { existsSync } from 'node:fs';
21+
import { join } from 'node:path';
2122
import { spawnProcess } from '../../../lib/process';
22-
import type { SpawnResult } from '../../../lib/process';
23+
import type { SpawnOptions, SpawnResult } from '../../../lib/process';
2324
import type { ResolvedAuth } from '../../../lib/auth-resolver';
2425
import logger from '../../../lib/logger';
2526
import { blank, error, print, success, text } from '../../../ui';
@@ -45,32 +46,71 @@ const BINARY_AUTH_TOKEN_ENV = 'SONAR_SECRETS_TOKEN';
4546
const SCAN_TIMEOUT_MS = 30000;
4647
const STDIN_READ_TIMEOUT_MS = 5000;
4748

49+
export const EXIT_CODE_SECRETS_FOUND = 51;
50+
51+
/**
52+
* Run sonar-secrets binary on the given files. Returns the full spawn result.
53+
* Kills the child process on timeout.
54+
*/
55+
export async function runSecretsBinary(
56+
binaryPath: string,
57+
files: string[],
58+
auth: ResolvedAuth,
59+
): Promise<SpawnResult> {
60+
return spawnWithTimeout(binaryPath, ['--non-interactive', ...files], {
61+
stdin: 'pipe',
62+
stdout: 'pipe',
63+
stderr: 'pipe',
64+
env: buildAuthEnv(auth),
65+
});
66+
}
67+
68+
function buildAuthEnv(auth: ResolvedAuth): Record<string, string> {
69+
return { [BINARY_AUTH_URL_ENV]: auth.serverUrl, [BINARY_AUTH_TOKEN_ENV]: auth.token };
70+
}
71+
72+
async function spawnWithTimeout(
73+
binaryPath: string,
74+
args: string[],
75+
options: SpawnOptions,
76+
): Promise<SpawnResult> {
77+
let killChild: (() => void) | undefined;
78+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
79+
try {
80+
return await Promise.race([
81+
spawnProcess(binaryPath, args, {
82+
...options,
83+
onSpawn: (kill) => {
84+
killChild = kill;
85+
},
86+
}),
87+
new Promise<never>((_, reject) => {
88+
timeoutId = setTimeout(() => {
89+
killChild?.();
90+
reject(new Error(`Scan timed out after ${SCAN_TIMEOUT_MS}ms`));
91+
}, SCAN_TIMEOUT_MS);
92+
}),
93+
]);
94+
} finally {
95+
clearTimeout(timeoutId);
96+
}
97+
}
98+
4899
async function handleCheckCommand(
49100
options: AnalyzeSecretsOptions,
50101
auth: ResolvedAuth,
51102
): Promise<void> {
52103
validateScanOptions(options);
53104
const binaryPath = await installSecretsBinary();
54-
const { authUrl, authToken } = setupScanEnvironment(binaryPath, auth);
55105
const scanStartTime = Date.now();
56106

57107
if (options.stdin) {
58-
await performStdinScan(binaryPath, authUrl, authToken, scanStartTime);
108+
reportScanResult(await runScanFromStdin(binaryPath, auth), scanStartTime);
59109
} else {
60-
await performPathsScan(binaryPath, options.paths ?? [], authUrl, authToken, scanStartTime);
110+
await performPathsScan(binaryPath, options.paths ?? [], auth, scanStartTime);
61111
}
62112
}
63113

64-
interface ScanEnvironment {
65-
binaryPath: string;
66-
authUrl?: string;
67-
authToken?: string;
68-
}
69-
70-
function setupScanEnvironment(binaryPath: string, auth: ResolvedAuth): ScanEnvironment {
71-
return { binaryPath, authUrl: auth.serverUrl, authToken: auth.token };
72-
}
73-
74114
function validateScanOptions(options: { paths?: string[]; stdin?: boolean }): void {
75115
const hasPaths = (options.paths?.length ?? 0) > 0;
76116
if (!hasPaths && !options.stdin) {
@@ -82,28 +122,10 @@ function validateScanOptions(options: { paths?: string[]; stdin?: boolean }): vo
82122
}
83123
}
84124

85-
async function performStdinScan(
86-
binaryPath: string,
87-
authUrl: string | undefined,
88-
authToken: string | undefined,
89-
scanStartTime: number,
90-
): Promise<void> {
91-
const result = await runScanFromStdin(binaryPath, authUrl, authToken);
92-
const scanDurationMs = Date.now() - scanStartTime;
93-
94-
const exitCode = result.exitCode ?? 1;
95-
if (exitCode === 0) {
96-
handleScanSuccess(result, scanDurationMs);
97-
} else {
98-
handleScanFailure(result, scanDurationMs, exitCode);
99-
}
100-
}
101-
102125
async function performPathsScan(
103126
binaryPath: string,
104127
paths: string[],
105-
authUrl: string | undefined,
106-
authToken: string | undefined,
128+
auth: ResolvedAuth,
107129
scanStartTime: number,
108130
): Promise<void> {
109131
if (paths.length === 0) {
@@ -116,9 +138,12 @@ async function performPathsScan(
116138
}
117139
}
118140

119-
const result = await runScan(binaryPath, paths, authUrl, authToken);
120-
const scanDurationMs = Date.now() - scanStartTime;
141+
const result = await runSecretsBinary(binaryPath, paths, auth);
142+
reportScanResult(result, scanStartTime);
143+
}
121144

145+
function reportScanResult(result: SpawnResult, scanStartTime: number): void {
146+
const scanDurationMs = Date.now() - scanStartTime;
122147
const exitCode = result.exitCode ?? 1;
123148
if (exitCode === 0) {
124149
handleScanSuccess(result, scanDurationMs);
@@ -127,72 +152,21 @@ async function performPathsScan(
127152
}
128153
}
129154

130-
async function runScan(
131-
binaryPath: string,
132-
paths: string[],
133-
authUrl: string | undefined,
134-
authToken: string | undefined,
135-
): Promise<SpawnResult> {
136-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
137-
try {
138-
return await Promise.race([
139-
spawnProcess(binaryPath, ['--non-interactive', ...paths], {
140-
stdin: 'pipe',
141-
stdout: 'pipe',
142-
stderr: 'pipe',
143-
env: {
144-
...(authUrl && authToken
145-
? { [BINARY_AUTH_URL_ENV]: authUrl, [BINARY_AUTH_TOKEN_ENV]: authToken }
146-
: {}),
147-
},
148-
}),
149-
new Promise<never>((_resolve, reject) => {
150-
timeoutId = setTimeout(() => {
151-
reject(new Error(`Scan timed out after ${SCAN_TIMEOUT_MS}ms`));
152-
}, SCAN_TIMEOUT_MS);
153-
}),
154-
]);
155-
} finally {
156-
clearTimeout(timeoutId);
157-
}
158-
}
159-
160-
async function runScanFromStdin(
161-
binaryPath: string,
162-
authUrl: string | undefined,
163-
authToken: string | undefined,
164-
): Promise<SpawnResult> {
155+
async function runScanFromStdin(binaryPath: string, auth: ResolvedAuth): Promise<SpawnResult> {
165156
const { writeFileSync, unlinkSync } = await import('node:fs');
166157
const { tmpdir } = await import('node:os');
167-
const pathModule = await import('node:path');
168-
const pathJoin = (...args: string[]) => pathModule.join(...args);
169158

170159
const stdinData = await readStdin();
160+
const tempFile = join(tmpdir(), `sonar-secrets-scan-${Date.now()}.tmp`);
171161

172-
const tempFile = pathJoin(tmpdir(), `sonar-secrets-scan-${Date.now()}.tmp`);
173-
174-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
162+
writeFileSync(tempFile, stdinData);
175163
try {
176-
writeFileSync(tempFile, stdinData);
177-
178-
return await Promise.race([
179-
spawnProcess(binaryPath, [tempFile], {
180-
stdout: 'pipe',
181-
stderr: 'pipe',
182-
env: {
183-
...(authUrl && authToken
184-
? { [BINARY_AUTH_URL_ENV]: authUrl, [BINARY_AUTH_TOKEN_ENV]: authToken }
185-
: {}),
186-
},
187-
}),
188-
new Promise<never>((_resolve, reject) => {
189-
timeoutId = setTimeout(() => {
190-
reject(new Error(`Scan timed out after ${SCAN_TIMEOUT_MS}ms`));
191-
}, SCAN_TIMEOUT_MS);
192-
}),
193-
]);
164+
return await spawnWithTimeout(binaryPath, [tempFile], {
165+
stdout: 'pipe',
166+
stderr: 'pipe',
167+
env: buildAuthEnv(auth),
168+
});
194169
} finally {
195-
clearTimeout(timeoutId);
196170
try {
197171
unlinkSync(tempFile);
198172
} catch {
@@ -228,21 +202,18 @@ async function readStdin(): Promise<string> {
228202
}
229203

230204
function handleScanSuccess(result: { stdout: string }, scanDurationMs: number): void {
205+
blank();
206+
success('Scan completed successfully');
231207
try {
232208
const scanResult = JSON.parse(result.stdout);
233-
blank();
234-
success('Scan completed successfully');
235209
text(` Duration: ${scanDurationMs}ms`);
236210
displayScanResults(scanResult);
237-
blank();
238211
} catch (parseError) {
239212
logger.debug(`Failed to parse JSON output: ${(parseError as Error).message}`);
240213
blank();
241-
success('Scan completed successfully');
242-
blank();
243214
print(result.stdout);
244-
blank();
245215
}
216+
blank();
246217
}
247218

248219
function displayScanResults(scanResult: {
@@ -270,8 +241,6 @@ function displayScanResults(scanResult: {
270241
});
271242
}
272243

273-
const EXIT_CODE_SECRETS_FOUND = 51;
274-
275244
function handleScanFailure(
276245
result: { exitCode: number | null; stderr: string; stdout: string },
277246
scanDurationMs: number,
@@ -293,11 +262,7 @@ function handleScanFailure(
293262
}
294263

295264
function handleScanError(err: unknown): void {
296-
if (err instanceof InvalidOptionError) {
297-
throw err;
298-
}
299-
300-
if (err instanceof CommandFailedError) {
265+
if (err instanceof InvalidOptionError || err instanceof CommandFailedError) {
301266
throw err;
302267
}
303268

0 commit comments

Comments
 (0)