Skip to content

Commit 39242f7

Browse files
CLI-248 Git hook callbacks for pre-commit and pre-push
1 parent c4b4665 commit 39242f7

14 files changed

Lines changed: 1043 additions & 133 deletions

src/cli/command-tree.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ 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 { gitPreCommit } from './commands/hook/git-pre-commit';
44+
import { gitPrePush } from './commands/hook/git-pre-push';
4345

4446
const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;
4547

@@ -231,42 +233,49 @@ COMMAND_TREE.command('self-update')
231233

232234
// Hidden callback command — internal handlers for agent and git hooks.
233235
// Shell hook scripts call `sonar hook <event>` to delegate all business logic to TypeScript.
234-
export const callbackCommand = COMMAND_TREE.command('hook', { hidden: true })
236+
export const hookCommand = COMMAND_TREE.command('hook', { hidden: true })
235237
.description('Internal callback handlers for agent and git hooks')
236238
.enablePositionalOptions()
237239
.anonymousAction(function (this: Command) {
238240
this.outputHelp();
239241
});
240242

241-
callbackCommand
243+
hookCommand
242244
.command('claude-pre-tool-use')
243245
.description('PreToolUse handler: scan files for secrets before agent reads them')
244246
.anonymousAction(() => claudePreToolUse());
245247

246248
// codex-pre-tool-use reuses the same handler (same hook event, different agent name)
247-
callbackCommand
249+
hookCommand
248250
.command('codex-pre-tool-use')
249251
.description('PreToolUse handler for Codex: scan files for secrets before agent reads them')
250252
.anonymousAction(() => claudePreToolUse());
251253

252-
// agent-prompt-submit and agent-post-tool-use are installed by `sonar integrate claude`.
253-
// They are implemented in subsequent PRs; stubs here ensure scripts don't fail with
254-
// "unknown command" on a CLI that only has CLI-244/245.
255-
callbackCommand
254+
hookCommand
256255
.command('agent-prompt-submit')
257256
.description('UserPromptSubmit handler: scan prompts for secrets before sending')
258257
.anonymousAction(() => {
259258
return;
260259
});
261260

262-
callbackCommand
261+
hookCommand
263262
.command('agent-post-tool-use')
264263
.option('-p, --project <project>', 'SonarCloud project key')
265264
.description('PostToolUse handler: run SQAA analysis on modified files')
266265
.anonymousAction(() => {
267266
return;
268267
});
269268

