CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
dependency-scanner.test.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 — unit tests. | |
| 3 | * | |
| 4 | * Tests the pure parsing helpers, the OSV result mapper, and issue-body | |
| 5 | * renderers without touching the DB or network. | |
| 6 | */ | |
| 7 | ||
| 8 | import { describe, it, expect } from "bun:test"; | |
| 9 | import { __internal } from "../lib/dependency-scanner"; | |
| 10 | import app from "../app"; | |
| 11 | ||
| 12 | const { | |
| 13 | parsePackageJson, | |
| 14 | parseRequirementsTxt, | |
| 15 | parseCargoToml, | |
| 16 | parseGoMod, | |
| 17 | parseGemfile, | |
| 18 | mapSeverity, | |
| 19 | extractFixVersion, | |
| 20 | osvResultsToFindings, | |
| 21 | renderVulnIssueBody, | |
| 22 | renderDigestBody, | |
| 23 | WEEKLY_DIGEST_MARKER, | |
| 24 | FILE_ECOSYSTEM, | |
| 25 | DEPENDENCY_FILES, | |
| 26 | } = __internal; | |
| 27 | ||
| 28 | // --------------------------------------------------------------------------- | |
| 29 | // parsePackageJson | |
| 30 | // --------------------------------------------------------------------------- | |
| 31 | ||
| 32 | describe("parsePackageJson", () => { | |
| 33 | it("parses dependencies and devDependencies", () => { | |
| 34 | const json = JSON.stringify({ | |
| 35 | dependencies: { lodash: "^4.17.11", express: "^4.18.0" }, | |
| 36 | devDependencies: { jest: "^29.0.0" }, | |
| 37 | }); | |
| 38 | const result = parsePackageJson(json, "npm"); | |
| 39 | expect(result.length).toBe(3); | |
| 40 | expect(result.map((p) => p.name)).toContain("lodash"); | |
| 41 | expect(result.map((p) => p.name)).toContain("express"); | |
| 42 | expect(result.map((p) => p.name)).toContain("jest"); | |
| 43 | }); | |
| 44 | ||
| 45 | it("strips leading ^ ~ from version specs", () => { | |
| 46 | const json = JSON.stringify({ dependencies: { react: "^18.2.0" } }); | |
| 47 | const result = parsePackageJson(json, "npm"); | |
| 48 | expect(result[0].version).toBe("18.2.0"); | |
| 49 | }); | |
| 50 | ||
| 51 | it("returns [] on invalid JSON", () => { | |
| 52 | expect(parsePackageJson("not json", "npm")).toEqual([]); | |
| 53 | }); | |
| 54 | ||
| 55 | it("sets correct ecosystem", () => { | |
| 56 | const json = JSON.stringify({ dependencies: { foo: "1.0.0" } }); | |
| 57 | const result = parsePackageJson(json, "npm"); | |
| 58 | expect(result[0].ecosystem).toBe("npm"); | |
| 59 | }); | |
| 60 | ||
| 61 | it("handles empty dependency sections", () => { | |
| 62 | const json = JSON.stringify({ name: "my-app" }); | |
| 63 | expect(parsePackageJson(json, "npm")).toEqual([]); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | // --------------------------------------------------------------------------- | |
| 68 | // parseRequirementsTxt | |
| 69 | // --------------------------------------------------------------------------- | |
| 70 | ||
| 71 | describe("parseRequirementsTxt", () => { | |
| 72 | it("parses pinned versions", () => { | |
| 73 | const content = "requests==2.28.0\nDjango>=4.1\nflask\n"; | |
| 74 | const result = parseRequirementsTxt(content, "PyPI"); | |
| 75 | expect(result.map((p) => p.name)).toContain("requests"); | |
| 76 | expect(result.find((p) => p.name === "requests")?.version).toBe("2.28.0"); | |
| 77 | expect(result.map((p) => p.name)).toContain("Django"); | |
| 78 | expect(result.map((p) => p.name)).toContain("flask"); | |
| 79 | }); | |
| 80 | ||
| 81 | it("skips comments and -r flags", () => { | |
| 82 | const content = "# comment\n-r base.txt\nnumpy==1.24.0\n"; | |
| 83 | const result = parseRequirementsTxt(content, "PyPI"); | |
| 84 | expect(result.length).toBe(1); | |
| 85 | expect(result[0].name).toBe("numpy"); | |
| 86 | }); | |
| 87 | ||
| 88 | it("returns [] for empty content", () => { | |
| 89 | expect(parseRequirementsTxt("", "PyPI")).toEqual([]); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | // --------------------------------------------------------------------------- | |
| 94 | // parseCargoToml | |
| 95 | // --------------------------------------------------------------------------- | |
| 96 | ||
| 97 | describe("parseCargoToml", () => { | |
| 98 | it("parses simple string versions", () => { | |
| 99 | const content = `[dependencies]\nserde = "1.0.163"\ntokio = "1.28.0"\n`; | |
| 100 | const result = parseCargoToml(content, "crates.io"); | |
| 101 | expect(result.map((p) => p.name)).toContain("serde"); | |
| 102 | expect(result.find((p) => p.name === "serde")?.version).toBe("1.0.163"); | |
| 103 | expect(result.find((p) => p.name === "tokio")?.version).toBe("1.28.0"); | |
| 104 | }); | |
| 105 | ||
| 106 | it("parses table-style versions", () => { | |
| 107 | const content = `[dependencies]\nactix-web = { version = "4.3.0", features = ["macros"] }\n`; | |
| 108 | const result = parseCargoToml(content, "crates.io"); | |
| 109 | expect(result.find((p) => p.name === "actix-web")?.version).toBe("4.3.0"); | |
| 110 | }); | |
| 111 | ||
| 112 | it("skips [package] and other sections", () => { | |
| 113 | const content = `[package]\nname = "my-crate"\n\n[dependencies]\nhyper = "0.14.27"\n`; | |
| 114 | const result = parseCargoToml(content, "crates.io"); | |
| 115 | expect(result.length).toBe(1); | |
| 116 | expect(result[0].name).toBe("hyper"); | |
| 117 | }); | |
| 118 | ||
| 119 | it("returns [] for empty content", () => { | |
| 120 | expect(parseCargoToml("", "crates.io")).toEqual([]); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | // --------------------------------------------------------------------------- | |
| 125 | // parseGoMod | |
| 126 | // --------------------------------------------------------------------------- | |
| 127 | ||
| 128 | describe("parseGoMod", () => { | |
| 129 | it("parses require lines", () => { | |
| 130 | const content = `module example.com/myapp\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n\tgolang.org/x/net v0.10.0\n)\n`; | |
| 131 | const result = parseGoMod(content, "Go"); | |
| 132 | expect(result.map((p) => p.name)).toContain("github.com/gin-gonic/gin"); | |
| 133 | expect(result.find((p) => p.name === "github.com/gin-gonic/gin")?.version).toBe("1.9.0"); | |
| 134 | }); | |
| 135 | ||
| 136 | it("strips leading v from version", () => { | |
| 137 | const content = `require github.com/foo/bar v2.1.0\n`; | |
| 138 | const result = parseGoMod(content, "Go"); | |
| 139 | expect(result[0].version).toBe("2.1.0"); | |
| 140 | }); | |
| 141 | ||
| 142 | it("returns [] for content with no require lines", () => { | |
| 143 | expect(parseGoMod("module foo\n\ngo 1.21\n", "Go")).toEqual([]); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 147 | // --------------------------------------------------------------------------- | |
| 148 | // parseGemfile | |
| 149 | // --------------------------------------------------------------------------- | |
| 150 | ||
| 151 | describe("parseGemfile", () => { | |
| 152 | it("parses gem declarations", () => { | |
| 153 | const content = `source 'https://rubygems.org'\ngem 'rails', '~> 7.0'\ngem 'pg'\n`; | |
| 154 | const result = parseGemfile(content, "RubyGems"); | |
| 155 | expect(result.map((p) => p.name)).toContain("rails"); | |
| 156 | expect(result.map((p) => p.name)).toContain("pg"); | |
| 157 | }); | |
| 158 | ||
| 159 | it("returns [] for non-gem lines", () => { | |
| 160 | expect(parseGemfile("source 'https://rubygems.org'\n", "RubyGems")).toEqual([]); | |
| 161 | }); | |
| 162 | }); | |
| 163 | ||
| 164 | // --------------------------------------------------------------------------- | |
| 165 | // mapSeverity | |
| 166 | // --------------------------------------------------------------------------- | |
| 167 | ||
| 168 | describe("mapSeverity", () => { | |
| 169 | it("returns 'critical' for CVSS score >= 9.0", () => { | |
| 170 | expect( | |
| 171 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "9.5" }] }) | |
| 172 | ).toBe("critical"); | |
| 173 | }); | |
| 174 | ||
| 175 | it("returns 'high' for CVSS score >= 7.0", () => { | |
| 176 | expect( | |
| 177 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "8.1" }] }) | |
| 178 | ).toBe("high"); | |
| 179 | }); | |
| 180 | ||
| 181 | it("returns 'medium' for CVSS score >= 4.0", () => { | |
| 182 | expect( | |
| 183 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "5.3" }] }) | |
| 184 | ).toBe("medium"); | |
| 185 | }); | |
| 186 | ||
| 187 | it("returns 'low' for CVSS score < 4.0", () => { | |
| 188 | expect( | |
| 189 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "2.1" }] }) | |
| 190 | ).toBe("low"); | |
| 191 | }); | |
| 192 | ||
| 193 | it("handles string CRITICAL severity", () => { | |
| 194 | expect( | |
| 195 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "CRITICAL" }] }) | |
| 196 | ).toBe("critical"); | |
| 197 | }); | |
| 198 | ||
| 199 | it("handles string HIGH severity", () => { | |
| 200 | expect( | |
| 201 | mapSeverity({ id: "x", severity: [{ type: "CVSS_V3", score: "HIGH" }] }) | |
| 202 | ).toBe("high"); | |
| 203 | }); | |
| 204 | ||
| 205 | it("returns 'medium' when no severity data", () => { | |
| 206 | expect(mapSeverity({ id: "x" })).toBe("medium"); | |
| 207 | expect(mapSeverity({ id: "x", severity: [] })).toBe("medium"); | |
| 208 | }); | |
| 209 | }); | |
| 210 | ||
| 211 | // --------------------------------------------------------------------------- | |
| 212 | // extractFixVersion | |
| 213 | // --------------------------------------------------------------------------- | |
| 214 | ||
| 215 | describe("extractFixVersion", () => { | |
| 216 | it("extracts fix version from affected ranges", () => { | |
| 217 | const vuln = { | |
| 218 | id: "CVE-2021-44228", | |
| 219 | affected: [ | |
| 220 | { | |
| 221 | ranges: [ | |
| 222 | { | |
| 223 | type: "SEMVER", | |
| 224 | events: [{ introduced: "2.0.0" }, { fixed: "2.15.0" }], | |
| 225 | }, | |
| 226 | ], | |
| 227 | }, | |
| 228 | ], | |
| 229 | }; | |
| 230 | expect(extractFixVersion(vuln)).toBe("2.15.0"); | |
| 231 | }); | |
| 232 | ||
| 233 | it("returns undefined when no fix is available", () => { | |
| 234 | const vuln = { | |
| 235 | id: "CVE-XXXX", | |
| 236 | affected: [{ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }] }] }], | |
| 237 | }; | |
| 238 | expect(extractFixVersion(vuln)).toBeUndefined(); | |
| 239 | }); | |
| 240 | ||
| 241 | it("returns undefined for vulns with no affected data", () => { | |
| 242 | expect(extractFixVersion({ id: "x" })).toBeUndefined(); | |
| 243 | }); | |
| 244 | }); | |
| 245 | ||
| 246 | // --------------------------------------------------------------------------- | |
| 247 | // osvResultsToFindings | |
| 248 | // --------------------------------------------------------------------------- | |
| 249 | ||
| 250 | describe("osvResultsToFindings", () => { | |
| 251 | it("converts OSV results to VulnFindings", () => { | |
| 252 | const packages = [{ name: "lodash", version: "4.17.11", ecosystem: "npm" }]; | |
| 253 | const results = [ | |
| 254 | { | |
| 255 | vulns: [ | |
| 256 | { | |
| 257 | id: "GHSA-p6mc-m468-83gw", | |
| 258 | summary: "Prototype pollution in lodash", | |
| 259 | severity: [{ type: "CVSS_V3", score: "9.8" }], | |
| 260 | affected: [ | |
| 261 | { | |
| 262 | ranges: [ | |
| 263 | { type: "SEMVER", events: [{ introduced: "0" }, { fixed: "4.17.12" }] }, | |
| 264 | ], | |
| 265 | }, | |
| 266 | ], | |
| 267 | }, | |
| 268 | ], | |
| 269 | }, | |
| 270 | ]; | |
| 271 | const findings = osvResultsToFindings(packages, results); | |
| 272 | expect(findings).toHaveLength(1); | |
| 273 | expect(findings[0].packageName).toBe("lodash"); | |
| 274 | expect(findings[0].installedVersion).toBe("4.17.11"); | |
| 275 | expect(findings[0].severity).toBe("critical"); | |
| 276 | expect(findings[0].cveId).toBe("GHSA-p6mc-m468-83gw"); | |
| 277 | expect(findings[0].fixVersion).toBe("4.17.12"); | |
| 278 | }); | |
| 279 | ||
| 280 | it("returns [] when no vulns in results", () => { | |
| 281 | const packages = [{ name: "safe-pkg", version: "1.0.0", ecosystem: "npm" }]; | |
| 282 | const results = [{ vulns: [] }]; | |
| 283 | expect(osvResultsToFindings(packages, results)).toEqual([]); | |
| 284 | }); | |
| 285 | ||
| 286 | it("handles mismatched results length gracefully", () => { | |
| 287 | const packages = [{ name: "pkg-a", version: "1.0.0", ecosystem: "npm" }]; | |
| 288 | const findings = osvResultsToFindings(packages, []); | |
| 289 | expect(findings).toEqual([]); | |
| 290 | }); | |
| 291 | }); | |
| 292 | ||
| 293 | // --------------------------------------------------------------------------- | |
| 294 | // renderVulnIssueBody | |
| 295 | // --------------------------------------------------------------------------- | |
| 296 | ||
| 297 | describe("renderVulnIssueBody", () => { | |
| 298 | const finding = { | |
| 299 | packageName: "lodash", | |
| 300 | installedVersion: "4.17.11", | |
| 301 | severity: "critical" as const, | |
| 302 | cveId: "GHSA-p6mc-m468-83gw", | |
| 303 | description: "Prototype pollution.", | |
| 304 | fixVersion: "4.17.12", | |
| 305 | }; | |
| 306 | ||
| 307 | it("includes package name and version", () => { | |
| 308 | const body = renderVulnIssueBody(finding); | |
| 309 | expect(body).toContain("lodash"); | |
| 310 | expect(body).toContain("4.17.11"); | |
| 311 | }); | |
| 312 | ||
| 313 | it("includes CVE ID with OSV link", () => { | |
| 314 | const body = renderVulnIssueBody(finding); | |
| 315 | expect(body).toContain("GHSA-p6mc-m468-83gw"); | |
| 316 | expect(body).toContain("https://osv.dev/vulnerability/GHSA-p6mc-m468-83gw"); | |
| 317 | }); | |
| 318 | ||
| 319 | it("includes fix version in remediation", () => { | |
| 320 | const body = renderVulnIssueBody(finding); | |
| 321 | expect(body).toContain("4.17.12"); | |
| 322 | }); | |
| 323 | ||
| 324 | it("mentions 'No fix' when fixVersion is absent", () => { | |
| 325 | const body = renderVulnIssueBody({ ...finding, fixVersion: undefined }); | |
| 326 | expect(body).toContain("No fix version"); | |
| 327 | }); | |
| 328 | ||
| 329 | it("includes CRITICAL urgency text for critical severity", () => { | |
| 330 | const body = renderVulnIssueBody(finding); | |
| 331 | expect(body).toContain("CRITICAL"); | |
| 332 | }); | |
| 333 | }); | |
| 334 | ||
| 335 | // --------------------------------------------------------------------------- | |
| 336 | // renderDigestBody | |
| 337 | // --------------------------------------------------------------------------- | |
| 338 | ||
| 339 | describe("renderDigestBody", () => { | |
| 340 | const findings = [ | |
| 341 | { | |
| 342 | packageName: "minimist", | |
| 343 | installedVersion: "1.2.0", | |
| 344 | severity: "medium" as const, | |
| 345 | cveId: "CVE-2020-7598", | |
| 346 | description: "Prototype pollution.", | |
| 347 | fixVersion: "1.2.2", | |
| 348 | }, | |
| 349 | ]; | |
| 350 | ||
| 351 | it("includes the weekly digest marker", () => { | |
| 352 | expect(renderDigestBody(findings)).toContain(WEEKLY_DIGEST_MARKER); | |
| 353 | }); | |
| 354 | ||
| 355 | it("includes package name and CVE in the table", () => { | |
| 356 | const body = renderDigestBody(findings); | |
| 357 | expect(body).toContain("minimist"); | |
| 358 | expect(body).toContain("CVE-2020-7598"); | |
| 359 | }); | |
| 360 | ||
| 361 | it("returns valid markdown table", () => { | |
| 362 | const body = renderDigestBody(findings); | |
| 363 | expect(body).toContain("| Package |"); | |
| 364 | expect(body).toContain("|---|"); | |
| 365 | }); | |
| 366 | }); | |
| 367 | ||
| 368 | // --------------------------------------------------------------------------- | |
| 369 | // Constants | |
| 370 | // --------------------------------------------------------------------------- | |
| 371 | ||
| 372 | describe("constants", () => { | |
| 373 | it("DEPENDENCY_FILES contains the five expected files", () => { | |
| 374 | expect(DEPENDENCY_FILES).toContain("package.json"); | |
| 375 | expect(DEPENDENCY_FILES).toContain("requirements.txt"); | |
| 376 | expect(DEPENDENCY_FILES).toContain("Cargo.toml"); | |
| 377 | expect(DEPENDENCY_FILES).toContain("go.mod"); | |
| 378 | expect(DEPENDENCY_FILES).toContain("Gemfile"); | |
| 379 | }); | |
| 380 | ||
| 381 | it("FILE_ECOSYSTEM maps each file to an ecosystem", () => { | |
| 382 | expect(FILE_ECOSYSTEM["package.json"]).toBe("npm"); | |
| 383 | expect(FILE_ECOSYSTEM["requirements.txt"]).toBe("PyPI"); | |
| 384 | expect(FILE_ECOSYSTEM["Cargo.toml"]).toBe("crates.io"); | |
| 385 | expect(FILE_ECOSYSTEM["go.mod"]).toBe("Go"); | |
| 386 | expect(FILE_ECOSYSTEM["Gemfile"]).toBe("RubyGems"); | |
| 387 | }); | |
| 388 | }); | |
| 389 | ||
| 390 | // --------------------------------------------------------------------------- | |
| 391 | // Route auth smoke tests | |
| 392 | // --------------------------------------------------------------------------- | |
| 393 | ||
| 394 | describe("GET /:owner/:repo/security/vulnerabilities — route auth", () => { | |
| 395 | it("returns 404 for non-existent owner", async () => { | |
| 396 | const res = await app.request( | |
| 397 | "/nonexistent-owner-99999/my-repo/security/vulnerabilities" | |
| 398 | ); | |
| 399 | expect(res.status).toBe(404); | |
| 400 | }); | |
| 401 | }); |