import { Injectable, Logger } from '@nestjs/common'; import { AppError, ErrorCode } from '../../common/errors/app.error'; import { HarFile, HarEntry, HarRequest, } from '../../common/interfaces/har.interface'; export interface ParsedEndpoint { method: string; url: string; path: string; queryParams: Array<{ name: string; value: string }>; pathParams: Array<{ name: string; example: string }>; headers: Array<{ name: string; value: string }>; cookies: Array<{ name: string; value: string }>; requestBody?: string; requestBodyParams?: Array<{ name: string; value: any; type: string }>; requestContentType?: string; responseStatus: number; responseBody?: string; responseBodySchema?: any; responseContentType?: string; responseHeaders: Array<{ name: string; value: string }>; authType?: 'bearer' | 'basic' | 'apiKey' | 'cookie' | 'none'; authDetails?: { scheme?: string; headerName?: string; cookieName?: string; location?: 'header' | 'query' | 'cookie'; }; } @Injectable() export class HarParserService { private readonly logger = new Logger(HarParserService.name); parseHarFile(fileContent: string): ParsedEndpoint[] { try { this.logger.debug(`HAR file content length: ${fileContent.length} bytes`); this.logger.debug(`First 100 chars: ${fileContent.substring(0, 100)}`); const harData: HarFile = JSON.parse(fileContent); this.logger.debug(`JSON parsed successfully`); if (!harData.log) { this.logger.error('HAR file missing "log" property'); throw new AppError('Invalid HAR file: missing "log" property', ErrorCode.HAR_PARSE_ERROR, { service: 'HarParserService', function: 'parseHarFile', }); } if (!Array.isArray(harData.log.entries)) { this.logger.error(`HAR file "entries" is not an array: ${typeof harData.log.entries}`); throw new AppError('Invalid HAR file: "entries" is not an array', ErrorCode.HAR_PARSE_ERROR, { service: 'HarParserService', function: 'parseHarFile', }); } this.logger.log(`Found ${harData.log.entries.length} entries in HAR file`); const endpoints = harData.log.entries .filter((entry) => entry.request.method.toUpperCase() !== 'OPTIONS') .map((entry, index) => { try { return this.parseEntry(entry, index); } catch (err) { this.logger.warn(`Failed to parse entry ${index}: ${(err as Error).message}`); throw err; } }); // Remove duplicates based on normalized path + method const uniqueEndpoints = this.removeDuplicateEndpoints(endpoints); this.logger.log( `Parsed ${uniqueEndpoints.length} unique endpoints from ${endpoints.length} total (OPTIONS excluded, duplicates removed)`, ); return uniqueEndpoints; } catch (error) { if (error instanceof AppError) { throw error; } if (error instanceof SyntaxError) { this.logger.error(`JSON parse error: ${error.message}`); throw new AppError( `Invalid JSON in HAR file: ${error.message}`, ErrorCode.HAR_PARSE_ERROR, { service: 'HarParserService', function: 'parseHarFile', originalError: error as Error, }, ); } this.logger.error(`Unexpected error: ${(error as Error).message}`); throw new AppError( 'Failed to parse HAR file', ErrorCode.HAR_PARSE_ERROR, { service: 'HarParserService', function: 'parseHarFile', originalError: error as Error, }, ); } } private parseEntry(entry: HarEntry, entryIndex: number): ParsedEndpoint { const request = entry.request; const response = entry.response; const urlObj = this.parseUrl(request.url); const pathParams = this.extractPathParams(urlObj.path); // Parse request body if exists let requestBodyParams: Array<{ name: string; value: any; type: string }> | undefined; if (request.postData?.text) { requestBodyParams = this.parseRequestBodyParams(request.postData.text, request.postData.mimeType); } // Parse response body schema let responseBodySchema: any; if (response.content.text) { responseBodySchema = this.parseResponseSchema(response.content.text, response.content.mimeType); } // Detect authentication const authInfo = this.detectAuth(request, `${request.method} ${urlObj.path}`, entryIndex); return { method: request.method.toUpperCase(), url: request.url, path: urlObj.path, queryParams: request.queryString.map((qs) => ({ name: qs.name, value: qs.value, })), pathParams, headers: request.headers .filter((h) => !this.isIgnoredHeader(h.name)) .map((h) => ({ name: h.name, value: h.value, })), cookies: request.cookies.map((c) => ({ name: c.name, value: c.value, })), requestBody: request.postData?.text, requestBodyParams, requestContentType: request.postData?.mimeType, responseStatus: response.status, responseBody: response.content.text, responseBodySchema, responseContentType: response.content.mimeType, responseHeaders: response.headers.map((h) => ({ name: h.name, value: h.value, })), ...authInfo, }; } private parseUrl(url: string): { path: string; baseUrl: string } { try { const urlObj = new URL(url); return { path: urlObj.pathname, baseUrl: `${urlObj.protocol}//${urlObj.host}`, }; } catch { return { path: url, baseUrl: '', }; } } private isIgnoredHeader(headerName: string): boolean { const ignoredHeaders = [ 'cookie', 'host', 'content-length', 'connection', 'user-agent', 'accept-encoding', ]; return ignoredHeaders.includes(headerName.toLowerCase()); } groupEndpointsByPath(endpoints: ParsedEndpoint[]): Map { const groupedMap = new Map(); for (const endpoint of endpoints) { const normalizedPath = this.normalizePath(endpoint.path); const existing = groupedMap.get(normalizedPath) || []; existing.push(endpoint); groupedMap.set(normalizedPath, existing); } return groupedMap; } private normalizePath(path: string): string { return path.replace(/\/\d+/g, '/{id}').replace(/\/[a-f0-9-]{36}/gi, '/{uuid}'); } private extractPathParams(path: string): Array<{ name: string; example: string }> { const params: Array<{ name: string; example: string }> = []; const segments = path.split('/'); segments.forEach((segment, index) => { // Detect numeric IDs if (/^\d+$/.test(segment)) { params.push({ name: 'id', example: segment }); } // Detect UUIDs else if (/^[a-f0-9-]{36}$/i.test(segment)) { params.push({ name: 'uuid', example: segment }); } // Detect other potential params (alphanumeric strings that look like IDs) else if (segment.length > 10 && /^[a-zA-Z0-9]+$/.test(segment)) { params.push({ name: `param${index}`, example: segment }); } }); return params; } private parseRequestBodyParams( body: string, contentType?: string, ): Array<{ name: string; value: any; type: string }> { const params: Array<{ name: string; value: any; type: string }> = []; if (!body) return params; try { if (contentType?.includes('json')) { const parsed = JSON.parse(body); this.extractParamsFromObject(parsed, params); } else if (contentType?.includes('form-urlencoded')) { const urlParams = new URLSearchParams(body); urlParams.forEach((value, key) => { params.push({ name: key, value, type: 'string' }); }); } else if (contentType?.includes('form-data')) { // Basic form-data parsing const lines = body.split('\n'); lines.forEach((line) => { const match = line.match(/name="([^"]+)"/); if (match) { params.push({ name: match[1], value: '', type: 'string' }); } }); } } catch (error) { this.logger.warn(`Failed to parse request body params: ${(error as Error).message}`); } return params; } private extractParamsFromObject( obj: any, params: Array<{ name: string; value: any; type: string }>, prefix = '', ): void { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; const type = Array.isArray(value) ? 'array' : typeof value; params.push({ name: fullKey, value, type, }); if (typeof value === 'object' && value !== null && !Array.isArray(value)) { this.extractParamsFromObject(value, params, fullKey); } } } private parseResponseSchema(body: string, contentType?: string): any { if (!body || !contentType?.includes('json')) { return undefined; } try { return JSON.parse(body); } catch (error) { this.logger.warn(`Failed to parse response body: ${(error as Error).message}`); return undefined; } } private detectAuth( request: any, endpoint: string, entryIndex: number, ): { authType?: 'bearer' | 'basic' | 'apiKey' | 'cookie' | 'none'; authDetails?: { scheme?: string; headerName?: string; cookieName?: string; location?: 'header' | 'query' | 'cookie'; }; } { // 1. Check Authorization header const authHeader = request.headers.find( (h: any) => h.name.toLowerCase() === 'authorization' ); if (authHeader) { const value = authHeader.value.trim(); if (value.toLowerCase().startsWith('bearer ')) { this.logger.log( `🔐 [Entry #${entryIndex}] [${endpoint}] Found Bearer token in Authorization header`, ); return { authType: 'bearer', authDetails: { scheme: 'bearer', headerName: 'Authorization', location: 'header', }, }; } else if (value.toLowerCase().startsWith('basic ')) { this.logger.log( `🔐 [Entry #${entryIndex}] [${endpoint}] Found Basic auth in Authorization header`, ); return { authType: 'basic', authDetails: { scheme: 'basic', headerName: 'Authorization', location: 'header', }, }; } } // 2. Check for API Key in headers (common patterns) const apiKeyHeaders = ['x-api-key', 'api-key', 'apikey', 'x-auth-token']; const foundApiKeyHeader = request.headers.find((h: any) => apiKeyHeaders.includes(h.name.toLowerCase()) ); if (foundApiKeyHeader) { this.logger.log( `🔑 [Entry #${entryIndex}] [${endpoint}] Found API Key in header: ${foundApiKeyHeader.name}`, ); return { authType: 'apiKey', authDetails: { headerName: foundApiKeyHeader.name, location: 'header', }, }; } // 3. 🧰 Detect hidden Bearer tokens in other headers const hiddenBearerHeader = request.headers.find((h: any) => { const name = h.name.toLowerCase(); const value = h.value || ''; // Skip HTTP/2 pseudo-headers and common non-auth headers const excludedHeaders = [ ':authority', ':method', ':path', ':scheme', 'host', 'referer', 'origin', 'accept', 'content-type', 'user-agent', ]; if (excludedHeaders.includes(name)) { return false; } // Check if header name contains auth/token keywords const hasAuthKeyword = ['token', 'auth', 'jwt', 'bearer', 'access'].some( (keyword) => name.includes(keyword) ); // Check if value looks like a JWT token (3 parts separated by dots) const looksLikeJWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value); // Check if value is a long alphanumeric string (likely a token, minimum 32 chars) const looksLikeToken = value.length > 32 && /^[A-Za-z0-9_-]+$/.test(value); return hasAuthKeyword || looksLikeJWT || looksLikeToken; }); if (hiddenBearerHeader) { this.logger.log( `🧰 [Entry #${entryIndex}] [${endpoint}] Found HIDDEN bearer token in header: ${hiddenBearerHeader.name}`, ); return { authType: 'bearer', authDetails: { scheme: 'bearer', headerName: hiddenBearerHeader.name, location: 'header', }, }; } // 4. Check for API Key in query string const apiKeyQueryParams = ['api_key', 'apikey', 'key', 'token', 'access_token']; const foundApiKeyQuery = request.queryString.find((q: any) => apiKeyQueryParams.includes(q.name.toLowerCase()) ); if (foundApiKeyQuery) { this.logger.log( `🔑 [Entry #${entryIndex}] [${endpoint}] Found API Key in query param: ${foundApiKeyQuery.name}`, ); return { authType: 'apiKey', authDetails: { headerName: foundApiKeyQuery.name, location: 'query', }, }; } // 5. Check for auth-related cookies (including hidden tokens) const authCookieNames = ['session', 'token', 'auth', 'jwt', 'access_token', 'bearer']; const foundAuthCookie = request.cookies.find((c: any) => { const name = c.name.toLowerCase(); const value = c.value || ''; // Check cookie name const hasAuthName = authCookieNames.some((pattern) => name.includes(pattern)); // Check if cookie value looks like JWT const looksLikeJWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value); // Check if cookie value is long token const looksLikeToken = value.length > 20 && /^[A-Za-z0-9_-]+$/.test(value); return hasAuthName || looksLikeJWT || looksLikeToken; }); if (foundAuthCookie) { this.logger.log( `🍪 [Entry #${entryIndex}] [${endpoint}] Found auth cookie: ${foundAuthCookie.name}`, ); return { authType: 'cookie', authDetails: { cookieName: foundAuthCookie.name, location: 'cookie', }, }; } return { authType: 'none' }; } private removeDuplicateEndpoints(endpoints: ParsedEndpoint[]): ParsedEndpoint[] { const seen = new Map(); for (const endpoint of endpoints) { const normalizedPath = this.normalizePath(endpoint.path); const key = `${endpoint.method}:${normalizedPath}`; if (!seen.has(key)) { seen.set(key, endpoint); } } return Array.from(seen.values()); } }