Improve secret redaction transparency

This commit is contained in:
Mikei386
2026-08-23 21:29:35 +02:00
parent d69fbcbd8b
commit b5118099ed
3 changed files with 173 additions and 43 deletions
+34
View File
@@ -279,6 +279,40 @@ class SecretRedactionTests(unittest.IsolatedAsyncioTestCase):
result = await guard.outlet(outlet) result = await guard.outlet(outlet)
self.assertNotIn("abcdefghijklmnopqrstuvwxyz", result["messages"][0]["content"]) self.assertNotIn("abcdefghijklmnopqrstuvwxyz", result["messages"][0]["content"])
async def test_documentation_placeholders_and_paths_are_not_redacted(self):
module = _load("secret_redaction")
guard = module.Filter()
content = "\n".join(
[
'ROUTER_API_KEY=${ROUTER_API_KEY:?required}',
'API_KEY=/etc/mike-ai/router-api-key',
'access_token=<YOUR_TOKEN>',
'password=[REDACTED]',
'API-Key: Inhalt von /etc/mike-ai/router-api-key',
]
)
redacted, counts = guard._redact(content)
self.assertEqual(redacted, content)
self.assertEqual(counts, {})
async def test_notification_reports_category_and_origin_but_not_value(self):
module = _load("secret_redaction")
guard = module.Filter()
events = []
async def emit(event):
events.append(event)
secret = "realistic-secret-value-123456"
body = {"messages": [{"role": "tool", "content": f"api_key={secret}"}]}
result = await guard.inlet(body, __event_emitter__=emit)
self.assertNotIn(secret, result["messages"][0]["content"])
description = events[0]["data"]["description"]
self.assertIn("API-Key: 1", description)
self.assertIn("Werkzeugausgaben", description)
self.assertIn("nicht protokolliert", description)
self.assertNotIn(secret, description)
class QuickActionTests(unittest.IsolatedAsyncioTestCase): class QuickActionTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self): async def asyncSetUp(self):
+5 -1
View File
@@ -22,7 +22,11 @@ Reihenfolge ist absichtlich festgelegt:
5. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens, 5. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens,
Passwörter, JWTs und private Schlüssel aus Tool-Ergebnissen, bevor sie das Passwörter, JWTs und private Schlüssel aus Tool-Ergebnissen, bevor sie das
Modell erreichen, sowie aus fertigen Modellantworten. Nutzereingaben und Modell erreichen, sowie aus fertigen Modellantworten. Nutzereingaben und
Authentifizierungswege werden nicht verändert. Authentifizierungswege werden nicht verändert. Offensichtliche
Dokumentationsplatzhalter, Beispielwerte und Dateipfade bleiben sichtbar.
Eine Statusmeldung nennt nur Trefferzahl, sichere Kategorie und Ursprung
(Werkzeugausgabe oder Modellantwort); der erkannte Wert wird weder angezeigt
noch protokolliert.
6. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten 6. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten
Werkzeugaufruf eines Antwortlaufs und löst im Browser genau eine kurze, Werkzeugaufruf eines Antwortlaufs und löst im Browser genau eine kurze,
passende Ansage für Web, Unraid, Home Assistant, Medienverwaltung oder passende Ansage für Web, Unraid, Home Assistant, Medienverwaltung oder
+134 -42
View File
@@ -1,8 +1,8 @@
""" """
title: MikeAI Secret Redaction title: MikeAI Secret Redaction
author: MikeAI author: MikeAI
version: 1.0.0 version: 1.1.0
description: Redacts common credentials from tool results and final assistant output. description: Redacts likely credentials while reporting only safe match categories and origins.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,63 +20,155 @@ class Filter:
self.valves = self.Valves() self.valves = self.Valves()
self.toggle = False self.toggle = False
def _redact(self, text: str) -> tuple[str, int]: @staticmethod
if not isinstance(text, str) or not text: def _merge(target: dict[str, int], source: dict[str, int]) -> None:
return text, 0 for category, count in source.items():
count = 0 target[category] = target.get(category, 0) + count
def replace_full(match): @staticmethod
nonlocal count def _safe_documentation_value(value: str) -> bool:
count += 1 """Ignore obvious examples, references and placeholders, never real values."""
candidate = value.strip().strip("'\"")
lowered = candidate.casefold()
if not candidate:
return True
if lowered in {
"[redacted]", "redacted", "[configured]", "configured",
"placeholder", "example", "example-value", "changeme", "unknown",
"none", "null", "true", "false",
}:
return True
if re.fullmatch(r"\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}", candidate):
return True
if re.fullmatch(r"<[A-Za-z0-9_.:/ -]+>", candidate):
return True
if re.fullmatch(r"[A-Z][A-Z0-9_]{7,}", candidate):
return True
if candidate.startswith(("/etc/", "/var/", "/opt/", "/data/", "/srv/", "./", "../")):
return True
if candidate.startswith(("http://", "https://", "ssh://")):
return True
return False
def _redact(self, text: str) -> tuple[str, dict[str, int]]:
if not isinstance(text, str) or not text:
return text, {}
counts: dict[str, int] = {}
def mark(category: str) -> None:
counts[category] = counts.get(category, 0) + 1
full_patterns = [
(
r"-----BEGIN (?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----.*?"
r"-----END (?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----",
"privater Schlüssel",
),
(
r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\."
r"[A-Za-z0-9_-]{10,}\b",
"JWT",
),
]
for pattern, category in full_patterns:
def replace_full(match, category=category):
mark(category)
return self.valves.replacement return self.valves.replacement
def replace_value(match): text = re.sub(
nonlocal count pattern, replace_full, text,
count += 1 flags=re.IGNORECASE | re.DOTALL,
)
bearer_pattern = re.compile(
r"((?:authorization\s*[:=]\s*)?(?:bearer\s+))"
r"([A-Za-z0-9._~+/=-]{16,})",
re.IGNORECASE,
)
def replace_bearer(match):
value = match.group(2)
if self._safe_documentation_value(value):
return match.group(0)
mark("Bearer-Token")
return match.group(1) + self.valves.replacement return match.group(1) + self.valves.replacement
patterns_full = [ text = bearer_pattern.sub(replace_bearer, text)
r"-----BEGIN (?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----.*?-----END (?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----",
r"\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b",
]
for pattern in patterns_full:
text = re.sub(pattern, replace_full, text, flags=re.IGNORECASE | re.DOTALL)
patterns_value = [ named_pattern = re.compile(
r"((?:authorization\s*[:=]\s*)?(?:bearer\s+))[A-Za-z0-9._~+/=-]{16,}", r"((?:\"|')?"
r"((?:\"|')?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret|token)(?:\"|')?\s*[:=]\s*(?:\"|')?)[^\s\"',;&}]{8,}", r"(api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|secret|token)"
r"([?&](?:api[_-]?key|access[_-]?token|token|key)=)[^&\s]+", r"(?:\"|')?\s*[:=]\s*(?:\"|')?)"
] r"(\$\{[^}\r\n]{1,200}\}|<[^>\r\n]{1,100}>|[^\s\"',;&}]{8,})",
for pattern in patterns_value: re.IGNORECASE,
text = re.sub(pattern, replace_value, text, flags=re.IGNORECASE) )
return text, count
def _redact_content(self, content) -> tuple[object, int]: def replace_named(match):
value = match.group(3)
if self._safe_documentation_value(value):
return match.group(0)
name = match.group(2).casefold().replace("-", "_")
if "password" in name or name == "passwd":
category = "Passwort"
elif "api" in name and "key" in name:
category = "API-Key"
elif "token" in name:
category = "Token"
else:
category = "Secret"
mark(category)
return match.group(1) + self.valves.replacement
text = named_pattern.sub(replace_named, text)
query_pattern = re.compile(
r"([?&](?:api[_-]?key|access[_-]?token|token|key)=)([^&\s]+)",
re.IGNORECASE,
)
def replace_query(match):
value = match.group(2)
if self._safe_documentation_value(value):
return match.group(0)
mark("URL-Zugangswert")
return match.group(1) + self.valves.replacement
text = query_pattern.sub(replace_query, text)
return text, counts
def _redact_content(self, content) -> tuple[object, dict[str, int]]:
if isinstance(content, str): if isinstance(content, str):
return self._redact(content) return self._redact(content)
if not isinstance(content, list): if not isinstance(content, list):
return content, 0 return content, {}
result = [] result = []
total = 0 counts: dict[str, int] = {}
for part in content: for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str): if isinstance(part, dict) and isinstance(part.get("text"), str):
item = dict(part) item = dict(part)
item["text"], found = self._redact(item["text"]) item["text"], found = self._redact(item["text"])
total += found self._merge(counts, found)
result.append(item) result.append(item)
else: else:
result.append(part) result.append(part)
return result, total return result, counts
async def _notify(self, emitter, count: int) -> None: async def _notify(self, emitter, counts: dict[str, int], origin: str) -> None:
if not count or emitter is None: total = sum(counts.values())
if not total or emitter is None:
return return
categories = ", ".join(
f"{category}: {count}" for category, count in sorted(counts.items())
)
try: try:
await emitter( await emitter(
{ {
"type": "status", "type": "status",
"data": { "data": {
"description": f"Secret-Schutz: {count} mögliche Zugangsdaten entfernt.", "description": (
f"Secret-Schutz: {total} Treffer in {origin} entfernt "
f"({categories}). Werte werden nicht protokolliert."
),
"done": True, "done": True,
}, },
} }
@@ -85,21 +177,21 @@ class Filter:
pass pass
async def inlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict: async def inlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict:
total = 0 counts: dict[str, int] = {}
for message in body.get("messages") or []: for message in body.get("messages") or []:
if message.get("role") != "tool": if message.get("role") != "tool":
continue continue
message["content"], count = self._redact_content(message.get("content", "")) message["content"], found = self._redact_content(message.get("content", ""))
total += count self._merge(counts, found)
await self._notify(__event_emitter__, total) await self._notify(__event_emitter__, counts, "Werkzeugausgaben")
return body return body
async def outlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict: async def outlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict:
total = 0 counts: dict[str, int] = {}
for message in body.get("messages") or []: for message in body.get("messages") or []:
if message.get("role") != "assistant": if message.get("role") != "assistant":
continue continue
message["content"], count = self._redact_content(message.get("content", "")) message["content"], found = self._redact_content(message.get("content", ""))
total += count self._merge(counts, found)
await self._notify(__event_emitter__, total) await self._notify(__event_emitter__, counts, "Modellantworten")
return body return body