构建基于JWT认证与动态策略的Vite依赖扫描插件


在CI/CD流水线中加入安全扫描是标准操作,但这种反馈机制存在固有的延迟。开发者提交了包含高危漏洞依赖的代码,需要等待数分钟甚至更久,才能在流水线的某个阶段收到失败通知。这种滞后破坏了开发心流,也增加了修复成本。我们团队的目标是将这种安全检查尽可能左移,直接集成到开发者的本地构建环节。当开发者执行 vite build 时,就应该能立即知道是否存在不符合安全策略的依赖。

初步的想法很简单:在 package.jsonbuild 脚本里加入 npm audit。但这个方案很快被否决。npm audit 的规则是固定的,我们无法根据环境(例如,production 构建比 development 更严格)、漏洞等级或特定依赖包来定制复杂的、动态的策略。我们需要一个能与公司安全中心联动的、策略可由安全团队动态更新的、并且能无缝融入Vite构建生态的解决方案。最终,我们决定自研一个Vite插件。

架构决策:插件、后端与认证

一个健壮的方案需要三个核心组件:

  1. Vite插件: 作为执行者,它必须能钩入Vite的构建生命周期,获取项目完整的依赖树,并根据外部指令决定是放行构建还是中断它。
  2. 策略与漏洞分析后端: 作为决策者,它维护着最新的漏洞数据库和动态的安全策略。将这部分逻辑抽离为服务,可以让我们在不改动成百上千个前端项目配置的情况下,集中更新安全规则。
  3. 认证机制: 插件与后端之间的通信必须是安全的。我们不希望任何人都能随意调用我们的内部安全API。JWT(JSON Web Tokens)是这种服务间认证的理想选择,特别是对于CI环境中使用的非交互式凭证(Service Account)。

整个工作流程设计如下:

sequenceDiagram
    participant Dev as 开发者
    participant Vite as Vite构建进程
    participant Plugin as 自定义插件
    participant Backend as 安全策略后端

    Dev->>Vite: 执行 vite build
    Vite-->>Plugin: 触发 buildStart 钩子
    Plugin->>Plugin: 解析 package-lock.json 获取依赖树
    Note right of Plugin: 必须解析lock文件以获得精确版本
    
    Plugin->>Backend: 请求认证 (携带Service Account Key)
    Backend-->>Plugin: 颁发一个短时效的JWT
    
    Plugin->>Backend: 发送依赖树 (携带JWT)
    Backend->>Backend: 根据动态策略进行漏洞分析
    alt 存在不合规依赖
        Backend-->>Plugin: 返回 { status: 'FAIL', details: [...] }
        Plugin->>Vite: 抛出错误,中断构建
        Vite-->>Dev: 显示详细的漏洞信息并退出
    else 所有依赖合规
        Backend-->>Plugin: 返回 { status: 'PASS' }
        Plugin->>Vite: 允许构建继续
        Vite-->>Dev: 构建成功
    end

这个架构将执行、决策和安全隔离,是典型的生产环境设计思路。接下来,我们分步实现它。

第一步:搭建模拟的安全策略后端

为了聚焦于插件的实现,我们先用Node.js和Express搭建一个功能最小化的后端。在真实项目中,这个后端会连接庞大的漏洞数据库和复杂的策略引擎。

这个后端需要提供两个API:

  • POST /auth: 用于签发JWT。在实际场景中,它会验证一个长期有效的API Key或其它凭证。
  • POST /scan: 受JWT保护,接收依赖列表并返回扫描结果。
// security-backend/server.js

const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.json());

const PORT = 3005;
const JWT_SECRET = 'a-very-secret-key-for-dev-only'; // 生产环境应从环境变量读取
const SERVICE_ACCOUNT_KEY = 'ci-super-secret-key';

// 模拟的漏洞数据库
const VULNERABILITY_DB = {
    '[email protected]': { cve: 'CVE-2021-41720', severity: 'HIGH' },
    '[email protected]': { cve: 'CVE-2021-3749', severity: 'CRITICAL' },
    '[email protected]': { cve: 'CVE-2020-1111', severity: 'LOW' }
};

