@coo-quack/sensitive-canary 0.5.3 → 0.7.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.
@@ -1,5 +1,23 @@
1
- import { describe, expect, it } from "vitest";
2
- import { entropy, luhn, redact, scan } from "../rules.ts";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ compileRule,
4
+ enabledCategoriesFromEnv,
5
+ entropy,
6
+ isReservedIpv4,
7
+ isReservedIpv6,
8
+ luhn,
9
+ parseCategories,
10
+ redact,
11
+ scan,
12
+ validateChineseID,
13
+ validateCodiceFiscale,
14
+ validateFrenchNIR,
15
+ validateGermanIdNr,
16
+ validateKoreanBRN,
17
+ validateKoreanRRN,
18
+ validateMyNumber,
19
+ validateSpanishNIF,
20
+ } from "../rules.ts";
3
21
 
4
22
  // ── luhn ──────────────────────────────────────────────────────────────────────
5
23
 
@@ -20,6 +38,11 @@ describe("luhn", () => {
20
38
  expect(luhn("4111 1111 1111 1111")).toBe(true);
21
39
  expect(luhn("4111-1111-1111-1111")).toBe(true);
22
40
  });
41
+
42
+ it("fails empty or digit-less input", () => {
43
+ expect(luhn("")).toBe(false);
44
+ expect(luhn("no-digits-here")).toBe(false);
45
+ });
23
46
  });
24
47
 
25
48
  // ── entropy ───────────────────────────────────────────────────────────────────
@@ -231,6 +254,75 @@ describe("scan — secrets", () => {
231
254
  });
232
255
  });
233
256
 
257
+ // ── scan: expanded secrets ───────────────────────────────────────────────────
258
+
259
+ describe("scan — expanded secrets (AI, cloud, SaaS)", () => {
260
+ it("detects a Replicate token", () => {
261
+ const findings = scan(`r8_${"A".repeat(37)}`);
262
+ expect(findings.some((f) => f.ruleId === "replicate-token")).toBe(true);
263
+ });
264
+
265
+ it("detects a Hugging Face token", () => {
266
+ const findings = scan(`hf_${"a".repeat(34)}`);
267
+ expect(findings.some((f) => f.ruleId === "huggingface-token")).toBe(true);
268
+ });
269
+
270
+ it("detects a Groq API key", () => {
271
+ const findings = scan(`gsk_${"A".repeat(52)}`);
272
+ expect(findings.some((f) => f.ruleId === "groq-key")).toBe(true);
273
+ });
274
+
275
+ it("detects an OpenRouter API key", () => {
276
+ const findings = scan(`sk-or-v1-${"a".repeat(64)}`);
277
+ expect(findings.some((f) => f.ruleId === "openrouter-key")).toBe(true);
278
+ });
279
+
280
+ it("detects an xAI (Grok) API key", () => {
281
+ const findings = scan(`xai-${"A".repeat(80)}`);
282
+ expect(findings.some((f) => f.ruleId === "xai-key")).toBe(true);
283
+ });
284
+
285
+ it("detects a Perplexity API key", () => {
286
+ const findings = scan(`pplx-${"a".repeat(48)}`);
287
+ expect(findings.some((f) => f.ruleId === "perplexity-key")).toBe(true);
288
+ });
289
+
290
+ it("detects a DigitalOcean PAT", () => {
291
+ const findings = scan(`dop_v1_${"a".repeat(64)}`);
292
+ expect(findings.some((f) => f.ruleId === "digitalocean-pat")).toBe(true);
293
+ });
294
+
295
+ it("detects a Square access token", () => {
296
+ const findings = scan(`EAAA${"A".repeat(60)}`);
297
+ expect(findings.some((f) => f.ruleId === "square-access-token")).toBe(true);
298
+ });
299
+
300
+ it("detects a Sentry user auth token", () => {
301
+ const findings = scan(`sntryu_${"a".repeat(64)}`);
302
+ expect(findings.some((f) => f.ruleId === "sentry-user-token")).toBe(true);
303
+ });
304
+
305
+ it("detects an Atlassian API token", () => {
306
+ const findings = scan(`ATATT3${"A".repeat(180)}`);
307
+ expect(findings.some((f) => f.ruleId === "atlassian-token")).toBe(true);
308
+ });
309
+
310
+ it("detects a Linear API key", () => {
311
+ const findings = scan(`lin_api_${"a".repeat(40)}`);
312
+ expect(findings.some((f) => f.ruleId === "linear-key")).toBe(true);
313
+ });
314
+
315
+ it("detects a Postman API key", () => {
316
+ const findings = scan(`PMAK-${"a".repeat(24)}-${"b".repeat(34)}`);
317
+ expect(findings.some((f) => f.ruleId === "postman-key")).toBe(true);
318
+ });
319
+
320
+ it("detects a Supabase PAT", () => {
321
+ const findings = scan(`sbp_${"a".repeat(40)}`);
322
+ expect(findings.some((f) => f.ruleId === "supabase-key")).toBe(true);
323
+ });
324
+ });
325
+
234
326
  // ── scan: PII ─────────────────────────────────────────────────────────────────
235
327
 
236
328
  describe("scan — PII", () => {
@@ -350,3 +442,929 @@ describe("scan — PII", () => {
350
442
  expect(findings.some((f) => f.ruleId === "pii-ipv4")).toBe(false);
351
443
  });
352
444
  });
