PageSourceSearch

https://cyfinoid.github.io/sbomplay/js/sbom-processor.js?v=0.2.1&cb=1779000000000

js cyfinoid.github.io collected 2026-10-03 08:51:10 UTC 86,219 bytes, 1,789 lines download raw bytes

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.