一篇修复泄漏的日志:如何查找漏洞并确保其永不再现

  • A+

译文声明
本文是翻译文章,文章原作者Nathan Brahms
原文地址:https://r2c.dev/blog/2020/fixing-leaky-logs-how-to-find-a-bug-and-ensure-it-never-returns/
译文仅供参考,具体内容表达以及含义以原文为准

介绍

作为开发人员和项目经理,我一直沉迷于寻找方法来快速解决整个项目中的安全问题,而无需完全让我们的安全团队参与进来。

为什么这很重要?我从中看到了很多好处:

  • 解决安全问题的速度很快。实际上,它的速度快到让我们可以在识别出它们后的几分钟内解决它们,而安全问题不会持续数天或数周。在以往的经验中,我看到内部已知的安全问题是公开存在的。
  • 当开发人员可以轻松地自行解决安全问题时,它可以使安全团队释放精力专注于“全局”的安全性。我希望安全工程师考虑如何选择框架,设置工具,帮助安全体系结构以及构建纵深防御,而不是找到我出现的XSS错误。

我将此概念称为“自助服务DevSec”。

在本文章的接下来内容中,我将介绍在常规开发工作的日常过程中遇到的一个安全漏洞。我将讨论我们如何发现此问题,以及如何在短短几个小时内解决安全问题,并使用Semgrep防止该错误再次发生。

事件经过

上个月,我与r2c的另一位工程师Clara McCreery一起调试Flask Web应用验证流程。我们的第一步就是将Web应用程序投入调试日志记录。

具体来说,我们想知道数据库操作的情况,因此我们将ORM(在这种情况下,我们使用SQLAlchemy)设置为INFO级别的日志记录,方法如下:
logging.getLogger("sqlalchemy.engine.base.Engine").setLevel(logging.INFO)

这会将SQLAlchemy配置为记录所有SQL语句以及传递的参数。让我们看一下我们看到的一些输出:

INFO:werkzeug:127.0.0.1 - - [25/Sep/2020 11:50:01] "POST /api/auth/authenticate HTTP/1.1" 200 -
INFO:sqlalchemy.engine.base.Engine:BEGIN (implicit)
INFO:sqlalchemy.engine.base.Engine:SELECT token.id AS token_id, token.token AS token_token, token.name AS token_name
FROM token
WHERE token.token = %(token_1)s
LIMIT %(param_1)s
INFO:sqlalchemy.engine.base.Engine:{'token_1': $2a$10$KVsyW1jjKn.pvkVi3w9Rn.1mwnZFd7F2SFveGDG8flIhbe.MoJH4G, 'param_1': 1}

我们绝对不应该记录密码(即使已安全地对其进行哈希处理)。

制定计划

至此,我们已经确定了一个安全问题,并希望我们既可以检查日志,同时又可以解决此安全漏洞。我们的计划如下:

  • 缓解当前的安全问题。
  • 寻找一个永久的解决方案,以备将来使用。永久性解决方案意味着我们在现有的系统,理想情况下,此解决方案在整个组织中是自动化且无缝的。
  • 添加一种机制来强制我们的解决方案在组织范围内使用。

在本文的其余部分,我将指导您完成每个步骤。值得注意的是,我们能够在几个小时内完成整个流程,而无需与安全团队合作。

1.缓解

这里的缓解措施非常简单,因为我们已经知道了问题的根本原因。我们可以快速还原日志记录来更改。然后,我们可以对日志进行快速审核,以确保仅泄漏了开发测试密码。

2.永久解决方案

我们如何防止SQLAlchemy记录敏感数据呢?

一次勇敢的尝试

首先是阅读文档。快速搜索“引擎日志中的sqlalchemy隐藏参数”将我们链接到SQLAlchemy Engine文档。在稍后的阅读中,我们找到了该hide_parameters标志,该标志可防止日志记录框架在日志或异常中发出任何参数。

虽然这肯定可以防止我们的安全问题,但对我们来说限制太多了:我们想知道(例如)数据库ID等用于调试。

真正的解决方案

然后,我们检查了相关的SQLAlchemy源代码。相关代码在
sqlalchemy/engine/base.py:

if self._echo:
self.engine.logger.info(statement)
if not self.engine.hide_parameters:
self.engine.logger.info(
"%r",
sql_util._repr_params(
parameters, batches=10, ismulti=context.executemany
),
)

sql_util._repr_params,依次运行:

def _repr_params(self, params, typ):
trunc = self.trunc
if typ is self._DICT:
return "{%s}" % (
", ".join(
"%r: %s" % (key, trunc(value))
for key, value in params.items()
)
)
...

研究trunc发现,它通过将repr为最大字符数来转换参数值。

这意味着我们应该重写repr参数对象的方法以防止敏感日志被记录。

在这一点上,我们像优秀的工程师一样,走了一条懒惰的路:站在您的同伴的肩膀上。我发现了GitHub上有这个问题,Mike Bayer已经发布了一个不错的解决方案。

后来进行了一些复制,我们有了这个Gist。关键代码是:

```
class ObfuscatedString(types.TypeDecorator):
"""
String column type for use with SQLAlchemy models whose
content should not appear in logs or exceptions
"""

impl = types.String

class Repr(str):
    def __repr__(self) -> str:
        return "********"

def process_bind_param(self, value: Optional[str], dialect: Any) -> Optional[Repr]:
    return self.Repr(value) if value else None

def process_result_value(
    self, value: Optional[Repr], dialect: Any
) -> Optional[str]:
    return str(value) if value else None

setattr(db, "ObfuscatedString", ObfuscatedString)
```

