简介:《等保三级评审-网络与信息安全-应用系统安全技术规范》是一份面向信息安全从业者、等保测评人员及企业安全管理人员的技术指导文档,聚焦应用系统在等保三级评审中的安全建设与合规落地。文档围绕应用系统全生命周期展开,涵盖安全需求分析、输入输出数据检查、消息认证、操作系统控制、测试数据保护、源代码访问控制、变更控制与软件包变更控制等关键环节,并给出最小权限、安全隔离、数据完整性与持续监控等原则,帮助读者补齐应用安全这一体系短板。资源包共1个doc文件,约65KB,内容按目的、范围、原则、开发安全管理、维护安全管理及附则等章节组织,目录结构清晰,便于按模块查阅与对照实施。目前已有213人学习,适合需要对照等保三级要求梳理应用安全措施、完善开发与运维安全控制的中高级技术人员参考使用。
1. 等保三级评审里,应用系统安全到底卡在哪一步
很多团队第一次接触等保三级评审,都会把注意力放在机房、边界防火墙、堡垒机上,觉得应用系统安全就是“装个 WAF、开个 HTTPS”。真到测评机构进场,才发现扣分最狠的往往是应用层:身份鉴别只做了单因素、访问控制没做最小权限、安全审计日志缺关键字段、数据完整性校验形同虚设。等保三级评审-网络与信息安全-应用系统安全技术规范这套要求,本质上是把应用系统从“能跑”拉到“可审计、可追溯、可控制”的工程标准。它适合正在准备三级测评的研发、运维和安全负责人,也适合想把安全能力沉淀进研发流程的团队。这一章先把应用系统安全在三级评审里的位置讲清楚,后面几章再落到具体怎么做、参数怎么设、坑在哪。
等保三级对应用系统的要求,核心围绕身份鉴别、访问控制、安全审计、数据完整性与保密性、剩余信息保护、入侵防范、可信验证这几块展开。它不要求你把系统做成银行核心,但要求每一项都有可验证的证据链。测评老师不会听你说“我们做了”,他要看配置、看日志、看代码、看流程记录。所以应用系统安全技术规范的落地,本质上是把安全动作变成可复现、可举证、可维护的工程动作,而不是一次性应付检查。
我见过太多团队在评审前两周才开始补日志、补权限矩阵,结果越补越乱。真正省力的做法,是在需求阶段就把等保三级的应用安全要求拆成研发任务,让身份、权限、审计、加密这些能力变成系统的基础设施,而不是外挂。接下来从身份鉴别与访问控制开始,把每一步拆到能直接抄作业的程度。
2. 身份鉴别与访问控制:从单因素到双因素的最小改造路径
2.1 为什么等保三级盯着身份鉴别不放
等保三级要求对登录用户进行身份标识和鉴别,且鉴别信息具有复杂度要求并定期更换;当进行远程管理时,要采用两种或两种以上组合的鉴别技术。很多系统只做了“用户名+密码”,这在三级评审里属于典型扣分项。原因不复杂:密码是唯一凭证,一旦泄露,攻击者就拥有合法身份,后续所有访问控制都形同虚设。
常见做法是引入双因素认证,但不必一上来就上硬件令牌。对大多数业务系统,短信验证码+密码、TOTP动态口令+密码、数字证书+密码,都能满足“两种或以上组合”的要求。选型时看三个维度:用户群体是否接受、运维成本是否可控、是否支持批量重置。面向内部员工的系统,TOTP 成本最低;面向外部用户的系统,短信验证码体验最好但要注意短信轰炸防护。
这里有个容易被忽略的点:鉴别失败处理。等保三级要求登录失败时采取结束会话、限制非法登录次数、当网络登录连接超时自动退出等措施。很多系统只做了“密码错误提示”,没做失败计数和锁定,测评时会被单独拎出来。
2.2 用 TOTP 给现有登录接口加第二因素
下面这段 Python 代码演示如何在现有用户名密码校验通过后,追加 TOTP 校验。依赖pyotp库,密钥在用户首次绑定双因素时生成并加密存储。
import pyotp import time import redis # 假设 r 是已连接的 redis 客户端,用于记录失败次数 r = redis.Redis(host='127.0.0.1', port=6379, db=0) def verify_login(username, password, totp_code): # 第一步:校验用户名密码,这里用伪代码表示 user = query_user(username) if not user or not check_password(user, password): record_failure(username) return {"ok": False, "msg": "用户名或密码错误"} # 第二步:检查是否已被锁定 fail_key = f"login_fail:{username}" if int(r.get(fail_key) or 0) >= 5: return {"ok": False, "msg": "账户已锁定,请15分钟后再试"} # 第三步:校验 TOTP,secret 是用户绑定后加密存储的密钥 secret = decrypt_secret(user.totp_secret) totp = pyotp.TOTP(secret) # valid_window=1 允许前后一个时间窗口,容忍客户端时钟偏差 if not totp.verify(totp_code, valid_window=1): record_failure(username) return {"ok": False, "msg": "动态口令错误"} # 第四步:登录成功,清除失败计数 r.delete(fail_key) return {"ok": True, "msg": "登录成功"} def record_failure(username): fail_key = f"login_fail:{username}" # 每次失败计数加一,并设置15分钟过期 r.incr(fail_key) r.expire(fail_key, 900)这段代码的关键参数有三个。valid_window=1表示允许当前时间窗口前后各一个窗口,通常一个窗口 30 秒,这样能容忍手机和服务器之间几十秒的时钟偏差,避免用户频繁遇到“口令错误”。失败计数阈值设为 5 次,锁定时间 900 秒,这两个值可以根据业务敏感度调整,但等保测评时最好有明确记录说明为什么这么设。record_failure里用 Redis 的incr加expire,保证计数不会无限累积,也避免内存泄漏。
提示:TOTP 密钥的存储必须加密,且加密密钥不能和数据库放在同一台机器上。测评时会检查密钥管理方式,明文存库直接判不符合。
2.3 访问控制:把“最小权限”落到接口和菜单两级
等保三级要求对登录用户分配账户和权限,并遵循最小权限原则;要求由授权主体配置访问控制策略,且策略具有时效性。翻译成工程语言就是:每个接口都要有权限校验,每个菜单都要按角色可见,权限变更要有记录。
很多系统只在菜单层面做了角色控制,接口层却裸奔。攻击者绕过前端直接调接口,照样能拿到数据。正确做法是在网关或框架层做统一的接口鉴权,把“谁能访问哪个资源”写成策略,而不是散落在各个业务代码里。
下面是一个基于角色的接口鉴权伪代码,用装饰器方式挂在需要保护的接口上。
from functools import wraps # 权限策略表:角色 -> 允许访问的资源集合 ROLE_POLICY = { "admin": {"user:read", "user:write", "order:read", "order:write"}, "operator": {"order:read", "order:write"}, "viewer": {"order:read"}, } def require_permission(resource): def decorator(func): @wraps(func) def wrapper(request, *args, **kwargs): role = request.user.role allowed = ROLE_POLICY.get(role, set()) if resource not in allowed: # 记录越权尝试,便于安全审计 audit_log(request.user.id, resource, "denied") return {"code": 403, "msg": "无权限访问"} return func(request, *args, **kwargs) return wrapper return decorator # 使用示例 @require_permission("order:write") def update_order(request, order_id): # 业务逻辑 return {"code": 0, "msg": "更新成功"}这段代码的核心是把权限策略集中到ROLE_POLICY一张表里,而不是散落在每个接口内部。这样做的好处是测评时可以直接导出这张表作为访问控制策略文档,也方便做权限变更审计。audit_log在拒绝访问时记录一条日志,这条日志在安全审计章节会用到。参数上,资源命名建议用“模块:操作”的格式,保持全局唯一,避免不同模块之间命名冲突。
注意:角色和权限的映射关系变更时,必须同步记录变更人、变更时间、变更内容。等保三级对访问控制策略的时效性有要求,没有变更记录会被扣分。
3. 安全审计与数据保护:日志字段、加密存储和剩余信息清理
3.1 安全审计日志必须包含哪些字段
等保三级要求对重要的用户行为和重要安全事件进行审计,审计记录包括事件的日期和时间、用户、事件类型、事件是否成功及其他与审计相关的信息;要求对审计记录进行保护,定期备份,避免受到未预期的删除、修改或覆盖。
很多系统的日志只记了“谁在什么时候调了什么接口”,缺少事件结果和关键业务字段。测评时会抽查日志,看能否还原一次完整的敏感操作。我一般建议审计日志至少包含这些字段:事件时间(精确到毫秒)、用户标识、来源 IP、事件类型、操作对象、操作结果、失败原因。对于涉及数据变更的操作,还要记录变更前后的关键字段值。
下面是一段审计日志写入的示例,用结构化 JSON 存储,方便后续检索和备份。
import json import time import logging audit_logger = logging.getLogger("audit") def write_audit(user_id, source_ip, event_type, target, result, detail=None): record = { "ts": int(time.time() * 1000), # 毫秒级时间戳 "user": user_id, "ip": source_ip, "event": event_type, # 如 login、update_order、delete_user "target": target, # 操作对象标识 "result": result, # success 或 fail "detail": detail or {}, # 变更前后值等补充信息 } # 审计日志单独输出到独立文件,与业务日志分离 audit_logger.info(json.dumps(record, ensure_ascii=False)) # 调用示例:记录一次订单金额变更 write_audit( user_id="u10086", source_ip="10.2.3.4", event_type="update_order", target="order:20240501", result="success", detail={"amount_before": 100, "amount_after": 200} )这段代码的关键设计是审计日志与业务日志分离输出。业务日志可能因为磁盘满被轮转删除,审计日志需要单独配置保留策略。detail字段记录变更前后值,这在测评时非常有用,能直接证明系统对重要操作有完整记录。时间戳用毫秒级,避免同一秒内多次操作无法区分顺序。
提示:审计日志的保留期一般要求不少于 6 个月,具体以测评要求为准。备份策略要能证明日志未被篡改,常见做法是定期计算哈希并异地存储。
3.2 数据保密性:哪些字段必须加密,怎么选加密方式
等保三级要求对重要数据在传输和存储过程中进行保密性保护。传输层用 TLS 已经是标配,存储层则容易被忽略。哪些字段算“重要数据”?一般包括身份证号、手机号、银行卡号、密码、密钥、生物特征信息。这些字段落库时必须加密或做不可逆哈希。
密码用 bcrypt 或 argon2 做不可逆哈希,不要用 MD5 或 SHA1。敏感业务字段用 AES-256-GCM 做对称加密,密钥通过独立的密钥管理服务管理,不要硬编码在代码或配置文件里。下面是一个 AES-GCM 加解密的示例。
from cryptography.hazmat.primitives.ciphers.aead import AESGCM import os import base64 # 密钥从环境变量或密钥管理服务获取,长度 32 字节 KEY = base64.b64decode(os.environ["DATA_ENCRYPT_KEY"]) aesgcm = AESGCM(KEY) def encrypt_field(plaintext: str) -> str: # 每次加密生成随机 nonce,长度 12 字节 nonce = os.urandom(12) ct = aesgcm.encrypt(nonce, plaintext.encode(), None) # 将 nonce 和密文一起存储,base64 编码便于入库 return base64.b64encode(nonce + ct).decode() def decrypt_field(ciphertext: str) -> str: raw = base64.b64decode(ciphertext) nonce, ct = raw[:12], raw[12:] return aesgcm.decrypt(nonce, ct, None).decode()AES-GCM 自带完整性校验,解密时如果密文被篡改会直接抛异常,这正好满足等保三级对数据完整性的要求。nonce 必须每次加密都重新生成,绝对不能复用,否则会严重削弱安全性。密钥长度固定 32 字节,对应 AES-256。密钥轮换时,需要保留旧密钥用于解密历史数据,新数据用新密钥加密,这个轮换逻辑要在密钥管理服务里实现。
3.3 剩余信息保护:删除数据不是一条 DELETE 就完事
等保三级要求保证鉴别信息、敏感数据所在的存储空间被释放或重新分配前得到完全清除。很多团队以为执行了DELETE FROM table就清除了数据,实际上数据库文件里可能还残留着旧数据页。测评时如果被问到剩余信息保护,只回答“我们删了”是不够的。
常见做法是:对敏感字段,删除时用随机值覆盖后再删除;对整库或整表,使用数据库提供的安全删除机制;对文件系统中的敏感文件,删除前先覆写。下面是一个覆盖删除的示例逻辑。
def secure_delete_user_data(user_id): # 第一步:用随机值覆盖敏感字段 random_phone = "1" + "".join([str(random.randint(0, 9)) for _ in range(10)]) db.execute( "UPDATE user SET phone = %s, id_card = %s WHERE id = %s", (random_phone, "0" * 18, user_id) ) # 第二步:再执行删除 db.execute("DELETE FROM user WHERE id = %s", (user_id,)) # 第三步:记录安全删除审计日志 write_audit(user_id, "system", "secure_delete", f"user:{user_id}", "success")这个逻辑的关键是先覆盖再删除,让旧数据页里不再包含真实敏感信息。对于数据库层面,如果支持VACUUM或类似机制,删除后执行一次也能帮助回收空间。文件系统上的敏感文件,删除前用随机数据覆写原文件内容,再删除文件。
注意:覆盖删除会增加一次写操作,对性能有轻微影响。建议只对敏感字段和敏感文件做,不要全表全库铺开。
4. 入侵防范与可信验证:应用层能做的几件事
4.1 输入校验与注入防护的工程化做法
等保三级要求应用系统具备入侵防范能力,能发现可能存在的已知漏洞并在发现后及时修补。应用层最常见的入侵入口是输入校验不严导致的 SQL 注入、命令注入、XSS。测评时通常会尝试几个经典 payload,看系统是否拦截。
工程化做法不是靠开发者自觉,而是在框架层做统一校验。所有入参经过白名单校验,数据库访问全部走参数化查询,输出到前端的内容做转义。下面是一个参数化查询和输入白名单校验的示例。
import re # 白名单:订单号只允许数字和字母,长度 6 到 20 ORDER_ID_PATTERN = re.compile(r"^[A-Za-z0-9]{6,20}$") def get_order(order_id): # 第一步:白名单校验,不匹配直接拒绝 if not ORDER_ID_PATTERN.match(order_id): write_audit("system", "unknown", "invalid_input", f"order:{order_id}", "fail") return {"code": 400, "msg": "参数格式错误"} # 第二步:参数化查询,避免 SQL 注入 cursor.execute("SELECT * FROM orders WHERE order_id = %s", (order_id,)) return cursor.fetchone()白名单校验比黑名单可靠得多,因为攻击 payload 的变种太多,黑名单永远列不全。参数化查询是防 SQL 注入的根本手段,不要用字符串拼接。write_audit在输入校验失败时记录日志,这既是入侵防范,也是安全审计的一部分。
4.2 可信验证:启动时校验应用完整性
等保三级要求对应用程序的关键执行环节进行可信验证。落地到应用系统,常见做法是在应用启动时校验自身关键文件的哈希值,如果被篡改则拒绝启动或告警。这个机制能防止攻击者替换应用文件后植入后门。
下面是一个启动时校验文件哈希的示例。
import hashlib import json # 预先生成的基线哈希清单,存储在只读位置 BASELINE_FILE = "/etc/app/baseline.json" def load_baseline(): with open(BASELINE_FILE, "r") as f: return json.load(f) def verify_file(path, expected_hash): sha256 = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) actual = sha256.hexdigest() return actual == expected_hash def startup_check(): baseline = load_baseline() for path, expected in baseline.items(): if not verify_file(path, expected): # 校验失败,记录并拒绝启动 write_audit("system", "localhost", "integrity_check", path, "fail") raise RuntimeError(f"文件完整性校验失败: {path}") write_audit("system", "localhost", "integrity_check", "all", "success")基线哈希清单要放在应用无法写入的位置,否则攻击者篡改文件后顺手改基线就失去意义。校验范围一般包括可执行文件、配置文件、关键依赖库。启动校验会增加几秒启动时间,但换来的是对篡改的及时发现。
提示:基线哈希在每次正常发布后更新,更新动作要有审批记录。测评时会检查基线更新流程是否受控。
5. 等保三级应用安全落地避坑:五条血泪经验
5.1 日志里记了密码,测评直接判不符合
现象:安全审计日志里能看到用户登录时提交的密码明文或 Base64。原因:日志切面把整个请求体打印出来,没有做脱敏。解决:在日志输出前对敏感字段做统一脱敏,密码、身份证号、手机号、银行卡号一律替换为掩码。脱敏逻辑要放在日志框架层,不要靠每个开发者手动处理。
5.2 双因素只做了前端,后端接口可以绕过
现象:前端登录页要求输入动态口令,但直接调后端登录接口只传用户名密码也能成功。原因:双因素校验写在了前端或网关的某个分支里,后端主登录逻辑没有强制校验。解决:把双因素校验下沉到身份认证服务的核心流程,所有登录入口共用同一套校验逻辑,前端只是展示层。
5.3 权限变更没有留痕,测评时说不清谁改的
现象:测评老师问“这个用户的管理员权限是谁在什么时候授予的”,团队答不上来。原因:权限变更直接改数据库,没有审计日志。解决:所有权限变更操作走统一的管理接口,接口内记录操作人、时间、变更前后权限。数据库层面禁止直接改权限表。
5.4 加密密钥硬编码在配置文件里
现象:代码仓库里能搜到 AES 密钥,或者密钥写在application.yml里跟着代码一起发布。原因:图省事,没有引入密钥管理。解决:密钥放到独立的密钥管理服务或环境变量中,配置文件里只放密钥的引用标识。代码仓库做密钥扫描,发现硬编码立即阻断合并。
5.5 审计日志和业务日志混在一起,被轮转删除
现象:出安全事件后想查三个月前的操作记录,发现日志已经被业务日志的轮转策略删掉了。原因:审计日志和业务日志用同一个 logger,共用同一套保留策略。解决:审计日志单独配置 logger、单独输出文件、单独设置保留期和备份策略。保留期至少覆盖等保要求的期限,备份要异地。
6. 把等保要求变成研发流程的一部分:一个可复用的检查脚本
等保三级评审不是一次性的考试,而是对研发流程的持续要求。我自己的习惯是写一个轻量检查脚本,在每次发布前跑一遍,把应用安全的关键项自动化验证掉。这样测评前不用临时抱佛脚,平时就把证据链攒好了。
下面这个脚本检查四件事:配置文件里有没有硬编码密钥、审计日志是否独立输出、双因素校验是否在核心登录路径、敏感字段是否加密存储。脚本用 Python 写成,可以挂在 CI 流程里。
import os import re import sys def check_hardcoded_secret(config_dir): # 检查配置文件中是否出现疑似密钥的明文 pattern = re.compile(r"(secret|key|password)\s*[:=]\s*['\"][A-Za-z0-9+/=]{16,}['\"]", re.I) for root, _, files in os.walk(config_dir): for name in files: path = os.path.join(root, name) with open(path, "r", errors="ignore") as f: for lineno, line in enumerate(f, 1): if pattern.search(line): print(f"[FAIL] 疑似硬编码密钥: {path}:{lineno}") return False return True def check_audit_logger_separate(source_dir): # 检查审计日志是否使用独立 logger for root, _, files in os.walk(source_dir): for name in files: if not name.endswith(".py"): continue path = os.path.join(root, name) with open(path, "r", errors="ignore") as f: content = f.read() if "audit_logger" in content and "getLogger(\"audit\")" not in content: print(f"[WARN] 审计日志可能未独立配置: {path}") return True def check_totp_in_login(source_dir): # 检查登录核心路径是否包含 TOTP 校验 login_file = os.path.join(source_dir, "auth", "login.py") if not os.path.exists(login_file): print("[WARN] 未找到登录核心文件,请手动确认双因素校验位置") return True with open(login_file, "r", errors="ignore") as f: content = f.read() if "totp" not in content.lower(): print("[FAIL] 登录核心路径未发现 TOTP 校验") return False return True def main(): ok = True ok &= check_hardcoded_secret("./config") ok &= check_audit_logger_separate("./src") ok &= check_totp_in_login("./src") if not ok: sys.exit(1) print("[OK] 应用安全检查通过") if __name__ == "__main__": main()这个脚本的检查逻辑不复杂,但能挡住最常见的几类问题。check_hardcoded_secret用正则匹配配置文件中疑似密钥的赋值,命中就失败。check_audit_logger_separate检查代码里是否用了独立的 audit logger,避免审计日志和业务日志混用。check_totp_in_login确认双因素校验在登录核心路径里,而不是只在前端。参数上,配置目录和源码目录按项目实际结构调整,正则表达式可以根据团队命名习惯微调。
提示:这个脚本只能做静态检查,不能替代人工评审。它的价值在于把重复性的检查自动化,让团队把精力放在真正需要判断的地方。
我自己的习惯是每次发布前跑一遍,失败就阻断合并。坚持半年后,等保测评前的准备工作从两周缩短到两天,因为大部分证据平时就已经在系统里了。安全这件事,临时补永远比平时做贵得多。希望帮到你。
本文还有配套的精品资源,点击获取