dsh-dlp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/SECURITY.md +47 -0
- package/cordis.patch.yml +22 -0
- package/lib/detectors.js +148 -0
- package/lib/guard.js +133 -0
- package/lib/index.js +212 -0
- package/lib/paths.js +227 -0
- package/lib/policy.js +274 -0
- package/lib/redaction.js +216 -0
- package/lib/results.js +238 -0
- package/lib/sink.js +105 -0
- package/lib/telemetry.js +80 -0
- package/lib/types/detectors.d.ts +102 -0
- package/lib/types/guard.d.ts +68 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/paths.d.ts +123 -0
- package/lib/types/policy.d.ts +128 -0
- package/lib/types/redaction.d.ts +113 -0
- package/lib/types/results.d.ts +71 -0
- package/lib/types/sink.d.ts +118 -0
- package/lib/types/telemetry.d.ts +51 -0
- package/package.json +84 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Tyshchenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# dsh-dlp
|
|
2
|
+
|
|
3
|
+
Data-loss prevention for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
|
|
4
|
+
built as an out-of-repo plugin.
|
|
5
|
+
|
|
6
|
+
It does four things:
|
|
7
|
+
|
|
8
|
+
1. **Denies credential-file access and secrets bound for the network** — unconditionally, from
|
|
9
|
+
`ctx.tools.guard()`. It tests the path-typed arguments of a call against a table of
|
|
10
|
+
credential stores, following symlinks first.
|
|
11
|
+
2. **Redacts secrets out of tool results** before the model reads them and before the session
|
|
12
|
+
log records them, and withholds a result it cannot clean.
|
|
13
|
+
3. **Redacts secrets out of exported telemetry**, patching a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
14
|
+
ships message text, tool arguments, tool results and workspace paths in the clear.
|
|
15
|
+
4. **Writes an audit record for every decision** to its own sink — rule id, rule version,
|
|
16
|
+
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What this is not
|
|
21
|
+
|
|
22
|
+
**This is not a containment boundary.** The plugin runs in-process, in the agent's own process,
|
|
23
|
+
at the agent's own uid. Anything the agent can execute — a `bash` command, a `run_code`
|
|
24
|
+
program, a mounted MCP server — can read every file the guard denies and can open its own
|
|
25
|
+
sockets without the plugin seeing anything. The guard closes the path where *the model* asks
|
|
26
|
+
for credential material through a tool. It does not stop code that is already running.
|
|
27
|
+
|
|
28
|
+
If you need containment, that is the sandbox, `landlock-run`, filesystem permissions, and
|
|
29
|
+
egress firewalling. Use this alongside them, not instead of them.
|
|
30
|
+
|
|
31
|
+
More limits worth stating up front:
|
|
32
|
+
|
|
33
|
+
- **The shell-command arm is advisory pattern-matching.** A `bash` command line is split on
|
|
34
|
+
shell-ish separators and each token is tested as a path. That catches an unobfuscated
|
|
35
|
+
`cat ~/.ssh/id_rsa`. It catches nothing that tries: `cat ~/.netr?` (one glob character),
|
|
36
|
+
`cat ~/.s""sh/id_r""sa`, `find ~ -name 'id_*' -exec cat {} +`, a `$(printf ...)`
|
|
37
|
+
reassembly, a base64 round-trip of the path, or `python3 -c` opening the file — every one
|
|
38
|
+
of those was verified to read the file with the guard abstaining. **Do not count this arm
|
|
39
|
+
as a control.** A shell command is a program, not a path, and the only way to decide what
|
|
40
|
+
it will open is to run it. If the agent has a shell, credential files need filesystem
|
|
41
|
+
permissions or a sandbox, not this plugin.
|
|
42
|
+
- **Tool arguments are never masked.** Model-visible implies logged: arguments are already in
|
|
43
|
+
the session log and already presented to the model, so rewriting them would desynchronise
|
|
44
|
+
the log from what actually ran. Argument-level DLP here is *denial with a reason the model
|
|
45
|
+
can act on*.
|
|
46
|
+
- **Outbound prompts cannot be rewritten.** `llm/stream` options are deep-frozen and `next()`
|
|
47
|
+
takes no arguments. A secret already in the conversation reaches the provider.
|
|
48
|
+
- **Detection is pattern-based.** A password, an internal token format, or a customer record
|
|
49
|
+
has no recognisable structure and is not detected. Neither is any encoded form: base64,
|
|
50
|
+
hex, URL-escaping and reversal all pass both tiers, as does a secret split across two
|
|
51
|
+
content blocks.
|
|
52
|
+
|
|
53
|
+
The full list is in [PLAN.md §8](PLAN.md).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
A profile carrying only `@deepseek-ai/dsh-base` has no agent loop. Add a runnable
|
|
60
|
+
bundle alongside it, or the profile boots with nothing for this plugin to guard:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
dsh plugin --profile <name> add @deepseek-ai/dsh-headless@0.1.0-rc.6
|
|
64
|
+
dsh plugin --profile <name> add dsh-dlp
|
|
65
|
+
dsh --profile <name> --dump-config # the dsh-dlp row should appear
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Pin `@deepseek-ai/dsh-headless` explicitly: its npm `latest` tag still points at
|
|
69
|
+
`0.0.1-rc.1`, so an unpinned install silently resolves to a much older harness.
|
|
70
|
+
|
|
71
|
+
The package ships a `cordis.patch.yml` bundle layer, so listing it in a profile's
|
|
72
|
+
`dsh.profile.bundles` is enough to mount it with working defaults.
|
|
73
|
+
|
|
74
|
+
**Install from the registry or a packed tarball, not from a git spec.**
|
|
75
|
+
`dsh plugin add github:CharlotteN7/dsh-dlp` resolves and writes the dependency,
|
|
76
|
+
but `lib/` is a build output that git does not carry and no `prepare` script
|
|
77
|
+
rebuilds it, so the row mounts and then fails to load. To install from a
|
|
78
|
+
checkout, build first and add the tarball:
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
|
|
82
|
+
pnpm install && pnpm run build && pnpm pack
|
|
83
|
+
dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Configure
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- id: dsh-dlp
|
|
90
|
+
name: 'dsh-dlp'
|
|
91
|
+
config:
|
|
92
|
+
auditLog: /var/log/dsh-dlp.audit.jsonl
|
|
93
|
+
redactionKeyFile: /var/lib/dsh/dsh-dlp.redaction-key
|
|
94
|
+
policyFile: ./.dsh-dlp.yml # optional, lowest trust — see below
|
|
95
|
+
maxScanBytes: 1048576
|
|
96
|
+
breadthTier: true
|
|
97
|
+
resultRedaction: true
|
|
98
|
+
telemetryRedaction: true
|
|
99
|
+
redactTelemetryWorkspacePaths: true
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
|
|
103
|
+
of version control: it is what makes a placeholder's hash keyed rather than a bare digest that
|
|
104
|
+
anyone holding a candidate secret could confirm.
|
|
105
|
+
|
|
106
|
+
**The guard floor has no configuration.** Credential-path denial and secret-argument denial are
|
|
107
|
+
security invariants, not deployment-varying tunables, so there is no switch that turns them off.
|
|
108
|
+
|
|
109
|
+
### Configuration trust ranking
|
|
110
|
+
|
|
111
|
+
| Rank | Source | May |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| 1 | invariants compiled into the package | everything; not configurable |
|
|
114
|
+
| 2 | `cordis.yml` / bundle patch config | set every field |
|
|
115
|
+
| 3 | `policyFile` — a repo-local YAML file | **tighten only** |
|
|
116
|
+
|
|
117
|
+
Rank 3 is attacker-controlled — a hostile repository ships one, and a prompt-injected agent can
|
|
118
|
+
write one — so it may only add deny patterns, add egress-capable tool names, raise a severity,
|
|
119
|
+
and switch a redaction pass on:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
v: 1
|
|
123
|
+
addCredentialPaths:
|
|
124
|
+
- id: acme/vault-token
|
|
125
|
+
pattern: '(^|/)\.vault-token$'
|
|
126
|
+
addEgressTools: [acme_publish]
|
|
127
|
+
raiseSeverity:
|
|
128
|
+
dsh-dlp/secret-assignment: high
|
|
129
|
+
enable: [telemetryRedaction]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Any other key, and any downgrade, makes the **whole file invalid**: it is logged on the
|
|
133
|
+
deployment's logger and ignored, never obeyed in part. There is no `disable`, no
|
|
134
|
+
`removeCredentialPaths`, and no way to redirect the audit sink. The file is parsed with
|
|
135
|
+
`js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather than code
|
|
136
|
+
execution, and it never goes near the Cordis loader.
|
|
137
|
+
|
|
138
|
+
A missing `policyFile` is not an error — it means the workspace ships no policy. The
|
|
139
|
+
recommended value is workspace-relative, so failing the mount would stop `dsh` from starting in
|
|
140
|
+
every repository without one, and would let a hostile repository remove the floor by shipping a
|
|
141
|
+
broken file. An added `pattern` is capped at 200 characters and rejected if it nests a
|
|
142
|
+
quantifier inside a quantified group: `^(a+)+$` blocks the synchronous guard for seconds on a
|
|
143
|
+
27-character path. That check is a heuristic, not a proof of linear-time matching.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## What gets denied
|
|
148
|
+
|
|
149
|
+
**Credential paths named in a path-typed argument**, for every tool: `.env` and `.env.*`
|
|
150
|
+
directories (but not `.env.example`), anything under `.ssh/`,
|
|
151
|
+
`id_rsa`/`id_ed25519`/`id_ecdsa`/`id_dsa` and their backups, `~/.aws/` and `~/.azure/`,
|
|
152
|
+
`$DSH_HOME/.credentials.yaml`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`,
|
|
153
|
+
`~/.config/gh/`, `~/.kube/` and `kubeconfig*`, `/etc/kubernetes/*.conf`,
|
|
154
|
+
`~/.docker/config.json` and `.dockercfg`, gcloud credential files, `rclone.conf`, `.pgpass`,
|
|
155
|
+
`.my.cnf`, `*service-account*.json`, `*.pem`/`*.p12`/`*.pfx`/`*.jks`/`*.keystore`/`*.key`/
|
|
156
|
+
`*.asc`/`*.gpg`, and any file whose name ends in a delimited `credential(s)`, `secret(s)` or
|
|
157
|
+
`token(s)` — which covers `.vault-token`, `.gem/credentials`, `.cargo/credentials.toml`,
|
|
158
|
+
`.terraform.d/credentials.tfrc.json` and a Kubernetes service-account `token`. Source and
|
|
159
|
+
documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
|
|
160
|
+
readable.
|
|
161
|
+
|
|
162
|
+
Also denied: this plugin's own `redactionKeyFile` and `auditLog`, and everything under
|
|
163
|
+
`$DSH_HOME`. The harness home holds the provider credentials, the session logs, and the
|
|
164
|
+
profiles that decide which plugins load at all; keep the files an agent is meant to work on
|
|
165
|
+
somewhere else.
|
|
166
|
+
|
|
167
|
+
Paths are normalised first — `..` traversal, `~`, Windows separators, quoting and a trailing
|
|
168
|
+
slash do not evade the table — and then resolved with `realpathSync`, so a symlink named
|
|
169
|
+
`notes.txt` pointing at `~/.ssh/id_rsa` is denied by what it resolves to. Only path-typed
|
|
170
|
+
argument keys are tested (`file_path`, `path`, `paths`, `notebook_path`, `cwd`, `command`, …).
|
|
171
|
+
File content is never treated as a path: writing a `.gitignore` that lists `.env` is ordinary
|
|
172
|
+
work, not an attempt to read a credential store.
|
|
173
|
+
|
|
174
|
+
`$DSH_HOME/.credentials.yaml` is on that list because core permits reading it. The harness has
|
|
175
|
+
no file-read restriction in any mode — reads pass through untouched in every permission mode —
|
|
176
|
+
so the provider token the agent authenticates with is agent-readable. That is the specific gap
|
|
177
|
+
this plugin closes.
|
|
178
|
+
|
|
179
|
+
**Some secrets in arguments**, for tools that can move data off the machine. Local tools
|
|
180
|
+
(`read`, `glob`, `grep`, `write`, `edit`, `todo_write`, the session-query tools, …) are exempt.
|
|
181
|
+
Everything else — every shell, `run_code`, the web tools, every `mcp__*` tool, and any tool
|
|
182
|
+
this build has never heard of — is treated as egress-capable. Unknown defaults to the safe side.
|
|
183
|
+
|
|
184
|
+
What this arm actually catches is a whole, unencoded secret of `high` severity or above sitting
|
|
185
|
+
in one argument string. `A=ghp_firsthalf; B=…; curl -H "Bearer $A$B"`, a base64 round-trip, and
|
|
186
|
+
`$(cat ~/.token)` all defeat it; a `password=` assignment is `medium` and is redacted rather
|
|
187
|
+
than denied. Treat it as a guard against accident, not against an adversary.
|
|
188
|
+
|
|
189
|
+
A denial reads like this, and reaches the model as the tool's error result. It names the rule
|
|
190
|
+
and a keyed hash, never the path — a path is itself sensitive, and this string is written to
|
|
191
|
+
the model and, in hashed form, to the audit sink:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
dsh-dlp denied "read": one of its path arguments is credential material (rule
|
|
195
|
+
dsh-dlp/path-aws, keyed hash ca9cad27f2b5). Reading or passing credential files through a
|
|
196
|
+
tool is blocked by policy and cannot be overridden. Ask the user to supply the value you
|
|
197
|
+
need, or use a path that is not a credential store.
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## What gets redacted
|
|
203
|
+
|
|
204
|
+
A redacted region becomes:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
[REDACTED:dsh-dlp:slack-token:ca9cad27f2b5]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The hash is `HMAC-SHA256(installation key, replaced text)` truncated to 12 hex characters. It
|
|
211
|
+
is **stable**: the same secret produces the same placeholder everywhere, so an operator can see
|
|
212
|
+
that one token appeared in four different tool results without the plugin ever writing the
|
|
213
|
+
token down.
|
|
214
|
+
|
|
215
|
+
For a successful tool result the plugin replaces the canonical `value`, which makes the registry
|
|
216
|
+
re-validate the tool's `output.schema`, re-run `output.render()` and re-derive
|
|
217
|
+
`presentationMeta()` — so the value, the model-facing content and the persisted card are all
|
|
218
|
+
redacted from one replacement. That arm is not a preference: the alternative, replacing
|
|
219
|
+
`content`, leaves `{...result}` in place, and `value` and `meta` go into the session log
|
|
220
|
+
exactly as the tool produced them. A successful result therefore never settles for the content
|
|
221
|
+
arm, which is used only for a failed result (where replacing the value throws) or where the
|
|
222
|
+
persisted surfaces are already clean.
|
|
223
|
+
|
|
224
|
+
When neither works — a failed result whose `meta` carries a secret, or a value that still scans
|
|
225
|
+
dirty after redaction — the result is **withheld**: the plugin returns a `block` decision, the
|
|
226
|
+
model gets an error naming the rule and the hash, and nothing dirty reaches the log. Blocking
|
|
227
|
+
is the only decision that replaces the whole result, so it is the only way to drop `meta`.
|
|
228
|
+
|
|
229
|
+
Two consequences worth knowing:
|
|
230
|
+
|
|
231
|
+
- Replacing a value re-validates it against the tool's `output.schema`. A schema that
|
|
232
|
+
constrains that string (a length, a pattern, an enum) rejects the placeholder and the call
|
|
233
|
+
fails with a `ToolOutputError`. A failed call is the intended outcome; the alternative is
|
|
234
|
+
writing the secret to the log.
|
|
235
|
+
- Redaction is per-detection, and each span grows to the nearest delimiter — whitespace,
|
|
236
|
+
quotes, `=`, `:`, `,`, brackets. A line of minified JSON loses the field that matched, not
|
|
237
|
+
the whole line.
|
|
238
|
+
|
|
239
|
+
Replacement runs before the `tool/result` session event is appended, so the durable log records
|
|
240
|
+
the redacted copy.
|
|
241
|
+
|
|
242
|
+
## Detection
|
|
243
|
+
|
|
244
|
+
Two tiers:
|
|
245
|
+
|
|
246
|
+
- **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
|
|
247
|
+
GitHub, Slack, Stripe, OpenAI, Anthropic, Google, npm), PEM private-key blocks, JWTs,
|
|
248
|
+
credential-bearing URLs, Slack/Discord/Teams webhook URLs, and high-signal secret
|
|
249
|
+
assignments. This is the tier the guard and the telemetry listener use, because both of
|
|
250
|
+
those seams are synchronous, and it is never capped.
|
|
251
|
+
- **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
|
|
252
|
+
recommended preset — 28 maintained rules, in-process, no subprocess. Used at
|
|
253
|
+
`tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
|
|
254
|
+
seam cannot reach it**: `session-telemetry/record` returns a record synchronously, so a
|
|
255
|
+
secret only secretlint recognises survives telemetry export.
|
|
256
|
+
|
|
257
|
+
A tool result is scanned twice: each of its strings on its own by tier 1, and all of them
|
|
258
|
+
joined by newlines through both tiers. The joined pass finds what no single string reproduces —
|
|
259
|
+
a PEM block arriving as one line per array element, which is exactly the shape `read` produces.
|
|
260
|
+
|
|
261
|
+
Measured cost of a tier-2 scan: 0.78 ms at 1 KB, 0.91 ms at 16 KB, 2.22 ms at 128 KB, 5.11 ms
|
|
262
|
+
at 512 KB. `maxScanBytes` caps **tier 2 only**, once per result, over the joined rendering;
|
|
263
|
+
tier 1 always scans everything. When tier 2 saw less than the whole result the audit record
|
|
264
|
+
says `truncatedScan: true`, and that record is written even when nothing was found, so a
|
|
265
|
+
partial scan never looks like a clean one.
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Audit output
|
|
270
|
+
|
|
271
|
+
One JSON object per line in `auditLog`. Nothing is ever written to the session log: the
|
|
272
|
+
harness's `Session.append()` cannot set the envelope's `ignorable` flag, and an out-of-repo
|
|
273
|
+
event type makes the user's next resume refuse the whole session. Each record therefore carries
|
|
274
|
+
its own identity.
|
|
275
|
+
|
|
276
|
+
```json
|
|
277
|
+
{
|
|
278
|
+
"v": 1,
|
|
279
|
+
"time": "2026-08-15T19:44:33.861Z",
|
|
280
|
+
"kind": "result-redaction",
|
|
281
|
+
"decisionId": "dlp-1e8ab1bb-5c8d-4410-b98d-39b83037ea63",
|
|
282
|
+
"tool": "read",
|
|
283
|
+
"callId": "mock-call-1",
|
|
284
|
+
"rootCallId": "mock-call-1",
|
|
285
|
+
"sessionId": "session-880b9ece-3633-427d-b0a8-cf202ea09917",
|
|
286
|
+
"turn": 1,
|
|
287
|
+
"step": 1,
|
|
288
|
+
"spans": [
|
|
289
|
+
{
|
|
290
|
+
"ruleId": "dsh-dlp/slack-token",
|
|
291
|
+
"ruleVersion": 1,
|
|
292
|
+
"severity": "critical",
|
|
293
|
+
"start": 17,
|
|
294
|
+
"end": 73,
|
|
295
|
+
"hash": "ca9cad27f2b5",
|
|
296
|
+
"path": "/lines/1/text"
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`kind` is one of `guard-deny`, `pre-execute-deny`, `result-redaction`, `telemetry-redaction`.
|
|
303
|
+
A record carries no free-text reason: the spans are the whole description of what matched, so
|
|
304
|
+
nothing built from a candidate path or command line can reach the file. An audit write failure
|
|
305
|
+
is logged and swallowed rather than turned into a denial: the sink is evidence, not
|
|
306
|
+
enforcement, and a full disk should not take the agent down.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Development
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
nvm use 22 # Node ^22.19.0 || >=24, and pnpm 11
|
|
314
|
+
pnpm install
|
|
315
|
+
pnpm run typecheck
|
|
316
|
+
pnpm run test # unit
|
|
317
|
+
pnpm run test:coverage
|
|
318
|
+
pnpm run test:e2e # boots a real dsh against a mock model; no API key
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The E2E harness boots a `dsh` checkout beside this one (`../dsh`); point `DSH_REPO` elsewhere
|
|
322
|
+
to override. That checkout needs `pnpm run build:lib:host` to have run at least once. Set
|
|
323
|
+
`DSH_CLI` to an installed `node_modules/@deepseek-ai/dsh/lib/bin.js` to run against the
|
|
324
|
+
published CLI instead, which needs no monorepo — that is what CI does.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
|---|---|
|
|
7
|
+
| 0.1.x | yes |
|
|
8
|
+
| < 0.1 | no |
|
|
9
|
+
|
|
10
|
+
Only the latest published `0.1.x` receives fixes. There is no long-term-support branch while
|
|
11
|
+
the package is pre-1.0.
|
|
12
|
+
|
|
13
|
+
## Reporting a vulnerability
|
|
14
|
+
|
|
15
|
+
Email **nsof@protonmail.com**. Please include:
|
|
16
|
+
|
|
17
|
+
- what an attacker gets — a credential reaching the model, the session log, the audit sink, or
|
|
18
|
+
the network;
|
|
19
|
+
- the smallest reproduction you have, ideally a failing test against this repository;
|
|
20
|
+
- the versions of `dsh-dlp`, DeepSeek Harness, and Node you ran.
|
|
21
|
+
|
|
22
|
+
Do not open a public issue for a vulnerability first.
|
|
23
|
+
|
|
24
|
+
**Response window:** acknowledgement within 3 working days, an assessment with a fix or a
|
|
25
|
+
rejection within 14 days. If a fix ships, the release notes credit the reporter unless asked
|
|
26
|
+
otherwise.
|
|
27
|
+
|
|
28
|
+
## What counts as a vulnerability here
|
|
29
|
+
|
|
30
|
+
This plugin is **not a containment boundary**. It runs in-process at the agent's own uid, so
|
|
31
|
+
anything the agent can execute can read the same files the guard denies. The following are
|
|
32
|
+
documented limits, not vulnerabilities — they are described in README.md and PLAN.md §8:
|
|
33
|
+
|
|
34
|
+
- shell-command obfuscation defeating the `bash` path arm (globbing, quoting, substitution, a
|
|
35
|
+
different binary);
|
|
36
|
+
- encoded or split secrets passing both detection tiers;
|
|
37
|
+
- a secret with no recognisable structure going undetected;
|
|
38
|
+
- a secret reaching the provider because it was already in the conversation.
|
|
39
|
+
|
|
40
|
+
These do count, and we want to hear about them:
|
|
41
|
+
|
|
42
|
+
- a credential path the table should match and does not, in a **path-typed argument**;
|
|
43
|
+
- a raw secret, path, or command line written to the audit sink or a log line;
|
|
44
|
+
- a secret surviving into the session log through a `tools/post-execute` arm;
|
|
45
|
+
- a repo-local `policyFile` loosening any part of the floor, executing code, or stalling the
|
|
46
|
+
agent;
|
|
47
|
+
- any way to make the guard abstain that does not require executing code.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Bundle layer applied when a profile lists this package in dsh.profile.bundles.
|
|
2
|
+
# The row references the package by NAME so Node resolution finds the installed
|
|
3
|
+
# code from the profile directory.
|
|
4
|
+
#
|
|
5
|
+
# `dshHomePath` is provided on the root context by the app boot, so a layer's
|
|
6
|
+
# `!!js` config expressions can reach it. A profile's own cordis.patch.yml
|
|
7
|
+
# overrides this row by id; a patch REPLACES a row's whole `config`, so an
|
|
8
|
+
# override must restate every key it wants to keep.
|
|
9
|
+
#
|
|
10
|
+
# The guard floor has no configuration here on purpose: it is a security
|
|
11
|
+
# invariant, not a deployment-varying tunable.
|
|
12
|
+
- insert:
|
|
13
|
+
- id: dsh-dlp
|
|
14
|
+
name: 'dsh-dlp'
|
|
15
|
+
config:
|
|
16
|
+
auditLog: !!js dshHomePath('dsh-dlp.audit.jsonl')
|
|
17
|
+
redactionKeyFile: !!js dshHomePath('dsh-dlp.redaction-key')
|
|
18
|
+
maxScanBytes: 1048576
|
|
19
|
+
breadthTier: true
|
|
20
|
+
resultRedaction: true
|
|
21
|
+
telemetryRedaction: true
|
|
22
|
+
redactTelemetryWorkspacePaths: true
|
package/lib/detectors.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two detection tiers and the vocabulary they share.
|
|
3
|
+
*
|
|
4
|
+
* Tier 1 is a synchronous table of prefix-anchored token formats, owned here
|
|
5
|
+
* because two of the three seams this plugin uses are synchronous:
|
|
6
|
+
* `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
|
|
7
|
+
* waterfall returns a record, neither of which can await. Tier 2 wraps
|
|
8
|
+
* `@secretlint/core`, which runs in-process with no subprocess but resolves a
|
|
9
|
+
* promise, so it is reachable only from `tools/pre-execute` and
|
|
10
|
+
* `tools/post-execute`.
|
|
11
|
+
*
|
|
12
|
+
* Neither tier ever returns the matched text. A {@link Detection} carries
|
|
13
|
+
* offsets; turning offsets into a keyed hash is `redaction.ts`'s job.
|
|
14
|
+
* @module dsh-dlp/detectors
|
|
15
|
+
*/
|
|
16
|
+
import { lintSource } from '@secretlint/core';
|
|
17
|
+
import { creator as recommendedPreset } from '@secretlint/secretlint-rule-preset-recommend';
|
|
18
|
+
/** Order two detections by position, so a caller can splice them left to right. */
|
|
19
|
+
function byPosition(left, right) {
|
|
20
|
+
return left.start - right.start || left.end - right.end;
|
|
21
|
+
}
|
|
22
|
+
/** Ascending comparison order for {@link Severity}. */
|
|
23
|
+
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
24
|
+
/**
|
|
25
|
+
* Position of a severity in the ordering.
|
|
26
|
+
* @param severity - the value to rank.
|
|
27
|
+
* @returns its index in the ascending order; higher means stricter.
|
|
28
|
+
*/
|
|
29
|
+
export function severityRank(severity) {
|
|
30
|
+
return SEVERITY_ORDER.indexOf(severity);
|
|
31
|
+
}
|
|
32
|
+
/** Severity at or above which the guard floor denies rather than only redacting. */
|
|
33
|
+
export const DENY_SEVERITY = 'high';
|
|
34
|
+
/**
|
|
35
|
+
* Tier 1's rule table. Deliberately narrow: only formats whose prefix or
|
|
36
|
+
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
37
|
+
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
38
|
+
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
39
|
+
*/
|
|
40
|
+
export const SYNC_RULES = [
|
|
41
|
+
{ id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
|
|
42
|
+
{ id: 'dsh-dlp/aws-secret-access-key', version: 1, severity: 'critical', pattern: /\baws_secret_access_key\b\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}["']?/gi },
|
|
43
|
+
{ id: 'dsh-dlp/github-token', version: 1, severity: 'critical', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,251}|github_pat_[A-Za-z0-9_]{22,251})\b/g },
|
|
44
|
+
{ id: 'dsh-dlp/slack-token', version: 1, severity: 'critical', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
45
|
+
{ id: 'dsh-dlp/stripe-secret-key', version: 1, severity: 'critical', pattern: /\b[sr]k_live_[A-Za-z0-9]{16,}\b/g },
|
|
46
|
+
{ id: 'dsh-dlp/anthropic-api-key', version: 1, severity: 'critical', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
47
|
+
{ id: 'dsh-dlp/openai-api-key', version: 1, severity: 'critical', pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/g },
|
|
48
|
+
{ id: 'dsh-dlp/google-api-key', version: 1, severity: 'critical', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
49
|
+
{ id: 'dsh-dlp/npm-token', version: 1, severity: 'critical', pattern: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
50
|
+
{ 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
|
+
{ 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
|
+
{ id: 'dsh-dlp/credential-url', version: 1, severity: 'high', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s/@]+@[^\s/]+/gi },
|
|
53
|
+
// Webhook URLs are bearer credentials whose path segment is the secret. They
|
|
54
|
+
// are in tier 1 rather than left to secretlint because the telemetry seam is
|
|
55
|
+
// synchronous and cannot reach tier 2 at all.
|
|
56
|
+
{ id: 'dsh-dlp/slack-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9/_+-]{10,}/g },
|
|
57
|
+
{ id: 'dsh-dlp/discord-webhook-url', version: 1, severity: 'critical', pattern: /\bhttps:\/\/(?:\w+\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]{10,}/g },
|
|
58
|
+
{ 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
|
+
{ 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
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Scan text with tier 1. Pure, synchronous, no I/O, and never capped: a table
|
|
63
|
+
* of anchored regular expressions costs a linear pass, so there is no reason
|
|
64
|
+
* to stop scanning where tier 2 has to. `truncated` is therefore always
|
|
65
|
+
* `false` here and only tier 2 can set it.
|
|
66
|
+
* @param text - the string to scan.
|
|
67
|
+
* @param rules - the rule table to apply; defaults to {@link SYNC_RULES}.
|
|
68
|
+
* @returns every match, ordered by start offset.
|
|
69
|
+
*/
|
|
70
|
+
export function scanSync(text, rules = SYNC_RULES) {
|
|
71
|
+
const detections = [];
|
|
72
|
+
for (const rule of rules) {
|
|
73
|
+
for (const match of text.matchAll(rule.pattern)) {
|
|
74
|
+
// `matchAll` on a global pattern always reports an index.
|
|
75
|
+
const start = match.index;
|
|
76
|
+
detections.push({
|
|
77
|
+
ruleId: rule.id,
|
|
78
|
+
ruleVersion: rule.version,
|
|
79
|
+
severity: rule.severity,
|
|
80
|
+
start,
|
|
81
|
+
end: start + match[0].length,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
detections.sort(byPosition);
|
|
86
|
+
return { detections, truncated: false };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Major version of the pinned `@secretlint/core` rule set, recorded as the
|
|
90
|
+
* rule version of every tier-2 detection. The exact dependency version is
|
|
91
|
+
* pinned in this package's manifest; the major is what changes a rule's
|
|
92
|
+
* meaning.
|
|
93
|
+
*/
|
|
94
|
+
export const SECRETLINT_RULE_VERSION = 13;
|
|
95
|
+
/** Config passed to every `lintSource` call; the preset registers its own child rules. */
|
|
96
|
+
const SECRETLINT_CONFIG = {
|
|
97
|
+
rules: [{ id: recommendedPreset.meta.id, rule: recommendedPreset }],
|
|
98
|
+
};
|
|
99
|
+
/** Map secretlint's message severity onto this plugin's ordering. */
|
|
100
|
+
function mapSecretlintSeverity(severity) {
|
|
101
|
+
/* v8 ignore next -- the recommended preset reports only `error`; the other arms serve rules a deployment adds. */
|
|
102
|
+
return severity === 'error' ? 'high' : severity === 'warning' ? 'medium' : 'low';
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Scan text with tier 2 (`@secretlint/core`, recommended preset). Runs
|
|
106
|
+
* in-process; no subprocess and no network.
|
|
107
|
+
*
|
|
108
|
+
* The reported spans are advisory. `@secretlint/secretlint-rule-aws` reports
|
|
109
|
+
* `[0, 40]` for `aws_secret_access_key = <40 chars>`, which covers the
|
|
110
|
+
* assignment prefix rather than the whole secret, so a caller must never
|
|
111
|
+
* splice a reported span directly — {@link redactText} expands every span to
|
|
112
|
+
* whitespace boundaries first.
|
|
113
|
+
* @param text - the string to scan.
|
|
114
|
+
* @param maxScanBytes - cap on scanned characters; input beyond it is not examined.
|
|
115
|
+
* @returns every match, ordered by start offset, and whether the input was capped.
|
|
116
|
+
*/
|
|
117
|
+
export async function scanWithSecretlint(text, maxScanBytes = Number.POSITIVE_INFINITY) {
|
|
118
|
+
const truncated = text.length > maxScanBytes;
|
|
119
|
+
const window = truncated ? text.slice(0, maxScanBytes) : text;
|
|
120
|
+
const result = await lintSource({
|
|
121
|
+
source: { filePath: '/dsh-dlp/scan.txt', content: window, ext: '.txt', contentType: 'text' },
|
|
122
|
+
options: { config: SECRETLINT_CONFIG, noPhysicFilePath: true },
|
|
123
|
+
});
|
|
124
|
+
const detections = result.messages.map((message) => ({
|
|
125
|
+
ruleId: message.ruleId,
|
|
126
|
+
ruleVersion: SECRETLINT_RULE_VERSION,
|
|
127
|
+
severity: mapSecretlintSeverity(message.severity),
|
|
128
|
+
start: message.range[0],
|
|
129
|
+
end: message.range[1],
|
|
130
|
+
}));
|
|
131
|
+
detections.sort(byPosition);
|
|
132
|
+
return { detections, truncated };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Run both tiers over one string and merge their detections. Tier 1 sees the
|
|
136
|
+
* whole string; only tier 2 is capped.
|
|
137
|
+
* @param text - the string to scan.
|
|
138
|
+
* @param rules - tier-1 rule table.
|
|
139
|
+
* @param maxScanBytes - cap on characters handed to tier 2.
|
|
140
|
+
* @returns the union of both tiers, ordered by start offset.
|
|
141
|
+
*/
|
|
142
|
+
export async function scanAll(text, rules = SYNC_RULES, maxScanBytes = Number.POSITIVE_INFINITY) {
|
|
143
|
+
const tier1 = scanSync(text, rules);
|
|
144
|
+
const tier2 = await scanWithSecretlint(text, maxScanBytes);
|
|
145
|
+
const detections = [...tier1.detections, ...tier2.detections];
|
|
146
|
+
detections.sort(byPosition);
|
|
147
|
+
return { detections, truncated: tier2.truncated };
|
|
148
|
+
}
|