1/** 2 * SBOM Processor - Analyzes and processes SBOM data 3 * BUILD: 1764042481675 (with repository association fix for all dependencies) 4 */ 5console.log('ð¦ SBOM Processor loaded - BUILD: 1764042481675 (repository association fix)'); 6 7class SBOMProcessor { 8 constructor() { 9 this.dependencies = new Map(); 10 this.repositories = new Map(); 11 this.totalRepos = 0; 12 this.processedRepos = 0; 13 this.successfulRepos = 0; 14 this.failedRepos = 0; 15 16 // Initialize license processor 17 this.licenseProcessor = new LicenseProcessor(); 18 19 // Initialize quality processor 20 this.qualityProcessor = window.SBOMQualityProcessor ? new window.SBOMQualityProcessor() : null; 21 22 // Initialize version resolver (will be created once when needed) 23 this.versionResolver = null; 24 25 // GitHub Actions analysis results 26 this.githubActionsAnalysis = null; 27 28 // Categorization mappings 29 this.purlTypeMap = { 30 'pypi': { type: 'code', language: 'Python', ecosystem: 'PyPI' }, 31 'npm': { type: 'code', language: 'JavaScript', ecosystem: 'npm' }, 32 'maven': { type: 'code', language: 'Java', ecosystem: 'Maven' }, 33 'nuget': { type: 'code', language: 'C#', ecosystem: 'NuGet' }, 34 'cargo': { type: 'code', language: 'Rust', ecosystem: 'Cargo' }, 35 'composer': { type: 'code', language: 'PHP', ecosystem: 'Composer' }, 36 'go': { type: 'code', language: 'Go', ecosystem: 'Go' }, 37 'githubactions': { type: 'workflow', language: 'YAML', ecosystem: 'GitHub Actions' }, 38 'github': { type: 'infrastructure', language: 'Various', ecosystem: 'GitHub' }, 39 'docker': { type: 'infrastructure', language: 'Various', ecosystem: 'Docker' }, 40 'helm': { type: 'infrastructure', language: 'YAML', ecosystem: 'Helm' }, 41 'terraform': { type: 'infrastructure', language: 'HCL', ec
41osystem: 'Terraform' } 42 }; 43 } 44 45 /** 46 * Categorize dependency based on PURL 47 */ 48 categorizeDependency(pkg) { 49 let category = { 50 type: 'unknown', 51 language: 'Unknown', 52 ecosystem: 'Unknown', 53 isWorkflow: false, 54 isInfrastructure: false, 55 isCode: false 56 }; 57 58 // Extract PURL information 59 if (pkg.externalRefs) { 60 const purlRef = pkg.externalRefs.find(ref => ref.referenceType === 'purl'); 61 if (purlRef && purlRef.referenceLocator) { 62 const purl = purlRef.referenceLocator; 63 const purlParts = purl.split('/'); 64 65 if (purlParts.length >= 2) { 66 let ecosystem = purlParts[0].replace('pkg:', ''); 67 68 // Normalize ecosystem using shared EcosystemMapper 69 if (window.ecosystemMapper) { 70 ecosystem = window.ecosystemMapper.normalizeEcosystem(ecosystem); 71 } else { 72 // Fallback: handle common aliases 73 const ecosystemMap = { 74 'golang': 'go', 75 'go': 'go' 76 }; 77 ecosystem = ecosystemMap[ecosystem] || ecosystem; 78 } 79 80 // Get category info using shared EcosystemMapper if available 81 let typeInfo; 82 if (window.ecosystemMapper) { 83 typeInfo = window.ecosystemMapper.getCategoryInfo(ecosystem) || this.purlTypeMap[ecosystem]; 84 } else { 85 typeInfo = this.purlTypeMap[ecosystem]; 86 } 87 88 if (typeInfo) { 89 category = { 90 ...typeInfo, 91 isWorkflow: typeInfo.type === 'workflow',
92 isInfrastructure: typeInfo.type === 'infrastructure', 93 isCode: typeInfo.type === 'code' 94 }; 95 } else { 96 // Try to infer from package name patterns 97 if (pkg.name.includes('action') || pkg.name.includes('actions/')) { 98 category = { 99 type: 'workflow', 100 language: 'YAML', 101 ecosystem: 'GitHub Actions', 102 isWorkflow: true, 103 isInfrastructure: false, 104 isCode: false 105 }; 106 } else if (pkg.name.includes('docker') || pkg.name.includes('container')) { 107 category = { 108 type: 'infrastructure', 109 language: 'Various', 110 ecosystem: 'Docker', 111 isWorkflow: false,
112 isInfrastructure: true, 113 isCode: false 114 }; 115 } 116 } 117 } 118 } 119 } 120 121 // Fallback: If no PURL found and still Unknown, try to detect from package name patterns 122 if (category.ecosystem === 'Unknown' && pkg.name) { 123 const name = pkg.name.toLowerCase(); 124 125 // GitHub Actions (e.g., "actions/checkout", "github/codeql-action/init") 126 if (name.startsWith('actions/') || name.startsWith('github/') || name.includes('/action')) { 127 const githubActionsTypeInfo = window.ecosystemMapper?.getCategoryInfo('githubactions') || this.purlTypeMap['githubactions']; 128 if (githubActionsTypeInfo) { 129 category = { 130 ...githubActionsTypeInfo, 131 isWorkflow: true, 132 isInfrastructure: false, 133 isCode: false 134 }; 135 } 136 } 137 // Maven packages use groupId:artifactId format (e.g., "org.codehaus.plexus:plexus-utils") 138 else if (pkg.name.includes(':') && !pkg.name.startsWith('@')) { 139 const mavenTypeInfo = window.ecosystemMapper?.getCategoryInfo('maven') || this.purlTypeMap['maven']; 140 if (mavenTypeInfo) { 141 category = { 142 ...mavenTypeInfo, 143 isWorkflow: false,
144 isInfrastructure: false, 145 isCode: true 146 }; 147 } else { 148 // Fallback if ecosystemMapper not available 149 category = { 150 type: 'code', 151 language: 'Java', 152 ecosystem: 'Maven', 153 isWorkflow: false, 154 isInfrastructure: false, 155 isCode: true 156 }; 157 } 158 } 159 // npm scoped packages start with @ 160 else if (pkg.name.startsWith('@')) { 161 const npmTypeInfo = window.ecosystemMapper?.getCategoryInfo('npm') || this.purlTypeMap['npm']; 162 if (npmTypeInfo) { 163 category = { 164 ...npmTypeInfo, 165 isWorkflow: false, 166 isInfrastructure: false, 167 isCode: true 168 }; 169 } 170 } 171 // Go modules (e.g., "github.com/user/repo", "golang.org/x/...") 172 // Note: These are package NAME patterns, not URLs - using regex for module path matching 173 else if (name.match(/^github\.com\//) || name.match(/^golang\.org\//) || name.match(/^go\./) || 174 (name.includes('/') && (name.endsWith('.go') || name.match(/^[a-z0-9.-]+\/[a-z0-9.-]+$/i)))) { 175 const goTypeInfo = window.ecosystemMapper?.getCategoryInfo('go') || this.purlTypeMap['go']; 176 if (goTypeInfo) { 177 category = { 178 ...goTypeInfo, 179 isWorkflow: false,
180 isInfrastructure: false, 181 isCode: true 182 }; 183 } 184 } 185 // Docker images (e.g., "alpine", "node", "python", or contain "/" and common docker patterns) 186 else if (name.includes('docker') || name.includes('container') || 187 (name.includes('/') && (name.includes('alpine') || name.includes('ubuntu') || 188 name.includes('debian') || name.includes('centos') || name.includes('fedora')))) { 189 const dockerTypeInfo = window.ecosystemMapper?.getCategoryInfo('docker') || this.purlTypeMap['docker']; 190 if (dockerTypeInfo) { 191 category = { 192 ...dockerTypeInfo, 193 isWorkflow: false, 194 isInfrastructure: true, 195 isCode: false 196 }; 197 } 198 } 199 // PyPI packages (common Python package naming patterns) 200 else if (name.match(/^[a-z0-9_-]+$/) && !name.includes('/') && !name.includes('@') && 201 (name.includes('_') || name.includes('-') || name.length > 3)) { 202 // Check if it looks like a Python package (common patterns) 203 // This is a heuristic - PyPI packages are often lowercase with underscores/hyphens 204 const pypiTypeInfo = window.ecosystemMapper?.getCategoryInfo('pypi') || this.purlTypeMap['pypi']; 205 if (pypiTypeInfo) { 206 category = { 207 ...pypiTypeInfo, 208 isWorkflow: false,
209 isInfrastructure: false, 210 isCode: true 211 }; 212 } 213 } 214 // RubyGems (common gem naming patterns - lowercase, may have hyphens) 215 else if (name.match(/^[a-z0-9_-]+$/) && !name.includes('/') && !name.includes('@') && 216 (name.includes('-') || name.length > 3)) { 217 // This is a heuristic - RubyGems often use lowercase with hyphens 218 const gemTypeInfo = window.ecosystemMapper?.getCategoryInfo('gem') || window.ecosystemMapper?.getCategoryInfo('rubygems') || this.purlTypeMap['rubygems']; 219 if (gemTypeInfo) { 220 category = { 221 ...gemTypeInfo, 222 isWorkflow: false, 223 isInfrastructure: false, 224 isCode: true 225 }; 226 } 227 } 228 } 229 230 return category; 231 } 232 233 /** 234 * Process SBOM data from a repository 235 * @param {string} owner - Repository owner 236 * @param {string} repo - Repository name 237 * @param {Object} sbomData - SBOM data from GitHub 238 * @param {string} repositoryLicense - Repository's own license (SPDX identifier, e.g., 'GPL-3.0', 'MIT') 239 * @param {boolean} archived - Whether the repository is archived 240 * @param {Object} [meta] - Optional GitHub repo metadata captured from REST or GraphQL. 241 * Shape: `{ pushedAt: string|null, primaryLanguage: string|null, defaultBranch: string|null }`. 242 * Persisted onto the repo entry so downstream features (Insights repo-hygiene 243 * activity-bucket histogram, Language stack, per-repo CSV export) can read the 244 * host repo's actual GitHub metadata instead of inferring it from dep ecosystems. 245 */ 246 async processSBOM(owner, repo, sbomData, repositoryLicense = null, archived = false, meta = null) { 247 if (!sbomData || !sbomData.sbom || !sbomData.sbom.packages) { 248 console.log(`â ï¸ Invalid SBOM data for ${owner}/${repo}`); 249 return false; 250 } 251 252 // Initialize version resolver if not already done 253 if (!this.versionResolver && window.DependencyTreeResolver) { 254 this.versionResolver = new window.DependencyTreeResolver(); 255 console.log('â Version resolver initialized for SBOM processing'); 256 } 257 258 console.log(`ð Processing SBOM for ${owner}/${repo}: ${sbomData.sbom.packages.length} packages found`); 259 260 const repoKey = `${owner}/${repo}`; 261 const repoData = { 262 name: repo, 263 owner: owner, 264 license: repositoryLicense || null, // Store repository's own license 265 archived: archived || false, // Store archived status 266 // GitHub repo metadata captured from REST/GraphQL â nullable for the 267 // upload path / org-listing edge cases where it was not provided. 268 pushedAt: meta?.pushedAt || null, 269 primaryLanguage: meta?.primaryLanguage || null, 270 defaultBranch: meta?.defaultBranch || null, 271 dependencies: new Set(), 272 directDependencies: new Set(), // Track direct dependencies from relationships 273 totalDependencies: 0, 274 dependencyCategories: { 275 code: new Set(), 276 workflow: new Set(), 277 infrastructure: new Set(), 278 unknown: new Set() 279 }, 280 languages: new Set(), 281 relationships: [], // Store relationship data for graph visualization 282 spdxPackages: [] // Store SPDX package info for mapping SPDXID to package details 283 }; 284 285 // Extract ALL dependency relationships (not just direct from main package) 286 // This allows us to build the full dependency tree 287 // 288 // For uploaded CycloneDX SBOMs: use _rootComponentSPDXID (root component's bom-ref converted to SPDXID) 289 // For GitHub SBOMs: use the main package SPDXID (com.github.owner/repo) 290 const mainPackageSPDXID = sbomData.sbom._rootComponentSPDXID || 291 sbomData.sbom.packages.find(p => 292 p.name === `com.github.${owner}/${repo}` || p.name === `${owner}/${repo}` 293 )?.SPDXID; 294 295 if (sbomData.sbom.relationships && Array.isArray(sbomData.sbom.relationships)) { 296 // Store all DEPENDS_ON relationships for graph visualization 297 sbomData.sbom.relationships.forEach(rel => { 298 if (rel.relationshipType === 'DEPENDS_ON') { 299 // Check if this is a direct dependency: 300 // 1. From CycloneDX: _isDirectDependency flag (set by sbom-parser) 301 // 2. From GitHub SBOM: spdxElementId matches mainPackageSPDXID 302 const isDirectFromMain = rel._isDirectDependency || 303 (mainPackageSPDXID && rel.spdxElementId === mainPackageSPDXID); 304 305 repoData.relationships.push({ 306 from: rel.spdxElementId, 307 to: rel.relatedSpdxElement, 308 type: rel.relationshipType, 309 isDirectFromMain: isDirectFromMain 310 }); 311 } 312 }); 313 } 314 315 let processedPackages = 0; 316 let skippedPackages = 0; 317 318 // Process each package in the SBOM 319 for (let index = 0; index < sbomData.sbom.packages.length; index++) { 320 const pkg = sbomData.sbom.packages[index]; 321 // GitHub SBOM uses 'versionInfo' instead of 'version' 322 let version = pkg.versionInfo || pkg.version; 323 324 // Normalize version: remove comparison operators like ">=", "<=", "^", "~", etc. 325 if (version) { 326 version = this.normalizeVersion(version); 327 } 328 329 // Skip the main repository package (it's not a dependency) 330 // GitHub SBOM includes the repository itself as a package (e.g., "com.github.owner/repo") 331 // This is filtered out because it's not an external dependency 332 // This explains why GitHub SBOM may show N packages but we display N-1 dependencies 333 if (pkg.name === `com.github.${owner}/${repo}` || pkg.name === `${owner}/${repo}`) { 334 console.log(` âï¸ Skipping main repository package: ${pkg.name} (not an external dependency)`); 335 skippedPackages++; 336 continue; 337 } 338 339 // Skip packages without names (cannot identify dependency) 340 if (!pkg.name) { 341 skippedPackages++; 342 console.log(`â ï¸ Package missing name in ${owner}/${repo}`); 343 continue; 344 } 345 346 // Categorize the dependency first (needed for version fetching) 347 const category = this.categorizeDependency(pkg); 348 349 // When version is missing, ALWAYS try to fetch latest version from registry 350 let displayVersion = version; 351 let assumedVersion = null; 352 if (!version) { 353 console.log(`â ï¸ Package missing version in ${owner}/${repo}: ${pkg.name} (attempting to fetch latest version)`);
354 const ecosystem = category?.ecosystem?.toLowerCase(); 355 356 if (ecosystem && this.versionResolver) { 357 try { 358 const latestVersion = await this.versionResolver.fetchLatestVersion(pkg.name, ecosystem); 359 if (latestVersion) { 360 version = latestVersion; // Use as actual version 361 displayVersion = latestVersion; 362 assumedVersion = latestVersion; 363 console.log(` â Assumed latest version for ${pkg.name}: ${latestVersion}`); 364 } else { 365 console.warn(` â ï¸ Could not fetch latest version for ${pkg.name} from ${ecosystem} registry`); 366 } 367 } catch (error) { 368 console.warn(` â ï¸ Failed to fetch latest version for ${pkg.name}: ${error.message}`); 369 } 370 } else if (!this.versionResolver) { 371 console.warn(` â ï¸ Version resolver not available for ${pkg.name}`); 372 } 373 374 // If still no version after fetch attempt, store as 'unknown' (not null) 375 if (!version) { 376 version = 'unknown'; 377 displayVersion = 'unknown'; 378 } 379 } 380 const depKey = `${pkg.name}@${displayVersion}`; 381 repoData.dependencies.add(depKey); 382 processedPackages++; 383 384 // Check if this is a direct dependency (directly from main package) 385 const isDirect = repoData.relationships.some(rel => 386 rel.to === pkg.SPDXID && rel.isDirectFromMain 387 ); 388 if (isDirect) { 389 repoData.directDependencies.add(depKey); 390 } 391 repoData.languages.add(category.language); 392 393 // Add to appropriate category 394 repoData.dependencyCategories[category.type].add(depKey); 395 396 // Extract GitHub Actions owner/repo if this is a GitHub Action 397 let githubActionInfo = null; 398 if (category.ecosystem === 'GitHub Actions' || category.isWorkflow) { 399 githubActionInfo = this.parseGitHubAction(pkg.name); 400 } 401 402 // Track global dependency usage 403 if (!this.dependencies.has(depKey)) { 404 this.dependencies.set(depKey, { 405 name: pkg.name, 406 version: version || 'unknown', // Never store null, always store a string value 407 displayVersion: displayVersion, // Display version (may be "unknown") 408 assumedVersion: assumedVersion, // Latest version if it was fetched (null if original version was present) 409 repositories: new Set(), 410 count: 0, 411 category: category, 412 languages: new Set([category.language]), 413 originalPackage: pkg, // Store original package data for PURL extraction 414 directIn: new Set(), // Track which repos use this as direct dependency 415 transitiveIn: new Set(), // Track which repos use this as transitive dependency 416 githubActionInfo: githubActionInfo, // Store parsed GitHub Action info 417 versionUnknown: !version && !assumedVersion // Flag to indicate version was missing and not assumed 418 }); 419 } 420 421 const dep = this.dependencies.get(depKey); 422 dep.repositories.add(repoKey); 423 dep.count++; 424 dep.languages.add(category.language); 425 426 // Update GitHub Action info if not already set 427 if (githubActionInfo && !dep.githubActionInfo) { 428 dep.githubActionInfo = githubActionInfo; 429 } 430 431 // Track if it's direct or transitive in this repo 432 if (isDirect) { 433 dep.directIn.add(repoKey); 434 } else { 435 dep.transitiveIn.add(repoKey); 436 } 437 438 // Log first few packages for debugging 439 if (index < 3) { 440 console.log(` ð¦ Package ${index + 1}: ${pkg.name}@${displayVersion} (${category.type}/${category.language})`); 441 } 442 } 443
444 repoData.totalDependencies = repoData.dependencies.size; 445 446 // Store SPDX package info for graph visualization 447 repoData.spdxPackages = sbomData.sbom.packages.map(pkg => ({ 448 SPDXID: pkg.SPDXID, 449 name: pkg.name, 450 version: pkg.versionInfo || pkg.version 451 })); 452 453 // Assess SBOM quality if quality processor is available 454 if (this.qualityProcessor) { 455 try { 456 const qualityAssessment = this.qualityProcessor.assessQuality(sbomData, owner, repo); 457 repoData.qualityAssessment = qualityAssessment; 458 console.log(`â SBOM Quality for ${repoKey}: ${qualityAssessment.overallScore}/100 (Grade ${qualityAssessment.grade})`); 459 } catch (error) { 460 console.error(`â Failed to assess SBOM quality for ${repoKey}:`, error); 461 repoData.qualityAssessment = null; 462 } 463 } else { 464 console.warn('â ï¸ SBOM Quality Processor not available'); 465 repoData.qualityAssessment = null; 466 } 467 468 this.repositories.set(repoKey, repoData); 469 470 // Centralised two-pass attribution after every processSBOM keeps 471 // dep.repositories / directIn / transitiveIn correct for callers that 472 // never run the full resolver (single-repo scans, uploaded SBOMs that 473 // already declare every transitive). resolveFullDependencyTrees calls 474 // the same helper again once it discovers more deps + edges. 475 this.computeDirectAndTransitive(); 476 477 console.log(`ð¦ Processed ${repoKey}: ${processedPackages} packages, ${skippedPackages}
477 skipped, ${repoData.totalDependencies} unique dependencies`); 478 479 return true; 480 } 481 482 /** 483 * Parse GitHub Action name to extract owner and repo 484 * Formats: "owner/action-name" or "owner/repo@version" 485 * @param {string} actionName - GitHub Action name 486 * @returns {Object|null} - {owner: string, actionName: string, repoName: string} or null 487 */ 488 parseGitHubAction(actionName) { 489 if (!actionName) return null; 490 491 // GitHub Actions format: owner/action-name or owner/repo@version 492 // Examples: "actions/checkout@v3", "docker/setup-buildx-action@v2" 493 const parts = actionName.split('/'); 494 if (parts.length < 2) return null; 495 496 const owner = parts[0]; 497 const rest = parts.slice(1).join('/'); // Handle cases with multiple slashes 498 const actionParts = rest.split('@'); 499 const repoOrAction = actionParts[0]; 500 501 return { 502 owner: owner, 503 actionName: repoOrAction, 504 repoName: repoOrAction, // For GitHub Actions, repo name is usually the action name 505 fullName: `${owner}/${repoOrAction}` 506 }; 507 } 508 509 /** 510 * Normalize version string by removing comparison operators 511 * Uses shared VersionUtils for consistency 512 */ 513 normalizeVersion(version) { 514 if (window.normalizeVersion) { 515 return window.normalizeVersion(version); 516 } 517 // Fallback if VersionUtils not available 518 if (!version) return version; 519 return version.trim() 520 .replace(/^[><=^~]+\s*/, '') 521 .replace(/\s+-\s+[\d.]+.*$/, '') // Only remove ranges with spaces around dash 522 .replace(/\s*\|\|.*$/, '') 523 .trim(); 524 } 525 526 /** 527 * Get top dependencies by usage count with categorization 528 */ 529 getTopDependencies(limit = 20, category = null) { 530 let deps = Array.from(this.dependencies.values()); 531 532 // Filter by category if specified 533 if (category) { 534 deps = deps.filter(dep => dep.category.type === category); 535 } 536 537 const sortedDeps = deps 538 .sort((a, b) => b.count - a.count) 539 .slice(0, limit); 540 541 return sortedDeps.map(dep => ({ 542 name: dep.name, 543 version: dep.displayVersion || dep.version, // Use displayVersion (may be assumed) 544 assumedVersion: dep.assumedVersion || null, // Latest version if assumed 545 count: dep.count, 546 repositories: Array.from(dep.repositories), 547 category: dep.category, 548 languages: Array.from(dep.languages) 549 })); 550 } 551 552 /** 553 * Get dependency statistics by category 554 */ 555 getDependencyCategoryStats() { 556 const stats = { 557 code: { count: 0, dependencies: new Set() }, 558 workflow: { count: 0, dependencies: new Set() }, 559 infrastructure: { count: 0, dependencies: new Set() }, 560 unknown: { count: 0, dependencies: new Set() } 561 }; 562 563 this.dependencies.forEach(dep => { 564 const category = dep.category.type; 565 if (stats[category]) { 566 stats[category].count += dep.count; 567 stats[category].dependencies.add(dep.name); 568 } 569 }); 570 571 return { 572 code: { 573 count: stats.code.count, 574 uniqueDependencies: stats.code.dependencies.size 575 }, 576 workflow: { 577 count: stats.workflow.count, 578 uniqueDependencies: stats.workflow.dependencies.size 579 }, 580 infrastructure: {
581 count: stats.infrastructure.count, 582 uniqueDependencies: stats.infrastructure.dependencies.size 583 }, 584 unknown: { 585 count: stats.unknown.count, 586 uniqueDependencies: stats.unknown.dependencies.size 587 } 588 }; 589 } 590 591 /** 592 * Get language statistics 593 */ 594 getLanguageStats() { 595 const languageStats = {}; 596 597 this.dependencies.forEach(dep => { 598 dep.languages.forEach(lang => { 599 if (!languageStats[lang]) { 600 languageStats[lang] = { count: 0, dependencies: new Set() }; 601 } 602 languageStats[lang].count += dep.count; 603 languageStats[lang].dependencies.add(dep.name); 604 }); 605 }); 606 607 return Object.entries(languageStats).map(([lang, stats]) => ({ 608 language: lang, 609 count: stats.count, 610 uniqueDependencies: stats.dependencies.size 611 })).sort((a, b) => b.count - a.count); 612 } 613 614 /** 615 * Get repository statistics 616 */ 617 getRepositoryStats() { 618 const repos = Array.from(this.repositories.values()); 619 // Use the actual global dependency count (includes transitive dependencies from deep resolution) 620 // instead of summing per-repo counts which are set before deep resolution runs 621 const totalDeps = this.dependencies.size; 622 623 // Calculate category breakdown 624 const categoryBreakdown = { 625 code: 0, 626 workflow: 0, 627 infrastructure: 0, 628 unknown: 0 629 }; 630 631 repos.forEach(repo => { 632 Object.keys(categoryBreakdown).forEach(category => { 633 const depCat = repo.dependencyCategories?.[category]; 634 categoryBreakdown[category] += depCat?.size || 0; 635 }); 636 }); 637 638 return { 639 totalRepositories: this.totalRepos, 640 processedRepositories: this.processedRepos, 641 successfulRepositories: this.successfulRepos, 642 failedRepositories: this.failedRepos, 643 repositoriesWithDependencies: repos.length, 644 totalDependencies: totalDeps, 645 averageDependenciesPerRepo: repos.length > 0 ? (totalDeps / repos.length).toFixed(2) : 0, 646 categoryBreakdown: categoryBreakdown 647 }; 648 } 649 650 /** 651 * Get repositories with most dependencies 652 */ 653 getTopRepositories(limit = 10) { 654 return Array.from(this.repositories.values()) 655 .sort((a, b) =>
655 b.totalDependencies - a.totalDependencies) 656 .slice(0, limit) 657 .map(repo => { 658 const depCat = repo.dependencyCategories || {}; 659 return { 660 name: repo.name, 661 owner: repo.owner, 662 totalDependencies: repo.totalDependencies, 663 dependencies: Array.from(repo.dependencies || []), 664 categoryBreakdown: { 665 code: depCat.code?.size || 0, 666 workflow: depCat.workflow?.size || 0, 667 infrastructure: depCat.infrastructure?.size || 0, 668 unknown: depCat.unknown?.size || 0 669 }, 670 languages: Array.from(repo.languages || []) 671 }; 672 }); 673 } 674 675 /** 676 * Get dependency distribution data 677 */ 678 getDependencyDistribution() { 679 const distribution = {}; 680 this.repositories.forEach(repo => { 681 const count = repo.totalDependencies; 682 const range = this.getDependencyRange(count); 683 distribution[range] = (distribution[range] || 0) + 1; 684 }); 685 return distribution; 686 } 687 688 /** 689 * Get dependency range for categorization 690 */ 691 getDependencyRange(count) { 692 if (count === 0) return '0'; 693 if (count <= 10) return '1-10'; 694 if (count <= 50) return '11-50'; 695 if (count <= 100) return '51-100'; 696 if (count <= 200) return '101-200'; 697 return '200+'; 698 } 699 700 /** 701 * Centralised direct/transitive attribution for every dep in this.dependencies. 702 * 703 * Two-pass design: 704 * Pass 1 (SBOM truth) â walk every repo.dependencies and use the repo's 705 * directDependencies set as the source of truth. Each (dep, repo) pair 706 * becomes either direct (if the repo declared it as such in the SBOM 707 * relationships) or transitive otherwise. Every dep that appears in any 708 * repo SBOM gets at least one entry in dep.repositories here. 709 * Pass 2 (per-repo BFS) â for every repo, BFS from that repo's SBOM-direct 710 * seed set through dep.children registry edges. Reaches transitives that 711 * the SBOM did not enumerate (e.g., resolver-discovered packages that 712 * were never in any repo.dependencies set). Pass 1 always wins on 713 * direct/transitive classification (we never overwrite directIn). 714 * 715 * BFS scope is naturally per-ecosystem because dep.children only contains 716 * same-ecosystem children â no cross-ecosystem leak possible. This replaces 717 * the inline parent-trace pass that previously lived inside the per-ecosystem 718 * resolver loop and that suffered from concurrent-resolve cross-attribution. 719 * 720 * Pre-condition for Pass 2 to actually walk anywhere: direct deps need 721 * `dep.children` populated. The resolver itself never calls `tree.set` on 722 * direct-dep keys (it only stores their transitive children), so 723 * resolveFullDependencyTrees() backfills `dep.children` on every parent 724 * referenced in the resolver tree before Pass 2 runs. Without that 725 * backfill, Pass 2 BFS terminates at every seed without visiting a single 726 * transitive â the regression that keeps re-appearing whenever the 727 * "rebuild attribution from scratch" rewrite forgets this step. 728 * 729 * Idempotent: every call resets dep.repositories / dep.directIn / 730 * dep.transitiveIn / dep.count before recomputing, so calling it after each 731 * resolver run AND at the end of processSBOM converges to the same answer. 732 */ 733 computeDirectAndTransitive() { 734 // Reset attribution state on every dep so successive runs converge. 735 for (const dep of this.dependencies.values()) { 736 dep.repositories = new Set(); 737 dep.directIn = new Set(); 738 dep.transitiveIn = new Set(); 739 } 740 741 // Pass 1: SBOM truth 742 for (const [repoKey, repoData] of this.repositories) { 743 const repoDeps = repoData.dependencies || new Set(); 744 const directSet = repoData.directDependencies || new Set(); 745 for (const depKey of repoDeps) { 746 const dep = this.dependencies.get(depKey); 747 if (!dep) continue; 748 dep.repositories.add(repoKey); 749 if (directSet.has(depKey)) { 750 dep.directIn.add(repoKey); 751 } else { 752 dep.transitiveIn.add(repoKey); 753 } 754 } 755 } 756 757 // Pass 2: per-repo BFS through children edges 758 let bfsAttributions = 0; 759 for (const [repoKey, repoData] of this.repositories) { 760 const directSet = repoData.directDependencies || new Set(); 761 if (directSet.size === 0) continue; 762 763 const visited = new Set(); 764 const queue = []; 765 for (const depKey of directSet) { 766 if (this.dependencies.has(depKey)) { 767 queue.push(depKey); 768 visited.add(depKey); 769 } 770 } 771 772 while (queue.length > 0) { 773 const currentKey = queue.shift(); 774 const currentDep = this.dependencies.get(currentKey); 775 if (!currentDep) continue; 776 const children = currentDep.children || []; 777 for (const childKey of children) { 778 if (visited.has(childKey)) continue; 779 visited.add(childKey); 780 const childDep = this.dependencies.get(childKey); 781 if (!childDep) continue; 782 // Pass 1 is authoritative for direct classification â only 783 // mark transitive if we did not already mark this pair as 784 // direct (the SBOM declared it as a top-level dep). 785 if (!childDep.directIn.has(repoKey)) { 786 if (!childDep.transitiveIn.has(repoKey)) { 787 bfsAttributions++; 788 } 789 childDep.transitiveIn.add(repoKey); 790 childDep.repositories.add(repoKey); 791 } 792 queue.push(childKey); 793 } 794 } 795 } 796 797 if (bfsAttributions > 0) { 798 console.log(`ð Resolver BFS added ${bfsAttributions} transitive (dep, repo) attribution${bfsAttributions === 1 ? '' : 's'} from registry tree`); 799 } 800 801 // Refresh count to reflect repository attribution 802 for (const dep of this.dependencies.values()) { 803 dep.count = dep.repositories.size; 804 } 805 } 806 807 /** 808 * Resolve full dependency trees using registry APIs 809 */ 810 async resolveFullDependencyTrees(onProgress = null) { 811 console.log('ð² Starting full dependency tree resolution...'); 812 813 if (!window.DependencyTreeResolver) {
814 console.warn('â ï¸ DependencyTreeResolver not available'); 815 return null; 816 } 817 818 const resolvedTrees = new Map(); // ecosystem -> tree 819 820 try { 821 // Group direct dependencies by ecosystem 822 const directDepsByEcosystem = new Map(); 823 824 for (const [depKey, dep] of this.dependencies) { 825 const ecosystem = dep.category?.ecosystem?.toLowerCase(); 826 if (!ecosystem || dep.directIn.size === 0) continue; 827 828 if (!directDepsByEcosystem.has(ecosystem)) { 829 directDepsByEcosystem.set(ecosystem, new Set()); 830 } 831 directDepsByEcosystem.get(ecosystem).add(depKey); 832 } 833 834 console.log(`ð Found ${directDepsByEcosystem.size} ecosystems with direct dependencies`); 835 836 // Resolve trees for all ecosystems in parallel 837 const ecosystemEntries = Array.from(directDepsByEcosystem.entries()); 838 839 // Helper function to resolve a single ecosystem 840 const resolveEcosystem = async ([ecosystem, directDeps], index) => { 841 console.log(` ð Resolving ${ecosystem} dependencies (${directDeps.size} direct)...`); 842 843 // Create a new resolver instance for each ecosystem to avoid counter conflicts when running in parallel 844 const resolver = new window.DependencyTreeResolver(); 845 846 try { 847 // Create progress callback for this ecosystem 848 const ecosystemProgressCallback = (progress) => { 849 if (onProgress && progress.phase === 'resolving-package') { 850 // Map package-level progress to ecosystem-level progress 851 const ecosystemProgress = index + (progress.processed / progress.total); 852 onProgress({ 853 phase: 'resolving-package', 854 ecosystem: ecosystem, 855 processed: progress.processed, 856 total: progress.total, 857 packageName: progress.packageName || progress.package || null, 858 remaining: progress.remaining || (progress.total - progress.processed), 859 depChain: progress.depChain || [], 860 totalPackagesProcessed: progress.totalPackagesProcessed || 0, 861 packageProgress: progress, 862 ecosystemProgress: ecosystemProgress 863 }); 864 } 865 }; 866 867 const tree = await resolver.resolveDependencyTree( 868 directDeps, 869 this.dependencies, 870 ecosystem, 871 ecosystemProgressCallback 872 ); 873 874 // Track packages not found in any registry (dependency confusion risk) 875 const notFoundPackages = resolver.getRegistryNotFoundPackages(); 876 if (notFoundPackages && notFoundPackages.size > 0) { 877 console.log(` â ï¸ ${notFoundPackages.size} package(s) not found in ${ecosystem} registry (potential dependency confusion)`); 878 notFoundPackages.forEach(pkgKey => { 879 const dep = this.dependencies.get(pkgKey); 880 if (dep) { 881 dep.registryNotFound = true; 882 // Get evidence URL if available 883 const evidence = resolver.getConfusionEvidence(pkgKey); 884 if (evidence) { 885 dep.confusionEvidence = evidence; 886 } 887 } 888 }); 889 } 890 891 // Track packages with namespaces not found (higher confidence dependency confusion) 892 const namespaceNotFound = resolver.getNamespaceNotFoundPackages(); 893 if (namespaceNotFound && namespaceNotFound.size > 0) { 894 console.log(` â ï¸â ï¸ ${namespaceNotFound.size} package(s) with NAMESPACE not found in ${ecosystem} registry (HIGH-CONFIDENCE dependency confusion)`);
895 namespaceNotFound.forEach(pkgKey => { 896 const dep = this.dependencies.get(pkgKey); 897 if (dep) { 898 dep.namespaceNotFound = true; 899 dep.registryNotFound = true; // Also mark as registry not found for backward compatibility 900 // Get evidence URL if available 901 const evidence = resolver.getConfusionEvidence(pkgKey); 902 if (evidence) { 903 dep.confusionEvidence = evidence; 904 } 905 } 906 }); 907 } 908 909 // Decorate every dep in the resolved tree with depth + parents/children 910 // edges. Repository attribution (directIn/transitiveIn/repositories) is NOT 911 // mutated here â it is computed centrally by computeDirectAndTransitive() 912 // after every per-ecosystem resolver finishes (see resolveFullDependencyTrees 913 // tail and processSBOM tail). Doing repo attribution per-ecosystem here used 914 // to leak across ecosystems (npm/Maven concurrent resolves attributing each 915 // others' transitives), so we keep this loop dedicated to depth/edge metadata. 916 // 917 // Also build a parent â children index from the tree so we can backfill 918 // `dep.children` on the direct-dep seeds below. The resolver itself only 919 // calls `tree.set(childKey, â¦)` for transitive children â direct deps are 920 // passed in as `parent` but never added as tree nodes â so without this 921 // backfill `dep.children` is empty/undefined on every direct dep, and the 922 // Pass-2 BFS in computeDirectAndTransitive() terminates at the seed without 923 // visiting a single transitive (a real, repeatedly-rediscovered regression). 924 const childrenByParentInTree = new Map(); 925 for (const [packageKey, treeNode] of tree) { 926 let dep = this.dependencies.get(packageKey); 927 928 // If dependency doesn't exist yet (discovered during tree resolution), create it 929 if (!dep) { 930 // Parse package name and version from packageKey 931 // Handle scoped packages (e.g., @scope/package@version) 932 let name, version; 933 if (packageKey.startsWith('@')) { 934 // Scoped package: @scope/package@version 935 const lastAtIndex = packageKey.lastIndexOf('@'); 936 name = packageKey.substring(0, lastAtIndex); 937 version = packageKey.substring(lastAtIndex + 1); 938 } else { 939 // Regular package: package@version 940 const firstAtIndex = packageKey.indexOf('@'); 941 if (firstAtIndex !== -1) { 942 name = packageKey.substring(0, firstAtIndex); 943 version = packageKey.substring(firstAtIndex + 1); 944 } else { 945 name = packageKey; 946 version = ''; 947 } 948 } 949 950 // Use the ecosystem we're currently resolving to categorize this dependency 951 // This is more reliable than trying to infer from name patterns 952 let category; 953 if (window.ecosystemMapper) { 954 const typeInfo = window.ecosystemMapper.getCategoryInfo(ecosystem); 955 if (typeInfo) { 956 category = { 957 ...typeInfo, 958 isWorkflow: typeInfo.type === 'workflow',
959 isInfrastructure: typeInfo.type === 'infrastructure', 960 isCode: typeInfo.type === 'code' 961 }; 962 } else { 963 // Fallback to categorizeDependency if ecosystem not found in mapper 964 category = this.categorizeDependency({ name }); 965 } 966 } else { 967 // Fallback: try to infer from name patterns 968 category = this.categorizeDependency({ name }); 969 } 970 971 dep = { 972 name: name, 973 version: version || null, 974 displayVersion: version || 'version unknown', 975 assumedVersion: null, 976 repositories: new Set(), 977 count: 0, 978 category: category, 979 languages: new Set([category.language]), 980 originalPackage: null, 981 directIn: new Set(), 982 transitiveIn: new Set(), 983 githubActionInfo: null, 984 versionUnknown: !version 985 }; 986 this.dependencies.set(packageKey, dep); 987 console.log(` ð¦ Added newly discovered transitive dependency: ${packageKey} (depth ${treeNode.depth}, ecosystem: ${ecosystem})`); 988 } 989 990 dep.depth = treeNode.depth; 991 dep.parents = Array.from(treeNode.parents); 992 dep.children = Array.from(treeNode.children); 993 994 // Index this node under each of its parents so the post-loop 995 // backfill can give direct-dep parents a non-empty .children. 996 for (const parentKey of treeNode.parents) { 997 if (!childrenByParentInTree.has(parentKe
997y)) { 998 childrenByParentInTree.set(parentKey, new Set()); 999 } 1000 childrenByParentInTree.get(parentKey).add(packageKey); 1001 } 1002 } 1003 1004 // Backfill `dep.children` for every parent referenced in the tree â 1005 // most importantly the direct deps, which the resolver never adds as 1006 // tree nodes (it seeds them as `parent` but only `tree.set`s their 1007 // transitive children; see DependencyTreeResolver.resolvePackageDependencies). 1008 // Without this step, direct deps end up with `dep.children = undefined` 1009 // and the Pass-2 BFS in computeDirectAndTransitive() walks nowhere from 1010 // its seeds, leaving every resolver-discovered transitive orphaned in 1011 // `repo.dependencies` / `dep.repositories` / `dep.directIn` / `dep.transitiveIn`. 1012 for (const [parentKey, childKeys] of childrenByParentInTree) { 1013 const parentDep = this.dependencies.get(parentKey); 1014 if (!parentDep) continue; 1015 const merged = new Set(Array.isArray(parentDep.children) ? parentDep.children : []); 1016 for (const childKey of childKeys) { 1017 merged.add(childKey); 1018 } 1019 parentDep.children = Array.from(merged); 1020 } 1021 1022 const stats = resolver.getTreeStats(tree); 1023 console.log(` â Resolved ${ecosystem}: ${stats.totalPackages} packages, max depth: ${stats.maxDepth}`); 1024 1025 return { ecosystem, tree, success: true }; 1026 } catch (error) { 1027 console.error(` â Error resolving ${ecosystem}:`, error); 1028 return { ecosystem, tree: null, success: false }; 1029 } 1030 }; 1031 1032 // Resolve all ecosystems in parallel 1033 const resolutionPromises = ecosystemEntries.map((entry, index) => 1034 resolveEcosystem(entry, index) 1035 ); 1036 1037 const resolutionResults = await Promise.allSettled(resolutionPromises); 1038 1039 // Process results and update progress 1040 let processedEcosystems = 0; 1041 resolutionResults.forEach((result, index) => { 1042 if (result.status === 'fulfilled' && result.value.success) { 1043 const { ecosystem, tree } = result.value; 1044 resolvedTrees.set(ecosystem, tree); 1045 processedEcosystems++; 1046 1047 if (onProgress) { 1048 onProgress({ 1049 phase: 'resolving-tree', 1050 ecosystem: ecosystem, 1051 processed: processedEcosystems, 1052 total: ecosystemEntries.length 1053 }); 1054 } 1055 } 1056 }); 1057 1058 console.log('â Dependency tree resolution complete'); 1059 this.dependencyTreesResolved = true; 1060 this.resolvedDependencyTrees = resolvedTrees; 1061 1062 // Centralised two-pass attribution sees every dep the resolver just 1063 // discovered (with depth/parents/children edges populated) and rebuilds 1064 // dep.repositories / dep.directIn / dep.transitiveIn from SBOM truth + 1065 // per-repo BFS. Replaces the inline parent-trace pass that used to live 1066 // inside the resolver loop and that leaked across ecosystems. 1067 this.computeDirectAndTransitive(); 1068 1069 // Proactively check all dependencies for confusion using their original PURLs 1070 // This catches cases where SBOM name != PURL package name (e.g., mislabeled dependencies) 1071 await this.checkDependencyConfusionFromPurls(); 1072 1073 return resolvedTrees; 1074 1075 } catch (error) { 1076 console.error('â Error during dependency tree resolution:', error); 1077 return null; 1078 } 1079 } 1080 1081 /** 1082 * Proactively check all dependencies for dependency confusion using their original PURLs 1083 * This catches cases where the SBOM package name differs from the PURL package name 1084 * (e.g., name: "content-type" but purl: "pkg:npm/[email protected]") 1085 */ 1086 async checkDependencyConfusionFromPurls() { 1087 if (!window.depConfuseService) { 1088 console.log('â ï¸ DepConfuseService not available, skipping proactive PURL confusion check'); 1089 return; 1090 } 1091 1092 console.log('ð Proactively checking dependencies for confusion using original PURLs...'); 1093 let checked = 0; 1094 let vulnerable = 0; 1095 1096 for (const [depKey, dep] of this.dependencies) {
1097 // Skip if already marked as not found (already checked) 1098 if (dep.registryNotFound || dep.namespaceNotFound) { 1099 continue; 1100 } 1101 1102 // Get original PURL from the dependency 1103 let purl = null; 1104 if (dep.originalPackage && dep.originalPackage.externalRefs) { 1105 const purlRef = dep.originalPackage.externalRefs.find(ref => ref.referenceType === 'purl'); 1106 if (purlRef && purlRef.referenceLocator) { 1107 purl = purlRef.referenceLocator; 1108 } 1109 } 1110 1111 if (!purl) continue; 1112 1113 // Extract package name from PURL and compare with dependency name 1114 // If they differ, this is a potential mislabeling that needs checking 1115 const purlMatch = purl.match(/pkg:[^\/]+\/([^@]+)/); 1116 if (!purlMatch) continue; 1117 1118 let purlPackageName = decodeURIComponent(purlMatch[1]); 1119 // Handle scoped packages (remove @ prefix for comparison) 1120 if (purlPackageName.startsWith('%40')) { 1121 purlPackageName = '@' + purlPackageName.substring(3); 1122 } 1123 1124 // If PURL package name matches dependency name, skip (already checked during tree resolution) 1125 if (purlPackageName === dep.name || purlPackageName.endsWith('/' + dep.name)) { 1126 continue; 1127 } 1128 1129 // PURL has different package name - check it for confusion 1130 console.log(` ð Checking PURL with different name: ${dep.name} vs PURL: ${purl}`); 1131 checked++; 1132 1133 try { 1134 const result = await window.depConfuseService.checkPackageForConfusion(purl); 1135 1136 if (result.vulnerable) { 1137 vulnerable++; 1138 // Store the PURL that was actually checked (may differ from dep.name) 1139 dep.confusionPurl = purl; 1140 dep.confusionPurlName = purlPackageName; 1141 1142 if (result.type === 'namespace_not_found') { 1143 dep.namespaceNotFound = true; 1144 dep.registryNotFound = true; 1145 console.log(` â ï¸â ï¸ PURL namespace not found: ${purl} - HIGH-CONFIDENCE dependency confusion`); 1146 } else { 1147 dep.registryNotFound = true; 1148 console.log(` â ï¸ PURL package not found: ${purl} - potential dependency confusion`); 1149 } 1150 1151 if (result.evidenceUrl) { 1152 dep.confusionEvidence = result.evidenceUrl; 1153 } 1154 1155 // Store severity and message from the check result 1156 if (result.severity) { 1157 dep.confusionSeverity = result.severity; 1158 } 1159 if (result.message) { 1160 dep.confusionMessage = result.message; 1161 } 1162 } 1163 } catch (error) { 1164 console.warn(` â ï¸ Failed to check PURL ${purl}: ${error.message}`); 1165 } 1166 } 1167 1168 if (checked > 0) { 1169 console.log(`â Proactive PURL check complete: ${checked} checked, ${vulnerable} vulnerable`); 1170 } else { 1171 console.log('â No mismatched PURL names found to check'); 1172 } 1173 } 1174 1175 /** 1176 * Export data as JSON 1177 */ 1178 exportData() { 1179 const stats = this.getRepositoryStats(); 1180 const topDeps = this.getTopDependencies(50); 1181 const topRepos = this.getTopRepositories(50); 1182 1183 // Direct/transitive attribution is now centralised in 1184 // computeDirectAndTransitive() (called from processSBOM and 1185 // resolveFullDependencyTrees), so the legacy "fix orphan deps by 1186 // attaching to first repo" safety net is removed â keeping it would 1187 // mask attribution bugs by silently mis-attributing orphans. 1188 const allDeps = Array.from(this.dependencies.values()).map(dep => { 1189 // Extract PURL from originalPackage if available 1190 let purl = null; 1191 if (dep.originalPackage && dep.originalPackage.externalRefs) { 1192 const purlRef = dep.originalPackage.externalRefs.find(ref => ref.referenceType === 'purl'); 1193 if (purlRef && purlRef.referenceLocator) { 1194 purl = purlRef.referenceLocator; 1195 } 1196 } 1197 1198 // Extract license from originalPackage.licenseConcluded if not already set 1199 // This handles licenses parsed from CycloneDX/SPDX SBOMs 1200 let license = dep.license || null; 1201 let licenseFull = dep.licenseFull || null; 1202 let licenseAugmented = dep._licenseAugmented || false; 1203 let licenseSource = dep._licenseSource || null; 1204 1205 if (!license && dep.originalPackage && dep.originalPackage.licenseConcluded) { 1206 license = dep.originalPackage.licenseConcluded; 1207 licenseFull = dep.originalPackage.licenseConcluded; 1208 licenseSource = 'sbom'; // License came from original SBOM 1209 } else if (license && !licenseSource) { 1210 // License was fetched externally 1211 licenseSource = licenseAugmented ? 'external' : 'sbom'; 1212 } 1213 1214 // Capture the consumer repo's license alongside the dep â this is the 1215 // license of the host/consumer repository that the dep was found in, 1216 // NOT the dep's own license. Used by the license-compatibility checker 1217 // to compare a copyleft dep against the consuming repo's license. 1218 // Renamed from `repositoryLicense` (which was misread by several callers 1219 // as the dep's own license) to make the semantic explicit. 1220 let consumerRepoLicense = null; 1221 if (dep.repositories && dep.repositories.size > 0) { 1222 const firstRepoKey = Array.from(dep.repositories)[0]; 1223 const repoData = this.repositories.get(firstRepoKey); 1224 if (repoData && repoData.license) { 1225 consumerRepoLicense = repoData.license; 1226 } 1227 } 1228 1229 return { 1230 name: dep.name, 1231 version: dep.displayVersion || dep.version, // Use displayVersion (may be assumed) 1232 assumedVersion: dep.assumedVersion || null, // Latest version if assumed 1233 count: dep.count, 1234 repositories: Array.from(dep.repositories), 1235 directIn: Array.from(dep.directIn || []), // Repos using as direct dependency 1236 transitiveIn: Array.from(dep.transitiveIn || []), // Repos using as transitive dependency 1237 category: dep.category, 1238 languages: Array.from(dep.languages), 1239 purl: purl, // Include extracted PURL for author analysis 1240 registryNotFound: dep.registryNotFound || false, // Potential dependency confusion risk 1241 namespaceNotFound: dep.namespaceNotFound || false, // HIGH-CONFIDENCE dependency confusion (namespace missing) 1242 confusionEvidence: dep.confusionEvidence || null, // URL proving the package/namespace doesn't exist 1243 confusionPurl: dep.confusionPurl || null, // The PURL that was checked (may differ from name) 1244 confusionPurlName: dep.confusionPurlName || null, // Package name from PURL that was not found 1245 confusionSeverity: dep.confusionSeverity || null, // Severity level from confusion check (e.g., 'low' for PyPI system packages) 1246 confusionMessage: dep.confusionMessage || null, // Detailed message from confusion check 1247 originalPackage: dep.originalPackage, // Include original package data 1248 depth: dep.depth || null, // Depth in dependency tree (1 = direct, 2+ = transitive) 1249 parents: dep.parents || [], // Parent dependencies (what brings this in) 1250 children: dep.children || [], // Child dependencies (what this brings in) 1251 license: license, // Include license (short form, from SBOM or fetched) 1252 licenseFull: licenseFull, // Include license (full form, from SBOM or fetched) 1253 licenseAugmented: licenseAugmented, // True if license was fetched externally 1254 licenseSource: licenseSource, // 'sbom' or 'deps.dev' or 'external' 1255 consumerRepoLicense: consumerRepoLicense, // License of the host/consumer repo (NOT the dep's own license â for compatibility checks only) 1256 // Source-repository metadata captured during enrichment. 1257 // Populated by LicenseFetcher (deps.dev `links[].SOURCE_REPO`) 1258 // and EnrichmentPipeline.hydrateRepoUrlsFromPackageCache 1259 // (ecosyste.ms `repository_url` / `homepage`). Persisted on the 1260 // exported dep so feed-url-builder and findings-page can resolve 1261 // GitHub feeds / dead-repo status for ecosystems whose SBOMs 1262 // typically lack a SOURCE-CONTROL externalRef (Maven, NuGet, â¦). 1263 repositoryUrl: dep.repositoryUrl || null, 1264 homepage: dep.homepage || null, 1265 issueTrackerUrl: dep.issueTrackerUrl || null, 1266 repositoryUrlSource: dep.repositoryUrlSource || null, 1267 // Phase D â enrichment outputs persisted per-dep so the deps 1268 // page, findings page, and feeds page (plus the upcoming 1269 // Insights page) keep their enrichment across page reloads. 1270 // Pre-Phase-D, these lived only on the in-memory dep array and 1271 // were silently erased on every save/load cycle. 1272 // 1273 // Each is a nested object (or null when the enrichment phase 1274 // didn't fire for this dep) â renderers already handle null / 1275 // undefined gracefully: 1276 // versionDrift â full drift result from VersionDriftAnalyzer 1277 // staleness â full staleness result (publishDate / monthsSinceRelease / probableEOL â¦) 1278 // eoxStatus â full EOX result from eox-service.js 1279 // sourceRepoStatus â array of dead-repo detector results from validateSourceRepos 1280 versionDrift: dep.versionDrift || null, 1281 staleness: dep.staleness || null, 1282 eoxStatus: dep.eoxStatus || null, 1283 sourceRepoStatus: dep.sourceRepoStatus || null 1284 }; 1285 }); 1286 const allRepos = Array.from(this.repositories.values()).map(repo => { 1287 const depCat = repo.dependencyCategories || {}; 1288 return { 1289 name: repo.name, 1290 owner: repo.owner, 1291 license: repo.license || null, // Include repository license 1292 archived: repo.archived || false, // Include archived status 1293 // GitHub repo metadata captured during processSBOM (Phase B): 1294 // pushedAt â last push timestamp (ISO-8601) 1295 // primaryLanguage â GitHub-detected primary language 1296 // defaultBranch â default branch name (typically main / master) 1297 // Used by Insights' repo-hygiene activity-bucket histogram, 1298 // Language-stack section, and per-repo CSV export. 1299 pushedAt: repo.pushedAt || null, 1300 primaryLanguage: repo.primaryLanguage || null, 1301 defaultBranch: repo.defaultBranch || null,
1302 totalDependencies: repo.totalDependencies, 1303 dependencies: Array.from(repo.dependencies || []), 1304 directDependencies: Array.from(repo.directDependencies || []), // Direct dependencies 1305 categoryBreakdown: { 1306 code: depCat.code?.size || 0, 1307 workflow: depCat.workflow?.size || 0, 1308 infrastructure: depCat.infrastructure?.size || 0, 1309 unknown: depCat.unknown?.size || 0 1310 }, 1311 languages: Array.from(repo.languages || []), 1312 relationships: repo.relationships || [], // Include ALL relationships for graph visualization 1313 spdxPackages: repo.spdxPackages || [], // Store SPDX package data for mapping 1314 qualityAssessment: repo.qualityAssessment || null // Include SBOM quality assessment 1315 }; 1316 }); 1317 1318 // Calculate aggregate quality analysis if quality processor is available 1319 let qualityAnalysis = null;
1320 if (this.qualityProcessor) { 1321 const qualityAssessments = allRepos 1322 .filter(repo => repo.qualityAssessment) 1323 .map(repo => repo.qualityAssessment); 1324 1325 if (qualityAssessments.length > 0) { 1326 qualityAnalysis = this.qualityProcessor.calculateAggregateQuality(qualityAssessments); 1327 } 1328 } 1329 1330 return { 1331 timestamp: new Date().toISOString(), 1332 statistics: stats, 1333 topDependencies: topDeps, 1334 topRepositories: topRepos, 1335 dependencyDistribution: this.getDependencyDistribution(), 1336 allDependencies: allDeps, 1337 allRepositories: allRepos, 1338 categoryStats: this.getDependencyCategoryStats(), 1339 languageStats: this.getLanguageStats(), 1340 vulnerabilityAnalysis: this.vulnerabilityAnalysis || null, 1341 malwareAnalysis: this.malwareAnalysis || null, 1342 licenseAnalysis: this.licenseAnalysis || null, 1343 qualityAnalysis: qualityAnalysis, // Add aggregate quality analysis 1344 githubActionsAnalysis: this.githubActionsAnalysis || null // Add GitHub Actions analysis 1345 }; 1346 } 1347 1348 /** 1349 * Analyze GitHub Actions for all repositories 1350 * @param {GitHubClient} githubClient - GitHub client instance 1351 * @param {AuthorService} authorService - Author service instance 1352 * @param {Function} onProgress - Optional progress callback 1353 * @returns {Promise<Object>} GitHub Actions analysis results 1354 */ 1355 async analyzeGitHubActions(githubClient, authorService, onProgress = null) { 1356 if (!window.GitHubActionsAnalyzer) { 1357 console.warn('â ï¸ GitHub Actions Analyzer not available'); 1358 return null; 1359 } 1360 1361 try { 1362 console.log('ð SBOM Processor: Starting GitHub Actions analysis...'); 1363 1364 const analyzer = new window.GitHubActionsAnalyzer(githubClient, authorService); 1365 const allResults = { 1366 repositories: [], 1367 totalActions: 0, 1368 uniqueActions: 0, 1369 allFindings: [], 1370 findingsByType: new Map() 1371 }; 1372 1373 // Analyze each repository 1374 for (const [repoKey, repoData] of this.repositories) { 1375 const [owner, repo] = repoKey.split('/'); 1376 1377 if (onProgress) { 1378 onProgress({ 1379 phase: 'github-actions-analysis', 1380 message: `Analyzing GitHub Actions for ${repoKey}...`, 1381 repository: repoKey 1382 }); 1383 } 1384 1385 try { 1386 const result = await analyzer.analyzeRepository(owner, repo, 'HEAD', onProgress); 1387 1388 if (result && result.findings) { 1389 allResults.repositories.push({ 1390 repository: repoKey, 1391 ...result 1392 }); 1393 allResults.totalActions += result.totalActions || 0; 1394 allResults.uniqueActions += result.uniqueActions || 0; 1395 allResults.allFindings.push(...result.findings); 1396 1397 // Aggregate findings by type 1398 if (result.findingsByType) { 1399 Object.entries(result.findingsByType).forEach(([ruleId, count]) => { 1400 const current = allResults.findingsByType.get(ruleId) || 0; 1401 allResults.findingsByType.set(ruleId, current + count); 1402 }); 1403 } 1404 } 1405 } catch (error) { 1406 console.warn(`Failed to analyze GitHub Actions for ${repoKey}:`, error); 1407 } 1408 } 1409 1410 // Convert Map to object for storage 1411 const findingsByTypeObj = Object.fromEntries(allResults.findingsByType); 1412 1413 this.githubActionsAnalysis = { 1414 totalActions: allResults.totalActions, 1415 uniqueActions: allResults.uniqueActions, 1416 repositories: allResults.repositories, 1417 findings: allResults.allFindings, 1418 findingsByType: findingsByTypeObj, 1419 timestamp: new Date().toISOString() 1420 }; 1421 1422 console.log(`â SBOM Processor: GitHub Actions analysis complete: ${allResults.allFindings.length} findings`); 1423 return this.githubActionsAnalysis; 1424 } catch (error) { 1425 console.error('â SBOM Processor: GitHub Actions analysis failed:', error); 1426 return null; 1427 } 1428 } 1429 1430 /** 1431 * Reset processor state 1432 */ 1433 reset() { 1434 this.dependencies.clear(); 1435 this.repositories.clear(); 1436 this.totalRepos = 0; 1437 this.processedRepos = 0; 1438 this.successfulRepos = 0; 1439 this.failedRepos = 0; 1440 this.githubActionsAnalysis = null; 1441 } 1442 1443 /** 1444 * Update progress counters 1445 */ 1446 updateProgress(success = true) { 1447 this.processedRepos++; 1448 if (success) { 1449 this.successfulRepos++; 1450 } else { 1451 this.failedRepos++; 1452 } 1453 } 1454 1455 /** 1456 * Set total repository count 1457 */ 1458 setTotalRepositories(count) { 1459 this.totalRepos = count; 1460 } 1461 1462 /** 1463 * Analyze vulnerabilities for all dependencies 1464 */ 1465 async analyzeVulnerabilities() { 1466 if (!window.osvService) { 1467 console.warn('â ï¸ OSV Service not available'); 1468 return null; 1469 } 1470 1471 try { 1472 console.log('ð SBOM Processor: Starting vulnerability analysis...'); 1473 1474 // Convert dependencies to the format expected by OSV service 1475 const dependencies = Array.from(this.dependencies.values()).map(dep => ({ 1476 name: dep.name, 1477 version: dep.version, 1478 pkg: dep.originalPackage // Pass original package data for PURL extraction 1479 })); 1480 1481 // Analyze vulnerabilities (using the original method for backward compatibility) 1482 this.vulnerabilityAnalysis = await window.osvService.analyzeDependencies(dependencies); 1483 1484 console.log('â SBOM Processor: Vulnerability analysis complete'); 1485 return this.vulnerabilityAnalysis; 1486 } catch (error) { 1487 console.error('â SBOM Processor: Vulnerability analysis failed:', error); 1488 return null; 1489 } 1490 } 1491 1492 /** 1493 * Analyze vulnerabilities for all dependencies with incremental saving 1494 */ 1495 async analyzeVulnerabilitiesWithIncrementalSaving(orgName, onProgress = null) { 1496 if (!window.osvService) { 1497 console.warn('â ï¸ OSV Service not available'); 1498 return null; 1499 } 1500 1501 try { 1502 console.log('ð SBOM Processor: Starting incremental vulnerability analysis...'); 1503 1504 // Convert dependencies to the format expected by OSV service 1505 const dependencies = Array.from(this.dependencies.values()).map(dep => ({ 1506 name: dep.name, 1507 version: dep.version, 1508 pkg: dep.originalPackage // Pass original package data for PURL extraction 1509 })); 1510 1511 // Analyze vulnerabilities with incremental saving 1512 this.vulnerabilityAnalysis = await window.osvService.analyzeDependenciesWithIncrementalSaving( 1513 dependencies, 1514 orgName, 1515 onProgress 1516 ); 1517 1518 console.log('â SBOM Processor: Incremental vulnerability analysis complete'); 1519 return this.vulnerabilityAnalysis; 1520 } catch (error) { 1521 console.error('â SBOM Processor: Incremental vulnerability analysis failed:', error); 1522 return null; 1523 } 1524 } 1525 1526 /** 1527 * Analyze license compliance for all dependencies 1528 */ 1529 analyzeLicenseCompliance() { 1530 try { 1531 console.log('ð SBOM Processor: Starting license compliance analysis...'); 1532 1533 // Convert dependencies to the format expected by license processor 1534 const dependencies = Array.from(this.dependencies.values()).map(dep => ({ 1535 name: dep.name, 1536 version: dep.version, 1537 originalPackage: dep.originalPackage 1538 })); 1539 1540 // Generate license compliance report 1541 this.licenseAnalysis = this.licenseProcessor.generateComplianceReport(dependencies); 1542 1543 console.log('â SBOM Processor: License compliance analysis complete'); 1544 return this.licenseAnalysis; 1545 } catch (error) { 1546 console.error('â SBOM Processor: License compliance analysis failed:', error); 1547 return null; 1548 } 1549 } 1550 1551 /** 1552 * Get license statistics for visualization 1553 */ 1554 getLicenseStats() { 1555 if (!this.licenseAnalysis) { 1556 return null; 1557 } 1558 return this.licenseProcessor.getLicenseStats(Array.from(this.dependencies.values())
1558); 1559 } 1560 1561 /** 1562 * Get license conflicts 1563 */ 1564 getLicenseConflicts() { 1565 if (!this.licenseAnalysis) { 1566 return []; 1567 } 1568 return this.licenseAnalysis.conflicts; 1569 } 1570 1571 /** 1572 * Get high-risk dependencies 1573 */ 1574 getHighRiskDependencies() { 1575 if (!this.licenseAnalysis) { 1576 return []; 1577 } 1578 return this.licenseAnalysis.highRiskDependencies; 1579 } 1580 1581 /** 1582 * Export partial data for incremental saving (memory optimized) 1583 */ 1584 exportPartialData() { 1585 // Only export essential data to reduce memory usage 1586 const statistics = { 1587 totalRepositories: this.totalRepos, 1588 processedRepositories: this.processedRepos, 1589 successfulRepositories: this.successfulRepos, 1590 failedRepositories: this.failedRepos, 1591 totalDependencies: this.dependencies.size, 1592 totalUniqueDependencies: this.dependencies.size 1593 }; 1594 1595 // Export only top dependencies and repositories to save memory 1596 const topDependencies = this.getTopDependencies(20); 1597 const topRepositories = this.getTopRepositories(10); 1598 1599 // Export category and language stats (these are lightweight) 1600 const categoryStats = this.getDependencyCategoryStats(); 1601 const languageStats = this.getLanguageStats(); 1602 const dependencyDistribution = this.getDependencyDistribution(); 1603 1604 // Only export all dependencies and repositories if we have a reasonable amount 1605 // This prevents memory issues with very large datasets 1606 let allDependencies = null; 1607 let allRepositories = null; 1608 1609 if (this.dependencies.size <= 1000) { 1610 // For smaller datasets, export everything 1611 allDependencies = Array.from(this.dependencies.values()).map(dep => { 1612 // Extract PURL from originalPackage if available 1613 let purl = null; 1614 if (dep.originalPackage && dep.originalPackage.externalRefs) { 1615 const purlRef = dep.originalPackage.externalRefs.find(ref => ref.referenceType === 'purl'); 1616 if (purlRef && purlRef.referenceLocator) { 1617 purl = purlRef.referenceLocator; 1618 } 1619 } 1620 1621 // Extract license from originalPackage if not already set 1622 let license = dep.license || dep.licenseFull; 1623 let licenseAugmented = dep._licenseAugmented || false; 1624 let licenseSource = dep._licenseSource || null; 1625 1626 if (!license && dep.originalPackage && dep.originalPackage.licenseConcluded) { 1627 license = dep.originalPackage.licenseConcluded; 1628 licenseSource = 'sbom'; 1629 } 1630 1631 return { 1632 name: dep.name, 1633 version: dep.displayVersion || dep.version, // Use displayVersion (may be assumed) 1634 assumedVersion: dep.assumedVersion || null, // Latest version if assumed 1635 count: dep.count, 1636 repositories: Array.from(dep.repositories), 1637 directIn: Array.from(dep.directIn || []), // Repos using as direct dependency 1638 transitiveIn: Array.from(dep.transitiveIn || []), // Repos using as transitive dependency 1639 parents: dep.parents ? Array.from(dep.parents) : [], // Parent packages (for transitive deps) 1640 depth: dep.depth || null, // Depth in dependency tree (1 = direct, 2+ = transitive) 1641 category: dep.category, 1642 languages: Array.from(dep.languages), 1643 purl: purl, // Include extracted PURL for author analysis 1644 registryNotFound: dep.registryNotFound || false, // Potential dependency confusion risk 1645 namespaceNotFound: dep.namespaceNotFound || false, // HIGH-CONFIDENCE dependency confusion (namespace missing) 1646 confusionEvidence: dep.confusionEvidence || null, // URL proving the package/namespace doesn't exist 1647 confusionPurl: dep.confusionPurl || null, // The PURL that was checked (may differ from name) 1648 confusionPurlName: dep.confusionPurlName || null, // Package name from PURL that was not found 1649 confusionSeverity: dep.confusionSeverity || null, // Severity level from confusion check (e.g., 'low' for PyPI system packages) 1650 confusionMessage: dep.confusionMessage || null, // Detailed message from confusion check 1651 // License info 1652 license: license, 1653 licenseFull: dep.licenseFull || license, 1654 licenseAugmented: licenseAugmented, 1655 licenseSource: licenseSource, 1656 // Source-repository metadata captured during enrichment 1657 // (see exportData() for the same fields and their sources). 1658 repositoryUrl: dep.repositoryUrl || null, 1659 homepage: dep.homepage || null, 1660 issueTrackerUrl: dep.issueTrackerUrl || null, 1661 repositoryUrlSource: dep.repositoryUrlSource || null, 1662 // Phase D â enrichment outputs persisted per-dep 1663 // (see exportData() for full per-field documentation). 1664 versionDrift: dep.versionDrift || null, 1665 staleness: dep.staleness || null, 1666 eoxStatus: dep.eoxStatus || null, 1667 sourceRepoStatus: dep.sourceRepoStatus || null, 1668 // Original package reference for detailed info 1669 originalPackage: dep.originalPackage 1670 }; 1671 }); 1672 } 1673 1674 if (this.repositories.size <= 500) { 1675 // For smaller datasets, export everything 1676 allRepositories = Array.from(this.repositories.values()).map(repo => { 1677 // Defensive: ensure dependencyCategories exists 1678 const depCategories = repo.dependencyCategories || { 1679 code: new Set(), 1680 workflow: new Set(),
1681 infrastructure: new Set(), 1682 unknown: new Set() 1683 }; 1684 return { 1685 name: repo.name, 1686 owner: repo.owner, 1687 license: repo.license || null, // Include repository license 1688 // GitHub repo metadata captured during processSBOM (Phase B); 1689 // see exportData() for the same fields and their downstream consumers. 1690 pushedAt: repo.pushedAt || null, 1691 primaryLanguage: repo.primaryLanguage || null, 1692 defaultBranch: repo.defaultBranch || null, 1693 totalDependencies: repo.totalDependencies, 1694 dependencies: Array.from(repo.dependencies || []), 1695 dependencyCategories: { 1696 code: Array.from(depCategories.code || []), 1697 workflow: Array.from(depCategories.workflow || []), 1698 infrastructure: Array.from(depCategories.infrastructure || []), 1699 unknown: Array.from(depCategories.unknown || []) 1700 }, 1701 languages: Array.from(repo.languages || []) 1702 }; 1703 }); 1704 } 1705 1706 return { 1707 statistics: statistics, 1708 topDependencies: topDependencies, 1709 topRepositories: topRepositories, 1710 allDependencies: allDependencies, 1711 allRepositories: allRepositories, 1712 categoryStats: categoryStats, 1713 languageStats: languageStats, 1714 dependencyDistribution: dependencyDistribution 1715 }; 1716 } 1717 1718 /** 1719 * Check if we should save incremental data (every 10 repositories) 1720 */ 1721 shouldSaveIncremental() { 1722 return this.processedRepos > 0 && this.processedRepos % 10 === 0; 1723 } 1724 1725 /** 1726 * Clear memory after incremental save to prevent DOM from holding unnecessary data 1727 */ 1728 clearMemoryAfterSave() { 1729 // Force garbage collection hints 1730 if (window.gc) { 1731 window.gc(); 1732 } 1733 1734 // Clear any cached data that's no longer needed 1735 if (this.vulnerabilityAnalysis && this.vulnerabilityAnalysis.vulnerableDependencies) { 1736 // Keep only essential vulnerability data, clear detailed data 1737 this.vulnerabilityAnalysis.vulnerableDependencies.forEach(dep => { 1738 if (dep.vulnerabilities) { 1739 dep.vulnerabilities.forEach(vuln => { 1740 // Keep only essential fields, clear large objects 1741 delete vuln.details; 1742 delete vuln.references; 1743 delete vuln.affected; 1744 delete vuln.database_specific; 1745 }); 1746 } 1747 }); 1748 } 1749 1750 // Clear processed repository data that's already saved (keep only essential info) 1751 // This reduces memory usage for large organizations 1752 if (this.repositories.size > 50) { 1753 // For large datasets, clear detailed relationship data after processing 1754 this.repositories.forEach((repoData, repoKey) => { 1755 // Keep essential data but clear large arrays/objects 1756 if (repoData.relationships && repoData.relationships.length > 100) { 1757 // Keep only direct relationships, clear transitive ones 1758 repoData.relationships = repoData.relationships.filter(rel => rel.isDirectFromMain); 1759 } 1760 // Clear SPDX packages if we have too many (keep only essential mapping) 1761 if (repoData.spdxPackages && repoData.spdxPackages.length > 200) { 1762 repoData.spdxPackages = repoData.spdxPackages.slice(0, 200); 1763 } 1764 }); 1765 } 1766 1767 // Clear intermediate dependency data structures if they're too large 1768 if (this.dependencies.size > 1000) { 1769 // For very large dependency sets, clear originalPackage data (already processed) 1770 let clearedCount = 0;
1771 this.dependencies.forEach((dep, depKey) => { 1772 if (dep.originalPackage && clearedCount < this.dependencies.size * 0.5) { 1773 // Keep essential PURL info but clear full package object 1774 if (dep.originalPackage.externalRefs) { 1775 const purlRef = dep.originalPackage.externalRefs.find(ref => ref.referenceType === 'purl'); 1776 dep.purl = purlRef ? purlRef.referenceLocator : null; 1777 } 1778 delete dep.originalPackage; 1779 clearedCount++; 1780 } 1781 }); 1782 } 1783 1784 console.log('ð§¹ Memory cleared after incremental save'); 1785 } 1786} 1787 1788// Export for use in other modules 1789window.SBOMProcessor = SBOMProcessor;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.