Terminal.skills
Use Cases/Build an Automated Security Scanner for Dependencies

Build an Automated Security Scanner for Dependencies

Build a CI-integrated dependency security scanner that detects known vulnerabilities, checks license compliance, identifies outdated packages, and blocks risky deployments automatically.

#postgresql#database#sql#relational#jsonb
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

Kai leads security at a 50-person fintech with 12 microservices. Each service has 200-400 npm dependencies. Nobody audits them. Last quarter, a post-incident review revealed that a critical vulnerability in jsonwebtoken (CVE-2022-23529) had been in production for 8 months — allowing JWT forgery attacks. The team ran npm audit manually once and got 847 findings, most of them false positives or irrelevant. They need an automated scanner that runs in CI, filters noise from real risks, tracks vulnerabilities across all services, and blocks deployments when critical issues exist.

Step 1: Build the Vulnerability Scanner

The scanner parses lock files, queries vulnerability databases, and scores findings by actual exploitability — not just CVSS numbers.

typescript
// src/scanner/dependency-scanner.ts — Scan dependencies against vulnerability databases
import { readFileSync } from "node:fs";
import { pool } from "../db";

interface Dependency {
  name: string;
  version: string;
  isDirect: boolean;      // direct dependency vs transitive
  depth: number;          // how deep in the dependency tree
  parentChain: string[];  // path from root to this dependency
}

interface Vulnerability {
  id: string;             // CVE or GHSA ID
  package: string;
  severity: "critical" | "high" | "moderate" | "low";
  cvssScore: number;
  title: string;
  description: string;
  fixedIn: string | null;      // version that fixes it (null if no fix)
  patchAvailable: boolean;
  exploitability: "active" | "poc" | "theoretical" | "unknown";
  affectedVersionRange: string;
  publishedAt: string;
}

interface ScanResult {
  service: string;
  totalDependencies: number;
  directDependencies: number;
  vulnerabilities: Array<Vulnerability & {
    dependency: Dependency;
    riskScore: number;        // computed risk (0-100)
    autoFixable: boolean;
  }>;
  licensIssues: LicenseIssue[];
  outdatedPackages: OutdatedPackage[];
  scanDuration: number;
}

interface LicenseIssue {
  package: string;
  version: string;
  license: string;
  risk: "blocked" | "review" | "ok";
  reason: string;
}

interface OutdatedPackage {
  name: string;
  current: string;
  latest: string;
  semverDrift: "major" | "minor" | "patch";
  daysBehind: number;
}

