Skip to content

Commit b91854e

Browse files
feat(rules): detect labeled passwords pasted in prose
Add a scoped `labeled-password` rule for `password:`/`passwd:`/`passphrase:` values pasted in chat — the single most common human leak, which slipped under the generic catch-all's 16-char floor. Lower length floor (8) with a modest entropy gate (3.0), medium severity. - Drop `pwd` from keywords: `PWD=/home/...` from printenv dumps would be a systematic false positive, and paths clear the entropy gate. - Add an optional per-rule `deny` regex in the detector to drop placeholder values that clear the entropy gate (`your-password` ≈ 3.24 outscores real weak passwords, so entropy alone can't filter it). Also documents the `npx skills add` install path for the broom-sweep skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 76397b0 commit b91854e

4 files changed

Lines changed: 53 additions & 1 deletion

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ broom proxy --uninstall # remove the daemon *and* the env vars
9999

100100
> Install globally (`npm install -g broomsticks`) when using this — the hook prefers the `broom` command but falls back to `npx broomsticks`, which re-resolves the package on every turn and adds noticeable latency.
101101
102+
Want just the skill (no Stop hook)? Install it directly with [`skills`](https://github.com/obra/skills):
103+
104+
```bash
105+
npx skills add digitaldrreamer/broomsticks/skills/broom-sweep
106+
```
107+
108+
This adds the `broom-sweep` skill so you can invoke `/broom-sweep` on demand, without wiring up the automatic per-turn scan.
109+
102110
## How it works
103111

104112
A matched secret is replaced inline with a stable, non-reversible placeholder:
@@ -123,6 +131,7 @@ Detection is a curated, gitleaks-style ruleset for high-confidence provider toke
123131
| Billing / SaaS | Stripe `sk_live_…`, Paddle `pdl_live_…`, Slack `xox[baprs]-…` |
124132
| Tokens | JWTs (`eyJ….eyJ….…`) |
125133
| Connection strings | `postgres://`, `mysql://`, `mongodb+srv://`, `redis://` with inline credentials |
134+
| Labeled passwords | `password:` / `passwd:` / `passphrase:` values pasted in prose (lower length floor; placeholder-filtered) |
126135
| Generic | `api_key` / `secret` / `password` / `token` assignments above an entropy threshold |
127136

128137
</details>

src/detector.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function scanText(text, rules, extras = []) {
3636
const [start, end] = indices
3737

3838
if (rule.entropy !== undefined && shannonEntropy(secret) < rule.entropy) continue
39+
if (rule.deny && rule.deny.test(secret)) continue
3940

4041
raw.push({ ruleId: rule.id, title: rule.title, severity: rule.severity, secret, start, end })
4142
}

src/rules.mjs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
/**
1010
* @typedef {'critical'|'high'|'medium'|'low'} Severity
11-
* @typedef {{ id:string, title:string, severity:Severity, pattern:RegExp, secretGroup?:number, entropy?:number }} Rule
11+
* @typedef {{ id:string, title:string, severity:Severity, pattern:RegExp, secretGroup?:number, entropy?:number, deny?:RegExp }} Rule
12+
*
13+
* `deny` (optional): if the captured secret matches this RegExp in full, the
14+
* match is discarded. Used to drop well-known placeholder values that clear the
15+
* entropy gate (e.g. `your-password`).
1216
*/
1317

1418
/**
@@ -235,6 +239,32 @@ export const RULES = [
235239
secretGroup: 1,
236240
},
237241

242+
// ── Labeled password in prose (scoped, low floor) ─────────────────────────
243+
// Pasting a credential as "Password: <value>" is the single most common human
244+
// leak, and it slips under the generic rule's 16-char floor. Here the label
245+
// itself is the signal, so we run a lower length floor (8) with only a modest
246+
// entropy gate (3.0) — enough to drop fixed-char placeholders (xxxxxxxx → 0
247+
// bits) but not to require token-grade randomness.
248+
//
249+
// `pwd` is deliberately NOT a keyword: transcripts routinely contain
250+
// `printenv`/`env` output, and `PWD=/home/...` (a path that clears the entropy
251+
// gate) would be a systematic false positive. `password|passwd|passphrase`
252+
// only.
253+
//
254+
// Genuinely weak human passwords (changeme ≈ 2.75, hunter2 ≈ 2.81) are left
255+
// uncaught on purpose: they sit in the same entropy band as — and below —
256+
// common placeholders (your-password ≈ 3.24), so no threshold separates them.
257+
// The `deny` list removes the placeholder strings that do clear the gate.
258+
{
259+
id: 'labeled-password',
260+
title: 'Labeled password or passphrase',
261+
severity: 'medium',
262+
pattern: /\b(?:password|passwd|passphrase)\s*[:=]\s*["']?([A-Za-z0-9+/=_\-!@#$%^&*]{8,128})["']?/gid,
263+
secretGroup: 1,
264+
entropy: 3,
265+
deny: /^(?:your[-_]?password(?:[-_]?here)?|password\d*|examplepassword|example|placeholder|changeme\d*|redacted|<.*>|\*+|x+)$/i,
266+
},
267+
238268
// ── Generic secret assignment (entropy-gated, runs last) ─────────────────
239269
// Catches unknown key formats assigned to recognisably secret-named variables.
240270
// Only fires when the value's Shannon entropy ≥ 3.5 bits/char, which eliminates

test/detector.test.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ const SAMPLES = {
3030
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U',
3131
'db-url': 'postgres://admin:s3cr3tP4ss@db.example.com:5432/app',
3232
'generic-secret': 'api_key = "aZ9xK2mQ7wL4pR8vT1nY6bC3dE5fG0hJ"',
33+
// 15-char value: under the generic rule's 16-char floor, caught by the label.
34+
'labeled-password': 'Root password: uG9p3EqZ7mK1nZ2',
3335
}
3436

3537
for (const [ruleId, sample] of Object.entries(SAMPLES)) {
@@ -67,12 +69,22 @@ test('decoys do not match any rule', () => {
6769
'See commit 1234567890abcdef1234567890abcdef12345678 for details', // bare sha
6870
'Connect to https://example.com/path?q=1', // url, no inline credentials
6971
'const timeout = 30000',
72+
'PWD=/home/user/Documents/Projects/broomsticks', // env dump: not a `pwd` keyword
73+
'password: your-password', // placeholder cleared by deny list
74+
'Password: <redacted>', // angle-bracket placeholder
7075
]
7176
for (const d of decoys) {
7277
assert.deepEqual([...ruleIds(d)], [], `unexpected match in decoy: ${d}`)
7378
}
7479
})
7580

81+
test('labeled-password: catches a labeled value under the generic 16-char floor', () => {
82+
// The reported leak: a 15-char credential the entropy catch-all missed on length.
83+
const ids = ruleIds('Root password: uG9p3EqZ7mK1nZ2')
84+
assert.ok(ids.has('labeled-password'), 'expected labeled-password to fire')
85+
assert.ok(!ids.has('generic-secret'), 'generic rule should not fire below 16 chars')
86+
})
87+
7688
test('overlapping matches resolve to a single non-nested finding', () => {
7789
// A db-url that also contains an assignment-shaped substring should yield
7890
// exactly one finding covering the URL, not two overlapping spans.

0 commit comments

Comments
 (0)