一、引入问题:还原阶段为什么要做漏洞扫描

做项目的人都知道,一个.NET项目动辄依赖几十个甚至上百个NuGet包。这些包从NuGet源拉下来,里面有没有已知漏洞,很多开发者压根没注意过。等到项目部署到生产环境,才被发现某个第三方包有个CVE编号的严重漏洞,那时候问题就已经坐大了。

很多人说"等出了安全事件再修也不迟",这个想法很危险。真实情况是,有些漏洞已经被公开利用甚至写入了攻击工具,你项目上线的那一刻,可能已经有扫描器在盯着你了。与其事后救火,不如在依赖包刚被拉取、项目刚被还原的时候就把漏洞信息暴露出来,发现高危风险立即阻断或者告警,从源头把问题掐掉。

这件事的核心思路其实很简单:把安全扫描这一步嵌入到dotnet restore这个大家每天都在跑的流程里,让它变成一个"顺便就做了"的动作,而不是一个需要额外记忆的步骤。

二、整体架构与方案设计

在动手之前,得先理清整个方案长什么样。理想状态下的流程是这样的:开发者执行还原命令,还原过程在拉取NuGet包的同时自动触发漏洞审计,审计结果根据严重等级进行分级处理——高危漏洞直接报错阻断还原,中危漏洞输出警告但允许通过,低危漏洞仅记录日志。如果需要,还能把告警信息推送到团队的协作工具里。

这个流程涉及三个核心环节:

第一个环节是漏洞数据源。 你需要一个可信的漏洞数据库,里面记录着哪些NuGet包的哪些版本存在什么漏洞、严重程度如何、有没有可用的修复版本。微软官方维护了NuGet漏洞数据库(NuGet Vulnerability Database),这也是我们后面会重点使用的数据源。

第二个环节是扫描执行引擎。 它负责读取项目的依赖信息,跟漏洞数据库做比对,判断当前使用的包版本是否落在已知漏洞的受影响范围内。这个引擎可以基于NuGet审计SDK实现,也可以自己写MSBuild任务。

第三个环节是结果处理与告警。 拿到扫描结果之后,根据预设的策略决定是阻断构建、输出警告、还是静默记录,同时把关键信息推送出去,让相关负责人及时看到。

三、方案一:利用.NET 8内置的NuGet Audit功能

3.1 开启内置审计

如果你用的SDK版本是.NET 8或者更新,那恭喜你,不用自己造轮子,NuGet包管理本身就内置了审计功能。只需要在项目的.csproj文件里加一个开关就行了。

技术栈:.NET 8 / C# / NuGet

<!-- 项目名称: VulnerabilityAuditDemo.csproj -->
<!-- 技术栈: .NET 8 SDK -->
<!-- 说明: 开启NuGet内置漏洞审计功能的最小配置 -->

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- 目标框架,.NET 8及以上才支持Audit特性 -->
    <TargetFramework>net8.0</TargetFramework>

    <!-- 开启NuGet包审计功能,还原时自动检查已知漏洞 -->
    <NuGetAudit>true</NuGetAudit>

    <!-- 指定漏洞数据库地址,默认使用微软官方NuGet漏洞库 -->
    <!-- 也可以换成企业内部维护的漏洞数据源 -->
    <NuGetAuditSource>https://api.nuget.org/v3-vulnerabilities-report</NuGetAuditSource>
  </PropertyGroup>

  <!-- 这里故意引入了几个存在已知漏洞的包,用于演示审计效果 -->
  <!-- Newtonsoft.Json 11.0.1 存在CVE-2019-0401等漏洞 -->
  <PackageReference Include="Newtonsoft.Json" Version="11.0.1" />

  <!-- 这个包版本很低,存在反序列化漏洞 -->
  <PackageReference Include="YamlDotNet" Version="5.1.2" />

  <!-- 一个安全的包版本,用于对比 -->
  <PackageReference Include="Serilog" Version="3.1.1" />

</Project>

上面的配置加上保存之后,你只需要正常执行还原命令:

# 执行标准还原,审计功能会自动跟着一起跑
# 不需要加任何额外的参数或开关
dotnet restore;

