freeCodeCamp.org

Secure by Design: How to Engineer Security into Architecture Before Writing Any Code

8.5内容质量

TL;DR · AI 摘要

在设计阶段融入安全性可避免后期修复成本,文章详细阐述了如何通过威胁建模、加密标准等方法实现安全设计。

核心要点

  • 安全设计应从需求阶段开始,而非后期添加
  • 威胁建模需在架构决策前完成,降低攻击面
  • CI/CD流程应包含安全门禁检查,自动化拦截漏洞

结构提纲

按章节快速跳转。

  1. 传统安全措施常在后期添加,导致高修复成本和被动应对模式。

  2. 将安全需求视为与功能需求同等重要的基础要求。

  3. 在架构决策前完成攻击面分析,识别关键安全边界。

  4. 通过加密标准、数据库安全、CORS策略等技术手段构建防护体系。

  5. ›CI/CD安全门

    自动化安全检查应贯穿于持续集成流水线各阶段。

  6. 安全设计是工程团队的职责,需在首次PR前完成架构安全验证。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Secure by Design
    • 设计原则
      • 安全需求前置化
      • 威胁建模优先
    • 实施层
      • 加密标准
      • 数据库安全
      • CORS策略
    • 流程保障
      • CI/CD安全门
      • 微服务安全

金句 / Highlights

值得收藏与分享的关键句。

#Secure by Design#威胁建模#CI/CD安全#软件架构
打开原文

设计安全:如何在编写任何代码之前将安全性融入架构

2026年10月15日

/

#安全

Oluwaseyi Fatunmole

大多数工程团队都会进行一场对话,但通常为时已晚。

想象一下,一个功能上线到生产环境后,安全审查才进行,或者渗透测试发现了问题,更糟糕的是,一份事故报告出现在你的邮箱里。

漏洞是真实的。修复成本高昂。而在对话中,有人会说:“我们需要为这个增加安全性。”

这句话就是问题所在。安全性不是你添加到软件中的东西,而是从一开始就构建进去的。当你谈论为已有的东西增加安全性时,你已经处于危机应对模式。

“设计安全”是一种完全不同的思维方式,你在此模式下会在架构决策之前定义安全需求。在编写任何类之前,你就能理解威胁模型。你在领域层识别并强制执行业务规则。所有这些都来自工程本身提供的能力,而不是在现有成果之上叠加的一层。

CI/CD流水线能捕捉到人工审查遗漏的问题。系统的每一层都以攻击者的视角进行设计,不是作为事后考虑,而是作为基础性需求。

这不是安全团队的责任,而是工程团队的责任。它在第一次提交代码请求之前就开始了。

在本手册中,我将带您了解工程团队如何在设计过程中培养将安全意识融入其中的纪律。

目录

  • 先决条件
  • 设计安全的实际含义
  • 威胁建模:在构建之前理解你的攻击面
  • 安全作为业务规则
  • 加密标准:组织决策
  • 数据库安全
  • CORS:你的API所执行的边界
  • 结构化日志和字段级遮蔽
  • CI/CD安全门禁
  • 微服务与内部服务安全
  • 数据隐私与合规性作为工程需求
  • 结论

先决条件

在阅读本文之前,你应该熟悉以下内容:

  • 使用任何语言或框架构建API和后端系统
  • 一般软件架构概念:分层、服务、网关
  • CI/CD流水线是什么以及它们在高层如何运作
  • 加密和哈希的基本概念

你不需要安全背景。本文从工程和架构的角度阐述每个概念。

设计安全的实际含义

设计安全不是一份检查清单。它不是一组你要安装的工具。它是一种思考软件的方式,将安全需求与功能需求同样对待。在这种工作模式下,你将安全需求视为系统被视作完整之前必须满足的条件。

大多数团队对安全采取被动态度。某物损坏了,某物被标记了,然后某物被修补了。这个循环重复进行。系统以与积累其他技术债务相同的方式积累技术安全债务,只是风险更高。

安全设计团队采取主动的安全措施。在功能开发之前,工程团队会提出问题:该功能面临哪些威胁?哪些数据是敏感的?如果攻击者控制了该输入会发生什么?如果该组件被攻破,影响范围有多大?这些问题的答案将塑造架构,而非架构决定答案。

这会产生根本不同的系统。不是因为单个组件不同,而是因为每一层的设计决策都体现了对可能发生问题的理解。

完美实例包括:API网关默认进行速率限制,因为设计时已考虑滥用风险。领域层强制执行业务规则,因为设计时已明确这些规则的归属位置。CI/CD流水线能检测代码中的敏感信息,因为设计之初就纳入了这一防护环节,等等。

在设计阶段正确处理安全问题的成本,仅为后期修复或管理成本的一小部分。而管理成本又仅为重大事故成本的一小部分。这仅仅是时间问题!

威胁建模:在构建系统前理解攻击面

威胁建模是在系统构建前系统性识别潜在风险的过程。这是一个正式的工程活动,而非安全团队的专项工作。

目标很简单:了解可能攻击你系统的人员、他们的目标、可能的攻击方式,以及成功后的潜在影响。基于这些理解,你将做出有意识的设计决策,降低攻击发生的可能性和影响程度。

