@analitikisler/ai-tag-debugger 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/package.json +1 -1
- package/src/browser.js +13 -4
- package/src/checks.js +21 -2
- package/src/mcp.js +1 -1
- package/src/redact.js +58 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
- The audited site's own email address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, `gtm.elementText`), such as the contact link in a GTM `gtm.linkClick` push, is no longer reported as unhashed user data or redacted. An own-domain address anywhere else, and every named user data field (`email`, `user_data`, `em`, …), is still redacted and reported.
|
|
6
|
+
|
|
3
7
|
## 0.6.2 (2026-10-12)
|
|
4
8
|
|
|
5
9
|
Fixes from Bahadır's audit of analitikisler.com.
|
package/README.md
CHANGED
|
@@ -316,7 +316,7 @@ A hosted server, so you don't need a tunnel, is part of the [Analitik İşler](h
|
|
|
316
316
|
- The browser stays on the audited site and its subdomains, plus any `allowed_hosts`. It opens only `http` and `https` pages.
|
|
317
317
|
- The model is told that page content is data, never instructions. A prompt rule can't guarantee a model is never misled, so the navigation limits above contain what a misled model can do.
|
|
318
318
|
- The browser runs without your API keys and other secrets in its environment.
|
|
319
|
-
- **User data sent for ad matching is redacted** before it is stored in `capture.json` or shown in the report: emails, phone numbers, names, addresses and user ids in GA4 and Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user`, and the `user_data` the site pushes to the dataLayer for enhanced conversions. Any value that looks like an email address is redacted whatever its field is called. Each value becomes a marker such as `[redacted: 64 chars, looks hashed]`, and a value that wasn't hashed is reported as a personal data risk. `--keep-bodies` (`"keep_bodies": true`) stores requests as sent, for debugging.
|
|
319
|
+
- **User data sent for ad matching is redacted** before it is stored in `capture.json` or shown in the report: emails, phone numbers, names, addresses and user ids in GA4 and Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user`, and the `user_data` the site pushes to the dataLayer for enhanced conversions. Any value that looks like an email address is redacted whatever its field is called, except the audited site's own address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, …), such as a contact link in a GTM click event, which is not visitor data. Each value becomes a marker such as `[redacted: 64 chars, looks hashed]`, and a value that wasn't hashed is reported as a personal data risk. `--keep-bodies` (`"keep_bodies": true`) stores requests as sent, for debugging.
|
|
320
320
|
- Stored request bodies are capped at 32 KB per request and 2 MB per journey. Past that, the report says the body wasn't stored.
|
|
321
321
|
- Only run it against sites you own or are authorized to test.
|
|
322
322
|
- Hosting the HTTP server for other people? Also block private and cloud metadata address ranges at the network level (an egress firewall or an isolated network), as defense in depth.
|
package/README.tr.md
CHANGED
|
@@ -318,7 +318,7 @@ Tünele gerek kalmayan, hazır bir adresle barındırılan sunucu, [Analitik İ
|
|
|
318
318
|
- Tarayıcı test edilen sitede ve alt alan adlarında, ayrıca `allowed_hosts` içindeki adreslerde kalır. Sadece `http` ve `https` sayfalarını açar.
|
|
319
319
|
- Modele, sayfa içeriğinin talimat değil veri olduğu söylenir. Bir prompt kuralı, modelin hiçbir zaman yanıltılmayacağını garanti edemez; bu yüzden yukarıdaki gezinme sınırları, yanıltılmış bir modelin yapabileceklerini kısıtlar.
|
|
320
320
|
- Tarayıcı, ortamında API anahtarlarınız ve diğer gizli bilgiler olmadan çalışır.
|
|
321
|
-
- **Reklam eşleştirmesi için gönderilen kullanıcı verileri**, `capture.json` dosyasına kaydedilmeden ya da raporda gösterilmeden önce **gizlenir**: GA4 ve Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user` içindeki e-postalar, telefon numaraları, adlar, adresler ve kullanıcı kimlikleri ile sitenin enhanced conversions için dataLayer'a gönderdiği `user_data`. E-posta adresine benzeyen her değer, alanın adı ne olursa olsun gizlenir. Her değer `[redacted: 64 chars, looks hashed]` gibi bir işarete dönüşür; hash'lenmemiş bir değer kişisel veri riski olarak raporlanır. `--keep-bodies` (`"keep_bodies": true`) hata ayıklama için istekleri gönderildiği haliyle saklar.
|
|
321
|
+
- **Reklam eşleştirmesi için gönderilen kullanıcı verileri**, `capture.json` dosyasına kaydedilmeden ya da raporda gösterilmeden önce **gizlenir**: GA4 ve Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user` içindeki e-postalar, telefon numaraları, adlar, adresler ve kullanıcı kimlikleri ile sitenin enhanced conversions için dataLayer'a gönderdiği `user_data`. E-posta adresine benzeyen her değer, alanın adı ne olursa olsun gizlenir. Denetlenen sitenin kendi adresi bir `mailto:` bağlantısında ya da bir bağlantı alanında (`link_url`, `link_text`, `gtm.elementUrl`, …) geçiyorsa, örneğin bir GTM tıklama olayındaki iletişim bağlantısında, ziyaretçi verisi olmadığı için bu kuralın dışında kalır. Her değer `[redacted: 64 chars, looks hashed]` gibi bir işarete dönüşür; hash'lenmemiş bir değer kişisel veri riski olarak raporlanır. `--keep-bodies` (`"keep_bodies": true`) hata ayıklama için istekleri gönderildiği haliyle saklar.
|
|
322
322
|
- Saklanan istek gövdeleri istek başına 32 KB ve senaryo başına 2 MB ile sınırlıdır. Bu sınırın üstünde rapor, gövdenin saklanmadığını belirtir.
|
|
323
323
|
- Sadece size ait ya da test etme yetkiniz olan sitelerde çalıştırın.
|
|
324
324
|
- HTTP sunucusunu başkaları için mi barındırıyorsunuz? Ek bir güvenlik katmanı olarak özel ağ ve bulut metadata adres aralıklarını ağ seviyesinde de engelleyin (bir egress firewall ya da izole bir ağ ile).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/browser.js
CHANGED
|
@@ -22,6 +22,14 @@ export const VIEWPORTS = {
|
|
|
22
22
|
// log survives page navigations.
|
|
23
23
|
/** A page address without its hash, for matching page_location to navigations. */
|
|
24
24
|
const pageKey = (url) => String(url ?? "").split("#")[0];
|
|
25
|
+
/** The host of a URL, or null. */
|
|
26
|
+
const hostOf = (url) => {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(url).hostname || null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
25
33
|
|
|
26
34
|
const INIT_SCRIPT = `(() => {
|
|
27
35
|
const record = (entry) => {
|
|
@@ -354,9 +362,10 @@ export class AuditSession {
|
|
|
354
362
|
/** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
|
|
355
363
|
redactHit({ userFields, ...hit }) {
|
|
356
364
|
const found = { fields: [], plain: [] };
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
redactParams(
|
|
365
|
+
const site = hostOf(hit.params?.page_location) ?? hostOf(this.page.url());
|
|
366
|
+
const url = redactUrl(hit.url, found, site).url;
|
|
367
|
+
const params = redactParams(hit.params, found, site);
|
|
368
|
+
redactParams(userFields, found, site); // fields from the POST body that the hit doesn't keep
|
|
360
369
|
if (!found.fields.length) return hit;
|
|
361
370
|
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
362
371
|
return this.opts.keepBodies ? { ...hit, userData } : { ...hit, url, params, userData };
|
|
@@ -364,7 +373,7 @@ export class AuditSession {
|
|
|
364
373
|
|
|
365
374
|
/** A dataLayer entry with user data redacted, and userData: { fields, plain } when there was any. */
|
|
366
375
|
redactEntry(entry) {
|
|
367
|
-
const found = redactPush(entry.value);
|
|
376
|
+
const found = redactPush(entry.value, undefined, hostOf(this.page.url()));
|
|
368
377
|
if (!found.fields.length) return entry;
|
|
369
378
|
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
370
379
|
return this.opts.keepBodies ? { ...entry, userData } : { ...entry, value: found.value, userData };
|
package/src/checks.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// They run without any API call; the AI model only explains the results.
|
|
3
3
|
|
|
4
4
|
import { messages } from "./i18n.js";
|
|
5
|
+
import { isOwnAddress } from "./redact.js";
|
|
5
6
|
|
|
6
7
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
7
|
-
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/
|
|
8
|
+
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
8
9
|
const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -24,6 +25,24 @@ function pageOf(hit) {
|
|
|
24
25
|
return hit.params?.page_location ?? hit.pageUrl ?? "";
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Just before an address: a mailto: link or a link field (link_url=..., "link_text":"...").
|
|
29
|
+
const LINK_BEFORE = /(mailto:|\b(link_url|link_text|click_url|click_text)(=|":"))$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether text holds an email address that could be a visitor's. The site's own address in a
|
|
33
|
+
* mailto: link or a link field is a contact link it shows; anywhere else it still counts.
|
|
34
|
+
*/
|
|
35
|
+
function visitorEmailIn(text, hit) {
|
|
36
|
+
let site = null;
|
|
37
|
+
try {
|
|
38
|
+
site = new URL(hit.params?.page_location ?? hit.pageUrl).hostname;
|
|
39
|
+
} catch { /* no page address */ }
|
|
40
|
+
return [...text.matchAll(EMAIL)].some((m) => {
|
|
41
|
+
const link = LINK_BEFORE.test(text.slice(Math.max(0, m.index - 16), m.index));
|
|
42
|
+
return !(link && isOwnAddress(m[0].replace(/%40/i, "@"), site));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
function expectedIn(planEvent, journeyId) {
|
|
28
47
|
return !planEvent.journeys || planEvent.journeys.includes(journeyId);
|
|
29
48
|
}
|
|
@@ -172,7 +191,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
172
191
|
if (hit.userData?.plain?.length) {
|
|
173
192
|
piiSeen.add(key(hit.platform, hit.name));
|
|
174
193
|
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.piiUnhashed(hit.platform, hit.name, hit.userData.plain) });
|
|
175
|
-
} else if (
|
|
194
|
+
} else if (visitorEmailIn(haystack, hit)) {
|
|
176
195
|
piiSeen.add(key(hit.platform, hit.name));
|
|
177
196
|
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.pii(hit.platform, hit.name) });
|
|
178
197
|
}
|
package/src/mcp.js
CHANGED
|
@@ -22,7 +22,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
22
22
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
23
23
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
24
24
|
|
|
25
|
-
const VERSION = "0.6.
|
|
25
|
+
const VERSION = "0.6.3";
|
|
26
26
|
const MAX_OPEN_AUDITS = 4;
|
|
27
27
|
const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
|
|
28
28
|
const MAX_BODY_BYTES = 1024 * 1024;
|
package/src/redact.js
CHANGED
|
@@ -14,9 +14,48 @@ const USER_KEYS = new Set([
|
|
|
14
14
|
const PUSH_KEYS = new Set(["email", "email_address", "phone", "phone_number", "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone"]);
|
|
15
15
|
const USER_OBJECTS = /(^|\.)(user_data|user_provided_data|enhanced_conversion_data)(\.|$)/;
|
|
16
16
|
|
|
17
|
-
const EMAIL = /^[^\s@]+@[^\s
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const EMAIL = /^(mailto:)?[^\s@]+@([^\s@?]+\.[^\s@?]+)(\?.*)?$/i;
|
|
18
|
+
|
|
19
|
+
// Second levels that are shared under a country code (shop.com.tr, shop.co.uk), and hosting
|
|
20
|
+
// suffixes whose subdomains belong to different owners (shop.myshopify.com).
|
|
21
|
+
const SHARED_SECOND_LEVELS = new Set(["com", "co", "org", "net", "gov", "edu", "ac", "gen", "web", "bel", "k12", "or", "ne", "go"]);
|
|
22
|
+
const SHARED_SUFFIXES = ["myshopify.com", "github.io", "vercel.app", "pages.dev", "netlify.app", "herokuapp.com", "web.app", "firebaseapp.com", "appspot.com", "wixsite.com", "blogspot.com", "azurewebsites.net", "cloudfront.net", "workers.dev"];
|
|
23
|
+
|
|
24
|
+
/** "www.shop.example.com.tr" -> "example.com.tr", "a.shop.myshopify.com" -> "shop.myshopify.com": the part one owner controls. */
|
|
25
|
+
function siteOf(host) {
|
|
26
|
+
const labels = String(host).toLowerCase().replace(/\.$/, "").split(".");
|
|
27
|
+
const name = labels.join(".");
|
|
28
|
+
const shared = SHARED_SUFFIXES.find((s) => name === s || name.endsWith(`.${s}`));
|
|
29
|
+
let n = 2;
|
|
30
|
+
if (shared) n = shared.split(".").length + 1;
|
|
31
|
+
else if (labels.length >= 3 && labels.at(-1).length === 2 && SHARED_SECOND_LEVELS.has(labels.at(-2))) n = 3;
|
|
32
|
+
return labels.slice(-n).join(".");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether an email address is on the audited site's own domain, like hello@shop.com on a link at shop.com. */
|
|
36
|
+
export function isOwnAddress(email, siteHost) {
|
|
37
|
+
if (!siteHost) return false;
|
|
38
|
+
const domain = String(email).trim().match(EMAIL)?.[2];
|
|
39
|
+
return Boolean(domain) && siteOf(domain) === siteOf(siteHost);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fields that hold a link or its text. The site's own address there is a contact link it shows.
|
|
43
|
+
const LINK_KEYS = new Set(["link_url", "link_text", "click_url", "click_text"]);
|
|
44
|
+
const isLinkKey = (key) => {
|
|
45
|
+
const k = String(key ?? "").toLowerCase();
|
|
46
|
+
return LINK_KEYS.has(k.split(".").pop()) || /(^|\.)gtm\.element(url|text)$/.test(k);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A value that is an email address (or a mailto: link to one), whatever its field is called.
|
|
51
|
+
* The site's own address in a mailto: link or a link field is a contact link it shows, not
|
|
52
|
+
* visitor data. Anywhere else even an own-domain address could be a visitor's (staff, B2B).
|
|
53
|
+
*/
|
|
54
|
+
const isEmail = (v, site, key) => {
|
|
55
|
+
if (typeof v !== "string" || !EMAIL.test(v.trim())) return false;
|
|
56
|
+
const link = /^mailto:/i.test(v.trim()) || isLinkKey(key);
|
|
57
|
+
return !(link && isOwnAddress(v, site));
|
|
58
|
+
};
|
|
20
59
|
|
|
21
60
|
/** Whether a request parameter (or dotted JSON path) carries user data for ad matching. */
|
|
22
61
|
export function isUserDataKey(key) {
|
|
@@ -49,34 +88,34 @@ function note(found, key, value) {
|
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
/** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
|
|
52
|
-
function redactParamString(text, found) {
|
|
91
|
+
function redactParamString(text, found, site) {
|
|
53
92
|
const params = new URLSearchParams(text);
|
|
54
|
-
if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v))) return null;
|
|
93
|
+
if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v, site, k))) return null;
|
|
55
94
|
const out = new URLSearchParams();
|
|
56
|
-
for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v);
|
|
95
|
+
for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v);
|
|
57
96
|
return out.toString();
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/** Redacts user data in a URL's query. Returns the URL unchanged when there is none. */
|
|
61
|
-
export function redactUrl(url, found = emptyFound()) {
|
|
99
|
+
/** Redacts user data in a URL's query. Returns the URL unchanged when there is none. site: see isOwnAddress. */
|
|
100
|
+
export function redactUrl(url, found = emptyFound(), site = null) {
|
|
62
101
|
let u;
|
|
63
102
|
try {
|
|
64
103
|
u = new URL(url);
|
|
65
104
|
} catch {
|
|
66
105
|
return { url, ...found };
|
|
67
106
|
}
|
|
68
|
-
const query = redactParamString(u.search, found);
|
|
107
|
+
const query = redactParamString(u.search, found, site);
|
|
69
108
|
if (query === null) return { url, ...found };
|
|
70
109
|
u.search = query;
|
|
71
110
|
return { url: u.href, ...found };
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
function redactJson(value, path, found, isKey) {
|
|
75
|
-
if (Array.isArray(value)) return value.map((v) => redactJson(v, path, found, isKey));
|
|
113
|
+
function redactJson(value, path, found, isKey, site = null) {
|
|
114
|
+
if (Array.isArray(value)) return value.map((v) => redactJson(v, path, found, isKey, site));
|
|
76
115
|
if (value && typeof value === "object") {
|
|
77
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactJson(v, path ? `${path}.${k}` : k, found, isKey)]));
|
|
116
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactJson(v, path ? `${path}.${k}` : k, found, isKey, site)]));
|
|
78
117
|
}
|
|
79
|
-
if ((typeof value === "string" || typeof value === "number") && ((path && isKey(path)) || isEmail(value))) return note(found, path || "(value)", value);
|
|
118
|
+
if ((typeof value === "string" || typeof value === "number") && ((path && isKey(path)) || isEmail(value, site, path))) return note(found, path || "(value)", value);
|
|
80
119
|
return value;
|
|
81
120
|
}
|
|
82
121
|
|
|
@@ -110,21 +149,21 @@ export function redactBody(body, found = emptyFound()) {
|
|
|
110
149
|
return { text, ...found };
|
|
111
150
|
}
|
|
112
151
|
|
|
113
|
-
/** Redacts user-data entries in parsed hit parameters. */
|
|
114
|
-
export function redactParams(params, found = emptyFound()) {
|
|
152
|
+
/** Redacts user-data entries in parsed hit parameters. site: see isOwnAddress. */
|
|
153
|
+
export function redactParams(params, found = emptyFound(), site = null) {
|
|
115
154
|
const out = {};
|
|
116
|
-
for (const [k, v] of Object.entries(params ?? {})) out[k] = isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v;
|
|
155
|
+
for (const [k, v] of Object.entries(params ?? {})) out[k] = isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v;
|
|
117
156
|
return out;
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
/**
|
|
121
160
|
* Redacts user data in a dataLayer push (GTM objects or gtag.js arguments):
|
|
122
|
-
* email and phone fields, anything under user_data, and email-like values.
|
|
161
|
+
* email and phone fields, anything under user_data, and email-like values not on the site's own domain.
|
|
123
162
|
*/
|
|
124
|
-
export function redactPush(value, found = emptyFound()) {
|
|
163
|
+
export function redactPush(value, found = emptyFound(), site = null) {
|
|
125
164
|
// gtag("set", "user_data", {...}) has no user_data key: the objects after the name are the user data.
|
|
126
165
|
if (Array.isArray(value) && value.includes("user_data")) {
|
|
127
166
|
return { value: value.map((v) => (v && typeof v === "object" ? redactJson(v, "user_data", found, isPushUserKey) : v)), ...found };
|
|
128
167
|
}
|
|
129
|
-
return { value: redactJson(value, "", found, isPushUserKey), ...found };
|
|
168
|
+
return { value: redactJson(value, "", found, isPushUserKey, site), ...found };
|
|
130
169
|
}
|