|
1 | 1 | import { describe, test, expect } from "bun:test"; |
2 | 2 | import { |
3 | 3 | CREDENTIAL_REDACTION, |
| 4 | + countExpensiveSecretScrubPasses, |
4 | 5 | scrubSecretShapedContent, |
5 | 6 | scrubSecretShapedValue, |
| 7 | + secretScrubPatternHits, |
6 | 8 | } from "./tool-result-secret-scrub.js"; |
7 | 9 | import { toolResultSecretScrubPlugin } from "./tool-result-secret-scrub-plugin.js"; |
8 | 10 | import { resultTruncationPlugin } from "./result-truncation-plugin.js"; |
@@ -462,3 +464,168 @@ describe("toolResultSecretScrubPlugin", () => { |
462 | 464 | expect(result.content.split(CREDENTIAL_REDACTION).length - 1).toBe(1); |
463 | 465 | }); |
464 | 466 | }); |
| 467 | + |
| 468 | +const SENTINEL = "zzzsecretvalue"; |
| 469 | + |
| 470 | +function expectEnteredAndRedacted(text: string): void { |
| 471 | + expect(secretScrubPatternHits(text)).toBe(true); |
| 472 | + const passes = countExpensiveSecretScrubPasses(() => { |
| 473 | + const out = scrubSecretShapedContent(text); |
| 474 | + expect(out).not.toContain(SENTINEL); |
| 475 | + expect(out).toContain(CREDENTIAL_REDACTION); |
| 476 | + }); |
| 477 | + expect(passes).toBe(1); |
| 478 | +} |
| 479 | + |
| 480 | +function expectEnteredUnchanged(text: string): void { |
| 481 | + expect(secretScrubPatternHits(text)).toBe(true); |
| 482 | + const passes = countExpensiveSecretScrubPasses(() => { |
| 483 | + expect(scrubSecretShapedContent(text)).toBe(text); |
| 484 | + }); |
| 485 | + expect(passes).toBe(1); |
| 486 | +} |
| 487 | + |
| 488 | +function expectSkipped(text: string): void { |
| 489 | + const passes = countExpensiveSecretScrubPasses(() => { |
| 490 | + expect(scrubSecretShapedContent(text)).toBe(text); |
| 491 | + }); |
| 492 | + expect(passes).toBe(0); |
| 493 | + expect(secretScrubPatternHits(text)).toBe(false); |
| 494 | +} |
| 495 | + |
| 496 | +describe("secret scrub prefilter", () => { |
| 497 | + test("a large clean fixture does not enter the scrub passes", () => { |
| 498 | + const clean = "The quick brown fox jumps over the lazy dog. ".repeat(5000); |
| 499 | + expectSkipped(clean); |
| 500 | + const secret = `sk-${"a".repeat(6)}${SENTINEL}`; |
| 501 | + expectEnteredAndRedacted(`${clean}${secret}`); |
| 502 | + }); |
| 503 | + |
| 504 | + test("prefilter state does not skip a later secret", () => { |
| 505 | + const first = `sk-${"a".repeat(6)}${SENTINEL}`; |
| 506 | + const second = `ghp_${"b".repeat(22)}${SENTINEL}`; |
| 507 | + const passes = countExpensiveSecretScrubPasses(() => { |
| 508 | + expect(scrubSecretShapedContent(first)).not.toContain(SENTINEL); |
| 509 | + expect(scrubSecretShapedContent(second)).not.toContain(SENTINEL); |
| 510 | + }); |
| 511 | + expect(passes).toBe(2); |
| 512 | + }); |
| 513 | + |
| 514 | + test("every scrub pattern still redacts and enters the passes", () => { |
| 515 | + const pem = (kind: string) => { |
| 516 | + const label = kind.length === 0 ? "PRIVATE KEY" : `${kind} PRIVATE KEY`; |
| 517 | + return `-----BEGIN ${label}-----\n${SENTINEL}\n-----END ${label}-----`; |
| 518 | + }; |
| 519 | + for (const kind of ["", "RSA", "EC", "OPENSSH", "ENCRYPTED"]) { |
| 520 | + expectEnteredAndRedacted(pem(kind)); |
| 521 | + } |
| 522 | + |
| 523 | + expectEnteredAndRedacted(`prefix sk-${"a".repeat(6)}${SENTINEL} tail`); |
| 524 | + expectEnteredAndRedacted(`ghp_${"a".repeat(22)}${SENTINEL}`); |
| 525 | + expectEnteredAndRedacted(`gho_${"a".repeat(22)}${SENTINEL}`); |
| 526 | + for (const flavor of ["b", "a", "p", "r", "s"]) { |
| 527 | + expectEnteredAndRedacted(`xox${flavor}-${SENTINEL}`); |
| 528 | + } |
| 529 | + const akia = `AKIA${"A".repeat(16)}`; |
| 530 | + expect(secretScrubPatternHits(akia)).toBe(true); |
| 531 | + const akiaPasses = countExpensiveSecretScrubPasses(() => { |
| 532 | + expect(scrubSecretShapedContent(akia)).not.toContain(akia); |
| 533 | + }); |
| 534 | + expect(akiaPasses).toBe(1); |
| 535 | + expectEnteredAndRedacted(`Bearer ${"a".repeat(6)}${SENTINEL}`); |
| 536 | + expectEnteredAndRedacted(`Bearer\t${"a".repeat(6)}${SENTINEL}`); |
| 537 | + expectEnteredAndRedacted(`Basic ${SENTINEL}`); |
| 538 | + expectEnteredAndRedacted(`Basic\n${SENTINEL}==`); |
| 539 | + |
| 540 | + const queryKeys = [ |
| 541 | + "apikey", |
| 542 | + "api_key", |
| 543 | + "api-key", |
| 544 | + "ApiKey", |
| 545 | + "accesstoken", |
| 546 | + "access_token", |
| 547 | + "access-token", |
| 548 | + "AccessToken", |
| 549 | + "token", |
| 550 | + "TOKEN", |
| 551 | + "password", |
| 552 | + "secret", |
| 553 | + "credential", |
| 554 | + ]; |
| 555 | + const markers = [ |
| 556 | + "api", |
| 557 | + "secret", |
| 558 | + "private", |
| 559 | + "access", |
| 560 | + "auth", |
| 561 | + "token", |
| 562 | + "password", |
| 563 | + "credential", |
| 564 | + "openai", |
| 565 | + ]; |
| 566 | + for (const prefix of ["", "my_", "a1_", "my_app_"]) { |
| 567 | + for (const marker of markers) { |
| 568 | + queryKeys.push(`${prefix}${marker}_key`); |
| 569 | + queryKeys.push(`${prefix}${marker}_key`.toUpperCase()); |
| 570 | + } |
| 571 | + } |
| 572 | + for (const key of queryKeys) { |
| 573 | + expectEnteredAndRedacted( |
| 574 | + `GET https://provider.invalid/v1?${key}=${SENTINEL}&format=json`, |
| 575 | + ); |
| 576 | + expectEnteredAndRedacted(`{"${key}":"${SENTINEL}"}`); |
| 577 | + expectEnteredAndRedacted(`{ "${key}" : "${SENTINEL}" }`); |
| 578 | + } |
| 579 | + expectEnteredAndRedacted(`{"authorization":"${SENTINEL}"}`); |
| 580 | + expectEnteredAndRedacted(`{ "Authorization" : "${SENTINEL}" }`); |
| 581 | + |
| 582 | + expectEnteredAndRedacted(`API_KEY=${SENTINEL}`); |
| 583 | + expectEnteredAndRedacted(`STRIPE_KEY=${SENTINEL}`); |
| 584 | + expectEnteredAndRedacted(`GITHUB_KEY=${SENTINEL}`); |
| 585 | + expectEnteredAndRedacted(`OPENAI_KEY=${SENTINEL}`); |
| 586 | + expectEnteredAndRedacted(`./app/.env:3:TOKEN=${SENTINEL}`); |
| 587 | + expectEnteredAndRedacted(`\nPASSWORD=${SENTINEL}`); |
| 588 | + expectEnteredAndRedacted(`note:PRIVATE=${SENTINEL}`); |
| 589 | + expectEnteredAndRedacted(`CREDENTIAL=${SENTINEL}`); |
| 590 | + expectEnteredAndRedacted(`AUTH=${SENTINEL}`); |
| 591 | + expectEnteredAndRedacted(`SECRET=${SENTINEL}`); |
| 592 | + expectEnteredAndRedacted(`OPENAI_KEY=proj-OPENAI_KEY-${SENTINEL}`); |
| 593 | + expectEnteredAndRedacted( |
| 594 | + `DATABASE_URL=postgres://admin:${SENTINEL}@db.internal:5432/appdb`, |
| 595 | + ); |
| 596 | + expectEnteredAndRedacted( |
| 597 | + `DATABASE_URL=Server=db.internal;Password=${SENTINEL};`, |
| 598 | + ); |
| 599 | + expectEnteredAndRedacted( |
| 600 | + `FOO_URI=postgres://admin:${SENTINEL}@db.internal/app`, |
| 601 | + ); |
| 602 | + expectEnteredAndRedacted(`FOO_DSN=Password=${SENTINEL}`); |
| 603 | + expectEnteredAndRedacted(`FOO_CONNECTION_STRING=Password=${SENTINEL}`); |
| 604 | + }); |
| 605 | + |
| 606 | + test("env matches that are not secrets still enter the passes", () => { |
| 607 | + expectEnteredUnchanged("MONKEY=banana"); |
| 608 | + expectEnteredUnchanged("AB=cd"); |
| 609 | + expectEnteredUnchanged("DATABASE_URL=sqlite:///local.db"); |
| 610 | + expectEnteredUnchanged( |
| 611 | + "REGISTRY_URL=https://registry.npmjs.org/@scope/pkg", |
| 612 | + ); |
| 613 | + expectEnteredUnchanged("DOCS_URL=https://medium.com/@octocat/hello"); |
| 614 | + expectEnteredUnchanged( |
| 615 | + "SUPPORT_URL=https://example.com/help?email=dev@example.com", |
| 616 | + ); |
| 617 | + }); |
| 618 | + |
| 619 | + test("prose and storage keys do not enter the scrub passes", () => { |
| 620 | + expectSkipped( |
| 621 | + "See docs/authentication.md for how API keys are issued and rotated. No secrets in this paragraph.", |
| 622 | + ); |
| 623 | + expectSkipped( |
| 624 | + "GET https://provider.invalid/v1?sort_key=created_at&partition_key=tenant&format=json", |
| 625 | + ); |
| 626 | + expectSkipped( |
| 627 | + '{"partition_key":"tenant","sort_key":2,"primary_key":["id"],"cache_key":[1],"prompt_cache_key":"sess"}', |
| 628 | + ); |
| 629 | + expectSkipped('{"MONKEY": "banana", "key": "ctrl+s"}'); |
| 630 | + }); |
| 631 | +}); |
0 commit comments