威胁建模的四个问题

  1. 我们正在构建什么?

该用例从绘制系统架构开始。系统包含哪些组件?它们如何通信?数据流经哪些路径?信任边界在哪里?信任边界是数据从一个信任环境转移到另一个信任环境的节点。

信任边界存在于API网关与互联网之间、服务层与数据库之间、系统与第三方集成之间。每个信任边界都可能成为攻击面。

在此阶段需要绘制数据流,标记数据进入系统、存储、处理和离开系统的所有位置。这将为你提供完整的保护对象全景图。

  1. 什么可能出错?

在此用例中,需要针对每个组件和每个数据流询问攻击者可能采取的行动。此时引入STRIDE模型。这是一个帮助完成该用例的问答框架缩写。

  • 伪造:攻击者能否伪装成他人?能否伪造令牌、冒充用户或声称不属于自己的身份?
  • 篡改:攻击者能否篡改传输中或静态存储的数据?能否修改交易金额、篡改回调负载或更改存储记录?
  • 否认:用户能否否认执行过某项操作?是否有足够的审计日志证明操作时间、操作者和具体行为?能否证明用户确实在系统中执行过某项操作?
  • 信息泄露:攻击者能否获取不应知晓的信息?能否读取其他用户数据、从错误响应中提取系统内部信息,或通过堆栈跟踪发现基础设施细节?
  • 拒绝服务:攻击者能否阻止合法用户访问系统?能否耗尽资源、触发高成本操作或用流量淹没系统?
  • 权限提升:攻击者是否能够获取未被授予的权限?普通用户能否访问管理员功能?外部调用者能否访问内部API?
  1. 我们将如何应对?

针对每个识别出的威胁,我们需要决定应对措施。可以通过降低发生概率或影响程度来缓解威胁,通过使用托管服务将风险转移,如果风险确实很低且缓解成本过高则选择接受风险,通过修改设计彻底消除威胁。

该决策必须明确且有文档记录,不能隐含或假设。必须形成书面记录。

  1. 我们的工作是否足够到位?

系统构建完成后,需要验证缓解措施是否真正实施。威胁建模是一个持续演进的文档,而非一次性工作。当系统发生变更时,必须重新审视威胁模型。

威胁建模的产出

威胁建模会产生两项真正有价值的内容:系统发布前必须满足的安全需求清单,以及对高风险区域的深入理解,使工程资源能够正确聚焦。

没有威胁模型时,安全措施往往被统一且表面化地应用,所有组件获得相同关注度而不管实际风险等级。有了威胁模型后,可以明确哪些组件属于高风险,哪些数据流涉及敏感信息,以及工程资源应重点投入的领域。

安全作为业务规则

领域驱动设计教导我们业务逻辑决定应用成败。领域层是定义系统正确性的规则所在。安全需求本质上就是业务规则,应与所有其他业务规则一起置于领域层。

销售代理在未确认覆盖范围时不应能创建销售订单。这是业务规则,应属于创建销售的领域实体或领域服务,在创建时强制执行,任何上层模块都无法绕过。

支付状态不应直接从待处理跳转到已完成,必须经过处理状态。这是业务规则,应属于支付实体的状态机,在实体层面强制执行,无法通过API或基础设施层篡改。

如果源账户被标记为欺诈审查,账户转账不应继续执行。这既是业务规则也是安全需求,应属于协调转账的领域服务,在任何资金转移前强制执行。

当安全需求被编码为领域业务规则时,无法通过直接调用API、使用不同客户端或通过ORM访问数据库来绕过规则。规则在领域对象创建或状态变更时强制执行。系统所有路径都会经过领域层,领域层强制执行规则。

值对象进一步强化了这一机制。在构造时验证的Money值对象确保领域中永远不存在无效金额。验证格式和校验码的AccountNumber值对象确保系统中不会出现畸形账户号。验证不是控制器可以跳过的检查,而是类型本身的固有属性。

这是在领域层面实现"安全设计"的工程机制:将安全需求编码为领域类型和领域操作的约束,使其无法被绕过。

加密标准:组织层面的决策

大多数组织中最危险的安全决策往往是未被做出的决策:组织使用哪种加密标准?

当没有统一的组织标准时,每个开发人员都会自行选择。一个团队使用AES-128,另一个团队使用AES-256与CBC模式,第三个团队使用AES-256与GCM模式,第四个团队仍在某些场景中使用MD5。

没有人希望出现这种混乱局面。这种局面的出现是因为组织层面没有做出决策并强制执行。

由临时加密选择带来的漏洞是严重且往往难以察觉的。填充Oracle攻击会利用特定的加密模式和错误处理组合。CBC模式如果初始化向量(IV)生成不当,会受到GCM模式不会受到的攻击。较弱的密钥长度可能被暴力破解。在加密失败时暴露堆栈跟踪会泄露内部实现细节,帮助攻击者。

任何处理敏感数据的组织都必须明确做出这些决策,并将其作为工程标准强制执行:

1. 使用哪种对称加密算法和模式?

