diff --git a/README.de.md b/README.de.md index b667f96..925cd41 100644 --- a/README.de.md +++ b/README.de.md @@ -3,7 +3,7 @@    - + 🇬🇧 [English version → README.md](README.md) @@ -167,17 +167,25 @@ Jede Aktion ist global in den Einstellungen steuerbar und per Shortcode übersch ### Maskierung sensibler Daten -BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Zwei-Pass-Sanitierung: +BreznFlow speichert nie das rohe Workflow-JSON. Vor dem Speichern läuft eine Drei-Pass-Sanitierung: **Pass 1 — String-Sanitierung:** Alle Strings durchlaufen `sanitize_text_field()`. Ausnahme: `jsCode`-Felder bleiben erhalten, werden aber mit `esc_html()` ausgegeben (nie ausgeführt). **Pass 2 — Secret-Erkennung:** - **URL-Parameter:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in Query-Strings → `[REDACTED]` +- **Generische `?key=`-URL-Parameter** *(1.0.4)*: werden nur redactet, wenn der gefangene Wert `looks_like_secret()` matcht — schließt die Google-API-Key-Lücke ohne False-Positives auf harmlosen Werten. - **Header-Werte:** Authorization, Bearer, X-API-Key und ähnliche Header-Namen in `{name, value}`-Paaren → Wert maskiert +- **Wert-Entropie-Fallback** *(1.0.4)*: `{name, value}`-Paare, deren Name die Allowlist nicht matcht, werden dennoch maskiert, wenn der Wert selbst secret-förmig aussieht — deckt Custom-Header (`X-App-Token`) und n8ns generisches `queryParameters.key`-Pattern ab. +- **Bekannte Vendor-Token** *(1.0.4)*: `looks_like_secret()` matcht `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — plus Längen- und Zeichenklassen-Entropie-Fallback sowie Pfad-/Whitespace-Denylist gegen False-Positives. - **Hochentropie-Bedingungen:** Werte in IF/Switch-Conditions, die UUID-Muster, Groß-/Kleinschreibung+Ziffern oder lange Strings ohne Leerzeichen matchen → per Entropie-Heuristik maskiert +- **Credential-Anzeigenamen** *(1.0.4)*: `credentials[].name` pro Node wird durch `[REDACTED]` ersetzt (die `id` bleibt — sie referenziert die n8n-DB und ist ohne den Server wertlos). -Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf — sichtbar in der Wizard-Vorschau (Schritt 3). +**Pass 3 — Identifizierende Metadaten** *(1.0.4)*: `meta.instanceId` wird geleert, damit Workflow-Exporte nicht mehr der ausgebenden n8n-Instanz zugeordnet werden können. + +**Optional — Tag-Entfernung** *(1.0.4)*: Wizard Schritt 3 bietet eine Opt-in-Checkbox zum Entfernen von Workflow-Tags. Tags sind oft harmlos (`production`, `v2`), manchmal aber identifizierend — der Publisher entscheidet pro Workflow. + +Ein **Maskierungs-Protokoll** zeichnet jedes maskierte Element mit Grund, Key und Hinweis auf. Schritt 3 zeigt es als ausklappbare Grund / Key / Hinweis-Tabelle, damit der Publisher vor Publish genau prüfen kann was veröffentlicht wird. --- diff --git a/README.md b/README.md index 4ddc48f..c1f5041 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@    - + 🇩🇪 [Deutsche Version → README.de.md](README.de.md) @@ -167,17 +167,25 @@ Each action can be toggled globally in settings and overridden per shortcode. ### Sensitive Data Masking -BreznFlow never stores raw workflow JSON. Before saving, a two-pass sanitization runs: +BreznFlow never stores raw workflow JSON. Before saving, a three-pass sanitization runs: **Pass 1 — String sanitization:** All string values pass through `sanitize_text_field()`. Exception: `jsCode` fields are preserved as-is but displayed with `esc_html()` (never executed). **Pass 2 — Secret detection:** - **URL parameters:** `api_key`, `token`, `secret`, `password`, `access_token`, `auth`, `client_secret` in query strings → `[REDACTED]` +- **Generic `?key=` URL params** *(1.0.4)*: redacted only when the captured value matches `looks_like_secret()` — closes the Google API key bypass without false-positives on harmless values. - **Header values:** Authorization, Bearer, X-API-Key and similar header names in `{name, value}` pairs → value masked +- **Value-entropy fallback** *(1.0.4)*: `{name, value}` pairs whose name does not match the allowlist are still masked when the value itself looks secret-shaped — covers custom headers (`X-App-Token`) and n8n's `queryParameters` generic-`key` pattern. +- **Known vendor tokens** *(1.0.4)*: `looks_like_secret()` matches `AIza…`, `sk-…`, `ghp_…`, `gho_…`, Slack `xox…`, `Bearer …` (JWT) — with a length+char-class entropy fallback and a path/whitespace denylist for false-positive control. - **High-entropy conditions:** Values in IF/Switch conditions that match UUID patterns, mixed-case+digits, or long strings without spaces → masked via entropy heuristic +- **Credential display names** *(1.0.4)*: `credentials[].name` is replaced with `[REDACTED]` per node (the credential `id` is retained — it references the n8n DB and is useless without the server). -A **mask log** records every masked item with the reason, key, and note — shown in the wizard's Step 3 preview. +**Pass 3 — Identifying metadata** *(1.0.4)*: `meta.instanceId` is cleared so workflow exports cannot be correlated to the originating n8n instance. + +**Optional — Tag removal** *(1.0.4)*: Wizard step 3 offers an opt-in checkbox to strip workflow tags. Tags are often innocuous (`production`, `v2`) but sometimes identifying — the publisher decides per workflow. + +A **mask log** records every masked item with reason, key, and note. Step 3 shows it as a collapsible Reason / Key / Note table so the publisher can review exactly what will be published. --- diff --git a/breznflow/assets/renderer.css b/breznflow/assets/renderer.css index 32adca9..1e2f03f 100644 --- a/breznflow/assets/renderer.css +++ b/breznflow/assets/renderer.css @@ -84,6 +84,11 @@ display: block; width: 100%; overflow: visible; + /* Claim all touch gestures on the diagram: single-finger pan, two-finger + pinch, double-tap. Trade-off: starting a touch on the SVG means page + scroll is blocked until the finger lifts. Page scroll around the + diagram (container margins) keeps default behavior. */ + touch-action: none; } /* Canvas (zoom/pan transform applied here) */ @@ -407,7 +412,7 @@ z-index: 5; } -.breznflow-minimap svg { display: block; } +.breznflow-minimap svg { display: block; touch-action: none; } /* Infobox node badges — interactive highlight */ .breznflow-infobox-node { diff --git a/breznflow/assets/renderer.js b/breznflow/assets/renderer.js index 41182cf..04ec92f 100644 --- a/breznflow/assets/renderer.js +++ b/breznflow/assets/renderer.js @@ -368,9 +368,16 @@ onNavigate(pos.x / fitScale, pos.y / fitScale); } - svg.addEventListener('mousedown', function(e) { dragging = true; navigate(e); e.stopPropagation(); }); - svg.addEventListener('mousemove', function(e) { if (dragging) navigate(e); }); - document.addEventListener('mouseup', function() { dragging = false; }); + svg.addEventListener('pointerdown', function(e) { + dragging = true; + navigate(e); + e.stopPropagation(); + if (e.cancelable) e.preventDefault(); + try { svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS quirk */ } + }, { passive: false }); + svg.addEventListener('pointermove', function(e) { if (dragging) navigate(e); }, { passive: false }); + svg.addEventListener('pointerup', function() { dragging = false; }); + svg.addEventListener('pointercancel', function() { dragging = false; }); return { el: wrap, @@ -1095,25 +1102,11 @@ self._applyTransform(); }, { passive: false }); - svg.addEventListener('pointerdown', function(e) { - const t = e.target; - const isBg = t === svg || - (t.getAttribute && (t.getAttribute('class') === 'breznflow-canvas' || t.getAttribute('class') === 'breznflow-connection-path')); - if (!isBg) return; - self.isPanning = true; - self.panStart = { x: e.clientX - self.tx, y: e.clientY - self.ty }; - svg.setPointerCapture(e.pointerId); - }); - - svg.addEventListener('pointermove', function(e) { - if (!self.isPanning) return; - self.tx = e.clientX - self.panStart.x; - self.ty = e.clientY - self.panStart.y; - self._applyTransform(); - }); - - svg.addEventListener('pointerup', function() { self.isPanning = false; }); - svg.addEventListener('pointercancel', function() { self.isPanning = false; }); + self._initPointerState(); + svg.addEventListener('pointerdown', function(e) { self._onPointerDown(e); }, { passive: false }); + svg.addEventListener('pointermove', function(e) { self._onPointerMove(e); }, { passive: false }); + svg.addEventListener('pointerup', function(e) { self._onPointerUp(e); }, { passive: false }); + svg.addEventListener('pointercancel', function(e) { self._onPointerUp(e); }, { passive: false }); svg.addEventListener('click', function(e) { const t = e.target; @@ -1132,6 +1125,156 @@ }); }; + BreznFlowRenderer.prototype._initPointerState = function() { + this._pointers = new Map(); + this._lastTap = { t: 0, x: 0, y: 0 }; + this._pinchStart = null; + }; + + BreznFlowRenderer.prototype._isBgTarget = function(t) { + if (!t) return false; + if (t === this._svg) return true; + if (!t.getAttribute) return false; + const cls = t.getAttribute('class'); + return cls === 'breznflow-canvas' || cls === 'breznflow-connection-path'; + }; + + BreznFlowRenderer.prototype._onPointerDown = function(e) { + const isBg = this._isBgTarget(e.target); + const rect = this._svg.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + + // Double-tap on empty canvas toggles zoom. Must run before pointer is + // added to the map so pointers.size===0 check is meaningful. + if (isBg && this._pointers.size === 0) { + const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + const dt = now - this._lastTap.t; + const dx = px - this._lastTap.x; + const dy = py - this._lastTap.y; + if (dt < 300 && (dx * dx + dy * dy) < 900) { + this._lastTap = { t: 0, x: 0, y: 0 }; + if (e.cancelable) e.preventDefault(); + this._handleDoubleTap(px, py); + return; + } + this._lastTap = { t: now, x: px, y: py }; + } + + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + // Non-bg targets (nodes, badges) fall through to default click handling. + if (!isBg) return; + if (e.cancelable) e.preventDefault(); + + try { this._svg.setPointerCapture(e.pointerId); } catch (_) { /* iOS Safari quirk */ } + + if (this._pointers.size === 1) { + this.isPanning = true; + this.panStart = { x: e.clientX - this.tx, y: e.clientY - this.ty }; + } else if (this._pointers.size === 2) { + this.isPanning = false; + this._pinchStart = this._computePinchState(); + } + }; + + BreznFlowRenderer.prototype._onPointerMove = function(e) { + if (!this._pointers.has(e.pointerId)) return; + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + if (this._pinchStart && this._pointers.size === 2) { + this._applyPinch(); + } else if (this.isPanning && this._pointers.size === 1) { + this.tx = e.clientX - this.panStart.x; + this.ty = e.clientY - this.panStart.y; + this._applyTransform(); + } + }; + + BreznFlowRenderer.prototype._onPointerUp = function(e) { + if (!this._pointers.delete(e.pointerId)) return; + + if (this._pinchStart && this._pointers.size < 2) { + this._pinchStart = null; + // Transitioning 2→1 fingers: re-seat pan-start on the remaining + // pointer so the surviving finger doesn't cause a jump. + if (this._pointers.size === 1) { + const remaining = this._pointers.values().next().value; + this.isPanning = true; + this.panStart = { x: remaining.x - this.tx, y: remaining.y - this.ty }; + } + } + + if (this._pointers.size === 0) { + this.isPanning = false; + } + }; + + BreznFlowRenderer.prototype._computePinchState = function() { + const pts = Array.from(this._pointers.values()); + const p1 = pts[0], p2 = pts[1]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return { + d: Math.max(1, Math.sqrt(dx * dx + dy * dy)), + cx: (p1.x + p2.x) / 2, + cy: (p1.y + p2.y) / 2, + tx: this.tx, + ty: this.ty, + userZoom: this.userZoom + }; + }; + + BreznFlowRenderer.prototype._applyPinch = function() { + if (!this._pinchStart) return; + const pts = Array.from(this._pointers.values()); + if (pts.length !== 2) return; + const p1 = pts[0], p2 = pts[1]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + const d = Math.max(1, Math.sqrt(dx * dx + dy * dy)); + const cx = (p1.x + p2.x) / 2; + const cy = (p1.y + p2.y) / 2; + + const ratio = d / this._pinchStart.d; + const newZoom = Math.max(MIN_SCALE * 100, Math.min(MAX_SCALE * 100, this._pinchStart.userZoom * ratio)); + const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1; + const oldS = baseScale * (this._pinchStart.userZoom / 100); + const newS = baseScale * (newZoom / 100); + if (oldS === 0) return; + + // Two moves compounded: (1) zoom anchored at the start-center in SVG + // coords, (2) pan by how far the current center drifted from the + // start-center. Order matters — zoom first, then translate. + const rect = this._svg.getBoundingClientRect(); + const sx = this._pinchStart.cx - rect.left; + const sy = this._pinchStart.cy - rect.top; + const dxCenter = cx - this._pinchStart.cx; + const dyCenter = cy - this._pinchStart.cy; + + this.tx = sx - (sx - this._pinchStart.tx) * (newS / oldS) + dxCenter; + this.ty = sy - (sy - this._pinchStart.ty) * (newS / oldS) + dyCenter; + this.userZoom = newZoom; + if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%'; + this._applyTransform(); + }; + + BreznFlowRenderer.prototype._handleDoubleTap = function(px, py) { + // Toggle 100↔200 %, zoom-anchored at tap point (identical math to + // wheel-zoom above — keeps the world point under the cursor fixed). + const newZoom = this.userZoom > 150 ? 100 : 200; + const baseScale = this.layout && this.layout.scale ? this.layout.scale : 1; + const oldS = baseScale * (this.userZoom / 100); + const newS = baseScale * (newZoom / 100); + if (oldS === 0) return; + + this.tx = px - (px - this.tx) * (newS / oldS); + this.ty = py - (py - this.ty) * (newS / oldS); + this.userZoom = newZoom; + if (this.zoomLabel) this.zoomLabel.textContent = Math.round(newZoom) + '%'; + this._applyTransform(); + }; + BreznFlowRenderer.prototype._toggleFullscreen = function(btn) { if (this._fsActive) { this._exitFullscreen(btn); diff --git a/breznflow/breznflow.php b/breznflow/breznflow.php index e6a205e..a1ac94b 100644 --- a/breznflow/breznflow.php +++ b/breznflow/breznflow.php @@ -8,7 +8,7 @@ * Plugin Name: BreznFlow * Plugin URI: https://breznflow.com/ * Description: Display n8n automation workflows with an interactive SVG diagram, node detail panel, and sensitive data masking. - * Version: 1.0.3 + * Version: 1.0.4 * Requires at least: 6.0 * Requires PHP: 8.0 * Author: NoSchmarrn.dev @@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'BREZNFLOW_VERSION', '1.0.3' ); +define( 'BREZNFLOW_VERSION', '1.0.4' ); define( 'BREZNFLOW_FILE', __FILE__ ); define( 'BREZNFLOW_DIR', plugin_dir_path( __FILE__ ) ); define( 'BREZNFLOW_URL', plugin_dir_url( __FILE__ ) ); diff --git a/breznflow/includes/Admin/WizardPage.php b/breznflow/includes/Admin/WizardPage.php index 833d5e4..ab4fcf5 100644 --- a/breznflow/includes/Admin/WizardPage.php +++ b/breznflow/includes/Admin/WizardPage.php @@ -404,6 +404,24 @@ class WizardPage { wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) ); } + // Opt-in tag removal. Runs against the already-sanitized stored JSON, + // so there is no risk of undoing the pass-1/2/3 masking from step 1. + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above + if ( ! empty( $_POST['breznflow_strip_tags'] ) ) { + $raw_json = get_post_meta( $post_id, '_breznflow_raw_json', true ); + $decoded = $raw_json ? json_decode( $raw_json, true ) : null; + if ( is_array( $decoded ) ) { + $mask_log_raw = get_post_meta( $post_id, '_breznflow_mask_log', true ); + $mask_log = $mask_log_raw ? json_decode( $mask_log_raw, true ) : array(); + if ( ! is_array( $mask_log ) ) { + $mask_log = array(); + } + $stripped = WorkflowSanitizer::strip_workflow_tags( $decoded, $mask_log ); + update_post_meta( $post_id, '_breznflow_raw_json', wp_slash( wp_json_encode( $stripped ) ) ); + update_post_meta( $post_id, '_breznflow_mask_log', wp_slash( wp_json_encode( $mask_log ) ) ); + } + } + wp_update_post( array( 'ID' => $post_id, diff --git a/breznflow/includes/Admin/views/wizard-step-3.php b/breznflow/includes/Admin/views/wizard-step-3.php index d6594d6..7fee8e7 100644 --- a/breznflow/includes/Admin/views/wizard-step-3.php +++ b/breznflow/includes/Admin/views/wizard-step-3.php @@ -30,15 +30,25 @@ $meta_zoom = (int) get_post_meta( $post_id, '_breznflow_default_zoom', true ) $zoom = $meta_zoom ? $meta_zoom : 100; $show_infobox = (int) get_post_meta( $post_id, '_breznflow_show_infobox', true ); -// Check for code nodes with jsCode. +// Check for code nodes with jsCode and collect workflow tag names. $has_code_nodes = false; +$workflow_tags = array(); if ( $raw_json ) { $data = json_decode( $raw_json, true ); - if ( is_array( $data ) && ! empty( $data['nodes'] ) ) { - foreach ( $data['nodes'] as $node ) { - if ( isset( $node['parameters']['jsCode'] ) ) { - $has_code_nodes = true; - break; + if ( is_array( $data ) ) { + if ( ! empty( $data['nodes'] ) ) { + foreach ( $data['nodes'] as $node ) { + if ( isset( $node['parameters']['jsCode'] ) ) { + $has_code_nodes = true; + break; + } + } + } + if ( ! empty( $data['tags'] ) && is_array( $data['tags'] ) ) { + foreach ( $data['tags'] as $tag ) { + if ( is_array( $tag ) && isset( $tag['name'] ) && '' !== $tag['name'] ) { + $workflow_tags[] = (string) $tag['name']; + } } } } @@ -142,6 +152,29 @@ $preview_theme = in_array( $saved_theme, $allowed_themes, true ) ? $saved_them ); ?>
+| + | + | + |
|---|---|---|
|
+ |
+ + |
+
+
+
+
+
+