export async function scanDependencies(
  lockFilePath: string,
  serviceName: string
): Promise<ScanResult> {
  const startTime = Date.now();

  // Parse the lock file to extract all dependencies
  const deps = parseLockFile(lockFilePath);

  // Query vulnerability databases
  const vulns = await queryVulnDatabase(deps);

  // Score each vulnerability by actual risk
  const scoredVulns = vulns.map((vuln) => {
    const dep = deps.find((d) => d.name === vuln.package)!;
    return {
      ...vuln,
      dependency: dep,
      riskScore: computeRiskScore(vuln, dep),
      autoFixable: vuln.patchAvailable && dep.isDirect,
    };
  });

  // Sort by risk score descending
  scoredVulns.sort((a, b) => b.riskScore - a.riskScore);

  // Check licenses
  const licenseIssues = await checkLicenses(deps);

  // Check for outdated packages (direct dependencies only)
  const outdated = await checkOutdated(deps.filter((d) => d.isDirect));

  const result: ScanResult = {
    service: serviceName,
    totalDependencies: deps.length,
    directDependencies: deps.filter((d) => d.isDirect).length,
    vulnerabilities: scoredVulns,
    licensIssues: licenseIssues,
    outdatedPackages: outdated,
    scanDuration: Date.now() - startTime,
  };

  // Persist scan results
  await pool.query(
    `INSERT INTO security_scans (service, total_deps, vuln_count, critical_count, high_count, license_issues, scan_duration_ms, scanned_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
    [
      serviceName,
      result.totalDependencies,
      scoredVulns.length,
      scoredVulns.filter((v) => v.severity === "critical").length,
      scoredVulns.filter((v) => v.severity === "high").length,
      licenseIssues.filter((l) => l.risk === "blocked").length,
      result.scanDuration,
    ]
  );

  return result;
}

function computeRiskScore(vuln: Vulnerability, dep: Dependency): number {
  let score = vuln.cvssScore * 10; // base: 0-100

  // Direct dependencies are more risky (actively imported in code)
  if (dep.isDirect) score *= 1.3;

  // Active exploits = much higher risk
  if (vuln.exploitability === "active") score *= 1.5;
  else if (vuln.exploitability === "poc") score *= 1.2;

  // No fix available = higher risk (can't remediate)
  if (!vuln.patchAvailable) score *= 1.2;

  // Deep transitive dependencies are lower risk (less likely to be reachable)
  if (dep.depth > 3) score *= 0.7;

  return Math.min(100, Math.round(score));
}

function parseLockFile(path: string): Dependency[] {
  const content = readFileSync(path, "utf-8");
  const deps: Dependency[] = [];

  if (path.endsWith("package-lock.json")) {
    const lock = JSON.parse(content);
    const packages = lock.packages || {};

    for (const [pkgPath, info] of Object.entries<any>(packages)) {
      if (pkgPath === "") continue; // root
      const name = pkgPath.replace(/^node_modules\//, "").replace(/.*node_modules\//, "");
      const depth = (pkgPath.match(/node_modules/g) || []).length;

      deps.push({
        name,
        version: info.version,
        isDirect: depth === 1,
        depth,
        parentChain: [], // simplified
      });
    }
  }

  return deps;
}

async function queryVulnDatabase(deps: Dependency[]): Promise<Vulnerability[]> {
  // Query GitHub Advisory Database (GHSA) via API
  const vulns: Vulnerability[] = [];
  const batchSize = 50;

  for (let i = 0; i < deps.length; i += batchSize) {
    const batch = deps.slice(i, i + batchSize);
    const query = batch.map((d) => ({ package: { ecosystem: "npm", name: d.name }, version: d.version }));

    const response = await fetch("https://api.osv.dev/v1/querybatch", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ queries: query }),
    });

    const data = await response.json();
    for (let j = 0; j < data.results.length; j++) {
      const result = data.results[j];
      if (!result.vulns) continue;

      for (const vuln of result.vulns) {
        const severity = vuln.database_specific?.severity || "moderate";
        vulns.push({
          id: vuln.id,
          package: batch[j].name,
          severity: severity.toLowerCase() as any,
          cvssScore: vuln.severity?.[0]?.score || 5.0,
          title: vuln.summary || vuln.id,
          description: vuln.details || "",
          fixedIn: vuln.affected?.[0]?.ranges?.[0]?.events?.find((e: any) => e.fixed)?.fixed || null,
          patchAvailable: !!vuln.affected?.[0]?.ranges?.[0]?.events?.find((e: any) => e.fixed),
          exploitability: "unknown",
          affectedVersionRange: vuln.affected?.[0]?.ranges?.[0]?.events?.map((e: any) => JSON.stringify(e)).join(" ") || "",
          publishedAt: vuln.published || "",
        });
      }
    }
  }

  return vulns;
}

// Blocked licenses for fintech (copyleft in proprietary code)
const BLOCKED_LICENSES = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "SSPL-1.0"];
const REVIEW_LICENSES = ["LGPL-2.1", "LGPL-3.0", "MPL-2.0", "EUPL-1.2"];

async function checkLicenses(deps: Dependency[]): Promise<LicenseIssue[]> {
  const issues: LicenseIssue[] = [];

  for (const dep of deps.filter((d) => d.isDirect)) {
    try {
      const res = await fetch(`https://registry.npmjs.org/${dep.name}/${dep.version}`);
      const data = await res.json();
      const license = data.license || "UNKNOWN";

      if (BLOCKED_LICENSES.includes(license)) {
        issues.push({ package: dep.name, version: dep.version, license, risk: "blocked", reason: `${license} is incompatible with proprietary software` });
      } else if (REVIEW_LICENSES.includes(license)) {
        issues.push({ package: dep.name, version: dep.version, license, risk: "review", reason: `${license} requires legal review for your use case` });
      }
    } catch { /* skip if registry unavailable */ }
  }

  return issues;
}

async function checkOutdated(deps: Dependency[]): Promise<OutdatedPackage[]> {
  const outdated: OutdatedPackage[] = [];

  for (const dep of deps.slice(0, 50)) { // check top 50 direct deps
    try {
      const res = await fetch(`https://registry.npmjs.org/${dep.name}/latest`);
      const data = await res.json();
      if (data.version !== dep.version) {
        const [curMajor, curMinor] = dep.version.split(".").map(Number);
        const [latMajor, latMinor] = data.version.split(".").map(Number);

        outdated.push({
          name: dep.name,
          current: dep.version,
          latest: data.version,
          semverDrift: curMajor !== latMajor ? "major" : curMinor !== latMinor ? "minor" : "patch",
          daysBehind: 0, // would need publish dates to calculate
        });
      }
    } catch { /* skip */ }
  }

  return outdated;
}

