Skip to content

Commit c5a5abe

Browse files
committed
Merge paths-rollout + hooks-mts and rename hooks (drop @socketsecurity/ scope)
Consolidates PR #621 (path-guard infra) and #622 (.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 #620 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 f76b7a8 + bcde330 commit c5a5abe

17 files changed

Lines changed: 905 additions & 506 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: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
return filterAllowedApiKeys(hits.map(h => h.line)).map(line => ({
144+
lineNumber: hits.find(h => h.line === line)!.lineNumber,
145+
line,
146+
}))
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+
// ── AI attribution scanner ─────────────────────────────────────────
202+
203+
const AI_ATTRIBUTION_RE =
204+
/(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
205+
206+
export const containsAiAttribution = (text: string): boolean =>
207+
AI_ATTRIBUTION_RE.test(text)
208+
209+
export const stripAiAttribution = (
210+
text: string,
211+
): { cleaned: string; removed: number } => {
212+
const lines = text.split('\n')
213+
const kept: string[] = []
214+
let removed = 0
215+
for (const line of lines) {
216+
if (AI_ATTRIBUTION_RE.test(line)) {
217+
removed++
218+
} else {
219+
kept.push(line)
220+
}
221+
}
222+
return { cleaned: kept.join('\n'), removed }
223+
}
224+
225+
// ── File classification ────────────────────────────────────────────
226+
227+
// Files we never scan: hooks themselves, husky shims, test fixtures.
228+
const SKIP_FILE_RE =
229+
/\.(test|spec)\.(m?[jt]s|tsx?|cts|mts)$|\.example$|\/test\/|\/tests\/|fixtures\/|\.git-hooks\/|\.husky\/|node_modules\/|pnpm-lock\.yaml/
230+
231+
export const shouldSkipFile = (filePath: string): boolean =>
232+
SKIP_FILE_RE.test(filePath)
233+
234+
// Returns file content as a string. For binaries, runs `strings` to
235+
// extract printable byte sequences (catches paths embedded in WASM
236+
// or other compiled artifacts).
237+
export const readFileForScan = (filePath: string): string => {
238+
if (!existsSync(filePath)) {
239+
return ''
240+
}
241+
try {
242+
if (statSync(filePath).isDirectory()) {
243+
return ''
244+
}
245+
} catch {
246+
return ''
247+
}
248+
// Detect binary via grep -I (matches text-only); if grep says
249+
// binary, fall back to `strings`.
250+
const grepResult = spawnSync('grep', ['-qI', '', filePath])
251+
if (grepResult.status === 0) {
252+
// Text file.
253+
try {
254+
return readFileSync(filePath, 'utf8')
255+
} catch {
256+
return ''
257+
}
258+
}
259+
// Binary — extract strings.
260+
const stringsResult = spawnSync('strings', [filePath], {
261+
encoding: 'utf8',
262+
})
263+
return stringsResult.stdout || ''
264+
}
265+
266+
// ── Git wrappers ───────────────────────────────────────────────────
267+
268+
export const git = (...args: string[]): string => {
269+
const result = spawnSync('git', args, { encoding: 'utf8' })
270+
return result.stdout.trim()
271+
}
272+
273+
export const gitLines = (...args: string[]): string[] => {
274+
const out = git(...args)
275+
return out ? out.split('\n') : []
276+
}

.git-hooks/_helpers.sh

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

0 commit comments

Comments
 (0)