PageSourceSearch

https://cyfinoid.github.io/sbomplay/js/common.js?v=0.2.1&cb=1777642497882

js cyfinoid.github.io collected 2026-10-03 08:51:28 UTC 31,445 bytes, 723 lines download raw bytes

1/**
2 * SBOM Play - Common Utilities
3 * 
4 * This file consolidates all shared utility functions used across the application.
5 * It should be loaded first (after Bootstrap) on all pages.
6 * 
7 * Contents:
8 * - Security utilities (escapeHtml, escapeJsString, isUrlFromHostname, safeSetHTML)
9 * - GitHub token format validation (isValidGitHubTokenFormat)
10 * - UI utilities (setupCollapseIcon, getSeverityBadgeClass)
11 * - Network utilities (fetchWithTimeout, debugLogUrl)
12 * - Data loading utilities (loadAnalysesList, loadOrganizationData, getUrlParams)
13 * - Finding utilities (getFindingName, getFindingDescription)
14 * - Repository formatting (formatRepoHTML, formatRepoListItem, generateRepoListHTML)
15 */
16
17console.log('📦 SBOM Play common.js loaded');
18
19// =============================================================================
20// SECURITY UTILITIES
21// =============================================================================
22
23/**
24 * Escape HTML special characters to prevent XSS attacks
25 * @param {string} text - The text to escape
26 * @returns {string} - The escaped HTML string
27 */
28function escapeHtml(text) {
29    if (!text) return '';
30    const div = document.createElement('div');
31    div.textContent = text;
32    return div.innerHTML;
33}
34
35/**
36 * Properly escape a string for use in JavaScript string literals
37 * Escapes backslashes first, then quotes and other control characters
38 * @param {string} text - The string to escape
39 * @returns {string} - The escaped string safe for use in JavaScript string literals
40 */
41function escapeJsString(text) {
42    if (!text || typeof text !== 'string') return '';
43    // Must escape backslashes FIRST, then quotes
44    return String(text)
45        .replace(/\\/g, '\\\\')  // Escape backslashes first
46        .replace(/'/g, "\\'")    // Then escape single quotes
47        .replace(/"/g, '\\"')     // Escape double quotes
48        .replace(/\n/g, '\\n')    // Escape newlines
49        .replace(/\r/g, '\\r')    // Escape carriage returns
50        .replace(/\t/g, '\\t')    // Escape tabs
51        .replace(/\f/g, '\\f')    // Escape form feeds
52        .replace(/\v/g, '\\v');   // Escape vertical tabs
53}
54
55/**
56 * Securely check if a URL belongs to a specific hostname
57 * This prevents security issues with substring matching (e.g., "evil.com/tidelift.com")
58 * @param {string} url - The URL to check
59 * @param {string} hostname - The expected hostname (e.g., "github.com", "tidelift.com")
60 * @param {string} pathPrefix - Optional path prefix to check (e.g., "/sponsors")
61 * @returns {boolean} - True if URL belongs to the hostname
62 */
63function isUrlFromHostname(url, hostname, pathPrefix = '') {
64    if (!url || typeof url !== 'string') return false;
65    
66    try {
67        // Ensure URL has a protocol
68        let urlToParse = url.trim();
69        if (!urlToParse.match(/^https?:\/\//i)) {
70            urlToParse = 'https://' + urlToParse;
71        }
72        
73        const parsedUrl = new URL(urlToParse);
74        const urlHostname = parsedUrl.hostname.toLowerCase();
75        const expectedHostname = hostname.toLowerCase();
76        
77        // Check exact hostname match or subdomain
78        // Allow subdomains (e.g., "www.github.com" matches "github.com")
79        const hostnameMatches = urlHostname === expectedHostname || 
80                               urlHostname.endsWith('.' + expectedHostname);
81        
82        if (!hostnameMatches) return false;
83        
84        // If path prefix is specified, check it
85        if (pathPrefix) {
86            const urlPath = parsedUrl.pathname.toLowerCase();
87            return urlPath.startsWith(pathPrefix.toLowerCase());
88        }
89        
90        return true;
91    } catch (e) {
92        // Invalid URL
93        return false;
94    }
95}
96
97/**
98 * Safely set innerHTML on an element
99 * Uses viewManager if available, otherwise sets directly
100 * Note: HTML should already have user data escaped before calling this
101 * @param {HTMLElement} element - The element to set HTML content for
102 * @param {string} html - The HTML string to insert
103 */
104function safeSetHTML(element, html) {
105    if (!element) return;
106    
107    if (window.viewManager && typeof window.viewManager.safeSetHTML === 'function') {
108        window.viewManager.safeSetHTML(element, html);
109    } else {
110        // Fallback - set innerHTML directly (HTML should already be escaped)
111        element.innerHTML = html || '';
112    }
113}
114
115/**
116 * Known GitHub API token prefixes (classic PAT, fine-grained PAT, OAuth, GitHub App).
117 * @see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-the-github-api
118 */
119const GITHUB_TOKEN_PREFIXES = ['ghp_', 'github_pat_', 'gho_', 'ghu_', 'ghs_', 'ghr_'];
120
121/**
122 * Return true if the string looks like a valid GitHub token (prefix + alphanumeric/underscore body).
123 * @param {string} token - Trimmed token from user input
124 * @returns {boolean}
125 */
126function isValidGitHubTokenFormat(token) {
127    if (!token || typeof token !== 'string') return false;
128    const trimmed = token.trim();
129    if (!trimmed) return false;
130    return GITHUB_TOKEN_PREFIXES.some((p) => trimmed.startsWith(p))
131        && /^[A-Za-z0-9_]+$/.test(trimmed);
132}
133
134// =============================================================================
135// UI UTILITIES
136// =============================================================================
137
138/**
139 * Setup collapse icon rotation for Bootstrap collapse elements
140 * @param {string} collapseId - The ID of the collapse element
141 * @param {string} iconId - The ID of the icon element
142 */
143function setupCollapseIcon(collapseId, iconId) {
144    const collapse = document.getElementById(collapseId);
145    const icon = document.getElementById(iconId);
146    if (collapse && icon) {
147        collapse.addEventListener('show.bs.collapse', () => {
148            icon.classList.remove('fa-chevron-up');
149            icon.classList.add('fa-chevron-down');
150        });
151        collapse.addEventListener('hide.bs.collapse', () => {
152            icon.classList.remove('fa-chevron-down');
153            icon.classList.add('fa-chevron-up');
154        });
155    }
156}
157
158/**
159 * Get severity badge class for Bootstrap
160 * Shared function for audit and findings pages
161 * @param {string} severity - Severity level
162 * @returns {string} - Bootstrap badge class
163 */
164function getSeverityBadgeClass(severity) {
165    switch (severity?.toLowerCase()) {
166        case 'critical':
167            return 'bg-danger';
168        case 'high':
169        case 'error':
170            return 'bg-danger';
171        case 'medium':
172            return 'bg-warning text-dark';
173        case 'warning':
174            return 'bg-info text-dark';
175        case 'low':
176            return 'bg-secondary';
177        default:
178            return 'bg-secondary';
179    }
180}
181
182// =============================================================================
183// NETWORK UTILITIES
184// =============================================================================
185
186/**
187 * Fetch with configurable timeout
188 * Reads timeout from localStorage (default: 10000ms)
189 * Uses AbortController to cancel request if timeout is exceeded
190 * @param {string} url - The URL to fetch
191 * @param {Object} options - Fetch options (same as fetch API)
192 * @param {number} timeout - Optional timeout override (in milliseconds)
193 * @returns {Promise<Response>} - Fetch response
194 */
195async function fetchWithTimeout(url, options = {}, timeout = null) {
196    // Get timeout from localStorage or use default
197    const defaultTimeout = timeout !== null ? timeout : (parseInt(localStorage.getItem('apiTimeout'), 10) || 10000);
198    
199    const controller = new AbortController();
200    const timeoutId = setTimeout(() => controller.abort(), defaultTimeout);
201    
202    try {
203        const response = await fetch(url, {
204            ...options,
205            signal: controller.signal
206        });
207        clearTimeout(timeoutId);
208        return response;
209    } catch (error) {
210        clearTimeout(timeoutId);
211        if (error.name === 'AbortError') {
212            throw new Error(`Request timeout after ${defaultTimeout}ms`);
213        }
214        throw error;
215    }
216}
217
218/**
219 * Debug log URL calls - only logs if debug URL logging is enabled in settings
220 * @param {...any} args - Arguments to pass to console.log
221 */
222function debugLogUrl(...args) {
223    if (localStorage.getItem('debugUrlLogging') === 'true') {
224        console.log(...args);
225    }
226}
227
228// =============================================================================
229// DATA LOADING UTILITIES
230// =============================================================================
231
232/**
233 * Load analyses list into a selector dropdown
234 * Shows aggregated data by default, individual analyses when selected
235 * @param {string} selectorId - ID of the select element
236 * @param {StorageManager} storageManager - StorageManager instance
237 * @param {HTMLElement|null} noDataSection - Optional element to show when no data
238 */
239async function loadAnalysesList(selectorId, storageManager, noDataSection = null) {
240    const selector = document.getElementById(selectorId);
241    if (!selector) {
242        console.error(`Selector element not found: ${selectorId}`);
243        return;
244    }
245    
246    try {
247        console.log(`📋 Loading analyses list for selector: ${selectorId}`);
248        
249        // Ensure storage manager is initialized
250        if (!storageManager.initialized) {
251            await storageManager.init();
252        }
253        
254        const storageInfo = await storageManager.getStorageInfo();
255        console.log(`📋 Storage info retrieved: ${storageInfo.organizations.length} orgs, ${storageInfo.repositories.length} repos`);
256        
257        // Filter out:
258        // 1. __ALL__ entries (legacy internal identifier)
259        // 2. Entries with 0 dependencies (repositories without SBOM/dependency graph)
260        const allEntries = [...storageInfo.organizations, ...storageInfo.repositories]
261            .filter(entry => entry.name !== '__ALL__' && entry.dependencies > 0);
262        
263        const filteredOutCount = (storageInfo.organizations.length + storageInfo.repositories.length) - allEntries.length;
264        if (filteredOutCount > 0) {
265            console.log(`📋 Filtered out ${filteredOutCount} entries (no dependencies/SBOM)`);
266        }
267        console.log(`📋 Total entries to add: ${allEntries.length}`);
268        
269        selector.innerHTML = '';
270        
271        if (allEntries.length === 0) {
272            console.warn(`⚠️ No entries found in storage. Selector will be disabled.`);
273            if (noDataSection) {
274                noDataSection.style.display = 'block';
275                noDataSection.classList.remove('d-none');
276            }
277            selector.disabled = true;
278            return;
279        }
280        
281        // Add placeholder option for "All Analyses"
282        const allOption = document.createElement('option');
283        allOption.value = '';
284        const totalDeps = allEntries.reduce((sum, entry) => sum + (entry.dependencies || 0), 0);
285        allOption.textContent = `All Analyses (${totalDeps} deps)`;
286        selector.appendChild(allOption);
287        console.log(`📋 Added "All Analyses" placeholder option`);
288        
289        // Add individual entries
290        allEntries.forEach(entry => {
291            const option = document.createElement('option');
292            option.value = entry.name;
293            const depCount = entry.dependencies || 0;
294            option.textContent = `${entry.name} (${depCount} deps)`;
295            selector.appendChild(option);
296            console.log(`📋 Added option: ${entry.name} (${depCount} deps)`);
297        });
298        
299        if (noDataSection) {
300            noDataSection.style.display = 'none';
301            noDataSection.classList.add('d-none');
302        }
303        selector.disabled = false;
304        console.log(`✅ Analysis selector populated with ${allEntries.length} entries`);
305    } catch (error) {
306        console.error('❌ Error loading analyses list:', error);
307        console.error('   Error details:', error.stack);
308        if (noDataSection) {
309            noDataSection.style.display = 'block';
310            noDataSection.classList.remove('d-none');
311        }
312        selector.disabled = true;
313    }
314}
315
316/**
317 * Load organization data with common filtering and validation
318 * @param {string} name - Organization/repository name, or empty/null for aggregated data
319 * @param {StorageManager} storageManager - StorageManager instance
320 * @param {Object} options - Configuration options
321 * @param {string|null} options.severityFilter - Severity filter value
322 * @param {string|null} options.sectionFilter - Section filter value (for audit page)
323 * @param {string|null} options.repoFilter - Repository filter value
324 * @param {string} options.containerId - ID of container element
325 * @param {HTMLElement|null} options.noDataSection - Optional no-data section element
326 * @param {Function} options.renderFunction - Function to render the data
327 * @returns {Promise<Object|null>} - Loaded data or null
328 */
329async function loadOrganizationData(name, storageManager, options = {}) {
330    const {
331        severityFilter = null,
332        sectionFilter = null,
333        repoFilter = null,
334        categoryFilter = null, // For license page
335        containerId,
336        noDataSection = null,
337        renderFunction
338    } = options;
339    
340    let data;
341    
342    console.log(`📋 Loading organization data for: ${name || 'All Analyses (aggregated)'}`);
343    
344    // If name is empty or null/undefined, load combined data (aggregated from all analyses)
345    if (!name || name === '') {
346        data = await storageManager.getCombinedData();
347        if (!data) {
348            data = {
349                name: 'All Analyses',
350                organization: 'All Analyses',
351                data: {}
352            };
353        }
354    } else {
355        data = await storageManager.loadAnalysisDataForOrganization(name);
356    }
357    
358    const container = document.getElementById(containerId);
359    if (!container) {
360        console.error(`Container element not found: ${containerId}`);
361        return null;
362    }
363    
364    if (!data || !data.data) {
365        console.warn(`⚠️ No data found for: ${name || 'aggregated data'}`);
366        if (noDataSection) {
367            noDataSection.classList.remove('d-none');
368        }
369        // Use safeSetHTML for security
370        const alertHtml = '<div class="alert alert-warning">No data found for this entry.</div>';
371        safeSetHTML(container, alertHtml);
372        return null;
373    }
374    
375    console.log(`✅ Data loaded: ${data.organization || data.name}, has licenseAnalysis: ${!!data.data.licenseAnalysis}`);
376    
377    // Filter by repository if repo parameter is present
378    if (repoFilter && data.data.vulnerabilityAnalysis && data.data.vulnerabilityAnalysis.vulnerableDependencies) {
379        const filteredVulnDeps = data.data.vulnerabilityAnalysis.vulnerableDependencies.filter(dep => {
380            if (dep.repositories && Array.isArray(dep.repositories)) {
381                return dep.repositories.includes(repoFilter);
382            }
383            if (data.data.allDependencies) {
384                const matchingDep = data.data.allDependencies.find(d => 
385                    d.name === dep.name && d.version === dep.version
386                );
387                return matchingDep && matchingDep.repositories && matchingDep.repositories.includes(repoFilter);
388            }
389            return false;
390        });
391        // Create a copy of data and filter vulnerable dependencies
392        data = JSON.parse(JSON.stringify(data));
393        data.data.vulnerabilityAnalysis.vulnerableDependencies = filteredVulnDeps;
394    }
395    
396    // Hide no data section if we have data
397    if (noDataSection) {
398        noDataSection.classList.add('d-none');
399    }
400    
401    // Call render function if provided
402    // Pass all filters: severityFilter, sectionFilter, repoFilter, categoryFilter
403    // Different pages use different filters, so we pass all of them
404    if (renderFunction && typeof renderFunction === 'function') {
405        await renderFunction(data, severityFilter, sectionFilter, repoFilter, categoryFilter);
406    } else if (!renderFunction && containerId === 'license-compliance-page') {
407        // Default rendering for license page if no renderFunction provided
408        if (window.viewManager && typeof window.viewManager.generateLicenseComplianceHTML === 'function') {
409            const html = await window.viewManager.generateLicenseComplianceHTML(data, categoryFilter);
410            window.viewManager.safeSetHTML(container, html);
411        }
412    }
413    
414    return data;
415}
416
417// =============================================================================
418// FILTER LOADING OVERLAY
419// =============================================================================
420// Debounced "the table is still re-rendering" overlay used by every page with
421// a non-trivial filter step (audit, vuln, malware, licenses, feeds, repos).
422//
423// Design:
424//   - The overlay only appears if the wrapped work is still running after a
425//     short delay (default 150 ms). Filters that complete quickly never flash
426//     a spinner — keeping fast interactions flicker-free.
427//   - Hide cancels any still-pending show timer, so an early hideFilterLoading
428//     leaves the UI completely untouched.
429//   - The container is positioned `relative` if it's currently `static`, so
430//     the absolutely-positioned overlay lays out correctly without any HTML
431//     change to the host page.
432//   - Pages that already ship a hand-authored `.loading-overlay` child reuse
433//     it; pages that don't get one injected on first use.
434//   - State is keyed by container id in a module-level Map so multiple
435//     concurrent overlays (different containers) coexist without leaking.
436
437const filterLoadingStates = new Map();
438
439/**
440 * Show a debounced "Loading..." overlay over a container. If the work
441 * completes before the debounce elapses (and `hideFilterLoading` is called
442 * for the same container), no overlay is ever rendered.
443 *
444 * @param {string} containerId - id of the element to overlay
445 * @param {Object} [opts]
446 * @param {number} [opts.delay=150] - debounce in ms before the overlay appears
447 * @param {string} [opts.message='Loading...'] - text shown under the spinner
448 */
449function showFilterLoading(containerId, opts = {}) {
450    const delay = typeof opts.delay === 'number' ? opts.delay : 150;
451    const message = opts.message || 'Loading...';
452
453    const container = document.getElementById(containerId);
454    if (!container) return;
455
456    const previous = filterLoadingStates.get(containerId);
457    if (previous && previous.timeoutId) {
458        clearTimeout(previous.timeoutId);
459    }
460
461    if (getComputedStyle(container).position === 'static') {
462        container.style.position = 'relative';
463    }
464
465    const timeoutId = setTimeout(() => {
466        let overlay = container.querySelector(':scope > .loading-overlay');
467
468        if (!overlay) {
469            overlay = document.createElement('div');
470            overlay.className = 'loading-overlay';
471
472            const wrap = document.createElement('div');
473            wrap.className = 'loading-spinner';
474
475            const spinner = document.createElement('div');
476            spinner.className = 'spinner-border text-primary';
477            spinner.setAttribute('role', 'status');
478
479            const sr = document.createElement('span');
480            sr.className = 'visually-hidden';
481            sr.textContent = 'Loading...';
482            spinner.appendChild(sr);
483
484            const label = document.createElement('p');
485            label.className = 'mt-2';
486            label.textContent = message;
487
488            wrap.appendChild(spinner);
489            wrap.appendChild(label);
490            overlay.appendChild(wrap);
491            container.appendChild(overlay);
492        } else {
493            const label = overlay.querySelector('.loading-spinner p');
494            if (label && message) label.textContent = message;
495            overlay.classList.remove('d-none');
496        }
497
498        filterLoadingStates.set(containerId, { timeoutId: null, overlayEl: overlay });
499    }, delay);
500
501    filterLoadingStates.set(containerId, {
502        timeoutId,
503        overlayEl: previous ? previous.overlayEl : null
504    });
505}
506
507/**
508 * Hide the loading overlay for a container. Cancels any pending debounce
509 * timer if the overlay hasn't been shown yet — safe to call when nothing is
510 * currently visible.
511 *
512 * @param {string} containerId
513 */
514function hideFilterLoading(containerId) {
515    const state = filterLoadingStates.get(containerId);
516    if (!state) return;
517    if (state.timeoutId) clearTimeout(state.timeoutId);
518    if (state.overlayEl) state.overlayEl.classList.add('d-none');
519    filterLoadingStates.delete(containerId);
520}
521
522/**
523 * Get URL parameters as an object with parsed values
524 * @param {Array<string>} filterNames - Array of parameter names to extract
525 * @returns {Object} - Object with parameter values
526 */
527function getUrlParams(filterNames = []) {
528    const urlParams = new URLSearchParams(window.location.search);
529    const result = {};
530    
531    filterNames.forEach(name => {
532        const value = urlParams.get(name);
533        if (value !== null) {
534            result[name] = value.toLowerCase();
535        }
536    });
537    
538    return result;
539}
540
541// =============================================================================
542// FINDING UTILITIES
543// =============================================================================
544
545/**
546 * Get finding name from rule ID
547 * Shared function for audit and findings pages
548 * @param {string} ruleId - Rule identifier
549 * @returns {string} - Human-readable name
550 */
551function getFindingName(ruleId) {
552    const names = {
553        'MUTABLE_TAG_REFERENCE': 'Mutable Tag Reference',
554        'DOCKER_FLOATING_TAG': 'Docker Floating Tag',
555        'DOCKER_IMPLICIT_LATEST': 'Docker Implicit Latest Tag',
556        'DOCKERFILE_FLOATING_BASE_IMAGE': 'Dockerfile Floating Base Image',
557        'DOCKER_UNPINNED_DEPENDENCIES': 'Docker Unpinned Dependencies',
558        'DOCKER_REMOTE_CODE_NO_INTEGRITY': 'Docker Remote Code Without Integrity Check',
559        'COMPOSITE_NESTED_UNPINNED_ACTION': 'Composite Nested Unpinned Action',
560        'COMPOSITE_UNPINNED_DEPENDENCIES': 'Composite Unpinned Dependencies',
561        'COMPOSITE_REMOTE_CODE_NO_INTEGRITY': 'Composite Remote Code Without Integrity Check',
562        'JS_REMOTE_CODE_NO_INTEGRITY': 'JavaScript Remote Code Without Integrity Check',
563        'JS_RUNTIME_UNPINNED_DEPENDENCIES': 'JavaScript Runtime Unpinned Dependencies',
564        'INDIRECT_UNPINNABLE_ACTION': 'Indirect Unpinnable Action',
565        'NAMESPACE_NOT_IN_REGISTRY': 'Namespace Not Found (Dependency Confusion)',
566        'PACKAGE_NOT_IN_REGISTRY': 'Package Not Found (Dependency Confusion)',
567        'PULL_REQUEST_TARGET_CHECKOUT': 'Dangerous PR Target Checkout',
568        'EXCESSIVE_WORKFLOW_PERMISSIONS': 'Excessive Workflow Permissions',
569        'EXCESSIVE_JOB_PERMISSIONS': 'Excessive Job Permissions',
570        'POTENTIAL_HARDCODED_SECRET': 'Potential Hardcoded Secret',
571        'ACTION_METADATA_UNAVAILABLE': 'Action Metadata Unavailable',
572        'ANALYSIS_ERROR': 'Analysis Error'
573    };
574    return names[ruleId] || ruleId;
575}
576
577/**
578 * Get finding description from rule ID
579 * Shared function for audit and findings pages
580 * @param {string} ruleId - Rule identifier
581 * @returns {string} - Description of the finding
582 */
583function getFindingDescription(ruleId) {
584    const descriptions = {
585        'MUTABLE_TAG_REFERENCE': 'Action is referenced by a mutable tag instead of a commit SHA. This covers branch-style tags (e.g. `main`, `master`, `latest`) and version tags (e.g. `v2`, `v2.3.4`) — both can be re-pointed by the publisher. Pin to a full 40-character commit SHA.',
586        'DOCKER_FLOATING_TAG': 'Docker images using floating tags (e.g., "latest", version ranges) are not immutable and can introduce unexpected changes.',
587        'DOCKER_IMPLICIT_LATEST': 'Docker images without explicit tags default to "latest", which is mutable and insecure.',
588        'DOCKERFILE_FLOATING_BASE_IMAGE': 'Dockerfile base images using floating tags can change, affecting build reproducibility and security.',
589        'DOCKER_UNPINNED_DEPENDENCIES': 'Docker container dependencies should be pinned to specific versions for security and reproducibility.',
590        'DOCKER_REMOTE_CODE_NO_INTEGRITY': 'Remote code execution in Docker without integrity checks can lead to supply chain attacks.',
591        'COMPOSITE_NESTED_UNPINNED_ACTION': 'Composite actions calling other actions without pinning create nested security risks.',
592        'COMPOSITE_UNPINNED_DEPENDENCIES': 'Composite actions with unpinned dependencies can introduce vulnerabilities.',
593        'COMPOSITE_REMOTE_CODE_NO_INTEGRITY': 'Composite actions executing remote code without integrity verification pose security risks.',
594        'JS_REMOTE_CODE_NO_INTEGRITY': 'JavaScript actions executing remote code without integrity checks can be compromised.',
595        'JS_RUNTIME_UNPINNED_DEPENDENCIES': 'JavaScript actions with unpinned runtime dependencies may include vulnerable packages.',
596        'INDIRECT_UNPINNABLE_ACTION': 'Actions that cannot be pinned due to indirect references create security blind spots.',
597        'NAMESPACE_NOT_IN_REGISTRY': 'Namespace/organization not found in public registry. This is a HIGH-CONFIDENCE dependency confusion risk. An attacker could register this namespace and all packages under it would be vulnerable to hijacking.',
598        'PACKAGE_NOT_IN_REGISTRY': 'Package not found in public registry. This could indicate a private/internal package that is vulnerable to dependency confusion attacks. Attackers can register a package with the same name on public registries.',
599        'PULL_REQUEST_TARGET_CHECKOUT': 'Dangerous pattern: pull_request_target workflow checks out PR code, which can execute untrusted code with elevated permissions.',
600        'EXCESSIVE_WORKFLOW_PERMISSIONS': 'Workflow uses broad permissions like write-all, violating the principle of least privilege.',
601        'EXCESSIVE_JOB_PERMISSIONS': 'Job uses write-all permissions. Specify only the required permissions.',
602        'POTENTIAL_HARDCODED_SECRET': 'Environment variable appears to contain a hardcoded secret. Use GitHub Secrets instead.',
603        'ACTION_METADATA_UNAVAILABLE': 'Could not retrieve action metadata. The action may be unavailable or the repository may have been deleted.',
604        'ANALYSIS_ERROR': 'An error occurred during analysis of this action.'
605    };
606    return descriptions[ruleId] || 'Security issue detected.';
607}
608
609// =============================================================================
610// REPOSITORY FORMATTING
611// =============================================================================
612
613/**
614 * Format a single repository name as HTML
615 * Uploaded SBOMs (starting with 'upload/') are shown without GitHub link
616 * @param {string} repo - Repository name
617 * @returns {string} - HTML string for the repository
618 */
619function formatRepoHTML(repo) {
620    const escapedRepo = escapeHtml(repo);
621    // Check if this is an uploaded SBOM (not from GitHub)
622    if (repo.startsWith('upload/')) {
623        // Display with upload icon instead of GitHub link
624        const displayName = repo.replace(/^upload\//, '');
625        return `<span class="text-muted"><i class="fas fa-upload me-1" title="Uploaded SBOM"></i>${escapeHtml(displayName)}</span>`;
626    }
627    // Regular GitHub repository - show as link
628    return `<a href="https://github.com/${escapedRepo}" target="_blank" rel="noreferrer noopener" class="text-decoration-none"><i class="fab fa-github me-1"></i>${escapedRepo}</a>`;
629}
630
631/**
632 * Format a single repository for modal list
633 * @param {string} repo - Repository name
634 * @param {number} idx - Index for badge
635 * @returns {string} - HTML string for the list item
636 */
637function formatRepoListItem(repo, idx) {
638    const escapedRepo = escapeHtml(repo);
639    if (repo.startsWith('upload/')) {
640        const displayName = repo.replace(/^upload\//, '');
641        return `
642            <div class="list-group-item">
643                <div class="d-flex align-items-center gap-2">
644                    <span class="badge bg-secondary">${idx + 1}</span>
645                    <span class="text-muted"><i class="fas fa-upload me-1" title="Uploaded SBOM"></i>${escapeHtml(displayName)}</span>
646                </div>
647            </div>`;
648    }
649    return `
650        <div class="list-group-item">
651            <div class="d-flex align-items-center gap-2">
652                <span class="badge bg-secondary">${idx + 1}</span>
653                <a href="https://github.com/${escapedRepo}" target="_blank" rel="noreferrer noopener" class="text-decoration-none">
654                    <i class="fab fa-github me-1"></i>${escapedRepo}
655                </a>
656            </div>
657        </div>`;
658}
659
660/**
661 * Generate repository list HTML with modal support for many repos
662 * Shared function for audit and findings pages
663 * @param {Array} repositories - Array of repository names
664 * @returns {string} - HTML string with repository links
665 */
666function generateRepoListHTML(repositories) {
667    if (!repositories || repositories.length === 0) {
668        return '-';
669    }
670    
671    const repoCount = repositories.length;
672    const repoLinks = repositories.map(r => formatRepoHTML(r));
673    
674    if (repoCount <= 3) {
675        return repoLinks.join(', ');
676    } else {
677        const modalId = `repos-modal-${Math.random().toString(36).substr(2, 9)}`;
678        const visibleRepos = repoLinks.slice(0, 3).join(', ');
679        
680        return `${visibleRepos} 
681            <a href="#" class="text-primary" data-bs-toggle="modal" data-bs-target="#${modalId}" onclick="event.preventDefault();">
682                and ${repoCount - 3} more
683            </a>
684            <div class="modal fade" id="${modalId}" tabindex="-1" aria-hidden="true">
685                <div class="modal-dialog modal-lg">
686                    <div class="modal-content">
687                        <div class="modal-header">
688                            <h5 class="modal-title">All Repositories (${repoCount})</h5>
689                            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
690                        </div>
691                        <div class="modal-body">
692                            <div class="list-group">
693                                ${repositories.map((repo, idx) => formatRepoListItem(repo, idx)).join('')}
694                            </div>
695                        </div>
696                        <div class="modal-footer">
697                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
698                        </div>
699                    </div>
700                </div>
701            </div>`;
702    }
703}
704
705// =============================================================================
706// GLOBAL EXPORTS
707// =============================================================================
708
709// Make network utilities globally available for services
710window.fetchWithTimeout = fetchWithTimeout;
711window.debugLogUrl = debugLogUrl;
712
713// Make security utilities globally available
714window.escapeHtml = escapeHtml;
715window.escapeJsString = escapeJsString;
716window.isUrlFromHostname = isUrlFromHostname;
717window.safeSetHTML = safeSetHTML;
718
719window.GITHUB_TOKEN_PREFIXES = GITHUB_TOKEN_PREFIXES;
720window.isValidGitHubTokenFormat = isValidGitHubTokenFormat;
721
722window.showFilterLoading = showFilterLoading;
723window.hideFilterLoading = hideFilterLoading;

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.