haechi 1.3.2 → 1.4.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.
@@ -540,12 +540,15 @@ function scanEntry(entry, rules, context = {}) {
540
540
  // own token. This is response-only on purpose: a REQUEST that contains a
541
541
  // marker-shaped string is NOT Haechi output (Haechi hasn't transformed it yet),
542
542
  // so it is scanned normally — otherwise an attacker could wrap a real secret in
543
- // a fake `[TOKEN:…]` to evade request-side detection.
543
+ // a fake `[TOKEN:…]` to evade request-side detection. On the RESPONSE side the
544
+ // same wrap-a-secret risk is closed by haechiMarkerSpans recording a span only
545
+ // when the inner content matches a GENUINE emitted format — a fake marker
546
+ // wrapping a real secret stays in the scan and is detected/blocked.
544
547
  // Markers are pure ASCII and NFKC-stable, so their spans are computed on the
545
548
  // ORIGINAL value exactly as before — they line up with the same-length
546
549
  // normalized scan (Case 2 below) and are irrelevant to the whole-leaf scan
547
550
  // (Case 3).
548
- const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value) : [];
551
+ const markerSpans = context?.direction === "response" ? haechiMarkerSpans(entry.value, rules, context) : [];
549
552
 
550
553
  // WS2d — Unicode evasion via NFKC normalization. A client can defeat every
551
554
  // regex rule by sending PII/secrets in a Unicode form that folds to ASCII
@@ -824,12 +827,157 @@ function isPositionStableNfkc(value, normalized) {
824
827
  return rebuilt === normalized;
825
828
  }
826
829
 
