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.