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,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-config-guard.js — Settings.json mutation guard
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §5.3
|
|
4
|
+
// Event: ConfigChange
|
|
5
|
+
// Target response time: < 100ms
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const {
|
|
11
|
+
readHookInput,
|
|
12
|
+
allow,
|
|
13
|
+
deny,
|
|
14
|
+
sha256,
|
|
15
|
+
appendEvidence,
|
|
16
|
+
} = require("./lib/sh-utils");
|
|
17
|
+
|
|
18
|
+
const HOOK_NAME = "sh-config-guard";
|
|
19
|
+
const SETTINGS_FILE = path.join(".claude", "settings.json");
|
|
20
|
+
const CONFIG_HASH_FILE = path.join(".claude", "logs", "config-hash.json");
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Config Analysis
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read and parse settings.json.
|
|
28
|
+
* @returns {Object|null}
|
|
29
|
+
*/
|
|
30
|
+
function readSettings() {
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.existsSync(SETTINGS_FILE)) return null;
|
|
33
|
+
return JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load previously stored config snapshot.
|
|
41
|
+
* @returns {{ hash: string, deny_rules: string[], hook_count: number, sandbox: boolean }|null}
|
|
42
|
+
*/
|
|
43
|
+
function loadStoredConfig() {
|
|
44
|
+
try {
|
|
45
|
+
if (!fs.existsSync(CONFIG_HASH_FILE)) return null;
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(CONFIG_HASH_FILE, "utf8"));
|
|
47
|
+
// Validate shield-harness format (deny_rules array required)
|
|
48
|
+
// Reject legacy-format snapshots (hash + snapshot_keys only)
|
|
49
|
+
if (!Array.isArray(data.deny_rules)) return null;
|
|
50
|
+
return data;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save current config snapshot.
|
|
58
|
+
* @param {Object} snapshot
|
|
59
|
+
*/
|
|
60
|
+
function saveConfigSnapshot(snapshot) {
|
|
61
|
+
const dir = path.dirname(CONFIG_HASH_FILE);
|
|
62
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
fs.writeFileSync(CONFIG_HASH_FILE, JSON.stringify(snapshot, null, 2));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract security-critical fields from settings.
|
|
68
|
+
* @param {Object} settings
|
|
69
|
+
* @returns {{ deny_rules: string[], hook_count: number, hook_events: string[], hook_commands: string[], sandbox: boolean, unsandboxed: boolean, disableAllHooks: boolean }}
|
|
70
|
+
*/
|
|
71
|
+
function extractSecurityFields(settings) {
|
|
72
|
+
const denyRules = (settings.permissions && settings.permissions.deny) || [];
|
|
73
|
+
|
|
74
|
+
// Count total hooks and collect command strings across all events
|
|
75
|
+
const hooks = settings.hooks || {};
|
|
76
|
+
let hookCount = 0;
|
|
77
|
+
const hookEvents = [];
|
|
78
|
+
const hookCommands = [];
|
|
79
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
80
|
+
hookEvents.push(event);
|
|
81
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
82
|
+
const hookList = entry.hooks || [];
|
|
83
|
+
hookCount += hookList.length;
|
|
84
|
+
for (const h of hookList) {
|
|
85
|
+
if (h.command) {
|
|
86
|
+
hookCommands.push(h.command);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
deny_rules: denyRules,
|
|
94
|
+
hook_count: hookCount,
|
|
95
|
+
hook_events: hookEvents,
|
|
96
|
+
hook_commands: hookCommands.sort(),
|
|
97
|
+
sandbox:
|
|
98
|
+
settings.sandbox !== undefined
|
|
99
|
+
? Boolean(settings.sandbox.enabled !== false)
|
|
100
|
+
: true,
|
|
101
|
+
unsandboxed: Boolean(settings.allowUnsandboxedCommands),
|
|
102
|
+
disableAllHooks: Boolean(settings.disableAllHooks),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check for dangerous mutations between stored and current config.
|
|
108
|
+
* @param {Object} stored - Previous security fields
|
|
109
|
+
* @param {Object} current - Current security fields
|
|
110
|
+
* @returns {{ blocked: boolean, reasons: string[] }}
|
|
111
|
+
*/
|
|
112
|
+
function detectDangerousMutations(stored, current) {
|
|
113
|
+
const reasons = [];
|
|
114
|
+
|
|
115
|
+
// Check 1: deny rules removed
|
|
116
|
+
for (const rule of stored.deny_rules) {
|
|
117
|
+
if (!current.deny_rules.includes(rule)) {
|
|
118
|
+
reasons.push(`deny rule removed: "${rule}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check 2: hooks removed (event-level check)
|
|
123
|
+
if (current.hook_count < stored.hook_count) {
|
|
124
|
+
const removedCount = stored.hook_count - current.hook_count;
|
|
125
|
+
reasons.push(`${removedCount} hook(s) removed from configuration`);
|
|
126
|
+
}
|
|
127
|
+
for (const event of stored.hook_events) {
|
|
128
|
+
if (!current.hook_events.includes(event)) {
|
|
129
|
+
reasons.push(`hook event "${event}" entirely removed`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check 2b: hook command content swap (B23 — same count, different commands)
|
|
134
|
+
const storedCmds = stored.hook_commands || [];
|
|
135
|
+
const currentCmds = current.hook_commands || [];
|
|
136
|
+
if (storedCmds.length > 0 && currentCmds.length > 0) {
|
|
137
|
+
for (const cmd of storedCmds) {
|
|
138
|
+
if (!currentCmds.includes(cmd)) {
|
|
139
|
+
reasons.push(`hook command removed or swapped: "${cmd}"`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check 3: sandbox disabled
|
|
145
|
+
if (stored.sandbox && !current.sandbox) {
|
|
146
|
+
reasons.push("sandbox.enabled set to false");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check 4: unsandboxed commands allowed
|
|
150
|
+
if (!stored.unsandboxed && current.unsandboxed) {
|
|
151
|
+
reasons.push("allowUnsandboxedCommands set to true");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check 5: all hooks disabled
|
|
155
|
+
if (!stored.disableAllHooks && current.disableAllHooks) {
|
|
156
|
+
reasons.push("disableAllHooks set to true");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
blocked: reasons.length > 0,
|
|
161
|
+
reasons,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Main
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
if (require.main === module) {
|
|
170
|
+
try {
|
|
171
|
+
const input = readHookInput();
|
|
172
|
+
|
|
173
|
+
const settings = readSettings();
|
|
174
|
+
if (!settings) {
|
|
175
|
+
deny(`[${HOOK_NAME}] settings.json not found or unreadable — fail-close`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const currentFields = extractSecurityFields(settings);
|
|
180
|
+
const settingsContent = fs.readFileSync(SETTINGS_FILE, "utf8");
|
|
181
|
+
const currentHash = sha256(settingsContent);
|
|
182
|
+
|
|
183
|
+
const stored = loadStoredConfig();
|
|
184
|
+
|
|
185
|
+
// First run: record baseline
|
|
186
|
+
if (!stored) {
|
|
187
|
+
saveConfigSnapshot({
|
|
188
|
+
hash: currentHash,
|
|
189
|
+
...currentFields,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
appendEvidence({
|
|
194
|
+
hook: HOOK_NAME,
|
|
195
|
+
event: "ConfigChange",
|
|
196
|
+
decision: "allow",
|
|
197
|
+
action: "baseline_recorded",
|
|
198
|
+
settings_hash: `sha256:${currentHash}`,
|
|
199
|
+
session_id: input.sessionId,
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
// Non-blocking
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
allow(`[${HOOK_NAME}] Config baseline recorded`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for dangerous mutations
|
|
210
|
+
const mutations = detectDangerousMutations(stored, currentFields);
|
|
211
|
+
|
|
212
|
+
if (mutations.blocked) {
|
|
213
|
+
try {
|
|
214
|
+
appendEvidence({
|
|
215
|
+
hook: HOOK_NAME,
|
|
216
|
+
event: "ConfigChange",
|
|
217
|
+
decision: "deny",
|
|
218
|
+
reasons: mutations.reasons,
|
|
219
|
+
settings_hash: `sha256:${currentHash}`,
|
|
220
|
+
previous_hash: `sha256:${stored.hash}`,
|
|
221
|
+
session_id: input.sessionId,
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
// Non-blocking
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
deny(
|
|
228
|
+
`[${HOOK_NAME}] Blocked dangerous config change: ${mutations.reasons.join("; ")}`,
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Safe change — update snapshot and allow
|
|
234
|
+
saveConfigSnapshot({
|
|
235
|
+
hash: currentHash,
|
|
236
|
+
...currentFields,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
appendEvidence({
|
|
241
|
+
hook: HOOK_NAME,
|
|
242
|
+
event: "ConfigChange",
|
|
243
|
+
decision: "allow",
|
|
244
|
+
action: "config_updated",
|
|
245
|
+
settings_hash: `sha256:${currentHash}`,
|
|
246
|
+
previous_hash: stored ? `sha256:${stored.hash}` : null,
|
|
247
|
+
session_id: input.sessionId,
|
|
248
|
+
});
|
|
249
|
+
} catch {
|
|
250
|
+
// Non-blocking
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
allow();
|
|
254
|
+
} catch (err) {
|
|
255
|
+
// SECURITY hook — fail-close (§2.3b)
|
|
256
|
+
process.stdout.write(
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
reason: `[${HOOK_NAME}] Hook error (fail-close): ${err.message}`,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
process.exit(2);
|
|
262
|
+
}
|
|
263
|
+
} // end require.main === module
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Exports (for testing)
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
readSettings,
|
|
271
|
+
loadStoredConfig,
|
|
272
|
+
saveConfigSnapshot,
|
|
273
|
+
extractSecurityFields,
|
|
274
|
+
detectDangerousMutations,
|
|
275
|
+
};
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// sh-data-boundary.js — Production data boundary guard + jurisdiction tracking
|
|
3
|
+
// Spec: DETAILED_DESIGN.md §3.4
|
|
4
|
+
// Hook event: PreToolUse
|
|
5
|
+
// Matcher: Bash|WebFetch
|
|
6
|
+
// Target response time: < 50ms
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const {
|
|
12
|
+
readHookInput,
|
|
13
|
+
allow,
|
|
14
|
+
deny,
|
|
15
|
+
readSession,
|
|
16
|
+
appendEvidence,
|
|
17
|
+
} = require("./lib/sh-utils");
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Config Paths
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const SH_CONFIG_DIR = path.join(".shield-harness", "config");
|
|
24
|
+
const PRODUCTION_HOSTS_FILE = path.join(SH_CONFIG_DIR, "production-hosts.json");
|
|
25
|
+
const ALLOWED_JURISDICTIONS_FILE = path.join(
|
|
26
|
+
SH_CONFIG_DIR,
|
|
27
|
+
"allowed-jurisdictions.json",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Default Production Host Patterns (used when config file has "patterns" key)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const DEFAULT_PROD_PATTERNS = [
|
|
35
|
+
/\bprod-/i,
|
|
36
|
+
/\bproduction\./i,
|
|
37
|
+
/\.prod\./i,
|
|
38
|
+
/\bprod\b.*\.(rds|database|db|sql)/i,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Hostname Extraction from Bash Commands
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
// Commands that commonly connect to external hosts
|
|
46
|
+
const HOST_EXTRACTORS = [
|
|
47
|
+
// curl/wget: extract URL or host argument
|
|
48
|
+
{
|
|
49
|
+
pattern: /\b(?:curl|wget)\s+(?:[^\s]*\s+)*(?:https?:\/\/)?([^\s/:]+)/i,
|
|
50
|
+
group: 1,
|
|
51
|
+
},
|
|
52
|
+
// ssh: user@host or just host
|
|
53
|
+
{ pattern: /\bssh\s+(?:[^\s]*\s+)*(?:\w+@)?([^\s/:]+)/i, group: 1 },
|
|
54
|
+
// psql: -h host
|
|
55
|
+
{ pattern: /\bpsql\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
|
|
56
|
+
// psql: host in connection string
|
|
57
|
+
{ pattern: /\bpsql\b.*?(?:host=|\/\/)([^\s/:;]+)/i, group: 1 },
|
|
58
|
+
// mysql: -h host
|
|
59
|
+
{ pattern: /\bmysql\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
|
|
60
|
+
// mongosh/mongo: host in connection string
|
|
61
|
+
{
|
|
62
|
+
pattern: /\b(?:mongosh|mongo)\b.*?(?:mongodb(?:\+srv)?:\/\/)([^\s/:]+)/i,
|
|
63
|
+
group: 1,
|
|
64
|
+
},
|
|
65
|
+
// redis-cli: -h host
|
|
66
|
+
{ pattern: /\bredis-cli\b.*?\s+-h\s+([^\s]+)/i, group: 1 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract hostnames from a Bash command string.
|
|
71
|
+
* @param {string} command
|
|
72
|
+
* @returns {string[]} Array of extracted hostnames (lowercase).
|
|
73
|
+
*/
|
|
74
|
+
function extractHostsFromCommand(command) {
|
|
75
|
+
const hosts = [];
|
|
76
|
+
for (const extractor of HOST_EXTRACTORS) {
|
|
77
|
+
const match = command.match(extractor.pattern);
|
|
78
|
+
if (match && match[extractor.group]) {
|
|
79
|
+
hosts.push(match[extractor.group].toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return hosts;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract hostname from a URL string.
|
|
87
|
+
* Applies URL decoding before parsing to defeat percent-encoding evasion.
|
|
88
|
+
* @param {string} url
|
|
89
|
+
* @returns {string|null} Lowercase hostname or null.
|
|
90
|
+
*/
|
|
91
|
+
function extractHostFromUrl(url) {
|
|
92
|
+
// Decode percent-encoded characters before parsing (SSRF evasion defense)
|
|
93
|
+
let decoded = url;
|
|
94
|
+
try {
|
|
95
|
+
decoded = decodeURIComponent(url);
|
|
96
|
+
} catch {
|
|
97
|
+
// Malformed percent encoding — proceed with original
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(decoded);
|
|
102
|
+
return parsed.hostname.toLowerCase();
|
|
103
|
+
} catch {
|
|
104
|
+
// Try extracting with regex as fallback
|
|
105
|
+
const match = decoded.match(/^https?:\/\/([^/:]+)/i);
|
|
106
|
+
return match ? match[1].toLowerCase() : null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Config Loaders (fail-safe: missing config = skip check)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load production hosts config.
|
|
116
|
+
* Returns null if file doesn't exist (= no restrictions configured).
|
|
117
|
+
*
|
|
118
|
+
* Expected format:
|
|
119
|
+
* {
|
|
120
|
+
* "hosts": ["prod-db.example.com", "production.internal"],
|
|
121
|
+
* "patterns": ["prod-", "production\\."]
|
|
122
|
+
* }
|
|
123
|
+
*
|
|
124
|
+
* @returns {{ hosts: string[], patterns: RegExp[] } | null}
|
|
125
|
+
*/
|
|
126
|
+
function loadProductionHosts() {
|
|
127
|
+
if (!fs.existsSync(PRODUCTION_HOSTS_FILE)) return null;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const config = JSON.parse(fs.readFileSync(PRODUCTION_HOSTS_FILE, "utf8"));
|
|
131
|
+
const hosts = (config.hosts || []).map((h) => h.toLowerCase());
|
|
132
|
+
const patterns = (config.patterns || []).map((p) => new RegExp(p, "i"));
|
|
133
|
+
return { hosts, patterns };
|
|
134
|
+
} catch {
|
|
135
|
+
// Corrupted config — fail-close for security
|
|
136
|
+
return { hosts: [], patterns: DEFAULT_PROD_PATTERNS };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Load allowed jurisdictions config.
|
|
142
|
+
* Returns null if file doesn't exist (= no jurisdiction restrictions).
|
|
143
|
+
*
|
|
144
|
+
* Expected format:
|
|
145
|
+
* {
|
|
146
|
+
* "allowed": ["JP", "US", "EU"],
|
|
147
|
+
* "tld_map": { ".jp": "JP", ".us": "US", ".eu": "EU", ".de": "EU", ... }
|
|
148
|
+
* }
|
|
149
|
+
*
|
|
150
|
+
* @returns {{ allowed: Set<string>, tldMap: Object } | null}
|
|
151
|
+
*/
|
|
152
|
+
function loadAllowedJurisdictions() {
|
|
153
|
+
if (!fs.existsSync(ALLOWED_JURISDICTIONS_FILE)) return null;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const config = JSON.parse(
|
|
157
|
+
fs.readFileSync(ALLOWED_JURISDICTIONS_FILE, "utf8"),
|
|
158
|
+
);
|
|
159
|
+
const allowed = new Set((config.allowed || []).map((j) => j.toUpperCase()));
|
|
160
|
+
const tldMap = config.tld_map || {};
|
|
161
|
+
return { allowed, tldMap };
|
|
162
|
+
} catch {
|
|
163
|
+
// Corrupted config — skip jurisdiction check (cannot determine safely)
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Production Host Check
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a hostname matches production host patterns.
|
|
174
|
+
* @param {string} hostname - Lowercase hostname to check.
|
|
175
|
+
* @param {{ hosts: string[], patterns: RegExp[] }} config
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function isProductionHost(hostname, config) {
|
|
179
|
+
// Exact match
|
|
180
|
+
if (config.hosts.includes(hostname)) return true;
|
|
181
|
+
|
|
182
|
+
// Pattern match
|
|
183
|
+
for (const pattern of config.patterns) {
|
|
184
|
+
if (pattern.test(hostname)) return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Internal Host Check (SSRF / private network defense)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
// Cloud metadata service IPs and hostnames
|
|
195
|
+
const CLOUD_METADATA_HOSTS = new Set([
|
|
196
|
+
"169.254.169.254", // AWS, Azure
|
|
197
|
+
"metadata.google.internal", // GCP
|
|
198
|
+
"100.100.100.200", // Alibaba Cloud
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
// Localhost aliases
|
|
202
|
+
const LOCALHOST_ALIASES = new Set([
|
|
203
|
+
"127.0.0.1",
|
|
204
|
+
"localhost",
|
|
205
|
+
"0.0.0.0",
|
|
206
|
+
"[::1]",
|
|
207
|
+
"::1",
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if a hostname is an internal/private network address.
|
|
212
|
+
* Detects: cloud metadata endpoints, localhost aliases, RFC 1918 private IPs.
|
|
213
|
+
* @param {string} hostname - Lowercase hostname to check.
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
function isInternalHost(hostname) {
|
|
217
|
+
if (!hostname) return false;
|
|
218
|
+
|
|
219
|
+
const h = hostname.toLowerCase();
|
|
220
|
+
|
|
221
|
+
// Cloud metadata endpoints
|
|
222
|
+
if (CLOUD_METADATA_HOSTS.has(h)) return true;
|
|
223
|
+
|
|
224
|
+
// Localhost aliases
|
|
225
|
+
if (LOCALHOST_ALIASES.has(h)) return true;
|
|
226
|
+
|
|
227
|
+
// RFC 1918 private IP ranges
|
|
228
|
+
const ipMatch = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
229
|
+
if (ipMatch) {
|
|
230
|
+
const [, a, b] = ipMatch.map(Number);
|
|
231
|
+
|
|
232
|
+
// 10.0.0.0/8
|
|
233
|
+
if (a === 10) return true;
|
|
234
|
+
|
|
235
|
+
// 172.16.0.0/12 (172.16.x.x — 172.31.x.x)
|
|
236
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
237
|
+
|
|
238
|
+
// 192.168.0.0/16
|
|
239
|
+
if (a === 192 && b === 168) return true;
|
|
240
|
+
|
|
241
|
+
// Link-local (169.254.x.x) — covers AWS metadata and other link-local
|
|
242
|
+
if (a === 169 && b === 254) return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Hostname ending with .internal (GCP convention)
|
|
246
|
+
if (h.endsWith(".internal")) return true;
|
|
247
|
+
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Jurisdiction Check
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Estimate jurisdiction from hostname TLD.
|
|
257
|
+
* @param {string} hostname
|
|
258
|
+
* @param {Object} tldMap - TLD to jurisdiction code mapping.
|
|
259
|
+
* @returns {string|null} Jurisdiction code (e.g., "JP") or null if unknown.
|
|
260
|
+
*/
|
|
261
|
+
function estimateJurisdiction(hostname, tldMap) {
|
|
262
|
+
// Extract TLD (last dot-segment)
|
|
263
|
+
const parts = hostname.split(".");
|
|
264
|
+
if (parts.length < 2) return null;
|
|
265
|
+
|
|
266
|
+
const tld = "." + parts[parts.length - 1];
|
|
267
|
+
|
|
268
|
+
// Check custom TLD map
|
|
269
|
+
const upperTld = tld.toLowerCase();
|
|
270
|
+
for (const [key, value] of Object.entries(tldMap)) {
|
|
271
|
+
if (key.toLowerCase() === upperTld) return value.toUpperCase();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Main (only runs when executed directly, not when required for testing)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
if (require.main === module) {
|
|
282
|
+
try {
|
|
283
|
+
const input = readHookInput();
|
|
284
|
+
const toolName = input.toolName;
|
|
285
|
+
const toolInput = input.toolInput;
|
|
286
|
+
|
|
287
|
+
// Check channel source for evidence metadata (§8.6.3)
|
|
288
|
+
let isChannel = false;
|
|
289
|
+
try {
|
|
290
|
+
const session = readSession();
|
|
291
|
+
isChannel = session.source === "channel";
|
|
292
|
+
} catch {
|
|
293
|
+
// Session read failure is non-blocking
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- Step 1: Production DB host detection ---
|
|
297
|
+
const prodConfig = loadProductionHosts();
|
|
298
|
+
|
|
299
|
+
if (prodConfig) {
|
|
300
|
+
let hostsToCheck = [];
|
|
301
|
+
|
|
302
|
+
if (toolName === "Bash") {
|
|
303
|
+
const command = (toolInput.command || "").trim();
|
|
304
|
+
hostsToCheck = extractHostsFromCommand(command);
|
|
305
|
+
} else if (toolName === "WebFetch") {
|
|
306
|
+
const url = toolInput.url || "";
|
|
307
|
+
const host = extractHostFromUrl(url);
|
|
308
|
+
if (host) hostsToCheck = [host];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const host of hostsToCheck) {
|
|
312
|
+
if (isProductionHost(host, prodConfig)) {
|
|
313
|
+
appendEvidence({
|
|
314
|
+
event: "data_boundary_deny",
|
|
315
|
+
hook: "sh-data-boundary",
|
|
316
|
+
tool: toolName,
|
|
317
|
+
host: host,
|
|
318
|
+
reason: "production_host_detected",
|
|
319
|
+
is_channel: isChannel,
|
|
320
|
+
});
|
|
321
|
+
deny(`Production environment access is prohibited: ${host}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Step 2: Jurisdiction check (WebFetch only) ---
|
|
328
|
+
if (toolName === "WebFetch") {
|
|
329
|
+
const jurisdictionConfig = loadAllowedJurisdictions();
|
|
330
|
+
|
|
331
|
+
if (jurisdictionConfig) {
|
|
332
|
+
const url = toolInput.url || "";
|
|
333
|
+
const host = extractHostFromUrl(url);
|
|
334
|
+
|
|
335
|
+
if (host) {
|
|
336
|
+
const jurisdiction = estimateJurisdiction(
|
|
337
|
+
host,
|
|
338
|
+
jurisdictionConfig.tldMap,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (jurisdiction && !jurisdictionConfig.allowed.has(jurisdiction)) {
|
|
342
|
+
appendEvidence({
|
|
343
|
+
event: "data_boundary_deny",
|
|
344
|
+
hook: "sh-data-boundary",
|
|
345
|
+
tool: toolName,
|
|
346
|
+
host: host,
|
|
347
|
+
jurisdiction: jurisdiction,
|
|
348
|
+
reason: "unauthorized_jurisdiction",
|
|
349
|
+
is_channel: isChannel,
|
|
350
|
+
});
|
|
351
|
+
deny(
|
|
352
|
+
`Unauthorized jurisdiction detected: ${jurisdiction} (host: ${host}). ` +
|
|
353
|
+
`Allowed: ${[...jurisdictionConfig.allowed].join(", ")}`,
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- Step 3: All checks passed ---
|
|
362
|
+
allow();
|
|
363
|
+
} catch (err) {
|
|
364
|
+
// fail-close: any uncaught error = deny
|
|
365
|
+
process.stdout.write(
|
|
366
|
+
JSON.stringify({
|
|
367
|
+
reason: `Hook error (sh-data-boundary): ${err.message}`,
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
process.exit(2);
|
|
371
|
+
}
|
|
372
|
+
} // end require.main === module
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Exports (for testing)
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
// Config paths (for test override)
|
|
380
|
+
PRODUCTION_HOSTS_FILE,
|
|
381
|
+
ALLOWED_JURISDICTIONS_FILE,
|
|
382
|
+
// Functions
|
|
383
|
+
extractHostsFromCommand,
|
|
384
|
+
extractHostFromUrl,
|
|
385
|
+
loadProductionHosts,
|
|
386
|
+
loadAllowedJurisdictions,
|
|
387
|
+
isProductionHost,
|
|
388
|
+
isInternalHost,
|
|
389
|
+
estimateJurisdiction,
|
|
390
|
+
};
|