// 动态安全策略
// 在真实项目中,这部分会更复杂,可能从数据库或配置中心加载
const SECURITY_POLICY = {
    failOnSeverity: ['CRITICAL', 'HIGH'], // 遇到CRITICAL或HIGH级别的漏洞就中断构建
    ignore: ['CVE-2020-1111'], // 忽略特定的CVE
};

// --- 认证中间件 ---
const authMiddleware = (req, res, next) => {
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ error: 'Unauthorized: Missing or invalid token' });
    }

    const token = authHeader.split(' ')[1];
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        req.user = decoded;
        next();
    } catch (err) {
        console.error('JWT Verification Error:', err.message);
        return res.status(403).json({ error: 'Forbidden: Invalid token' });
    }
};


// --- API Endpoints ---

// 1. 认证接口
app.post('/auth', (req, res) => {
    const { apiKey } = req.body;
    if (apiKey === SERVICE_ACCOUNT_KEY) {
        const token = jwt.sign({ service: 'vite-plugin-scanner' }, JWT_SECRET, { expiresIn: '5m' });
        console.log(`[AUTH] Issued JWT for service`);
        return res.json({ token });
    }
    console.warn(`[AUTH] Failed auth attempt with key: ${apiKey}`);
    res.status(403).json({ error: 'Invalid API key' });
});

// 2. 依赖扫描接口
app.post('/scan', authMiddleware, (req, res) => {
    const { dependencies } = req.body;
    if (!dependencies || typeof dependencies !== 'object') {
        return res.status(400).json({ error: 'Bad Request: "dependencies" field is missing or invalid' });
    }

    console.log(`[SCAN] Received scan request for ${Object.keys(dependencies).length} packages.`);

    const findings = [];
    for (const [pkg, version] of Object.entries(dependencies)) {
        const key = `${pkg}@${version}`;
        if (VULNERABILITY_DB[key]) {
            const vulnerability = VULNERABILITY_DB[key];
            if (SECURITY_POLICY.ignore.includes(vulnerability.cve)) {
                console.log(`[SCAN] Ignoring vulnerability ${vulnerability.cve} for ${key} due to policy.`);
                continue;
            }
            if (SECURITY_POLICY.failOnSeverity.includes(vulnerability.severity)) {
                findings.push({
                    package: pkg,
                    version: version,
                    ...vulnerability
                });
            }
        }
    }

    if (findings.length > 0) {
        console.error(`[SCAN] Found ${findings.length} critical vulnerabilities. Reporting FAIL.`);
        return res.json({ status: 'FAIL', details: findings });
    }

    console.log(`[SCAN] All dependencies conform to the policy. Reporting PASS.`);
    res.json({ status: 'PASS' });
});

app.listen(PORT, () => {
    console.log(`Mock Security Backend is running on http://localhost:${PORT}`);
});

启动这个后端服务。现在,我们有了一个可以交互的目标。

第二步:实现核心的Vite插件

一个Vite插件本质上是一个包含特定钩子(hooks)的对象。我们的核心逻辑将放在 buildStart 钩子中,因为它在每次构建开始时执行,并且是执行异步操作的理想位置。

我们的插件需要做到:

  1. 读取并解析 package-lock.json: 这是获取项目中所有依赖(包括子依赖)精确版本的唯一可靠来源。
  2. 管理与后端的认证: 获取JWT并附加到后续请求中。
  3. 调用扫描API: 发送依赖数据。
  4. 处理API响应: 如果响应是 FAIL,则调用Vite插件上下文的 this.error() 方法来中断构建。这是Vite提供的标准中断方式,能确保错误信息清晰地展示给用户。

下面是插件的完整实现。

// vite-plugin-dependency-scan/index.ts

import type { Plugin } from 'vite';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import fetch from 'node-fetch'; // 在Node.js环境中使用fetch

// --- 类型定义 ---
interface PluginOptions {
    apiEndpoint: string;
    apiKey: string;
    // 如果为true,构建会失败;否则只打印警告
    strict?: boolean; 
    // 自定义项目根目录,默认process.cwd()
    projectRoot?: string; 
}

interface PackageLock {
    packages: {
        [path: string]: {
            version: string;
        };
    };
}

interface VulnerabilityFinding {
    package: string;
    version: string;
    cve: string;
    severity: string;
}

