← 返回蜂巢洞察

“从设计层面确保安全性”:在编写任何代码之前,如何将安全机制纳入系统架构之中

在大多数工程团队中,都会进行这样的对话——不过往往为时已晚。 想象一下:某个功能被部署到生产环境中之后,才会进行安全审查;或者在进行渗透测试时发现了问题;更糟糕的是,还会收到事故报告。 这些漏洞确实是存在的,修复它们也需要投入大量成本。而在这些讨论中,总会有人说:“我们需要为这个功能添加安全措施。” 但这个问题就出在这里:安全并不是事后才添加到软件中的东西,而应该从项目一开始就被纳入设计之中。一旦开始考虑为已经存在的东西添加安全功能,那就意味着你已经进入了“事故应对模式”。 “安全驱动设计”是一种完全不同的思维方式——在制定架构决策之前,就应该明确安全要求;在编写任何代码之前,就必须了解潜在的

在大多数工程团队中,都会进行这样的对话——不过往往为时已晚。 想象一下:某个功能被部署到生产环境中之后,才会进行安全审查;或者在进行渗透测试时发现了问题;更糟糕的是,还会收到事故报告。 这些漏洞确实是存在的,修复它们也需要投入大量成本。而在这些讨论中,总会有人说:“我们需要为这个功能添加安全措施。” 但这个问题就出在这里:安全并不是事后才添加到软件中的东西,而应该从项目一开始就被纳入设计之中。一旦开始考虑为已经存在的东西添加安全功能,那就意味着你已经进入了“事故应对模式”。 “安全驱动设计”是一种完全不同的思维方式——在制定架构决策之前,就应该明确安全要求;在编写任何代码之前,就必须了解潜在的威胁模型;在业务逻辑层,就要识别并执行相关的安全规则。所有这些措施都应该利用工程技术本身所提供的工具来实现,而不是作为对已有系统的补充。 CI/CD流程能够发现人工审查中可能遗漏的问题。系统的每一层设计都应该从攻击者的角度出发来考虑,而这不是事后才想到的,而应该是最基本的设计要求。 这并不是安全团队的责任,而是整个工程团队的责任——而且这种责任在第一个拉取请求提交之前就应当开始履行。 在这本手册中,我会为大家详细讲解如何让工程团队在设计过程中始终将安全性作为重要考量因素。

目录

先决条件

在阅读本文之前,您应该已经掌握了以下内容:
  • 使用任何语言或框架来开发API及后端系统

  • 软件架构的基本概念:层次结构、服务组件、网关等

  • 了解CI/CD管道的含义及其基本工作原理

  • 对加密与哈希技术的含义有基本的认知

你并不需要具备安全方面的背景知识。本文从工程和架构的角度来阐释每一个相关概念。

“安全设计”究竟意味着什么

“安全设计”并不是一份检查清单,也不是一组需要安装的工具。它是一种思考软件开发的方式——在这种方式中,你会像对待功能需求一样来对待安全需求。在这种工作模式下,你会把安全需求视为系统必须满足的条件,只有满足了这些条件,系统才能被视为完整的。

大多数团队都是被动地处理安全问题:当出现故障或安全隐患时,才会进行修复或打补丁,这个循环会不断重复。系统因此会积累技术性安全风险,这种风险的后果往往比其他技术问题的后果更为严重。

而采用“安全设计”方法的团队则会主动应对安全问题。在开发某个功能之前,工程团队会先思考:这个功能可能面临哪些威胁?哪些数据属于敏感信息?如果攻击者控制了这些输入数据,会发生什么?如果某个组件遭到破坏,其影响范围会有多广?这些问题的答案会直接影响系统的架构设计,而不是相反。

这种做法最终会产生截然不同的系统。之所以会这样,并不是因为各个组件的具体实现有所不同,而是因为在每一层设计阶段,开发人员都充分考虑了可能出现的各种问题。

典型的例子包括:
API网关会默认设置速率限制,因为设计之初就已经考虑到了被滥用的可能性;
业务逻辑层会严格执行各种业务规则,因为设计时已经明确了这些规则的适用场景;
持续集成/持续交付流程会在代码中检测敏感信息,因为设计阶段就包含了这样的检查机制等等。

在设计阶段就确保系统安全性所带来的成本,远远低于事后试图修复或管理安全问题所花费的成本。而管理安全问题的成本,更是远低于一旦发生重大安全事故所造成的损失。迟早会意识到这一点吧!

威胁建模:在开发之前了解你的攻击面

