Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 316 additions & 33 deletions common/template/mako_utils/mako_safety.py

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion common/template/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from common.template.mako_utils.checker import check_mako_template_safety
from common.template.mako_utils.exceptions import ForbiddenMakoTemplateException
from common.template.mako_utils.string import deformat_var_key
from common.template.sandbox import Sandbox
from common.template.sandbox import Sandbox, _ForbiddenProxy
from common.utils import sanitize_user_content

logger = logging.getLogger("root")
Expand All @@ -33,6 +33,13 @@
NESTED_INDEX_STR_PATTERN = r'^(\w+)(?:\[(?:"\w+"|\'\w+\'|\d+)\])+$'
INDEX_STR_PATTERN = r'\[("\w+"|\'\w+\'|\d+)\]'

# 渲染数据中需要额外注入屏蔽代理的 Mako runtime 名。
# 注:Mako 运行时会在模板执行命名空间内自动注入 self/local/context 等;这里在 data 中
# 同步覆盖一份 _ForbiddenProxy,作为静态层 AST 白名单之外的纵深防御。若 Mako 运行时
# 优先使用自身注入对象,本层覆盖无副作用;若有遗漏的代码路径读到 data 中的同名键,
# 任何属性访问/调用/格式化都会立即抛 ForbiddenMakoTemplateException。
_RUNTIME_SHIELD_KEYS = ("self", "local", "context", "caller", "next", "parent", "capture")


class Template:
def __init__(self, data: Any):
Expand Down Expand Up @@ -191,6 +198,9 @@ def _render_template(template: str, context: dict) -> Any:
data = {}
data.update(context)
data.update(Sandbox().get())
# 运行时纵深防御:覆盖 Mako Namespace 相关名为屏蔽代理,禁止经由 data 抵达 self.module 等
for shield_key in _RUNTIME_SHIELD_KEYS:
data[shield_key] = _ForbiddenProxy(shield_key)

if not isinstance(template, str):
raise TypeError(
Expand Down
21 changes: 17 additions & 4 deletions itsm/component/esb/backend_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,14 @@ def http(self, config):
try:
query_params = map_data(before_req, query_params, "query_params")
except Exception:
# 不要将 traceback 细节变成响应体(包含文件路径/行号/函数名,是
# 内部实现信息泄露)。完整 traceback 仅进入服务端日志。
logger.exception(
"http(): before_req map_data failed, path=%s", path
)
return {
"result": False,
"message": traceback.format_exc().split("\n")[-2],
"message": "请求参数预处理失败",
"data": {},
}

Expand All @@ -241,10 +246,15 @@ def http(self, config):
method, path, query_params, system_domain, **kwargs
)
except Exception as e:
logger.error("[{}] response.Exception: {}".format(path, e))
# 仅在服务端记录上游异常原始信息,响应体返回中性提示,
# 避免将 ESB / 上游 SDK 内部错误信息透传给调用方。
logger.exception(
"http(): client.request raised exception, path=%s", path
)
_ = e # 仅为保留原名,异常仅走日志路径
return {
"result": False,
"message": str(e),
"message": "外部服务暂不可用,请稍后重试",
"data": {},
}

Expand All @@ -253,9 +263,12 @@ def http(self, config):
try:
response = map_data(map_code, response, "response")
except Exception:
logger.exception(
"http(): map_code map_data failed, path=%s", path
)
return {
"result": False,
"message": traceback.format_exc().split("\n")[-2],
"message": "响应数据后处理失败",
"data": {},
}

Expand Down
19 changes: 16 additions & 3 deletions itsm/component/generics.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
PermissionDenied,
ValidationError,
)
from django.conf import settings
from rest_framework.response import Response

from common.log import logger
Expand Down Expand Up @@ -129,13 +130,25 @@ def exception_handler(exc, context):
}
)
else:
# 调试模式
# 全局兜底分支:未被识别的异常一律视为 5xx
# 生产环境必须屏蔽 str(exc) / traceback,避免 SQL 报错、字段名、
# 文件路径、KeyError 键名等敏感信息回吐至前端。
# 仅当 settings.DEBUG=True 时才把异常细节带给调用方,便于本地排错。
logger.error(traceback.format_exc())
# 正式环境,屏蔽500

