@goodandready/dsh-approval-gate 0.1.8 → 0.1.10
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 +15 -0
- package/README.md +10 -2
- package/lib/engine.js +23 -15
- package/lib/index.js +125 -17
- package/lib/inspect-env.js +57 -0
- package/lib/inspect.js +21 -47
- package/lib/messages.js +95 -39
- package/lib/paths.js +59 -9
- package/lib/redact.js +47 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.10
|
|
4
|
+
|
|
5
|
+
- Policy: точное определение сессионной политики апрува в `readKnobs` через `approval.effectivePolicy(session)`, историю событий `session.eventAt` и пресет `danger-full-access` (Refs: #140).
|
|
6
|
+
- Execution: корректный пропуск некритичных команд в режиме «Полный доступ» (`never`) без ложного поднятия `ask` и автоматического отказа DSH `Error: the user rejected tool` (Refs: #140).
|
|
7
|
+
- Observability: форматирование `sessionTag` с получением строкового идентификатора сессии `session.id` / `session.name` вместо `[object Object]` (Refs: #140).
|
|
8
|
+
|
|
9
|
+
## 0.1.9
|
|
10
|
+
|
|
11
|
+
- Inspect: декомпозиция `lib/inspect.js` с выносом `checkEnvExfiltration` в `lib/inspect-env.js`, строгое соблюдение порога 600 строк (Refs: #35).
|
|
12
|
+
- Security: автоматическое маскирование и redaction чувствительных данных, токенов и паролей в сетевых URL и аудиторских логах `redactUrl` и `redactCredentials` (Refs: #42).
|
|
13
|
+
- Paths: интеграция `resolveSafePath` как основного примитива безопасности файловых путей и защита от symlink и path traversal обходов (Refs: #94, #137).
|
|
14
|
+
- Engine: интеграция 5-уровневого каскада принятия решений `SecurityEngine` (P0 Hard-Deny -> P1 Grants -> P2 Static -> P3 LLM -> P4 Ask) в хук `tools/pre-execute` и рантайм-гард `tools.guard` (Refs: #122).
|
|
15
|
+
- Messages: расширенная мультиязычная локализация причин блокировки (ru, en, zh) с контекстными подсказками безопасных альтернатив `hintPrefix` и `ruleHints` (Refs: #131).
|
|
16
|
+
- Sync: автоматический тест контроля паритета рантайм-файлов и синхронизация метаданных публичных релизов (Refs: #138).
|
|
17
|
+
|
|
3
18
|
## 0.1.8
|
|
4
19
|
|
|
5
20
|
- Paths: нормализация разделителей путей `normalizeSeparators` для кроссплатформенной обработки Windows (`\`) и POSIX (`/`) слешей (Refs: #109).
|
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/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
|
|
@@ -43,8 +44,15 @@ export function isUnattended(knobs) {
|
|
|
43
44
|
return policies.includes(policy);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Effective permission knobs, as the harness exposes them.
|
|
49
|
+
* Resolves session-level approval policy if an active execution carrying a session is provided.
|
|
50
|
+
*
|
|
51
|
+
* @param {any} ctx - Cordis context
|
|
52
|
+
* @param {any} [execution] - Optional tool execution carrying agent/session
|
|
53
|
+
* @returns {{ policy: string, sandbox: string }}
|
|
54
|
+
*/
|
|
55
|
+
export function readKnobs(ctx, execution) {
|
|
48
56
|
const read = (name) => {
|
|
49
57
|
try {
|
|
50
58
|
return ctx && typeof ctx.get === "function" ? ctx.get(name) : undefined;
|
|
@@ -54,8 +62,84 @@ function readKnobs(ctx) {
|
|
|
54
62
|
};
|
|
55
63
|
const approval = read("approval");
|
|
56
64
|
const shell = read("shell");
|
|
65
|
+
|
|
66
|
+
const agent = execution && execution.agent;
|
|
67
|
+
const session = agent && (agent.session || (typeof agent === "object" && typeof agent.eventAt === "function" ? agent : undefined));
|
|
68
|
+
|
|
69
|
+
let policy = "";
|
|
70
|
+
|
|
71
|
+
// 1. Live approval service effectivePolicy(session)
|
|
72
|
+
if (approval && session && typeof approval.effectivePolicy === "function") {
|
|
73
|
+
try {
|
|
74
|
+
policy = approval.effectivePolicy(session) || "";
|
|
75
|
+
} catch {
|
|
76
|
+
// ignore
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Direct session property fallback
|
|
81
|
+
if (!policy && session && typeof session.approvalPolicy === "string") {
|
|
82
|
+
policy = session.approvalPolicy;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Fallback: inspect session events backwards for approval/policy
|
|
86
|
+
if (!policy && session && typeof session.eventAt === "function" && typeof session.seq === "number") {
|
|
87
|
+
try {
|
|
88
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
89
|
+
const event = session.eventAt(seq);
|
|
90
|
+
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
91
|
+
policy = event.data.policy;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4. Fallback: inspect session events for permission/preset === 'danger-full-access'
|
|
101
|
+
if (!policy && session && typeof session.eventAt === "function" && typeof session.seq === "number") {
|
|
102
|
+
try {
|
|
103
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
104
|
+
const event = session.eventAt(seq);
|
|
105
|
+
if (event && event.type === "permission/preset" && event.data && event.data.preset) {
|
|
106
|
+
if (event.data.preset === "danger-full-access") {
|
|
107
|
+
policy = "never";
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. In-memory session.events array fallback (common in mocks / tests)
|
|
118
|
+
if (!policy && session && Array.isArray(session.events)) {
|
|
119
|
+
try {
|
|
120
|
+
for (let i = session.events.length - 1; i >= 0; i -= 1) {
|
|
121
|
+
const event = session.events[i];
|
|
122
|
+
if (event && event.type === "approval/policy" && event.data && event.data.policy) {
|
|
123
|
+
policy = event.data.policy;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (event && event.type === "permission/preset" && event.data && event.data.preset === "danger-full-access") {
|
|
127
|
+
policy = "never";
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 6. Global static fallback from approval service config
|
|
137
|
+
if (!policy && approval && approval.config && approval.config.policy) {
|
|
138
|
+
policy = approval.config.policy;
|
|
139
|
+
}
|
|
140
|
+
|
|
57
141
|
return {
|
|
58
|
-
policy:
|
|
142
|
+
policy: policy || "",
|
|
59
143
|
sandbox: (shell && shell.sandboxMode) || "",
|
|
60
144
|
};
|
|
61
145
|
}
|
|
@@ -98,27 +182,49 @@ export function apply(ctx, config) {
|
|
|
98
182
|
};
|
|
99
183
|
const unattendedPolicies = cfg.unattendedPolicies || DEFAULT_UNATTENDED_POLICIES.slice();
|
|
100
184
|
const forcedUnattended = cfg.unattended === true;
|
|
185
|
+
const engine = cfg.engine || new SecurityEngine({
|
|
186
|
+
inspectConfig: { toolName: toolName, fileWriteTools: fileWriteTools },
|
|
187
|
+
});
|
|
188
|
+
|
|
101
189
|
const hasPreExecute = ctx && typeof ctx.on === "function";
|
|
102
190
|
if (hasPreExecute) {
|
|
103
191
|
ctx.on("tools/pre-execute", function (execution, next) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
192
|
+
const knobs = readKnobs(ctx, execution);
|
|
193
|
+
const who = sessionTag(execution);
|
|
194
|
+
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
195
|
+
const decision = engine.decide(execution, { sessionId, knobs });
|
|
196
|
+
|
|
197
|
+
if (decision.verdict === "ask") {
|
|
198
|
+
if (forcedUnattended || isUnattended({ policy: knobs.policy, unattendedPolicies })) {
|
|
199
|
+
return next();
|
|
200
|
+
}
|
|
201
|
+
const hit = decision.hit || {
|
|
202
|
+
ask: true,
|
|
203
|
+
reason: decision.reason,
|
|
204
|
+
detail: decision.detail,
|
|
205
|
+
snippet: decision.detail || (execution && execution.arguments && execution.arguments.command),
|
|
206
|
+
};
|
|
207
|
+
return { kind: "ask", reason: askMessage(hit, who, translate, ctx && ctx.logger) };
|
|
111
208
|
}
|
|
112
|
-
return
|
|
209
|
+
return next();
|
|
113
210
|
});
|
|
114
211
|
}
|
|
115
212
|
|
|
116
213
|
const guard = function (execution) {
|
|
117
|
-
const hit = inspectExecution(execution, { toolName: toolName, fileWriteTools: fileWriteTools });
|
|
118
|
-
if (!hit.deny && !(hit.ask && !hasPreExecute)) return undefined;
|
|
119
214
|
const who = sessionTag(execution);
|
|
120
|
-
|
|
121
|
-
|
|
215
|
+
const sessionId = (execution && execution.agent && (execution.agent.session || execution.agent)) || "default";
|
|
216
|
+
const decision = engine.decide(execution, { sessionId });
|
|
217
|
+
|
|
218
|
+
if (decision.verdict === "deny" || (decision.verdict === "ask" && !hasPreExecute)) {
|
|
219
|
+
const hit = decision.hit || {
|
|
220
|
+
deny: true,
|
|
221
|
+
reason: decision.reason,
|
|
222
|
+
snippet: decision.detail || (execution && execution.arguments && execution.arguments.command) || execution.name,
|
|
223
|
+
};
|
|
224
|
+
logBlocked(ctx, hit, who);
|
|
225
|
+
return denyMessage(hit, who, translate, ctx && ctx.logger);
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
122
228
|
};
|
|
123
229
|
|
|
124
230
|
const mount = function () {
|
|
@@ -151,6 +257,8 @@ export {
|
|
|
151
257
|
hashCall,
|
|
152
258
|
sortKeys,
|
|
153
259
|
} from "./canonical.js";
|
|
154
|
-
export { expandHome } from "./paths.js";
|
|
260
|
+
export { expandHome, resolveSafePath, isPathTraversal, canonicalizeFsPath } from "./paths.js";
|
|
155
261
|
export { splitSubcommands } from "./tokenizer.js";
|
|
156
|
-
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
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,54 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
if (!Array.isArray(words) || words.length === 0) return { deny: false };
|
|
4
|
-
const cmd = basename(words[0]);
|
|
5
|
-
|
|
6
|
-
// 1. Reading /proc/*/environ
|
|
7
|
-
if (fullCommand && /\/proc\/(?:self|\$\$|\d+)\/environ\b/i.test(fullCommand)) {
|
|
8
|
-
return { deny: true, reason: "env-dump-leak", detail: "Attempted read of process environ file" };
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// 2. Bare env
|
|
12
|
-
if (cmd === "env") {
|
|
13
|
-
if (words.length === 1) {
|
|
14
|
-
return { deny: true, reason: "env-dump-leak", detail: "Bare env command dumps all process secrets" };
|
|
15
|
-
}
|
|
16
|
-
const nonEnvWords = words.slice(1).filter((w) => !w.startsWith("-"));
|
|
17
|
-
const onlyAssignments = nonEnvWords.length > 0 && nonEnvWords.every((w) => w.includes("="));
|
|
18
|
-
if (nonEnvWords.length === 0 || onlyAssignments) {
|
|
19
|
-
return { deny: true, reason: "env-dump-leak", detail: "env invocation without command dumps environment" };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// 3. Bare printenv or sensitive key read
|
|
24
|
-
if (cmd === "printenv") {
|
|
25
|
-
if (words.length === 1) {
|
|
26
|
-
return { deny: true, reason: "env-dump-leak", detail: "Bare printenv command dumps all process secrets" };
|
|
27
|
-
}
|
|
28
|
-
for (const w of words.slice(1)) {
|
|
29
|
-
if (/(?:secret|token|key|password|credential|auth|dsh_)/i.test(w)) {
|
|
30
|
-
return { deny: true, reason: "env-dump-leak", detail: "printenv querying sensitive variable" };
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// 4. export without assignment
|
|
36
|
-
if (cmd === "export") {
|
|
37
|
-
if (words.length === 1 || (words.length === 2 && words[1] === "-p")) {
|
|
38
|
-
return { deny: true, reason: "env-dump-leak", detail: "export without assignment dumps environment" };
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { deny: false };
|
|
43
|
-
}
|
|
44
|
-
|
|
1
|
+
import { checkEnvExfiltration } from "./inspect-env.js";
|
|
2
|
+
export { checkEnvExfiltration } from "./inspect-env.js";
|
|
45
3
|
import { DYNAMIC_MARKER, HEREDOC_OPS, REDIR_OPS, SPLIT_OPS, tokenize, readCommandSubstitution, readBacktick, readParameterExpansion } from "./tokenizer.js";
|
|
46
|
-
import { expandHome, isProtectedConfigPath } from "./paths.js";
|
|
4
|
+
import { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
|
|
47
5
|
import { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
48
6
|
|
|
49
7
|
export { DYNAMIC_MARKER, tokenize, splitSubcommands } from "./tokenizer.js";
|
|
50
8
|
export { askMessage, denyMessage, sessionTag } from "./messages.js";
|
|
51
|
-
export { expandHome, isProtectedConfigPath } from "./paths.js";
|
|
9
|
+
export { expandHome, isProtectedConfigPath, resolveSafePath, isPathTraversal, canonicalizeFsPath, normalizeSeparators } from "./paths.js";
|
|
52
10
|
|
|
53
11
|
// Pure command/path inspector for dsh-approval-gate.
|
|
54
12
|
// No Cordis imports: unit tests run this without the harness.
|
|
@@ -522,6 +480,13 @@ export function inspectBashCommand(command, depth) {
|
|
|
522
480
|
|
|
523
481
|
for (const redirect of redirectsOf(seg)) {
|
|
524
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
|
+
}
|
|
525
490
|
if (isProtectedPath(redirect.path)) return deny("secret-write", redirect.path);
|
|
526
491
|
}
|
|
527
492
|
|
|
@@ -596,7 +561,16 @@ export function inspectExecution(execution, config) {
|
|
|
596
561
|
if (typeof name === "string" && fileTools.has(name)) {
|
|
597
562
|
const path = pathFromArgs(args);
|
|
598
563
|
if (!path) return ask("unknown-write-target", "file write target could not be identified", name);
|
|
599
|
-
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
|
+
}
|
|
600
574
|
}
|
|
601
575
|
return allow();
|
|
602
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,22 +161,34 @@ 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
|
|
|
141
178
|
export function sessionTag(execution) {
|
|
142
179
|
const agent = execution && execution.agent;
|
|
143
180
|
if (typeof agent === "string") return " (session " + agent + ")";
|
|
144
|
-
if (agent && agent.session)
|
|
181
|
+
if (agent && agent.session) {
|
|
182
|
+
const s = agent.session;
|
|
183
|
+
const sid = typeof s === "string" ? s : (s.id || s.name || s.sessionId || s.sessionKey);
|
|
184
|
+
return sid ? " (session " + String(sid) + ")" : "";
|
|
185
|
+
}
|
|
145
186
|
return "";
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
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);
|
|
190
|
+
const flattened = String(value || "").replace(/[\r\n\t\u0000-\u001f]+/g, " ");
|
|
191
|
+
return redactCredentials(flattened).slice(0, 120);
|
|
154
192
|
}
|
|
155
193
|
|
|
156
194
|
function translated(translate, key, logger) {
|
|
@@ -183,6 +221,19 @@ const RULE_KEYS = Object.freeze({
|
|
|
183
221
|
"nested-shell": "ruleNestedShell",
|
|
184
222
|
"interpreter-payload": "ruleInterpreterPayload",
|
|
185
223
|
"env-dump-leak": "ruleEnvDumpLeak",
|
|
224
|
+
"path-traversal": "rulePathTraversal",
|
|
225
|
+
"band-hard-deny": "ruleBandHardDeny",
|
|
226
|
+
"circuit-breaker-tripped": "ruleCircuitBreakerTripped",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const HINT_KEYS = Object.freeze({
|
|
230
|
+
"recursive-rm": "hintRecursiveRm",
|
|
231
|
+
"path-traversal": "hintPathTraversal",
|
|
232
|
+
"secret-write": "hintSecretWrite",
|
|
233
|
+
"env-dump-leak": "hintEnvDumpLeak",
|
|
234
|
+
"circuit-breaker-tripped": "hintCircuitBreakerTripped",
|
|
235
|
+
"destructive-sql": "hintDestructiveSql",
|
|
236
|
+
"curl-pipe": "hintCurlPipe",
|
|
186
237
|
});
|
|
187
238
|
|
|
188
239
|
const ASK_KEYS = Object.freeze({
|
|
@@ -213,10 +264,15 @@ const DETAIL_KEYS = Object.freeze({
|
|
|
213
264
|
});
|
|
214
265
|
|
|
215
266
|
export function denyMessage(hit, who, translate, logger) {
|
|
216
|
-
const
|
|
267
|
+
const reason = hit && hit.reason;
|
|
268
|
+
const key = RULE_KEYS[reason] || "ruleUnknown";
|
|
217
269
|
const snippet = safeSnippet(hit && hit.snippet);
|
|
218
270
|
const parts = [translated(translate, "blockedPrefix", logger), translated(translate, key, logger)];
|
|
219
271
|
if (snippet) parts.push(translated(translate, "detectedLabel", logger) + ": " + snippet);
|
|
272
|
+
const hintKey = HINT_KEYS[reason];
|
|
273
|
+
if (hintKey) {
|
|
274
|
+
parts.push(translated(translate, "hintPrefix", logger) + ": " + translated(translate, hintKey, logger));
|
|
275
|
+
}
|
|
220
276
|
if (who) parts.push(who.trim());
|
|
221
277
|
return parts.join(" — ") + ".";
|
|
222
278
|
}
|
package/lib/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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.
|
|
3
4
|
|
|
@@ -146,18 +147,28 @@ export function isPathTraversal(p, baseDir = "/workspace") {
|
|
|
146
147
|
// Detect encoded traversal sequences
|
|
147
148
|
if (/(?:%2e|%2f|%5c|\.\.)(?:%2e|%2f|%5c|\.\.)/i.test(p)) return true;
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
|
|
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);
|
|
151
156
|
|
|
152
|
-
|
|
157
|
+
const normBase = canonicalizeFsPath(baseDir);
|
|
153
158
|
if (isAbsolute) {
|
|
154
|
-
candidate
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
// Relative path traversal checking
|
|
169
|
+
const candidate = canonicalizeFsPath(`${normBase}/${p}`);
|
|
159
170
|
if (candidate === normBase) return false;
|
|
160
|
-
return !
|
|
171
|
+
return !candidate.startsWith(normBase.endsWith("/") ? normBase : `${normBase}/`);
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
/**
|
|
@@ -168,6 +179,12 @@ export function isPathTraversal(p, baseDir = "/workspace") {
|
|
|
168
179
|
* @returns {{ safe: boolean, path: string, canonical: string, reason?: string }}
|
|
169
180
|
*/
|
|
170
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
|
+
|
|
171
188
|
if (isPathTraversal(p, baseDir)) {
|
|
172
189
|
return {
|
|
173
190
|
safe: false,
|
|
@@ -176,9 +193,42 @@ export function resolveSafePath(p, baseDir = "/workspace") {
|
|
|
176
193
|
canonical: canonicalizeFsPath(p),
|
|
177
194
|
};
|
|
178
195
|
}
|
|
179
|
-
|
|
196
|
+
|
|
180
197
|
const isAbsolute = /^(?:\/|[A-Za-z]:\/|\/\/)/.test(normalizeSeparators(p));
|
|
181
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
|
+
|
|
182
232
|
return {
|
|
183
233
|
safe: true,
|
|
184
234
|
path: p,
|
|
@@ -267,4 +317,4 @@ export function isProtectedConfigPath(p, env = process.env) {
|
|
|
267
317
|
|
|
268
318
|
return false;
|
|
269
319
|
}
|
|
270
|
-
|
|
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.10",
|
|
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",
|