CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
dependency-scanner.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| da3fc18 | 1 | /** |
| 2 | * Dependency CVE Scanner — auto-opens security issues on push. | |
| 3 | * | |
| 4 | * When a push touches package.json, requirements.txt, Cargo.toml, go.mod, | |
| 5 | * or Gemfile, this module: | |
| 6 | * 1. Reads the dependency file from the pushed commit via `git show`. | |
| 7 | * 2. Queries the OSV (Open Source Vulnerabilities) API for each package. | |
| 8 | * 3. For critical/high findings: opens an issue per CVE (deduplicates via | |
| 9 | * title ILIKE check so the same CVE never gets a second open issue). | |
| 10 | * 4. For medium/low findings: batches them into a weekly digest issue. | |
| 11 | * | |
| 12 | * Integrates into post-receive.ts as a fire-and-forget call guarded by | |
| 13 | * `DEPENDENCY_SCAN_ENABLED=1`. Never throws — every external call is wrapped. | |
| 14 | */ | |
| 15 | ||
| 16 | import { and, eq, ilike, or } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { | |
| 19 | issueLabels, | |
| 20 | issues, | |
| 21 | labels, | |
| 22 | repositories, | |
| 23 | users, | |
| 24 | } from "../db/schema"; | |
| 25 | import { getRepoPath } from "../git/repository"; | |
| 26 | ||
| 27 | // --------------------------------------------------------------------------- | |
| 28 | // Public types | |
| 29 | // --------------------------------------------------------------------------- | |
| 30 | ||
| 31 | export interface VulnFinding { | |
| 32 | packageName: string; | |
| 33 | installedVersion: string; | |
| 34 | severity: "critical" | "high" | "medium" | "low"; | |
| 35 | cveId: string; | |
| 36 | description: string; | |
| 37 | fixVersion?: string; | |
| 38 | } | |
| 39 | ||
| 40 | // --------------------------------------------------------------------------- | |
| 41 | // Ecosystem map — maps manifest filename to OSV ecosystem string | |
| 42 | // --------------------------------------------------------------------------- | |
| 43 | ||
| 44 | const DEPENDENCY_FILES = [ | |
| 45 | "package.json", | |
| 46 | "requirements.txt", | |
| 47 | "Cargo.toml", | |
| 48 | "go.mod", | |
| 49 | "Gemfile", | |
| 50 | ] as const; | |
| 51 | ||
| 52 | type DependencyFile = (typeof DEPENDENCY_FILES)[number]; | |
| 53 | ||
| 54 | const FILE_ECOSYSTEM: Record<DependencyFile, string> = { | |
| 55 | "package.json": "npm", | |
| 56 | "requirements.txt": "PyPI", | |
| 57 | "Cargo.toml": "crates.io", | |
| 58 | "go.mod": "Go", | |
| 59 | "Gemfile": "RubyGems", | |
| 60 | }; | |
| 61 | ||
| 62 | // --------------------------------------------------------------------------- | |
| 63 | // Git helpers | |
| 64 | // --------------------------------------------------------------------------- | |
| 65 | ||
| 66 | /** Reads a file at a specific commit SHA via `git show`. Returns null on failure. */ | |
| 67 | async function gitShow( | |
| 68 | owner: string, | |
| 69 | repoName: string, | |
| 70 | sha: string, | |
| 71 | filePath: string | |
| 72 | ): Promise<string | null> { | |
| 73 | try { | |
| 74 | const cwd = getRepoPath(owner, repoName); | |
| 75 | const proc = Bun.spawn( | |
| 76 | ["git", "show", `${sha}:${filePath}`], | |
| 77 | { cwd, stdout: "pipe", stderr: "pipe" } | |
| 78 | ); | |
| 79 | const text = await new Response(proc.stdout).text(); | |
| 80 | const code = await proc.exited; | |
| 81 | if (code !== 0) return null; | |
| 82 | return text; | |
| 83 | } catch { | |
| 84 | return null; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /** Returns the list of files changed between oldSha and newSha. */ | |
| 89 | async function gitDiffNames( | |
| 90 | owner: string, | |
| 91 | repoName: string, | |
| 92 | oldSha: string, | |
| 93 | newSha: string | |
| 94 | ): Promise<string[]> { | |
| 95 | try { | |
| 96 | const cwd = getRepoPath(owner, repoName); | |
| 97 | const allZero = /^0+$/.test(oldSha); | |
| 98 | const cmd = allZero | |
| 99 | ? ["git", "ls-tree", "-r", "--name-only", newSha] | |
| 100 | : ["git", "diff", "--name-only", oldSha, newSha]; | |
| 101 | const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" }); | |
| 102 | const text = await new Response(proc.stdout).text(); | |
| 103 | await proc.exited; | |
| 104 | return text.split("\n").map((s) => s.trim()).filter(Boolean); | |
| 105 | } catch { | |
| 106 | return []; | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | // --------------------------------------------------------------------------- | |
| 111 | // Manifest parsers | |
| 112 | // --------------------------------------------------------------------------- | |
| 113 | ||
| 114 | interface ParsedPackage { | |
| 115 | name: string; | |
| 116 | version: string; | |
| 117 | ecosystem: string; | |
| 118 | } | |
| 119 | ||
| 120 | function parsePackageJson(content: string, ecosystem: string): ParsedPackage[] { | |
| 121 | try { | |
| 122 | const json = JSON.parse(content) as Record<string, unknown>; | |
| 123 | const deps: Record<string, string> = {}; | |
| 124 | const raw = json["dependencies"]; | |
| 125 | const rawDev = json["devDependencies"]; | |
| 126 | if (raw && typeof raw === "object") { | |
| 127 | Object.assign(deps, raw as Record<string, string>); | |
| 128 | } | |
| 129 | if (rawDev && typeof rawDev === "object") { | |
| 130 | Object.assign(deps, rawDev as Record<string, string>); | |
| 131 | } | |
| 132 | return Object.entries(deps) | |
| 133 | .filter(([, v]) => typeof v === "string") | |
| 134 | .map(([name, versionSpec]) => ({ | |
| 135 | name, | |
| 136 | // Strip leading ^~>< to get a bare version for the OSV query. | |
| 137 | // If the spec is something like "workspace:*" we'll still query | |
| 138 | // with the empty string; OSV returns all advisories for the package. | |
| 139 | version: versionSpec.replace(/^[^0-9]*/, "").split(" ")[0] || "", | |
| 140 | ecosystem, | |
| 141 | })); | |
| 142 | } catch { | |
| 143 | return []; | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | function parseRequirementsTxt(content: string, ecosystem: string): ParsedPackage[] { | |
| 148 | const result: ParsedPackage[] = []; | |
| 149 | for (const rawLine of content.split("\n")) { | |
| 150 | const line = rawLine.trim(); | |
| 151 | if (!line || line.startsWith("#") || line.startsWith("-")) continue; | |
| 152 | // Lines like: requests==2.28.0, Django>=4.1, flask | |
| 153 | const match = line.match(/^([A-Za-z0-9_.\-]+)\s*(?:[=<>!]+\s*([0-9][^\s,;#]*))?/); | |
| 154 | if (!match) continue; | |
| 155 | const name = match[1]; | |
| 156 | const version = match[2] || ""; | |
| 157 | result.push({ name, version, ecosystem }); | |
| 158 | } | |
| 159 | return result; | |
| 160 | } | |
| 161 | ||
| 162 | function parseCargoToml(content: string, ecosystem: string): ParsedPackage[] { | |
| 163 | const result: ParsedPackage[] = []; | |
| 164 | let inDeps = false; | |
| 165 | for (const rawLine of content.split("\n")) { | |
| 166 | const line = rawLine.trim(); | |
| 167 | if (line.startsWith("[")) { | |
| 168 | inDeps = | |
| 169 | line === "[dependencies]" || | |
| 170 | line === "[dev-dependencies]" || | |
| 171 | line === "[build-dependencies]"; | |
| 172 | continue; | |
| 173 | } | |
| 174 | if (!inDeps) continue; | |
| 175 | if (!line || line.startsWith("#")) continue; | |
| 176 | // name = "1.2.3" | |
| 177 | const simpleMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*"([0-9][^"]*)"$/); | |
| 178 | if (simpleMatch) { | |
| 179 | result.push({ name: simpleMatch[1], version: simpleMatch[2], ecosystem }); | |
| 180 | continue; | |
| 181 | } | |
| 182 | // name = { version = "1.2.3", ... } | |
| 183 | const tableMatch = line.match(/^([A-Za-z0-9_\-]+)\s*=\s*\{.*version\s*=\s*"([0-9][^"]*)".*\}$/); | |
| 184 | if (tableMatch) { | |
| 185 | result.push({ name: tableMatch[1], version: tableMatch[2], ecosystem }); | |
| 186 | } | |
| 187 | } | |
| 188 | return result; | |
| 189 | } | |
| 190 | ||
| 191 | function parseGoMod(content: string, ecosystem: string): ParsedPackage[] { | |
| 192 | const result: ParsedPackage[] = []; | |
| 193 | for (const rawLine of content.split("\n")) { | |
| 194 | const line = rawLine.trim(); | |
| 195 | // Matches both block form: github.com/foo/bar v1.2.3 | |
| 196 | // and standalone require: require github.com/foo/bar v1.2.3 | |
| 197 | // Strip a leading "require " if present. | |
| 198 | const stripped = line.startsWith("require ") ? line.slice("require ".length).trim() : line; | |
| 199 | const match = stripped.match(/^([A-Za-z0-9_.\-\/]+)\s+(v[0-9][^\s]*)/); | |
| 200 | if (match && match[1] !== "go" && match[1] !== "module" && match[1] !== "toolchain") { | |
| 201 | result.push({ name: match[1], version: match[2].replace(/^v/, ""), ecosystem }); | |
| 202 | } | |
| 203 | } | |
| 204 | return result; | |
| 205 | } | |
| 206 | ||
| 207 | function parseGemfile(content: string, ecosystem: string): ParsedPackage[] { | |
| 208 | const result: ParsedPackage[] = []; | |
| 209 | for (const rawLine of content.split("\n")) { | |
| 210 | const line = rawLine.trim(); | |
| 211 | if (!line.startsWith("gem ")) continue; | |
| 212 | // gem 'rails', '~> 7.0' | |
| 213 | const nameMatch = line.match(/gem\s+['"]([A-Za-z0-9_\-]+)['"]/); | |
| 214 | if (!nameMatch) continue; | |
| 215 | const versionMatch = line.match(/,\s*['"]([~><=!^]?\s*[0-9][^\s,'"]*)['"]/); | |
| 216 | const version = versionMatch | |
| 217 | ? versionMatch[1].replace(/[^0-9.].*/, "").trim() | |
| 218 | : ""; | |
| 219 | result.push({ name: nameMatch[1], version, ecosystem }); | |
| 220 | } | |
| 221 | return result; | |
| 222 | } | |
| 223 | ||
| 224 | function parseManifest( | |
| 225 | file: DependencyFile, | |
| 226 | content: string | |
| 227 | ): ParsedPackage[] { | |
| 228 | const ecosystem = FILE_ECOSYSTEM[file]; | |
| 229 | switch (file) { | |
| 230 | case "package.json": | |
| 231 | return parsePackageJson(content, ecosystem); | |
| 232 | case "requirements.txt": | |
| 233 | return parseRequirementsTxt(content, ecosystem); | |
| 234 | case "Cargo.toml": | |
| 235 | return parseCargoToml(content, ecosystem); | |
| 236 | case "go.mod": | |
| 237 | return parseGoMod(content, ecosystem); | |
| 238 | case "Gemfile": | |
| 239 | return parseGemfile(content, ecosystem); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | // --------------------------------------------------------------------------- | |
| 244 | // OSV API | |
| 245 | // --------------------------------------------------------------------------- | |
| 246 | ||
| 247 | interface OsvVuln { | |
| 248 | id: string; | |
| 249 | summary?: string; | |
| 250 | severity?: Array<{ type: string; score: string }>; | |
| 251 | affected?: Array<{ | |
| 252 | ranges?: Array<{ type: string; events?: Array<{ introduced?: string; fixed?: string }> }>; | |
| 253 | versions?: string[]; | |
| 254 | }>; | |
| 255 | } | |
| 256 | ||
| 257 | interface OsvResult { | |
| 258 | vulns?: OsvVuln[]; | |
| 259 | } | |
| 260 | ||
| 261 | /** Batch-query OSV for a list of packages. Returns per-package vulnerability arrays. */ | |
| 262 | async function queryOsv( | |
| 263 | packages: ParsedPackage[] | |
| 264 | ): Promise<OsvResult[]> { | |
| 265 | if (packages.length === 0) return []; | |
| 266 | try { | |
| 267 | const response = await fetch("https://api.osv.dev/v1/querybatch", { | |
| 268 | method: "POST", | |
| 269 | headers: { "Content-Type": "application/json" }, | |
| 270 | body: JSON.stringify({ | |
| 271 | queries: packages.map((p) => ({ | |
| 272 | version: p.version, | |
| 273 | package: { name: p.name, ecosystem: p.ecosystem }, | |
| 274 | })), | |
| 275 | }), | |
| 276 | signal: AbortSignal.timeout(15_000), | |
| 277 | }); | |
| 278 | if (!response.ok) return packages.map(() => ({})); | |
| 279 | const data = await response.json() as { results?: OsvResult[] }; | |
| 280 | return data.results || packages.map(() => ({})); | |
| 281 | } catch { | |
| 282 | return packages.map(() => ({})); | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | /** Map OSV severity (CVSS score or type) to our severity enum. */ | |
| 287 | function mapSeverity( | |
| 288 | vuln: OsvVuln | |
| 289 | ): "critical" | "high" | "medium" | "low" { | |
| 290 | if (!vuln.severity || vuln.severity.length === 0) return "medium"; | |
| 291 | // Try CVSS score first | |
| 292 | for (const sev of vuln.severity) { | |
| 293 | if (sev.score) { | |
| 294 | const score = parseFloat(sev.score); | |
| 295 | if (!isNaN(score)) { | |
| 296 | if (score >= 9.0) return "critical"; | |
| 297 | if (score >= 7.0) return "high"; | |
| 298 | if (score >= 4.0) return "medium"; | |
| 299 | return "low"; | |
| 300 | } | |
| 301 | // Some OSV entries use CRITICAL/HIGH/MEDIUM/LOW strings | |
| 302 | const upper = sev.score.toUpperCase(); | |
| 303 | if (upper === "CRITICAL") return "critical"; | |
| 304 | if (upper === "HIGH") return "high"; | |
| 305 | if (upper === "MEDIUM" || upper === "MODERATE") return "medium"; | |
| 306 | if (upper === "LOW") return "low"; | |
| 307 | } | |
| 308 | } | |
| 309 | return "medium"; | |
| 310 | } | |
| 311 | ||
| 312 | /** Extract the fix version from the OSV affected ranges. */ | |
| 313 | function extractFixVersion(vuln: OsvVuln): string | undefined { | |
| 314 | for (const affected of vuln.affected || []) { | |
| 315 | for (const range of affected.ranges || []) { | |
| 316 | for (const event of range.events || []) { | |
| 317 | if (event.fixed) return event.fixed; | |
| 318 | } | |
| 319 | } | |
| 320 | } | |
| 321 | return undefined; | |
| 322 | } | |
| 323 | ||
| 324 | /** Convert OSV results into VulnFindings. */ | |
| 325 | function osvResultsToFindings( | |
| 326 | packages: ParsedPackage[], | |
| 327 | results: OsvResult[] | |
| 328 | ): VulnFinding[] { | |
| 329 | const findings: VulnFinding[] = []; | |
| 330 | for (let i = 0; i < packages.length; i++) { | |
| 331 | const pkg = packages[i]; | |
| 332 | const result = results[i] || {}; | |
| 333 | for (const vuln of result.vulns || []) { | |
| 334 | const cveId = vuln.id || "UNKNOWN"; | |
| 335 | const severity = mapSeverity(vuln); | |
| 336 | findings.push({ | |
| 337 | packageName: pkg.name, | |
| 338 | installedVersion: pkg.version || "unknown", | |
| 339 | severity, | |
| 340 | cveId, | |
| 341 | description: vuln.summary || "No description available.", | |
| 342 | fixVersion: extractFixVersion(vuln), | |
| 343 | }); | |
| 344 | } | |
| 345 | } | |
| 346 | return findings; | |
| 347 | } | |
| 348 | ||
| 349 | // --------------------------------------------------------------------------- | |
| 350 | // Issue creation helpers | |
| 351 | // --------------------------------------------------------------------------- | |
| 352 | ||
| 353 | const WEEKLY_DIGEST_MARKER = "<!-- gluecron:dep-scan-digest:v1 -->"; | |
| 354 | const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; | |
| 355 | ||
| 356 | /** Returns the repo owner's user ID for issue attribution. */ | |
| 357 | async function getRepoOwnerId(repoId: number | string): Promise<string | null> { | |
| 358 | try { | |
| 359 | const [row] = await db | |
| 360 | .select({ ownerId: repositories.ownerId }) | |
| 361 | .from(repositories) | |
| 362 | .where(eq(repositories.id, repoId as string)) | |
| 363 | .limit(1); | |
| 364 | return row?.ownerId || null; | |
| 365 | } catch { | |
| 366 | return null; | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | /** | |
| 371 | * Find or create a `security` label for the repo. | |
| 372 | * Returns the label ID or null on failure. | |
| 373 | */ | |
| 374 | async function getOrCreateSecurityLabel( | |
| 375 | repoId: string | |
| 376 | ): Promise<string | null> { | |
| 377 | try { | |
| 378 | const [existing] = await db | |
| 379 | .select({ id: labels.id }) | |
| 380 | .from(labels) | |
| 381 | .where( | |
| 382 | and(eq(labels.repositoryId, repoId), eq(labels.name, "security")) | |
| 383 | ) | |
| 384 | .limit(1); | |
| 385 | if (existing) return existing.id; | |
| 386 | ||
| 387 | const [inserted] = await db | |
| 388 | .insert(labels) | |
| 389 | .values({ | |
| 390 | repositoryId: repoId, | |
| 391 | name: "security", | |
| 392 | color: "#d73a4a", | |
| 393 | description: "Security vulnerability", | |
| 394 | }) | |
| 395 | .onConflictDoNothing() | |
| 396 | .returning(); | |
| 397 | return inserted?.id || null; | |
| 398 | } catch { | |
| 399 | return null; | |
| 400 | } | |
| 401 | } | |
| 402 | ||
| 403 | /** Attach a label to an issue. Best-effort; swallows errors. */ | |
| 404 | async function attachLabel(issueId: string, labelId: string): Promise<void> { | |
| 405 | try { | |
| 406 | await db | |
| 407 | .insert(issueLabels) | |
| 408 | .values({ issueId, labelId }) | |
| 409 | .onConflictDoNothing(); | |
| 410 | } catch { | |
| 411 | /* best-effort */ | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | /** Bump the repo's issue count. Best-effort. */ | |
| 416 | async function bumpIssueCount(repoId: string): Promise<void> { | |
| 417 | try { | |
| 418 | await db | |
| 419 | .update(repositories) | |
| 420 | .set({ | |
| 421 | issueCount: db.$with("cte").as( | |
| 422 | db | |
| 423 | .select({ v: repositories.issueCount }) | |
| 424 | .from(repositories) | |
| 425 | .where(eq(repositories.id, repoId)) | |
| 426 | ) as any, | |
| 427 | }) | |
| 428 | .where(eq(repositories.id, repoId)); | |
| 429 | } catch { | |
| 430 | /* best-effort */ | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | /** | |
| 435 | * Check whether an issue for this CVE already exists (open or recently closed). | |
| 436 | * Avoids spamming duplicate issues on every push. | |
| 437 | */ | |
| 438 | async function cveIssueExists( | |
| 439 | repoId: string, | |
| 440 | cveId: string | |
| 441 | ): Promise<boolean> { | |
| 442 | try { | |
| 443 | const rows = await db | |
| 444 | .select({ id: issues.id }) | |
| 445 | .from(issues) | |
| 446 | .where( | |
| 447 | and( | |
| 448 | eq(issues.repositoryId, repoId), | |
| 449 | or( | |
| 450 | ilike(issues.title, `%${cveId}%`), | |
| 451 | ilike(issues.body, `%${cveId}%`) | |
| 452 | ) | |
| 453 | ) | |
| 454 | ) | |
| 455 | .limit(1); | |
| 456 | return rows.length > 0; | |
| 457 | } catch { | |
| 458 | return false; | |
| 459 | } | |
| 460 | } | |
| 461 | ||
| 462 | /** Render an issue body for a critical/high finding. */ | |
| 463 | function renderVulnIssueBody(f: VulnFinding): string { | |
| 464 | const fix = f.fixVersion | |
| 465 | ? `Upgrade to **${f.fixVersion}** or later.` | |
| 466 | : "No fix version is currently available. Monitor the advisory for updates."; | |
| 467 | ||
| 468 | const impact = severityImpactText(f.severity); | |
| 469 | ||
| 470 | return [ | |
| 471 | `> **Automated security scan.** This issue was opened by GlueCron's dependency scanner after detecting a vulnerable dependency in a recent push.`, | |
| 472 | "", | |
| 473 | `## Vulnerability details`, | |
| 474 | "", | |
| 475 | `| Field | Value |`, | |
| 476 | `|---|---|`, | |
| 477 | `| Package | \`${f.packageName}\` |`, | |
| 478 | `| Installed version | \`${f.installedVersion}\` |`, | |
| 479 | `| Severity | **${f.severity.toUpperCase()}** |`, | |
| 480 | `| CVE / Advisory | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) |`, | |
| 481 | "", | |
| 482 | `## Description`, | |
| 483 | "", | |
| 484 | f.description, | |
| 485 | "", | |
| 486 | `## Remediation`, | |
| 487 | "", | |
| 488 | fix, | |
| 489 | "", | |
| 490 | `## Impact & urgency`, | |
| 491 | "", | |
| 492 | impact, | |
| 493 | "", | |
| 494 | `---`, | |
| 495 | `_This issue was auto-generated by the GlueCron dependency scanner. Close it once the vulnerability is resolved or confirmed not applicable._`, | |
| 496 | ].join("\n"); | |
| 497 | } | |
| 498 | ||
| 499 | function severityImpactText(severity: VulnFinding["severity"]): string { | |
| 500 | switch (severity) { | |
| 501 | case "critical": | |
| 502 | return ( | |
| 503 | "This is a **CRITICAL** vulnerability. It should be addressed immediately — " + | |
| 504 | "critical CVEs are commonly exploited in the wild and can lead to full system compromise, " + | |
| 505 | "data exfiltration, or remote code execution. Treat this as an emergency patch." | |
| 506 | ); | |
| 507 | case "high": | |
| 508 | return ( | |
| 509 | "This is a **HIGH** severity vulnerability. It should be patched in the next sprint at the latest. " + | |
| 510 | "High severity issues often allow privilege escalation, authentication bypass, or significant data leakage." | |
| 511 | ); | |
| 512 | case "medium": | |
| 513 | return ( | |
| 514 | "This is a **MEDIUM** severity vulnerability. It should be addressed in the next maintenance window. " + | |
| 515 | "Medium issues typically require specific conditions to exploit but should not be ignored." | |
| 516 | ); | |
| 517 | case "low": | |
| 518 | return ( | |
| 519 | "This is a **LOW** severity vulnerability. While lower risk, it should be tracked " + | |
| 520 | "and resolved as part of routine dependency maintenance." | |
| 521 | ); | |
| 522 | } | |
| 523 | } | |
| 524 | ||
| 525 | /** | |
| 526 | * Open a single issue for a critical/high CVE finding. | |
| 527 | * Returns the issue number, or null if skipped/failed. | |
| 528 | */ | |
| 529 | async function openVulnIssue( | |
| 530 | repoId: string, | |
| 531 | authorId: string, | |
| 532 | finding: VulnFinding, | |
| 533 | labelId: string | null | |
| 534 | ): Promise<number | null> { | |
| 535 | // Dedup: skip if an issue for this CVE already exists. | |
| 536 | const exists = await cveIssueExists(repoId, finding.cveId); | |
| 537 | if (exists) return null; | |
| 538 | ||
| 539 | const title = `[CVE] ${finding.packageName} ${finding.installedVersion} — ${finding.severity.toUpperCase()} vulnerability (${finding.cveId})`; | |
| 540 | const body = renderVulnIssueBody(finding); | |
| 541 | ||
| 542 | try { | |
| 543 | const [inserted] = await db | |
| 544 | .insert(issues) | |
| 545 | .values({ repositoryId: repoId, authorId, title, body, state: "open" }) | |
| 546 | .returning(); | |
| 547 | if (!inserted) return null; | |
| 548 | ||
| 549 | if (labelId) await attachLabel(inserted.id, labelId); | |
| 550 | ||
| 551 | // Bump issue count. | |
| 552 | try { | |
| 553 | const [repo] = await db | |
| 554 | .select({ issueCount: repositories.issueCount }) | |
| 555 | .from(repositories) | |
| 556 | .where(eq(repositories.id, repoId)) | |
| 557 | .limit(1); | |
| 558 | if (repo) { | |
| 559 | await db | |
| 560 | .update(repositories) | |
| 561 | .set({ issueCount: (repo.issueCount || 0) + 1 }) | |
| 562 | .where(eq(repositories.id, repoId)); | |
| 563 | } | |
| 564 | } catch { | |
| 565 | /* best-effort */ | |
| 566 | } | |
| 567 | ||
| 568 | return inserted.number; | |
| 569 | } catch { | |
| 570 | return null; | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | /** | |
| 575 | * Find the weekly digest issue for this repo, if one was already opened this week. | |
| 576 | * Returns the issue ID or null. | |
| 577 | */ | |
| 578 | async function findThisWeeksDigestIssue( | |
| 579 | repoId: string | |
| 580 | ): Promise<{ id: string; number: number } | null> { | |
| 581 | try { | |
| 582 | const weekAgo = new Date(Date.now() - MS_PER_WEEK); | |
| 583 | const rows = await db | |
| 584 | .select({ id: issues.id, number: issues.number, body: issues.body, createdAt: issues.createdAt }) | |
| 585 | .from(issues) | |
| 586 | .where( | |
| 587 | and( | |
| 588 | eq(issues.repositoryId, repoId), | |
| 589 | eq(issues.state, "open"), | |
| 590 | ilike(issues.title, "%Dependency scan digest%") | |
| 591 | ) | |
| 592 | ) | |
| 593 | .limit(5); | |
| 594 | ||
| 595 | for (const row of rows) { | |
| 596 | if ( | |
| 597 | row.body?.includes(WEEKLY_DIGEST_MARKER) && | |
| 598 | row.createdAt >= weekAgo | |
| 599 | ) { | |
| 600 | return { id: row.id, number: row.number }; | |
| 601 | } | |
| 602 | } | |
| 603 | return null; | |
| 604 | } catch { | |
| 605 | return null; | |
| 606 | } | |
| 607 | } | |
| 608 | ||
| 609 | /** Render the digest body for medium/low findings. */ | |
| 610 | function renderDigestBody(findings: VulnFinding[]): string { | |
| 611 | const rows = findings | |
| 612 | .map( | |
| 613 | (f) => | |
| 614 | `| \`${f.packageName}\` | \`${f.installedVersion}\` | ${f.severity.toUpperCase()} | [${f.cveId}](https://osv.dev/vulnerability/${f.cveId}) | ${f.fixVersion ? `\`${f.fixVersion}\`` : "None available"} |` | |
| 615 | ) | |
| 616 | .join("\n"); | |
| 617 | ||
| 618 | return [ | |
| 619 | WEEKLY_DIGEST_MARKER, | |
| 620 | "", | |
| 621 | `> Automated weekly digest of medium/low severity dependency vulnerabilities detected by GlueCron.`, | |
| 622 | "", | |
| 623 | `## Findings`, | |
| 624 | "", | |
| 625 | `| Package | Version | Severity | Advisory | Fix |`, | |
| 626 | `|---|---|---|---|---|`, | |
| 627 | rows, | |
| 628 | "", | |
| 629 | `---`, | |
| 630 | `_This digest is updated by GlueCron on each push that touches dependency files. Close once all findings are resolved or accepted._`, | |
| 631 | ].join("\n"); | |
| 632 | } | |
| 633 | ||
| 634 | /** | |
| 635 | * Open or update the weekly digest issue for medium/low findings. | |
| 636 | */ | |
| 637 | async function upsertDigestIssue( | |
| 638 | repoId: string, | |
| 639 | authorId: string, | |
| 640 | findings: VulnFinding[], | |
| 641 | labelId: string | null | |
| 642 | ): Promise<void> { | |
| 643 | if (findings.length === 0) return; | |
| 644 | ||
| 645 | const existing = await findThisWeeksDigestIssue(repoId); | |
| 646 | const title = `Dependency scan digest — ${new Date().toISOString().slice(0, 10)}`; | |
| 647 | const body = renderDigestBody(findings); | |
| 648 | ||
| 649 | try { | |
| 650 | if (existing) { | |
| 651 | // Update the existing digest issue body. | |
| 652 | await db | |
| 653 | .update(issues) | |
| 654 | .set({ body, updatedAt: new Date() }) | |
| 655 | .where(eq(issues.id, existing.id)); | |
| 656 | } else { | |
| 657 | // Open a new digest issue. | |
| 658 | const [inserted] = await db | |
| 659 | .insert(issues) | |
| 660 | .values({ repositoryId: repoId, authorId, title, body, state: "open" }) | |
| 661 | .returning(); | |
| 662 | if (inserted && labelId) await attachLabel(inserted.id, labelId); | |
| 663 | if (inserted) { | |
| 664 | try { | |
| 665 | const [repo] = await db | |
| 666 | .select({ issueCount: repositories.issueCount }) | |
| 667 | .from(repositories) | |
| 668 | .where(eq(repositories.id, repoId)) | |
| 669 | .limit(1); | |
| 670 | if (repo) { | |
| 671 | await db | |
| 672 | .update(repositories) | |
| 673 | .set({ issueCount: (repo.issueCount || 0) + 1 }) | |
| 674 | .where(eq(repositories.id, repoId)); | |
| 675 | } | |
| 676 | } catch { | |
| 677 | /* best-effort */ | |
| 678 | } | |
| 679 | } | |
| 680 | } | |
| 681 | } catch { | |
| 682 | /* best-effort */ | |
| 683 | } | |
| 684 | } | |
| 685 | ||
| 686 | // --------------------------------------------------------------------------- | |
| 687 | // Main entry point | |
| 688 | // --------------------------------------------------------------------------- | |
| 689 | ||
| 690 | /** | |
| 691 | * Scans the dependency files changed in a push and opens/updates security issues. | |
| 692 | * | |
| 693 | * @param repoId - DB repository UUID | |
| 694 | * @param owner - Repository owner username | |
| 695 | * @param repoName - Repository name | |
| 696 | * @param headSha - The new commit SHA (used to read dependency files) | |
| 697 | * @param oldSha - The previous commit SHA (used to detect changed files) | |
| 698 | * @param pusherUserId - The user who pushed (used as issue author) | |
| 699 | * @returns Array of VulnFindings found (may be empty) | |
| 700 | */ | |
| 701 | export async function scanDependencies( | |
| 702 | repoId: string, | |
| 703 | owner: string, | |
| 704 | repoName: string, | |
| 705 | headSha: string, | |
| 706 | oldSha: string, | |
| 707 | pusherUserId: string | |
| 708 | ): Promise<VulnFinding[]> { | |
| 709 | try { | |
| 710 | // 1. Detect which dependency files changed in this push. | |
| 711 | const changedFiles = await gitDiffNames(owner, repoName, oldSha, headSha); | |
| 712 | const relevantFiles = DEPENDENCY_FILES.filter((f) => | |
| 713 | changedFiles.includes(f) | |
| 714 | ); | |
| 715 | if (relevantFiles.length === 0) return []; | |
| 716 | ||
| 717 | // 2. Parse all changed dependency files into a package list. | |
| 718 | const allPackages: ParsedPackage[] = []; | |
| 719 | for (const file of relevantFiles) { | |
| 720 | const content = await gitShow(owner, repoName, headSha, file); | |
| 721 | if (!content) continue; | |
| 722 | const parsed = parseManifest(file, content); | |
| 723 | allPackages.push(...parsed); | |
| 724 | } | |
| 725 | if (allPackages.length === 0) return []; | |
| 726 | ||
| 727 | // 3. Deduplicate by (name, version, ecosystem) to avoid redundant OSV queries. | |
| 728 | const seen = new Set<string>(); | |
| 729 | const uniquePackages = allPackages.filter((p) => { | |
| 730 | const key = `${p.ecosystem}:${p.name}@${p.version}`; | |
| 731 | if (seen.has(key)) return false; | |
| 732 | seen.add(key); | |
| 733 | return true; | |
| 734 | }); | |
| 735 | ||
| 736 | // 4. Query OSV in batches of 200 (API limit). | |
| 737 | const BATCH = 200; | |
| 738 | const findings: VulnFinding[] = []; | |
| 739 | for (let i = 0; i < uniquePackages.length; i += BATCH) { | |
| 740 | const batch = uniquePackages.slice(i, i + BATCH); | |
| 741 | const results = await queryOsv(batch); | |
| 742 | findings.push(...osvResultsToFindings(batch, results)); | |
| 743 | } | |
| 744 | ||
| 745 | if (findings.length === 0) return []; | |
| 746 | ||
| 747 | // 5. Ensure the security label exists. | |
| 748 | const labelId = await getOrCreateSecurityLabel(repoId); | |
| 749 | ||
| 750 | // 6. Use pusherUserId as author; fall back to repo owner if not available. | |
| 751 | const authorId = pusherUserId || (await getRepoOwnerId(repoId)) || ""; | |
| 752 | if (!authorId) return findings; | |
| 753 | ||
| 754 | // 7. Separate critical/high from medium/low. | |
| 755 | const urgent = findings.filter( | |
| 756 | (f) => f.severity === "critical" || f.severity === "high" | |
| 757 | ); | |
| 758 | const digest = findings.filter( | |
| 759 | (f) => f.severity === "medium" || f.severity === "low" | |
| 760 | ); | |
| 761 | ||
| 762 | // 8. Open one issue per critical/high CVE (idempotent via dedup check). | |
| 763 | for (const f of urgent) { | |
| 764 | await openVulnIssue(repoId, authorId, f, labelId).catch(() => {}); | |
| 765 | } | |
| 766 | ||
| 767 | // 9. Upsert the weekly digest for medium/low findings. | |
| 768 | await upsertDigestIssue(repoId, authorId, digest, labelId).catch(() => {}); | |
| 769 | ||
| 770 | return findings; | |
| 771 | } catch (err) { | |
| 772 | console.error("[dependency-scanner] unexpected error:", err); | |
| 773 | return []; | |
| 774 | } | |
| 775 | } | |
| 776 | ||
| 777 | // --------------------------------------------------------------------------- | |
| 778 | // Test seam | |
| 779 | // --------------------------------------------------------------------------- | |
| 780 | ||
| 781 | export const __internal = { | |
| 782 | parsePackageJson, | |
| 783 | parseRequirementsTxt, | |
| 784 | parseCargoToml, | |
| 785 | parseGoMod, | |
| 786 | parseGemfile, | |
| 787 | mapSeverity, | |
| 788 | extractFixVersion, | |
| 789 | osvResultsToFindings, | |
| 790 | renderVulnIssueBody, | |
| 791 | renderDigestBody, | |
| 792 | WEEKLY_DIGEST_MARKER, | |
| 793 | FILE_ECOSYSTEM, | |
| 794 | DEPENDENCY_FILES, | |
| 795 | }; |