Skip to content

Commit 384bf12

Browse files
CLI-248 Git hook callbacks for pre-commit and pre-push
1 parent 129cc50 commit 384bf12

14 files changed

Lines changed: 1029 additions & 123 deletions

src/cli/command-tree.ts

Lines changed: 12 additions & 0 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

@@ -259,6 +261,16 @@ hookCommand
259261
return;
260262
});
261263

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

src/cli/commands/hook/stdin.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@
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 raw: string;
40+
try {
41+
raw = await readRawStdin();
42+
} catch {
43+
return []; // timeout or read error — allow the push
44+
}
45+
return raw
46+
.trim()
47+
.split('\n')
48+
.filter(Boolean)
49+
.map((line) => {
50+
const [localRef, localSha, remoteRef, remoteSha] = line.split(' ');
51+
return { localRef, localSha, remoteRef, remoteSha };
52+
})
53+
.filter((r) => r.localSha && r.remoteSha);
54+
}
55+
2656
/**
2757
* Read stdin, parse as JSON, and return the result typed as T.
2858
* Throws if stdin is not valid JSON or if the read times out.

src/cli/commands/integrate/git/git-shell-fragments.ts

Lines changed: 4 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -27,38 +27,10 @@ export const HOOK_MARKER = 'Sonar secrets scan - installed by sonar integrate gi
2727

2828
export const SONAR_HOOK_SKIP_SECRETS_MESSAGE = 'sonarqube-cli not found, skipping secrets scan';
2929

30-
/**
31-
* All-zero object id Git passes on pre-push stdin for ref deletion (`local_sha`) and new refs
32-
* (`remote_sha`). See githooks(5) "pre-push". SHA-1 length; SHA-256 repos use 64 hex zeros instead.
33-
*/
34-
const GIT_HOOK_NULL_OID = '0000000000000000000000000000000000000000';
35-
36-
// ─── Shared block ─────────────────────────────────────────────────────────────
37-
// Used inside `while read ... done` in both native and Husky pre-push scripts.
38-
// filesVar: shell variable name to assign results to.
39-
// Indented 4 spaces to sit inside `while` + `if [ remote_sha = null oid ]`.
40-
// `$EMPTY_TREE` is set once before the loop (see prePushBody).
41-
function newBranchPushBlock(filesVar: string): string {
42-
return (
43-
` # New branch push — enumerate commits not yet on any remote, then diff-tree each one\n` +
44-
` COMMITS=$(git rev-list "$local_sha" --not --remotes 2>/dev/null)\n` +
45-
` if [ -n "$COMMITS" ]; then\n` +
46-
` ${filesVar}=$(echo "$COMMITS" | while IFS= read -r c; do\n` +
47-
` git diff-tree --no-commit-id -r --name-only --diff-filter=ACMR "$c" 2>/dev/null\n` +
48-
` done | sort -u)\n` +
49-
` else\n` +
50-
` # No other remotes to compare against — diff the full branch against an empty tree\n` +
51-
` ${filesVar}=$(git diff --name-only --diff-filter=ACMR $EMPTY_TREE "$local_sha" 2>/dev/null)\n` +
52-
` fi`
53-
);
54-
}
55-
5630
// ─── Binary resolution blocks ──────────────────────────────────────────────────
5731
// The only material difference between native and Husky variants.
5832
// Husky injects node_modules/.bin into PATH — strip it before looking up sonar.
5933

