@goodandready/dsh-approval-gate 0.1.7 → 0.1.9
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/CHANGELOG.md +17 -0
- package/README.md +10 -2
- package/lib/bands.js +98 -6
- package/lib/engine.js +23 -15
- package/lib/index.js +38 -13
- package/lib/inspect-env.js +57 -0
- package/lib/inspect.js +39 -16
- package/lib/messages.js +91 -38
- package/lib/paths.js +296 -1
- package/lib/redact.js +47 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9
|
|
4
|
+
|
|
5
|
+
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
|
6
|
+
- Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
|
|
7
|
+
- Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
|
|
8
|
+
- Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
|
|
9
|
+
- Messages: расширенная мультиязычная локализация причин блокировки (ru, en, zh) с контекстными подсказками безопасных альтернатив `hintPrefix` и `ruleHints` (Refs: #131).
|
|
10
|
+
- Sync: автоматический тест контроля паритета рантайм-файлов и синхронизация метаданных публичных релизов (Refs: #138).
|
|
11
|
+
|
|
12
|
+
## 0.1.8
|
|
13
|
+
|
|
14
|
+
- Paths: нормализация разделителей путей `normalizeSeparators` для кроссплатформенной обработки Windows (`\`) и POSIX (`/`) слешей (Refs: #109).
|
|
15
|
+
- Paths: канонизация путей и превентивная защита от path traversal обходов (`..`, `.` сегменты) `canonicalizePath` и `isPathTraversal` (Refs: #94).
|
|
16
|
+
- Security: блокировка модификации скрытых системных файлов и каталогов конфигурации (`.git`, `.env`, `.ssh`) в путях инструментов и перенаправлениях shell (Refs: #108).
|
|
17
|
+
- Security: защита от утечки переменных окружения и секретов через команды `env`, `printenv`, `export` (Refs: #105).
|
|
18
|
+
- Bands: безусловное разрешение безопасных read-only инструментов и инспекций файловой системы через Band 1 (`isReadOnlyTool`, `isSafeInspectionCommand`) (Refs: #120).
|
|
19
|
+
|
|
3
20
|
## 0.1.7
|
|
4
21
|
|
|
5
22
|
- Canonical: каноническая сериализация вызовов инструментов `canonicalizeCall` со стабильной рекурсивной сортировкой ключей и хэшированием `hashCall` (Refs: #124).
|
package/README.md
CHANGED
|
@@ -57,10 +57,18 @@ graph LR
|
|
|
57
57
|
|
|
58
58
|
| Module | Responsibility |
|
|
59
59
|
|---|---|
|
|
60
|
-
| lib/index.js | Registers the monotonic tools.guard and pre-execution approval hook; wires
|
|
60
|
+
| lib/index.js | Registers the monotonic tools.guard and pre-execution approval hook; wires SecurityEngine into DSH. |
|
|
61
|
+
| lib/engine.js | Coordinates the 5-tier decision cascade (P0 Hard-Deny, P1 Grants, P2 Static, P3 LLM, P4 Ask). |
|
|
62
|
+
| lib/bands.js | Fast two-band prefilter for critical hard-denials (Band 0) and zero-overhead safe inspections (Band 1). |
|
|
63
|
+
| lib/breaker.js | Session circuit breaker protecting against infinite agent retry loops upon security denials. |
|
|
64
|
+
| lib/grants.js | Temporary scoped session grant store with TTL and usage limits. |
|
|
65
|
+
| lib/canonical.js | Canonical serialization and hashing of tool execution calls. |
|
|
66
|
+
| lib/paths.js | Path normalization, home expansion, and symlink/traversal boundary verification via resolveSafePath. |
|
|
61
67
|
| lib/tokenizer.js | Tokenizes bounded Bash syntax, tracks operators/heredocs, and extracts parameter/command substitutions. |
|
|
62
68
|
| lib/inspect.js | Evaluates safety rules, inspects command argv and expansions, and returns pass, deny, or ask decisions. |
|
|
63
|
-
| lib/
|
|
69
|
+
| lib/inspect-env.js | Inspects and prevents environment exfiltration, procfs reads, and secret dumping. |
|
|
70
|
+
| lib/redact.js | Masks sensitive credentials, authorization headers, and query tokens in audit messages and URLs. |
|
|
71
|
+
| lib/messages.js | Provides English, Simplified Chinese, and Russian rule names, contextual hints, and formatted messages. |
|
|
64
72
|
| cordis.patch.yml | Declares the host-side plugin bundle and optional tool configuration. |
|
|
65
73
|
|
|
66
74
|
The guard runs before the tool body. A recognized dangerous action is denied even if a prompt could otherwise be shown. Uncertain syntax or targets are sent to DSH approval; DSH policy still decides whether a prompt is available.
|
package/lib/bands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// lib/bands.js
|
|
2
2
|
// Two-band deterministic filtering engine for dsh-approval-gate.
|
|
3
3
|
// Band 0: Hard Deny (fast regexes for exfiltration, fork-bombs, disk wipes)
|
|
4
|
-
// Band 1: Safe Allow (prefix globs & curated safe inspection commands)
|
|
4
|
+
// Band 1: Safe Allow (prefix globs & curated safe read-only inspection commands and tools)
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
7
7
|
/\b(?:nc|ncat|netcat)\s+.*-e\s+/i,
|
|
@@ -16,20 +16,50 @@ export const DEFAULT_DENY_PATTERNS = Object.freeze([
|
|
|
16
16
|
]);
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
19
|
+
// Git inspection commands
|
|
19
20
|
"git status*",
|
|
20
21
|
"git diff*",
|
|
21
22
|
"git log*",
|
|
22
23
|
"git branch*",
|
|
23
24
|
"git show*",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
25
|
+
"git remote*",
|
|
26
|
+
"git tag*",
|
|
27
|
+
"git rev-parse*",
|
|
28
|
+
|
|
29
|
+
// File viewing & analysis
|
|
26
30
|
"cat *",
|
|
27
31
|
"head *",
|
|
28
32
|
"tail *",
|
|
33
|
+
"more *",
|
|
34
|
+
"less *",
|
|
29
35
|
"wc *",
|
|
36
|
+
"file *",
|
|
37
|
+
"stat *",
|
|
38
|
+
"diff *",
|
|
39
|
+
|
|
40
|
+
// Search & finding
|
|
41
|
+
"grep *",
|
|
42
|
+
"fgrep *",
|
|
43
|
+
"egrep *",
|
|
44
|
+
"rg *",
|
|
45
|
+
"ag *",
|
|
46
|
+
"find *",
|
|
47
|
+
|
|
48
|
+
// Directory listing & environment info
|
|
49
|
+
"ls*",
|
|
50
|
+
"dir*",
|
|
51
|
+
"pwd*",
|
|
30
52
|
"which *",
|
|
31
53
|
"whereis *",
|
|
32
54
|
"whoami*",
|
|
55
|
+
"hostname*",
|
|
56
|
+
"uname*",
|
|
57
|
+
"date*",
|
|
58
|
+
"uptime*",
|
|
59
|
+
"echo *",
|
|
60
|
+
"printf *",
|
|
61
|
+
|
|
62
|
+
// Language / runtime versions & test runners
|
|
33
63
|
"node --version*",
|
|
34
64
|
"node -v*",
|
|
35
65
|
"npm test*",
|
|
@@ -45,21 +75,32 @@ export const DEFAULT_ALLOW_GLOBS = Object.freeze([
|
|
|
45
75
|
"pytest*",
|
|
46
76
|
"python --version*",
|
|
47
77
|
"python3 --version*",
|
|
48
|
-
"echo *",
|
|
49
78
|
]);
|
|
50
79
|
|
|
51
80
|
export const DEFAULT_READ_ONLY_TOOLS = Object.freeze([
|
|
52
81
|
"read",
|
|
53
82
|
"read_file",
|
|
54
83
|
"readFile",
|
|
84
|
+
"read_file_content",
|
|
55
85
|
"view_file",
|
|
86
|
+
"view",
|
|
87
|
+
"show",
|
|
56
88
|
"glob",
|
|
57
89
|
"grep",
|
|
58
90
|
"find_files",
|
|
91
|
+
"find_by_name",
|
|
59
92
|
"search_code",
|
|
60
93
|
"file_search",
|
|
61
94
|
"list_dir",
|
|
62
95
|
"list_directory",
|
|
96
|
+
"directory_list",
|
|
97
|
+
"tree",
|
|
98
|
+
"read_url_content",
|
|
99
|
+
"fetch_web_page",
|
|
100
|
+
"web_search",
|
|
101
|
+
"search_web",
|
|
102
|
+
"list_resources",
|
|
103
|
+
"read_resource",
|
|
63
104
|
]);
|
|
64
105
|
|
|
65
106
|
/**
|
|
@@ -79,7 +120,11 @@ export function compileGlob(pattern) {
|
|
|
79
120
|
|
|
80
121
|
export class BandsEngine {
|
|
81
122
|
/**
|
|
82
|
-
* @param {{
|
|
123
|
+
* @param {{
|
|
124
|
+
* denyPatterns?: Array<RegExp|string>,
|
|
125
|
+
* allowGlobs?: Array<RegExp|string>,
|
|
126
|
+
* readOnlyTools?: Array<string>
|
|
127
|
+
* }} [options]
|
|
83
128
|
*/
|
|
84
129
|
constructor(options = {}) {
|
|
85
130
|
this.denyPatterns = (options.denyPatterns || DEFAULT_DENY_PATTERNS).map((p) =>
|
|
@@ -91,6 +136,52 @@ export class BandsEngine {
|
|
|
91
136
|
this.readOnlyTools = new Set(options.readOnlyTools || DEFAULT_READ_ONLY_TOOLS);
|
|
92
137
|
}
|
|
93
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Check if a tool is configured as an unconditional read-only tool.
|
|
141
|
+
* @param {string} tool
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
isReadOnlyTool(tool) {
|
|
145
|
+
return typeof tool === "string" && this.readOnlyTools.has(tool.trim());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if a command matches safe inspection patterns.
|
|
150
|
+
* @param {string} command
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
isSafeInspectionCommand(command) {
|
|
154
|
+
if (typeof command !== "string" || command.trim() === "") return false;
|
|
155
|
+
const text = command.trim();
|
|
156
|
+
for (const pattern of this.denyPatterns) {
|
|
157
|
+
if (pattern.test(text)) return false;
|
|
158
|
+
}
|
|
159
|
+
for (const glob of this.allowGlobs) {
|
|
160
|
+
if (glob.test(text)) return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Register an additional read-only tool name.
|
|
167
|
+
* @param {string} tool
|
|
168
|
+
*/
|
|
169
|
+
addReadOnlyTool(tool) {
|
|
170
|
+
if (typeof tool === "string" && tool.trim()) {
|
|
171
|
+
this.readOnlyTools.add(tool.trim());
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register an additional allow-glob pattern.
|
|
177
|
+
* @param {string} globPattern
|
|
178
|
+
*/
|
|
179
|
+
addAllowGlob(globPattern) {
|
|
180
|
+
if (typeof globPattern === "string" && globPattern.trim()) {
|
|
181
|
+
this.allowGlobs.push(compileGlob(globPattern.trim()));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
94
185
|
/**
|
|
95
186
|
* Evaluates an execution request through Band 0 and Band 1.
|
|
96
187
|
* @param {string} tool - Tool name
|
|
@@ -98,7 +189,7 @@ export class BandsEngine {
|
|
|
98
189
|
* @returns {"deny" | "allow" | "unmatched"}
|
|
99
190
|
*/
|
|
100
191
|
evaluate(tool, command) {
|
|
101
|
-
if (this.
|
|
192
|
+
if (this.isReadOnlyTool(tool)) {
|
|
102
193
|
return "allow";
|
|
103
194
|
}
|
|
104
195
|
|
|
@@ -123,3 +214,4 @@ export class BandsEngine {
|
|
|
123
214
|
return "unmatched";
|
|
124
215
|
}
|
|
125
216
|
}
|
|
217
|
+
|
package/lib/engine.js
CHANGED
|
@@ -35,7 +35,7 @@ export class SecurityEngine {
|
|
|
35
35
|
*
|
|
36
36
|
* @param {{ name: string, arguments?: any }} execution - Tool call
|
|
37
37
|
* @param {{ sessionId?: string, knobs?: any }} [context] - Context options
|
|
38
|
-
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string }}
|
|
38
|
+
* @returns {{ verdict: "allow" | "deny" | "ask", stage: "P0" | "P1" | "P2" | "P3" | "P4", reason: string, detail?: string, hit?: any }}
|
|
39
39
|
*/
|
|
40
40
|
decide(execution, context = {}) {
|
|
41
41
|
const sessionId = context.sessionId || "default";
|
|
@@ -46,14 +46,15 @@ export class SecurityEngine {
|
|
|
46
46
|
const bandVerdict = this.bands.evaluate(toolName, command);
|
|
47
47
|
if (bandVerdict === "deny") {
|
|
48
48
|
this.breaker.countDeny(sessionId);
|
|
49
|
-
|
|
49
|
+
const hit = { deny: true, reason: "band-hard-deny", detail: command, snippet: command };
|
|
50
|
+
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// Inspect execution for hard denials and syntax checks
|
|
53
54
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
54
55
|
if (hit.deny) {
|
|
55
56
|
this.breaker.countDeny(sessionId);
|
|
56
|
-
return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet };
|
|
57
|
+
return { verdict: "deny", stage: "P0", reason: hit.reason || "hard-deny", detail: hit.detail || hit.snippet, hit };
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// P1: Session Grants
|
|
@@ -61,24 +62,36 @@ export class SecurityEngine {
|
|
|
61
62
|
const grantMatch = this.grantRegistry.decide(toolName, execution.arguments, context);
|
|
62
63
|
if (grantMatch === "allow") {
|
|
63
64
|
this.breaker.resetConsecutive(sessionId);
|
|
64
|
-
return { verdict: "allow", stage: "P1", reason: "session-grant" };
|
|
65
|
+
return { verdict: "allow", stage: "P1", reason: "session-grant", hit };
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// If Circuit Breaker is tripped, suspend auto-mode and force P4 Human Ask
|
|
69
70
|
if (this.breaker.isTripped(sessionId)) {
|
|
71
|
+
const breakerHit = {
|
|
72
|
+
ask: true,
|
|
73
|
+
reason: "circuit-breaker-tripped",
|
|
74
|
+
detail: "Circuit breaker tripped due to repeated security denials. Manual confirmation required.",
|
|
75
|
+
snippet: command || toolName,
|
|
76
|
+
};
|
|
70
77
|
return {
|
|
71
78
|
verdict: "ask",
|
|
72
79
|
stage: "P4",
|
|
73
80
|
reason: "circuit-breaker-tripped",
|
|
74
|
-
detail:
|
|
81
|
+
detail: breakerHit.detail,
|
|
82
|
+
hit: breakerHit,
|
|
75
83
|
};
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
// P4: Human Ask if syntax is unparseable or uncertain
|
|
87
|
+
if (hit.ask) {
|
|
88
|
+
return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet, hit };
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
// P2: Static Rules / Safe Allow
|
|
79
92
|
if (bandVerdict === "allow") {
|
|
80
93
|
this.breaker.resetConsecutive(sessionId);
|
|
81
|
-
return { verdict: "allow", stage: "P2", reason: "safe-allow" };
|
|
94
|
+
return { verdict: "allow", stage: "P2", reason: "safe-allow", hit };
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
// P3: Optional Semantic Classifier
|
|
@@ -87,24 +100,19 @@ export class SecurityEngine {
|
|
|
87
100
|
const cls = this.classifier(execution, context);
|
|
88
101
|
if (cls && cls.verdict === "allow") {
|
|
89
102
|
this.breaker.resetConsecutive(sessionId);
|
|
90
|
-
return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow" };
|
|
103
|
+
return { verdict: "allow", stage: "P3", reason: cls.reason || "classifier-allow", hit };
|
|
91
104
|
}
|
|
92
105
|
if (cls && cls.verdict === "deny") {
|
|
93
106
|
this.breaker.countDeny(sessionId);
|
|
94
|
-
|
|
107
|
+
const clsHit = { deny: true, reason: cls.reason || "classifier-deny", snippet: command || toolName };
|
|
108
|
+
return { verdict: "deny", stage: "P3", reason: cls.reason || "classifier-deny", detail: cls.reason, hit: clsHit };
|
|
95
109
|
}
|
|
96
110
|
} catch (err) {
|
|
97
111
|
// Classifier error fails closed to P4 Ask
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
// P4: Human Ask (uncertain syntax or unlisted command)
|
|
102
|
-
if (hit.ask) {
|
|
103
|
-
return { verdict: "ask", stage: "P4", reason: hit.reason || "uncertain-syntax", detail: hit.detail || hit.snippet };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
115
|
// Safe default for unclassified commands
|
|
107
|
-
return { verdict: "allow", stage: "P2", reason: "unmatched-allow" };
|
|
116
|
+
return { verdict: "allow", stage: "P2", reason: "unmatched-allow", hit };
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
|
-
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SecurityEngine } from "./engine.js";
|
|
1
2
|
// dsh-approval-gate - approval gate for DeepSeek Harness.
|
|
2
3
|
//
|
|
3
4
|
// Registers tools.guard on the tool runtime and denies dangerous bash
|
|
@@ -98,27 +99,49 @@ export function apply(ctx, config) {
|
|
|
98
99
|
};
|
|
99
100
|
const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
|
|
100
101
|
const forcedUnattended = cfg.unattended === true;
|
|
102
|
+
const engine = cfg.engine || new SecurityEngine({
|
|
103
|
+
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools },
|
|
104
|
+
});
|
|
105
|
+
|
|
101
106
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
102
107
|
if (hasPreExecute) {
|
|
103
108
|
ctx.on("tools/pre-execute", function (execution, next) {
|
|
104
|
-
const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
|
|
105
|
-
if (!hit.ask) return next();
|
|
106
109
|
const knobs = readKnobs(ctx);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const who = sessionTag(execution);
|
|
111
|
+
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
112
|
+
const decision = engine.decide(execution, { sessionId, knobs });
|
|
113
|
+
|
|
114
|
+
if (decision.verdict === "ask") {
|
|
115
|
+
if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
|
|
116
|
+
return next();
|
|
117
|
+
}
|
|
118
|
+
const hit = decision.hit || {
|
|
119
|
+
ask: true,
|
|
120
|
+
reason: decision.reason,
|
|
121
|
+
detail: decision.detail,
|
|
122
|
+
snippet: decision.detail || (execution && execution.arguments && execution.arguments.command),
|
|
123
|
+
};
|
|
124
|
+
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
111
125
|
}
|
|
112
|
-
return
|
|
126
|
+
return next();
|
|
113
127
|
});
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
const guard = function (execution) {
|
|
117
|
-
const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
|
|
118
|
-
if (!hit.deny && !(hit.ask && !hasPreExecute)) return undefined;
|
|
119
131
|
const who = sessionTag(execution);
|
|
120
|
-
|
|
121
|
-
|
|
132
|
+
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
133
|
+
const decision = engine.decide(execution, { sessionId });
|
|
134
|
+
|
|
135
|
+
if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
|
|
136
|
+
const hit = decision.hit || {
|
|
137
|
+
deny: true,
|
|
138
|
+
reason: decision.reason,
|
|
139
|
+
snippet: decision.detail || (execution && execution.arguments && execution.arguments.command) || execution.name,
|
|
140
|
+
};
|
|
141
|
+
logBlocked(ctx, hit, who);
|
|
142
|
+
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
122
145
|
};
|
|
123
146
|
|
|
124
147
|
const mount = function () {
|
|
@@ -151,6 +174,8 @@ export {
|
|
|
151
174
|
hashCall,
|
|
152
175
|
sortKeys,
|
|
153
176
|
} from "./canonical.js";
|
|
154
|
-
export { expandHome } from "./paths.js";
|
|
177
|
+
export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
|
|
155
178
|
export { splitSubcommands } from "./tokenizer.js";
|
|
156
|
-
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
export { redactCredentials, redactUrl } from "./redact.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// lib/inspect-env.js
|
|
2
|
+
// Inspection of environment exfiltration, memory dumps, and bare env commands.
|
|
3
|
+
|
|
4
|
+
export function basename(path) {
|
|
5
|
+
const parts = String(path).replace(/\\/g, "/").split("/");
|
|
6
|
+
return parts[parts.length - 1] || "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inspects argv and command strings for process environment leaks and credential dumping.
|
|
11
|
+
*
|
|
12
|
+
* @param {string[]} words - Command words
|
|
13
|
+
* @param {string} [fullCommand] - Full unparsed command string
|
|
14
|
+
* @returns {{ deny: boolean, reason?: string, detail?: string }}
|
|
15
|
+
*/
|
|
16
|
+
export function checkEnvExfiltration(words, fullCommand = "") {
|
|
17
|
+
if (!Array.isArray(words) || words.length === 0) return { deny: false };
|
|
18
|
+
const cmd = basename(words[0]);
|
|
19
|
+
|
|
20
|
+
// 1. Reading /proc/*/environ
|
|
21
|
+
if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
|
|
22
|
+
return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Bare env
|
|
26
|
+
if (cmd === "env") {
|
|
27
|
+
if (words.length === 1) {
|
|
28
|
+
return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
|
|
29
|
+
}
|
|
30
|
+
const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
|
|
31
|
+
const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
|
|
32
|
+
if (nonEnvWords.length === 0 || onlyAssignments) {
|
|
33
|
+
return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. Bare printenv or sensitive key read
|
|
38
|
+
if (cmd === "printenv") {
|
|
39
|
+
if (words.length === 1) {
|
|
40
|
+
return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
|
|
41
|
+
}
|
|
42
|
+
for (const w of words.slice(1)) {
|
|
43
|
+
if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
|
|
44
|
+
return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 4. export without assignment
|
|
50
|
+
if (cmd === "export") {
|
|
51
|
+
if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
|
|
52
|
+
return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { deny: false };
|
|
57
|
+
}
|
package/lib/inspect.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { checkEnvExfiltration } from "./inspect-env.js";
|
|
2
|
+
export { checkEnvExfiltration } from "./inspect-env.js";
|
|
1
3
|
import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
|
|
2
|
-
import { expandHome } from "./paths.js";
|
|
4
|
+
import { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
|
|
3
5
|
import { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
4
6
|
|
|
5
7
|
export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
|
|
6
8
|
export { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
7
|
-
export { expandHome } from "./paths.js";
|
|
9
|
+
export { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
|
|
8
10
|
|
|
9
11
|
// Pure command/path inspector for dsh-approval-gate.
|
|
10
12
|
// No Cordis imports: unit tests run this without the harness.
|
|
@@ -40,19 +42,7 @@ export function basename(path) {
|
|
|
40
42
|
|
|
41
43
|
export function isProtectedPath(path, env = process.env) {
|
|
42
44
|
if (typeof path !== "string" || path.trim() === "") return false;
|
|
43
|
-
|
|
44
|
-
const normalized = expanded.replace(/\\/g, "/");
|
|
45
|
-
if (normalized.includes("/etc/sudoers.d/")) return true;
|
|
46
|
-
const base = basename(normalized);
|
|
47
|
-
if (base === ".env" || base.startsWith(".env.")) return true;
|
|
48
|
-
if (/^credentials\.ya?ml$/i.test(base)) return true;
|
|
49
|
-
if (/^settings\.ya?ml$/i.test(base)) return true;
|
|
50
|
-
if (base === "cordis.patch.yml") return true;
|
|
51
|
-
if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) return true;
|
|
52
|
-
if (/^(id_rsa|id_ed25519|id_ecdsa)$/.test(base)) return true;
|
|
53
|
-
if (/^(shadow|gshadow|shadow-|gshadow-|sudoers|authorized_keys2?)$/i.test(base)) return true;
|
|
54
|
-
if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(base)) return true;
|
|
55
|
-
return false;
|
|
45
|
+
return isProtectedConfigPath(path, env);
|
|
56
46
|
}
|
|
57
47
|
|
|
58
48
|
function wordsOf(seg) {
|
|
@@ -190,6 +180,18 @@ function inspectArgv(argv, depth, hasHeredoc) {
|
|
|
190
180
|
const cmd = basename(argv[0]);
|
|
191
181
|
const rest = argv.slice(1);
|
|
192
182
|
|
|
183
|
+
const envHit = checkEnvExfiltration(argv, argv.join(" "));
|
|
184
|
+
if (envHit.deny) {
|
|
185
|
+
return deny("env-dump-leak", envHit.detail || argv.join(" "));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (cmd === "env") {
|
|
189
|
+
const sub = rest.filter(a => !a.startsWith("-") && !a.includes("="));
|
|
190
|
+
if (sub.length > 0) {
|
|
191
|
+
return inspectArgv(sub, depth, hasHeredoc);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
193
195
|
if (cmd === "sudo" || cmd === "command" || cmd === "builtin" || cmd === "nohup" || cmd === "time" || cmd === "nice" || cmd === "ionice" || cmd === "stdbuf" || cmd === "timeout") {
|
|
194
196
|
const arity = new Set(["-u", "--user", "-g", "--group", "-p", "-s", "--signal", "-k", "--kill-after", "-o", "-e", "-i"]);
|
|
195
197
|
let next = skipWrapper(rest, arity);
|
|
@@ -456,6 +458,11 @@ export function inspectBashCommand(command, depth) {
|
|
|
456
458
|
|
|
457
459
|
const segs = splitSegments(tokens);
|
|
458
460
|
for (const seg of segs) {
|
|
461
|
+
const rawWords = wordsOf(seg);
|
|
462
|
+
const envHit = checkEnvExfiltration(rawWords, command);
|
|
463
|
+
if (envHit.deny) {
|
|
464
|
+
return deny("env-dump-leak", envHit.detail || command);
|
|
465
|
+
}
|
|
459
466
|
const wordTokens = wordTokensOf(seg);
|
|
460
467
|
const heredocs = seg.filter(function (t) { return t.kind === "heredoc"; });
|
|
461
468
|
const argv = stripPrefixes(wordsOf(seg));
|
|
@@ -473,6 +480,13 @@ export function inspectBashCommand(command, depth) {
|
|
|
473
480
|
|
|
474
481
|
for (const redirect of redirectsOf(seg)) {
|
|
475
482
|
if (redirect.dynamic) return ask("dynamic-redirect", "expanded redirect target cannot be verified", redirect.path);
|
|
483
|
+
const isRel = !/^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(redirect.path));
|
|
484
|
+
if (isRel && isPathTraversal(redirect.path, process.cwd())) {
|
|
485
|
+
return deny("path-traversal", redirect.path);
|
|
486
|
+
}
|
|
487
|
+
if (!isRel && isPathTraversal(redirect.path, "/")) {
|
|
488
|
+
return deny("path-traversal", redirect.path);
|
|
489
|
+
}
|
|
476
490
|
if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
|
|
477
491
|
}
|
|
478
492
|
|
|
@@ -547,7 +561,16 @@ export function inspectExecution(execution, config) {
|
|
|
547
561
|
if (typeof name === "string" && fileTools.has(name)) {
|
|
548
562
|
const path = pathFromArgs(args);
|
|
549
563
|
if (!path) return ask("unknown-write-target", "file write target could not be identified", name);
|
|
550
|
-
if (
|
|
564
|
+
if (config.workspaceDir) {
|
|
565
|
+
const safeCheck = resolveSafePath(path, config.workspaceDir);
|
|
566
|
+
if (!safeCheck.safe) return deny("path-traversal", safeCheck.reason || "path-traversal-escape", path);
|
|
567
|
+
if (isProtectedPath(safeCheck.canonical || path)) return deny("secret-write", path);
|
|
568
|
+
} else {
|
|
569
|
+
if (isPathTraversal(path, process.cwd())) {
|
|
570
|
+
return deny("path-traversal", "path-traversal-escape", path);
|
|
571
|
+
}
|
|
572
|
+
if (isProtectedPath(path)) return deny("secret-write", path);
|
|
573
|
+
}
|
|
551
574
|
}
|
|
552
575
|
return allow();
|
|
553
576
|
}
|
package/lib/messages.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactCredentials } from "./redact.js";
|
|
1
2
|
export const LOCALE_NS = "dsh-approval-gate";
|
|
2
3
|
|
|
3
4
|
export const MESSAGES = Object.freeze({
|
|
@@ -6,6 +7,7 @@ export const MESSAGES = Object.freeze({
|
|
|
6
7
|
detectedLabel: "Detected",
|
|
7
8
|
commandLabel: "Command",
|
|
8
9
|
parseReasonLabel: "Parse reason",
|
|
10
|
+
hintPrefix: "Hint",
|
|
9
11
|
detailProcessSubstitution: "process substitution needs confirmation",
|
|
10
12
|
detailUnclosedProcessSubstitution: "unclosed process substitution",
|
|
11
13
|
detailUnclosedCommandSubstitution: "unclosed command substitution",
|
|
@@ -45,12 +47,24 @@ export const MESSAGES = Object.freeze({
|
|
|
45
47
|
ruleEval: "Dynamic shell evaluation",
|
|
46
48
|
ruleNestedShell: "Nested shell depth limit",
|
|
47
49
|
ruleInterpreterPayload: "Unsafe interpreter payload",
|
|
50
|
+
ruleEnvDumpLeak: "Process environment or credential exfiltration",
|
|
51
|
+
rulePathTraversal: "Directory traversal attack detected",
|
|
52
|
+
ruleBandHardDeny: "Command matched Band 0 critical security blocklist",
|
|
53
|
+
ruleCircuitBreakerTripped: "Circuit breaker tripped due to repeated security denials",
|
|
54
|
+
hintRecursiveRm: "Specify exact target files instead of recursive deletion",
|
|
55
|
+
hintPathTraversal: "Keep operations strictly within the project directory",
|
|
56
|
+
hintSecretWrite: "Modify configuration via settings or environment variables, not raw file overwrite",
|
|
57
|
+
hintEnvDumpLeak: "Query specific non-secret environment variables individually",
|
|
58
|
+
hintCircuitBreakerTripped: "Request manual operator approval before attempting new system commands",
|
|
59
|
+
hintDestructiveSql: "Use migrations or execute non-destructive database operations",
|
|
60
|
+
hintCurlPipe: "Download the script, inspect its content, and execute locally",
|
|
48
61
|
},
|
|
49
62
|
zh: {
|
|
50
63
|
blockedPrefix: "dsh-approval-gate 规则已阻止",
|
|
51
64
|
detectedLabel: "检测到",
|
|
52
65
|
commandLabel: "命令",
|
|
53
66
|
parseReasonLabel: "解析原因",
|
|
67
|
+
hintPrefix: "建议",
|
|
54
68
|
detailProcessSubstitution: "进程替换需要确认",
|
|
55
69
|
detailUnclosedProcessSubstitution: "进程替换未闭合",
|
|
56
70
|
detailUnclosedCommandSubstitution: "命令替换未闭合",
|
|
@@ -58,24 +72,24 @@ export const MESSAGES = Object.freeze({
|
|
|
58
72
|
detailUnclosedParameterExpansion: "参数展开未闭合",
|
|
59
73
|
detailUnclosedSingleQuote: "单引号未闭合",
|
|
60
74
|
detailUnclosedDoubleQuote: "双引号未闭合",
|
|
61
|
-
detailDanglingEscape: "
|
|
62
|
-
detailInvalidHeredoc: "
|
|
63
|
-
detailUnterminatedHeredoc: "未找到
|
|
64
|
-
detailUnsupportedGrouping: "
|
|
75
|
+
detailDanglingEscape: "未完成的转义符",
|
|
76
|
+
detailInvalidHeredoc: "无效的 heredoc 定界符",
|
|
77
|
+
detailUnterminatedHeredoc: "未找到 heredoc 结束符",
|
|
78
|
+
detailUnsupportedGrouping: "分析器不支持该 shell 分组语法",
|
|
65
79
|
detailUnsupported: "不支持的 shell 语法",
|
|
66
|
-
approvalRequired: "
|
|
67
|
-
askUnknown: "
|
|
68
|
-
askUnparseable: "
|
|
69
|
-
askUnknownFormat: "
|
|
70
|
-
askUnknownWriteTarget: "
|
|
71
|
-
askDynamicCommand: "
|
|
72
|
-
askDynamicArguments: "
|
|
73
|
-
askDynamicRedirect: "
|
|
74
|
-
askScriptFile: "
|
|
75
|
-
askShellStdin: "
|
|
76
|
-
askInterpreterPayload: "
|
|
77
|
-
askCredentialInArgv: "
|
|
78
|
-
ruleUnknown: "
|
|
80
|
+
approvalRequired: "需要批准",
|
|
81
|
+
askUnknown: "命令无法完整检查;请在允许前进行审查",
|
|
82
|
+
askUnparseable: "Shell 语法无法完整检查;请在允许前进行审查",
|
|
83
|
+
askUnknownFormat: "工具调用格式无法检查;请在允许前进行审查",
|
|
84
|
+
askUnknownWriteTarget: "无法识别文件写入目标;请在允许前进行审查",
|
|
85
|
+
askDynamicCommand: "无法验证展开后的命令名;请在允许前进行审查",
|
|
86
|
+
askDynamicArguments: "敏感命令的展开参数无法验证;请在允许前进行审查",
|
|
87
|
+
askDynamicRedirect: "展开后的重定向目标无法验证;请在允许前进行审查",
|
|
88
|
+
askScriptFile: "卫兵无法获取脚本文件内容;请在允许前进行审查",
|
|
89
|
+
askShellStdin: "卫兵无法完整获取 Shell 输入;请在允许前进行审查",
|
|
90
|
+
askInterpreterPayload: "解释器副作用未完全分析;请在允许前进行审查",
|
|
91
|
+
askCredentialInArgv: "展开后的授权凭据作为进程参数传递;请使用凭据安全的 API 辅助工具,或在允许前进行审查",
|
|
92
|
+
ruleUnknown: "命中阻止性安全规则",
|
|
79
93
|
ruleRecursiveRm: "递归删除文件",
|
|
80
94
|
ruleProcessSignal: "终止进程",
|
|
81
95
|
ruleServiceControl: "更改服务状态",
|
|
@@ -84,35 +98,47 @@ export const MESSAGES = Object.freeze({
|
|
|
84
98
|
ruleFindDelete: "find -delete",
|
|
85
99
|
ruleDestructiveSql: "破坏性 SQL",
|
|
86
100
|
ruleSecretWrite: "写入受保护的敏感文件",
|
|
87
|
-
ruleDeviceWrite: "
|
|
101
|
+
ruleDeviceWrite: "写入系统设备或受保护文件",
|
|
88
102
|
ruleMkfsDevice: "格式化文件系统",
|
|
89
|
-
ruleCurlPipe: "
|
|
90
|
-
ruleEval: "动态 shell
|
|
91
|
-
ruleNestedShell: "
|
|
92
|
-
ruleInterpreterPayload: "
|
|
103
|
+
ruleCurlPipe: "将下载内容通过管道传入 shell",
|
|
104
|
+
ruleEval: "动态 shell 执行 (eval)",
|
|
105
|
+
ruleNestedShell: "超出嵌套 shell 深度限制",
|
|
106
|
+
ruleInterpreterPayload: "不安全的解释器执行代码",
|
|
107
|
+
ruleEnvDumpLeak: "尝试泄露环境变量与敏感凭据",
|
|
108
|
+
rulePathTraversal: "检测到路径穿越攻击",
|
|
109
|
+
ruleBandHardDeny: "命令匹配 Band 0 关键安全黑名单",
|
|
110
|
+
ruleCircuitBreakerTripped: "因连续多次安全拒绝,断路熔断器已触发",
|
|
111
|
+
hintRecursiveRm: "请指定具体目标文件,避免递归删除",
|
|
112
|
+
hintPathTraversal: "请将操作限制在项目根目录内",
|
|
113
|
+
hintSecretWrite: "请通过设置或环境变量修改配置,不要直接覆盖敏感配置文件",
|
|
114
|
+
hintEnvDumpLeak: "请单独查询非敏感环境变量,避免全量导出",
|
|
115
|
+
hintCircuitBreakerTripped: "请在尝试新的系统命令前请求操作员人工授权",
|
|
116
|
+
hintDestructiveSql: "请使用版本化数据库迁移,或执行非破坏性查询",
|
|
117
|
+
hintCurlPipe: "先将脚本下载到本地检查内容,再安全执行",
|
|
93
118
|
},
|
|
94
119
|
ru: {
|
|
95
120
|
blockedPrefix: "Заблокировано правилом dsh-approval-gate",
|
|
96
121
|
detectedLabel: "Обнаружено",
|
|
97
122
|
commandLabel: "Команда",
|
|
98
|
-
parseReasonLabel: "Причина
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
parseReasonLabel: "Причина разбора",
|
|
124
|
+
hintPrefix: "Подсказка",
|
|
125
|
+
detailProcessSubstitution: "подстановка процесса требует подтверждения",
|
|
126
|
+
detailUnclosedProcessSubstitution: "незакрытая подстановка процесса",
|
|
101
127
|
detailUnclosedCommandSubstitution: "незакрытая подстановка команды",
|
|
102
|
-
detailUnclosedBacktick: "незакрытая подстановка
|
|
103
|
-
detailUnclosedParameterExpansion: "незакрытое расширение
|
|
128
|
+
detailUnclosedBacktick: "незакрытая подстановка в обратных кавычках",
|
|
129
|
+
detailUnclosedParameterExpansion: "незакрытое расширение параметра",
|
|
104
130
|
detailUnclosedSingleQuote: "незакрытая одинарная кавычка",
|
|
105
131
|
detailUnclosedDoubleQuote: "незакрытая двойная кавычка",
|
|
106
132
|
detailDanglingEscape: "незавершённое экранирование",
|
|
107
133
|
detailInvalidHeredoc: "неверный разделитель here-document",
|
|
108
|
-
detailUnterminatedHeredoc: "
|
|
109
|
-
detailUnsupportedGrouping: "группировка
|
|
110
|
-
detailUnsupported: "неподдерживаемый синтаксис
|
|
134
|
+
detailUnterminatedHeredoc: "ограничитель here-document не найден",
|
|
135
|
+
detailUnsupportedGrouping: "группировка shell не поддерживается анализатором",
|
|
136
|
+
detailUnsupported: "неподдерживаемый синтаксис shell",
|
|
111
137
|
approvalRequired: "Требуется подтверждение",
|
|
112
138
|
askUnknown: "Команда не может быть проверена полностью; проверьте её перед подтверждением",
|
|
113
|
-
askUnparseable: "Синтаксис
|
|
139
|
+
askUnparseable: "Синтаксис shell не может быть проверен полностью; проверьте его перед подтверждением",
|
|
114
140
|
askUnknownFormat: "Формат вызова инструмента не может быть проверен; проверьте его перед подтверждением",
|
|
115
|
-
askUnknownWriteTarget: "
|
|
141
|
+
askUnknownWriteTarget: "Цель записи файла не может быть определена; проверьте её перед подтверждением",
|
|
116
142
|
askDynamicCommand: "Развёрнутое имя команды не может быть проверено; проверьте его перед подтверждением",
|
|
117
143
|
askDynamicArguments: "Развёрнутые аргументы чувствительной команды не могут быть проверены; проверьте их перед подтверждением",
|
|
118
144
|
askDynamicRedirect: "Развёрнутая цель перенаправления не может быть проверена; проверьте её перед подтверждением",
|
|
@@ -135,6 +161,17 @@ export const MESSAGES = Object.freeze({
|
|
|
135
161
|
ruleEval: "Динамическое выполнение шелла (eval)",
|
|
136
162
|
ruleNestedShell: "Превышен лимит вложенности шелла",
|
|
137
163
|
ruleInterpreterPayload: "Небезопасный исполняемый код интерпретатора",
|
|
164
|
+
ruleEnvDumpLeak: "Попытка утечки переменных окружения и секретов",
|
|
165
|
+
rulePathTraversal: "Обнаружена попытка обхода каталогов (path traversal)",
|
|
166
|
+
ruleBandHardDeny: "Команда заблокирована критическим списком безопасности Band 0",
|
|
167
|
+
ruleCircuitBreakerTripped: "Предохранитель сессии сработал из-за повторяющихся блокировок",
|
|
168
|
+
hintRecursiveRm: "Указывайте конкретные файлы вместо рекурсивного удаления",
|
|
169
|
+
hintPathTraversal: "Ограничьте операции пределами рабочего каталога проекта",
|
|
170
|
+
hintSecretWrite: "Изменяйте настройки через UI или переменные окружения, не перезаписывайте секретные файлы напрямую",
|
|
171
|
+
hintEnvDumpLeak: "Запрашивайте конкретные несекретные переменные по отдельности",
|
|
172
|
+
hintCircuitBreakerTripped: "Запросите подтверждение оператора перед повторной отправкой системных команд",
|
|
173
|
+
hintDestructiveSql: "Используйте версионированные миграции или выполняйте неразрушающие запросы",
|
|
174
|
+
hintCurlPipe: "Скачайте скрипт локально, проверьте его содержимое и выполните безопасно",
|
|
138
175
|
},
|
|
139
176
|
});
|
|
140
177
|
|
|
@@ -146,11 +183,8 @@ export function sessionTag(execution) {
|
|
|
146
183
|
}
|
|
147
184
|
|
|
148
185
|
function safeSnippet(value) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.replace(/(authorization\s*:\s*(?:bearer|token)\s+)\S+/gi, "$1[redacted]")
|
|
152
|
-
.replace(/((?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, "$1[redacted]")
|
|
153
|
-
.slice(0, 120);
|
|
186
|
+
const flattened = String(value || "").replace(/[\r\n\t\u0000-\u001f]+/g, " ");
|
|
187
|
+
return redactCredentials(flattened).slice(0, 120);
|
|
154
188
|
}
|
|
155
189
|
|
|
156
190
|
function translated(translate, key, logger) {
|
|
@@ -182,6 +216,20 @@ const RULE_KEYS = Object.freeze({
|
|
|
182
216
|
"eval": "ruleEval",
|
|
183
217
|
"nested-shell": "ruleNestedShell",
|
|
184
218
|
"interpreter-payload": "ruleInterpreterPayload",
|
|
219
|
+
"env-dump-leak": "ruleEnvDumpLeak",
|
|
220
|
+
"path-traversal": "rulePathTraversal",
|
|
221
|
+
"band-hard-deny": "ruleBandHardDeny",
|
|
222
|
+
"circuit-breaker-tripped": "ruleCircuitBreakerTripped",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const HINT_KEYS = Object.freeze({
|
|
226
|
+
"recursive-rm": "hintRecursiveRm",
|
|
227
|
+
"path-traversal": "hintPathTraversal",
|
|
228
|
+
"secret-write": "hintSecretWrite",
|
|
229
|
+
"env-dump-leak": "hintEnvDumpLeak",
|
|
230
|
+
"circuit-breaker-tripped": "hintCircuitBreakerTripped",
|
|
231
|
+
"destructive-sql": "hintDestructiveSql",
|
|
232
|
+
"curl-pipe": "hintCurlPipe",
|
|
185
233
|
});
|
|
186
234
|
|
|
187
235
|
const ASK_KEYS = Object.freeze({
|
|
@@ -212,10 +260,15 @@ const DETAIL_KEYS = Object.freeze({
|
|
|
212
260
|
});
|
|
213
261
|
|
|
214
262
|
export function denyMessage(hit, who, translate, logger) {
|
|
215
|
-
const
|
|
263
|
+
const reason = hit && hit.reason;
|
|
264
|
+
const key = RULE_KEYS[reason] || "ruleUnknown";
|
|
216
265
|
const snippet = safeSnippet(hit && hit.snippet);
|
|
217
266
|
const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
|
|
218
267
|
if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
|
|
268
|
+
const hintKey = HINT_KEYS[reason];
|
|
269
|
+
if (hintKey) {
|
|
270
|
+
parts.push(translated(translate, "hintPrefix", logger) + ": " + translated(translate, hintKey, logger));
|
|
271
|
+
}
|
|
219
272
|
if (who) parts.push(who.trim());
|
|
220
273
|
return parts.join(" — ") + ".";
|
|
221
274
|
}
|
package/lib/paths.js
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
// lib/paths.js
|
|
2
|
-
//
|
|
3
|
+
// Cross-platform path normalization, home expansion, canonicalization, and path boundary traversal protection for dsh-approval-gate.
|
|
4
|
+
|
|
5
|
+
const PROTECTED_BASENAMES = new Set([
|
|
6
|
+
".bashrc",
|
|
7
|
+
".bash_profile",
|
|
8
|
+
".bash_login",
|
|
9
|
+
".profile",
|
|
10
|
+
".zshrc",
|
|
11
|
+
".zprofile",
|
|
12
|
+
".zshenv",
|
|
13
|
+
".cshrc",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes Windows and POSIX path separators to uniform forward slashes.
|
|
19
|
+
* Collapses redundant slashes and standardizes Windows drive letters.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} p
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeSeparators(p) {
|
|
25
|
+
if (typeof p !== "string") return "";
|
|
26
|
+
let s = p.trim();
|
|
27
|
+
if (s === "") return "";
|
|
28
|
+
|
|
29
|
+
// Check UNC network share (e.g. \\server\share or //server/share)
|
|
30
|
+
const isUnc = /^([\\/]{2})([^\\/]+)/.test(s);
|
|
31
|
+
|
|
32
|
+
// Convert all backslashes to forward slashes
|
|
33
|
+
s = s.replace(/\\/g, "/");
|
|
34
|
+
|
|
35
|
+
// Collapse multiple consecutive slashes
|
|
36
|
+
if (isUnc) {
|
|
37
|
+
s = "//" + s.slice(2).replace(/\/+/g, "/");
|
|
38
|
+
} else {
|
|
39
|
+
s = s.replace(/\/+/g, "/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Normalize Windows drive letter: c:/ -> C:/
|
|
43
|
+
s = s.replace(/^([a-zA-Z]):(\/|$)/, (_, drive, rest) => `${drive.toUpperCase()}:${rest}`);
|
|
44
|
+
|
|
45
|
+
// Strip trailing slash unless root / or C:/
|
|
46
|
+
if (s.length > 1 && s.endsWith("/")) {
|
|
47
|
+
if (!/^(?:\/|[A-Za-z]:\/)$/.test(s)) {
|
|
48
|
+
s = s.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
3
54
|
|
|
4
55
|
/**
|
|
5
56
|
* Expand `~`, `${HOME}`, `$HOME`, and `%USERPROFILE%` at the start of or within a path.
|
|
@@ -23,3 +74,247 @@ export function expandHome(p, env = process.env) {
|
|
|
23
74
|
res = res.replace(/%USERPROFILE%/gi, cleanHome);
|
|
24
75
|
return res;
|
|
25
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Canonicalizes a path resolving dot and dot-dot segments without disk I/O.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} p
|
|
82
|
+
* @param {{ env?: Record<string, string|undefined> }} [options]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function canonicalizeFsPath(p, options = {}) {
|
|
86
|
+
if (typeof p !== "string") return "";
|
|
87
|
+
let s = expandHome(p, options.env);
|
|
88
|
+
s = normalizeSeparators(s);
|
|
89
|
+
if (!s) return "";
|
|
90
|
+
|
|
91
|
+
// Check prefix: / (root), C:/ (Windows root), //server/share (UNC), or relative
|
|
92
|
+
let prefix = "";
|
|
93
|
+
if (s.startsWith("//")) {
|
|
94
|
+
const m = s.match(/^(\/\/[^/]+\/[^/]+)(?:\/|$)/);
|
|
95
|
+
if (m) {
|
|
96
|
+
prefix = m[1];
|
|
97
|
+
s = s.slice(prefix.length);
|
|
98
|
+
}
|
|
99
|
+
} else if (/^[A-Za-z]:\//.test(s)) {
|
|
100
|
+
prefix = s.slice(0, 3);
|
|
101
|
+
s = s.slice(3);
|
|
102
|
+
} else if (s.startsWith("/")) {
|
|
103
|
+
prefix = "/";
|
|
104
|
+
s = s.slice(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rawSegments = s.split("/");
|
|
108
|
+
const stack = [];
|
|
109
|
+
|
|
110
|
+
for (const seg of rawSegments) {
|
|
111
|
+
if (seg === "" || seg === ".") continue;
|
|
112
|
+
if (seg === "..") {
|
|
113
|
+
if (stack.length > 0 && stack[stack.length - 1] !== "..") {
|
|
114
|
+
stack.pop();
|
|
115
|
+
} else if (!prefix) {
|
|
116
|
+
stack.push("..");
|
|
117
|
+
}
|
|
118
|
+
// If there is an absolute prefix (e.g. / or C:/), .. at root cannot escape above root
|
|
119
|
+
} else {
|
|
120
|
+
stack.push(seg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const resolvedBody = stack.join("/");
|
|
125
|
+
if (!prefix) {
|
|
126
|
+
return resolvedBody || ".";
|
|
127
|
+
}
|
|
128
|
+
if (!resolvedBody) {
|
|
129
|
+
return prefix;
|
|
130
|
+
}
|
|
131
|
+
return prefix.endsWith("/") ? `${prefix}${resolvedBody}` : `${prefix}/${resolvedBody}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks whether a path contains directory traversal sequences attempting to escape baseDir.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} p
|
|
138
|
+
* @param {string} [baseDir]
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
export function isPathTraversal(p, baseDir = "/workspace") {
|
|
142
|
+
if (typeof p !== "string" || p.trim() === "") return false;
|
|
143
|
+
|
|
144
|
+
// Detect null byte injection
|
|
145
|
+
if (p.includes("\0") || /%00/i.test(p)) return true;
|
|
146
|
+
|
|
147
|
+
// Detect encoded traversal sequences
|
|
148
|
+
if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
|
|
149
|
+
|
|
150
|
+
const norm = normalizeSeparators(p);
|
|
151
|
+
if (/^\/dev\/(?:null|zero|stdout|stderr|fd\/\d+)$/.test(norm)) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(norm);
|
|
156
|
+
|
|
157
|
+
const normBase = canonicalizeFsPath(baseDir);
|
|
158
|
+
if (isAbsolute) {
|
|
159
|
+
// For absolute path, it is a traversal only if caller specifies a strict baseDir and candidate is outside
|
|
160
|
+
const candidate = canonicalizeFsPath(p);
|
|
161
|
+
if (baseDir && baseDir !== "/" && normBase !== "/") {
|
|
162
|
+
if (candidate === normBase) return false;
|
|
163
|
+
return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Relative path traversal checking
|
|
169
|
+
const candidate = canonicalizeFsPath(`${normBase}/${p}`);
|
|
170
|
+
if (candidate === normBase) return false;
|
|
171
|
+
return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Safely resolves a path relative to a base directory, verifying boundaries.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} p
|
|
178
|
+
* @param {string} [baseDir]
|
|
179
|
+
* @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
|
|
180
|
+
*/
|
|
181
|
+
export function resolveSafePath(p, baseDir = "/workspace") {
|
|
182
|
+
if (typeof p !== "string" || p.trim() === "") {
|
|
183
|
+
return { safe: false, reason: "empty-path", path: p, canonical: "" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const normBase = canonicalizeFsPath(baseDir);
|
|
187
|
+
|
|
188
|
+
if (isPathTraversal(p, baseDir)) {
|
|
189
|
+
return {
|
|
190
|
+
safe: false,
|
|
191
|
+
reason: "path-traversal-escape",
|
|
192
|
+
path: p,
|
|
193
|
+
canonical: canonicalizeFsPath(p),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
|
|
198
|
+
const resolved = isAbsolute ? canonicalizeFsPath(p) : canonicalizeFsPath(`${normBase}/${p}`);
|
|
199
|
+
|
|
200
|
+
if (isAbsolute && baseDir && normBase !== "/" && !resolved.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && resolved !== normBase) {
|
|
201
|
+
return {
|
|
202
|
+
safe: false,
|
|
203
|
+
reason: "path-traversal-escape",
|
|
204
|
+
path: p,
|
|
205
|
+
canonical: resolved,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Inspect filesystem symlinks if the target file or its parent directory exists
|
|
210
|
+
try {
|
|
211
|
+
if (fs.existsSync(resolved)) {
|
|
212
|
+
const real = fs.realpathSync(resolved);
|
|
213
|
+
const normReal = canonicalizeFsPath(real);
|
|
214
|
+
if (!normReal.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`) && normReal !== normBase) {
|
|
215
|
+
return {
|
|
216
|
+
safe: false,
|
|
217
|
+
reason: "symlink-traversal-escape",
|
|
218
|
+
path: p,
|
|
219
|
+
canonical: normReal,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
safe: true,
|
|
224
|
+
path: p,
|
|
225
|
+
canonical: normReal,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Non-filesystem paths or missing directories fall back safely to lexical canonical
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
safe: true,
|
|
234
|
+
path: p,
|
|
235
|
+
canonical: resolved,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Checks whether a path targets protected system, git, ssh, or secret configuration.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} p
|
|
243
|
+
* @param {Record<string, string|undefined>} [env]
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
export function isProtectedConfigPath(p, env = process.env) {
|
|
247
|
+
if (typeof p !== "string" || p.trim() === "") return false;
|
|
248
|
+
|
|
249
|
+
const expanded = expandHome(p, env);
|
|
250
|
+
const normalized = canonicalizeFsPath(expanded);
|
|
251
|
+
const lower = normalized.toLowerCase();
|
|
252
|
+
|
|
253
|
+
// 1. .git internal tree
|
|
254
|
+
if (
|
|
255
|
+
lower === ".git" ||
|
|
256
|
+
lower.startsWith(".git/") ||
|
|
257
|
+
lower.endsWith("/.git") ||
|
|
258
|
+
lower.includes("/.git/")
|
|
259
|
+
) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 2. .ssh directory and key files
|
|
264
|
+
if (
|
|
265
|
+
lower === ".ssh" ||
|
|
266
|
+
lower.startsWith(".ssh/") ||
|
|
267
|
+
lower.endsWith("/.ssh") ||
|
|
268
|
+
lower.includes("/.ssh/")
|
|
269
|
+
) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 3. System auth and security files
|
|
274
|
+
if (
|
|
275
|
+
lower === "/etc/sudoers" ||
|
|
276
|
+
lower.includes("/etc/sudoers.d/") ||
|
|
277
|
+
lower === "/etc/shadow" ||
|
|
278
|
+
lower === "/etc/gshadow" ||
|
|
279
|
+
lower.includes("/etc/pam.d/")
|
|
280
|
+
) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 4. Shell startup files
|
|
285
|
+
const parts = normalized.split("/");
|
|
286
|
+
const base = parts[parts.length - 1];
|
|
287
|
+
const baseLower = base.toLowerCase();
|
|
288
|
+
|
|
289
|
+
if (PROTECTED_BASENAMES.has(baseLower)) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 5. Env files (.env, .env.local, .env.prod)
|
|
294
|
+
if (baseLower === ".env" || baseLower.startsWith(".env.")) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 6. Credentials and settings
|
|
299
|
+
if (/^credentials\.ya?ml$/i.test(base) || /^settings\.ya?ml$/i.test(base)) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 7. Certificates and keys
|
|
304
|
+
if (/\.(pem|key|crt|p12|pfx)$/i.test(base)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 8. SSH key filenames anywhere
|
|
309
|
+
if (/^(id_rsa|id_ed25519|id_ecdsa|authorized_keys2?)$/i.test(base)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 9. Generic secrets / tokens / passwords
|
|
314
|
+
if (/(?:^|[._-])(?:secret|token|credential|passwd|api[_-]?key)(?:$|[._-])/i.test(baseLower)) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// lib/redact.js
|
|
2
|
+
// Sensitive credential and URL masking for audit logs, approval messages, and traces.
|
|
3
|
+
|
|
4
|
+
const SENSITIVE_QUERY_PARAMS = /([?&](?:token|key|api_key|apikey|access_token|secret|password|passwd|auth|client_secret)=)[^&#\s]+/gi;
|
|
5
|
+
const USER_INFO_CREDENTIALS = /((?:https?|ftp|ssh|git):\/\/[^:@\/\s]+:)[^@\/\s]+(@)/gi;
|
|
6
|
+
const AUTH_HEADERS = /((?:authorization|proxy-authorization)\s*:\s*(?:bearer|basic|token)\s+)\S+/gi;
|
|
7
|
+
const SENSITIVE_ASSIGNMENTS = /((?:^|[\s,;])(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*)[^\s,;]+/gi;
|
|
8
|
+
const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g;
|
|
9
|
+
const TOKEN_PREFIX_PATTERNS = /\b(?:ghp_[a-zA-Z0-9]{30,}|gho_[a-zA-Z0-9]{30,}|github_pat_[a-zA-Z0-9_]{30,}|xox[baprs]-[0-9a-zA-Z-]+|AKIA[0-9A-Z]{16})\b/g;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Redacts credentials in userinfo and sensitive query params from a URL string.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} urlStr
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function redactUrl(urlStr) {
|
|
18
|
+
if (typeof urlStr !== "string" || urlStr.trim() === "") return "";
|
|
19
|
+
let redacted = urlStr.replace(USER_INFO_CREDENTIALS, "$1***$2");
|
|
20
|
+
redacted = redacted.replace(SENSITIVE_QUERY_PARAMS, "$1***");
|
|
21
|
+
return redacted;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Sanitizes and masks sensitive credentials, tokens, passwords, and URLs in arbitrary text.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} text - Arbitrary command, snippet, or log text
|
|
28
|
+
* @returns {string} Text with sensitive tokens masked with ***
|
|
29
|
+
*/
|
|
30
|
+
export function redactCredentials(text) {
|
|
31
|
+
if (typeof text !== "string") return "";
|
|
32
|
+
if (text.trim() === "") return text;
|
|
33
|
+
|
|
34
|
+
let out = text;
|
|
35
|
+
// 1. Private key blocks
|
|
36
|
+
out = out.replace(PRIVATE_KEY_BLOCK, "[REDACTED PRIVATE KEY]");
|
|
37
|
+
// 2. URLs with user:pass@host
|
|
38
|
+
out = redactUrl(out);
|
|
39
|
+
// 3. Authorization headers
|
|
40
|
+
out = out.replace(AUTH_HEADERS, "$1***");
|
|
41
|
+
// 4. Secret variable assignments
|
|
42
|
+
out = out.replace(SENSITIVE_ASSIGNMENTS, "$1***");
|
|
43
|
+
// 5. Well-known token prefixes
|
|
44
|
+
out = out.replace(TOKEN_PREFIX_PATTERNS, "***");
|
|
45
|
+
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-approval-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Host-only command safety gate for DeepSeek Harness: blocks recognized dangerous operations and requests DSH approval when a command cannot be inspected.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|