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 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.
@@ -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
@@ -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
+ }