60-
type BinBlock = () => string;
61-
6234
function nativeBinBlock(): string {
6335
return (
6436
// `|| :` avoids exiting under `sh -e` when `command -v` fails (missing sonar).
@@ -75,54 +47,16 @@ function huskyBinBlock(): string {
7547
);
7648
}
7749

78-
// ─── Shared script templates ───────────────────────────────────────────────────
79-
// Accept a binBlock function (native or Husky) to produce the correct resolver.
80-
81-
function preCommitBody(filesVar: string, binBlock: BinBlock): string {
82-
return (
83-
`${filesVar}=$(git diff --cached --name-only --diff-filter=ACMR)\n` +
84-
`[ -z "$${filesVar}" ] && exit 0\n` +
85-
`${binBlock()}\n` +
86-
`echo "$${filesVar}" | tr '\\n' '\\0' | xargs -0 "$SONAR_BIN" analyze secrets -- || exit 1\n`
87-
);
88-
}
89-
90-
function prePushBody(filesVar: string, binBlock: BinBlock): string {
91-
return (
92-
`${binBlock()}\n` +
93-
`# Canonical empty tree: \`git mktree\` with no entries (correct for the repo's hash algorithm).\n` +
94-
`EMPTY_TREE=$(printf '' | git mktree)\n` +
95-
`# For each ref being pushed, scan files in the new commits\n` +
96-
`while read -r local_ref local_sha remote_ref remote_sha; do\n` +
97-
` # Branch deletion — nothing to scan\n` +
98-
` [ "$local_sha" = '${GIT_HOOK_NULL_OID}' ] && continue\n` +
99-
` if [ "$remote_sha" = '${GIT_HOOK_NULL_OID}' ]; then\n` +
100-
`${newBranchPushBlock(filesVar)}\n` +
101-
` else\n` +
102-
` ${filesVar}=$(git diff --name-only --diff-filter=ACMR "$remote_sha" "$local_sha")\n` +
103-
` fi\n` +
104-
` [ -z "$${filesVar}" ] && continue\n` +
105-
` echo "$${filesVar}" | tr '\\n' '\\0' | xargs -0 "$SONAR_BIN" analyze secrets -- || exit 1\n` +
106-
`done\n` +
107-
`exit 0\n`
108-
);
109-
}
110-
11150
// ─── Native .git/hooks/ scripts ───────────────────────────────────────────────
11251
// Standalone files written to .git/hooks/pre-commit or .git/hooks/pre-push.
11352
// Use plain PATH lookup — git does not inject node_modules/.bin.
11453

11554
export function getPreCommitHookScript(): string {
116-
return (
117-
`#!/bin/sh\n` +
118-
`# ${HOOK_MARKER}\n` +
119-
`# Staged files (added/copy/modified, not deleted)\n` +
120-
preCommitBody('FILES', nativeBinBlock)
121-
);
55+
return `#!/bin/sh\n# ${HOOK_MARKER}\n${nativeBinBlock()}\n"$SONAR_BIN" hook git-pre-commit\n`;
12256
}
12357

12458
export function getPrePushHookScript(): string {
125-
return `#!/bin/sh\n# ${HOOK_MARKER}\n` + prePushBody('FILES', nativeBinBlock);
59+
return `#!/bin/sh\n# ${HOOK_MARKER}\n${nativeBinBlock()}\n"$SONAR_BIN" hook git-pre-push\n`;
12660
}
12761

12862
export function getHookScript(hook: GitHookType): string {
@@ -135,11 +69,11 @@ export function getHookScript(hook: GitHookType): string {
13569
// looking up `sonar` to avoid accidentally running a project-local package.
13670

13771
export function getHuskyPreCommitSnippet(): string {
138-
return `\n# ${HOOK_MARKER}\n` + preCommitBody('FILES', huskyBinBlock);
72+
return `\n# ${HOOK_MARKER}\n${huskyBinBlock()}\n"$SONAR_BIN" hook git-pre-commit\n`;
13973
}
14074

14175
export function getHuskyPrePushSnippet(): string {
142-
return `\n# ${HOOK_MARKER}\n` + prePushBody('FILES', huskyBinBlock);
76+
return `\n# ${HOOK_MARKER}\n${huskyBinBlock()}\n"$SONAR_BIN" hook git-pre-push\n`;
14377
}
14478

14579
export function getHuskySnippet(hook: GitHookType): string {

src/lib/process.ts

Lines changed: 6 additions & 0 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;
@@ -76,6 +77,11 @@ export async function spawnProcess(
7677
options.onSpawn(() => proc.kill());
7778
}
7879

80+
if (options.stdinData !== undefined && proc.stdin) {
81+
proc.stdin.write(options.stdinData);
82+
proc.stdin.end();
83+
}
84+
7985
proc.on('error', reject);
8086

8187
proc.on('exit', (code) => {

0 commit comments

Comments
 (0)