威胁建模是一种系统化的方法,用于在系统开发之前就识别出可能出现的各种问题。这是一种正式的工程活动,并非仅由安全团队来执行。

其目的很简单:弄清楚谁可能会攻击你的系统,他们想要达到什么目的,他们会采取什么样的手段来实现这些目标,以及如果他们的攻击成功了,将会产生什么样的后果。有了这些了解,你就可以做出有针对性的设计决策,从而降低这些攻击发生的可能性及其造成的影响。

威胁建模的四个核心问题

1. 我们正在开发什么?

在进行威胁建模时,首先需要对系统进行梳理:系统由哪些组件构成?这些组件之间是如何通信的?数据流动的路径是怎样的?哪些地方存在信任边界,即数据在不同的信任环境之间是如何传递的?

你的API网关与互联网之间、服务层与数据库之间、系统与第三方集成模块之间,都存在着信任边界。而每一个信任边界都有可能成为攻击的入口。

在这里,你需要绘制数据流图,标明数据进入系统的位置、存储地点、处理环节以及最终输出的位置。这样你就能清楚地了解自己正在保护哪些信息。

2. 可能会出现什么问题呢?

在这种应用场景中,你需要针对每一个组件和数据流,分析攻击者可能采取的行动。这时STRIDE就派上了用场。这是一个由一系列具体问题组成的框架,能够帮助你解决这类问题。

  • 伪装: 攻击者能否冒充他人?他们是否可以伪造令牌、假冒用户身份或窃取他人的权限?

  • 篡改: 攻击者能否修改传输中或已存储的数据?他们能否更改交易金额、修改回调数据,或者改动存储记录的内容?

  • 抵赖: 用户是否可以否认自己曾经执行过某些操作?是否有足够的审计日志来证明事情的经过、发生时间以及责任方是谁?我们能否证明某个用户确实对系统进行了某种操作?

  • 信息泄露: 攻击者能否获取本不应知道的信息?他们是否可以读取其他用户的数据,从错误响应中提取系统内部信息,或者通过堆栈跟踪发现基础设施的详细配置?

  • 服务拒绝: 攻击者能否阻止合法用户访问系统?他们是否可以通过消耗系统资源、触发高成本的操作,或者用大量流量使系统不堪重负来达到这一目的?

  • 权限提升: 攻击者能否获得本不应拥有的权限?普通用户是否能够使用管理员功能,或者外部访问者是否能够调用系统的内部接口?

3>我们将采取哪些措施来应对这些威胁呢?

对于每一个被识别出的威胁,我们都会制定相应的对策。可以通过降低其发生概率或减少其影响程度来减轻这种威胁;或者利用专门的服务来处理这些威胁;如果风险确实很低而采取缓解措施的代价过高,也可以选择接受这一风险;最后,如果通过改变系统设计可以使该威胁不再存在,那么就可以彻底消除它。

所有的决策都应当明确记录下来,不能只是隐含在某个计划或假设之中。

4>我们的工作做得够好吗?

在系统建成之后,必须验证各项缓解措施是否真正得到了落实。威胁建模是一份动态变化的文档,而不是一次性完成的工作。当系统发生变化时,就必须重新审视威胁模型。

威胁建模的作用

威胁建模能够产生两样非常有价值的东西:一是系统发布之前必须满足的安全要求列表,二是对高风险区域的明确识别,这样开发人员就可以把精力集中在这些关键领域上。

如果没有威胁建模,安全措施往往会被统一、肤浅地应用到所有组件上,而不管这些组件的实际风险水平如何。而有了威胁建模,我们就能知道哪些组件属于高风险区域,哪些数据流具有敏感性,从而有针对性地分配开发资源。

将安全性作为业务规则

领域驱动设计告诉我们,业务逻辑决定了一个应用程序的成败。在领域层中,那些定义系统正确性的规则得以体现。安全需求属于业务规则,因此它们应该与其它所有业务规则一样被放置在领域层中。

销售人员不应该能够在未确认保险覆盖范围的情况下创建销售订单。这就是一条业务规则。它应当被包含在负责创建销售订单的领域实体或领域服务中,在创建订单的过程中就被强制执行,任何位于其上层的系统都无法绕过这一规则。

一笔付款在未经过处理流程之前,不应该直接从“待处理”状态变为“已完成”状态。这也是一条业务规则。它应当被包含在支付实体的状态机中,在实体层面就被强制执行,通过API或基础设施层也无法篡改这一规则。