827
- // Spans of Haechi's own transform markers in a string, so detection can skip
828
- // them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`.
829
- function haechiMarkerSpans(text) {
830
+ // Spans of Haechi's own transform markers in a string, so RESPONSE-direction
831
+ // detection can skip them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`. A
832
+ // tokenized round-trip echoed by the model would otherwise be re-flagged as a
833
+ // secret (Haechi blocking its own output).
834
+ //
835
+ // CR-???: a span is recorded ONLY when its inner content matches a GENUINE
836
+ // format actually emitted by core's transform (packages/core/index.mjs
837
+ // replacementFor). Without this check the marker frame `[(?:TOKEN|…):[^\]]*]`
838
+ // would skip ANY inner content, so a hostile model could exfiltrate a real
839
+ // secret by wrapping it in a FAKE marker — `[TOKEN:sk-ant-api03-<secret>]`,
840
+ // `[HAECHI_ENC:<secret>]`, `[REDACTED:<secret>]` — and that span would be
841
+ // dropped from the scan. A marker-SHAPED string whose inner content is not
842
+ // genuine is left in the scan, so the wrapped secret is detected/blocked.
843
+ // Genuine inner formats:
844
+ // [REDACTED:<type>] <type> is a detection type name (lowercase
845
+ // identifier: [a-z][a-z0-9_]*).
846
+ // [TOKEN:<vaultTokenId>] vault id shape `tok_<type>_<hexhash>`
847
+ // (matches token-vault VAULT_TOKEN_SHAPE).
848
+ // [TOKEN:<type>:<shortHash>] non-vault deterministic token: type name + hex.
849
+ // [HAECHI_ENC:<base64url>] base64url that decodes to a VALID envelope
850
+ // JSON object (cryptoProvider.encrypt envelope:
851
+ // has `kid`+`aadHash`). A real secret string will
852
+ // not base64url-decode to such an object.
853
+ // Markers are pure ASCII / NFKC-stable and spans are computed on the ORIGINAL
854
+ // entry.value, so offset integrity is unchanged.
855
+
856
+ // Detection-type name shape (the `detection.type` written by core into REDACTED
857
+ // and the type segment of a non-vault TOKEN). Built-in rule types and custom
858
+ // rule types are lowercase identifiers; a real secret (hyphens, uppercase,
859
+ // length) does not match, so a wrapped secret stays in the scan.
860
+ const MARKER_TYPE_NAME = /^[a-z][a-z0-9_]*$/;
861
+ // Vault token id shape — mirrors token-vault VAULT_TOKEN_SHAPE
862
+ // (`tok_<type>_<hexhash>`, random: 16 hex, deterministic: 32 hex). Kept in sync
863
+ // with packages/token-vault/index.mjs (not exported from there).
864
+ const MARKER_VAULT_TOKEN = /^tok_[a-z0-9_]+_[a-f0-9]{16,}$/;
865
+ // Non-vault deterministic token: `<type>:<hex>` (core shortHash → 12 hex; allow
866
+ // any reasonable hex run so the check does not over-fit a single length).
867
+ const MARKER_NONVAULT_TOKEN = /^[a-z][a-z0-9_]*:[a-f0-9]{8,}$/;
868
+ // base64url alphabet only (core emits base64url with no padding).
869
+ const MARKER_BASE64URL = /^[A-Za-z0-9_-]+$/;
870
+
871
+ function isGenuineTokenInner(inner) {
872
+ return MARKER_VAULT_TOKEN.test(inner) || MARKER_NONVAULT_TOKEN.test(inner);
873
+ }
874
+
875
+ function isGenuineRedactedInner(inner) {
876
+ return MARKER_TYPE_NAME.test(inner);
877
+ }
878
+
879
+ // True only when `inner` base64url-decodes to a valid UTF-8 JSON object that
880
+ // carries the encrypt-envelope signature (`kid` + `aadHash` — the contract keys
881
+ // asserted by assertCryptoProviderConformance, present in the local AES-GCM
882
+ // envelope and any conformant external provider). Any decode/parse failure or a
883
+ // non-envelope shape → NOT a genuine marker (so a wrapped secret is scanned).
884
+ function isGenuineEncInner(inner) {
885
+ if (!MARKER_BASE64URL.test(inner)) {
886
+ return false;
887
+ }
888
+ try {
889
+ const bytes = Buffer.from(inner, "base64url");
890
+ // Reject inputs that do not round-trip through base64url (e.g. an invalid
891
+ // tail that Buffer silently truncates): a genuine marker always round-trips.
892
+ if (bytes.toString("base64url") !== inner) {
893
+ return false;
894
+ }
895
+ if (!isUtf8(bytes)) {
896
+ return false;
897
+ }
898
+ const parsed = JSON.parse(bytes.toString("utf8"));
899
+ return (
900
+ parsed !== null &&
901
+ typeof parsed === "object" &&
902
+ !Array.isArray(parsed) &&
903
+ typeof parsed.kid === "string" &&
904
+ typeof parsed.aadHash === "string"
905
+ );
906
+ } catch {
907
+ return false;
908
+ }
909
+ }
910
+
911
+ // Belt-and-suspenders for the genuine-marker shapes: even a correctly-SHAPED
912
+ // TOKEN/REDACTED inner must not itself carry a detectable secret. The lowercase-
913
+ // identifier classes (MARKER_TYPE_NAME, the type segments of the token shapes)
914
+ // overlap the body of real lowercase-bodied secrets (notably GitHub `gh[pousr]_`
915
+ // tokens), so a hostile model could smuggle such a secret as the `<type>` segment
916
+ // of an otherwise genuine-shaped marker. Re-scan the inner with the SAME rules and
917
+ // refuse to treat it as genuine if anything detectable is inside — this un-skips a
918
+ // marker exactly when skipping it would hide a leak.
919
+ function textHasDetection(text, rules, context) {
920
+ for (const rule of rules) {
921
+ if (rule.direction && rule.direction !== context?.direction) {
922
+ continue;
923
+ }
924
+ const regex = new RegExp(rule.pattern, rule.flags.includes("g") ? rule.flags : `${rule.flags}g`);
925
+ for (const match of text.matchAll(regex)) {
926
+ if (!rule.validate || rule.validate(match[0])) {
927
+ return true;
928
+ }
929
+ }
930
+ }
931
+ return false;
932
+ }
933
+
934
+ // The attacker-controllable segment(s) of a genuine-shaped marker inner — i.e. the
935
+ // `<type>` position(s) a hostile model could smuggle a secret into. For TOKEN we
936
+ // peel off the structural framing (`tok_<type>_<hex>` → `<type>`, `<type>:<hex>` →
937
+ // `<type>`) and scan the segment IN ISOLATION as well as the whole inner: a `\b`-
938
+ // anchored rule (e.g. GitHub `\bghp_…`) misses a token glued to the `tok_` prefix
939
+ // (no word boundary after `_`), but matches the segment scanned on its own.
940
+ function markerSecretSurfaces(kind, inner) {
941
+ const surfaces = [inner];
942
+ if (kind === "TOKEN") {
943
+ const vault = /^tok_(.+)_[a-f0-9]{16,}$/.exec(inner);
944
+ if (vault) {
945
+ surfaces.push(vault[1]);
946
+ }
947
+ const nonVault = /^(.+):[a-f0-9]{8,}$/.exec(inner);
948
+ if (nonVault) {
949
+ surfaces.push(nonVault[1]);
950
+ }
951
+ }
952
+ return surfaces;
953
+ }
954
+
955
+ function innerContainsDetection(kind, inner, rules, context) {
956
+ return markerSecretSurfaces(kind, inner).some((surface) => textHasDetection(surface, rules, context));
957
+ }
958
+
959
+ function haechiMarkerSpans(text, rules = [], context = {}) {
830
960
  const spans = [];
831
- for (const m of text.matchAll(/\[(?:TOKEN|HAECHI_ENC|REDACTED):[^\]]*\]/g)) {
832
- spans.push([m.index, m.index + m[0].length]);
961
+ for (const m of text.matchAll(/\[(TOKEN|HAECHI_ENC|REDACTED):([^\]]*)\]/g)) {
962
+ const kind = m[1];
963
+ const inner = m[2];
964
+ let genuine = false;
965
+ if (kind === "TOKEN") {
966
+ genuine = isGenuineTokenInner(inner);
967
+ } else if (kind === "REDACTED") {
968
+ genuine = isGenuineRedactedInner(inner);
969
+ } else {
970
+ genuine = isGenuineEncInner(inner);
971
+ }
972
+ // HAECHI_ENC is exempt from the inner re-scan: its inner is an opaque base64url
973
+ // envelope validated by decode above (a raw secret cannot forge a valid
974
+ // envelope, and the envelope's base64url body is not a detectable leaf).
975
+ if (genuine && kind !== "HAECHI_ENC" && innerContainsDetection(kind, inner, rules, context)) {
976
+ genuine = false;
977
+ }
978
+ if (genuine) {
979
+ spans.push([m.index, m.index + m[0].length]);
980
+ }
833
981
  }
834
982
  return spans;
835
983
  }