对称加密使用相同的密钥进行数据加密和解密。它速度快且效率高,是加密静态数据或传输中大量数据的正确选择。

AES-256与GCM模式是当前大多数应用的标准。AES代表高级加密标准。256表示密钥长度(位数):通常密钥越长,可能的组合越多,暴力破解难度越大。

GCM(Galois/Counter模式)提供认证加密,这意味着它不仅加密数据,还会生成一个认证标签以检测密文是否被篡改。通过一次操作即可实现保密性和完整性。

CBC(密码块链模式)仍在使用,但需要仔细处理初始化向量(IV)和消息认证码(MAC),才能实现GCM模式原生提供的相同特性。如果处理不当,会引发填充Oracle攻击,攻击者可以通过仔细观察系统对修改后密文的响应来解密数据或伪造密文。

2. 使用哪种密码哈希算法?

哈希是单向操作。你无法将哈希值还原为原始值。这就是为什么你存储密码的哈希值而非密码本身。当用户登录时,你会对输入内容进行哈希运算,并与存储的哈希值进行比较。

bcrypt、scrypt和Argon2是当前密码哈希的标准算法。这些算法故意设计得缓慢且计算成本高昂,即使攻击者获取了哈希数据库,暴力破解攻击也变得不切实际。

SHA-256不是密码哈希算法。它是一种通用的密码学哈希函数,设计上速度很快...而这正是密码哈希所不希望的。攻击者可以在现代硬件上每秒尝试数十亿次SHA-256哈希运算。

MD5不适合任何敏感数据的哈希运算。它多年前已被攻破,不应出现在任何安全敏感的上下文中。这些不是我的个人观点,而是密码学研究和现实攻击的结果。

3. 使用哪种非对称算法进行签名和密钥交换?

非对称加密使用一对密钥:所有人都能访问的公钥和只有你自己持有的私钥。用公钥加密的数据只能用私钥解密。用私钥生成的签名可以通过任何持有公钥的人验证。

非对称操作至少应使用 RSA-2048。在高安全场景中使用 JWT 签名时,应选择 RS256 或 ES256。RS256 使用 RSA 结合 SHA-256 进行签名。ES256 使用椭圆曲线数字签名算法(ECDSA)和 P-256 曲线。在同等安全级别下,EC 密钥通常比 RSA 更受青睐,因为它们能生成更小的密钥和签名,同时提供相同的密码强度。

4. 加密如何交付给开发者

最重要的决定是:不要为每个项目从零开始实现加密。高级工程师会将加密工具构建为内部软件包或云函数。然后你只需导入并使用即可。你永远不应该自己实现密码学原语。这能从源头上消除一类实现缺陷。

当加密实现集中化时,修复漏洞只需在一个地方进行。当加密实现分散在每个项目中时,修复漏洞需要先找到所有实现,这通常是不可能的。

数据库安全

在大多数系统中,数据库是存储最敏感数据的地方。它也是整个技术栈中最常配置错误的组件之一。

SQL 注入

SQL 注入已有 25 年历史,自 1998 年就已存在。由于工程标准未被严格执行,它仍然出现在生产系统中。

原因始终如一:字符串拼接。开发人员获取用户输入后,直接将其粘贴到 SQL 字符串中。数据库无法区分开发人员本意的查询和攻击者构造的查询。

code
-- 开发人员本意如此
SELECT * FROM users WHERE email = '[email protected]'

-- 攻击者输入:[email protected]' OR '1'='1
SELECT * FROM users WHERE email = '[email protected]' OR '1'='1'
-- 返回表中所有用户

解决方案只能是参数化查询或 ORM。查询结构是固定的,值是单独传递的。数据库将它们视为数据,而非查询结构的一部分。

CI/CD 流水线中的静态分析必须标记原始 SQL 字符串拼接。这是一项零容忍政策。包含用户输入拼接的原始 SQL 字符串永远不可接受,必须不得进入生产环境。

数据库访问的最小权限原则

应用程序的数据库用户应仅拥有其所需的权限,不多不少。只读报表端点应以仅具有 SELECT 权限的用户身份连接。它不应拥有 INSERT、UPDATE 或 DELETE 权限。仅用于写入的 API 不应拥有 DROP TABLE 权限。

这能限制成功 SQL 注入或凭证泄露后的破坏范围。如果应用程序只能从特定表中 SELECT,那么攻击者即使攻破数据库凭证,也只能从这些表中 SELECT。他们无法 DROP 数据库、INSERT 假记录或 UPDATE 账户余额。

这是必须为每个应用程序组件明确做出的数据库配置决策,并需在基础设施层面强制执行。

所有数据库连接必须加密

数据库连接必须使用 TLS。未加密的数据库连接会在网络中以明文形式传输查询结果。应用与数据库之间的任何能够观察网络流量的组件都可能读取所有查询结果。

在云环境中,服务通常通过内部网络通信,这些网络看似安全但实际上并非如此。加密数据库连接不是可选的。

在实践中,强制数据库连接使用 TLS 的方式如下:

