-
Notifications
You must be signed in to change notification settings - Fork 64
Expand file tree
/
Copy pathcommit-msg.mts
More file actions
325 lines (303 loc) · 12.1 KB
/
Copy pathcommit-msg.mts
File metadata and controls
325 lines (303 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
#!/usr/bin/env node
// Socket Security Commit-msg Hook
//
// Two responsibilities:
// 1. Block commits that introduce API keys / .env files (security
// layer that runs even when pre-commit is bypassed via
// `--no-verify`).
// 2. Auto-strip AI attribution lines from the commit message before
// git records the commit.
//
// Wired via .git-hooks/commit-msg, the sibling shell shim, which git
// invokes when `core.hooksPath` points at .git-hooks/ — set by
// `node scripts/install-git-hooks.mts` at `pnpm install` time. The
// shim execs this .mts file with the path to the commit message file
// as argv[2], after the script path itself.
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
import {
gitLines,
readFileForScan,
scanExternalIssueRefs,
scanGitHubTokens,
scanLinearRefs,
scanSocketApiKeys,
shouldSkipFile,
stripAiAttribution,
stripScanLabels,
} from '../_shared/helpers.mts'
// Canonical shared identity reader (.git-hooks/_shared/). Same source the
// commit-author-guard PreToolUse hook uses; the DATA is the cascaded
// .config/fleet|repo/git-authors.json.
import {
isAllowedAuthor,
isDeniedIdentity,
parseGitIdentLine,
readIdentityPolicy,
} from '../_shared/git-identity.mts'
import {
commitSubject,
commitSubjectVerdict,
} from '../_shared/commit-subject.mts'
// Conventional Commits header validation — the SAME source the
// commit-message-format-guard PreToolUse hook uses. That guard only sees
// `git commit -m` tool calls; this git-stage twin enforces the format on a
// subprocess / worktree / CI / test-harness commit the tool layer misses.
import {
isAutoGeneratedSubject,
validateHeader,
} from '../_shared/commit-format.mts'
const logger = getDefaultLogger()
function identLabel(which: 'GIT_AUTHOR_IDENT' | 'GIT_COMMITTER_IDENT'): string {
return which === 'GIT_AUTHOR_IDENT' ? 'author' : 'committer'
}
// Security layer that runs even when pre-commit is bypassed via
// `--no-verify`: API keys and .env files in the staged content itself.
function scanStagedFilesForLeaks(committedFiles: string[]): number {
let errors = 0
for (let i = 0, { length } = committedFiles; i < length; i += 1) {
const file = committedFiles[i]!
if (!file || shouldSkipFile(file)) {
continue
}
const text = readFileForScan(file)
if (!text) {
continue
}
// Socket API keys (allowlist-aware).
const apiHits = scanSocketApiKeys(text)
if (apiHits.length > 0) {
logger.fail('Potential API key detected in commit!')
logger.info(`File: ${file}`)
errors++
}
// .env files at any depth — allow only .env.example, .env.test,
// .env.precommit (templates / tracked placeholders).
const base = path.basename(file)
if (
/^\.env(\.[^/]+)?$/.test(base) &&
!/^\.env\.(example|precommit|test)$/.test(base)
) {
logger.fail('.env file in commit!')
logger.info(`File: ${file}`)
errors++
}
}
return errors
}
// Block Linear issue references in the commit message. Linear
// tracking lives in Linear; commit history stays tool-agnostic. The
// canonical CLAUDE.md "public-surface hygiene" block documents the
// policy; this hook makes it mechanical so a typo in a hot rebase
// can't slip through.
function reportLinearRefs(original: string): number {
const linearHits = scanLinearRefs(original)
if (linearHits.length === 0) {
return 0
}
logger.fail('Commit message references Linear issue(s):')
for (const ref of linearHits) {
logger.info(` ${ref}`)
}
logger.info(
'Linear tracking lives in Linear. Remove the reference from the commit message.',
)
return 1
}
// Block foreign `<owner>/<repo>#<num>` issue/PR references. GitHub
// auto-links these tokens and posts an 'added N commits that
// reference this issue' event back to the target — a fleet cascade
// of N commits = N pings to a maintainer. The same matcher feeds the
// Bash-time no-ext-issue-ref-guard; this git-stage backstop catches a
// subprocess / worktree / CI / `--no-verify` commit the tool layer
// misses. Only `SocketDev/<repo>#<num>` (case-insensitive) is
// allowed inline.
function reportExternalIssueRefs(original: string): number {
const extIssueHits = scanExternalIssueRefs(original)
if (extIssueHits.length === 0) {
return 0
}
const seen = new Set<string>()
logger.fail('Commit message references a non-SocketDev GitHub issue/PR:')
for (const ref of extIssueHits) {
if (seen.has(ref.raw)) {
continue
}
seen.add(ref.raw)
logger.info(` ${ref.raw}`)
}
logger.info(
'GitHub backrefs the target issue on every commit. Remove the ref from the commit message and put the link in the PR description prose instead. For a SocketDev-owned repo, write it as `SocketDev/<repo>#<num>`.',
)
return 1
}
// Conventional Commits subject format. Git-stage twin of the
// commit-message-format-guard PreToolUse hook (which only sees
// `git commit -m` tool calls) — this catches a malformed subject on a
// subprocess / worktree / CI / test-harness commit the tool layer misses.
// commitSubject() skips leading blanks and `#` comment lines; git's own
// auto-generated Merge/Revert/fixup!/squash! subjects are exempt.
function reportSubjectFormat(original: string): number {
const subjectLine = commitSubject(original)
if (!subjectLine || isAutoGeneratedSubject(subjectLine)) {
return 0
}
const header = validateHeader(subjectLine)
if (header.kind === 'ok') {
return 0
}
logger.fail(
`Commit blocked: subject is not Conventional Commits format: "${subjectLine}".`,
)
logger.info(
'Required format: <type>[(scope)][!]: <description> (e.g. `fix(scan): handle empty manifest`). Allowed types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. Spec: https://www.conventionalcommits.org/en/v1.0.0/',
)
return 1
}
// GitHub tokens in the commit message body. Pasting a `ghs_*` /
// `ghp_*` / `ghu_*` token into a commit message is exactly the
// leak vector commit-msg should block (the body lands in the
// remote repo's commit-log permanently — can't be unpushed). The
// scanGitHubTokens regex covers both the classic opaque format
// and the new JWT format from the 2026-05-15 GitHub rollout.
function reportGitHubTokensInMessage(original: string): number {
const ghHits = scanGitHubTokens(original)
if (ghHits.length === 0) {
return 0
}
logger.fail('Commit message contains a potential GitHub token:')
const shownHits = ghHits.slice(0, 3)
for (let i = 0, { length } = shownHits; i < length; i += 1) {
const hit = shownHits[i]!
logger.info(` line ${hit.lineNumber}: ${hit.line.trim()}`)
}
logger.info(
'Remove the token from the commit message. If this is intentional documentation of a token-shape pattern, paste the value into a test fixture instead, not the commit message.',
)
return 1
}
// Auto-strip AI attribution lines AND scan-report-internal labels
// (B5/M9/H3/L4) from the commit message. The scan-label-in-commit-guard
// PreToolUse hook blocks those labels at Claude `git commit` Bash time;
// this is the commit-msg-stage twin for commits that never route through
// that layer. Both scrubbers MUTATE: thread the AI-attribution output
// into the label scrubber so a single rewrite carries both passes, and
// write the file ONCE so the placeholder-subject check below sees the
// fully-cleaned text.
function scrubCommitMessage(commitMsgFile: string, original: string): string {
const aiResult = stripAiAttribution(original)
const labelResult = stripScanLabels(aiResult.cleaned)
const cleaned = labelResult.cleaned
const aiRemoved = aiResult.removed
const labelsRemoved = labelResult.removed
if (aiRemoved > 0 || labelsRemoved > 0) {
writeFileSync(commitMsgFile, cleaned)
if (aiRemoved > 0) {
logger.success(
`Auto-stripped ${aiRemoved} AI attribution line(s) from commit message`,
)
}
if (labelsRemoved > 0) {
logger.success(
`Auto-stripped ${labelsRemoved} scan-report label(s) (B/M/H/L) from commit message`,
)
}
}
return cleaned
}
// Placeholder-subject git-stage backstop. The companion
// no-placeholder-commit-subject-guard catches Claude `git commit -m` tool
// calls; this catches the same junk subject (`initial`/`wip`/`test`) on a
// subprocess / worktree / CI / test-harness commit the tool layer misses.
function reportPlaceholderSubject(text: string): number {
const subject = commitSubject(text)
const verdict = commitSubjectVerdict(subject)
if (verdict === 'empty') {
// An empty subject is a MECHANICAL failure, not a lazy one. Naming the
// denylist here sent authors hunting for a rule that never fired.
logger.fail('Commit blocked: the commit message is empty.')
logger.info(
'Usually the message never arrived: a `-F <file>` path that does not exist, a command line truncated before its `-m`/`-F`, or an editor closed without saving. Check the file is readable and the flag survived, then re-run.',
)
return 1
}
if (verdict === 'placeholder') {
logger.fail(`Commit blocked: placeholder subject "${subject}".`)
logger.info(
'Write a Conventional Commits subject stating what changed (e.g. `fix(scan): handle empty manifest`). Placeholder titles like "initial"/"wip"/"test" are the fingerprint of a test-harness or replayed commit.',
)
return 1
}
return 0
}
// Every gate that reads the commit-message file itself, in the order the
// hook has always run them. The scrub happens BEFORE the placeholder check
// so that check sees the fully-cleaned text.
function scanCommitMessageFile(commitMsgFile: string): number {
const original = readFileSync(commitMsgFile, 'utf8')
let errors = 0
errors += reportLinearRefs(original)
errors += reportExternalIssueRefs(original)
errors += reportSubjectFormat(original)
errors += reportGitHubTokensInMessage(original)
const cleaned = scrubCommitMessage(commitMsgFile, original)
errors += reportPlaceholderSubject(cleaned || original)
return errors
}
// Git-stage backstop for commit author/committer identity. The
// commit-author-guard PreToolUse hook checks Claude `git commit` tool
// calls, but a subprocess / fresh worktree / CI / test-harness commit
// never routes through that layer — that is how a batch of
// test@example.com commits once reached a fleet repo's main. This fires on
// the `git commit`-msg stage regardless of origin, reading the SAME cascaded
// .config/fleet|repo/git-authors.json policy so the two never diverge.
function scanCommitIdentities(): number {
const policy = readIdentityPolicy(process.cwd())
let errors = 0
for (const which of ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT'] as const) {
let ident = ''
try {
ident = gitLines('var', which)[0] ?? ''
} catch {
// `git var` failed, unusual env — fail open, don't block a real commit.
continue
}
const who = parseGitIdentLine(ident)
const denied = isDeniedIdentity(who, policy)
if (denied || !isAllowedAuthor(who, policy)) {
const id = `${who.name ?? '(unset)'} <${who.email ?? '(unset)'}>`
logger.fail(
denied
? `Commit blocked: ${identLabel(which)} is a placeholder/sandbox identity ${id}.`
: `Commit blocked: ${identLabel(which)} ${id} is not on the allowed-author list.`,
)
logger.info(
'Set a real identity (`git config user.email "<you>@<domain>"`). Allowed authors come from .config/repo/git-authors.json (per-repo) over .config/fleet/git-authors.json (cascaded); placeholder identities (test@example.com, Test, …) are never allowed.',
)
errors++
}
}
return errors
}
const main = (): number => {
const committedFiles = gitLines(
'diff',
'--cached',
'--name-only',
'--diff-filter=ACM',
)
let errors = scanStagedFilesForLeaks(committedFiles)
const commitMsgFile = process.argv[2]
if (commitMsgFile && existsSync(commitMsgFile)) {
errors += scanCommitMessageFile(commitMsgFile)
}
errors += scanCommitIdentities()
if (errors > 0) {
logger.fail('Commit blocked by security validation')
return 1
}
return 0
}
process.exitCode = main()