445
+
446
+ // ── parseCategories ───────────────────────────────────────────────────────────
447
+
448
+ describe("parseCategories", () => {
449
+ it("defaults to all categories when unset", () => {
450
+ expect(parseCategories(undefined)).toEqual(new Set(["secret", "pii"]));
451
+ });
452
+
453
+ it("defaults to all categories when empty", () => {
454
+ expect(parseCategories("")).toEqual(new Set(["secret", "pii"]));
455
+ });
456
+
457
+ it("parses a single category", () => {
458
+ expect(parseCategories("secret")).toEqual(new Set(["secret"]));
459
+ expect(parseCategories("pii")).toEqual(new Set(["pii"]));
460
+ });
461
+
462
+ it("parses a comma-separated list", () => {
463
+ expect(parseCategories("secret,pii")).toEqual(new Set(["secret", "pii"]));
464
+ });
465
+
466
+ it("treats 'all' as every category", () => {
467
+ expect(parseCategories("all")).toEqual(new Set(["secret", "pii"]));
468
+ expect(parseCategories("secret,all")).toEqual(new Set(["secret", "pii"]));
469
+ });
470
+
471
+ it("is case-insensitive and trims whitespace", () => {
472
+ expect(parseCategories(" Secret , PII ")).toEqual(
473
+ new Set(["secret", "pii"]),
474
+ );
475
+ });
476
+
477
+ it("falls back to all when no valid token is present", () => {
478
+ expect(parseCategories("foo,bar")).toEqual(new Set(["secret", "pii"]));
479
+ });
480
+ });
481
+
482
+ // ── scan: category filter ─────────────────────────────────────────────────────
483
+
484
+ describe("scan — category filter", () => {
485
+ const text = "key=AKIAIOSFODNN7EXAMPLE card: 4111111111111111";
486
+
487
+ it("scans all categories by default", () => {
488
+ const findings = scan(text);
489
+ expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(true);
490
+ expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(true);
491
+ });
492
+
493
+ it("scans only secrets when limited to the secret category", () => {
494
+ const findings = scan(text, new Set(["secret"]));
495
+ expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(true);
496
+ expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(false);
497
+ });
498
+
499
+ it("scans only PII when limited to the pii category", () => {
500
+ const findings = scan(text, new Set(["pii"]));
501
+ expect(findings.some((f) => f.ruleId === "aws-access-key")).toBe(false);
502
+ expect(findings.some((f) => f.ruleId === "pii-credit-card")).toBe(true);
503
+ });
504
+
505
+ it("returns nothing when no categories are enabled", () => {
506
+ expect(scan(text, new Set())).toEqual([]);
507
+ });
508
+ });
509
+
510
+ // ── enabledCategoriesFromEnv ──────────────────────────────────────────────────
511
+
512
+ describe("enabledCategoriesFromEnv", () => {
513
+ const ENV_KEY = "SENSITIVE_CANARY_CATEGORIES";
514
+ const original = process.env[ENV_KEY];
515
+
516
+ afterEach(() => {
517
+ if (original === undefined) {
518
+ delete process.env[ENV_KEY];
519
+ } else {
520
+ process.env[ENV_KEY] = original;
521
+ }
522
+ });
523
+
524
+ it("returns all categories when the env var is unset", () => {
525
+ delete process.env[ENV_KEY];
526
+ expect(enabledCategoriesFromEnv()).toEqual(new Set(["secret", "pii"]));
527
+ });
528
+
529
+ it("returns the parsed categories when the env var is set", () => {
530
+ process.env[ENV_KEY] = "pii";
531
+ expect(enabledCategoriesFromEnv()).toEqual(new Set(["pii"]));
532
+ });
533
+ });
534
+
535
+ // ── National ID checksum validators ───────────────────────────────────────────
536
+
537
+ describe("validateMyNumber", () => {
538
+ it("passes a valid My Number", () => {
539
+ expect(validateMyNumber("123456789018")).toBe(true);
540
+ });
541
+
542
+ it("fails an incorrect check digit", () => {
543
+ expect(validateMyNumber("123456789019")).toBe(false);
544
+ });
545
+
546
+ it("fails a wrong length", () => {
547
+ expect(validateMyNumber("12345678901")).toBe(false);
548
+ expect(validateMyNumber("1234567890123")).toBe(false);
549
+ });
550
+ });
551
+
552
+ describe("validateFrenchNIR", () => {
553
+ it("passes a valid NIR", () => {
554
+ expect(validateFrenchNIR("123456789012311")).toBe(true);
555
+ });
556
+
557
+ it("fails an incorrect check key", () => {
558
+ expect(validateFrenchNIR("123456789012399")).toBe(false);
559
+ });
560
+
561
+ it("fails a wrong length", () => {
562
+ expect(validateFrenchNIR("1234567890123")).toBe(false);
563
+ });
564
+
565
+ it("passes a valid NIR with Corsica 2A", () => {
566
+ expect(validateFrenchNIR("188022A12345632")).toBe(true);
567
+ });
568
+
569
+ it("passes a valid NIR with Corsica 2B", () => {
570
+ expect(validateFrenchNIR("188022B12345659")).toBe(true);
571
+ });
572
+ });
573
+
574
+ describe("validateCodiceFiscale", () => {
575
+ it("passes a valid Codice Fiscale", () => {
576
+ expect(validateCodiceFiscale("RSSMRA85M01H501Q")).toBe(true);
577
+ });
578
+
579
+ it("fails an incorrect control character", () => {
580
+ expect(validateCodiceFiscale("RSSMRA85M01H501Z")).toBe(false);
581
+ });
582
+
583
+ it("fails a wrong shape", () => {
584
+ expect(validateCodiceFiscale("RSSMRA85M01H501")).toBe(false);
585
+ });
586
+ });
587
+
588
+ describe("validateGermanIdNr", () => {
589
+ it("passes a valid Steuer-IdNr.", () => {
590
+ expect(validateGermanIdNr("12345678903")).toBe(true);
591
+ });
592
+
593
+ it("fails an incorrect check digit", () => {
594
+ expect(validateGermanIdNr("12345678900")).toBe(false);
595
+ });
596
+
597
+ it("fails when the first digit is 0", () => {
598
+ expect(validateGermanIdNr("02345678903")).toBe(false);
599
+ });
600
+ });
601
+
602
+ describe("validateSpanishNIF", () => {
603
+ it("passes a valid DNI", () => {
604
+ expect(validateSpanishNIF("12345678Z")).toBe(true);
605
+ });
606
+
607
+ it("fails an incorrect DNI control letter", () => {
608
+ expect(validateSpanishNIF("12345678Y")).toBe(false);
609
+ });
610
+
611
+ it("passes a valid NIE", () => {
612
+ expect(validateSpanishNIF("X1234567L")).toBe(true);
613
+ });
614
+
615
+ it("fails an incorrect NIE control letter", () => {
616
+ expect(validateSpanishNIF("X1234567M")).toBe(false);
617
+ });
618
+ });
619
+
620
+ // ── scan: national ID numbers ─────────────────────────────────────────────────
621
+
622
+ describe("scan — national ID numbers", () => {
623
+ it("detects a valid My Number", () => {
624
+ const findings = scan("number: 123456789018");
625
+ expect(findings.some((f) => f.ruleId === "pii-mynumber-jp")).toBe(true);
626
+ });
627
+
628
+ it("does not flag a My Number with a bad check digit", () => {
629
+ const findings = scan("number: 123456789019");
630
+ expect(findings.some((f) => f.ruleId === "pii-mynumber-jp")).toBe(false);
631
+ });
632
+
633
+ it("detects a valid French NIR", () => {
634
+ const findings = scan("secu: 1850175056001 49");
635
+ expect(findings.some((f) => f.ruleId === "pii-nir-fr")).toBe(true);
636
+ });
637
+
638
+ it("detects a valid Italian Codice Fiscale", () => {
639
+ const findings = scan("cf: RSSMRA85M01H501Q");
640
+ expect(findings.some((f) => f.ruleId === "pii-codice-fiscale-it")).toBe(
641
+ true,
642
+ );
643
+ });
644
+
645
+ it("detects a valid German Steuer-IdNr.", () => {
646
+ const findings = scan("idnr: 12345678903");
647
+ expect(findings.some((f) => f.ruleId === "pii-steuer-id-de")).toBe(true);
648
+ });
649
+
650
+ it("detects a valid Spanish DNI", () => {
651
+ const findings = scan("dni: 12345678Z");
652
+ expect(findings.some((f) => f.ruleId === "pii-dni-nie-es")).toBe(true);
653
+ });
654
+
655
+ it("detects a valid Spanish NIE", () => {
656
+ const findings = scan("nie: X1234567L");
657
+ expect(findings.some((f) => f.ruleId === "pii-dni-nie-es")).toBe(true);
658
+ });
659
+
660
+ it("does not flag a French NIR with a bad check key", () => {
661
+ const findings = scan("secu: 1850175056001 99");
662
+ expect(findings.some((f) => f.ruleId === "pii-nir-fr")).toBe(false);
663
+ });
664
+
665
+ it("does not flag a Codice Fiscale with a bad control character", () => {
666
+ const findings = scan("cf: RSSMRA85M01H501Z");
667
+ expect(findings.some((f) => f.ruleId === "pii-codice-fiscale-it")).toBe(
668
+ false,
669
+ );
670
+ });
671
+
672
+ it("does not flag a Steuer-IdNr. with a bad check digit", () => {
673
+ const findings = scan("idnr: 12345678900");
674
+ expect(findings.some((f) => f.ruleId === "pii-steuer-id-de")).toBe(false);
675
+ });
676
+
677
+ it("does not flag a DNI with a bad control letter", () => {
678
+ const findings = scan("dni: 12345678Y");
679
+ expect(findings.some((f) => f.ruleId === "pii-dni-nie-es")).toBe(false);
680
+ });
681
+ });
682
+
683
+ // ── scan: FIGS phone numbers (context-gated) ──────────────────────────────────
684
+
685
+ describe("scan — FIGS phone numbers", () => {
686
+ it("detects a French phone number with context", () => {
687
+ const findings = scan("tél: 01 23 45 67 89");
688
+ expect(findings.some((f) => f.ruleId === "pii-phone-fr")).toBe(true);
689
+ });
690
+
691
+ it("does not flag a bare French number without context", () => {
692
+ const findings = scan("ref 01 23 45 67 89 done");
693
+ expect(findings.some((f) => f.ruleId === "pii-phone-fr")).toBe(false);
694
+ });
695
+
696
+ it("detects an Italian phone number with context", () => {
697
+ const findings = scan("telefono: 0212345678");
698
+ expect(findings.some((f) => f.ruleId === "pii-phone-it")).toBe(true);
699
+ });
700
+
701
+ it("does not flag a bare Italian number without context", () => {
702
+ const findings = scan("id 0212345678 end");
703
+ expect(findings.some((f) => f.ruleId === "pii-phone-it")).toBe(false);
704
+ });
705
+
706
+ it("detects a German phone number with context", () => {
707
+ const findings = scan("Telefon: 0301234567");
708
+ expect(findings.some((f) => f.ruleId === "pii-phone-de")).toBe(true);
709
+ });
710
+
711
+ it("does not flag a bare German number without context", () => {
712
+ const findings = scan("code 0301234567 end");
713
+ expect(findings.some((f) => f.ruleId === "pii-phone-de")).toBe(false);
714
+ });
715
+
716
+ it("detects a Spanish phone number with context", () => {
717
+ const findings = scan("teléfono: 612345678");
718
+ expect(findings.some((f) => f.ruleId === "pii-phone-es")).toBe(true);
719
+ });
720
+
721
+ it("does not flag a bare Spanish number without context", () => {
722
+ const findings = scan("num 612345678 end");
723
+ expect(findings.some((f) => f.ruleId === "pii-phone-es")).toBe(false);
724
+ });
725
+ });
726
+
727
+ // ── scan: postal code (context-gated) ─────────────────────────────────────────
728
+
729
+ describe("scan — postal code", () => {
730
+ it("detects a US ZIP with context", () => {
731
+ const findings = scan("ZIP: 90210");
732
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
733
+ });
734
+
735
+ it("detects a US ZIP+4 with context", () => {
736
+ const findings = scan("postal: 90210-1234");
737
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
738
+ });
739
+
740
+ it("detects a German PLZ with context", () => {
741
+ const findings = scan("PLZ: 10115");
742
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
743
+ });
744
+
745
+ it("detects an Italian CAP with context", () => {
746
+ const findings = scan("CAP: 00184");
747
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
748
+ });
749
+
750
+ it("does not flag a bare 5-digit number", () => {
751
+ const findings = scan("count: 12345 items");
752
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(false);
753
+ });
754
+
755
+ it("does not flag a ZIP without a context label", () => {
756
+ const findings = scan("order 90210 confirmed");
757
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(false);
758
+ });
759
+
760
+ it("detects a French postal code with context", () => {
761
+ const findings = scan("postal: 75001");
762
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
763
+ });
764
+
765
+ it("detects a Spanish postal code with context", () => {
766
+ const findings = scan("postal: 28013");
767
+ expect(findings.some((f) => f.ruleId === "pii-postal-code")).toBe(true);
768
+ });
769
+ });
770
+
771
+ // ── scan: context scoring ─────────────────────────────────────────────────────
772
+
773
+ describe("scan — context scoring", () => {
774
+ it("assigns score 1.0 to a context-gated match", () => {
775
+ const findings = scan("ZIP: 90210");
776
+ const postal = findings.find((f) => f.ruleId === "pii-postal-code");
777
+ expect(postal?.score).toBe(1.0);
778
+ });
779
+
780
+ it("assigns score 1.0 to rules without context requirements", () => {
781
+ const findings = scan("contact: user@example.com");
782
+ const email = findings.find((f) => f.ruleId === "pii-email");
783
+ expect(email?.score).toBe(1.0);
784
+ });
785
+ });
786
+
787
+ // ── Korean / Chinese ID validators ────────────────────────────────────────────
788
+
789
+ describe("validateKoreanRRN", () => {
790
+ it("passes a valid RRN", () => {
791
+ expect(validateKoreanRRN("8001011000008")).toBe(true);
792
+ });
793
+
794
+ it("fails an incorrect check digit", () => {
795
+ expect(validateKoreanRRN("8001011000009")).toBe(false);
796
+ });
797
+
798
+ it("fails a wrong length", () => {
799
+ expect(validateKoreanRRN("800101100000")).toBe(false);
800
+ });
801
+ });
802
+
803
+ describe("validateKoreanBRN", () => {
804
+ it("passes a valid BRN", () => {
805
+ expect(validateKoreanBRN("1348672612")).toBe(true);
806
+ });
807
+
808
+ it("fails an incorrect check digit", () => {
809
+ expect(validateKoreanBRN("1348672610")).toBe(false);
810
+ });
811
+
812
+ it("fails a wrong length", () => {
813
+ expect(validateKoreanBRN("134867261")).toBe(false);
814
+ });
815
+ });
816
+
817
+ describe("validateChineseID", () => {
818
+ it("passes a valid Resident Identity Card", () => {
819
+ expect(validateChineseID("110102199001010011")).toBe(true);
820
+ });
821
+
822
+ it("fails an incorrect check digit", () => {
823
+ expect(validateChineseID("110102199001010010")).toBe(false);
824
+ });
825
+
826
+ it("fails a wrong length", () => {
827
+ expect(validateChineseID("11010219900101001")).toBe(false);
828
+ });
829
+ });
830
+
831
+ // ── IP reserved-range checks ──────────────────────────────────────────────────
832
+
833
+ describe("isReservedIpv4", () => {
834
+ it("returns false for a public IP", () => {
835
+ expect(isReservedIpv4("8.8.8.8")).toBe(false);
836
+ });
837
+
838
+ it("returns true for private ranges", () => {
839
+ expect(isReservedIpv4("192.168.1.1")).toBe(true);
840
+ expect(isReservedIpv4("10.0.0.1")).toBe(true);
841
+ expect(isReservedIpv4("172.16.0.1")).toBe(true);
842
+ });
843
+
844
+ it("returns true for TEST-NET addresses", () => {
845
+ expect(isReservedIpv4("192.0.2.1")).toBe(true);
846
+ expect(isReservedIpv4("203.0.113.1")).toBe(true);
847
+ });
848
+
849
+ it("returns true for loopback and link-local", () => {
850
+ expect(isReservedIpv4("127.0.0.1")).toBe(true);
851
+ expect(isReservedIpv4("169.254.1.1")).toBe(true);
852
+ });
853
+
854
+ it("returns true for partially-numeric octets", () => {
855
+ expect(isReservedIpv4("1a.2.3.4")).toBe(true);
856
+ expect(isReservedIpv4("8.8.8.8x")).toBe(true);
857
+ expect(isReservedIpv4("1.2.3.4 ")).toBe(true);
858
+ });
859
+
860
+ it("returns true for other RFC 6890 special-purpose ranges", () => {
861
+ expect(isReservedIpv4("192.0.0.1")).toBe(true); // IETF protocol assignments
862
+ expect(isReservedIpv4("192.88.99.1")).toBe(true); // 6to4 relay anycast
863
+ });
864
+ });
865
+
866
+ describe("isReservedIpv6", () => {
867
+ it("returns false for a public IPv6", () => {
868
+ expect(isReservedIpv6("2001:4860:4860::8888")).toBe(false);
869
+ });
870
+
871
+ it("returns true for loopback", () => {
872
+ expect(isReservedIpv6("::1")).toBe(true);
873
+ });
874
+
875
+ it("returns true for fully-expanded loopback", () => {
876
+ expect(isReservedIpv6("0:0:0:0:0:0:0:1")).toBe(true);
877
+ });
878
+
879
+ it("returns true for fully-expanded unspecified", () => {
880
+ expect(isReservedIpv6("0:0:0:0:0:0:0:0")).toBe(true);
881
+ });
882
+
883
+ it("returns true for compressed unspecified", () => {
884
+ expect(isReservedIpv6("::")).toBe(true);
885
+ });
886
+
887
+ it("returns true for link-local", () => {
888
+ expect(isReservedIpv6("fe80::1")).toBe(true);
889
+ });
890
+
891
+ it("returns true for fully-expanded link-local", () => {
892
+ expect(isReservedIpv6("fe80:0:0:0:0:0:0:1")).toBe(true);
893
+ });
894
+
895
+ it("returns true for unique-local", () => {
896
+ expect(isReservedIpv6("fd00::1")).toBe(true);
897
+ });
898
+
899
+ it("returns true for multicast", () => {
900
+ expect(isReservedIpv6("ff02::1")).toBe(true);
901
+ });
902
+
903
+ it("returns true for documentation addresses", () => {
904
+ expect(isReservedIpv6("2001:db8::1")).toBe(true);
905
+ });
906
+
907
+ it("returns true for groups with non-hex trailing characters", () => {
908
+ expect(isReservedIpv6("abcdZ::1")).toBe(true);
909
+ expect(isReservedIpv6("2001:4860:4860::8888g")).toBe(true);
910
+ });
911
+
912
+ it("returns true for groups longer than 4 hex digits", () => {
913
+ expect(isReservedIpv6("12345::1")).toBe(true);
914
+ });
915
+
916
+ it("returns true when :: compresses zero groups", () => {
917
+ // RFC 4291 §2.2: "::" must compress at least one 16-bit group.
918
+ expect(isReservedIpv6("1:2:3:4:5:6:7::8")).toBe(true);
919
+ });
920
+ });
921
+
922
+ // ── scan: Korean / Chinese IDs ────────────────────────────────────────────────
923
+
924
+ describe("scan — Korean / Chinese IDs", () => {
925
+ it("detects a valid Korean RRN", () => {
926
+ const findings = scan("rrn: 800101-1000008");
927
+ expect(findings.some((f) => f.ruleId === "pii-rrn-kr")).toBe(true);
928
+ });
929
+
930
+ it("does not flag an RRN with a bad check digit", () => {
931
+ const findings = scan("rrn: 800101-1000009");
932
+ expect(findings.some((f) => f.ruleId === "pii-rrn-kr")).toBe(false);
933
+ });
934
+
935
+ it("detects a valid Chinese Resident ID", () => {
936
+ const findings = scan("id: 110102199001010011");
937
+ expect(findings.some((f) => f.ruleId === "pii-resident-id-cn")).toBe(true);
938
+ });
939
+
940
+ it("does not flag a Chinese ID with a bad check digit", () => {
941
+ const findings = scan("id: 110102199001010010");
942
+ expect(findings.some((f) => f.ruleId === "pii-resident-id-cn")).toBe(false);
943
+ });
944
+
945
+ it("detects a valid Korean BRN", () => {
946
+ const findings = scan("brn: 134-86-72612");
947
+ expect(findings.some((f) => f.ruleId === "pii-brn-kr")).toBe(true);
948
+ });
949
+
950
+ it("does not flag a BRN with a bad check digit", () => {
951
+ const findings = scan("brn: 134-86-72610");
952
+ expect(findings.some((f) => f.ruleId === "pii-brn-kr")).toBe(false);
953
+ });
954
+ });
955
+
956
+ // ── scan: Korean / Chinese phone numbers ──────────────────────────────────────
957
+
958
+ describe("scan — Korean / Chinese phones", () => {
959
+ it("detects a Korean phone with context", () => {
960
+ const findings = scan("전화: 010-1234-5678");
961
+ expect(findings.some((f) => f.ruleId === "pii-phone-kr")).toBe(true);
962
+ });
963
+
964
+ it("does not flag a bare Korean number without context", () => {
965
+ const findings = scan("ref 010-1234-5678 end");
966
+ expect(findings.some((f) => f.ruleId === "pii-phone-kr")).toBe(false);
967
+ });
968
+
969
+ it("detects a Chinese phone with context", () => {
970
+ const findings = scan("电话: 13812345678");
971
+ expect(findings.some((f) => f.ruleId === "pii-phone-cn")).toBe(true);
972
+ });
973
+
974
+ it("does not flag a bare Chinese number without context", () => {
975
+ const findings = scan("id 13812345678 end");
976
+ expect(findings.some((f) => f.ruleId === "pii-phone-cn")).toBe(false);
977
+ });
978
+ });
979
+
980
+ // ── scan: Chinese postal code ─────────────────────────────────────────────────
981
+
982
+ describe("scan — Chinese postal code", () => {
983
+ it("detects a Chinese postal code with context", () => {
984
+ const findings = scan("邮编: 100000");
985
+ expect(findings.some((f) => f.ruleId === "pii-postal-cn")).toBe(true);
986
+ });
987
+
988
+ it("does not flag a bare 6-digit number", () => {
989
+ const findings = scan("count 123456 done");
990
+ expect(findings.some((f) => f.ruleId === "pii-postal-cn")).toBe(false);
991
+ });
992
+ });
993
+
994
+ // ── scan: public IP addresses ─────────────────────────────────────────────────
995
+
996
+ describe("scan — public IPs", () => {
997
+ it("detects a public IPv4 with context", () => {
998
+ const findings = scan("ip: 8.8.8.8");
999
+ expect(findings.some((f) => f.ruleId === "pii-ipv4-public")).toBe(true);
1000
+ });
1001
+
1002
+ it("does not flag a public IPv4 without context", () => {
1003
+ const findings = scan("ping 8.8.8.8 now");
1004
+ expect(findings.some((f) => f.ruleId === "pii-ipv4-public")).toBe(false);
1005
+ });
1006
+
1007
+ it("does not flag a private IPv4 as public", () => {
1008
+ const findings = scan("ip: 192.168.1.1");
1009
+ expect(findings.some((f) => f.ruleId === "pii-ipv4-public")).toBe(false);
1010
+ });
1011
+
1012
+ it("still flags a private IPv4 via the private-range rule", () => {
1013
+ const findings = scan("server: 192.168.1.1");
1014
+ expect(findings.some((f) => f.ruleId === "pii-ipv4")).toBe(true);
1015
+ });
1016
+
1017
+ it("detects an IPv6 with context", () => {
1018
+ const findings = scan("ipv6: 2001:4860:4860::8888");
1019
+ expect(findings.some((f) => f.ruleId === "pii-ipv6")).toBe(true);
1020
+ });
1021
+
1022
+ it("does not flag a link-local IPv6", () => {
1023
+ const findings = scan("ipv6: fe80::1");
1024
+ expect(findings.some((f) => f.ruleId === "pii-ipv6")).toBe(false);
1025
+ });
1026
+ });
1027
+
1028
+ // ── compileRule ───────────────────────────────────────────────────────────────
1029
+
1030
+ describe("compileRule", () => {
1031
+ it("compiles regex source with default g flag", () => {
1032
+ const rule = compileRule({
1033
+ id: "test",
1034
+ description: "Test",
1035
+ regex: "\\d{4}",
1036
+ category: "pii",
1037
+ });
1038
+ expect(rule.regex.flags).toBe("g");
1039
+ expect("1234".match(rule.regex)).not.toBeNull();
1040
+ });
1041
+
1042
+ it("compiles with custom flags", () => {
1043
+ const rule = compileRule({
1044
+ id: "test",
1045
+ description: "Test",
1046
+ regex: "\\d{4}",
1047
+ flags: "gi",
1048
+ category: "pii",
1049
+ });
1050
+ expect(rule.regex.flags).toBe("gi");
1051
+ });
1052
+
1053
+ it("resolves a validator by name", () => {
1054
+ const rule = compileRule({
1055
+ id: "test",
1056
+ description: "Test",
1057
+ regex: "\\d{12}",
1058
+ category: "pii",
1059
+ validate: "mynumber-jp",
1060
+ });
1061
+ expect(rule.validate).toBeDefined();
1062
+ expect(rule.validate?.("123456789018")).toBe(true);
1063
+ });
1064
+
1065
+ it("preserves secretGroup and entropyThreshold", () => {
1066
+ const rule = compileRule({
1067
+ id: "test",
1068
+ description: "Test",
1069
+ regex: "key=(\\S+)",
1070
+ category: "secret",
1071
+ secretGroup: 1,
1072
+ entropyThreshold: 3.5,
1073
+ });
1074
+ expect(rule.secretGroup).toBe(1);
1075
+ expect(rule.entropyThreshold).toBe(3.5);
1076
+ });
1077
+ });
1078
+
1079
+ // ── compileRule: schema validation ───────────────────────────────────────────
1080
+
1081
+ describe("compileRule — schema validation", () => {
1082
+ const valid = {
1083
+ id: "test",
1084
+ description: "Test",
1085
+ regex: "\\d+",
1086
+ category: "pii" as const,
1087
+ };
1088
+
1089
+ it("rejects missing id", () => {
1090
+ expect(() => compileRule({ ...valid, id: "" } as never)).toThrow('"id"');
1091
+ });
1092
+
1093
+ it("rejects missing description", () => {
1094
+ expect(() => compileRule({ ...valid, description: "" } as never)).toThrow(
1095
+ '"description"',
1096
+ );
1097
+ });
1098
+
1099
+ it("rejects missing regex", () => {
1100
+ expect(() => compileRule({ ...valid, regex: "" } as never)).toThrow(
1101
+ '"regex"',
1102
+ );
1103
+ });
1104
+
1105
+ it("rejects invalid category", () => {
1106
+ expect(() => compileRule({ ...valid, category: "other" as never })).toThrow(
1107
+ '"category"',
1108
+ );
1109
+ });
1110
+
1111
+ it("rejects non-integer secretGroup", () => {
1112
+ expect(() => compileRule({ ...valid, secretGroup: 1.5 } as never)).toThrow(
1113
+ '"secretGroup"',
1114
+ );
1115
+ });
1116
+
1117
+ it("rejects negative entropyThreshold", () => {
1118
+ expect(() =>
1119
+ compileRule({ ...valid, entropyThreshold: -1 } as never),
1120
+ ).toThrow('"entropyThreshold"');
1121
+ });
1122
+
1123
+ it("rejects contextWords with empty string", () => {
1124
+ expect(() =>
1125
+ compileRule({ ...valid, contextWords: ["ok", ""] } as never),
1126
+ ).toThrow('"contextWords"');
1127
+ });
1128
+
1129
+ it("rejects non-integer contextWindow", () => {
1130
+ expect(() => compileRule({ ...valid, contextWindow: 0 } as never)).toThrow(
1131
+ '"contextWindow"',
1132
+ );
1133
+ });
1134
+
1135
+ it("rejects requireContext without contextWords", () => {
1136
+ expect(() =>
1137
+ compileRule({ ...valid, requireContext: true } as never),
1138
+ ).toThrow("requireContext");
1139
+ });
1140
+
1141
+ it("rejects requireContext with empty contextWords array", () => {
1142
+ expect(() =>
1143
+ compileRule({
1144
+ ...valid,
1145
+ requireContext: true,
1146
+ contextWords: [],
1147
+ } as never),
1148
+ ).toThrow("requireContext");
1149
+ });
1150
+ });
1151
+
1152
+ // ── User config (custom rules) ────────────────────────────────────────────────
1153
+
1154
+ describe("user config — custom rules", () => {
1155
+ const ENV_KEY = "SENSITIVE_CANARY_CONFIG";
1156
+ const envBackup = process.env[ENV_KEY];
1157
+
1158
+ afterEach(() => {
1159
+ if (envBackup === undefined) {
1160
+ delete process.env[ENV_KEY];
1161
+ } else {
1162
+ process.env[ENV_KEY] = envBackup;
1163
+ }
1164
+ vi.resetModules();
1165
+ });
1166
+
1167
+ it("adds a custom rule from a user config file", async () => {
1168
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1169
+ const { tmpdir } = await import("node:os");
1170
+ const { join } = await import("node:path");
1171
+
1172
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1173
+ writeFileSync(
1174
+ join(dir, "config.json"),
1175
+ JSON.stringify({
1176
+ rules: [
1177
+ {
1178
+ id: "custom-token",
1179
+ description: "Custom Service Token",
1180
+ regex: "MYSVC-[A-Za-z0-9]{20}",
1181
+ category: "secret",
1182
+ },
1183
+ ],
1184
+ }),
1185
+ );
1186
+
1187
+ process.env[ENV_KEY] = join(dir, "config.json");
1188
+ vi.resetModules();
1189
+
1190
+ const { RULES, scan } = await import("../rules.ts");
1191
+ expect(RULES.some((r) => r.id === "custom-token")).toBe(true);
1192
+
1193
+ const findings = scan("token: MYSVC-abcdefghijklmnopqrst");
1194
+ expect(findings.some((f) => f.ruleId === "custom-token")).toBe(true);
1195
+ });
1196
+
1197
+ it("overrides a built-in rule by id", async () => {
1198
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1199
+ const { tmpdir } = await import("node:os");
1200
+ const { join } = await import("node:path");
1201
+
1202
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1203
+ writeFileSync(
1204
+ join(dir, "config.json"),
1205
+ JSON.stringify({
1206
+ rules: [
1207
+ {
1208
+ id: "pii-email",
1209
+ description: "Replaced Email Rule",
1210
+ regex: "NEVERMATCH[a-z]+",
1211
+ category: "pii",
1212
+ },
1213
+ ],
1214
+ }),
1215
+ );
1216
+
1217
+ process.env[ENV_KEY] = join(dir, "config.json");
1218
+ vi.resetModules();
1219
+
1220
+ const { RULES, scan } = await import("../rules.ts");
1221
+ const emailRules = RULES.filter((r) => r.id === "pii-email");
1222
+ expect(emailRules).toHaveLength(1);
1223
+ expect(emailRules[0]?.description).toBe("Replaced Email Rule");
1224
+
1225
+ const findings = scan("contact: user@example.com");
1226
+ expect(findings.some((f) => f.ruleId === "pii-email")).toBe(false);
1227
+ });
1228
+
1229
+ it("de-duplicates duplicate user rule ids (last definition wins)", async () => {
1230
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1231
+ const { tmpdir } = await import("node:os");
1232
+ const { join } = await import("node:path");
1233
+
1234
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1235
+ writeFileSync(
1236
+ join(dir, "config.json"),
1237
+ JSON.stringify({
1238
+ rules: [
1239
+ {
1240
+ id: "custom-token",
1241
+ description: "First Definition",
1242
+ regex: "MYSVC-[A-Za-z0-9]{20}",
1243
+ category: "secret",
1244
+ },
1245
+ {
1246
+ id: "custom-token",
1247
+ description: "Last Definition",
1248
+ regex: "MYSVC2-[A-Za-z0-9]{20}",
1249
+ category: "secret",
1250
+ },
1251
+ ],
1252
+ }),
1253
+ );
1254
+
1255
+ process.env[ENV_KEY] = join(dir, "config.json");
1256
+ vi.resetModules();
1257
+
1258
+ const { RULES, scan } = await import("../rules.ts");
1259
+ const dupes = RULES.filter((r) => r.id === "custom-token");
1260
+ expect(dupes).toHaveLength(1);
1261
+ expect(dupes[0]?.description).toBe("Last Definition");
1262
+
1263
+ // Only the last regex is active, and a match produces a single finding.
1264
+ expect(scan("token: MYSVC-abcdefghijklmnopqrst")).toHaveLength(0);
1265
+ const findings = scan("token: MYSVC2-abcdefghijklmnopqrst");
1266
+ expect(findings.filter((f) => f.ruleId === "custom-token")).toHaveLength(1);
1267
+ });
1268
+
1269
+ it("respects a custom contextWindow", async () => {
1270
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1271
+ const { tmpdir } = await import("node:os");
1272
+ const { join } = await import("node:path");
1273
+
1274
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1275
+ writeFileSync(
1276
+ join(dir, "config.json"),
1277
+ JSON.stringify({
1278
+ contextWindow: 1,
1279
+ rules: [],
1280
+ }),
1281
+ );
1282
+
1283
+ process.env[ENV_KEY] = join(dir, "config.json");
1284
+ vi.resetModules();
1285
+
1286
+ const { getDefaultContextWindow: getWindow } = await import("../rules.ts");
1287
+ expect(getWindow()).toBe(1);
1288
+ });
1289
+
1290
+ it("skips rules with invalid regex", async () => {
1291
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1292
+ const { tmpdir } = await import("node:os");
1293
+ const { join } = await import("node:path");
1294
+
1295
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1296
+ writeFileSync(
1297
+ join(dir, "config.json"),
1298
+ JSON.stringify({
1299
+ rules: [
1300
+ {
1301
+ id: "bad-regex",
1302
+ description: "Bad",
1303
+ regex: "[invalid(",
1304
+ category: "secret",
1305
+ },
1306
+ {
1307
+ id: "good-regex",
1308
+ description: "Good",
1309
+ regex: "GOODKEY-\\d+",
1310
+ category: "secret",
1311
+ },
1312
+ ],
1313
+ }),
1314
+ );
1315
+
1316
+ process.env[ENV_KEY] = join(dir, "config.json");
1317
+ vi.resetModules();
1318
+
1319
+ const { RULES } = await import("../rules.ts");
1320
+ expect(RULES.some((r) => r.id === "bad-regex")).toBe(false);
1321
+ expect(RULES.some((r) => r.id === "good-regex")).toBe(true);
1322
+ });
1323
+
1324
+ it("skips null or non-object rule entries without crashing", async () => {
1325
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1326
+ const { tmpdir } = await import("node:os");
1327
+ const { join } = await import("node:path");
1328
+
1329
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1330
+ writeFileSync(
1331
+ join(dir, "config.json"),
1332
+ JSON.stringify({
1333
+ rules: [
1334
+ null,
1335
+ {
1336
+ id: "good-rule",
1337
+ description: "Good",
1338
+ regex: "GOODKEY-\\d+",
1339
+ category: "secret",
1340
+ },
1341
+ ],
1342
+ }),
1343
+ );
1344
+
1345
+ process.env[ENV_KEY] = join(dir, "config.json");
1346
+ vi.resetModules();
1347
+
1348
+ const { RULES } = await import("../rules.ts");
1349
+ expect(RULES.some((r) => r.id === "good-rule")).toBe(true);
1350
+ });
1351
+
1352
+ it("ignores a non-array rules field without crashing", async () => {
1353
+ const { mkdtempSync, writeFileSync } = await import("node:fs");
1354
+ const { tmpdir } = await import("node:os");
1355
+ const { join } = await import("node:path");
1356
+
1357
+ const dir = mkdtempSync(join(tmpdir(), "canary-"));
1358
+ writeFileSync(
1359
+ join(dir, "config.json"),
1360
+ JSON.stringify({ rules: { id: "not-an-array" } }),
1361
+ );
1362
+
1363
+ process.env[ENV_KEY] = join(dir, "config.json");
1364
+ vi.resetModules();
1365
+
1366
+ // Built-in defaults still load (spot-check a well-known rule).
1367
+ const { RULES } = await import("../rules.ts");
1368
+ expect(RULES.some((r) => r.id === "pii-email")).toBe(true);
1369
+ });
1370
+ });