Step 2: Build the CI Gate

typescript
// src/ci/gate.ts — CI pipeline gate that blocks deploys on critical vulnerabilities
import { scanDependencies, ScanResult } from "../scanner/dependency-scanner";

interface GatePolicy {
  blockOnCritical: boolean;
  blockOnHigh: boolean;
  maxHighVulns: number;
  blockOnBlockedLicense: boolean;
  maxRiskScore: number;         // block if any vuln exceeds this
  allowedExceptions: string[];  // CVE/GHSA IDs that are accepted risks
}

const DEFAULT_POLICY: GatePolicy = {
  blockOnCritical: true,
  blockOnHigh: false,
  maxHighVulns: 5,
  blockOnBlockedLicense: true,
  maxRiskScore: 85,
  allowedExceptions: [],
};

export async function runCIGate(
  lockFilePath: string,
  serviceName: string,
  policy: GatePolicy = DEFAULT_POLICY
): Promise<{ pass: boolean; reasons: string[]; scan: ScanResult }> {
  const scan = await scanDependencies(lockFilePath, serviceName);
  const reasons: string[] = [];

  // Filter out accepted exceptions
  const activeVulns = scan.vulnerabilities.filter(
    (v) => !policy.allowedExceptions.includes(v.id)
  );

  // Check critical vulnerabilities
  const criticals = activeVulns.filter((v) => v.severity === "critical");
  if (policy.blockOnCritical && criticals.length > 0) {
    reasons.push(`${criticals.length} critical vulnerabilities: ${criticals.map((v) => `${v.package}@${v.dependency.version} (${v.id})`).join(", ")}`);
  }

  // Check high vulnerabilities
  const highs = activeVulns.filter((v) => v.severity === "high");
  if (policy.blockOnHigh && highs.length > 0) {
    reasons.push(`${highs.length} high vulnerabilities`);
  } else if (highs.length > policy.maxHighVulns) {
    reasons.push(`${highs.length} high vulnerabilities exceed limit of ${policy.maxHighVulns}`);
  }

  // Check risk scores
  const highRisk = activeVulns.filter((v) => v.riskScore > policy.maxRiskScore);
  if (highRisk.length > 0) {
    reasons.push(`${highRisk.length} vulnerabilities exceed risk score ${policy.maxRiskScore}`);
  }

  // Check licenses
  if (policy.blockOnBlockedLicense) {
    const blocked = scan.licensIssues.filter((l) => l.risk === "blocked");
    if (blocked.length > 0) {
      reasons.push(`${blocked.length} blocked licenses: ${blocked.map((l) => `${l.package} (${l.license})`).join(", ")}`);
    }
  }

  // Output CI-friendly results
  console.log(`\n${"=".repeat(60)}`);
  console.log(`Security Scan: ${serviceName}`);
  console.log(`${"=".repeat(60)}`);
  console.log(`Dependencies: ${scan.totalDependencies} (${scan.directDependencies} direct)`);
  console.log(`Vulnerabilities: ${activeVulns.length} (${criticals.length} critical, ${highs.length} high)`);
  console.log(`License issues: ${scan.licensIssues.length}`);
  console.log(`Scan time: ${scan.scanDuration}ms`);
  console.log(`\nResult: ${reasons.length === 0 ? "✅ PASS" : "❌ BLOCKED"}`);
  if (reasons.length > 0) {
    console.log(`\nBlock reasons:`);
    reasons.forEach((r) => console.log(`  • ${r}`));
  }

  return { pass: reasons.length === 0, reasons, scan };
}

Results

After deploying the dependency security scanner:

  • CVE detection time dropped from 8 months to 24 hours — every deployment triggers a scan; critical vulnerabilities are caught before they reach production
  • Vulnerability noise reduced by 90% — risk scoring filters out theoretical vulnerabilities in deep transitive dependencies; the team sees 12 actionable findings instead of 847 raw audit results
  • License compliance automated — GPL-licensed packages in production code are blocked automatically; legal review requests trigger for borderline licenses
  • CI gate blocked 4 risky deployments in the first month — each contained critical vulnerabilities with known exploits; without the gate, they would have shipped to production
  • Auto-fix available for 60% of vulnerabilities — the scanner identifies which vulnerabilities can be fixed by upgrading a direct dependency, and which require deeper changes