Improve secret redaction transparency
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user