还原完成之后,你打开obj/project.assets.json或者直接在终端里看输出,就能看到类似下面的审计信息:

  Successfully created package.
  NU1903: 包 'Newtonsoft.Json' (版本 11.0.1) 包含已知漏洞
    严重性: 高
    CVE编号: CVE-2019-0401
    说明: Newtonsoft.Json <= 11.0.1 存在反序列化漏洞
    修复版本: >= 12.0.3
  NU1903: 包 'YamlDotNet' (版本 5.1.2) 包含已知漏洞
    严重性: 严重
    CVE编号: CVE-2019-15061
    说明: YamlDotNet <= 5.1.2 存在任意对象反序列化漏洞
    修复版本: >= 6.0.0

3.2 配置审计策略

内置审计默认对所有严重等级的漏洞都会报告。但有些团队希望只关心高危以上的,中低危的可以先放行,这个可以通过设置阈值来实现:

<!-- 项目名称: VulnerabilityAuditDemo.csproj -->
<!-- 技术栈: .NET 8 SDK -->
<!-- 说明: 配置审计阈值,只阻断中危及以上的漏洞 -->

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>

    <!-- 开启审计 -->
    <NuGetAudit>true</NuGetAudit>

    <!-- 审计阈值: 设置为 Moderate 表示中危及以上才会输出警告或错误 -->
    <!-- 可选值: Low, Moderate, High, Critical -->
    <!-- 如果设置为 Critical,就只有严重漏洞才告警 -->
    <NuGetAuditLevel>Low</NuGetAuditLevel>
  </PropertyGroup>

</Project>

还可以在整个解决方案级别或者全局的.props文件里统一配置,这样所有项目都能继承这个策略:

<!-- 文件名: Directory.Build.props -->
<!-- 技术栈: .NET 8 SDK -->
<!-- 说明: 放在解决方案根目录,所有子项目自动继承此配置 -->
<!-- 适合团队统一安全基线 -->

<Project>

  <PropertyGroup>
    <!-- 整个团队统一开启NuGet审计 -->
    <NuGetAudit>true</NuGetAudit>

    <!-- 统一阈值设为 Low,也就是所有等级都报 -->
    <!-- 如果想更严格,可以设成 Low -->
    <!-- 如果想宽松一点,设成 Moderate 或 High -->
    <NuGetAuditLevel>Low</NuGetAuditLevel>

    <!-- 可选:将审计结果以警告还是错误的方式输出 -->
    <!-- true 表示高危漏洞会直接导致构建失败 -->
    <!-- false 表示所有漏洞只输出警告,不阻断构建 -->
    <NuGetAuditVulnerabilitySeverity>error</NuGetAuditVulnerabilitySeverity>
  </PropertyGroup>

</Project>

3.3 查看审计结果的多种方式

内置审计的结果可以通过多种途径查看。最直接的就是上面说的终端输出,但如果团队规模大、项目多,你可能需要更结构化的方式来处理这些审计信息。

第一种方式是生成SBOM(软件物料清单),里面包含了所有依赖的完整信息和对应的漏洞状态:

# 还原并生成SBOM文件,格式支持spdx和cyclonedx
# SBOM文件会输出到当前目录下的 sbom.json 中
dotnet restore /p:GenerateSBOM=true /p:SBOMFormat=spdx;

生成的SBOM文件长这样:

{
  "spdxVersion": "SPDX-2.3",
  "documentNamespace": "https://example.com/software-license-scope",
  "name": "VulnerabilityAuditDemo",
  "packages": [
    {
      "name": "Newtonsoft.Json",
      "versionInfo": "11.0.1",
      "downloadLocation": "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/11.0.1/newtonsoft.json.11.0.1.nupkg",
      "checksums": [
        {
          "algorithm": "SHA512",
          "checksumValue": "abcd1234..."
        }
      ],
      "externalRefs": [
        {
          "referenceType": "SECURITY",
          "referenceLocator": "CVE-2019-0401",
          "comment": "Deserialization vulnerability, fixed in 12.0.3"
        }
      ]
    }
  ]
}

第二种方式是在CI环境里把审计结果导出成JSON格式,方便对接其他安全平台。

四、方案二:自定义MSBuild Target深度集成

内置审计虽然省事,但有些场景需要更精细的控制。比如你想在还原之后自动从NVD数据库拉取最新漏洞信息、想根据自己团队的规则自定义告警逻辑、或者想把扫描结果写入到项目专属的安全报告文件里。这些就需要自己写MSBuild Target了。

4.1 创建自定义构建目标

MSBuild Target是一个可以在构建流程的任意阶段插入自定义逻辑的扩展机制。我们可以在ResolvePackageAssets这个目标之后插入自己的检查逻辑,这样依赖信息就已经解析完毕了,可以直接拿到所有包的名称和版本。

