Skip to content

Commit f578e8b

Browse files
committed
Merge paths-rollout + hooks-mts and rename hooks (drop @socketsecurity/ scope)
Consolidates PR #1280 (path-guard infra) and #1281 (.sh→.mts hook conversion) into this branch. Resolves the modify/delete conflict on .git-hooks/{commit-msg,pre-push} by accepting the .mts versions — the env allowlist tweak from #1279 (.env.precommit + skip-hook- scripts) is already covered in commit-msg.mts via shouldSkipFile and the precommit allowlist. Also renames internal hook packages to drop the @socketsecurity/ scope (hook-path-guard, hook-token-guard, hook-check-new-deps) — they're private:true and never published.
2 parents 3921257 + f504816 commit f578e8b

16 files changed

Lines changed: 959 additions & 398 deletions

File tree

.claude/hooks/check-new-deps/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@socketsecurity/hook-check-new-deps",
2+
"name": "hook-check-new-deps",
33
"private": true,
44
"type": "module",
55
"main": "./index.mts",

.claude/hooks/path-guard/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Hook bugs fail **open** — a crash in the hook writes a log line and returns ex
5050
## Testing
5151

5252
```bash
53-
pnpm --filter @socketsecurity/hook-path-guard test
53+
pnpm --filter hook-path-guard test
5454
```
5555

5656
Adding a new detection pattern: update `STAGE_SEGMENTS` (or `KNOWN_SIBLING_PACKAGES`) in `index.mts`, add a positive and negative test in `test/path-guard.test.mts`.

.claude/hooks/path-guard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@socketsecurity/hook-path-guard",
2+
"name": "hook-path-guard",
33
"private": true,
44
"type": "module",
55
"main": "./index.mts",

.claude/hooks/path-guard/test/path-guard.test.mts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// mock PreToolUse payload to the hook's stdin and asserts on its exit
33
// code + stderr. Exit 2 = blocked; exit 0 = allowed.
44
//
5-
// Run: pnpm --filter @socketsecurity/hook-path-guard test
5+
// Run: pnpm --filter hook-path-guard test
66
// (or directly: node --test test/*.test.mts)
77

88
import { spawnSync } from 'node:child_process'