如果源账户被标记为需要进行欺诈审查,那么该账户的转账操作就不应该继续进行。这既是一条业务规则,也是一项安全要求。它应当被包含在负责处理转账操作的领域服务中,在任何资金转移发生之前就被强制执行。

当安全需求被编码为领域层面的业务规则时,直接调用API、使用不同的客户端程序,或者通过ORM访问数据库,都无法绕过这些规则。这些规则会在领域对象被创建或其状态发生变化的时候被严格执行。系统中的每一条数据流都会经过领域层,而领域层会确保这些规则得到遵守。

值对象进一步强化了这一机制。例如,“Money”值对象在创建时就会进行格式验证,这样一来,系统中就永远不会存在无效的金额;“AccountNumber”值对象会检查数字格式和校验位,从而确保系统不会接收格式错误的账户号码。这种验证不是控制器层面可以跳过的操作,而是该数据类型本身固有的属性。

这就是在领域层实现“安全设计”的工程机制:将安全需求编码为对领域类型及领域操作的约束条件,并确保这些约束条件无法被绕过。

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

在大多数组织中,最危险的安全决策往往就是那些根本没有被做出的决策——即该组织应该使用哪种加密标准?

当没有统一的组织标准时,每个开发人员都会自行选择使用哪种加密算法。有的团队使用AES-128,有的使用CBC模式的AES-256,还有的使用GCM模式的AES-256,甚至还有团队仍在使用MD5算法。

没有人希望出现这种情况,但它之所以会发生,正是因为没有人在组织层面上做出明确的决策并加以执行。

这些随意选择的加密方式所带来的安全漏洞往往非常严重,而且很难被察觉。例如,“填充预言机攻击”会利用特定的加密模式和错误处理机制;如果CBC模式的初始向量生成不当,就会容易受到攻击,而GCM模式则不会;较弱的密钥长度也可能被暴力破解;而在加密失败时,如果暴露了堆栈跟踪信息,就会泄露内部实现细节,从而帮助攻击者发动攻击。

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

1. 应选择哪种对称加密算法及模式?

对称加密使用相同的密钥来对数据进行加密和解密。这种加密方式速度快捷、效率较高,因此非常适合用于加密大量静态数据或传输中的数据。

AES-256结合GCM模式是目前大多数应用领域的首选标准。AES代表“高级加密标准”,而256则表示密钥的长度以比特为单位计算——通常来说,密钥长度越长,可用的密钥组合数量就越多,因此暴力攻击的难度也会相应增加。

GCM模式能够提供经过认证的加密服务,也就是说它不仅能对数据本身进行加密,还会生成一个认证标签,用来检测加密后的数据是否被篡改过。这样一来,通过一次操作就能同时确保数据的保密性和完整性。

CBC模式仍然被广泛使用,但为了获得与GCM相同的安全性能,必须仔细处理初始化向量和消息认证码。如果处理不当,就可能会让攻击者利用“填充预言机攻击”技术来解密数据或伪造加密内容——因为攻击者可以通过观察系统对修改后的加密数据的反应,从而实现这一目的。

2. 应选择哪种哈希算法来处理密码?

哈希运算是一种单向操作,无法将哈希值逆向还原成原始数据。因此,我们存储的是密码的哈希值,而不是密码本身。当用户登录时,系统会将其输入的密码进行哈希处理,然后与存储在数据库中的哈希值进行比对。

bcrypt、scrypt和Argon2是目前用于密码哈希处理的常用算法。这些算法的设计初衷就是使计算过程变得缓慢且耗时巨大,因此即使攻击者获得了哈希值数据库,也很难通过暴力攻击来破解密码。

SHA-256并不适用于密码哈希处理。它是一种通用性的加密哈希函数,虽然运算速度很快,但正因为如此,它才不适合用于存储密码——因为攻击者可以利用现代硬件每秒尝试数十亿次SHA-256哈希计算。

MD5也绝不能被用来处理任何敏感信息。这种算法早在多年前就被证明存在安全漏洞,因此在任何涉及安全的场景中都不应被使用。这些结论并非我的个人观点,而是基于密码学研究及实际攻击案例得出的。

3> 应选择哪些非对称加密算法来进行签名和密钥交换?

非对称加密使用一对密钥:公钥可以被任何人看到,而私钥只有持有者自己才知道。用公钥加密的数据只能用私钥来解密;而用私钥生成的签名,则任何拥有公钥的人都可以验证其真实性。