<!-- 文件名: VulnerabilityCheck.targets -->
<!-- 技术栈: .NET 8 SDK / MSBuild -->
<!-- 说明: 自定义MSBuild Target,在还原完成后执行漏洞检查 -->
<!-- 用法: 在项目csproj中引用此文件即可 -->

<Project>

  <!-- 在 ResolvePackageAssets 之后执行 -->
  <!-- ResolvePackageAssets 是NuGet还原流程的核心步骤 -->
  <Target Name="CheckNuGetVulnerabilities"
          AfterTargets="ResolvePackageAssets"
          Condition="'$(NuGetAudit)' == 'true'">

    <!-- 步骤1: 收集所有已还原的包信息 -->
    <ItemGroup>
      <!-- 从 RestoreAdditionalProjectColumns 中提取包名和版本 -->
      <!-- 也可以用 ReadLinesFromText 读取 assets.json -->
      <VulnerablePackageToCheck
          Include="@(_ResolvedPackageAssets)"
          Condition="'%(Identity)' != ''" />
    </ItemGroup>

    <!-- 步骤2: 执行漏洞检查脚本 -->
    <!-- 这里调用一个C#脚本或者外部工具来跟漏洞数据库比对 -->
    <Exec Command="dotnet run --project ./tools/SecurityScanner/ -- &quot;@(_ResolvedPackageAssets)&quot;"
          WorkingDirectory="$(MSBuildProjectDirectory)"
          ContinueOnError="false"
          ConsoleLoggerParameters="ErrorsOnly"
          Condition="'$(RunSecurityScan)' == 'true'" />

  </Target>

</Project>

4.2 编写漏洞检查逻辑

上面的MSBuild Target调用了另一个.NET项目来执行实际的漏洞检查。我们来看看这个检查程序怎么写。它读取项目的assets.json文件,从里面提取所有包的名称和版本,然后跟一个本地或远程的漏洞数据源做比对。

技术栈:.NET 8 / C#

