This workflow corresponds to n8n.io template #18048 — we link there as the canonical source.
This workflow follows the Agent → Form Trigger recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "s0NJQOKFfeOc7EEM",
"name": "N8N-Website Report - Headless",
"tags": [],
"nodes": [
{
"id": "318b5d01-0514-4c52-b247-2eccebb4ef0f",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-432,
-144
],
"parameters": {
"width": 480,
"height": 896,
"content": "## N8N-Website Report - Headless\n\n### How it works\n\nThis workflow generates a headless website security report from a submitted form URL. It fetches the site through ScrapingBee, normalizes the response, runs separate AI audits on page content and HTTP headers, then merges and processes the findings. It uses Claude again to create remediation guidance, builds an HTML report, converts it to a PDF through Gotenberg, and emails the result via Outlook.\n\n### Setup steps\n\n- Configure the form trigger fields so it captures the target website URL and any recipient details needed later in the workflow.\n- Add the ScrapingBee API key and confirm the fetch request points to the correct ScrapingBee endpoint and request parameters.\n- Configure Anthropic/Claude credentials for all AI Agent language model sub-nodes.\n- Set the Gotenberg service URL in the PDF conversion HTTP request and ensure the service is reachable from n8n.\n- Connect Microsoft Outlook credentials and verify the email node uses the intended recipient, subject, body, and PDF attachment fields.\n\n### Customization\n\nYou can adjust the audit prompts in the security, configuration, and remediation agents, change the branded HTML template in the report-building code node, or swap Gotenberg/Outlook for another PDF or email provider."
},
"typeVersion": 1
},
{
"id": "105af174-5dae-4265-9f88-c95c341bb1f8",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
128,
-16
],
"parameters": {
"color": 7,
"width": 576,
"height": 320,
"content": "## Capture and fetch site\n\nStarts from the submitted form, retrieves the target website through ScrapingBee, and normalizes the response into a consistent data and headers structure for downstream branches."
},
"typeVersion": 1
},
{
"id": "8aa5a781-bbc5-4c41-84d0-4b4e914776be",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
752,
-144
],
"parameters": {
"color": 7,
"width": 512,
"height": 480,
"content": "## Audit response headers\n\nExtracts HTTP headers into a readable format and sends them to a Claude-powered configuration audit agent to identify missing or weak security header settings."
},
"typeVersion": 1
},
{
"id": "2af6eba2-fd6c-4bd6-9dd8-9137b54f8f10",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
864,
368
],
"parameters": {
"color": 7,
"width": 368,
"height": 480,
"content": "## Audit page content\n\nRuns a separate Claude-powered security audit agent over the normalized website content to find application and content-level security issues."
},
"typeVersion": 1
},
{
"id": "73f9ba39-f4fe-4104-9718-410339fea046",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1328,
-16
],
"parameters": {
"color": 7,
"width": 624,
"height": 320,
"content": "## Combine audit results\n\nMerges findings from the header and content audit branches, aggregates them into a single collection, and processes the combined data into a structured findings set."
},
"typeVersion": 1
},
{
"id": "d1a582e2-0b67-4cef-8a70-9dffba4fc40d",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
1984,
-32
],
"parameters": {
"color": 7,
"width": 368,
"height": 528,
"content": "## Generate remediation guidance\n\nUses a Claude-backed remediation agent to turn the processed findings into practical fix instructions for the final report."
},
"typeVersion": 1
},
{
"id": "08377186-5653-4ece-9c1b-8044afb01e21",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
2384,
16
],
"parameters": {
"color": 7,
"width": 720,
"height": 304,
"content": "## Build and deliver report\n\nBuilds the branded HTML report, converts it into a file, posts it to Gotenberg for PDF generation, and emails the finished report through Microsoft Outlook."
},
"typeVersion": 1
},
{
"id": "83ec6b1c-e815-4adb-8eaa-3b8c4f6ab4b1",
"name": "Form Submission Trigger",
"type": "n8n-nodes-base.formTrigger",
"position": [
176,
144
],
"parameters": {
"options": {},
"formTitle": "Website Security Scanner",
"formFields": {
"values": [
{
"fieldLabel": "Landing Page Url",
"placeholder": "https://example.com",
"requiredField": true
}
]
},
"formDescription": "Check your website for security vulnerabilities and get a detailed report"
},
"typeVersion": 2.2
},
{
"id": "dd5f759d-dd7c-4750-8ad5-12dcfbd2a835",
"name": "Fetch ScrapingBee Data",
"type": "n8n-nodes-base.httpRequest",
"position": [
384,
144
],
"parameters": {
"url": "https://app.scrapingbee.com/api/v1",
"options": {
"timeout": 120000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "api_key",
"value": "YOURAPIKEYHERE"
},
{
"name": "url",
"value": "={{ $json['Landing Page Url'] }}"
},
{
"name": "render_js",
"value": "true"
},
{
"name": "json_response",
"value": "true"
},
{
"name": "return_page_source",
"value": "false"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "885407c0-664b-42fb-ba14-f760806db437",
"name": "Claude Header Analysis",
"type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
"position": [
928,
192
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "claude-sonnet-4-6",
"cachedResultName": "Claude Sonnet 4.6"
},
"options": {
"temperature": 0.2
}
},
"typeVersion": 1.3
},
{
"id": "f5c67738-b044-4d3a-b260-cc6766c3e710",
"name": "Claude Content Analysis",
"type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
"position": [
912,
736
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "claude-sonnet-4-6",
"cachedResultName": "Claude Sonnet 4.6"
},
"options": {
"temperature": 0.2
}
},
"typeVersion": 1.3
},
{
"id": "88982227-e7d9-4370-9523-c22fcb4b99d9",
"name": "Security Review Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
928,
544
],
"parameters": {
"text": "=You are an elite cybersecurity expert specializing in web application security.\n\nAnalyze the HTML and visible content of the webpage below to identify security vulnerabilities that are detectable from client-side code only. Do not speculate about server-side issues you cannot observe, and do not invent issues.\n\nPresent your findings in exactly three sections using these exact markdown headings:\n\n## Critical Vulnerabilities\n## Information Leakage\n## Client-Side Weaknesses\n\nWithin each section, list findings as numbered items in EXACTLY this format:\n\n1. **[Short Vulnerability Title]**\n **Description**: A clear description of the vulnerability, quoting the exact code or content that triggered it\n **Impact**: The potential impact if exploited\n **Recommendation**: A specific, actionable fix (include code examples where useful)\n\nIf a section has no findings, write exactly: No issues found in this category.\n\nHere is the content of the webpage: {{ ($json.data || '').slice(0, 100000) }}",
"options": {},
"promptType": "define"
},
"typeVersion": 1.7
},
{
"id": "53fd1942-76d1-41cd-9849-960b2b9e24ee",
"name": "Configuration Checker Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
960,
16
],
"parameters": {
"text": "=You are an elite web security expert specializing in secure configurations.\n\nAnalyze the HTTP response headers below to identify security misconfigurations detectable from client-side inspection. Only report what the headers actually show; do not speculate or invent issues.\n\nBegin with a section listing ALL security headers using EXACTLY this format:\n\n### Security Headers Present\n\n1. **[Header Name]**\n - **Present?** Yes/No\n - **Value:** `actual-header-value`\n\nCover at minimum: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection.\n\nThen present your findings in three sections with these exact headings:\n\n### Header Security\n### Cookie Security\n### Content Security\n\nWithin each section, list findings as numbered items in EXACTLY this format:\n\n1. **[Finding Title]**\n - **Description:** what is misconfigured or missing\n - **Impact:** the security implications\n - **Recommendation:**\n```\nexact configuration code to apply\n```\n\nIf a section has no findings, write exactly: No issues found in this category.\n\nHere are the response headers: {{ $json.formattedHeaders }}",
"options": {},
"promptType": "define"
},
"typeVersion": 1.7
},
{
"id": "fbc881da-e2cf-4be2-b9f7-e35ffc29d41b",
"name": "Combine Audit Results",
"type": "n8n-nodes-base.merge",
"position": [
1376,
144
],
"parameters": {},
"typeVersion": 3,
"alwaysOutputData": true
},
{
"id": "06e32e6b-2488-45a4-884b-da30ab5acf37",
"name": "Summarize Audit Results",
"type": "n8n-nodes-base.aggregate",
"position": [
1584,
144
],
"parameters": {
"options": {},
"fieldsToAggregate": {
"fieldToAggregate": [
{
"fieldToAggregate": "output"
}
]
}
},
"typeVersion": 1
},
{
"id": "58dc61f1-bfb8-4cee-8d23-1c2ddbb49e02",
"name": "Extract Security Headers",
"type": "n8n-nodes-base.code",
"position": [
1808,
144
],
"parameters": {
"jsCode": "// \u2705 Updated extractSecurityHeaders and related logic remains unchanged\n\nfunction extractSecurityHeaders(rawHeaders = {}, configOutput = '') {\n const securityHeaders = [\n 'Content-Security-Policy',\n 'Strict-Transport-Security',\n 'X-Content-Type-Options',\n 'X-Frame-Options',\n 'Referrer-Policy',\n 'Permissions-Policy',\n 'X-XSS-Protection',\n 'Cross-Origin-Embedder-Policy',\n 'Cross-Origin-Opener-Policy',\n 'X-Permitted-Cross-Domain-Policies'\n ];\n\n const headerStatus = {};\n for (const header of securityHeaders) {\n headerStatus[header] = { present: false, value: '' };\n }\n\n for (const header in rawHeaders) {\n const norm = header.trim().toLowerCase();\n for (const standard of securityHeaders) {\n if (norm === standard.toLowerCase()) {\n headerStatus[standard].present = true;\n headerStatus[standard].value = rawHeaders[header];\n }\n }\n }\n\n const presentSection = configOutput.match(/(?:###|##|\\*\\*)[^\\n]*?\\bheaders?\\b[\\s\\S]*?(?=###|##|\\*\\*|$)/i);\n if (presentSection) {\n const section = presentSection[0];\n for (const header of securityHeaders) {\n const title = header.replace(/-/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());\n const regex = new RegExp(`\\\\*\\\\*${title}\\\\*\\\\*[^\\\\n]*?\\\\*\\\\*Present\\\\?\\\\*\\\\*\\\\s*Yes[^\\\\n]*?\\\\*\\\\*Value:\\\\*\\\\*\\\\s*\\`([^\\\\\\`]+)\\``, 'is');\n const match = section.match(regex);\n if (match && match[1]) {\n headerStatus[header].present = true;\n headerStatus[header].value = match[1].trim();\n }\n }\n }\n\n return headerStatus;\n}\n\nfunction hasUnsafeInline(value) {\n return value && value.includes('unsafe-inline');\n}\n\nfunction determineGrade(headerStatus) {\n const critical = [\n 'Content-Security-Policy',\n 'Strict-Transport-Security',\n 'X-Content-Type-Options',\n 'X-Frame-Options'\n ];\n const important = ['Referrer-Policy', 'Permissions-Policy'];\n const additional = [\n 'X-XSS-Protection',\n 'Cross-Origin-Embedder-Policy',\n 'Cross-Origin-Opener-Policy',\n 'X-Permitted-Cross-Domain-Policies'\n ];\n\n let criticalCount = 0;\n let importantCount = 0;\n let hasCSPIssue = false;\n\n for (const h of critical) {\n if (headerStatus[h]?.present) {\n criticalCount++;\n if (h === 'Content-Security-Policy' && hasUnsafeInline(headerStatus[h].value)) {\n hasCSPIssue = true;\n }\n }\n }\n\n for (const h of important) {\n if (headerStatus[h]?.present) importantCount++;\n }\n\n if (criticalCount === critical.length) {\n if (importantCount === important.length) return hasCSPIssue ? 'A-' : 'A+';\n if (importantCount >= 1) return hasCSPIssue ? 'B+' : 'A-';\n return hasCSPIssue ? 'B' : 'B+';\n } else if (criticalCount >= critical.length - 1) {\n return importantCount >= 1 ? 'B' : 'C+';\n } else if (criticalCount >= 2) {\n return 'C';\n } else if (criticalCount >= 1) {\n return 'D';\n } else {\n return 'F';\n }\n}\n\nfunction formatHeadersForDisplay(headerStatus) {\n const present = Object.keys(headerStatus).filter(h => headerStatus[h].present);\n return present.length > 0 ? present.join(', ') : 'No security headers detected';\n}\n\nfunction processSecurityHeaders(items) {\n try {\n const json = items[0].json || items[0];\n\n // \u26cf\ufe0f Try to grab from originalHeaders if available\n const rawHeaders =\n json?.originalHeaders ||\n $('Format HTTP Headers')?.first()?.json?.originalHeaders ||\n json?.headers ||\n {};\n\n const configOutput = json.configOutput || json.output?.[0] || '';\n const vulnOutput = json.vulnOutput || json.output?.[1] || '';\n\n const headerStatus = extractSecurityHeaders(rawHeaders, configOutput);\n const presentHeaders = formatHeadersForDisplay(headerStatus);\n const grade = determineGrade(headerStatus);\n\n const timestamp = new Date().toLocaleString('en-US', {\n timeZone: 'America/New_York',\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n timeZoneName: 'short'\n });\n\n const url =\n json?.formValues?.url ||\n json?.['Landing Page Url'] ||\n $('Form Submission Trigger')?.first()?.json?.['Landing Page Url'] ||\n json?.Landing_Page_Url ||\n json?.landingPageUrl ||\n json?.url ||\n 'https://example.com';\n\n return [\n {\n json: {\n ...json,\n auditData: {\n url,\n timestamp,\n grade,\n criticalCount:\n headerStatus['Content-Security-Policy'].present &&\n hasUnsafeInline(headerStatus['Content-Security-Policy'].value)\n ? 1\n : 0,\n warningCount: Object.keys(headerStatus).filter(\n h =>\n !headerStatus[h].present &&\n !['Strict-Transport-Security', 'Content-Security-Policy'].includes(h)\n ).length,\n presentHeaders,\n configOutput,\n vulnOutput,\n headerStatus,\n originalHeaders: rawHeaders\n }\n }\n }\n ];\n } catch (err) {\n return [{ json: { ...items[0].json, error: err.message } }];\n }\n}\n\nreturn processSecurityHeaders(items);\n"
},
"typeVersion": 2
},
{
"id": "cef61fc2-255a-42ad-9a36-1e29f0f718e9",
"name": "Format HTTP Headers",
"type": "n8n-nodes-base.code",
"position": [
800,
16
],
"parameters": {
"jsCode": "// Format headers into a readable string\nlet formattedHeaders = '';\nif (items[0].json.headers) {\n for (const key in items[0].json.headers) {\n formattedHeaders += `${key}: ${items[0].json.headers[key]}\\n`;\n }\n}\n\n// Return both the original data and the formatted headers\nreturn [{\n json: {\n ...items[0].json,\n formattedHeaders: formattedHeaders,\n originalHeaders: items[0].json.headers // Keep the original headers too\n }\n}];"
},
"typeVersion": 2
},
{
"id": "54e80c95-a5b0-409e-8171-bfb2f98854a9",
"name": "Create HTML Security Report",
"type": "n8n-nodes-base.code",
"position": [
2432,
144
],
"parameters": {
"jsCode": "// Build branded security report: full HTML (for the Gotenberg PDF) + summary HTML (for the Outlook email body)\nconst auditData = $('Extract Security Headers').first().json.auditData;\nconst remediationMd = (items[0].json.output || '').trim();\n\nconst BRAND = {\n name: 'AI Solutions, Inc.',\n website: 'automatedintelligentsolutions.com',\n websiteUrl: 'https://automatedintelligentsolutions.com',\n phone: '404-293-0331',\n email: 'user@example.com',\n primary: '#2c3e50',\n accent: '#3498DB'\n};\nconst reportYear = new Date().getFullYear();\n\n// Escape AI-generated text before injecting it into the report HTML. Without this, a finding\n// that mentions literal tokens like <iframe> or <object> opens a real element in the PDF\n// renderer and swallows the rest of the document, silently truncating the PDF.\nconst esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n\n// --- Severity scoring: derive real counts + an overall risk rating from the AI findings ---\nconst GRADE_COLORS = {\n 'A+':'#27AE60','A':'#27AE60','A-':'#27AE60','B+':'#3498DB','B':'#3498DB','B-':'#3498DB',\n 'C+':'#F39C12','C':'#F39C12','C-':'#F39C12','D+':'#E74C3C','D':'#E74C3C','D-':'#E74C3C','F':'#E74C3C'\n};\nfunction _countItems(section){ return (section.match(/^\\s*\\d+\\.\\s+\\*\\*/gm) || []).length; }\nfunction parseFindingCounts() {\n const counts = { critical: 0, infoLeak: 0, clientSide: 0, config: 0 };\n const vuln = auditData.vulnOutput || '';\n for (const s of vuln.split(/(?=^##\\s+)/gm)) {\n const m = s.match(/^##\\s+(.*)/); if (!m) continue;\n const t = m[1].toLowerCase(); const n = _countItems(s);\n if (n === 0) continue;\n if (t.includes('critical')) counts.critical += n;\n else if (t.includes('leakage') || t.includes('information') || t.includes('disclosure')) counts.infoLeak += n;\n else counts.clientSide += n;\n }\n const conf = auditData.configOutput || '';\n for (const s of conf.split(/(?=^###\\s+)/gm)) {\n const m = s.match(/^###\\s+(.*)/); if (!m) continue;\n if (m[1].toLowerCase().includes('security headers present')) continue;\n counts.config += _countItems(s);\n }\n return counts;\n}\nconst FC = parseFindingCounts();\nconst TOTAL_FINDINGS = FC.critical + FC.infoLeak + FC.clientSide + FC.config;\nconst _CRIT_HEADERS = ['Content-Security-Policy','Strict-Transport-Security','X-Content-Type-Options','X-Frame-Options'];\nconst _IMP_HEADERS = ['Referrer-Policy','Permissions-Policy'];\nconst _hs = auditData.headerStatus || {};\nconst HEADER_GAPS_CRIT = _CRIT_HEADERS.filter(h => !_hs[h] || !_hs[h].present).length;\nconst HEADER_GAPS_ALL = [..._CRIT_HEADERS, ..._IMP_HEADERS, 'X-XSS-Protection'].filter(h => !_hs[h] || !_hs[h].present).length;\nlet OVERALL, OVERALL_COLOR;\nif (FC.critical >= 3) { OVERALL = 'Critical'; OVERALL_COLOR = '#C0392B'; }\nelse if (FC.critical >= 1) { OVERALL = 'High'; OVERALL_COLOR = '#E74C3C'; }\nelse if (FC.clientSide >= 1 || HEADER_GAPS_CRIT >= 3) { OVERALL = 'Moderate'; OVERALL_COLOR = '#F39C12'; }\nelse if (FC.infoLeak >= 1 || FC.config >= 1 || HEADER_GAPS_ALL >= 1) { OVERALL = 'Low'; OVERALL_COLOR = '#3498DB'; }\nelse { OVERALL = 'Minimal'; OVERALL_COLOR = '#27AE60'; }\nfunction formatOverallBadge() {\n return `<div style=\"font-size: 22px; font-weight: bold; padding: 20px 8px; width: 130px; text-align: center; background-color: ${OVERALL_COLOR}; color: white; border-radius: 8px; margin: 0 auto; text-transform: uppercase; letter-spacing: 1px;\">${OVERALL}<div style=\"font-size: 10px; font-weight: normal; margin-top: 5px; letter-spacing: 1px; opacity: 0.9;\">RISK LEVEL</div></div>`;\n}\nfunction gradeChip() {\n const c = GRADE_COLORS[auditData.grade] || '#E74C3C';\n return `<span style=\"display: inline-block; min-width: 26px; padding: 2px 9px; background-color: ${c}; color: white; border-radius: 4px; font-weight: bold; text-align: center;\">${auditData.grade}</span>`;\n}\n\n// --- Simple markdown -> HTML converter (used for remediation guide + as fallback when structured parsing finds nothing) ---\nfunction markdownToHtml(md) {\n if (!md || !md.trim()) return '';\n const codeBlocks = [];\n let text = md.replace(/```[a-zA-Z]*\\n?([\\s\\S]*?)```/g, (m, code) => {\n codeBlocks.push(code.trim());\n return '\ue000CODE' + (codeBlocks.length - 1) + '\ue000';\n });\n text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n text = text\n .replace(/^####\\s+(.*)$/gm, '<h4 style=\"color:#2c3e50;margin:15px 0 5px;\">$1</h4>')\n .replace(/^###\\s+(.*)$/gm, '<h3 style=\"color:#2c3e50;margin:20px 0 5px;\">$1</h3>')\n .replace(/^##\\s+(.*)$/gm, '<h3 style=\"color:#2c3e50;margin:20px 0 5px;\">$1</h3>')\n .replace(/^#\\s+(.*)$/gm, '<h2 style=\"color:#2c3e50;\">$1</h2>')\n .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')\n .replace(/`([^`]+)`/g, '<code style=\"background-color:#f8f9fa;padding:1px 4px;border-radius:3px;font-family:monospace;\">$1</code>');\n const lines = text.split('\\n');\n let out = '';\n let inUl = false;\n let inOl = false;\n for (const line of lines) {\n const ul = line.match(/^\\s*[-*]\\s+(.*)/);\n const ol = line.match(/^\\s*\\d+\\.\\s+(.*)/);\n if (ul) {\n if (inOl) { out += '</ol>'; inOl = false; }\n if (!inUl) { out += '<ul style=\"margin:5px 0 10px 20px;padding-left:15px;\">'; inUl = true; }\n out += '<li style=\"margin-bottom:4px;\">' + ul[1] + '</li>';\n } else if (ol) {\n if (inUl) { out += '</ul>'; inUl = false; }\n if (!inOl) { out += '<ol style=\"margin:5px 0 10px 20px;padding-left:15px;\">'; inOl = true; }\n out += '<li style=\"margin-bottom:4px;\">' + ol[1] + '</li>';\n } else {\n if (inUl) { out += '</ul>'; inUl = false; }\n if (inOl) { out += '</ol>'; inOl = false; }\n if (line.indexOf('<h') === 0) out += line;\n else if (line.trim() === '') out += '';\n else out += '<p style=\"margin:5px 0;\">' + line + '</p>';\n }\n }\n if (inUl) out += '</ul>';\n if (inOl) out += '</ol>';\n out = out.replace(/\ue000CODE(\\d+)\ue000/g, (m, i) => '<pre style=\"background-color:#f8f9fa;padding:10px;border-radius:5px;overflow-x:auto;font-family:monospace;\">' + codeBlocks[+i].replace(/&/g, '&').replace(/</g, '<') + '</pre>');\n return out;\n}\n\n// --- Configuration issues (structured parse with markdown fallback) ---\nfunction formatConfigurationIssues() {\n if (!auditData.configOutput || auditData.configOutput.trim() === '') {\n return '<p>No specific configuration issues detected.</p>';\n }\n try {\n const config = auditData.configOutput.trim();\n let html = '';\n const renderedKeys = new Set();\n\n const renderBlock = (title, description, impact, recommendation) => `\n <div style=\"border-left: 4px solid #3498DB; padding: 10px; margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #3498DB;\">${title}</div>\n ${description ? `<div style=\"margin-top: 5px;\">${description}</div>` : ''}\n ${impact ? `<div style=\"margin-top: 5px; font-style: italic; color: #7F8C8D;\">Impact: ${impact}</div>` : ''}\n ${recommendation ? `<div style=\"margin-top: 5px;\"><strong>Recommendation:</strong></div>\n <pre style=\"background-color: #f8f9fa; padding: 10px; border-radius: 5px; overflow-x: auto; font-family: monospace;\">${recommendation}</pre>` : ''}\n </div>`;\n\n const sections = config.split(/(?=^###\\s+)/gm).filter(Boolean);\n\n for (const section of sections) {\n const sectionTitleMatch = section.match(/^###\\s+(.*)/);\n const sectionTitle = sectionTitleMatch?.[1]?.trim() || 'Unnamed Section';\n const sectionKey = sectionTitle.toLowerCase();\n\n // The header inventory is already rendered in the Raw Headers table\n if (/security headers present/i.test(sectionTitle)) continue;\n // Skip sections that explicitly report no issues\n if (/no issues? (found|were found)/i.test(section)) continue;\n\n const blocks = section.split(/(?=^\\s*\\d+\\.\\s+\\*\\*)/gm).slice(1);\n\n for (const block of blocks) {\n const titleMatch = block.match(/^\\s*\\d+\\.\\s+\\*\\*(.*?)\\*\\*/);\n const title = titleMatch?.[1]?.trim() || 'Finding';\n const key = sectionKey + '::' + title.toLowerCase();\n if (renderedKeys.has(key)) continue;\n\n const descMatch = block.match(/\\*\\*Description:?\\*\\*:?\\s*([\\s\\S]*?)(?=\\n\\s*-?\\s*\\*\\*|\\n\\s*```|$)/i);\n const impactMatch = block.match(/\\*\\*(?:Impact|Security Implications?|Potential Impact):?\\*\\*:?\\s*([\\s\\S]*?)(?=\\n\\s*-?\\s*\\*\\*|\\n\\s*```|$)/i);\n const recMatch = block.match(/```(?:\\w*)?\\n?([\\s\\S]*?)```/);\n const presentMatch = block.match(/\\*\\*Present\\?\\*\\*\\s*(Yes|No)/i);\n const valueMatch = block.match(/\\*\\*Value:\\*\\*\\s*`?([^`\\n]*)`?/i);\n\n let description = descMatch?.[1]?.trim() || '';\n if (!description && (presentMatch || valueMatch)) {\n description = 'This header is ' + (presentMatch?.[1] || 'unknown').toLowerCase() + '. Value: ' + (valueMatch?.[1]?.trim() || '[Not provided]') + '.';\n }\n const impact = impactMatch?.[1]?.trim() || '';\n const recommendation = recMatch?.[1]?.trim() || '';\n\n if (description || impact || recommendation) {\n html += renderBlock(esc(title), esc(description), esc(impact), esc(recommendation));\n } else {\n // Structured labels weren't found (LLM phrasing drift). Never silently drop the\n // finding - render its raw text as a fallback block instead of skipping it.\n const rawBody = block.replace(/^\\s*\\d+\\.\\s+\\*\\*.*?\\*\\*/, '').trim();\n html += renderBlock(esc(title), markdownToHtml(rawBody) || '<em>Unable to parse this finding; see raw remediation notes.</em>', '', '');\n }\n renderedKeys.add(key);\n }\n }\n\n // Fallback: if structured parsing produced nothing at all, render the raw markdown so\n // findings are never lost.\n return html || markdownToHtml(config) || '<p>No configuration issues detected.</p>';\n } catch (e) {\n return `<p>Error processing configuration issues: ${e.message}</p>`;\n }\n}\n\n// --- Vulnerabilities (structured parse with markdown fallback) ---\nfunction formatCriticalVulnerabilities() {\n if (!auditData.vulnOutput || auditData.vulnOutput.trim() === '') {\n return '<p>No vulnerabilities detected.</p>';\n }\n try {\n const vuln = auditData.vulnOutput.trim();\n let html = '';\n const renderedTitles = new Set();\n\n const categories = vuln.split(/(?=^##\\s+)/gm).filter(Boolean);\n\n for (const categoryBlock of categories) {\n const categoryMatch = categoryBlock.match(/^##\\s+(.*)/);\n const categoryTitle = categoryMatch?.[1]?.trim() || 'Uncategorized';\n\n if (/no issues? found/i.test(categoryBlock) && !/^\\s*\\d+\\.\\s+\\*\\*/m.test(categoryBlock)) continue;\n\n const vulns = categoryBlock.split(/(?=^\\s*\\d+\\.\\s+\\*\\*)/gm).filter(Boolean);\n\n for (const vulnBlock of vulns) {\n const titleMatch = vulnBlock.match(/^\\s*\\d+\\.\\s+\\*\\*(.*?)\\*\\*/);\n if (!titleMatch) continue;\n const title = titleMatch[1].trim();\n const key = `${categoryTitle}::${title}`.toLowerCase();\n if (renderedTitles.has(key)) continue;\n\n const descriptionMatch = vulnBlock.match(/\\*\\*Description\\*\\*:?\\s*([\\s\\S]*?)(?=\\n\\s*\\*\\*|$)/i);\n const impactMatch = vulnBlock.match(/\\*\\*(?:Impact|Potential Impact)\\*\\*:?\\s*([\\s\\S]*?)(?=\\n\\s*\\*\\*|$)/i);\n const recommendationMatch = vulnBlock.match(/\\*\\*(?:Recommendation|Mitigation|Fix)\\*\\*:?\\s*([\\s\\S]*?)(?=\\n\\s*\\*\\*|\\n\\s*\\d+\\.|$)/i);\n\n const description = descriptionMatch?.[1]?.trim() || '';\n const impact = impactMatch?.[1]?.trim() || '';\n const recommendation = recommendationMatch?.[1]?.trim() || '';\n\n if (description || impact || recommendation) {\n html += `\n <div style=\"border-left: 4px solid #E74C3C; padding: 10px; margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #E74C3C;\">${esc(categoryTitle)}: ${esc(title)}</div>\n ${description ? `<div style=\"margin-top: 5px;\">${esc(description)}</div>` : ''}\n ${impact ? `<div style=\"margin-top: 5px; font-style: italic; color: #7F8C8D;\">Impact: ${esc(impact)}</div>` : ''}\n ${recommendation ? `<div style=\"margin-top: 5px;\"><strong>Recommendation:</strong> ${esc(recommendation)}</div>` : ''}\n </div>`;\n } else {\n // Structured labels weren't found (LLM phrasing drift). Never silently drop the\n // finding - render its raw text as a fallback block instead of skipping it.\n const rawBody = vulnBlock.replace(/^\\s*\\d+\\.\\s+\\*\\*.*?\\*\\*/, '').trim();\n html += `\n <div style=\"border-left: 4px solid #E74C3C; padding: 10px; margin-bottom: 15px;\">\n <div style=\"font-weight: bold; color: #E74C3C;\">${esc(categoryTitle)}: ${esc(title)}</div>\n <div style=\"margin-top: 5px;\">${markdownToHtml(rawBody) || '<em>Unable to parse this finding; see raw remediation notes.</em>'}</div>\n </div>`;\n }\n renderedTitles.add(key);\n }\n }\n\n // Fallback: never lose findings if the AI deviated from the expected format\n return html || markdownToHtml(vuln) || '<p>No vulnerabilities detected.</p>';\n } catch (e) {\n return `<p>Error processing vulnerabilities: ${e.message}</p>`;\n }\n}\n\n// --- Platform remediation guide (WordPress / GoDaddy / Hostinger / Squarespace) ---\nfunction formatRemediationSection() {\n if (!remediationMd) {\n return '<p>No platform-specific remediation instructions were generated for this scan.</p>';\n }\n const platforms = remediationMd.split(/(?=^##\\s+)/gm).filter(s => s.trim());\n let html = '';\n for (const block of platforms) {\n const m = block.match(/^##\\s+(.*)/);\n const title = m ? m[1].trim() : 'General Guidance';\n const body = m ? block.replace(/^##\\s+.*\\n?/, '') : block;\n html += `\n <div style=\"border: 1px solid #e0e0e0; border-radius: 5px; margin-bottom: 15px;\">\n <div style=\"background-color: #2c3e50; color: white; padding: 10px 15px; font-weight: bold; border-radius: 5px 5px 0 0;\">${title}</div>\n <div style=\"padding: 15px;\">${markdownToHtml(body)}</div>\n </div>`;\n }\n return html || markdownToHtml(remediationMd);\n}\n\n// --- Header badge helpers ---\nfunction createHeaderBadge(headerName, isWarning = false) {\n const isPresent = auditData.headerStatus &&\n auditData.headerStatus[headerName] &&\n auditData.headerStatus[headerName].present;\n const color = isWarning && isPresent ? '#F39C12' : (isPresent ? '#27AE60' : '#E74C3C');\n const icon = isPresent ? '✓' : '✗';\n return `<span style=\"display: inline-block; margin: 2px; padding: 4px 8px; background-color: ${color}; color: white; border-radius: 4px; font-size: 12px;\">${icon} ${headerName}</span>`;\n}\n\nfunction generateAllHeaderBadges() {\n const securityHeaders = [\n 'Content-Security-Policy',\n 'Strict-Transport-Security',\n 'X-Content-Type-Options',\n 'X-Frame-Options',\n 'Referrer-Policy',\n 'Permissions-Policy'\n ];\n let badges = '';\n securityHeaders.forEach(header => {\n const isWarning = header === 'Strict-Transport-Security' &&\n auditData.headerStatus?.[header]?.value &&\n parseInt(auditData.headerStatus[header].value.match(/max-age=(\\d+)/)?.[1] || 0) < 2592000;\n badges += createHeaderBadge(header, isWarning);\n });\n return badges;\n}\n\n// --- Warnings section ---\nfunction formatWarningsSection() {\n if (!auditData.warningCount || auditData.warningCount === 0 || !auditData.headerStatus) {\n return '<p>No warnings detected.</p>';\n }\n const csp = Object.entries(auditData.headerStatus).find(([k]) => k.toLowerCase() === 'content-security-policy');\n const hsts = Object.entries(auditData.headerStatus).find(([k]) => k.toLowerCase() === 'strict-transport-security');\n const xss = Object.entries(auditData.headerStatus).find(([k]) => k.toLowerCase() === 'x-xss-protection');\n\n let warnings = '';\n\n if (csp && csp[1].value && csp[1].value.includes('unsafe-inline')) {\n warnings += `\n <div style=\"margin-top: 15px;\">\n <div style=\"border-left: 4px solid #F39C12; padding: 10px;\">\n <strong style=\"color: #F39C12;\">Content-Security-Policy: unsafe-inline</strong>\n <p>The use of 'unsafe-inline' allows potentially malicious scripts to execute.</p>\n </div>\n </div>`;\n }\n\n if (hsts && hsts[1].value) {\n const match = hsts[1].value.match(/max-age=(\\d+)/);\n const age = match ? parseInt(match[1]) : 0;\n if (age < 2592000) {\n warnings += `\n <div style=\"margin-top: 15px;\">\n <div style=\"border-left: 4px solid #F39C12; padding: 10px;\">\n <strong style=\"color: #F39C12;\">Strict-Transport-Security</strong>\n <p>max-age is too low (${age}). Should be at least 2592000 (30 days).</p>\n </div>\n </div>`;\n }\n }\n\n if (xss && !xss[1].present) {\n warnings += `\n <div style=\"margin-top: 15px;\">\n <div style=\"border-left: 4px solid #F39C12; padding: 10px;\">\n <strong style=\"color: #F39C12;\">Missing X-XSS-Protection</strong>\n <p>This header enables the browser's XSS filter. Lack of it increases XSS risks.</p>\n </div>\n </div>`;\n }\n\n if (!warnings) {\n warnings = `\n <div style=\"margin-top: 15px;\">\n <div style=\"border-left: 4px solid #F39C12; padding: 10px;\">\n <strong style=\"color: #F39C12;\">${auditData.warningCount} warnings detected</strong>\n <p>See the Configuration Issues section below for more info.</p>\n </div>\n </div>`;\n }\n\n return warnings;\n}\n\nfunction formatLongValue(value) {\n if (!value || typeof value !== 'string') return '[empty]';\n value = esc(value);\n value = value.replace(/(https?:\\/\\/[^\\s]+)/g, '<a href=\"$1\" style=\"color: #3498DB; text-decoration: none;\" target=\"_blank\">$1</a>');\n if (value.length > 100) {\n value = value.replace(/([,;])\\s*/g, '$1<br>');\n }\n return value;\n}\n\nfunction formatDetailedRawHeaders() {\n const allHeaders = [];\n const seen = new Set();\n\n const addHeader = (name, value) => {\n const key = name.toLowerCase();\n if (seen.has(key)) return;\n seen.add(key);\n const status = Object.entries(auditData.headerStatus || {}).find(\n ([k]) => k.toLowerCase() === name.toLowerCase()\n );\n const present = status ? status[1].present : !!value;\n allHeaders.push({ name: name.trim(), present, value: value || '[empty]' });\n };\n\n Object.entries(auditData.originalHeaders || {}).forEach(([key, value]) => {\n if (key) addHeader(key, value);\n });\n\n const securityHeaders = [\n 'content-security-policy',\n 'strict-transport-security',\n 'x-content-type-options',\n 'x-frame-options',\n 'referrer-policy',\n 'permissions-policy',\n 'x-xss-protection'\n ];\n\n const isWarningHeader = (name, value) => {\n const lower = name.toLowerCase();\n if (lower === 'strict-transport-security') {\n const match = value.match(/max-age=(\\d+)/);\n return match && parseInt(match[1]) < 2592000;\n }\n if (lower === 'content-security-policy') return value.includes(\"'unsafe-inline'\");\n return false;\n };\n\n const tableRows = allHeaders.map(header => {\n const isSecurity = securityHeaders.includes(header.name.toLowerCase());\n const warning = isSecurity && isWarningHeader(header.name, header.value);\n const missing = isSecurity && !header.present;\n\n let bgColor = '#F8F9FA';\n let textColor = '#333';\n if (isSecurity) {\n if (missing) { bgColor = '#FFEBEE'; textColor = '#C62828'; }\n else if (warning) { bgColor = '#FFF9C4'; textColor = '#F57F17'; }\n else { bgColor = '#E8F5E9'; textColor = '#2E7D32'; }\n }\n\n return `\n <tr style=\"background-color: ${bgColor}; color: ${textColor};\">\n <td title=\"${isSecurity ? (missing ? 'Missing' : (warning ? 'Needs review' : 'Secure')) : 'Informational'}\" style=\"padding: 8px; font-weight: bold;\">${header.name}</td>\n <td style=\"padding: 8px; text-align: center;\">${header.present ? 'present' : 'absent'}</td>\n <td style=\"padding: 8px; word-break: break-word; font-family: monospace;\">${formatLongValue(header.value)}</td>\n </tr>`;\n }).join('');\n\n return `\n <table style=\"width: 100%; border-collapse: collapse; margin-top: 10px;\">\n <thead>\n <tr style=\"background-color: #E0E0E0;\">\n <th style=\"padding: 10px;\">Header</th>\n <th style=\"padding: 10px;\">Status</th>\n <th style=\"padding: 10px;\">Value</th>\n </tr>\n </thead>\n <tbody>${tableRows}</tbody>\n </table>`;\n}\n\nfunction formatAdditionalInfo() {\n const headers = [\n { name: 'access-control-allow-origin', description: 'This is a very lax CORS policy. Such a policy should only be used on a public CDN.' },\n { name: 'strict-transport-security', description: 'HTTP Strict Transport Security is an excellent feature to support on your site and strengthens your implementation of TLS by getting the User Agent to enforce the use of HTTPS.' },\n { name: 'content-security-policy', description: 'Content Security Policy is an effective measure to protect your site from XSS attacks. By whitelisting sources of approved content, you can prevent the browser from loading malicious assets.' },\n { name: 'permissions-policy', description: 'Permissions Policy allows a site to control which features and APIs can be used in the browser.' },\n { name: 'referrer-policy', description: 'Referrer Policy allows a site to control how much information the browser includes with navigations away from a document and should be set by all sites.' },\n { name: 'x-content-type-options', description: 'X-Content-Type-Options stops a browser from trying to MIME-sniff the content type and forces it to stick with the declared content-type. The only valid value for this header is \"X-Content-Type-Options: nosniff\".' },\n { name: 'x-frame-options', description: 'X-Frame-Options tells the browser whether you want to allow your site to be framed or not. By preventing a browser from framing your site you can defend against attacks like clickjacking.' },\n { name: 'report-to', description: 'Report-To enables the Reporting API. This allows a website to collect reports from the browser about various errors that may occur.' },\n { name: 'nel', description: 'Network Error Logging instructs the browser to send reports during various network or application errors.' },\n { name: 'server', description: 'The Server header should not reveal detailed version information. Typically you will see values like \"Microsoft-IIS/8.0\" or \"nginx 1.7.2\" \u2014 hide or genericize these.' }\n ];\n\n let rows = '';\n for (const header of headers) {\n const isSecurityHeader = ['content-security-policy', 'strict-transport-security', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy'].includes(header.name);\n const headerColor = isSecurityHeader ? '#27AE60' : '#3498DB';\n rows += `\n <tr>\n <td style=\"padding: 8px; border-bottom: 1px solid #eee; color: ${headerColor}; font-weight: bold;\">${header.name}</td>\n <td style=\"padding: 8px; border-bottom: 1px solid #eee;\">${header.description}</td>\n </tr>`;\n }\n\n return `\n <table style=\"width: 100%; border-collapse: collapse; margin-top: 10px;\">\n <tbody>${rows}</tbody>\n </table>`;\n}\n\nfunction formatSecurityGrade() {\n const gradeColors = {\n 'A+': '#27AE60', 'A': '#27AE60', 'A-': '#27AE60',\n 'B+': '#3498DB', 'B': '#3498DB', 'B-': '#3498DB',\n 'C+': '#F39C12', 'C': '#F39C12', 'C-': '#F39C12',\n 'D+': '#E74C3C', 'D': '#E74C3C', 'D-': '#E74C3C',\n 'F': '#E74C3C'\n };\n return `<div style=\"font-size: 64px; font-weight: bold; width: 100px; height: 100px; line-height: 100px; text-align: center; background-color: ${gradeColors[auditData.grade] || '#E74C3C'}; color: white; border-radius: 5px; margin: 0 auto;\">${auditData.grade}</div>`;\n}\n\n// --- Branded header & footer (shared look between PDF and email) ---\nconst brandHeader = `\n <div style=\"background-color: ${BRAND.primary}; color: white; padding: 25px 20px; text-align: center;\">\n <div style=\"font-size: 13px; letter-spacing: 3px; text-transform: uppercase; color: #AEB6BF; margin-bottom: 8px;\">${BRAND.name}</div>\n <h1 style=\"color: white; font-size: 28px; margin: 0; text-shadow: 1px 1px 2px rgba(0,0,0,0.5);\">Website Security Audit Report</h1>\n <div style=\"margin-top: 8px; font-size: 14px; color: #D6DBDF;\">Prepared for ${auditData.url} • ${auditData.timestamp}</div>\n <div style=\"margin-top: 10px; height: 4px; background-color: ${BRAND.accent}; width: 120px; margin-left: auto; margin-right: auto; border-radius: 2px;\"></div>\n </div>`;\n\nconst brandFooter = `\n <div style=\"text-align: center; padding: 20px; font-size: 12px; color: #777; border-top: 3px solid ${BRAND.primary}; margin-top: 10px;\">\n <p style=\"font-weight: bold; color: ${BRAND.primary}; font-size: 14px; margin: 0 0 4px;\">${BRAND.name}</p>\n <p style=\"margin: 4px 0;\"><a href=\"${BRAND.websiteUrl}\" style=\"color: ${BRAND.accent}; text-decoration: none;\">${BRAND.website}</a> | ${BRAND.phone} | <a href=\"mailto:${BRAND.email}\" style=\"color: ${BRAND.accent}; text-decoration: none;\">${BRAND.email}</a></p>\n <p>This report was automatically generated and represents an automated assessment of publicly accessible aspects of your website. For a comprehensive security assessment, contact ${BRAND.name} for a professional consultation.</p>\n <p>© ${reportYear} ${BRAND.name} | Generated on ${auditData.timestamp}</p>\n </div>`;\n\n// --- Full report (rendered to PDF by Gotenberg) ---\nconst reportHtml = `<!DOCTYPE html>\n<html>\n<head>\n <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Website Security Audit Report</title>\n <style>\n body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f9f9f9; }\n .container { max-width: 950px; margin: 0 auto; }\n .content { padding: 20px; }\n .summary-box { background-color: #EBF5FB; padding: 15px; margin-bottom: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n .warning-box { background-color: #FEF5E7; padding: 15px; margin-bottom: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n .findings-box { background-color: white; padding: 15px; margin-bottom: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n .raw-headers-box { background-color: #F5F7FA; padding: 15px; margin-bottom: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n .additional-info-box { background-color: #F5F7FA; padding: 15px; margin-bottom: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n h1, h2, h3 { color: #2c3e50; }\n pre { background-color: #f8f9fa; padding: 10px; border-radius: 5px; overflow-x: auto; font-family: monospace; margin-top: 5px; white-space: pre-wrap; word-break: break-word; }\n @page { margin: 12mm 10mm; }\n @media print {\n body { background-color: #ffffff; }\n .summary-box, .warning-box, .raw-headers-box, .additional-info-box { box-shadow: none; border: 1px solid #e0e0e0; page-break-inside: avoid; }\n .findings-box { box-shadow: none; border: 1px solid #e0e0e0; }\n h2, h3 { page-break-after: avoid; }\n .findings-box > div[style*=\"border-left\"] { page-break-inside: avoid; }\n }\n </style>\n</head>\n<body>\n <div class=\"container\">\n ${brandHeader}\n <div class=\"content\">\n <div class=\"summary-box\">\n <h2>Security Report Summary</h2>\n <p style=\"margin: 0 0 15px; color: #555; font-size: 13px; line-height: 1.5;\"><strong>Overall Risk</strong> reflects the severity of the findings in this report. <strong>Header Hygiene</strong> is a separate letter grade based only on which HTTP security headers are present, so a low letter here does not by itself mean the site is compromised.</p>\n <table style=\"width: 100%;\">\n <tr>\n <td style=\"width: 150px;\" valign=\"top\">${formatOverallBadge()}</td>\n <td valign=\"top\">\n <table style=\"width: 100%;\">\n <tr><td><strong>Site:</strong></td><td><a href=\"${auditData.url}\" style=\"color: #3498db;\">${auditData.url}</a></td></tr>\n <tr><td><strong>Report Time:</strong></td><td>${auditData.timestamp}</td></tr>\n <tr><td valign=\"top\"><strong>Headers:</strong></td><td><div style=\"margin-top: 5px;\">${generateAllHeaderBadges()}</div></td></tr>\n <tr><td><strong>Header Hygiene:</strong></td><td>${gradeChip()} <span style=\"color:#777;font-size:12px;\">(HTTP security headers only)</span></td></tr>\n <tr><td><strong>Critical Vulnerabilities:</strong></td><td>${FC.critical}</td></tr>\n <tr><td><strong>Information Disclosure:</strong></td><td>${FC.infoLeak}</td></tr>\n <tr><td><strong>Client-Side Weaknesses:</strong></td><td>${FC.clientSide}</td></tr>\n <tr><td><strong>Configuration Issues:</strong></td><td>${FC.config}</td></tr>\n <tr><td><strong>Missing Security Headers:</strong></td><td>${HEADER_GAPS_ALL} of 7</td></tr>\n </table>\n </td>\n </tr>\n </table>\n </div>\n\n <div class=\"warning-box\">\n <h2>Warnings</h2>\n ${formatWarningsSection()}\n </div>\n\n <div class=\"raw-headers-box\">\n <h2>Raw Headers</h2>\n ${formatDetailedRawHeaders()}\n </div>\n\n <div class=\"findings-box\">\n <h2>Security Findings</h2>\n <h3>Vulnerabilities</h3>\n ${formatCriticalVulnerabilities()}\n <h3>Configuration Issues</h3>\n ${formatConfigurationIssues()}\n </div>\n\n <div class=\"findings-box\">\n <h2>Platform Fix Instructions</h2>\n <p>Step-by-step remediation guidance tailored to the issues found on this site, for the most common website platforms.</p>\n ${formatRemediationSection()}\n </div>\n\n <div class=\"additional-info-box\">\n <h2>Additional Information</h2>\n ${formatAdditionalInfo()}\n </div>\n\n <div class=\"findings-box\">\n <h2>Implementation Guide</h2>\n <p>This report highlights security issues detected through client-side analysis. For a comprehensive security assessment, consider engaging a professional penetration tester.</p>\n <div style=\"background-color: #eafaf1; padding: 15px; margin-top: 15px; border-left: 4px solid #2ecc71; border-radius: 3px;\">\n <p><strong>To implement the fixes above:</strong></p>\n <ol style=\"padding-left: 20px; margin-top: 10px;\">\n <li>Address each issue in order of criticality using the platform instructions for your hosting environment</li>\n <li>Retest after implementing each fix</li>\n <li>Consider implementing a web application firewall for additional protection</li>\n </ol>\n </div>\n </div>\n\n ${brandFooter}\n </div>\n </div>\n</body>\n</html>`;\n\n// --- Short branded summary for the Outlook email body ---\nconst domain = String(auditData.url || '').replace(/^https?:\\/\\//, '').replace(/\\/.*$/, '') || 'website';\n\nconst emailSummaryHtml = `<!DOCTYPE html>\n<html>\n<head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"></head>\n<body style=\"font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f9f9f9;\">\n <div style=\"max-width: 650px; margin: 0 auto; background-color: #ffffff;\">\n <div style=\"background-color: ${BRAND.primary}; color: white; padding: 25px 20px; text-align: center;\">\n <div style=\"font-size: 12px; letter-spacing: 3px; text-transform: uppercase; color: #AEB6BF; margin-bottom: 8px;\">${BRAND.name}</div>\n <h1 style=\"color: white; font-size: 24px; margin: 0;\">Website Security Audit</h1>\n <div style=\"margin-top: 10px; height: 4px; background-color: ${BRAND.accent}; width: 100px; margin-left: auto; margin-right: auto; border-radius: 2px;\"></div>\n </div>\n <div style=\"padding: 25px 20px;\">\n <table style=\"width: 100%;\">\n <tr>\n <td style=\"width: 150px;\" valign=\"top\">${formatOverallBadge()}</td>\n <td valign=\"top\" style=\"padding-left: 15px;\">\n <table style=\"width: 100%; font-size: 14px;\">\n <tr><td style=\"padding: 4px 0;\"><strong>Site:</strong></td><td><a href=\"${auditData.url}\" style=\"color: ${BRAND.accent};\">${auditData.url}</a></td></tr>\n <tr><td style=\"padding: 4px 0;\"><strong>Scanned:</strong></td><td>${auditData.timestamp}</td></tr>\n <tr><td style=\"padding: 4px 0;\"><strong>Overall Risk:</strong></td><td>${OVERALL}</td></tr>\n <tr><td style=\"padding: 4px 0;\"><strong>Header Hygiene:</strong></td><td>${auditData.grade} (headers only)</td></tr>\n <tr><td style=\"padding: 4px 0;\"><strong>Critical Vulnerabilities:</strong></td><td>${FC.critical}</td></tr>\n <tr><td style=\"padding: 4px 0;\"><strong>Total Findings:</strong></td><td>${TOTAL_FINDINGS}</td></tr>\n </table>\n </td>\n </tr>\n </table>\n <div style=\"margin-top: 15px;\">${generateAllHeaderBadges()}</div>\n <div style=\"background-color: #EBF5FB; border-left: 4px solid ${BRAND.accent}; padding: 15px; margin-top: 20px; border-radius: 3px;\">\n <strong>Your full report is attached as a PDF.</strong>\n <p style=\"margin: 8px 0 0;\">It includes every finding in detail plus step-by-step fix instructions for WordPress, GoDaddy Hosting, Hostinger Hosting, and Squarespace.</p>\n </div>\n </div>\n <div style=\"text-align: center; padding: 20px; font-size: 12px; color: #777; border-top: 3px solid ${BRAND.primary};\">\n <p style=\"font-weight: bold; color: ${BRAND.primary}; font-size: 14px; margin: 0 0 4px;\">${BRAND.name}</p>\n <p style=\"margin: 4px 0;\"><a href=\"${BRAND.websiteUrl}\" style=\"color: ${BRAND.accent}; text-decoration: none;\">${BRAND.website}</a> | ${BRAND.phone} | <a href=\"mailto:${BRAND.email}\" style=\"color: ${BRAND.accent}; text-decoration: none;\">${BRAND.email}</a></p>\n <p style=\"margin: 4px 0;\">© ${reportYear} ${BRAND.name}</p>\n </div>\n </div>\n</body>\n</html>`;\n\nreturn [{\n json: {\n auditData,\n domain,\n reportHtml,\n emailSummaryHtml,\n remediationMarkdown: remediationMd\n }\n}];"
},
"typeVersion": 2
},
{
"id": "98cb3d27-df85-4313-91ca-dca331035963",
"name": "Claude Remediation Analysis",
"type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
"position": [
2080,
352
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "claude-sonnet-4-6",
"cachedResultName": "Claude Sonnet 4.6"
},
"options": {
"temperature": 0.2
}
},
"typeVersion": 1.3
},
{
"id": "31c33d9c-7f98-468b-8410-ea9a5a582126",
"name": "Develop Fix Recommendations",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
2048,
144
],
"parameters": {
"text": "=You are a senior web security consultant at AI Solutions, Inc. writing remediation guidance for a website owner who is not a security expert.\n\nWebsite audited: {{ $json.auditData.url }}\n\nConfiguration findings:\n{{ $json.auditData.configOutput }}\n\nVulnerability findings:\n{{ $json.auditData.vulnOutput }}\n\nWrite step-by-step instructions for fixing the specific issues found above on each of the following platforms, using EXACTLY this markdown structure:\n\n## WordPress\n## GoDaddy Hosting\n## Hostinger Hosting\n## Squarespace\n\nRules:\n- Tailor every step to the actual findings above. Do not include generic advice for issues that were not found.\n- WordPress: prefer plugin-based fixes (e.g., a security headers plugin) plus .htaccess / wp-config.php edits, with exact code snippets in fenced code blocks.\n- GoDaddy Hosting: cover cPanel / GoDaddy hosting dashboard steps and .htaccess edits where applicable.\n- Hostinger Hosting: cover hPanel steps and .htaccess edits where applicable.\n- Squarespace: use built-in settings (SSL/HSTS) and Code Injection where possible, and clearly state which issues CANNOT be fixed on Squarespace because custom HTTP headers are not supported, suggesting the closest alternative.\n- Number the steps within each platform section. Keep each section self-contained and actionable.\n- If no issues were found at all, say the site is in good shape and list brief hardening tips per platform.",
"options": {},
"promptType": "define"
},
"typeVersion": 1.7
},
{
"id": "2e4a2092-912f-49f7-9af6-0ef5942f4857",
"name": "HTML to PDF Conversion",
"type": "n8n-nodes-base.convertToFile",
"position": [
2592,
144
],
"parameters": {
"options": {
"encoding": "utf8",
"fileName": "index.html"
},
"operation": "toText",
"sourceProperty": "reportHtml"
},
"typeVersion": 1.1
},
{
"id": "f0e3f85f-0fdb-4d4a-8a13-b0592287bc1c",
"name": "Submit to Gotenberg Service",
"type": "n8n-nodes-base.httpRequest",
"position": [
2784,
144
],
"parameters": {
"url": "https://YOUR_SERVICE_URL",
"method": "POST",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
},
"sendBody": true,
"contentType": "multipart-form-data",
"sendHeaders": true,
"authentication": "genericCredentialType",
"bodyParameters": {
"parameters": [
{
"name": "files",
"parameterType": "formBinaryData",
"inputDataFieldName": "data"
},
{
"name": "printBackground",
"value": "true"
},
{
"name": "marginTop",
"value": "0.4"
},
{
"name": "marginBottom",
"value": "0.4"
},
{
"name": "marginLeft",
"value": "0.3"
},
{
"name": "marginRight",
"value": "0.3"
}
]
},
"genericAuthType": "httpBasicAuth",
"headerParameters": {
"parameters": [
{
"name": "Gotenberg-Output-Filename",
"value": "=Security-Report-{{ ($('Create HTML Security Report').first().json.domain || 'website').replace(/[^a-zA-Z0-9.-]/g, '_') }}"
}
]
}
},
"typeVersion": 4.2
},
{
"id": "d8c5a125-39af-4b40-9a8b-d7aad7bb4c5e",
"name": "Send Report via Outlook",
"type": "n8n-nodes-base.microsoftOutlook",
"position": [
2960,
144
],
"parameters": {
"subject": "=Website Security Audit - {{ $('Create HTML Security Report').first().json.auditData.url }}",
"bodyContent": "={{ $('Create HTML Security Report').first().json.emailSummaryHtml }}",
"toRecipients": "user@example.com",
"additionalFields": {
"attachments": {
"attachments": [
{
"binaryPropertyName": "data"
}
]
},
"bodyContentType": "html"
}
},
"typeVersion": 2
},
{
"id": "958c718b-1600-41c7-a119-143fcf306775",
"name": "Normalize ScrapingBee Output",
"type": "n8n-nodes-base.code",
"position": [
560,
144
],
"parameters": {
"jsCode": "// Normalize a ScrapingBee json_response payload back into the { data, headers } shape\n// that the rest of the workflow expects (matches the old plain-HTTP Scrape Website output).\nconst r = items[0].json || {};\n\n// Rendered HTML lives in `body` for ScrapingBee json_response; fall back to older shapes.\nconst html = r.body || r.data || (typeof r === 'string' ? r : '') || '';\n\n// Headers may come back as an array of {name,value} OR an object keyed by name.\n// ScrapingBee sometimes prefixes origin headers with \"Spb-\" \u2014 strip it so the\n// downstream security-header matcher sees canonical names.\nconst rawHeaders = {};\nconst h = r.headers;\nconst clean = (k) => String(k).replace
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow accepts a website URL via an n8n form, scrapes the page with ScrapingBee, and uses Anthropic Claude to audit HTTP security headers and client-side vulnerabilities. It then generates a branded HTML report, converts it to a PDF with Gotenberg, and emails it via…
Source: https://n8n.io/workflows/18048/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n template demonstrates how to automatically process PDF purchase orders received via email and convert them into sales orders in Adobe Commerce (Magento 2) using Company Credit as the payment
This workflow contains community nodes that are only compatible with the self-hosted version of n8n.
This workflow is designed for marketers, founders, agencies, and product teams who want to understand how real customers talk about a product category, market, or problem space.
My workflow 30. Uses lmChatOpenAi, httpRequest, agent, lmChatOllama. Event-driven trigger; 52 nodes.
Episode 14: Seedance + ASMR. Uses lmChatOpenAi, agent, httpRequest, lmChatOllama. Event-driven trigger; 51 nodes.