// --- 插件实现 ---
export default function dependencyScan(options: PluginOptions): Plugin {
    const { apiEndpoint, apiKey, strict = true, projectRoot = process.cwd() } = options;

    let jwtToken: string | null = null;

    // 获取JWT的辅助函数
    async function getAuthToken(): Promise<string> {
        if (jwtToken) {
            // 这里可以加入JWT过期检查逻辑,但为简化起见,我们假设它在单次构建中有效
            return jwtToken;
        }
        
        console.log('[DepScan] Authenticating with security backend...');
        try {
            const response = await fetch(`${apiEndpoint}/auth`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ apiKey }),
            });

            if (!response.ok) {
                throw new Error(`Authentication failed with status: ${response.status}`);
            }

            const data = await response.json() as { token: string };
            jwtToken = data.token;
            console.log('[DepScan] Authentication successful.');
            return jwtToken;
        } catch (error) {
            console.error('[DepScan] Error during authentication:', error);
            throw new Error('Could not authenticate with the security backend.');
        }
    }

    return {
        name: 'vite-plugin-dependency-scan',

        // buildStart钩子在构建开始时执行,支持异步
        async buildStart() {
            console.log('[DepScan] Starting dependency scan...');

            // 1. 解析 package-lock.json
            let dependencies: Record<string, string> = {};
            try {
                const lockfilePath = resolve(projectRoot, 'package-lock.json');
                const lockfileContent = readFileSync(lockfilePath, 'utf-8');
                const lockfile: PackageLock = JSON.parse(lockfileContent);
                
                // 我们只关心 node_modules/ 下的包
                for (const path in lockfile.packages) {
                    if (path.startsWith('node_modules/')) {
                        // 从 'node_modules/lodash' 提取 'lodash'
                        const pkgName = path.replace(/^node_modules\//, '').split('/node_modules/').pop();
                        if (pkgName && !pkgName.includes('/')) { // 忽略嵌套的子依赖定义
                           dependencies[pkgName] = lockfile.packages[path].version;
                        }
                    }
                }
                console.log(`[DepScan] Found ${Object.keys(dependencies).length} total dependencies.`);
            } catch (error) {
                this.warn('Could not read or parse package-lock.json. Skipping scan.');
                return;
            }

            if (Object.keys(dependencies).length === 0) {
                this.warn('No dependencies found to scan.');
                return;
            }

            try {
                // 2. 获取Token并执行扫描
                const token = await getAuthToken();
                const response = await fetch(`${apiEndpoint}/scan`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${token}`,
                    },
                    body: JSON.stringify({ dependencies }),
                });

                if (!response.ok) {
                    throw new Error(`API call failed with status: ${response.status}`);
                }

                const result = await response.json() as { status: string; details?: VulnerabilityFinding[] };

                // 3. 处理扫描结果
                if (result.status === 'FAIL' && result.details) {
                    const errorMessage = [
                        'Build failed due to security policy violations:',
                        ...result.details.map(d => 
                            `  - Package: ${d.package}@${d.version} | Severity: ${d.severity} | CVE: ${d.cve}`
                        )
                    ].join('\n');

                    if (strict) {
                        // 使用 this.error() 中断构建
                        this.error(errorMessage);
                    } else {
                        // 使用 this.warn() 打印警告,但不中断
                        this.warn(errorMessage);
                    }
                } else {
                    console.log('[DepScan] Scan passed. All dependencies are compliant.');
                }

            } catch (error) {
                const message = error instanceof Error ? error.message : String(error);
                const errorMessage = `[DepScan] An unexpected error occurred: ${message}`;
                // 在API调用失败时,一个常见的错误是直接放行。
                // 在真实项目中,我们应该选择“默认失败”策略。如果扫描服务不可达,构建也应该失败。
                if (strict) {
                    this.error(errorMessage);
                } else {
                    this.warn(errorMessage + ' (Build will continue because strict mode is off)');
                }
            }
        },
    };
}

这个插件的设计考虑了几个真实世界的问题:

  • 配置化: API地址和密钥通过选项传入,而不是硬编码。
  • strict模式: 允许在不同环境下采取不同策略。例如,本地开发时设为false仅作提醒,而CI环境设为true强制执行。
  • 错误处理: 对文件读取、网络请求和认证失败都做了处理。尤其是在扫描服务本身出现问题时,选择中断构建(安全默认)还是放行(可用性默认)是一个重要的策略决策,这里通过strict模式交由使用者决定。

第三步:在Vite项目中使用插件

现在,将插件集成到一个Vite项目中。

首先,在项目根目录创建一个 plugins 文件夹,并将上面的插件代码保存为 plugins/dependency-scan.ts

然后,安装依赖:

npm install express jsonwebtoken body-parser node-fetch
# 如果你的项目是ts,确保有@types/node

修改 vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dependencyScan from './plugins/dependency-scan'; // 引入我们的本地插件

export default defineConfig({
  plugins: [
    react(),
    // 在这里使用插件
    dependencyScan({
      apiEndpoint: 'http://localhost:3005', // 指向我们模拟的后端
      apiKey: 'ci-super-secret-key', // 这是后端 server.js 中定义的密钥
      strict: true, // 强制中断构建
    }),
  ],
});

为了测试失败场景,我们需要在项目中安装一个已知的“漏洞”版本依赖:

npm install [email protected]

现在,运行 vite build

预期输出(失败场景):
首先,你会看到后端的日志:

Mock Security Backend is running on http://localhost:3005
[AUTH] Issued JWT for service
[SCAN] Received scan request for ... packages.
[SCAN] Found 1 critical vulnerabilities. Reporting FAIL.

然后,Vite构建进程会立即中断,并打印出插件通过 this.error() 抛出的清晰信息:

[vite-plugin-dependency-scan] Starting dependency scan...
[vite-plugin-dependency-scan] Found ... total dependencies.
[vite-plugin-dependency-scan] Authenticating with security backend...
[vite-plugin-dependency-scan] Authentication successful.
error: [vite-plugin-dependency-scan] Build failed due to security policy violations:
  - Package: [email protected] | Severity: CRITICAL | CVE: CVE-2021-3749

  at PluginContext.error (file:///.../node_modules/vite/dist/node/chunks/dep-....js:59166:24)
  ...

这个输出明确地告诉开发者是哪个包、哪个版本、因为哪个漏洞导致了构建失败,实现了我们最初的目标。

如果卸载 axios 或者将其升级到一个安全版本,再次运行 vite build,构建将会顺利通过。

局限性与未来迭代方向

尽管此方案有效地解决了问题,但它仍是一个原型,在投入生产前需要考虑以下几点:

  1. 性能: 当前的实现在 buildStart 中是阻塞式的。对于有数千个依赖的大型项目,解析lock文件和等待网络请求可能会明显拖慢构建启动速度。一个优化方向是将扫描过程移至buildEnd钩子,或者使用worker thread来执行,但这会失去“启动即失败”的快速反馈特性。更实际的优化是为扫描结果添加缓存,只有在package-lock.json发生变化时才触发全量扫描。

  2. 依赖树的精确性: package-lock.json 的解析逻辑相对简单,可能无法处理所有边缘情况,例如peerDependenciesoptionalDependencies。在生产环境中,使用像@npm/arborist这样的专业库来遍历依赖树会更加健壮。

  3. 后端的成熟度: 模拟后端的功能非常有限。一个生产级的安全后端需要:

    • 对接多个漏洞源(NVD, GitHub Advisories等)并持续更新。
    • 一个灵活的策略引擎,支持基于路径、环境、项目风险等级等更多维度的规则。
    • 提供漏洞修复建议,甚至能自动创建Pull Request来更新依赖。
  4. 密钥管理: 在vite.config.ts中硬编码apiKey是危险的。在CI环境中,这个密钥应该通过环境变量注入。对于本地开发,可以使用dotenv之类的工具从.env文件加载,并确保.env文件不被提交到版本库。

这个Vite插件的实现,本质上是在前端构建工具链中建立了一个可编程的安全关卡。它将安全策略的定义权从分散的项目配置中收归到统一的后端服务,并把安全反馈从CI管道的末端前置到了开发者的指尖。这正是”Shift Left”理念在前端工程化领域的一次具体实践。


  目录