// 文件名: SecurityScanner/Program.cs
// 技术栈: .NET 8 / C#
// 说明: 漏洞检查核心逻辑,读取依赖信息并与漏洞数据库比对

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace SecurityScanner
{
    /// <summary>
    /// 表示一个NuGet包及其版本信息
    /// </summary>
    public class PackageInfo
    {
        [JsonPropertyName("name")]
        public string Name { get; set; } = string.Empty;

        [JsonPropertyName("version")]
        public string Version { get; set; } = string.Empty;
    }

    /// <summary>
    /// 表示一条漏洞记录
    /// </summary>
    public class VulnerabilityRecord
    {
        [JsonPropertyName("cve")]
        public string CveId { get; set; } = string.Empty;

        [JsonPropertyName("package")]
        public string PackageName { get; set; } = string.Empty;

        /// <summary>
        /// 受影响的版本范围,使用NuGet版本范围语法
        /// 例如 "[1.0.0, 5.0.0)" 表示 1.0.0 到 4.9.9.x
        /// </summary>
        [JsonPropertyName("affectedRange")]
        public string AffectedRange { get; set; } = string.Empty;

        /// <summary>
        /// 严重等级: Critical / High / Moderate / Low
        /// </summary>
        [JsonPropertyName("severity")]
        public string Severity { get; set; } = string.Empty;

        [JsonPropertyName("fixedVersion")]
        public string FixedVersion { get; set; } = string.Empty;

        [JsonPropertyName("description")]
        public string Description { get; set; } = string.Empty;
    }

    class Program
    {
        // 漏洞检查的核心入口
        static int Main(string[] args)
        {
            // 从命令行参数获取项目路径
            var projectDir = args.Length > 0 ? args[0] : Directory.GetCurrentDirectory();

            // 读取 assets.json 获取依赖信息
            var packages = LoadPackageAssets(projectDir);

            // 加载漏洞数据库(可以是本地JSON文件,也可以是远程API)
            var vulnerabilities = LoadVulnerabilityDatabase();

            // 执行比对,找出有漏洞的包
            var results = CheckForVulnerabilities(packages, vulnerabilities);

            // 输出结果并决定退出码
            return OutputAndDecide(results);
        }

        /// <summary>
        /// 从 assets.json 文件中解析出所有引用的包及版本
        /// assets.json 是 dotnet restore 生成的,包含完整的依赖树信息
        /// </summary>
        static List<PackageInfo> LoadPackageAssets(string projectDir)
        {
            var assetsPath = Path.Combine(projectDir, "obj", "project.assets.json");

            if (!File.Exists(assetsPath))
            {
                Console.WriteLine("[SecurityScanner] 未找到 project.assets.json,跳过扫描");
                return new List<PackageInfo>();
            }

            var json = File.ReadAllText(assetsPath);
            // assets.json 的结构比较嵌套,这里提取顶层库信息
            var packages = new List<PackageInfo>();
            var document = JsonSerializer.Deserialize<JsonDocument>(json);

            // 提取 libraries 节点下的所有包
            if (document.RootElement.TryGetProperty("libraries", out var libraries))
            {
                foreach (var lib in libraries.EnumerateObject())
                {
                    // 库键格式通常是 "PackageId/version"
                    var parts = lib.Name.Split('/');
                    if (parts.Length == 2)
                    {
                        packages.Add(new PackageInfo
                        {
                            Name = parts[0],
                            Version = parts[1]
                        });
                    }
                }
            }

            Console.WriteLine($"[SecurityScanner] 已加载 {packages.Count} 个依赖包,开始漏洞比对...");
            return packages;
        }

        /// <summary>
        /// 加载漏洞数据库
        /// 实际项目中可以对接 NVD API 或 NuGet 官方漏洞库
        /// </summary>
        static List<VulnerabilityRecord> LoadVulnerabilityDatabase()
        {
            // 这里用本地JSON文件模拟漏洞数据库
            // 实际场景可以定期从 https://api.nvd.nist.gov 拉取
            var dbPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "vulnerability-db.json");

            if (!File.Exists(dbPath))
            {
                // 没有本地数据库时创建一份示例数据
                Console.WriteLine("[SecurityScanner] 未找到本地漏洞数据库,使用示例数据");
                return new List<VulnerabilityRecord>
                {
                    new VulnerabilityRecord
                    {
                        CveId = "CVE-2019-0401",
                        PackageName = "Newtonsoft.Json",
                        AffectedRange = "[0, 12.0.3)",
                        Severity = "High",
                        FixedVersion = "12.0.3",
                        Description = "Newtonsoft.Json存在反序列化漏洞,可导致远程代码执行"
                    },
                    new VulnerabilityRecord
                    {
                        CveId = "CVE-2019-15061",
                        PackageName = "YamlDotNet",
                        AffectedRange = "[0, 6.0.0)",
                        Severity = "Critical",
                        FixedVersion = "6.0.0",
                        Description = "YamlDotNet存在任意对象反序列化漏洞"
                    }
                };
            }

            var json = File.ReadAllText(dbPath);
            return JsonSerializer.Deserialize<List<VulnerabilityRecord>>(json)
                   ?? new List<VulnerabilityRecord>();
        }

        /// <summary>
        /// 将依赖包列表与漏洞数据库做比对
        /// 判断每个包的当前版本是否落在受影响版本范围内
        /// </summary>
        static List<(PackageInfo Package, VulnerabilityRecord Vuln)> CheckForVulnerabilities(
            List<PackageInfo> packages,
            List<VulnerabilityRecord> vulnerabilities)
        {
            var results = new List<(PackageInfo, VulnerabilityRecord)>();

            foreach (var pkg in packages)
            {
                // 找出所有跟当前包匹配的漏洞记录
                var matchingVulns = vulnerabilities
                    .Where(v => v.PackageName.Equals(pkg.Name, StringComparison.OrdinalIgnoreCase))
                    .ToList();

                foreach (var vuln in matchingVulns)
                {
                    // 解析版本范围,判断当前版本是否受影响
                    // 简化处理:用 NuGet.Versioning 库来做范围判断
                    if (IsVersionAffected(pkg.Version, vuln.AffectedRange))
                    {
                        results.Add((pkg, vuln));
                    }
                }
            }

            return results;
        }

        /// <summary>
        /// 判断指定版本是否落在受影响的版本范围内
        /// 使用 NuGet.Versioning 包提供的版本范围解析能力
        /// </summary>
        static bool IsVersionAffected(string version, string range)
        {
            try
            {
                // 引入 NuGet.Versioning NuGet包
                // 用法: dotnet add package NuGet.Versioning
                var currentVersion = new NuGet.Versioning.SemanticVersion(version);
                var affectedRange = NuGet.Versioning.VersionRange.Parse(range);

                // IsMinInclusive: 范围左端是否包含
                // IsMaxInclusive: 范围右端是否包含
                return affectedRange.SatisfiedBy(currentVersion);
            }
            catch
            {
                // 版本解析失败时,保守处理,返回true(假设受影响)
                return true;
            }
        }

        /// <summary>
        /// 输出扫描结果,根据最高严重等级决定退出码
        /// 退出码 0 表示构建通过
        /// 退出码 1 表示存在高危/严重漏洞,需要阻断
        /// </summary>
        static int OutputAndDecide(List<(PackageInfo, VulnerabilityRecord)> results)
        {
            if (results.Count == 0)
            {
                Console.WriteLine("\n[SecurityScanner] ✅ 扫描完成,未发现已知漏洞。");
                return 0;
            }

            // 按严重等级排序: Critical > High > Moderate > Low
            var ordered = results
                .OrderByDescending(r => GetSeverityRank(r.Item2.Severity))
                .ToList();

            Console.WriteLine("\n╔══════════════════════════════════════════════════╗");
            Console.WriteLine("║         NuGet 依赖包安全漏洞扫描报告             ║");
            Console.WriteLine("╚══════════════════════════════════════════════════╝");

            foreach (var (pkg, vuln) in ordered)
            {
                Console.WriteLine($"\n  包名: {pkg.Name} {pkg.Version}");
                Console.WriteLine($"  CVE:  {vuln.CveId}");
                Console.WriteLine($"  等级: {vuln.Severity}");
                Console.WriteLine($"  说明: {vuln.Description}");
                Console.WriteLine($"  修复: 升级到 {vuln.FixedVersion} 或更高版本");
            }

            Console.WriteLine($"\n  共发现 {results.Count} 个漏洞。");

            // 判断最高严重等级,决定是否阻断构建
            var highestSeverity = ordered[0].Item2.Severity;
            var severityRank = GetSeverityRank(highestSeverity);

            // 严重和高危漏洞阻断构建,中等和低等仅告警
            if (severityRank >= 2)
            {
                Console.WriteLine($"\n  ⚠️  存在 {highestSeverity} 级别漏洞,构建已阻断!");
                Console.WriteLine("  请升级相关包版本后重新还原。");
                return 1;
            }
            else
            {
                Console.WriteLine($"\n  ⚠️  存在 {highestSeverity} 级别漏洞,建议尽快修复。");
                return 0;
            }
        }

        /// <summary>
        /// 获取严重等级的数值排名,用于排序和判断
        /// 数值越大,严重程度越高
        /// </summary>
        static int GetSeverityRank(string severity)
        {
            return severity switch
            {
                "Critical" => 4,
                "High" => 3,
                "Moderate" => 2,
                "Low" => 1,
                _ => 0
            };
        }
    }
}