code
final connection = await Connection.open(
  Endpoint(
    host: 'your-db-host.example.com',
    port: 5432,
    database: 'your_database',
    username: dbUser,
    password: dbPassword,
  ),
  settings: ConnectionSettings(
    sslMode: SslMode.require,
  ),
);
code
var connectionString =
    "Host=your-db-host.example.com;" +
    "Port=5432;" +
    "Database=your_database;" +
    "Username=db_user;" +
    "Password=db_password;" +
    "SSL Mode=Require;" +
    "Trust Server Certificate=false;";

await using var dataSource = NpgsqlDataSource.Create(connectionString);

设置 SSL Mode=Require 表示如果数据库服务器不支持 TLS,连接将完全失败。这是正确的做法。当 TLS 不可用时,静默回退到明文传输的连接完全无法提供任何保护。

数据库凭证必须存储在密钥管理中

数据库凭证必须在运行时从密钥管理服务获取。不能存储在配置文件中、提交到版本控制,或硬编码在连接字符串中。

运行时获取凭证的实际操作方式如下:

code
final dbHost = Platform.environment['DB_HOST']!;
final dbUser = Platform.environment['DB_USER']!;
final dbPassword = Platform.environment['DB_PASSWORD']!;

final connection = await Connection.open(
  Endpoint(
    host: dbHost,
    database: 'your_database',
    username: dbUser,
    password: dbPassword,
  ),
  settings: ConnectionSettings(sslMode: SslMode.require),
);
code
builder.Configuration.AddAzureKeyVault(
    new Uri("https://your-vault.vault.azure.net/"),
    new DefaultAzureCredential()
);

var connectionString = builder.Configuration["DatabaseConnectionString"];

连接池也必须得到适当控制。无限制的连接池可能导致数据库服务器可用连接耗尽,影响所有租户的访问。应有意识地配置池大小:

code
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = 20;
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = 5;
dataSourceBuilder.ConnectionStringBuilder.ConnectionIdleLifetime = 300;
await using var dataSource = dataSourceBuilder.Build();

存储过程作为参数化策略

存储过程是存储在数据库内部的命名预编译 SQL 代码块。您不需要从应用发送完整的 SQL 字符串,而是通过名称调用存储过程并单独传递参数。

由于 SQL 结构在数据库内部是固定的,参数始终被视为数据(而不是拼接到查询字符串中),存储过程能有效抵御 SQL 注入攻击。

code
CREATE OR REPLACE PROCEDURE get_user_by_email(p_email VARCHAR(255))
LANGUAGE plpgsql
AS $$
BEGIN
    SELECT id, first_name, last_name, email
    FROM users
    WHERE email = p_email;
END;
$$;
code
public async Task<User?> FindUserByEmail(string email)
{
    await using var command = dataSource.CreateCommand("get_user_by_email");
    command.CommandType = CommandType.StoredProcedure;
    command.Parameters.AddWithValue("p_email", email);

    await using var reader = await command.ExecuteReaderAsync();
    if (await reader.ReadAsync())
    {
        return new User
        {
            Id = reader.GetGuid(0),
            FirstName = reader.GetString(1),
            LastName = reader.GetString(2),
            Email = reader.GetString(3)
        };
    }
    return null;
}

当攻击者将 ' OR '1'='1 作为电子邮件值传入时,数据库会将该字符串原样作为参数处理。数据库永远不会将其解释为SQL语法。存储过程只会执行创建时定义的固定查询。这也是为什么存储过程仍然是合法的安全工具,而不仅仅是性能优化手段的原因之一。

CORS:API实施的边界

跨域资源共享(CORS)是浏览器安全机制,用于控制哪些域名可以代表网页向您的API发起请求。

生产环境中最常见的CORS配置错误也是最危险的:通配符CORS。

code
Access-Control-Allow-Origin: *

这会告诉所有浏览器,互联网上任何网页都可以使用该用户的凭证向您的API发起认证请求。攻击者托管恶意网页,用户访问该网页后,攻击者的网页就可以以该用户身份向您的API发起请求。

在开发环境中,通配符CORS使用起来很方便。但在生产环境中,这会构成安全漏洞。

每个生产环境的API都必须使用明确允许的来源配置CORS。不能使用通配符模式,也不能使用 *.yourapp.com 这样的模糊匹配。必须明确列出允许发起跨域请求的具体域名。

code
Access-Control-Allow-Origin: https://app.yourproduct.com
Access-Control-Allow-Origin: https://admin.yourproduct.com

域名匹配必须完全一致。如果来源头信息与明确允许的来源不匹配,浏览器将不会包含响应内容。

允许的方法和允许的头信息也必须明确具体。默认采用拒绝策略:如果未明确允许,就禁止访问。

这是在API网关或框架层面做出的配置决策。该配置会统一适用于所有API端点,而不是由各个开发者逐个端点进行配置,从而避免可能的错误配置。

结构化日志记录和字段级屏蔽

日志记录是大多数系统中最常被忽视的安全表面之一。开发人员添加日志语句以帮助调试。这些日志语句会传输到日志平台。日志平台会保留数月甚至数年的日志。这些日志包含开发人员在深夜处理事件时认为有用的任何信息。

