dsh-dlp 0.3.0 → 0.4.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/README.md +130 -11
- 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 +136 -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 +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Data-loss prevention for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness),
|
|
4
4
|
built as an out-of-repo plugin.
|
|
5
5
|
|
|
6
|
-
It does
|
|
6
|
+
It does seven things:
|
|
7
7
|
|
|
8
8
|
1. **Denies credential-file access and secrets bound for the network** — unconditionally, from
|
|
9
9
|
`ctx.tools.guard()`. It tests the path-typed arguments of a call against a table of
|
|
@@ -19,7 +19,12 @@ It does six things:
|
|
|
19
19
|
plugin rewrote after the session log recorded it. Both are partial mitigations for defects
|
|
20
20
|
in the harness rather than in your configuration —
|
|
21
21
|
[see below](#mitigations-for-defects-in-the-harness-itself), including what they do not close.
|
|
22
|
-
6. **
|
|
22
|
+
6. **Asks before the agent writes a file that changes future behaviour** — agent settings and
|
|
23
|
+
hooks, `CLAUDE.md`, `.cursor/rules/**`, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI
|
|
24
|
+
workflows, shell startup files — and before it writes a `*_BASE_URL` that would redirect a
|
|
25
|
+
provider credential. This tier prompts rather than denying, and is neutralizable;
|
|
26
|
+
[see below](#behaviour-changing-config-paths).
|
|
27
|
+
7. **Writes an audit record for every decision** to its own sink — rule id, rule version,
|
|
23
28
|
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
24
29
|
`dsh-dlp report` reads that sink back.
|
|
25
30
|
|
|
@@ -248,7 +253,7 @@ checkout, build first and add the tarball:
|
|
|
248
253
|
```sh
|
|
249
254
|
git clone https://github.com/CharlotteN7/dsh-dlp && cd dsh-dlp
|
|
250
255
|
pnpm install && pnpm run build && pnpm pack
|
|
251
|
-
dsh plugin --profile <name> add ./dsh-dlp-0.
|
|
256
|
+
dsh plugin --profile <name> add ./dsh-dlp-0.3.0.tgz
|
|
252
257
|
```
|
|
253
258
|
|
|
254
259
|
## Configure
|
|
@@ -266,6 +271,7 @@ dsh plugin --profile <name> add ./dsh-dlp-0.1.0.tgz
|
|
|
266
271
|
telemetryRedaction: true
|
|
267
272
|
remoteImageNeutralization: true
|
|
268
273
|
redactTelemetryWorkspacePaths: true
|
|
274
|
+
configWriteAsk: true
|
|
269
275
|
```
|
|
270
276
|
|
|
271
277
|
`redactionKeyFile` is created on first mount with 32 random bytes at mode `0600`. Keep it out
|
|
@@ -295,7 +301,7 @@ addCredentialPaths:
|
|
|
295
301
|
addEgressTools: [acme_publish]
|
|
296
302
|
raiseSeverity:
|
|
297
303
|
dsh-dlp/secret-assignment: high
|
|
298
|
-
enable: [telemetryRedaction]
|
|
304
|
+
enable: [telemetryRedaction, configWriteAsk]
|
|
299
305
|
```
|
|
300
306
|
|
|
301
307
|
Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
|
|
@@ -328,6 +334,22 @@ directories (but not `.env.example`), anything under `.ssh/`,
|
|
|
328
334
|
documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
|
|
329
335
|
readable.
|
|
330
336
|
|
|
337
|
+
**Coding-agent and infrastructure credential stores**, which IronWorm's 44 packages and
|
|
338
|
+
SANDWORM_MODE name verbatim: an `auth.json` under `.codex/`, `Cursor/`, `.composer/`,
|
|
339
|
+
`.windsurf/`, `.continue/`, `.aider/`, `.claude/` or `.gemini/`; an `mcp.json` under any of the
|
|
340
|
+
same directories, because an MCP manifest carries each server's `env` and that is where its API
|
|
341
|
+
keys are written; Cursor's `state.vscdb` session database; anything under `Library/Keychains/`;
|
|
342
|
+
`*.tfvars` and `terraform.tfstate`, which hold provider credentials in plaintext.
|
|
343
|
+
|
|
344
|
+
**A home-level agent settings file is denied for writing only.** `~/.claude/settings.json`,
|
|
345
|
+
`~/.gemini/settings.json` and the equivalents for Codex, Cursor, Windsurf and Continue decide
|
|
346
|
+
how every future session in every repository behaves — this is where the Miasma worm put its
|
|
347
|
+
`SessionStart` hooks — so writing one is on the floor. Reading one is ordinary work, since a
|
|
348
|
+
user asking why their agent behaves a certain way is a normal request, so the rule is lifted for
|
|
349
|
+
a tool that provably cannot change anything. The **repository-local** copies of those same file
|
|
350
|
+
names are a different question with a different answer: see
|
|
351
|
+
[behaviour-changing config paths](#behaviour-changing-config-paths) below.
|
|
352
|
+
|
|
331
353
|
Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
|
|
332
354
|
|
|
333
355
|
**`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
|
|
@@ -382,6 +404,69 @@ need, or use a path that is not a credential store.
|
|
|
382
404
|
|
|
383
405
|
---
|
|
384
406
|
|
|
407
|
+
## Behaviour-changing config paths
|
|
408
|
+
|
|
409
|
+
Everything above governs **reads**. The dominant technique of 2026 is the opposite: the agent
|
|
410
|
+
*writes* a file that changes what happens next time. The Miasma worm put `SessionStart` hooks in
|
|
411
|
+
`.claude/settings.json` and `.gemini/settings.json`, an always-apply `.cursor/rules/setup.mdc`,
|
|
412
|
+
a `folderOpen` task in `.vscode/tasks.json` and a hijacked `npm test` into `Azure/durabletask`;
|
|
413
|
+
GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples over it, 39 of them
|
|
414
|
+
inside 38 seconds. See also CVE-2025-53773, CVE-2026-25725, CVE-2026-33068, CVE-2026-48124,
|
|
415
|
+
CVE-2026-26268 and CVE-2025-59041.
|
|
416
|
+
|
|
417
|
+
A write to one of these **asks the user first**:
|
|
418
|
+
|
|
419
|
+
| Rule | Paths |
|
|
420
|
+
|---|---|
|
|
421
|
+
| `config-agent-settings` | `.claude/settings*.json`, and the same under `.gemini/`, `.codex/`, `.cursor/`, `.windsurf/`, `.continue/` |
|
|
422
|
+
| `config-agent-hooks` | `.claude/hooks/**` and the same under the other agent directories |
|
|
423
|
+
| `config-agent-instructions` | `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.windsurfrules` |
|
|
424
|
+
| `config-agent-rules` | `.cursor/rules/**`, `.windsurf/rules/**`, `.continue/rules/**` |
|
|
425
|
+
| `config-mcp-manifest` | `.mcp.json` |
|
|
426
|
+
| `config-editor-tasks` | `.vscode/settings.json`, `.vscode/tasks.json`, `.vscode/launch.json` |
|
|
427
|
+
| `config-git` | `.git/config`, `.git/hooks/**` |
|
|
428
|
+
| `config-git-hooks-managed` | `.husky/**` |
|
|
429
|
+
| `config-ci-workflow` | `.github/workflows/**`, `.gitlab-ci.yml`, `.circleci/**` |
|
|
430
|
+
| `config-shell-rc` | `.bashrc`, `.bash_profile`, `.profile`, `.zshrc`, `.zprofile`, `.zshenv`, `.kshrc`, `config.fish`, … |
|
|
431
|
+
| `config-harness-bundle` | `cordis*.yml` |
|
|
432
|
+
| `config-api-base-url` | not a path — content setting a `*_BASE_URL` or `*_API_BASE` to an `http(s)` URL |
|
|
433
|
+
|
|
434
|
+
The last row is CVE-2026-21852: a repo-local settings file that sets `ANTHROPIC_BASE_URL` sends
|
|
435
|
+
the user's own API key to whatever host it names. That is neither a path nor a secret — it is a
|
|
436
|
+
config key whose *value* redirects a credential — so it is matched against the bytes the call
|
|
437
|
+
would write rather than against where they would go.
|
|
438
|
+
|
|
439
|
+
**Rules match by name, so creating a file is covered as well as changing one.** CVE-2026-25725
|
|
440
|
+
worked precisely because the path did not exist yet and was therefore writable with nothing to
|
|
441
|
+
prompt about.
|
|
442
|
+
|
|
443
|
+
**This tier is `ask`, and it is therefore neutralizable — unlike the floor.** That is deliberate
|
|
444
|
+
and it is the important sentence in this section. A developer asks the agent to edit `CLAUDE.md`
|
|
445
|
+
or add a workflow constantly; the guard floor is deny-only and non-overridable by design, so a
|
|
446
|
+
rule with that false-positive rate must not go there. It lives at `tools/pre-execute`, which
|
|
447
|
+
means a listener registered ahead of ours can return without calling `next()` and switch the
|
|
448
|
+
whole tier off. Treat it as a prompt, not as a control.
|
|
449
|
+
|
|
450
|
+
**A call the floor already denies is left to the floor**, and no prompt appears for it. Any
|
|
451
|
+
non-allow decision at `tools/pre-execute` skips guards entirely, so asking about a call the
|
|
452
|
+
guard would deny would replace an unconditional denial with a prompt a user can grant. That is
|
|
453
|
+
also why `~/.claude/settings.json` and a repository's own `.claude/settings.json` behave
|
|
454
|
+
differently: the first is on the floor, the second is a prompt.
|
|
455
|
+
|
|
456
|
+
Two more limits worth stating:
|
|
457
|
+
|
|
458
|
+
- **A shell redirection is not covered.** Only path-typed arguments are tested, and unlike the
|
|
459
|
+
floor the command line is not tokenised: a shell command cannot be told apart from a *read* of
|
|
460
|
+
the same file, and prompting on `cat .github/workflows/ci.yml` is exactly the false positive
|
|
461
|
+
that gets a tier switched off.
|
|
462
|
+
- **With no approval service mounted, the tier abstains rather than denying.** The registry
|
|
463
|
+
resolves an `ask` through `ctx.get('approval')` and degrades to a *denial* when nothing is
|
|
464
|
+
composed — which would turn this tier into the silent hard deny it was designed not to be. It
|
|
465
|
+
reports once on `process.stderr` and `ctx.logger` and lets the call through. `configWriteAsk:
|
|
466
|
+
false` turns it off entirely.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
385
470
|
## What gets redacted
|
|
386
471
|
|
|
387
472
|
A redacted region becomes:
|
|
@@ -430,10 +515,16 @@ the redacted copy.
|
|
|
430
515
|
Two tiers:
|
|
431
516
|
|
|
432
517
|
- **Tier 1**, synchronous and owned by this package: prefix-anchored token formats (AWS,
|
|
433
|
-
GitHub, Slack, Stripe, OpenAI, Anthropic, Google
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
518
|
+
GitHub, GitLab, Slack, Stripe, OpenAI, OpenRouter, Anthropic, Google API keys and
|
|
519
|
+
`GOCSPX-` OAuth client secrets, npm, HuggingFace, Groq, xAI, Databricks, SendGrid,
|
|
520
|
+
Supabase, Notion), PEM private-key blocks, JWTs, credential-bearing URLs,
|
|
521
|
+
Slack/Discord/Teams webhook URLs, and high-signal secret assignments. This is the tier the
|
|
522
|
+
guard and the telemetry listener use, because both of those seams are synchronous, and it is
|
|
523
|
+
never capped.
|
|
524
|
+
|
|
525
|
+
Prefix-anchored is the whole criterion for being in this tier, and the reason the table keeps
|
|
526
|
+
growing rather than deferring to tier 2 is the line below: **the telemetry seam cannot reach
|
|
527
|
+
tier 2**, so a format missing from tier 1 is exported in the clear when telemetry is on.
|
|
437
528
|
- **Tier 2**, [`@secretlint/core`](https://github.com/secretlint/secretlint) with the
|
|
438
529
|
recommended preset — 28 maintained rules, in-process, no subprocess. Used at
|
|
439
530
|
`tools/pre-execute` and `tools/post-execute`, the two seams that can await. **The telemetry
|
|
@@ -456,7 +547,9 @@ session titles — and never on the tool-result path.
|
|
|
456
547
|
| Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
|
|
457
548
|
| Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
|
|
458
549
|
| Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
|
|
459
|
-
| Variation selectors | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
|
|
550
|
+
| Variation selectors, 1–3 in a row | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
|
|
551
|
+
| Variation selectors, 4 or more in a row | the same class | replaced |
|
|
552
|
+
| Terminal control sequences | CSI, OSC, DCS, SOS, PM, APC, other `ESC` forms, C1 `U+0080–U+009F` | counted in tool results, **replaced in the audit sink** |
|
|
460
553
|
|
|
461
554
|
The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
|
|
462
555
|
alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
|
|
@@ -469,6 +562,30 @@ character is never turned into a denial. A replaced run becomes an ordinary plac
|
|
|
469
562
|
unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
|
|
470
563
|
delimiters, so the visible word it hid inside survives.
|
|
471
564
|
|
|
565
|
+
**Variation selectors are split by run length.** One selector picks a glyph — VS15/VS16 after a
|
|
566
|
+
base character, one selector after one ideograph in an Ideographic Variation Sequence — so an
|
|
567
|
+
isolated occurrence stays counted-only. A run of four or more is not glyph selection: it is a
|
|
568
|
+
byte string wearing the same code points, which is how GlassWorm hid executable JavaScript
|
|
569
|
+
across five waves, 35,800 installs, 300+ repositories and the first MCP package compromises. An
|
|
570
|
+
emoji ZWJ sequence separates its selectors with a joiner, so no legitimate sequence produces a
|
|
571
|
+
run at all; four is a conservative floor, and a real payload is hundreds of selectors long.
|
|
572
|
+
|
|
573
|
+
**Terminal control sequences are split by lane rather than by class.** A tool result carrying
|
|
574
|
+
SGR colour codes is the normal output of `git diff`, `rg` and `pytest`, so on that lane the
|
|
575
|
+
class is counted and left alone. On the lane that ends in an audit record it is **replaced**
|
|
576
|
+
with `[REDACTED:dsh-dlp:control-sequence]`, because a record is evidence and evidence must not
|
|
577
|
+
be able to rewrite itself: `JSON.stringify` escapes the byte in the file, but `dsh-dlp report`,
|
|
578
|
+
`jq -r` and every log viewer parse it back into a live escape, so a tool registered under a name
|
|
579
|
+
containing `ESC [ 1 A ESC [ 2 K` could overwrite the audit line describing it. The whole CSI
|
|
580
|
+
form is matched, not the SGR subset, along with OSC, DCS, SOS, PM, APC, the other escape forms
|
|
581
|
+
and the 8-bit C1 controls; an unterminated OSC is matched to the end of the string, because that
|
|
582
|
+
is how much of the display it would swallow. A repo-local `policyFile` whose rule `id` carries
|
|
583
|
+
one is rejected outright, since a rule id is quoted in the denial the user reads.
|
|
584
|
+
|
|
585
|
+
Not covered: a bare `\r`, `\b` or `\f` can still overprint a line on a terminal. Those have
|
|
586
|
+
ordinary uses in tool output and are escaped by `JSON.stringify` in the sink; the escape-driven
|
|
587
|
+
forms above are the ones with no benign use in a record.
|
|
588
|
+
|
|
472
589
|
**A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
|
|
473
590
|
`аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
|
|
474
591
|
confusable tables, which is a data set and a different cost class. This plugin does not attempt
|
|
@@ -530,8 +647,10 @@ its own identity.
|
|
|
530
647
|
}
|
|
531
648
|
```
|
|
532
649
|
|
|
533
|
-
`kind` is one of `guard-deny`, `pre-execute-deny`, `
|
|
534
|
-
`telemetry-redaction`, `assistant-image-neutralized`.
|
|
650
|
+
`kind` is one of `guard-deny`, `pre-execute-deny`, `pre-execute-ask`, `execution-mutation`,
|
|
651
|
+
`result-redaction`, `telemetry-redaction`, `assistant-image-neutralized`. A `pre-execute-ask`
|
|
652
|
+
record carries a top-level `ruleId` instead of `spans`: the finding is that a path names a
|
|
653
|
+
behaviour-changing file, not that any region of it matched. An `execution-mutation` record carries
|
|
535
654
|
`mutatedFields` and, when a tool substitution happened, the `originalTool` the log recorded. An
|
|
536
655
|
`assistant-image-neutralized` record carries `host` — the hostname of the blocked destination
|
|
537
656
|
and nothing else from the URL.
|
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,95 @@ 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, 35,800 installs and 300+ repositories, so a real
|
|
107
|
+
* payload is hundreds of selectors long and 4 is a conservative floor rather
|
|
108
|
+
* than a tight one.
|
|
109
|
+
*/
|
|
110
|
+
const VARIATION_SELECTOR_RUN = 4;
|
|
111
|
+
/**
|
|
112
|
+
* One terminal control sequence: the full CSI form, not only the SGR colour
|
|
113
|
+
* subset, plus the string-introducer families and the 8-bit C1 equivalents.
|
|
114
|
+
*
|
|
115
|
+
* Each alternative in order: an OSC/DCS/SOS/PM/APC introducer and its body up
|
|
116
|
+
* to a string terminator that may never arrive; a complete CSI — parameter
|
|
117
|
+
* bytes, intermediate bytes, one final byte; any other escape sequence; and a
|
|
118
|
+
* lone escape or C1 control that introduces nothing.
|
|
119
|
+
*
|
|
120
|
+
* Terminating at end of input matters: an unterminated OSC swallows everything
|
|
121
|
+
* a terminal prints after it, which is the whole trick, so the tail is part of
|
|
122
|
+
* the match rather than a miss.
|
|
123
|
+
*/
|
|
124
|
+
const CONTROL_SEQUENCE = [
|
|
125
|
+
String.raw `(?:\u001B[\]P^_X]|[\u0090\u0098\u009D\u009E\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)`,
|
|
126
|
+
String.raw `(?:\u001B\[|\u009B)[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]`,
|
|
127
|
+
String.raw `\u001B[\u0020-\u002F]*[\u0030-\u007E]`,
|
|
128
|
+
String.raw `[\u001B\u0080-\u009F]`,
|
|
129
|
+
].join('|');
|
|
130
|
+
/**
|
|
131
|
+
* Text substituted for a stripped control sequence. Visible on purpose: the
|
|
132
|
+
* lanes that strip are the ones an operator reads as evidence, and silently
|
|
133
|
+
* deleting the bytes would hide that a forgery was attempted.
|
|
134
|
+
*/
|
|
135
|
+
export const CONTROL_SEQUENCE_PLACEHOLDER = '[REDACTED:dsh-dlp:control-sequence]';
|
|
136
|
+
/**
|
|
137
|
+
* Remove every terminal control sequence from one string.
|
|
138
|
+
*
|
|
139
|
+
* This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
|
|
140
|
+
* lanes that must never carry forgeable bytes: an audit record and the strings
|
|
141
|
+
* an operator or an approval prompt reads back. Ordinary tool-result text takes
|
|
142
|
+
* the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
|
|
143
|
+
* colourise their output.
|
|
144
|
+
* @param text - the string to clean.
|
|
145
|
+
* @returns the string with each control sequence replaced by a visible marker.
|
|
146
|
+
*/
|
|
147
|
+
export function stripControlSequences(text) {
|
|
148
|
+
return text.replace(new RegExp(CONTROL_SEQUENCE, 'gu'), CONTROL_SEQUENCE_PLACEHOLDER);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Terminal control sequences in ordinary text.
|
|
152
|
+
*
|
|
153
|
+
* `report` rather than `strip`, deliberately: a tool result carrying SGR colour
|
|
154
|
+
* codes is the normal output of half the commands an agent runs, and replacing
|
|
155
|
+
* them would corrupt every one of those results. What the class buys on that
|
|
156
|
+
* lane is the count in the audit record.
|
|
157
|
+
*/
|
|
158
|
+
const CONTROL_SEQUENCE_RULE = {
|
|
159
|
+
id: 'dsh-dlp/control-sequence',
|
|
160
|
+
version: 1,
|
|
161
|
+
severity: 'medium',
|
|
162
|
+
action: 'report',
|
|
163
|
+
pattern: new RegExp(CONTROL_SEQUENCE, 'gu'),
|
|
164
|
+
};
|
|
65
165
|
/**
|
|
66
166
|
* Character classes that hide text from the reader while the model still reads
|
|
67
|
-
* it, verified against the Unicode character database
|
|
167
|
+
* it, verified against the Unicode character database, plus the terminal
|
|
168
|
+
* control sequences that show the reader something other than what is there.
|
|
68
169
|
*
|
|
69
170
|
* Every class is `medium`. These are injection *indicators*, not credentials:
|
|
70
171
|
* the guard floor denies at `high` and above, so an argument carrying one is
|
|
@@ -88,14 +189,22 @@ export const UNICODE_RULES = [
|
|
|
88
189
|
unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
|
|
89
190
|
// Bidi marks, unlike the overrides above, appear in real right-to-left text.
|
|
90
191
|
unicodeRule('dsh-dlp/unicode-bidi-mark', 'report', String.raw `\u{061C}\u{200E}\u{200F}`),
|
|
91
|
-
|
|
192
|
+
// An isolated selector is glyph selection and is left alone; a run of them
|
|
193
|
+
// is a byte string wearing the same code points.
|
|
194
|
+
unicodeRule('dsh-dlp/unicode-variation-selector', 'report', VARIATION_SELECTORS, `{1,${VARIATION_SELECTOR_RUN - 1}}`, true),
|
|
195
|
+
unicodeRule('dsh-dlp/unicode-variation-selector-run', 'strip', VARIATION_SELECTORS, `{${VARIATION_SELECTOR_RUN},}`),
|
|
196
|
+
CONTROL_SEQUENCE_RULE,
|
|
92
197
|
];
|
|
198
|
+
/** Classes whose matches are a run of one character class. */
|
|
199
|
+
const CHARACTER_RULES = UNICODE_RULES.filter(rule => rule.ranges !== undefined);
|
|
200
|
+
/** Classes whose matches have an ASCII body and are scanned over the whole input. */
|
|
201
|
+
const SEQUENCE_RULES = UNICODE_RULES.filter(rule => rule.ranges === undefined);
|
|
93
202
|
/**
|
|
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.
|
|
203
|
+
* One run of any character-class indicator. The scan is a single pass over the
|
|
204
|
+
* input with this pattern; the per-class patterns then run over the matched
|
|
205
|
+
* runs only, which are a handful of characters each.
|
|
97
206
|
*/
|
|
98
|
-
const UNICODE_RUN = new RegExp(`[${
|
|
207
|
+
const UNICODE_RUN = new RegExp(`[${[...new Set(CHARACTER_RULES.map(rule => rule.ranges))].join('')}]+`, 'gu');
|
|
99
208
|
/**
|
|
100
209
|
* Find every invisible or direction-changing character in one string.
|
|
101
210
|
*
|
|
@@ -107,20 +216,25 @@ const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('
|
|
|
107
216
|
*/
|
|
108
217
|
export function scanUnicode(text) {
|
|
109
218
|
const findings = [];
|
|
219
|
+
const found = (rule, start, length) => {
|
|
220
|
+
findings.push({
|
|
221
|
+
ruleId: rule.id,
|
|
222
|
+
ruleVersion: rule.version,
|
|
223
|
+
severity: rule.severity,
|
|
224
|
+
start,
|
|
225
|
+
end: start + length,
|
|
226
|
+
exact: true,
|
|
227
|
+
action: rule.action,
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
for (const rule of SEQUENCE_RULES) {
|
|
231
|
+
for (const match of text.matchAll(rule.pattern))
|
|
232
|
+
found(rule, match.index, match[0].length);
|
|
233
|
+
}
|
|
110
234
|
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
|
-
}
|
|
235
|
+
for (const rule of CHARACTER_RULES) {
|
|
236
|
+
for (const match of run[0].matchAll(rule.pattern))
|
|
237
|
+
found(rule, run.index + match.index, match[0].length);
|
|
124
238
|
}
|
|
125
239
|
}
|
|
126
240
|
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
|
package/lib/policy.js
CHANGED
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
* @module dsh-dlp/policy
|
|
16
16
|
*/
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
18
19
|
import { resolve } from 'node:path';
|
|
19
20
|
import { JSON_SCHEMA, load } from 'js-yaml';
|
|
20
21
|
import z from '@deepseek-ai/schemastery';
|
|
21
|
-
import { SYNC_RULES, severityRank } from "./detectors.js";
|
|
22
|
+
import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js";
|
|
22
23
|
import { resolveDshHome } from "./home.js";
|
|
23
|
-
import { CREDENTIAL_PATH_RULES } from "./paths.js";
|
|
24
|
+
import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
|
|
24
25
|
export const Config = z.object({
|
|
25
26
|
auditLog: z.string().required(),
|
|
26
27
|
redactionKeyFile: z.string().required(),
|
|
@@ -31,6 +32,7 @@ export const Config = z.object({
|
|
|
31
32
|
telemetryRedaction: z.boolean().default(true),
|
|
32
33
|
remoteImageNeutralization: z.boolean().default(true),
|
|
33
34
|
redactTelemetryWorkspacePaths: z.boolean().default(true),
|
|
35
|
+
configWriteAsk: z.boolean().default(true),
|
|
34
36
|
});
|
|
35
37
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
36
38
|
const ENABLEABLE = [
|
|
@@ -39,6 +41,7 @@ const ENABLEABLE = [
|
|
|
39
41
|
'telemetryRedaction',
|
|
40
42
|
'remoteImageNeutralization',
|
|
41
43
|
'redactTelemetryWorkspacePaths',
|
|
44
|
+
'configWriteAsk',
|
|
42
45
|
];
|
|
43
46
|
/** Keys a repo-local policy file may carry; anything else fails the load. */
|
|
44
47
|
const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
|
|
@@ -119,6 +122,12 @@ function parseCredentialPathEntry(node, index) {
|
|
|
119
122
|
if (typeof id !== 'string' || id.length === 0) {
|
|
120
123
|
throw new PolicyError(`addCredentialPaths[${index}].id must be a non-empty string`);
|
|
121
124
|
}
|
|
125
|
+
// A rule id is quoted verbatim in a model-facing denial and in every audit
|
|
126
|
+
// record the rule produces, and this file is attacker-controlled.
|
|
127
|
+
if (stripControlSequences(id) !== id) {
|
|
128
|
+
throw new PolicyError(`addCredentialPaths[${index}].id carries a terminal control sequence; a rule id is quoted in a denial`
|
|
129
|
+
+ ' the user reads and in the audit record, so it may not rewrite what is on screen');
|
|
130
|
+
}
|
|
122
131
|
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
123
132
|
throw new PolicyError(`addCredentialPaths[${index}].pattern must be a non-empty string`);
|
|
124
133
|
}
|
|
@@ -218,10 +227,6 @@ export function loadRepoPolicy(path) {
|
|
|
218
227
|
return { kind: 'invalid', problem: String(error) };
|
|
219
228
|
}
|
|
220
229
|
}
|
|
221
|
-
/** Escape one literal path so it can anchor a regular expression. */
|
|
222
|
-
function escapePattern(literal) {
|
|
223
|
-
return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
224
|
-
}
|
|
225
230
|
/**
|
|
226
231
|
* Deny rules protecting this plugin's own state and the harness home.
|
|
227
232
|
*
|
|
@@ -244,10 +249,10 @@ function escapePattern(literal) {
|
|
|
244
249
|
* @returns rules appended after the built-in table.
|
|
245
250
|
*/
|
|
246
251
|
function selfProtectionRules(config, dshHome) {
|
|
247
|
-
const home =
|
|
252
|
+
const home = escapePathPattern(resolve(dshHome));
|
|
248
253
|
return [
|
|
249
|
-
{ id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${
|
|
250
|
-
{ id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${
|
|
254
|
+
{ id: 'dsh-dlp/path-own-redaction-key', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.redactionKeyFile))}$`, 'i') },
|
|
255
|
+
{ id: 'dsh-dlp/path-own-audit-log', version: 1, pattern: new RegExp(`^${escapePathPattern(resolve(config.auditLog))}$`, 'i') },
|
|
251
256
|
{ id: 'dsh-dlp/path-dsh-sessions', version: 1, pattern: new RegExp(`^${home}/sessions(/|$)`, 'i') },
|
|
252
257
|
{ id: 'dsh-dlp/path-dsh-home', version: 2, enforcement: 'writes-only', pattern: new RegExp(`^${home}(/|$)`, 'i') },
|
|
253
258
|
];
|
|
@@ -263,6 +268,7 @@ export function resolvePolicy(config, repo) {
|
|
|
263
268
|
return {
|
|
264
269
|
credentialPathRules: [
|
|
265
270
|
...CREDENTIAL_PATH_RULES,
|
|
271
|
+
...homeCredentialPathRules(resolve(homedir())),
|
|
266
272
|
...selfProtectionRules(config, resolveDshHome()),
|
|
267
273
|
...repo?.addCredentialPaths ?? [],
|
|
268
274
|
],
|
|
@@ -277,5 +283,6 @@ export function resolvePolicy(config, repo) {
|
|
|
277
283
|
telemetryRedaction: enabled('telemetryRedaction'),
|
|
278
284
|
remoteImageNeutralization: enabled('remoteImageNeutralization'),
|
|
279
285
|
redactTelemetryWorkspacePaths: enabled('redactTelemetryWorkspacePaths'),
|
|
286
|
+
configWriteAsk: enabled('configWriteAsk'),
|
|
280
287
|
};
|
|
281
288
|
}
|
package/lib/sink.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { appendFileSync } from 'node:fs';
|
|
18
18
|
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import { stripControlSequences } from "./detectors.js";
|
|
19
20
|
/**
|
|
20
21
|
* Mint a decision id.
|
|
21
22
|
* @returns an id unique to one guard verdict or redaction pass.
|
|
@@ -25,6 +26,29 @@ export function newDecisionId() {
|
|
|
25
26
|
}
|
|
26
27
|
/** Payload version carried inside every record this plugin writes. */
|
|
27
28
|
export const RECORD_VERSION = 1;
|
|
29
|
+
/**
|
|
30
|
+
* One record with every string cleaned of terminal control sequences.
|
|
31
|
+
*
|
|
32
|
+
* A record carries strings this plugin did not author — a tool's registered
|
|
33
|
+
* name, a call id, a rule id from the repo-local policy tier — and a reader
|
|
34
|
+
* gets them back unescaped: `JSON.stringify` writes `\u001b` to the file, but
|
|
35
|
+
* `dsh-dlp report`, `jq -r` and any log viewer parse that back into a live
|
|
36
|
+
* escape. A tool named with a CSI sequence could then overwrite the line
|
|
37
|
+
* describing it, which is the forged-audit-record half of CVE-2026-35651.
|
|
38
|
+
* Cleaning here means every consumer of the file gets the cleaned form.
|
|
39
|
+
* @param value - any part of a record.
|
|
40
|
+
* @returns the same structure with control sequences replaced.
|
|
41
|
+
*/
|
|
42
|
+
function cleaned(value) {
|
|
43
|
+
if (typeof value === 'string')
|
|
44
|
+
return stripControlSequences(value);
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map(cleaned);
|
|
47
|
+
if (typeof value === 'object' && value !== null) {
|
|
48
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [stripControlSequences(key), cleaned(item)]));
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
28
52
|
/** Append-only JSONL sink for this plugin's decisions. */
|
|
29
53
|
export class AuditSink {
|
|
30
54
|
#path;
|
|
@@ -49,7 +73,7 @@ export class AuditSink {
|
|
|
49
73
|
*/
|
|
50
74
|
write(record) {
|
|
51
75
|
try {
|
|
52
|
-
appendFileSync(this.#path, `${JSON.stringify(record)}\n`);
|
|
76
|
+
appendFileSync(this.#path, `${JSON.stringify(cleaned(record))}\n`);
|
|
53
77
|
}
|
|
54
78
|
catch (error) {
|
|
55
79
|
this.#onFailure(error);
|
|
@@ -0,0 +1,89 @@
|
|
|
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 type { ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
20
|
+
/** What a behaviour-config rule is matched against. */
|
|
21
|
+
export type ConfigMatch =
|
|
22
|
+
/** A path-typed argument: the file the call would create or change. */
|
|
23
|
+
'path'
|
|
24
|
+
/** A content-typed argument: the bytes the call would write. */
|
|
25
|
+
| 'content';
|
|
26
|
+
/** One file, or one written value, that changes what happens next time. */
|
|
27
|
+
export interface ConfigWriteRule {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly version: number;
|
|
30
|
+
readonly match: ConfigMatch;
|
|
31
|
+
readonly pattern: RegExp;
|
|
32
|
+
/** What the file or value does, quoted in the prompt the user answers. */
|
|
33
|
+
readonly effect: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Paths whose contents change future behaviour, and the config key whose value
|
|
37
|
+
* redirects credentials.
|
|
38
|
+
*
|
|
39
|
+
* Every path here was used in the wild. The Miasma worm wrote `SessionStart`
|
|
40
|
+
* hooks into `.claude/settings.json` and `.gemini/settings.json`, an
|
|
41
|
+
* always-apply `.cursor/rules/setup.mdc`, a `folderOpen` task into
|
|
42
|
+
* `.vscode/tasks.json`, and a hijacked `npm test` into `Azure/durabletask`;
|
|
43
|
+
* GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples
|
|
44
|
+
* over it, 39 of them inside 38 seconds. See also CVE-2025-53773,
|
|
45
|
+
* CVE-2026-25725, CVE-2026-33068, CVE-2026-48124, CVE-2026-26268 and
|
|
46
|
+
* CVE-2025-59041.
|
|
47
|
+
*
|
|
48
|
+
* The rules match by name, never by what is on disk, so a file the call is
|
|
49
|
+
* about to *create* is matched exactly like one it would change:
|
|
50
|
+
* CVE-2026-25725 worked precisely because the path did not exist yet and was
|
|
51
|
+
* therefore writable without any prompt.
|
|
52
|
+
*/
|
|
53
|
+
export declare const CONFIG_WRITE_RULES: readonly ConfigWriteRule[];
|
|
54
|
+
/**
|
|
55
|
+
* Argument keys whose values are the bytes a call would write.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately separate from the floor's path-typed keys: the floor must never
|
|
58
|
+
* run its path table over file content, and this tier must run its one content
|
|
59
|
+
* rule over nothing else.
|
|
60
|
+
*/
|
|
61
|
+
export declare const CONTENT_ARGUMENT_KEYS: ReadonlySet<string>;
|
|
62
|
+
/**
|
|
63
|
+
* The strings one call would write.
|
|
64
|
+
* @param args - the pending call's parsed arguments.
|
|
65
|
+
* @returns every string under a content-typed key, at any depth.
|
|
66
|
+
*/
|
|
67
|
+
export declare function contentArguments(args: unknown): string[];
|
|
68
|
+
/** A call this tier wants a human to confirm. */
|
|
69
|
+
export interface ConfigWriteFinding {
|
|
70
|
+
readonly rule: ConfigWriteRule;
|
|
71
|
+
/** Model- and user-facing text; names the rule and what the file does, never the path. */
|
|
72
|
+
readonly reason: string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decide whether one call should be confirmed by a human.
|
|
76
|
+
*
|
|
77
|
+
* Only calls that can change something are examined: a tool
|
|
78
|
+
* {@link isReadOnlyTool} classifies as query-only cannot write a hook. Shell
|
|
79
|
+
* command lines are deliberately **not** tokenised here, unlike in the floor:
|
|
80
|
+
* a command line cannot be told apart from a read of the same path, and
|
|
81
|
+
* prompting on `cat .github/workflows/ci.yml` is exactly the false positive
|
|
82
|
+
* that gets a tier switched off. A shell redirection into one of these files
|
|
83
|
+
* is therefore not covered, which README.md says beside the feature.
|
|
84
|
+
* @param exec - the pending call.
|
|
85
|
+
* @param rules - the rule table; defaults to {@link CONFIG_WRITE_RULES}.
|
|
86
|
+
* @returns the finding, or `undefined` to leave the call alone.
|
|
87
|
+
*/
|
|
88
|
+
export declare function evaluateConfigWrite(exec: Pick<ToolExecution, 'name' | 'arguments'>, rules?: readonly ConfigWriteRule[]): ConfigWriteFinding | undefined;
|
|
89
|
+
//# sourceMappingURL=config-writes.d.ts.map
|
package/lib/types/detectors.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export interface SyncRule {
|
|
|
64
64
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
65
65
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
66
66
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
67
|
+
*
|
|
68
|
+
* Prefix-anchored is the whole membership criterion, and the reason this table
|
|
69
|
+
* keeps growing rather than deferring to tier 2: the `session-telemetry/record`
|
|
70
|
+
* waterfall is synchronous and cannot reach tier 2 at all, so a format missing
|
|
71
|
+
* here is exported in the clear when telemetry is on.
|
|
67
72
|
*/
|
|
68
73
|
export declare const SYNC_RULES: readonly SyncRule[];
|
|
69
74
|
/**
|
|
@@ -72,8 +77,14 @@ export declare const SYNC_RULES: readonly SyncRule[];
|
|
|
72
77
|
*
|
|
73
78
|
* `strip` classes have no legitimate use in tool output, so they are replaced
|
|
74
79
|
* like any other detection. `report` classes do: `U+200D` joins an emoji
|
|
75
|
-
* sequence
|
|
76
|
-
*
|
|
80
|
+
* sequence, a variation selector chooses a glyph, and a CSI sequence colours
|
|
81
|
+
* the output of `git diff`, so replacing them would corrupt ordinary text.
|
|
82
|
+
* They are counted and never rewritten.
|
|
83
|
+
*
|
|
84
|
+
* The split is per class and per lane both: {@link stripControlSequences}
|
|
85
|
+
* applies the `strip` treatment to a `report` class on the audit and
|
|
86
|
+
* approval-facing lanes, where colour buys nothing and a forged record costs
|
|
87
|
+
* everything.
|
|
77
88
|
*/
|
|
78
89
|
export type UnicodeAction = 'strip' | 'report';
|
|
79
90
|
/** One class of invisible or direction-changing characters. */
|
|
@@ -84,12 +95,37 @@ export interface UnicodeRule {
|
|
|
84
95
|
readonly action: UnicodeAction;
|
|
85
96
|
/** Global, unicode-flagged, matching one run of this class. */
|
|
86
97
|
readonly pattern: RegExp;
|
|
87
|
-
/**
|
|
88
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The class's ranges as a character-class body, for the combined run pattern.
|
|
100
|
+
*
|
|
101
|
+
* Absent for a class whose matches are not a run of one character class —
|
|
102
|
+
* a terminal control sequence has an ASCII body — which is scanned over the
|
|
103
|
+
* whole input instead of within a combined run.
|
|
104
|
+
*/
|
|
105
|
+
readonly ranges?: string;
|
|
89
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Text substituted for a stripped control sequence. Visible on purpose: the
|
|
109
|
+
* lanes that strip are the ones an operator reads as evidence, and silently
|
|
110
|
+
* deleting the bytes would hide that a forgery was attempted.
|
|
111
|
+
*/
|
|
112
|
+
export declare const CONTROL_SEQUENCE_PLACEHOLDER = "[REDACTED:dsh-dlp:control-sequence]";
|
|
113
|
+
/**
|
|
114
|
+
* Remove every terminal control sequence from one string.
|
|
115
|
+
*
|
|
116
|
+
* This is the `strip` half of {@link CONTROL_SEQUENCE_RULE}, applied on the
|
|
117
|
+
* lanes that must never carry forgeable bytes: an audit record and the strings
|
|
118
|
+
* an operator or an approval prompt reads back. Ordinary tool-result text takes
|
|
119
|
+
* the `report` half instead, because `git diff`, `rg` and `pytest` legitimately
|
|
120
|
+
* colourise their output.
|
|
121
|
+
* @param text - the string to clean.
|
|
122
|
+
* @returns the string with each control sequence replaced by a visible marker.
|
|
123
|
+
*/
|
|
124
|
+
export declare function stripControlSequences(text: string): string;
|
|
90
125
|
/**
|
|
91
126
|
* Character classes that hide text from the reader while the model still reads
|
|
92
|
-
* it, verified against the Unicode character database
|
|
127
|
+
* it, verified against the Unicode character database, plus the terminal
|
|
128
|
+
* control sequences that show the reader something other than what is there.
|
|
93
129
|
*
|
|
94
130
|
* Every class is `medium`. These are injection *indicators*, not credentials:
|
|
95
131
|
* the guard floor denies at `high` and above, so an argument carrying one is
|
package/lib/types/index.d.ts
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.
|
package/lib/types/paths.d.ts
CHANGED
|
@@ -40,6 +40,31 @@ export interface CredentialPathRule {
|
|
|
40
40
|
* authenticates with is agent-readable. That is the gap this table closes.
|
|
41
41
|
*/
|
|
42
42
|
export declare const CREDENTIAL_PATH_RULES: readonly CredentialPathRule[];
|
|
43
|
+
/**
|
|
44
|
+
* Escape one literal path so it can anchor a regular expression.
|
|
45
|
+
* @param literal - the path to quote.
|
|
46
|
+
* @returns the same text with every metacharacter escaped.
|
|
47
|
+
*/
|
|
48
|
+
export declare function escapePathPattern(literal: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Credential-path rules anchored at the user's home directory, resolved at
|
|
51
|
+
* mount because the directory is not known until then.
|
|
52
|
+
*
|
|
53
|
+
* A coding agent's *home* configuration decides how every future session in
|
|
54
|
+
* every repository behaves: the Miasma worm's `SessionStart` hooks went into
|
|
55
|
+
* exactly these files. Writing one is never ordinary repository work, so it is
|
|
56
|
+
* on the floor. Reading one is — a user asking the agent why its own
|
|
57
|
+
* configuration behaves a certain way is a normal request — so the rule is
|
|
58
|
+
* `writes-only` rather than `every-call`, unlike the `auth.json` and `mcp.json`
|
|
59
|
+
* stores in {@link CREDENTIAL_PATH_RULES}, which hold nothing but credentials.
|
|
60
|
+
*
|
|
61
|
+
* The *repository-local* copies of these same file names are a different
|
|
62
|
+
* question with a different answer: they are edited legitimately and often, so
|
|
63
|
+
* they sit in the neutralizable `ask` tier rather than on the floor.
|
|
64
|
+
* @param home - the user's home directory.
|
|
65
|
+
* @returns rules appended after the built-in table.
|
|
66
|
+
*/
|
|
67
|
+
export declare function homeCredentialPathRules(home: string): readonly CredentialPathRule[];
|
|
43
68
|
/**
|
|
44
69
|
* Normalize one candidate path for matching: Windows separators become
|
|
45
70
|
* forward slashes, surrounding quotes come off, `~` expands to a
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -37,10 +37,16 @@ export interface Config {
|
|
|
37
37
|
remoteImageNeutralization: boolean;
|
|
38
38
|
/** Whether telemetry's `session.cwd` attribute is replaced with a keyed hash. */
|
|
39
39
|
redactTelemetryWorkspacePaths: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a write to a behaviour-changing config path asks the user first.
|
|
42
|
+
* Needs an approval service; without one the tier abstains rather than
|
|
43
|
+
* letting an `ask` degrade into a denial.
|
|
44
|
+
*/
|
|
45
|
+
configWriteAsk: boolean;
|
|
40
46
|
}
|
|
41
47
|
export declare const Config: z<Config>;
|
|
42
48
|
/** Config toggles a repo-local policy may switch on, and never off. */
|
|
43
|
-
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths"];
|
|
49
|
+
declare const ENABLEABLE: readonly ["breadthTier", "resultRedaction", "telemetryRedaction", "remoteImageNeutralization", "redactTelemetryWorkspacePaths", "configWriteAsk"];
|
|
44
50
|
/** One toggle name a repo-local policy may name in `enable`. */
|
|
45
51
|
export type EnableableToggle = typeof ENABLEABLE[number];
|
|
46
52
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -63,6 +69,7 @@ export interface ResolvedPolicy {
|
|
|
63
69
|
readonly telemetryRedaction: boolean;
|
|
64
70
|
readonly remoteImageNeutralization: boolean;
|
|
65
71
|
readonly redactTelemetryWorkspacePaths: boolean;
|
|
72
|
+
readonly configWriteAsk: boolean;
|
|
66
73
|
}
|
|
67
74
|
/** Thrown when a policy file is malformed or attempts to loosen the policy. */
|
|
68
75
|
export declare class PolicyError extends Error {
|
package/lib/types/sink.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function newDecisionId(): DecisionId;
|
|
|
28
28
|
/** Payload version carried inside every record this plugin writes. */
|
|
29
29
|
export declare const RECORD_VERSION = 1;
|
|
30
30
|
/** What produced one audit record. */
|
|
31
|
-
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
31
|
+
export type AuditKind = 'guard-deny' | 'pre-execute-deny' | 'pre-execute-ask' | 'execution-mutation' | 'result-redaction' | 'telemetry-redaction' | 'assistant-image-neutralized' | 'audit-failure';
|
|
32
32
|
/** One durable record. Never carries matched secret text. */
|
|
33
33
|
export interface AuditRecord {
|
|
34
34
|
readonly v: number;
|
|
@@ -60,6 +60,12 @@ export interface AuditRecord {
|
|
|
60
60
|
* instruction is exactly the content this file must not repeat.
|
|
61
61
|
*/
|
|
62
62
|
readonly unicode?: Readonly<Record<string, number>>;
|
|
63
|
+
/**
|
|
64
|
+
* The single rule behind a decision that has no matched region to describe,
|
|
65
|
+
* which is every `pre-execute-ask`: the finding is that a path names a
|
|
66
|
+
* behaviour-changing file, not that any part of it matched a secret.
|
|
67
|
+
*/
|
|
68
|
+
readonly ruleId?: string;
|
|
63
69
|
/** Telemetry record channel, for `telemetry-redaction`. */
|
|
64
70
|
readonly channel?: string;
|
|
65
71
|
/** Fields another plugin rewrote after the call was logged, for `execution-mutation`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Data-loss-prevention plugin for DeepSeek Harness: a non-configurable tool guard floor, tool-result redaction, and fail-closed telemetry redaction",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivan Tyshchenko <nsof@protonmail.com>",
|