上面这段代码是完整的漏洞检查程序。它做了几件关键的事:从assets.json里读出所有依赖包的名称和版本,然后拿这些信息去跟漏洞数据库做交叉比对,最后根据比对结果输出可读的报告,并根据最高漏洞等级决定退出码。

4.3 集成到还原流程

把上面两个文件(.targets文件放到项目目录,SecurityScanner项目放到tools子目录)配置好之后,在.csproj里引用一下:

<!-- 在项目csproj中加入以下引用 -->
<!-- 让MSBuild在构建时加载自定义Target -->
<Import Project="./VulnerabilityCheck.targets" />

然后正常运行还原,漏洞检查就会自动跟着执行:

# 正常还原,安全扫描自动执行
# 如果检查程序返回非0退出码,还原过程会被阻断
dotnet restore;

整个过程对开发者来说几乎是透明的——他们正常执行dotnet restore,还原的同时安全扫描就跟着跑了。有高危漏洞的包会被直接阻断,不会进入后续构建环节。

五、方案三:CI/CD流水线中的自动化告警

本地做扫描解决的是开发阶段的问题。但有些场景下,团队成员可能跳过了本地扫描直接提交代码。这时候就需要在CI/CD流水线上再加一道防线。

5.1 配置GitHub Actions工作流

GitHub Actions是一个非常主流的CI/CD平台。我们可以在PR或者push的时候自动触发漏洞扫描,发现高危风险就阻断合并或者发通知。

# 文件名: .github/workflows/nuget-security-scan.yml
# 技术栈: GitHub Actions / .NET 8
# 说明: 在PR和push时自动执行NuGet漏洞扫描,高危漏洞阻断流水线

name: NuGet安全漏洞扫描