.claude/hooks/token-guard/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The hook reads the tool-use payload from stdin, type-checks `tool_name === 'Bash
4141
## Testing
4242

4343
```bash
44-
pnpm --filter @socketsecurity/hook-token-guard test
44+
pnpm --filter hook-token-guard test
4545
```
4646

4747
Adding new token-shape detections: update `LITERAL_TOKEN_PATTERNS` in `index.mts`, add a positive and negative test in `test/token-guard.test.mts`.

.claude/hooks/token-guard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@socketsecurity/hook-token-guard",
2+
"name": "hook-token-guard",
33
"private": true,
44
"type": "module",
55
"main": "./index.mts",

.git-hooks/_helpers.mts

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Shared helpers for git hooks — API-key allowlist + ANSI colors +
2+
// content scanners. Imported by .git-hooks/{commit-msg,pre-commit,
3+
// pre-push}.mts. No third-party deps; uses only Node built-ins.
4+
//
5+
// Requires Node 25+ for stable .mts type-stripping (no flag needed).
6+
// Earlier Node versions either lacked --experimental-strip-types or
7+
// shipped it under a flag, both unacceptable for hook ergonomics.
8+
9+
import { spawnSync } from 'node:child_process'
10+
import { existsSync, readFileSync, statSync } from 'node:fs'
11+
12+
// Hard-fail if Node is below 25. This runs at module load — every
13+
// hook invocation imports _helpers.mts before doing anything, so the
14+
// version check is the first thing that happens.
15+
const NODE_MIN_MAJOR = 25
16+
const nodeMajor = Number.parseInt(
17+
process.versions.node.split('.')[0] || '0',
18+
10,
19+
)
20+
if (nodeMajor < NODE_MIN_MAJOR) {
21+
process.stderr.write(
22+
`\x1b[0;31m✗ Hook requires Node >= ${NODE_MIN_MAJOR}.0.0 (have v${process.versions.node})\x1b[0m\n`,
23+
)
24+
process.stderr.write(
25+
'Install Node 25+ — these hooks rely on stable .mts type stripping.\n',
26+
)
27+
process.exit(1)
28+
}
29+
30+
// ── Allowlist constants ────────────────────────────────────────────
31+
// These exempt known-safe matches from the API-key scanner. Each
32+
// allowlist entry is a substring; if the matched line contains it,
33+
// the line is dropped from the findings.
34+
35+
// Real public API key shipped in socket-lib test fixtures. Safe to
36+
// appear anywhere in the fleet.
37+
export const ALLOWED_PUBLIC_KEY =
38+
'sktsec_t_--RAN5U4ivauy4w37-6aoKyYPDt5ZbaT5JBVMqiwKo_api'
39+
40+
// Substring marker used in test fixtures (see
41+
// socket-lib/test/unit/utils/fake-tokens.ts). Lines containing this
42+
// are treated as test fixtures.
43+
export const FAKE_TOKEN_MARKER = 'socket-test-fake-token'
44+
45+
// Legacy lib-scoped marker — accepted during the rename from
46+
// `socket-lib-test-fake-token` to `socket-test-fake-token`. Drop when
47+
// lib's rename PR lands.
48+
export const FAKE_TOKEN_LEGACY = 'socket-lib-test-fake-token'
49+
50+
// Name of the env var used in shell examples; not a token value.
51+
export const SOCKET_SECURITY_ENV = 'SOCKET_SECURITY_API_KEY='
52+
53+
// ── ANSI colors ────────────────────────────────────────────────────
54+
55+
export const RED = '\x1b[0;31m'
56+
export const GREEN = '\x1b[0;32m'
57+
export const YELLOW = '\x1b[1;33m'
58+
export const NC = '\x1b[0m'
59+
60+
// ── Output helpers ─────────────────────────────────────────────────
61+
62+
export const out = (msg: string): void => {
63+
process.stdout.write(msg + '\n')
64+
}
65+
66+
export const err = (msg: string): void => {
67+
process.stderr.write(msg + '\n')
68+
}
69+
70+
export const red = (msg: string): string => `${RED}${msg}${NC}`
71+
export const green = (msg: string): string => `${GREEN}${msg}${NC}`
72+
export const yellow = (msg: string): string => `${YELLOW}${msg}${NC}`
73+
74+
// ── API-key allowlist filter ───────────────────────────────────────
75+
76+
// Drops any line that matches an allowlist entry.
77+
export const filterAllowedApiKeys = (lines: readonly string[]): string[] => {
78+
return lines.filter(
79+
line =>
80+
!line.includes(ALLOWED_PUBLIC_KEY) &&
81+
!line.includes(FAKE_TOKEN_MARKER) &&
82+
!line.includes(FAKE_TOKEN_LEGACY) &&
83+
!line.includes(SOCKET_SECURITY_ENV) &&
84+
!line.includes('.example'),
85+
)
86+
}
87+
88+
// ── Personal-path scanner ──────────────────────────────────────────
89+
90+
// Real personal paths to flag: /Users/foo/, /home/foo/, C:\Users\foo\.
91+
const PERSONAL_PATH_RE =
92+
/(\/Users\/[^/\s]+\/|\/home\/[^/\s]+\/|C:\\Users\\[^\\]+\\)/
93+
94+
// Placeholders we ALLOW (documentation, not real leaks): any path
95+
// component wrapped in <...> or starting with $VAR / ${VAR}.
96+
const PERSONAL_PATH_PLACEHOLDER_RE =
97+
/(\/Users\/<[^>]*>\/|\/home\/<[^>]*>\/|C:\\Users\\<[^>]*>\\|\/Users\/\$\{?[A-Z_]+\}?\/|\/home\/\$\{?[A-Z_]+\}?\/)/
98+
99+
export type LineHit = { lineNumber: number; line: string }
100+
101+
// Returns lines that contain a real personal path (excludes lines
102+
// that are pure placeholders). Caller decides what to do with hits.
103+
export const scanPersonalPaths = (text: string): LineHit[] => {
104+
const hits: LineHit[] = []
105+
const lines = text.split('\n')
106+
for (let i = 0; i < lines.length; i++) {
107+
const line = lines[i]!
108+
if (!PERSONAL_PATH_RE.test(line)) {
109+
continue
110+
}
111+
if (PERSONAL_PATH_PLACEHOLDER_RE.test(line)) {
112+
// Has placeholder — but might also have a real path on the
113+
// same line. Strip placeholder forms and re-test.
114+
const stripped = line.replace(
115+
new RegExp(PERSONAL_PATH_PLACEHOLDER_RE, 'g'),
116+
'',
117+
)
118+
if (!PERSONAL_PATH_RE.test(stripped)) {
119+
continue
120+
}
121+
}
122+
hits.push({ lineNumber: i + 1, line })
123+
}
124+
return hits
125+
}
126+
127+
// ── Secret scanners ────────────────────────────────────────────────
128+
129+
const SOCKET_API_KEY_RE = /sktsec_[a-zA-Z0-9_-]+/
130+
const AWS_KEY_RE = /(aws_access_key|aws_secret|\bAKIA[0-9A-Z]{16}\b)/i
131+
const GITHUB_TOKEN_RE = /gh[ps]_[a-zA-Z0-9]{36}/
132+
const PRIVATE_KEY_RE = /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/
133+
134+
export const scanSocketApiKeys = (text: string): LineHit[] => {
135+
const hits: LineHit[] = []
136+
const lines = text.split('\n')
137+
for (let i = 0; i < lines.length; i++) {
138+
const line = lines[i]!
139+
if (SOCKET_API_KEY_RE.test(line)) {
140+
hits.push({ lineNumber: i + 1, line })
141+
}
142+
}
143+
// Filter the LineHit objects directly so duplicate-content lines
144+
// at different line numbers keep their correct numbers.
145+
const allowedSet = new Set(filterAllowedApiKeys(hits.map(h => h.line)))
146+
return hits.filter(h => allowedSet.has(h.line))
147+
}
148+
149+
export const scanAwsKeys = (text: string): LineHit[] => {
150+
const hits: LineHit[] = []
151+
const lines = text.split('\n')
152+
for (let i = 0; i < lines.length; i++) {
153+
const line = lines[i]!
154+
if (AWS_KEY_RE.test(line)) {
155+
hits.push({ lineNumber: i + 1, line })
156+
}
157+
}
158+
return hits
159+
}
160+
161+
export const scanGitHubTokens = (text: string): LineHit[] => {
162+
const hits: LineHit[] = []
163+
const lines = text.split('\n')
164+
for (let i = 0; i < lines.length; i++) {
165+
const line = lines[i]!
166+
if (GITHUB_TOKEN_RE.test(line)) {
167+
hits.push({ lineNumber: i + 1, line })
168+
}
169+
}
170+
return hits
171+
}
172+
173+
export const scanPrivateKeys = (text: string): LineHit[] => {
174+
const hits: LineHit[] = []
175+
const lines = text.split('\n')
176+
for (let i = 0; i < lines.length; i++) {
177+
const line = lines[i]!
178+
if (PRIVATE_KEY_RE.test(line)) {
179+
hits.push({ lineNumber: i + 1, line })
180+
}
181+
}
182+
return hits
183+
}
184+
185+
// ── npx/dlx scanner ────────────────────────────────────────────────
186+
187+
const NPX_DLX_RE = /\b(npx|pnpm dlx|yarn dlx)\b/
188+
189+
export const scanNpxDlx = (text: string): LineHit[] => {
190+
const hits: LineHit[] = []
191+
const lines = text.split('\n')
192+
for (let i = 0; i < lines.length; i++) {
193+
const line = lines[i]!
194+
if (NPX_DLX_RE.test(line) && !line.includes('# zizmor:')) {
195+
hits.push({ lineNumber: i + 1, line })
196+
}
197+
}
198+
return hits
199+
}
200+
201+
// ── Linear issue reference scanner ─────────────────────────────────
202+
// CLAUDE.md "ABSOLUTE RULES": NEVER reference Linear issues in commits.
203+
// Team keys enumerated from the Socket workspace. PATCH listed before
204+
// PAT so the alternation matches the longer prefix first.
205+
206+
const LINEAR_TEAM_KEYS =
207+
'ASK|AUTO|BOT|CE|CORE|DAT|DES|DEV|ENG|INFRA|LAB|MAR|MET|OPS|PAR|PATCH|PAT|PLAT|REA|SALES|SBOM|SEC|SMO|SUP|TES|TI|WEB'
208+
209+
const LINEAR_ISSUE_RE = new RegExp(
210+
`(?:^|[^A-Za-z0-9_])((?:${LINEAR_TEAM_KEYS})-[0-9]+)(?:$|[^A-Za-z0-9_])`,
211+
'gm',
212+
)
213+
214+
const LINEAR_URL_RE = /linear\.app\/[A-Za-z0-9/_-]+/g
215+
216+
export const scanLinearReferences = (commitMsg: string): string[] => {
217+
const hits: string[] = []
218+
const lines = commitMsg.split('\n').filter(l => !l.startsWith('#'))
219+
const body = lines.join('\n')
220+
for (const m of body.matchAll(LINEAR_ISSUE_RE)) {
221+
hits.push(m[1]!)
222+
}
223+
for (const m of body.matchAll(LINEAR_URL_RE)) {
224+
hits.push(m[0]!)
225+
}
226+
return hits.slice(0, 5)
227+
}
228+
229+
// ── AI attribution scanner ─────────────────────────────────────────
230+
231+
const AI_ATTRIBUTION_RE =
232+
/(Generated with.*(Claude|AI)|Co-Authored-By: Claude|Co-Authored-By: AI|🤖 Generated|AI generated|@anthropic\.com|Assistant:|Generated by Claude|Machine generated|Claude Code)/i
233+
234+
export const containsAiAttribution = (text: string): boolean =>
235+
AI_ATTRIBUTION_RE.test(text)
236+
237+
export const stripAiAttribution = (
238+
text: string,
239+
): { cleaned: string; removed: number } => {
240+
const lines = text.split('\n')
241+
const kept: string[] = []
242+
let removed = 0
243+
for (const line of lines) {
244+
if (AI_ATTRIBUTION_RE.test(line)) {
245+
removed++
246+
} else {
247+
kept.push(line)
248+
}
249+
}
250+
return { cleaned: kept.join('\n'), removed }
251+
}
252+
253+
// ── File classification ────────────────────────────────────────────
254+
255+
// Files we never scan: hooks themselves, husky shims, test fixtures.
256+
const SKIP_FILE_RE =
257+
/\.(test|spec)\.(m?[jt]s|tsx?|cts|mts)$|\.example$|\/test\/|\/tests\/|fixtures\/|\.git-hooks\/|\.husky\/|node_modules\/|pnpm-lock\.yaml/
258+
259+
export const shouldSkipFile = (filePath: string): boolean =>
260+
SKIP_FILE_RE.test(filePath)
261+
262+
// Returns file content as a string. For binaries, runs `strings` to
263+
// extract printable byte sequences (catches paths embedded in WASM
264+
// or other compiled artifacts).
265+
export const readFileForScan = (filePath: string): string => {
266+
if (!existsSync(filePath)) {
267+
return ''
268+
}
269+
try {
270+
if (statSync(filePath).isDirectory()) {
271+
return ''
272+
}
273+
} catch {
274+
return ''
275+
}
276+
// Detect binary via grep -I (matches text-only); if grep says
277+
// binary, fall back to `strings`.
278+
const grepResult = spawnSync('grep', ['-qI', '', filePath])
279+
if (grepResult.status === 0) {
280+
// Text file.
281+
try {
282+
return readFileSync(filePath, 'utf8')
283+
} catch {
284+
return ''
285+
}
286+
}
287+
// Binary — extract strings.
288+
const stringsResult = spawnSync('strings', [filePath], {
289+
encoding: 'utf8',
290+
})
291+
return stringsResult.stdout || ''
292+
}
293+
294+
// ── Git wrappers ───────────────────────────────────────────────────
295+
296+
export const git = (...args: string[]): string => {
297+
const result = spawnSync('git', args, { encoding: 'utf8' })
298+
return result.stdout.trim()
299+
}
300+
301+
export const gitLines = (...args: string[]): string[] => {
302+
const out = git(...args)
303+
return out ? out.split('\n') : []
304+
}

.git-hooks/_helpers.sh

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)