@desplega.ai/agent-swarm 1.165.0 → 1.166.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{acp-adapter-w6zws6me.js → acp-adapter-yzfxn8vj.js} +4 -4
- package/dist/{actions-jna0b3wm.js → actions-qz3m692w.js} +20 -24
- package/dist/{amp-adapter-0eve81pz.js → amp-adapter-j1xbmsqz.js} +4 -4
- package/dist/{app-75hm5cfa.js → app-43vrdmv8.js} +6 -7
- package/dist/{assistant-yzd51j33.js → assistant-ertffmka.js} +21 -25
- package/dist/{boot-reembed-z158xq32.js → boot-reembed-998jtpdg.js} +9 -12
- package/dist/{boot-reembed-2ex2mcsh.js → boot-reembed-s7k7c6v2.js} +10 -12
- package/dist/boot-scrub-sweep-nepsk4t1.js +211 -0
- package/dist/{boot-seeds-2bmnh18p.js → boot-seeds-vvzs1zhk.js} +8 -8
- package/dist/{claude-adapter-jbj1mycz.js → claude-adapter-2zvmekrp.js} +9 -11
- package/dist/{claude-managed-adapter-17ggz09t.js → claude-managed-adapter-s49wbk8t.js} +3 -3
- package/dist/{claude-managed-setup-ptm1nzm2.js → claude-managed-setup-cvhqq8qn.js} +4 -4
- package/dist/{claude-sdk-session-9re1g7r2.js → claude-sdk-session-kkbxtw5q.js} +2618 -2600
- package/dist/{cli-k6xx3pve.js → cli-0nhdwxd5.js} +4 -4
- package/dist/{cli-n9ehc28t.js → cli-0qjcsxgz.js} +4 -4
- package/dist/{cli-zn2v3t8v.js → cli-3ddybbbx.js} +1 -1
- package/dist/{cli-5anb0ev3.js → cli-3qn4r334.js} +71 -14
- package/dist/{cli-1kzfvd1z.js → cli-4r1sjfc4.js} +9 -5
- package/dist/{cli-ba66wyhy.js → cli-66zh26sp.js} +6 -6
- package/dist/{cli-9xb89xca.js → cli-68r1j4sg.js} +6 -6
- package/dist/{cli-xnzrzets.js → cli-8kgvmkak.js} +2 -2
- package/dist/{cli-28smfvbn.js → cli-8t229cmr.js} +5 -5
- package/dist/{cli-9866bd07.js → cli-aqcey741.js} +1 -1
- package/dist/{cli-90v942q1.js → cli-cpfmqz3n.js} +8 -8
- package/dist/{cli-50eg13rj.js → cli-df56tqdw.js} +459 -282
- package/dist/{cli-8gpbwy09.js → cli-dw2q9f7g.js} +5 -5
- package/dist/{cli-0n4fasz0.js → cli-ecknp7p1.js} +15 -15
- package/dist/{cli-xb3c6aat.js → cli-f2n17zhp.js} +1 -1
- package/dist/{cli-ftry7975.js → cli-feh3dkah.js} +1 -1
- package/dist/{cli-amwhs8h2.js → cli-fn697bnt.js} +2 -2
- package/dist/{cli-prt9emct.js → cli-gt8azyet.js} +3 -3
- package/dist/{cli-59jn5twa.js → cli-h3x613ze.js} +2 -2
- package/dist/{cli-mmzwwptd.js → cli-hf5s9pgf.js} +1 -1
- package/dist/cli-j2jbr8af.js +516 -0
- package/dist/{cli-wfajtmf7.js → cli-jhm6hzjn.js} +593 -10
- package/dist/{cli-xtgtyafv.js → cli-jvj6a0cv.js} +190 -439
- package/dist/{cli-hpryr6em.js → cli-mb75wpxf.js} +1 -1
- package/dist/{cli-420bdgw0.js → cli-mv2520nm.js} +1 -1
- package/dist/{cli-pjxnr1jn.js → cli-mwegs4af.js} +3 -3
- package/dist/{cli-5b2fjjzh.js → cli-p4ztb80h.js} +2 -2
- package/dist/{cli-e7t4snpa.js → cli-rggdq8wq.js} +4 -4
- package/dist/{cli-bdv39kgj.js → cli-rsy933yz.js} +5 -5
- package/dist/{cli-gh5yqzpe.js → cli-v2a8x45d.js} +2 -2
- package/dist/{cli-bns5c8k9.js → cli-vzc73wet.js} +43 -47
- package/dist/cli-wt7ccy4r.js +2654 -0
- package/dist/{cli-j0pdrmwg.js → cli-x7kpr72a.js} +3 -3
- package/dist/{cli-x7afk5wm.js → cli-xvc3q9br.js} +1 -1
- package/dist/{cli-9s3h836g.js → cli-xvdgasgy.js} +1 -1
- package/dist/{cli-zvckv750.js → cli-y59h2pk9.js} +2 -2
- package/dist/{cli-qdv8kgz7.js → cli-yshaazs1.js} +13884 -3241
- package/dist/{cli-nfygw434.js → cli-ysnyhws9.js} +2 -2
- package/dist/{cli-fw6vdcq5.js → cli-zp67qg5x.js} +34 -1
- package/dist/{cli-ma3zvh39.js → cli-zy7vwsne.js} +6066 -5829
- package/dist/cli.js +24 -20
- package/dist/{codex-adapter-3qnxshje.js → codex-adapter-dw1wxqt8.js} +7 -7
- package/dist/{codex-login-brkxhhdg.js → codex-login-terbdsqq.js} +2 -2
- package/dist/{codex-session-runner-612wnapp.js → codex-session-runner-3cgb8y1g.js} +7 -7
- package/dist/{commands-2ybfcxkj.js → commands-mhxam6z2.js} +6 -6
- package/dist/{cursor-adapter-p3jgdehv.js → cursor-adapter-2qda3st7.js} +3 -3
- package/dist/{db-f4ans0jj.js → db-sjks418k.js} +8 -6
- package/dist/{devin-adapter-dhzt01ff.js → devin-adapter-bffjj5n4.js} +2 -2
- package/dist/{dispatcher-pk182apd.js → dispatcher-1es759rn.js} +18 -22
- package/dist/{dsh-adapter-7twaeps1.js → dsh-adapter-bek4cyw9.js} +3 -3
- package/dist/{e2b-y8t3jzyz.js → e2b-nd50eccg.js} +1 -1
- package/dist/{event-bus-w7ac4n3c.js → event-bus-vnfthafg.js} +2 -2
- package/dist/{grok-adapter-yt9pab3k.js → grok-adapter-ar9cw9jg.js} +5 -5
- package/dist/{handlers-g2wnr4t9.js → handlers-1gkbhktr.js} +21 -25
- package/dist/{hook-f7jt9vnm.js → hook-ds2zxq9v.js} +14 -16
- package/dist/{hook-bbdpnkb4.js → hook-vxa3tcsy.js} +8 -10
- package/dist/{http-w40q4w94.js → http-3h21z9md.js} +247 -164
- package/dist/{index-056j5kzn.js → index-9b65j1m5.js} +8 -8
- package/dist/{index-9bfssf54.js → index-bn6cy8ps.js} +18 -22
- package/dist/{index-cj1r9q83.js → index-dkfxpkmm.js} +19 -23
- package/dist/{index-7n70jwbb.js → index-f8xnqrye.js} +19 -21
- package/dist/{index-fdk1t4d6.js → index-n0rxa4ca.js} +11962 -9900
- package/dist/{index-k8xc083e.js → index-n99fycq7.js} +18 -22
- package/dist/{keepalive-1tj10tf4.js → keepalive-3xspa9jw.js} +11 -8
- package/dist/{lead-st0bg7v4.js → lead-6qphc124.js} +34 -38
- package/dist/{maintenance-nz71zats.js → maintenance-0teem1bb.js} +7 -9
- package/dist/{node-f0ykebbv.js → node-9sg7ajby.js} +25 -7
- package/dist/{oauth-refresh-sweep-aefzwvvq.js → oauth-refresh-sweep-sh9q25xp.js} +10 -7
- package/dist/{onboard-kgpr9mbn.js → onboard-54qmnqxe.js} +2 -2
- package/dist/{opencode-adapter-0c8e70dk.js → opencode-adapter-wjfa2wvd.js} +2 -2
- package/dist/{otel-impl-x8zp4y5a.js → otel-impl-ycqzp2a1.js} +2 -2
- package/dist/{pi-mono-adapter-t280ah5c.js → pi-mono-adapter-e77e9zst.js} +4 -4
- package/dist/{pricing-refresh-9e0q7cwk.js → pricing-refresh-atnkngqs.js} +7 -7
- package/dist/{rbac-roles-kcp6zge2.js → rbac-roles-9pjphh9q.js} +6 -6
- package/dist/{rbac-roles-790j3ga8.js → rbac-roles-j78wdksm.js} +6 -6
- package/dist/{render-v2-tch4nefj.js → render-v2-pxjs49fx.js} +6 -7
- package/dist/{setup-rcm303ns.js → setup-ymej0xtx.js} +2 -2
- package/dist/{work-objects-ctg7t70w.js → work-objects-3nh4ymzb.js} +22 -26
- package/dist/{worker-5dp0xm55.js → worker-qa9fkz5s.js} +34 -38
- package/dist/{x-v3yw66zs.js → x-ma4gnfgy.js} +2 -2
- package/openapi.json +5 -2
- package/package.json +9 -5
- package/src/be/automation-preflight.ts +9 -4
- package/src/be/boot-scrub-sweep.ts +302 -0
- package/src/be/crypto/key-bootstrap.ts +1 -1
- package/src/be/db/approvals.ts +15 -1
- package/src/be/db/codex-oauth-device-flows.ts +40 -0
- package/src/be/db/config-secrets.ts +31 -0
- package/src/be/db/runtime.ts +4 -0
- package/src/be/db/script-runs.ts +15 -0
- package/src/be/db/tasks/write.ts +1 -1
- package/src/be/db-queries/mcp-oauth.ts +5 -0
- package/src/be/db-queries/oauth.ts +77 -0
- package/src/be/db.ts +162 -59
- package/src/be/events.ts +17 -1
- package/src/be/memory/index-content.ts +2 -2
- package/src/be/memory/providers/sqlite-store.ts +63 -11
- package/src/be/memory/types.ts +14 -0
- package/src/be/migrations/201_workflow_replay_columns.sql +10 -0
- package/src/be/migrations/202_codex_oauth_device_flows.sql +24 -0
- package/src/be/scripts/db.ts +28 -4
- package/src/be/scripts/source-secrets.ts +37 -0
- package/src/be/scrub-json.ts +32 -0
- package/src/be/sealed-json.ts +59 -0
- package/src/be/secret-registry.ts +131 -0
- package/src/be/workflow-replay.ts +77 -0
- package/src/http/codex-oauth-device.ts +8 -10
- package/src/http/db-query-bounded.ts +3 -0
- package/src/http/db-query-shared.ts +107 -15
- package/src/http/db-query.ts +10 -2
- package/src/http/index.ts +23 -6
- package/src/http/script-runs.ts +9 -1
- package/src/http/scripts.ts +15 -0
- package/src/http/workflows.ts +3 -2
- package/src/oauth/mcp-wrapper.ts +1 -1
- package/src/providers/acp-adapter.ts +25 -11
- package/src/providers/acp-target-catalog.ts +35 -1
- package/src/providers/acp-targets.ts +69 -3
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/script-workflows/supervisor.ts +11 -2
- package/src/scripts-runtime/eval-harness.ts +2 -12
- package/src/scripts-runtime/executors/quickjs-bundle.ts +152 -0
- package/src/scripts-runtime/executors/quickjs-prelude.ts +285 -0
- package/src/scripts-runtime/executors/quickjs-runner.ts +580 -0
- package/src/scripts-runtime/executors/quickjs-worker.ts +81 -0
- package/src/scripts-runtime/executors/quickjs.ts +196 -0
- package/src/scripts-runtime/executors/registry.ts +2 -0
- package/src/scripts-runtime/executors/sourcemap.ts +79 -0
- package/src/scripts-runtime/script-error-hints.ts +14 -0
- package/src/server.ts +3 -1
- package/src/tests/acp-adapter.test.ts +127 -2
- package/src/tests/boot-scrub-sweep.test.ts +390 -0
- package/src/tests/check-text-columns.test.ts +145 -0
- package/src/tests/claude-sdk-process.fixture.ts +13 -2
- package/src/tests/codex-oauth-device.test.ts +23 -37
- package/src/tests/db-query-denylist.test.ts +202 -0
- package/src/tests/db-task-read-contract.test.ts +1 -0
- package/src/tests/db-task-write-contract.test.ts +1 -0
- package/src/tests/entrypoint-acp-target-binary.test.ts +31 -1
- package/src/tests/events-scrub.test.ts +88 -0
- package/src/tests/fixtures/secret-corpus.ts +320 -0
- package/src/tests/script-connections.test.ts +5 -0
- package/src/tests/script-executor-conformance.test.ts +3 -0
- package/src/tests/script-executor-quickjs.test.ts +442 -0
- package/src/tests/script-executor-registry.test.ts +6 -1
- package/src/tests/script-runs-http.test.ts +88 -1
- package/src/tests/script-runs-scrub.test.ts +275 -0
- package/src/tests/scripts-http.test.ts +82 -1
- package/src/tests/scrubbed-text-12e.test.ts +289 -0
- package/src/tests/sealed-key-guard.test.ts +166 -0
- package/src/tests/secret-canary-sweep.test.ts +958 -0
- package/src/tests/secret-registry.test.ts +355 -0
- package/src/tests/secret-rules.test.ts +230 -0
- package/src/tests/secret-scrubber.test.ts +3 -2
- package/src/tests/workflow-replay.test.ts +518 -0
- package/src/tests/workflow-scrub.test.ts +242 -0
- package/src/tools/db-query.ts +1 -1
- package/src/tools/inject-learning.ts +1 -1
- package/src/tools/workflows/get-workflow-run.ts +3 -2
- package/src/types.ts +1 -1
- package/src/utils/secret-rules.generated.ts +2055 -0
- package/src/utils/secret-scrubber.ts +363 -29
- package/src/workflows/engine.ts +39 -4
- package/src/workflows/recovery.ts +10 -1
- package/src/workflows/resume.ts +62 -3
- package/src/workflows/retry-poller.ts +3 -0
- package/dist/boot-scrub-logs-246yn076.js +0 -99
- package/dist/cli-3a4m1aws.js +0 -572
- package/dist/cli-a87b04k8.js +0 -951
- package/dist/cli-cvk6yzw5.js +0 -40
- package/dist/cli-h1spv8v4.js +0 -427
- package/dist/cli-jqt9hrnz.js +0 -168
- package/dist/cli-yhmwmv0g.js +0 -9281
- package/src/be/boot-scrub-logs.ts +0 -132
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API-side secret registry (src/be/secret-registry.ts) and the scrubber's
|
|
3
|
+
* combined known-value matcher.
|
|
4
|
+
*
|
|
5
|
+
* Every secret here is built at runtime from random bytes. Assertions check
|
|
6
|
+
* both directions: the value is gone, and the `[REDACTED:` marker plus the
|
|
7
|
+
* surrounding non-secret text survive.
|
|
8
|
+
*/
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { __resetEncryptionKeyForTests, encryptSecret } from "../be/crypto";
|
|
15
|
+
import {
|
|
16
|
+
closeDb,
|
|
17
|
+
createAgent,
|
|
18
|
+
createMcpServer,
|
|
19
|
+
getDbClient,
|
|
20
|
+
initDb,
|
|
21
|
+
upsertSwarmConfig,
|
|
22
|
+
} from "../be/db";
|
|
23
|
+
import { upsertMcpOAuthToken } from "../be/db-queries/mcp-oauth";
|
|
24
|
+
import {
|
|
25
|
+
getOAuthApp,
|
|
26
|
+
updateAuthorizationTokens,
|
|
27
|
+
upsertAuthorization,
|
|
28
|
+
upsertOAuthApp,
|
|
29
|
+
} from "../be/db-queries/oauth";
|
|
30
|
+
import { createScriptApi, insertScript, rotateScriptApiSecret } from "../be/scripts/db";
|
|
31
|
+
import { encodedForms, loadSecretRegistry, registerStoredSecret } from "../be/secret-registry";
|
|
32
|
+
import {
|
|
33
|
+
clearVolatileSecretsForTesting,
|
|
34
|
+
refreshSecretScrubberCache,
|
|
35
|
+
registerVolatileSecret,
|
|
36
|
+
scrubSecrets,
|
|
37
|
+
} from "../utils/secret-scrubber";
|
|
38
|
+
import { randomToken } from "./synthetic-secret-helpers";
|
|
39
|
+
|
|
40
|
+
let tempDir: string;
|
|
41
|
+
let savedKey: string | undefined;
|
|
42
|
+
let agentId: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A synthetic secret with `/`, `+` and `=` in it, so its URL-encoded form
|
|
46
|
+
* differs from the raw value and its base64 is not just alphanumerics.
|
|
47
|
+
*/
|
|
48
|
+
function makeSecret(label: string): string {
|
|
49
|
+
return [label, `${randomToken(12)}/${randomToken(12)}+${randomToken(8)}=`].join("_");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PREFIX = "deploy step 4 printed";
|
|
53
|
+
const SUFFIX = "and moved on";
|
|
54
|
+
|
|
55
|
+
function wrap(form: string): string {
|
|
56
|
+
return `${PREFIX} ${form} ${SUFFIX}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every encoded shape of `value` a log line can carry, keyed by shape name. */
|
|
60
|
+
function shapes(value: string): Record<string, string> {
|
|
61
|
+
return {
|
|
62
|
+
raw: value,
|
|
63
|
+
base64: Buffer.from(value).toString("base64"),
|
|
64
|
+
base64url: Buffer.from(value).toString("base64url"),
|
|
65
|
+
urlEncoded: encodeURIComponent(value),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The value survives scrubbing in every shape (nothing knows it yet). */
|
|
70
|
+
function expectKnownNowhere(value: string): void {
|
|
71
|
+
for (const form of Object.values(shapes(value))) {
|
|
72
|
+
expect(scrubSecrets(wrap(form))).toBe(wrap(form));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The value is redacted in every shape, marker and context intact. */
|
|
77
|
+
function expectRedactedEverywhere(value: string, name: string): void {
|
|
78
|
+
for (const [shape, form] of Object.entries(shapes(value))) {
|
|
79
|
+
const out = scrubSecrets(wrap(form));
|
|
80
|
+
expect({ shape, leaked: out.includes(form) }).toEqual({ shape, leaked: false });
|
|
81
|
+
expect(out).toBe(`${PREFIX} [REDACTED:${name}] ${SUFFIX}`);
|
|
82
|
+
}
|
|
83
|
+
// Embedded in a larger base64 blob at each byte offset mod 3 (Basic auth).
|
|
84
|
+
for (const user of ["ab:", "abc:", "abcd:"]) {
|
|
85
|
+
const basic = `Authorization: Basic ${Buffer.from(`${user}${value}`).toString("base64")} ok`;
|
|
86
|
+
expect(scrubSecrets(basic)).toMatch(
|
|
87
|
+
new RegExp(
|
|
88
|
+
`^Authorization: Basic [A-Za-z0-9+/]{4,8}\\[REDACTED:${escapeRe(name)}\\][A-Za-z0-9+/=]{0,4} ok$`,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeRe(s: string): string {
|
|
95
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
beforeAll(async () => {
|
|
99
|
+
savedKey = process.env.SECRETS_ENCRYPTION_KEY;
|
|
100
|
+
process.env.SECRETS_ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
|
101
|
+
__resetEncryptionKeyForTests();
|
|
102
|
+
tempDir = mkdtempSync(join(tmpdir(), "secret-registry-"));
|
|
103
|
+
closeDb();
|
|
104
|
+
initDb(join(tempDir, "registry.sqlite"));
|
|
105
|
+
refreshSecretScrubberCache();
|
|
106
|
+
const agent = await createAgent({ name: "registry-worker", isLead: false, status: "idle" });
|
|
107
|
+
agentId = agent.id;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(() => {
|
|
111
|
+
closeDb();
|
|
112
|
+
clearVolatileSecretsForTesting();
|
|
113
|
+
if (savedKey === undefined) delete process.env.SECRETS_ENCRYPTION_KEY;
|
|
114
|
+
else process.env.SECRETS_ENCRYPTION_KEY = savedKey;
|
|
115
|
+
__resetEncryptionKeyForTests();
|
|
116
|
+
refreshSecretScrubberCache();
|
|
117
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("secret registry boot load", () => {
|
|
121
|
+
test("redacts every stored secret and its encoded forms after a simulated restart", async () => {
|
|
122
|
+
const configValue = makeSecret("regcfg");
|
|
123
|
+
await upsertSwarmConfig({
|
|
124
|
+
scope: "agent",
|
|
125
|
+
scopeId: agentId,
|
|
126
|
+
key: "REGISTRY_PROBE_TOKEN",
|
|
127
|
+
value: configValue,
|
|
128
|
+
isSecret: true,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const clientSecret = makeSecret("regclient");
|
|
132
|
+
const accessToken = makeSecret("regaccess");
|
|
133
|
+
const refreshToken = makeSecret("regrefresh");
|
|
134
|
+
await upsertOAuthApp("regprovider", {
|
|
135
|
+
clientId: "registry-client",
|
|
136
|
+
clientSecret,
|
|
137
|
+
authorizeUrl: "https://auth.example.test/authorize",
|
|
138
|
+
tokenUrl: "https://auth.example.test/token",
|
|
139
|
+
redirectUri: "https://swarm.example.test/callback",
|
|
140
|
+
scopes: "read",
|
|
141
|
+
});
|
|
142
|
+
const app = await getOAuthApp("regprovider");
|
|
143
|
+
if (!app) throw new Error("oauth app missing");
|
|
144
|
+
await upsertAuthorization({ appId: app.id, accessToken, refreshToken });
|
|
145
|
+
|
|
146
|
+
const server = await createMcpServer({
|
|
147
|
+
name: `registry-mcp-${randomToken(6)}`,
|
|
148
|
+
transport: "http",
|
|
149
|
+
url: "https://mcp.example.test",
|
|
150
|
+
scope: "swarm",
|
|
151
|
+
});
|
|
152
|
+
const mcpAccess = makeSecret("regmcp");
|
|
153
|
+
await upsertMcpOAuthToken({
|
|
154
|
+
mcpServerId: server.id,
|
|
155
|
+
accessToken: mcpAccess,
|
|
156
|
+
refreshToken: null,
|
|
157
|
+
resourceUrl: "https://mcp.example.test/",
|
|
158
|
+
authorizationServerIssuer: "https://as.example.test",
|
|
159
|
+
authorizeUrl: "https://as.example.test/authorize",
|
|
160
|
+
tokenUrl: "https://as.example.test/token",
|
|
161
|
+
clientSource: "manual",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const script = await insertScript({
|
|
165
|
+
name: `registry-probe-${randomToken(6)}`,
|
|
166
|
+
scope: "agent",
|
|
167
|
+
scopeId: agentId,
|
|
168
|
+
source: "export default async function run() { return {}; }",
|
|
169
|
+
description: "registry probe",
|
|
170
|
+
intent: "test fixture",
|
|
171
|
+
signatureJson: "{}",
|
|
172
|
+
argsJsonSchema: null,
|
|
173
|
+
agentId,
|
|
174
|
+
typeChecked: true,
|
|
175
|
+
});
|
|
176
|
+
const api = await createScriptApi({ scriptId: script.id, agentId, authMode: "bearer" });
|
|
177
|
+
if (!api.token) throw new Error("bearer token missing");
|
|
178
|
+
const scriptToken = api.token;
|
|
179
|
+
|
|
180
|
+
// Simulated restart: the scrubber forgets everything registered at write time.
|
|
181
|
+
clearVolatileSecretsForTesting();
|
|
182
|
+
for (const value of [configValue, clientSecret, accessToken, refreshToken, mcpAccess]) {
|
|
183
|
+
expectKnownNowhere(value);
|
|
184
|
+
}
|
|
185
|
+
expect(scrubSecrets(wrap(scriptToken))).toBe(wrap(scriptToken));
|
|
186
|
+
|
|
187
|
+
const loaded = await loadSecretRegistry();
|
|
188
|
+
expect(loaded.failed).toBe(0);
|
|
189
|
+
expect(loaded.config).toBeGreaterThanOrEqual(1);
|
|
190
|
+
expect(loaded.oauth).toBeGreaterThanOrEqual(4);
|
|
191
|
+
expect(loaded.scriptApi).toBeGreaterThanOrEqual(1);
|
|
192
|
+
|
|
193
|
+
expectRedactedEverywhere(configValue, "config:REGISTRY_PROBE_TOKEN");
|
|
194
|
+
expectRedactedEverywhere(clientSecret, "oauth:regprovider:client_secret");
|
|
195
|
+
expectRedactedEverywhere(accessToken, "oauth:regprovider:access_token");
|
|
196
|
+
expectRedactedEverywhere(refreshToken, "oauth:regprovider:refresh_token");
|
|
197
|
+
expectRedactedEverywhere(mcpAccess, `oauth:mcp-${server.id}:access_token`);
|
|
198
|
+
expectRedactedEverywhere(scriptToken, `script-api:${api.id}`);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a row that cannot be decrypted is counted and does not block the rest", async () => {
|
|
202
|
+
const good = makeSecret("reggood");
|
|
203
|
+
await upsertSwarmConfig({
|
|
204
|
+
scope: "global",
|
|
205
|
+
key: "REGISTRY_GOOD_SECRET",
|
|
206
|
+
value: good,
|
|
207
|
+
isSecret: true,
|
|
208
|
+
});
|
|
209
|
+
const id = crypto.randomUUID();
|
|
210
|
+
const now = new Date().toISOString();
|
|
211
|
+
await getDbClient().run(
|
|
212
|
+
`INSERT INTO swarm_config (id, scope, scopeId, key, value, isSecret, envPath, description, createdAt, lastUpdatedAt, encrypted)
|
|
213
|
+
VALUES (?, 'global', NULL, 'REGISTRY_WRONG_KEY_SECRET', ?, 1, NULL, NULL, ?, ?, 1)`,
|
|
214
|
+
[id, encryptSecret(makeSecret("regbad"), randomBytes(32)), now, now],
|
|
215
|
+
);
|
|
216
|
+
try {
|
|
217
|
+
clearVolatileSecretsForTesting();
|
|
218
|
+
const loaded = await loadSecretRegistry();
|
|
219
|
+
expect(loaded.failed).toBe(1);
|
|
220
|
+
expectRedactedEverywhere(good, "config:REGISTRY_GOOD_SECRET");
|
|
221
|
+
} finally {
|
|
222
|
+
await getDbClient().run("DELETE FROM swarm_config WHERE id = ?", [id]);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("secret registry write hooks", () => {
|
|
228
|
+
test("a write after boot is redacted without a reload", async () => {
|
|
229
|
+
clearVolatileSecretsForTesting();
|
|
230
|
+
await loadSecretRegistry();
|
|
231
|
+
|
|
232
|
+
const configValue = makeSecret("regwrite");
|
|
233
|
+
await upsertSwarmConfig({
|
|
234
|
+
scope: "agent",
|
|
235
|
+
scopeId: agentId,
|
|
236
|
+
key: "REGISTRY_LATE_TOKEN",
|
|
237
|
+
value: configValue,
|
|
238
|
+
isSecret: true,
|
|
239
|
+
});
|
|
240
|
+
expectRedactedEverywhere(configValue, "config:REGISTRY_LATE_TOKEN");
|
|
241
|
+
|
|
242
|
+
const app = await getOAuthApp("regprovider");
|
|
243
|
+
if (!app) throw new Error("oauth app missing");
|
|
244
|
+
const auth = await upsertAuthorization({ appId: app.id, accessToken: makeSecret("regseed") });
|
|
245
|
+
const refreshedAccess = makeSecret("regrefreshed");
|
|
246
|
+
const refreshedRefresh = makeSecret("regrotated");
|
|
247
|
+
await updateAuthorizationTokens(auth.id, {
|
|
248
|
+
accessToken: refreshedAccess,
|
|
249
|
+
refreshToken: refreshedRefresh,
|
|
250
|
+
});
|
|
251
|
+
expectRedactedEverywhere(refreshedAccess, "oauth:regprovider:access_token");
|
|
252
|
+
expectRedactedEverywhere(refreshedRefresh, "oauth:regprovider:refresh_token");
|
|
253
|
+
|
|
254
|
+
const [scriptApiId] = (
|
|
255
|
+
await getDbClient().query<{ id: string }>("SELECT id FROM script_apis LIMIT 1")
|
|
256
|
+
).map((r) => r.id);
|
|
257
|
+
if (!scriptApiId) throw new Error("script api missing");
|
|
258
|
+
const rotated = await rotateScriptApiSecret(scriptApiId);
|
|
259
|
+
if (!rotated?.token) throw new Error("rotation failed");
|
|
260
|
+
expectRedactedEverywhere(rotated.token, `script-api:${scriptApiId}`);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("combined known-value matcher", () => {
|
|
265
|
+
test("encodedForms covers base64, base64url and URL encoding without the raw value", () => {
|
|
266
|
+
const value = makeSecret("regforms");
|
|
267
|
+
const forms = encodedForms(value);
|
|
268
|
+
expect(forms).toContain(Buffer.from(value).toString("base64"));
|
|
269
|
+
expect(forms).toContain(Buffer.from(value).toString("base64url"));
|
|
270
|
+
expect(forms).toContain(encodeURIComponent(value));
|
|
271
|
+
expect(forms).not.toContain(value);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("longest value wins when one known value is a prefix of another", () => {
|
|
275
|
+
clearVolatileSecretsForTesting();
|
|
276
|
+
const short = `regshort_${randomToken(16)}`;
|
|
277
|
+
const long = `${short}${randomToken(12)}`;
|
|
278
|
+
registerVolatileSecret(short, "SHORT_ONE");
|
|
279
|
+
registerVolatileSecret(long, "LONG_ONE");
|
|
280
|
+
expect(scrubSecrets(`a ${long} b ${short} c`)).toBe(
|
|
281
|
+
"a [REDACTED:LONG_ONE] b [REDACTED:SHORT_ONE] c",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("regex metacharacters in a value match literally", () => {
|
|
286
|
+
clearVolatileSecretsForTesting();
|
|
287
|
+
const value = `reg.*+?^\${}()|[]\\_${randomToken(16)}`;
|
|
288
|
+
registerVolatileSecret(value, "META_ONE");
|
|
289
|
+
const lookalike = value.replace(".*", "xx");
|
|
290
|
+
expect(scrubSecrets(`x ${value} y ${lookalike} z`)).toBe(
|
|
291
|
+
`x [REDACTED:META_ONE] y ${lookalike} z`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("an env value keeps its env name when it is also registered as volatile", () => {
|
|
296
|
+
clearVolatileSecretsForTesting();
|
|
297
|
+
const envKey = `REGISTRY_ENV_${randomToken(6).toUpperCase()}_TOKEN`;
|
|
298
|
+
const value = `regenv_${randomToken(24)}`;
|
|
299
|
+
process.env[envKey] = value;
|
|
300
|
+
try {
|
|
301
|
+
refreshSecretScrubberCache();
|
|
302
|
+
registerVolatileSecret(value, "volatile-name");
|
|
303
|
+
expect(scrubSecrets(`v=${value};`)).toBe(`v=[REDACTED:${envKey}];`);
|
|
304
|
+
} finally {
|
|
305
|
+
delete process.env[envKey];
|
|
306
|
+
refreshSecretScrubberCache();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Timing-sensitive (wall-clock ratio on a shared CI runner): retry absorbs a
|
|
311
|
+
// noisy-neighbour spike; a real regression fails every attempt.
|
|
312
|
+
test(
|
|
313
|
+
"200 known values cost no more than 2x the 20-value baseline over 10k lines",
|
|
314
|
+
() => {
|
|
315
|
+
const lines: string[] = [];
|
|
316
|
+
for (let i = 0; i < 10_000; i++) {
|
|
317
|
+
lines.push(
|
|
318
|
+
`{"type":"assistant","line":${i},"text":"ran step ${i} in /workspace/repo with id ${randomToken(16)} and exit 0"}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const timeWith = (count: number): number => {
|
|
323
|
+
clearVolatileSecretsForTesting();
|
|
324
|
+
const values: string[] = [];
|
|
325
|
+
for (let i = 0; i < count; i++) {
|
|
326
|
+
const value = makeSecret(`regbench${i}`);
|
|
327
|
+
values.push(value);
|
|
328
|
+
registerStoredSecret(value, `bench:${i}`);
|
|
329
|
+
}
|
|
330
|
+
// Some lines carry a known value, so the replace path is exercised too.
|
|
331
|
+
const corpus = lines.map((line, i) =>
|
|
332
|
+
i % 100 === 0 ? `${line} ${values[i % count]}` : line,
|
|
333
|
+
);
|
|
334
|
+
for (const line of corpus.slice(0, 500)) scrubSecrets(line); // warm-up + matcher build
|
|
335
|
+
const runs: number[] = [];
|
|
336
|
+
for (let r = 0; r < 3; r++) {
|
|
337
|
+
const start = performance.now();
|
|
338
|
+
for (const line of corpus) scrubSecrets(line);
|
|
339
|
+
runs.push(performance.now() - start);
|
|
340
|
+
}
|
|
341
|
+
// Spot-check correctness on the same corpus.
|
|
342
|
+
expect(scrubSecrets(corpus[100] ?? "")).toContain("[REDACTED:bench:");
|
|
343
|
+
return runs.sort((a, b) => a - b)[1] ?? 0;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const baseline = timeWith(20);
|
|
347
|
+
const scaled = timeWith(200);
|
|
348
|
+
console.log(
|
|
349
|
+
`[secret-registry bench] 20 values: ${baseline.toFixed(1)} ms, 200 values: ${scaled.toFixed(1)} ms (10k lines)`,
|
|
350
|
+
);
|
|
351
|
+
expect(scaled).toBeLessThanOrEqual(baseline * 2);
|
|
352
|
+
},
|
|
353
|
+
{ retry: 2, timeout: 60_000 },
|
|
354
|
+
);
|
|
355
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { convertGoRegex, stripKeyPrefix } from "../../scripts/gen-secret-rules";
|
|
4
|
+
import { GITLEAKS_RULES } from "../utils/secret-rules.generated";
|
|
5
|
+
import {
|
|
6
|
+
clearVolatileSecretsForTesting,
|
|
7
|
+
scrubSecrets,
|
|
8
|
+
setGitleaksPassEnabledForTesting,
|
|
9
|
+
} from "../utils/secret-scrubber";
|
|
10
|
+
import { buildNegatives, buildPositives } from "./fixtures/secret-corpus";
|
|
11
|
+
import { randomToken } from "./synthetic-secret-helpers";
|
|
12
|
+
|
|
13
|
+
describe("convertGoRegex", () => {
|
|
14
|
+
test("turns a leading (?i) into the i flag", () => {
|
|
15
|
+
expect(convertGoRegex("(?i)abc")).toEqual({ source: "abc", flags: "i" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("scopes a mid-pattern (?i) to the rest of its group", () => {
|
|
19
|
+
const { source, flags } = convertGoRegex(String.raw`\b(p8e-(?i)[a-z0-9]{4})x`);
|
|
20
|
+
expect(source).toBe(String.raw`\b(p8e-(?i:[a-z0-9]{4}))x`);
|
|
21
|
+
const re = new RegExp(source, flags);
|
|
22
|
+
expect(re.test("p8e-AbCdx")).toBe(true);
|
|
23
|
+
expect(re.test("P8E-abcdx")).toBe(false);
|
|
24
|
+
expect(re.test("p8e-abcdX")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("keeps a mid-pattern (?i) on every later branch of its group", () => {
|
|
28
|
+
const { source } = convertGoRegex("(a(?i)b|c)d");
|
|
29
|
+
expect(source).toBe("(a(?i:b)|(?i:c))d");
|
|
30
|
+
const re = new RegExp(`^${source}$`);
|
|
31
|
+
expect(re.test("aBd")).toBe(true);
|
|
32
|
+
expect(re.test("Cd")).toBe(true);
|
|
33
|
+
expect(re.test("Ab")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rewrites named groups, POSIX classes, \\z, \\A and a leading ] in a class", () => {
|
|
37
|
+
expect(convertGoRegex("(?P<x>a)").source).toBe("(?<x>a)");
|
|
38
|
+
expect(convertGoRegex("[[:alnum:]]{2}").source).toBe("[A-Za-z0-9]{2}");
|
|
39
|
+
expect(convertGoRegex(String.raw`\Aa\z`).source).toBe("^a$");
|
|
40
|
+
expect(convertGoRegex("[^]]+").source).toBe(String.raw`[^\]]+`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects RE2-only syntax instead of converting it wrong", () => {
|
|
44
|
+
expect(() => convertGoRegex(String.raw`\pL`)).toThrow(/unsupported escape/);
|
|
45
|
+
expect(() => convertGoRegex("(?U)a+")).toThrow(/unsupported inline flags/);
|
|
46
|
+
expect(() => convertGoRegex("[[:graph:]]")).toThrow(/POSIX class/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("strips the keyword-context name prefix, also inside a leading (?i:", () => {
|
|
50
|
+
const prefix = String.raw`[\w.-]{0,50}?`;
|
|
51
|
+
expect(stripKeyPrefix(`${prefix}(?:okta)`)).toBe("(?:okta)");
|
|
52
|
+
expect(stripKeyPrefix(`${prefix}(?i:${prefix}(?:okta))`)).toBe("(?i:(?:okta))");
|
|
53
|
+
expect(stripKeyPrefix("(?:okta)")).toBe("(?:okta)");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("generated gitleaks rules", () => {
|
|
58
|
+
test("every rule compiles under Bun and has a keyword", () => {
|
|
59
|
+
expect(GITLEAKS_RULES.rules.length).toBeGreaterThan(200);
|
|
60
|
+
for (const rule of GITLEAKS_RULES.rules) {
|
|
61
|
+
expect(() => new RegExp(rule.source, `${rule.flags}gd`), rule.id).not.toThrow();
|
|
62
|
+
expect(rule.keywords.length, rule.id).toBeGreaterThan(0);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("generic-api-key is excluded with a reason", () => {
|
|
67
|
+
const excluded = GITLEAKS_RULES.excluded.find((rule) => rule.id === "generic-api-key");
|
|
68
|
+
expect(excluded?.reason).toBeTruthy();
|
|
69
|
+
expect(GITLEAKS_RULES.rules.some((rule) => rule.id === "generic-api-key")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ReDoS bound: the rules were written for RE2 (linear time); JS backtracks.
|
|
73
|
+
// Timing-sensitive on a shared CI runner, so retry absorbs a noisy-neighbour
|
|
74
|
+
// spike; a real catastrophic pattern takes seconds and fails every attempt.
|
|
75
|
+
test(
|
|
76
|
+
"each rule finishes a 100 KB adversarial input in under 50 ms",
|
|
77
|
+
() => {
|
|
78
|
+
const SIZE = 100_000;
|
|
79
|
+
const fill = (unit: string) => unit.repeat(Math.ceil(SIZE / unit.length)).slice(0, SIZE);
|
|
80
|
+
const slow: string[] = [];
|
|
81
|
+
for (const rule of GITLEAKS_RULES.rules) {
|
|
82
|
+
const re = new RegExp(rule.source, `${rule.flags}gd`);
|
|
83
|
+
const kw = rule.keywords[0] as string;
|
|
84
|
+
const inputs = [
|
|
85
|
+
fill("a"),
|
|
86
|
+
fill(kw),
|
|
87
|
+
fill(`${kw} = `),
|
|
88
|
+
fill(`${kw}_key = "${"a1B2".repeat(4)}`),
|
|
89
|
+
fill(`${kw}${"aB3-".repeat(30)}\n`),
|
|
90
|
+
fill(`${kw}.${"a".repeat(60)}.`),
|
|
91
|
+
fill(`${kw}:${"9".repeat(12)}:`),
|
|
92
|
+
fill(`${kw}${"=".repeat(20)} `),
|
|
93
|
+
];
|
|
94
|
+
for (const input of inputs) {
|
|
95
|
+
const start = performance.now();
|
|
96
|
+
re.lastIndex = 0;
|
|
97
|
+
for (let m = re.exec(input); m !== null; m = re.exec(input)) {
|
|
98
|
+
if (m[0].length === 0) re.lastIndex++;
|
|
99
|
+
}
|
|
100
|
+
const ms = performance.now() - start;
|
|
101
|
+
if (ms >= 50) slow.push(`${rule.id}: ${ms.toFixed(1)} ms on ${input.slice(0, 24)}…`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
expect(slow).toEqual([]);
|
|
105
|
+
},
|
|
106
|
+
{ retry: 2, timeout: 60_000 },
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("secret corpus", () => {
|
|
111
|
+
const positives = buildPositives();
|
|
112
|
+
|
|
113
|
+
test.each(positives.map((p) => [p.name, p] as const))("redacts %s", (_name, p) => {
|
|
114
|
+
const out = scrubSecrets(p.text);
|
|
115
|
+
expect(out).not.toContain(p.secret);
|
|
116
|
+
expect(out).toContain("[REDACTED:");
|
|
117
|
+
if (p.gitleaksRule) expect(out).toContain(`[REDACTED:gitleaks:${p.gitleaksRule}]`);
|
|
118
|
+
// Context around the secret survives, and a second scrub is a no-op.
|
|
119
|
+
expect(out.length).toBeGreaterThan(0);
|
|
120
|
+
expect(scrubSecrets(out)).toBe(out);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("redacts only the secret of a keyword-context rule, not its key", () => {
|
|
124
|
+
const p = positives.find((x) => x.name === "mailgun");
|
|
125
|
+
expect(scrubSecrets(p?.text)).toBe("mailgun: [REDACTED:gitleaks:mailgun-private-api-token]");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("redacts two secrets that share one delimiter", () => {
|
|
129
|
+
// azure-ad-client-secret consumes a delimiter on each side, so `a b`
|
|
130
|
+
// shares one space between the two matches.
|
|
131
|
+
const azure = () =>
|
|
132
|
+
[randomBytes(3).toString("hex").slice(0, 3), "7Q", "~", randomToken(31)].join("");
|
|
133
|
+
const pairs: [string, string][] = [[azure(), azure()]];
|
|
134
|
+
// The same check for every bare-secret positive (no custom context).
|
|
135
|
+
const again = new Map(buildPositives().map((p) => [p.name, p]));
|
|
136
|
+
for (const p of positives) {
|
|
137
|
+
const q = again.get(p.name);
|
|
138
|
+
if (q && p.text === `the response included ${p.secret} in its body`) {
|
|
139
|
+
pairs.push([p.secret, q.secret]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [a, b] of pairs) {
|
|
143
|
+
for (const sep of [" ", "\n", "\t"]) {
|
|
144
|
+
const out = scrubSecrets(`${a}${sep}${b}`);
|
|
145
|
+
expect(out, `${a.slice(0, 6)}… ${JSON.stringify(sep)}`).not.toContain(a);
|
|
146
|
+
expect(out, `${b.slice(0, 6)}… ${JSON.stringify(sep)}`).not.toContain(b);
|
|
147
|
+
expect(out).toMatch(new RegExp(`^\\[REDACTED:[^\\]]+\\]${sep}\\[REDACTED:[^\\]]+\\]$`));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("redacts a token whose last character is a dash or equals sign", () => {
|
|
153
|
+
// `\b` after a trailing `-` backtracks one character and leaves it behind.
|
|
154
|
+
const tokens = [
|
|
155
|
+
`ATATT3${randomToken(185)}-`,
|
|
156
|
+
`ATATT3${randomToken(185)}=`,
|
|
157
|
+
`glpat-${randomToken(19)}-`,
|
|
158
|
+
`AIza${randomToken(34)}-`,
|
|
159
|
+
`npm_${randomToken(35)}-`,
|
|
160
|
+
`lin_api_${randomToken(39)}-`,
|
|
161
|
+
];
|
|
162
|
+
for (const token of tokens) {
|
|
163
|
+
for (const tail of ["", " next", "\n"]) {
|
|
164
|
+
expect(scrubSecrets(`${token}${tail}`), token.slice(0, 6)).toMatch(
|
|
165
|
+
new RegExp(`^\\[REDACTED:[^\\]]+\\]${tail}$`),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("leaves every negative untouched", () => {
|
|
172
|
+
const changed = buildNegatives().filter((text) => scrubSecrets(text) !== text);
|
|
173
|
+
expect(changed).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a gitleaks rule below its entropy floor is not a finding", () => {
|
|
177
|
+
// stripe-access-token has entropy 2; a run of one char has entropy 0.
|
|
178
|
+
const lowEntropy = ["sk", "live", "a".repeat(24)].join("_");
|
|
179
|
+
expect(scrubSecrets(`got ${lowEntropy} back`)).toBe(`got ${lowEntropy} back`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("a rule's allowlist claims a match, and only that match", () => {
|
|
183
|
+
// vault-service-token allowlists legacy `s.` + 24 letters (no digits).
|
|
184
|
+
const lettersOnly = `vault said s.${"AbCdEfGhIjKlMnOpQrStUvWx"} ok`;
|
|
185
|
+
expect(scrubSecrets(lettersOnly)).toBe(lettersOnly);
|
|
186
|
+
const withDigit = ["s", `${randomToken(23)}7`].join(".");
|
|
187
|
+
expect(scrubSecrets(`vault said ${withDigit} ok`)).toBe(
|
|
188
|
+
"vault said [REDACTED:gitleaks:vault-service-token] ok",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("pass 5 throughput", () => {
|
|
194
|
+
// Informational budget. Timing-sensitive on a shared CI runner: retry
|
|
195
|
+
// absorbs a spike; a real regression fails every attempt.
|
|
196
|
+
test(
|
|
197
|
+
"10k lines with pass 5 cost under 2x the run without it",
|
|
198
|
+
() => {
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
for (let i = 0; i < 10_000; i++) {
|
|
201
|
+
lines.push(
|
|
202
|
+
`{"type":"assistant","line":${i},"text":"ran task step ${i} in /workspace/repo with key ${randomToken(16)} and they exit 0"}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const time = (enabled: boolean): number => {
|
|
206
|
+
setGitleaksPassEnabledForTesting(enabled);
|
|
207
|
+
for (const line of lines.slice(0, 500)) scrubSecrets(line);
|
|
208
|
+
const runs: number[] = [];
|
|
209
|
+
for (let r = 0; r < 3; r++) {
|
|
210
|
+
const start = performance.now();
|
|
211
|
+
for (const line of lines) scrubSecrets(line);
|
|
212
|
+
runs.push(performance.now() - start);
|
|
213
|
+
}
|
|
214
|
+
return runs.sort((a, b) => a - b)[1] ?? 0;
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
clearVolatileSecretsForTesting();
|
|
218
|
+
const without = time(false);
|
|
219
|
+
const withPass = time(true);
|
|
220
|
+
console.log(
|
|
221
|
+
`[secret-rules bench] 10k lines: ${without.toFixed(1)} ms without pass 5, ${withPass.toFixed(1)} ms with it`,
|
|
222
|
+
);
|
|
223
|
+
expect(withPass).toBeLessThanOrEqual(without * 2);
|
|
224
|
+
} finally {
|
|
225
|
+
setGitleaksPassEnabledForTesting(true);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{ retry: 2, timeout: 60_000 },
|
|
229
|
+
);
|
|
230
|
+
});
|
|
@@ -838,7 +838,8 @@ describe("scrubSecrets — over-redaction guards", () => {
|
|
|
838
838
|
});
|
|
839
839
|
|
|
840
840
|
// Each input is ~200 KB and built to make a naive pattern backtrack.
|
|
841
|
-
//
|
|
841
|
+
// Allow shared-runner headroom; a real ReDoS on these inputs takes seconds.
|
|
842
|
+
// retry: tolerate transient contention without weakening the per-input guard.
|
|
842
843
|
test(
|
|
843
844
|
"new key-context rules stay linear on 200 KB adversarial input",
|
|
844
845
|
() => {
|
|
@@ -867,7 +868,7 @@ describe("scrubSecrets — over-redaction guards", () => {
|
|
|
867
868
|
for (const input of inputs) {
|
|
868
869
|
const start = performance.now();
|
|
869
870
|
scrubSecrets(input);
|
|
870
|
-
expect(performance.now() - start).toBeLessThan(
|
|
871
|
+
expect(performance.now() - start).toBeLessThan(500);
|
|
871
872
|
}
|
|
872
873
|
},
|
|
873
874
|
{ retry: 2 },
|