结果是,生产环境日志中经常包含不应共享的信息。例如,完整的请求和响应正文、包含承载令牌的授权头、与敏感操作配对的用户ID、账户号码,有时甚至包含明文密码,因为有人在验证之前记录了原始请求正文。

再次强调,这不是假设情况。这些是真实生产日志平台中发现的真实模式。

工程控制是在日志框架级别应用的字段级遮蔽。敏感字段在日志配置中只需定义一次。框架会自动在所有日志语句中遮蔽这些字段,所有环境均适用,无论开发人员在日志语句中输入了什么内容。

绝不能出现在日志中的敏感字段包括:密码、令牌和授权头、完整的账户号码和卡号、BVN和NIN、任何国家识别码、加密密钥和秘密信息,以及处理敏感数据的端点的完整请求体。

遮蔽必须在框架级别实现,而非开发人员级别。当你添加日志语句时,不需要记住去遮蔽敏感字段。框架会自动处理。你只需记录对调试有用的信息。框架会生成日志条目,其中敏感字段会被替换为遮蔽值。

以下是字段级遮蔽的实际应用示例:

code
class SecureLogger {
  final Logger _logger;

  static const _sensitiveKeys = {
    'password', 'authorization', 'token',
    'account_number', 'card_number', 'bvn', 'nin', 'secret', 'api_key',
  };

  SecureLogger(this._logger);

  void info(String message, {Map<String, dynamic>? data}) {
    _logger.info(message, _mask(data));
  }

  void error(String message, {Map<String, dynamic>? data, Object? error}) {
    _logger.error(message, _mask(data), error);
  }

  Map<String, dynamic>? _mask(Map<String, dynamic>? data) {
    if (data == null) return null;
    return data.map((key, value) {
      if (_sensitiveKeys.contains(key.toLowerCase())) {
        return MapEntry(key, '***MASKED***');
      }
      if (value is Map<String, dynamic>) return MapEntry(key, _mask(value));
      return MapEntry(key, value);
    });
  }
}

final logger = SecureLogger(Logger('PaymentService'));

logger.info('Processing payment', data: {
  'transaction_id': 'txn_001',
  'amount': 50000,
  'account_number': '0123456789',
  'authorization': 'Bearer eyJ...',
});

// output:
// Processing payment
// { transaction_id: txn_001, amount: 50000,
//   account_number: ***MASKED***, authorization: ***MASKED*** }
code
public class MaskSensitiveFieldsPolicy : IDestructuringPolicy
{
    private static readonly HashSet<string> SensitiveFields =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "password", "authorization", "token", "account_number",
            "card_number", "bvn", "nin", "secret", "api_key"
        };

    public bool TryDestructure(
        object value,
        ILogEventPropertyValueFactory factory,
        out LogEventPropertyValue result)
    {
        if (value is not IDictionary<string, object> dict)
        {
            result = null!;
            return false;
        }

        var properties = dict.Select(kvp => new LogEventProperty(
            kvp.Key,
            SensitiveFields.Contains(kvp.Key)
                ? factory.CreatePropertyValue("***MASKED***")
                : factory.CreatePropertyValue(kvp.Value, true)));

        result = new StructureValue(properties);
        return true;
    }
}

Log.Logger = new LoggerConfiguration()
    .Destructure.With<MaskSensitiveFieldsPolicy>()
    .WriteTo.Console()
    .CreateLogger();

所有环境均适用此规则,不仅限于生产环境。开发日志经常被导出到第三方日志平台,并且通常被多个团队成员搜索。在开发环境中使用真实账户进行测试并记录请求体的开发人员,会将真实用户数据写入不应存在的日志平台。

CI/CD 安全门机制

CI/CD 流水线是代码进入生产环境前的最后一个自动化检查点。它也是大多数工程组织中最常被忽视的安全控制措施之一。

大多数流水线仅运行测试和检查格式。安全门机制是额外的检查步骤,用于发现人工代码审查经常遗漏的安全相关问题。

秘密检测

每次向任何分支推送代码时都必须扫描敏感信息。包括 API 密钥、数据库密码、私钥、包含凭证的连接字符串以及配置文件中的 JWT 密钥。

Gitleaks 是一个专为此目的开发的免费开源工具。以下是将其集成到 GitHub Actions 流水线的方法:

code
# .github/workflows/security.yml
name: Security Gates

on:
  push:
    branches: ["*"]
  pull_request:
    branches: ["*"]

jobs:
  secret-detection:
    name: Scan for secrets
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

当 Gitleaks 检测到敏感信息时,会阻断流水线,输出确切的文件和行号,并阻止合并。需要先删除敏感信息,轮换凭证(因为该凭证已接触版本控制系统,必须视为已被泄露),然后推送干净的提交才能通过流水线。

扫描必须覆盖完整的 Git 历史记录,而不仅仅是最新提交。即使某个敏感信息在三份提交前被添加又在后续提交中被删除,它仍然存在于仓库历史中,应被视为泄露。

组织还应制定明确的响应流程:检测到敏感信息时通知谁、轮换哪些凭证、以及在什么时间范围内处理。没有响应的检测不能视为有效的安全控制。

安全静态分析

