dsh-dlp 0.1.0 → 0.2.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 +144 -19
- package/lib/cli.js +307 -0
- package/lib/detectors.js +87 -0
- package/lib/guard.js +9 -6
- package/lib/home.js +37 -0
- package/lib/index.js +31 -6
- package/lib/paths.js +38 -0
- package/lib/policy.js +15 -16
- package/lib/redaction.js +9 -1
- package/lib/results.js +25 -9
- package/lib/schema.js +99 -0
- package/lib/types/cli.d.ts +107 -0
- package/lib/types/detectors.d.ts +66 -0
- package/lib/types/guard.d.ts +6 -4
- package/lib/types/home.d.ts +31 -0
- package/lib/types/paths.d.ts +38 -0
- package/lib/types/policy.d.ts +0 -9
- package/lib/types/results.d.ts +15 -6
- package/lib/types/schema.d.ts +41 -0
- package/lib/types/sink.d.ts +6 -0
- package/package.json +4 -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 five 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
|
|
@@ -12,8 +12,12 @@ It does four things:
|
|
|
12
12
|
log records them, and withholds a result it cannot clean.
|
|
13
13
|
3. **Redacts secrets out of exported telemetry**, patching a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
14
14
|
ships message text, tool arguments, tool results and workspace paths in the clear.
|
|
15
|
-
4. **
|
|
15
|
+
4. **Strips the invisible characters that carry hidden instructions** out of tool results —
|
|
16
|
+
the Tags block and bidi overrides — and counts the classes it will not touch because they
|
|
17
|
+
also appear in legitimate text.
|
|
18
|
+
5. **Writes an audit record for every decision** to its own sink — rule id, rule version,
|
|
16
19
|
offsets, and a keyed hash. Never the secret, and never the path or command that matched.
|
|
20
|
+
`dsh-dlp report` reads that sink back.
|
|
17
21
|
|
|
18
22
|
---
|
|
19
23
|
|
|
@@ -43,12 +47,30 @@ More limits worth stating up front:
|
|
|
43
47
|
the session log and already presented to the model, so rewriting them would desynchronise
|
|
44
48
|
the log from what actually ran. Argument-level DLP here is *denial with a reason the model
|
|
45
49
|
can act on*.
|
|
46
|
-
- **
|
|
47
|
-
|
|
50
|
+
- **Already-logged history cannot be rewritten; a not-yet-logged inbound message can.** At
|
|
51
|
+
`llm/stream` the options are deep-frozen and `next()` takes no arguments, so a request the
|
|
52
|
+
agent has assembled goes out as it stands and a secret already in the conversation reaches
|
|
53
|
+
the provider. That is not the whole rule, though: `agent/pre-step` is an async waterfall
|
|
54
|
+
returning `{ kind: 'enter'; messages }`, and the only production append of `user/message`
|
|
55
|
+
happens *after* it, so a message arriving from outside can still be rewritten before it is
|
|
56
|
+
logged or presented. This release does not do that; it is recorded here because the earlier
|
|
57
|
+
flat claim that outbound redaction is impossible was too strong.
|
|
58
|
+
- **A redacted value is not restored when the agent runs a command.** `ctx.shellEnv` rebuilds a
|
|
59
|
+
trusted `DSH_*` namespace for every model shell call, which is a way to hand `bash` and
|
|
60
|
+
`pwsh` — and only those two — the real value behind a placeholder without the model ever
|
|
61
|
+
seeing it. Planned work, not implemented here.
|
|
48
62
|
- **Detection is pattern-based.** A password, an internal token format, or a customer record
|
|
49
63
|
has no recognisable structure and is not detected. Neither is any encoded form: base64,
|
|
50
64
|
hex, URL-escaping and reversal all pass both tiers, as does a secret split across two
|
|
51
|
-
content blocks.
|
|
65
|
+
content blocks. **A homoglyph defeats every rule in this package**, including the
|
|
66
|
+
invisible-character ones.
|
|
67
|
+
- **There is no entropy rule, and that was measured rather than assumed.** Shannon entropy is
|
|
68
|
+
bounded by log₂L for a string of length L, so a 20-character token cannot score above 4.32
|
|
69
|
+
bits per character however random it is. At the threshold where ordinary tool output —
|
|
70
|
+
hashes, minified bundles, base64 blobs, UUIDs — produces no false positives, the miss rate
|
|
71
|
+
is 100% for anything up to 22 characters, which is most of the credential formats worth
|
|
72
|
+
catching. A detector that fires on the long ones the prefix rules already catch and misses
|
|
73
|
+
the rest is not worth the false positives it costs.
|
|
52
74
|
|
|
53
75
|
The full list is in [PLAN.md §8](PLAN.md).
|
|
54
76
|
|
|
@@ -129,11 +151,11 @@ raiseSeverity:
|
|
|
129
151
|
enable: [telemetryRedaction]
|
|
130
152
|
```
|
|
131
153
|
|
|
132
|
-
Any other key, and any downgrade, makes the **whole file invalid**: it is
|
|
133
|
-
deployment's logger
|
|
134
|
-
`removeCredentialPaths`, and no way to redirect the audit sink. The file is
|
|
135
|
-
`js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather
|
|
136
|
-
execution, and it never goes near the Cordis loader.
|
|
154
|
+
Any other key, and any downgrade, makes the **whole file invalid**: it is reported on
|
|
155
|
+
`process.stderr` and the deployment's logger, then ignored, never obeyed in part. There is no
|
|
156
|
+
`disable`, no `removeCredentialPaths`, and no way to redirect the audit sink. The file is
|
|
157
|
+
parsed with `js-yaml` under `JSON_SCHEMA`, so a `!!js/function` tag is a parse error rather
|
|
158
|
+
than code execution, and it never goes near the Cordis loader.
|
|
137
159
|
|
|
138
160
|
A missing `policyFile` is not an error — it means the workspace ships no policy. The
|
|
139
161
|
recommended value is workspace-relative, so failing the mount would stop `dsh` from starting in
|
|
@@ -159,10 +181,24 @@ directories (but not `.env.example`), anything under `.ssh/`,
|
|
|
159
181
|
documentation extensions are excluded from that last rule, so `src/auth/token.ts` stays
|
|
160
182
|
readable.
|
|
161
183
|
|
|
162
|
-
Also denied: this plugin's own `redactionKeyFile` and `auditLog
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
Also denied for every tool: this plugin's own `redactionKeyFile` and `auditLog`.
|
|
185
|
+
|
|
186
|
+
**`$DSH_HOME` is split by direction.** Every *write* under the harness home is denied, for
|
|
187
|
+
every tool: editing a profile's `cordis.yml` mounts an arbitrary plugin, which is the exact
|
|
188
|
+
threat that makes the directory worth protecting. *Reads* are denied only where the contents
|
|
189
|
+
are credentials — `$DSH_HOME/.credentials.yaml`, `$DSH_HOME/sessions/**`, `$DSH_HOME/.env`,
|
|
190
|
+
this plugin's key file and audit log, and any `*.key` — so the installed plugin tree under
|
|
191
|
+
`profiles/node_modules/` and every profile manifest stay readable. A blanket read denial there
|
|
192
|
+
made debugging a plugin, reading a profile, and running the sibling `dsh-plugin-inspector`
|
|
193
|
+
against an installed tree impossible, with a message saying the denial could not be overridden.
|
|
194
|
+
|
|
195
|
+
Which side of that split a call lands on is decided by the tool's name, from a table of tools
|
|
196
|
+
that can only look: `read`, `read_image`, `glob`, `grep`, `lsp`, the session-query tools,
|
|
197
|
+
`job_list`, `job_output`, `terminal_list`, `terminal_read`, `list_agents`, `get_goal`.
|
|
198
|
+
Every other name — every shell, every editor, every `mcp__*` tool, and any tool this build has
|
|
199
|
+
never heard of — is treated as able to write, so a new tool is denied until it is classified.
|
|
200
|
+
A shell is never on the read side even for a command that only reads: a shell that can `cat` a
|
|
201
|
+
profile can also rewrite it.
|
|
166
202
|
|
|
167
203
|
Paths are normalised first — `..` traversal, `~`, Windows separators, quoting and a trailing
|
|
168
204
|
slash do not evade the table — and then resolved with `realpathSync`, so a symlink named
|
|
@@ -228,10 +264,13 @@ is the only decision that replaces the whole result, so it is the only way to dr
|
|
|
228
264
|
|
|
229
265
|
Two consequences worth knowing:
|
|
230
266
|
|
|
231
|
-
- Replacing a value re-validates it against the tool's `output.schema
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
267
|
+
- Replacing a value re-validates it against the tool's `output.schema`, and a schema that pins
|
|
268
|
+
that string — an `enum`, a `const`, a `oneOf` branch it selects — would reject the
|
|
269
|
+
placeholder. The plugin asks that question first and withholds the result with the message
|
|
270
|
+
above, rather than letting the registry raise a `ToolOutputError` that names a validation
|
|
271
|
+
failure and tells the model nothing it can act on. The call still fails; it fails
|
|
272
|
+
comprehensibly. Where the plugin cannot answer the question — no schema resolved, or a
|
|
273
|
+
schema whose own value it cannot validate — the registry decides as before.
|
|
235
274
|
- Redaction is per-detection, and each span grows to the nearest delimiter — whitespace,
|
|
236
275
|
quotes, `=`, `:`, `,`, brackets. A line of minified JSON loses the field that matched, not
|
|
237
276
|
the whole line.
|
|
@@ -258,6 +297,51 @@ A tool result is scanned twice: each of its strings on its own by tier 1, and al
|
|
|
258
297
|
joined by newlines through both tiers. The joined pass finds what no single string reproduces —
|
|
259
298
|
a PEM block arriving as one line per array element, which is exactly the shape `read` produces.
|
|
260
299
|
|
|
300
|
+
### Invisible characters
|
|
301
|
+
|
|
302
|
+
Tier 1 also looks for characters that hide text from the person reading a tool result while
|
|
303
|
+
the model still reads it. The harness strips directional controls in exactly one place —
|
|
304
|
+
session titles — and never on the tool-result path.
|
|
305
|
+
|
|
306
|
+
| Class | Code points | What happens |
|
|
307
|
+
|---|---|---|
|
|
308
|
+
| Tags block | `U+E0000–U+E007F` | replaced |
|
|
309
|
+
| Bidi overrides and isolates | `U+202A–U+202E`, `U+2066–U+2069` | replaced |
|
|
310
|
+
| Zero-width | `U+200B–U+200D`, `U+2060`, `U+FEFF` | counted only |
|
|
311
|
+
| Bidi marks | `U+061C`, `U+200E–U+200F` | counted only |
|
|
312
|
+
| Variation selectors | `U+FE00–U+FE0F`, `U+E0100–U+E01EF` | counted only |
|
|
313
|
+
|
|
314
|
+
The first two have no legitimate use in tool output — the Tags block is a full invisible ASCII
|
|
315
|
+
alphabet, which is what makes it the standard carrier for a hidden instruction. The last three
|
|
316
|
+
do: `U+200D` joins an emoji sequence and a variation selector picks a glyph, so replacing them
|
|
317
|
+
would corrupt ordinary text. They are counted in the audit record's `unicode` field and left
|
|
318
|
+
alone, as a `medium` finding.
|
|
319
|
+
|
|
320
|
+
Every class is `medium`, below the severity at which the guard floor denies, so an invisible
|
|
321
|
+
character is never turned into a denial. A replaced run becomes an ordinary placeholder and,
|
|
322
|
+
unlike a secret, is replaced exactly: an invisible character is not widened to its surrounding
|
|
323
|
+
delimiters, so the visible word it hid inside survives.
|
|
324
|
+
|
|
325
|
+
**A homoglyph defeats all of this**, and every other rule in this plugin. A Cyrillic `а` in
|
|
326
|
+
`аdmin` is a normal, visible, legitimately-encoded character; detecting it means UTS #39
|
|
327
|
+
confusable tables, which is a data set and a different cost class. This plugin does not attempt
|
|
328
|
+
it, and no rule here should be read as covering it.
|
|
329
|
+
|
|
330
|
+
Measured cost of the invisible-character scan over 512 KB, median of 30 runs on an i9-12900H
|
|
331
|
+
under Node 22.23.2:
|
|
332
|
+
|
|
333
|
+
| Input | Cost |
|
|
334
|
+
|---|---|
|
|
335
|
+
| clean Latin-1 text | 0.002 ms |
|
|
336
|
+
| one hidden instruction (69 characters) | 0.355 ms |
|
|
337
|
+
| 7,653 separate runs | 7.9 ms |
|
|
338
|
+
| 512 KB of alternating invisible characters (524,286 runs) | 56–113 ms |
|
|
339
|
+
|
|
340
|
+
Clean text is free because every character in the table is above `U+00FF`: the regular
|
|
341
|
+
expression engine rejects a Latin-1 string on its encoding without scanning it. The last row is
|
|
342
|
+
a crafted input, not a plausible one, and it is the only case that leaves the ≤10 ms per result
|
|
343
|
+
budget; `maxScanBytes` caps tier 2 only, so tier 1 always sees the whole result.
|
|
344
|
+
|
|
261
345
|
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
346
|
at 512 KB. `maxScanBytes` caps **tier 2 only**, once per result, over the joined rendering;
|
|
263
347
|
tier 1 always scans everything. When tier 2 saw less than the whole result the audit record
|
|
@@ -300,11 +384,52 @@ its own identity.
|
|
|
300
384
|
```
|
|
301
385
|
|
|
302
386
|
`kind` is one of `guard-deny`, `pre-execute-deny`, `result-redaction`, `telemetry-redaction`.
|
|
387
|
+
A `result-redaction` record may also carry `unicode`, a count of invisible-character runs per
|
|
388
|
+
class — counts only, because a hidden instruction is exactly the content this file must not
|
|
389
|
+
repeat. A record is written whenever there is something to say, including a result that was
|
|
390
|
+
only counted and a result whose tier-2 scan was truncated.
|
|
303
391
|
A record carries no free-text reason: the spans are the whole description of what matched, so
|
|
304
392
|
nothing built from a candidate path or command line can reach the file. An audit write failure
|
|
305
|
-
is
|
|
393
|
+
is reported and swallowed rather than turned into a denial: the sink is evidence, not
|
|
306
394
|
enforcement, and a full disk should not take the agent down.
|
|
307
395
|
|
|
396
|
+
Reported means `process.stderr` **and** `ctx.logger`, for that failure and for an invalid
|
|
397
|
+
policy file. The logger alone is not enough: its default exporter is an in-memory 1000-entry
|
|
398
|
+
ring buffer and no shipped bundle mounts a console exporter, so a message sent only there is
|
|
399
|
+
invisible on a stock install. `process.stderr` is what the headless runner itself writes to.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Reading the audit log
|
|
404
|
+
|
|
405
|
+
The package installs a `dsh-dlp` command that reads the JSONL sink and summarises it. It
|
|
406
|
+
imports nothing from the harness, so it runs wherever the package is installed, with no profile
|
|
407
|
+
and no `dsh` on the path:
|
|
408
|
+
|
|
409
|
+
```sh
|
|
410
|
+
dsh-dlp report # everything in $DSH_HOME/dsh-dlp.audit.jsonl
|
|
411
|
+
dsh-dlp report --since 24h # or an ISO timestamp
|
|
412
|
+
dsh-dlp report --session <id>
|
|
413
|
+
dsh-dlp report --would-have # only the calls that were let through
|
|
414
|
+
dsh-dlp report --log /var/log/dsh-dlp.audit.jsonl
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
It prints counts by decision, by rule, by tool and by invisible-character class, then the ten
|
|
418
|
+
most recent decisions. `--would-have` drops the denials and leaves the redactions and the
|
|
419
|
+
invisible-character findings: those are the calls that ran, with their results rewritten, and
|
|
420
|
+
they are what a policy that denied instead of rewriting would have blocked.
|
|
421
|
+
|
|
422
|
+
The sink is append-only and a run can be interrupted mid-append, so a line that does not parse
|
|
423
|
+
as a record is counted and reported rather than trusted. If the deployment set `auditLog` to
|
|
424
|
+
somewhere other than the default, pass `--log`; the command says which file it looked at.
|
|
425
|
+
|
|
426
|
+
A plugin installed into a profile puts its bin in that profile's `node_modules/.bin`, which is
|
|
427
|
+
not on `PATH`. Run it from there, or install the package globally:
|
|
428
|
+
|
|
429
|
+
```sh
|
|
430
|
+
"$DSH_HOME/profiles/<name>/node_modules/.bin/dsh-dlp" report
|
|
431
|
+
```
|
|
432
|
+
|
|
308
433
|
---
|
|
309
434
|
|
|
310
435
|
## Development
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dsh-dlp report` — read this plugin's audit JSONL and say what it decided.
|
|
4
|
+
*
|
|
5
|
+
* The sink is the only evidence a decision happened, and nothing read it: a
|
|
6
|
+
* user could not answer "what did this block today?". This command reads the
|
|
7
|
+
* file directly and imports nothing from the harness, so it runs wherever the
|
|
8
|
+
* package is installed, with no profile and no `dsh` on the path.
|
|
9
|
+
*
|
|
10
|
+
* The file is a durable boundary — written by an older version of this
|
|
11
|
+
* package, appended to under crash — so every line is parsed defensively and a
|
|
12
|
+
* line that is not a record is counted rather than trusted.
|
|
13
|
+
* @module dsh-dlp/cli
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { defaultAuditLog } from "./home.js";
|
|
18
|
+
/** Decision kinds that stopped a call, as opposed to rewriting its result. */
|
|
19
|
+
const DENYING_KINDS = new Set(['guard-deny', 'pre-execute-deny']);
|
|
20
|
+
/** How many decisions the report lists individually. */
|
|
21
|
+
const RECENT_LIMIT = 10;
|
|
22
|
+
/** Read one string field, or `undefined` when the line does not carry it. */
|
|
23
|
+
function stringField(record, key) {
|
|
24
|
+
const value = record[key];
|
|
25
|
+
return typeof value === 'string' ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Rule ids named by a record's spans, in file order and without repeats. */
|
|
28
|
+
function ruleIdsOf(record) {
|
|
29
|
+
const spans = record['spans'];
|
|
30
|
+
if (!Array.isArray(spans))
|
|
31
|
+
return [];
|
|
32
|
+
const ids = spans.flatMap((span) => {
|
|
33
|
+
if (typeof span !== 'object' || span === null)
|
|
34
|
+
return [];
|
|
35
|
+
const ruleId = span['ruleId'];
|
|
36
|
+
return typeof ruleId === 'string' ? [ruleId] : [];
|
|
37
|
+
});
|
|
38
|
+
return [...new Set(ids)];
|
|
39
|
+
}
|
|
40
|
+
/** Invisible-character counts a record carries, keeping only numeric entries. */
|
|
41
|
+
function unicodeOf(record) {
|
|
42
|
+
const counts = record['unicode'];
|
|
43
|
+
if (typeof counts !== 'object' || counts === null || Array.isArray(counts))
|
|
44
|
+
return {};
|
|
45
|
+
return Object.fromEntries(Object.entries(counts).flatMap(([key, value]) => typeof value === 'number' ? [[key, value]] : []));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse one JSONL line into the fields this command reports on.
|
|
49
|
+
* @param line - one line of the audit file.
|
|
50
|
+
* @returns the record, or `undefined` when the line is not one.
|
|
51
|
+
*/
|
|
52
|
+
export function parseRecord(line) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(line);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// A torn final line from an interrupted append is the expected cause.
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
62
|
+
return undefined;
|
|
63
|
+
const record = parsed;
|
|
64
|
+
const kind = stringField(record, 'kind');
|
|
65
|
+
const written = stringField(record, 'time');
|
|
66
|
+
if (kind === undefined || written === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
const time = Date.parse(written);
|
|
69
|
+
if (Number.isNaN(time))
|
|
70
|
+
return undefined;
|
|
71
|
+
const tool = stringField(record, 'tool');
|
|
72
|
+
const sessionId = stringField(record, 'sessionId');
|
|
73
|
+
return {
|
|
74
|
+
time,
|
|
75
|
+
kind,
|
|
76
|
+
...tool === undefined ? {} : { tool },
|
|
77
|
+
...sessionId === undefined ? {} : { sessionId },
|
|
78
|
+
ruleIds: ruleIdsOf(record),
|
|
79
|
+
unicode: unicodeOf(record),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Text printed for `--help` and alongside a usage error. */
|
|
83
|
+
export const USAGE = [
|
|
84
|
+
'Usage: dsh-dlp report [options]',
|
|
85
|
+
'',
|
|
86
|
+
'Reads the JSONL audit sink this plugin writes and summarises what it decided.',
|
|
87
|
+
'',
|
|
88
|
+
'Options:',
|
|
89
|
+
' --log <path> audit file to read (default: $DSH_HOME/dsh-dlp.audit.jsonl)',
|
|
90
|
+
' --since <when> only decisions at or after an ISO timestamp, or a span back',
|
|
91
|
+
' from now written as 30m, 24h or 7d',
|
|
92
|
+
' --session <id> only decisions from one session',
|
|
93
|
+
' --would-have only the decisions that let the call through: the redactions',
|
|
94
|
+
' and invisible-character findings, which is what a policy that',
|
|
95
|
+
' denied instead of rewriting would have blocked',
|
|
96
|
+
' -h, --help print this text',
|
|
97
|
+
].join('\n');
|
|
98
|
+
/** Milliseconds in one `--since` suffix; {@link parseSince} accepts no other. */
|
|
99
|
+
function spanUnitMs(unit) {
|
|
100
|
+
switch (unit) {
|
|
101
|
+
case 's': return 1000;
|
|
102
|
+
case 'm': return 60_000;
|
|
103
|
+
case 'h': return 3_600_000;
|
|
104
|
+
default: return 86_400_000;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Read `--since`: an ISO timestamp, or a span back from now.
|
|
109
|
+
* @param value - the argument as written.
|
|
110
|
+
* @param now - epoch milliseconds a relative span counts back from.
|
|
111
|
+
* @returns epoch milliseconds, or `undefined` when the value is neither.
|
|
112
|
+
*/
|
|
113
|
+
export function parseSince(value, now) {
|
|
114
|
+
if (/^\d+[smhd]$/.test(value))
|
|
115
|
+
return now - Number(value.slice(0, -1)) * spanUnitMs(value.slice(-1));
|
|
116
|
+
const absolute = Date.parse(value);
|
|
117
|
+
return Number.isNaN(absolute) ? undefined : absolute;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read the command line.
|
|
121
|
+
* @param argv - arguments after the program name.
|
|
122
|
+
* @param env - environment used for the default sink path.
|
|
123
|
+
* @param now - epoch milliseconds a relative `--since` counts back from.
|
|
124
|
+
* @returns what to run, or the usage error to print.
|
|
125
|
+
*/
|
|
126
|
+
export function parseArguments(argv, env, now) {
|
|
127
|
+
const [command, ...rest] = argv;
|
|
128
|
+
if (command === undefined || command === '-h' || command === '--help')
|
|
129
|
+
return { kind: 'help' };
|
|
130
|
+
if (command !== 'report')
|
|
131
|
+
return { kind: 'error', message: `dsh-dlp: unknown command ${JSON.stringify(command)}` };
|
|
132
|
+
let log = defaultAuditLog(env);
|
|
133
|
+
let since;
|
|
134
|
+
let session;
|
|
135
|
+
let wouldHave = false;
|
|
136
|
+
let consumed = false;
|
|
137
|
+
for (const [index, flag] of rest.entries()) {
|
|
138
|
+
if (consumed) {
|
|
139
|
+
consumed = false;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
switch (flag) {
|
|
143
|
+
case '--log':
|
|
144
|
+
case '--since':
|
|
145
|
+
case '--session': {
|
|
146
|
+
const value = rest[index + 1];
|
|
147
|
+
if (value === undefined)
|
|
148
|
+
return { kind: 'error', message: `dsh-dlp: ${flag} needs a value` };
|
|
149
|
+
consumed = true;
|
|
150
|
+
if (flag === '--log')
|
|
151
|
+
log = value;
|
|
152
|
+
else if (flag === '--session')
|
|
153
|
+
session = value;
|
|
154
|
+
else {
|
|
155
|
+
const parsed = parseSince(value, now);
|
|
156
|
+
if (parsed === undefined) {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'error',
|
|
159
|
+
message: `dsh-dlp: --since ${JSON.stringify(value)} is neither a timestamp nor a span like 24h`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
since = parsed;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case '--would-have':
|
|
167
|
+
wouldHave = true;
|
|
168
|
+
break;
|
|
169
|
+
case '-h':
|
|
170
|
+
case '--help':
|
|
171
|
+
return { kind: 'help' };
|
|
172
|
+
default:
|
|
173
|
+
return { kind: 'error', message: `dsh-dlp: unknown option ${JSON.stringify(flag)}` };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
kind: 'report',
|
|
178
|
+
options: {
|
|
179
|
+
log,
|
|
180
|
+
...since === undefined ? {} : { since },
|
|
181
|
+
...session === undefined ? {} : { session },
|
|
182
|
+
wouldHave,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Read and parse the audit file.
|
|
188
|
+
* @param path - the file to read.
|
|
189
|
+
* @returns its records, its absence, or the problem to print.
|
|
190
|
+
*/
|
|
191
|
+
export function readAuditFile(path) {
|
|
192
|
+
let text;
|
|
193
|
+
try {
|
|
194
|
+
text = readFileSync(path, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code === 'ENOENT')
|
|
198
|
+
return { kind: 'absent' };
|
|
199
|
+
return { kind: 'unreadable', problem: `dsh-dlp: cannot read ${path}: ${String(error)}` };
|
|
200
|
+
}
|
|
201
|
+
const lines = text.split('\n').filter(line => line.trim().length > 0);
|
|
202
|
+
const records = lines.flatMap((line) => {
|
|
203
|
+
const record = parseRecord(line);
|
|
204
|
+
return record === undefined ? [] : [record];
|
|
205
|
+
});
|
|
206
|
+
return { kind: 'read', records, unreadable: lines.length - records.length };
|
|
207
|
+
}
|
|
208
|
+
/** Count each label over the records, most frequent first. */
|
|
209
|
+
function tally(records, label) {
|
|
210
|
+
const counts = new Map();
|
|
211
|
+
for (const record of records) {
|
|
212
|
+
for (const key of label(record))
|
|
213
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
214
|
+
}
|
|
215
|
+
return [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
|
|
216
|
+
}
|
|
217
|
+
/** One `name count` line per entry, padded so the column lines up. */
|
|
218
|
+
function countLines(entries) {
|
|
219
|
+
const width = Math.max(...entries.map(([name]) => name.length));
|
|
220
|
+
return entries.map(([name, count]) => ` ${name.padEnd(width)} ${count}`);
|
|
221
|
+
}
|
|
222
|
+
/** A heading and its counts, or nothing when there are none. */
|
|
223
|
+
function section(heading, entries) {
|
|
224
|
+
return entries.length === 0 ? [] : ['', heading, ...countLines(entries)];
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Render the report.
|
|
228
|
+
* @param records - every record the file yielded.
|
|
229
|
+
* @param unreadable - how many of its lines were not records.
|
|
230
|
+
* @param options - the filters the invocation asked for.
|
|
231
|
+
* @returns the lines to print.
|
|
232
|
+
*/
|
|
233
|
+
export function formatReport(records, unreadable, options) {
|
|
234
|
+
const selected = records.filter((record) => {
|
|
235
|
+
if (options.since !== undefined && record.time < options.since)
|
|
236
|
+
return false;
|
|
237
|
+
if (options.session !== undefined && record.sessionId !== options.session)
|
|
238
|
+
return false;
|
|
239
|
+
if (options.wouldHave && DENYING_KINDS.has(record.kind))
|
|
240
|
+
return false;
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
const lines = [`dsh-dlp: ${selected.length} decision(s) in ${options.log}`];
|
|
244
|
+
if (options.since !== undefined)
|
|
245
|
+
lines.push(` since ${new Date(options.since).toISOString()}`);
|
|
246
|
+
if (options.session !== undefined)
|
|
247
|
+
lines.push(` session ${options.session}`);
|
|
248
|
+
if (options.wouldHave)
|
|
249
|
+
lines.push(' only decisions that let the call through');
|
|
250
|
+
if (unreadable > 0)
|
|
251
|
+
lines.push(` ${unreadable} line(s) were not readable as records`);
|
|
252
|
+
if (selected.length === 0)
|
|
253
|
+
return lines;
|
|
254
|
+
lines.push(...section('by decision', tally(selected, record => [record.kind])), ...section('by rule', tally(selected, record => record.ruleIds)), ...section('by tool', tally(selected, record => record.tool === undefined ? [] : [record.tool])), ...section('results carrying invisible characters', tally(selected, record => Object.keys(record.unicode))));
|
|
255
|
+
const recent = [...selected].sort((left, right) => right.time - left.time).slice(0, RECENT_LIMIT);
|
|
256
|
+
lines.push('', `most recent ${recent.length}`);
|
|
257
|
+
for (const record of recent) {
|
|
258
|
+
const rules = record.ruleIds.length === 0 ? '-' : record.ruleIds.join(', ');
|
|
259
|
+
lines.push(` ${new Date(record.time).toISOString()} ${record.kind} ${record.tool ?? '-'} ${rules}`);
|
|
260
|
+
}
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Run one invocation.
|
|
265
|
+
* @param argv - arguments after the program name.
|
|
266
|
+
* @param write - receives each line of output.
|
|
267
|
+
* @param fail - receives each line of error output.
|
|
268
|
+
* @param env - environment used for the default sink path.
|
|
269
|
+
* @param now - epoch milliseconds a relative `--since` counts back from.
|
|
270
|
+
* @returns the process exit code.
|
|
271
|
+
*/
|
|
272
|
+
export function main(argv, write, fail, env = process.env, now = Date.now()) {
|
|
273
|
+
const invocation = parseArguments(argv, env, now);
|
|
274
|
+
if (invocation.kind === 'help') {
|
|
275
|
+
write(USAGE);
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
if (invocation.kind === 'error') {
|
|
279
|
+
fail(invocation.message);
|
|
280
|
+
fail(USAGE);
|
|
281
|
+
return 2;
|
|
282
|
+
}
|
|
283
|
+
const file = readAuditFile(invocation.options.log);
|
|
284
|
+
switch (file.kind) {
|
|
285
|
+
case 'absent':
|
|
286
|
+
write(`dsh-dlp: no audit file at ${invocation.options.log}`);
|
|
287
|
+
write('Nothing has been recorded yet, or the deployment set `auditLog` elsewhere — pass --log <path>.');
|
|
288
|
+
return 0;
|
|
289
|
+
case 'unreadable':
|
|
290
|
+
fail(file.problem);
|
|
291
|
+
return 1;
|
|
292
|
+
case 'read':
|
|
293
|
+
for (const line of formatReport(file.records, file.unreadable, invocation.options))
|
|
294
|
+
write(line);
|
|
295
|
+
return 0;
|
|
296
|
+
/* v8 ignore next 4 -- unreachable while `AuditFileRead` stays closed; the arm exists so adding a variant fails the build. */
|
|
297
|
+
default: {
|
|
298
|
+
const unhandled = file;
|
|
299
|
+
throw new TypeError(`dsh-dlp: unhandled audit file read ${JSON.stringify(unhandled)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/* v8 ignore start -- the process entry, exercised by tests/e2e/report.e2e.ts against the built CLI rather than by the instrumented unit run. */
|
|
304
|
+
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
305
|
+
process.exitCode = main(process.argv.slice(2), line => process.stdout.write(`${line}\n`), line => process.stderr.write(`${line}\n`));
|
|
306
|
+
}
|
|
307
|
+
/* v8 ignore stop */
|
package/lib/detectors.js
CHANGED
|
@@ -58,11 +58,94 @@ export const SYNC_RULES = [
|
|
|
58
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
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
60
|
];
|
|
61
|
+
/** Build one class's run pattern from its ranges, so the two cannot drift apart. */
|
|
62
|
+
function unicodeRule(id, action, ranges) {
|
|
63
|
+
return { id, version: 1, severity: 'medium', action, ranges, pattern: new RegExp(`[${ranges}]+`, 'gu') };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Character classes that hide text from the reader while the model still reads
|
|
67
|
+
* it, verified against the Unicode character database.
|
|
68
|
+
*
|
|
69
|
+
* Every class is `medium`. These are injection *indicators*, not credentials:
|
|
70
|
+
* the guard floor denies at `high` and above, so an argument carrying one is
|
|
71
|
+
* never denied on that basis. What they buy is a redaction and an audit record
|
|
72
|
+
* on a path the harness does not cover — it strips directional controls in
|
|
73
|
+
* exactly one place, session titles, and never on the tool-result path.
|
|
74
|
+
*
|
|
75
|
+
* Not attempted here: UTS #39 confusables. A Cyrillic `а` needs a data table
|
|
76
|
+
* to detect and is a different cost class, and it defeats every rule in this
|
|
77
|
+
* file. README.md says so rather than implying coverage.
|
|
78
|
+
*/
|
|
79
|
+
export const UNICODE_RULES = [
|
|
80
|
+
// Tags block: a full ASCII alphabet with no rendering, the standard carrier
|
|
81
|
+
// for instructions meant for the model and not for the reader.
|
|
82
|
+
unicodeRule('dsh-dlp/unicode-tag-characters', 'strip', String.raw `\u{E0000}-\u{E007F}`),
|
|
83
|
+
// Bidi overrides and isolates reorder what is displayed without changing the
|
|
84
|
+
// characters a model reads.
|
|
85
|
+
unicodeRule('dsh-dlp/unicode-bidi-override', 'strip', String.raw `\u{202A}-\u{202E}\u{2066}-\u{2069}`),
|
|
86
|
+
// U+200D joins legitimate emoji sequences, so stripping this class has a
|
|
87
|
+
// real false positive.
|
|
88
|
+
unicodeRule('dsh-dlp/unicode-zero-width', 'report', String.raw `\u{200B}-\u{200D}\u{2060}\u{FEFF}`),
|
|
89
|
+
// Bidi marks, unlike the overrides above, appear in real right-to-left text.
|
|
90
|
+
unicodeRule('dsh-dlp/unicode-bidi-mark', 'report', String.raw `\u{061C}\u{200E}\u{200F}`),
|
|
91
|
+
unicodeRule('dsh-dlp/unicode-variation-selector', 'report', String.raw `\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}`),
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* One run of any indicator class. The scan is a single pass over the input
|
|
95
|
+
* with this pattern; the per-class patterns then run over the matched runs
|
|
96
|
+
* only, which are a handful of characters each.
|
|
97
|
+
*/
|
|
98
|
+
const UNICODE_RUN = new RegExp(`[${UNICODE_RULES.map(rule => rule.ranges).join('')}]+`, 'gu');
|
|
99
|
+
/**
|
|
100
|
+
* Find every invisible or direction-changing character in one string.
|
|
101
|
+
*
|
|
102
|
+
* Offsets are UTF-16 indices into `text`, so a caller can splice them
|
|
103
|
+
* directly; they are exact rather than advisory, and {@link Detection.exact}
|
|
104
|
+
* says so.
|
|
105
|
+
* @param text - the string to scan.
|
|
106
|
+
* @returns every indicator run, ordered by start offset.
|
|
107
|
+
*/
|
|
108
|
+
export function scanUnicode(text) {
|
|
109
|
+
const findings = [];
|
|
110
|
+
for (const run of text.matchAll(UNICODE_RUN)) {
|
|
111
|
+
for (const rule of UNICODE_RULES) {
|
|
112
|
+
for (const match of run[0].matchAll(rule.pattern)) {
|
|
113
|
+
const start = run.index + match.index;
|
|
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
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
findings.sort(byPosition);
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* How many runs of each indicator class one string carries.
|
|
131
|
+
* @param text - the string to scan.
|
|
132
|
+
* @returns a count per rule id; absent means none were found.
|
|
133
|
+
*/
|
|
134
|
+
export function countUnicodeIndicators(text) {
|
|
135
|
+
const counts = {};
|
|
136
|
+
for (const finding of scanUnicode(text)) {
|
|
137
|
+
counts[finding.ruleId] = (counts[finding.ruleId] ?? 0) + 1;
|
|
138
|
+
}
|
|
139
|
+
return counts;
|
|
140
|
+
}
|
|
61
141
|
/**
|
|
62
142
|
* Scan text with tier 1. Pure, synchronous, no I/O, and never capped: a table
|
|
63
143
|
* of anchored regular expressions costs a linear pass, so there is no reason
|
|
64
144
|
* to stop scanning where tier 2 has to. `truncated` is therefore always
|
|
65
145
|
* `false` here and only tier 2 can set it.
|
|
146
|
+
*
|
|
147
|
+
* The `strip` half of {@link UNICODE_RULES} is included, so every seam reading
|
|
148
|
+
* tier 1 — including the synchronous telemetry waterfall — gets it.
|
|
66
149
|
* @param text - the string to scan.
|
|
67
150
|
* @param rules - the rule table to apply; defaults to {@link SYNC_RULES}.
|
|
68
151
|
* @returns every match, ordered by start offset.
|
|
@@ -82,6 +165,10 @@ export function scanSync(text, rules = SYNC_RULES) {
|
|
|
82
165
|
});
|
|
83
166
|
}
|
|
84
167
|
}
|
|
168
|
+
for (const finding of scanUnicode(text)) {
|
|
169
|
+
if (finding.action === 'strip')
|
|
170
|
+
detections.push(finding);
|
|
171
|
+
}
|
|
85
172
|
detections.sort(byPosition);
|
|
86
173
|
return { detections, truncated: false };
|
|
87
174
|
}
|