@goodandready/dsh-approval-gate 0.1.14 → 0.1.15
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 +8 -0
- package/lib/engine.js +28 -3
- package/lib/hotwords.js +122 -0
- package/lib/index.js +28 -0
- package/lib/rules-loader.js +175 -0
- package/lib/when-matcher.js +89 -0
- package/lib/yaml-parser.js +210 -0
- package/package.json +2 -1
- package/rules/builtin-high-risk.yaml +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.15
|
|
4
|
+
|
|
5
|
+
- Rules: Каскадный декларативный YAML-конфиг правил `rules.yaml` (`.dsh/rules.yaml` в проекте и `~/.dsh/rules.yaml` пользователя) с поддержкой allow/deny/ask политик (Refs: #38).
|
|
6
|
+
- Rules: Вынесение каталога встроенных критических угроз нулевого уровня (P0) в отчуждаемый версионируемый каталог `rules/builtin-high-risk.yaml` (Refs: #45).
|
|
7
|
+
- Rules: Условные предикаты правил `when` с сопоставлением контекста окружения (ОС `os`, git-ветка `branch`, переменные окружения `env` и инструмент `tool`) с поддержкой отрицания `!` (Refs: #44).
|
|
8
|
+
- Performance: Высокоскоростной пре-фильтр ключевых слов `HotwordsMatcher` (rm, kill, shadow, sudoers, curl, format и др.) для микросекундного пропуска безопасных команд (Refs: #93).
|
|
9
|
+
- Testing: Корпус тестовых векторов `test/vectors/corpus.json` из 110+ команд с автотестом контроля регрессий (FPR=0, FNR=0) (Refs: #46).
|
|
10
|
+
|
|
3
11
|
## 0.1.14
|
|
4
12
|
|
|
5
13
|
- Feature: Режим теневого аудита (`dryRun: true` / `shadowMode: true`) в `Config` и `SecurityEngine` для безопасного тестирования правил на проде без блокировки выполнения процессов агента (Refs: #128).
|
package/lib/engine.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// lib/engine.js
|
|
2
2
|
// 5-tier Security Decision Engine for dsh-approval-gate.
|
|
3
3
|
// Waterfall:
|
|
4
|
-
// P0: Hard Deny (Bands Band 0 + inspectExecution hard denials + protected files + retry exhaustion)
|
|
5
|
-
// P1: Session Grants (scoped temporary allowances)
|
|
4
|
+
// P0: Hard Deny (Bands Band 0 + Declarative Rules Deny + inspectExecution hard denials + protected files + retry exhaustion)
|
|
5
|
+
// P1: Session Grants & Declarative Allow (scoped temporary allowances and whitelists)
|
|
6
6
|
// P2: Static Rules / Safe Allow (Bands Band 1 + safe read-only commands)
|
|
7
7
|
// P3: Optional Semantic Classifier
|
|
8
|
-
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker)
|
|
8
|
+
// P4: Human Ask Fallback (uncertain syntax / ambiguous commands / tripped breaker / declarative ask)
|
|
9
9
|
|
|
10
10
|
import { BandsEngine } from "./bands.js";
|
|
11
11
|
import { CircuitBreaker } from "./breaker.js";
|
|
@@ -15,6 +15,7 @@ import { VerdictCache } from "./cache.js";
|
|
|
15
15
|
import { RetryExhaustionGuard } from "./retry-exhaustion.js";
|
|
16
16
|
import { QualityLedger } from "./ledger.js";
|
|
17
17
|
import { AuditLogger } from "./audit-log.js";
|
|
18
|
+
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
18
19
|
|
|
19
20
|
export class SecurityEngine {
|
|
20
21
|
/**
|
|
@@ -25,6 +26,7 @@ export class SecurityEngine {
|
|
|
25
26
|
* retryGuard?: RetryExhaustionGuard,
|
|
26
27
|
* ledger?: QualityLedger,
|
|
27
28
|
* auditLogger?: AuditLogger,
|
|
29
|
+
* declarativeRules?: DeclarativeRulesEngine,
|
|
28
30
|
* inspectConfig?: { toolName?: string, fileWriteTools?: string[], workspaceDir?: string },
|
|
29
31
|
* classifier?: ((execution: any, context?: any) => { verdict: "allow"|"deny", reason?: string }),
|
|
30
32
|
* grantRegistry?: { decide: (tool: string, args: any, context?: any) => "allow"|"no-match" },
|
|
@@ -46,6 +48,9 @@ export class SecurityEngine {
|
|
|
46
48
|
this.retryGuard = options.retryGuard !== undefined ? options.retryGuard : new RetryExhaustionGuard();
|
|
47
49
|
this.ledger = options.ledger !== undefined ? options.ledger : new QualityLedger();
|
|
48
50
|
this.auditLogger = options.auditLogger !== undefined ? options.auditLogger : new AuditLogger();
|
|
51
|
+
this.declarativeRules = options.declarativeRules || new DeclarativeRulesEngine({
|
|
52
|
+
workspaceDir: this.inspectConfig?.workspaceDir,
|
|
53
|
+
});
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -157,6 +162,26 @@ export class SecurityEngine {
|
|
|
157
162
|
return { verdict: "deny", stage: "P0", reason: "band-hard-deny", detail: command, hit };
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
// Declarative YAML Rules evaluation (#38, #45, #44)
|
|
166
|
+
if (this.declarativeRules && command) {
|
|
167
|
+
const decl = this.declarativeRules.evaluate(toolName, command, context);
|
|
168
|
+
if (decl.match) {
|
|
169
|
+
if (decl.verdict === "deny") {
|
|
170
|
+
this.breaker.countDeny(sessionId);
|
|
171
|
+
const hit = { deny: true, reason: decl.reason || "declarative-deny", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
172
|
+
return { verdict: "deny", stage: "P0", reason: decl.reason || "declarative-deny", detail: command, hit };
|
|
173
|
+
}
|
|
174
|
+
if (decl.verdict === "allow") {
|
|
175
|
+
this.breaker.resetConsecutive(sessionId);
|
|
176
|
+
return { verdict: "allow", stage: "P1", reason: decl.reason || "declarative-allow", hit: null };
|
|
177
|
+
}
|
|
178
|
+
if (decl.verdict === "ask") {
|
|
179
|
+
const hit = { ask: true, reason: decl.reason || "declarative-ask", detail: command, snippet: command, ruleId: decl.ruleId };
|
|
180
|
+
return { verdict: "ask", stage: "P4", reason: decl.reason || "declarative-ask", detail: command, hit };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
160
185
|
// Inspect execution for hard denials and syntax checks
|
|
161
186
|
const hit = inspectExecution(execution, this.inspectConfig);
|
|
162
187
|
if (hit.deny) {
|
package/lib/hotwords.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/hotwords.js
|
|
2
|
+
// High-speed keyword pre-filter (Hotwords / Lexicon Matcher) for dsh-approval-gate (#93).
|
|
3
|
+
// Provides sub-microsecond pre-check to bypass heavy AST/regex parsing for clean commands.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_HOTWORDS = Object.freeze([
|
|
6
|
+
"rm",
|
|
7
|
+
"mkfs",
|
|
8
|
+
"fdisk",
|
|
9
|
+
"parted",
|
|
10
|
+
"dd",
|
|
11
|
+
"shadow",
|
|
12
|
+
"sudoers",
|
|
13
|
+
"passwd",
|
|
14
|
+
"kill",
|
|
15
|
+
"pkill",
|
|
16
|
+
"killall",
|
|
17
|
+
"systemctl",
|
|
18
|
+
"service",
|
|
19
|
+
"curl",
|
|
20
|
+
"wget",
|
|
21
|
+
"bash",
|
|
22
|
+
"sh",
|
|
23
|
+
"zsh",
|
|
24
|
+
"nc",
|
|
25
|
+
"netcat",
|
|
26
|
+
"ncat",
|
|
27
|
+
"socat",
|
|
28
|
+
"chmod",
|
|
29
|
+
"chown",
|
|
30
|
+
"eval",
|
|
31
|
+
"printenv",
|
|
32
|
+
"drop",
|
|
33
|
+
"truncate",
|
|
34
|
+
"format",
|
|
35
|
+
"sudo",
|
|
36
|
+
"su",
|
|
37
|
+
"ssh",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Splits text into normalized lowercase word tokens.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} text
|
|
44
|
+
* @returns {string[]}
|
|
45
|
+
*/
|
|
46
|
+
export function extractWords(text) {
|
|
47
|
+
if (typeof text !== "string") return [];
|
|
48
|
+
// Split on whitespace and common shell operators/delimiters
|
|
49
|
+
const tokens = text.toLowerCase().split(/[^a-z0-9_-]+/);
|
|
50
|
+
return tokens.filter((t) => t.length > 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class HotwordsMatcher {
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[]} [customHotwords]
|
|
56
|
+
*/
|
|
57
|
+
constructor(customHotwords) {
|
|
58
|
+
/** @type {Set<string>} */
|
|
59
|
+
this.lexicon = new Set();
|
|
60
|
+
const words = Array.isArray(customHotwords) && customHotwords.length > 0
|
|
61
|
+
? customHotwords
|
|
62
|
+
: DEFAULT_HOTWORDS;
|
|
63
|
+
|
|
64
|
+
for (const w of words) {
|
|
65
|
+
if (typeof w === "string" && w.trim()) {
|
|
66
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Add new words to lexicon.
|
|
73
|
+
* @param {string | string[]} words
|
|
74
|
+
*/
|
|
75
|
+
add(words) {
|
|
76
|
+
const list = Array.isArray(words) ? words : [words];
|
|
77
|
+
for (const w of list) {
|
|
78
|
+
if (typeof w === "string" && w.trim()) {
|
|
79
|
+
this.lexicon.add(w.trim().toLowerCase());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks if text contains any word present in the hotwords lexicon.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} text
|
|
88
|
+
* @returns {boolean} True if any high-risk keyword matched
|
|
89
|
+
*/
|
|
90
|
+
hasMatch(text) {
|
|
91
|
+
if (!text || typeof text !== "string") return false;
|
|
92
|
+
const words = extractWords(text);
|
|
93
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
94
|
+
if (this.lexicon.has(words[i])) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns list of matching hotwords found in text.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @returns {string[]}
|
|
106
|
+
*/
|
|
107
|
+
findMatches(text) {
|
|
108
|
+
if (!text || typeof text !== "string") return [];
|
|
109
|
+
const words = extractWords(text);
|
|
110
|
+
const matches = [];
|
|
111
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
112
|
+
if (this.lexicon.has(words[i]) && !matches.includes(words[i])) {
|
|
113
|
+
matches.push(words[i]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return matches;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get size() {
|
|
120
|
+
return this.lexicon.size;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SecurityEngine } from "./engine.js";
|
|
2
2
|
import { BandsEngine } from "./bands.js";
|
|
3
3
|
import { AuditLogger } from "./audit-log.js";
|
|
4
|
+
import { DeclarativeRulesEngine } from "./rules-loader.js";
|
|
4
5
|
import Schema from "@deepseek-ai/schemastery";
|
|
5
6
|
|
|
6
7
|
if (typeof Schema.prototype?.volatile !== "function") {
|
|
@@ -212,6 +213,8 @@ export const Config = Schema.object({
|
|
|
212
213
|
shadowMode: Schema.boolean().default(false).volatile(),
|
|
213
214
|
/** Path to append-only audit log file (#56). */
|
|
214
215
|
auditLogPath: Schema.string().default("").volatile(),
|
|
216
|
+
/** Optional custom declarative rules YAML file path (#38). */
|
|
217
|
+
rulesFile: Schema.string().default("").volatile(),
|
|
215
218
|
/** Security engine instance (internal/testing). */
|
|
216
219
|
engine: Schema.any().hidden(),
|
|
217
220
|
});
|
|
@@ -257,6 +260,10 @@ export function apply(ctx, config) {
|
|
|
257
260
|
defaultVerdict: cfg.defaultVerdict || "allow",
|
|
258
261
|
dryRun: Boolean(cfg.dryRun || cfg.shadowMode),
|
|
259
262
|
auditLogger: new AuditLogger({ logPath: cfg.auditLogPath || undefined }),
|
|
263
|
+
declarativeRules: new DeclarativeRulesEngine({
|
|
264
|
+
workspaceDir: cfg.workspaceDir || "",
|
|
265
|
+
builtinCatalogPath: cfg.rulesFile || undefined,
|
|
266
|
+
}),
|
|
260
267
|
});
|
|
261
268
|
|
|
262
269
|
const gateService = {
|
|
@@ -266,6 +273,7 @@ export function apply(ctx, config) {
|
|
|
266
273
|
cache: engine.cache,
|
|
267
274
|
retryGuard: engine.retryGuard,
|
|
268
275
|
ledger: engine.ledger,
|
|
276
|
+
declarativeRules: engine.declarativeRules,
|
|
269
277
|
getLedgerSummary: (sessionId) => engine.getLedgerSummary(sessionId),
|
|
270
278
|
};
|
|
271
279
|
if (ctx && typeof ctx === "object") {
|
|
@@ -450,3 +458,23 @@ export {
|
|
|
450
458
|
AuditLogger,
|
|
451
459
|
DEFAULT_AUDIT_LOG_RELATIVE,
|
|
452
460
|
} from "./audit-log.js";
|
|
461
|
+
export {
|
|
462
|
+
parseYaml,
|
|
463
|
+
parseScalar,
|
|
464
|
+
stripComment,
|
|
465
|
+
} from "./yaml-parser.js";
|
|
466
|
+
export {
|
|
467
|
+
matchConditionValue,
|
|
468
|
+
evaluateWhen,
|
|
469
|
+
} from "./when-matcher.js";
|
|
470
|
+
export {
|
|
471
|
+
HotwordsMatcher,
|
|
472
|
+
DEFAULT_HOTWORDS,
|
|
473
|
+
extractWords,
|
|
474
|
+
} from "./hotwords.js";
|
|
475
|
+
export {
|
|
476
|
+
DeclarativeRulesEngine,
|
|
477
|
+
BUILTIN_RULES_FILE,
|
|
478
|
+
WORKSPACE_RULES_REL,
|
|
479
|
+
USER_RULES_REL,
|
|
480
|
+
} from "./rules-loader.js";
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// lib/rules-loader.js
|
|
2
|
+
// Cascading declarative YAML rules loader and evaluator for dsh-approval-gate (#38, #45, #44, #93).
|
|
3
|
+
// Evaluates rules cascading from workspace (.dsh/rules.yaml), user (~/.dsh/rules.yaml) and builtin threats.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { parseYaml } from "./yaml-parser.js";
|
|
9
|
+
import { evaluateWhen } from "./when-matcher.js";
|
|
10
|
+
import { HotwordsMatcher } from "./hotwords.js";
|
|
11
|
+
|
|
12
|
+
export const BUILTIN_RULES_FILE = path.join("rules", "builtin-high-risk.yaml");
|
|
13
|
+
export const WORKSPACE_RULES_REL = path.join(".dsh", "rules.yaml");
|
|
14
|
+
export const USER_RULES_REL = path.join(".dsh", "rules.yaml");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} CompiledRule
|
|
18
|
+
* @property {string} id
|
|
19
|
+
* @property {string} [tool]
|
|
20
|
+
* @property {RegExp} regex
|
|
21
|
+
* @property {"allow" | "deny" | "ask"} verdict
|
|
22
|
+
* @property {string} reason
|
|
23
|
+
* @property {string} [description]
|
|
24
|
+
* @property {Record<string, any>} [when]
|
|
25
|
+
* @property {string} source - "workspace" | "user" | "builtin"
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class DeclarativeRulesEngine {
|
|
29
|
+
/**
|
|
30
|
+
* @param {{
|
|
31
|
+
* workspaceDir?: string,
|
|
32
|
+
* userHomeDir?: string,
|
|
33
|
+
* builtinCatalogPath?: string,
|
|
34
|
+
* customRules?: any[],
|
|
35
|
+
* hotwords?: HotwordsMatcher
|
|
36
|
+
* }} [options]
|
|
37
|
+
*/
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.workspaceDir = options.workspaceDir || "";
|
|
40
|
+
this.userHomeDir = options.userHomeDir || (os.homedir ? os.homedir() : (process.env.HOME || process.env.USERPROFILE || ""));
|
|
41
|
+
this.builtinCatalogPath = options.builtinCatalogPath || "";
|
|
42
|
+
this.hotwords = options.hotwords || new HotwordsMatcher();
|
|
43
|
+
|
|
44
|
+
/** @type {CompiledRule[]} */
|
|
45
|
+
this.rules = [];
|
|
46
|
+
this.load(options);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Loads and merges all rule sources (workspace -> user -> builtin).
|
|
51
|
+
*
|
|
52
|
+
* @param {{ customRules?: any[] }} [options]
|
|
53
|
+
*/
|
|
54
|
+
load(options = {}) {
|
|
55
|
+
this.rules = [];
|
|
56
|
+
const rawList = [];
|
|
57
|
+
|
|
58
|
+
// 1. Workspace local rules (.dsh/rules.yaml)
|
|
59
|
+
if (this.workspaceDir) {
|
|
60
|
+
const wsPath = path.isAbsolute(this.workspaceDir)
|
|
61
|
+
? path.join(this.workspaceDir, WORKSPACE_RULES_REL)
|
|
62
|
+
: path.resolve(this.workspaceDir, WORKSPACE_RULES_REL);
|
|
63
|
+
const wsRules = this._loadFile(wsPath, "workspace");
|
|
64
|
+
rawList.push(...wsRules);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. User global rules (~/.dsh/rules.yaml)
|
|
68
|
+
if (this.userHomeDir) {
|
|
69
|
+
const userPath = path.join(this.userHomeDir, USER_RULES_REL);
|
|
70
|
+
const userRules = this._loadFile(userPath, "user");
|
|
71
|
+
rawList.push(...userRules);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Builtin threat catalog
|
|
75
|
+
const builtinPath = this.builtinCatalogPath || path.resolve(process.cwd(), BUILTIN_RULES_FILE);
|
|
76
|
+
const builtinRules = this._loadFile(builtinPath, "builtin");
|
|
77
|
+
rawList.push(...builtinRules);
|
|
78
|
+
|
|
79
|
+
// 4. In-memory / custom rules passed via options
|
|
80
|
+
if (Array.isArray(options.customRules)) {
|
|
81
|
+
rawList.unshift(...options.customRules.map((r) => ({ ...r, source: "custom" })));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Compile and deduplicate by rule ID (first defined wins!)
|
|
85
|
+
const seenIds = new Set();
|
|
86
|
+
for (const r of rawList) {
|
|
87
|
+
if (!r || !r.id || !r.verdict) continue;
|
|
88
|
+
if (seenIds.has(r.id)) continue;
|
|
89
|
+
seenIds.add(r.id);
|
|
90
|
+
|
|
91
|
+
const patternStr = r.pattern || r.regex || r.command;
|
|
92
|
+
if (!patternStr) continue;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const regex = patternStr instanceof RegExp ? patternStr : new RegExp(patternStr, r.flags || "");
|
|
96
|
+
this.rules.push({
|
|
97
|
+
id: String(r.id),
|
|
98
|
+
tool: r.tool ? String(r.tool).toLowerCase() : undefined,
|
|
99
|
+
regex,
|
|
100
|
+
verdict: r.verdict,
|
|
101
|
+
reason: r.reason || r.id,
|
|
102
|
+
description: r.description,
|
|
103
|
+
when: r.when,
|
|
104
|
+
source: r.source || "custom",
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Invalid regex in user file must not crash the loader
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Safely reads and parses a rules file.
|
|
114
|
+
* @private
|
|
115
|
+
*/
|
|
116
|
+
_loadFile(filePath, source) {
|
|
117
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
118
|
+
try {
|
|
119
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
120
|
+
const doc = parseYaml(text);
|
|
121
|
+
const rules = Array.isArray(doc) ? doc : (doc && Array.isArray(doc.rules) ? doc.rules : []);
|
|
122
|
+
return rules.map((r) => ({ ...r, source }));
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Evaluates a command against the declarative rule chain.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} toolName
|
|
132
|
+
* @param {string} command
|
|
133
|
+
* @param {{ os?: string, branch?: string, env?: Record<string, string>, cwd?: string }} [context]
|
|
134
|
+
* @returns {{ match: boolean, ruleId?: string, verdict?: "allow"|"deny"|"ask", reason?: string, source?: string }}
|
|
135
|
+
*/
|
|
136
|
+
evaluate(toolName, command, context = {}) {
|
|
137
|
+
if (!command || typeof command !== "string") {
|
|
138
|
+
return { match: false };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const normTool = String(toolName || "").toLowerCase();
|
|
142
|
+
const envContext = { ...context, tool: normTool };
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < this.rules.length; i += 1) {
|
|
145
|
+
const rule = this.rules[i];
|
|
146
|
+
|
|
147
|
+
// Tool filter
|
|
148
|
+
if (rule.tool && rule.tool !== normTool) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Context predicate (when) filter
|
|
153
|
+
if (rule.when && !evaluateWhen(rule.when, envContext)) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Pattern match
|
|
158
|
+
if (rule.regex.test(command)) {
|
|
159
|
+
return {
|
|
160
|
+
match: true,
|
|
161
|
+
ruleId: rule.id,
|
|
162
|
+
verdict: rule.verdict,
|
|
163
|
+
reason: rule.reason,
|
|
164
|
+
source: rule.source,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { match: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get ruleCount() {
|
|
173
|
+
return this.rules.length;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// lib/when-matcher.js
|
|
2
|
+
// Conditional predicates (when) evaluator for dsh-approval-gate rules (#44).
|
|
3
|
+
// Evaluates OS, git branch, environment variables, and tool context.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks a string or regex value against a target with negation support.
|
|
7
|
+
*
|
|
8
|
+
* @param {string | RegExp} expected
|
|
9
|
+
* @param {string} actual
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function matchConditionValue(expected, actual) {
|
|
13
|
+
if (expected === undefined || expected === null) return true;
|
|
14
|
+
const actualStr = String(actual || "");
|
|
15
|
+
|
|
16
|
+
if (typeof expected === "string") {
|
|
17
|
+
const trimmed = expected.trim();
|
|
18
|
+
if (trimmed.startsWith("!")) {
|
|
19
|
+
const negated = trimmed.slice(1).trim();
|
|
20
|
+
return actualStr !== negated;
|
|
21
|
+
}
|
|
22
|
+
return actualStr === trimmed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (expected instanceof RegExp) {
|
|
26
|
+
return expected.test(actualStr);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return expected === actual;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluates a rule's `when` section against runtime facts.
|
|
34
|
+
*
|
|
35
|
+
* @param {Record<string, any>} [when] - When clause from rule
|
|
36
|
+
* @param {{
|
|
37
|
+
* os?: string,
|
|
38
|
+
* branch?: string,
|
|
39
|
+
* tool?: string,
|
|
40
|
+
* env?: Record<string, string | undefined>
|
|
41
|
+
* }} [context] - Environmental context
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
export function evaluateWhen(when, context = {}) {
|
|
45
|
+
if (!when || typeof when !== "object" || Object.keys(when).length === 0) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const currentOs = context.os || process.platform;
|
|
50
|
+
const currentBranch = context.branch || "";
|
|
51
|
+
const currentTool = (context.tool || "").toLowerCase();
|
|
52
|
+
const currentEnv = context.env || process.env || {};
|
|
53
|
+
|
|
54
|
+
// 1. OS check
|
|
55
|
+
if (when.os !== undefined) {
|
|
56
|
+
if (!matchConditionValue(when.os, currentOs)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Branch check
|
|
62
|
+
if (when.branch !== undefined) {
|
|
63
|
+
if (!matchConditionValue(when.branch, currentBranch)) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 3. Tool check
|
|
69
|
+
if (when.tool !== undefined) {
|
|
70
|
+
if (!matchConditionValue(String(when.tool).toLowerCase(), currentTool)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4. Environment variables check
|
|
76
|
+
if (when.env && typeof when.env === "object") {
|
|
77
|
+
for (const [varName, expectedVal] of Object.entries(when.env)) {
|
|
78
|
+
const actualVal = currentEnv[varName];
|
|
79
|
+
if (typeof expectedVal === "boolean") {
|
|
80
|
+
const isPresent = actualVal !== undefined && actualVal !== "" && actualVal !== "0" && actualVal !== "false";
|
|
81
|
+
if (isPresent !== expectedVal) return false;
|
|
82
|
+
} else if (!matchConditionValue(expectedVal, actualVal)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// lib/yaml-parser.js
|
|
2
|
+
// Compact zero-dependency YAML subset parser for dsh-approval-gate (#38).
|
|
3
|
+
// Supports scalar values, lists, mappings, nested blocks, and comments.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses scalar string into JS primitive (string, number, boolean, null).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} val
|
|
9
|
+
* @returns {any}
|
|
10
|
+
*/
|
|
11
|
+
export function parseScalar(val) {
|
|
12
|
+
if (val === undefined || val === null) return null;
|
|
13
|
+
const trimmed = val.trim();
|
|
14
|
+
if (trimmed === "" || trimmed === "~" || trimmed === "null") return null;
|
|
15
|
+
if (trimmed === "true") return true;
|
|
16
|
+
if (trimmed === "false") return false;
|
|
17
|
+
|
|
18
|
+
// Single or double quoted strings
|
|
19
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
20
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
21
|
+
const quote = trimmed[0];
|
|
22
|
+
const inner = trimmed.slice(1, -1);
|
|
23
|
+
if (quote === '"') {
|
|
24
|
+
return inner
|
|
25
|
+
.replace(/\\"/g, '"')
|
|
26
|
+
.replace(/\\\\/g, "\\")
|
|
27
|
+
.replace(/\\n/g, "\n")
|
|
28
|
+
.replace(/\\t/g, "\t");
|
|
29
|
+
}
|
|
30
|
+
return inner.replace(/''/g, "'");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Numbers
|
|
34
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
35
|
+
return Number(trimmed);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Strips comments from a line, preserving comments inside quotes.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} line
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function stripComment(line) {
|
|
48
|
+
let inSingle = false;
|
|
49
|
+
let inDouble = false;
|
|
50
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
51
|
+
const ch = line[i];
|
|
52
|
+
if (ch === "'" && !inDouble) {
|
|
53
|
+
inSingle = !inSingle;
|
|
54
|
+
} else if (ch === '"' && !inSingle) {
|
|
55
|
+
inDouble = !inDouble;
|
|
56
|
+
} else if (ch === "#" && !inSingle && !inDouble) {
|
|
57
|
+
return line.slice(0, i);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return line;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parses a simple YAML string into JavaScript objects and arrays.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} yamlText
|
|
67
|
+
* @returns {any}
|
|
68
|
+
*/
|
|
69
|
+
export function parseYaml(yamlText) {
|
|
70
|
+
if (typeof yamlText !== "string" || !yamlText.trim()) return {};
|
|
71
|
+
|
|
72
|
+
const trimmedText = yamlText.trim();
|
|
73
|
+
// Fast path for JSON-compatible YAML
|
|
74
|
+
if (trimmedText.startsWith("{") || trimmedText.startsWith("[")) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(trimmedText);
|
|
77
|
+
} catch {
|
|
78
|
+
// Fallback to YAML line parser
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rawLines = yamlText.split(/\r?\n/);
|
|
83
|
+
const lines = [];
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
86
|
+
const clean = stripComment(rawLines[i]);
|
|
87
|
+
if (!clean.trim()) continue; // skip blank / comment-only lines
|
|
88
|
+
const indent = clean.match(/^(\s*)/)[0].length;
|
|
89
|
+
lines.push({ indent, text: clean.trim(), lineNum: i + 1 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (lines.length === 0) return {};
|
|
93
|
+
|
|
94
|
+
let index = 0;
|
|
95
|
+
|
|
96
|
+
function parseBlock(currentIndent) {
|
|
97
|
+
if (index >= lines.length) return null;
|
|
98
|
+
|
|
99
|
+
const firstLine = lines[index];
|
|
100
|
+
if (firstLine.text.startsWith("- ") || firstLine.text === "-") {
|
|
101
|
+
// It's a sequence (array)
|
|
102
|
+
const list = [];
|
|
103
|
+
while (index < lines.length) {
|
|
104
|
+
const line = lines[index];
|
|
105
|
+
if (line.indent < currentIndent) break;
|
|
106
|
+
if (line.indent > currentIndent) {
|
|
107
|
+
// Handled within child elements
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (line.text.startsWith("- ") || line.text === "-") {
|
|
112
|
+
const itemPayload = line.text.slice(2).trim();
|
|
113
|
+
index += 1;
|
|
114
|
+
|
|
115
|
+
if (!itemPayload) {
|
|
116
|
+
// Nested block under list item
|
|
117
|
+
if (index < lines.length && lines[index].indent > line.indent) {
|
|
118
|
+
list.push(parseBlock(lines[index].indent));
|
|
119
|
+
} else {
|
|
120
|
+
list.push(null);
|
|
121
|
+
}
|
|
122
|
+
} else if (itemPayload.includes(":") && !itemPayload.startsWith("{")) {
|
|
123
|
+
// Key-value on the same line as list dash: e.g. "- id: my-rule"
|
|
124
|
+
const colonIdx = itemPayload.indexOf(":");
|
|
125
|
+
const k = itemPayload.slice(0, colonIdx).trim();
|
|
126
|
+
const v = itemPayload.slice(colonIdx + 1).trim();
|
|
127
|
+
const obj = {};
|
|
128
|
+
if (v) {
|
|
129
|
+
obj[k] = parseScalar(v);
|
|
130
|
+
} else {
|
|
131
|
+
// Nested under this key
|
|
132
|
+
if (index < lines.length && lines[index].indent > line.indent) {
|
|
133
|
+
obj[k] = parseBlock(lines[index].indent);
|
|
134
|
+
} else {
|
|
135
|
+
obj[k] = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read subsequent keys belonging to this list item
|
|
140
|
+
const itemIndent = line.indent + 2;
|
|
141
|
+
while (index < lines.length) {
|
|
142
|
+
const nextLine = lines[index];
|
|
143
|
+
if (nextLine.indent < itemIndent) break;
|
|
144
|
+
if (nextLine.text.startsWith("- ") && nextLine.indent <= line.indent) break;
|
|
145
|
+
|
|
146
|
+
if (nextLine.text.includes(":")) {
|
|
147
|
+
const cIdx = nextLine.text.indexOf(":");
|
|
148
|
+
const subK = nextLine.text.slice(0, cIdx).trim();
|
|
149
|
+
const subV = nextLine.text.slice(cIdx + 1).trim();
|
|
150
|
+
index += 1;
|
|
151
|
+
if (subV) {
|
|
152
|
+
obj[subK] = parseScalar(subV);
|
|
153
|
+
} else {
|
|
154
|
+
if (index < lines.length && lines[index].indent > nextLine.indent) {
|
|
155
|
+
obj[subK] = parseBlock(lines[index].indent);
|
|
156
|
+
} else {
|
|
157
|
+
obj[subK] = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
index += 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
list.push(obj);
|
|
165
|
+
} else {
|
|
166
|
+
// Scalar list item: e.g. "- item1"
|
|
167
|
+
list.push(parseScalar(itemPayload));
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return list;
|
|
174
|
+
} else {
|
|
175
|
+
// It's a mapping (object)
|
|
176
|
+
const obj = {};
|
|
177
|
+
while (index < lines.length) {
|
|
178
|
+
const line = lines[index];
|
|
179
|
+
if (line.indent < currentIndent) break;
|
|
180
|
+
if (line.indent > currentIndent) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (line.text.includes(":")) {
|
|
185
|
+
const colonIdx = line.text.indexOf(":");
|
|
186
|
+
const key = line.text.slice(0, colonIdx).trim();
|
|
187
|
+
const rest = line.text.slice(colonIdx + 1).trim();
|
|
188
|
+
index += 1;
|
|
189
|
+
|
|
190
|
+
if (rest) {
|
|
191
|
+
obj[key] = parseScalar(rest);
|
|
192
|
+
} else {
|
|
193
|
+
// Value is a nested block
|
|
194
|
+
if (index < lines.length && lines[index].indent > line.indent) {
|
|
195
|
+
obj[key] = parseBlock(lines[index].indent);
|
|
196
|
+
} else {
|
|
197
|
+
obj[key] = null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
index += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return obj;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = parseBlock(lines[0].indent);
|
|
209
|
+
return result || {};
|
|
210
|
+
}
|
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.15",
|
|
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",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"lib",
|
|
14
|
+
"rules",
|
|
14
15
|
"cordis.patch.yml",
|
|
15
16
|
"README.md",
|
|
16
17
|
"README.zh.md",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
catalog: builtin-high-risk
|
|
3
|
+
description: Core Band 0 critical security threat definitions for dsh-approval-gate
|
|
4
|
+
|
|
5
|
+
rules:
|
|
6
|
+
- id: builtin-recursive-rm
|
|
7
|
+
tool: bash
|
|
8
|
+
pattern: '(?:^|[;&|\s])rm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:/(?:$|\s|\*)|/(?:var|etc|usr|bin|sbin|boot|lib|root|home)(?:/|$|\s|\*))'
|
|
9
|
+
verdict: deny
|
|
10
|
+
reason: recursive-rm
|
|
11
|
+
description: Recursive deletion of critical system directory
|
|
12
|
+
|
|
13
|
+
- id: builtin-process-signal
|
|
14
|
+
tool: bash
|
|
15
|
+
pattern: '(?:^|[;&|\s])kill\s+-[a-zA-Z0-9]*9\s+1(?:$|\s)'
|
|
16
|
+
verdict: deny
|
|
17
|
+
reason: process-signal
|
|
18
|
+
description: SIGKILL targeted at PID 1 / init
|
|
19
|
+
|
|
20
|
+
- id: builtin-service-stop
|
|
21
|
+
tool: bash
|
|
22
|
+
pattern: '(?:^|[;&|\s])systemctl\s+(?:stop|disable|mask|poweroff|reboot|halt)(?:$|\s)'
|
|
23
|
+
verdict: deny
|
|
24
|
+
reason: service-control
|
|
25
|
+
description: System service shutdown, masking, or machine halt
|
|
26
|
+
|
|
27
|
+
- id: builtin-mkfs-format
|
|
28
|
+
tool: bash
|
|
29
|
+
pattern: '(?:^|[;&|\s])(?:mkfs|fdisk|parted)\b'
|
|
30
|
+
verdict: deny
|
|
31
|
+
reason: mkfs-device
|
|
32
|
+
description: Block device partitioning or filesystem formatting
|
|
33
|
+
|
|
34
|
+
- id: builtin-curl-pipe-shell
|
|
35
|
+
tool: bash
|
|
36
|
+
pattern: '(?:curl|wget)\s+[^|;]+?\|\s*(?:sudo\s+)?(?:bash|sh|zsh|python|perl|ruby)'
|
|
37
|
+
verdict: deny
|
|
38
|
+
reason: curl-pipe
|
|
39
|
+
description: Piping downloaded remote web content directly into shell interpreter
|
|
40
|
+
|
|
41
|
+
- id: builtin-reverse-shell-nc
|
|
42
|
+
tool: bash
|
|
43
|
+
pattern: '(?:^|[;&|\s])(?:nc|netcat|ncat)\s+.*-[a-zA-Z0-9]*e\s+(?:/bin/(?:ba)?sh|sh)'
|
|
44
|
+
verdict: deny
|
|
45
|
+
reason: band-hard-deny
|
|
46
|
+
description: Netcat reverse shell execution
|
|
47
|
+
|
|
48
|
+
- id: builtin-reverse-shell-tcp
|
|
49
|
+
tool: bash
|
|
50
|
+
pattern: '/dev/tcp/\d+\.\d+\.\d+\.\d+/\d+'
|
|
51
|
+
verdict: deny
|
|
52
|
+
reason: band-hard-deny
|
|
53
|
+
description: Direct bash TCP socket reverse shell attempt
|
|
54
|
+
|
|
55
|
+
- id: builtin-git-reset-hard
|
|
56
|
+
tool: bash
|
|
57
|
+
pattern: '(?:^|[;&|\s])git\s+reset\s+--hard'
|
|
58
|
+
verdict: deny
|
|
59
|
+
reason: git-reset-hard
|
|
60
|
+
description: Destructive git reset --hard discarding working tree
|
|
61
|
+
|
|
62
|
+
- id: builtin-git-clean-force
|
|
63
|
+
tool: bash
|
|
64
|
+
pattern: '(?:^|[;&|\s])git\s+clean\s+-[a-zA-Z0-9]*f'
|
|
65
|
+
verdict: deny
|
|
66
|
+
reason: git-clean
|
|
67
|
+
description: Destructive git clean -f discarding untracked files
|
|
68
|
+
|
|
69
|
+
- id: builtin-git-force-push
|
|
70
|
+
tool: bash
|
|
71
|
+
pattern: '(?:^|[;&|\s])git\s+push\s+.*(?:--force|-f\b|\+refs/)'
|
|
72
|
+
verdict: deny
|
|
73
|
+
reason: git-force-push
|
|
74
|
+
description: Destructive forced push to git repository
|
|
75
|
+
|
|
76
|
+
- id: builtin-chmod-chown-root
|
|
77
|
+
tool: bash
|
|
78
|
+
pattern: '(?:^|[;&|\s])(?:chmod|chown)\s+-[a-zA-Z]*[rR].*\s+/(?:$|\s|\*|etc|var|usr|root)'
|
|
79
|
+
verdict: deny
|
|
80
|
+
reason: chmod-chown
|
|
81
|
+
description: Recursive permission mutation of system root directories
|