工业边缘的漏洞管理不能等同于“跑一次扫描工具”。网关、边缘服务器、容器镜像、固件包、Agent、数据库、消息通道和云端控制面都可能暴露在不同风险里;如果只保存扫描报告,不做资产关联、风险排序、修复跟踪和复测验证,漏洞治理很快会退化成一堆无法闭环的 Excel 记录。
本文从工程实战角度梳理一套可落地的闭环:资产清单 -> SBOM -> 多面扫描 -> 风险评估 -> SLA 跟踪 -> 修复执行 -> 验证关闭 -> 指标复盘。代码示例以 Linux 网关和 Kubernetes 环境为主,可直接作为设计模板,再按团队现状裁剪。
一、为什么漏洞管理必须是生命周期
零散扫描的常见问题包括:
有效的漏洞管理不是追求“扫描器数量”,而是让每个发现都有唯一身份、责任人、风险判断、处置状态和验证证据。
二、先建立资产清单和 SBOM
资产清单是漏洞管理的基础。对工业边缘来说,资产至少要覆盖:
| 边缘设备 | 工业网关、边缘服务器 | 序列号、站点、系统版本、在线状态、责任人 |
| 工作负载 | Deployment、DaemonSet、systemd 服务 | 镜像 digest、运行环境、暴露面 |
| 软件组件 | OS 包、语言依赖、固件组件 | PURL、版本、来源、许可证 |
| 网络服务 | MQTT、OPC UA、HTTP、数据库 | 监听地址、认证方式、是否可从公网访问 |
| 云端依赖 | 镜像仓库、对象存储、控制面 API | 账号、区域、责任人、协议版本 |
SBOM 建议使用标准 PURL 标识组件,例如:
pkg:deb/debian/libssl3@3.0.15-1~deb12u1?arch=amd64
pkg:golang/github.com/jackc/pgx/v5@v5.7.5
pkg:pypi/cryptography@44.0.1
pkg:oci/edge-gateway@sha256:8f4c…?repository_url=registry.local/edge
一个简化但可用的数据模型如下:
CREATE TYPE finding_status AS ENUM (
'open', 'triaged', 'in_progress', 'patched',
'risk_accepted', 'false_positive', 'reopened'
);
CREATE TABLE assets (
id BIGSERIAL PRIMARY KEY,
asset_key TEXT NOT NULL UNIQUE,
asset_type TEXT NOT NULL,
environment TEXT NOT NULL,
owner_team TEXT NOT NULL,
business_criticality SMALLINT NOT NULL CHECK (business_criticality BETWEEN 1 AND 5),
exposure TEXT NOT NULL CHECK (exposure IN ('internet', 'site_network', 'private_network', 'isolated')),
platform_version TEXT,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE components (
id BIGSERIAL PRIMARY KEY,
purl TEXT NOT NULL,
component_name TEXT NOT NULL,
version TEXT NOT NULL,
supplier TEXT,
sbom_id UUID NOT NULL,
asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
UNIQUE (asset_id, purl)
);
CREATE TABLE vulnerability_findings (
id BIGSERIAL PRIMARY KEY,
vulnerability_id TEXT NOT NULL, — CVE、GHSA、厂商通告或内部编号
source_name TEXT NOT NULL, — trivy、grype、osv、vendor advisory
component_id BIGINT NOT NULL REFERENCES components(id) ON DELETE CASCADE,
cvss_score NUMERIC(3, 1) CHECK (cvss_score BETWEEN 0 AND 10),
severity TEXT NOT NULL CHECK (severity IN ('none', 'low', 'medium', 'high', 'critical')),
title TEXT,
description TEXT,
status finding_status NOT NULL DEFAULT 'open',
owner_team TEXT,
risk_score NUMERIC(6, 2),
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sla_deadline TIMESTAMPTZ,
UNIQUE (vulnerability_id, source_name, component_id)
);
CREATE INDEX idx_findings_open_sla
ON vulnerability_findings (status, sla_deadline)
WHERE status IN ('open', 'triaged', 'in_progress', 'reopened');
first_seen_at 和 last_seen_at 很重要:前者用于计算处置时长,后者用于识别已经随版本升级自然消失的发现。不要只保存“最新一次扫描结果”,否则会丢掉历史轨迹。
三、扫描面与工具选择
扫描工具没有银弹,建议按风险面组合使用。
1. 镜像与依赖扫描
# 输出 JSON 便于入库和去重
trivy image –scanners vuln,secret –format json –output edge-gateway.json \\
registry.local/edge/edge-gateway@sha256:8f4c...
# Grype 适合与 SBOM 和 CI 流水线集成
grype registry:registry.local/edge/edge-gateway@sha256:8f4c... \\
–output json > grype-edge-gateway.json
Snyk 也可以扫描容器镜像,但需要账号认证、项目配置和配额管理,不适合作为无人管理的匿名任务。
2. 源码与应用扫描
# 生产环境优先使用团队评审过的规则集,而不是长期依赖 –config=auto
semgrep scan –config rules/ –json –output semgrep.json
# Python 安全审计
bandit -r app/ -f json -o bandit.json
# Go 安全审计
gosec -fmt=json -out gosec.json ./...
Semgrep 的 auto 便于快速体验,但在 CI 中建议固化规则来源、版本和豁免清单,避免规则漂移导致流水线结果不可比较。
3. 主机与系统配置
# Lynis 做主机安全基线和加固建议
lynis audit system –quick
# OpenSCAP 按基准评估系统配置
oscap xccdf eval \\
–profile xccdf_org.ssgproject.content_profile_standard \\
–results host-xccdf.xml \\
/usr/share/xml/scap/ssg/content/ssg-debian12-ds.xml
对工业设备,还应覆盖固件扫描、内核与驱动版本、默认账号、开放端口、证书有效期和厂商安全通告。
4. 网络暴露验证
# 从站点外验证暴露面,不要只看本机监听
nmap -sV -Pn –top-ports 1000 gateway.example.com
# 检查 TLS 配置
testssl.sh gateway.example.com:443 –quiet
网络扫描必须提前获得授权、限定时间窗口和目标范围。工业网络中某些老旧协议对异常流量敏感,不能直接把互联网扫描策略搬进产线。
四、扫描任务接入 CI/CD
推荐把扫描放在四个触发点:
| 代码提交 | 源码、依赖声明 | Semgrep、Bandit、Gosec、lock 文件审计 |
| 镜像构建 | 依赖、基础镜像、Secret | Trivy、Grype、SBOM 生成并归档 |
| 部署前 | 运行版本与准入策略 | 校验 digest、签名、严重漏洞和例外单 |
| 运行期 | 新披露漏洞 | Registry/SBOM 定期扫描、主机补扫 |
CI 示例:
name: Security Scan
on:
pull_request:
push:
branches: [main]
jobs:
scan:
runs-on: ubuntu–latest
permissions:
contents: read
steps:
– uses: actions/checkout@v4
– name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y jq
– name: Source scan
run: semgrep scan ––config rules/ ––json ––output semgrep.json
– name: Export module list
run: go list –m all > modules.txt
– name: Build image
run: docker build –t edge–gateway:${{ github.sha }} .
– name: Vulnerability scan
run: |
trivy image \\
–scanners vuln,secret \\
–format json \\
–output trivy.json \\
edge-gateway:${{ github.sha }}
– name: Fail on policy violations
run: |
critical_count=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy.json)
if [ "$critical_count" -gt 0 ]; then
echo "Found $critical_count critical vulnerabilities; check trivy.json for details."
exit 1
fi
上面是最基础的 Critical 阻断门禁。CI 脚本应在合并前真实演练一次,避免安全流水线自己先坏掉。
更合理的阻断策略是:
五、运行期扫描不要只扫当前 Pod
一个常见误区是在 CronJob 里执行 kubectl get pods,把当前运行的镜像全部扫一遍。这个方法看似简单,但会漏掉:
运行期扫描应以 镜像仓库、SBOM 归档和资产清单 为准,Kubernetes 只作为运行时关联信息来源。若确实要在集群内扫描,需要补齐 ServiceAccount、RBAC、报告存储、Registry 凭证、资源限制和超时:
apiVersion: v1
kind: ServiceAccount
metadata:
name: image–inventory–reader
namespace: security
—
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: image–inventory–reader
rules:
– apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
– apiGroups: ["apps"]
resources: ["deployments", "daemonsets", "statefulsets", "replicasets"]
verbs: ["get", "list", "watch"]
—
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: image–inventory–reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: image–inventory–reader
subjects:
– kind: ServiceAccount
name: image–inventory–reader
namespace: security
apiVersion: batch/v1
kind: CronJob
metadata:
name: registry–vulnerability–scan
namespace: security
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: vulnerability–scanner
spec:
serviceAccountName: image–inventory–reader
restartPolicy: OnFailure
containers:
– name: scanner
# 实际环境应替换为内部镜像仓库中的固定 digest。
image: registry.local/security/trivy:0.71.0
command:
– sh
– –c
– |
set -eu
wget -qO /tmp/images.txt "$IMAGE_LIST_URL"
while IFS= read -r image; do
[ -z "$image" ] && continue
report_name=$(printf '%s' "$image" | sha256sum | cut -d' ' -f1)
trivy image \\
–scanners vuln \\
–format json \\
–output "/reports/${report_name}.json" \\
"$image"
done < /tmp/images.txt
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 4Gi
env:
– name: TRIVY_CACHE_DIR
value: /cache
– name: IMAGE_LIST_URL
valueFrom:
secretKeyRef:
name: scanner–config
key: imageListUrl
volumeMounts:
– name: cache
mountPath: /cache
– name: reports
mountPath: /reports
volumes:
– name: cache
emptyDir: {}
– name: reports
emptyDir: {}
这个示例只展示必要结构。生产上还要加入 Secret 管理、镜像摘要、上传对象存储、失败重试、告警和缓存持久化;报告不能只写在 emptyDir 里随 Job 删除。
六、CVSS 只是输入,不是唯一结论
CVSS 严重度区间如下:
| None | 0.0 |
| Low | 0.1 – 3.9 |
| Medium | 4.0 – 6.9 |
| High | 7.0 – 8.9 |
| Critical | 9.0 – 10.0 |
但工业边缘不能机械地执行“Critical 全部 72 小时修完”。同一个 CVE 在不同上下文中的实际风险可能完全不同。评估时至少要加入:
一套可配置的 SLA 策略示例:
risk_policy:
default_timezone: Asia/Shanghai
rules:
– name: critical–internet–facing
when:
severity: critical
exposure: [internet]
exploited: true
sla_hours: 72
action: urgent–ticket
– name: critical–internal
when:
severity: critical
exposure: [site_network, private_network]
sla_hours: 240
action: security–review
– name: high–safety–asset
when:
severity: [high, critical]
business_criticality: [4, 5]
impact: [control_integrity, safety_system]
sla_hours: 120
action: change–window
– name: medium–standard
when:
severity: medium
sla_hours: 2160
action: planned–backlog
SLA 数字应结合行业要求、客户合同、企业安全策略和现场停机窗口制定,并在评审后固化到系统中。自动计算只负责提醒和升级,不替代安全决策。
七、扫描结果入库与去重
扫描结果入库时,不要按“CVE + 组件名”粗暴去重。不同发行版、不同架构、不同镜像层可能同名不同版本,甚至同一个组件会出现在 OS 包和语言依赖中。建议按以下维度建立唯一键:
vulnerability_id + source_name + component_purl + asset_id
简化导入逻辑:
from dataclasses import dataclass
from datetime import UTC, datetime
@dataclass(frozen=True)
class ScanFinding:
vulnerability_id: str
source_name: str
purl: str
severity: str
cvss_score: float
asset_key: str
title: str
async def upsert_findings(db, asset_id: int, component_id: int, findings: list[ScanFinding]):
now = datetime.now(UTC)
for finding in findings:
row = await db.fetchrow(
"""
SELECT id, status FROM vulnerability_findings
WHERE vulnerability_id = $1
AND source_name = $2
AND component_id = $3
""",
finding.vulnerability_id,
finding.source_name,
component_id,
)
if row is None:
await db.execute(
"""
INSERT INTO vulnerability_findings (
vulnerability_id, source_name, component_id,
cvss_score, severity, title,
first_seen_at, last_seen_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
""",
finding.vulnerability_id,
finding.source_name,
component_id,
finding.cvss_score,
finding.severity,
finding.title,
now,
)
continue
await db.execute(
"""
UPDATE vulnerability_findings
SET last_seen_at = $2,
cvss_score = $3,
severity = $4,
title = $5
WHERE id = $1
""",
row["id"],
now,
finding.cvss_score,
finding.severity,
finding.title,
)
# 修复证据失效时重新打开;false_positive 保持人工复核状态,
# 因为扫描器下次仍可能输出同一个上下文相关的误报。
if row["status"] == "patched":
await db.execute(
"""
UPDATE vulnerability_findings
SET status = 'reopened'
WHERE id = $1
""",
row["id"],
)
入库后要处理“消失了的问题”:如果某组件版本不再出现在最新 SBOM,可把相关发现标记为 patched,但必须记录证据,例如新版本号、SBOM ID、镜像 digest 或复测任务 ID。
八、修复工作流与例外治理
修复不是只创建工单。每个高风险发现至少要记录:
| risk_score | 综合风险评分,可由策略引擎计算 |
| owner_team | 责任团队,不建议只写个人 |
| remediation | 升级版本、配置修复、隔离或禁用功能 |
| rollout | 灰度范围、停机窗口、回滚方案 |
| verification | 复测报告、镜像 digest、部署证明 |
| exception | 例外原因、审批人、补偿控制、到期时间 |
一个修复任务的状态流可以设计为:
open
-> triaged 确认影响、风险和责任人
-> in_progress 进入升级、配置或缓解流程
-> patched 修复已部署
-> closed 复测通过并留存证据
可选状态:
risk_accepted 有审批、补偿控制和到期时间
false_positive 有技术依据和复核记录
reopened 旧版本回滚、复测失败或再次发现
特别注意:patched 不等于 closed。关闭应该由验证结果驱动,例如:
对无法立即修复的漏洞,例外单至少包含:
exception:
finding_id: 1024
reason: "供应商驱动暂无补丁,当前版本升级需硬件兼容性验证"
compensating_controls:
– "管理面仅限 VPN 访问"
– "服务启用 mTLS 与强认证"
– "告警规则覆盖异常登录"
owner: edge–platform
approver: security–committee
approved_at: 2026-10-27T09:00:00+08:00
expires_at: 2027-01-27T09:00:00+08:00
review_cycle: monthly
例外必须到期自动重新评估,不允许“一次批准,永久有效”。
九、指标与运营复盘
漏洞管理需要用指标持续验证效果。建议至少看:
示例 PromQL 指标:
# 未关闭发现,按严重度和团队统计
vulnerability_findings{status=~"open|triaged|in_progress|reopened"}
# 扫描覆盖率
sum(scanned_assets) / sum(known_assets)
# SLA 达成率
sum(vulnerability_remediation_closed_before_sla) /
sum(vulnerability_remediation_closed)
# 平均修复时长,单位可由 exporter 转换为小时
avg by (team) (vulnerability_remediation_duration_hours)
指标的目标不是制造排名,而是找到流程堵点:资产认领慢、测试环境缺失、停机窗口难约、供应商补丁滞后、例外审批不清,都是常见根因。
十、常见坑与应对
| 无资产清单 | 每次出事才开始找设备和服务 | 资产自动发现加人工认领,owner 必填 |
| 只扫当前运行镜像 | 回滚版本和仓库历史版本漏扫 | 以 Registry 与 SBOM 为扫描源 |
| CVSS 唯一论 | 高分但不可达的问题淹没真实风险 | 加入暴露面、可利用性和业务影响 |
| 无 SLA | 工单长期停留 | 自动计算截止时间并逾期升级 |
| 无验证 | 补丁合入就关闭 | 复测、digest 与证据留档 |
| 例外失控 | 风险接受永久有效 | 例外带审批、补偿控制和到期时间 |
| 报告孤岛 | 每个工具一套 Excel | 建统一 finding 模型和状态流 |
| 离线节点漏扫 | 设备恢复在线后无补扫任务 | 记录 last_seen_at,支持任务补发和本地缓存 |
十一、实施路线
第 1 阶段:可见
建资产清单、责任团队、环境和暴露面标签;完成主要镜像与主机的首轮扫描。
第 2 阶段:可跟踪
统一 finding 数据模型,接入 Trivy、Grype、Semgrep 等工具,建立去重和状态流。
第 3 阶段:可决策
引入 CVSS 之外的风险上下文,定义团队认可的分诊规则和 SLA。
第 4 阶段:可闭环
打通工单、部署、复测和证据归档,修复后自动验证关闭。
第 5 阶段:可持续
建设覆盖率、SLA、回归率和例外质量看板,按月复盘流程瓶颈。
十二、TL;DR
工业边缘漏洞管理的核心不是“多扫几次”,而是建立闭环:资产清单定义范围,SBOM 绑定组件版本,多工具扫描提供输入,风险上下文决定优先级,SLA 驱动处置,复测和证据决定关闭。
最容易踩的坑是:资产不清、只扫当前 Pod、CVSS 唯一论、无验证关闭、例外永久化。把这几个问题解决掉,漏洞管理才能真正从报告驱动变成风险治理。