# 触发条件: push到main分支,或者创建/更新PR
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  nuget-vulnerability-scan:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      # 第一步: 拉取代码
      - name: 检出代码
        uses: actions/checkout@v4

      # 第二步: 安装.NET 8 SDK
      - name: 安装 .NET 8 SDK
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      # 第三步: 开启NuGet内置审计,执行还原
      - name: 还原项目(含漏洞审计)
        run: |
          # NuGetAudit=true 开启审计
          # NuGetAuditLevel=Low 所有等级都检查
          dotnet restore /p:NuGetAudit=true /p:NuGetAuditLevel=Low

      # 第四步: 生成SBOM文件,供后续分析或归档使用
      - name: 生成SBOM
        run: |
          dotnet restore /p:GenerateSBOM=true /p:SBOMFormat=cyclonedx

      # 第五步: 运行自定义安全扫描工具
      - name: 执行深度漏洞扫描
        run: |
          cd tools/SecurityScanner
          dotnet run --project . ../src/VulnerabilityAuditDemo/
          # 如果上面命令返回非0,整个步骤会失败,流水线会被阻断

      # 第六步: 上传SBOM和扫描结果作为构建产物
      - name: 上传安全扫描结果
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: security-scan-results
          path: |
            *.sbom
            tools/SecurityScanner/output/

      # 第七步: 如果发现高危漏洞,发送通知
      - name: 发送漏洞告警通知
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            # 在PR评论中留下安全扫描失败的提醒
            const { data: pr } = await github.rest.pulls.get({
              owner: context.repo.owner,
              repo: context.repo.repo,
              pull_number: context.issue.number
            });
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: '⚠️ **安全扫描未通过**\n\n本次PR引入了存在已知漏洞的NuGet依赖包,请检查并升级相关包版本后再提交。\n\n详细漏洞信息见上方日志输出。'
            });

上面这个工作流做到了几件事:在每次代码变更时自动跑漏洞扫描,扫描结果作为构建产物归档,如果扫描失败还会自动在PR里留评论提醒开发者。这样开发团队就完全不需要手动记着去跑安全扫描了。

5.2 告警通知实现

除了PR评论,很多时候团队更希望在飞书、钉钉或者企业微信里收到告警通知。这里给一个飞书机器人的示例:

// 文件名: NotificationHelper.cs
// 技术栈: .NET 8 / C#
// 说明: 将漏洞扫描结果推送到飞书群机器人

using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

namespace SecurityScanner
{
    /// <summary>
    /// 告警通知辅助类
    /// 支持飞书、钉钉、企业微信等机器人通道
    /// </summary>
    public static class NotificationHelper
    {
        private static readonly HttpClient _httpClient = new();

        /// <summary>
        /// 发送飞书机器人告警
        /// </summary>
        /// <param name="webhookUrl">飞书自定义机器人Webhook地址</param>
        /// <param name="projectName">项目名称</param>
        /// <param name="vulnerabilityCount">发现的漏洞数量</param>
        /// <param name="highestSeverity">最高严重等级</param>
        /// <param name="details">漏洞详情文本</param>
        public static async Task SendFeishuNotificationAsync(
            string webhookUrl,
            string projectName,
            int vulnerabilityCount,
            string highestSeverity,
            string details)
        {
            // 构造飞书消息体
            // msg_type: interactive 表示富文本卡片消息
            var message = new
            {
                msg_type = "interactive",
                card = new
                {
                    header = new
                    {
                        title = new
                        {
                            tag = "plain_text",
                            content = $"⚠️ NuGet安全漏洞告警 - {projectName}"
                        },
                        template = highestSeverity switch
                        {
                            "Critical" => "red",
                            "High" => "orange",
                            _ => "blue"
                        }
                    },
                    elements = new object[]
                    {
                        new
                        {
                            tag = "div",
                            text = new
                            {
                                tag = "lark_md",
                                content = $"""
                                    **项目:** {projectName}
                                    **漏洞总数:** {vulnerabilityCount} 个
                                    **最高等级:** {highestSeverity}
                                    **扫描时间:** {DateTime.Now:yyyy-MM-dd HH:mm:ss}
                                """
                            }
                        },
                        new
                        {
                            tag = "div",
                            text = new
                            {
                                tag = "lark_md",
                                content = $"<font color='red'>📋 漏洞详情:\n{details}</font>"
                            }
                        }
                    }
                }
            };

            // 发送HTTP POST请求到飞书Webhook
            var json = JsonSerializer.Serialize(message);
            var content = new StringContent(json, Encoding.UTF8, "application/json");
            var response = await _httpClient.PostAsync(webhookUrl, content);

            Console.WriteLine($"[通知] 飞书告警发送状态: {response.StatusCode}");
        }
    }
}

