switchroom 0.18.12 → 0.18.13
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/agent-scheduler/index.js +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -90,13 +90,30 @@ function defaultDeriveName(agentDir: string): string {
|
|
|
90
90
|
return leaf;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Two-tier owner-identity resolution shared by `ownedWorktreeCwds` and
|
|
95
|
+
* `refreshOwnedWorktreeHeartbeats`:
|
|
96
|
+
* Tier 1 — env fast path (`SWITCHROOM_AGENT_NAME`).
|
|
97
|
+
* Tier 2 — durable fallback derived from the agent's OWN directory basename.
|
|
98
|
+
* Returns "" when neither source yields a usable identity — callers MUST treat
|
|
99
|
+
* "" as fail-closed (never guess ownership).
|
|
100
|
+
*/
|
|
101
|
+
export function resolveOwnerIdentity(
|
|
102
|
+
self: string | undefined,
|
|
103
|
+
agentDir: string | null | undefined,
|
|
104
|
+
deriveName?: (agentDir: string) => string,
|
|
105
|
+
): string {
|
|
106
|
+
let resolved: string = self != null ? self : "";
|
|
107
|
+
if (resolved === "" && agentDir != null && agentDir !== "") {
|
|
108
|
+
const derive = deriveName ?? defaultDeriveName;
|
|
109
|
+
resolved = derive(agentDir) || "";
|
|
110
|
+
}
|
|
111
|
+
return resolved;
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
|
|
94
115
|
// Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
|
|
95
|
-
|
|
96
|
-
if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
|
|
97
|
-
const derive = opts.deriveName ?? defaultDeriveName;
|
|
98
|
-
resolved = derive(opts.agentDir) || "";
|
|
99
|
-
}
|
|
116
|
+
const resolved = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
|
|
100
117
|
|
|
101
118
|
if (resolved === "") {
|
|
102
119
|
// Both env and durable config unavailable. Keep the historical
|
|
@@ -133,3 +150,175 @@ export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
|
|
|
133
150
|
return [];
|
|
134
151
|
}
|
|
135
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Default minimum interval (ms) between heartbeat writes for the same
|
|
156
|
+
* worktree record. The gateway invokes the refresh on every ~1s rescan tick;
|
|
157
|
+
* writing every tick would be needless churn. Refreshing at most every 2 min
|
|
158
|
+
* keeps every live claim's heartbeat FAR fresher than the reaper's 10-min
|
|
159
|
+
* `STALE_THRESHOLD_MS`, so a live claim never reads as stale, while a dead
|
|
160
|
+
* gateway (no ticks) lets the heartbeat age out and become reap-eligible.
|
|
161
|
+
*/
|
|
162
|
+
export const DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS = 2 * 60_000;
|
|
163
|
+
|
|
164
|
+
/** Minimal record shape needed to refresh a worktree heartbeat. */
|
|
165
|
+
export interface WorktreeHeartbeatRecord {
|
|
166
|
+
id: string;
|
|
167
|
+
ownerAgent?: string;
|
|
168
|
+
/** ISO 8601 timestamp of the record's current heartbeat (for throttling). */
|
|
169
|
+
heartbeatAt?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface RefreshOwnedHeartbeatsOptions {
|
|
173
|
+
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
|
|
174
|
+
self: string | undefined;
|
|
175
|
+
/** Durable, non-env identity fallback: the agent's OWN directory. */
|
|
176
|
+
agentDir?: string | null;
|
|
177
|
+
/** Host-global registry read (`listRecords` from src/worktree/registry). */
|
|
178
|
+
listRecords: () => WorktreeHeartbeatRecord[];
|
|
179
|
+
/** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
|
|
180
|
+
touchHeartbeat: (id: string) => void;
|
|
181
|
+
/**
|
|
182
|
+
* Skip a touch while the record's heartbeat is younger than this (ms).
|
|
183
|
+
* Defaults to `DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS`. Set to 0 to always
|
|
184
|
+
* touch (used by tests).
|
|
185
|
+
*/
|
|
186
|
+
minRefreshIntervalMs?: number;
|
|
187
|
+
/** `Date.now` override for tests. */
|
|
188
|
+
now?: () => number;
|
|
189
|
+
/** Injectable name derivation (defaults to `path.basename`). */
|
|
190
|
+
deriveName?: (agentDir: string) => string;
|
|
191
|
+
/** Best-effort error sink. */
|
|
192
|
+
log?: (msg: string) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Refresh the heartbeat of every worktree THIS agent owns.
|
|
197
|
+
*
|
|
198
|
+
* This is the production driver that keeps `touchHeartbeat` (previously dead —
|
|
199
|
+
* F1/H3) alive: the gateway calls it on the same rescan tick that re-derives
|
|
200
|
+
* the watched worktree cwds, so a claim held by a LIVE agent has its heartbeat
|
|
201
|
+
* advanced continuously and never trips the reaper's staleness gate. When the
|
|
202
|
+
* owning gateway dies, the ticks stop, the heartbeat ages past
|
|
203
|
+
* `STALE_THRESHOLD_MS`, and the (now fail-safe) reaper can reclaim the truly
|
|
204
|
+
* abandoned claim.
|
|
205
|
+
*
|
|
206
|
+
* Fail-CLOSED, mirroring `ownedWorktreeCwds`:
|
|
207
|
+
* - Unresolved identity ⇒ touch nothing (never guess ownership).
|
|
208
|
+
* - Ownerless records (`ownerAgent` undefined) are NEVER matched — we must
|
|
209
|
+
* not advance another agent's / an unattributable claim's heartbeat.
|
|
210
|
+
* - A registry read failure ⇒ touch nothing.
|
|
211
|
+
* - A per-record touch failure is swallowed (logged) — one bad record must
|
|
212
|
+
* not abort the rest, and this runs on the hot watch loop.
|
|
213
|
+
*
|
|
214
|
+
* @returns the number of heartbeats actually advanced this call.
|
|
215
|
+
*/
|
|
216
|
+
export function refreshOwnedWorktreeHeartbeats(
|
|
217
|
+
opts: RefreshOwnedHeartbeatsOptions,
|
|
218
|
+
): number {
|
|
219
|
+
const identity = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
|
|
220
|
+
if (identity === "") return 0; // fail-closed: never guess ownership
|
|
221
|
+
|
|
222
|
+
const nowMs = (opts.now ?? Date.now)();
|
|
223
|
+
const minInterval =
|
|
224
|
+
opts.minRefreshIntervalMs ?? DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS;
|
|
225
|
+
|
|
226
|
+
let records: WorktreeHeartbeatRecord[];
|
|
227
|
+
try {
|
|
228
|
+
records = opts.listRecords();
|
|
229
|
+
} catch {
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let touched = 0;
|
|
234
|
+
for (const r of records) {
|
|
235
|
+
if (r.ownerAgent !== identity) continue; // ownerless never matched
|
|
236
|
+
// Throttle: skip if the heartbeat is still comfortably fresh.
|
|
237
|
+
if (minInterval > 0 && r.heartbeatAt != null) {
|
|
238
|
+
const age = nowMs - new Date(r.heartbeatAt).getTime();
|
|
239
|
+
if (Number.isFinite(age) && age >= 0 && age < minInterval) continue;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
opts.touchHeartbeat(r.id);
|
|
243
|
+
touched++;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
opts.log?.(
|
|
246
|
+
`worktree heartbeat refresh failed for ${r.id}: ${(err as Error).message}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return touched;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A registry record carrying everything both watch operations need. */
|
|
254
|
+
export type WorktreeWatchRecord = WorktreeOwnershipRecord & WorktreeHeartbeatRecord;
|
|
255
|
+
|
|
256
|
+
export interface WorktreeWatchProviderOptions {
|
|
257
|
+
/** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
|
|
258
|
+
self: string | undefined;
|
|
259
|
+
/** Durable, non-env identity fallback: the agent's OWN directory. */
|
|
260
|
+
agentDir?: string | null;
|
|
261
|
+
/** Host-global registry read (`listRecords` from src/worktree/registry). */
|
|
262
|
+
listRecords: () => WorktreeWatchRecord[];
|
|
263
|
+
/** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
|
|
264
|
+
touchHeartbeat: (id: string) => void;
|
|
265
|
+
/** Injectable realpath for the cwd derivation (defaults to fs.realpathSync). */
|
|
266
|
+
realpath?: (p: string) => string;
|
|
267
|
+
/** Injectable name derivation from `agentDir` (defaults to path.basename). */
|
|
268
|
+
deriveName?: (agentDir: string) => string;
|
|
269
|
+
/** Heartbeat throttle window (ms); see refreshOwnedWorktreeHeartbeats. */
|
|
270
|
+
minRefreshIntervalMs?: number;
|
|
271
|
+
/** `Date.now` override for tests. */
|
|
272
|
+
now?: () => number;
|
|
273
|
+
/** Best-effort log sink shared by both operations. */
|
|
274
|
+
log?: (msg: string) => void;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Build the `extraWatchCwdsProvider` closure the gateway installs on the
|
|
279
|
+
* subagent watcher.
|
|
280
|
+
*
|
|
281
|
+
* The provider is invoked on every ~1s rescan tick and does TWO things on that
|
|
282
|
+
* single tick:
|
|
283
|
+
* 1. advances the heartbeat of every worktree THIS agent owns
|
|
284
|
+
* (`refreshOwnedWorktreeHeartbeats` — the production driver that keeps
|
|
285
|
+
* `touchHeartbeat` alive; without it every claim reads "stale" 10 min
|
|
286
|
+
* after creation and the reaper's staleness guarantee collapses), AND
|
|
287
|
+
* 2. returns the set of owned worktree cwds for the watcher to also watch
|
|
288
|
+
* (`ownedWorktreeCwds`).
|
|
289
|
+
*
|
|
290
|
+
* It is extracted from the gateway (rather than inlined) SO THAT the wiring —
|
|
291
|
+
* specifically that the provider actually DRIVES heartbeats, not merely returns
|
|
292
|
+
* cwds — is under direct unit test. Deleting the heartbeat refresh here is
|
|
293
|
+
* caught by the provider's behaviour test (it would return cwds but stop
|
|
294
|
+
* advancing heartbeats), which the pre-extraction inline closure could not
|
|
295
|
+
* assert against.
|
|
296
|
+
*
|
|
297
|
+
* Both operations use the SAME two-tier identity (env fast path + agentDir
|
|
298
|
+
* fallback), so a single `agentDir` kill-switch on the caller governs both.
|
|
299
|
+
* Fully best-effort: neither operation throws out of the returned closure.
|
|
300
|
+
*/
|
|
301
|
+
export function makeWorktreeWatchProvider(
|
|
302
|
+
opts: WorktreeWatchProviderOptions,
|
|
303
|
+
): () => string[] {
|
|
304
|
+
return () => {
|
|
305
|
+
refreshOwnedWorktreeHeartbeats({
|
|
306
|
+
self: opts.self,
|
|
307
|
+
agentDir: opts.agentDir,
|
|
308
|
+
listRecords: opts.listRecords,
|
|
309
|
+
touchHeartbeat: opts.touchHeartbeat,
|
|
310
|
+
minRefreshIntervalMs: opts.minRefreshIntervalMs,
|
|
311
|
+
now: opts.now,
|
|
312
|
+
deriveName: opts.deriveName,
|
|
313
|
+
log: opts.log,
|
|
314
|
+
});
|
|
315
|
+
return ownedWorktreeCwds({
|
|
316
|
+
self: opts.self,
|
|
317
|
+
agentDir: opts.agentDir,
|
|
318
|
+
listRecords: opts.listRecords,
|
|
319
|
+
realpath: opts.realpath,
|
|
320
|
+
deriveName: opts.deriveName,
|
|
321
|
+
log: opts.log,
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Secretlint wrapper — adapts `@secretlint/core` + the recommend preset into
|
|
3
|
-
* our `Detection` shape so it can merge with the vendored pattern engine.
|
|
4
|
-
*
|
|
5
|
-
* Secretlint is async (it loads rules, applies preset config, walks the
|
|
6
|
-
* source). This module exposes `detectViaSecretlint(text)` returning a
|
|
7
|
-
* Promise. The synchronous `detectSecrets()` path in `index.ts` stays the
|
|
8
|
-
* fast default; callers that want the full engine use `detectSecretsAsync()`
|
|
9
|
-
* which fans out both and merges.
|
|
10
|
-
*
|
|
11
|
-
* Slug derivation: Secretlint rules don't give us a clean LHS (KEY=value),
|
|
12
|
-
* so we derive from the rule id (e.g. `@secretlint/secretlint-rule-slack`
|
|
13
|
-
* becomes the slug `@secretlint-rule-slack_YYYYMMDD` via `deriveSlug`'s
|
|
14
|
-
* rule_id + date fallback path).
|
|
15
|
-
*
|
|
16
|
-
* Confidence tier: Secretlint is a curated engine with checksum-validated
|
|
17
|
-
* rules for most providers, so every hit is `high`.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { lintSource } from '@secretlint/core'
|
|
21
|
-
import { creator as presetRecommendCreator } from '@secretlint/secretlint-rule-preset-recommend'
|
|
22
|
-
import type { Detection } from './index.js'
|
|
23
|
-
import { deriveSlug } from './slug.js'
|
|
24
|
-
import { isSuppressed } from './suppressor.js'
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Map a single Secretlint rule id to a short rule slug used as the
|
|
28
|
-
* `Detection.rule_id`. The full `@secretlint/secretlint-rule-foo` names are
|
|
29
|
-
* long; we strip the scope/prefix to keep rule_ids readable in logs.
|
|
30
|
-
*/
|
|
31
|
-
function normalizeRuleId(secretlintRuleId: string): string {
|
|
32
|
-
// "@secretlint/secretlint-rule-slack" → "secretlint_slack"
|
|
33
|
-
// "secretlint-rule-custom-thing" → "secretlint_custom_thing"
|
|
34
|
-
const stripped = secretlintRuleId
|
|
35
|
-
.replace(/^@secretlint\//, '')
|
|
36
|
-
.replace(/^secretlint-rule-/, '')
|
|
37
|
-
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
38
|
-
.replace(/_+/g, '_')
|
|
39
|
-
.replace(/^_+|_+$/g, '')
|
|
40
|
-
return `secretlint_${stripped || 'unknown'}`
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function detectViaSecretlint(text: string): Promise<Detection[]> {
|
|
44
|
-
if (!text || text.length === 0) return []
|
|
45
|
-
|
|
46
|
-
let result
|
|
47
|
-
try {
|
|
48
|
-
result = await lintSource({
|
|
49
|
-
source: {
|
|
50
|
-
content: text,
|
|
51
|
-
filePath: 'message.txt',
|
|
52
|
-
ext: '.txt',
|
|
53
|
-
contentType: 'text',
|
|
54
|
-
},
|
|
55
|
-
options: {
|
|
56
|
-
config: {
|
|
57
|
-
rules: [
|
|
58
|
-
{
|
|
59
|
-
id: '@secretlint/secretlint-rule-preset-recommend',
|
|
60
|
-
rule: presetRecommendCreator,
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
},
|
|
64
|
-
noPhysicFilePath: true,
|
|
65
|
-
},
|
|
66
|
-
})
|
|
67
|
-
} catch {
|
|
68
|
-
// Fail-open: Secretlint crashes must never break the detector path.
|
|
69
|
-
return []
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const existing = new Set<string>()
|
|
73
|
-
const out: Detection[] = []
|
|
74
|
-
for (const msg of result.messages) {
|
|
75
|
-
const [start, end] = msg.range
|
|
76
|
-
if (typeof start !== 'number' || typeof end !== 'number' || end <= start) continue
|
|
77
|
-
const matched_text = text.slice(start, end)
|
|
78
|
-
if (!matched_text) continue
|
|
79
|
-
const rule_id = normalizeRuleId(msg.ruleId)
|
|
80
|
-
const suggested_slug = deriveSlug({ rule_id }, existing)
|
|
81
|
-
existing.add(suggested_slug)
|
|
82
|
-
out.push({
|
|
83
|
-
rule_id,
|
|
84
|
-
matched_text,
|
|
85
|
-
start,
|
|
86
|
-
end,
|
|
87
|
-
confidence: 'high',
|
|
88
|
-
suppressed: isSuppressed(text, start, end),
|
|
89
|
-
suggested_slug,
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
return out
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export { normalizeRuleId as __normalizeRuleId }
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
3
|
-
detectViaSecretlint,
|
|
4
|
-
detectSecretsAsync,
|
|
5
|
-
} from '../secret-detect/index.js'
|
|
6
|
-
|
|
7
|
-
// All three fixtures are assembled at runtime so the source file never
|
|
8
|
-
// contains a contiguous token pattern. Matches the corresponding
|
|
9
|
-
// secretlint regex when evaluated; evades GitHub Push Protection's
|
|
10
|
-
// static-text scan. See CLAUDE.md "Secrets in tests".
|
|
11
|
-
const SLACK_FIXTURE = ['xoxb', '0000000000', '0000000000000', 'FIXTURE0NOTAREALTOKEN000'].join('-')
|
|
12
|
-
const GITHUB_FIXTURE = 'ghp' + '_' + '16C7e42F292c6912E7710c838347Ae178B4a'
|
|
13
|
-
const NPM_FIXTURE = 'npm' + '_' + 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789'
|
|
14
|
-
|
|
15
|
-
describe('secretlint-source.detectViaSecretlint', () => {
|
|
16
|
-
it('catches a realistic-looking Slack bot token', async () => {
|
|
17
|
-
const text = 'Slack: ' + SLACK_FIXTURE
|
|
18
|
-
const hits = await detectViaSecretlint(text)
|
|
19
|
-
expect(hits.length).toBeGreaterThan(0)
|
|
20
|
-
const slack = hits.find((h) => h.rule_id.includes('slack'))
|
|
21
|
-
expect(slack).toBeDefined()
|
|
22
|
-
expect(slack!.confidence).toBe('high')
|
|
23
|
-
expect(slack!.suppressed).toBe(false)
|
|
24
|
-
// rule_id normalized from @secretlint/secretlint-rule-slack → secretlint_slack
|
|
25
|
-
expect(slack!.rule_id).toMatch(/^secretlint_/)
|
|
26
|
-
expect(slack!.rule_id).toContain('slack')
|
|
27
|
-
// Matched text should be the actual token bytes.
|
|
28
|
-
expect(slack!.matched_text.startsWith('xoxb-')).toBe(true)
|
|
29
|
-
// Slug derived from rule_id + date (rule_id fallback path).
|
|
30
|
-
expect(slack!.suggested_slug).toMatch(/^secretlint_slack_\d{8}/)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('catches a GitHub personal access token', async () => {
|
|
34
|
-
const text = 'token=' + GITHUB_FIXTURE + ' rest of message'
|
|
35
|
-
const hits = await detectViaSecretlint(text)
|
|
36
|
-
const gh = hits.find((h) => h.rule_id.includes('github'))
|
|
37
|
-
expect(gh).toBeDefined()
|
|
38
|
-
expect(gh!.confidence).toBe('high')
|
|
39
|
-
expect(gh!.matched_text.startsWith('ghp_')).toBe(true)
|
|
40
|
-
expect(gh!.rule_id).toContain('github')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('catches an NPM access token', async () => {
|
|
44
|
-
const text = 'NPM_TOKEN=' + NPM_FIXTURE
|
|
45
|
-
const hits = await detectViaSecretlint(text)
|
|
46
|
-
const npm = hits.find((h) => h.rule_id.includes('npm'))
|
|
47
|
-
expect(npm).toBeDefined()
|
|
48
|
-
expect(npm!.confidence).toBe('high')
|
|
49
|
-
expect(npm!.matched_text.startsWith('npm_')).toBe(true)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('returns empty for empty input', async () => {
|
|
53
|
-
expect(await detectViaSecretlint('')).toEqual([])
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('returns empty for text with no secrets', async () => {
|
|
57
|
-
expect(await detectViaSecretlint('hello how are you today')).toEqual([])
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('marks nearby test/mock markers as suppressed', async () => {
|
|
61
|
-
const text = 'test example: ' + SLACK_FIXTURE
|
|
62
|
-
const hits = await detectViaSecretlint(text)
|
|
63
|
-
const slack = hits.find((h) => h.rule_id.includes('slack'))
|
|
64
|
-
expect(slack).toBeDefined()
|
|
65
|
-
expect(slack!.suppressed).toBe(true)
|
|
66
|
-
})
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
describe('detectSecretsAsync merge', () => {
|
|
70
|
-
it('merges Secretlint hits with vendored pattern hits, deduped by range', async () => {
|
|
71
|
-
// Slack token that matches both the vendored anchored pattern AND Secretlint.
|
|
72
|
-
const text = 'a ' + SLACK_FIXTURE + ' end'
|
|
73
|
-
const hits = await detectSecretsAsync(text)
|
|
74
|
-
// One entry for the Slack token — not two. Vendored wins on ties.
|
|
75
|
-
const slackHits = hits.filter(
|
|
76
|
-
(h) => h.matched_text.startsWith('xoxb-'),
|
|
77
|
-
)
|
|
78
|
-
expect(slackHits).toHaveLength(1)
|
|
79
|
-
// Vendored rule id wins on exact-range ties (listed first in merge).
|
|
80
|
-
expect(slackHits[0]!.rule_id).toBe('slack_token')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('detects a Shopify token via the async (Secretlint-augmented) path', async () => {
|
|
84
|
-
// Shopify is now ALSO a vendored PROVIDER_PATTERN (shopify_shared_secret),
|
|
85
|
-
// so on this span the merge prefers the vendored high hit over the
|
|
86
|
-
// Secretlint one — both are valid Shopify classifications. Secretlint
|
|
87
|
-
// remains the fallback for the long tail of providers we don't vendor;
|
|
88
|
-
// this asserts the async path still detects + classifies the token.
|
|
89
|
-
const text = 'SHOPIFY=shpss_1234567890abcdef1234567890abcdef and go'
|
|
90
|
-
const hits = await detectSecretsAsync(text)
|
|
91
|
-
const shopify = hits.find((h) => h.matched_text.startsWith('shpss_'))
|
|
92
|
-
expect(shopify).toBeDefined()
|
|
93
|
-
expect(shopify!.rule_id).toMatch(/shopify/)
|
|
94
|
-
expect(shopify!.confidence).toBe('high')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('produces unique slugs across the merged detection list', async () => {
|
|
98
|
-
const text =
|
|
99
|
-
'tok1=' + GITHUB_FIXTURE +
|
|
100
|
-
' and tok2=' + SLACK_FIXTURE
|
|
101
|
-
const hits = await detectSecretsAsync(text)
|
|
102
|
-
const slugs = hits.map((h) => h.suggested_slug)
|
|
103
|
-
expect(new Set(slugs).size).toBe(slugs.length)
|
|
104
|
-
})
|
|
105
|
-
})
|