在进行非对称加密操作时,至少应使用RSA-2048算法;在需要高安全性的场景中,建议使用RS256或ES256算法来进行JWT签名。RS256结合了RSA和SHA-256技术,而ES256则采用椭圆曲线数字签名算法(ECDSA)及P-256曲线。在相同的安全性能要求下,EC密钥通常比RSA更受欢迎,因为它们生成的密钥和签名文件体积更小,但却具备相同的加密强度。

4. 加密技术是如何提供给开发者的

最重要的原则就是:不要为每个项目都从头开始实现加密功能。工程师们会将相关的加密工具开发成内部软件包或云函数,这样开发者就可以直接导入并使用这些工具。绝对不应该自己编写底层加密代码,因为这样做可以从源头上避免许多实施错误。

当加密功能的实现是集中式的时,修复某个安全漏洞只需要在一个地方进行修改即可;但如果这些功能分散在各个项目中,那么要修复漏洞就需要先找到所有相关的实现代码,而这往往是不可能的。

数据库安全

在大多数系统中,数据库存储着最敏感的数据,同时也是整个技术架构中配置错误最频繁的组件之一。

SQL注入攻击

SQL注入攻击已经存在了25年,从1998年开始就一直被使用。直到现在,生产环境中仍然会出现这种安全问题,原因在于工程规范并没有得到严格执行。

造成SQL注入攻击的根本原因总是字符串拼接操作。开发者会将用户输入直接插入到SQL查询语句中,而数据库无法区分开发者原本打算执行的查询语句与攻击者篡改后的查询语句。

-- 开发者的原意是这样
SELECT * FROM users WHERE email = 'user@example.com';

-- 攻击者修改后变成了:anything@example.com' OR '1'='1
SELECT * FROM users WHERE email = 'anything@example.com' OR '1'='1';
-- 这样就会查询出数据库中的所有用户记录

解决这个问题的方法是使用参数化查询或对象关系映射框架。查询语句的结构应该是固定的,而输入的数据应该单独传递给数据库。这样,数据库就会将这些数据视为普通数据,而不会将其视为查询语句的一部分。

在持续集成/持续部署流程中,必须对任何包含原始SQL字符串拼接的操作进行检测。这种政策是不能有丝毫容忍度的——任何使用用户输入进行字符串拼接的代码都是不可接受的,绝对不能被允许进入生产环境。

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

应用程序的数据库用户应该只拥有其所需的最小权限,而不应拥有更多的权限。例如,仅用于数据查询的报告接口就应该使用只有SELECT权限的用户账户来连接数据库,而不应该拥有INSERT、UPDATE或DELETE权限;仅用于数据写入的API也不应该具有DROP TABLE权限。

这种设计可以有效地限制SQL注入攻击或凭证泄露所带来的危害范围。如果应用程序只能从特定的表中读取数据,那么即使攻击者获得了数据库访问权限,他们也只能对这些特定表进行操作,而无法删除整个数据库、插入虚假记录或修改账户余额。

这是一个必须针对每个应用程序组件明确制定并在基础设施层面严格执行的数据库配置原则。

所有数据库连接都必须进行加密

数据库连接必须使用TLS协议。未加密的数据库连接会在网络中以明文形式传输查询结果,因此任何位于应用程序与数据库之间的组件只要能够监控网络流量,就能读取所有的查询结果。

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

以下是在实际操作中强制使用TLS协议进行数据库连接的示例:

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,
  ),
);
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无法使用时默默地切换回明文传输模式的连接方式,根本无法提供任何安全保护。

数据库凭据必须保存在安全的存储系统中

数据库凭据必须在运行时从专门的秘密管理工具中获取,而不能被保存在配置文件中、提交到版本控制系统中,或硬编码在连接字符串中。

以下是在实际运行环境中获取数据库凭据的示例:

final dbHost = Platform.Environment['DB_HOST']!;
final dbUser = PlatformEnvironment['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),
);
builder.Configuration.AddAzureKeyVault(
    new Uri("https://your-vault.vault.azure.net/"),
    new DefaultAzureCredential()
);

var connectionString = builderConfiguration["DatabaseConnectionString"];

连接池的管理也必须得当。如果连接池的大小没有得到合理控制,那么它可能会耗尽数据库服务器的所有可用连接资源,从而导致所有使用该数据库服务的用户都无法正常访问数据。因此,必须谨慎地配置连接池的大小:

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

将存储过程作为参数化策略

