dsh-dlp 0.3.0 → 0.4.1
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/README.md +78 -537
- package/SECURITY.md +13 -5
- package/cordis.patch.yml +1 -0
- package/lib/cli.js +7 -3
- package/lib/config-writes.js +215 -0
- package/lib/detectors.js +135 -22
- package/lib/index.js +59 -1
- package/lib/paths.js +52 -0
- package/lib/policy.js +16 -9
- package/lib/sink.js +25 -1
- package/lib/types/config-writes.d.ts +89 -0
- package/lib/types/detectors.d.ts +41 -5
- package/lib/types/index.d.ts +5 -1
- package/lib/types/paths.d.ts +25 -0
- package/lib/types/policy.d.ts +8 -1
- package/lib/types/sink.d.ts +7 -1
- package/package.json +5 -5
package/SECURITY.md
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
| Version | Supported |
|
|
6
6
|
|---|---|
|
|
7
|
-
| 0.
|
|
8
|
-
| < 0.
|
|
7
|
+
| 0.3.x | yes |
|
|
8
|
+
| < 0.3 | no |
|
|
9
9
|
|
|
10
|
-
Only the latest published `0.
|
|
11
|
-
the package is pre-1.0
|
|
10
|
+
Only the latest published `0.3.x` receives fixes. There is no long-term-support branch while
|
|
11
|
+
the package is pre-1.0: each minor supersedes the one before it, and a fix ships as the next
|
|
12
|
+
`0.3.x` patch or, if the minor has already moved on, as the next minor.
|
|
12
13
|
|
|
13
14
|
## Reporting a vulnerability
|
|
14
15
|
|
|
@@ -44,4 +45,11 @@ These do count, and we want to hear about them:
|
|
|
44
45
|
- a secret surviving into the session log through a `tools/post-execute` arm;
|
|
45
46
|
- a repo-local `policyFile` loosening any part of the floor, executing code, or stalling the
|
|
46
47
|
agent;
|
|
47
|
-
- any way to make the guard abstain that does not require executing code
|
|
48
|
+
- any way to make the guard abstain that does not require executing code;
|
|
49
|
+
- a terminal control sequence, or any other forgeable bytes, reaching the audit sink or a
|
|
50
|
+
denial the user reads.
|
|
51
|
+
|
|
52
|
+
The `ask` tier for behaviour-changing config paths is **not** part of the floor and is
|
|
53
|
+
documented as neutralizable: it lives at `tools/pre-execute`, so a listener registered ahead of
|
|
54
|
+
it disables it. A missed path there is a gap worth reporting; the fact that another plugin can
|
|
55
|
+
switch the tier off is a stated design limit, not a vulnerability.
|
package/cordis.patch.yml
CHANGED
package/lib/cli.js
CHANGED
|
@@ -24,18 +24,22 @@ function stringField(record, key) {
|
|
|
24
24
|
const value = record[key];
|
|
25
25
|
return typeof value === 'string' ? value : undefined;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Rule ids a record names, in file order and without repeats: one per span,
|
|
29
|
+
* plus the top-level `ruleId` a decision with no matched region carries.
|
|
30
|
+
*/
|
|
28
31
|
function ruleIdsOf(record) {
|
|
32
|
+
const named = stringField(record, 'ruleId');
|
|
29
33
|
const spans = record['spans'];
|
|
30
34
|
if (!Array.isArray(spans))
|
|
31
|
-
return [];
|
|
35
|
+
return named === undefined ? [] : [named];
|
|
32
36
|
const ids = spans.flatMap((span) => {
|
|
33
37
|
if (typeof span !== 'object' || span === null)
|
|
34
38
|
return [];
|
|
35
39
|
const ruleId = span['ruleId'];
|
|
36
40
|
return typeof ruleId === 'string' ? [ruleId] : [];
|
|
37
41
|
});
|
|
38
|
-
return [...new Set(ids)];
|
|
42
|
+
return [...new Set(named === undefined ? ids : [named, ...ids])];
|
|
39
43
|
}
|
|
40
44
|
/** Invisible-character counts a record carries, keeping only numeric entries. */
|
|
41
45
|
function unicodeOf(record) {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ask` tier: files whose contents decide how the agent, the editor or CI
|
|
3
|
+
* behaves next time, and the one config *key* whose value redirects a
|
|
4
|
+
* credential.
|
|
5
|
+
*
|
|
6
|
+
* This tier is deliberately **not** on the guard floor, and that is the whole
|
|
7
|
+
* design. `ctx.tools.guard()` has no `ask` arm and cannot be overridden, so a
|
|
8
|
+
* rule that lands there must be one a developer never legitimately trips. A
|
|
9
|
+
* developer asks the agent to edit `CLAUDE.md`, add a `.github/workflows`
|
|
10
|
+
* job or extend `.vscode/settings.json` constantly. Putting those on an
|
|
11
|
+
* unoverridable floor produces a plugin that gets uninstalled, which removes
|
|
12
|
+
* the floor as well.
|
|
13
|
+
*
|
|
14
|
+
* The cost is stated rather than hidden: `tools/pre-execute` is neutralizable.
|
|
15
|
+
* A listener registered ahead of this one can return without calling `next()`
|
|
16
|
+
* and this tier never runs. Only the guard floor is order-independent.
|
|
17
|
+
* @module dsh-dlp/config-writes
|
|
18
|
+
*/
|
|
19
|
+
import { isReadOnlyTool, normalizeCandidatePath, pathArguments } from "./paths.js";
|
|
20
|
+
import { nestedStrings } from "./redaction.js";
|
|
21
|
+
/**
|
|
22
|
+
* Paths whose contents change future behaviour, and the config key whose value
|
|
23
|
+
* redirects credentials.
|
|
24
|
+
*
|
|
25
|
+
* Every path here was used in the wild. The Miasma worm wrote `SessionStart`
|
|
26
|
+
* hooks into `.claude/settings.json` and `.gemini/settings.json`, an
|
|
27
|
+
* always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
|
|
28
|
+
* `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
|
|
29
|
+
* GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
|
|
30
|
+
* over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
|
|
31
|
+
* CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
|
|
32
|
+
* CVE-2025-59041.
|
|
33
|
+
*
|
|
34
|
+
* The rules match by name, never by what is on disk, so a file the call is
|
|
35
|
+
* about to *create* is matched exactly like one it would change:
|
|
36
|
+
* CVE-2026-25725 worked precisely because the path did not exist yet and was
|
|
37
|
+
* therefore writable without any prompt.
|
|
38
|
+
*/
|
|
39
|
+
export const CONFIG_WRITE_RULES = [
|
|
40
|
+
{
|
|
41
|
+
id: 'dsh-dlp/config-agent-settings',
|
|
42
|
+
version: 1,
|
|
43
|
+
match: 'path',
|
|
44
|
+
pattern: /(^|\/)\.(claude|gemini|codex|cursor|windsurf|continue)\/settings[^/]*\.json$/i,
|
|
45
|
+
effect: 'agent settings, which can register hooks that run on every future session',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'dsh-dlp/config-agent-hooks',
|
|
49
|
+
version: 1,
|
|
50
|
+
match: 'path',
|
|
51
|
+
pattern: /(^|\/)\.(claude|gemini|codex|windsurf|continue)\/hooks(\/|$)/i,
|
|
52
|
+
effect: 'an agent hook, which runs on a session event without the model asking for it',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'dsh-dlp/config-agent-instructions',
|
|
56
|
+
version: 1,
|
|
57
|
+
match: 'path',
|
|
58
|
+
pattern: /(^|\/)(CLAUDE|AGENTS|GEMINI|\.cursorrules|\.windsurfrules)(\.md)?$/i,
|
|
59
|
+
effect: 'standing instructions every future session in this repository reads',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'dsh-dlp/config-agent-rules',
|
|
63
|
+
version: 1,
|
|
64
|
+
match: 'path',
|
|
65
|
+
pattern: /(^|\/)\.(cursor|windsurf|continue)\/rules(\/|$)/i,
|
|
66
|
+
effect: 'an always-apply rules file every future session in this repository reads',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'dsh-dlp/config-mcp-manifest',
|
|
70
|
+
version: 1,
|
|
71
|
+
match: 'path',
|
|
72
|
+
pattern: /(^|\/)\.mcp\.json$/i,
|
|
73
|
+
effect: 'the MCP manifest, which decides which servers the agent starts and with what environment',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'dsh-dlp/config-editor-tasks',
|
|
77
|
+
version: 1,
|
|
78
|
+
match: 'path',
|
|
79
|
+
pattern: /(^|\/)\.vscode\/(settings|tasks|launch)\.json$/i,
|
|
80
|
+
effect: 'editor configuration, which can run a task the moment the folder is opened',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'dsh-dlp/config-git',
|
|
84
|
+
version: 1,
|
|
85
|
+
match: 'path',
|
|
86
|
+
pattern: /(^|\/)\.git\/(config$|hooks(\/|$))/i,
|
|
87
|
+
effect: 'git configuration or a git hook, which runs on the next commit, checkout or push',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'dsh-dlp/config-git-hooks-managed',
|
|
91
|
+
version: 1,
|
|
92
|
+
match: 'path',
|
|
93
|
+
pattern: /(^|\/)\.husky(\/|$)/i,
|
|
94
|
+
effect: 'a managed git hook, which runs on the next commit or push',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'dsh-dlp/config-ci-workflow',
|
|
98
|
+
version: 1,
|
|
99
|
+
match: 'path',
|
|
100
|
+
pattern: /(^|\/)\.(github\/workflows|gitlab-ci\.yml|circleci)(\/|$)/i,
|
|
101
|
+
effect: 'a CI workflow, which runs on the shared runner with the repository\'s secrets',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'dsh-dlp/config-shell-rc',
|
|
105
|
+
version: 1,
|
|
106
|
+
match: 'path',
|
|
107
|
+
pattern: /(^|\/)(\.bashrc|\.bash_profile|\.bash_login|\.bash_logout|\.profile|\.zshrc|\.zprofile|\.zshenv|\.zlogin|\.kshrc|config\.fish)$/i,
|
|
108
|
+
effect: 'a shell startup file, which runs on every future shell this agent opens',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'dsh-dlp/config-harness-bundle',
|
|
112
|
+
version: 1,
|
|
113
|
+
match: 'path',
|
|
114
|
+
pattern: /(^|\/)cordis[^/]*\.ya?ml$/i,
|
|
115
|
+
effect: 'a harness bundle manifest, which decides which plugins load',
|
|
116
|
+
},
|
|
117
|
+
// CVE-2026-21852: a repo-local settings file setting `ANTHROPIC_BASE_URL`
|
|
118
|
+
// sends the user's own API key to whatever host it names. This is neither a
|
|
119
|
+
// path nor a secret — it is a key whose *value* redirects a credential — so
|
|
120
|
+
// it is matched against what would be written rather than against where.
|
|
121
|
+
{
|
|
122
|
+
id: 'dsh-dlp/config-api-base-url',
|
|
123
|
+
version: 1,
|
|
124
|
+
match: 'content',
|
|
125
|
+
pattern: /\b[A-Z][A-Z0-9_]*(?:_BASE_URL|_API_BASE)\b["']?\s*[=:]\s*["']?\s*https?:\/\//,
|
|
126
|
+
effect: 'a provider base URL, which sends the credential for that provider to whatever host it names',
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
/**
|
|
130
|
+
* Argument keys whose values are the bytes a call would write.
|
|
131
|
+
*
|
|
132
|
+
* Deliberately separate from the floor's path-typed keys: the floor must never
|
|
133
|
+
* run its path table over file content, and this tier must run its one content
|
|
134
|
+
* rule over nothing else.
|
|
135
|
+
*/
|
|
136
|
+
export const CONTENT_ARGUMENT_KEYS = new Set([
|
|
137
|
+
'content', 'contents', 'text', 'file_text', 'fileText',
|
|
138
|
+
'new_string', 'newString', 'new_str', 'replacement', 'body',
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* The strings one call would write.
|
|
142
|
+
* @param args - the pending call's parsed arguments.
|
|
143
|
+
* @returns every string under a content-typed key, at any depth.
|
|
144
|
+
*/
|
|
145
|
+
export function contentArguments(args) {
|
|
146
|
+
const found = [];
|
|
147
|
+
const walk = (node) => {
|
|
148
|
+
if (Array.isArray(node)) {
|
|
149
|
+
for (const item of node)
|
|
150
|
+
walk(item);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (typeof node !== 'object' || node === null)
|
|
154
|
+
return;
|
|
155
|
+
for (const [key, value] of Object.entries(node)) {
|
|
156
|
+
if (CONTENT_ARGUMENT_KEYS.has(key))
|
|
157
|
+
found.push(...nestedStrings(value));
|
|
158
|
+
else
|
|
159
|
+
walk(value);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
walk(args);
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Prompt text for one behaviour-changing write.
|
|
167
|
+
*
|
|
168
|
+
* The path is not quoted, for the same reason the floor never quotes one: this
|
|
169
|
+
* string is model-visible and a path carries tenant and customer names. What
|
|
170
|
+
* the user needs in order to answer is which tool, which rule, and what the
|
|
171
|
+
* file does.
|
|
172
|
+
*/
|
|
173
|
+
function configWriteReason(toolName, rule) {
|
|
174
|
+
return `dsh-dlp is asking before ${JSON.stringify(toolName)} writes ${rule.effect} (rule ${rule.id}). `
|
|
175
|
+
+ 'This kind of file changes what happens on a later session, commit or CI run rather than now, so it is worth '
|
|
176
|
+
+ 'one look. Approve it if you asked for this change; decline it if you did not.';
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Decide whether one call should be confirmed by a human.
|
|
180
|
+
*
|
|
181
|
+
* Only calls that can change something are examined: a tool
|
|
182
|
+
* {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
|
|
183
|
+
* command lines are deliberately **not** tokenised here, unlike in the floor:
|
|
184
|
+
* a command line cannot be told apart from a read of the same path, and
|
|
185
|
+
* prompting on `cat .github/workflows/ci.yml` is exactly the false positive
|
|
186
|
+
* that gets a tier switched off. A shell redirection into one of these files
|
|
187
|
+
* is therefore not covered, which README.md says beside the feature.
|
|
188
|
+
* @param exec - the pending call.
|
|
189
|
+
* @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
|
|
190
|
+
* @returns the finding, or `undefined` to leave the call alone.
|
|
191
|
+
*/
|
|
192
|
+
export function evaluateConfigWrite(exec, rules = CONFIG_WRITE_RULES) {
|
|
193
|
+
if (isReadOnlyTool(exec.name))
|
|
194
|
+
return undefined;
|
|
195
|
+
const targets = pathArguments(exec.arguments).filter(argument => !argument.shell);
|
|
196
|
+
for (const rule of rules) {
|
|
197
|
+
if (rule.match !== 'path')
|
|
198
|
+
continue;
|
|
199
|
+
for (const target of targets) {
|
|
200
|
+
if (rule.pattern.test(normalizeCandidatePath(target.text))) {
|
|
201
|
+
return { rule, reason: configWriteReason(exec.name, rule) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const written = contentArguments(exec.arguments);
|
|
206
|
+
for (const rule of rules) {
|
|
207
|
+
if (rule.match !== 'content')
|
|
208
|
+
continue;
|
|
209
|
+
for (const text of written) {
|
|
210
|
+
if (rule.pattern.test(text))
|
|
211
|
+
return { rule, reason: configWriteReason(exec.name, rule) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
package/lib/detectors.js
CHANGED
|
@@ -36,6 +36,11 @@ export const DENY_SEVERITY = 'high';
|
|
|
36
36
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
37
37
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
38
38
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
39
|
+
*
|
|
40
|
+
* Prefix-anchored is the whole membership criterion, and the reason this table
|
|
41
|
+
* keeps growing rather than deferring to tier 2: the `session-telemetry/record`
|
|
42
|
+
* waterfall is synchronous and cannot reach tier 2 at all, so a format missing
|
|
43
|
+
* here is exported in the clear when telemetry is on.
|
|
39
44
|
*/
|
|
40
45
|
export const SYNC_RULES = [
|
|
41
46
|
{ id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
|
|
@@ -44,9 +49,23 @@ export const SYNC_RULES = [
|
|
|
44
49
|
{ id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
45
50
|
{ id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
|
|
46
51
|
{ id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
52
|
+
// Ahead of the OpenAI rule, whose `sk-` prefix also covers this shape: two
|
|
53
|
+
// detections over the same span merge into one placeholder attributed to
|
|
54
|
+
// whichever rule the table reached first, and the specific rule is the
|
|
55
|
+
// useful attribution.
|
|
56
|
+
{ id: 'dsh-dlp/openrouter-api-key', version: 1, severity: 'critical', pattern: /\bsk-or-v1-[0-9a-f]{64}\b/g },
|
|
47
57
|
{ id: 'dsh-dlp/openai-api-key', version: 1, severity: 'critical', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
|
|
48
58
|
{ id: 'dsh-dlp/google-api-key', version: 1, severity: 'critical', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
49
59
|
{ id: 'dsh-dlp/npm-token', version: 1, severity: 'critical', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
60
|
+
{ id: 'dsh-dlp/gitlab-token', version: 1, severity: 'critical', pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g },
|
|
61
|
+
{ id: 'dsh-dlp/huggingface-token', version: 1, severity: 'critical', pattern: /\bhf_[A-Za-z0-9]{34,}/g },
|
|
62
|
+
{ id: 'dsh-dlp/groq-api-key', version: 1, severity: 'critical', pattern: /\bgsk_[A-Za-z0-9]{40,}/g },
|
|
63
|
+
{ id: 'dsh-dlp/xai-api-key', version: 1, severity: 'critical', pattern: /\bxai-[A-Za-z0-9]{32,}/g },
|
|
64
|
+
{ id: 'dsh-dlp/google-oauth-client-secret', version: 1, severity: 'critical', pattern: /\bGOCSPX-[A-Za-z0-9_-]{24,}/g },
|
|
65
|
+
{ id: 'dsh-dlp/databricks-token', version: 1, severity: 'critical', pattern: /\bdapi[0-9a-f]{32}(?:-\d+)?\b/g },
|
|
66
|
+
{ id: 'dsh-dlp/sendgrid-api-key', version: 1, severity: 'critical', pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g },
|
|
67
|
+
{ id: 'dsh-dlp/supabase-service-key', version: 1, severity: 'critical', pattern: /\bsbp_[0-9a-f]{40}\b/g },
|
|
68
|
+
{ id: 'dsh-dlp/notion-token', version: 1, severity: 'critical', pattern: /\bntn_[A-Za-z0-9]{40,}/g },
|
|
50
69
|
{ id: 'dsh-dlp/private-key-block', version: 1, severity: 'critical', pattern: /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z]+ )*PRIVATE KEY-----/g },
|
|
51
70
|
{ id: 'dsh-dlp/json-web-token', version: 1, severity: 'high', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
52
71
|
{ id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
|
|
@@ -58,13 +77,94 @@ export const SYNC_RULES = [
|
|
|
58
77
|
{ id: 'dsh-dlp/teams-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/[A-Za-z0-9.-]*webhook\.office\.com\/webhookb2\/[A-Za-z0-9@/_-]{10,}/g },
|
|
59
78
|
{ id: 'dsh-dlp/secret-assignment', version: 1, severity: 'medium', pattern: /\b(?:api[_-]?key|secret[_-]?key|client[_-]?secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[=:]\s*["']?[A-Za-z0-9/+=_-]{16,}["']?/gi },
|
|
60
79
|
];
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Build one class's run pattern from its ranges, so the two cannot drift apart.
|
|
82
|
+
* @param id - the rule's identity.
|
|
83
|
+
* @param action - what the scan does with a match.
|
|
84
|
+
* @param ranges - the class's ranges as a character-class body.
|
|
85
|
+
* @param quantifier - applied to the class; the default matches a whole run.
|
|
86
|
+
* @param wholeRun - whether a match must be a complete run rather than part of a longer one.
|
|
87
|
+
* @returns the rule.
|
|
88
|
+
*/
|
|
89
|
+
function unicodeRule(id, action, ranges, quantifier = '+', wholeRun = false) {
|
|
90
|
+
const source = wholeRun
|
|
91
|
+
? `(?<![${ranges}])[${ranges}]${quantifier}(?![${ranges}])`
|
|
92
|
+
: `[${ranges}]${quantifier}`;
|
|
93
|
+
return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(source, 'gu') };
|
|
64
94
|
}
|
|
95
|
+
/** Variation-selector code points, shared by the isolated rule and the run rule. */
|
|
96
|
+
const VARIATION_SELECTORS = String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`;
|
|
97
|
+
/**
|
|
98
|
+
* Consecutive variation selectors at which the run stops being glyph selection
|
|
99
|
+
* and starts being a payload.
|
|
100
|
+
*
|
|
101
|
+
* One selector picks a glyph: VS15/VS16 after a base character, one selector
|
|
102
|
+
* after one ideograph in an Ideographic Variation Sequence. Two in a row have
|
|
103
|
+
* no standard meaning — an emoji ZWJ sequence separates its selectors with a
|
|
104
|
+
* joiner, so a run stays at one — and four leaves no plausible reading but
|
|
105
|
+
* "these are bytes". GlassWorm encoded executable JavaScript one byte per
|
|
106
|
+
* selector across five waves, so a real payload is hundreds of selectors long
|
|
107
|
+
* and 4 is a conservative floor rather than a tight one.
|
|
108
|
+
*/
|
|
109
|
+
const VARIATION_SELECTOR_RUN = 4;
|
|
110
|
+
/**
|
|
111
|
+
* One terminal control sequence: the full CSI form, not only the SGR colour
|
|
112
|
+
* subset, plus the string-introducer families and the 8-bit C1 equivalents.
|
|
113
|
+
*
|
|
114
|
+
* Each alternative in order: an OSC/DCS/SOS/PM/APC introducer and its body up
|
|
115
|
+
* to a string terminator that may never arrive; a complete CSI — parameter
|
|
116
|
+
* bytes, intermediate bytes, one final byte; any other escape sequence; and a
|
|
117
|
+
* lone escape or C1 control that introduces nothing.
|
|
118
|
+
*
|
|
119
|
+
* Terminating at end of input matters: an unterminated OSC swallows everything
|
|
120
|
+
* a terminal prints after it, which is the whole trick, so the tail is part of
|
|
121
|
+
* the match rather than a miss.
|
|
122
|
+
*/
|
|
123
|
+
const CONTROL_SEQUENCE = [
|
|
124
|
+
String.raw `(?:\u001B[\]P^_X]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)`,
|
|
125
|
+
String.raw `(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`,
|
|
126
|
+
String.raw `\u001B[\u0020-\u002F]*[\u0030-\u007E]`,
|
|
127
|
+
String.raw `[\u001B\u0080-\u009F]`,
|
|
128
|
+
].join('|');
|
|
129
|
+
/**
|
|
130
|
+
* Text substituted for a stripped control sequence. Visible on purpose: the
|
|
131
|
+
* lanes that strip are the ones an operator reads as evidence, and silently
|
|
132
|
+
* deleting the bytes would hide that a forgery was attempted.
|
|
133
|
+
*/
|
|
134
|
+
export const CONTROL_SEQUENCE_PLACEHOLDER = '[REDACTED:dsh-dlp:control-sequence]';
|
|
135
|
+
/**
|
|
136
|
+
* Remove every terminal control sequence from one string.
|
|
137
|
+
*
|
|
138
|
+
* This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
|
|
139
|
+
* lanes that must never carry forgeable bytes: an audit record and the strings
|
|
140
|
+
* an operator or an approval prompt reads back. Ordinary tool-result text takes
|
|
141
|
+
* the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
|
|
142
|
+
* colourise their output.
|
|
143
|
+
* @param text - the string to clean.
|
|
144
|
+
* @returns the string with each control sequence replaced by a visible marker.
|
|
145
|
+
*/
|
|
146
|
+
export function stripControlSequences(text) {
|
|
147
|
+
return text.replace(new RegExp(CONTROL_SEQUENCE, 'gu'), CONTROL_SEQUENCE_PLACEHOLDER);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Terminal control sequences in ordinary text.
|
|
151
|
+
*
|
|
152
|
+
* `report` rather than `strip`, deliberately: a tool result carrying SGR colour
|
|
153
|
+
* codes is the normal output of half the commands an agent runs, and replacing
|
|
154
|
+
* them would corrupt every one of those results. What the class buys on that
|
|
155
|
+
* lane is the count in the audit record.
|
|
156
|
+
*/
|
|
157
|
+
const CONTROL_SEQUENCE_RULE = {
|
|
158
|
+
id: 'dsh-dlp/control-sequence',
|
|
159
|
+
version: 1,
|
|
160
|
+
severity: 'medium',
|
|
161
|
+
action: 'report',
|
|
162
|
+
pattern: new RegExp(CONTROL_SEQUENCE, 'gu'),
|
|
163
|
+
};
|
|
65
164
|
/**
|
|
66
165
|
* Character classes that hide text from the reader while the model still reads
|
|
67
|
-
* it, verified against the Unicode character database
|
|
166
|
+
* it, verified against the Unicode character database, plus the terminal
|
|
167
|
+
* control sequences that show the reader something other than what is there.
|
|
68
168
|
*
|
|
69
169
|
* Every class is `medium`. These are injection *indicators*, not credentials:
|
|
70
170
|
* the guard floor denies at `high` and above, so an argument carrying one is
|
|
@@ -88,14 +188,22 @@ export const UNICODE_RULES = [
|
|
|
88
188
|
unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
|
|
89
189
|
// Bidi marks, unlike the overrides above, appear in real right-to-left text.
|
|
90
190
|
unicodeRule('dsh-dlp/unicode-bidi-mark', 'report', String.raw `\u{061C}\u{200E}\u{200F}`),
|
|
91
|
-
|
|
191
|
+
// An isolated selector is glyph selection and is left alone; a run of them
|
|
192
|
+
// is a byte string wearing the same code points.
|
|
193
|
+
unicodeRule('dsh-dlp/unicode-variation-selector', 'report', VARIATION_SELECTORS, `{1,${VARIATION_SELECTOR_RUN - 1}}`, true),
|
|
194
|
+
unicodeRule('dsh-dlp/unicode-variation-selector-run', 'strip', VARIATION_SELECTORS, `{${VARIATION_SELECTOR_RUN},}`),
|
|
195
|
+
CONTROL_SEQUENCE_RULE,
|
|
92
196
|
];
|
|
197
|
+
/** Classes whose matches are a run of one character class. */
|
|
198
|
+
const CHARACTER_RULES = UNICODE_RULES.filter(rule => rule.ranges !== undefined);
|
|
199
|
+
/** Classes whose matches have an ASCII body and are scanned over the whole input. */
|
|
200
|
+
const SEQUENCE_RULES = UNICODE_RULES.filter(rule => rule.ranges === undefined);
|
|
93
201
|
/**
|
|
94
|
-
* One run of any indicator
|
|
95
|
-
* with this pattern; the per-class patterns then run over the matched
|
|
96
|
-
* only, which are a handful of characters each.
|
|
202
|
+
* One run of any character-class indicator. The scan is a single pass over the
|
|
203
|
+
* input with this pattern; the per-class patterns then run over the matched
|
|
204
|
+
* runs only, which are a handful of characters each.
|
|
97
205
|
*/
|
|
98
|
-
const UNICODE_RUN = new RegExp(`[${
|
|
206
|
+
const UNICODE_RUN = new RegExp(`[${[...new Set(CHARACTER_RULES.map(rule => rule.ranges))].join('')}]+`, 'gu');
|
|
99
207
|
/**
|
|
100
208
|
* Find every invisible or direction-changing character in one string.
|
|
101
209
|
*
|
|
@@ -107,20 +215,25 @@ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('
|
|
|
107
215
|
*/
|
|
108
216
|
export function scanUnicode(text) {
|
|
109
217
|
const findings = [];
|
|
218
|
+
const found = (rule, start, length) => {
|
|
219
|
+
findings.push({
|
|
220
|
+
ruleId: rule.id,
|
|
221
|
+
ruleVersion: rule.version,
|
|
222
|
+
severity: rule.severity,
|
|
223
|
+
start,
|
|
224
|
+
end: start + length,
|
|
225
|
+
exact: true,
|
|
226
|
+
action: rule.action,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
for (const rule of SEQUENCE_RULES) {
|
|
230
|
+
for (const match of text.matchAll(rule.pattern))
|
|
231
|
+
found(rule, match.index, match[0].length);
|
|
232
|
+
}
|
|
110
233
|
for (const run of text.matchAll(UNICODE_RUN)) {
|
|
111
|
-
for (const rule of
|
|
112
|
-
for (const match of run[0].matchAll(rule.pattern))
|
|
113
|
-
|
|
114
|
-
findings.push({
|
|
115
|
-
ruleId: rule.id,
|
|
116
|
-
ruleVersion: rule.version,
|
|
117
|
-
severity: rule.severity,
|
|
118
|
-
start,
|
|
119
|
-
end: start + match[0].length,
|
|
120
|
-
exact: true,
|
|
121
|
-
action: rule.action,
|
|
122
|
-
});
|
|
123
|
-
}
|
|
234
|
+
for (const rule of CHARACTER_RULES) {
|
|
235
|
+
for (const match of run[0].matchAll(rule.pattern))
|
|
236
|
+
found(rule, run.index + match.index, match[0].length);
|
|
124
237
|
}
|
|
125
238
|
}
|
|
126
239
|
findings.sort(byPosition);
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `dsh-dlp` — data-loss prevention for DeepSeek Harness.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Six registrations, in descending order of how much they can be trusted:
|
|
5
5
|
*
|
|
6
6
|
* 1. `ctx.tools.guard()` — an unconditional, non-configurable deny floor for
|
|
7
7
|
* credential paths named in a path-typed argument and for secrets heading
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* has no allow arm.
|
|
10
10
|
* 2. `tools/pre-execute` — the async breadth tier, which can await
|
|
11
11
|
* `@secretlint/core`. Neutralizable by any listener registered ahead of it.
|
|
12
|
+
* 2b. `tools/pre-execute` — the `ask` tier for writes to behaviour-changing
|
|
13
|
+
* config paths. Deliberately here rather than on the floor: its rules have a
|
|
14
|
+
* real false-positive rate and the floor cannot ask. Neutralizable, and it
|
|
15
|
+
* abstains entirely when no approval service is mounted.
|
|
12
16
|
* 3. `tools/post-execute` — result redaction, applied before the `tool/result`
|
|
13
17
|
* session event is appended, so the durable log records the redacted copy;
|
|
14
18
|
* a result that cannot be cleaned is withheld rather than accepted.
|
|
@@ -36,6 +40,7 @@ import { SpanHasher } from "./redaction.js";
|
|
|
36
40
|
import { safeEvaluateGuard } from "./guard.js";
|
|
37
41
|
import { neutralizeImageStream } from "./images.js";
|
|
38
42
|
import { ExecutionSnapshots, mutationReason } from "./mutation.js";
|
|
43
|
+
import { evaluateConfigWrite } from "./config-writes.js";
|
|
39
44
|
import { breadthTierDenial, evaluateBreadthTier, redactDecision } from "./results.js";
|
|
40
45
|
import { redactRecord, telemetrySeamNotice } from "./telemetry.js";
|
|
41
46
|
import { AuditSink, CallCorrelator, newDecisionId, RECORD_VERSION } from "./sink.js";
|
|
@@ -230,6 +235,59 @@ export function apply(ctx, config) {
|
|
|
230
235
|
});
|
|
231
236
|
return verdict.reason;
|
|
232
237
|
}), 'dsh-dlp guard floor');
|
|
238
|
+
/**
|
|
239
|
+
* Report once that the `ask` tier has nowhere to ask.
|
|
240
|
+
*
|
|
241
|
+
* The registry resolves an `ask` through `ctx.get('approval')` and keeps the
|
|
242
|
+
* historical degrade to *deny* when no service is composed. This tier exists
|
|
243
|
+
* because its rules are too false-positive-prone for a deny, so under a
|
|
244
|
+
* deployment with no approval channel it abstains instead of becoming the
|
|
245
|
+
* silent hard deny it was designed not to be. Evaluated at decision time
|
|
246
|
+
* rather than at mount, because by then the harness is running and an absent
|
|
247
|
+
* service is conclusive rather than a load order.
|
|
248
|
+
*/
|
|
249
|
+
let approvalSeamReported = false;
|
|
250
|
+
const discloseApprovalSeam = () => {
|
|
251
|
+
if (approvalSeamReported)
|
|
252
|
+
return;
|
|
253
|
+
approvalSeamReported = true;
|
|
254
|
+
notice(ctx, 'dsh-dlp: configWriteAsk is enabled, but no approval service is mounted, so an ask would degrade'
|
|
255
|
+
+ ' to a denial. This tier abstains instead: a write to a behaviour-changing config path is allowed through'
|
|
256
|
+
+ ' with no prompt. The guard floor is unaffected.');
|
|
257
|
+
};
|
|
258
|
+
if (policy.configWriteAsk) {
|
|
259
|
+
// Registered ahead of the breadth tier, so a call that is both a config
|
|
260
|
+
// write and carries a secret is denied rather than merely asked about:
|
|
261
|
+
// this listener sees whatever the rest of the waterfall settled on and
|
|
262
|
+
// only ever narrows `allow` into `ask`.
|
|
263
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
264
|
+
const decision = await next();
|
|
265
|
+
if (decision.kind !== 'allow')
|
|
266
|
+
return decision;
|
|
267
|
+
const finding = evaluateConfigWrite(exec);
|
|
268
|
+
if (finding === undefined)
|
|
269
|
+
return decision;
|
|
270
|
+
// A call the floor will deny anyway is left to the floor. Any non-allow
|
|
271
|
+
// decision from this waterfall skips guards entirely, so asking here
|
|
272
|
+
// would replace an unconditional denial with a prompt a user can grant,
|
|
273
|
+
// and would file the decision as an ask rather than as a guard denial.
|
|
274
|
+
if (safeEvaluateGuard(exec, policy, hasher) !== undefined)
|
|
275
|
+
return decision;
|
|
276
|
+
if (ctx.get('approval') === undefined) {
|
|
277
|
+
discloseApprovalSeam();
|
|
278
|
+
return decision;
|
|
279
|
+
}
|
|
280
|
+
sink.write({
|
|
281
|
+
v: RECORD_VERSION,
|
|
282
|
+
time: new Date().toISOString(),
|
|
283
|
+
kind: 'pre-execute-ask',
|
|
284
|
+
decisionId: newDecisionId(),
|
|
285
|
+
...identity(exec),
|
|
286
|
+
ruleId: finding.rule.id,
|
|
287
|
+
});
|
|
288
|
+
return { kind: 'ask', reason: finding.reason };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
233
291
|
if (policy.breadthTier) {
|
|
234
292
|
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
235
293
|
const decision = await next();
|
package/lib/paths.js
CHANGED
|
@@ -48,9 +48,61 @@ export const CREDENTIAL_PATH_RULES = [
|
|
|
48
48
|
{ id: 'dsh-dlp/path-pgpass', version: 1, pattern: /(^|\/)\.pgpass$/i },
|
|
49
49
|
{ id: 'dsh-dlp/path-mysql-config', version: 1, pattern: /(^|\/)\.my\.cnf$/i },
|
|
50
50
|
{ id: 'dsh-dlp/path-service-account', version: 1, pattern: /(^|\/)[^/]*service[._-]?account[^/]*\.json$/i },
|
|
51
|
+
// Coding-agent credential stores. IronWorm's 44 packages and SANDWORM_MODE
|
|
52
|
+
// name these verbatim; an `auth.json` under an agent's own directory is a
|
|
53
|
+
// token file whatever else the directory holds.
|
|
54
|
+
{ id: 'dsh-dlp/path-agent-auth', version: 1, pattern: /(^|\/)\.?(codex|cursor|composer|windsurf|continue|aider|claude|gemini)\/auth\.json$/i },
|
|
55
|
+
// An MCP manifest carries each server's `env`, which is where its API keys
|
|
56
|
+
// are written.
|
|
57
|
+
{ id: 'dsh-dlp/path-agent-mcp-config', version: 1, pattern: /(^|\/)\.(cursor|windsurf|continue|codex|claude|gemini)\/mcp\.json$/i },
|
|
58
|
+
// Cursor keeps session tokens in a SQLite state database rather than a
|
|
59
|
+
// credential file.
|
|
60
|
+
{ id: 'dsh-dlp/path-editor-state-db', version: 1, pattern: /(^|\/)state\.vscdb(-journal|-wal|-shm)?$/i },
|
|
61
|
+
{ id: 'dsh-dlp/path-macos-keychain', version: 1, pattern: /(^|\/)Library\/Keychains(\/|$)/i },
|
|
62
|
+
// A Terraform variables file is where provider credentials are written by
|
|
63
|
+
// convention, and state holds every provider's secrets in plaintext.
|
|
64
|
+
{ id: 'dsh-dlp/path-terraform-vars', version: 1, pattern: /\.tfvars(\.json)?$/i },
|
|
65
|
+
{ id: 'dsh-dlp/path-terraform-state', version: 1, pattern: /(^|\/)terraform\.tfstate(\.backup)?$/i },
|
|
51
66
|
{ id: 'dsh-dlp/path-keystore', version: 2, pattern: /\.(pem|p12|pfx|jks|keystore|key|asc|gpg)$/i },
|
|
52
67
|
{ id: 'dsh-dlp/path-credential-name', version: 1, pattern: new RegExp(String.raw `(^|\/)(?!.*\.(?:${CODE_EXTENSIONS})$)[^/]*(credentials?|secrets?|tokens?)([._-][^/]*)?$`, 'i') },
|
|
53
68
|
];
|
|
69
|
+
/**
|
|
70
|
+
* Escape one literal path so it can anchor a regular expression.
|
|
71
|
+
* @param literal - the path to quote.
|
|
72
|
+
* @returns the same text with every metacharacter escaped.
|
|
73
|
+
*/
|
|
74
|
+
export function escapePathPattern(literal) {
|
|
75
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Credential-path rules anchored at the user's home directory, resolved at
|
|
79
|
+
* mount because the directory is not known until then.
|
|
80
|
+
*
|
|
81
|
+
* A coding agent's *home* configuration decides how every future session in
|
|
82
|
+
* every repository behaves: the Miasma worm's `SessionStart` hooks went into
|
|
83
|
+
* exactly these files. Writing one is never ordinary repository work, so it is
|
|
84
|
+
* on the floor. Reading one is — a user asking the agent why its own
|
|
85
|
+
* configuration behaves a certain way is a normal request — so the rule is
|
|
86
|
+
* `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
|
|
87
|
+
* stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
|
|
88
|
+
*
|
|
89
|
+
* The *repository-local* copies of these same file names are a different
|
|
90
|
+
* question with a different answer: they are edited legitimately and often, so
|
|
91
|
+
* they sit in the neutralizable `ask` tier rather than on the floor.
|
|
92
|
+
* @param home - the user's home directory.
|
|
93
|
+
* @returns rules appended after the built-in table.
|
|
94
|
+
*/
|
|
95
|
+
export function homeCredentialPathRules(home) {
|
|
96
|
+
// `~` survives normalization as a root-anchored marker, so a home-relative
|
|
97
|
+
// spelling reaches the same rule as the absolute one.
|
|
98
|
+
const anchor = `(?:${escapePathPattern(home)}|/~)`;
|
|
99
|
+
return [{
|
|
100
|
+
id: 'dsh-dlp/path-agent-home-settings',
|
|
101
|
+
version: 1,
|
|
102
|
+
enforcement: 'writes-only',
|
|
103
|
+
pattern: new RegExp(`^${anchor}/\\.(claude|gemini|codex|cursor|windsurf|continue)/settings[^/]*\\.json$`, 'i'),
|
|
104
|
+
}];
|
|
105
|
+
}
|
|
54
106
|
/**
|
|
55
107
|
* Normalize one candidate path for matching: Windows separators become
|
|
56
108
|
* forward slashes, surrounding quotes come off, `~` expands to a
|