静态分析工具无需运行代码即可分析代码中的漏洞模式。它们可以检测原始 SQL 字符串拼接、不安全反序列化、硬编码凭证、已弃用的加密函数、缺失的输入验证以及危险函数调用。

Semgrep 是一个免费开源的静态分析工具,拥有由安全社区维护的大型安全规则库。以下是其在流水线中的集成方式:

code
static-analysis:
    name: Static analysis
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/secrets
            p/sql-injection
        env:
          SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

p/owasp-top-ten 规则集可检测对应 OWASP 十大漏洞的模式。p/sql-injection 规则集专门标记数据库查询中的字符串拼接。当 Semgrep 发现匹配项时,流水线会失败并标识触发问题的确切文件、行号和规则。问题解决前代码无法合并。

对于 .NET 项目,Microsoft Security Code Analysis 和 Roslyn Analyzers 提供了类似功能,可直接集成到构建过程中。

规则必须经过有意配置。并非所有警告都代表实际漏洞。您的团队将定义哪些规则是阻止类、哪些是建议类以及哪些被抑制。这种配置本身是一项安全决策,必须定期审查。

依赖项漏洞扫描

项目中的每个依赖项都是潜在的攻击面。OWASP Dependency-Check 是一款免费工具,它将项目依赖项与国家漏洞数据库(NVD)进行比对,并标记已知的 CVE。

code
dependency-scan:
    name: 依赖项漏洞扫描
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: dependency-check/Dependency-Check_Action@main
        with:
          project: "your-project-name"
          path: "."
          format: "HTML"
          args: >
            --failOnCVSS 7
            --enableRetired

      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: dependency-check-report
          path: reports/

--failOnCVSS 7 参数表示如果任何依赖项存在 CVSS 分数为 7 或更高的已知漏洞,流水线将失败。CVSS(通用漏洞评分系统)分数范围是 0 到 10。7 分及以上被视为高严重性。您可以根据风险承受能力调整此阈值。

对于 Node.js 项目,npm audit 提供了开箱即用的类似功能。对于 Dart 项目,dart pub deps 结合漏洞数据库检查可以覆盖依赖项范围。

环境特定配置验证

在部署到生产环境之前,流水线必须验证生产配置是否符合所需的安全标准。这不仅仅是检查配置文件是否存在。而是主动断言特定的安全关键设置是否正确。

作为流水线步骤的验证示例如下:

code
config-validation:
    name: 验证生产配置
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v3

      - name: 验证安全配置
        run: |
          if grep -r "debugEndpoint\|swagger-ui\|/debug" src/ --include="*.dart" --include="*.cs"; then
            echo "ERROR: 生产构建中发现调试端点"
            exit 1
          fi

          if grep -r "AllowAnyOrigin\|\*" config/ --include="*.yaml" --include="*.json"; then
            echo "ERROR: 检测到通配符 CORS 配置"
            exit 1
          fi

          if grep -rE "(password|secret|key)\s*=\s*['\"][^$\{]" config/ --include="*.yaml" --include="*.json"; then
            echo "ERROR: 配置中检测到硬编码凭证"
            exit 1
          fi

          if grep -r "showStackTrace.*true\|expose_errors.*true" config/ --include="*.yaml"; then
            echo "ERROR: 生产配置中启用了堆栈跟踪暴露"
            exit 1
          fi

此脚本会在每次生产部署前运行。调试端点、通配符 CORS、硬编码凭证和堆栈跟踪暴露都是阻止类故障。所有检查通过之前部署无法继续进行。

不同环境的不同工作流程

开发、预发布和生产环境必须使用不同的流水线工作流程。不仅仅是部署目标不同,验证要求也应有所区别。生产环境的流水线比预发布环境的流水线更加严格,预发布环境的流水线又比开发环境的流水线更严格。

生产环境的流水线包含完整的安全门禁检查。开发环境的流水线可能会跳过一些耗时较长的安全检查以保持开发效率。但生产环境的流水线绝不会跳过任何安全检查。

这种分层的架构意味着代码在接近生产环境时会逐步通过更严格的安全验证。当代码到达生产环境的部署门禁时,它已经通过了多个低环境中的自动化安全验证层。

微服务与内部服务安全

构建微服务引入了一个单体架构不存在的安全挑战:每个服务间通信都可能成为攻击入口。

在单体架构中,组件间的调用是内存中的函数调用,无法被拦截、伪造或篡改。

在微服务架构中,每个服务调用都是网络请求。网络请求可能被拦截、伪造和篡改。那些因为服务是"内部"的而隐式信任彼此的服务,实际上做出了危险的假设。

每个微服务必须默认将所有请求视为不可信的,包括来自其他内部服务的请求。所有服务间通信都必须进行身份验证。API网关处理外部身份验证。内部服务间的身份验证必须单独处理。

两种模式覆盖了主要场景:同步调用使用mTLS,异步通信使用认证事件流。

同步服务间调用的mTLS(双向TLS)意味着两个服务会互相出示证书。支付服务调用账户服务时,必须出示账户服务信任的有效证书。如果证书缺失或无效,连接会在任何应用代码运行前被拒绝。这是基于密码学的身份证明:没有可窃取的令牌,无需轮换API密钥。

