UNPKG

@sun-asterisk/sunlint

Version:

☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

304 lines (253 loc) 8.88 kB
/** * Upload Service */ const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const chalk = require('chalk'); class UploadService { /** * Upload report file to API endpoint using curl */ async uploadReportToApi(filePath, apiUrl, options = {}) { try { this.validateUploadParameters(filePath, apiUrl); const oidcToken = this.getOidcToken(); console.log(chalk.blue(`📤 Uploading report to: ${apiUrl}`)); if (oidcToken) { console.log(chalk.green(`🔐 Using OIDC authentication (CI detected)`)); } const uploadResult = await this.executeUploadCommand(filePath, apiUrl, options); console.log(chalk.green(`✅ Report uploaded successfully!`)); return { success: true, url: apiUrl, filePath: filePath, response: uploadResult.response, statusCode: uploadResult.statusCode }; } catch (error) { const errorContext = { filePath, apiUrl, error: error.message, timestamp: new Date().toISOString() }; console.error(chalk.red('❌ Upload failed:'), error.message); if (options.verbose || options.debug) { console.error('Upload error context:', errorContext); } return { success: false, url: apiUrl, filePath: filePath, error: error.message, errorContext }; } } /** * Validate upload parameters */ validateUploadParameters(filePath, apiUrl) { if (!filePath) { throw new Error('File path is required for upload'); } if (!apiUrl) { throw new Error('API URL is required for upload'); } if (!fs.existsSync(filePath)) { throw new Error(`Upload file does not exist: ${filePath}`); } // Basic URL validation try { new URL(apiUrl); } catch (error) { throw new Error(`Invalid API URL format: ${apiUrl}`); } } /** * Execute curl command to upload file */ async executeUploadCommand(filePath, apiUrl, options = {}) { const fileName = path.basename(filePath); const timeout = options.timeout || 30; // 30 seconds default timeout // Build curl command with status code output const curlCommand = this.buildCurlCommand(filePath, apiUrl, fileName, timeout); try { // Execute curl command with write-out to get status code const output = execSync(curlCommand, { encoding: 'utf8', maxBuffer: 1024 * 1024, // 1MB buffer timeout: timeout * 1000 // Convert to milliseconds }); // Parse response with headers included (-i flag) // Output format: "HTTP/1.1 200 OK\nHeaders...\n\nResponse Body" const lines = output.trim().split('\n'); // Find the status line (first line starting with HTTP) let statusCode = null; const statusLine = lines.find(line => line.startsWith('HTTP/')); if (statusLine) { const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d{3})/); if (statusMatch) { statusCode = parseInt(statusMatch[1]); } } // Find response body (after empty line separating headers from body) let response = ''; let foundEmptyLine = false; for (const line of lines) { if (foundEmptyLine) { response += (response ? '\n' : '') + line; } else if (line.trim() === '') { foundEmptyLine = true; } } // If no empty line found, assume entire output is response if (!foundEmptyLine) { response = output.trim(); } return { response: response, statusCode: statusCode }; } catch (error) { throw new Error(`curl command failed: ${error.message}. Command: ${curlCommand}`); } } /** * Build curl command for file upload */ buildCurlCommand(filePath, apiUrl, fileName, timeout) { const curlOptions = [ 'curl', '-X POST', `--connect-timeout ${timeout}`, `--max-time ${timeout}`, '-H "Content-Type: application/json"', '-H "User-Agent: SunLint-Report-Uploader/1.0"' ]; // Add Idempotency-Key for safe retries const idempotencyKey = this.generateIdempotencyKey(filePath); curlOptions.push(`-H "Idempotency-Key: ${idempotencyKey}"`); // Add OIDC token if running in CI environment const oidcToken = this.getOidcToken(); if (oidcToken) { curlOptions.push(`-H "Authorization: Bearer ${oidcToken}"`); } curlOptions.push( `--data-binary @"${filePath}"`, '-i', // Include response headers in output '-s', // Silent mode (no progress meter) `"${apiUrl}"` ); return curlOptions.join(' '); } /** * Check if curl is available on system */ checkCurlAvailability() { try { execSync('curl --version', { stdio: 'ignore' }); return true; } catch (error) { return false; } } /** * Get OIDC token from environment variables (for CI authentication) */ getOidcToken() { // Try to get GitHub Actions OIDC token first (requires API call) if (process.env.GITHUB_ACTIONS && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && process.env.ACTIONS_ID_TOKEN_REQUEST_URL) { try { const githubToken = this.requestGitHubOidcToken(); if (githubToken) { return githubToken; } } catch (error) { console.log(chalk.yellow(`⚠️ Failed to get GitHub OIDC token: ${error.message}`)); } } // Check if running in GitHub Actions without OIDC token if (process.env.GITHUB_ACTIONS) { console.log(chalk.yellow('⚠️ Running in GitHub Actions but no OIDC token available. Upload may require authentication.')); } return null; } /** * Request OIDC token from GitHub Actions */ requestGitHubOidcToken() { const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; if (!requestToken || !requestUrl) { throw new Error('Missing GitHub OIDC request credentials'); } try { // Use curl to request OIDC token from GitHub with specific audience const curlCommand = `curl -H "Authorization: bearer ${requestToken}" "${requestUrl}&audience=coding-standards-report-api"`; const response = execSync(curlCommand, { encoding: 'utf8', timeout: 10000, // 10 second timeout stdio: 'pipe' }); const responseData = JSON.parse(response); if (responseData.value) { return responseData.value; } else { throw new Error('No token in response'); } } catch (error) { throw new Error(`GitHub OIDC request failed: ${error.message}`); } } /** * Generate idempotency key for safe retries */ generateIdempotencyKey(filePath) { // Create deterministic key based on file content and timestamp const fileContent = fs.readFileSync(filePath, 'utf8'); const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format // Include CI context for uniqueness across environments and attempts const ciContext = process.env.GITHUB_ACTIONS ? `${process.env.GITHUB_REPOSITORY || 'unknown'}-${process.env.GITHUB_RUN_ID || 'local'}-${process.env.GITHUB_RUN_ATTEMPT || '1'}` : 'local'; // Create hash from content + date + CI context + run attempt const hashInput = `${fileContent}-${timestamp}-${ciContext}`; const hash = crypto.createHash('sha256').update(hashInput).digest('hex'); // Return first 32 characters for reasonable key length return `sunlint-${hash.substring(0, 24)}`; } /** * Validate API endpoint accessibility */ async validateApiEndpoint(apiUrl, options = {}) { try { const timeout = options.timeout || 10; // Build command with auth header if available let testCommand = `curl -X HEAD --connect-timeout ${timeout} --max-time ${timeout} -s -o /dev/null -w "%{http_code}"`; const oidcToken = this.getOidcToken(); if (oidcToken) { testCommand += ` -H "Authorization: Bearer ${oidcToken}"`; } testCommand += ` "${apiUrl}"`; const statusCode = execSync(testCommand, { encoding: 'utf8' }).trim(); return { accessible: true, statusCode: parseInt(statusCode), url: apiUrl, authenticated: !!oidcToken }; } catch (error) { return { accessible: false, error: error.message, url: apiUrl, authenticated: !!this.getOidcToken() }; } } } module.exports = UploadService;