使用Gmail发送的自动化每周安全审计报告
高级
这是一个SecOps领域的自动化工作流,包含 23 个节点。主要使用 If, N8n, Set, Code, Cron 等节点。 使用Gmail发送的自动化每周安全审计报告
前置要求
- •Google 账号和 Gmail API 凭证
分类
工作流预览
可视化展示节点连接关系,支持缩放和平移
导出工作流
复制以下 JSON 配置到 n8n 导入,即可使用此工作流
{
"meta": {
"instanceId": "3568945d2a3f637c54ef170c26005913624678bc725f58cac81dfa10a714a2ca",
"templateCredsSetupCompleted": true
},
"nodes": [
{
"id": "628f28dc-b550-4501-b3f7-656756a84f0b",
"name": "设置配置变量",
"type": "n8n-nodes-base.set",
"position": [
-1552,
64
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "af567071-143f-4361-9f18-12c730802196",
"name": "email_to",
"type": "string",
"value": "abc@xyz.com"
},
{
"id": "0b68ba86-9c64-4f67-9f1d-c1914f42722c",
"name": "project_name",
"type": "string",
"value": "N8N-main"
},
{
"id": "e6cf406c-ed0b-40bf-bd24-5e3a98990f66",
"name": "server_url",
"type": "string",
"value": "YOUR N8N SERVER URL WITHOUT THE / AT THE END"
},
{
"id": "195a7808-14a4-44b6-9b03-51eede551f87",
"name": "Language",
"type": "string",
"value": "EN"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "29de9117-4e5d-42c0-b2bb-b12b37cd6bf4",
"name": "发送 Gmail(HTML)",
"type": "n8n-nodes-base.gmail",
"position": [
0,
64
],
"parameters": {
"toList": [
"={{ $('Set Config Variables').first().json.email_to }}"
],
"message": "=",
"subject": "={{ $json.emailSubject }}",
"resource": "message",
"htmlMessage": "={{ $json.html }}",
"includeHtml": true,
"additionalFields": {}
},
"credentials": {
"gmailOAuth2": {
"id": "ILUgVCush8y34I4O",
"name": "Gmail account"
}
},
"typeVersion": 1
},
{
"id": "3de2498a-7b6d-4971-aaa5-01c708e9a7a6",
"name": "计划触发器(每周)",
"type": "n8n-nodes-base.cron",
"position": [
-1808,
64
],
"parameters": {
"triggerTimes": {
"item": [
{
"hour": 6,
"mode": "everyWeek"
}
]
}
},
"typeVersion": 1
},
{
"id": "57015778-7300-4c12-b7f2-c795b7316d59",
"name": "生成安全审计",
"type": "n8n-nodes-base.n8n",
"position": [
-1280,
64
],
"parameters": {
"resource": "audit",
"operation": "generate",
"requestOptions": {},
"additionalOptions": {}
},
"credentials": {
"n8nApi": {
"id": "l5HQ7xfVxL2LP772",
"name": "n8n account"
}
},
"typeVersion": 1
},
{
"id": "fec96d1e-e966-4fcc-9900-9a8d32211008",
"name": "格式化审计报告 - 法语",
"type": "n8n-nodes-base.code",
"position": [
-256,
-32
],
"parameters": {
"jsCode": "// === INPUTS / CONFIG ===\nconst data = $('Generate a security audit').first().json;\nconst project = $('Set Config Variables').first().json.project_name || 'n8n';\nconst date = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' });\nconst baseUrl = $('Set Config Variables').first().json.server_url?.replace(/\\/$/, '') || 'https://n8n.example.com';\n\n\n// ✅ Récupère les résultats de la loop précédente, peu importe la structure ou la connexion\nlet workflowExecutions = [];\n\ntry {\n const allInputs = $input.all();\n for (const i of allInputs) {\n const j = i.json;\n if (Array.isArray(j) && j[0]?.workflowId) {\n workflowExecutions.push(...j); // cas d’un tableau complet\n } else if (j?.workflowId) {\n workflowExecutions.push(j); // cas d’un item unique\n }\n }\n\n console.log('Detected workflows:', workflowExecutions.length);\n if (workflowExecutions.length > 0) {\n console.log('First workflow execution:', workflowExecutions[0]);\n }\n} catch (e) {\n console.log('⚠️ Impossible de lire les exécutions:', e.message);\n}\n\n\n// Si le premier élément est lui-même un tableau, on le \"déplie\"\nif (Array.isArray(workflowExecutions[0])) {\n workflowExecutions = workflowExecutions[0];\n}\n\n\n// === STATS ===\nlet totalSections = 0;\nlet totalLocations = 0;\nlet totalCommunity = 0;\nlet totalCredentials = 0;\nlet totalNodes = 0;\nlet nodeTypeStats = {};\nlet sectionsPerReport = {};\nconst uniqueCredentials = new Set(); // ✅ Nouvel ensemble pour compter les credentials uniques\n\n// === HELPERS ===\nfunction getNodeIcon(nodeType) {\n const iconMap = {\n 'n8n-nodes-base.code': '💻',\n 'n8n-nodes-base.function': '⚡',\n 'n8n-nodes-base.httpRequest': '🌐',\n 'n8n-nodes-base.executeCommand': '⌨️',\n 'n8n-nodes-base.ssh': '🔐',\n 'n8n-nodes-base.ftp': '📁',\n 'n8n-nodes-base.webhook': '🪝'\n };\n return iconMap[nodeType] || '⚙️';\n}\n\nfunction groupNodesByWorkflow(locations) {\n const workflows = {};\n for (const loc of locations) {\n if (loc.kind === 'node' && loc.workflowId) {\n const id = loc.workflowId;\n if (!workflows[id]) {\n workflows[id] = { name: loc.workflowName || 'Workflow inconnu', id, nodes: [] };\n }\n workflows[id].nodes.push(loc);\n }\n }\n return workflows;\n}\n\nconsole.log('=== DEBUG WORKFLOW MATCH ===');\nconsole.log('Sample from workflowExecutions:', workflowExecutions[0]);\n\n// Extraire tous les IDs connus dans ta loop\nconst loopIds = new Set(workflowExecutions.map(w => String(w.workflowId).trim()));\nconsole.log('IDs connus dans loop:', Array.from(loopIds));\n\n// On va aussi log les IDs qu’on essaye de trouver\nconst exampleIds = [];\nfor (const [title, report] of Object.entries(data)) {\n if (!report.sections?.length) continue;\n for (const section of report.sections) {\n if (!section.location) continue;\n for (const loc of section.location) {\n if (loc.workflowId) exampleIds.push(loc.workflowId);\n }\n }\n}\nconsole.log('IDs trouvés dans rapport:', Array.from(new Set(exampleIds.map(i => String(i).trim()))));\n\n\n// ✅ Trouver les infos d'exécution correspondantes pour un workflowId\nfunction findWorkflowRun(workflowId) {\n if (!workflowExecutions || !Array.isArray(workflowExecutions)) return null;\n const normalizedId = String(workflowId).trim();\n\n const match = workflowExecutions.find(w => {\n const loopId = String(w.workflowId).trim();\n if (loopId === normalizedId) {\n console.log(`✅ MATCH trouvé: ${loopId}`);\n return true;\n }\n return false;\n });\n\n if (!match) console.log(`❌ Aucun match pour: ${normalizedId}`);\n return match || null;\n}\n\n\n// === FORMATTERS ===\nfunction formatSection(section) {\n if (!section) return '';\n totalSections++;\n if (!sectionsPerReport.current) sectionsPerReport.current = 0;\n sectionsPerReport.current++;\n if (section.location?.length) totalLocations += section.location.length;\n\n let md = `### 🔹 ${section.title}\\n${section.description || ''}\\n\\n`;\n if (section.location?.length) {\n const nodes = section.location.filter(l => l.kind === 'node');\n const others = section.location.filter(l => l.kind !== 'node');\n\n for (const loc of others) {\n if (loc.kind === 'community') {\n totalCommunity++;\n const pkg = loc.packageUrl ? `[${loc.nodeType}](${loc.packageUrl})` : loc.nodeType;\n md += `- 🧩 ${pkg}\\n`;\n } else if (loc.kind === 'credential') {\n totalCredentials++;\n const credName = loc.name?.trim() || 'Credential sans nom';\n uniqueCredentials.add(credName);\n md += `- 🔑 ${credName}\\n`;\n }\n }\n\n if (nodes.length > 0) {\n totalNodes += nodes.length;\n for (const n of nodes) {\n const t = n.nodeType || 'unknown';\n nodeTypeStats[t] = (nodeTypeStats[t] || 0) + 1;\n }\n const workflows = groupNodesByWorkflow(nodes);\n for (const [id, wf] of Object.entries(workflows)) {\n const link = `${baseUrl}/workflow/${id}`;\n md += `\\n**📋 Workflow : [${wf.name}](${link})**\\n`;\n for (const n of wf.nodes) {\n const icon = getNodeIcon(n.nodeType);\n md += ` - ${icon} ${n.nodeName || n.nodeType || 'Node inconnu'}\\n`;\n }\n }\n }\n }\n if (section.recommendation) md += `\\n> 💡 ${section.recommendation}\\n`;\n return md + '\\n';\n}\n\nfunction formatSectionHTML(section) {\n if (!section) return '';\n let html = `<h3>🔹 ${section.title}</h3>`;\n if (section.description) html += `<p>${section.description}</p>`;\n\n if (section.location?.length) {\n const nodes = section.location.filter(l => l.kind === 'node');\n const others = section.location.filter(l => l.kind !== 'node');\n html += `<ul>`;\n\n for (const loc of others) {\n if (loc.kind === 'community') {\n const pkg = loc.packageUrl\n ? `<a href=\"${loc.packageUrl}\" target=\"_blank\">${loc.nodeType}</a>`\n : loc.nodeType;\n html += `<li>🧩 ${pkg}</li>`;\n } else if (loc.kind === 'credential') {\n const credName = loc.name?.trim() || 'Credential sans nom';\n uniqueCredentials.add(credName);\n html += `<li>🔑 ${credName}</li>`;\n }\n }\n html += `</ul>`;\n\n if (nodes.length > 0) {\n const workflows = groupNodesByWorkflow(nodes);\n for (const [id, wf] of Object.entries(workflows)) {\n const link = `${baseUrl}/workflow/${id}`;\n const run = findWorkflowRun(id);\n\n // ✅ Construction du badge de statut et des horaires\n let runInfo = '';\nif (run) {\n const color = run.status === 'success' ? '🟢' : '🔴';\n\n const started = run.startedAt && run.startedAt !== 'NoRun'\n ? new Date(run.startedAt).toLocaleString('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false\n }).replace(',', '').replace(/\\//g, '-')\n : 'N/A';\n\n const stopped = run.stoppedAt && run.stoppedAt !== 'NoRun'\n ? new Date(run.stoppedAt).toLocaleString('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false\n }).replace(',', '').replace(/\\//g, '-')\n : 'N/A';\n\n runInfo = ` <small style=\"color:#888;\">${color} (${started} → ${stopped})</small>`;\n} else {\n runInfo = ` <small style=\"color:#aaa;\">⚪ Non exécuté récemment</small>`;\n }\n\n html += `<h4>📋 Workflow : <a href=\"${link}\" target=\"_blank\">${wf.name}</a>${runInfo}</h4><ul>`;\n for (const n of wf.nodes) {\n const icon = getNodeIcon(n.nodeType);\n html += `<li>${icon} ${n.nodeName || n.nodeType || 'Node inconnu'}</li>`;\n }\n html += `</ul>`;\n }\n }\n }\n\n if (section.recommendation) html += `<blockquote>💡 ${section.recommendation}</blockquote>`;\n return html;\n}\n\n// === SECURITY SETTINGS ===\nfunction formatSecuritySettings(settings) {\n if (!settings) return { md: '', html: '' };\n let md = `### 🔹 Security settings\\nVoici les paramètres de sécurité actuels de cette instance :\\n\\n`;\n for (const [cat, items] of Object.entries(settings)) {\n md += `**${cat.charAt(0).toUpperCase() + cat.slice(1)}:**\\n`;\n for (const [k, v] of Object.entries(items)) md += `- ${k}: ${v}\\n`;\n md += `\\n`;\n }\n\n let html = `<h3>🔹 Security settings</h3><ul>`;\n for (const [cat, items] of Object.entries(settings)) {\n for (const [k, v] of Object.entries(items)) html += `<li><b>${cat}</b> ${k}: ${v}</li>`;\n }\n html += `</ul>`;\n return { md, html };\n}\n\n// === BUILD REPORT ===\nlet markdown = `# 🔒 Rapport d'audit de sécurité ${project}\\n\\n**Date :** ${date}\\n\\n`;\nlet html = `<h1>🔒 Rapport d'audit de sécurité ${project}</h1><p><strong>Date :</strong> ${date}</p>`;\n\nconst reportIcons = {\n 'Credentials Risk Report': '🔐',\n 'Nodes Risk Report': '🧩',\n 'Instance Risk Report': '🏢'\n};\n\nfor (const [title, report] of Object.entries(data)) {\n sectionsPerReport.current = 0;\n const icon = reportIcons[title] || '📊';\n markdown += `## ${icon} ${title}\\n\\n`;\n html += `<h2>${icon} ${title}</h2>`;\n if (!report.sections?.length) continue;\n for (const section of report.sections) {\n if (section.settings) {\n const sec = formatSecuritySettings(section.settings);\n markdown += sec.md;\n html += sec.html;\n } else {\n markdown += formatSection(section);\n html += formatSectionHTML(section);\n }\n }\n sectionsPerReport[title] = sectionsPerReport.current;\n}\n\n// === SYNTHÈSE ===\nlet riskLevel = '🟩 Faible';\nlet riskEmoji = '🟩';\nlet riskText = 'Faible';\nif (totalCredentials > 5 || totalNodes > 10 || totalCommunity > 3) {\n riskLevel = '🟥 Élevé'; riskEmoji = '🟥'; riskText = 'Élevé';\n} else if (totalCredentials > 2 || totalNodes > 5 || totalCommunity > 1) {\n riskLevel = '🟧 Modéré'; riskEmoji = '🟧'; riskText = 'Modéré';\n}\n\n// === Breakdown des types de nodes ===\nlet nodeTypeBreakdown = '';\nlet nodeTypeBreakdownHTML = '';\nif (Object.keys(nodeTypeStats).length > 0) {\n const sortedNodeTypes = Object.entries(nodeTypeStats).sort((a, b) => b[1] - a[1]);\n nodeTypeBreakdown = '\\n**Détail par type de node :**\\n';\n nodeTypeBreakdownHTML = '<li><b>Détail par type de node :</b><ul>';\n for (const [nodeType, count] of sortedNodeTypes) {\n const icon = getNodeIcon(nodeType);\n const simpleName = nodeType.replace('n8n-nodes-base.', '');\n nodeTypeBreakdown += ` - ${icon} ${simpleName}: ${count}\\n`;\n nodeTypeBreakdownHTML += `<li>${icon} ${simpleName}: ${count}</li>`;\n }\n nodeTypeBreakdownHTML += '</ul></li>';\n}\n\n// === SYNTHÈSE (avec unique credentials) ===\nconst uniqueCredCount = uniqueCredentials.size;\n\nconst summaryText =\n`## 📊 Synthèse de l'audit\\n\n- Credentials concernés : ${totalCredentials} (${uniqueCredCount} uniques)\n- Nodes concernés : ${totalNodes}${nodeTypeBreakdown}\n- Community nodes : ${totalCommunity}\n- **Niveau de risque global : ${riskLevel}**\\n\\n`;\n\nmarkdown = markdown.replace(/^# 🔒.*?\\n\\n/, `$&${summaryText}`);\n\nconst emailSubject = `🔒 Rapport d'audit ${project} – Risque ${riskEmoji} ${riskText}`;\n\n// === HTML synthèse ===\nhtml = html.replace(\n /<p><strong>Date :<\\/strong>.*?<\\/p>/,\n `$&<h2>📊 Synthèse</h2>\n <ul>\n <li><b>Credentials concernés :</b> ${totalCredentials} (${uniqueCredCount} uniques)</li>\n <li><b>Nodes concernés :</b> ${totalNodes}</li>\n ${nodeTypeBreakdownHTML}\n <li><b>Community nodes :</b> ${totalCommunity}</li>\n <li><b>Niveau de risque global :</b> ${riskLevel}</li>\n </ul>`\n);\n\nreturn [{\n json: {\n markdown,\n html,\n project,\n date,\n emailSubject,\n riskLevel: riskText,\n riskEmoji,\n uniqueCredentials: uniqueCredCount\n }\n}];"
},
"executeOnce": true,
"typeVersion": 2
},
{
"id": "2f877b5d-0bcc-43c1-804b-6ab835c98373",
"name": "格式化审计报告 - 英语",
"type": "n8n-nodes-base.code",
"position": [
-256,
112
],
"parameters": {
"jsCode": "// === INPUTS / CONFIG ===\nconst data = $('Generate a security audit').first().json;\nconst project = $('Set Config Variables').first().json.project_name || 'n8n';\nconst date = new Date().toLocaleString('en-GB', { timeZone: 'Europe/Paris' });\nconst baseUrl = $('Set Config Variables').first().json.server_url?.replace(/\\/$/, '') || 'https://n8n.example.com';\n\n\n// ✅ Retrieve results from previous loop, regardless of structure or connection\nlet workflowExecutions = [];\n\ntry {\n const allInputs = $input.all();\n for (const i of allInputs) {\n const j = i.json;\n if (Array.isArray(j) && j[0]?.workflowId) {\n workflowExecutions.push(...j); // case of a full array\n } else if (j?.workflowId) {\n workflowExecutions.push(j); // case of a single item\n }\n }\n\n console.log('Detected workflows:', workflowExecutions.length);\n if (workflowExecutions.length > 0) {\n console.log('First workflow execution:', workflowExecutions[0]);\n }\n} catch (e) {\n console.log('⚠️ Unable to read executions:', e.message);\n}\n\n\n// If the first element is itself an array, flatten it\nif (Array.isArray(workflowExecutions[0])) {\n workflowExecutions = workflowExecutions[0];\n}\n\n\n// === STATS ===\nlet totalSections = 0;\nlet totalLocations = 0;\nlet totalCommunity = 0;\nlet totalCredentials = 0;\nlet totalNodes = 0;\nlet nodeTypeStats = {};\nlet sectionsPerReport = {};\nconst uniqueCredentials = new Set(); // ✅ New set to count unique credentials\n\n// === HELPERS ===\nfunction getNodeIcon(nodeType) {\n const iconMap = {\n 'n8n-nodes-base.code': '💻',\n 'n8n-nodes-base.function': '⚡',\n 'n8n-nodes-base.httpRequest': '🌐',\n 'n8n-nodes-base.executeCommand': '⌨️',\n 'n8n-nodes-base.ssh': '🔐',\n 'n8n-nodes-base.ftp': '📁',\n 'n8n-nodes-base.webhook': '🪝'\n };\n return iconMap[nodeType] || '⚙️';\n}\n\nfunction groupNodesByWorkflow(locations) {\n const workflows = {};\n for (const loc of locations) {\n if (loc.kind === 'node' && loc.workflowId) {\n const id = loc.workflowId;\n if (!workflows[id]) {\n workflows[id] = { name: loc.workflowName || 'Unknown workflow', id, nodes: [] };\n }\n workflows[id].nodes.push(loc);\n }\n }\n return workflows;\n}\n\nconsole.log('=== DEBUG WORKFLOW MATCH ===');\nconsole.log('Sample from workflowExecutions:', workflowExecutions[0]);\n\n// Extract all known IDs from loop\nconst loopIds = new Set(workflowExecutions.map(w => String(w.workflowId).trim()));\nconsole.log('Known IDs in loop:', Array.from(loopIds));\n\n// Also log IDs found in the report\nconst exampleIds = [];\nfor (const [title, report] of Object.entries(data)) {\n if (!report.sections?.length) continue;\n for (const section of report.sections) {\n if (!section.location) continue;\n for (const loc of section.location) {\n if (loc.workflowId) exampleIds.push(loc.workflowId);\n }\n }\n}\nconsole.log('IDs found in report:', Array.from(new Set(exampleIds.map(i => String(i).trim()))));\n\n\n// ✅ Find matching execution info for a given workflowId\nfunction findWorkflowRun(workflowId) {\n if (!workflowExecutions || !Array.isArray(workflowExecutions)) return null;\n const normalizedId = String(workflowId).trim();\n\n const match = workflowExecutions.find(w => {\n const loopId = String(w.workflowId).trim();\n if (loopId === normalizedId) {\n console.log(`✅ MATCH found: ${loopId}`);\n return true;\n }\n return false;\n });\n\n if (!match) console.log(`❌ No match for: ${normalizedId}`);\n return match || null;\n}\n\n\n// === FORMATTERS ===\nfunction formatSection(section) {\n if (!section) return '';\n totalSections++;\n if (!sectionsPerReport.current) sectionsPerReport.current = 0;\n sectionsPerReport.current++;\n if (section.location?.length) totalLocations += section.location.length;\n\n let md = `### 🔹 ${section.title}\\n${section.description || ''}\\n\\n`;\n if (section.location?.length) {\n const nodes = section.location.filter(l => l.kind === 'node');\n const others = section.location.filter(l => l.kind !== 'node');\n\n for (const loc of others) {\n if (loc.kind === 'community') {\n totalCommunity++;\n const pkg = loc.packageUrl ? `[${loc.nodeType}](${loc.packageUrl})` : loc.nodeType;\n md += `- 🧩 ${pkg}\\n`;\n } else if (loc.kind === 'credential') {\n totalCredentials++;\n const credName = loc.name?.trim() || 'Unnamed credential';\n uniqueCredentials.add(credName);\n md += `- 🔑 ${credName}\\n`;\n }\n }\n\n if (nodes.length > 0) {\n totalNodes += nodes.length;\n for (const n of nodes) {\n const t = n.nodeType || 'unknown';\n nodeTypeStats[t] = (nodeTypeStats[t] || 0) + 1;\n }\n const workflows = groupNodesByWorkflow(nodes);\n for (const [id, wf] of Object.entries(workflows)) {\n const link = `${baseUrl}/workflow/${id}`;\n md += `\\n**📋 Workflow: [${wf.name}](${link})**\\n`;\n for (const n of wf.nodes) {\n const icon = getNodeIcon(n.nodeType);\n md += ` - ${icon} ${n.nodeName || n.nodeType || 'Unknown node'}\\n`;\n }\n }\n }\n }\n if (section.recommendation) md += `\\n> 💡 ${section.recommendation}\\n`;\n return md + '\\n';\n}\n\nfunction formatSectionHTML(section) {\n if (!section) return '';\n let html = `<h3>🔹 ${section.title}</h3>`;\n if (section.description) html += `<p>${section.description}</p>`;\n\n if (section.location?.length) {\n const nodes = section.location.filter(l => l.kind === 'node');\n const others = section.location.filter(l => l.kind !== 'node');\n html += `<ul>`;\n\n for (const loc of others) {\n if (loc.kind === 'community') {\n const pkg = loc.packageUrl\n ? `<a href=\"${loc.packageUrl}\" target=\"_blank\">${loc.nodeType}</a>`\n : loc.nodeType;\n html += `<li>🧩 ${pkg}</li>`;\n } else if (loc.kind === 'credential') {\n const credName = loc.name?.trim() || 'Unnamed credential';\n uniqueCredentials.add(credName);\n html += `<li>🔑 ${credName}</li>`;\n }\n }\n html += `</ul>`;\n\n if (nodes.length > 0) {\n const workflows = groupNodesByWorkflow(nodes);\n for (const [id, wf] of Object.entries(workflows)) {\n const link = `${baseUrl}/workflow/${id}`;\n const run = findWorkflowRun(id);\n\n // ✅ Build status badge and timing info\nlet runInfo = '';\nif (run) {\n const color = run.status === 'success' ? '🟢' : '🔴';\n\n const started = run.startedAt && run.startedAt !== 'NoRun'\n ? new Date(run.startedAt).toLocaleString('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false\n }).replace(',', '').replace(/\\//g, '-')\n : 'N/A';\n\n const stopped = run.stoppedAt && run.stoppedAt !== 'NoRun'\n ? new Date(run.stoppedAt).toLocaleString('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false\n }).replace(',', '').replace(/\\//g, '-')\n : 'N/A';\n\n runInfo = ` <small style=\"color:#888;\">${color} (${started} → ${stopped})</small>`;\n} else {\n runInfo = ` <small style=\"color:#aaa;\">⚪ Not executed recently</small>`;\n}\n\n\n html += `<h4>📋 Workflow: <a href=\"${link}\" target=\"_blank\">${wf.name}</a>${runInfo}</h4><ul>`;\n for (const n of wf.nodes) {\n const icon = getNodeIcon(n.nodeType);\n html += `<li>${icon} ${n.nodeName || n.nodeType || 'Unknown node'}</li>`;\n }\n html += `</ul>`;\n }\n }\n }\n\n if (section.recommendation) html += `<blockquote>💡 ${section.recommendation}</blockquote>`;\n return html;\n}\n\n// === SECURITY SETTINGS ===\nfunction formatSecuritySettings(settings) {\n if (!settings) return { md: '', html: '' };\n let md = `### 🔹 Security settings\\nHere are the current security settings for this instance:\\n\\n`;\n for (const [cat, items] of Object.entries(settings)) {\n md += `**${cat.charAt(0).toUpperCase() + cat.slice(1)}:**\\n`;\n for (const [k, v] of Object.entries(items)) md += `- ${k}: ${v}\\n`;\n md += `\\n`;\n }\n\n let html = `<h3>🔹 Security settings</h3><ul>`;\n for (const [cat, items] of Object.entries(settings)) {\n for (const [k, v] of Object.entries(items)) html += `<li><b>${cat}</b> ${k}: ${v}</li>`;\n }\n html += `</ul>`;\n return { md, html };\n}\n\n// === BUILD REPORT ===\nlet markdown = `# 🔒 Security Audit Report ${project}\\n\\n**Date:** ${date}\\n\\n`;\nlet html = `<h1>🔒 Security Audit Report ${project}</h1><p><strong>Date:</strong> ${date}</p>`;\n\nconst reportIcons = {\n 'Credentials Risk Report': '🔐',\n 'Nodes Risk Report': '🧩',\n 'Instance Risk Report': '🏢'\n};\n\nfor (const [title, report] of Object.entries(data)) {\n sectionsPerReport.current = 0;\n const icon = reportIcons[title] || '📊';\n markdown += `## ${icon} ${title}\\n\\n`;\n html += `<h2>${icon} ${title}</h2>`;\n if (!report.sections?.length) continue;\n for (const section of report.sections) {\n if (section.settings) {\n const sec = formatSecuritySettings(section.settings);\n markdown += sec.md;\n html += sec.html;\n } else {\n markdown += formatSection(section);\n html += formatSectionHTML(section);\n }\n }\n sectionsPerReport[title] = sectionsPerReport.current;\n}\n\n// === SUMMARY ===\nlet riskLevel = '🟩 Low';\nlet riskEmoji = '🟩';\nlet riskText = 'Low';\nif (totalCredentials > 5 || totalNodes > 10 || totalCommunity > 3) {\n riskLevel = '🟥 High'; riskEmoji = '🟥'; riskText = 'High';\n} else if (totalCredentials > 2 || totalNodes > 5 || totalCommunity > 1) {\n riskLevel = '🟧 Moderate'; riskEmoji = '🟧'; riskText = 'Moderate';\n}\n\n// === Node type breakdown ===\nlet nodeTypeBreakdown = '';\nlet nodeTypeBreakdownHTML = '';\nif (Object.keys(nodeTypeStats).length > 0) {\n const sortedNodeTypes = Object.entries(nodeTypeStats).sort((a, b) => b[1] - a[1]);\n nodeTypeBreakdown = '\\n**Breakdown by node type:**\\n';\n nodeTypeBreakdownHTML = '<li><b>Breakdown by node type:</b><ul>';\n for (const [nodeType, count] of sortedNodeTypes) {\n const icon = getNodeIcon(nodeType);\n const simpleName = nodeType.replace('n8n-nodes-base.', '');\n nodeTypeBreakdown += ` - ${icon} ${simpleName}: ${count}\\n`;\n nodeTypeBreakdownHTML += `<li>${icon} ${simpleName}: ${count}</li>`;\n }\n nodeTypeBreakdownHTML += '</ul></li>';\n}\n\n// === SUMMARY (with unique credentials) ===\nconst uniqueCredCount = uniqueCredentials.size;\n\nconst summaryText =\n`## 📊 Audit Summary\\n\n- Credentials involved: ${totalCredentials} (${uniqueCredCount} unique)\n- Nodes involved: ${totalNodes}${nodeTypeBreakdown}\n- Community nodes: ${totalCommunity}\n- **Overall risk level: ${riskLevel}**\\n\\n`;\n\nmarkdown = markdown.replace(/^# 🔒.*?\\n\\n/, `$&${summaryText}`);\n\nconst emailSubject = `🔒 Audit Report ${project} – Risk ${riskEmoji} ${riskText}`;\n\n// === HTML summary ===\nhtml = html.replace(\n /<p><strong>Date:?<\\/strong>.*?<\\/p>/,\n `$&<h2>📊 Summary</h2>\n <ul>\n <li><b>Credentials involved:</b> ${totalCredentials} (${uniqueCredCount} unique)</li>\n <li><b>Nodes involved:</b> ${totalNodes}</li>\n ${nodeTypeBreakdownHTML}\n <li><b>Community nodes:</b> ${totalCommunity}</li>\n <li><b>Overall risk level:</b> ${riskLevel}</li>\n </ul>`\n);\n\nreturn [{\n json: {\n markdown,\n html,\n project,\n date,\n emailSubject,\n riskLevel: riskText,\n riskEmoji,\n uniqueCredentials: uniqueCredCount\n }\n}];"
},
"executeOnce": true,
"typeVersion": 2
},
{
"id": "27bdda1e-fda2-4657-949b-5b231bae8d2a",
"name": "过滤重复的 WorkflowID",
"type": "n8n-nodes-base.code",
"position": [
-1024,
64
],
"parameters": {
"jsCode": "// Récupération du tableau d'entrée\nconst locations = $json[\"Nodes Risk Report\"].sections[0].location;\n\n// Vérification\nif (!Array.isArray(locations)) {\n throw new Error(\"Le champ 'location' est introuvable ou n'est pas un tableau.\");\n}\n\n// Extraction des workflowId uniques\nconst uniqueWorkflows = Array.from(\n new Map(\n locations\n .filter(loc => loc.workflowId) // garde seulement ceux avec un ID\n .map(loc => [loc.workflowId, loc]) // on mappe workflowId → objet complet\n ).values()\n);\n\n// Sortie d'un item par workflow\nreturn uniqueWorkflows.map(wf => ({\n json: {\n workflowId: wf.workflowId,\n workflowName: wf.workflowName || 'Nom inconnu',\n nodeCount: locations.filter(l => l.workflowId === wf.workflowId).length,\n nodeTypes: Array.from(new Set(\n locations\n .filter(l => l.workflowId === wf.workflowId)\n .map(l => l.nodeType)\n )),\n }\n}));"
},
"typeVersion": 2
},
{
"id": "7c05f33f-787a-4ea8-9dcb-07349e6a1e46",
"name": "获取最后执行记录",
"type": "n8n-nodes-base.n8n",
"position": [
-768,
64
],
"parameters": {
"limit": 1,
"filters": {
"workflowId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.workflowId }}"
}
},
"options": {
"activeWorkflows": false
},
"resource": "execution",
"requestOptions": {}
},
"credentials": {
"n8nApi": {
"id": "l5HQ7xfVxL2LP772",
"name": "n8n account"
}
},
"typeVersion": 1,
"alwaysOutputData": true
},
{
"id": "f2f6087e-70cc-4d18-988d-01fa1d7f7b79",
"name": "如果语言",
"type": "n8n-nodes-base.if",
"position": [
-528,
64
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "d0f639a0-be97-4dd4-a701-b35f85ccde45",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $('Set Config Variables').first().json.Language }}",
"rightValue": "=FR"
}
]
},
"looseTypeValidation": true
},
"typeVersion": 2.2
},
{
"id": "7e88ca33-bf1f-4976-9b4d-2e98fc50e698",
"name": "便签",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1872,
-432
],
"parameters": {
"color": 4,
"height": 656,
"content": "## 1️⃣ 计划触发器(每周)"
},
"typeVersion": 1
},
{
"id": "6eeaba97-94de-4369-b0ce-8e962fa6a198",
"name": "便签1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1616,
-432
],
"parameters": {
"color": 4,
"height": 656,
"content": "## 2️⃣ 设置配置变量"
},
"typeVersion": 1
},
{
"id": "d1624f02-332d-4b7b-89ef-aceb3d3acd43",
"name": "便签2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1360,
-432
],
"parameters": {
"color": 5,
"height": 656,
"content": "## 3️⃣ 生成安全审计"
},
"typeVersion": 1
},
{
"id": "7c1f7a9f-f0eb-4daa-99e2-592ac12036f8",
"name": "便签说明4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1104,
-432
],
"parameters": {
"color": 3,
"height": 656,
"content": "## 4️⃣ 过滤重复的 WorkflowID"
},
"typeVersion": 1
},
{
"id": "78d9ef00-71f9-47c5-b5da-1b6d0db98242",
"name": "便签说明5",
"type": "n8n-nodes-base.stickyNote",
"position": [
-848,
-432
],
"parameters": {
"color": 5,
"height": 656,
"content": "## 5️⃣ 获取最后执行记录"
},
"typeVersion": 1
},
{
"id": "ae647e6b-448b-4fde-89f2-63b412822c18",
"name": "便签 6",
"type": "n8n-nodes-base.stickyNote",
"position": [
-592,
-432
],
"parameters": {
"color": 3,
"height": 656,
"content": "## 6️⃣ 如果语言"
},
"typeVersion": 1
},
{
"id": "45a3aaa6-9f14-4aec-80f0-22cca126e6a7",
"name": "便签 7",
"type": "n8n-nodes-base.stickyNote",
"position": [
-336,
-432
],
"parameters": {
"color": 3,
"height": 656,
"content": "## 7️⃣ 格式化审计报告"
},
"typeVersion": 1
},
{
"id": "caf87b67-3966-4114-a9ef-24f32ff8d78f",
"name": "## 为什么选择 4o 模型?👆",
"type": "n8n-nodes-base.stickyNote",
"position": [
-80,
-432
],
"parameters": {
"color": 5,
"height": 656,
"content": "## 8️⃣ 发送 Gmail(HTML)"
},
"typeVersion": 1
},
{
"id": "2ad8307a-b770-46b4-b8ea-478b490e80b3",
"name": "便签3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1872,
-960
],
"parameters": {
"width": 320,
"height": 496,
"content": "## 🎯 快速设置清单"
},
"typeVersion": 1
},
{
"id": "3b9d3299-d93e-4e5b-9f16-66cc40607402",
"name": "便签 9",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1536,
-960
],
"parameters": {
"width": 304,
"height": 496,
"content": "## 💡 配置提示"
},
"typeVersion": 1
},
{
"id": "d992360f-2eb3-4f03-9fbb-769ab9f56e52",
"name": "便签10",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1216,
-960
],
"parameters": {
"width": 352,
"height": 496,
"content": "## 📊 报告内容"
},
"typeVersion": 1
},
{
"id": "efcad0e0-a50b-4e0f-95dc-5bc221b8cdd2",
"name": "便签11",
"type": "n8n-nodes-base.stickyNote",
"position": [
-848,
-960
],
"parameters": {
"width": 272,
"height": 496,
"content": "## ⚠️ 重要说明"
},
"typeVersion": 1
},
{
"id": "5d147ec8-9000-41c1-b67f-650666c23f95",
"name": "便签12",
"type": "n8n-nodes-base.stickyNote",
"position": [
-560,
-960
],
"parameters": {
"width": 288,
"height": 496,
"content": "## 🐛 故障排除"
},
"typeVersion": 1
},
{
"id": "4ca7fc9a-1bd6-4514-b72f-23474abac940",
"name": "便签13",
"type": "n8n-nodes-base.stickyNote",
"position": [
-256,
-960
],
"parameters": {
"width": 416,
"height": 496,
"content": "## 📈 预期结果"
},
"typeVersion": 1
}
],
"pinData": {},
"connections": {
"If Language": {
"main": [
[
{
"node": "Format Audit Report - FR",
"type": "main",
"index": 0
}
],
[
{
"node": "Format Audit Report - EN",
"type": "main",
"index": 0
}
]
]
},
"Get last executions": {
"main": [
[
{
"node": "If Language",
"type": "main",
"index": 0
}
]
]
},
"Set Config Variables": {
"main": [
[
{
"node": "Generate a security audit",
"type": "main",
"index": 0
}
]
]
},
"Format Audit Report - EN": {
"main": [
[
{
"node": "Send Gmail (HTML)",
"type": "main",
"index": 0
}
]
]
},
"Format Audit Report - FR": {
"main": [
[
{
"node": "Send Gmail (HTML)",
"type": "main",
"index": 0
}
]
]
},
"Generate a security audit": {
"main": [
[
{
"node": "Filter duplicate WorkflowID",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger (Weekly)": {
"main": [
[
{
"node": "Set Config Variables",
"type": "main",
"index": 0
}
]
]
},
"Filter duplicate WorkflowID": {
"main": [
[
{
"node": "Get last executions",
"type": "main",
"index": 0
}
]
]
}
}
}常见问题
如何使用这个工作流?
复制上方的 JSON 配置代码,在您的 n8n 实例中创建新工作流并选择「从 JSON 导入」,粘贴配置后根据需要修改凭证设置即可。
这个工作流适合什么场景?
高级 - 安全运维
需要付费吗?
本工作流完全免费,您可以直接导入使用。但请注意,工作流中使用的第三方服务(如 OpenAI API)可能需要您自行付费。
相关工作流推荐
GRC - 安全问卷自动填写
自动化安全问卷回复:GPT-4o和Google Sheets
If
Set
Code
+7
11 节点Adnan Tariq
安全运维
欺诈预订检测器:使用AI识别可疑旅行交易
欺诈预订检测器:使用Google Gemini识别可疑旅行交易
If
Set
Code
+8
20 节点Oneclick AI Squad
安全运维
AI分类处理的安全中心警报
使用GPT-4.1 Mini分类AWS安全配置错误并向Gmail发送警报
If
Set
Code
+6
12 节点Calistus Christian
安全运维
工作日日志记录
AI工时表生成器 - 集成Gmail、日历和GitHub到Google表格
If
Set
Code
+11
31 节点Luka Zivkovic
个人效率
博客与LinkedIn内容创作自动化
使用OpenAI和Replicate AI图像自动化博客与LinkedIn内容创作
If
Set
Code
+9
28 节点Sagar Budhathoki
内容创作
使用GPT-4o、Brevo和NocoDB自动化销售外联和响应管理
使用GPT-4o、Brevo和NocoDB自动化销售外联和响应管理
If
Set
Code
+13
77 节点Evervise
客户培育
工作流信息
难度等级
高级
节点数量23
分类1
节点类型7
作者
Matthieu
@neon8n8 years exp in IT over different kind of task, from support to sysadmin. Automation learner and builder. I will share simple workflow that can be used in various situation.
外部链接
在 n8n.io 查看 →
分享此工作流