六、完整实战演示

前面介绍了三种方案,现在把它们组合起来做一次完整的实战演示,模拟一个真实场景:开发者引入了一个有已知漏洞的包,团队的安全机制自动发现问题并阻断。

6.1 环境准备

首先创建一个干净的.NET 8 Web API项目作为测试载体:

# 创建解决方案和项目
mkdir SecurityDemo && cd SecurityDemo
dotnet new sln -n SecurityDemo
dotnet new webapi -n DemoApi
dotnet sln add DemoApi/DemoApi.csproj

# 创建tools目录,放安全扫描工具
mkdir tools/SecurityScanner
cd tools/SecurityScanner
dotnet new console -n SecurityScanner --framework net8.0
cd ..
cd ..

# 为SecurityScanner添加版本解析依赖
cd tools/SecurityScanner
dotnet add package NuGet.Versioning
cd ../..

6.2 重现漏洞场景

现在在DemoApi项目里引入几个有已知漏洞的旧版本包:

<!-- 文件名: DemoApi/DemoApi.csproj -->
<!-- 技术栈: .NET 8 / C# -->
<!-- 说明: 故意引入有漏洞的包,用于演示安全扫描效果 -->

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- 开启NuGet内置审计 -->
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditLevel>Low</NuGetAuditLevel>
  </PropertyGroup>

  <!-- 引入有已知漏洞的包 -->
  <!-- JWT验证库的旧版本,存在拒绝服务漏洞 -->
  <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.6.0" />

  <!-- 旧版本的Newtonsoft.Json,存在反序列化漏洞 -->
  <PackageReference Include="Newtonsoft.Json" Version="10.0.3" />

  <!-- 安全版本包,作为对照组 -->
  <PackageReference Include="FluentValidation" Version="11.9.0" />

</Project>

执行还原:

# 在项目根目录执行还原
cd DemoApi
dotnet restore;

终端输出应该包含漏洞警告信息,类似:

  Determining projects to restore...
  Restoring /path/to/DemoApi/DemoApi.csproj (net8.0)...
  Restored /path/to/DemoApi/DemoApi.csproj (in 3.5 sec).

  DemoApi.csproj : warning NU1903: Package 'Newtonsoft.Json' 10.0.3 has a known high severity vulnerability
    https://github.com/advisories/GHSA-5rrm-8592-cvpc
    Package Newtonsoft.Json version 10.0.3 has a known High severity vulnerability.
    Affected versions: [0, 12.0.3)
    Fixed version: >= 12.0.3

6.3 修复与验证

发现漏洞之后,修复方式很直接——升级包版本到没有已知漏洞的版本:

# 方式一: 使用dotnet CLI自动升级到最新版
# 命令会找到最新的稳定版本并更新csproj中的版本号
dotnet add package Newtonsoft.Json;
<!-- 修复后的PackageReference,版本已升级到安全版本 -->
<!-- 注意: 升级后建议跑一遍单元测试,确认功能不受影响 -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

再次执行还原确认漏洞已清除:

# 重新还原,确认不再出现漏洞警告
dotnet restore;

终端输出中应该不再有任何NU1903警告,只会有干净的还原成功信息。

七、应用场景分析

这套方案适用面其实相当广,不是只有大型项目才需要。

第一个典型场景是微服务架构下的依赖管理。 一个微服务项目里可能有几十个甚至上百个服务,每个服务都有自己的一堆NuGet依赖。如果每个团队、每个服务都各自为政地去管依赖安全,很容易漏掉。把扫描集成到还原流程里之后,每个服务还原的时候自动检查,相当于给整个微服务体系加了一道统一的安全门。

第二个场景是企业内部的NuGet源管控。 很多企业会用私有NuGet源来管理内部包,同时镜像公共NuGet包。在这种情况下,可以在私有源层面也做一层漏洞过滤——有高危漏洞的包版本直接不允许被还原,从仓库源头就把有问题的包挡掉。

第三个场景是合规审计要求。 很多行业(金融、医疗、政务)对软件供应链安全有明确的合规要求,需要记录每个组件的漏洞状态、修复时间和责任人。把扫描集成到还原流程之后,审计记录是自动产生的,不需要额外的人工统计。

