{
  "id": "L7BVeftD2mXPNrHN",
  "name": "Audit n8n workflows for hardcoded secrets and commit redacted snapshots to GitHub",
  "tags": [],
  "nodes": [
    {
      "id": "d6ae0f58-8c19-4f22-ba7d-032d46b47907",
      "name": "Daily Audit Schedule",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        1648,
        400
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 3
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "ec39f031-632a-47d2-8b37-5bc76839d287",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "notes": "Fill in all six values before the first run",
      "position": [
        1840,
        400
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "repo-owner",
              "name": "repoOwner",
              "type": "string",
              "value": "YOUR_GITHUB_USERNAME"
            },
            {
              "id": "repo-name",
              "name": "repoName",
              "type": "string",
              "value": "YOUR_BACKUP_REPO"
            },
            {
              "id": "repo-path",
              "name": "repoPath",
              "type": "string",
              "value": "workflows/"
            },
            {
              "id": "tg-chat",
              "name": "telegramChatId",
              "type": "string",
              "value": "YOUR_TELEGRAM_CHAT_ID"
            },
            {
              "id": "sev-gate",
              "name": "alertSeverityThreshold",
              "type": "string",
              "value": "medium"
            },
            {
              "id": "redact-flag",
              "name": "redactSecretValues",
              "type": "string",
              "value": "true"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "91bb58a8-22f0-443e-8a0e-c9e4e0701fe2",
      "name": "Get All Workflows",
      "type": "n8n-nodes-base.n8n",
      "notes": "Reads every workflow on this instance",
      "position": [
        2032,
        400
      ],
      "parameters": {
        "filters": {},
        "requestOptions": {}
      },
      "typeVersion": 1
    },
    {
      "id": "5e9ab470-547a-4efe-9bc7-3794a2093e60",
      "name": "Scan and Redact",
      "type": "n8n-nodes-base.code",
      "notes": "Rule set lives here, edit RULES to add patterns",
      "position": [
        2304,
        400
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configuration').first().json;\nconst items = $input.all();\nconst redactValues = String(cfg.redactSecretValues) !== 'false';\n\nconst RULES = [\n  { id: 'private_key', sev: 'critical', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },\n  { id: 'aws_key', sev: 'critical', re: /\\bAKIA[0-9A-Z]{16}/g },\n  { id: 'jwt', sev: 'critical', re: /\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}/g },\n  { id: 'bearer_token', sev: 'critical', re: /\\bBearer\\s+[A-Za-z0-9._~+\\/-]{20,}/gi },\n  { id: 'slack_token', sev: 'critical', re: /\\bxox[abprs]-[A-Za-z0-9-]{10,}/g },\n  { id: 'github_pat', sev: 'critical', re: /\\bgh[pousr]_[A-Za-z0-9]{30,}/g },\n  { id: 'openai_key', sev: 'critical', re: /\\bsk-[A-Za-z0-9_-]{20,}/g },\n  { id: 'url_secret_param', sev: 'high', re: /([?&](?:key|token|api_?key|access_?token|auth|password|secret)=)([A-Za-z0-9._~+\\/=-]{8,})/gi },\n  { id: 'email_address', sev: 'medium', re: /\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g }\n];\n\nconst RANK = { critical: 4, high: 3, medium: 2, low: 1, none: 0 };\nconst out = [];\n\nfor (const item of items) {\n  const wf = JSON.parse(JSON.stringify(item.json));\n  const findings = [];\n  const add = (id, severity, location, occurrences) => findings.push({ id, severity, location, occurrences });\n\n  if (wf.meta && wf.meta.instanceId) { delete wf.meta.instanceId; add('instance_id', 'low', 'meta.instanceId', 1); }\n\n  if (wf.pinData && Object.keys(wf.pinData).length > 0) {\n    const pinnedCount = Object.keys(wf.pinData).length;\n    wf.pinData = {};\n    add('pinned_data', 'medium', 'pinData', pinnedCount);\n  }\n\n  let hookCount = 0;\n  let credCount = 0;\n  const nodeList = wf.nodes || [];\n  for (const nd of nodeList) {\n    if (nd.webhookId) { delete nd.webhookId; hookCount++; }\n    if (nd.credentials) {\n      for (const key of Object.keys(nd.credentials)) {\n        if (nd.credentials[key] && nd.credentials[key].id) { nd.credentials[key].id = ''; credCount++; }\n      }\n    }\n  }\n  if (hookCount > 0) add('webhook_id', 'low', 'nodes[].webhookId', hookCount);\n  if (credCount > 0) add('credential_id', 'medium', 'nodes[].credentials', credCount);\n\n  let text = JSON.stringify(wf, null, 2);\n  for (const rule of RULES) {\n    const hits = text.match(rule.re);\n    if (hits && hits.length > 0) {\n      add(rule.id, rule.sev, 'node parameters', hits.length);\n      if (redactValues) {\n        if (rule.id === 'url_secret_param') {\n          text = text.replace(rule.re, '$1[REDACTED]');\n        } else {\n          text = text.replace(rule.re, '[REDACTED_' + rule.id.toUpperCase() + ']');\n        }\n      }\n    }\n  }\n\n  let top = 'none';\n  for (const f of findings) { if (RANK[f.severity] > RANK[top]) top = f.severity; }\n\n  const slug = String(wf.name || 'unnamed').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);\n\n  out.push({ json: {\n    workflowId: String(wf.id || 'unknown'),\n    workflowName: wf.name || 'unnamed',\n    filePath: String(cfg.repoPath || 'workflows/') + String(wf.id || 'unknown') + '-' + slug + '.json',\n    redactedJson: text,\n    findings: findings,\n    findingCount: findings.length,\n    highestSeverity: top\n  }});\n}\n\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "11e8cca6-51f8-4e49-868b-13f87fd0503c",
      "name": "Loop Over Workflows",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        2656,
        512
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "6912dcbf-6c0f-45bf-aeaf-5c85368f6a55",
      "name": "Check Snapshot Exists",
      "type": "n8n-nodes-base.github",
      "notes": "404 is expected for a new workflow",
      "onError": "continueRegularOutput",
      "position": [
        2880,
        544
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoOwner }}"
        },
        "filePath": "={{ $json.filePath }}",
        "resource": "file",
        "operation": "get",
        "repository": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoName }}"
        },
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "typeVersion": 1.1,
      "alwaysOutputData": true
    },
    {
      "id": "8cf04ee5-04e4-49a9-8bec-15d863686660",
      "name": "Snapshot Exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        3120,
        544
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-content",
              "operator": {
                "type": "string",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.content }}",
              "rightValue": ""
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "c48be97a-e86b-40d8-ab3a-4ee47240268b",
      "name": "Update Redacted Snapshot",
      "type": "n8n-nodes-base.github",
      "onError": "continueRegularOutput",
      "position": [
        3376,
        528
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoOwner }}"
        },
        "filePath": "={{ $('Loop Over Workflows').item.json.filePath }}",
        "resource": "file",
        "operation": "edit",
        "repository": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoName }}"
        },
        "fileContent": "={{ $('Loop Over Workflows').item.json.redactedJson }}",
        "commitMessage": "=audit: update {{ $('Loop Over Workflows').item.json.workflowName }} ({{ $('Loop Over Workflows').item.json.findingCount }} findings)"
      },
      "typeVersion": 1.1
    },
    {
      "id": "fedbf91c-61f4-40bd-bab3-051c01dfd6f2",
      "name": "Create Redacted Snapshot",
      "type": "n8n-nodes-base.github",
      "onError": "continueRegularOutput",
      "position": [
        3520,
        736
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoOwner }}"
        },
        "filePath": "={{ $('Loop Over Workflows').item.json.filePath }}",
        "resource": "file",
        "repository": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Configuration').first().json.repoName }}"
        },
        "fileContent": "={{ $('Loop Over Workflows').item.json.redactedJson }}",
        "commitMessage": "=audit: add {{ $('Loop Over Workflows').item.json.workflowName }} ({{ $('Loop Over Workflows').item.json.findingCount }} findings)"
      },
      "typeVersion": 1.1
    },
    {
      "id": "2c32dc20-d4a8-42e1-8bbc-20703f4c58db",
      "name": "Build Audit Report",
      "type": "n8n-nodes-base.code",
      "position": [
        2800,
        64
      ],
      "parameters": {
        "jsCode": "const cfg = $('Configuration').first().json;\nconst scans = $('Scan and Redact').all().map(i => i.json);\n\nconst RANK = { critical: 4, high: 3, medium: 2, low: 1, none: 0 };\nconst gate = String(cfg.alertSeverityThreshold || 'medium').toLowerCase();\nconst gateRank = RANK[gate] || 2;\n\nlet totalFindings = 0;\nconst bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };\nconst offenders = [];\n\nfor (const s of scans) {\n  totalFindings += s.findingCount || 0;\n  const fl = s.findings || [];\n  for (const f of fl) {\n    if (bySeverity[f.severity] !== undefined) bySeverity[f.severity] += (f.occurrences || 1);\n  }\n  if ((s.findingCount || 0) > 0 && (RANK[s.highestSeverity] || 0) >= gateRank) offenders.push(s);\n}\n\noffenders.sort((a, b) => ((RANK[b.highestSeverity] || 0) - (RANK[a.highestSeverity] || 0)) || (b.findingCount - a.findingCount));\n\nconst esc = (t) => String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\nconst lines = [];\nlines.push('<b>n8n secret audit</b>');\nlines.push('Scanned ' + scans.length + ' workflows, ' + totalFindings + ' findings total');\nlines.push('critical ' + bySeverity.critical + ' / high ' + bySeverity.high + ' / medium ' + bySeverity.medium + ' / low ' + bySeverity.low);\n\nif (offenders.length === 0) {\n  lines.push('');\n  lines.push('Nothing at or above ' + gate + ' severity.');\n} else {\n  lines.push('');\n  lines.push('<b>At or above ' + gate + '</b>');\n  const shown = offenders.slice(0, 15);\n  for (const o of shown) {\n    const kinds = (o.findings || []).map(f => f.id).filter((v, i, a) => a.indexOf(v) === i).join(', ');\n    lines.push('');\n    lines.push('<b>' + esc(o.workflowName) + '</b> [' + o.highestSeverity + ']');\n    lines.push('<code>' + esc(o.workflowId) + '</code> ' + o.findingCount + ' findings');\n    lines.push(esc(kinds));\n  }\n  if (offenders.length > shown.length) {\n    lines.push('');\n    lines.push('and ' + (offenders.length - shown.length) + ' more workflows');\n  }\n}\n\nlet reportText = lines.join('\\n');\nif (reportText.length > 3800) reportText = reportText.slice(0, 3800) + '\\n...truncated';\n\nreturn [{ json: {\n  reportText: reportText,\n  totalWorkflows: scans.length,\n  totalFindings: totalFindings,\n  alertCount: offenders.length,\n  bySeverity: bySeverity\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b910ae41-77fe-44cd-af68-01e22982b630",
      "name": "Only If Something Found",
      "type": "n8n-nodes-base.filter",
      "notes": "A clean instance sends no message",
      "position": [
        2992,
        64
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-alerts",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.alertCount }}",
              "rightValue": 0
            }
          ]
        },
        "looseTypeValidation": true
      },
      "typeVersion": 2.3
    },
    {
      "id": "e9adf62c-ed2f-432c-85ac-b315e41b0be6",
      "name": "Send Audit Report",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3216,
        64
      ],
      "parameters": {
        "text": "={{ $json.reportText }}",
        "chatId": "={{ $('Configuration').first().json.telegramChatId }}",
        "additionalFields": {
          "parse_mode": "HTML",
          "appendAttribution": false,
          "disable_web_page_preview": true
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "1595a5e5-408b-4d2a-83b8-cfc1408d889c",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        592,
        -192
      ],
      "parameters": {
        "width": 960,
        "height": 1304,
        "content": "## Audit n8n workflows for hardcoded secrets and commit redacted snapshots to GitHub\n\nThis is not a plain backup. Every workflow on the instance is pulled through a rule set that looks for credentials, tokens, and personal data pasted directly into node parameters. Whatever it finds is reported to Telegram by severity, and the snapshot committed to GitHub has those values masked.\n\n## Who it is for\n\nTeams who version n8n workflows in a shared repository and do not want a token, a private key, or a client email address landing in git history where it cannot be removed. Also useful before publishing a workflow as a template, because the values that fail a marketplace review are the same ones flagged here.\n\n## How it works\n\n1. A daily schedule reads every workflow on the instance through the n8n API.\n2. Each workflow is stripped of four structural leaks: the instance ID, pinned test data, webhook IDs, and credential IDs.\n3. The remaining JSON is matched against a pattern rule set covering private keys, AWS keys, JWTs, Bearer tokens, Slack tokens, GitHub PATs, OpenAI keys, secrets in URL query strings, and email addresses.\n4. Every match becomes a finding with a severity. Matched values are masked in the copy that gets committed, never on the instance itself.\n5. Snapshots are committed one at a time, keyed on workflow ID so a rename does not orphan the file history.\n6. Findings from the whole run are aggregated into a single Telegram report. If nothing meets the configured threshold, no message is sent.\n\n## How to set up\n\n1. Attach an n8n API credential to Get All Workflows, a GitHub credential to the three GitHub nodes, and a Telegram credential to Send Audit Report.\n2. Open Configuration and set repoOwner, repoName, repoPath, and telegramChatId.\n3. Set alertSeverityThreshold to critical, high, medium, or low. Only workflows at or above that level reach the report.\n4. Leave redactSecretValues as true to mask matched values before committing. Set it to false to commit the structurally sanitised JSON with values intact.\n5. Adjust the schedule. It runs daily at 03:00 by default.\n\n## Requirements\n\nn8n API key with read access to workflows. GitHub credential with write access to the target repository. Telegram bot token and the chat ID to report into.\n\n## How to customize\n\nAdd or remove patterns in the RULES array inside Scan and Redact. Each rule is an id, a severity, and a regular expression, so extending it to your own token formats is a one line change. Swap the Telegram node for Slack, Google Chat, or email, since only reportText is consumed downstream. Raise alertSeverityThreshold to critical to stay quiet until something serious appears.\n\n## Note on restoring\n\nSnapshots are sanitised, so they suit audit and change history rather than a byte exact restore. Credential IDs and pinned data are removed by design, and with redaction on, matched secret values are masked."
      },
      "typeVersion": 1
    },
    {
      "id": "0d1f9d68-2059-41e9-a068-1bd76090dcef",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        112
      ],
      "parameters": {
        "color": 7,
        "width": 620,
        "height": 424,
        "content": "## 1. Pull every workflow\n\nThe n8n API node returns the full JSON of every workflow on the instance, including node parameters, credential references, and pinned data. Nothing is filtered yet.\n\nConfiguration holds the four values you need to fill in, plus the two switches that control reporting and redaction."
      },
      "typeVersion": 1
    },
    {
      "id": "b79b4997-134f-41df-9be9-a4cf55fea9e1",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2256,
        112
      ],
      "parameters": {
        "color": 7,
        "width": 220,
        "height": 424,
        "content": "## 2. Scan and sanitise\n\nStructural leaks are stripped first, then the pattern rules run over what remains.\n\nEdit the RULES array here to add your own token formats."
      },
      "typeVersion": 1
    },
    {
      "id": "794af3b8-09c8-4936-bd32-40152b8bb430",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2592,
        288
      ],
      "parameters": {
        "color": 7,
        "width": 1068,
        "height": 662,
        "content": "## 3. Commit the sanitised snapshot\n\nOne workflow per iteration. The file path is built from the workflow ID rather than its name, so renaming a workflow keeps its history in the same file instead of orphaning it.\n\n**Check Snapshot Exists** is expected to 404 on a workflow that has never been committed, which is why it continues on error. **Snapshot Exists?** routes to an update or a create accordingly, and the commit message carries that workflow's finding count."
      },
      "typeVersion": 1
    },
    {
      "id": "d4f3dd6b-31b8-42d0-85f5-bf57776b4c50",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2736,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 392,
        "content": "## 4. Report what was found\n\nOnce the loop drains, findings from every workflow are aggregated and grouped by severity. The filter drops the report when nothing meets the threshold, so a clean instance stays silent."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "441ce5d5-2973-42e2-849a-95c7d39e9c2b",
  "nodeGroups": [],
  "connections": {
    "Configuration": {
      "main": [
        [
          {
            "node": "Get All Workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scan and Redact": {
      "main": [
        [
          {
            "node": "Loop Over Workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Snapshot Exists?": {
      "main": [
        [
          {
            "node": "Update Redacted Snapshot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Redacted Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get All Workflows": {
      "main": [
        [
          {
            "node": "Scan and Redact",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Audit Report": {
      "main": [
        [
          {
            "node": "Only If Something Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Workflows": {
      "main": [
        [
          {
            "node": "Build Audit Report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Check Snapshot Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Audit Schedule": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Snapshot Exists": {
      "main": [
        [
          {
            "node": "Snapshot Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only If Something Found": {
      "main": [
        [
          {
            "node": "Send Audit Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Redacted Snapshot": {
      "main": [
        [
          {
            "node": "Loop Over Workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Redacted Snapshot": {
      "main": [
        [
          {
            "node": "Loop Over Workflows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}