n8n backup — hardcoded credential scrubbing
Owner: dev-coder (script + workflows) · sys-admin/infra-task (live n8n fixes) · secret-manager (rotation) Related: #4691, #4888, #5707, static-api-key-incident-rotation.md Added: 2026-07-30 · mojibake guard added 2026-08-01 (#4888) · jsCode credential-literal rule added 2026-08-06 (#5707)
What this covers
Two GitHub Actions export live n8n workflows as JSON and commit them:
| Workflow | Exports to | Schedule |
|---|---|---|
.github/workflows/n8n-backup.yml | n8n-workflows/ | daily 04:00 UTC |
.github/workflows/n8n-workflow-snapshot.yml | infra-src/n8n-workflows-export/ | daily 04:00 UTC |
n8n stores an HTTP Request node’s headers as plain {name, value} pairs in the workflow document.
So if anyone types a token straight into an Authorization header in the n8n UI instead of attaching
a credential, that token is in the exported JSON and gets committed on the next nightly run.
That is exactly what happened in #4691: Bearer sb_… service-role keys across 4 Supabase-targeting
workflows, a Bearer gho_… GitHub OAuth token, an opaque static key for the p24-auth CF Worker, and
scraped Cookie: session jars for the Kravag portal — all in git.
scripts/scrub-n8n-export.py is the gate that stops this recurring.
How the gate works
Both workflows now run, after the export and before git add:
python3 scripts/scrub-n8n-export.py --write --json /tmp/n8n-scrub-report.json <export-dir>/
python3 scripts/scrub-n8n-export.py --check <export-dir>/ # hard verification--writeredacts in place. The backup still commits — a redacted export is useful, a leaked token is not. This is deliberately not a blocking check: the live workflow is the thing that is wrong, and failing the nightly backup every night until someone fixes n8n would just mean no backups.--checkruns afterwards as a hard gate. If anything credential-shaped survived the scrub the job fails and nothing is committed.- When
--writefinds anything,n8n-backup.ymlfires the repo’s standard error path — a Discord embed onP24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URLplus a rolling GitHub issue titled[Infra] n8n — hardcoded credentials in live workflow nodes(commented on, not re-opened, each night).
What gets redacted
- Sensitive header values —
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-Api-Key,apikey,X-N8N-Api-Key,X-Auth-Token,token,secret, and friends (full list:SENSITIVE_HEADERSin the script). - Known secret shapes anywhere in the document — including inside
jsCode, request bodies and URLs:sb_secret_…,gh[pousr]_…,github_pat_…,sk-ant-…,sk-…, 3-segment JWTs,AGE-SECRET-KEY-1…,AIzaSy…,xox[baprs]-…,glpat-…,SG.…,re_…, Mailgunkey-<32hex>,AKIA…, Discord webhook tokens, andscheme://user:password@hostbasic-auth. - jsCode credential-named literals (#5707) — a quoted string literal bound (
=or:) to a credential-named identifier inside a Code node’sjsCode, e.g.const PROM_PASS = '<value>'. This is the class shapes (2) and headers (1) both miss: an opaque static credential (a Prometheus basic-auth password, a bespoke service secret) matches no known token format and is not a header, so before this rule it shipped into the nightly export undetected. The identifier is matched by its word-parts (PROM_PASS→pass,apiKey→api+key,authToken→token), never as a substring — socompass,passenger,tokenizerare not treated as credentials (_CRED_WORDSin the script). See What is deliberately left alone for the value guards that keep it from firing on templated / benign values.
What is deliberately left alone
- n8n expressions.
=Bearer {{ $credentials.token }}is already correct — the{{ … }}segments are preserved verbatim and only the literal text around them is scanned. Clobbering an expression would silently break the live workflow on a restore. credentialsblocks. They hold{id, name}references only, never values.- Obvious placeholders.
YOUR_API_KEY_HERE,CHANGEME,xxxxxxxx,REDACTED_…and similar stay readable — a reviewer should be able to tell an unconfigured node from a scrubbed one. - Short literals. Anything under 16 characters, so
application/json,Bearer,no-cacheandreturn=representationare never touched. - jsCode value guards (#5707). The credential-named-literal rule (item 3 above) fires only when
the assigned value actually looks like credential material. It skips: an n8n template
'{{ PROMETHEUS_BASIC_AUTH_PASSWORD }}'(the correct, hand-maintained canonical form — left byte-identical); stand-in placeholders (your-password-here,CHANGEME); a URL value (://, handled by the basic-auth rule instead); a whitespace-bearing string (UI copy / a sentence, not a token); and anything under 4 characters. This is what lets the rule scrub the resolved-value export (*_<id>.json) without touching the templated twin (mezmo-alert-router.json).
Properties worth knowing
- Idempotent. Re-scrubbing an already-scrubbed file finds nothing and writes nothing, so the nightly backup does not churn a diff every night.
- Minimal diff. The script patches the raw file text rather than re-serialising, so indentation,
key order, trailing newline and UTF-8 BOM are all preserved. One committed export was originally
written by PowerShell’s
ConvertTo-Json(4-space indent,": "separators); re-serialising it would have produced a ~10k-line diff around a 6-line redaction. If the textual patch cannot produce a provably clean document, the script falls back to a full re-serialisation — a big diff is acceptable, shipping a secret is not. - The report never contains a value. It carries file / workflow / node / header names and a rule
id only. That is load-bearing: the report is pasted into Actions logs, Discord embeds and GitHub
issue bodies.
test_report_never_contains_the_secretandtest_finding_dataclass_has_no_value_fieldguard it.
Responding to a finding
The nightly issue tells you which workflow and which node. The backup already protected git; what is left is the live misconfiguration.
-
Fix the source in n8n (role:
sys-admin/infra-taskon bms-4 — needs n8n API access). Open the named node, delete the hardcoded header, and attach a credential instead:- generic API keys → Header Auth credential (
httpHeaderAuth) - a supported vendor → the predefined credential type for it (
predefinedCredentialType)
Verify with a read-back through the n8n API that only that node changed.
- generic API keys → Header Auth credential (
-
Decide on rotation (role:
secret-manager). The token was in a git-tracked file, so anyone with repo read access could have taken it, and redacting the working tree does not rewrite history. Followstatic-api-key-incident-rotation.md. Rotation is asecret-managercall, never a dev-issue one — seesecret-manager-request.md. -
Re-run the backup (
gh workflow run n8n-backup.yml --repo radieu/p24-infra) and confirm the scrub reportsclean.
Restore semantics — read this before restoring from JSON
The committed JSON exports are a structure backup: nodes, connections, parameters and settings.
They are not, and were never meant to be, a credential backup. After the scrub, a node that relied on
a hardcoded header will import with REDACTED_CREDENTIAL in place of the token — by design.
Full-fidelity restore paths, in order of preference:
- The Postgres dump —
n8n-backup.ymlalso dumps the n8n database tos3://p24-infra/n8n/postgres-<date>.sql.gzon Wasabi. That is the real disaster-recovery artefact and it is unaffected by the scrub. - The n8n credential store — credentials live there, referenced by id from the workflow JSON.
- SOPS —
secrets/n8n-bms4.env.sopsfor the n8n instance’s own environment.
Mojibake signature guard (#4888)
A second, independent rule set runs alongside the credential gate in --check mode only.
It catches a different failure mode entirely: encoding corruption, not a secret leak.
What happened
An ad-hoc Windows/PowerShell edit session during the 4694 credential-hardening pass
round-tripped workflow JSON through a CP1250 (Windows Central European) decode of
already-UTF-8-encoded bytes. That mangles every non-ASCII character in the touched
workflows — reproduced exactly by "płatności".encode("utf-8").decode("cp1250"), which
yields "płatności". The corruption landed in the live n8n database and was then mirrored
into git by the nightly backup, undetected, because nothing was checking for it.
This is the same root-cause family as the documented Windows/PowerShell encoding hazard for
SOPS files (docs/playbooks/sops-windows-crlf.md, CLAUDE.md §Secrets) — an ASCII-locale
Windows tool touching UTF-8 content without an explicit encoding.
Why it’s a separate rule from SECRET_PATTERNS
- Different failure class. A credential finding means a token leaked; a mojibake hit means a string is corrupted. Confusing the two on a red CI run sends on-call down the wrong path (rotating a credential fixes nothing here — the live n8n string needs repair).
- Different remediation. Credential findings are fixed by converting the node to an n8n
credential reference. Mojibake hits are fixed by repairing the corrupted string directly
in the live n8n instance (sys-admin/infra-task role, not dev-coder) — see
format_mojibake_report()’s output andMojibakeHit.describe(). - Different report shape need. Keeping it a distinct type (
MojibakeHit, not aFindingwith a special rule id) keepsformat_report()— and its no-secret-value contract for credential findings — completely unchanged.
How it works
find_mojibake_signatures(text) -> list[str] scans raw file text for a fixed, exact
list of known CP1250-as-UTF-8 double-encoding byte signatures (MOJIBAKE_SIGNATURES in the
script) — currently Ĺ‚ (ł), Ĺ› (ś), Ä… (ą), Ĺ„ (ń), Ăł (ó). Each is the literal
substring produced by encoding the legitimate Polish character as UTF-8 and mis-decoding
those bytes as cp1250.
This is deliberately not a general non-ASCII heuristic. A broader rule (e.g. “any
non-ASCII byte sequence that looks unusual”) would false-positive on ordinary Polish/German
UTF-8 text — płatności, Kontoauszüge and similar are legitimate and correct, and must
never be flagged. Only the exact signatures already confirmed corrupted in the wild are
checked. Adding a new signature later: append it to MOJIBAKE_SIGNATURES with the same
“encode as UTF-8, decode as cp1250” derivation, plus a synthetic (non-real-value)
parametrized test case in test_scrub_n8n_export.py.
scan_mojibake(path) runs this per file during --check only (never --write — this
script does not attempt to repair the corruption, only detect it). A hit makes the
“Verify export is clean” step fail exactly like a credential finding does, and the
--json report gains a mojibake_signatures field (file + signature only, no
surrounding text — the same no-value contract as findings, even though these are
encoding artifacts rather than secrets).
Responding to a mojibake hit
- Do not rotate anything — this is not a credential leak.
- Identify the correct spelling by checking against the live source of truth (e.g. the
Pinbox24 category list for a
costCategoryfield) — sys-admin/infra-task role. - Repair the string directly in the live n8n instance, not just in git — git is a downstream mirror; the next nightly backup overwrites a git-only edit.
- Read the repaired value back from n8n to confirm the write took, then re-run
python3 scripts/scrub-n8n-export.py --check <export-dir>/and confirm it reports clean. - When editing n8n content from a Windows/PowerShell session, always write with explicit
UTF-8:
[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))— never rely on the console/locale default encoding.
Running it locally
# Report what is hardcoded, without writing (exit 1 if anything found)
python3 scripts/scrub-n8n-export.py --check n8n-workflows/
# Redact in place
python3 scripts/scrub-n8n-export.py --write n8n-workflows/
# Machine-readable report (no values) — also includes mojibake_signatures
python3 scripts/scrub-n8n-export.py --check --json /tmp/report.json n8n-workflows/
# Tests
python -m pytest scripts/tests/test_scrub_n8n_export.py -v--check runs both gates: the credential scan and the mojibake-signature scan (see
above). Either one finding fails the run.
Exit codes: 0 clean (or --write completed), 1 findings in --check mode (credential
gate or mojibake gate), 2 usage/IO error.
Adding a new detection rule
- Add the header name to
SENSITIVE_HEADERS, the shape toSECRET_PATTERNS, or — for a credential bound to a variable insidejsCode— the credential word to_CRED_WORDS, inscripts/scrub-n8n-export.py. - Add a case to the matching
@pytest.mark.parametrizeblock inscripts/tests/test_scrub_n8n_export.py— use a synthetic token, never a real one. - Run
python3 scripts/scrub-n8n-export.py --check n8n-workflows/against the committed corpus and confirm the new rule does not fire on expressions or benign values.
When adding a pattern, prefer a slightly over-eager rule to a slightly under-eager one: redacting an extra base64 blob costs a reviewer nothing, missing a token costs a rotation.