mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
219 lines
8.9 KiB
Python
219 lines
8.9 KiB
Python
"""Model-facing diagnostic copies; never rewrite persisted execution evidence.
|
|
|
|
This profile is broader than credential configuration redaction and does not
|
|
participate in configuration-write validation. It hides structured credentials
|
|
and known credential values repeated in text, not arbitrary unknown prose.
|
|
"""
|
|
|
|
import re
|
|
from copy import deepcopy
|
|
from typing import Any
|
|
from urllib.parse import unquote_plus
|
|
|
|
from core.dify_builder.models import Graph, NodeOutput, Run
|
|
from services.dify_builder.credentials import REDACTED
|
|
|
|
_SENSITIVE_FIELDS = frozenset(
|
|
{
|
|
"apikey",
|
|
"accesstoken",
|
|
"refreshtoken",
|
|
"authtoken",
|
|
"bearertoken",
|
|
"token",
|
|
"secret",
|
|
"secretkey",
|
|
"clientsecret",
|
|
"password",
|
|
"credential",
|
|
"credentials",
|
|
"privatekey",
|
|
"xapikey",
|
|
}
|
|
)
|
|
_CONTAINERS = frozenset({"headers", "params", "query", "queryparams", "queryparameters", "cookies"})
|
|
|
|
|
|
def _field_name(key: str) -> str:
|
|
return "".join(char for char in key.lower() if char.isalnum())
|
|
|
|
|
|
def _hide(value: Any, known: set[str]) -> Any:
|
|
if isinstance(value, dict):
|
|
return {key: _hide(item, known) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_hide(item, known) for item in value]
|
|
if isinstance(value, str) and value and value != REDACTED:
|
|
known.add(value)
|
|
stripped = value.strip()
|
|
if stripped:
|
|
known.add(stripped)
|
|
scheme, separator, token = stripped.partition(" ")
|
|
if separator and scheme.lower() in {"bearer", "basic"} and token.strip():
|
|
known.add(token.strip())
|
|
return REDACTED if value else value
|
|
|
|
|
|
def _container(value: Any, known: set[str], field: str) -> Any:
|
|
if isinstance(value, str):
|
|
if field.startswith("query") or (field == "params" and ":" not in value and "=" in value):
|
|
parts = []
|
|
for part in value.split("&"):
|
|
name, separator, item = part.partition("=")
|
|
if separator and item:
|
|
_hide(unquote_plus(item), known)
|
|
parts.append(f"{name}={_hide(item, known)}")
|
|
else:
|
|
parts.append(part if separator else str(_hide(part, known)))
|
|
return "&".join(parts)
|
|
# Native HTTP headers/params are newline-delimited name:value pairs.
|
|
lines = []
|
|
for line in value.splitlines():
|
|
name, separator, item = line.partition(":")
|
|
if separator:
|
|
lines.append(f"{name}:{_hide(item.strip(), known)}" if item.strip() else line)
|
|
else:
|
|
lines.append(str(_hide(line, known)))
|
|
return "\n".join(lines)
|
|
if isinstance(value, list):
|
|
return [
|
|
{key: item if key in {"key", "name"} else _hide(item, known) for key, item in row.items()}
|
|
if isinstance(row, dict)
|
|
else _hide(row, known)
|
|
for row in value
|
|
]
|
|
return _hide(value, known)
|
|
|
|
|
|
def _authorization(value: Any, known: set[str]) -> Any:
|
|
if not isinstance(value, dict):
|
|
return _hide(value, known)
|
|
return {
|
|
key: item
|
|
if key in {"type", "header"}
|
|
else _authorization(item, known)
|
|
if key == "config"
|
|
else _hide(item, known)
|
|
for key, item in value.items()
|
|
}
|
|
|
|
|
|
def _structured(value: Any, known: set[str]) -> Any:
|
|
if isinstance(value, dict):
|
|
result = {}
|
|
for key, item in value.items():
|
|
field = _field_name(str(key))
|
|
if field == "authorization":
|
|
result[key] = _authorization(item, known)
|
|
elif field in _CONTAINERS:
|
|
result[key] = _container(item, known, field)
|
|
elif field in _SENSITIVE_FIELDS:
|
|
result[key] = _hide(item, known)
|
|
else:
|
|
result[key] = _structured(item, known)
|
|
return result
|
|
if isinstance(value, (list, tuple)):
|
|
return [_structured(item, known) for item in value]
|
|
if isinstance(value, str):
|
|
_collect_text_credentials(value, known)
|
|
return value
|
|
|
|
|
|
def _replace_text(text: str, known: list[str]) -> str:
|
|
if not known:
|
|
return text
|
|
# Match whole existing sentinel spans before shorter secrets within them,
|
|
# including sentinels inserted during structured redaction. One pass also
|
|
# protects newly inserted sentinels. Longest first handles overlaps.
|
|
protected = sorted({REDACTED, *known}, key=len, reverse=True)
|
|
return re.sub("|".join(re.escape(secret) for secret in protected), lambda _: REDACTED, text)
|
|
|
|
|
|
def _replace_known(value: Any, known: list[str]) -> Any:
|
|
if isinstance(value, str):
|
|
return _replace_text(value, known)
|
|
if isinstance(value, dict):
|
|
return {
|
|
_replace_text(key, known) if isinstance(key, str) else key: _replace_known(item, known)
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
return [_replace_known(item, known) for item in value]
|
|
return value
|
|
|
|
|
|
def _collect_text_credentials(text: str, known: set[str]) -> None:
|
|
"""Collect explicit credential assignments in arbitrary source strings."""
|
|
# Match only the key prefix. A non-sensitive outer assignment must not
|
|
# consume a value containing another explicit credential assignment.
|
|
for match in re.finditer(r"(?i)\b([a-z][a-z0-9_-]{0,63})[\"']?\s*[=:]\s*", text):
|
|
if _field_name(match.group(1)) not in _SENSITIVE_FIELDS:
|
|
continue
|
|
value = re.match(r"(?:\"([^\"]+)\"|'([^']+)'|([^\s\"',;}]+))", text[match.end() :])
|
|
if value:
|
|
_hide(next(group for group in value.groups() if group is not None), known)
|
|
for match in re.finditer(r"(?i)\b(?:bearer|basic)\s+([^\s\"',;]+)", text):
|
|
_hide(match.group(1), known)
|
|
|
|
|
|
def _known_credentials(failed_run: Run, graph: Graph, node_outputs: list[NodeOutput]) -> set[str]:
|
|
"""Collect from raw provenance before any projection discards values."""
|
|
known: set[str] = set()
|
|
_structured(graph, known)
|
|
_collect_text_credentials(failed_run.error, known)
|
|
for output in [*failed_run.per_node, *node_outputs]:
|
|
_collect_text_credentials(output.error, known)
|
|
_structured(output.inputs, known)
|
|
_structured(output.outputs, known)
|
|
return known
|
|
|
|
|
|
def redact_diagnostic_text(text: str, failed_run: Run, graph: Graph, node_outputs: list[NodeOutput]) -> str:
|
|
"""Apply the complete graph/runtime credential policy to rendered prompt text."""
|
|
known = _known_credentials(failed_run, graph, node_outputs)
|
|
return _replace_text(text, sorted(known, key=len, reverse=True))
|
|
|
|
|
|
def redact_run_context(failed_run: Run, graph: Graph, node_outputs: list[NodeOutput]) -> tuple[Run, list[NodeOutput]]:
|
|
"""Return independent, sanitized diagnostic evidence before prompt truncation."""
|
|
known = _known_credentials(failed_run, graph, node_outputs)
|
|
safe_run, safe_outputs = deepcopy((failed_run, node_outputs))
|
|
# Gather structured secrets first, so earlier error/body text can safely
|
|
# repeat a credential held by a later node or output.
|
|
for output in [*safe_run.per_node, *safe_outputs]:
|
|
output.inputs = _structured(output.inputs, known)
|
|
output.outputs = _structured(output.outputs, known)
|
|
secrets = sorted(known, key=len, reverse=True)
|
|
safe_run.error = _replace_text(safe_run.error, secrets)
|
|
for output in [*safe_run.per_node, *safe_outputs]:
|
|
output.node_id = _replace_text(output.node_id, secrets)
|
|
output.title = _replace_text(output.title, secrets)
|
|
output.error = _replace_text(output.error, secrets)
|
|
output.inputs = _replace_known(output.inputs, secrets)
|
|
output.outputs = _replace_known(output.outputs, secrets)
|
|
return safe_run, safe_outputs
|
|
|
|
|
|
def redact_diagnostic_value(value: Any, failed_run: Run, graph: Graph, node_outputs: list[NodeOutput]) -> Any:
|
|
"""Sanitize graph projection from the full provenance before truncation."""
|
|
known = _known_credentials(failed_run, graph, node_outputs)
|
|
structured = _structured(deepcopy(value), known)
|
|
return _replace_known(structured, sorted(known, key=len, reverse=True))
|
|
|
|
|
|
def redact_dsl_context(dsl: Graph) -> Graph:
|
|
"""Return a credential-safe continuation copy from fresh or historical DSL.
|
|
|
|
Accept a DSL context mapping, collecting secret environment provenance before
|
|
withholding it. Reuse the diagnostic profile for all other structured fields
|
|
and known echoes. The source graph, execution evidence and write-admission
|
|
policy remain untouched; this projection is only for context and prompts.
|
|
"""
|
|
safe = deepcopy(dsl)
|
|
known: set[str] = set()
|
|
for variable in safe.get("environment_variables", []):
|
|
if isinstance(variable, dict) and variable.get("value_type") == "secret":
|
|
variable["value"] = _hide(variable.get("value"), known)
|
|
structured = _structured(safe, known)
|
|
return _replace_known(structured, sorted(known, key=len, reverse=True))
|