@ciphyrshq/sdk 3.0.0 → 3.0.1

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.
@@ -1,446 +0,0 @@
1
- /**
2
- * protectTool — authorization wrapper for the published @ciphyrshq/sdk.
3
- * Run with: npm test (node --test src/**\/*.test.js)
4
- *
5
- * This package ships to customers at v2.6.0 and had no tests at all, while the
6
- * 0.1.0 `ciphyrs` package next door had seventy. The gap showed: protectTool
7
- * accepted a redact_args verdict as "permitted" and then invoked the tool with
8
- * the CALLER'S ORIGINAL ARGUMENTS, so a redaction rule reported success on the
9
- * dashboard while the tool received the raw value. Nothing was wired to notice.
10
- *
11
- * So the organising question here is not "does it block?" — blocking was fine.
12
- * It is: WHEN THE POLICY SAYS SOMETHING OTHER THAN YES OR NO, DOES ANYTHING
13
- * ACTUALLY HAPPEN?
14
- */
15
-
16
- import './no-network.test-helper.js'
17
- import { describe, it, beforeEach, afterEach } from 'node:test'
18
- import assert from 'node:assert/strict'
19
- import { protectTool, ToolBlocked, ToolApprovalTimeout } from './protect-tool.js'
20
- import { spanStorage, traceStorage, remoteStorage } from './context.js'
21
-
22
- // protectTool announces wrapped tools on a 2s debounce. The timer is unref'd
23
- // so it cannot hold the runner open, but a client without _announceTools skips
24
- // the registry entirely and keeps these tests free of background work.
25
- const clientOf = (toolCheck, approvalStatus) => ({
26
- _toolCheck: toolCheck,
27
- _approvalStatus: approvalStatus,
28
- })
29
-
30
- const wrap = (client, fn, opts = {}) =>
31
- protectTool(client, { agent: 'billing-bot', name: 'charge', ...opts }, fn)
32
-
33
- // ── redact_args: the verdict that did nothing ───────────────────────────────
34
-
35
- describe('protectTool — redact_args must actually redact', () => {
36
- const REDACTED = { card: '****4242', amount: 50 }
37
-
38
- it('invokes the tool with the server\'s redacted args, not the caller\'s', async () => {
39
- let seen
40
- const charge = wrap(clientOf(async () => ({ action: 'redact_args', redacted_args: REDACTED })),
41
- async (a) => { seen = a; return 'ok' })
42
- await charge({ card: '4111111111114242', amount: 50 })
43
- assert.equal(seen.card, '****4242')
44
- assert.ok(!JSON.stringify(seen).includes('4111111111114242'),
45
- 'the raw PAN reached the tool despite a redaction policy')
46
- })
47
-
48
- it('still returns the tool\'s value', async () => {
49
- const charge = wrap(clientOf(async () => ({ action: 'redact_args', redacted_args: REDACTED })),
50
- async () => 'receipt-1')
51
- assert.equal(await charge({ card: '4111111111114242' }), 'receipt-1')
52
- })
53
-
54
- it('redacts positional arguments too, matching how they were sent', async () => {
55
- // serializeArgs puts positional args under `args`, so the redaction comes
56
- // back the same way. Unwrapping one shape and not the other would leave
57
- // positional callers silently unredacted.
58
- let seen
59
- const charge = wrap(clientOf(async () => ({
60
- action: 'redact_args', redacted_args: { args: ['****4242', 50] },
61
- })), async (...a) => { seen = a })
62
- await charge('4111111111114242', 50)
63
- assert.deepEqual(seen, ['****4242', 50])
64
- })
65
-
66
- it('refuses when redaction is demanded but none was supplied', async () => {
67
- // There is no safe way to guess what should have been masked, and running
68
- // on the originals is precisely the leak this closes.
69
- let ran = false
70
- const charge = wrap(clientOf(async () => ({ action: 'redact_args' })),
71
- async () => { ran = true })
72
- await assert.rejects(() => charge({ card: '4111111111114242' }), ToolBlocked)
73
- assert.equal(ran, false)
74
- })
75
-
76
- it('leaves args untouched on a plain allow', async () => {
77
- let seen
78
- const charge = wrap(clientOf(async () => ({ action: 'allow' })), async (a) => { seen = a })
79
- await charge({ card: '4111111111114242' })
80
- assert.equal(seen.card, '4111111111114242')
81
- })
82
- })
83
-
84
- // ── The rest of the verdict surface ─────────────────────────────────────────
85
-
86
- describe('protectTool — verdicts', () => {
87
- it('runs the tool on allow', async () => {
88
- let ran = false
89
- await wrap(clientOf(async () => ({ action: 'allow' })), async () => { ran = true })({})
90
- assert.equal(ran, true)
91
- })
92
-
93
- it('runs the tool on log_only — it observes, it does not stop', async () => {
94
- let ran = false
95
- await wrap(clientOf(async () => ({ action: 'log_only' })), async () => { ran = true })({})
96
- assert.equal(ran, true)
97
- })
98
-
99
- it('does not run the tool on block, and carries the rule that fired', async () => {
100
- let ran = false
101
- const charge = wrap(clientOf(async () => ({
102
- action: 'block', reason: 'over the ceiling',
103
- rule_id: 'r-1', rule_name: 'Refund ceiling', decision_id: 'd-9',
104
- })), async () => { ran = true })
105
- await assert.rejects(charge, (e) => {
106
- assert.ok(e instanceof ToolBlocked)
107
- assert.equal(e.ruleId, 'r-1')
108
- assert.equal(e.ruleName, 'Refund ceiling')
109
- assert.equal(e.decisionId, 'd-9')
110
- return true
111
- })
112
- assert.equal(ran, false, 'the blocked tool executed anyway')
113
- })
114
-
115
- it('blocks on an action it does not recognise — allow-list, not deny-list', async () => {
116
- const charge = wrap(clientOf(async () => ({ action: 'quarantine' })), async () => {})
117
- await assert.rejects(charge, ToolBlocked)
118
- })
119
-
120
- it('accepts `decision` as an alias for `action`', async () => {
121
- let ran = false
122
- await wrap(clientOf(async () => ({ decision: 'allow' })), async () => { ran = true })({})
123
- assert.equal(ran, true)
124
- })
125
- })
126
-
127
- // ── Reachability ────────────────────────────────────────────────────────────
128
- //
129
- // This block changed posture. protectTool used to fail OPEN, so an outage of
130
- // OURS executed the customer's tool with nobody having authorised it — while
131
- // guard.wrap and scan.protect on the same client refused to proceed under the
132
- // identical outage. Tool execution is the path that moves money, so it is the
133
- // one that gets the safe default.
134
-
135
- describe('protectTool — when Ciphyrs is unreachable', () => {
136
- const boom = () => clientOf(async () => { throw new Error('ECONNREFUSED') })
137
-
138
- it('fails CLOSED by default — the tool does not run', async () => {
139
- let ran = false
140
- await assert.rejects(wrap(boom(), async () => { ran = true })({}), ToolBlocked)
141
- assert.equal(ran, false, 'an outage of ours executed an unauthorised tool call')
142
- })
143
-
144
- it('says what happened and how to opt out', async () => {
145
- // A default that changes under a customer on upgrade has to explain
146
- // itself in the one place they will actually look: the error.
147
- await assert.rejects(wrap(boom(), async () => {})({}), (e) => {
148
- assert.ok(/did not run/i.test(e.message), e.message)
149
- assert.ok(/fails closed by default/i.test(e.message), e.message)
150
- assert.ok(/failOpen: true/.test(e.message), e.message)
151
- return true
152
- })
153
- })
154
-
155
- it('runs the tool when the call explicitly asks to fail open', async () => {
156
- let ran = false
157
- await wrap(boom(), async () => { ran = true }, { failOpen: true })({})
158
- assert.equal(ran, true)
159
- })
160
-
161
- it('runs the tool when the CLIENT asks to fail open', async () => {
162
- let ran = false
163
- const c = { ...boom(), _failOpen: true }
164
- await wrap(c, async () => { ran = true })({})
165
- assert.equal(ran, true)
166
- })
167
-
168
- it('lets a call override a fail-open client back to closed', async () => {
169
- let ran = false
170
- const c = { ...boom(), _failOpen: true }
171
- await assert.rejects(wrap(c, async () => { ran = true }, { failOpen: false })({}), ToolBlocked)
172
- assert.equal(ran, false)
173
- })
174
-
175
- it('still honours the legacy failClosed: TRUE — against a fail-open client', async () => {
176
- // This assertion used to be made against a DEFAULT client, where the new
177
- // default already fails closed: it passed whether the legacy key was read
178
- // or thrown away, so it guarded nothing. The posture only has to be read
179
- // for the test to mean anything when something else is pulling the other
180
- // way — here, a client-wide failOpen the key must override.
181
- let ran = false
182
- const c = { ...boom(), _failOpen: true }
183
- await assert.rejects(wrap(c, async () => { ran = true }, { failClosed: true })({}), ToolBlocked)
184
- assert.equal(ran, false, 'the legacy failClosed: true no longer closes a fail-open client')
185
- })
186
-
187
- it('still honours the legacy failClosed: FALSE — nobody who asked for fail-open loses it', async () => {
188
- // The whole risk of changing a published default. `failClosed: false` is
189
- // a caller who wrote their posture down; re-reading it as "made no choice"
190
- // and applying the new default would be the silent upgrade break.
191
- let ran = false
192
- await wrap(boom(), async () => { ran = true }, { failClosed: false })({})
193
- assert.equal(ran, true)
194
- })
195
-
196
- it('a NULL failClosed is not a posture — it fails closed like any unset value', async () => {
197
- // A posture read out of a JSON config, a database column or a spread
198
- // default arrives as null far more often than as undefined, and `!null`
199
- // is true: this used to run the tool during an outage because nobody had
200
- // set the option.
201
- let ran = false
202
- await assert.rejects(wrap(boom(), async () => { ran = true }, { failClosed: null })({}), ToolBlocked)
203
- assert.equal(ran, false, 'an unset posture resolved to fail OPEN')
204
- })
205
-
206
- it('a string "false" posture is read as written, not as truthiness', async () => {
207
- // `Boolean('false')` is true, so failOpen from an env var said fail open.
208
- let ran = false
209
- await assert.rejects(wrap(boom(), async () => { ran = true }, { failOpen: 'false' })({}), ToolBlocked)
210
- assert.equal(ran, false)
211
- })
212
-
213
- it('a verdict-less response must not read as allow', async () => {
214
- // `action || decision || 'allow'` turns an empty 200 into permission. A
215
- // proxy or a half-deployed gateway produces exactly that.
216
- let ran = false
217
- const empty = clientOf(async () => ({ status: 'ok' }))
218
- await assert.rejects(wrap(empty, async () => { ran = true })({}),
219
- (e) => e instanceof ToolBlocked && /no verdict/i.test(e.message))
220
- assert.equal(ran, false)
221
- })
222
-
223
- it('a caller who fails open accepts the verdict-less response too', async () => {
224
- let ran = false
225
- await wrap(clientOf(async () => ({ status: 'ok' })), async () => { ran = true }, { failOpen: true })({})
226
- assert.equal(ran, true)
227
- })
228
- })
229
-
230
- // ── Which step is asking ────────────────────────────────────────────────────
231
- //
232
- // The gateway's trust.001 rule correlates an exported tool span against the
233
- // gate call that authorised it BY SPAN ID (lib/trust-checks.js,
234
- // unverifiedToolSpans). A span id is worth sending only when it names the span
235
- // this tool call will export as a TOOL span — and protectTool emits no span, so
236
- // the ambient span is normally the enclosing AGENT span.
237
- //
238
- // Sending that agent-span id is strictly worse than sending nothing, because
239
- // the gateway reads the two halves separately:
240
- // - the exact match compares ids, and an agent-span id can never equal a
241
- // tool-span id, so that path stays dead either way;
242
- // - spanCorrelatedAgents() reads ANY non-`gs_` id as proof this agent CAN be
243
- // correlated, which moves it out of trust.001's abstention and into
244
- // judgement on the unvalidated (agent, tool) ±2-minute fallback.
245
- //
246
- // So the wrong id converts "we refuse to judge you" into a high-severity
247
- // enforcement_bypass accusation decided by a guess. Field names below are the
248
- // gateway's: POST /v1/guard/tool-check destructures `trace_id` and `span_id`.
249
-
250
- describe('protectTool — span attribution on the tool gate', () => {
251
- const sent = () => {
252
- const calls = []
253
- return [calls, clientOf(async (p) => { calls.push(p); return { action: 'allow' } })]
254
- }
255
-
256
- it('does NOT send the id of an enclosing agent span — only its trace', async () => {
257
- const [calls, c] = sent()
258
- await spanStorage.run({ trace_id: 'tr_abc', span_id: 'sp_def', kind: 'agent' },
259
- () => wrap(c, async () => {})({ amount: 1 }))
260
- assert.equal(calls[0].trace_id, 'tr_abc', 'the trace id is always true and always useful')
261
- assert.ok(!('span_id' in calls[0]),
262
- 'sent an agent span id: it can never match a tool span, and it tells trust.001 to judge this agent on the fallback')
263
- })
264
-
265
- it('does not send the id of an llm or retriever span either', async () => {
266
- for (const kind of ['llm', 'retriever', undefined]) {
267
- const [calls, c] = sent()
268
- await spanStorage.run({ trace_id: 'tr_k', span_id: 'sp_k', kind },
269
- () => wrap(c, async () => {})({}))
270
- assert.ok(!('span_id' in calls[0]), `sent the id of a ${kind} span`)
271
- }
272
- })
273
-
274
- it('DOES send the span id when the enclosing span is a tool span', async () => {
275
- // The one case where the wrapper knows the id names the span this call
276
- // will export: the customer opened `span(name, { kind: 'tool' })` around
277
- // it. This is the only way the exact-match path can ever fire.
278
- for (const kind of ['tool', 'tool_use', 'tool_call']) {
279
- const [calls, c] = sent()
280
- await spanStorage.run({ trace_id: 'tr_t', span_id: 'sp_tool', kind },
281
- () => wrap(c, async () => {})({}))
282
- assert.equal(calls[0].span_id, 'sp_tool', `dropped a ${kind} span id, which is the one worth sending`)
283
- assert.equal(calls[0].trace_id, 'tr_t')
284
- }
285
- })
286
-
287
- it('does not attribute to a remote parent span — that is the CALLER\'s span', async () => {
288
- // A propagated context carries no span kind, and the span it names belongs
289
- // to whoever called us. Its id can never be this tool call's tool span.
290
- const [calls, c] = sent()
291
- await remoteStorage.run({ trace_id: 'tr_rem', span_id: 'sp_rem' },
292
- () => wrap(c, async () => {})({}))
293
- assert.equal(calls[0].trace_id, 'tr_rem', 'the distributed trace is still worth reporting')
294
- assert.ok(!('span_id' in calls[0]), 'claimed the calling process\'s span as this tool call\'s span')
295
- })
296
-
297
- it('sends the trace id alone when a trace is open but no span is', async () => {
298
- const [calls, c] = sent()
299
- await traceStorage.run({ trace_id: 'tr_only' }, () => wrap(c, async () => {})({}))
300
- assert.equal(calls[0].trace_id, 'tr_only')
301
- assert.ok(!('span_id' in calls[0]), 'invented a span id for a trace with no open span')
302
- })
303
-
304
- it('OMITS both rather than fabricating when there is no span at all', async () => {
305
- const [calls, c] = sent()
306
- await wrap(c, async () => {})({})
307
- assert.ok(!('span_id' in calls[0]), 'fabricated a span id with no span open')
308
- assert.ok(!('trace_id' in calls[0]), 'fabricated a trace id with no trace open')
309
- })
310
-
311
- it('drops a gateway-shaped span id rather than echoing it back', async () => {
312
- // `gs_…` is what the gateway mints for a caller that sent nothing, and the
313
- // rule reads it as "not producer-supplied". An upstream can put one in our
314
- // baggage; sending it on would claim we chose it — even from a tool span.
315
- const [calls, c] = sent()
316
- await spanStorage.run({ trace_id: 'tr_x', span_id: 'gs_deadbeef', kind: 'tool' },
317
- () => wrap(c, async () => {})({}))
318
- assert.equal(calls[0].trace_id, 'tr_x')
319
- assert.ok(!('span_id' in calls[0]))
320
- })
321
- })
322
-
323
- describe('protectTool — the tracer supplies the span kind it will export', () => {
324
- // The rule above is only enforceable if the ambient store actually says what
325
- // kind of span it is. It did not until this change, which is why protectTool
326
- // could not tell an agent span from a tool span in the first place.
327
- it('a real tracer span carries its kind into the ambient context', async () => {
328
- const { CiphyrsTracer } = await import('./tracer.js')
329
- const { activeSpanKind } = await import('./context.js')
330
- // A fake client, a flush interval no test will reach, and no heartbeat:
331
- // this asserts what the ambient store holds, not what ingestion does.
332
- const collected = []
333
- const client = { _baseUrl: 'http://ciphyrs.invalid', trace: { ingest: async () => ({ ok: true }) } }
334
- const t = new CiphyrsTracer(client, { projectName: 'billing', flushIntervalMs: 3_600_000 })
335
- const trace = t.trace('checkout')
336
- await trace.span('refund', { kind: 'tool' }).run(async () => { collected.push(activeSpanKind()) })
337
- const s = trace.span('refund_again', { kind: 'tool' }).enter()
338
- collected.push(activeSpanKind())
339
- s.end()
340
- assert.deepEqual(collected, ['tool', 'tool'],
341
- 'the span kind did not reach the ambient store, so protectTool cannot tell a tool span from an agent span')
342
- })
343
- })
344
-
345
- // ── Human approval ──────────────────────────────────────────────────────────
346
-
347
- describe('protectTool — require_approval', () => {
348
- const pending = { action: 'require_approval', approval_id: 'a-1', approval_timeout_seconds: 0.2 }
349
- const opts = { pollIntervalMs: 1 }
350
-
351
- it('runs the tool once a human approves', async () => {
352
- let ran = false
353
- const statuses = [{ status: 'pending' }, { status: 'approved' }]
354
- let i = 0
355
- const c = clientOf(async () => pending, async () => statuses[Math.min(i++, 1)])
356
- await wrap(c, async () => { ran = true }, opts)({})
357
- assert.equal(ran, true)
358
- })
359
-
360
- for (const status of ['denied', 'timeout', 'cancelled']) {
361
- it(`blocks ON the ${status} itself, not by outwaiting the window`, async () => {
362
- // Asserting only "it throws" is too weak: a terminal status the loop
363
- // fails to RECOGNISE still ends in a throw once the deadline passes, so
364
- // the test stays green while the agent stalls for the whole window on a
365
- // decision already made. 'cancelled' was missing for exactly this reason.
366
- let ran = false
367
- const started = Date.now()
368
- const c = clientOf(async () => pending, async () => ({ status }))
369
- await assert.rejects(wrap(c, async () => { ran = true }, opts)({}), (e) => {
370
- assert.ok(e instanceof ToolBlocked)
371
- assert.ok(!(e instanceof ToolApprovalTimeout),
372
- 'blocked by running out the clock, not by reading the decision')
373
- assert.equal(e.action, status)
374
- return true
375
- })
376
- assert.equal(ran, false)
377
- assert.ok(Date.now() - started < 200, 'must return as soon as it is decided')
378
- })
379
- }
380
-
381
- it('blocks when the window closes with nobody deciding', async () => {
382
- // Silence is refusal.
383
- let ran = false
384
- const c = clientOf(async () => pending, async () => ({ status: 'pending' }))
385
- await assert.rejects(wrap(c, async () => { ran = true }, opts)({}), ToolApprovalTimeout)
386
- assert.equal(ran, false)
387
- })
388
-
389
- it('keeps polling through transient errors rather than giving up', async () => {
390
- let n = 0
391
- const c = clientOf(async () => pending, async () => {
392
- if (n++ < 2) throw new Error('ETIMEDOUT')
393
- return { status: 'approved' }
394
- })
395
- let ran = false
396
- await wrap(c, async () => { ran = true }, opts)({})
397
- assert.equal(ran, true)
398
- })
399
-
400
- it('blocks — never runs — if the poll endpoint is broken for the whole window', async () => {
401
- let ran = false
402
- const c = clientOf(async () => pending, async () => { throw new Error('ECONNREFUSED') })
403
- await assert.rejects(wrap(c, async () => { ran = true }, opts)({}), ToolApprovalTimeout)
404
- assert.equal(ran, false)
405
- })
406
- })
407
-
408
- // ── Wiring ──────────────────────────────────────────────────────────────────
409
-
410
- describe('protectTool — argument handling and wiring', () => {
411
- it('requires an agent name', () => {
412
- assert.throws(() => protectTool(clientOf(async () => ({})), {}, async () => {}),
413
- /opts.agent is required/)
414
- })
415
-
416
- it('requires a function', () => {
417
- assert.throws(() => protectTool(clientOf(async () => ({})), { agent: 'a' }, null),
418
- /a function is required/)
419
- })
420
-
421
- it('defaults the reported tool name to the function name', async () => {
422
- let sent
423
- const c = clientOf(async (p) => { sent = p; return { action: 'allow' } })
424
- await protectTool(c, { agent: 'billing-bot' }, async function issueRefund() {})({})
425
- assert.equal(sent.tool_name, 'issueRefund')
426
- assert.equal(sent.agent_name, 'billing-bot')
427
- })
428
-
429
- it('unwraps a lone object so rules can address fields by name', async () => {
430
- let sent
431
- const c = clientOf(async (p) => { sent = p; return { action: 'allow' } })
432
- await wrap(c, async () => {})({ amount: 85000 })
433
- // A rule reads `args.amount`; sending {args:[{amount}]} would never match.
434
- assert.equal(sent.args.amount, 85000)
435
- })
436
-
437
- it('survives arguments JSON cannot represent', async () => {
438
- let sent
439
- const c = clientOf(async (p) => { sent = p; return { action: 'allow' } })
440
- const cyclic = { name: 'x' }; cyclic.self = cyclic
441
- await wrap(c, async () => {})(cyclic)
442
- // Must not throw: a serialization failure inside the guard would take down
443
- // the tool call it is supposed to be protecting.
444
- assert.ok(sent !== undefined)
445
- })
446
- })
@@ -1,155 +0,0 @@
1
- /**
2
- * SecretDetector — credential scanning in the published @ciphyrshq/sdk.
3
- * Run with: npm test
4
- *
5
- * A detector has two ways to fail and they are not symmetric. A false positive
6
- * is noise. A false NEGATIVE is a credential that went out in a prompt and
7
- * nobody knew — so most of this file is "does it still find X", pinned per
8
- * pattern so a regex edit cannot quietly narrow one.
9
- *
10
- * The third failure is the one this suite was written after: the finding
11
- * ITSELF leaking the secret. Findings are meant to be safe to log and to show
12
- * on a dashboard, and value_masked used to reveal the first four and last four
13
- * characters regardless of length — which on a short match is all of them.
14
- */
15
-
16
- // No test in this package may open a socket — see no-network.test-helper.js.
17
- // Imported per file because node --test runs each file in its own process.
18
- import './no-network.test-helper.js'
19
- import { describe, it } from 'node:test'
20
- import assert from 'node:assert/strict'
21
- import { SecretDetector, maskSecret } from './secret-detector.js'
22
-
23
- const d = new SecretDetector()
24
- const types = (text) => d.detect(text).map((f) => f.type)
25
-
26
- // ── The masked value must not be the secret ─────────────────────────────────
27
-
28
- describe('maskSecret — a mask that hides less than half is not a mask', () => {
29
- it('reveals nothing from a short value', () => {
30
- // xoxb-1234 is a legal match for the Slack pattern (xox[bpras]- plus one
31
- // or more chars). Under the old rule it masked to "xoxb***1234".
32
- assert.equal(maskSecret('xoxb-1234'), '***')
33
- })
34
-
35
- it('never leaves the majority of a value visible, at any length', () => {
36
- for (let n = 1; n <= 80; n++) {
37
- const secret = 'a'.repeat(n)
38
- const revealed = maskSecret(secret).replace(/\*/g, '').length
39
- assert.ok(revealed * 2 <= n || revealed === 0,
40
- `length ${n}: ${revealed} of ${n} characters revealed`)
41
- }
42
- })
43
-
44
- it('is never longer than the value it masks, for values worth masking', () => {
45
- // "xoxb***1234" was eleven characters standing in for a nine-character
46
- // secret — a tell that the mask was adding information, not removing it.
47
- for (let n = 12; n <= 80; n++) {
48
- const secret = 'a'.repeat(n)
49
- assert.ok(maskSecret(secret).length <= n, `length ${n} grew`)
50
- }
51
- })
52
-
53
- it('still gives enough of a long key to identify it', () => {
54
- // The point of masking rather than dropping: an operator has to be able to
55
- // tell WHICH key leaked in order to rotate it.
56
- const masked = maskSecret('AKIAIOSFODNN7EXAMPLE')
57
- assert.ok(masked.startsWith('AKI'))
58
- assert.ok(masked.includes('***'))
59
- assert.ok(!masked.includes('IOSFODNN7'))
60
- })
61
-
62
- it('applies to what detect() reports, not just the helper', () => {
63
- const [finding] = d.detect('token=xoxb-1234')
64
- assert.ok(finding)
65
- assert.ok(!finding.value_masked.includes('1234'),
66
- 'the finding published the secret it found')
67
- })
68
- })
69
-
70
- // ── Per-pattern coverage ────────────────────────────────────────────────────
71
-
72
- describe('SecretDetector — finds each credential class', () => {
73
- const CASES = [
74
- ['aws_access_key', 'AKIAIOSFODNN7EXAMPLE'],
75
- ['github_token', 'ghp_' + 'a'.repeat(36)],
76
- ['gitlab_token', 'glpat-' + 'a'.repeat(20)],
77
- ['slack_token', 'xoxb-123456789012-abcdefghijkl'],
78
- ['stripe_key', 'sk_live_' + 'a'.repeat(24)],
79
- ['jwt', 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456'],
80
- ['private_key', '-----BEGIN RSA PRIVATE KEY-----'],
81
- ['generic_api_key', 'api_key="' + 'a'.repeat(24) + '"'],
82
- ]
83
-
84
- for (const [type, sample] of CASES) {
85
- it(`detects ${type}`, () => {
86
- assert.ok(types(sample).includes(type), `missed ${type} in: ${sample}`)
87
- })
88
- }
89
-
90
- it('finds a secret embedded in ordinary prose', () => {
91
- const text = `Here is the key you asked for: ghp_${'b'.repeat(36)} — do not share it.`
92
- assert.ok(types(text).includes('github_token'))
93
- })
94
-
95
- it('finds every occurrence, not just the first', () => {
96
- const text = `AKIAIOSFODNN7EXAMPLE and AKIA${'B'.repeat(16)}`
97
- assert.equal(types(text).filter((t) => t === 'aws_access_key').length, 2)
98
- })
99
-
100
- it('reports offsets that actually locate the match', () => {
101
- // A consumer redacts text.slice(start, end); off-by-one here leaves a
102
- // character of the credential behind.
103
- const text = `prefix ghp_${'c'.repeat(36)} suffix`
104
- const f = d.detect(text).find((x) => x.type === 'github_token')
105
- assert.equal(text.slice(f.start, f.end), `ghp_${'c'.repeat(36)}`)
106
- })
107
- })
108
-
109
- // ── Input handling ──────────────────────────────────────────────────────────
110
-
111
- describe('SecretDetector — inputs that are not text', () => {
112
- for (const [label, input] of [
113
- ['null', null], ['undefined', undefined], ['empty string', ''],
114
- ['a number', 42], ['an object', { a: 1 }], ['an array', []],
115
- ]) {
116
- it(`returns [] for ${label} rather than throwing`, () => {
117
- // This runs inside the request path of a shipped SDK; throwing here
118
- // would take down the call it is meant to be inspecting.
119
- assert.deepEqual(d.detect(input), [])
120
- })
121
- }
122
-
123
- it('finds nothing in clean text', () => {
124
- assert.deepEqual(d.detect('Please refund order 1234 for the customer.'), [])
125
- })
126
-
127
- it('does not carry regex state between calls', () => {
128
- // Every pattern is /g. Reusing a global regex across calls advances
129
- // lastIndex and makes the SECOND scan of the same text miss — the classic
130
- // way a detector goes quiet under load. detect() clones each pattern.
131
- const text = 'AKIAIOSFODNN7EXAMPLE'
132
- assert.deepEqual(types(text), types(text))
133
- assert.ok(types(text).includes('aws_access_key'))
134
- })
135
- })
136
-
137
- // ── Known imprecision, pinned deliberately ──────────────────────────────────
138
-
139
- describe('SecretDetector — documented false positives', () => {
140
- it('flags any 40-character base64-ish run as a possible AWS secret', () => {
141
- // A git SHA-1 is forty hex characters and matches. This is why the pattern
142
- // carries confidence 0.7 rather than 0.95: it is a prompt to look, not a
143
- // verdict. Pinned so that a future "cleanup" of the confidence field does
144
- // not silently promote this to a certainty.
145
- const findings = d.detect('commit ' + 'a'.repeat(40) + ' ')
146
- const aws = findings.find((f) => f.type === 'aws_secret_key')
147
- assert.ok(aws)
148
- assert.ok(aws.confidence < 0.8, 'a heuristic must not report high confidence')
149
- })
150
-
151
- it('keeps high confidence for patterns that are unambiguous', () => {
152
- const gh = d.detect('ghp_' + 'a'.repeat(36))[0]
153
- assert.ok(gh.confidence >= 0.95)
154
- })
155
- })