code
支付服务                    账户服务
      |                                  |
      |-- 出示其证书 ---->             |
      |                                  |-- 验证证书是否来自受信任的CA
      |                                  |-- 证书无效则拒绝连接
      | <-- 出示其证书 ---             |
      |-- 验证证书                   |
      |                                  |
      |-- 已授权的请求 ------->       |
      |                                  |-- 处理请求(已验证身份)

异步通信的认证事件流意味着每个发布到消息代理的消息都携带签名,消费服务在处理前会验证该签名。发布PaymentCompleted事件的服务会使用其私钥对事件进行签名。消费服务在处理事件前会验证签名。没有有效签名的伪造事件会被丢弃。

code
class PaymentEventPublisher {
  final KafkaProducer _producer;
  final EventSigner _signer;
dart
Future<void> publishPaymentCompleted(Payment payment) async {
  final event = PaymentCompletedEvent(
    paymentId: payment.id,
    amount: payment.amount,
    timestamp: DateTime.now().toIso8601String(),
  );

  final signature = await _signer.sign(jsonEncode(event.toJson()));

  await _producer.send(
    topic: 'payment.completed',
    value: jsonEncode({
      'payload': event.toJson(),
      'signature': signature,
      'publisher': 'payment-service',
    }),
  );
}

class AccountEventConsumer { final EventVerifier _verifier; final AccountService _accountService;

Future<void> onMessage(KafkaMessage message) async { final data = jsonDecode(message.value); final isValid = await _verifier.verify( jsonEncode(data['payload']), data['signature'] as String, data['publisher'] as String, );

if (!isValid) { logger.warning('Invalid signature from ${data['publisher']} — discarding'); return; }

final event = PaymentCompletedEvent.fromJson(data['payload']); await _accountService.handlePaymentCompleted(event); } }

code

服务必须通过 API 网关访问,不能直接暴露在互联网中。网关是唯一的入口点,负责处理所有外部流量的速率限制、身份验证和路由。网关后的服务属于内部服务:它们之间仍然互不信任,但不会对外暴露。

过度设计本身就是一种安全风险。一个本应设计为良好单体架构的应用,如果被实现为微服务,会引入团队无法管理的操作复杂性。多个部署流水线、多个服务边界、API 网关、消息代理和分布式追踪都需要专门的工程能力来保障安全运行。一个没有足够资源维护的单体架构,远比一个完全没有资源运行的微服务架构危险性小得多。

选择架构时应基于团队实际能安全运营的能力,而不是基于流行趋势。

## 数据隐私与合规作为工程要求

数据隐私要求是工程要求,而不是法律要求。它们不是工程需要适应的法律要求,而是工程需要负责的工程要求。

根据尼日利亚 2023 年《数据保护法》(NDPA),个人数据只能用于明确指定、合法的特定目的。BVN 数据的存储和处理有特定的驻留要求,由尼日利亚中央银行 (CBN) 指南强制执行。根据 PCI-DSS 标准,任何存储、处理或传输持卡人数据的环境都属于合规范围。

这些要求具有具体的工程影响。

### 1. 数据最小化

您只能收集和存储实际需要完成指定目的的个人数据。收集数据多于实际需求的 API 会带来合规风险。返回数据多于调用方所需的数据响应,既是 BOPLA 漏洞,也是数据最小化原则的违反。

### 2. 目的限制

为一个目的收集的数据,未经适当依据不得用于其他目的。工程上的影响是系统内流动的数据必须记录并强制执行。一个服务的数据不得流向另一个服务用于未经授权的目的。

### 3. 数据驻留

某些类型的数据必须存储在特定的地理区域。这是系统架构必须设计的基础设施要求,而不是部署后通过补丁解决的问题。

### 4. 保留与删除

当收集个人数据的目的已达成,或隐私声明中定义的保留期限已过期时,必须删除个人数据。工程实现上的含义是,每类个人数据都必须有明确的保留策略和自动删除机制。依赖某人记住删除数据的手动删除策略不符合合规要求。

### 5. 审计日志

对个人数据的每次访问都必须记录足够详细的信息以证明合规性。包括谁访问了哪些数据、何时访问、声明的用途是什么。该审计日志必须具备防篡改特性,并保留规定期限。

以下是这五项政策在单一功能中(用户资料端点)的实现示例:

class UserProfileService { final UserRepository _repository; final AuditLogger _auditLogger; final DataResidencyValidator _residencyValidator;

UserProfileService(this._repository, this._auditLogger, this._residencyValidator);

Future<UserProfileResponse> getProfile( String requestingUserId, String targetUserId, String purpose, ) async { if (!_isValidPurpose(purpose)) { throw UnauthorizedException('Invalid or undeclared access purpose'); }

final user = await _repository.findById(targetUserId); if (user == null) throw NotFoundException('User not found');

_residencyValidator.assertCompliant(user.dataRegion, requestingUserId);

await _auditLogger.log(AuditEntry( action: 'READ_USER_PROFILE', actorId: requestingUserId, resourceId: targetUserId, purpose: purpose, timestamp: DateTime.now(), dataFieldsAccessed: ['first_name', 'last_name', 'email'], ));

return UserProfileResponse( id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, ); }

bool _isValidPurpose(String purpose) { const validPurposes = { 'user_self_view', 'customer_support_verified', 'compliance_audit', }; return validPurposes.contains(purpose); } }

class DataRetentionService { final UserRepository _repository; final AuditLogger _auditLogger;

Future<void> enforceRetentionPolicy() async { final cutoff = DateTime.now().subtract(const Duration(days: 2555)); final expiredUsers = await _repository.findInactiveUsersBefore(cutoff);

for (final user in expiredUsers) { await _auditLogger.log(AuditEntry( action: 'AUTOMATED_DATA_DELETION', resourceId: user.id, purpose: 'retention_policy_enforcement', timestamp: DateTime.now(), ));

await _repository.anonymize(user.id); } } }

code

这个服务通过实际代码展示了所有五项合规政策的运作方式。每次访问都会声明用途。在返回数据前会验证数据驻留地。审计日志会记录每次访问的完整上下文。响应只返回必要的字段。定时任务会自动执行删除操作,无需人工记忆。

这些要求必须是每个处理个人数据功能的需求规格说明的一部分。不是在功能构建后才处理,而是在设计阶段就解决。

## 结论

安全不是软件中添加的功能,而是从一开始就设计进去的属性。

在架构决策之前进行威胁建模,意味着你在决定如何构建系统之前,已经清楚了解需要保护的内容以及需要防范的威胁。

将安全作为业务规则,意味着领域层以与执行其他业务规则相同的方式强制执行安全要求,无法绕过,也无法被遗忘。

将加密标准作为组织决策,意味着没有开发人员会出于偏好或便利性自行选择加密原语。

数据库安全意味着应用程序只能执行其必需的操作,即使攻击者攻破了一个组件,也无法危及整个系统。

CORS配置意味着浏览器安全机制真正执行了边界控制,而不是被通配符绕过。

结构化日志记录配合字段级数据脱敏,意味着敏感数据无论开发人员记录了什么内容,都不会出现在日志文件中。

CI/CD安全门禁意味着自动化验证可以捕捉人工审查遗漏的问题,每次提交、每次部署前都会执行。

这些概念都不是异想天开或难以实现的。它们是需要刻意决策和严格执行的工程学科。不采取这些措施本身也是一种决策,其后果会体现在事件报告中。

修复安全漏洞最昂贵的时机是漏洞已被利用之后。第二昂贵的时机是漏洞已部署到生产环境之后。最经济的时机是在设计阶段,代码尚未编写之前。

这就是“安全设计”在实践中真正的含义。它不是完美的安全或零风险,而是关于系统如何处理对抗性条件的刻意、记录在案且可执行的决策,这些决策在系统构建之前就已确定,从而确保工程从一开始就稳固可靠。

作为技术爱好者,值得期待的一天是应用程序将安全作为开发过程的组成部分,而非事后补救或仅为部署而必须满足的条件。这就是为什么你需要理解、接受并采用“安全设计”作为标准化流程,不仅在应用安全领域,也在核心软件工程中。

快乐安全编码!

合作伙伴推荐课程

(在新标签页中打开)

广告

- Udemy Flutter高级课程 - 使用MVVM的整洁架构 Mina Farid 4.2(1,341条评分) 20小时30分钟 中级 证书 查看课程(在新标签页中打开)

- Coursera高级道德黑客与网络安全 LearnKartS 4.7(52条评分) 9周,每周1小时 中级 证书 查看课程(在新标签页中打开)

- Udemy网络安全入门:终极新手课程 CyberProStudy Ltd. 4.3(1,879条评分) 1小时32分钟 初级 证书 查看课程(在新标签页中打开)

- Coursera认证云安全专家考试准备 LearnKartS 4周,每周10小时 初级 证书 查看课程(在新标签页中打开)

更多网络安全课程请访问Class Central

→

工程主管及高级产品工程师,拥有7年以上设计、构建和领导可扩展企业级软件系统及数字产品交付的经验。我专注于端到端系统设计,涵盖架构与平台工程直至部署的全流程,特别强调可扩展性、性能、可维护性以及长期技术可持续性。我曾领导跨职能工程团队,制定技术战略、提升交付速度、建立工程最佳实践,并指导开发者在技术和职业发展上共同成长。我的经验覆盖移动端、后端及平台工程,使我能够设计出与业务需求同步扩展的系统。我的核心技术栈包括Flutter、Dart、C#、.NET和.NET Core,拥有构建企业级应用、集成分布式服务并交付稳定生产就绪解决方案的丰富经验。除了产品交付,我还通过构建并发布Flutter插件、参与开源项目、倡导工程卓越以及撰写技术内容来为工程社区做贡献,让复杂的软件架构和工程概念变得易于理解。我在一线工程实践、软件架构和技术领导的交汇点上表现尤为出色,致力于构建韧性系统、赋能高效团队,并交付能产生可衡量商业影响的技术方案。

如果你读到了这里,请感谢作者以表达你的支持。说声谢谢

免费学习编程。freeCodeCamp的开源课程已帮助超过40,000人成为开发者。立即开始