# 仅信任"已注册业务异常类(ServerError 子类)"的 message 字段;
# 其它异常一律使用中性提示。
if isinstance(exc, ServerError):
safe_message = exc.message
elif getattr(settings, "DEBUG", False):
safe_message = getattr(exc, "message", str(exc))
else:
safe_message = _("系统繁忙,请稍后重试")

data.update(
{
"code": ResponseCodeStatus.SERVER_500_ERROR,
"message": getattr(exc, "message", str(exc)),
"message": safe_message,
}
)

Expand Down
9 changes: 7 additions & 2 deletions itsm/component/misc_middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ def process_response(self, request, response):
pstats.Stats(self.profiler, stream=s).sort_stats(sortby).print_stats(count)
for output in outputs:
if output == "console":
print(s.getvalue())
# 安全修复(H8):Profiler 产生的 stats 包含文件路径/函数名等内部
# 实现细节,不要直接 print 到容器 stdout(会被采集为运维侧日志)。
# 改走 logger.debug,生产默认不输出;PROFILER 需明示开启才生效。
logger.debug(s.getvalue())

if output == "file":
file_location = settings.PROFILER.get("file_location", "profiles")
Expand Down Expand Up @@ -140,7 +143,9 @@ def process_response(self, request, response):
for output in outputs:
output_text = self.profiler.output_html()
if output == "console":
print(output_text)
# 安全修复(H8):pyinstrument 输出 HTML 含完整调用栈与参数信息,
# 不能直接写入容器 stdout。改为 debug 级别日志,默认不输出。
logger.debug("pyinstrument profiler html generated, size=%s", len(output_text))

if output == "file":
file_location = settings.PROFILER.get("file_location", "profiles")
Expand Down
16 changes: 14 additions & 2 deletions itsm/component/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from blueapps.contrib.celery_tools.periodic import periodic_task
from django.core.cache import cache
from django.conf import settings
from common.log import logger
from itsm.component.constants import CACHE_10MIN, CACHE_5MIN
from itsm.component.esb.esbclient import client_backend
from itsm.component.utils.lock import share_lock
Expand Down Expand Up @@ -74,7 +75,10 @@ def update():
cache.set(cache_key, search_business_list, CACHE_5MIN)
return search_business_list
except ComponentCallError as e:
print("获取业务角色人员失败: %s" % e)
# 安全修复(H8):print() 会将异常原始信息写入容器 stdout;
# 改为 logger.warning,同时不拼接 str(e),仅记录上下文变量。
logger.warning("获取业务角色人员失败: bk_biz_id=%s", bk_biz_id)
logger.debug("detail of get business roles failure: %s", e)
return []

result = update()
Expand All @@ -92,7 +96,15 @@ def update():
{"id": username, "with_family": True}
)
except ComponentCallError as e:
print("获取组织架构失败:username=%s,error=%s" % (username, str(e)))
# 安全修复(H8):原实现会将明文 username 与上游错误拼接进 stdout,
# 这里进行脱敏(仅保留首末字符)并调低到 warning + debug,防止 PII 泄露。
masked_user = (
username[:1] + "***" + username[-1:]
if username and len(username) >= 2
else "***"
)
logger.warning("获取组织架构失败:user=%s", masked_user)
logger.debug("detail of list_profile_departments failure: %s", e)
return []

if not id_only:
Expand Down
6 changes: 4 additions & 2 deletions itsm/component/utils/client_backend_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,10 @@ def get_bk_business(bk_biz_id, role_type):
return ""

bk_business = []
print("----search_business_list is {}".format(search_business_list))
print("----search_business_list type is {}".format(type(search_business_list)))
# 安全修复(H8):原代码使用 print() 把 CMDB 查询返回的完整业务列表 dump 到
# stdout,这会被容器采集进入运维侧日志系统,违反“日志最小化”原则。
# 调试需要可通过上调 logger 级别实现,生产默认不输出。
logger.debug("search_business_list size=%s", len(search_business_list))
for business in search_business_list:
if not business:
continue
Expand Down
Loading
Loading