此代码完成什么工作?它将我们的原始str参数替换为新ObfuscatedString.Repr参数。登录时(或发出异常消息时),该字符串将被我们使用** 代替。由于参数仍绑定为原始字符串(通过 impl = types.String),因此仍将插入正确的值并从数据库中选择正确的值。

要使用此新列类型,我们设置token的列类型:

class Token(db.Model):
...
token = db.Column(db.ObfuscatedString, ...)
...

然后,我们重新启用INFO日志记录,并检查我们是否正确混淆了文本:

INFO:werkzeug:127.0.0.1 - - [25/Sep/2020 13:48:55] "GET /api/agent/deployments/1/policies HTTP/1.1" 200 -
INFO:sqlalchemy.engine.base.Engine:BEGIN (implicit)
INFO:sqlalchemy.engine.base.Engine:SELECT token.id AS token_id, token.token AS token_token, token.name AS token_name
FROM token
WHERE token.token = %(token_1)s
LIMIT %(param_1)s
INFO:sqlalchemy.engine.base.Engine:{'token_1': ********, 'param_1': 1}

为了完整起见,我们还在开发数据库控制台中验证了是否存储和检索了正确的值。

巨大的成功!

3.进一步研究

很想在这里固步自封。我们暂时已经解决了安全问题,我们可以重新调试原始的身份验证问题。

但是我们想保证这个问题不再出现。我们将如何做?

以下是一些想法,我相信我们之前都遇到过:

  • 在安全审查中阻止对SQLAlchemy模型的所有提交!
  • 为所有开发人员举办年度安全培训,包括记录敏感数据的陷阱!
  • 每周审核日志!
  • 向您的SAST供应商提出问题,要求他们添加检查以捕获敏感记录的数据!

如果从这篇博文中得到一个核心要点, 那就是:这些不是理想的解决方案

  • 阻止提交会在开发过程中引入不必要的问题,降低开发速度,并不必要地分散安全团队的注意力。
  • 安全培训是安全计划的重要组成部分,对于使开发人员了解不断发展的安全威胁是必不可少的,但是人类的记忆力很低,我们可以忘记过去几个月甚至几天听到的事情。
  • 定期审核(例如阻止提交)会给几乎肯定是超负荷的安全团队带来沉重的工作量。
  • 您的SAST提供商当然会欢迎您的建议,但是您会迷恋他们的软件发布周期,并且可能几个月都看不到收益。此外,如果您的问题是特定领域,则在通常产品中实施检查甚至没有意义。

幸运的是,Semgrep在这里为我们提供了一个简单的解决方案:在代码中定义一个 不变式,并在每次CI运行时使用Semgrep扫描对其进行强制执行。

在r2c,我们使用GitHub Actions在每个合并请求上运行Semgrep。我们定义Semgrep应该使用一个受管理的策略、一个由Semgrep .dev管理的规则列表和通知设置来运行哪些检查。

为了保证我们的代码不会出现将来的问题,我访问了 semgrep.dev/editor,并编写了一条快速规则来检测潜在的不安全记录的SQLAlchemy列。

这是Semgrep的YAML定义语言中的规则定义

rules:
- id: obfuscate-sensitive-string-columns
patterns:
- pattern: |
$COLUMN = db.Column(db.String, ...)
- metavariable-regex:
metavariable: $COLUMN
regex: '.*(?<![A-Za-z])(token|key|email|secret)(?![A-RT-Za-rt-z]).*'
message: |
'$COLUMN' may expose sensitive information in logs and exceptions. Use
'db.ObfuscatedString' instead of 'db.String'.
severity: WARNING

这条规则是做什么的?让我们分析一下:

  • id:我们为规则提供了简洁的描述性ID,以供开发人员在其编辑器或CI输出中弹出该ID时轻松参考。
  • patterns:这由两部分组成:
  • pattern:此表达式告诉Semgrep在我们的代码库(db在此示例中称为SQLAlchemy实例)中查找具有 String列类型的任何SQLAlchemy ORM列定义。它也结合了列名的metavariable命名COLUMN。
  • metavariable-regex:这个表达式告诉Semgrep只报告一次匹配,如果COLUMN metavariable包含像一个字片段token,email,key,或secret。正则表达式包含全局声明,以防止我们匹配不相关的词(例如) keyboard。
  • message:当Semgrep匹配我们的模式时,我们要确保我们解释检测到的问题是什么,为什么是漏洞以及如何解决它。遵循这些消息的最佳实践将有助于开发人员独立解决问题,而不会造成混乱。

稍后快速按下“部署到策略”按钮,我们现在可以保证我们所有的Web应用程序都将得到长期保护。

通过我们的VS Code扩展 将Semgrep集成到其编程工作流中的开发人员,也将开始在他们的IDE中产生成果。

请注意,此解决方案是有意迭代更新的:我们可能会发现更多列名称被标识为敏感列,或者还希望包含db.Text类型。幸运的是,这是一种快速修订,并根据需要重新部署。

结论

在本文中,我演示了您(作为开发人员或项目经理)如何使用轻量级静态分析(如Semgrep)来帮助在代码中强制执行不变式。

在r2c,我们习惯性地借助于Semgrep,以防止自己重复犯错:

如何防止意外地使调试器处于提交状态?有一个规则可以防止这种情况。

当发现导入某个库会减慢程序初始化速度时,我们编写了一条规则以确保延迟加载。

当我们发现不安全的日志记录时,您已经读完了这个故事。