存储过程是一种具有名称的、预先编译好的SQL代码块,它被保存在数据库中。与直接从应用程序发送完整的SQL字符串不同,你可以通过名称调用存储过程,并分别传递参数。

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

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;
$$;
public async Task 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执行程序Async();
    if (await reader.ReadAsync())
    {
        return new User
        {
            Id = reader.GetGuid(0),
            FirstName = reader.GetString(1),
            LastName = readerGetString(2),
            Email = reader.GetString(3)
        };
    }
    return null;
}

如果攻击者将' OR '1'='1'作为电子邮件值传递给系统,数据库会将其视为普通的字符串参数,而永远不会将其解读为SQL语句。存储过程只会执行在创建时定义好的固定查询脚本。正因为如此,存储程序才是一种真正有效的安全防护机制,而不仅仅是一种提升性能的手段。

CORS:你的API所设定的边界

跨源资源共享是一种浏览器安全机制,它用于控制哪些域名可以从网页上向你的API发送请求。

在实际情况中,最常见的CORS配置错误也是最危险的错误类型:通配符形式的CORS设置。

Access-Control-Allow-Origin: *

这种配置意味着互联网上的任何网页都可以使用该用户的身份凭证向你的API发送请求。如果攻击者创建了一个恶意网页,用户访问了这个页面,那么攻击者的网页就可以以该用户的身份向你的API发起请求。

在开发环境中,通配符形式的CORS设置确实比较方便,但在生产环境中,这却会带来严重的安全风险。

任何正式投入使用的API都必须明确指定允许哪些域名进行跨源请求,而不能使用通配符或像*.yourapp.com这样的模糊配置。必须列出具体的、被允许的域名列表。

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

域名的匹配必须完全准确。如果请求源头的值与允许的域名列表中的任何一项都不匹配,浏览器就不会返回响应数据。

允许使用的方法和允许设置的头部信息也必须明确指定。默认情况下,所有未被明确允许的内容都是不被允许的。

这一配置决策需要在API网关或框架层面进行设定。它会对所有API端点统一适用,而不会由个别开发者针对每个端点进行不同的配置设置,从而避免出现错误配置的情况。

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

在大多数系统中,日志记录往往是被忽视的安全防护环节。开发人员会添加日志记录语句来辅助调试工作,这些日志信息会被传输到日志平台,而该平台会将这些日志保存数月甚至数年。然而,这些日志中往往包含了开发人员在处理故障时认为有用的各种信息。

其结果是,生产环境中的日志常常包含一些本不应被公开的信息。例如,完整的请求和响应内容、包含 bearer token 的授权头部信息、用户ID与敏感操作相关联的数据、账户号码,有时甚至还有明文形式的密码——因为有人在验证流程之前就直接记录了原始的请求内容。

需要强调的是,这些情况并非假设出来的,它们确实存在于现实中的生产环境日志系统中。

为了解决这个问题,可以在日志框架层面实施字段级屏蔽机制。只需在日志配置中明确指定哪些字段属于敏感信息,该框架就会在所有环境中、自动地对这些字段进行屏蔽处理,无论开发人员在日志记录中实际写了什么内容。

那些绝对不能出现在日志中的敏感信息包括:密码、token 和授权头部信息、完整的账户号码和银行卡号、BVN 和 NIN 等身份识别信息、加密密钥及各种机密数据,以及那些处理敏感数据的端点的完整请求内容。

这种屏蔽措施必须是在框架层面进行的,而不是由开发人员手动处理的。当你添加一条日志记录时,根本不需要担心如何屏蔽敏感字段——框架会自动完成这项工作,你只需记录那些对调试有帮助的信息即可。最终生成的日志条目中,敏感字段都会被替换成屏蔽后的值。