269+
hookCommand
270+
.command('git-pre-commit')
271+
.description('git pre-commit handler: scan staged files for secrets')
272+
.anonymousAction(() => gitPreCommit());
273+
274+
hookCommand
275+
.command('git-pre-push')
276+
.description('git pre-push handler: scan files in new commits for secrets')
277+
.anonymousAction(() => gitPrePush());
278+
270279
// Hidden flush command — only registered when running as a telemetry worker.
271280
if (process.env[TELEMETRY_FLUSH_MODE_ENV]) {
272281
COMMAND_TREE.command('flush-telemetry', { hidden: true }).anonymousAction(flushTelemetry);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
// git pre-commit callback handler — scans staged files for secrets before commit.
22+
// Replaces the shell logic that was previously embedded in the git hook script.
23+
24+
import { resolveAuth } from '../../../lib/auth-resolver';
25+
import logger from '../../../lib/logger';
26+
import { spawnProcess } from '../../../lib/process';
27+
import { resolveSecretsBinaryPath, scanFiles, EXIT_CODE_SECRETS_FOUND } from './secrets-scan';
28+
import { CommandFailedError } from '../_common/error';
29+
30+
export async function gitPreCommit(): Promise<void> {
31+
const stagedFiles = await getStagedFiles();
32+
if (stagedFiles.length === 0) return;
33+
34+
const auth = await resolveAuth().catch(() => null);
35+
if (!auth) return; // not authenticated — skip gracefully
36+
37+
const binaryPath = resolveSecretsBinaryPath();
38+
if (!binaryPath) return; // binary not installed — skip gracefully
39+
40+
try {
41+
const exitCode = await scanFiles(binaryPath, stagedFiles, auth);
42+
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
43+
throw new CommandFailedError('Secrets detected in staged files');
44+
}
45+
} catch (err) {
46+
if (err instanceof CommandFailedError) throw err;
47+
logger.debug(`git pre-commit secrets scan failed: ${(err as Error).message}`);
48+
throw new CommandFailedError('Secrets scan failed');
49+
}
50+
}
51+
52+
async function getStagedFiles(): Promise<string[]> {
53+
try {
54+
const result = await spawnProcess('git', [
55+
'diff',
56+
'--cached',
57+
'--name-only',
58+
'--diff-filter=ACMR',
59+
]);
60+
if (result.exitCode !== 0) return [];
61+
return result.stdout.trim().split('\n').filter(Boolean);
62+
} catch {
63+
return [];
64+
}
65+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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+
// git pre-push callback handler — scans files in new commits for secrets before push.
22+
// Replaces the shell logic that was previously embedded in the git hook script.
23+
24+
import { resolveAuth } from '../../../lib/auth-resolver';
25+
import logger from '../../../lib/logger';
26+
import { spawnProcess } from '../../../lib/process';
27+
import { resolveSecretsBinaryPath, scanFiles, EXIT_CODE_SECRETS_FOUND } from './secrets-scan';
28+
import { readGitPushRefs } from './stdin';
29+
import type { PushRef } from './stdin';
30+
import { CommandFailedError } from '../_common/error';
31+
32+
const GIT_NULL_OID = '0000000000000000000000000000000000000000';
33+
34+
export async function gitPrePush(): Promise<void> {
35+
const refs = await readGitPushRefs();
36+
if (refs.length === 0) return;
37+
38+
const auth = await resolveAuth().catch(() => null);
39+
if (!auth) return; // not authenticated — skip gracefully
40+
41+
const binaryPath = resolveSecretsBinaryPath();
42+
if (!binaryPath) return; // binary not installed — skip gracefully
43+
44+
const emptyTree = await getEmptyTree();
45+
46+
for (const ref of refs) {
47+
if (ref.localSha === GIT_NULL_OID) continue; // branch deletion — nothing to scan
48+
49+
const files = await getFilesForRef(ref, emptyTree);
50+
if (files.length === 0) continue;
51+
52+
try {
53+
const exitCode = await scanFiles(binaryPath, files, auth);
54+
if (exitCode === EXIT_CODE_SECRETS_FOUND) {
55+
throw new CommandFailedError('Secrets detected in pushed commits');
56+
}
57+
} catch (err) {
58+
if (err instanceof CommandFailedError) throw err;
59+
logger.debug(`git pre-push secrets scan failed: ${(err as Error).message}`);
60+
throw new CommandFailedError('Secrets scan failed');
61+
}
62+
}
63+
}
64+
65+
async function getEmptyTree(): Promise<string> {
66+
try {
67+
const result = await spawnProcess('git', ['mktree'], { stdin: 'pipe', stdinData: '' });
68+
return result.stdout.trim() || GIT_NULL_OID;
69+
} catch {
70+
return GIT_NULL_OID;
71+
}
72+
}
73+
74+
async function getFilesForRef(ref: PushRef, emptyTree: string): Promise<string[]> {
75+
try {
76+
if (ref.remoteSha === GIT_NULL_OID) {
77+
return await getFilesForNewBranch(ref.localSha, emptyTree);
78+
}
79+
const result = await spawnProcess('git', [
80+
'diff',
81+
'--name-only',
82+
'--diff-filter=ACMR',
83+
ref.remoteSha,
84+
ref.localSha,
85+
]);
86+
return result.stdout.trim().split('\n').filter(Boolean);
87+
} catch {
88+
return [];
89+
}
90+
}
91+
92+
async function getFilesForNewBranch(localSha: string, emptyTree: string): Promise<string[]> {
93+
try {
94+
const commitsResult = await spawnProcess('git', ['rev-list', localSha, '--not', '--remotes']);
95+
const commits = commitsResult.stdout.trim().split('\n').filter(Boolean);
96+
97+
if (commits.length > 0) {
98+
const fileSet = new Set<string>();
99+
for (const commit of commits) {
100+
const result = await spawnProcess('git', [
101+
'diff-tree',
102+
'--root',
103+
'--no-commit-id',
104+
'-r',
105+
'--name-only',
106+
'--diff-filter=ACMR',
107+
commit,
108+
]);
109+
result.stdout
110+
.trim()
111+
.split('\n')
112+
.filter(Boolean)
113+
.forEach((f) => fileSet.add(f));
114+
}
115+
return Array.from(fileSet);
116+
}
117+
118+
// No other remotes — diff full branch against empty tree
119+
const result = await spawnProcess('git', [
120+
'diff',
121+
'--name-only',
122+
'--diff-filter=ACMR',
123+
emptyTree,
124+
localSha,
125+
]);
126+
return result.stdout.trim().split('\n').filter(Boolean);
127+
} catch {
128+
return [];
129+
}
130+
}

src/cli/commands/hook/stdin.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@
2323

2424
const STDIN_TIMEOUT_MS = 5000;
2525

26+
export interface PushRef {
27+
localRef: string;
28+
localSha: string;
29+
remoteRef: string;
30+
remoteSha: string;
31+
}
32+
33+
/**
34+
* Read git pre-push refs from stdin.
35+
* Git passes refs as raw space-separated lines: <localRef> <localSha> <remoteRef> <remoteSha>
36+
* Returns an empty array on timeout or error.
37+
*/
38+
export async function readGitPushRefs(): Promise<PushRef[]> {
39+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
40+
try {
41+
return await Promise.race([
42+
new Promise<PushRef[]>((resolve) => {
43+
const chunks: Buffer[] = [];
44+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
45+
process.stdin.on('end', () => {
46+
const raw = Buffer.concat(chunks).toString('utf-8');
47+
const refs = raw
48+
.trim()
49+
.split('\n')
50+
.filter(Boolean)
51+
.map((line) => {
52+
const [localRef, localSha, remoteRef, remoteSha] = line.split(' ');
53+
return { localRef, localSha, remoteRef, remoteSha };
54+
})
55+
.filter((r) => r.localSha && r.remoteSha);
56+
resolve(refs);
57+
});
58+
process.stdin.on('error', () => {
59+
resolve([]);
60+
});
61+
}),
62+
new Promise<PushRef[]>((resolve) => {
63+
timeoutId = setTimeout(() => { resolve([]); }, STDIN_TIMEOUT_MS);
64+
}),
65+
]);
66+
} finally {
67+
clearTimeout(timeoutId);
68+
}
69+
}
70+
2671
/**
2772
* Read stdin, parse as JSON, and return the result typed as T.
2873
* Throws if stdin is not valid JSON or if the read times out.

0 commit comments

Comments
 (0)