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.
- package/README.ko.md +46 -4
- package/README.md +49 -5
- package/docs/current/config-version.md +2 -2
- package/docs/current/configuration.ko.md +2 -2
- package/docs/current/configuration.md +2 -2
- package/docs/current/operations-runbook.ko.md +12 -1
- package/docs/current/operations-runbook.md +18 -1
- package/docs/current/plugin-signing-and-trust.ko.md +143 -0
- package/docs/current/plugin-signing-and-trust.md +148 -0
- package/docs/current/release-process.ko.md +2 -1
- package/docs/current/release-process.md +2 -1
- package/docs/current/reliability-hardening-track.ko.md +1 -1
- package/docs/current/reliability-hardening-track.md +1 -1
- package/docs/current/risk-register-release-gate.ko.md +6 -5
- package/docs/current/risk-register-release-gate.md +5 -4
- package/docs/current/shared-responsibility.ko.md +1 -1
- package/docs/current/shared-responsibility.md +1 -1
- package/docs/current/threat-model.ko.md +1 -1
- package/docs/current/threat-model.md +1 -1
- package/package.json +1 -1
- package/packages/cli/bin/haechi.mjs +275 -3
- package/packages/filter/index.mjs +155 -7
|
@@ -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
|
|
828
|
-
// them: `[TOKEN:…]`, `[HAECHI_ENC:…]`, `[REDACTED:…]`.
|
|
829
|
-
|
|
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(/\[(
|
|
832
|
-
|
|
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
|
}
|