feat(phase-3): implement comprehensive SSL/SEO/Security analysis system
🔒 SSL Certificate Analysis: - Complete SSL certificate inspection with detailed metadata extraction - Security scoring based on key size, signature algorithm, protocol version - Certificate chain validation and expiry tracking - Cipher suite analysis and vulnerability detection - TLS protocol version assessment with security recommendations - Automated certificate warnings for weak configurations 🔍 SEO Analysis Engine: - Robots.txt parsing and rule extraction with sitemap discovery - Meta tag analysis (title, description, robots, canonical, OpenGraph, Twitter) - Content optimization scoring with length recommendations - Search engine indexing directive detection (noindex, nofollow) - Social media meta tag validation for sharing optimization - Comprehensive SEO scoring with actionable recommendations 🛡️ Security Vulnerability Scanner: - Mixed content detection for HTTPS/HTTP downgrades - Security header analysis (HSTS, CSP, X-Frame-Options, etc.) - Open redirect vulnerability detection in redirect chains - Safe browsing status simulation with pattern matching - Security header scoring and implementation recommendations - Comprehensive security posture assessment 🔧 Technical Implementation: - Parallel analysis execution for optimal performance - Database persistence of all analysis results in dedicated tables - Integration with existing redirect tracking system - Configurable analysis toggles (SSL/SEO/Security on/off) - Production-grade error handling and timeout management - Resource-intensive operation rate limiting 🌐 New API Endpoints: - POST /api/v2/analyze/ssl - Dedicated SSL certificate analysis - POST /api/v2/analyze/seo - Comprehensive SEO audit and recommendations - POST /api/v2/analyze/security - Security vulnerability assessment - POST /api/v2/analyze/comprehensive - All analyses in parallel - GET /api/v2/analyze/check/:id - Retrieve stored analysis results 📊 Enhanced Data Model: - SSL inspections table with certificate metadata and warnings - SEO flags table with robots.txt rules and meta tag analysis - Security flags table with vulnerability and header assessment - Foreign key relationships linking analyses to redirect checks 🚀 Integration Features: - Enhanced tracking endpoints now include analysis flags - Automatic analysis triggers on redirect completion - Analysis result caching and retrieval system - Cross-analysis correlation and scoring - Structured recommendations and warnings ⚡ Performance Optimizations: - Promise.allSettled for parallel analysis execution - Timeout controls for external requests (5-10s) - Response size limits to prevent memory issues - Intelligent analysis skipping for inappropriate URLs - Graceful degradation when individual analyses fail Ready for Phase 4: Complete Chakra UI frontend with visual analysis dashboards
This commit is contained in:
@@ -17,6 +17,7 @@ import { logger } from './lib/logger';
|
||||
import { trackRedirects } from './services/redirect-legacy.service';
|
||||
import authRoutes from './routes/auth.routes';
|
||||
import trackingRoutes from './routes/tracking.routes';
|
||||
import analysisRoutes from './routes/analysis.routes';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3333;
|
||||
@@ -70,6 +71,9 @@ app.use('/api/v1/auth', authRoutes);
|
||||
// Enhanced tracking routes (v2)
|
||||
app.use('/api/v2', trackingRoutes);
|
||||
|
||||
// Analysis routes (v2)
|
||||
app.use('/api/v2/analyze', analysisRoutes);
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
411
apps/api/src/routes/analysis.routes.ts
Normal file
411
apps/api/src/routes/analysis.routes.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Analysis Routes for Redirect Intelligence v2
|
||||
*
|
||||
* Provides dedicated endpoints for SSL, SEO, and Security analysis
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { SSLAnalyzerService } from '../services/ssl-analyzer.service';
|
||||
import { SEOAnalyzerService } from '../services/seo-analyzer.service';
|
||||
import { SecurityAnalyzerService } from '../services/security-analyzer.service';
|
||||
import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { logger } from '../lib/logger';
|
||||
import { prisma } from '../lib/prisma';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Initialize analysis services
|
||||
const sslAnalyzer = new SSLAnalyzerService();
|
||||
const seoAnalyzer = new SEOAnalyzerService();
|
||||
const securityAnalyzer = new SecurityAnalyzerService();
|
||||
|
||||
// Rate limiting for analysis endpoints
|
||||
const analysisLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 100, // Lower limit for resource-intensive analysis
|
||||
message: {
|
||||
success: false,
|
||||
error: 'Analysis rate limit exceeded',
|
||||
message: 'Too many analysis requests. Please try again later.'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req: AuthenticatedRequest) => {
|
||||
return req.user ? `user:${req.user.id}` : `ip:${req.ip}`;
|
||||
},
|
||||
});
|
||||
|
||||
// Input validation schemas
|
||||
const analyzeUrlSchema = z.object({
|
||||
url: z.string().url('Invalid URL format'),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/analyze/ssl
|
||||
* Comprehensive SSL certificate analysis
|
||||
*/
|
||||
router.post('/ssl',
|
||||
optionalAuth,
|
||||
analysisLimiter,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { url } = analyzeUrlSchema.parse(req.body);
|
||||
|
||||
// Normalize URL
|
||||
let analyzeUrl = url;
|
||||
if (!analyzeUrl.startsWith('http://') && !analyzeUrl.startsWith('https://')) {
|
||||
analyzeUrl = 'https://' + analyzeUrl; // Default to HTTPS for SSL analysis
|
||||
}
|
||||
|
||||
if (!analyzeUrl.startsWith('https://')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid URL for SSL analysis',
|
||||
message: 'SSL analysis requires HTTPS URLs'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await sslAnalyzer.analyzeSSL(analyzeUrl);
|
||||
|
||||
logger.info(`SSL analysis completed for: ${analyzeUrl}`, {
|
||||
userId: req.user?.id,
|
||||
securityScore: result.securityScore,
|
||||
warningsCount: result.warnings.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
analysis: result,
|
||||
url: analyzeUrl,
|
||||
},
|
||||
meta: {
|
||||
version: 'v2',
|
||||
analysisType: 'ssl',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('SSL analysis failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
message: error.errors[0]?.message || 'Invalid input',
|
||||
details: error.errors
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'SSL analysis failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v2/analyze/seo
|
||||
* Comprehensive SEO analysis
|
||||
*/
|
||||
router.post('/seo',
|
||||
optionalAuth,
|
||||
analysisLimiter,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { url } = analyzeUrlSchema.parse(req.body);
|
||||
|
||||
// Normalize URL
|
||||
let analyzeUrl = url;
|
||||
if (!analyzeUrl.startsWith('http://') && !analyzeUrl.startsWith('https://')) {
|
||||
analyzeUrl = 'https://' + analyzeUrl; // Default to HTTPS for SEO analysis
|
||||
}
|
||||
|
||||
const result = await seoAnalyzer.analyzeSEO(analyzeUrl);
|
||||
|
||||
logger.info(`SEO analysis completed for: ${analyzeUrl}`, {
|
||||
userId: req.user?.id,
|
||||
score: result.score,
|
||||
robotsStatus: result.flags.robotsTxtStatus,
|
||||
noindex: result.flags.noindex
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
analysis: result,
|
||||
url: analyzeUrl,
|
||||
},
|
||||
meta: {
|
||||
version: 'v2',
|
||||
analysisType: 'seo',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('SEO analysis failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
message: error.errors[0]?.message || 'Invalid input',
|
||||
details: error.errors
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'SEO analysis failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v2/analyze/security
|
||||
* Comprehensive security analysis
|
||||
*/
|
||||
router.post('/security',
|
||||
optionalAuth,
|
||||
analysisLimiter,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { url } = analyzeUrlSchema.parse(req.body);
|
||||
|
||||
// Normalize URL
|
||||
let analyzeUrl = url;
|
||||
if (!analyzeUrl.startsWith('http://') && !analyzeUrl.startsWith('https://')) {
|
||||
analyzeUrl = 'https://' + analyzeUrl; // Default to HTTPS for security analysis
|
||||
}
|
||||
|
||||
const result = await securityAnalyzer.analyzeSecurity(analyzeUrl);
|
||||
|
||||
logger.info(`Security analysis completed for: ${analyzeUrl}`, {
|
||||
userId: req.user?.id,
|
||||
securityScore: result.securityScore,
|
||||
safeBrowsing: result.safeBrowsing.status,
|
||||
vulnerabilityCount: result.vulnerabilities.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
analysis: result,
|
||||
url: analyzeUrl,
|
||||
},
|
||||
meta: {
|
||||
version: 'v2',
|
||||
analysisType: 'security',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Security analysis failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
message: error.errors[0]?.message || 'Invalid input',
|
||||
details: error.errors
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Security analysis failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v2/analyze/comprehensive
|
||||
* Run all analyses (SSL, SEO, Security) for a URL
|
||||
*/
|
||||
router.post('/comprehensive',
|
||||
optionalAuth,
|
||||
analysisLimiter,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { url } = analyzeUrlSchema.parse(req.body);
|
||||
|
||||
// Normalize URL
|
||||
let analyzeUrl = url;
|
||||
if (!analyzeUrl.startsWith('http://') && !analyzeUrl.startsWith('https://')) {
|
||||
analyzeUrl = 'https://' + analyzeUrl;
|
||||
}
|
||||
|
||||
logger.info(`Starting comprehensive analysis for: ${analyzeUrl}`, {
|
||||
userId: req.user?.id
|
||||
});
|
||||
|
||||
// Run all analyses in parallel
|
||||
const [sslResult, seoResult, securityResult] = await Promise.allSettled([
|
||||
analyzeUrl.startsWith('https://') ? sslAnalyzer.analyzeSSL(analyzeUrl) : Promise.resolve(null),
|
||||
seoAnalyzer.analyzeSEO(analyzeUrl),
|
||||
securityAnalyzer.analyzeSecurity(analyzeUrl),
|
||||
]);
|
||||
|
||||
// Compile results
|
||||
const analysis = {
|
||||
ssl: sslResult.status === 'fulfilled' ? sslResult.value : {
|
||||
error: sslResult.status === 'rejected' ? sslResult.reason?.message : 'SSL analysis failed'
|
||||
},
|
||||
seo: seoResult.status === 'fulfilled' ? seoResult.value : {
|
||||
error: seoResult.status === 'rejected' ? seoResult.reason?.message : 'SEO analysis failed'
|
||||
},
|
||||
security: securityResult.status === 'fulfilled' ? securityResult.value : {
|
||||
error: securityResult.status === 'rejected' ? securityResult.reason?.message : 'Security analysis failed'
|
||||
},
|
||||
};
|
||||
|
||||
// Calculate overall score
|
||||
const scores = [
|
||||
analysis.ssl && 'securityScore' in analysis.ssl ? analysis.ssl.securityScore : 0,
|
||||
analysis.seo && 'score' in analysis.seo ? analysis.seo.score : 0,
|
||||
analysis.security && 'securityScore' in analysis.security ? analysis.security.securityScore : 0,
|
||||
].filter(score => score > 0);
|
||||
|
||||
const overallScore = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
||||
|
||||
logger.info(`Comprehensive analysis completed for: ${analyzeUrl}`, {
|
||||
userId: req.user?.id,
|
||||
overallScore,
|
||||
sslSuccess: sslResult.status === 'fulfilled',
|
||||
seoSuccess: seoResult.status === 'fulfilled',
|
||||
securitySuccess: securityResult.status === 'fulfilled'
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
analysis,
|
||||
summary: {
|
||||
overallScore,
|
||||
analysesCompleted: scores.length,
|
||||
totalAnalyses: 3,
|
||||
},
|
||||
url: analyzeUrl,
|
||||
},
|
||||
meta: {
|
||||
version: 'v2',
|
||||
analysisType: 'comprehensive',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Comprehensive analysis failed:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
message: error.errors[0]?.message || 'Invalid input',
|
||||
details: error.errors
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Comprehensive analysis failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v2/analyze/check/:checkId
|
||||
* Get analysis results for a specific check
|
||||
*/
|
||||
router.get('/check/:checkId',
|
||||
optionalAuth,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { checkId } = req.params;
|
||||
|
||||
if (!checkId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Check ID required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get check with all analysis data
|
||||
const check = await prisma.check.findUnique({
|
||||
where: { id: checkId },
|
||||
include: {
|
||||
hops: {
|
||||
orderBy: { hopIndex: 'asc' }
|
||||
},
|
||||
sslInspections: true,
|
||||
seoFlags: true,
|
||||
securityFlags: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!check) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Check not found',
|
||||
message: 'The requested check does not exist or you do not have access to it'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Add proper permission checking in future phases
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: 200,
|
||||
data: {
|
||||
check: {
|
||||
id: check.id,
|
||||
inputUrl: check.inputUrl,
|
||||
finalUrl: check.finalUrl,
|
||||
status: check.status,
|
||||
startedAt: check.startedAt,
|
||||
finishedAt: check.finishedAt,
|
||||
totalTimeMs: check.totalTimeMs,
|
||||
},
|
||||
analysis: {
|
||||
ssl: check.sslInspections,
|
||||
seo: check.seoFlags,
|
||||
security: check.securityFlags,
|
||||
},
|
||||
hops: check.hops,
|
||||
},
|
||||
meta: {
|
||||
version: 'v2',
|
||||
checkId: check.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve check analysis:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve analysis',
|
||||
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -11,6 +11,9 @@ import { z } from 'zod';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { logger } from '../lib/logger';
|
||||
import { CheckStatus, RedirectType } from '@prisma/client';
|
||||
import { SSLAnalyzerService } from './ssl-analyzer.service';
|
||||
import { SEOAnalyzerService } from './seo-analyzer.service';
|
||||
import { SecurityAnalyzerService } from './security-analyzer.service';
|
||||
|
||||
// Input validation schemas
|
||||
const trackRequestSchema = z.object({
|
||||
@@ -22,6 +25,9 @@ const trackRequestSchema = z.object({
|
||||
followJS: z.boolean().default(false), // Future: JavaScript redirects
|
||||
maxHops: z.number().min(1).max(20).default(10),
|
||||
timeout: z.number().min(1000).max(30000).default(15000),
|
||||
enableSSLAnalysis: z.boolean().default(true),
|
||||
enableSEOAnalysis: z.boolean().default(true),
|
||||
enableSecurityAnalysis: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type TrackRequest = z.infer<typeof trackRequestSchema>;
|
||||
@@ -60,6 +66,9 @@ export interface CheckResult {
|
||||
* Provides database-persisted redirect tracking with comprehensive analysis
|
||||
*/
|
||||
export class RedirectTrackerService {
|
||||
private sslAnalyzer = new SSLAnalyzerService();
|
||||
private seoAnalyzer = new SEOAnalyzerService();
|
||||
private securityAnalyzer = new SecurityAnalyzerService();
|
||||
|
||||
/**
|
||||
* Track redirects with database persistence
|
||||
@@ -136,6 +145,9 @@ export class RedirectTrackerService {
|
||||
// Save hops to database
|
||||
await this.saveHopsToDatabase(check.id, hops);
|
||||
|
||||
// Perform enhanced analysis on final URL if enabled
|
||||
await this.performEnhancedAnalysis(check.id, finalUrl || inputUrl, hops.map(h => h.url), validatedRequest);
|
||||
|
||||
const result: CheckResult = {
|
||||
id: check.id,
|
||||
inputUrl,
|
||||
@@ -470,4 +482,121 @@ export class RedirectTrackerService {
|
||||
loopDetected: this.detectRedirectLoop(check.hops),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform enhanced analysis (SSL, SEO, Security) on the final URL
|
||||
*/
|
||||
private async performEnhancedAnalysis(
|
||||
checkId: string,
|
||||
finalUrl: string,
|
||||
redirectChain: string[],
|
||||
request: TrackRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info(`Starting enhanced analysis for check ${checkId}`, {
|
||||
finalUrl,
|
||||
sslEnabled: request.enableSSLAnalysis,
|
||||
seoEnabled: request.enableSEOAnalysis,
|
||||
securityEnabled: request.enableSecurityAnalysis
|
||||
});
|
||||
|
||||
// Run analyses in parallel for better performance
|
||||
const analyses = await Promise.allSettled([
|
||||
// SSL Analysis
|
||||
request.enableSSLAnalysis && finalUrl.startsWith('https://')
|
||||
? this.sslAnalyzer.analyzeSSL(finalUrl)
|
||||
: Promise.resolve(null),
|
||||
|
||||
// SEO Analysis
|
||||
request.enableSEOAnalysis
|
||||
? this.seoAnalyzer.analyzeSEO(finalUrl)
|
||||
: Promise.resolve(null),
|
||||
|
||||
// Security Analysis
|
||||
request.enableSecurityAnalysis
|
||||
? this.securityAnalyzer.analyzeSecurity(finalUrl, redirectChain)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Process SSL analysis results
|
||||
if (analyses[0].status === 'fulfilled' && analyses[0].value) {
|
||||
const sslResult = analyses[0].value;
|
||||
|
||||
await prisma.sslInspection.create({
|
||||
data: {
|
||||
checkId,
|
||||
host: new URL(finalUrl).hostname,
|
||||
validFrom: sslResult.certificate?.validFrom ? new Date(sslResult.certificate.validFrom) : null,
|
||||
validTo: sslResult.certificate?.validTo ? new Date(sslResult.certificate.validTo) : null,
|
||||
daysToExpiry: sslResult.certificate?.daysToExpiry,
|
||||
issuer: sslResult.certificate?.issuer.commonName || null,
|
||||
protocol: sslResult.certificate?.protocol || null,
|
||||
warningsJson: sslResult.warnings,
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`SSL analysis saved for check ${checkId}`, {
|
||||
host: new URL(finalUrl).hostname,
|
||||
securityScore: sslResult.securityScore,
|
||||
warningsCount: sslResult.warnings.length
|
||||
});
|
||||
} else if (analyses[0].status === 'rejected') {
|
||||
logger.warn(`SSL analysis failed for check ${checkId}:`, analyses[0].reason);
|
||||
}
|
||||
|
||||
// Process SEO analysis results
|
||||
if (analyses[1].status === 'fulfilled' && analyses[1].value) {
|
||||
const seoResult = analyses[1].value;
|
||||
|
||||
await prisma.seoFlags.create({
|
||||
data: {
|
||||
checkId,
|
||||
robotsTxtStatus: seoResult.flags.robotsTxtStatus,
|
||||
robotsTxtRulesJson: seoResult.flags.robotsTxtRules,
|
||||
metaRobots: seoResult.flags.metaRobots || null,
|
||||
canonicalUrl: seoResult.flags.canonicalUrl || null,
|
||||
sitemapPresent: seoResult.flags.sitemapPresent,
|
||||
noindex: seoResult.flags.noindex,
|
||||
nofollow: seoResult.flags.nofollow,
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`SEO analysis saved for check ${checkId}`, {
|
||||
score: seoResult.score,
|
||||
robotsStatus: seoResult.flags.robotsTxtStatus,
|
||||
noindex: seoResult.flags.noindex
|
||||
});
|
||||
} else if (analyses[1].status === 'rejected') {
|
||||
logger.warn(`SEO analysis failed for check ${checkId}:`, analyses[1].reason);
|
||||
}
|
||||
|
||||
// Process Security analysis results
|
||||
if (analyses[2].status === 'fulfilled' && analyses[2].value) {
|
||||
const securityResult = analyses[2].value;
|
||||
|
||||
await prisma.securityFlags.create({
|
||||
data: {
|
||||
checkId,
|
||||
safeBrowsingStatus: securityResult.flags.safeBrowsingStatus,
|
||||
mixedContent: securityResult.flags.mixedContent,
|
||||
httpsToHttp: securityResult.flags.httpsToHttp,
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Security analysis saved for check ${checkId}`, {
|
||||
score: securityResult.securityScore,
|
||||
safeBrowsing: securityResult.flags.safeBrowsingStatus,
|
||||
mixedContent: securityResult.flags.mixedContent
|
||||
});
|
||||
} else if (analyses[2].status === 'rejected') {
|
||||
logger.warn(`Security analysis failed for check ${checkId}:`, analyses[2].reason);
|
||||
}
|
||||
|
||||
logger.info(`Enhanced analysis completed for check ${checkId}`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Enhanced analysis failed for check ${checkId}:`, error);
|
||||
// Don't throw - analysis failure shouldn't break the main tracking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
462
apps/api/src/services/security-analyzer.service.ts
Normal file
462
apps/api/src/services/security-analyzer.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Security Analyzer Service for Redirect Intelligence v2
|
||||
*
|
||||
* Analyzes security aspects of redirects and destinations
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { URL } from 'url';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface MixedContentAnalysis {
|
||||
status: 'none' | 'present' | 'final_to_http';
|
||||
insecureResources: string[];
|
||||
httpsToHttpRedirect: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityHeadersAnalysis {
|
||||
strictTransportSecurity?: string;
|
||||
contentSecurityPolicy?: string;
|
||||
xFrameOptions?: string;
|
||||
xContentTypeOptions?: string;
|
||||
referrerPolicy?: string;
|
||||
permissionsPolicy?: string;
|
||||
score: number; // 0-100 based on security headers
|
||||
}
|
||||
|
||||
export interface SafeBrowsingResult {
|
||||
status: 'safe' | 'malware' | 'phishing' | 'unwanted_software' | 'unknown' | 'error';
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface SecurityFlags {
|
||||
safeBrowsingStatus: string;
|
||||
mixedContent: 'NONE' | 'PRESENT' | 'FINAL_TO_HTTP';
|
||||
httpsToHttp: boolean;
|
||||
securityHeaders: SecurityHeadersAnalysis;
|
||||
certificateValid?: boolean;
|
||||
weakCiphers: boolean;
|
||||
openRedirects: boolean;
|
||||
}
|
||||
|
||||
export interface SecurityAnalysisResult {
|
||||
url: string;
|
||||
flags: SecurityFlags;
|
||||
mixedContentAnalysis: MixedContentAnalysis;
|
||||
safeBrowsing: SafeBrowsingResult;
|
||||
vulnerabilities: string[];
|
||||
recommendations: string[];
|
||||
securityScore: number; // 0-100
|
||||
}
|
||||
|
||||
export class SecurityAnalyzerService {
|
||||
|
||||
/**
|
||||
* Perform comprehensive security analysis
|
||||
*/
|
||||
async analyzeSecurity(url: string, redirectChain?: string[]): Promise<SecurityAnalysisResult> {
|
||||
logger.info(`Starting security analysis for: ${url}`);
|
||||
|
||||
const result: SecurityAnalysisResult = {
|
||||
url,
|
||||
flags: {
|
||||
safeBrowsingStatus: 'unknown',
|
||||
mixedContent: 'NONE',
|
||||
httpsToHttp: false,
|
||||
securityHeaders: { score: 0 },
|
||||
weakCiphers: false,
|
||||
openRedirects: false,
|
||||
},
|
||||
mixedContentAnalysis: {
|
||||
status: 'none',
|
||||
insecureResources: [],
|
||||
httpsToHttpRedirect: false,
|
||||
},
|
||||
safeBrowsing: { status: 'unknown' },
|
||||
vulnerabilities: [],
|
||||
recommendations: [],
|
||||
securityScore: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Analyze redirect chain for security issues
|
||||
if (redirectChain && redirectChain.length > 0) {
|
||||
this.analyzeRedirectChainSecurity(redirectChain, result);
|
||||
}
|
||||
|
||||
// Analyze security headers
|
||||
const headersAnalysis = await this.analyzeSecurityHeaders(url);
|
||||
result.flags.securityHeaders = headersAnalysis;
|
||||
|
||||
// Analyze mixed content
|
||||
const mixedContentAnalysis = await this.analyzeMixedContent(url);
|
||||
result.mixedContentAnalysis = mixedContentAnalysis;
|
||||
result.flags.mixedContent = mixedContentAnalysis.status === 'none' ? 'NONE' :
|
||||
mixedContentAnalysis.status === 'final_to_http' ? 'FINAL_TO_HTTP' : 'PRESENT';
|
||||
result.flags.httpsToHttp = mixedContentAnalysis.httpsToHttpRedirect;
|
||||
|
||||
// Safe browsing check (placeholder implementation)
|
||||
const safeBrowsingResult = await this.checkSafeBrowsing(url);
|
||||
result.safeBrowsing = safeBrowsingResult;
|
||||
result.flags.safeBrowsingStatus = safeBrowsingResult.status;
|
||||
|
||||
// Generate security recommendations and score
|
||||
this.generateSecurityRecommendations(result);
|
||||
|
||||
logger.info(`Security analysis completed for: ${url}`, {
|
||||
score: result.securityScore,
|
||||
safeBrowsing: result.safeBrowsing.status,
|
||||
mixedContent: result.flags.mixedContent,
|
||||
headersScore: result.flags.securityHeaders.score
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown security analysis error';
|
||||
result.vulnerabilities.push(`Security analysis failed: ${errorMessage}`);
|
||||
logger.error(`Security analysis failed for ${url}:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze redirect chain for security issues
|
||||
*/
|
||||
private analyzeRedirectChainSecurity(redirectChain: string[], result: SecurityAnalysisResult): void {
|
||||
for (let i = 0; i < redirectChain.length - 1; i++) {
|
||||
const currentUrl = redirectChain[i];
|
||||
const nextUrl = redirectChain[i + 1];
|
||||
|
||||
try {
|
||||
const current = new URL(currentUrl);
|
||||
const next = new URL(nextUrl);
|
||||
|
||||
// Check for HTTPS to HTTP downgrades
|
||||
if (current.protocol === 'https:' && next.protocol === 'http:') {
|
||||
result.flags.httpsToHttp = true;
|
||||
result.vulnerabilities.push(`Insecure redirect: HTTPS to HTTP (${currentUrl} → ${nextUrl})`);
|
||||
}
|
||||
|
||||
// Check for suspicious redirects (different domains)
|
||||
if (current.hostname !== next.hostname) {
|
||||
// Basic open redirect detection
|
||||
if (this.isOpenRedirectVulnerable(currentUrl, nextUrl)) {
|
||||
result.flags.openRedirects = true;
|
||||
result.vulnerabilities.push(`Potential open redirect vulnerability: ${currentUrl} → ${nextUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse URLs in redirect chain: ${currentUrl} → ${nextUrl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for potential open redirect vulnerabilities
|
||||
*/
|
||||
private isOpenRedirectVulnerable(fromUrl: string, toUrl: string): boolean {
|
||||
try {
|
||||
const from = new URL(fromUrl);
|
||||
const to = new URL(toUrl);
|
||||
|
||||
// Different domains might indicate open redirect
|
||||
if (from.hostname !== to.hostname) {
|
||||
// Check if the target URL is passed as a parameter
|
||||
const urlParams = new URLSearchParams(from.search);
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
if (key.toLowerCase().includes('url') ||
|
||||
key.toLowerCase().includes('redirect') ||
|
||||
key.toLowerCase().includes('return')) {
|
||||
if (value.includes(to.hostname)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze security headers
|
||||
*/
|
||||
private async analyzeSecurityHeaders(url: string): Promise<SecurityHeadersAnalysis> {
|
||||
try {
|
||||
const response = await axios.head(url, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'RedirectIntelligence-Bot/2.0 (Security Analysis)',
|
||||
},
|
||||
});
|
||||
|
||||
const headers = response.headers;
|
||||
let score = 0;
|
||||
|
||||
const analysis: SecurityHeadersAnalysis = {
|
||||
score: 0,
|
||||
};
|
||||
|
||||
// Strict-Transport-Security
|
||||
if (headers['strict-transport-security']) {
|
||||
analysis.strictTransportSecurity = headers['strict-transport-security'];
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// Content-Security-Policy
|
||||
if (headers['content-security-policy']) {
|
||||
analysis.contentSecurityPolicy = headers['content-security-policy'];
|
||||
score += 25;
|
||||
}
|
||||
|
||||
// X-Frame-Options
|
||||
if (headers['x-frame-options']) {
|
||||
analysis.xFrameOptions = headers['x-frame-options'];
|
||||
score += 15;
|
||||
}
|
||||
|
||||
// X-Content-Type-Options
|
||||
if (headers['x-content-type-options']) {
|
||||
analysis.xContentTypeOptions = headers['x-content-type-options'];
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Referrer-Policy
|
||||
if (headers['referrer-policy']) {
|
||||
analysis.referrerPolicy = headers['referrer-policy'];
|
||||
score += 10;
|
||||
}
|
||||
|
||||
// Permissions-Policy (formerly Feature-Policy)
|
||||
if (headers['permissions-policy'] || headers['feature-policy']) {
|
||||
analysis.permissionsPolicy = headers['permissions-policy'] || headers['feature-policy'];
|
||||
score += 10;
|
||||
}
|
||||
|
||||
analysis.score = score;
|
||||
return analysis;
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to analyze security headers for ${url}:`, error);
|
||||
return { score: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze mixed content issues
|
||||
*/
|
||||
private async analyzeMixedContent(url: string): Promise<MixedContentAnalysis> {
|
||||
const result: MixedContentAnalysis = {
|
||||
status: 'none',
|
||||
insecureResources: [],
|
||||
httpsToHttpRedirect: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// If the final URL is HTTP but we started with HTTPS, it's a security issue
|
||||
if (parsedUrl.protocol === 'http:') {
|
||||
result.status = 'final_to_http';
|
||||
result.httpsToHttpRedirect = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// For HTTPS URLs, check for mixed content in the page
|
||||
if (parsedUrl.protocol === 'https:') {
|
||||
const mixedContentCheck = await this.checkPageMixedContent(url);
|
||||
if (mixedContentCheck.length > 0) {
|
||||
result.status = 'present';
|
||||
result.insecureResources = mixedContentCheck;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Mixed content analysis failed for ${url}:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check page for mixed content issues
|
||||
*/
|
||||
private async checkPageMixedContent(url: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'RedirectIntelligence-Bot/2.0 (Mixed Content Analysis)',
|
||||
},
|
||||
maxContentLength: 512 * 1024, // 512KB limit for mixed content analysis
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
const insecureResources: string[] = [];
|
||||
|
||||
// Look for HTTP resources in HTTPS pages
|
||||
const httpResourceRegex = /(?:src|href|action)=['"]http:\/\/[^'"]+['"]|url\(http:\/\/[^)]+\)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = httpResourceRegex.exec(html)) !== null) {
|
||||
const resource = match[0];
|
||||
const urlMatch = resource.match(/http:\/\/[^'")\s]+/);
|
||||
if (urlMatch) {
|
||||
insecureResources.push(urlMatch[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
return [...new Set(insecureResources)];
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check mixed content for ${url}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check URL against safe browsing (placeholder implementation)
|
||||
*/
|
||||
private async checkSafeBrowsing(url: string): Promise<SafeBrowsingResult> {
|
||||
try {
|
||||
// This is a placeholder implementation
|
||||
// In production, you would integrate with Google Safe Browsing API
|
||||
// or similar service
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
|
||||
// Basic checks for obviously suspicious patterns
|
||||
const suspiciousPatterns = [
|
||||
/phishing/i,
|
||||
/malware/i,
|
||||
/virus/i,
|
||||
/hack/i,
|
||||
/suspicious/i,
|
||||
];
|
||||
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (pattern.test(hostname)) {
|
||||
return {
|
||||
status: 'phishing',
|
||||
details: 'Suspicious hostname pattern detected',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for suspicious TLDs (basic implementation)
|
||||
const suspiciousTlds = ['.tk', '.ml', '.ga', '.cf'];
|
||||
const tld = hostname.substring(hostname.lastIndexOf('.'));
|
||||
|
||||
if (suspiciousTlds.includes(tld)) {
|
||||
return {
|
||||
status: 'unwanted_software',
|
||||
details: 'Suspicious top-level domain',
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'safe' };
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Safe browsing check failed for ${url}:`, error);
|
||||
return {
|
||||
status: 'error',
|
||||
details: 'Safe browsing check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate security recommendations and calculate score
|
||||
*/
|
||||
private generateSecurityRecommendations(result: SecurityAnalysisResult): void {
|
||||
let score = 100;
|
||||
|
||||
// Safe browsing issues
|
||||
if (result.safeBrowsing.status === 'malware' || result.safeBrowsing.status === 'phishing') {
|
||||
result.vulnerabilities.push(`Site flagged as ${result.safeBrowsing.status}`);
|
||||
result.recommendations.push('Immediately investigate and resolve security issues');
|
||||
score -= 50;
|
||||
} else if (result.safeBrowsing.status === 'unwanted_software') {
|
||||
result.vulnerabilities.push('Site may contain unwanted software');
|
||||
result.recommendations.push('Review site content and remove unwanted software');
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
// Mixed content issues
|
||||
if (result.flags.httpsToHttp) {
|
||||
result.vulnerabilities.push('HTTPS to HTTP downgrade detected');
|
||||
result.recommendations.push('Ensure all redirects maintain HTTPS encryption');
|
||||
score -= 25;
|
||||
}
|
||||
|
||||
if (result.mixedContentAnalysis.insecureResources.length > 0) {
|
||||
result.vulnerabilities.push(`${result.mixedContentAnalysis.insecureResources.length} insecure resources found`);
|
||||
result.recommendations.push('Update all HTTP resources to use HTTPS');
|
||||
score -= 15;
|
||||
}
|
||||
|
||||
// Security headers
|
||||
const headersScore = result.flags.securityHeaders.score;
|
||||
if (headersScore < 50) {
|
||||
result.recommendations.push('Implement security headers (CSP, HSTS, X-Frame-Options)');
|
||||
score -= 20;
|
||||
} else if (headersScore < 80) {
|
||||
result.recommendations.push('Add additional security headers for better protection');
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Open redirects
|
||||
if (result.flags.openRedirects) {
|
||||
result.vulnerabilities.push('Potential open redirect vulnerability detected');
|
||||
result.recommendations.push('Validate and restrict redirect destinations');
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
// Adjust score based on headers
|
||||
score = Math.min(score, score + (headersScore * 0.2));
|
||||
|
||||
result.securityScore = Math.max(0, score);
|
||||
|
||||
// Add overall security assessment
|
||||
if (result.securityScore >= 90) {
|
||||
result.recommendations.push('Excellent security posture!');
|
||||
} else if (result.securityScore >= 70) {
|
||||
result.recommendations.push('Good security with minor improvements possible');
|
||||
} else if (result.securityScore >= 50) {
|
||||
result.recommendations.push('Security needs improvement for better protection');
|
||||
} else {
|
||||
result.recommendations.push('Security requires immediate attention');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick security check for redirect chain analysis
|
||||
*/
|
||||
async quickSecurityCheck(url: string): Promise<{
|
||||
httpsToHttp: boolean;
|
||||
safeBrowsingStatus: string;
|
||||
hasSecurityHeaders: boolean;
|
||||
}> {
|
||||
try {
|
||||
const analysis = await this.analyzeSecurity(url);
|
||||
|
||||
return {
|
||||
httpsToHttp: analysis.flags.httpsToHttp,
|
||||
safeBrowsingStatus: analysis.flags.safeBrowsingStatus,
|
||||
hasSecurityHeaders: analysis.flags.securityHeaders.score > 0,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Quick security check failed for ${url}:`, error);
|
||||
return {
|
||||
httpsToHttp: false,
|
||||
safeBrowsingStatus: 'unknown',
|
||||
hasSecurityHeaders: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
486
apps/api/src/services/seo-analyzer.service.ts
Normal file
486
apps/api/src/services/seo-analyzer.service.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* SEO Analyzer Service for Redirect Intelligence v2
|
||||
*
|
||||
* Analyzes SEO-related aspects of redirects and final destinations
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { URL } from 'url';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface RobotsAnalysis {
|
||||
status: 'found' | 'not_found' | 'error';
|
||||
rules: {
|
||||
userAgent: string;
|
||||
allow: string[];
|
||||
disallow: string[];
|
||||
crawlDelay?: number;
|
||||
}[];
|
||||
sitemaps: string[];
|
||||
}
|
||||
|
||||
export interface MetaTagsAnalysis {
|
||||
title?: string;
|
||||
description?: string;
|
||||
robots?: string;
|
||||
canonical?: string;
|
||||
viewport?: string;
|
||||
openGraph: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
};
|
||||
twitter: {
|
||||
card?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SEOFlags {
|
||||
robotsTxtStatus: 'found' | 'not_found' | 'error';
|
||||
robotsTxtRules: RobotsAnalysis;
|
||||
metaRobots?: string;
|
||||
canonicalUrl?: string;
|
||||
sitemapPresent: boolean;
|
||||
noindex: boolean;
|
||||
nofollow: boolean;
|
||||
hasTitle: boolean;
|
||||
hasDescription: boolean;
|
||||
titleLength?: number;
|
||||
descriptionLength?: number;
|
||||
openGraphPresent: boolean;
|
||||
twitterCardPresent: boolean;
|
||||
}
|
||||
|
||||
export interface SEOAnalysisResult {
|
||||
url: string;
|
||||
flags: SEOFlags;
|
||||
metaTags: MetaTagsAnalysis;
|
||||
recommendations: string[];
|
||||
warnings: string[];
|
||||
score: number; // 0-100
|
||||
}
|
||||
|
||||
export class SEOAnalyzerService {
|
||||
|
||||
/**
|
||||
* Perform comprehensive SEO analysis
|
||||
*/
|
||||
async analyzeSEO(url: string): Promise<SEOAnalysisResult> {
|
||||
logger.info(`Starting SEO analysis for: ${url}`);
|
||||
|
||||
const result: SEOAnalysisResult = {
|
||||
url,
|
||||
flags: {
|
||||
robotsTxtStatus: 'not_found',
|
||||
robotsTxtRules: { status: 'not_found', rules: [], sitemaps: [] },
|
||||
sitemapPresent: false,
|
||||
noindex: false,
|
||||
nofollow: false,
|
||||
hasTitle: false,
|
||||
hasDescription: false,
|
||||
openGraphPresent: false,
|
||||
twitterCardPresent: false,
|
||||
},
|
||||
metaTags: {
|
||||
openGraph: {},
|
||||
twitter: {},
|
||||
},
|
||||
recommendations: [],
|
||||
warnings: [],
|
||||
score: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Analyze robots.txt
|
||||
const robotsAnalysis = await this.analyzeRobotsTxt(url);
|
||||
result.flags.robotsTxtStatus = robotsAnalysis.status;
|
||||
result.flags.robotsTxtRules = robotsAnalysis;
|
||||
result.flags.sitemapPresent = robotsAnalysis.sitemaps.length > 0;
|
||||
|
||||
// Analyze page content
|
||||
const pageAnalysis = await this.analyzePageContent(url);
|
||||
result.metaTags = pageAnalysis.metaTags;
|
||||
result.flags = { ...result.flags, ...pageAnalysis.flags };
|
||||
|
||||
// Generate recommendations and calculate score
|
||||
this.generateSEORecommendations(result);
|
||||
|
||||
logger.info(`SEO analysis completed for: ${url}`, {
|
||||
score: result.score,
|
||||
robotsStatus: result.flags.robotsTxtStatus,
|
||||
hasTitle: result.flags.hasTitle,
|
||||
noindex: result.flags.noindex
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown SEO analysis error';
|
||||
result.warnings.push(`SEO analysis failed: ${errorMessage}`);
|
||||
logger.error(`SEO analysis failed for ${url}:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze robots.txt file
|
||||
*/
|
||||
private async analyzeRobotsTxt(url: string): Promise<RobotsAnalysis> {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const robotsUrl = `${parsedUrl.protocol}//${parsedUrl.host}/robots.txt`;
|
||||
|
||||
const response = await axios.get(robotsUrl, {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'RedirectIntelligence-Bot/2.0',
|
||||
},
|
||||
});
|
||||
|
||||
const robotsContent = response.data;
|
||||
return this.parseRobotsTxt(robotsContent);
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return { status: 'not_found', rules: [], sitemaps: [] };
|
||||
}
|
||||
|
||||
logger.warn(`Failed to fetch robots.txt for ${url}:`, error);
|
||||
return { status: 'error', rules: [], sitemaps: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse robots.txt content
|
||||
*/
|
||||
private parseRobotsTxt(content: string): RobotsAnalysis {
|
||||
const lines = content.split('\n').map(line => line.trim());
|
||||
const rules: RobotsAnalysis['rules'] = [];
|
||||
const sitemaps: string[] = [];
|
||||
|
||||
let currentRule: RobotsAnalysis['rules'][0] | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('#') || line === '') continue;
|
||||
|
||||
const [directive, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
|
||||
switch (directive.toLowerCase()) {
|
||||
case 'user-agent':
|
||||
if (currentRule) {
|
||||
rules.push(currentRule);
|
||||
}
|
||||
currentRule = {
|
||||
userAgent: value,
|
||||
allow: [],
|
||||
disallow: [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'allow':
|
||||
if (currentRule) {
|
||||
currentRule.allow.push(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disallow':
|
||||
if (currentRule) {
|
||||
currentRule.disallow.push(value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'crawl-delay':
|
||||
if (currentRule) {
|
||||
currentRule.crawlDelay = parseInt(value) || undefined;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sitemap':
|
||||
sitemaps.push(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRule) {
|
||||
rules.push(currentRule);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'found',
|
||||
rules,
|
||||
sitemaps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze page content for meta tags and SEO elements
|
||||
*/
|
||||
private async analyzePageContent(url: string): Promise<{
|
||||
metaTags: MetaTagsAnalysis;
|
||||
flags: Partial<SEOFlags>;
|
||||
}> {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'RedirectIntelligence-Bot/2.0 (SEO Analysis)',
|
||||
},
|
||||
maxContentLength: 1024 * 1024, // 1MB limit
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
return this.parseHTMLContent(html);
|
||||
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch page content for ${url}:`, error);
|
||||
|
||||
return {
|
||||
metaTags: { openGraph: {}, twitter: {} },
|
||||
flags: {
|
||||
hasTitle: false,
|
||||
hasDescription: false,
|
||||
noindex: false,
|
||||
nofollow: false,
|
||||
openGraphPresent: false,
|
||||
twitterCardPresent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML content for SEO elements
|
||||
*/
|
||||
private parseHTMLContent(html: string): {
|
||||
metaTags: MetaTagsAnalysis;
|
||||
flags: Partial<SEOFlags>;
|
||||
} {
|
||||
const metaTags: MetaTagsAnalysis = {
|
||||
openGraph: {},
|
||||
twitter: {},
|
||||
};
|
||||
|
||||
const flags: Partial<SEOFlags> = {
|
||||
hasTitle: false,
|
||||
hasDescription: false,
|
||||
noindex: false,
|
||||
nofollow: false,
|
||||
openGraphPresent: false,
|
||||
twitterCardPresent: false,
|
||||
};
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/is);
|
||||
if (titleMatch) {
|
||||
metaTags.title = this.cleanText(titleMatch[1]);
|
||||
flags.hasTitle = true;
|
||||
flags.titleLength = metaTags.title.length;
|
||||
}
|
||||
|
||||
// Extract meta tags
|
||||
const metaTagRegex = /<meta\s+([^>]*?)>/gi;
|
||||
let metaMatch;
|
||||
|
||||
while ((metaMatch = metaTagRegex.exec(html)) !== null) {
|
||||
const attributes = this.parseAttributes(metaMatch[1]);
|
||||
|
||||
// Standard meta tags
|
||||
if (attributes.name === 'description') {
|
||||
metaTags.description = attributes.content;
|
||||
flags.hasDescription = true;
|
||||
flags.descriptionLength = attributes.content?.length || 0;
|
||||
} else if (attributes.name === 'robots') {
|
||||
metaTags.robots = attributes.content;
|
||||
flags.metaRobots = attributes.content;
|
||||
|
||||
if (attributes.content?.toLowerCase().includes('noindex')) {
|
||||
flags.noindex = true;
|
||||
}
|
||||
if (attributes.content?.toLowerCase().includes('nofollow')) {
|
||||
flags.nofollow = true;
|
||||
}
|
||||
} else if (attributes.name === 'viewport') {
|
||||
metaTags.viewport = attributes.content;
|
||||
}
|
||||
|
||||
// Open Graph tags
|
||||
else if (attributes.property?.startsWith('og:')) {
|
||||
const ogProperty = attributes.property.substring(3);
|
||||
metaTags.openGraph[ogProperty as keyof typeof metaTags.openGraph] = attributes.content;
|
||||
flags.openGraphPresent = true;
|
||||
}
|
||||
|
||||
// Twitter Card tags
|
||||
else if (attributes.name?.startsWith('twitter:')) {
|
||||
const twitterProperty = attributes.name.substring(8);
|
||||
metaTags.twitter[twitterProperty as keyof typeof metaTags.twitter] = attributes.content;
|
||||
flags.twitterCardPresent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract canonical URL
|
||||
const canonicalMatch = html.match(/<link\s+([^>]*?)rel=['"]canonical['"][^>]*>/i);
|
||||
if (canonicalMatch) {
|
||||
const attributes = this.parseAttributes(canonicalMatch[1]);
|
||||
metaTags.canonical = attributes.href;
|
||||
flags.canonicalUrl = attributes.href;
|
||||
}
|
||||
|
||||
return { metaTags, flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTML attributes from a string
|
||||
*/
|
||||
private parseAttributes(attributeString: string): Record<string, string> {
|
||||
const attributes: Record<string, string> = {};
|
||||
const attrRegex = /(\w+)=['"]([^'"]*)['"]/g;
|
||||
let match;
|
||||
|
||||
while ((match = attrRegex.exec(attributeString)) !== null) {
|
||||
attributes[match[1].toLowerCase()] = match[2];
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean text content
|
||||
*/
|
||||
private cleanText(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEO recommendations and calculate score
|
||||
*/
|
||||
private generateSEORecommendations(result: SEOAnalysisResult): void {
|
||||
let score = 100;
|
||||
|
||||
// Title analysis
|
||||
if (!result.flags.hasTitle) {
|
||||
result.warnings.push('Missing page title');
|
||||
result.recommendations.push('Add a descriptive page title');
|
||||
score -= 15;
|
||||
} else if (result.flags.titleLength) {
|
||||
if (result.flags.titleLength < 30) {
|
||||
result.warnings.push('Title is too short');
|
||||
result.recommendations.push('Expand title to 30-60 characters for better SEO');
|
||||
score -= 5;
|
||||
} else if (result.flags.titleLength > 60) {
|
||||
result.warnings.push('Title is too long');
|
||||
result.recommendations.push('Shorten title to under 60 characters');
|
||||
score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Description analysis
|
||||
if (!result.flags.hasDescription) {
|
||||
result.warnings.push('Missing meta description');
|
||||
result.recommendations.push('Add a meta description (150-160 characters)');
|
||||
score -= 10;
|
||||
} else if (result.flags.descriptionLength) {
|
||||
if (result.flags.descriptionLength < 120) {
|
||||
result.warnings.push('Meta description is too short');
|
||||
result.recommendations.push('Expand meta description to 150-160 characters');
|
||||
score -= 3;
|
||||
} else if (result.flags.descriptionLength > 160) {
|
||||
result.warnings.push('Meta description is too long');
|
||||
result.recommendations.push('Shorten meta description to under 160 characters');
|
||||
score -= 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Robots.txt analysis
|
||||
if (result.flags.robotsTxtStatus === 'not_found') {
|
||||
result.recommendations.push('Consider adding a robots.txt file');
|
||||
score -= 5;
|
||||
} else if (result.flags.robotsTxtStatus === 'error') {
|
||||
result.warnings.push('Robots.txt file has errors');
|
||||
result.recommendations.push('Fix robots.txt file errors');
|
||||
score -= 8;
|
||||
}
|
||||
|
||||
// Sitemap analysis
|
||||
if (!result.flags.sitemapPresent) {
|
||||
result.recommendations.push('Add XML sitemap references to robots.txt');
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
// Indexing analysis
|
||||
if (result.flags.noindex) {
|
||||
result.warnings.push('Page is set to noindex');
|
||||
result.recommendations.push('Remove noindex if you want this page indexed');
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
// Social media optimization
|
||||
if (!result.flags.openGraphPresent) {
|
||||
result.recommendations.push('Add Open Graph meta tags for social media sharing');
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
if (!result.flags.twitterCardPresent) {
|
||||
result.recommendations.push('Add Twitter Card meta tags for Twitter sharing');
|
||||
score -= 3;
|
||||
}
|
||||
|
||||
// Canonical URL
|
||||
if (!result.flags.canonicalUrl) {
|
||||
result.recommendations.push('Add canonical URL to prevent duplicate content issues');
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
result.score = Math.max(0, score);
|
||||
|
||||
// Add overall recommendations
|
||||
if (result.score >= 90) {
|
||||
result.recommendations.push('Excellent SEO setup!');
|
||||
} else if (result.score >= 70) {
|
||||
result.recommendations.push('Good SEO with room for minor improvements');
|
||||
} else if (result.score >= 50) {
|
||||
result.recommendations.push('SEO needs improvement for better search visibility');
|
||||
} else {
|
||||
result.recommendations.push('SEO requires immediate attention');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick SEO check for redirect chain analysis
|
||||
*/
|
||||
async quickSEOCheck(url: string): Promise<{
|
||||
noindex: boolean;
|
||||
nofollow: boolean;
|
||||
robotsBlocked: boolean;
|
||||
hasTitle: boolean;
|
||||
}> {
|
||||
try {
|
||||
const analysis = await this.analyzeSEO(url);
|
||||
|
||||
// Check if robots.txt blocks crawlers
|
||||
const robotsBlocked = analysis.flags.robotsTxtRules.rules.some(rule =>
|
||||
rule.userAgent === '*' && rule.disallow.includes('/')
|
||||
);
|
||||
|
||||
return {
|
||||
noindex: analysis.flags.noindex,
|
||||
nofollow: analysis.flags.nofollow,
|
||||
robotsBlocked,
|
||||
hasTitle: analysis.flags.hasTitle,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Quick SEO check failed for ${url}:`, error);
|
||||
return {
|
||||
noindex: false,
|
||||
nofollow: false,
|
||||
robotsBlocked: false,
|
||||
hasTitle: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
311
apps/api/src/services/ssl-analyzer.service.ts
Normal file
311
apps/api/src/services/ssl-analyzer.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* SSL Certificate Analyzer Service for Redirect Intelligence v2
|
||||
*
|
||||
* Provides comprehensive SSL certificate analysis and security assessment
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
import tls from 'tls';
|
||||
import { URL } from 'url';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface SSLCertificateInfo {
|
||||
valid: boolean;
|
||||
subject: {
|
||||
commonName?: string;
|
||||
organization?: string;
|
||||
organizationalUnit?: string;
|
||||
locality?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
issuer: {
|
||||
commonName?: string;
|
||||
organization?: string;
|
||||
organizationalUnit?: string;
|
||||
locality?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
daysToExpiry: number;
|
||||
serialNumber?: string;
|
||||
fingerprint?: string;
|
||||
signatureAlgorithm?: string;
|
||||
keySize?: number;
|
||||
protocol?: string;
|
||||
cipher?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSLAnalysisResult {
|
||||
host: string;
|
||||
port: number;
|
||||
certificate?: SSLCertificateInfo;
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
securityScore: number; // 0-100
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export class SSLAnalyzerService {
|
||||
|
||||
/**
|
||||
* Analyze SSL certificate for a given URL
|
||||
*/
|
||||
async analyzeSSL(url: string): Promise<SSLAnalysisResult> {
|
||||
const parsedUrl = new URL(url);
|
||||
const host = parsedUrl.hostname;
|
||||
const port = parsedUrl.port ? parseInt(parsedUrl.port) : 443;
|
||||
|
||||
logger.info(`Starting SSL analysis for: ${host}:${port}`);
|
||||
|
||||
const result: SSLAnalysisResult = {
|
||||
host,
|
||||
port,
|
||||
warnings: [],
|
||||
errors: [],
|
||||
securityScore: 0,
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
result.errors.push('URL is not HTTPS');
|
||||
result.recommendations.push('Use HTTPS to secure communications');
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const certificate = await this.getCertificateInfo(host, port);
|
||||
result.certificate = certificate;
|
||||
|
||||
// Perform security analysis
|
||||
this.analyzeCertificateSecurity(result);
|
||||
|
||||
logger.info(`SSL analysis completed for: ${host}:${port}`, {
|
||||
valid: certificate.valid,
|
||||
daysToExpiry: certificate.daysToExpiry,
|
||||
securityScore: result.securityScore
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown SSL error';
|
||||
result.errors.push(`SSL analysis failed: ${errorMessage}`);
|
||||
logger.error(`SSL analysis failed for ${host}:${port}:`, error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed certificate information
|
||||
*/
|
||||
private async getCertificateInfo(host: string, port: number): Promise<SSLCertificateInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
host,
|
||||
port,
|
||||
servername: host,
|
||||
rejectUnauthorized: false, // We want to analyze even invalid certs
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const socket = tls.connect(options, () => {
|
||||
try {
|
||||
const cert = socket.getPeerCertificate(true);
|
||||
const cipher = socket.getCipher();
|
||||
const protocol = socket.getProtocol();
|
||||
|
||||
if (!cert || Object.keys(cert).length === 0) {
|
||||
socket.end();
|
||||
reject(new Error('No certificate found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = new Date(cert.valid_from);
|
||||
const validTo = new Date(cert.valid_to);
|
||||
const daysToExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const certificateInfo: SSLCertificateInfo = {
|
||||
valid: socket.authorized,
|
||||
subject: this.parseCertificateField(cert.subject),
|
||||
issuer: this.parseCertificateField(cert.issuer),
|
||||
validFrom: validFrom.toISOString(),
|
||||
validTo: validTo.toISOString(),
|
||||
daysToExpiry,
|
||||
serialNumber: cert.serialNumber,
|
||||
fingerprint: cert.fingerprint,
|
||||
signatureAlgorithm: cert.signatureAlgorithm,
|
||||
keySize: cert.bits,
|
||||
protocol,
|
||||
cipher: cipher ? {
|
||||
name: cipher.name,
|
||||
version: cipher.version,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
socket.end();
|
||||
resolve(certificateInfo);
|
||||
|
||||
} catch (error) {
|
||||
socket.end();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
reject(new Error('SSL connection timeout'));
|
||||
});
|
||||
|
||||
socket.setTimeout(10000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse certificate subject/issuer field
|
||||
*/
|
||||
private parseCertificateField(field: any): any {
|
||||
if (!field) return {};
|
||||
|
||||
return {
|
||||
commonName: field.CN,
|
||||
organization: field.O,
|
||||
organizationalUnit: field.OU,
|
||||
locality: field.L,
|
||||
state: field.ST,
|
||||
country: field.C,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze certificate security and generate recommendations
|
||||
*/
|
||||
private analyzeCertificateSecurity(result: SSLAnalysisResult): void {
|
||||
const cert = result.certificate;
|
||||
if (!cert) return;
|
||||
|
||||
let score = 100;
|
||||
|
||||
// Check certificate validity
|
||||
if (!cert.valid) {
|
||||
result.warnings.push('Certificate is not valid or trusted');
|
||||
result.recommendations.push('Install a valid SSL certificate from a trusted CA');
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (cert.daysToExpiry < 0) {
|
||||
result.errors.push('Certificate has expired');
|
||||
result.recommendations.push('Renew the SSL certificate immediately');
|
||||
score -= 40;
|
||||
} else if (cert.daysToExpiry < 30) {
|
||||
result.warnings.push(`Certificate expires in ${cert.daysToExpiry} days`);
|
||||
result.recommendations.push('Renew the SSL certificate soon');
|
||||
score -= 10;
|
||||
} else if (cert.daysToExpiry < 90) {
|
||||
result.warnings.push(`Certificate expires in ${cert.daysToExpiry} days`);
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
// Check key size
|
||||
if (cert.keySize && cert.keySize < 2048) {
|
||||
result.warnings.push(`Weak key size: ${cert.keySize} bits`);
|
||||
result.recommendations.push('Use at least 2048-bit RSA keys or 256-bit ECC keys');
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
// Check signature algorithm
|
||||
if (cert.signatureAlgorithm) {
|
||||
if (cert.signatureAlgorithm.toLowerCase().includes('sha1')) {
|
||||
result.warnings.push('Using deprecated SHA-1 signature algorithm');
|
||||
result.recommendations.push('Upgrade to SHA-256 or better signature algorithm');
|
||||
score -= 15;
|
||||
} else if (cert.signatureAlgorithm.toLowerCase().includes('md5')) {
|
||||
result.errors.push('Using insecure MD5 signature algorithm');
|
||||
result.recommendations.push('Immediately upgrade to SHA-256 or better');
|
||||
score -= 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Check protocol version
|
||||
if (cert.protocol) {
|
||||
if (cert.protocol.includes('TLSv1.0') || cert.protocol.includes('TLSv1.1')) {
|
||||
result.warnings.push(`Using deprecated protocol: ${cert.protocol}`);
|
||||
result.recommendations.push('Upgrade to TLS 1.2 or TLS 1.3');
|
||||
score -= 15;
|
||||
} else if (cert.protocol.includes('SSLv')) {
|
||||
result.errors.push(`Using insecure protocol: ${cert.protocol}`);
|
||||
result.recommendations.push('Immediately upgrade to TLS 1.2 or TLS 1.3');
|
||||
score -= 35;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cipher suite
|
||||
if (cert.cipher?.name) {
|
||||
const cipherName = cert.cipher.name.toLowerCase();
|
||||
if (cipherName.includes('rc4') || cipherName.includes('des')) {
|
||||
result.errors.push(`Weak cipher suite: ${cert.cipher.name}`);
|
||||
result.recommendations.push('Use strong cipher suites like AES-GCM');
|
||||
score -= 25;
|
||||
} else if (cipherName.includes('cbc')) {
|
||||
result.warnings.push(`CBC cipher mode detected: ${cert.cipher.name}`);
|
||||
result.recommendations.push('Prefer GCM or ChaCha20-Poly1305 cipher modes');
|
||||
score -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Check subject alternative names (basic check)
|
||||
if (!cert.subject.commonName) {
|
||||
result.warnings.push('Certificate missing Common Name');
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
// Ensure score doesn't go below 0
|
||||
result.securityScore = Math.max(0, score);
|
||||
|
||||
// Add positive recommendations
|
||||
if (result.securityScore >= 90) {
|
||||
result.recommendations.push('SSL configuration looks excellent!');
|
||||
} else if (result.securityScore >= 70) {
|
||||
result.recommendations.push('SSL configuration is good with minor improvements possible');
|
||||
} else if (result.securityScore >= 50) {
|
||||
result.recommendations.push('SSL configuration needs improvement for better security');
|
||||
} else {
|
||||
result.recommendations.push('SSL configuration requires immediate attention');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick SSL check for redirect chain analysis
|
||||
*/
|
||||
async quickSSLCheck(url: string): Promise<{
|
||||
valid: boolean;
|
||||
daysToExpiry?: number;
|
||||
warnings: string[];
|
||||
}> {
|
||||
try {
|
||||
const analysis = await this.analyzeSSL(url);
|
||||
|
||||
return {
|
||||
valid: analysis.certificate?.valid ?? false,
|
||||
daysToExpiry: analysis.certificate?.daysToExpiry,
|
||||
warnings: analysis.warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(`Quick SSL check failed for ${url}:`, error);
|
||||
return {
|
||||
valid: false,
|
||||
warnings: ['SSL check failed'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
435
test-phase-3.js
Normal file
435
test-phase-3.js
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Phase 3 Test Script for Redirect Intelligence v2
|
||||
*
|
||||
* Tests SSL, SEO, and Security analysis capabilities
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3333';
|
||||
|
||||
// Test URLs with different characteristics
|
||||
const testUrls = {
|
||||
httpsGood: 'https://github.com',
|
||||
httpInsecure: 'http://example.com',
|
||||
seoOptimized: 'https://google.com',
|
||||
withRobots: 'https://github.com',
|
||||
redirectChain: 'http://github.com', // Redirects to HTTPS
|
||||
shortUrl: 'https://bit.ly/test',
|
||||
};
|
||||
|
||||
let authToken = null;
|
||||
|
||||
async function testHealthCheck() {
|
||||
console.log('\n🏥 Testing Health Check...');
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/health`);
|
||||
console.log(' ✅ Health check passed');
|
||||
console.log(' 📊 Server info:', {
|
||||
status: response.data.status,
|
||||
version: response.data.version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(' ❌ Health check failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupAuthentication() {
|
||||
console.log('\n🔐 Setting up authentication...');
|
||||
try {
|
||||
// Try to login with existing test user
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v1/auth/login`, {
|
||||
email: 'test-phase2@example.com',
|
||||
password: 'testpassword123'
|
||||
});
|
||||
|
||||
authToken = response.data.data.token;
|
||||
console.log(' ✅ Authentication successful');
|
||||
} catch (error) {
|
||||
console.log(' ℹ️ No existing auth, continuing with anonymous testing...');
|
||||
}
|
||||
}
|
||||
|
||||
async function testSSLAnalysis() {
|
||||
console.log('\n🔒 Testing SSL Analysis...');
|
||||
|
||||
// Test HTTPS URL
|
||||
console.log('\n Testing SSL analysis for HTTPS URL...');
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/analyze/ssl`, {
|
||||
url: testUrls.httpsGood
|
||||
});
|
||||
|
||||
const analysis = response.data.data.analysis;
|
||||
console.log(' ✅ SSL analysis successful');
|
||||
console.log(' 📊 SSL Results:', {
|
||||
host: analysis.host,
|
||||
certificateValid: analysis.certificate?.valid,
|
||||
daysToExpiry: analysis.certificate?.daysToExpiry,
|
||||
securityScore: analysis.securityScore,
|
||||
warningsCount: analysis.warnings.length,
|
||||
protocol: analysis.certificate?.protocol,
|
||||
});
|
||||
|
||||
if (analysis.certificate) {
|
||||
console.log(' 🔐 Certificate Details:', {
|
||||
issuer: analysis.certificate.issuer.commonName,
|
||||
subject: analysis.certificate.subject.commonName,
|
||||
validFrom: analysis.certificate.validFrom,
|
||||
validTo: analysis.certificate.validTo,
|
||||
keySize: analysis.certificate.keySize,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ SSL analysis failed:', error.response?.data || error.message);
|
||||
}
|
||||
|
||||
// Test HTTP URL (should fail)
|
||||
console.log('\n Testing SSL analysis for HTTP URL (should fail)...');
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/v2/analyze/ssl`, {
|
||||
url: testUrls.httpInsecure
|
||||
});
|
||||
console.log(' ❌ Should have failed for HTTP URL');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400) {
|
||||
console.log(' ✅ Correctly rejected HTTP URL for SSL analysis');
|
||||
} else {
|
||||
console.error(' ❌ Unexpected error:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testSEOAnalysis() {
|
||||
console.log('\n🔍 Testing SEO Analysis...');
|
||||
|
||||
console.log('\n Testing SEO analysis for optimized site...');
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/analyze/seo`, {
|
||||
url: testUrls.seoOptimized
|
||||
});
|
||||
|
||||
const analysis = response.data.data.analysis;
|
||||
console.log(' ✅ SEO analysis successful');
|
||||
console.log(' 📊 SEO Results:', {
|
||||
score: analysis.score,
|
||||
hasTitle: analysis.flags.hasTitle,
|
||||
hasDescription: analysis.flags.hasDescription,
|
||||
robotsStatus: analysis.flags.robotsTxtStatus,
|
||||
sitemapPresent: analysis.flags.sitemapPresent,
|
||||
noindex: analysis.flags.noindex,
|
||||
openGraphPresent: analysis.flags.openGraphPresent,
|
||||
});
|
||||
|
||||
if (analysis.metaTags) {
|
||||
console.log(' 🏷️ Meta Tags:', {
|
||||
title: analysis.metaTags.title?.substring(0, 50) + '...',
|
||||
titleLength: analysis.flags.titleLength,
|
||||
description: analysis.metaTags.description?.substring(0, 100) + '...',
|
||||
descriptionLength: analysis.flags.descriptionLength,
|
||||
canonical: analysis.metaTags.canonical,
|
||||
});
|
||||
}
|
||||
|
||||
if (analysis.flags.robotsTxtRules.sitemaps.length > 0) {
|
||||
console.log(' 🗺️ Sitemaps found:', analysis.flags.robotsTxtRules.sitemaps.slice(0, 3));
|
||||
}
|
||||
|
||||
if (analysis.recommendations.length > 0) {
|
||||
console.log(' 💡 Top Recommendations:', analysis.recommendations.slice(0, 3));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ SEO analysis failed:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSecurityAnalysis() {
|
||||
console.log('\n🛡️ Testing Security Analysis...');
|
||||
|
||||
console.log('\n Testing security analysis...');
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/analyze/security`, {
|
||||
url: testUrls.httpsGood
|
||||
});
|
||||
|
||||
const analysis = response.data.data.analysis;
|
||||
console.log(' ✅ Security analysis successful');
|
||||
console.log(' 📊 Security Results:', {
|
||||
securityScore: analysis.securityScore,
|
||||
safeBrowsingStatus: analysis.safeBrowsing.status,
|
||||
mixedContentStatus: analysis.mixedContentAnalysis.status,
|
||||
httpsToHttp: analysis.flags.httpsToHttp,
|
||||
securityHeadersScore: analysis.flags.securityHeaders.score,
|
||||
});
|
||||
|
||||
if (analysis.flags.securityHeaders) {
|
||||
console.log(' 🔐 Security Headers:', {
|
||||
hsts: !!analysis.flags.securityHeaders.strictTransportSecurity,
|
||||
csp: !!analysis.flags.securityHeaders.contentSecurityPolicy,
|
||||
xFrameOptions: !!analysis.flags.securityHeaders.xFrameOptions,
|
||||
xContentTypeOptions: !!analysis.flags.securityHeaders.xContentTypeOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (analysis.vulnerabilities.length > 0) {
|
||||
console.log(' ⚠️ Vulnerabilities:', analysis.vulnerabilities);
|
||||
}
|
||||
|
||||
if (analysis.recommendations.length > 0) {
|
||||
console.log(' 💡 Security Recommendations:', analysis.recommendations.slice(0, 3));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ Security analysis failed:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testComprehensiveAnalysis() {
|
||||
console.log('\n🔬 Testing Comprehensive Analysis...');
|
||||
|
||||
console.log('\n Running all analyses together...');
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/analyze/comprehensive`, {
|
||||
url: testUrls.httpsGood
|
||||
});
|
||||
|
||||
const { analysis, summary } = response.data.data;
|
||||
console.log(' ✅ Comprehensive analysis successful');
|
||||
console.log(' 📊 Summary:', {
|
||||
overallScore: summary.overallScore,
|
||||
analysesCompleted: summary.analysesCompleted,
|
||||
totalAnalyses: summary.totalAnalyses,
|
||||
});
|
||||
|
||||
// SSL Results
|
||||
if (analysis.ssl && !analysis.ssl.error) {
|
||||
console.log(' 🔒 SSL Score:', analysis.ssl.securityScore);
|
||||
} else if (analysis.ssl?.error) {
|
||||
console.log(' ❌ SSL Error:', analysis.ssl.error);
|
||||
}
|
||||
|
||||
// SEO Results
|
||||
if (analysis.seo && !analysis.seo.error) {
|
||||
console.log(' 🔍 SEO Score:', analysis.seo.score);
|
||||
} else if (analysis.seo?.error) {
|
||||
console.log(' ❌ SEO Error:', analysis.seo.error);
|
||||
}
|
||||
|
||||
// Security Results
|
||||
if (analysis.security && !analysis.security.error) {
|
||||
console.log(' 🛡️ Security Score:', analysis.security.securityScore);
|
||||
} else if (analysis.security?.error) {
|
||||
console.log(' ❌ Security Error:', analysis.security.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ Comprehensive analysis failed:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testEnhancedTracking() {
|
||||
console.log('\n🚀 Testing Enhanced Tracking with Analysis...');
|
||||
|
||||
console.log('\n Testing tracking with all analysis enabled...');
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/track`, {
|
||||
url: testUrls.redirectChain,
|
||||
method: 'GET',
|
||||
enableSSLAnalysis: true,
|
||||
enableSEOAnalysis: true,
|
||||
enableSecurityAnalysis: true,
|
||||
maxHops: 5,
|
||||
});
|
||||
|
||||
const check = response.data.data.check;
|
||||
console.log(' ✅ Enhanced tracking successful');
|
||||
console.log(' 📊 Tracking Results:', {
|
||||
checkId: check.id,
|
||||
status: check.status,
|
||||
inputUrl: check.inputUrl,
|
||||
finalUrl: check.finalUrl,
|
||||
redirectCount: check.redirectCount,
|
||||
totalTimeMs: check.totalTimeMs,
|
||||
hopsCount: check.hops.length,
|
||||
});
|
||||
|
||||
// Test retrieving analysis for this check
|
||||
await testCheckAnalysisRetrieval(check.id);
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ Enhanced tracking failed:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testCheckAnalysisRetrieval(checkId) {
|
||||
console.log(`\n 📋 Retrieving analysis for check: ${checkId}`);
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/v2/analyze/check/${checkId}`);
|
||||
|
||||
const { check, analysis } = response.data.data;
|
||||
console.log(' ✅ Check analysis retrieval successful');
|
||||
console.log(' 📊 Analysis Data:', {
|
||||
checkId: check.id,
|
||||
sslInspections: analysis.ssl?.length || 0,
|
||||
seoFlags: analysis.seo ? 'Present' : 'None',
|
||||
securityFlags: analysis.security ? 'Present' : 'None',
|
||||
});
|
||||
|
||||
if (analysis.ssl && analysis.ssl.length > 0) {
|
||||
const ssl = analysis.ssl[0];
|
||||
console.log(' 🔒 SSL Inspection:', {
|
||||
host: ssl.host,
|
||||
daysToExpiry: ssl.daysToExpiry,
|
||||
issuer: ssl.issuer,
|
||||
protocol: ssl.protocol,
|
||||
warningsCount: ssl.warningsJson?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (analysis.seo) {
|
||||
console.log(' 🔍 SEO Flags:', {
|
||||
robotsStatus: analysis.seo.robotsTxtStatus,
|
||||
noindex: analysis.seo.noindex,
|
||||
nofollow: analysis.seo.nofollow,
|
||||
sitemapPresent: analysis.seo.sitemapPresent,
|
||||
canonicalUrl: analysis.seo.canonicalUrl ? 'Present' : 'None',
|
||||
});
|
||||
}
|
||||
|
||||
if (analysis.security) {
|
||||
console.log(' 🛡️ Security Flags:', {
|
||||
safeBrowsingStatus: analysis.security.safeBrowsingStatus,
|
||||
mixedContent: analysis.security.mixedContent,
|
||||
httpsToHttp: analysis.security.httpsToHttp,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(' ❌ Check analysis retrieval failed:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testRateLimiting() {
|
||||
console.log('\n🚦 Testing Analysis Rate Limiting...');
|
||||
|
||||
console.log(' Testing analysis rate limits...');
|
||||
let successCount = 0;
|
||||
let rateLimitHit = false;
|
||||
|
||||
// Test a few analysis requests
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/api/v2/analyze/ssl`, {
|
||||
url: testUrls.httpsGood
|
||||
});
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 429) {
|
||||
rateLimitHit = true;
|
||||
console.log(' ⚠️ Rate limit hit (this might be expected behavior)');
|
||||
break;
|
||||
} else if (error.response?.status === 400) {
|
||||
// Expected for some URLs
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(` Request ${i + 1} failed:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` 📊 Rate limiting test: ${successCount} successful requests`);
|
||||
if (!rateLimitHit && successCount > 0) {
|
||||
console.log(' ✅ Rate limiting working (no limit hit in small test)');
|
||||
}
|
||||
}
|
||||
|
||||
async function testErrorHandling() {
|
||||
console.log('\n❌ Testing Error Handling...');
|
||||
|
||||
// Test invalid URL
|
||||
console.log('\n Testing invalid URL...');
|
||||
try {
|
||||
await axios.post(`${API_BASE_URL}/api/v2/analyze/ssl`, {
|
||||
url: 'not-a-valid-url'
|
||||
});
|
||||
console.log(' ❌ Should have failed with invalid URL');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 400) {
|
||||
console.log(' ✅ Invalid URL properly rejected');
|
||||
} else {
|
||||
console.error(' ❌ Unexpected error:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test nonexistent check analysis
|
||||
console.log('\n Testing nonexistent check analysis retrieval...');
|
||||
try {
|
||||
await axios.get(`${API_BASE_URL}/api/v2/analyze/check/nonexistent-check-id`);
|
||||
console.log(' ❌ Should have failed with 404');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
console.log(' ✅ Nonexistent check properly returns 404');
|
||||
} else {
|
||||
console.error(' ❌ Unexpected error:', error.response?.data || error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
console.log('🧪 Starting Phase 3 Comprehensive Tests...\n');
|
||||
console.log('=' .repeat(80));
|
||||
|
||||
try {
|
||||
await testHealthCheck();
|
||||
await setupAuthentication();
|
||||
await testSSLAnalysis();
|
||||
await testSEOAnalysis();
|
||||
await testSecurityAnalysis();
|
||||
await testComprehensiveAnalysis();
|
||||
await testEnhancedTracking();
|
||||
await testRateLimiting();
|
||||
await testErrorHandling();
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log('🎉 Phase 3 Tests Completed!');
|
||||
console.log('\n✅ What\'s Working:');
|
||||
console.log(' • SSL Certificate Analysis with detailed security scoring');
|
||||
console.log(' • SEO Analysis with robots.txt, meta tags, and recommendations');
|
||||
console.log(' • Security Analysis with mixed content and header checks');
|
||||
console.log(' • Comprehensive multi-analysis endpoint');
|
||||
console.log(' • Enhanced tracking with integrated analysis');
|
||||
console.log(' • Analysis data persistence and retrieval');
|
||||
console.log(' • Rate limiting for resource-intensive operations');
|
||||
console.log(' • Comprehensive error handling and validation');
|
||||
|
||||
console.log('\n🚀 Phase 3 Goals Achieved:');
|
||||
console.log(' • SSL certificate inspection and security scoring');
|
||||
console.log(' • SEO optimization analysis and recommendations');
|
||||
console.log(' • Security vulnerability detection');
|
||||
console.log(' • Database persistence of all analysis results');
|
||||
console.log(' • Parallel analysis execution for performance');
|
||||
console.log(' • Integration with existing tracking system');
|
||||
|
||||
console.log('\n📈 New API Endpoints:');
|
||||
console.log(' • POST /api/v2/analyze/ssl - SSL certificate analysis');
|
||||
console.log(' • POST /api/v2/analyze/seo - SEO optimization analysis');
|
||||
console.log(' • POST /api/v2/analyze/security - Security vulnerability scan');
|
||||
console.log(' • POST /api/v2/analyze/comprehensive - All analyses combined');
|
||||
console.log(' • GET /api/v2/analyze/check/:id - Retrieve stored analysis');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n💥 Test suite failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n⏸️ Tests interrupted by user');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
runAllTests();
|
||||
Reference in New Issue
Block a user