第四个场景是安全左移的实践落地。 安全左移的核心理念就是在软件开发最早期就引入安全检查,而不是等到上线之后再发现问题。把漏洞扫描放到还原阶段,就是在开发的第一步就把安全考虑进去了,修复漏洞的成本在这个阶段是最低的。

八、技术优缺点

优点

第一,零感知集成。 开发者不需要额外记住一个命令,不需要切换工具,正常跑dotnet restore就自动触发了。这是最重要的优势,因为如果扫描工具是独立的,大概率会被开发者跳过或者忘记执行。

第二,反馈及时。 漏洞信息在还原完成的那一刻就出来了,开发者立刻就能知道当前依赖有没有问题。跟那些需要部署完再跑扫描工具的方式相比,反馈速度快了几十倍。

第三,阻断能力强。 因为扫描是在还原阶段做的,有高危漏洞的包根本就不会被还原到项目里,也就不会进入后续的编译和部署环节。这个阻断是硬阻断,不是"警告一下你自己看着办"。

第四,与现有工具链无缝对接。 MSBuild Target是.NET生态的原生扩展机制,不需要引入外部构建系统或者改变现有的项目结构。

缺点

第一,对SDK版本有要求。 NuGet内置审计功能需要.NET 8 SDK以上,如果团队还在用.NET 6或者更低版本,就需要额外安装更新版本的SDK或者使用自定义方案。

第二,本地扫描依赖网络连接。 NuGet漏洞数据库托管在云端,本地还原时需要能访问到数据库地址。在离线开发环境或者网络受限的环境里,本地扫描可能无法正常工作。解决方案是定期拉取漏洞数据库缓存到本地。

第三,扫描范围有限。 NuGet的审计功能只覆盖NuGet源上的公共包,对于私有包或者内部包,需要团队自己维护漏洞数据。

第四,可能有误报。 自动扫描难免会遇到误判的情况,比如某个版本范围判断有偏差,或者漏洞数据库的信息还没更新。需要团队有一定的甄别和处置能力。

九、注意事项

在落地这套方案的过程中,有几个坑值得提前注意。

不要一上来就把所有漏洞都设成阻断。 如果直接配置成所有等级的漏洞都报错,项目可能一夜之间因为一堆历史遗留的中低危漏洞而完全无法构建。建议先跑一遍扫描,把结果分类统计,对历史问题做一次集中修复,之后再逐步提高阻断阈值。

升级包版本之前一定要跑回归测试。 把一个包从旧版本升级到新版本,即使新版修复了漏洞,也有可能引入API变更或者行为差异。尤其是大版本跨越(比如从1.0升到2.0),破坏性变更的风险更高。建议每次升级后至少跑一遍完整的单元测试和集成测试。

私有NuGet源也要纳入审计范围。 如果团队有私有包源,那些内部包同样可能有安全问题,而且因为不对外公开,漏洞信息更不容易被发现。建议在内部包发布流程中也加入安全扫描环节。

保持漏洞数据库的时效性。 漏洞数据库不是一次拉下来就一劳永逸的,新的CVE每天都在产生。建议配置一个定时任务,每隔几个小时或者每天自动更新一次本地缓存的漏洞数据。

不要忽略传递依赖。 有些漏洞不在你直接引用的包里,而在A依赖的B依赖的C里面。NuGet的审计功能默认会检查传递依赖,但自定义扫描方案如果只读了顶层依赖就漏掉了。一定要确认扫描覆盖了完整的依赖树。

十、总结

把NuGet安全漏洞扫描集成到还原流程,本质上是把安全从"事后补救"变成了"事前预防"。这个转变说起来简单,做起来需要一些工程上的巧思——利用MSBuild的原生扩展机制做无感集成,利用NuGet的审计特性做版本比对,利用CI/CD流水线做团队级别的强制管控。

对于.NET开发者来说,这件事的起步门槛其实很低。如果你用的是.NET 8,只要在.csproj里加两行配置就能享受内置审计;如果想要更细粒度的控制,就自己写一个MSBuild Target;如果要覆盖团队和CI,就配一个GitHub Actions工作流。三层防线叠加在一起,就能在还原阶段形成一道相当可靠的安全屏障。

安全这件事没有终点,但每一个小的改进都在让项目的风险面缩小。从还原阶段开始做起,至少能保证每一个新引入的依赖都经过了安全审视,这一点就已经比很多团队的做法要好得多了。