dsh-dlp 0.9.0 → 0.10.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 +36 -15
- package/cordis.patch.yml +6 -0
- package/lib/detectors.js +152 -9
- package/lib/policy.js +41 -1
- package/lib/steps.js +11 -4
- package/lib/types/detectors.d.ts +49 -8
- package/lib/types/policy.d.ts +38 -1
- package/lib/types/steps.d.ts +10 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,32 +18,36 @@ built as an out-of-repo plugin.
|
|
|
18
18
|
the inbox that the user did not type (a `dsh-webhook` delivery's third-party payload, a
|
|
19
19
|
settled subagent result, an agent relay). All of it reaches the model and the durable log
|
|
20
20
|
through `agent/pre-step` without ever being a tool result. A message whose `source.kind` is
|
|
21
|
-
`user` is exempt
|
|
22
|
-
this plugin
|
|
23
|
-
|
|
24
|
-
webhook incident needs to read
|
|
21
|
+
`user` is exempt below `aggressiveness: high`; at `high` the user's own typed prompt is
|
|
22
|
+
redacted too, because this plugin cannot know which provider the request is bound for. The
|
|
23
|
+
`agent/inbox/spliced` delivery record keeps a delivery's original text, which is deliberate —
|
|
24
|
+
it derives no model message, and an operator investigating a webhook incident needs to read
|
|
25
|
+
what was actually delivered.
|
|
25
26
|
4. **Redacts secrets out of exported telemetry**, closing a hole where `DSH_TELEMETRY_MODE=FULL`
|
|
26
27
|
ships message text, tool arguments, results and workspace paths in the clear.
|
|
27
|
-
5. **
|
|
28
|
+
5. **Detects payment card numbers** — issuer range, a length that issuer assigns, and a Luhn
|
|
29
|
+
check digit — so cardholder data does not reach a third-party model in a tool result, a
|
|
30
|
+
spliced file, exported telemetry, or (at `aggressiveness: high`) a prompt someone typed.
|
|
31
|
+
6. **Strips invisible characters that carry hidden instructions** — the Tags block, bidi
|
|
28
32
|
overrides, runs of variation selectors — and strips terminal control sequences from the audit
|
|
29
33
|
lane so a tool result cannot forge its own audit record.
|
|
30
|
-
|
|
34
|
+
7. **Neutralises remote markdown images in assistant output** and detects a tool call another
|
|
31
35
|
plugin rewrote after the session log recorded it.
|
|
32
|
-
|
|
36
|
+
8. **Asks before the agent writes a file that changes future behaviour** — agent settings and
|
|
33
37
|
hooks, `CLAUDE.md`, `.claude/rules/**` and the other agent rules directories, prompt
|
|
34
38
|
templates, `.vscode/tasks.json`, `.mcp.json`, git hooks, CI workflows, shell startup files,
|
|
35
39
|
`pnpm-workspace.yaml` — and before it writes a `*_BASE_URL` that would redirect a provider
|
|
36
40
|
credential.
|
|
37
|
-
|
|
41
|
+
9. **Asks before a call switches off its own confirmation** — `non_interactive: true`,
|
|
38
42
|
`approval_mode: auto`, an `apply` whose approval is still pending. Both `ask` tiers are
|
|
39
43
|
prompts rather than controls: they live at `tools/pre-execute`, they can be neutralised, and
|
|
40
44
|
they abstain wherever the approval seam prompts nobody — which includes every install under
|
|
41
45
|
`DSH_PERMISSION_MODE=danger-full-access` and a stock headless install under any mode.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
10. **Writes an audit record for every decision.** A redaction or denial names the rule, its
|
|
47
|
+
version, the offsets and a keyed hash; the three kinds with no matched region to describe —
|
|
48
|
+
an ask, a rewritten call, a neutralised image — carry a rule id, the changed field names or
|
|
49
|
+
the destination hostname instead. Never the secret, never the path or command that matched.
|
|
50
|
+
`dsh-dlp report` reads it back.
|
|
47
51
|
|
|
48
52
|
## What this is not
|
|
49
53
|
|
|
@@ -69,7 +73,9 @@ Three limits worth knowing before you rely on it:
|
|
|
69
73
|
control.**
|
|
70
74
|
- **Detection is pattern-based.** No entropy rule (measured, not assumed: the lowest
|
|
71
75
|
false-positive-free threshold cannot flag anything shorter than 64–66 characters). Encoded
|
|
72
|
-
forms pass. A homoglyph defeats every rule in this package.
|
|
76
|
+
forms pass. A homoglyph defeats every rule in this package. The card rule is Luhn-validated
|
|
77
|
+
and range-checked and found nothing across 272,635 lines of real source and docs, but
|
|
78
|
+
a *uniformly random* 16-digit number trips it 2.7% of the time and Maestro is not covered.
|
|
73
79
|
|
|
74
80
|
[The full list of limits →](https://charlotten7.github.io/dsh-dlp/)
|
|
75
81
|
|
|
@@ -105,6 +111,7 @@ load.
|
|
|
105
111
|
auditLog: /var/log/dsh-dlp.audit.jsonl
|
|
106
112
|
redactionKeyFile: /var/lib/dsh/dsh-dlp.redaction-key
|
|
107
113
|
policyFile: ./.dsh-dlp.yml # optional, lowest trust
|
|
114
|
+
aggressiveness: medium # low | medium | high
|
|
108
115
|
breadthTier: true
|
|
109
116
|
resultRedaction: true
|
|
110
117
|
telemetryRedaction: true
|
|
@@ -118,10 +125,24 @@ load.
|
|
|
118
125
|
version control — it is what makes a placeholder's hash keyed rather than a bare digest anyone
|
|
119
126
|
holding a candidate secret could confirm.
|
|
120
127
|
|
|
128
|
+
**`aggressiveness` is one word for how far redaction reaches.** `low` guarantees nothing and lets
|
|
129
|
+
each toggle stand alone; `medium` — the default — guarantees every pass is on and that no toggle
|
|
130
|
+
can take one away; `high` adds the user's own typed prompt to what is redacted. It composes with
|
|
131
|
+
the toggles rather than overriding them: at `medium` and `high` a toggle set to `false`
|
|
132
|
+
contradicts the level and **fails the mount** with the fix in the message, rather than one setting
|
|
133
|
+
quietly beating the other.
|
|
134
|
+
|
|
135
|
+
> **Upgrading from 0.9.0.** The default `medium` matches the shipped toggle defaults exactly, so
|
|
136
|
+
> an install that never wrote a toggle is unchanged. An install that set any redaction toggle to
|
|
137
|
+
> `false` now refuses to mount; add `aggressiveness: low` to the same row and it means what it
|
|
138
|
+
> meant before. `high` is opt-in.
|
|
139
|
+
|
|
121
140
|
**The guard floor has no configuration.** Credential-path denial and secret-argument denial are
|
|
122
141
|
security invariants, not deployment-varying tunables. A repo-local `policyFile` is the lowest
|
|
123
142
|
trust rank and may only *tighten*: add deny patterns, add egress tool names, raise a severity,
|
|
124
|
-
switch a pass on.
|
|
143
|
+
switch a pass on. It cannot reach `aggressiveness` — raising the level would let a hostile
|
|
144
|
+
workspace put placeholders into the user's own prompt. Any downgrade makes the whole file
|
|
145
|
+
invalid.
|
|
125
146
|
|
|
126
147
|
[Configuration reference →](https://charlotten7.github.io/dsh-dlp/configuration.html) ·
|
|
127
148
|
[What gets denied →](https://charlotten7.github.io/dsh-dlp/denials.html) ·
|
package/cordis.patch.yml
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
config:
|
|
16
16
|
auditLog: !!js dshHomePath('dsh-dlp.audit.jsonl')
|
|
17
17
|
redactionKeyFile: !!js dshHomePath('dsh-dlp.redaction-key')
|
|
18
|
+
# `medium` guarantees every pass below is on and that none of them can
|
|
19
|
+
# be switched off; `high` adds the user's own typed prompt to what is
|
|
20
|
+
# redacted; `low` guarantees nothing and is what a deployment needing
|
|
21
|
+
# a pass off must set. A `false` below contradicting the level is a
|
|
22
|
+
# load-time error rather than a setting that quietly loses.
|
|
23
|
+
aggressiveness: medium
|
|
18
24
|
maxScanBytes: 1048576
|
|
19
25
|
breadthTier: true
|
|
20
26
|
resultRedaction: true
|
package/lib/detectors.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two detection tiers and the vocabulary they share.
|
|
3
3
|
*
|
|
4
|
-
* Tier 1 is a synchronous table of prefix-anchored
|
|
5
|
-
* because two of the three seams
|
|
4
|
+
* Tier 1 is a synchronous table of token formats — prefix-anchored but for the
|
|
5
|
+
* check-digit-validated card rule — owned here because two of the three seams
|
|
6
|
+
* this plugin uses are synchronous:
|
|
6
7
|
* `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
|
|
7
8
|
* waterfall returns a record, neither of which can await. Tier 2 wraps
|
|
8
9
|
* `@secretlint/core`, which runs in-process with no subprocess but resolves a
|
|
@@ -31,16 +32,136 @@ export function severityRank(severity) {
|
|
|
31
32
|
}
|
|
32
33
|
/** Severity at or above which the guard floor denies rather than only redacting. */
|
|
33
34
|
export const DENY_SEVERITY = 'high';
|
|
35
|
+
/**
|
|
36
|
+
* Whether a digit string satisfies the Luhn check digit (ISO/IEC 7812-1).
|
|
37
|
+
* @param digits - the number with every separator already removed.
|
|
38
|
+
* @returns whether the trailing check digit is consistent with the rest.
|
|
39
|
+
*/
|
|
40
|
+
function luhnValid(digits) {
|
|
41
|
+
let sum = 0;
|
|
42
|
+
let double = false;
|
|
43
|
+
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
|
44
|
+
let value = digits.charCodeAt(index) - 48;
|
|
45
|
+
if (double) {
|
|
46
|
+
value *= 2;
|
|
47
|
+
if (value > 9)
|
|
48
|
+
value -= 9;
|
|
49
|
+
}
|
|
50
|
+
sum += value;
|
|
51
|
+
double = !double;
|
|
52
|
+
}
|
|
53
|
+
return sum % 10 === 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Issuer identification ranges, each with the lengths that issuer assigns.
|
|
57
|
+
*
|
|
58
|
+
* The length is half of each rule. Luhn alone accepts one digit run in ten,
|
|
59
|
+
* and these ranges at these lengths accept 26.8% of the sixteen-digit space
|
|
60
|
+
* (measured over 200,000 numbers with the check digit forced valid), so it is
|
|
61
|
+
* the pair that separates a card number from an order id beginning with a 4.
|
|
62
|
+
*
|
|
63
|
+
* Maestro is deliberately absent. Its ranges run from `50` and `56`-`58`
|
|
64
|
+
* through a bare leading `6` at any length from 12 to 19, which is most of the
|
|
65
|
+
* six-prefixed numeric space at most of the lengths an identifier uses;
|
|
66
|
+
* including it would cost more ordinary text than it catches.
|
|
67
|
+
* `docs/redaction.md` records that gap rather than implying coverage.
|
|
68
|
+
*/
|
|
69
|
+
const CARD_RANGES = [
|
|
70
|
+
{ prefix: /^4/, lengths: [13, 16, 19] },
|
|
71
|
+
{ prefix: /^5[1-5]/, lengths: [16] },
|
|
72
|
+
// Mastercard's 2-series, 222100 through 272099.
|
|
73
|
+
{ prefix: /^2(?:22[1-9]|2[3-9]\d|[3-6]\d\d|7[01]\d|720)/, lengths: [16] },
|
|
74
|
+
{ prefix: /^3[47]/, lengths: [15] },
|
|
75
|
+
// Discover: 6011, 622126-622925, 644-649, 65.
|
|
76
|
+
{ prefix: /^6(?:011|5\d\d|4[4-9]\d|22(?:12[6-9]|1[3-9]\d|[2-8]\d\d|9[01]\d|92[0-5]))/, lengths: [16, 19] },
|
|
77
|
+
{ prefix: /^35(?:2[89]|[3-8]\d)/, lengths: [16, 17, 18, 19] },
|
|
78
|
+
// Diners Club: 300-305, 3095, 36, 38, 39.
|
|
79
|
+
{ prefix: /^3(?:0(?:[0-5]|95)|[689])/, lengths: [14, 16, 19] },
|
|
80
|
+
{ prefix: /^62/, lengths: [16, 17, 18, 19] },
|
|
81
|
+
];
|
|
82
|
+
/** Whether a bare digit string is a number some issuer would have handed out. */
|
|
83
|
+
function isIssuedCardNumber(digits) {
|
|
84
|
+
const issued = CARD_RANGES.some(range => range.lengths.includes(digits.length) && range.prefix.test(digits));
|
|
85
|
+
return issued && luhnValid(digits);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Shortest printed digit group.
|
|
89
|
+
*
|
|
90
|
+
* This is the constant that keeps an amount written with space thousands
|
|
91
|
+
* separators from reading as a card number. Measured over 200,000 random
|
|
92
|
+
* sixteen-digit amounts written `4 920 007 989 245 430`: admitting groups
|
|
93
|
+
* shorter than three flags 3.75% of them, requiring three flags 0.19%.
|
|
94
|
+
*/
|
|
95
|
+
const MIN_CARD_GROUP = 3;
|
|
96
|
+
/** Longest printed digit group: American Express is printed 4-6-5. */
|
|
97
|
+
const MAX_CARD_GROUP = 6;
|
|
98
|
+
/** Most groups one match may span — a 19-digit number takes five, leaving room for one neighbour. */
|
|
99
|
+
const MAX_CARD_GROUPS = 6;
|
|
100
|
+
/** One printed group of a grouped card number. */
|
|
101
|
+
const CARD_GROUP = String.raw `\d{${MIN_CARD_GROUP},${MAX_CARD_GROUP}}`;
|
|
102
|
+
/**
|
|
103
|
+
* A digit run that could be a card number: either unbroken, or printed in
|
|
104
|
+
* groups separated by single spaces or hyphens.
|
|
105
|
+
*
|
|
106
|
+
* The two lookarounds drop the commonest long digit run in machine output that
|
|
107
|
+
* is not an identifier at all — the fractional part of a decimal. A JavaScript
|
|
108
|
+
* `Math.random()` prints sixteen digits after the point, and 1.63% of them get
|
|
109
|
+
* past both the issuer ranges and the check digit; measured over 200,000
|
|
110
|
+
* printouts, the lookbehind takes that to zero.
|
|
111
|
+
*/
|
|
112
|
+
const CARD_CANDIDATE = new RegExp(String.raw `(?<!\d\.)\b(?:\d{13,19}|${CARD_GROUP}(?:[ -]${CARD_GROUP}){1,${MAX_CARD_GROUPS - 1}})\b(?!\.\d)`, 'g');
|
|
113
|
+
/**
|
|
114
|
+
* Narrow one candidate digit run to the card number inside it.
|
|
115
|
+
*
|
|
116
|
+
* The pattern is greedy across printed groups, so a run holding a card number
|
|
117
|
+
* beside an expiry date, an amount or a year arrives here as one match. Every
|
|
118
|
+
* group-aligned window of the run is tested, longest first, and each window's
|
|
119
|
+
* edges sit on a separator or on the ends of the match — where the pattern
|
|
120
|
+
* already established a word boundary — so narrowing never reports part of a
|
|
121
|
+
* longer unbroken number. An unbroken run has exactly one window, itself,
|
|
122
|
+
* which is what keeps a 20-digit identifier from being reported as the
|
|
123
|
+
* 16-digit card number hiding in its first sixteen digits.
|
|
124
|
+
* @param match - the matched candidate.
|
|
125
|
+
* @returns offsets of the card number within the match, or `undefined` when there is none.
|
|
126
|
+
*/
|
|
127
|
+
export function refinePaymentCardNumber(match) {
|
|
128
|
+
const groups = [];
|
|
129
|
+
let cursor = 0;
|
|
130
|
+
for (const digits of match.split(/[ -]/)) {
|
|
131
|
+
groups.push({ start: cursor, end: cursor + digits.length, digits });
|
|
132
|
+
cursor += digits.length + 1;
|
|
133
|
+
}
|
|
134
|
+
const windows = [];
|
|
135
|
+
for (const [index, first] of groups.entries()) {
|
|
136
|
+
let digits = '';
|
|
137
|
+
for (const group of groups.slice(index)) {
|
|
138
|
+
digits += group.digits;
|
|
139
|
+
windows.push({ start: first.start, end: group.end, digits });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Longest first, so a number is reported whole rather than as a prefix of
|
|
143
|
+
// itself that happens to validate at a shorter issuer length.
|
|
144
|
+
windows.sort((left, right) => (right.end - right.start) - (left.end - left.start));
|
|
145
|
+
for (const window of windows) {
|
|
146
|
+
if (isIssuedCardNumber(window.digits))
|
|
147
|
+
return { start: window.start, end: window.end };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
34
151
|
/**
|
|
35
152
|
* Tier 1's rule table. Deliberately narrow: only formats whose prefix or
|
|
36
153
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
37
154
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
38
155
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
39
156
|
*
|
|
40
|
-
*
|
|
41
|
-
* keeps growing rather than deferring to tier 2: the
|
|
42
|
-
* waterfall is synchronous and cannot reach tier 2
|
|
43
|
-
* here is exported in the clear when telemetry is
|
|
157
|
+
* A structurally unambiguous match is the membership criterion, and the reason
|
|
158
|
+
* this table keeps growing rather than deferring to tier 2: the
|
|
159
|
+
* `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
|
|
160
|
+
* at all, so a format missing here is exported in the clear when telemetry is
|
|
161
|
+
* on. For every rule but one that means a prefix. The exception is
|
|
162
|
+
* `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
|
|
163
|
+
* its place through an issuer range, a length that issuer assigns and a check
|
|
164
|
+
* digit instead.
|
|
44
165
|
*/
|
|
45
166
|
export const SYNC_RULES = [
|
|
46
167
|
{ id: 'dsh-dlp/aws-access-key-id', version: 1, severity: 'critical', pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/g },
|
|
@@ -126,6 +247,23 @@ export const SYNC_RULES = [
|
|
|
126
247
|
{ 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 },
|
|
127
248
|
{ 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 },
|
|
128
249
|
{ 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 },
|
|
250
|
+
// Cardholder data, which is the one class here that is not a credential the
|
|
251
|
+
// agent could have fetched: it is typed, pasted or read out of a customer
|
|
252
|
+
// record, and it is regulated wherever it goes. `medium` is deliberate and
|
|
253
|
+
// is the only rule in this table below `high` other than the assignment
|
|
254
|
+
// heuristic above it. The guard floor denies at `high`, and a denial from
|
|
255
|
+
// this rule would be unoverridable, while its false positives are ordinary
|
|
256
|
+
// long numbers rather than malformed credentials. A deployment that wants
|
|
257
|
+
// the denial raises the severity from its repo-local policy, which the
|
|
258
|
+
// tighten-only tier already allows.
|
|
259
|
+
{
|
|
260
|
+
id: 'dsh-dlp/payment-card-number',
|
|
261
|
+
version: 1,
|
|
262
|
+
severity: 'medium',
|
|
263
|
+
pattern: CARD_CANDIDATE,
|
|
264
|
+
refine: refinePaymentCardNumber,
|
|
265
|
+
exact: true,
|
|
266
|
+
},
|
|
129
267
|
];
|
|
130
268
|
/**
|
|
131
269
|
* Build one class's run pattern from its ranges, so the two cannot drift apart.
|
|
@@ -318,13 +456,18 @@ export function scanSync(text, rules = SYNC_RULES) {
|
|
|
318
456
|
for (const rule of rules) {
|
|
319
457
|
for (const match of text.matchAll(rule.pattern)) {
|
|
320
458
|
// `matchAll` on a global pattern always reports an index.
|
|
321
|
-
const
|
|
459
|
+
const region = rule.refine === undefined
|
|
460
|
+
? { start: 0, end: match[0].length }
|
|
461
|
+
: rule.refine(match[0]);
|
|
462
|
+
if (region === undefined)
|
|
463
|
+
continue;
|
|
322
464
|
detections.push({
|
|
323
465
|
ruleId: rule.id,
|
|
324
466
|
ruleVersion: rule.version,
|
|
325
467
|
severity: rule.severity,
|
|
326
|
-
start,
|
|
327
|
-
end:
|
|
468
|
+
start: match.index + region.start,
|
|
469
|
+
end: match.index + region.end,
|
|
470
|
+
...rule.exact === true ? { exact: true } : {},
|
|
328
471
|
});
|
|
329
472
|
}
|
|
330
473
|
}
|
package/lib/policy.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* Rank 3 is a file inside the workspace, so a hostile repository ships one and
|
|
12
12
|
* a prompt-injected agent can write one. It may add deny patterns, add egress
|
|
13
13
|
* tools, raise a severity, and switch a redaction pass on. Every other key,
|
|
14
|
-
* and every downgrade, is a load-time error rather than a silent ignore.
|
|
14
|
+
* and every downgrade, is a load-time error rather than a silent ignore. It
|
|
15
|
+
* cannot reach {@link Config.aggressiveness}: the level's own lever is
|
|
16
|
+
* redacting what the user typed, and a workspace that could force that could
|
|
17
|
+
* garble the user's own words on their way to the model.
|
|
15
18
|
* @module dsh-dlp/policy
|
|
16
19
|
*/
|
|
17
20
|
import { readFileSync } from 'node:fs';
|
|
@@ -23,6 +26,7 @@ import { SYNC_RULES, severityRank, stripControlSequences } from "./detectors.js"
|
|
|
23
26
|
import { resolveDshHome } from "./home.js";
|
|
24
27
|
import { CREDENTIAL_PATH_RULES, escapePathPattern, homeCredentialPathRules } from "./paths.js";
|
|
25
28
|
export const Config = z.object({
|
|
29
|
+
aggressiveness: z.union([z.const('low'), z.const('medium'), z.const('high')]).default('medium'),
|
|
26
30
|
auditLog: z.string().required(),
|
|
27
31
|
redactionKeyFile: z.string().required(),
|
|
28
32
|
policyFile: z.string(),
|
|
@@ -49,6 +53,39 @@ const ENABLEABLE = [
|
|
|
49
53
|
'configWriteAsk',
|
|
50
54
|
'approvalSuppressionAsk',
|
|
51
55
|
];
|
|
56
|
+
/**
|
|
57
|
+
* Passes each level requires, whatever the toggles say.
|
|
58
|
+
*
|
|
59
|
+
* `low` requires none, which is what makes it the level a deployment moves to
|
|
60
|
+
* when it needs a pass off — and what makes every configuration that was legal
|
|
61
|
+
* before this field existed still legal. `medium` and `high` require all of
|
|
62
|
+
* them, so the one-word setting and the nine booleans can never describe
|
|
63
|
+
* different plugins.
|
|
64
|
+
*/
|
|
65
|
+
const LEVEL_REQUIRES = {
|
|
66
|
+
low: [],
|
|
67
|
+
medium: ENABLEABLE,
|
|
68
|
+
high: ENABLEABLE,
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Reject a configuration whose level and whose toggles describe different
|
|
72
|
+
* behaviour.
|
|
73
|
+
*
|
|
74
|
+
* Loud at load rather than silently resolved either way: two
|
|
75
|
+
* deployment-controlled settings disagreeing is self-contained
|
|
76
|
+
* misconfiguration, and whichever of them lost would be a pass an operator
|
|
77
|
+
* believes is running and is not, or one they believe is off and is not.
|
|
78
|
+
* @param config - the deployment-controlled configuration.
|
|
79
|
+
* @throws PolicyError naming every toggle that contradicts the level.
|
|
80
|
+
*/
|
|
81
|
+
function assertLevelAgrees(config) {
|
|
82
|
+
const contradicted = LEVEL_REQUIRES[config.aggressiveness].filter(toggle => !config[toggle]);
|
|
83
|
+
if (contradicted.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
throw new PolicyError(`aggressiveness: ${config.aggressiveness} requires every pass this package ships, but`
|
|
86
|
+
+ ` ${contradicted.join(', ')} ${contradicted.length === 1 ? 'is' : 'are'} set to false.`
|
|
87
|
+
+ ' Set aggressiveness: low to choose passes individually, or drop the false setting.');
|
|
88
|
+
}
|
|
52
89
|
/** Keys a repo-local policy file may carry; anything else fails the load. */
|
|
53
90
|
const POLICY_KEYS = ['v', 'addCredentialPaths', 'addEgressTools', 'raiseSeverity', 'enable'];
|
|
54
91
|
/** Payload version this package writes and accepts for repo-local policy files. */
|
|
@@ -270,6 +307,7 @@ function selfProtectionRules(config, dshHome) {
|
|
|
270
307
|
* @returns the effective policy every seam reads.
|
|
271
308
|
*/
|
|
272
309
|
export function resolvePolicy(config, repo) {
|
|
310
|
+
assertLevelAgrees(config);
|
|
273
311
|
const enabled = (toggle) => config[toggle] || (repo?.enable.includes(toggle) ?? false);
|
|
274
312
|
return {
|
|
275
313
|
credentialPathRules: [
|
|
@@ -284,6 +322,8 @@ export function resolvePolicy(config, repo) {
|
|
|
284
322
|
return raised === undefined ? rule : { ...rule, severity: raised };
|
|
285
323
|
}),
|
|
286
324
|
maxScanBytes: config.maxScanBytes,
|
|
325
|
+
aggressiveness: config.aggressiveness,
|
|
326
|
+
userTypedInputRedaction: config.aggressiveness === 'high',
|
|
287
327
|
breadthTier: enabled('breadthTier'),
|
|
288
328
|
resultRedaction: enabled('resultRedaction'),
|
|
289
329
|
telemetryRedaction: enabled('telemetryRedaction'),
|
package/lib/steps.js
CHANGED
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
20
|
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
21
|
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
-
* only one
|
|
23
|
-
*
|
|
22
|
+
* only one, but it is not unconditional: it applies at `aggressiveness: low`
|
|
23
|
+
* and `medium`, and stops applying at `high`. This plugin cannot know where
|
|
24
|
+
* the request is going, and a card number a person typed on purpose is still
|
|
25
|
+
* cardholder data at whatever provider the deployment happens to be pointed
|
|
26
|
+
* at, so the deployment says which reading it wants rather than this module
|
|
27
|
+
* assuming one.
|
|
24
28
|
*
|
|
25
29
|
* A claimed message differs from an added one in exactly one way, and it is
|
|
26
30
|
* narrower than it looks. Its delivery was already recorded as
|
|
@@ -44,7 +48,7 @@ import { messageStrings, prepareScan } from "./results.js";
|
|
|
44
48
|
import { redactUserMessages } from "./redaction.js";
|
|
45
49
|
/**
|
|
46
50
|
* Whether one message the loop claimed from the inbox is the user's own
|
|
47
|
-
* typing,
|
|
51
|
+
* typing, which exempts it below `aggressiveness: high`.
|
|
48
52
|
*
|
|
49
53
|
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
50
54
|
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
@@ -62,6 +66,9 @@ import { redactUserMessages } from "./redaction.js";
|
|
|
62
66
|
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
63
67
|
* needs a fact the source does not carry, and inventing one would put a
|
|
64
68
|
* guessed value in a security decision.
|
|
69
|
+
*
|
|
70
|
+
* At `aggressiveness: high` the caller stops consulting this, so the two
|
|
71
|
+
* borrowed cases stop being exempt with everything else.
|
|
65
72
|
* @param message - one message the loop claimed from the inbox.
|
|
66
73
|
* @returns whether the message came from a person typing into their own prompt.
|
|
67
74
|
*/
|
|
@@ -86,7 +93,7 @@ export async function redactStepContext(decision, claimed, policy, hasher) {
|
|
|
86
93
|
// claimed message is the same object it arrived as.
|
|
87
94
|
const claimedSet = new Set(claimed);
|
|
88
95
|
const inScope = (message) => claimedSet.has(message)
|
|
89
|
-
? policy.claimedInputRedaction && !isUserTyped(message)
|
|
96
|
+
? policy.claimedInputRedaction && (policy.userTypedInputRedaction || !isUserTyped(message))
|
|
90
97
|
: policy.stepContextRedaction;
|
|
91
98
|
const selected = decision.messages.filter(inScope);
|
|
92
99
|
if (selected.length === 0)
|
package/lib/types/detectors.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two detection tiers and the vocabulary they share.
|
|
3
3
|
*
|
|
4
|
-
* Tier 1 is a synchronous table of prefix-anchored
|
|
5
|
-
* because two of the three seams
|
|
4
|
+
* Tier 1 is a synchronous table of token formats — prefix-anchored but for the
|
|
5
|
+
* check-digit-validated card rule — owned here because two of the three seams
|
|
6
|
+
* this plugin uses are synchronous:
|
|
6
7
|
* `ToolGuard` returns `string | undefined` and the `session-telemetry/record`
|
|
7
8
|
* waterfall returns a record, neither of which can await. Tier 2 wraps
|
|
8
9
|
* `@secretlint/core`, which runs in-process with no subprocess but resolves a
|
|
@@ -39,8 +40,10 @@ export interface Detection {
|
|
|
39
40
|
readonly end: number;
|
|
40
41
|
/**
|
|
41
42
|
* Set when the offsets cover exactly what must be replaced, so redaction
|
|
42
|
-
* must not widen them to the surrounding delimiters.
|
|
43
|
-
*
|
|
43
|
+
* must not widen them to the surrounding delimiters. Two kinds of rule set
|
|
44
|
+
* it: the Unicode indicators, whose matches are the characters themselves,
|
|
45
|
+
* and the payment-card rule, whose match is validated digit by digit and so
|
|
46
|
+
* covers precisely the number. Everything else leaves it unset, because a
|
|
44
47
|
* secret's reported span is advisory and verified to under-cover (ADR §4).
|
|
45
48
|
*/
|
|
46
49
|
readonly exact?: true;
|
|
@@ -58,17 +61,55 @@ export interface SyncRule {
|
|
|
58
61
|
readonly severity: Severity;
|
|
59
62
|
/** Global-flagged; matched through `matchAll`, which never mutates this instance's `lastIndex`. */
|
|
60
63
|
readonly pattern: RegExp;
|
|
64
|
+
/**
|
|
65
|
+
* Narrows one raw match to the region that is really a detection, or rejects
|
|
66
|
+
* it outright by returning `undefined`. Offsets are relative to the match.
|
|
67
|
+
*
|
|
68
|
+
* Present for the one format a regular expression cannot decide on its own:
|
|
69
|
+
* a payment card number is a digit run that must also carry a known issuer
|
|
70
|
+
* prefix at a length that issuer assigns and satisfy a check digit, and the
|
|
71
|
+
* run it sits in may carry a neighbouring number the pattern swept up.
|
|
72
|
+
* Absent everywhere else, where the prefix makes the match unambiguous.
|
|
73
|
+
*/
|
|
74
|
+
readonly refine?: (match: string) => {
|
|
75
|
+
readonly start: number;
|
|
76
|
+
readonly end: number;
|
|
77
|
+
} | undefined;
|
|
78
|
+
/** Whether the reported offsets cover exactly the text to replace; see {@link Detection.exact}. */
|
|
79
|
+
readonly exact?: true;
|
|
61
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Narrow one candidate digit run to the card number inside it.
|
|
83
|
+
*
|
|
84
|
+
* The pattern is greedy across printed groups, so a run holding a card number
|
|
85
|
+
* beside an expiry date, an amount or a year arrives here as one match. Every
|
|
86
|
+
* group-aligned window of the run is tested, longest first, and each window's
|
|
87
|
+
* edges sit on a separator or on the ends of the match — where the pattern
|
|
88
|
+
* already established a word boundary — so narrowing never reports part of a
|
|
89
|
+
* longer unbroken number. An unbroken run has exactly one window, itself,
|
|
90
|
+
* which is what keeps a 20-digit identifier from being reported as the
|
|
91
|
+
* 16-digit card number hiding in its first sixteen digits.
|
|
92
|
+
* @param match - the matched candidate.
|
|
93
|
+
* @returns offsets of the card number within the match, or `undefined` when there is none.
|
|
94
|
+
*/
|
|
95
|
+
export declare function refinePaymentCardNumber(match: string): {
|
|
96
|
+
start: number;
|
|
97
|
+
end: number;
|
|
98
|
+
} | undefined;
|
|
62
99
|
/**
|
|
63
100
|
* Tier 1's rule table. Deliberately narrow: only formats whose prefix or
|
|
64
101
|
* delimiters make a match structurally unambiguous, plus PEM blocks and
|
|
65
102
|
* credential-bearing URLs. Anything requiring entropy heuristics is left to
|
|
66
103
|
* tier 2, where a false positive costs a redaction rather than a denial.
|
|
67
104
|
*
|
|
68
|
-
*
|
|
69
|
-
* keeps growing rather than deferring to tier 2: the
|
|
70
|
-
* waterfall is synchronous and cannot reach tier 2
|
|
71
|
-
* here is exported in the clear when telemetry is
|
|
105
|
+
* A structurally unambiguous match is the membership criterion, and the reason
|
|
106
|
+
* this table keeps growing rather than deferring to tier 2: the
|
|
107
|
+
* `session-telemetry/record` waterfall is synchronous and cannot reach tier 2
|
|
108
|
+
* at all, so a format missing here is exported in the clear when telemetry is
|
|
109
|
+
* on. For every rule but one that means a prefix. The exception is
|
|
110
|
+
* `dsh-dlp/payment-card-number`, which has no prefix to anchor on and earns
|
|
111
|
+
* its place through an issuer range, a length that issuer assigns and a check
|
|
112
|
+
* digit instead.
|
|
72
113
|
*/
|
|
73
114
|
export declare const SYNC_RULES: readonly SyncRule[];
|
|
74
115
|
/**
|
package/lib/types/policy.d.ts
CHANGED
|
@@ -11,14 +11,41 @@
|
|
|
11
11
|
* Rank 3 is a file inside the workspace, so a hostile repository ships one and
|
|
12
12
|
* a prompt-injected agent can write one. It may add deny patterns, add egress
|
|
13
13
|
* tools, raise a severity, and switch a redaction pass on. Every other key,
|
|
14
|
-
* and every downgrade, is a load-time error rather than a silent ignore.
|
|
14
|
+
* and every downgrade, is a load-time error rather than a silent ignore. It
|
|
15
|
+
* cannot reach {@link Config.aggressiveness}: the level's own lever is
|
|
16
|
+
* redacting what the user typed, and a workspace that could force that could
|
|
17
|
+
* garble the user's own words on their way to the model.
|
|
15
18
|
* @module dsh-dlp/policy
|
|
16
19
|
*/
|
|
17
20
|
import z from '@deepseek-ai/schemastery';
|
|
18
21
|
import { type Severity, type SyncRule } from './detectors.ts';
|
|
19
22
|
import { type CredentialPathRule } from './paths.ts';
|
|
23
|
+
/**
|
|
24
|
+
* How far redaction reaches, as one word.
|
|
25
|
+
*
|
|
26
|
+
* The level is not a tenth switch beside the nine below it. It states what the
|
|
27
|
+
* deployment guarantees, the toggles state which passes carry it out, and the
|
|
28
|
+
* two may not disagree: at `medium` and `high` a toggle set to `false`
|
|
29
|
+
* contradicts the guarantee and is a load-time error rather than a setting
|
|
30
|
+
* that quietly loses.
|
|
31
|
+
*
|
|
32
|
+
* - `low` guarantees nothing. Every pass is exactly its own toggle, which is
|
|
33
|
+
* still `true` unless the deployment says otherwise. This is the level to
|
|
34
|
+
* pick when a pass has to be switched off.
|
|
35
|
+
* - `medium` guarantees that every pass this package ships is on and that no
|
|
36
|
+
* toggle can take one away. The user's own typing stays exempt.
|
|
37
|
+
* - `high` is `medium` plus the one thing no toggle can express: the exemption
|
|
38
|
+
* in `isUserTyped` stops applying, so a secret in the user's own prompt is
|
|
39
|
+
* redacted before the request is built from it.
|
|
40
|
+
*/
|
|
41
|
+
export type Aggressiveness = 'low' | 'medium' | 'high';
|
|
20
42
|
/** Deployment configuration, validated from `cordis.yml`. */
|
|
21
43
|
export interface Config {
|
|
44
|
+
/**
|
|
45
|
+
* How far redaction reaches. See {@link Aggressiveness}; `high` is the only
|
|
46
|
+
* value that puts the user's own typed prompt in scope.
|
|
47
|
+
*/
|
|
48
|
+
aggressiveness: Aggressiveness;
|
|
22
49
|
/** Absolute path of this plugin's own JSONL audit sink. Never the session log. */
|
|
23
50
|
auditLog: string;
|
|
24
51
|
/** Absolute path of the installation's redaction key; created with 32 random bytes if absent. */
|
|
@@ -85,6 +112,16 @@ export interface ResolvedPolicy {
|
|
|
85
112
|
readonly extraEgressTools: ReadonlySet<string>;
|
|
86
113
|
readonly syncRules: readonly SyncRule[];
|
|
87
114
|
readonly maxScanBytes: number;
|
|
115
|
+
readonly aggressiveness: Aggressiveness;
|
|
116
|
+
/**
|
|
117
|
+
* Whether a message the user typed themselves is scanned like any other.
|
|
118
|
+
*
|
|
119
|
+
* Set by the level alone, at `high` only, and by nothing else: there is no
|
|
120
|
+
* toggle for it and a repo-local policy cannot reach it. `isUserTyped` still
|
|
121
|
+
* decides which messages this applies to; this decides whether that answer
|
|
122
|
+
* exempts them.
|
|
123
|
+
*/
|
|
124
|
+
readonly userTypedInputRedaction: boolean;
|
|
88
125
|
readonly breadthTier: boolean;
|
|
89
126
|
readonly resultRedaction: boolean;
|
|
90
127
|
readonly telemetryRedaction: boolean;
|
package/lib/types/steps.d.ts
CHANGED
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
* payload with `agent.followup()`, a subagent's settled result and an
|
|
20
20
|
* agent-to-agent relay arrive the same way, and so does anything
|
|
21
21
|
* `agent.inject()` seeded. {@link isUserTyped} is the exemption, and it is the
|
|
22
|
-
* only one
|
|
23
|
-
*
|
|
22
|
+
* only one, but it is not unconditional: it applies at `aggressiveness: low`
|
|
23
|
+
* and `medium`, and stops applying at `high`. This plugin cannot know where
|
|
24
|
+
* the request is going, and a card number a person typed on purpose is still
|
|
25
|
+
* cardholder data at whatever provider the deployment happens to be pointed
|
|
26
|
+
* at, so the deployment says which reading it wants rather than this module
|
|
27
|
+
* assuming one.
|
|
24
28
|
*
|
|
25
29
|
* A claimed message differs from an added one in exactly one way, and it is
|
|
26
30
|
* narrower than it looks. Its delivery was already recorded as
|
|
@@ -64,7 +68,7 @@ export interface StepRedaction {
|
|
|
64
68
|
}
|
|
65
69
|
/**
|
|
66
70
|
* Whether one message the loop claimed from the inbox is the user's own
|
|
67
|
-
* typing,
|
|
71
|
+
* typing, which exempts it below `aggressiveness: high`.
|
|
68
72
|
*
|
|
69
73
|
* `MessageSourceMap` is merge-extensible and every producer picks its own
|
|
70
74
|
* `kind`, so the exemption is a single allowed value rather than a list of
|
|
@@ -82,6 +86,9 @@ export interface StepRedaction {
|
|
|
82
86
|
* prompt under `kind: 'user'`. Those stay exempt here. Distinguishing them
|
|
83
87
|
* needs a fact the source does not carry, and inventing one would put a
|
|
84
88
|
* guessed value in a security decision.
|
|
89
|
+
*
|
|
90
|
+
* At `aggressiveness: high` the caller stops consulting this, so the two
|
|
91
|
+
* borrowed cases stop being exempt with everything else.
|
|
85
92
|
* @param message - one message the loop claimed from the inbox.
|
|
86
93
|
* @returns whether the message came from a person typing into their own prompt.
|
|
87
94
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dlp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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>",
|