Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

dependency-scanner.tsBlame795 lines · 1 contributor
da3fc18Claude1/**
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
16import { and, eq, ilike, or } from "drizzle-orm";
17import { db } from "../db";
18import {
19 issueLabels,
20 issues,
21 labels,
22 repositories,
23 users,
24} from "../db/schema";
25import { getRepoPath } from "../git/repository";
26
27// ---------------------------------------------------------------------------
28// Public types
29// ---------------------------------------------------------------------------
30
31export 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
44const DEPENDENCY_FILES = [
45 "package.json",
46 "requirements.txt",
47 "Cargo.toml",
48 "go.mod",
49 "Gemfile",
50] as const;
51
52type DependencyFile = (typeof DEPENDENCY_FILES)[number];
53
54const 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. */
67async 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. */
89async 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
114interface ParsedPackage {
115 name: string;
116 version: string;
117 ecosystem: string;
118}
119
120function 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
147function 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
162function 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
191function 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
207function 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
224function 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
247interface 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
257interface OsvResult {
258 vulns?: OsvVuln[];
259}
260
261/** Batch-query OSV for a list of packages. Returns per-package vulnerability arrays. */
262async 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. */
287function 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. */
313function 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. */
325function 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
353const WEEKLY_DIGEST_MARKER = "<!-- gluecron:dep-scan-digest:v1 -->";
354const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
355
356/** Returns the repo owner's user ID for issue attribution. */
357async 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 */
374async 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. */
404async 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. */
416async 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 */
438async 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. */
463function 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
499function 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 */
529async 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 */
578async 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. */
610function 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 */
637async 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 */
701export 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
781export 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};