以下是字段级屏蔽在实践中的具体应用示例:

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? data}) {
    _logger.info(message, _mask(data));
  }

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

  Map? _mask(Map? data) {
    if (data == null) return null;
    return data.map((key, value) {
      if (_sensitiveKeys.contains(key.toLowerCase())) {
        return MapEntry(key, '***MASKED***');
      }
      if (value is Map) 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...',
});

// 输出结果:
// Processing payment
// { transaction_id: txn_001, amount: 50000,
//   account_number: ***MASKED***, authorization: ***MASKED*** }
public class MaskSensitiveFieldsPolicy : IDestructuringPolicy { private static readonly HashSet 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 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() .WriteTo.Console() .CreateLogger();

这一规定在所有环境中都无一例外地适用,而不仅仅是在生产环境中。开发日志经常会被导出到第三方日志平台,而且往往会有多名团队成员对这些日志进行查询。如果开发人员在测试过程中使用真实账户,并将请求体内容记录下来,那么真实用户数据就会被写入日志平台,而这显然是不应该发生的。

CI/CD安全检查机制

CI/CD流程是代码在进入生产环境之前经过的最后一道自动化检测环节。然而,在大多数工程组织中,这种安全控制措施却往往没有得到充分利用。

虽然大多数CI/CD流程都会执行测试并检查代码格式,但安全检查机制能够发现那些人工代码审查经常会忽略的安全问题。

秘密信息检测

每当有代码被推送到任何分支时,都必须对其进行秘密信息的扫描。需要检查的内容包括API密钥、数据库密码、私钥、包含凭据的连接字符串,以及配置文件中的JWT密钥。

Gitleaks是一款专为这项任务设计的免费开源工具。以下是将其集成到GitHub Actions流程中的方法:

# .github/workflows/security.yml name: 安全检查机制 on: push: branches: ["*"] pull_request: branches: ["*"] jobs: secret-detection: name: 检查秘密信息 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是一款免费的开源静态分析工具,其安全规则库由安全社区共同维护。以下是它在构建流程中的使用方式:

  static-analysis:
    name: 静态分析
    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是一款免费工具,它会对项目的依赖项进行扫描,检查这些依赖项是否与国家漏洞数据库中的已知漏洞相对应,并会标记出存在的安全风险。

  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` 命令结合漏洞数据库检查,也能有效覆盖所有依赖项的安全性检测需求。

针对特定环境的配置验证

在将代码部署到生产环境之前,必须确保生产环境中的配置符合所需的安全标准。这不仅仅意味着要检查配置文件是否存在,更重要的是要确认那些涉及安全性的关键设置是否正确。

以下是这种配置验证在构建流程中的具体实现方式:
  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 "错误:在生产环境中检测到调试端点"
            exit 1
          fi

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

          if grep -rE "(password|secret|key)\s*=\s*['\"][^$\{]" config/ --include="*.yaml" --include="*.json"; then
            echo "错误:配置文件中存在硬编码的密码信息"
            exit 1
          fi

          if grep -r "showStackTrace.*true\|expose_errors.*true" config/ --include="*.yaml"; then
            echo "错误:生产环境配置中启用了堆栈跟踪显示功能"
            exit 1
          fi
这段脚本会在每次进行生产环境部署之前被执行。如果检测到调试端点、通配符相关的 CORS 配置、硬编码的密码信息或堆栈跟踪显示功能,构建流程将会立即停止,直到所有检查都通过为止。

不同环境所需的不同的构建流程

开发环境、测试环境和生产环境需要采用不同的构建流程。这些流程不仅部署目标不同,验证要求也有所差异:生产环境的构建流程比测试环境更严格,而测试环境又比开发环境更为严谨。

生产环境的构建流程会包含所有安全检查环节;而开发环境为了不影响开发人员的效率,可能会跳过一些耗时较长的安全检测步骤。但无论如何,生产环境中的构建流程绝不会忽略任何安全验证环节。 这种分层式的构建方式意味着:代码在逐步接近生产环境的过程中,会依次通过越来越严格的安全部署流程。当最终到达生产环境进行部署时,它已经经历了多个低级别环境中的自动化安全检测。

微服务与内部服务安全

构建微服务会带来一种单体架构所没有的安全挑战:任何服务之间的交互都可能成为攻击目标。

在单体架构中,组件之间的方法调用只是内存中的函数调用,这类请求在传输过程中无法被拦截、伪造或篡改。

而在微服务架构中,每一次服务调用都是一种网络请求,这类请求完全有可能被拦截、伪造或篡改。那些因为“属于同一系统”而盲目信任彼此的服务,其实正在犯一个危险的错误。

默认情况下,每一项微服务都应该将接收到的所有请求视为不可信的,包括来自其他内部服务的请求。任何服务之间的交互都需要进行身份验证。API网关负责处理外部用户的身份验证,而内部服务之间的身份验证则需要另行设计。

针对这两种情况,主要有两种解决方案:对于同步调用,可以使用MTLS进行安全保护;而对于异步通信,则可以通过经过认证的事件流来实现安全性控制。

用于同步服务间调用的MTLS要求两侧服务都必须出示自己的证书。例如,当支付服务调用账户服务时,必须提供一份账户服务能够信任的有效证书。如果证书缺失或无效,连接请求会在任何应用程序代码执行之前就被拒绝。这种机制是一种加密级别的身份验证方式:既没有可供窃取的 bearer token,也没有需要定期更换的API密钥。

支付服务 账户服务 | | |-- 提交自己的证书 ----> | | |-- 通过受信任的CA机构验证证书 | |-- 如果证书无效,则拒绝连接 | <-- 提交自己的证书 --- | |-- 验证证书 | | | |-- 如果身份合法,则授权请求 ----------> | | |-- 接受并处理请求

用于异步通信的经过认证的事件流意味着,任何被发布到消息代理中的消息都会附带一个签名,接收方在处理这些消息之前会先验证这个签名。例如,当某个服务发布PaymentCompleted事件时,它会使用自己的私钥为该事件生成签名;而接收方在处理该事件之前,会先验证这个签名。任何伪造的、没有有效签名的事件都会被直接丢弃。

class PaymentEventPublisher { final KafkaProducer _producer; final EventSigner _signer; Future 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 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('来自${data['publisher']}的签名无效 — 将被丢弃'); return; } final event = PaymentCompletedEvent.fromJson(data['payload']); await _accountService.handlePaymentCompleted(event); } }

任何服务都不得直接通过互联网访问,而必须经过API网关。该网关是所有外部请求的唯一入口点,它负责处理速率限制、身份验证以及路由等功能。位于网关之后的服务属于内部系统:这些服务之间仍然不存在相互信任的关系,但也不会对外公开。

过度设计本身就会带来安全风险。那些本应被设计成结构清晰的单体应用,如果被实现为微服务架构,就会增加运营复杂性,而团队可能并没有能力有效管理这种复杂性。多条部署流程、不同的服务边界、API网关、消息代理以及分布式追踪机制等,都需要专门的工程技术支持才能确保安全运行。相比之下,一个没有得到适当维护的单体应用,其危险性要远远低于一个根本无法正常运行的微服务架构。

在选择技术架构时,应该根据团队实际具备的安全保障能力来决定,而不是追随某种流行趋势。

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

数据隐私相关的要求属于工程范畴,而非法律强制规定的事项;这些要求是由工程师们负责制定和执行的。

根据尼日利亚2023年的《数据保护法》,个人数据的处理必须仅限于明确规定的、合法的用途。关于银行账户号码的数据,其存储和处理方式也受到中央银行的明确规定。而根据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('访问目的无效或未明确说明');
    }

    final user = await _repository.findById(targetUserId);
    if (user == null) throwNotFoundException('用户未找到');

    _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);
    }
  }
}

这个示例服务通过实际代码展示了这五项合规政策的具体实现方式:每次数据访问时都会明确说明访问目的;在返回数据之前会验证数据的存储是否符合规定要求;审计日志会详细记录每一次数据访问的操作细节;响应结果中只包含真正需要的信息;此外,还会通过定时任务自动执行数据删除操作,这样就无需有人专门去记得这些事项了。

对于任何处理个人数据的功能来说,这些要求都必须被纳入其需求规格说明中。不能等到功能开发完成后才去考虑这些问题,而应该在设计阶段就将其充分考虑进去。

结论

安全性并不是可以添加到软件中的某个功能,而是从项目一开始就必须被纳入设计考量之中的属性。

在制定架构决策之前进行威胁建模,意味着你在决定如何构建系统之前,就已经清楚地了解了自己需要保护什么,以及需要防范哪些威胁。 将安全性作为一项业务规则,意味着领域层会像执行其他任何业务规则一样严格执行安全要求——这些要求既不可被绕过,也不可能被遗忘。 将加密标准纳入组织层面的决策流程,意味着没有任何开发人员能够根据自己的喜好或便利性来选择使用哪种加密算法。 数据库安全措施意味着应用程序只能执行其被允许执行的操作;如果攻击者破坏了系统的某个组成部分,他们也无法控制整个系统。 CORS配置确保浏览器的安全机制能够有效维护系统边界,wildcard地址也无法绕过这些安全限制。 通过字段级数据脱敏进行结构化日志记录,可以确保敏感信息永远不会被记录到日志文件中,无论开发人员记录了什么内容。 CI/CD流程中的安全检测机制能够自动发现那些人工审核时可能遗漏的问题,每次代码提交、每次系统部署之前都会进行这些检查。 这些概念并不复杂也不难理解。它们都属于工程学范畴,需要经过深思熟虑后才能制定相应的措施并确保其得到严格执行。如果不采取这些措施,同样会带来后果,而这些后果会在安全事件报告中体现出来。 解决安全漏洞的成本最高的地方是在漏洞被利用之后;其次是在系统投入生产环境之后;而成本最低的时机是在系统设计阶段,也就是在编写任何代码之前。 “安全设计”在实际操作中的含义就是:并非追求绝对的安全或零风险,而是在系统开发之前,就系统如何应对各种威胁情况制定出明确、有文档支持且可强制执行的决策,从而确保整个系统的设计从一开始就是可靠的。 对于技术爱好者来说,最值得期待的时刻就是当应用程序的开发过程真正将安全性作为不可或缺的一部分时。那时,安全性不再是被事后才考虑的因素,也不再是部署系统前必须满足的条件。因此,我们不仅需要在应用安全领域,也需要在核心软件工程领域,将“安全设计”作为一种标准化流程来理解和采纳。 祝大家编写安全、可靠的代码!

相关文章

技术实践

如何向英国税务海关总署的“数字化纳税”API提交季度更新报告

每年有四次,所有参与“税收数字化”计划的个体经营者和房东都必须向英国税务海关总署提交一份收入与支出的汇总报表。 2026-27纳税年度的第一个截止日期是8月7日,这个期限已经过去;第二个截止日期则是11月7日。每份汇总报表都需要通过软件向英国税务海关总署发送一次API请求,而本教程正是专门讲解如何正确完成这些请求操作的。 您无需事先阅读任何其他资料即可跟随本教程进行操作。每次进行“税收数字化”集成时所需的一次性设置信息(包括沙箱应用程序、OAuth 2.0访问令牌以及防欺诈相关配置)已在下方的简要总结中列出,同时每段代码示例中也都会使用到一些辅助函数。 如果您想深入了解这些设置步骤,我曾在 之

阅读全文
技术实践

如何阻止人工智能代理生成尼日利亚的邮政编码

2026年10月1日,尼日利亚的邮政编码从六位数字改为由11个字符组成的代码,这种编码能够唯一标识某栋建筑,例如 EK-01-A03-FK-01 。 在此之前训练的语言模型仍然会自信地使用旧格式来处理邮政编码。这会影响两种类型的程序:一种是负责生成验证代码的编程程序,另一种是在运行时从客户信息中读取邮政编码或填写配送表格的程序。 本文将介绍如何解决这个问题。对于那些生成验证代码的程序,你需要检测它们目前生成的代码是否存在错误,并为它们提供相应的规则来纠正这些错误;而对于在运行时处理邮政编码的程序,则需要为它们设定明确的边界:当模型提出一个邮政编码时,由特定的程序来判断该编码是否符合规范、是否已

阅读全文
技术实践

如何将结构化数据(JSON-LD)添加到Next.js网站中并进行验证

结构化数据对搜索引擎来说一直都非常重要。当搜索引擎读取你的页面时,它看到的是HTML代码:标题、段落、日期以及名称等等。 结构化数据能够直接告诉搜索引擎这些内容的含义。例如,这个页面是一篇文章,这是它的标题,这篇文章是由某个人撰写的,并且发布于某个特定的日期。 对我来说,搜索引擎优化并不是一个需要简单勾选的步骤。我希望自己创建的产品和网站能够被人们找到,并且在被找到之后也能被正确理解。 因此,只要合适,我就会在每一页中添加结构化数据。而且我认为,随着人工智能系统开始根据网页内容来回答问题,结构化数据的重要性只会越来越明显,而不会减弱。 在Next.js应用程序中,没有任何工具会自动为你生成这些

阅读全文
技术实践

使用n8n创建可扩展的人工智能自动化系统

大多数自动化教程都只会教你如何连接两个简单的应用程序,然后就认为教学任务完成了。但实际上,要构建企业能够在生产环境中实际使用的工作流程,就需要掌握完全不同的技能——这些技能涉及后端架构设计、智能决策机制以及高度可靠的系统实现方式。 我们刚刚在freeCodeCamp.org的YouTube频道上发布了一门内容全面的的新课程,这门课程会教你关于n8n和人工智能自动化的相关知识。你将学会如何利用n8n来构建一个由现代人工智能技术支持的全面自动化工作流程管理系统。 以下是这门课程中你会掌握的核心内容: 核心架构与配置 学习n8n的基本使用方法,了解如何操作工作流编辑界面、管理Webhook触发器,并

阅读全文