switchroom 0.14.30 → 0.14.31
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/dist/cli/switchroom.js +98 -11
- package/dist/host-control/main.js +87 -9
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +142 -22
- package/telegram-plugin/gateway/gateway.ts +27 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/hooks/sentinel-reply-guard-pretool.mjs +114 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +61 -5
- package/telegram-plugin/secret-detect/generic-entropy.ts +87 -0
- package/telegram-plugin/secret-detect/index.ts +42 -23
- package/telegram-plugin/secret-detect/patterns.ts +64 -2
- package/telegram-plugin/secret-detect/redact.ts +10 -1
- package/telegram-plugin/tests/secret-detect-generic-entropy.test.ts +94 -0
- package/telegram-plugin/tests/secret-detect-providers.test.ts +74 -0
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +8 -4
- package/telegram-plugin/tests/sentinel-reply-guard-pretool.test.ts +109 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +118 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +41 -0
- package/telegram-plugin/turn-flush-safety.ts +41 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse hook — drops a `reply` / `stream_reply` call whose entire
|
|
4
|
+
* payload is only the silent sentinel(s) NO_REPLY / HEARTBEAT_OK.
|
|
5
|
+
*
|
|
6
|
+
* Defense-in-depth for #2053. The silent-end Stop hook and the gateway
|
|
7
|
+
* flush gate already recognise prose+trailing-NO_REPLY as "intentionally
|
|
8
|
+
* silent", but if a nag-loop (or any other path) ever pushes a
|
|
9
|
+
* sentinel-only payload through the reply tool, it must NEVER reach the
|
|
10
|
+
* Telegram chat. This guard is the last line: it intercepts the tool
|
|
11
|
+
* call itself, before the gateway sees it.
|
|
12
|
+
*
|
|
13
|
+
* Match discipline — EXACT, not substring:
|
|
14
|
+
* - The trimmed payload must be ONLY one or more silent markers
|
|
15
|
+
* (each on its own line, optional trailing punctuation per marker).
|
|
16
|
+
* - A real reply that happens to mention "NO_REPLY" inside genuine
|
|
17
|
+
* prose (e.g. "reply with exactly NO_REPLY if nothing to add") is
|
|
18
|
+
* NOT dropped — it has non-marker content, so it is delivered.
|
|
19
|
+
*
|
|
20
|
+
* Claude Code PreToolUse protocol (v1):
|
|
21
|
+
* Input: JSON on stdin — { session_id, tool_name, tool_input, ... }
|
|
22
|
+
* Output: exit 0 + empty stdout → allow.
|
|
23
|
+
* exit 0 + JSON stdout { decision: "block", reason } → block.
|
|
24
|
+
*
|
|
25
|
+
* Fail-open on any parse/IO error — a malfunctioning guard must not wedge
|
|
26
|
+
* the reply path.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync } from 'node:fs'
|
|
30
|
+
import { argv } from 'node:process'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
33
|
+
const REPLY_TOOLS = new Set([
|
|
34
|
+
'mcp__switchroom-telegram__reply',
|
|
35
|
+
'mcp__switchroom-telegram__stream_reply',
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
// Mirrors turn-flush-safety.ts:isSilentFlushMarker and
|
|
39
|
+
// silent-end-scan.mjs:SILENT_MARKER_RE — a single bare marker with
|
|
40
|
+
// optional trailing punctuation.
|
|
41
|
+
const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
|
|
42
|
+
|
|
43
|
+
function readStdin() {
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(0, 'utf8')
|
|
46
|
+
} catch {
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when `text` is composed ENTIRELY of silent markers — every
|
|
53
|
+
* non-empty line is a bare NO_REPLY / HEARTBEAT_OK — with at least one
|
|
54
|
+
* such line. Exact-match per line, never a substring of prose.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} text
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isSentinelOnly(text) {
|
|
60
|
+
if (typeof text !== 'string') return false
|
|
61
|
+
const lines = text
|
|
62
|
+
.split('\n')
|
|
63
|
+
.map((l) => l.trim())
|
|
64
|
+
.filter((l) => l.length > 0)
|
|
65
|
+
if (lines.length === 0) return false
|
|
66
|
+
return lines.every((l) => SILENT_MARKER_RE.test(l))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function main() {
|
|
70
|
+
const raw = readStdin().trim()
|
|
71
|
+
if (!raw) process.exit(0)
|
|
72
|
+
|
|
73
|
+
let event
|
|
74
|
+
try {
|
|
75
|
+
event = JSON.parse(raw)
|
|
76
|
+
} catch {
|
|
77
|
+
process.exit(0)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const toolName = event?.tool_name
|
|
81
|
+
if (!REPLY_TOOLS.has(toolName)) process.exit(0)
|
|
82
|
+
|
|
83
|
+
const text = event?.tool_input?.text
|
|
84
|
+
if (typeof text !== 'string') process.exit(0)
|
|
85
|
+
|
|
86
|
+
if (isSentinelOnly(text)) {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
'[sentinel-reply-guard] dropped sentinel-only reply payload (#2053) — ' +
|
|
89
|
+
'NO_REPLY/HEARTBEAT_OK must never reach chat\n',
|
|
90
|
+
)
|
|
91
|
+
process.stdout.write(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
decision: 'block',
|
|
94
|
+
reason:
|
|
95
|
+
'This reply payload is only the silent sentinel (NO_REPLY / ' +
|
|
96
|
+
'HEARTBEAT_OK). That sentinel signals "send nothing" — it must not ' +
|
|
97
|
+
'be delivered to the user as a message. The turn is already ' +
|
|
98
|
+
'treated as intentionally silent; do not call the reply tool with ' +
|
|
99
|
+
'it. End your turn.',
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
process.exit(0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.exit(0)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Only run the stdin-reading entrypoint when invoked directly as the hook
|
|
109
|
+
// script. When imported (e.g. by the unit test exercising `isSentinelOnly`)
|
|
110
|
+
// the top-level `readFileSync(0)` would otherwise block on the importer's
|
|
111
|
+
// stdin and hang the process.
|
|
112
|
+
if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) {
|
|
113
|
+
main()
|
|
114
|
+
}
|
|
@@ -43,6 +43,38 @@ const FINAL_ANSWER_MIN_CHARS = 200
|
|
|
43
43
|
// variants like "NO_REPLY." / "no_reply").
|
|
44
44
|
const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* True when `text`'s final non-empty line is a bare silent marker
|
|
48
|
+
* (NO_REPLY / HEARTBEAT_OK + optional trailing punctuation), regardless
|
|
49
|
+
* of what precedes it. Closes #2053: a turn that emits prose then a
|
|
50
|
+
* trailing bare `NO_REPLY` line is the model explicitly signalling
|
|
51
|
+
* "intentionally silent". The anchored `SILENT_MARKER_RE` only matches
|
|
52
|
+
* when the ENTIRE trimmed output is the bare marker, so prose+NO_REPLY
|
|
53
|
+
* slipped through → the hook blocked → nag loop → sentinel leak.
|
|
54
|
+
*
|
|
55
|
+
* Approximately mirrors `turn-flush-safety.ts:endsWithSilentMarker` (TS
|
|
56
|
+
* gateway side). NOT byte-identical: this .mjs uses `SILENT_MARKER_RE`
|
|
57
|
+
* directly (no length cap, unlimited trailing punctuation), whereas the
|
|
58
|
+
* TS side delegates to `isSilentFlushMarker` (length-capped, single
|
|
59
|
+
* trailing punct). This side is intentionally the more permissive of the
|
|
60
|
+
* two; the divergence is benign in direction — both suppress the common
|
|
61
|
+
* `prose\nNO_REPLY` shape, and the extra leniency here only ever
|
|
62
|
+
* suppresses MORE (never leaks, never wrongly silences a user-awaited
|
|
63
|
+
* reply, which is gated separately).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function endsWithSilentMarker(text) {
|
|
69
|
+
if (typeof text !== 'string') return false
|
|
70
|
+
const lines = text
|
|
71
|
+
.split('\n')
|
|
72
|
+
.map((l) => l.trim())
|
|
73
|
+
.filter((l) => l.length > 0)
|
|
74
|
+
if (lines.length === 0) return false
|
|
75
|
+
return SILENT_MARKER_RE.test(lines[lines.length - 1])
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
/**
|
|
47
79
|
* Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
|
|
48
80
|
* Kept in this .mjs so the hook is fully self-contained (no TS import).
|
|
@@ -69,13 +101,15 @@ export function isFinalAnswerReply({ text, disableNotification, done }) {
|
|
|
69
101
|
* @returns {{ chatId: string | null, threadId: number | null }}
|
|
70
102
|
*/
|
|
71
103
|
function parseChannelEnvelope(content) {
|
|
72
|
-
if (typeof content !== 'string') return { chatId: null, threadId: null }
|
|
104
|
+
if (typeof content !== 'string') return { chatId: null, threadId: null, source: null }
|
|
73
105
|
const chatMatch = content.match(/chat_id="([^"]+)"/)
|
|
74
106
|
const threadMatch = content.match(/message_thread_id="([^"]+)"/)
|
|
107
|
+
const sourceMatch = content.match(/<channel[^>]*\bsource="([^"]+)"/)
|
|
75
108
|
const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
|
|
76
109
|
return {
|
|
77
110
|
chatId: chatMatch ? chatMatch[1] : null,
|
|
78
111
|
threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
|
|
112
|
+
source: sourceMatch ? sourceMatch[1] : null,
|
|
79
113
|
}
|
|
80
114
|
}
|
|
81
115
|
|
|
@@ -128,7 +162,7 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
128
162
|
|
|
129
163
|
// 1. Walk backward to most-recent queue-operation/enqueue.
|
|
130
164
|
let startIdx = -1
|
|
131
|
-
let envelope = { chatId: null, threadId: null }
|
|
165
|
+
let envelope = { chatId: null, threadId: null, source: null }
|
|
132
166
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
133
167
|
const line = lines[i]
|
|
134
168
|
if (!line || line[0] !== '{') continue
|
|
@@ -159,15 +193,27 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
159
193
|
const content = obj?.message?.content
|
|
160
194
|
if (!Array.isArray(content)) continue
|
|
161
195
|
for (const c of content) {
|
|
196
|
+
// Plain assistant text carve-out (#2053): a turn that ends with a
|
|
197
|
+
// trailing bare NO_REPLY / HEARTBEAT_OK line — emitted as plain
|
|
198
|
+
// transcript text, NOT through the reply tool — is the model
|
|
199
|
+
// explicitly signalling "intentionally silent". The anchored
|
|
200
|
+
// SILENT_MARKER_RE below only fires when the ENTIRE reply-tool
|
|
201
|
+
// text is the bare marker, so a plain-text prose+NO_REPLY turn
|
|
202
|
+
// matched nothing here → block → nag → sentinel leak. Treat a
|
|
203
|
+
// trailing-marker text block as a valid silent end.
|
|
204
|
+
if (c?.type === 'text' && endsWithSilentMarker(String(c.text ?? ''))) {
|
|
205
|
+
return { decided: 'allow', reason: 'silent-marker-text' }
|
|
206
|
+
}
|
|
162
207
|
if (c?.type !== 'tool_use') continue
|
|
163
208
|
if (!REPLY_TOOLS.has(c.name)) continue
|
|
164
209
|
const input = c.input ?? {}
|
|
165
210
|
const text = String(input.text ?? '')
|
|
166
211
|
// Silent-marker carve-out: the operator explicitly signaled
|
|
167
212
|
// "intentionally silent" (cron HEARTBEAT_OK, model-driven
|
|
168
|
-
// NO_REPLY).
|
|
169
|
-
//
|
|
170
|
-
|
|
213
|
+
// NO_REPLY). Accept both the whole-text bare marker and the
|
|
214
|
+
// prose+trailing-marker shape (#2053). Same posture as the
|
|
215
|
+
// gateway's silent-marker suppression at gateway.ts:6692.
|
|
216
|
+
if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
|
|
171
217
|
return { decided: 'allow', reason: 'silent-marker' }
|
|
172
218
|
}
|
|
173
219
|
if (isFinalAnswerReply({
|
|
@@ -180,6 +226,16 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
180
226
|
}
|
|
181
227
|
}
|
|
182
228
|
|
|
229
|
+
// Cron-fired turns (#2053): a scheduled turn that produced no
|
|
230
|
+
// qualifying reply is NOT a delivery failure the user is waiting on —
|
|
231
|
+
// nagging it only pushes the model to escape the loop by shoving a
|
|
232
|
+
// NO_REPLY sentinel through the reply tool, which leaks to chat. A
|
|
233
|
+
// cron turn that genuinely needs to speak will have called reply
|
|
234
|
+
// (caught above); otherwise let it end silently.
|
|
235
|
+
if (envelope.source === 'cron') {
|
|
236
|
+
return { decided: 'allow', reason: 'cron-source' }
|
|
237
|
+
}
|
|
238
|
+
|
|
183
239
|
const block = { decided: 'block', reason: 'no-final-reply' }
|
|
184
240
|
if (envelope.chatId) {
|
|
185
241
|
block.chatId = envelope.chatId
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic bare-high-entropy detector — the long-tail fallback.
|
|
3
|
+
*
|
|
4
|
+
* The provider/anchored patterns only catch tokens with a known prefix
|
|
5
|
+
* (sk-, ghp_, shpat_, …) or a KEY=value context. A STANDALONE high-entropy
|
|
6
|
+
* token pasted in prose — a raw Sanctum/base62 token with no prefix —
|
|
7
|
+
* matches none of them and used to slip through (the 2026-06-01 Sanctum
|
|
8
|
+
* incident). This scanner closes that gap.
|
|
9
|
+
*
|
|
10
|
+
* Emitted at **`ambiguous`** confidence, and `redact()` deliberately
|
|
11
|
+
* EXCLUDES this rule (see redact.ts): a generic guess must never silently
|
|
12
|
+
* mask — it would corrupt agent replies and stored messages (dense
|
|
13
|
+
* identifiers look high-entropy too). Its sole job is to drive the inbound
|
|
14
|
+
* gate's "👀 looks like a high-entropy string — stash to vault or ignore?"
|
|
15
|
+
* ASK prompt, where the operator confirms.
|
|
16
|
+
*
|
|
17
|
+
* Precision (the hard part — distinguishing a random token from a long
|
|
18
|
+
* technical identifier), via three cheap, composable filters:
|
|
19
|
+
* 1. CHARSET `[A-Za-z0-9]` only — NO `_` `-` `/` `+` `=` `.` `:`. This
|
|
20
|
+
* breaks snake_case / kebab-case / npm paths / slugs / version strings
|
|
21
|
+
* into sub-28 runs, so identifiers like `get_user_profile_by_org`,
|
|
22
|
+
* `flex-row-gap-4`, `@babel/plugin-transform-modules-commonjs` never
|
|
23
|
+
* form a candidate. (Cost: base64url tokens with `-`/`_` aren't caught
|
|
24
|
+
* here — they usually appear in Bearer/JWT/KV contexts other rules
|
|
25
|
+
* handle.)
|
|
26
|
+
* 2. ≥18 DISTINCT chars — excludes hex hashes/SHAs (≤16), digit runs
|
|
27
|
+
* (≤10) by construction; and since 18 distinct is unreachable with
|
|
28
|
+
* digits alone, a passing token necessarily contains letters.
|
|
29
|
+
* 3. Contains ≥1 DIGIT — kills CamelCase-without-digits identifiers
|
|
30
|
+
* (`AbstractSingletonProxyFactoryBeanGenerator`, `TheQuickBrownFox…`),
|
|
31
|
+
* which are the residual no-separator FP shape. Real base62 tokens
|
|
32
|
+
* almost always contain a digit (>99% at 28+ chars).
|
|
33
|
+
*/
|
|
34
|
+
import type { RawHit } from './kv-scanner.js'
|
|
35
|
+
|
|
36
|
+
const CANDIDATE_RE = /[A-Za-z0-9]{28,}/g
|
|
37
|
+
|
|
38
|
+
// Unreachable with digits alone (10) → excludes hex (≤16) and digit runs;
|
|
39
|
+
// real base62 tokens have 24–62 distinct.
|
|
40
|
+
export const GENERIC_MIN_DISTINCT = 18
|
|
41
|
+
|
|
42
|
+
// A real message has at most a handful of credentials; bound the work on
|
|
43
|
+
// pathological/junk input (the O(n²) overlap-dedup downstream is the cost).
|
|
44
|
+
const MAX_GENERIC_HITS = 20
|
|
45
|
+
|
|
46
|
+
/** True once `tok` has at least `n` distinct chars (early-exit). ASCII-only
|
|
47
|
+
* by construction — CANDIDATE_RE admits no code point ≥ 128. */
|
|
48
|
+
function hasDistinctChars(tok: string, n: number): boolean {
|
|
49
|
+
const seen = new Uint8Array(128)
|
|
50
|
+
let distinct = 0
|
|
51
|
+
for (let i = 0; i < tok.length; i++) {
|
|
52
|
+
const c = tok.charCodeAt(i)
|
|
53
|
+
if (seen[c] === 0) {
|
|
54
|
+
seen[c] = 1
|
|
55
|
+
if (++distinct >= n) return true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasDigit(tok: string): boolean {
|
|
62
|
+
for (let i = 0; i < tok.length; i++) {
|
|
63
|
+
const c = tok.charCodeAt(i)
|
|
64
|
+
if (c >= 48 && c <= 57) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function scanGenericSecrets(text: string): RawHit[] {
|
|
70
|
+
const hits: RawHit[] = []
|
|
71
|
+
CANDIDATE_RE.lastIndex = 0
|
|
72
|
+
let m: RegExpExecArray | null
|
|
73
|
+
while ((m = CANDIDATE_RE.exec(text)) !== null) {
|
|
74
|
+
if (hits.length >= MAX_GENERIC_HITS) break
|
|
75
|
+
const tok = m[0]
|
|
76
|
+
if (!hasDigit(tok)) continue
|
|
77
|
+
if (!hasDistinctChars(tok, GENERIC_MIN_DISTINCT)) continue
|
|
78
|
+
hits.push({
|
|
79
|
+
rule_id: 'generic_high_entropy',
|
|
80
|
+
start: m.index,
|
|
81
|
+
end: m.index + tok.length,
|
|
82
|
+
matched_text: tok,
|
|
83
|
+
confidence: 'ambiguous',
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
return hits
|
|
87
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { ALL_PATTERNS } from './patterns.js'
|
|
27
27
|
import { scanKeyValue, type RawHit } from './kv-scanner.js'
|
|
28
|
+
import { scanGenericSecrets } from './generic-entropy.js'
|
|
28
29
|
import { shannonEntropy } from './entropy.js'
|
|
29
30
|
import { chunk } from './chunker.js'
|
|
30
31
|
import { isSuppressed } from './suppressor.js'
|
|
@@ -118,6 +119,14 @@ export function detectSecrets(text: string): Detection[] {
|
|
|
118
119
|
for (const h of kvHits) {
|
|
119
120
|
raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
|
|
120
121
|
}
|
|
122
|
+
// Generic bare-high-entropy fallback (ambiguous). Catches standalone
|
|
123
|
+
// tokens no prefix/KV rule matched. dropOverlaps/dedupeRaw below prefer
|
|
124
|
+
// a high-confidence pattern hit over a generic one on the same range,
|
|
125
|
+
// so a recognized token isn't double-flagged.
|
|
126
|
+
const genHits = scanGenericSecrets(win.text)
|
|
127
|
+
for (const h of genHits) {
|
|
128
|
+
raw.push({ ...h, start: h.start + win.offset, end: h.end + win.offset })
|
|
129
|
+
}
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
// Dedupe by range + rule. If two rules hit the same range, prefer the
|
|
@@ -171,24 +180,28 @@ function dedupeRaw(raw: RawHit[]): RawHit[] {
|
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
/**
|
|
174
|
-
* Drop
|
|
175
|
-
*
|
|
176
|
-
* Authorization Bearer match
|
|
183
|
+
* Drop an AMBIGUOUS hit that is fully contained inside another (larger)
|
|
184
|
+
* hit — e.g. a `generic_high_entropy` sub-span sitting inside a recognized
|
|
185
|
+
* high token, or inside an Authorization Bearer match. Narrow by design:
|
|
186
|
+
* it never drops a high-confidence hit and never touches high-vs-high
|
|
187
|
+
* overlaps, so it can't suppress a real detection — it only removes the
|
|
188
|
+
* redundant low-precision sub-spans the generic fallback can emit.
|
|
177
189
|
*/
|
|
178
190
|
function dropOverlaps(hits: RawHit[]): RawHit[] {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
const out = hits.filter(
|
|
192
|
+
(h) =>
|
|
193
|
+
!(
|
|
194
|
+
h.confidence === 'ambiguous' &&
|
|
195
|
+
hits.some(
|
|
196
|
+
(o) =>
|
|
197
|
+
o !== h &&
|
|
198
|
+
o.start <= h.start &&
|
|
199
|
+
o.end >= h.end &&
|
|
200
|
+
!(o.start === h.start && o.end === h.end),
|
|
201
|
+
)
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
// Sort by start offset for deterministic downstream handling.
|
|
192
205
|
out.sort((a, b) => a.start - b.start || a.end - b.end)
|
|
193
206
|
return out
|
|
194
207
|
}
|
|
@@ -217,16 +230,22 @@ export async function detectSecretsAsync(text: string): Promise<Detection[]> {
|
|
|
217
230
|
import('./secretlint-source.js').then((m) => m.detectViaSecretlint(text)),
|
|
218
231
|
])
|
|
219
232
|
|
|
220
|
-
// Merge with range-based dedupe.
|
|
233
|
+
// Merge with range-based dedupe. On an exact-range tie, prefer the
|
|
234
|
+
// higher-confidence detection (else vendored-first). This matters since
|
|
235
|
+
// the vendored generic high-entropy fallback emits `ambiguous` — without
|
|
236
|
+
// the confidence tie-break it would shadow a Secretlint `high` provider
|
|
237
|
+
// hit on the same span and silently downgrade it (mirrors the sync
|
|
238
|
+
// dedupeRaw's high-over-ambiguous rule).
|
|
221
239
|
const seen = new Map<string, Detection>()
|
|
222
|
-
|
|
240
|
+
const consider = (d: Detection): void => {
|
|
223
241
|
const key = `${d.start}:${d.end}`
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (!seen.has(key)) seen.set(key, d)
|
|
242
|
+
const existing = seen.get(key)
|
|
243
|
+
if (!existing || (existing.confidence === 'ambiguous' && d.confidence === 'high')) {
|
|
244
|
+
seen.set(key, d)
|
|
245
|
+
}
|
|
229
246
|
}
|
|
247
|
+
for (const d of vendored) consider(d)
|
|
248
|
+
for (const d of viaSecretlint) consider(d)
|
|
230
249
|
|
|
231
250
|
// Re-derive slugs against the merged set (Secretlint and vendored each
|
|
232
251
|
// had independent `existing` sets; we coalesce here).
|
|
@@ -118,6 +118,68 @@ export const STRUCTURED_PATTERNS: PatternDef[] = [
|
|
|
118
118
|
]
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
121
|
+
* High-precision PREFIXED provider patterns (gitleaks/secret-scanner style).
|
|
122
|
+
*
|
|
123
|
+
* Every rule here has a distinctive literal prefix + length, so false
|
|
124
|
+
* positives on ordinary chat/code are near-zero — which is load-bearing,
|
|
125
|
+
* because the inbound gate DELETES a message on a high-confidence hit.
|
|
126
|
+
* We deliberately keep ONLY prefix-anchored rules here; a generic
|
|
127
|
+
* "any high-entropy string" detector (the bare-token gap that let the
|
|
128
|
+
* Sanctum `<id>|<token>` slip) is intentionally NOT here — that's a
|
|
129
|
+
* separate, ambiguous-routed change so it can't auto-delete on a guess.
|
|
130
|
+
*
|
|
131
|
+
* Baked as TS (not loaded from the vendored gitleaks.toml at runtime):
|
|
132
|
+
* the bundler inlines secret-detect into dist/server.js + gateway.js and
|
|
133
|
+
* does NOT ship the .toml alongside, so a runtime `loadGitleaksPatterns()`
|
|
134
|
+
* would silently resolve to nothing in the agent image. TS entries flow
|
|
135
|
+
* through ALL_PATTERNS into the shared detectSecrets engine, so they
|
|
136
|
+
* protect the inbound gate, the outbound mask, AND the issues pipeline.
|
|
137
|
+
*
|
|
138
|
+
* Provider prefixes already in ANCHORED_PATTERNS (sk-ant-, sk-, ghp_,
|
|
139
|
+
* github_pat_, AKIA, AIza, xox*, gsk_, pplx-, npm_, telegram id:token, jwt)
|
|
140
|
+
* are intentionally omitted to avoid duplicate hits.
|
|
141
|
+
*/
|
|
142
|
+
export const PROVIDER_PATTERNS: PatternDef[] = [
|
|
143
|
+
{ rule_id: 'slack_webhook', regex: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_/]+)/g, captureIndex: 1, slugHint: 'slack_webhook' },
|
|
144
|
+
{ rule_id: 'stripe_live_secret', regex: /\b(sk_live_[A-Za-z0-9]{24,})\b/g, captureIndex: 1, slugHint: 'stripe_key' },
|
|
145
|
+
{ rule_id: 'stripe_restricted', regex: /\b(rk_live_[A-Za-z0-9]{24,})\b/g, captureIndex: 1, slugHint: 'stripe_key' },
|
|
146
|
+
{ rule_id: 'sendgrid_api_key', regex: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})\b/g, captureIndex: 1, slugHint: 'sendgrid_key' },
|
|
147
|
+
{ rule_id: 'gitlab_pat', regex: /\b(glpat-[A-Za-z0-9_-]{20})\b/g, captureIndex: 1, slugHint: 'gitlab_pat' },
|
|
148
|
+
{ rule_id: 'huggingface_token', regex: /\b(hf_[A-Za-z0-9]{34,})\b/g, captureIndex: 1, slugHint: 'huggingface_token' },
|
|
149
|
+
// (openai sk-proj-/sk-svcacct- are already covered by the anchored
|
|
150
|
+
// `openai_api_key` sk- rule — no separate entry needed.)
|
|
151
|
+
{ rule_id: 'twilio_api_key', regex: /\b(SK[0-9a-f]{32})\b/g, captureIndex: 1, slugHint: 'twilio_api_key' },
|
|
152
|
+
{ rule_id: 'mailgun_key', regex: /\b(key-[0-9a-f]{32})\b/g, captureIndex: 1, slugHint: 'mailgun_key' },
|
|
153
|
+
// (mailchimp keys are `<32-hex>-us<N>` with NO distinctive prefix — that
|
|
154
|
+
// collides with md5 hashes / ETags followed by `-usN` and would auto-delete
|
|
155
|
+
// benign messages. Deferred to the planned generic high-entropy detector,
|
|
156
|
+
// which asks instead of auto-deleting. Review #2054.)
|
|
157
|
+
{ rule_id: 'digitalocean_pat', regex: /\b(dop_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
|
|
158
|
+
{ rule_id: 'digitalocean_oauth', regex: /\b(doo_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
|
|
159
|
+
{ rule_id: 'digitalocean_refresh', regex: /\b(dor_v1_[a-f0-9]{64})\b/g, captureIndex: 1, slugHint: 'digitalocean_token' },
|
|
160
|
+
{ rule_id: 'doppler_token', regex: /\b(dp\.(?:pt|st|ct|sa|scim|audit)\.[A-Za-z0-9]{40,44})\b/g, captureIndex: 1, slugHint: 'doppler_token' },
|
|
161
|
+
{ rule_id: 'linear_api_key', regex: /\b(lin_api_[A-Za-z0-9]{40})\b/g, captureIndex: 1, slugHint: 'linear_api_key' },
|
|
162
|
+
{ rule_id: 'shopify_access_token', regex: /\b(shpat_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
|
|
163
|
+
{ rule_id: 'shopify_shared_secret', regex: /\b(shpss_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
|
|
164
|
+
{ rule_id: 'shopify_private_app', regex: /\b(shppa_[a-fA-F0-9]{32})\b/g, captureIndex: 1, slugHint: 'shopify_token' },
|
|
165
|
+
{ rule_id: 'square_access_token', regex: /\b(sq0atp-[A-Za-z0-9_-]{22})\b/g, captureIndex: 1, slugHint: 'square_token' },
|
|
166
|
+
{ rule_id: 'square_oauth_secret', regex: /\b(sq0csp-[A-Za-z0-9_-]{43})\b/g, captureIndex: 1, slugHint: 'square_token' },
|
|
167
|
+
{ rule_id: 'newrelic_key', regex: /\b(NRAK-[A-Z0-9]{27})\b/g, captureIndex: 1, slugHint: 'newrelic_key' },
|
|
168
|
+
{ rule_id: 'notion_token', regex: /\b(ntn_[A-Za-z0-9]{46})\b/g, captureIndex: 1, slugHint: 'notion_token' },
|
|
169
|
+
{ rule_id: 'planetscale_password', regex: /\b(pscale_pw_[A-Za-z0-9_.-]{43})\b/g, captureIndex: 1, slugHint: 'planetscale_token' },
|
|
170
|
+
{ rule_id: 'planetscale_token', regex: /\b(pscale_tkn_[A-Za-z0-9_.-]{43})\b/g, captureIndex: 1, slugHint: 'planetscale_token' },
|
|
171
|
+
{ rule_id: 'supabase_service_key', regex: /\b(sbp_[a-f0-9]{40})\b/g, captureIndex: 1, slugHint: 'supabase_key' },
|
|
172
|
+
{ rule_id: 'atlassian_token', regex: /\b(ATATT[A-Za-z0-9_\-=]{20,})\b/g, captureIndex: 1, slugHint: 'atlassian_token' },
|
|
173
|
+
{ rule_id: 'dropbox_token', regex: /\b(sl\.[A-Za-z0-9_-]{130,})/g, captureIndex: 1, slugHint: 'dropbox_token' },
|
|
174
|
+
{ rule_id: 'databricks_token', regex: /\b(dapi[a-f0-9]{32})\b/g, captureIndex: 1, slugHint: 'databricks_token' },
|
|
175
|
+
{ rule_id: 'grafana_service_account', regex: /\b(glsa_[A-Za-z0-9]{32}_[a-fA-F0-9]{8})\b/g, captureIndex: 1, slugHint: 'grafana_token' },
|
|
176
|
+
{ rule_id: 'pypi_token', regex: /\b(pypi-AgEIcHlwaS[A-Za-z0-9_-]{50,})/g, captureIndex: 1, slugHint: 'pypi_token' },
|
|
177
|
+
{ rule_id: 'aws_temp_access_key', regex: /\b(ASIA[0-9A-Z]{16})\b/g, captureIndex: 1, slugHint: 'aws_access_key' },
|
|
178
|
+
{ rule_id: 'gcp_oauth_token', regex: /\b(ya29\.[A-Za-z0-9_-]{30,})/g, captureIndex: 1, slugHint: 'gcp_oauth_token' },
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Concatenated registry — anchored + provider prefixes first (high
|
|
183
|
+
* precision), then structured.
|
|
122
184
|
*/
|
|
123
|
-
export const ALL_PATTERNS: PatternDef[] = [...ANCHORED_PATTERNS, ...STRUCTURED_PATTERNS]
|
|
185
|
+
export const ALL_PATTERNS: PatternDef[] = [...ANCHORED_PATTERNS, ...PROVIDER_PATTERNS, ...STRUCTURED_PATTERNS]
|
|
@@ -50,7 +50,16 @@ export function redact(text: string): string {
|
|
|
50
50
|
const urlScrubbed = redactUrls(text)
|
|
51
51
|
|
|
52
52
|
// Step 2 — token shape detection over the URL-scrubbed text.
|
|
53
|
-
|
|
53
|
+
// EXCLUDE the generic high-entropy fallback: it is a low-precision
|
|
54
|
+
// "looks like a secret" signal (it flags dense technical identifiers —
|
|
55
|
+
// CamelCase class names, snake_case symbols, npm paths, slugs — as well
|
|
56
|
+
// as real tokens). It exists to drive the inbound "stash to vault or
|
|
57
|
+
// ignore?" ASK prompt, NOT to silently mask. Letting it into redact()
|
|
58
|
+
// would corrupt agent replies (the outbound mask) and stored messages.
|
|
59
|
+
// Only prefix/structured (high) + the contextual kv_entropy hits mask.
|
|
60
|
+
const hits: Detection[] = detectSecrets(urlScrubbed).filter(
|
|
61
|
+
(h) => h.rule_id !== 'generic_high_entropy',
|
|
62
|
+
)
|
|
54
63
|
if (hits.length === 0) return urlScrubbed
|
|
55
64
|
|
|
56
65
|
// Apply replacements right-to-left so byte offsets stay valid.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { detectSecrets } from '../secret-detect/index.js'
|
|
3
|
+
import { scanGenericSecrets, GENERIC_MIN_DISTINCT } from '../secret-detect/generic-entropy.js'
|
|
4
|
+
import { redact } from '../secret-detect/redact.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generic bare-high-entropy fallback (#1) — the long-tail detector for
|
|
8
|
+
* standalone tokens that no prefix/KV rule matches (the Sanctum class).
|
|
9
|
+
* Emitted at `ambiguous` confidence: the inbound gate ASKS ("stash to
|
|
10
|
+
* vault or ignore?") rather than auto-deleting, so recall can be generous.
|
|
11
|
+
*
|
|
12
|
+
* Fixtures built by concatenation (no contiguous secret-shaped literals).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// 32 varied base62 chars → high entropy (~5 bits/char).
|
|
16
|
+
const HIGH_ENTROPY = 'q7Wm2Zx9' + 'Lk4Rp1Vn' + '8Bs3Yt6H' + 'd5Gj0Fc7'
|
|
17
|
+
// 32 chars but only 3 distinct → low entropy (< 4), must NOT flag.
|
|
18
|
+
const LOW_ENTROPY = 'abc'.repeat(11) // 33 chars, entropy ~1.6
|
|
19
|
+
|
|
20
|
+
describe('generic high-entropy detector', () => {
|
|
21
|
+
it('flags a standalone high-entropy token as ambiguous', () => {
|
|
22
|
+
const hits = detectSecrets(`the value is ${HIGH_ENTROPY} ok`)
|
|
23
|
+
const hit = hits.find((d) => d.rule_id === 'generic_high_entropy')
|
|
24
|
+
expect(hit).toBeDefined()
|
|
25
|
+
expect(hit!.matched_text).toBe(HIGH_ENTROPY)
|
|
26
|
+
expect(hit!.confidence).toBe('ambiguous') // asks, never auto-deletes
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('redact() does NOT mask a generic-flagged token (the #2059 outbound-corruption regression)', () => {
|
|
30
|
+
// HIGH_ENTROPY flags as generic_high_entropy (ambiguous). redact() — the
|
|
31
|
+
// chokepoint for the outbound reply mask + history + issues — must leave
|
|
32
|
+
// it intact; masking it would corrupt agent replies. This is the exact
|
|
33
|
+
// BLOCK that shipped to review; pin it.
|
|
34
|
+
const text = `use ${HIGH_ENTROPY} for the deploy`
|
|
35
|
+
expect(redact(text)).toBe(text)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('respects the distinct-char floor (repetitive long strings do not flag)', () => {
|
|
39
|
+
expect(scanGenericSecrets(LOW_ENTROPY).length).toBe(0) // 3 distinct < 18
|
|
40
|
+
expect(GENERIC_MIN_DISTINCT).toBe(18)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('caps hits on pathological input (bounds the O(n²) overlap-dedup)', () => {
|
|
44
|
+
// 100 distinct high-entropy tokens; the scanner must not return all 100.
|
|
45
|
+
const blob = Array.from({ length: 100 }, (_, i) =>
|
|
46
|
+
('q7Wm2Zx9Lk4Rp1Vn8Bs3Yt6H' + 'd5Gj0Fc7') + String(i).padStart(3, '0'),
|
|
47
|
+
).join(' ')
|
|
48
|
+
expect(scanGenericSecrets(blob).length).toBeLessThanOrEqual(20)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('respects the length floor (short tokens do not flag)', () => {
|
|
52
|
+
const short = 'q7Wm2Zx9Lk4Rp1Vn' // 16 chars
|
|
53
|
+
expect(scanGenericSecrets(short).length).toBe(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does NOT downgrade a recognized high-confidence token', () => {
|
|
57
|
+
// A ghp_ token is matched by the anchored pattern (high). The generic
|
|
58
|
+
// pass must not swallow/downgrade it to ambiguous.
|
|
59
|
+
const ghp = 'ghp_' + 'A1b2C3d4E5'.repeat(3) // ghp_ + 30
|
|
60
|
+
const hits = detectSecrets(`token ${ghp} here`)
|
|
61
|
+
const ghpHit = hits.find((d) => d.matched_text === ghp || d.rule_id === 'github_pat_classic')
|
|
62
|
+
expect(ghpHit).toBeDefined()
|
|
63
|
+
expect(ghpHit!.confidence).toBe('high')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('false-positive guards — benign high-entropy shapes do NOT flag', () => {
|
|
67
|
+
const BENIGN: Array<[string, string]> = [
|
|
68
|
+
['a UUID', '550e8400-e29b-41d4-a716-446655440000'],
|
|
69
|
+
['a git SHA (40 hex)', 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'],
|
|
70
|
+
['a sha256 (64 hex)', 'e3b0c44298fc1c149afbf4c8996fb924' + '27ae41e4649b934ca495991b7852b855'],
|
|
71
|
+
['an md5 (32 hex)', 'd41d8cd98f00b204' + 'e9800998ecf8427e'],
|
|
72
|
+
['a long digit run', '123456789012345678901234567890'],
|
|
73
|
+
['plain prose', 'the quick brown fox jumps over the lazy dog repeatedly today'],
|
|
74
|
+
['a file path', '/usr/local/lib/python3.11/site-packages/somepackage/internal/module.py'],
|
|
75
|
+
// Dense technical identifiers — the FP shapes the reviewer flagged.
|
|
76
|
+
// CamelCase-no-digit → killed by the digit requirement; separator
|
|
77
|
+
// styles (snake/kebab/npm/slug) → broken into sub-28 runs by the
|
|
78
|
+
// charset (no `_ - / .`).
|
|
79
|
+
['a CamelCase class name', 'AbstractSingletonProxyFactoryBeanGenerator'],
|
|
80
|
+
['a snake_case symbol', 'get_user_profile_by_organization_identifier'],
|
|
81
|
+
['a kebab-case slug', 'how-to-configure-kubernetes-ingress-with-cert-manager'],
|
|
82
|
+
['an npm package path', '@babel/plugin-transform-modules-commonjs'],
|
|
83
|
+
['a CSS class string (has a digit)', 'flex-row-justify-between-items-center-gap-4'],
|
|
84
|
+
['a long CamelCase phrase', 'TheQuickBrownFoxJumpsOverTheLazyDogToday'],
|
|
85
|
+
['a 32-char base62 with NO digit', 'AbcdefGhijkLmnopQrstuVwxyzABCDEFG'],
|
|
86
|
+
]
|
|
87
|
+
for (const [label, text] of BENIGN) {
|
|
88
|
+
it(`${label} does not flag generic_high_entropy`, () => {
|
|
89
|
+
const hits = detectSecrets(text).filter((d) => d.rule_id === 'generic_high_entropy')
|
|
90
|
+
expect(hits, `unexpected: ${JSON.stringify(hits.map((h) => h.matched_text))}`).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
})
|