shield-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/lib/ocsf-mapper.js +279 -0
- package/.claude/hooks/lib/openshell-detect.js +235 -0
- package/.claude/hooks/lib/policy-compat.js +176 -0
- package/.claude/hooks/lib/session-modules/.gitkeep +0 -0
- package/.claude/hooks/lib/sh-utils.js +340 -0
- package/.claude/hooks/lint-on-save.js +240 -0
- package/.claude/hooks/sh-circuit-breaker.js +113 -0
- package/.claude/hooks/sh-config-guard.js +275 -0
- package/.claude/hooks/sh-data-boundary.js +390 -0
- package/.claude/hooks/sh-dep-audit.js +101 -0
- package/.claude/hooks/sh-elicitation.js +244 -0
- package/.claude/hooks/sh-evidence.js +193 -0
- package/.claude/hooks/sh-gate.js +365 -0
- package/.claude/hooks/sh-injection-guard.js +196 -0
- package/.claude/hooks/sh-instructions.js +212 -0
- package/.claude/hooks/sh-output-control.js +217 -0
- package/.claude/hooks/sh-permission-learn.js +227 -0
- package/.claude/hooks/sh-permission.js +157 -0
- package/.claude/hooks/sh-pipeline.js +623 -0
- package/.claude/hooks/sh-postcompact.js +173 -0
- package/.claude/hooks/sh-precompact.js +114 -0
- package/.claude/hooks/sh-quiet-inject.js +148 -0
- package/.claude/hooks/sh-session-end.js +143 -0
- package/.claude/hooks/sh-session-start.js +277 -0
- package/.claude/hooks/sh-subagent.js +86 -0
- package/.claude/hooks/sh-task-gate.js +141 -0
- package/.claude/hooks/sh-user-prompt.js +185 -0
- package/.claude/hooks/sh-worktree.js +230 -0
- package/.claude/patterns/injection-patterns.json +137 -0
- package/.claude/policies/openshell-default.yaml +65 -0
- package/.claude/rules/binding-governance.md +62 -0
- package/.claude/rules/channel-security.md +90 -0
- package/.claude/rules/coding-principles.md +79 -0
- package/.claude/rules/dev-environment.md +40 -0
- package/.claude/rules/implementation-context.md +132 -0
- package/.claude/rules/language.md +26 -0
- package/.claude/rules/security.md +109 -0
- package/.claude/rules/testing.md +43 -0
- package/LICENSE +21 -0
- package/README.ja.md +176 -0
- package/README.md +174 -0
- package/bin/shield-harness.js +241 -0
- package/package.json +42 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-gate.js — Destructive command blocker + hook evasion defense
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §3.2
|
|
4
|
+
// Event: PreToolUse (Bash)
|
|
5
|
+
// Target response time: < 50ms
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
readHookInput,
|
|
10
|
+
allow,
|
|
11
|
+
deny,
|
|
12
|
+
nfkcNormalize,
|
|
13
|
+
normalizePath,
|
|
14
|
+
appendEvidence,
|
|
15
|
+
} = require("./lib/sh-utils");
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Pattern Arrays (§3.2 — all 7 attack vectors + destructive commands)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
// Destructive commands — catastrophic file system / device operations
|
|
22
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
23
|
+
[/^rm\s+-rf\s+\//, "rm -rf / (root filesystem destruction)"],
|
|
24
|
+
[/^rm\s+-rf\s+~/, "rm -rf ~ (home directory destruction)"],
|
|
25
|
+
[/^rm\s+-rf\s+\.\//, "rm -rf ./ (relative path destruction)"],
|
|
26
|
+
[/^rm\s+-[a-z]*r[a-z]*\s+-[a-z]*f/, "rm flag separation (destructive)"],
|
|
27
|
+
[/^rm\s+-[a-z]*f[a-z]*\s+-[a-z]*r/, "rm flag separation (destructive)"],
|
|
28
|
+
[/^del\s+\/s\s+\/q\s+[A-Z]:\\/, "del /s /q (Windows recursive delete)"],
|
|
29
|
+
[/^format\s+[A-Z]:/, "format drive (disk format)"],
|
|
30
|
+
[/^mkfs\./, "mkfs (filesystem creation on device)"],
|
|
31
|
+
[/^dd\s+if=.*\s+of=\/dev\//, "dd to device (raw disk write)"],
|
|
32
|
+
[/\bfind\b.*\s-delete/, "find -delete (recursive file deletion)"],
|
|
33
|
+
[/\bshred\b/, "shred (secure file destruction)"],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// E-1: Tool switching — bypass Edit/Write tool via Bash scripting languages
|
|
37
|
+
const TOOL_SWITCHING_PATTERNS = [
|
|
38
|
+
[/sed\s+-i/, "sed -i (in-place edit bypasses Edit tool)"],
|
|
39
|
+
[
|
|
40
|
+
/sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
|
|
41
|
+
"sed e/w modifier (execute/write via sed)",
|
|
42
|
+
],
|
|
43
|
+
[/sed\s.*-e\s/, "sed -e (expression, potential execute)"],
|
|
44
|
+
[/python3?\s+-c\s+['"].*open\(/, "python -c open() (file write via python)"],
|
|
45
|
+
[/node\s+-e\s+['"].*fs\./, "node -e fs.* (file write via node)"],
|
|
46
|
+
[
|
|
47
|
+
/\bnode\s+-e\s+.*child_process/,
|
|
48
|
+
"node -e child_process (arbitrary exec via node)",
|
|
49
|
+
],
|
|
50
|
+
[/ruby\s+-e\s+['"].*File\./, "ruby -e File.* (file write via ruby)"],
|
|
51
|
+
[/perl\s+-[pei]/, "perl -p/-e/-i (in-place or eval mode)"],
|
|
52
|
+
[
|
|
53
|
+
/powershell.*-Command.*Set-Content/i,
|
|
54
|
+
"PowerShell Set-Content (file write via powershell)",
|
|
55
|
+
],
|
|
56
|
+
[/echo\s+.*>\s/, "echo redirect (file write via echo)"],
|
|
57
|
+
[/echo\s+.*>(?=\S)/, "echo redirect no-space (file write via echo)"],
|
|
58
|
+
[/printf\s+.*>\s/, "printf redirect (file write via printf)"],
|
|
59
|
+
[/\|\s*tee\s/, "pipe to tee (file write via tee)"],
|
|
60
|
+
[/\blua\s+-e\b/, "lua -e (arbitrary code execution via lua)"],
|
|
61
|
+
[/\bphp\s+-r\b/, "php -r (arbitrary code execution via php)"],
|
|
62
|
+
[
|
|
63
|
+
/\bawk\b.*\bsystem\s*\(/,
|
|
64
|
+
"awk system() (arbitrary command execution via awk)",
|
|
65
|
+
],
|
|
66
|
+
[/\bbash\s+-c\b/, "bash -c (arbitrary command execution via bash)"],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// E-3: Dynamic linker — execute arbitrary code via loader injection
|
|
70
|
+
const DYNAMIC_LINKER_PATTERNS = [
|
|
71
|
+
[/LD_PRELOAD=/, "LD_PRELOAD (shared library injection)"],
|
|
72
|
+
[/LD_LIBRARY_PATH=/, "LD_LIBRARY_PATH (library path hijack)"],
|
|
73
|
+
[/DYLD_INSERT_LIBRARIES=/, "DYLD_INSERT_LIBRARIES (macOS library injection)"],
|
|
74
|
+
[/ld-linux/, "ld-linux (direct dynamic linker invocation)"],
|
|
75
|
+
[/\/lib.*\/ld-/, "/lib*/ld- (dynamic linker path)"],
|
|
76
|
+
[/\/usr\/lib.*\/ld-/, "/usr/lib*/ld- (dynamic linker path)"],
|
|
77
|
+
[/rundll32/i, "rundll32 (Windows DLL execution)"],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// E-4: sed dangerous modifiers (subset of tool switching, explicit check)
|
|
81
|
+
const SED_DANGER_PATTERNS = [
|
|
82
|
+
[
|
|
83
|
+
/sed\s.*['"][^'"]*[/][^'"]*[ew]\s*['"]/,
|
|
84
|
+
"sed e/w modifier (arbitrary command execution)",
|
|
85
|
+
],
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// E-5: Self-config modification — agent modifying its own governance files
|
|
89
|
+
const CONFIG_MODIFY_PATTERNS = [
|
|
90
|
+
[/>\s*\.claude\//, "redirect to .claude/ (config overwrite)"],
|
|
91
|
+
[/>>\s*\.claude\//, "append redirect to .claude/ (config modification)"],
|
|
92
|
+
[/tee\s+.*\.claude\//, "tee to .claude/ (config write)"],
|
|
93
|
+
[/cp\s+.*\.claude\//, "cp to .claude/ (config copy)"],
|
|
94
|
+
[/mv\s+.*\.claude\//, "mv to .claude/ (config move)"],
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// FR-02-06: PATH hijack — override command resolution
|
|
98
|
+
const PATH_HIJACK_PATTERNS = [
|
|
99
|
+
[/^PATH=/, "PATH= (command search path override)"],
|
|
100
|
+
[/export\s+PATH=/, "export PATH= (persistent path override)"],
|
|
101
|
+
[/\$SHELL/, "$SHELL (shell variable reference)"],
|
|
102
|
+
[/\$PATH/, "$PATH (path variable reference)"],
|
|
103
|
+
[/env\s+-[SiuC]/, "env -S/-i/-u/-C (environment manipulation)"],
|
|
104
|
+
[/env\s+--split-string/, "env --split-string (argument injection)"],
|
|
105
|
+
[/NODE_OPTIONS\s*=/, "NODE_OPTIONS= (Node.js runtime manipulation)"],
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// Git security bypass patterns
|
|
109
|
+
const GIT_BYPASS_PATTERNS = [
|
|
110
|
+
[/\bgit\b.*--no-verify/, "git --no-verify (hook bypass)"],
|
|
111
|
+
[
|
|
112
|
+
/\bgit\s+config\s+core\.hooksPath/,
|
|
113
|
+
"git config core.hooksPath (hook path hijack)",
|
|
114
|
+
],
|
|
115
|
+
[/\bgit\s+config\s+alias\./, "git config alias (alias injection)"],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// Variable expansion / command substitution patterns
|
|
119
|
+
const VARIABLE_EXPANSION_PATTERNS = [
|
|
120
|
+
[/\$\(/, "$() (command substitution)"],
|
|
121
|
+
[/\$\{/, "${} (variable expansion)"],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
// FR-02-09, FR-02-10: Windows-specific attack vectors
|
|
125
|
+
const WINDOWS_PATTERNS = [
|
|
126
|
+
[/\.lnk\b/, ".lnk (Windows shortcut — potential code execution)"],
|
|
127
|
+
[/\.scf\b/, ".scf (Shell Command File — potential code execution)"],
|
|
128
|
+
[/\.url\b/, ".url (Internet shortcut — potential redirect)"],
|
|
129
|
+
[/\.cmd\b/i, ".cmd (Windows batch — uncontrolled execution)"],
|
|
130
|
+
[/\.bat\b/i, ".bat (Windows batch — uncontrolled execution)"],
|
|
131
|
+
[/\bpowershell\b.*-enc/i, "powershell -enc (encoded command — obfuscation)"],
|
|
132
|
+
[/::\$DATA/, "NTFS ADS (Alternate Data Stream)"],
|
|
133
|
+
[/\\\\\?\\UNC\\/, "UNC extended path (network path injection)"],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// E-8: Pipeline environment variable spoofing (§8.1)
|
|
137
|
+
const PIPELINE_SPOOFING_PATTERNS = [
|
|
138
|
+
[/export\s+SH_PIPELINE/, "export SH_PIPELINE (pipeline env spoofing)"],
|
|
139
|
+
[/SH_PIPELINE=1/, "SH_PIPELINE=1 (pipeline env spoofing)"],
|
|
140
|
+
[/env\s+SH_PIPELINE/, "env SH_PIPELINE (pipeline env spoofing)"],
|
|
141
|
+
[/set\s+SH_PIPELINE/, "set SH_PIPELINE (pipeline env spoofing)"],
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// E-2: Path obfuscation (checked after normalization)
|
|
145
|
+
const PATH_OBFUSCATION_PATTERNS = [
|
|
146
|
+
[/\/proc\/self\/root/, "/proc/self/root (filesystem escape)"],
|
|
147
|
+
[/\/proc\/[0-9]+\/root/, "/proc/PID/root (filesystem escape)"],
|
|
148
|
+
[/PROGRA~[0-9]/, "8.3 short name (path obfuscation)"],
|
|
149
|
+
[/::\$DATA/, "NTFS ADS :$DATA (hidden data stream)"],
|
|
150
|
+
[/::\$INDEX_ALLOCATION/, "NTFS ADS :$INDEX_ALLOCATION"],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Command preprocessing utilities
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Split a command on pipe chain separators (; && || |).
|
|
159
|
+
* Handles simple cases — does NOT parse quoted strings.
|
|
160
|
+
* @param {string} command
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
function splitPipeChain(command) {
|
|
164
|
+
return command.split(/\s*(?:;|&&|\|\|)\s*/);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Strip sudo prefix from a command.
|
|
169
|
+
* Handles: sudo, sudo -u user, sudo -E, sudo --preserve-env, etc.
|
|
170
|
+
* @param {string} command
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function stripSudo(command) {
|
|
174
|
+
return command.replace(
|
|
175
|
+
/^\s*sudo\s+(?:(?:-u\s+\S+|--\S+|-[A-Za-z]+)\s+)*/,
|
|
176
|
+
"",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Strip command/builtin/env prefix from a command.
|
|
182
|
+
* @param {string} command
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function stripPrefix(command) {
|
|
186
|
+
return command.replace(/^\s*(?:command|builtin)\s+/, "");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Pattern matching engine
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Test a command against an array of [RegExp, label] patterns.
|
|
195
|
+
* @param {string} command - Normalized command string
|
|
196
|
+
* @param {Array<[RegExp, string]>} patterns - Pattern array
|
|
197
|
+
* @returns {{ pattern: RegExp, label: string } | null}
|
|
198
|
+
*/
|
|
199
|
+
function matchPatterns(command, patterns) {
|
|
200
|
+
for (const [pattern, label] of patterns) {
|
|
201
|
+
if (pattern.test(command)) {
|
|
202
|
+
return { pattern, label };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check a command against all pattern arrays, with pipe-chain splitting,
|
|
210
|
+
* sudo stripping, and prefix stripping.
|
|
211
|
+
* @param {string} command - Raw command string (already NFKC normalized)
|
|
212
|
+
* @param {Array<Array<[RegExp, string]>>} allPatternArrays
|
|
213
|
+
* @returns {{ pattern: RegExp, label: string } | null}
|
|
214
|
+
*/
|
|
215
|
+
function matchAllPatterns(command, allPatternArrays) {
|
|
216
|
+
// Check the full command first
|
|
217
|
+
for (const patterns of allPatternArrays) {
|
|
218
|
+
const m = matchPatterns(command, patterns);
|
|
219
|
+
if (m) return m;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Split on chain separators and check each segment
|
|
223
|
+
const segments = splitPipeChain(command);
|
|
224
|
+
if (segments.length > 1) {
|
|
225
|
+
for (const seg of segments) {
|
|
226
|
+
const cleaned = stripPrefix(stripSudo(seg.trim()));
|
|
227
|
+
for (const patterns of allPatternArrays) {
|
|
228
|
+
const m = matchPatterns(cleaned, patterns);
|
|
229
|
+
if (m) return m;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Try sudo + prefix stripping on full command
|
|
235
|
+
const stripped = stripPrefix(stripSudo(command));
|
|
236
|
+
if (stripped !== command) {
|
|
237
|
+
for (const patterns of allPatternArrays) {
|
|
238
|
+
const m = matchPatterns(stripped, patterns);
|
|
239
|
+
if (m) return m;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Evidence recording helper
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Record a deny decision to the evidence ledger.
|
|
252
|
+
* @param {string} hookName
|
|
253
|
+
* @param {string} decision - "deny"
|
|
254
|
+
* @param {string} reason
|
|
255
|
+
* @param {string} command - Truncated command for audit
|
|
256
|
+
* @param {string} sessionId
|
|
257
|
+
*/
|
|
258
|
+
function recordEvidence(hookName, decision, reason, command, sessionId) {
|
|
259
|
+
try {
|
|
260
|
+
appendEvidence({
|
|
261
|
+
hook: hookName,
|
|
262
|
+
event: "PreToolUse",
|
|
263
|
+
tool: "Bash",
|
|
264
|
+
decision,
|
|
265
|
+
reason,
|
|
266
|
+
command: command.length > 120 ? command.slice(0, 120) + "..." : command,
|
|
267
|
+
session_id: sessionId,
|
|
268
|
+
});
|
|
269
|
+
} catch (_) {
|
|
270
|
+
// Evidence recording failure must not block the deny response
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Main: 10-step judgment flow (§3.2)
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
// Guard: only execute main logic when run directly (not when require'd for testing)
|
|
279
|
+
if (require.main === module) {
|
|
280
|
+
try {
|
|
281
|
+
const input = readHookInput();
|
|
282
|
+
const command = (input.toolInput && input.toolInput.command) || "";
|
|
283
|
+
|
|
284
|
+
// Empty command = not a Bash tool call or no-op — allow
|
|
285
|
+
if (!command) {
|
|
286
|
+
allow();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Step 1: Path normalization (E-2 defense)
|
|
291
|
+
// normalizePath resolves symlinks, Windows backslashes, 8.3 short names
|
|
292
|
+
// We apply it to the command string to detect obfuscated paths
|
|
293
|
+
let normalizedCommand = command;
|
|
294
|
+
// Note: normalizePath works on file paths; for commands we rely on
|
|
295
|
+
// NFKC normalization + pattern matching. Path-specific normalization
|
|
296
|
+
// is applied within PATH_OBFUSCATION_PATTERNS check.
|
|
297
|
+
|
|
298
|
+
// Step 2: NFKC normalization (E-7 defense)
|
|
299
|
+
// Normalizes zero-width characters, homoglyphs, fullwidth chars
|
|
300
|
+
normalizedCommand = nfkcNormalize(normalizedCommand);
|
|
301
|
+
|
|
302
|
+
// Step 3: Unified pattern matching with pipe-chain splitting,
|
|
303
|
+
// sudo stripping, and prefix stripping
|
|
304
|
+
const allPatternArrays = [
|
|
305
|
+
DESTRUCTIVE_PATTERNS,
|
|
306
|
+
TOOL_SWITCHING_PATTERNS,
|
|
307
|
+
SED_DANGER_PATTERNS,
|
|
308
|
+
DYNAMIC_LINKER_PATTERNS,
|
|
309
|
+
CONFIG_MODIFY_PATTERNS,
|
|
310
|
+
PATH_HIJACK_PATTERNS,
|
|
311
|
+
WINDOWS_PATTERNS,
|
|
312
|
+
PIPELINE_SPOOFING_PATTERNS,
|
|
313
|
+
PATH_OBFUSCATION_PATTERNS,
|
|
314
|
+
GIT_BYPASS_PATTERNS,
|
|
315
|
+
VARIABLE_EXPANSION_PATTERNS,
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const match = matchAllPatterns(normalizedCommand, allPatternArrays);
|
|
319
|
+
if (match) {
|
|
320
|
+
recordEvidence(
|
|
321
|
+
"sh-gate",
|
|
322
|
+
"deny",
|
|
323
|
+
match.label,
|
|
324
|
+
normalizedCommand,
|
|
325
|
+
input.sessionId,
|
|
326
|
+
);
|
|
327
|
+
deny(`[sh-gate] Blocked: ${match.label}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Step 10: All checks passed — allow
|
|
332
|
+
allow();
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// fail-close: any uncaught error = deny (§2.3b)
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
JSON.stringify({
|
|
337
|
+
reason: `Hook error (sh-gate): ${err.message}`,
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
} // end require.main guard
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Exports for testing
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
module.exports = {
|
|
348
|
+
DESTRUCTIVE_PATTERNS,
|
|
349
|
+
TOOL_SWITCHING_PATTERNS,
|
|
350
|
+
DYNAMIC_LINKER_PATTERNS,
|
|
351
|
+
SED_DANGER_PATTERNS,
|
|
352
|
+
CONFIG_MODIFY_PATTERNS,
|
|
353
|
+
PATH_HIJACK_PATTERNS,
|
|
354
|
+
WINDOWS_PATTERNS,
|
|
355
|
+
PIPELINE_SPOOFING_PATTERNS,
|
|
356
|
+
PATH_OBFUSCATION_PATTERNS,
|
|
357
|
+
GIT_BYPASS_PATTERNS,
|
|
358
|
+
VARIABLE_EXPANSION_PATTERNS,
|
|
359
|
+
matchPatterns,
|
|
360
|
+
matchAllPatterns,
|
|
361
|
+
splitPipeChain,
|
|
362
|
+
stripSudo,
|
|
363
|
+
stripPrefix,
|
|
364
|
+
recordEvidence,
|
|
365
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-injection-guard.js — 9-category 50+ pattern injection detection (Injection Stage 2)
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §3.3
|
|
4
|
+
// Hook event: PreToolUse
|
|
5
|
+
// Matcher: Bash|Edit|Write|Read|WebFetch
|
|
6
|
+
// Target response time: < 50ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
readHookInput,
|
|
11
|
+
allow,
|
|
12
|
+
deny,
|
|
13
|
+
nfkcNormalize,
|
|
14
|
+
loadPatterns,
|
|
15
|
+
appendEvidence,
|
|
16
|
+
} = require("./lib/sh-utils");
|
|
17
|
+
|
|
18
|
+
// Zero-width character regex (checked BEFORE pattern matching to prevent bypass)
|
|
19
|
+
// U+00AD: soft hyphen
|
|
20
|
+
// U+034F: combining grapheme joiner
|
|
21
|
+
// U+180E: Mongolian vowel separator
|
|
22
|
+
// U+200A-200F: hair space, zero-width space, non-joiner, joiner, LTR mark, RTL mark
|
|
23
|
+
// U+2028-2029: line separator, paragraph separator
|
|
24
|
+
// U+205F: medium mathematical space
|
|
25
|
+
// U+2060-2064: word joiner, invisible operators
|
|
26
|
+
// U+3000: ideographic space
|
|
27
|
+
// U+FEFF: byte order mark
|
|
28
|
+
const ZERO_WIDTH_RE =
|
|
29
|
+
/[\u00ad\u034f\u180e\u200a-\u200f\u2028\u2029\u205f\u2060-\u2064\u3000\ufeff]/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract the text to scan from tool_input based on tool_name.
|
|
33
|
+
* @param {string} toolName
|
|
34
|
+
* @param {Object} toolInput
|
|
35
|
+
* @returns {string} text to scan (empty string if nothing to scan)
|
|
36
|
+
*/
|
|
37
|
+
function extractText(toolName, toolInput) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
switch (toolName) {
|
|
40
|
+
case "Bash":
|
|
41
|
+
parts.push(toolInput.command || "");
|
|
42
|
+
break;
|
|
43
|
+
case "Edit":
|
|
44
|
+
parts.push(toolInput.new_string || "");
|
|
45
|
+
parts.push(toolInput.old_string || "");
|
|
46
|
+
parts.push(toolInput.file_path || "");
|
|
47
|
+
break;
|
|
48
|
+
case "Write":
|
|
49
|
+
parts.push(toolInput.content || "");
|
|
50
|
+
parts.push(toolInput.file_path || "");
|
|
51
|
+
break;
|
|
52
|
+
case "Read":
|
|
53
|
+
parts.push(toolInput.file_path || "");
|
|
54
|
+
break;
|
|
55
|
+
case "WebFetch":
|
|
56
|
+
parts.push(toolInput.url || "");
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
// Join with newline separator so patterns don't span across fields
|
|
62
|
+
return parts.filter(Boolean).join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Guard: only execute main logic when run directly (not when require'd for testing)
|
|
66
|
+
if (require.main === module) {
|
|
67
|
+
try {
|
|
68
|
+
const input = readHookInput();
|
|
69
|
+
const { toolName, toolInput, sessionId } = input;
|
|
70
|
+
|
|
71
|
+
// Step 0: Extract text to scan
|
|
72
|
+
const rawText = extractText(toolName, toolInput);
|
|
73
|
+
|
|
74
|
+
// If no text to scan, allow (nothing to check)
|
|
75
|
+
if (!rawText) {
|
|
76
|
+
allow();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Step 1: NFKC normalization
|
|
81
|
+
const text = nfkcNormalize(rawText);
|
|
82
|
+
|
|
83
|
+
// Step 2: Zero-width character detection (BEFORE pattern load — prevents bypass)
|
|
84
|
+
if (ZERO_WIDTH_RE.test(rawText)) {
|
|
85
|
+
// Test against raw text (pre-NFKC) since NFKC may normalize some away
|
|
86
|
+
appendEvidence({
|
|
87
|
+
hook: "sh-injection-guard",
|
|
88
|
+
event: "deny",
|
|
89
|
+
tool: toolName,
|
|
90
|
+
category: "zero_width",
|
|
91
|
+
severity: "high",
|
|
92
|
+
detail: "Zero-width character detected in raw input",
|
|
93
|
+
session_id: sessionId,
|
|
94
|
+
});
|
|
95
|
+
deny(
|
|
96
|
+
"[sh-injection-guard] Zero-width character detected. " +
|
|
97
|
+
"Invisible characters can be used to bypass security patterns. " +
|
|
98
|
+
"Category: zero_width (severity: high)",
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Step 3: Load injection patterns (fail-close on missing/corrupted file)
|
|
104
|
+
const patterns = loadPatterns();
|
|
105
|
+
|
|
106
|
+
if (!patterns || !patterns.categories) {
|
|
107
|
+
deny(
|
|
108
|
+
"[sh-injection-guard] injection-patterns.json has invalid structure.",
|
|
109
|
+
);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Step 4: Match each category's patterns in severity order
|
|
114
|
+
// Collect medium-severity warnings (not blocking)
|
|
115
|
+
const warnings = [];
|
|
116
|
+
const categories = patterns.categories;
|
|
117
|
+
|
|
118
|
+
for (const [categoryName, category] of Object.entries(categories)) {
|
|
119
|
+
const severity = category.severity || "medium";
|
|
120
|
+
const categoryPatterns = category.patterns || [];
|
|
121
|
+
|
|
122
|
+
for (const patternStr of categoryPatterns) {
|
|
123
|
+
let re;
|
|
124
|
+
try {
|
|
125
|
+
re = new RegExp(patternStr, "i");
|
|
126
|
+
} catch {
|
|
127
|
+
// Invalid regex in patterns file — skip (don't crash the hook)
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (re.test(text)) {
|
|
132
|
+
if (severity === "critical" || severity === "high") {
|
|
133
|
+
// Deny immediately with evidence
|
|
134
|
+
appendEvidence({
|
|
135
|
+
hook: "sh-injection-guard",
|
|
136
|
+
event: "deny",
|
|
137
|
+
tool: toolName,
|
|
138
|
+
category: categoryName,
|
|
139
|
+
severity,
|
|
140
|
+
pattern: patternStr,
|
|
141
|
+
session_id: sessionId,
|
|
142
|
+
});
|
|
143
|
+
deny(
|
|
144
|
+
`[sh-injection-guard] Injection pattern detected. ` +
|
|
145
|
+
`Category: ${categoryName} (severity: ${severity}). ` +
|
|
146
|
+
`Description: ${category.description || "N/A"}`,
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (severity === "medium") {
|
|
152
|
+
// Collect warning — do not deny
|
|
153
|
+
warnings.push({
|
|
154
|
+
category: categoryName,
|
|
155
|
+
severity,
|
|
156
|
+
pattern: patternStr,
|
|
157
|
+
description: category.description || "",
|
|
158
|
+
});
|
|
159
|
+
// Only record the first match per category for warnings
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Step 5: If only medium warnings, allow with additionalContext
|
|
167
|
+
if (warnings.length > 0) {
|
|
168
|
+
const warningMessages = warnings.map(
|
|
169
|
+
(w) => `[${w.category}] ${w.description}`,
|
|
170
|
+
);
|
|
171
|
+
allow(
|
|
172
|
+
`[sh-injection-guard] Warning: potential security concern detected.\n` +
|
|
173
|
+
warningMessages.join("\n"),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// All patterns passed — allow
|
|
178
|
+
allow();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// fail-close: any uncaught error = deny
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
reason: `Hook error (sh-injection-guard): ${err.message}`,
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
process.exit(2);
|
|
187
|
+
}
|
|
188
|
+
} // end require.main guard
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Exports for testing
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
module.exports = {
|
|
194
|
+
ZERO_WIDTH_RE,
|
|
195
|
+
extractText,
|
|
196
|
+
};
|