cuonztech-privacy-chaos-gate 0.2.0 → 0.2.2
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/LICENSE +97 -0
- package/README.md +197 -30
- package/action.yml +187 -26
- package/dist/chunk-42D7PLVP.js +9146 -0
- package/dist/cli.js +96 -93
- package/dist/index.d.ts +710 -88
- package/dist/index.js +41 -12
- package/docs/BRAND-PILOT-EVIDENCE.md +41 -49
- package/package.json +6 -5
- package/dist/chunk-FN3QUFUZ.js +0 -36
- package/dist/chunk-FN3QUFUZ.js.map +0 -1
- package/dist/chunk-T5D6BUAC.js +0 -4514
- package/dist/chunk-T5D6BUAC.js.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/resolveGrant-6U6VG3DM.js +0 -230
- package/dist/resolveGrant-6U6VG3DM.js.map +0 -1
- package/docs/LIZENZ-KAUFFLUSS.md +0 -105
- package/docs/PREISMODELL.md +0 -63
package/dist/chunk-T5D6BUAC.js
DELETED
|
@@ -1,4514 +0,0 @@
|
|
|
1
|
-
// src/types/index.ts
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
var ChaosScenarioSchema = z.enum([
|
|
4
|
-
"happy-path-denied",
|
|
5
|
-
"happy-path-accepted",
|
|
6
|
-
"fast-click",
|
|
7
|
-
"state-manipulation",
|
|
8
|
-
"network-drop",
|
|
9
|
-
"consent-revoked",
|
|
10
|
-
"category-selective",
|
|
11
|
-
"consent-persistence",
|
|
12
|
-
"banner-ignored",
|
|
13
|
-
"subpage-spa",
|
|
14
|
-
"cmp-failure",
|
|
15
|
-
"embed-leaks",
|
|
16
|
-
"consent-mode-v2",
|
|
17
|
-
"duplicate-events",
|
|
18
|
-
"mobile-usability"
|
|
19
|
-
]);
|
|
20
|
-
var EvidenceStatusSchema = z.enum(["passed", "suspicious", "not-checkable", "not-applicable"]);
|
|
21
|
-
var StorageSnapshotSchema = z.object({
|
|
22
|
-
cookies: z.array(z.string()),
|
|
23
|
-
localStorageKeys: z.array(z.string()),
|
|
24
|
-
sessionStorageKeys: z.array(z.string()),
|
|
25
|
-
/** Consent-relevante Einträge mit Wert — zeigt erlaubt/abgelehnt */
|
|
26
|
-
localStorageEntries: z.array(z.object({ key: z.string(), value: z.string() })).optional()
|
|
27
|
-
});
|
|
28
|
-
var RequestEventSchema = z.object({
|
|
29
|
-
at: z.string(),
|
|
30
|
-
url: z.string(),
|
|
31
|
-
trackerName: z.string().optional(),
|
|
32
|
-
trackerCategory: z.string().optional(),
|
|
33
|
-
phase: z.enum([
|
|
34
|
-
"pre-consent",
|
|
35
|
-
"post-accept",
|
|
36
|
-
"post-reject",
|
|
37
|
-
"post-selection",
|
|
38
|
-
"post-revoke",
|
|
39
|
-
"post-wipe",
|
|
40
|
-
"post-reject-restart",
|
|
41
|
-
"other"
|
|
42
|
-
])
|
|
43
|
-
});
|
|
44
|
-
var EvidenceRecordSchema = z.object({
|
|
45
|
-
scenario: ChaosScenarioSchema,
|
|
46
|
-
status: EvidenceStatusSchema,
|
|
47
|
-
note: z.string(),
|
|
48
|
-
actionSequence: z.array(z.string()),
|
|
49
|
-
/** Zeitstempel je Aktion, parallel zu actionSequence */
|
|
50
|
-
actionTimeline: z.array(z.object({ at: z.string(), action: z.string() })).optional(),
|
|
51
|
-
requestTimeline: z.array(RequestEventSchema),
|
|
52
|
-
storageBefore: StorageSnapshotSchema,
|
|
53
|
-
storageAfter: StorageSnapshotSchema,
|
|
54
|
-
/** Storage-Höhepunkt nach Accept (vor Widerruf) für den echten Widerruf-Diff */
|
|
55
|
-
storageAfterAccept: StorageSnapshotSchema.optional(),
|
|
56
|
-
residualAfterRevoke: z.array(z.string()).optional(),
|
|
57
|
-
/** Pfade zu Screenshots (Auswahl, Banner, nach Aktion) */
|
|
58
|
-
screenshots: z.array(z.object({ label: z.string(), path: z.string(), at: z.string() })).optional(),
|
|
59
|
-
screenshotPath: z.string().optional(),
|
|
60
|
-
targetUrl: z.string(),
|
|
61
|
-
checkedAt: z.string()
|
|
62
|
-
});
|
|
63
|
-
var TrackerCategorySchema = z.enum([
|
|
64
|
-
"analytics",
|
|
65
|
-
"marketing",
|
|
66
|
-
"functional",
|
|
67
|
-
"unknown"
|
|
68
|
-
]);
|
|
69
|
-
var TrackerViolationSchema = z.object({
|
|
70
|
-
id: z.string(),
|
|
71
|
-
scenario: ChaosScenarioSchema,
|
|
72
|
-
trackerName: z.string(),
|
|
73
|
-
trackerCategory: TrackerCategorySchema,
|
|
74
|
-
requestUrl: z.string(),
|
|
75
|
-
/** Verletzte Regel — steuert Gate-Wortlaut und Empfehlungen */
|
|
76
|
-
ruleId: z.string().optional(),
|
|
77
|
-
method: z.string().default("GET"),
|
|
78
|
-
timestamp: z.string(),
|
|
79
|
-
domContext: z.object({
|
|
80
|
-
triggerElement: z.string().optional(),
|
|
81
|
-
consentBannerVisible: z.boolean().optional(),
|
|
82
|
-
scriptsFound: z.array(z.string()).optional()
|
|
83
|
-
}).optional(),
|
|
84
|
-
description: z.string()
|
|
85
|
-
});
|
|
86
|
-
var LeakReportSchema = z.object({
|
|
87
|
-
targetUrl: z.string().url(),
|
|
88
|
-
scannedAt: z.string(),
|
|
89
|
-
durationMs: z.number(),
|
|
90
|
-
totalViolations: z.number(),
|
|
91
|
-
violations: z.array(TrackerViolationSchema),
|
|
92
|
-
passedScenarios: z.array(ChaosScenarioSchema),
|
|
93
|
-
failedScenarios: z.array(ChaosScenarioSchema),
|
|
94
|
-
summary: z.string(),
|
|
95
|
-
evidence: z.array(EvidenceRecordSchema).optional()
|
|
96
|
-
});
|
|
97
|
-
var FilePatchSchema = z.object({
|
|
98
|
-
filePath: z.string(),
|
|
99
|
-
originalContent: z.string().optional(),
|
|
100
|
-
patchedContent: z.string(),
|
|
101
|
-
diff: z.string().optional(),
|
|
102
|
-
explanation: z.string()
|
|
103
|
-
});
|
|
104
|
-
var ReviewVerdictSchema = z.object({
|
|
105
|
-
passed: z.boolean(),
|
|
106
|
-
score: z.number().min(0).max(100),
|
|
107
|
-
comments: z.array(z.string()),
|
|
108
|
-
approvedPatches: z.array(FilePatchSchema),
|
|
109
|
-
rejectedPatches: z.array(FilePatchSchema)
|
|
110
|
-
});
|
|
111
|
-
var PullRequestResultSchema = z.object({
|
|
112
|
-
branchName: z.string(),
|
|
113
|
-
title: z.string(),
|
|
114
|
-
body: z.string(),
|
|
115
|
-
prNumber: z.number().optional(),
|
|
116
|
-
prUrl: z.string().optional(),
|
|
117
|
-
simulated: z.boolean().default(false)
|
|
118
|
-
});
|
|
119
|
-
var RemediationResultSchema = z.object({
|
|
120
|
-
success: z.boolean(),
|
|
121
|
-
patches: z.array(FilePatchSchema),
|
|
122
|
-
reviewVerdict: ReviewVerdictSchema.optional(),
|
|
123
|
-
commitHash: z.string().optional(),
|
|
124
|
-
commitMessage: z.string().optional(),
|
|
125
|
-
pullRequest: PullRequestResultSchema.optional(),
|
|
126
|
-
error: z.string().optional()
|
|
127
|
-
});
|
|
128
|
-
var GateConfigSchema = z.object({
|
|
129
|
-
targetUrl: z.string().url({ message: "Invalid target URL" }),
|
|
130
|
-
apiKey: z.string().optional(),
|
|
131
|
-
llmProvider: z.enum(["openai", "anthropic", "gemini"]).default("openai"),
|
|
132
|
-
llmModel: z.string().default("gpt-4o"),
|
|
133
|
-
autoFix: z.boolean().default(true),
|
|
134
|
-
failOnLeak: z.boolean().default(true),
|
|
135
|
-
reportOutput: z.string().default("reports/privacy-chaos-report.json"),
|
|
136
|
-
sourceDir: z.string().default("."),
|
|
137
|
-
gitCommit: z.boolean().default(false),
|
|
138
|
-
createPr: z.boolean().default(false),
|
|
139
|
-
githubToken: z.string().optional(),
|
|
140
|
-
baseBranch: z.string().optional(),
|
|
141
|
-
branchPrefix: z.string().default("fix/cpcg-privacy"),
|
|
142
|
-
/** Optional: nur diese Szenarien ausführen (leer = alle). Für Tests/CI gezielt. */
|
|
143
|
-
scenarios: z.array(ChaosScenarioSchema).optional(),
|
|
144
|
-
/** Demo-Lauf mit synthetischen Testdaten — Report wird gekennzeichnet */
|
|
145
|
-
demo: z.boolean().optional(),
|
|
146
|
-
/** Deluxe-PDF (Deckblatt + Evidence) nach dem Scan erzeugen */
|
|
147
|
-
reportPdf: z.boolean().optional(),
|
|
148
|
-
/** Kundenname für das PDF-Deckblatt */
|
|
149
|
-
clientName: z.string().optional(),
|
|
150
|
-
headless: z.boolean().default(true),
|
|
151
|
-
timeoutMs: z.number().default(3e4)
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
// src/config/index.ts
|
|
155
|
-
import { config as loadDotenv } from "dotenv";
|
|
156
|
-
loadDotenv();
|
|
157
|
-
function parseBoolean(val, defaultVal) {
|
|
158
|
-
if (typeof val === "boolean") return val;
|
|
159
|
-
if (typeof val === "string") {
|
|
160
|
-
const lower = val.toLowerCase().trim();
|
|
161
|
-
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
|
162
|
-
if (lower === "false" || lower === "0" || lower === "no") return false;
|
|
163
|
-
}
|
|
164
|
-
return defaultVal;
|
|
165
|
-
}
|
|
166
|
-
function resolveGateConfig(cliOptions) {
|
|
167
|
-
const merged = {
|
|
168
|
-
targetUrl: cliOptions.url || process.env.CPCG_TARGET_URL || process.env.STAGING_URL || "",
|
|
169
|
-
apiKey: cliOptions.apiKey || process.env.CPCG_LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY || "",
|
|
170
|
-
llmProvider: cliOptions.llmProvider || process.env.CPCG_LLM_PROVIDER || "openai",
|
|
171
|
-
llmModel: cliOptions.llmModel || process.env.CPCG_LLM_MODEL || "gpt-4o",
|
|
172
|
-
autoFix: parseBoolean(
|
|
173
|
-
cliOptions.autoFix ?? process.env.CPCG_AUTO_FIX,
|
|
174
|
-
true
|
|
175
|
-
),
|
|
176
|
-
failOnLeak: parseBoolean(
|
|
177
|
-
cliOptions.failOnLeak ?? process.env.CPCG_FAIL_ON_LEAK,
|
|
178
|
-
true
|
|
179
|
-
),
|
|
180
|
-
reportOutput: cliOptions.reportOutput || process.env.CPCG_REPORT_OUTPUT || "reports/privacy-chaos-report.json",
|
|
181
|
-
sourceDir: cliOptions.sourceDir || process.env.CPCG_SOURCE_DIR || ".",
|
|
182
|
-
gitCommit: parseBoolean(
|
|
183
|
-
cliOptions.gitCommit ?? process.env.CPCG_GIT_COMMIT,
|
|
184
|
-
false
|
|
185
|
-
),
|
|
186
|
-
createPr: parseBoolean(
|
|
187
|
-
cliOptions.createPr ?? process.env.CPCG_CREATE_PR,
|
|
188
|
-
false
|
|
189
|
-
),
|
|
190
|
-
reportPdf: parseBoolean(cliOptions.reportPdf ?? process.env.CPCG_REPORT_PDF, false),
|
|
191
|
-
demo: parseBoolean(cliOptions.demo ?? process.env.CPCG_DEMO, false),
|
|
192
|
-
clientName: cliOptions.client || process.env.CPCG_CLIENT_NAME || void 0,
|
|
193
|
-
githubToken: cliOptions.githubToken || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "",
|
|
194
|
-
branchPrefix: cliOptions.branchPrefix || process.env.CPCG_BRANCH_PREFIX || "fix/cpcg-privacy",
|
|
195
|
-
baseBranch: cliOptions.baseBranch || process.env.CPCG_BASE_BRANCH || void 0,
|
|
196
|
-
headless: parseBoolean(
|
|
197
|
-
cliOptions.headless ?? process.env.CPCG_HEADLESS,
|
|
198
|
-
true
|
|
199
|
-
),
|
|
200
|
-
timeoutMs: cliOptions.timeoutMs ? Number(cliOptions.timeoutMs) : process.env.CPCG_TIMEOUT_MS ? Number(process.env.CPCG_TIMEOUT_MS) : 3e4
|
|
201
|
-
};
|
|
202
|
-
const parsed = GateConfigSchema.safeParse(merged);
|
|
203
|
-
if (!parsed.success) {
|
|
204
|
-
const errorDetails = parsed.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
205
|
-
throw new Error(`Invalid Configuration:
|
|
206
|
-
${errorDetails}`);
|
|
207
|
-
}
|
|
208
|
-
return parsed.data;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// src/core/scanner/index.ts
|
|
212
|
-
import { chromium } from "playwright";
|
|
213
|
-
import fs from "fs/promises";
|
|
214
|
-
import path from "path";
|
|
215
|
-
|
|
216
|
-
// src/core/scanner/trackerDatabase.ts
|
|
217
|
-
var KNOWN_TRACKERS = [
|
|
218
|
-
{
|
|
219
|
-
name: "Google Analytics 4",
|
|
220
|
-
category: "analytics",
|
|
221
|
-
patterns: [
|
|
222
|
-
/google-analytics\.com\/g\/collect/i,
|
|
223
|
-
/analytics\.google\.com\/g\/collect/i,
|
|
224
|
-
/google-analytics\.com\/j\/collect/i,
|
|
225
|
-
/google-analytics\.com\/collect/i
|
|
226
|
-
]
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
name: "Google Tag Manager",
|
|
230
|
-
category: "analytics",
|
|
231
|
-
patterns: [
|
|
232
|
-
/googletagmanager\.com\/gtm\.js/i,
|
|
233
|
-
/googletagmanager\.com\/gtag\/js/i
|
|
234
|
-
]
|
|
235
|
-
},
|
|
236
|
-
{
|
|
237
|
-
name: "Meta (Facebook) Pixel",
|
|
238
|
-
category: "marketing",
|
|
239
|
-
patterns: [
|
|
240
|
-
/connect\.facebook\.net\/.*\/fbevents\.js/i,
|
|
241
|
-
/facebook\.com\/tr(\/|\?)/i
|
|
242
|
-
]
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
name: "TikTok Pixel",
|
|
246
|
-
category: "marketing",
|
|
247
|
-
patterns: [
|
|
248
|
-
/analytics\.tiktok\.com\/i18n\/pixel/i,
|
|
249
|
-
/analytics\.tiktok\.com\/api\/v2\/pixel/i
|
|
250
|
-
]
|
|
251
|
-
},
|
|
252
|
-
{
|
|
253
|
-
name: "Criteo OneTag",
|
|
254
|
-
category: "marketing",
|
|
255
|
-
patterns: [
|
|
256
|
-
/static\.criteo\.net\/js\/ld\/ld\.js/i,
|
|
257
|
-
/gum\.criteo\.com\/sync/i
|
|
258
|
-
]
|
|
259
|
-
},
|
|
260
|
-
{
|
|
261
|
-
name: "Hotjar",
|
|
262
|
-
category: "analytics",
|
|
263
|
-
patterns: [
|
|
264
|
-
/static\.hotjar\.com\/c\/hotjar-/i,
|
|
265
|
-
/script\.hotjar\.com\/modules\./i
|
|
266
|
-
]
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
name: "LinkedIn Insight Tag",
|
|
270
|
-
category: "marketing",
|
|
271
|
-
patterns: [
|
|
272
|
-
/snap\.licdn\.com\/li\.lms-analytics\/insight\.min\.js/i,
|
|
273
|
-
/px\.ads\.linkedin\.com\/collect/i
|
|
274
|
-
]
|
|
275
|
-
},
|
|
276
|
-
{
|
|
277
|
-
name: "Marketo Munchkin",
|
|
278
|
-
category: "marketing",
|
|
279
|
-
patterns: [
|
|
280
|
-
/munchkin\.marketo\.net\/munchkin\.js/i,
|
|
281
|
-
/munchkin\.marketo\.net\/munchkin/i
|
|
282
|
-
]
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
name: "HubSpot",
|
|
286
|
-
category: "marketing",
|
|
287
|
-
patterns: [
|
|
288
|
-
/js\.hs-scripts\.com/i,
|
|
289
|
-
/js\.hs-analytics\.net/i,
|
|
290
|
-
/hubspot\.com\/_hcms\//i
|
|
291
|
-
]
|
|
292
|
-
},
|
|
293
|
-
{
|
|
294
|
-
name: "Matomo",
|
|
295
|
-
category: "analytics",
|
|
296
|
-
patterns: [
|
|
297
|
-
/matomo\.php\?/i,
|
|
298
|
-
/matomo\.js/i,
|
|
299
|
-
/piwik\.php\?/i
|
|
300
|
-
]
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
name: "YouTube Embed",
|
|
304
|
-
category: "marketing",
|
|
305
|
-
patterns: [
|
|
306
|
-
/youtube\.com\/embed\//i,
|
|
307
|
-
/youtube-nocookie\.com\/embed\//i,
|
|
308
|
-
/i\.ytimg\.com\/vi\//i
|
|
309
|
-
]
|
|
310
|
-
},
|
|
311
|
-
{
|
|
312
|
-
name: "Google Maps Embed",
|
|
313
|
-
category: "functional",
|
|
314
|
-
patterns: [
|
|
315
|
-
/google\.com\/maps/i,
|
|
316
|
-
/maps\.googleapis\.com\//i,
|
|
317
|
-
/maps\.gstatic\.com\//i
|
|
318
|
-
]
|
|
319
|
-
},
|
|
320
|
-
{
|
|
321
|
-
name: "Facebook Social Embed",
|
|
322
|
-
category: "marketing",
|
|
323
|
-
patterns: [
|
|
324
|
-
/facebook\.com\/plugins\//i,
|
|
325
|
-
/facebook\.com\/tr\?/i
|
|
326
|
-
]
|
|
327
|
-
},
|
|
328
|
-
{
|
|
329
|
-
name: "Twitter/X Embed",
|
|
330
|
-
category: "marketing",
|
|
331
|
-
patterns: [
|
|
332
|
-
/platform\.twitter\.com\//i,
|
|
333
|
-
/syndication\.twitter\.com\//i
|
|
334
|
-
]
|
|
335
|
-
}
|
|
336
|
-
];
|
|
337
|
-
function identifyTracker(url) {
|
|
338
|
-
for (const tracker of KNOWN_TRACKERS) {
|
|
339
|
-
for (const pattern of tracker.patterns) {
|
|
340
|
-
if (pattern.test(url)) {
|
|
341
|
-
return {
|
|
342
|
-
name: tracker.name,
|
|
343
|
-
category: tracker.category
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// src/core/scanner/bannerLocators.ts
|
|
352
|
-
var ACCEPT_SELECTORS = [
|
|
353
|
-
// OneTrust
|
|
354
|
-
"#onetrust-accept-btn-handler",
|
|
355
|
-
"#accept-recommended-btn-handler",
|
|
356
|
-
// Cookiebot
|
|
357
|
-
"#CybotCookiebotDialogBodyButtonAccept",
|
|
358
|
-
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll",
|
|
359
|
-
// Usercentrics (incl. shadow-host button via text)
|
|
360
|
-
"#usercentrics-root [data-testid='uc-accept-all-button']",
|
|
361
|
-
"button[data-testid='uc-accept-all-button']",
|
|
362
|
-
// Consentmanager / Borlabs / Klaro
|
|
363
|
-
"#cmpbox-acceptall",
|
|
364
|
-
".cmpboxbtn.cmpboxbtnyes",
|
|
365
|
-
"#BorlabsCookieBox .btn-accept-all",
|
|
366
|
-
"#klaro .klaro .confirm-button",
|
|
367
|
-
// Tealium (utag consent)
|
|
368
|
-
"#tealium-consent-banner .consent-accept",
|
|
369
|
-
".tealium-consent-accept",
|
|
370
|
-
"button[data-tealium-consent='accept']",
|
|
371
|
-
// iubenda
|
|
372
|
-
"#iubenda-cs-banner .iubenda-cs-accept-btn",
|
|
373
|
-
".iubenda-cs-accept-btn",
|
|
374
|
-
"#iubenda-cs-customize-btn + .iubenda-cs-accept-btn",
|
|
375
|
-
// etracker
|
|
376
|
-
"#ec-js-etracker-cmp .ec-cmp-accept",
|
|
377
|
-
"#etrackerCookieConsent .accept",
|
|
378
|
-
"button[data-et-consent='accept']",
|
|
379
|
-
// JustRelate
|
|
380
|
-
"#justrelate-consent .jr-accept",
|
|
381
|
-
".justrelate-consent-accept",
|
|
382
|
-
"button[data-jr-consent='accept']",
|
|
383
|
-
// Didomi / Axeptio
|
|
384
|
-
"#didomi-notice-agree-button",
|
|
385
|
-
"#axeptio_btn_acceptAll",
|
|
386
|
-
// Generic
|
|
387
|
-
"#btn-accept",
|
|
388
|
-
"#accept-all",
|
|
389
|
-
"#cookie-accept",
|
|
390
|
-
"button[data-action='accept']",
|
|
391
|
-
"button:has-text('Alle akzeptieren')",
|
|
392
|
-
"button:has-text('Akzeptieren')",
|
|
393
|
-
"button:has-text('Accept all')",
|
|
394
|
-
"button:has-text('Accept')",
|
|
395
|
-
"button:has-text('Zustimmen')",
|
|
396
|
-
".btn-accept"
|
|
397
|
-
];
|
|
398
|
-
var REJECT_SELECTORS = [
|
|
399
|
-
// OneTrust
|
|
400
|
-
"#onetrust-reject-all-handler",
|
|
401
|
-
// Cookiebot
|
|
402
|
-
"#CybotCookiebotDialogBodyButtonDecline",
|
|
403
|
-
"#CybotCookiebotDialogBodyLevelButtonLevelOptinDeclineAll",
|
|
404
|
-
// Usercentrics
|
|
405
|
-
"#usercentrics-root [data-testid='uc-deny-all-button']",
|
|
406
|
-
"button[data-testid='uc-deny-all-button']",
|
|
407
|
-
// Consentmanager / Borlabs / Klaro
|
|
408
|
-
"#cmpbox-createnecessary",
|
|
409
|
-
".cmpboxbtn.cmpboxbtndeny",
|
|
410
|
-
"#BorlabsCookieBox .btn-only-essential",
|
|
411
|
-
"#klaro .klaro .decline-button",
|
|
412
|
-
// Tealium / iubenda / etracker / JustRelate
|
|
413
|
-
".tealium-consent-reject",
|
|
414
|
-
"#tealium-consent-banner .consent-reject",
|
|
415
|
-
".iubenda-cs-reject-btn",
|
|
416
|
-
"#ec-js-etracker-cmp .ec-cmp-reject",
|
|
417
|
-
"button[data-et-consent='reject']",
|
|
418
|
-
".justrelate-consent-reject",
|
|
419
|
-
"button[data-jr-consent='reject']",
|
|
420
|
-
// Didomi / Axeptio
|
|
421
|
-
"#didomi-notice-disagree-button",
|
|
422
|
-
"#axeptio_btn_dismiss",
|
|
423
|
-
// Generic
|
|
424
|
-
"#btn-reject",
|
|
425
|
-
"#btn-revoke",
|
|
426
|
-
// Sourcepoint
|
|
427
|
-
".sp_choice_type_12",
|
|
428
|
-
".sp_choice_type_13",
|
|
429
|
-
"button[title='Ablehnen']",
|
|
430
|
-
"button[title='Einstellungen']",
|
|
431
|
-
"#btn-revoke",
|
|
432
|
-
// Sourcepoint (Axel Springer, viele DACH-Publisher)
|
|
433
|
-
".sp_choice_type_11",
|
|
434
|
-
"button[title='Alle akzeptieren']",
|
|
435
|
-
"[aria-label='Alle akzeptieren']",
|
|
436
|
-
// Generic text fallbacks handled below
|
|
437
|
-
"#reject-all",
|
|
438
|
-
"#cookie-reject",
|
|
439
|
-
"button[data-action='reject']",
|
|
440
|
-
"button:has-text('Ablehnen')",
|
|
441
|
-
"button:has-text('Reject all')",
|
|
442
|
-
"button:has-text('Reject')",
|
|
443
|
-
"button:has-text('Nur notwendige')",
|
|
444
|
-
"button:has-text('Essential only')",
|
|
445
|
-
".btn-reject"
|
|
446
|
-
];
|
|
447
|
-
async function clickFirstMatching(page, selectors, timeoutMs = 1500) {
|
|
448
|
-
const perSelector = Math.max(250, timeoutMs / selectors.length);
|
|
449
|
-
for (const selector of selectors) {
|
|
450
|
-
try {
|
|
451
|
-
const locator = page.locator(selector).first();
|
|
452
|
-
const isVisible = await locator.isVisible({ timeout: perSelector }).catch(() => false);
|
|
453
|
-
if (isVisible) {
|
|
454
|
-
await locator.click({ timeout: 1e3 }).catch(() => {
|
|
455
|
-
});
|
|
456
|
-
return true;
|
|
457
|
-
}
|
|
458
|
-
} catch {
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return false;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// src/core/scanner/index.ts
|
|
465
|
-
function isOnTargetOrigin(pageUrl, targetOrigin) {
|
|
466
|
-
if (!pageUrl || pageUrl === "about:blank") return true;
|
|
467
|
-
try {
|
|
468
|
-
return new URL(pageUrl).origin === targetOrigin;
|
|
469
|
-
} catch {
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
async function clickFirstSameOriginLink(page, targetOrigin) {
|
|
474
|
-
return page.evaluate((origin) => {
|
|
475
|
-
const anchors = Array.from(document.querySelectorAll("a[href]"));
|
|
476
|
-
for (const a of anchors) {
|
|
477
|
-
const href = a.getAttribute("href");
|
|
478
|
-
if (!href || href.startsWith("#")) continue;
|
|
479
|
-
try {
|
|
480
|
-
const resolved = new URL(href, location.href);
|
|
481
|
-
if (resolved.origin === origin) {
|
|
482
|
-
a.click();
|
|
483
|
-
return true;
|
|
484
|
-
}
|
|
485
|
-
} catch {
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
return false;
|
|
489
|
-
}, targetOrigin);
|
|
490
|
-
}
|
|
491
|
-
var ChaosScanner = class {
|
|
492
|
-
async scan(config) {
|
|
493
|
-
const startTime = Date.now();
|
|
494
|
-
const violations = [];
|
|
495
|
-
const passedScenarios = [];
|
|
496
|
-
const failedScenarios = [];
|
|
497
|
-
const evidence = [];
|
|
498
|
-
const targetOrigin = new URL(config.targetUrl).origin;
|
|
499
|
-
const want = (s) => !config.scenarios?.length || config.scenarios.includes(s);
|
|
500
|
-
let browser = null;
|
|
501
|
-
try {
|
|
502
|
-
try {
|
|
503
|
-
const preflight = await fetch(config.targetUrl, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
|
|
504
|
-
} catch (connErr) {
|
|
505
|
-
try {
|
|
506
|
-
await fetch(config.targetUrl, { method: "GET", signal: AbortSignal.timeout(3e3) });
|
|
507
|
-
} catch {
|
|
508
|
-
throw new Error(
|
|
509
|
-
`Target URL is unreachable: ${config.targetUrl}. Please ensure your server or local fixture is running (e.g. 'pnpm run serve:fixture').`
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
browser = await chromium.launch({
|
|
514
|
-
headless: config.headless
|
|
515
|
-
});
|
|
516
|
-
if (want("happy-path-denied")) await this.runScenario(
|
|
517
|
-
"happy-path-denied",
|
|
518
|
-
browser,
|
|
519
|
-
config,
|
|
520
|
-
targetOrigin,
|
|
521
|
-
async (page, _captured, ctx) => {
|
|
522
|
-
ctx.log("goto");
|
|
523
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
524
|
-
if (_captured.length > 0) {
|
|
525
|
-
ctx.log(`pre-consent-leak-on-load(${_captured.length})`);
|
|
526
|
-
}
|
|
527
|
-
const denied = await clickFirstMatching(page, REJECT_SELECTORS, 2e3);
|
|
528
|
-
ctx.log(denied ? "click:reject" : "click:reject-missing");
|
|
529
|
-
if (!denied) return "not-checkable";
|
|
530
|
-
ctx.setPhase("post-reject");
|
|
531
|
-
ctx.log("phase:post-reject");
|
|
532
|
-
await page.waitForTimeout(500);
|
|
533
|
-
return _captured.length === 0;
|
|
534
|
-
},
|
|
535
|
-
violations,
|
|
536
|
-
passedScenarios,
|
|
537
|
-
failedScenarios,
|
|
538
|
-
evidence
|
|
539
|
-
);
|
|
540
|
-
if (want("happy-path-accepted")) await this.runScenario(
|
|
541
|
-
"happy-path-accepted",
|
|
542
|
-
browser,
|
|
543
|
-
config,
|
|
544
|
-
targetOrigin,
|
|
545
|
-
async (page, _captured, ctx) => {
|
|
546
|
-
ctx.log("goto");
|
|
547
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
548
|
-
if (_captured.length > 0) {
|
|
549
|
-
ctx.log(`pre-consent-leak-on-load(${_captured.length})`);
|
|
550
|
-
}
|
|
551
|
-
const preConsentHits = _captured.length;
|
|
552
|
-
const accepted = await clickFirstMatching(page, ACCEPT_SELECTORS, 2e3);
|
|
553
|
-
ctx.log(accepted ? "click:accept" : "click:accept-missing");
|
|
554
|
-
if (!accepted) return "not-checkable";
|
|
555
|
-
ctx.setPhase("post-accept");
|
|
556
|
-
ctx.log("phase:post-accept");
|
|
557
|
-
await page.waitForTimeout(1200);
|
|
558
|
-
await page.evaluate(() => {
|
|
559
|
-
window.dispatchEvent(new Event("scroll"));
|
|
560
|
-
window.dispatchEvent(new Event("click"));
|
|
561
|
-
});
|
|
562
|
-
await page.waitForTimeout(800);
|
|
563
|
-
return preConsentHits === 0;
|
|
564
|
-
},
|
|
565
|
-
violations,
|
|
566
|
-
passedScenarios,
|
|
567
|
-
failedScenarios,
|
|
568
|
-
evidence
|
|
569
|
-
);
|
|
570
|
-
if (want("fast-click")) await this.runScenario(
|
|
571
|
-
"fast-click",
|
|
572
|
-
browser,
|
|
573
|
-
config,
|
|
574
|
-
targetOrigin,
|
|
575
|
-
async (page, _captured, ctx) => {
|
|
576
|
-
ctx.log("goto:commit");
|
|
577
|
-
await page.goto(config.targetUrl, { waitUntil: "commit", timeout: config.timeoutMs });
|
|
578
|
-
await page.waitForSelector("a[href]", { timeout: 2e3 }).catch(() => {
|
|
579
|
-
});
|
|
580
|
-
const clicked = await clickFirstSameOriginLink(page, targetOrigin);
|
|
581
|
-
ctx.log(clicked ? "click:same-origin-link" : "click:link-missing");
|
|
582
|
-
if (!clicked) return "not-checkable";
|
|
583
|
-
await page.waitForTimeout(500);
|
|
584
|
-
return _captured.length === 0;
|
|
585
|
-
},
|
|
586
|
-
violations,
|
|
587
|
-
passedScenarios,
|
|
588
|
-
failedScenarios,
|
|
589
|
-
evidence
|
|
590
|
-
);
|
|
591
|
-
if (want("state-manipulation")) await this.runScenario(
|
|
592
|
-
"state-manipulation",
|
|
593
|
-
browser,
|
|
594
|
-
config,
|
|
595
|
-
targetOrigin,
|
|
596
|
-
async (page, _captured, ctx) => {
|
|
597
|
-
ctx.log("goto");
|
|
598
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
599
|
-
await page.waitForTimeout(300);
|
|
600
|
-
if (_captured.length > 0) {
|
|
601
|
-
ctx.log(`pre-consent-leak-on-load(${_captured.length})`);
|
|
602
|
-
}
|
|
603
|
-
const preConsentHits = _captured.length;
|
|
604
|
-
const accepted = await clickFirstMatching(page, ACCEPT_SELECTORS, 1500);
|
|
605
|
-
ctx.log(accepted ? "click:accept" : "click:accept-missing");
|
|
606
|
-
if (!accepted) return "not-checkable";
|
|
607
|
-
ctx.setPhase("post-accept");
|
|
608
|
-
ctx.log("phase:post-accept");
|
|
609
|
-
await page.waitForTimeout(600);
|
|
610
|
-
await page.evaluate(() => {
|
|
611
|
-
localStorage.clear();
|
|
612
|
-
sessionStorage.clear();
|
|
613
|
-
});
|
|
614
|
-
const context = page.context();
|
|
615
|
-
await context.clearCookies();
|
|
616
|
-
ctx.setPhase("post-wipe");
|
|
617
|
-
ctx.log("wipe:storage+cookies phase:post-wipe");
|
|
618
|
-
await page.reload({ waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
619
|
-
ctx.log("reload:post-wipe");
|
|
620
|
-
await page.waitForTimeout(400);
|
|
621
|
-
await page.evaluate(() => {
|
|
622
|
-
window.dispatchEvent(new Event("scroll"));
|
|
623
|
-
window.dispatchEvent(new Event("click"));
|
|
624
|
-
document.body?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
625
|
-
});
|
|
626
|
-
ctx.log("events:post-wipe");
|
|
627
|
-
await page.waitForTimeout(800);
|
|
628
|
-
return preConsentHits === 0;
|
|
629
|
-
},
|
|
630
|
-
violations,
|
|
631
|
-
passedScenarios,
|
|
632
|
-
failedScenarios,
|
|
633
|
-
evidence
|
|
634
|
-
);
|
|
635
|
-
if (want("network-drop")) await this.runScenario(
|
|
636
|
-
"network-drop",
|
|
637
|
-
browser,
|
|
638
|
-
config,
|
|
639
|
-
targetOrigin,
|
|
640
|
-
async (page, _captured, ctx) => {
|
|
641
|
-
let cmpBlocked = false;
|
|
642
|
-
await page.route(
|
|
643
|
-
(url) => {
|
|
644
|
-
const href = url.href;
|
|
645
|
-
if (href === config.targetUrl) return false;
|
|
646
|
-
if (/\.html?([?#]|$)/i.test(href)) return false;
|
|
647
|
-
return /consent|cookiebot|onetrust|klaro|usercentrics/i.test(href);
|
|
648
|
-
},
|
|
649
|
-
(route) => {
|
|
650
|
-
cmpBlocked = true;
|
|
651
|
-
return route.abort();
|
|
652
|
-
}
|
|
653
|
-
);
|
|
654
|
-
ctx.log("goto:cmp-blocked");
|
|
655
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
656
|
-
await page.waitForTimeout(600);
|
|
657
|
-
if (!cmpBlocked) {
|
|
658
|
-
ctx.log("cmp-script-not-found");
|
|
659
|
-
return "not-checkable";
|
|
660
|
-
}
|
|
661
|
-
return _captured.length === 0;
|
|
662
|
-
},
|
|
663
|
-
violations,
|
|
664
|
-
passedScenarios,
|
|
665
|
-
failedScenarios,
|
|
666
|
-
evidence
|
|
667
|
-
);
|
|
668
|
-
if (want("consent-revoked")) await this.runRevocationScenario(
|
|
669
|
-
browser,
|
|
670
|
-
config,
|
|
671
|
-
targetOrigin,
|
|
672
|
-
violations,
|
|
673
|
-
passedScenarios,
|
|
674
|
-
failedScenarios,
|
|
675
|
-
evidence
|
|
676
|
-
);
|
|
677
|
-
if (want("category-selective")) await this.runCategorySelectiveScenario(
|
|
678
|
-
browser,
|
|
679
|
-
config,
|
|
680
|
-
targetOrigin,
|
|
681
|
-
violations,
|
|
682
|
-
passedScenarios,
|
|
683
|
-
failedScenarios,
|
|
684
|
-
evidence
|
|
685
|
-
);
|
|
686
|
-
if (want("consent-persistence")) await this.runPersistenceScenario(
|
|
687
|
-
browser,
|
|
688
|
-
config,
|
|
689
|
-
targetOrigin,
|
|
690
|
-
violations,
|
|
691
|
-
passedScenarios,
|
|
692
|
-
failedScenarios,
|
|
693
|
-
evidence
|
|
694
|
-
);
|
|
695
|
-
if (want("banner-ignored")) await this.runBannerIgnoredScenario(
|
|
696
|
-
browser,
|
|
697
|
-
config,
|
|
698
|
-
targetOrigin,
|
|
699
|
-
violations,
|
|
700
|
-
passedScenarios,
|
|
701
|
-
failedScenarios,
|
|
702
|
-
evidence
|
|
703
|
-
);
|
|
704
|
-
if (want("subpage-spa")) await this.runSubpageSpaScenario(
|
|
705
|
-
browser,
|
|
706
|
-
config,
|
|
707
|
-
targetOrigin,
|
|
708
|
-
violations,
|
|
709
|
-
passedScenarios,
|
|
710
|
-
failedScenarios,
|
|
711
|
-
evidence
|
|
712
|
-
);
|
|
713
|
-
if (want("cmp-failure")) await this.runCmpFailureScenario(
|
|
714
|
-
browser,
|
|
715
|
-
config,
|
|
716
|
-
targetOrigin,
|
|
717
|
-
violations,
|
|
718
|
-
passedScenarios,
|
|
719
|
-
failedScenarios,
|
|
720
|
-
evidence
|
|
721
|
-
);
|
|
722
|
-
if (want("embed-leaks")) await this.runEmbedScenario(
|
|
723
|
-
browser,
|
|
724
|
-
config,
|
|
725
|
-
targetOrigin,
|
|
726
|
-
violations,
|
|
727
|
-
passedScenarios,
|
|
728
|
-
failedScenarios,
|
|
729
|
-
evidence
|
|
730
|
-
);
|
|
731
|
-
if (want("consent-mode-v2")) await this.runConsentModeV2Scenario(
|
|
732
|
-
browser,
|
|
733
|
-
config,
|
|
734
|
-
targetOrigin,
|
|
735
|
-
violations,
|
|
736
|
-
passedScenarios,
|
|
737
|
-
failedScenarios,
|
|
738
|
-
evidence
|
|
739
|
-
);
|
|
740
|
-
if (want("duplicate-events")) await this.runDuplicateEventsScenario(
|
|
741
|
-
browser,
|
|
742
|
-
config,
|
|
743
|
-
targetOrigin,
|
|
744
|
-
violations,
|
|
745
|
-
passedScenarios,
|
|
746
|
-
failedScenarios,
|
|
747
|
-
evidence
|
|
748
|
-
);
|
|
749
|
-
if (want("mobile-usability")) await this.runMobileUsabilityScenario(
|
|
750
|
-
browser,
|
|
751
|
-
config,
|
|
752
|
-
targetOrigin,
|
|
753
|
-
violations,
|
|
754
|
-
passedScenarios,
|
|
755
|
-
failedScenarios,
|
|
756
|
-
evidence
|
|
757
|
-
);
|
|
758
|
-
} finally {
|
|
759
|
-
if (browser) {
|
|
760
|
-
await browser.close().catch(() => {
|
|
761
|
-
});
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
const durationMs = Date.now() - startTime;
|
|
765
|
-
const allClean = violations.length === 0 && failedScenarios.length === 0;
|
|
766
|
-
const naCount = evidence.filter((e) => e.status === "not-applicable").length;
|
|
767
|
-
const summary = allClean ? `Clean: ${passedScenarios.length} scenario(s) passed${naCount ? `, ${naCount} not applicable` : ""} for ${config.targetUrl}. No privacy leaks detected.` : `Privacy issues: ${violations.length} tracker leak(s), ${failedScenarios.length} scenario(s) not clean.`;
|
|
768
|
-
return {
|
|
769
|
-
targetUrl: config.targetUrl,
|
|
770
|
-
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
771
|
-
durationMs,
|
|
772
|
-
totalViolations: violations.length,
|
|
773
|
-
violations,
|
|
774
|
-
passedScenarios,
|
|
775
|
-
failedScenarios,
|
|
776
|
-
summary,
|
|
777
|
-
evidence
|
|
778
|
-
};
|
|
779
|
-
}
|
|
780
|
-
async snapshotStorage(page) {
|
|
781
|
-
const cookies = (await page.context().cookies().catch(() => [])).map((c) => `${c.name}@${c.domain}`);
|
|
782
|
-
const local = await page.evaluate(() => Object.keys(localStorage)).catch(() => []);
|
|
783
|
-
const session = await page.evaluate(() => Object.keys(sessionStorage)).catch(() => []);
|
|
784
|
-
const localStorageEntries = await page.evaluate(() => {
|
|
785
|
-
const out = [];
|
|
786
|
-
const interesting = /consent|cookie|cpcg|optanon|usercentrics|didomi|category|purpose|analytics|marketing|statistic/i;
|
|
787
|
-
for (const key of Object.keys(localStorage)) {
|
|
788
|
-
if (!interesting.test(key)) continue;
|
|
789
|
-
const v = localStorage.getItem(key) ?? "";
|
|
790
|
-
out.push({ key, value: v.length > 120 ? v.slice(0, 120) + "\u2026" : v });
|
|
791
|
-
}
|
|
792
|
-
return out;
|
|
793
|
-
}).catch(() => []);
|
|
794
|
-
return {
|
|
795
|
-
cookies,
|
|
796
|
-
localStorageKeys: local,
|
|
797
|
-
sessionStorageKeys: session,
|
|
798
|
-
localStorageEntries: localStorageEntries.length ? localStorageEntries : void 0
|
|
799
|
-
};
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Fünf Kern-Chaos-Szenarien mit vollständigem Evidence-Record.
|
|
803
|
-
* Phasen statt Capture-Wipe: Pre-Consent-Funde bleiben immer erhalten.
|
|
804
|
-
* Ergebnis: passed nur bei ausgeführter Aktion und null Illegal-Funden.
|
|
805
|
-
*/
|
|
806
|
-
async runScenario(scenario, browser, _config, targetOrigin, runner, violations, passedScenarios, failedScenarios, evidence) {
|
|
807
|
-
const context = await browser.newContext();
|
|
808
|
-
const page = await context.newPage();
|
|
809
|
-
const scenarioViolations = [];
|
|
810
|
-
const timeline = [];
|
|
811
|
-
const actionSequence = [];
|
|
812
|
-
const phaseById = /* @__PURE__ */ new Map();
|
|
813
|
-
let phase = "pre-consent";
|
|
814
|
-
page.on("request", (req) => {
|
|
815
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
816
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) {
|
|
817
|
-
return;
|
|
818
|
-
}
|
|
819
|
-
const url = req.url();
|
|
820
|
-
const tracker = identifyTracker(url);
|
|
821
|
-
timeline.push({
|
|
822
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
823
|
-
url,
|
|
824
|
-
trackerName: tracker?.name,
|
|
825
|
-
trackerCategory: tracker?.category,
|
|
826
|
-
phase
|
|
827
|
-
});
|
|
828
|
-
if (tracker) {
|
|
829
|
-
const ruleId = phase === "post-wipe" ? "tracking-without-consent" : phase === "post-reject" || phase === "post-reject-restart" ? "tracking-after-deny" : phase === "post-revoke" ? "tracking-after-revoke" : "pre-consent-tracking";
|
|
830
|
-
const violation = {
|
|
831
|
-
id: `${scenario}-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
|
832
|
-
ruleId,
|
|
833
|
-
scenario,
|
|
834
|
-
trackerName: tracker.name,
|
|
835
|
-
trackerCategory: tracker.category,
|
|
836
|
-
requestUrl: url,
|
|
837
|
-
method: req.method(),
|
|
838
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
839
|
-
description: `Unauthorized ${tracker.name} (${tracker.category}) request triggered during scenario '${scenario}'.`
|
|
840
|
-
};
|
|
841
|
-
scenarioViolations.push(violation);
|
|
842
|
-
phaseById.set(violation.id, phase);
|
|
843
|
-
}
|
|
844
|
-
});
|
|
845
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
846
|
-
let outcome = false;
|
|
847
|
-
try {
|
|
848
|
-
outcome = await runner(page, scenarioViolations, {
|
|
849
|
-
setPhase: (p) => {
|
|
850
|
-
phase = p;
|
|
851
|
-
},
|
|
852
|
-
log: (a) => actionSequence.push(a)
|
|
853
|
-
});
|
|
854
|
-
} catch (err) {
|
|
855
|
-
outcome = false;
|
|
856
|
-
actionSequence.push(`error:${String(err).slice(0, 80)}`);
|
|
857
|
-
}
|
|
858
|
-
const storageAfter = await this.snapshotStorage(page).catch(() => storageBefore);
|
|
859
|
-
await context.close().catch(() => {
|
|
860
|
-
});
|
|
861
|
-
const illegal = scenarioViolations.filter((v) => {
|
|
862
|
-
const p = phaseById.get(v.id) ?? "pre-consent";
|
|
863
|
-
return p === "pre-consent" || p === "post-wipe" || p === "post-revoke" || p === "post-reject" || p === "post-reject-restart";
|
|
864
|
-
});
|
|
865
|
-
const postAccept = scenarioViolations.length - illegal.length;
|
|
866
|
-
let status;
|
|
867
|
-
if (outcome === "not-checkable") {
|
|
868
|
-
status = "not-checkable";
|
|
869
|
-
failedScenarios.push(scenario);
|
|
870
|
-
violations.push(...illegal);
|
|
871
|
-
} else if (illegal.length > 0 || outcome === false) {
|
|
872
|
-
status = "suspicious";
|
|
873
|
-
failedScenarios.push(scenario);
|
|
874
|
-
violations.push(...illegal);
|
|
875
|
-
} else {
|
|
876
|
-
status = "passed";
|
|
877
|
-
passedScenarios.push(scenario);
|
|
878
|
-
}
|
|
879
|
-
const note = illegal.length > 0 ? actionSequence.some((a) => a.startsWith("pre-consent-leak-on-load")) ? `Pre-Consent-Leak bereits beim Seitenaufruf (${illegal.filter((v) => (phaseById.get(v.id) ?? "") === "pre-consent").length} Request(s)). Szenario wurde trotzdem ausgef\xFChrt und vollst\xE4ndig dokumentiert.` : `${illegal.length} Tracker-Request(s) in unzul\xE4ssiger Phase (pre-consent/post-reject/post-wipe/post-revoke).` : status === "passed" ? `Aktion ausgef\xFChrt, keine Tracker-Requests vor der Zustimmung.${postAccept ? ` ${postAccept} post-accept Request(s) (Re-Activation, legal).` : ""}` : status === "not-checkable" ? "Szenario nicht pr\xFCfbar \u2014 erforderliche Aktion (Button/Link) nicht ausf\xFChrbar (kein gr\xFCnes H\xE4kchen)." : "Szenario ohne sauberen Nachweis abgeschlossen.";
|
|
880
|
-
evidence.push({
|
|
881
|
-
scenario,
|
|
882
|
-
status,
|
|
883
|
-
note,
|
|
884
|
-
actionSequence,
|
|
885
|
-
requestTimeline: timeline,
|
|
886
|
-
storageBefore,
|
|
887
|
-
storageAfter,
|
|
888
|
-
targetUrl: _config.targetUrl,
|
|
889
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
async snap(page, scenario, label, shots, acts, actionSequence) {
|
|
893
|
-
const dir = path.resolve("reports/evidence");
|
|
894
|
-
await fs.mkdir(dir, { recursive: true });
|
|
895
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
896
|
-
const file = path.join(dir, `${scenario}-${label}-${Date.now()}.png`);
|
|
897
|
-
await page.screenshot({ path: file, fullPage: true }).catch(() => {
|
|
898
|
-
});
|
|
899
|
-
shots.push({ label, path: file, at });
|
|
900
|
-
actionSequence.push(`screenshot:${label}`);
|
|
901
|
-
acts.push({ at, action: `screenshot:${label}` });
|
|
902
|
-
}
|
|
903
|
-
logAct(acts, actionSequence, action) {
|
|
904
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
905
|
-
actionSequence.push(action);
|
|
906
|
-
acts.push({ at, action });
|
|
907
|
-
}
|
|
908
|
-
/**
|
|
909
|
-
* Widerruf: erst akzeptieren, dann widerrufen. Prüft (a) keine neuen Tracker-Requests
|
|
910
|
-
* nach Widerruf, (b) ob Tracking-IDs in Cookies/Storage zurückbleiben.
|
|
911
|
-
* Kein Widerruf-Button — status «not-checkable» (nie grün).
|
|
912
|
-
*/
|
|
913
|
-
/**
|
|
914
|
-
* Kategorien-Granularität: Statistik erlaubt, Marketing abgelehnt.
|
|
915
|
-
* Marketing-Tracker (Meta, TikTok, LinkedIn, Criteo ⬦) dürfen danach NICHT feuern.
|
|
916
|
-
* Keine Kategorie-UI — status «not-checkable».
|
|
917
|
-
*/
|
|
918
|
-
async runCategorySelectiveScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
919
|
-
const context = await browser.newContext();
|
|
920
|
-
const page = await context.newPage();
|
|
921
|
-
const timeline = [];
|
|
922
|
-
const actionSequence = [];
|
|
923
|
-
let phase = "pre-consent";
|
|
924
|
-
page.on("request", (req) => {
|
|
925
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
926
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
927
|
-
const hit = identifyTracker(req.url());
|
|
928
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url: req.url(), trackerName: hit?.name, trackerCategory: hit?.category, phase });
|
|
929
|
-
if (hit && phase === "post-selection" && hit.category === "marketing") {
|
|
930
|
-
violations.push({
|
|
931
|
-
id: `category-selective-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
932
|
-
ruleId: "tracking-declined-category",
|
|
933
|
-
scenario: "category-selective",
|
|
934
|
-
trackerName: hit.name,
|
|
935
|
-
trackerCategory: hit.category,
|
|
936
|
-
requestUrl: req.url(),
|
|
937
|
-
method: req.method(),
|
|
938
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
939
|
-
description: `${hit.name} feuert, obwohl Marketing-Kategorie abgelehnt wurde.`
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
});
|
|
943
|
-
try {
|
|
944
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
945
|
-
actionSequence.push("goto");
|
|
946
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
947
|
-
await clickFirstMatching(page, ["#btn-settings", "button[data-action='settings']", ".open-prefs"], 1200);
|
|
948
|
-
actionSequence.push("click:settings");
|
|
949
|
-
await page.waitForTimeout(200);
|
|
950
|
-
const marketingOff = await page.evaluate(() => {
|
|
951
|
-
const boxes = Array.from(
|
|
952
|
-
document.querySelectorAll('input[type="checkbox"]')
|
|
953
|
-
);
|
|
954
|
-
let touched = 0;
|
|
955
|
-
for (const box of boxes) {
|
|
956
|
-
const label = (box.id + " " + box.name + " " + (box.closest("label")?.textContent || "") + " " + (box.getAttribute("aria-label") || "")).toLowerCase();
|
|
957
|
-
if (/marketing|advertising|werbung|ads/.test(label)) {
|
|
958
|
-
if (box.checked) box.click();
|
|
959
|
-
touched++;
|
|
960
|
-
}
|
|
961
|
-
if (/statistic|analytics|statistik|messung/.test(label)) {
|
|
962
|
-
if (!box.checked) box.click();
|
|
963
|
-
touched++;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
return touched;
|
|
967
|
-
}).catch(() => 0);
|
|
968
|
-
if (marketingOff === 0) {
|
|
969
|
-
const storageAfter2 = await this.snapshotStorage(page);
|
|
970
|
-
evidence.push({
|
|
971
|
-
scenario: "category-selective",
|
|
972
|
-
status: "not-checkable",
|
|
973
|
-
note: "Keine Kategorie-Auswahl (Statistik/Marketing) im Banner gefunden \u2014 nicht pr\xFCfbar.",
|
|
974
|
-
actionSequence,
|
|
975
|
-
requestTimeline: timeline,
|
|
976
|
-
storageBefore,
|
|
977
|
-
storageAfter: storageAfter2,
|
|
978
|
-
targetUrl: config.targetUrl,
|
|
979
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
980
|
-
});
|
|
981
|
-
failedScenarios.push("category-selective");
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
phase = "post-selection";
|
|
985
|
-
actionSequence.push(`categories:marketing-off(${marketingOff})`);
|
|
986
|
-
const saved = await clickFirstMatching(page, ["#btn-save", "button[data-action='save']", ".save-settings"], 1500) || await clickFirstMatching(page, ACCEPT_SELECTORS, 1500);
|
|
987
|
-
actionSequence.push(saved ? "click:save-choices" : "click:save-missing");
|
|
988
|
-
if (!saved) {
|
|
989
|
-
const storageAfterNoSave = await this.snapshotStorage(page);
|
|
990
|
-
evidence.push({
|
|
991
|
-
scenario: "category-selective",
|
|
992
|
-
status: "not-checkable",
|
|
993
|
-
note: "Kategorie-Auswahl konnte nicht gespeichert werden \u2014 kein Save/Accept-Button (kein gr\xFCnes H\xE4kchen).",
|
|
994
|
-
actionSequence,
|
|
995
|
-
requestTimeline: timeline,
|
|
996
|
-
storageBefore,
|
|
997
|
-
storageAfter: storageAfterNoSave,
|
|
998
|
-
targetUrl: config.targetUrl,
|
|
999
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1000
|
-
});
|
|
1001
|
-
failedScenarios.push("category-selective");
|
|
1002
|
-
return;
|
|
1003
|
-
}
|
|
1004
|
-
await page.waitForTimeout(1800);
|
|
1005
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1006
|
-
const decisionPhase = "post-selection";
|
|
1007
|
-
const marketingLeaks = timeline.filter(
|
|
1008
|
-
(e) => e.phase === decisionPhase && e.trackerName && e.trackerCategory === "marketing"
|
|
1009
|
-
);
|
|
1010
|
-
const preConsentLeaks = timeline.filter(
|
|
1011
|
-
(e) => e.phase === "pre-consent" && e.trackerName
|
|
1012
|
-
);
|
|
1013
|
-
const analyticsOk = timeline.filter(
|
|
1014
|
-
(e) => e.phase === decisionPhase && e.trackerName && e.trackerCategory === "analytics"
|
|
1015
|
-
);
|
|
1016
|
-
const catValues = (storageAfter.localStorageEntries ?? []).filter((e) => /analytics|marketing|consent|category/i.test(e.key)).map((e) => `${e.key}=${e.value}`).join(", ");
|
|
1017
|
-
const decisionRequests = timeline.filter((e) => e.phase === "post-selection" && e.trackerName);
|
|
1018
|
-
const status = marketingLeaks.length > 0 ? "suspicious" : "passed";
|
|
1019
|
-
evidence.push({
|
|
1020
|
-
scenario: "category-selective",
|
|
1021
|
-
status,
|
|
1022
|
-
note: status === "passed" ? [
|
|
1023
|
-
preConsentLeaks.length > 0 ? `Kategorie-Auswahl bestanden (keine Marketing-Tracker nach \xABnur Statistik\xBB); zus\xE4tzlich ${preConsentLeaks.length} Pre-Consent-Request(s) vor der Auswahl dokumentiert.` : "Nach \xABnur Statistik\xBB keine Marketing-Tracker-Requests beobachtet.",
|
|
1024
|
-
analyticsOk.length > 0 ? `Erlaubtes Analytics-Tracking nach Freigabe belegt (${analyticsOk.length} Request(s), Phase post-selection).` : "Erlaubtes Analytics-Tracking nach Freigabe nicht beobachtet.",
|
|
1025
|
-
catValues ? `Gespeicherte Kategorien: ${catValues}.` : "",
|
|
1026
|
-
`Pr\xFCfumfang: Klick auf \xABAuswahl speichern\xBB (Marketing aus, Statistik an), danach 1,8 s Beobachtungsfenster mit ${decisionRequests.length} Tracker-Request(s) in Phase post-selection. Interaktionen: Settings \xF6ffnen, Checkboxen setzen, Speichern.`
|
|
1027
|
-
].filter(Boolean).join(" ") : `${marketingLeaks.length} Marketing-Tracker trotz abgelehnter Kategorie aktiv.`,
|
|
1028
|
-
actionSequence,
|
|
1029
|
-
requestTimeline: timeline,
|
|
1030
|
-
storageBefore,
|
|
1031
|
-
storageAfter,
|
|
1032
|
-
targetUrl: config.targetUrl,
|
|
1033
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1034
|
-
});
|
|
1035
|
-
if (status === "passed") passedScenarios.push("category-selective");
|
|
1036
|
-
else failedScenarios.push("category-selective");
|
|
1037
|
-
} catch (err) {
|
|
1038
|
-
evidence.push({
|
|
1039
|
-
scenario: "category-selective",
|
|
1040
|
-
status: "not-checkable",
|
|
1041
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1042
|
-
actionSequence: [],
|
|
1043
|
-
requestTimeline: [],
|
|
1044
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1045
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1046
|
-
targetUrl: config.targetUrl,
|
|
1047
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1048
|
-
});
|
|
1049
|
-
failedScenarios.push("category-selective");
|
|
1050
|
-
} finally {
|
|
1051
|
-
await context.close().catch(() => {
|
|
1052
|
-
});
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
/**
|
|
1056
|
-
* Entscheidung merken: nach «Ablehnen» Reload, Unterseiten-URL und Browser-Neustart
|
|
1057
|
-
* (storageState). Tracker müssen ausbleiben, die Entscheidung muss erhalten bleiben.
|
|
1058
|
-
*/
|
|
1059
|
-
async runPersistenceScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1060
|
-
const context = await browser.newContext();
|
|
1061
|
-
const page = await context.newPage();
|
|
1062
|
-
const timeline = [];
|
|
1063
|
-
const actionSequence = [];
|
|
1064
|
-
let phase = "pre-consent";
|
|
1065
|
-
let denied = false;
|
|
1066
|
-
const onReq = (req) => {
|
|
1067
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
1068
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1069
|
-
const hit = identifyTracker(req.url());
|
|
1070
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url: req.url(), trackerName: hit?.name, trackerCategory: hit?.category, phase });
|
|
1071
|
-
if (hit && denied && (phase === "post-reject" || phase === "post-reject-restart")) {
|
|
1072
|
-
violations.push({
|
|
1073
|
-
id: `consent-persistence-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1074
|
-
ruleId: "tracking-after-deny",
|
|
1075
|
-
scenario: "consent-persistence",
|
|
1076
|
-
trackerName: hit.name,
|
|
1077
|
-
trackerCategory: hit.category,
|
|
1078
|
-
requestUrl: req.url(),
|
|
1079
|
-
method: req.method(),
|
|
1080
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1081
|
-
description: `${hit.name} feuert, obwohl Ablehnung gespeichert sein sollte.`
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1084
|
-
};
|
|
1085
|
-
page.on("request", onReq);
|
|
1086
|
-
try {
|
|
1087
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1088
|
-
actionSequence.push("goto");
|
|
1089
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1090
|
-
denied = await clickFirstMatching(page, REJECT_SELECTORS, 2500);
|
|
1091
|
-
actionSequence.push(denied ? "click:reject" : "click:reject-missing");
|
|
1092
|
-
if (!denied) {
|
|
1093
|
-
const storageAfter2 = await this.snapshotStorage(page);
|
|
1094
|
-
evidence.push({
|
|
1095
|
-
scenario: "consent-persistence",
|
|
1096
|
-
status: "not-checkable",
|
|
1097
|
-
note: "Kein Ablehnen-Button \u2014 Persistenz der Entscheidung nicht pr\xFCfbar.",
|
|
1098
|
-
actionSequence,
|
|
1099
|
-
requestTimeline: timeline,
|
|
1100
|
-
storageBefore,
|
|
1101
|
-
storageAfter: storageAfter2,
|
|
1102
|
-
targetUrl: config.targetUrl,
|
|
1103
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1104
|
-
});
|
|
1105
|
-
failedScenarios.push("consent-persistence");
|
|
1106
|
-
return;
|
|
1107
|
-
}
|
|
1108
|
-
await page.waitForTimeout(600);
|
|
1109
|
-
const consentStillSet = await page.evaluate(() => {
|
|
1110
|
-
const keys = ["cpcg_consent", "OptanonConsent", "CookieConsent", "usercentrics-consent", "consent"];
|
|
1111
|
-
const ls = keys.filter((k) => {
|
|
1112
|
-
const v = localStorage.getItem(k);
|
|
1113
|
-
return v && v !== "accepted";
|
|
1114
|
-
}).length;
|
|
1115
|
-
const cookieKeys = /OptanonConsent|CookieConsent|usercentrics|consent/i;
|
|
1116
|
-
const ck = document.cookie.split(";").map((c) => c.trim().split("=")[0]).filter((name) => cookieKeys.test(name)).length;
|
|
1117
|
-
return ls + ck;
|
|
1118
|
-
}).catch(() => 0);
|
|
1119
|
-
phase = "post-reject";
|
|
1120
|
-
await page.reload({ waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1121
|
-
actionSequence.push("reload");
|
|
1122
|
-
await page.waitForTimeout(500);
|
|
1123
|
-
await page.goto(`${targetOrigin}/subpage-persistence-check`, {
|
|
1124
|
-
waitUntil: "domcontentloaded",
|
|
1125
|
-
timeout: config.timeoutMs
|
|
1126
|
-
}).catch(() => {
|
|
1127
|
-
});
|
|
1128
|
-
actionSequence.push("goto:subpage");
|
|
1129
|
-
await page.waitForTimeout(400);
|
|
1130
|
-
const storageAtRestart = await this.snapshotStorage(page);
|
|
1131
|
-
const state = await context.storageState();
|
|
1132
|
-
await context.close();
|
|
1133
|
-
const ctx2 = await browser.newContext({ storageState: state });
|
|
1134
|
-
const page2 = await ctx2.newPage();
|
|
1135
|
-
page2.on("request", (req) => {
|
|
1136
|
-
const frameUrl = req.frame()?.url() || page2.url();
|
|
1137
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1138
|
-
const hit = identifyTracker(req.url());
|
|
1139
|
-
timeline.push({
|
|
1140
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1141
|
-
url: req.url(),
|
|
1142
|
-
trackerName: hit?.name,
|
|
1143
|
-
trackerCategory: hit?.category,
|
|
1144
|
-
phase: "post-reject-restart"
|
|
1145
|
-
});
|
|
1146
|
-
if (hit && denied) {
|
|
1147
|
-
violations.push({
|
|
1148
|
-
id: `consent-persistence-restart-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1149
|
-
ruleId: "tracking-after-deny",
|
|
1150
|
-
scenario: "consent-persistence",
|
|
1151
|
-
trackerName: hit.name,
|
|
1152
|
-
trackerCategory: hit.category,
|
|
1153
|
-
requestUrl: req.url(),
|
|
1154
|
-
method: req.method(),
|
|
1155
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1156
|
-
description: `${hit.name} feuert nach Ablehnung und Browser-Neustart.`
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
});
|
|
1160
|
-
await page2.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1161
|
-
actionSequence.push("restart:storageState");
|
|
1162
|
-
await page2.waitForTimeout(800);
|
|
1163
|
-
await ctx2.close();
|
|
1164
|
-
const storageAfter = await this.snapshotStorage(page2).catch(() => storageAtRestart);
|
|
1165
|
-
const leaks = timeline.filter((e) => e.trackerName && (e.phase === "post-reject" || e.phase === "post-reject-restart"));
|
|
1166
|
-
const deniedPersists = consentStillSet > 0;
|
|
1167
|
-
const status = leaks.length > 0 ? "suspicious" : !deniedPersists ? "not-checkable" : "passed";
|
|
1168
|
-
evidence.push({
|
|
1169
|
-
scenario: "consent-persistence",
|
|
1170
|
-
status,
|
|
1171
|
-
note: status === "passed" ? "Ablehnung \xFCberlebt Reload, Unterseite und Neustart; kein Tracking ohne erneute Zustimmung." : status === "suspicious" ? `${leaks.length} Tracker-Request(s) trotz gespeicherter Ablehnung.` : "Keine konsistente Entscheidung im Storage gefunden \u2014 nicht pr\xFCfbar.",
|
|
1172
|
-
actionSequence,
|
|
1173
|
-
requestTimeline: timeline,
|
|
1174
|
-
storageBefore,
|
|
1175
|
-
storageAfter,
|
|
1176
|
-
targetUrl: config.targetUrl,
|
|
1177
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1178
|
-
});
|
|
1179
|
-
if (status === "passed") passedScenarios.push("consent-persistence");
|
|
1180
|
-
else failedScenarios.push("consent-persistence");
|
|
1181
|
-
} catch (err) {
|
|
1182
|
-
evidence.push({
|
|
1183
|
-
scenario: "consent-persistence",
|
|
1184
|
-
status: "not-checkable",
|
|
1185
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1186
|
-
actionSequence: [],
|
|
1187
|
-
requestTimeline: [],
|
|
1188
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1189
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1190
|
-
targetUrl: config.targetUrl,
|
|
1191
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1192
|
-
});
|
|
1193
|
-
failedScenarios.push("consent-persistence");
|
|
1194
|
-
} finally {
|
|
1195
|
-
await context.close().catch(() => {
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
/**
|
|
1200
|
-
* Banner ignorieren: kein Klick, nur warten, scrollen, Navigation.
|
|
1201
|
-
* Tracker dürfen nicht über Timer/Scroll/Input allein starten.
|
|
1202
|
-
*/
|
|
1203
|
-
/**
|
|
1204
|
-
* Unterseiten & SPA: Direkteinstieg auf Subpage, dann Client-Navigation ohne Reload.
|
|
1205
|
-
* Consent-Schutz muss auch dort greifen.
|
|
1206
|
-
*/
|
|
1207
|
-
/**
|
|
1208
|
-
* CMP langsam oder defekt: CMP-Skripte verzögern/abbringen, dann beobachten,
|
|
1209
|
-
* ob im Zeitfenster Tracker starten (Default-State-Leak).
|
|
1210
|
-
*/
|
|
1211
|
-
/**
|
|
1212
|
-
* Eingebettete Inhalte (YouTube, Maps, Social, Chat): Drittanbieter-Requests
|
|
1213
|
-
* vor Consent-Freigabe des Embeds. Inklusive iframe-src und nachgeladenen SDKs.
|
|
1214
|
-
*/
|
|
1215
|
-
/**
|
|
1216
|
-
* Consent Mode v2: dataLayer-Konsens-Zustände (ad_storage, analytics_storage, ⬦)
|
|
1217
|
-
* gegen tatsächliche Google-Requests und Cookies trennen.
|
|
1218
|
-
* Bei Basic dürfen nach Ablehnung keine Nutzungsdaten laufen; bei Advanced
|
|
1219
|
-
* sind cookielose Pings möglich — beides wird getrennt ausgewiesen.
|
|
1220
|
-
*/
|
|
1221
|
-
/**
|
|
1222
|
-
* Doppelte Events: ein Seitenaufruf nach Consent darf page_view nicht doppelt zählen
|
|
1223
|
-
* (Plugin + GTM parallel). First-Party-Heuristik: verdächtige same-origin Endpunkte
|
|
1224
|
-
* (collect, pixel, track) werden markiert — Server-zu-Server ist von aussen begrenzt beweisbar.
|
|
1225
|
-
*/
|
|
1226
|
-
/**
|
|
1227
|
-
* Mobile: iPhone-Viewport. Reject und Settings müssen sichtbar und klickbar sein.
|
|
1228
|
-
* Nur Accept bedienbar — suspicious (Dark Pattern); keine Buttons — not-checkable.
|
|
1229
|
-
*/
|
|
1230
|
-
async runMobileUsabilityScenario(browser, config, targetOrigin, _violations, passedScenarios, failedScenarios, evidence) {
|
|
1231
|
-
const context = await browser.newContext({
|
|
1232
|
-
viewport: { width: 390, height: 844 },
|
|
1233
|
-
isMobile: true,
|
|
1234
|
-
hasTouch: true,
|
|
1235
|
-
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
|
1236
|
-
});
|
|
1237
|
-
const page = await context.newPage();
|
|
1238
|
-
const actionSequence = [];
|
|
1239
|
-
const actionTimeline = [];
|
|
1240
|
-
const screenshots = [];
|
|
1241
|
-
const timeline = [];
|
|
1242
|
-
try {
|
|
1243
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1244
|
-
this.logAct(actionTimeline, actionSequence, "goto:mobile-390x844");
|
|
1245
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1246
|
-
await this.snap(page, "mobile-usability", "01-mobile-banner", screenshots, actionTimeline, actionSequence);
|
|
1247
|
-
const probe = async (selectors) => {
|
|
1248
|
-
for (const sel of selectors) {
|
|
1249
|
-
const loc = page.locator(sel).first();
|
|
1250
|
-
const visible = await loc.isVisible({ timeout: 400 }).catch(() => false);
|
|
1251
|
-
if (visible) {
|
|
1252
|
-
const box = await loc.boundingBox().catch(() => null);
|
|
1253
|
-
const w = Math.round(box?.width ?? 0);
|
|
1254
|
-
const h = Math.round(box?.height ?? 0);
|
|
1255
|
-
return { found: true, selector: sel, visible: true, touchOk: w >= 24 && h >= 24, width: w, height: h };
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
return { found: false, selector: "", visible: false, touchOk: false, width: 0, height: 0 };
|
|
1259
|
-
};
|
|
1260
|
-
const accept = await probe(ACCEPT_SELECTORS);
|
|
1261
|
-
const reject = await probe(REJECT_SELECTORS);
|
|
1262
|
-
const describe = (p) => `${p.found ? "gefunden" : "fehlt"} sichtbar=${p.visible ? "ja" : "nein"} ${p.width}x${p.height}px touch=${p.touchOk ? "ok" : "zu-klein"}`;
|
|
1263
|
-
this.logAct(actionTimeline, actionSequence, `probe:accept ${describe(accept)}`);
|
|
1264
|
-
this.logAct(actionTimeline, actionSequence, `probe:reject ${describe(reject)}`);
|
|
1265
|
-
let rejectTapOk = false;
|
|
1266
|
-
if (reject.found) {
|
|
1267
|
-
const loc = page.locator(reject.selector).first();
|
|
1268
|
-
rejectTapOk = await loc.tap({ timeout: 2e3 }).then(() => true).catch(() => loc.click({ timeout: 2e3 }).then(() => true).catch(() => false));
|
|
1269
|
-
this.logAct(actionTimeline, actionSequence, `tap:reject=${rejectTapOk ? "ok" : "failed"}`);
|
|
1270
|
-
}
|
|
1271
|
-
await page.waitForTimeout(400);
|
|
1272
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1273
|
-
const acceptUsable = accept.found && accept.visible && accept.touchOk;
|
|
1274
|
-
const rejectUsable = reject.found && reject.visible && reject.touchOk;
|
|
1275
|
-
const status = !accept.found && !reject.found ? "not-checkable" : acceptUsable && rejectUsable && rejectTapOk ? "passed" : acceptUsable && reject.found && !rejectUsable ? "suspicious" : "not-checkable";
|
|
1276
|
-
const probeSummary = `Accept: ${describe(accept)} \xB7 Reject: ${describe(reject)} \xB7 Tap-Reject: ${rejectTapOk ? "ok" : "failed/keiner"}`;
|
|
1277
|
-
evidence.push({
|
|
1278
|
-
scenario: "mobile-usability",
|
|
1279
|
-
status,
|
|
1280
|
-
note: status === "passed" ? `Accept und Reject im Mobile-Viewport bedienbar. ${probeSummary}` : status === "suspicious" ? `Nur \xABAkzeptieren\xBB ist bedienbar, \xABAblehnen\xBB nicht (Dark-Pattern-Verdacht). ${probeSummary}` : `Ohne eindeutigen Nachweis nicht pr\xFCfbar. ${probeSummary}`,
|
|
1281
|
-
actionSequence,
|
|
1282
|
-
actionTimeline,
|
|
1283
|
-
requestTimeline: timeline,
|
|
1284
|
-
storageBefore,
|
|
1285
|
-
storageAfter,
|
|
1286
|
-
screenshots,
|
|
1287
|
-
screenshotPath: screenshots[0]?.path,
|
|
1288
|
-
targetUrl: config.targetUrl,
|
|
1289
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1290
|
-
});
|
|
1291
|
-
if (status === "passed") passedScenarios.push("mobile-usability");
|
|
1292
|
-
else failedScenarios.push("mobile-usability");
|
|
1293
|
-
} catch (err) {
|
|
1294
|
-
evidence.push({
|
|
1295
|
-
scenario: "mobile-usability",
|
|
1296
|
-
status: "not-checkable",
|
|
1297
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1298
|
-
actionSequence: [],
|
|
1299
|
-
requestTimeline: [],
|
|
1300
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1301
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1302
|
-
targetUrl: config.targetUrl,
|
|
1303
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1304
|
-
});
|
|
1305
|
-
failedScenarios.push("mobile-usability");
|
|
1306
|
-
await context.close().catch(() => {
|
|
1307
|
-
});
|
|
1308
|
-
} finally {
|
|
1309
|
-
await context.close().catch(() => {
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
async runDuplicateEventsScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1314
|
-
const context = await browser.newContext();
|
|
1315
|
-
const page = await context.newPage();
|
|
1316
|
-
const timeline = [];
|
|
1317
|
-
const actionSequence = [];
|
|
1318
|
-
const actionTimeline = [];
|
|
1319
|
-
const screenshots = [];
|
|
1320
|
-
const pageviews = [];
|
|
1321
|
-
const firstPartySuspicious = [];
|
|
1322
|
-
let phase = "pre-consent";
|
|
1323
|
-
page.on("request", (req) => {
|
|
1324
|
-
const url = req.url();
|
|
1325
|
-
const hit = identifyTracker(url);
|
|
1326
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url, trackerName: hit?.name, phase: "post-accept" });
|
|
1327
|
-
if (/\/(collect|pixel|track|beacon|analytics\.php)/i.test(url) && /page_view|pageview|en=page/i.test(url)) {
|
|
1328
|
-
pageviews.push(url);
|
|
1329
|
-
}
|
|
1330
|
-
try {
|
|
1331
|
-
if (url.startsWith(targetOrigin) && /\/(collect|pixel|track|beacon|_stm|_paq)/i.test(url)) {
|
|
1332
|
-
firstPartySuspicious.push(url);
|
|
1333
|
-
}
|
|
1334
|
-
} catch {
|
|
1335
|
-
}
|
|
1336
|
-
});
|
|
1337
|
-
try {
|
|
1338
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1339
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1340
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1341
|
-
const accepted = await clickFirstMatching(page, ACCEPT_SELECTORS, 2e3);
|
|
1342
|
-
this.logAct(actionTimeline, actionSequence, accepted ? "click:accept" : "click:accept-missing");
|
|
1343
|
-
phase = "post-accept";
|
|
1344
|
-
await page.waitForTimeout(800);
|
|
1345
|
-
pageviews.length = 0;
|
|
1346
|
-
firstPartySuspicious.length = 0;
|
|
1347
|
-
await page.reload({ waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1348
|
-
this.logAct(actionTimeline, actionSequence, "reload:after-consent");
|
|
1349
|
-
await page.waitForTimeout(1500);
|
|
1350
|
-
await this.snap(page, "duplicate-events", "01-after-reload", screenshots, actionTimeline, actionSequence);
|
|
1351
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1352
|
-
const duplicates = pageviews.length > 1;
|
|
1353
|
-
const status = pageviews.length === 0 ? "not-checkable" : duplicates ? "suspicious" : "passed";
|
|
1354
|
-
evidence.push({
|
|
1355
|
-
scenario: "duplicate-events",
|
|
1356
|
-
status,
|
|
1357
|
-
note: [
|
|
1358
|
-
`page_view-Beacons nach Consent-Reload: ${pageviews.length}`,
|
|
1359
|
-
pageviews.length === 0 ? "Keine page_view-Beacons erfasst \u2014 Szenario nicht pr\xFCfbar (keine Analytics-Installation sichtbar)." : duplicates ? "Doppelte Events m\xF6glich (Plugin + GTM parallel)." : "Keine doppelten page_view-Events beobachtet.",
|
|
1360
|
-
firstPartySuspicious.length ? `First-Party-Verdacht: ${firstPartySuspicious.length} same-origin Tracking-Pfad(e) \u2014 Server-zu-Server nur begrenzt beweisbar.` : "Keine verd\xE4chtigen First-Party-Tracking-Pfade."
|
|
1361
|
-
].join(" \xB7 "),
|
|
1362
|
-
actionSequence,
|
|
1363
|
-
actionTimeline,
|
|
1364
|
-
requestTimeline: timeline,
|
|
1365
|
-
storageBefore,
|
|
1366
|
-
storageAfter,
|
|
1367
|
-
screenshots,
|
|
1368
|
-
screenshotPath: screenshots[0]?.path,
|
|
1369
|
-
targetUrl: config.targetUrl,
|
|
1370
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1371
|
-
});
|
|
1372
|
-
if (status === "passed") {
|
|
1373
|
-
passedScenarios.push("duplicate-events");
|
|
1374
|
-
} else if (status === "suspicious") {
|
|
1375
|
-
failedScenarios.push("duplicate-events");
|
|
1376
|
-
violations.push({
|
|
1377
|
-
id: `duplicate-events-${Date.now()}`,
|
|
1378
|
-
ruleId: "duplicate-events",
|
|
1379
|
-
scenario: "duplicate-events",
|
|
1380
|
-
trackerName: "Analytics page_view",
|
|
1381
|
-
trackerCategory: "analytics",
|
|
1382
|
-
requestUrl: pageviews[0] || config.targetUrl,
|
|
1383
|
-
method: "GET",
|
|
1384
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1385
|
-
description: `Mehrere page_view-Beacons pro Aufruf (${pageviews.length}).`
|
|
1386
|
-
});
|
|
1387
|
-
} else {
|
|
1388
|
-
failedScenarios.push("duplicate-events");
|
|
1389
|
-
}
|
|
1390
|
-
} catch (err) {
|
|
1391
|
-
evidence.push({
|
|
1392
|
-
scenario: "duplicate-events",
|
|
1393
|
-
status: "not-checkable",
|
|
1394
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1395
|
-
actionSequence: [],
|
|
1396
|
-
requestTimeline: [],
|
|
1397
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1398
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1399
|
-
targetUrl: config.targetUrl,
|
|
1400
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1401
|
-
});
|
|
1402
|
-
failedScenarios.push("duplicate-events");
|
|
1403
|
-
} finally {
|
|
1404
|
-
await context.close().catch(() => {
|
|
1405
|
-
});
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
async runConsentModeV2Scenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1409
|
-
const context = await browser.newContext();
|
|
1410
|
-
const page = await context.newPage();
|
|
1411
|
-
const timeline = [];
|
|
1412
|
-
const actionSequence = [];
|
|
1413
|
-
const actionTimeline = [];
|
|
1414
|
-
const screenshots = [];
|
|
1415
|
-
const googleRequests = [];
|
|
1416
|
-
let phase = "pre-consent";
|
|
1417
|
-
page.on("request", (req) => {
|
|
1418
|
-
const url = req.url();
|
|
1419
|
-
const isGoogle = /google-analytics\.com|googletagmanager\.com|doubleclick\.net|googleadservices\.com/i.test(url);
|
|
1420
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url, trackerName: identifyTracker(url)?.name, trackerCategory: identifyTracker(url)?.category, phase });
|
|
1421
|
-
if (isGoogle) googleRequests.push(url);
|
|
1422
|
-
});
|
|
1423
|
-
const readConsent = async () => {
|
|
1424
|
-
return page.evaluate(() => {
|
|
1425
|
-
const out = {};
|
|
1426
|
-
try {
|
|
1427
|
-
const dl = window.dataLayer;
|
|
1428
|
-
if (Array.isArray(dl)) {
|
|
1429
|
-
for (const entry of dl) {
|
|
1430
|
-
const arr = entry;
|
|
1431
|
-
if (arr && arr[0] === "consent" && (arr[1] === "default" || arr[1] === "update")) {
|
|
1432
|
-
const v = arr[2];
|
|
1433
|
-
if (v && typeof v === "object") {
|
|
1434
|
-
for (const k of ["ad_storage", "analytics_storage", "ad_user_data", "ad_personalization"]) {
|
|
1435
|
-
if (v[k]) out[k] = v[k];
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
} catch {
|
|
1442
|
-
}
|
|
1443
|
-
return out;
|
|
1444
|
-
}).catch(() => ({}));
|
|
1445
|
-
};
|
|
1446
|
-
try {
|
|
1447
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1448
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1449
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1450
|
-
const defaults = await readConsent();
|
|
1451
|
-
this.logAct(actionTimeline, actionSequence, `consent:default(${Object.keys(defaults).join(",") || "none"})`);
|
|
1452
|
-
const denied = await clickFirstMatching(page, REJECT_SELECTORS, 2e3);
|
|
1453
|
-
this.logAct(actionTimeline, actionSequence, denied ? "click:reject" : "click:accept-or-none");
|
|
1454
|
-
if (!denied) await clickFirstMatching(page, ACCEPT_SELECTORS, 1500);
|
|
1455
|
-
phase = "post-accept";
|
|
1456
|
-
await page.waitForTimeout(1200);
|
|
1457
|
-
const after = await readConsent();
|
|
1458
|
-
this.logAct(actionTimeline, actionSequence, `consent:after(${Object.keys(after).join(",") || "none"})`);
|
|
1459
|
-
await this.snap(page, "consent-mode-v2", "01-after-choice", screenshots, actionTimeline, actionSequence);
|
|
1460
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1461
|
-
const hasConsentMode = Object.keys(defaults).length > 0 || Object.keys(after).length > 0;
|
|
1462
|
-
const gaCookies = storageAfter.cookies.filter((c) => /^_ga|_gid|_gat|_gcl/i.test(c));
|
|
1463
|
-
const analyticsState = after.analytics_storage ?? defaults.analytics_storage ?? "unset";
|
|
1464
|
-
const adState = after.ad_storage ?? defaults.ad_storage ?? "unset";
|
|
1465
|
-
const mismatch = analyticsState === "denied" && gaCookies.length > 0 || adState === "denied" && googleRequests.some((u) => /doubleclick|googleadservices/i.test(u));
|
|
1466
|
-
const noteParts = [
|
|
1467
|
-
`consent-mode states: analytics_storage=${analyticsState}, ad_storage=${adState}, ad_user_data=${after.ad_user_data ?? defaults.ad_user_data ?? "unset"}, ad_personalization=${after.ad_personalization ?? defaults.ad_personalization ?? "unset"}`,
|
|
1468
|
-
`google requests: ${googleRequests.length}`,
|
|
1469
|
-
`ga cookies: ${gaCookies.length || "none"}`,
|
|
1470
|
-
hasConsentMode ? mismatch ? "Zustand und Verhalten widersprechen sich." : "Zustand und Verhalten stimmen \xFCberein." : "Kein Consent Mode (dataLayer) gefunden \u2014 nicht pr\xFCfbar."
|
|
1471
|
-
];
|
|
1472
|
-
evidence.push({
|
|
1473
|
-
scenario: "consent-mode-v2",
|
|
1474
|
-
status: !hasConsentMode ? "not-checkable" : mismatch ? "suspicious" : "passed",
|
|
1475
|
-
note: noteParts.join(" \xB7 "),
|
|
1476
|
-
actionSequence,
|
|
1477
|
-
actionTimeline,
|
|
1478
|
-
requestTimeline: timeline,
|
|
1479
|
-
storageBefore,
|
|
1480
|
-
storageAfter,
|
|
1481
|
-
screenshots,
|
|
1482
|
-
screenshotPath: screenshots[0]?.path,
|
|
1483
|
-
targetUrl: config.targetUrl,
|
|
1484
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1485
|
-
});
|
|
1486
|
-
if (!hasConsentMode) {
|
|
1487
|
-
failedScenarios.push("consent-mode-v2");
|
|
1488
|
-
} else if (mismatch) {
|
|
1489
|
-
violations.push({
|
|
1490
|
-
id: `consent-mode-v2-${Date.now()}`,
|
|
1491
|
-
ruleId: "consent-mode-mismatch",
|
|
1492
|
-
scenario: "consent-mode-v2",
|
|
1493
|
-
trackerName: "Google Consent Mode",
|
|
1494
|
-
trackerCategory: "analytics",
|
|
1495
|
-
requestUrl: googleRequests[0] || config.targetUrl,
|
|
1496
|
-
method: "GET",
|
|
1497
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1498
|
-
description: "Consent-Mode-Zustand und tats\xE4chliche Google-Requests/Cookies widersprechen sich."
|
|
1499
|
-
});
|
|
1500
|
-
failedScenarios.push("consent-mode-v2");
|
|
1501
|
-
} else {
|
|
1502
|
-
passedScenarios.push("consent-mode-v2");
|
|
1503
|
-
}
|
|
1504
|
-
} catch (err) {
|
|
1505
|
-
evidence.push({
|
|
1506
|
-
scenario: "consent-mode-v2",
|
|
1507
|
-
status: "not-checkable",
|
|
1508
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1509
|
-
actionSequence: [],
|
|
1510
|
-
requestTimeline: [],
|
|
1511
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1512
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1513
|
-
targetUrl: config.targetUrl,
|
|
1514
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1515
|
-
});
|
|
1516
|
-
failedScenarios.push("consent-mode-v2");
|
|
1517
|
-
} finally {
|
|
1518
|
-
await context.close().catch(() => {
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
async runEmbedScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1523
|
-
const context = await browser.newContext();
|
|
1524
|
-
const page = await context.newPage();
|
|
1525
|
-
const timeline = [];
|
|
1526
|
-
const actionSequence = [];
|
|
1527
|
-
const actionTimeline = [];
|
|
1528
|
-
const screenshots = [];
|
|
1529
|
-
page.on("request", (req) => {
|
|
1530
|
-
const hit = identifyTracker(req.url());
|
|
1531
|
-
timeline.push({
|
|
1532
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1533
|
-
url: req.url(),
|
|
1534
|
-
trackerName: hit?.name,
|
|
1535
|
-
phase: "pre-consent"
|
|
1536
|
-
});
|
|
1537
|
-
if (hit && /youtube|maps|facebook|twitter|embed|chat|widget/i.test(hit.name)) {
|
|
1538
|
-
violations.push({
|
|
1539
|
-
id: `embed-leaks-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1540
|
-
ruleId: "pre-consent-embed",
|
|
1541
|
-
scenario: "embed-leaks",
|
|
1542
|
-
trackerName: hit.name,
|
|
1543
|
-
trackerCategory: hit.category,
|
|
1544
|
-
requestUrl: req.url(),
|
|
1545
|
-
method: req.method(),
|
|
1546
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1547
|
-
description: `${hit.name} (Embed) kontaktiert Dritte vor Consent-Freigabe.`
|
|
1548
|
-
});
|
|
1549
|
-
}
|
|
1550
|
-
});
|
|
1551
|
-
try {
|
|
1552
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1553
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1554
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1555
|
-
const EMBED_SCOPE = "iframe, [data-src*='youtube'], [data-src*='maps'], .fb-video, .twitter-tweet";
|
|
1556
|
-
const embedsEval = await page.evaluate(() => {
|
|
1557
|
-
const nodes = Array.from(
|
|
1558
|
-
document.querySelectorAll("iframe, [data-src*='youtube'], [data-src*='maps'], .fb-video, .twitter-tweet")
|
|
1559
|
-
);
|
|
1560
|
-
return nodes.map((n) => {
|
|
1561
|
-
const el = n;
|
|
1562
|
-
return {
|
|
1563
|
-
tag: el.tagName.toLowerCase(),
|
|
1564
|
-
src: el.getAttribute("src") || el.getAttribute("data-src") || "",
|
|
1565
|
-
visible: !!(el.offsetParent || el.getClientRects().length)
|
|
1566
|
-
};
|
|
1567
|
-
});
|
|
1568
|
-
}).catch(() => null);
|
|
1569
|
-
const embeds = embedsEval ?? [];
|
|
1570
|
-
const embedsEvalOk = embedsEval !== null;
|
|
1571
|
-
this.logAct(actionTimeline, actionSequence, `embeds:found(${embeds.length})`);
|
|
1572
|
-
await page.waitForTimeout(1500);
|
|
1573
|
-
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight / 2)).catch(() => {
|
|
1574
|
-
});
|
|
1575
|
-
this.logAct(actionTimeline, actionSequence, "scroll:half");
|
|
1576
|
-
await page.waitForTimeout(1500);
|
|
1577
|
-
await this.snap(page, "embed-leaks", "01-embeds", screenshots, actionTimeline, actionSequence);
|
|
1578
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1579
|
-
const embedLeaks = timeline.filter((e) => e.trackerName && /youtube|maps|facebook|twitter/i.test(e.trackerName));
|
|
1580
|
-
if (embeds.length === 0 && embedLeaks.length === 0) {
|
|
1581
|
-
const status2 = embedsEvalOk ? "not-applicable" : "not-checkable";
|
|
1582
|
-
evidence.push({
|
|
1583
|
-
scenario: "embed-leaks",
|
|
1584
|
-
status: status2,
|
|
1585
|
-
note: embedsEvalOk ? `Nicht anwendbar: keine eingebetteten Inhalte im Suchumfang vorhanden (${EMBED_SCOPE}).` : `DOM-Auswertung der Embeds fehlgeschlagen \u2014 Erkennung nicht pr\xFCfbar (Suchumfang: ${EMBED_SCOPE}).`,
|
|
1586
|
-
actionSequence,
|
|
1587
|
-
actionTimeline,
|
|
1588
|
-
requestTimeline: timeline,
|
|
1589
|
-
storageBefore,
|
|
1590
|
-
storageAfter,
|
|
1591
|
-
screenshots,
|
|
1592
|
-
screenshotPath: screenshots[0]?.path,
|
|
1593
|
-
targetUrl: config.targetUrl,
|
|
1594
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1595
|
-
});
|
|
1596
|
-
if (!embedsEvalOk) failedScenarios.push("embed-leaks");
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
const status = embedLeaks.length > 0 ? "suspicious" : "passed";
|
|
1600
|
-
evidence.push({
|
|
1601
|
-
scenario: "embed-leaks",
|
|
1602
|
-
status,
|
|
1603
|
-
note: status === "passed" ? `${embeds.length} Embed(s) gefunden, keine Drittanbieter-Requests vor Freigabe.` : `${embedLeaks.length} Embed-/Widget-Request(s) vor Consent-Freigabe.`,
|
|
1604
|
-
actionSequence,
|
|
1605
|
-
actionTimeline,
|
|
1606
|
-
requestTimeline: timeline,
|
|
1607
|
-
storageBefore,
|
|
1608
|
-
storageAfter,
|
|
1609
|
-
screenshots,
|
|
1610
|
-
screenshotPath: screenshots[0]?.path,
|
|
1611
|
-
targetUrl: config.targetUrl,
|
|
1612
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1613
|
-
});
|
|
1614
|
-
if (status === "passed") passedScenarios.push("embed-leaks");
|
|
1615
|
-
else failedScenarios.push("embed-leaks");
|
|
1616
|
-
} catch (err) {
|
|
1617
|
-
evidence.push({
|
|
1618
|
-
scenario: "embed-leaks",
|
|
1619
|
-
status: "not-checkable",
|
|
1620
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1621
|
-
actionSequence: [],
|
|
1622
|
-
requestTimeline: [],
|
|
1623
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1624
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1625
|
-
targetUrl: config.targetUrl,
|
|
1626
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1627
|
-
});
|
|
1628
|
-
failedScenarios.push("embed-leaks");
|
|
1629
|
-
} finally {
|
|
1630
|
-
await context.close().catch(() => {
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
async runCmpFailureScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1635
|
-
const context = await browser.newContext();
|
|
1636
|
-
const page = await context.newPage();
|
|
1637
|
-
const timeline = [];
|
|
1638
|
-
const actionSequence = [];
|
|
1639
|
-
const actionTimeline = [];
|
|
1640
|
-
const screenshots = [];
|
|
1641
|
-
page.on("request", (req) => {
|
|
1642
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
1643
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1644
|
-
const hit = identifyTracker(req.url());
|
|
1645
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url: req.url(), trackerName: hit?.name, phase: "pre-consent" });
|
|
1646
|
-
if (hit) {
|
|
1647
|
-
violations.push({
|
|
1648
|
-
id: `cmp-failure-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1649
|
-
ruleId: "pre-consent-tracking",
|
|
1650
|
-
scenario: "cmp-failure",
|
|
1651
|
-
trackerName: hit.name,
|
|
1652
|
-
trackerCategory: hit.category,
|
|
1653
|
-
requestUrl: req.url(),
|
|
1654
|
-
method: req.method(),
|
|
1655
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1656
|
-
description: `${hit.name} startet, obwohl das CMP nicht verf\xFCgbar/verz\xF6gert ist.`
|
|
1657
|
-
});
|
|
1658
|
-
}
|
|
1659
|
-
});
|
|
1660
|
-
try {
|
|
1661
|
-
await page.route(
|
|
1662
|
-
(url) => {
|
|
1663
|
-
const href = url.href;
|
|
1664
|
-
if (href.startsWith(targetOrigin)) return false;
|
|
1665
|
-
return /cookiebot|onetrust|cookielaw|usercentrics|iubenda|didomi|axept|tealium|consentmanager|borlabs|klaro|etracker/i.test(
|
|
1666
|
-
href
|
|
1667
|
-
);
|
|
1668
|
-
},
|
|
1669
|
-
async (route) => {
|
|
1670
|
-
await new Promise((r) => setTimeout(r, 2500));
|
|
1671
|
-
await route.continue().catch(() => route.abort().catch(() => {
|
|
1672
|
-
}));
|
|
1673
|
-
}
|
|
1674
|
-
);
|
|
1675
|
-
this.logAct(actionTimeline, actionSequence, "route:cmp-delay-2500ms");
|
|
1676
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1677
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1678
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1679
|
-
await page.waitForTimeout(2e3);
|
|
1680
|
-
this.logAct(actionTimeline, actionSequence, "wait:window-2000ms");
|
|
1681
|
-
await this.snap(page, "cmp-failure", "01-delay-window", screenshots, actionTimeline, actionSequence);
|
|
1682
|
-
await page.unrouteAll().catch(() => {
|
|
1683
|
-
});
|
|
1684
|
-
await page.route(
|
|
1685
|
-
(url) => {
|
|
1686
|
-
const href = url.href;
|
|
1687
|
-
if (href.startsWith(targetOrigin)) return false;
|
|
1688
|
-
return /cookiebot|onetrust|cookielaw|usercentrics|iubenda|didomi|axept|tealium|consentmanager|borlabs|klaro|etracker/i.test(
|
|
1689
|
-
href
|
|
1690
|
-
);
|
|
1691
|
-
},
|
|
1692
|
-
(route) => route.abort()
|
|
1693
|
-
);
|
|
1694
|
-
this.logAct(actionTimeline, actionSequence, "route:cmp-abort");
|
|
1695
|
-
await page.reload({ waitUntil: "domcontentloaded", timeout: config.timeoutMs }).catch(() => {
|
|
1696
|
-
});
|
|
1697
|
-
await page.waitForTimeout(1800);
|
|
1698
|
-
this.logAct(actionTimeline, actionSequence, "reload:without-cmp");
|
|
1699
|
-
await this.snap(page, "cmp-failure", "02-cmp-abort", screenshots, actionTimeline, actionSequence);
|
|
1700
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1701
|
-
const leaks = timeline.filter((e) => e.trackerName);
|
|
1702
|
-
const status = leaks.length > 0 ? "suspicious" : "passed";
|
|
1703
|
-
evidence.push({
|
|
1704
|
-
scenario: "cmp-failure",
|
|
1705
|
-
status,
|
|
1706
|
-
note: status === "passed" ? "Kein Tracking im Zeitfenster bei verz\xF6gertem oder ausgefallenem CMP." : `${leaks.length} Tracker im Zeitfenster bei verz\xF6gertem/defektem CMP.`,
|
|
1707
|
-
actionSequence,
|
|
1708
|
-
actionTimeline,
|
|
1709
|
-
requestTimeline: timeline,
|
|
1710
|
-
storageBefore,
|
|
1711
|
-
storageAfter,
|
|
1712
|
-
screenshots,
|
|
1713
|
-
screenshotPath: screenshots[0]?.path,
|
|
1714
|
-
targetUrl: config.targetUrl,
|
|
1715
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1716
|
-
});
|
|
1717
|
-
if (status === "passed") passedScenarios.push("cmp-failure");
|
|
1718
|
-
else failedScenarios.push("cmp-failure");
|
|
1719
|
-
} catch (err) {
|
|
1720
|
-
evidence.push({
|
|
1721
|
-
scenario: "cmp-failure",
|
|
1722
|
-
status: "not-checkable",
|
|
1723
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1724
|
-
actionSequence: [],
|
|
1725
|
-
requestTimeline: [],
|
|
1726
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1727
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1728
|
-
targetUrl: config.targetUrl,
|
|
1729
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1730
|
-
});
|
|
1731
|
-
failedScenarios.push("cmp-failure");
|
|
1732
|
-
} finally {
|
|
1733
|
-
await context.close().catch(() => {
|
|
1734
|
-
});
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
async runSubpageSpaScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1738
|
-
const context = await browser.newContext();
|
|
1739
|
-
const page = await context.newPage();
|
|
1740
|
-
const timeline = [];
|
|
1741
|
-
const actionSequence = [];
|
|
1742
|
-
const actionTimeline = [];
|
|
1743
|
-
const screenshots = [];
|
|
1744
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1745
|
-
const capture = (phase, req) => {
|
|
1746
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
1747
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1748
|
-
const hit = identifyTracker(req.url());
|
|
1749
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url: req.url(), trackerName: hit?.name, trackerCategory: hit?.category, phase });
|
|
1750
|
-
if (hit && phase === "pre-consent") {
|
|
1751
|
-
violations.push({
|
|
1752
|
-
id: `subpage-spa-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1753
|
-
ruleId: "pre-consent-tracking",
|
|
1754
|
-
scenario: "subpage-spa",
|
|
1755
|
-
trackerName: hit.name,
|
|
1756
|
-
trackerCategory: hit.category,
|
|
1757
|
-
requestUrl: req.url(),
|
|
1758
|
-
method: req.method(),
|
|
1759
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1760
|
-
description: `${hit.name} feuert auf Unterseite/SPA-Navigation ohne Consent.`
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
};
|
|
1764
|
-
page.on("request", (req) => capture("pre-consent", req));
|
|
1765
|
-
try {
|
|
1766
|
-
const subpaths = ["/blog/", "/kontakt.html", "/shop/", "/impressum.html"];
|
|
1767
|
-
for (const p of subpaths) {
|
|
1768
|
-
await page.goto(`${targetOrigin}${p}`, { waitUntil: "domcontentloaded", timeout: config.timeoutMs }).catch(() => {
|
|
1769
|
-
});
|
|
1770
|
-
this.logAct(actionTimeline, actionSequence, `goto:subpage${p}`);
|
|
1771
|
-
await page.waitForTimeout(600);
|
|
1772
|
-
}
|
|
1773
|
-
await this.snap(page, "subpage-spa", "01-subpages", screenshots, actionTimeline, actionSequence);
|
|
1774
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1775
|
-
this.logAct(actionTimeline, actionSequence, "goto:root");
|
|
1776
|
-
await page.evaluate(() => {
|
|
1777
|
-
history.pushState({}, "", "/spa-route-1");
|
|
1778
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
1779
|
-
}).catch(() => {
|
|
1780
|
-
});
|
|
1781
|
-
this.logAct(actionTimeline, actionSequence, "spa:pushState");
|
|
1782
|
-
await page.waitForTimeout(800);
|
|
1783
|
-
const clicked = await page.evaluate(() => {
|
|
1784
|
-
const a = Array.from(document.querySelectorAll("a[href]")).find((el) => {
|
|
1785
|
-
try {
|
|
1786
|
-
const u = new URL(el.getAttribute("href") || "", location.href);
|
|
1787
|
-
return u.origin === location.origin && !u.hash;
|
|
1788
|
-
} catch {
|
|
1789
|
-
return false;
|
|
1790
|
-
}
|
|
1791
|
-
});
|
|
1792
|
-
if (a) {
|
|
1793
|
-
a.click();
|
|
1794
|
-
return true;
|
|
1795
|
-
}
|
|
1796
|
-
return false;
|
|
1797
|
-
}).catch(() => false);
|
|
1798
|
-
this.logAct(actionTimeline, actionSequence, clicked ? "spa:click-internal" : "spa:no-internal-link");
|
|
1799
|
-
await page.waitForTimeout(1e3);
|
|
1800
|
-
await this.snap(page, "subpage-spa", "02-after-spa", screenshots, actionTimeline, actionSequence);
|
|
1801
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1802
|
-
const leaks = timeline.filter((e) => e.trackerName && e.phase === "pre-consent");
|
|
1803
|
-
const status = leaks.length > 0 ? "suspicious" : "passed";
|
|
1804
|
-
evidence.push({
|
|
1805
|
-
scenario: "subpage-spa",
|
|
1806
|
-
status,
|
|
1807
|
-
note: status === "passed" ? "Kein unconsent-Tracking auf Unterseiten oder bei SPA-Navigation." : `${leaks.length} Tracker auf Unterseite/SPA ohne Consent.`,
|
|
1808
|
-
actionSequence,
|
|
1809
|
-
actionTimeline,
|
|
1810
|
-
requestTimeline: timeline,
|
|
1811
|
-
storageBefore,
|
|
1812
|
-
storageAfter,
|
|
1813
|
-
screenshots,
|
|
1814
|
-
screenshotPath: screenshots[0]?.path,
|
|
1815
|
-
targetUrl: config.targetUrl,
|
|
1816
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1817
|
-
});
|
|
1818
|
-
if (status === "passed") passedScenarios.push("subpage-spa");
|
|
1819
|
-
else failedScenarios.push("subpage-spa");
|
|
1820
|
-
} catch (err) {
|
|
1821
|
-
evidence.push({
|
|
1822
|
-
scenario: "subpage-spa",
|
|
1823
|
-
status: "not-checkable",
|
|
1824
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1825
|
-
actionSequence: [],
|
|
1826
|
-
requestTimeline: [],
|
|
1827
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1828
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1829
|
-
targetUrl: config.targetUrl,
|
|
1830
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1831
|
-
});
|
|
1832
|
-
failedScenarios.push("subpage-spa");
|
|
1833
|
-
} finally {
|
|
1834
|
-
await context.close().catch(() => {
|
|
1835
|
-
});
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
async runBannerIgnoredScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1839
|
-
const context = await browser.newContext();
|
|
1840
|
-
const page = await context.newPage();
|
|
1841
|
-
const timeline = [];
|
|
1842
|
-
const actionSequence = [];
|
|
1843
|
-
const actionTimeline = [];
|
|
1844
|
-
const screenshots = [];
|
|
1845
|
-
const phase = "pre-consent";
|
|
1846
|
-
page.on("request", (req) => {
|
|
1847
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
1848
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1849
|
-
const hit = identifyTracker(req.url());
|
|
1850
|
-
timeline.push({ at: (/* @__PURE__ */ new Date()).toISOString(), url: req.url(), trackerName: hit?.name, trackerCategory: hit?.category, phase });
|
|
1851
|
-
if (hit) {
|
|
1852
|
-
violations.push({
|
|
1853
|
-
id: `banner-ignored-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1854
|
-
ruleId: "pre-consent-tracking",
|
|
1855
|
-
scenario: "banner-ignored",
|
|
1856
|
-
trackerName: hit.name,
|
|
1857
|
-
trackerCategory: hit.category,
|
|
1858
|
-
requestUrl: req.url(),
|
|
1859
|
-
method: req.method(),
|
|
1860
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1861
|
-
description: `${hit.name} startet ohne Consent-Klick (Timer/Scroll/Interaktion).`
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
});
|
|
1865
|
-
try {
|
|
1866
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1867
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1868
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1869
|
-
await this.snap(page, "banner-ignored", "01-loaded", screenshots, actionTimeline, actionSequence);
|
|
1870
|
-
await page.waitForTimeout(2500);
|
|
1871
|
-
this.logAct(actionTimeline, actionSequence, "wait:2500ms");
|
|
1872
|
-
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)).catch(() => {
|
|
1873
|
-
});
|
|
1874
|
-
this.logAct(actionTimeline, actionSequence, "scroll:bottom");
|
|
1875
|
-
await page.waitForTimeout(1200);
|
|
1876
|
-
await page.mouse.move(120, 200).catch(() => {
|
|
1877
|
-
});
|
|
1878
|
-
await page.mouse.click(120, 200).catch(() => {
|
|
1879
|
-
});
|
|
1880
|
-
this.logAct(actionTimeline, actionSequence, "click:page-body");
|
|
1881
|
-
await page.waitForTimeout(1500);
|
|
1882
|
-
await page.keyboard.type("test").catch(() => {
|
|
1883
|
-
});
|
|
1884
|
-
this.logAct(actionTimeline, actionSequence, "type:test");
|
|
1885
|
-
await page.waitForTimeout(800);
|
|
1886
|
-
await this.snap(page, "banner-ignored", "02-after-interact", screenshots, actionTimeline, actionSequence);
|
|
1887
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
1888
|
-
const leaks = timeline.filter((e) => e.trackerName);
|
|
1889
|
-
const status = leaks.length > 0 ? "suspicious" : "passed";
|
|
1890
|
-
evidence.push({
|
|
1891
|
-
scenario: "banner-ignored",
|
|
1892
|
-
status,
|
|
1893
|
-
note: status === "passed" ? "Kein Tracking allein durch Warten, Scrollen, Klick auf die Seite oder Tippen." : `${leaks.length} Tracker ohne Consent-Klick aktiv (Timer/Scroll/Interaktion).`,
|
|
1894
|
-
actionSequence,
|
|
1895
|
-
actionTimeline,
|
|
1896
|
-
requestTimeline: timeline,
|
|
1897
|
-
storageBefore,
|
|
1898
|
-
storageAfter,
|
|
1899
|
-
screenshots,
|
|
1900
|
-
screenshotPath: screenshots[0]?.path,
|
|
1901
|
-
targetUrl: config.targetUrl,
|
|
1902
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1903
|
-
});
|
|
1904
|
-
if (status === "passed") passedScenarios.push("banner-ignored");
|
|
1905
|
-
else failedScenarios.push("banner-ignored");
|
|
1906
|
-
} catch (err) {
|
|
1907
|
-
evidence.push({
|
|
1908
|
-
scenario: "banner-ignored",
|
|
1909
|
-
status: "not-checkable",
|
|
1910
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
1911
|
-
actionSequence: [],
|
|
1912
|
-
requestTimeline: [],
|
|
1913
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1914
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
1915
|
-
targetUrl: config.targetUrl,
|
|
1916
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1917
|
-
});
|
|
1918
|
-
failedScenarios.push("banner-ignored");
|
|
1919
|
-
} finally {
|
|
1920
|
-
await context.close().catch(() => {
|
|
1921
|
-
});
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
async runRevocationScenario(browser, config, targetOrigin, violations, passedScenarios, failedScenarios, evidence) {
|
|
1925
|
-
const context = await browser.newContext();
|
|
1926
|
-
const page = await context.newPage();
|
|
1927
|
-
const timeline = [];
|
|
1928
|
-
const actionSequence = [];
|
|
1929
|
-
const actionTimeline = [];
|
|
1930
|
-
const screenshots = [];
|
|
1931
|
-
let phase = "pre-consent";
|
|
1932
|
-
const identify = (url) => identifyTracker(url);
|
|
1933
|
-
page.on("request", (req) => {
|
|
1934
|
-
const frameUrl = req.frame()?.url() || page.url();
|
|
1935
|
-
if (!isOnTargetOrigin(frameUrl, targetOrigin)) return;
|
|
1936
|
-
const hit = identify(req.url());
|
|
1937
|
-
timeline.push({
|
|
1938
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1939
|
-
url: req.url(),
|
|
1940
|
-
trackerName: hit?.name,
|
|
1941
|
-
phase
|
|
1942
|
-
});
|
|
1943
|
-
if (hit) {
|
|
1944
|
-
const violation = {
|
|
1945
|
-
id: `consent-revoked-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1946
|
-
ruleId: phase === "post-revoke" ? "tracking-after-revoke" : "pre-consent-tracking",
|
|
1947
|
-
scenario: "consent-revoked",
|
|
1948
|
-
trackerName: hit.name,
|
|
1949
|
-
trackerCategory: hit.category,
|
|
1950
|
-
requestUrl: req.url(),
|
|
1951
|
-
method: req.method(),
|
|
1952
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1953
|
-
description: phase === "post-revoke" ? `${hit.name} feuert nach Widerruf der Einwilligung.` : `Unauthorized ${hit.name} during consent-revoked scenario (${phase}).`
|
|
1954
|
-
};
|
|
1955
|
-
if (phase === "post-revoke") {
|
|
1956
|
-
violations.push(violation);
|
|
1957
|
-
}
|
|
1958
|
-
}
|
|
1959
|
-
});
|
|
1960
|
-
try {
|
|
1961
|
-
await page.goto(config.targetUrl, { waitUntil: "domcontentloaded", timeout: config.timeoutMs });
|
|
1962
|
-
this.logAct(actionTimeline, actionSequence, "goto");
|
|
1963
|
-
const storageBefore = await this.snapshotStorage(page);
|
|
1964
|
-
await this.snap(page, "consent-revoked", "01-banner", screenshots, actionTimeline, actionSequence);
|
|
1965
|
-
const accepted = await clickFirstMatching(page, ACCEPT_SELECTORS, 2500);
|
|
1966
|
-
this.logAct(actionTimeline, actionSequence, accepted ? "click:accept" : "click:accept-missing");
|
|
1967
|
-
phase = "post-accept";
|
|
1968
|
-
await page.waitForTimeout(800);
|
|
1969
|
-
await page.waitForTimeout(400);
|
|
1970
|
-
const storageAfterAccept = await this.snapshotStorage(page);
|
|
1971
|
-
this.logAct(actionTimeline, actionSequence, "snapshot:after-accept");
|
|
1972
|
-
await this.snap(page, "consent-revoked", "02-after-accept", screenshots, actionTimeline, actionSequence);
|
|
1973
|
-
let revokeTargetVisible = false;
|
|
1974
|
-
for (const sel of REJECT_SELECTORS) {
|
|
1975
|
-
const vis = await page.locator(sel).first().isVisible({ timeout: 300 }).catch(() => false);
|
|
1976
|
-
if (vis) {
|
|
1977
|
-
revokeTargetVisible = true;
|
|
1978
|
-
break;
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
if (revokeTargetVisible) {
|
|
1982
|
-
phase = "post-revoke";
|
|
1983
|
-
this.logAct(actionTimeline, actionSequence, "phase:post-revoke");
|
|
1984
|
-
}
|
|
1985
|
-
const revoked = await clickFirstMatching(page, REJECT_SELECTORS, 2500);
|
|
1986
|
-
this.logAct(actionTimeline, actionSequence, revoked ? "click:revoke" : "click:revoke-missing");
|
|
1987
|
-
if (!revoked && !accepted) {
|
|
1988
|
-
const storageAfter2 = await this.snapshotStorage(page);
|
|
1989
|
-
evidence.push({
|
|
1990
|
-
scenario: "consent-revoked",
|
|
1991
|
-
status: "not-checkable",
|
|
1992
|
-
note: "Weder Accept- noch Revoke-Button auffindbar \u2014 Widerruf nicht pr\xFCfbar (kein gr\xFCnes H\xE4kchen).",
|
|
1993
|
-
actionSequence,
|
|
1994
|
-
actionTimeline,
|
|
1995
|
-
requestTimeline: timeline,
|
|
1996
|
-
storageBefore,
|
|
1997
|
-
storageAfter: storageAfter2,
|
|
1998
|
-
screenshots,
|
|
1999
|
-
screenshotPath: screenshots[0]?.path,
|
|
2000
|
-
targetUrl: config.targetUrl,
|
|
2001
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2002
|
-
});
|
|
2003
|
-
failedScenarios.push("consent-revoked");
|
|
2004
|
-
return;
|
|
2005
|
-
}
|
|
2006
|
-
if (!revoked) {
|
|
2007
|
-
actionSequence.push("fallback:storage-wipe-skipped");
|
|
2008
|
-
const storageAfter2 = await this.snapshotStorage(page);
|
|
2009
|
-
evidence.push({
|
|
2010
|
-
scenario: "consent-revoked",
|
|
2011
|
-
status: "not-checkable",
|
|
2012
|
-
note: "Kein Widerruf-Button \u2014 Persistenz/Widerruf nicht pr\xFCfbar.",
|
|
2013
|
-
actionSequence,
|
|
2014
|
-
actionTimeline,
|
|
2015
|
-
requestTimeline: timeline,
|
|
2016
|
-
storageBefore,
|
|
2017
|
-
storageAfter: storageAfter2,
|
|
2018
|
-
screenshots,
|
|
2019
|
-
screenshotPath: screenshots[0]?.path,
|
|
2020
|
-
targetUrl: config.targetUrl,
|
|
2021
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2022
|
-
});
|
|
2023
|
-
failedScenarios.push("consent-revoked");
|
|
2024
|
-
return;
|
|
2025
|
-
}
|
|
2026
|
-
if (!accepted) {
|
|
2027
|
-
const storageAfter2 = await this.snapshotStorage(page);
|
|
2028
|
-
evidence.push({
|
|
2029
|
-
scenario: "consent-revoked",
|
|
2030
|
-
status: "not-checkable",
|
|
2031
|
-
note: "Widerruf nicht pr\xFCfbar \u2014 keine vorherige Einwilligung erteilt (Accept-Button fehlte).",
|
|
2032
|
-
actionSequence,
|
|
2033
|
-
actionTimeline,
|
|
2034
|
-
requestTimeline: timeline,
|
|
2035
|
-
storageBefore,
|
|
2036
|
-
storageAfter: storageAfter2,
|
|
2037
|
-
storageAfterAccept,
|
|
2038
|
-
screenshots,
|
|
2039
|
-
screenshotPath: screenshots[0]?.path,
|
|
2040
|
-
targetUrl: config.targetUrl,
|
|
2041
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2042
|
-
});
|
|
2043
|
-
failedScenarios.push("consent-revoked");
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
await page.waitForTimeout(300);
|
|
2047
|
-
const observationMs = 2e3;
|
|
2048
|
-
await page.goto(`${targetOrigin}/after-revoke`, { waitUntil: "domcontentloaded", timeout: config.timeoutMs }).catch(() => {
|
|
2049
|
-
});
|
|
2050
|
-
this.logAct(actionTimeline, actionSequence, "post-revoke:goto");
|
|
2051
|
-
await page.evaluate(() => {
|
|
2052
|
-
window.dispatchEvent(new Event("scroll"));
|
|
2053
|
-
window.dispatchEvent(new Event("click"));
|
|
2054
|
-
document.body?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
2055
|
-
}).catch(() => {
|
|
2056
|
-
});
|
|
2057
|
-
this.logAct(actionTimeline, actionSequence, `post-revoke:events observe=${observationMs}ms`);
|
|
2058
|
-
await page.waitForTimeout(observationMs);
|
|
2059
|
-
const storageAfter = await this.snapshotStorage(page);
|
|
2060
|
-
await this.snap(page, "consent-revoked", "03-after-revoke", screenshots, actionTimeline, actionSequence);
|
|
2061
|
-
const RESIDUAL_COOKIE = /^(_ga|_gid|_gat|_gcl|_fbp|_fbc|_tt|_ttp|_uetsid|_uetsid|li_s|li_at|hjSessionUser_|hjSession_|mkto_trk|hubspotutk|__hssc|__hstc|_opt_out)/i;
|
|
2062
|
-
const RESIDUAL_LS = /^(_ga|_gid|_gat|_gcl|_fbp|_fbc|_tt|_ttp|_hs|_hs_|mkto_|hubspot|munchkin|_gcl_)[A-Z0-9_.-]*$/i;
|
|
2063
|
-
const residual = [
|
|
2064
|
-
...storageAfter.cookies.filter((c) => RESIDUAL_COOKIE.test(c.split("@")[0])),
|
|
2065
|
-
...storageAfter.localStorageKeys.filter((k) => RESIDUAL_LS.test(k))
|
|
2066
|
-
];
|
|
2067
|
-
const postRevokeTrackers = timeline.filter((e) => e.phase === "post-revoke" && e.trackerName);
|
|
2068
|
-
const status = postRevokeTrackers.length > 0 || residual.length > 0 ? "suspicious" : "passed";
|
|
2069
|
-
const removedIds = [
|
|
2070
|
-
...storageAfterAccept.cookies.filter((c) => !storageAfter.cookies.includes(c)),
|
|
2071
|
-
...storageAfterAccept.localStorageKeys.filter((k) => !storageAfter.localStorageKeys.includes(k))
|
|
2072
|
-
];
|
|
2073
|
-
const keptIds = [
|
|
2074
|
-
...storageAfter.cookies.filter((c) => storageAfterAccept.cookies.includes(c)),
|
|
2075
|
-
...storageAfter.localStorageKeys.filter((k) => storageAfterAccept.localStorageKeys.includes(k))
|
|
2076
|
-
];
|
|
2077
|
-
evidence.push({
|
|
2078
|
-
scenario: "consent-revoked",
|
|
2079
|
-
status,
|
|
2080
|
-
note: (status === "passed" ? `Keine Tracker-Requests nach Widerruf (Beobachtungsfenster ${observationMs} ms mit Seitenwechsel und Events); keine auff\xE4lligen Tracking-IDs zur\xFCckgeblieben.` : `Nach Widerruf (Beobachtungsfenster ${observationMs} ms, Seitenwechsel + Events): ${postRevokeTrackers.length} Tracker-Request(s), ${residual.length} Rest-ID(s).`) + ` Widerruf-Diff: ${removedIds.length} Storage-Eintrag(e) entfernt, ${keptIds.length} verblieben (Peak nach Accept \u2192 Endzustand).`,
|
|
2081
|
-
actionSequence,
|
|
2082
|
-
actionTimeline,
|
|
2083
|
-
requestTimeline: timeline,
|
|
2084
|
-
storageBefore,
|
|
2085
|
-
storageAfter,
|
|
2086
|
-
storageAfterAccept,
|
|
2087
|
-
residualAfterRevoke: residual,
|
|
2088
|
-
screenshots,
|
|
2089
|
-
screenshotPath: screenshots[0]?.path,
|
|
2090
|
-
targetUrl: config.targetUrl,
|
|
2091
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2092
|
-
});
|
|
2093
|
-
if (status === "passed") {
|
|
2094
|
-
passedScenarios.push("consent-revoked");
|
|
2095
|
-
} else {
|
|
2096
|
-
failedScenarios.push("consent-revoked");
|
|
2097
|
-
}
|
|
2098
|
-
} catch (err) {
|
|
2099
|
-
evidence.push({
|
|
2100
|
-
scenario: "consent-revoked",
|
|
2101
|
-
status: "not-checkable",
|
|
2102
|
-
note: `Szenario mit Fehler abgebrochen: ${String(err).slice(0, 120)}`,
|
|
2103
|
-
actionSequence: [],
|
|
2104
|
-
requestTimeline: [],
|
|
2105
|
-
storageBefore: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
2106
|
-
storageAfter: { cookies: [], localStorageKeys: [], sessionStorageKeys: [] },
|
|
2107
|
-
targetUrl: config.targetUrl,
|
|
2108
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2109
|
-
});
|
|
2110
|
-
failedScenarios.push("consent-revoked");
|
|
2111
|
-
} finally {
|
|
2112
|
-
await context.close().catch(() => {
|
|
2113
|
-
});
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
};
|
|
2117
|
-
|
|
2118
|
-
// src/core/remediation/index.ts
|
|
2119
|
-
import fs2 from "fs/promises";
|
|
2120
|
-
import path2 from "path";
|
|
2121
|
-
import { execFile as execFile2 } from "child_process";
|
|
2122
|
-
import { promisify as promisify2 } from "util";
|
|
2123
|
-
|
|
2124
|
-
// src/core/remediation/reviewer.ts
|
|
2125
|
-
import ts from "typescript";
|
|
2126
|
-
|
|
2127
|
-
// src/core/legal/disclaimer.ts
|
|
2128
|
-
var LEGAL_DISCLAIMER = "\u26A0\uFE0F **Hinweis / Disclaimer:** CPCG ist ein technisches Hilfsmittel zur Unterst\xFCtzung bei der Implementierung von Consent-L\xF6sungen \u2014 **keine Rechtsberatung** und keine Garantie f\xFCr DSGVO-/TTDSG-Konformit\xE4t. Bitte lassen Sie die endg\xFCltige Umsetzung anwaltlich pr\xFCfen. / CPCG is a technical aid, **not legal advice** and no guarantee of GDPR/ePrivacy compliance. Have the final implementation reviewed by counsel.";
|
|
2129
|
-
|
|
2130
|
-
// src/core/remediation/reviewer.ts
|
|
2131
|
-
var CONSENT_GUARD = /(getItem\(\s*['"`][^'"`]*consent[^'"`]*['"`]\s*\)|\bhasConsent\b|\bconsentGiven\b|\bisConsented\b|\bcookiecategory\b|\bgetConsent\w*\()/i;
|
|
2132
|
-
var TRACKER_MARKERS = [
|
|
2133
|
-
{
|
|
2134
|
-
match: (n) => /google|gtm|analytics/i.test(n),
|
|
2135
|
-
// gtag('consent'|'set'...) is Consent Mode API, not a tracker fire; function gtag(){} is the helper
|
|
2136
|
-
code: /googletagmanager\.com|google-analytics\.com|\bgtag\s*\(\s*['"](?!(?:consent|set)['"])|\bga\s*\(/i
|
|
2137
|
-
},
|
|
2138
|
-
{ match: (n) => /meta|facebook/i.test(n), code: /connect\.facebook\.net|facebook\.com\/tr|\bfbq\s*\(/i },
|
|
2139
|
-
{ match: (n) => /tiktok/i.test(n), code: /analytics\.tiktok\.com|\bttq\b/i },
|
|
2140
|
-
{ match: (n) => /criteo/i.test(n), code: /criteo\.(net|com)/i },
|
|
2141
|
-
{ match: (n) => /hotjar/i.test(n), code: /hotjar\.com|\bhj\s*\(/i },
|
|
2142
|
-
{ match: (n) => /linkedin/i.test(n), code: /licdn\.com|ads\.linkedin\.com|_linkedin_partner_id/i }
|
|
2143
|
-
];
|
|
2144
|
-
var JS_EXT = /\.(js|mjs|cjs|jsx|ts|tsx)$/i;
|
|
2145
|
-
var HTML_EXT = /\.(html|htm)$/i;
|
|
2146
|
-
function stripComments(code) {
|
|
2147
|
-
return code.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:\\'"`])\/\/.*$/gm, "$1");
|
|
2148
|
-
}
|
|
2149
|
-
function syntaxErrors(code, fileName) {
|
|
2150
|
-
const out = ts.transpileModule(code, {
|
|
2151
|
-
fileName,
|
|
2152
|
-
reportDiagnostics: true,
|
|
2153
|
-
compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.ES2022 }
|
|
2154
|
-
});
|
|
2155
|
-
return (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error).map((d) => {
|
|
2156
|
-
const msg = ts.flattenDiagnosticMessageText(d.messageText, " ");
|
|
2157
|
-
const pos = d.file && d.start !== void 0 ? d.file.getLineAndCharacterOfPosition(d.start) : void 0;
|
|
2158
|
-
return `[Syntax] ${fileName}${pos ? `:${pos.line + 1}:${pos.character + 1}` : ""} ${msg}`;
|
|
2159
|
-
});
|
|
2160
|
-
}
|
|
2161
|
-
function extractScripts(html) {
|
|
2162
|
-
const blocks = [];
|
|
2163
|
-
const re = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
|
|
2164
|
-
let m;
|
|
2165
|
-
while ((m = re.exec(html)) !== null) {
|
|
2166
|
-
blocks.push({ attrs: m[1] ?? "", body: m[2] ?? "" });
|
|
2167
|
-
}
|
|
2168
|
-
return blocks;
|
|
2169
|
-
}
|
|
2170
|
-
function isExecutableJs(attrs) {
|
|
2171
|
-
const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(attrs)?.[1]?.toLowerCase();
|
|
2172
|
-
return !type || type === "module" || type.includes("javascript");
|
|
2173
|
-
}
|
|
2174
|
-
function isBlockedByType(attrs) {
|
|
2175
|
-
return /\btype\s*=\s*["']?text\/plain/i.test(attrs);
|
|
2176
|
-
}
|
|
2177
|
-
function findUnguardedTrackerUsages(code, fileName, marker) {
|
|
2178
|
-
const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, true);
|
|
2179
|
-
const unguarded = [];
|
|
2180
|
-
const exitsEarly = (s) => {
|
|
2181
|
-
if (ts.isReturnStatement(s) || ts.isThrowStatement(s)) return true;
|
|
2182
|
-
if (ts.isBlock(s)) return s.statements.some(exitsEarly);
|
|
2183
|
-
return false;
|
|
2184
|
-
};
|
|
2185
|
-
const isGuarded = (node) => {
|
|
2186
|
-
let child = node;
|
|
2187
|
-
for (let p = node.parent; p; child = p, p = p.parent) {
|
|
2188
|
-
if (ts.isIfStatement(p) && CONSENT_GUARD.test(p.expression.getText(sf)) && child === p.thenStatement) {
|
|
2189
|
-
return true;
|
|
2190
|
-
}
|
|
2191
|
-
if (ts.isBinaryExpression(p) && p.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && child === p.right && CONSENT_GUARD.test(p.left.getText(sf))) {
|
|
2192
|
-
return true;
|
|
2193
|
-
}
|
|
2194
|
-
if (ts.isConditionalExpression(p) && child === p.whenTrue && CONSENT_GUARD.test(p.condition.getText(sf))) {
|
|
2195
|
-
return true;
|
|
2196
|
-
}
|
|
2197
|
-
if (ts.isBlock(p) || ts.isSourceFile(p)) {
|
|
2198
|
-
const stmts = p.statements;
|
|
2199
|
-
const idx = stmts.indexOf(child);
|
|
2200
|
-
for (let i = 0; i < idx; i++) {
|
|
2201
|
-
const s = stmts[i];
|
|
2202
|
-
if (ts.isIfStatement(s) && CONSENT_GUARD.test(s.expression.getText(sf)) && exitsEarly(s.thenStatement)) {
|
|
2203
|
-
return true;
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
return false;
|
|
2209
|
-
};
|
|
2210
|
-
const visit = (node) => {
|
|
2211
|
-
let isUsage = false;
|
|
2212
|
-
if (ts.isCallExpression(node)) {
|
|
2213
|
-
const callee = node.expression.getText(sf);
|
|
2214
|
-
const firstStr = node.arguments.find(
|
|
2215
|
-
(a) => ts.isStringLiteral(a) || ts.isNoSubstitutionTemplateLiteral(a)
|
|
2216
|
-
);
|
|
2217
|
-
const head = firstStr ? `${callee}(${firstStr.getText(sf)}` : `${callee}(`;
|
|
2218
|
-
isUsage = marker.test(head);
|
|
2219
|
-
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
2220
|
-
isUsage = marker.test(node.text);
|
|
2221
|
-
}
|
|
2222
|
-
if (isUsage && !isGuarded(node)) {
|
|
2223
|
-
unguarded.push(sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1);
|
|
2224
|
-
}
|
|
2225
|
-
ts.forEachChild(node, visit);
|
|
2226
|
-
};
|
|
2227
|
-
visit(sf);
|
|
2228
|
-
return unguarded;
|
|
2229
|
-
}
|
|
2230
|
-
var CodeReviewer = class {
|
|
2231
|
-
async review(patches, report, config) {
|
|
2232
|
-
const comments = [];
|
|
2233
|
-
const approvedPatches = [];
|
|
2234
|
-
const rejectedPatches = [];
|
|
2235
|
-
if (patches.length === 0) {
|
|
2236
|
-
return {
|
|
2237
|
-
passed: false,
|
|
2238
|
-
score: 0,
|
|
2239
|
-
comments: ["No patches provided for review."],
|
|
2240
|
-
approvedPatches: [],
|
|
2241
|
-
rejectedPatches: []
|
|
2242
|
-
};
|
|
2243
|
-
}
|
|
2244
|
-
for (const patch of patches) {
|
|
2245
|
-
const errors = this.reviewPatch(patch, report);
|
|
2246
|
-
if (errors.length === 0) {
|
|
2247
|
-
approvedPatches.push(patch);
|
|
2248
|
-
comments.push(`\u2714 [APPROVED] ${patch.filePath}: valid syntax, structure preserved, trackers consent-gated.`);
|
|
2249
|
-
} else {
|
|
2250
|
-
rejectedPatches.push(patch);
|
|
2251
|
-
comments.push(`\u2716 [REJECTED] ${patch.filePath}:
|
|
2252
|
-
${errors.join("\n ")}`);
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
if (config.apiKey && rejectedPatches.length === 0) {
|
|
2256
|
-
comments.push(...await this.runLlmPeerReview(approvedPatches, config));
|
|
2257
|
-
}
|
|
2258
|
-
return {
|
|
2259
|
-
passed: rejectedPatches.length === 0 && approvedPatches.length > 0,
|
|
2260
|
-
score: Math.round(approvedPatches.length / patches.length * 100),
|
|
2261
|
-
comments: [...comments, LEGAL_DISCLAIMER],
|
|
2262
|
-
approvedPatches,
|
|
2263
|
-
rejectedPatches
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
reviewPatch(patch, report) {
|
|
2267
|
-
const errors = [];
|
|
2268
|
-
const content = patch.patchedContent;
|
|
2269
|
-
const isHtml = HTML_EXT.test(patch.filePath);
|
|
2270
|
-
const isJs = JS_EXT.test(patch.filePath);
|
|
2271
|
-
if (!content || content.trim().length === 0) {
|
|
2272
|
-
return [`[Syntax] Patched content is empty.`];
|
|
2273
|
-
}
|
|
2274
|
-
if (patch.originalContent === void 0) {
|
|
2275
|
-
errors.push("[Integrity] originalContent missing; cannot verify the patch against the original.");
|
|
2276
|
-
} else {
|
|
2277
|
-
if (patch.originalContent === content) {
|
|
2278
|
-
errors.push("[Compliance] Patch is identical to the original file; nothing was fixed.");
|
|
2279
|
-
}
|
|
2280
|
-
const origLines = patch.originalContent.split("\n").length;
|
|
2281
|
-
const newLines = content.split("\n").length;
|
|
2282
|
-
if (origLines > 10 && newLines < origLines * 0.5) {
|
|
2283
|
-
errors.push(
|
|
2284
|
-
`[Regression] File shrank from ${origLines} to ${newLines} lines (suspected code loss).`
|
|
2285
|
-
);
|
|
2286
|
-
}
|
|
2287
|
-
if (isHtml) {
|
|
2288
|
-
for (const tag of ["</body>", "</html>"]) {
|
|
2289
|
-
if (patch.originalContent.includes(tag) && !content.includes(tag)) {
|
|
2290
|
-
errors.push(`[Integrity] Essential closing tag '${tag}' was removed.`);
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
if (isHtml) {
|
|
2296
|
-
const markup = content.replace(/(<script\b[^>]*>)[\s\S]*?(<\/script\s*>)/gi, "$1$2");
|
|
2297
|
-
const opens = (markup.match(/<script\b[^>]*>/gi) ?? []).length;
|
|
2298
|
-
const closes = (markup.match(/<\/script\s*>/gi) ?? []).length;
|
|
2299
|
-
if (opens !== closes) {
|
|
2300
|
-
errors.push(`[Syntax] Unbalanced <script> tags: ${opens} opened vs ${closes} closed.`);
|
|
2301
|
-
}
|
|
2302
|
-
extractScripts(content).forEach((b, i) => {
|
|
2303
|
-
if (isExecutableJs(b.attrs) && b.body.trim()) {
|
|
2304
|
-
errors.push(...syntaxErrors(b.body, `inline-script-${i + 1}.js`));
|
|
2305
|
-
}
|
|
2306
|
-
});
|
|
2307
|
-
} else if (isJs) {
|
|
2308
|
-
errors.push(...syntaxErrors(content, patch.filePath));
|
|
2309
|
-
}
|
|
2310
|
-
errors.push(...this.checkPrivacyCompliance(patch, report));
|
|
2311
|
-
return errors;
|
|
2312
|
-
}
|
|
2313
|
-
/**
|
|
2314
|
-
* For every tracker family that leaked, each script block / source file that references it must be gated:
|
|
2315
|
-
* blocked via type="text/plain" / data-cookiecategory, or guarded by real (non-comment) consent code.
|
|
2316
|
-
*/
|
|
2317
|
-
checkPrivacyCompliance(patch, report) {
|
|
2318
|
-
const errors = [];
|
|
2319
|
-
const markers = /* @__PURE__ */ new Set();
|
|
2320
|
-
for (const v of report.violations) {
|
|
2321
|
-
for (const m of TRACKER_MARKERS) {
|
|
2322
|
-
if (m.match(v.trackerName)) markers.add(m);
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
if (markers.size === 0) return errors;
|
|
2326
|
-
const isHtml = HTML_EXT.test(patch.filePath);
|
|
2327
|
-
const isJs = JS_EXT.test(patch.filePath);
|
|
2328
|
-
const units = isHtml ? extractScripts(patch.patchedContent).map((b, i) => ({
|
|
2329
|
-
attrs: b.attrs,
|
|
2330
|
-
code: b.body,
|
|
2331
|
-
label: `<script> #${i + 1}`,
|
|
2332
|
-
fileName: `inline-script-${i + 1}.js`
|
|
2333
|
-
})) : [{ attrs: "", code: patch.patchedContent, label: "file", fileName: patch.filePath }];
|
|
2334
|
-
for (const marker of markers) {
|
|
2335
|
-
for (const u of units) {
|
|
2336
|
-
if (isBlockedByType(u.attrs)) continue;
|
|
2337
|
-
if (marker.code.test(u.attrs)) {
|
|
2338
|
-
errors.push(`[Compliance] ${u.label} loads a tracker without type="text/plain" (still executes before consent).`);
|
|
2339
|
-
continue;
|
|
2340
|
-
}
|
|
2341
|
-
const executable = isHtml ? isExecutableJs(u.attrs) && u.code.trim().length > 0 : true;
|
|
2342
|
-
if (!executable) continue;
|
|
2343
|
-
if (isHtml || isJs) {
|
|
2344
|
-
for (const line of findUnguardedTrackerUsages(u.code, u.fileName, marker.code)) {
|
|
2345
|
-
errors.push(`[Compliance] ${u.label} line ${line}: tracker usage is not inside/after a consent guard.`);
|
|
2346
|
-
}
|
|
2347
|
-
} else {
|
|
2348
|
-
const code = stripComments(u.code);
|
|
2349
|
-
if (marker.code.test(code) && !CONSENT_GUARD.test(code)) {
|
|
2350
|
-
errors.push(`[Compliance] ${u.label} references a tracker without any consent guard (heuristic).`);
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
return errors;
|
|
2356
|
-
}
|
|
2357
|
-
async runLlmPeerReview(patches, config) {
|
|
2358
|
-
if (config.llmProvider !== "openai") return [];
|
|
2359
|
-
try {
|
|
2360
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
2361
|
-
method: "POST",
|
|
2362
|
-
headers: {
|
|
2363
|
-
"Content-Type": "application/json",
|
|
2364
|
-
Authorization: `Bearer ${config.apiKey}`
|
|
2365
|
-
},
|
|
2366
|
-
signal: AbortSignal.timeout(2e4),
|
|
2367
|
-
body: JSON.stringify({
|
|
2368
|
-
model: config.llmModel || "gpt-4o",
|
|
2369
|
-
messages: [
|
|
2370
|
-
{
|
|
2371
|
-
role: "system",
|
|
2372
|
-
content: "You are an expert Code Reviewer. Inspect the provided code patches for privacy fixes. Respond in 1 concise sentence verifying code safety."
|
|
2373
|
-
},
|
|
2374
|
-
{
|
|
2375
|
-
role: "user",
|
|
2376
|
-
content: `Review these patches:
|
|
2377
|
-
${patches.map((p) => p.patchedContent).join("\n---\n")}`
|
|
2378
|
-
}
|
|
2379
|
-
],
|
|
2380
|
-
temperature: 0.1,
|
|
2381
|
-
max_tokens: 150
|
|
2382
|
-
})
|
|
2383
|
-
});
|
|
2384
|
-
if (response.ok) {
|
|
2385
|
-
const json = await response.json();
|
|
2386
|
-
const reply = json.choices?.[0]?.message?.content?.trim();
|
|
2387
|
-
if (reply) return [`\u{1F916} [LLM Peer Reviewer]: ${reply}`];
|
|
2388
|
-
}
|
|
2389
|
-
} catch {
|
|
2390
|
-
}
|
|
2391
|
-
return [];
|
|
2392
|
-
}
|
|
2393
|
-
};
|
|
2394
|
-
|
|
2395
|
-
// src/core/remediation/pullRequest.ts
|
|
2396
|
-
import { execFile } from "child_process";
|
|
2397
|
-
import { promisify } from "util";
|
|
2398
|
-
var execFileAsync = promisify(execFile);
|
|
2399
|
-
var BRANCH_PREFIX_RE = /^[A-Za-z0-9._/-]+$/;
|
|
2400
|
-
function validateBranchPrefix(prefix) {
|
|
2401
|
-
if (!BRANCH_PREFIX_RE.test(prefix) || prefix.includes("..") || prefix.startsWith("-") || prefix.startsWith("/") || prefix.endsWith("/") || prefix.endsWith(".lock") || prefix.includes("//")) {
|
|
2402
|
-
throw new Error(`Invalid branch prefix '${prefix}'. Allowed: letters, digits, '.', '_', '/', '-'.`);
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
function redactSecrets(text, token) {
|
|
2406
|
-
let out = text.replace(/\/\/[^@/\s]+@/g, "//***@");
|
|
2407
|
-
if (token) out = out.split(token).join("***");
|
|
2408
|
-
return out;
|
|
2409
|
-
}
|
|
2410
|
-
function parseGitHubRepo(url) {
|
|
2411
|
-
if (!url) return null;
|
|
2412
|
-
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/i);
|
|
2413
|
-
return m ? { owner: m[1], repo: m[2] } : null;
|
|
2414
|
-
}
|
|
2415
|
-
function escapeCell(s) {
|
|
2416
|
-
return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
2417
|
-
}
|
|
2418
|
-
function tokenArgs(token) {
|
|
2419
|
-
if (!token) return [];
|
|
2420
|
-
const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
2421
|
-
return ["-c", `http.extraheader=AUTHORIZATION: basic ${basic}`];
|
|
2422
|
-
}
|
|
2423
|
-
var PullRequestEngine = class {
|
|
2424
|
-
async git(args, token) {
|
|
2425
|
-
try {
|
|
2426
|
-
const { stdout } = await execFileAsync("git", args, {
|
|
2427
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
2428
|
-
env: process.env
|
|
2429
|
-
});
|
|
2430
|
-
return stdout.trim();
|
|
2431
|
-
} catch (err) {
|
|
2432
|
-
const e = err;
|
|
2433
|
-
throw new Error(
|
|
2434
|
-
`git ${args[0]} failed: ${redactSecrets((e.stderr || e.message || String(err)).trim(), token)}`
|
|
2435
|
-
);
|
|
2436
|
-
}
|
|
2437
|
-
}
|
|
2438
|
-
/**
|
|
2439
|
-
* Creates a fix branch, commits only the given files, pushes and opens a PR when a remote + token exist.
|
|
2440
|
-
* Always returns to the starting branch. Throws on any git/API failure (caller restores the working tree).
|
|
2441
|
-
*/
|
|
2442
|
-
async createPullRequest(patches, report, reviewVerdict, config) {
|
|
2443
|
-
validateBranchPrefix(config.branchPrefix);
|
|
2444
|
-
const startBranch = await this.git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2445
|
-
if (startBranch === "HEAD") {
|
|
2446
|
-
throw new Error("Detached HEAD: cannot create a fix branch.");
|
|
2447
|
-
}
|
|
2448
|
-
const branchName = `${config.branchPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
2449
|
-
const title = `fix(privacy): remediate ${report.totalViolations} unauthorized tracker leak(s)`;
|
|
2450
|
-
const body = this.buildPrDescription(report, reviewVerdict, patches, config);
|
|
2451
|
-
const files = patches.map((p) => p.filePath);
|
|
2452
|
-
await this.git(["checkout", "-b", branchName]);
|
|
2453
|
-
let keepBranch = false;
|
|
2454
|
-
try {
|
|
2455
|
-
await this.git(["add", "--", ...files]);
|
|
2456
|
-
await this.git(["commit", "-m", title, "--", ...files]);
|
|
2457
|
-
let remoteUrl = "";
|
|
2458
|
-
try {
|
|
2459
|
-
remoteUrl = await this.git(["config", "--get", "remote.origin.url"]);
|
|
2460
|
-
} catch {
|
|
2461
|
-
}
|
|
2462
|
-
const repo = parseGitHubRepo(remoteUrl);
|
|
2463
|
-
if (!config.githubToken || !repo) {
|
|
2464
|
-
keepBranch = true;
|
|
2465
|
-
return { branchName, title, body, simulated: true };
|
|
2466
|
-
}
|
|
2467
|
-
const baseBranch = config.baseBranch || await this.resolveBaseBranch(startBranch);
|
|
2468
|
-
await this.git([...tokenArgs(config.githubToken), "push", "-u", "origin", branchName], config.githubToken);
|
|
2469
|
-
keepBranch = true;
|
|
2470
|
-
const res = await fetch(`https://api.github.com/repos/${repo.owner}/${repo.repo}/pulls`, {
|
|
2471
|
-
method: "POST",
|
|
2472
|
-
headers: {
|
|
2473
|
-
Accept: "application/vnd.github+json",
|
|
2474
|
-
Authorization: `Bearer ${config.githubToken}`,
|
|
2475
|
-
"Content-Type": "application/json"
|
|
2476
|
-
},
|
|
2477
|
-
signal: AbortSignal.timeout(15e3),
|
|
2478
|
-
body: JSON.stringify({ title, head: branchName, base: baseBranch, body })
|
|
2479
|
-
});
|
|
2480
|
-
if (!res.ok) {
|
|
2481
|
-
const text = redactSecrets(await res.text().catch(() => ""), config.githubToken);
|
|
2482
|
-
throw new Error(`GitHub PR creation failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
|
|
2483
|
-
}
|
|
2484
|
-
const pr = await res.json();
|
|
2485
|
-
return { branchName, title, body, prNumber: pr.number, prUrl: pr.html_url, simulated: false };
|
|
2486
|
-
} finally {
|
|
2487
|
-
if (!keepBranch) {
|
|
2488
|
-
await this.git(["reset", "-q", "--", ...files]).catch(() => void 0);
|
|
2489
|
-
}
|
|
2490
|
-
await this.git(["checkout", startBranch]).catch(() => void 0);
|
|
2491
|
-
if (!keepBranch) {
|
|
2492
|
-
await this.git(["branch", "-D", branchName]).catch(() => void 0);
|
|
2493
|
-
}
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
async resolveBaseBranch(fallback) {
|
|
2497
|
-
try {
|
|
2498
|
-
const ref = await this.git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
2499
|
-
return ref.replace(/^origin\//, "") || fallback;
|
|
2500
|
-
} catch {
|
|
2501
|
-
return fallback;
|
|
2502
|
-
}
|
|
2503
|
-
}
|
|
2504
|
-
buildPrDescription(report, reviewVerdict, patches, config) {
|
|
2505
|
-
const lines = [
|
|
2506
|
-
"## \u{1F6E1}\uFE0F Cuonztech Privacy Chaos Gate (CPCG) - Auto-Remediation PR",
|
|
2507
|
-
"",
|
|
2508
|
-
`This Pull Request was automatically generated to remediate **${report.totalViolations} unauthorized tracker leak(s)** detected during Privacy Chaos Engineering tests on \`${config.targetUrl}\`.`,
|
|
2509
|
-
"",
|
|
2510
|
-
"### \u{1F50D} Review Agent Verdict",
|
|
2511
|
-
`- **Score:** \`${reviewVerdict.score}/100\``,
|
|
2512
|
-
`- **Status:** ${reviewVerdict.passed ? "\u2705 **APPROVED (No defects detected)**" : "\u26A0\uFE0F **REVIEW COMMENTS**"}`
|
|
2513
|
-
];
|
|
2514
|
-
for (const comment of reviewVerdict.comments) {
|
|
2515
|
-
lines.push(` * ${comment.replace(/\r?\n\s*/g, " ")}`);
|
|
2516
|
-
}
|
|
2517
|
-
lines.push("", "### \u{1F6A8} Fixed Privacy Violations");
|
|
2518
|
-
lines.push("| Scenario | Tracker | Category | Intercepted Request |");
|
|
2519
|
-
lines.push("| :--- | :--- | :--- | :--- |");
|
|
2520
|
-
for (const v of report.violations) {
|
|
2521
|
-
lines.push(
|
|
2522
|
-
`| \`${escapeCell(v.scenario)}\` | **${escapeCell(v.trackerName)}** | \`${escapeCell(v.trackerCategory)}\` | \`${escapeCell(v.requestUrl)}\` |`
|
|
2523
|
-
);
|
|
2524
|
-
}
|
|
2525
|
-
lines.push("", "### \u{1F4C1} Modified Files");
|
|
2526
|
-
for (const patch of patches) {
|
|
2527
|
-
lines.push(`* **\`${patch.filePath}\`**: ${patch.explanation}`);
|
|
2528
|
-
}
|
|
2529
|
-
lines.push(
|
|
2530
|
-
"",
|
|
2531
|
-
"### \u{1F9EA} Verification",
|
|
2532
|
-
"Run the Privacy Chaos Gate locally to verify all chaos scenarios pass:",
|
|
2533
|
-
"```bash",
|
|
2534
|
-
`npx @cuonztech/privacy-chaos-gate --url "${config.targetUrl}"`,
|
|
2535
|
-
"```",
|
|
2536
|
-
"",
|
|
2537
|
-
"---",
|
|
2538
|
-
"_Generated by Cuonztech Privacy Chaos Gate Auto-Remediation Engine & Review Agent._"
|
|
2539
|
-
);
|
|
2540
|
-
lines.push("", LEGAL_DISCLAIMER);
|
|
2541
|
-
return lines.join("\n");
|
|
2542
|
-
}
|
|
2543
|
-
};
|
|
2544
|
-
|
|
2545
|
-
// src/core/remediation/index.ts
|
|
2546
|
-
var execFileAsync2 = promisify2(execFile2);
|
|
2547
|
-
var CONSENT_ACTIVATION_RUNTIME = `<script data-cpcg="consent-activation">
|
|
2548
|
-
(function () {
|
|
2549
|
-
if (window.__cpcgConsentRuntime) return;
|
|
2550
|
-
window.__cpcgConsentRuntime = true;
|
|
2551
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2552
|
-
|
|
2553
|
-
function cpcgHasConsent() {
|
|
2554
|
-
try {
|
|
2555
|
-
var raw;
|
|
2556
|
-
// Explicit hook for custom CMPs
|
|
2557
|
-
if (window.__cpcgConsentGranted === true) return true;
|
|
2558
|
-
// Our own / simple stores
|
|
2559
|
-
raw = localStorage.getItem("cpcg_consent");
|
|
2560
|
-
if (raw === "accepted") return true;
|
|
2561
|
-
raw = localStorage.getItem("consent");
|
|
2562
|
-
if (raw === "accepted") return true;
|
|
2563
|
-
raw = localStorage.getItem("usercentrics-consent");
|
|
2564
|
-
if (raw === "accepted") return true;
|
|
2565
|
-
// OneTrust: OptanonConsent is a URL-encoded query string (has consentId, groups=\u2026)
|
|
2566
|
-
raw = localStorage.getItem("OptanonConsent");
|
|
2567
|
-
if (raw) {
|
|
2568
|
-
if (raw === "accepted") return true;
|
|
2569
|
-
try {
|
|
2570
|
-
var q = new URLSearchParams(raw);
|
|
2571
|
-
if (q.get("status") === "accepted" || q.get("groups") && q.get("groups").indexOf(":1") !== -1) return true;
|
|
2572
|
-
if (q.has("consentId") && !/isIabGlobal=false;status=-1/.test(raw)) return true;
|
|
2573
|
-
} catch (e) {}
|
|
2574
|
-
}
|
|
2575
|
-
// Cookiebot: JSON with consented / stamp
|
|
2576
|
-
raw = localStorage.getItem("CookieConsent");
|
|
2577
|
-
if (raw) {
|
|
2578
|
-
if (raw === "accepted" || raw === "true") return true;
|
|
2579
|
-
try {
|
|
2580
|
-
var cb = JSON.parse(raw);
|
|
2581
|
-
if (cb && (cb.consented === true || cb.necessary === true || cb.stamp)) return true;
|
|
2582
|
-
} catch (e) {}
|
|
2583
|
-
}
|
|
2584
|
-
// OneTrust OptanonConsent cookie fallback
|
|
2585
|
-
if (/(^|;s*)OptanonConsent=/.test(document.cookie)) return true;
|
|
2586
|
-
return false;
|
|
2587
|
-
} catch (_) {
|
|
2588
|
-
return false;
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
function cpcgActivateDeferred() {
|
|
2593
|
-
if (!cpcgHasConsent()) return false;
|
|
2594
|
-
document.querySelectorAll('script[type="text/plain"][data-src]').forEach(function (old) {
|
|
2595
|
-
if (old.getAttribute("data-cpcg-activated") === "1") return;
|
|
2596
|
-
old.setAttribute("data-cpcg-activated", "1");
|
|
2597
|
-
var s = document.createElement("script");
|
|
2598
|
-
s.src = old.getAttribute("data-src");
|
|
2599
|
-
s.async = old.async !== false;
|
|
2600
|
-
if (old.getAttribute("data-cookiecategory")) {
|
|
2601
|
-
s.setAttribute("data-cookiecategory", old.getAttribute("data-cookiecategory"));
|
|
2602
|
-
}
|
|
2603
|
-
old.parentNode.insertBefore(s, old.nextSibling);
|
|
2604
|
-
});
|
|
2605
|
-
(window.__cpcgInits || []).forEach(function (fn) {
|
|
2606
|
-
try { fn(); } catch (e) { /* init failed */ }
|
|
2607
|
-
});
|
|
2608
|
-
window.__cpcgInits = [];
|
|
2609
|
-
return true;
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
window.cpcgActivateTrackers = cpcgActivateDeferred;
|
|
2613
|
-
window.cpcgGrantConsent = function () {
|
|
2614
|
-
window.__cpcgConsentGranted = true;
|
|
2615
|
-
return cpcgActivateDeferred();
|
|
2616
|
-
};
|
|
2617
|
-
|
|
2618
|
-
["click", "storage", "consent", "usercentrics-consent", "OneTrustLoaded"].forEach(function (evt) {
|
|
2619
|
-
window.addEventListener(evt, function () {
|
|
2620
|
-
setTimeout(cpcgActivateDeferred, 50);
|
|
2621
|
-
}, true);
|
|
2622
|
-
});
|
|
2623
|
-
document.addEventListener("click", function () {
|
|
2624
|
-
setTimeout(cpcgActivateDeferred, 50);
|
|
2625
|
-
}, true);
|
|
2626
|
-
|
|
2627
|
-
// Late accept: poll briefly while the page is open
|
|
2628
|
-
var tries = 0;
|
|
2629
|
-
var timer = setInterval(function () {
|
|
2630
|
-
tries += 1;
|
|
2631
|
-
if (cpcgActivateDeferred() || tries > 40) clearInterval(timer);
|
|
2632
|
-
}, 500);
|
|
2633
|
-
|
|
2634
|
-
if (document.readyState === "loading") {
|
|
2635
|
-
document.addEventListener("DOMContentLoaded", cpcgActivateDeferred);
|
|
2636
|
-
} else {
|
|
2637
|
-
cpcgActivateDeferred();
|
|
2638
|
-
}
|
|
2639
|
-
})();
|
|
2640
|
-
</script>`;
|
|
2641
|
-
var RemediationEngine = class {
|
|
2642
|
-
reviewer;
|
|
2643
|
-
prEngine;
|
|
2644
|
-
constructor(reviewer, prEngine) {
|
|
2645
|
-
this.reviewer = reviewer ?? new CodeReviewer();
|
|
2646
|
-
this.prEngine = prEngine ?? new PullRequestEngine();
|
|
2647
|
-
}
|
|
2648
|
-
async remediate(report, config) {
|
|
2649
|
-
if (report.totalViolations === 0) {
|
|
2650
|
-
return {
|
|
2651
|
-
success: true,
|
|
2652
|
-
patches: [],
|
|
2653
|
-
commitMessage: "No violations to remediate."
|
|
2654
|
-
};
|
|
2655
|
-
}
|
|
2656
|
-
try {
|
|
2657
|
-
const candidateFiles = await this.findCandidateSourceFiles(config.sourceDir);
|
|
2658
|
-
if (candidateFiles.length === 0) {
|
|
2659
|
-
return {
|
|
2660
|
-
success: false,
|
|
2661
|
-
patches: [],
|
|
2662
|
-
error: `No candidate source files found in source directory: ${config.sourceDir}`
|
|
2663
|
-
};
|
|
2664
|
-
}
|
|
2665
|
-
let candidatePatches = [];
|
|
2666
|
-
if (config.apiKey) {
|
|
2667
|
-
candidatePatches = await this.remediateWithLlm(report, candidateFiles, config);
|
|
2668
|
-
}
|
|
2669
|
-
if (candidatePatches.length === 0) {
|
|
2670
|
-
candidatePatches = await this.remediateWithRuleEngine(report, candidateFiles);
|
|
2671
|
-
}
|
|
2672
|
-
if (candidatePatches.length === 0) {
|
|
2673
|
-
return {
|
|
2674
|
-
success: false,
|
|
2675
|
-
patches: [],
|
|
2676
|
-
error: "Could not identify and patch the leaking code in candidate source files."
|
|
2677
|
-
};
|
|
2678
|
-
}
|
|
2679
|
-
const reviewVerdict = await this.reviewer.review(candidatePatches, report, config);
|
|
2680
|
-
if (!reviewVerdict.passed) {
|
|
2681
|
-
return {
|
|
2682
|
-
success: false,
|
|
2683
|
-
patches: [],
|
|
2684
|
-
reviewVerdict,
|
|
2685
|
-
error: `Review Agent rejected candidate patches (Score: ${reviewVerdict.score}/100):
|
|
2686
|
-
${reviewVerdict.comments.join("\n")}`
|
|
2687
|
-
};
|
|
2688
|
-
}
|
|
2689
|
-
const approvedPatches = reviewVerdict.approvedPatches;
|
|
2690
|
-
const originals = /* @__PURE__ */ new Map();
|
|
2691
|
-
for (const patch of approvedPatches) {
|
|
2692
|
-
originals.set(patch.filePath, patch.originalContent ?? await fs2.readFile(patch.filePath, "utf-8"));
|
|
2693
|
-
}
|
|
2694
|
-
const rollback = async () => {
|
|
2695
|
-
for (const [file, content] of originals) {
|
|
2696
|
-
await fs2.writeFile(file, content, "utf-8").catch(() => void 0);
|
|
2697
|
-
}
|
|
2698
|
-
};
|
|
2699
|
-
for (const patch of approvedPatches) {
|
|
2700
|
-
await fs2.writeFile(patch.filePath, patch.patchedContent, "utf-8");
|
|
2701
|
-
}
|
|
2702
|
-
let commitHash;
|
|
2703
|
-
let pullRequest;
|
|
2704
|
-
const commitMessage = `fix(privacy): auto-remediate ${report.totalViolations} tracker leak(s) via CPCG`;
|
|
2705
|
-
try {
|
|
2706
|
-
if (config.createPr) {
|
|
2707
|
-
pullRequest = await this.prEngine.createPullRequest(
|
|
2708
|
-
approvedPatches,
|
|
2709
|
-
report,
|
|
2710
|
-
reviewVerdict,
|
|
2711
|
-
config
|
|
2712
|
-
);
|
|
2713
|
-
await rollback();
|
|
2714
|
-
} else if (config.gitCommit) {
|
|
2715
|
-
const files = approvedPatches.map((p) => p.filePath);
|
|
2716
|
-
await execFileAsync2("git", ["add", "--", ...files]);
|
|
2717
|
-
const { stdout } = await execFileAsync2("git", ["commit", "-m", commitMessage, "--", ...files]);
|
|
2718
|
-
const hashMatch = stdout.match(/\[.*?\s+([a-f0-9]+)\]/);
|
|
2719
|
-
commitHash = hashMatch ? hashMatch[1] : "committed";
|
|
2720
|
-
}
|
|
2721
|
-
} catch (gitErr) {
|
|
2722
|
-
await rollback();
|
|
2723
|
-
await execFileAsync2("git", ["reset", "-q", "--", ...originals.keys()]).catch(() => void 0);
|
|
2724
|
-
return {
|
|
2725
|
-
success: false,
|
|
2726
|
-
patches: [],
|
|
2727
|
-
reviewVerdict,
|
|
2728
|
-
error: `Git/PR step failed; working-tree changes were rolled back and the index was reset: ${redactSecrets(
|
|
2729
|
-
gitErr instanceof Error ? gitErr.message : String(gitErr),
|
|
2730
|
-
config.githubToken
|
|
2731
|
-
)}`
|
|
2732
|
-
};
|
|
2733
|
-
}
|
|
2734
|
-
return {
|
|
2735
|
-
success: true,
|
|
2736
|
-
patches: approvedPatches,
|
|
2737
|
-
reviewVerdict,
|
|
2738
|
-
commitHash,
|
|
2739
|
-
commitMessage,
|
|
2740
|
-
pullRequest
|
|
2741
|
-
};
|
|
2742
|
-
} catch (err) {
|
|
2743
|
-
return {
|
|
2744
|
-
success: false,
|
|
2745
|
-
patches: [],
|
|
2746
|
-
error: err instanceof Error ? err.message : String(err)
|
|
2747
|
-
};
|
|
2748
|
-
}
|
|
2749
|
-
}
|
|
2750
|
-
async findCandidateSourceFiles(dir) {
|
|
2751
|
-
const candidates = [];
|
|
2752
|
-
const allowedExtensions = /* @__PURE__ */ new Set([".html", ".htm", ".jsx", ".tsx", ".js", ".ts", ".vue", ".svelte"]);
|
|
2753
|
-
async function walk(currentDir) {
|
|
2754
|
-
const entries = await fs2.readdir(currentDir, { withFileTypes: true }).catch(() => []);
|
|
2755
|
-
for (const entry of entries) {
|
|
2756
|
-
const fullPath = path2.join(currentDir, entry.name);
|
|
2757
|
-
if (entry.isDirectory()) {
|
|
2758
|
-
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name === "reports" || entry.name === "test-fixtures" || entry.name === "tests") {
|
|
2759
|
-
continue;
|
|
2760
|
-
}
|
|
2761
|
-
await walk(fullPath);
|
|
2762
|
-
} else if (entry.isFile() && allowedExtensions.has(path2.extname(entry.name).toLowerCase())) {
|
|
2763
|
-
candidates.push(fullPath);
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
}
|
|
2767
|
-
await walk(path2.resolve(dir));
|
|
2768
|
-
return candidates;
|
|
2769
|
-
}
|
|
2770
|
-
/**
|
|
2771
|
-
* Deterministic Privacy Patcher: Analyzes violations and injects consent gates or CMP categorization attributes
|
|
2772
|
-
*/
|
|
2773
|
-
async remediateWithRuleEngine(report, candidateFiles) {
|
|
2774
|
-
const patches = [];
|
|
2775
|
-
for (const filePath of candidateFiles) {
|
|
2776
|
-
const content = await fs2.readFile(filePath, "utf-8");
|
|
2777
|
-
let updatedContent = content;
|
|
2778
|
-
let fileModified = false;
|
|
2779
|
-
for (const violation of report.violations) {
|
|
2780
|
-
if (violation.trackerName.includes("Google") && (updatedContent.includes("googletagmanager.com") || updatedContent.includes("google-analytics.com") || updatedContent.includes("gtag("))) {
|
|
2781
|
-
const externalGtagRegex = /<script\s+async\s+src="https:\/\/www\.googletagmanager\.com\/gtag\/js\?id=([^"]+)">\s*<\/script>/gi;
|
|
2782
|
-
if (externalGtagRegex.test(updatedContent)) {
|
|
2783
|
-
updatedContent = updatedContent.replace(
|
|
2784
|
-
externalGtagRegex,
|
|
2785
|
-
`<script type="text/plain" data-cookiecategory="analytics" data-src="https://www.googletagmanager.com/gtag/js?id=$1"></script>`
|
|
2786
|
-
);
|
|
2787
|
-
fileModified = true;
|
|
2788
|
-
}
|
|
2789
|
-
const inlineGtagRegex = /<script>[\s\S]*?gtag\(\s*['"]config['"]\s*,\s*['"]([^'"]+)['"][\s\S]*?<\/script>/i;
|
|
2790
|
-
const inlineMatch = inlineGtagRegex.exec(updatedContent);
|
|
2791
|
-
if (inlineMatch && !updatedContent.includes('data-cpcg="deferred-init"')) {
|
|
2792
|
-
const measurementId = inlineMatch[1];
|
|
2793
|
-
const inlineBeacon = /fetch\("([^"]*google-analytics\.com\/g\/collect[^"]*)"/.exec(inlineMatch[0])?.[1];
|
|
2794
|
-
updatedContent = updatedContent.replace(
|
|
2795
|
-
inlineMatch[0],
|
|
2796
|
-
`<script data-cpcg="deferred-init">
|
|
2797
|
-
// [CPCG Auto-Remediation]: run after explicit consent, including late accept
|
|
2798
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2799
|
-
window.__cpcgInits.push(function initAnalytics() {
|
|
2800
|
-
if (localStorage.getItem('cpcg_consent') !== 'accepted') return;
|
|
2801
|
-
window.dataLayer = window.dataLayer || [];
|
|
2802
|
-
function gtag(){dataLayer.push(arguments);}
|
|
2803
|
-
gtag('js', new Date());
|
|
2804
|
-
gtag('config', '${measurementId}');
|
|
2805
|
-
${inlineBeacon ? `fetch(${JSON.stringify(inlineBeacon)}).catch(function () {});` : ""}
|
|
2806
|
-
});
|
|
2807
|
-
if (localStorage.getItem('cpcg_consent') === 'accepted' && window.cpcgActivateTrackers) {
|
|
2808
|
-
window.cpcgActivateTrackers();
|
|
2809
|
-
}
|
|
2810
|
-
</script>`
|
|
2811
|
-
);
|
|
2812
|
-
fileModified = true;
|
|
2813
|
-
}
|
|
2814
|
-
const beaconRe = /fetch\("(https:\/\/www\.google-analytics\.com\/g\/collect[^"]*)"\)\.catch\(\s*(?:\(\)\s*=>\s*\{\}|function\s*\(\)\s*\{\s*\})\s*\);?/g;
|
|
2815
|
-
let beaconFound = false;
|
|
2816
|
-
let capturedBeaconUrl = "";
|
|
2817
|
-
updatedContent = updatedContent.replace(beaconRe, (_m, url) => {
|
|
2818
|
-
beaconFound = true;
|
|
2819
|
-
if (!capturedBeaconUrl) capturedBeaconUrl = url;
|
|
2820
|
-
return `/* [CPCG Remediation]: beacon deferred to consent runtime */`;
|
|
2821
|
-
});
|
|
2822
|
-
if (beaconFound) {
|
|
2823
|
-
const beaconUrl = capturedBeaconUrl || "https://www.google-analytics.com/g/collect?v=2&en=page_view";
|
|
2824
|
-
updatedContent = updatedContent.replace(
|
|
2825
|
-
"</body>",
|
|
2826
|
-
` <script data-cpcg="deferred-beacon">
|
|
2827
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2828
|
-
window.__cpcgInits.push(function () {
|
|
2829
|
-
if (localStorage.getItem('cpcg_consent') !== 'accepted') return;
|
|
2830
|
-
fetch(${JSON.stringify(beaconUrl)}).catch(function () {});
|
|
2831
|
-
});
|
|
2832
|
-
</script>
|
|
2833
|
-
</body>`
|
|
2834
|
-
);
|
|
2835
|
-
fileModified = true;
|
|
2836
|
-
}
|
|
2837
|
-
}
|
|
2838
|
-
if (violation.trackerName.includes("Meta") && (updatedContent.includes("connect.facebook.net") || updatedContent.includes("fbq("))) {
|
|
2839
|
-
const metaRegex = /<script([^>]*)src="https:\/\/connect\.facebook\.net\/([^"]*)"([^>]*)><\/script>/gi;
|
|
2840
|
-
if (metaRegex.test(updatedContent)) {
|
|
2841
|
-
updatedContent = updatedContent.replace(
|
|
2842
|
-
metaRegex,
|
|
2843
|
-
(_m, pre, src, post) => `<script${pre} type="text/plain" data-cookiecategory="marketing" data-src="https://connect.facebook.net/${src}"${post}></script>`
|
|
2844
|
-
);
|
|
2845
|
-
fileModified = true;
|
|
2846
|
-
}
|
|
2847
|
-
if (updatedContent.includes("fbq(") && !updatedContent.includes('data-cpcg="deferred-fbq"')) {
|
|
2848
|
-
updatedContent = updatedContent.replace(
|
|
2849
|
-
/<script>\s*(!function\(f,b,e,v,n,t,s\)[\s\S]*?fbq\([\s\S]*?)<\/script>/i,
|
|
2850
|
-
`<script data-cpcg="deferred-fbq">
|
|
2851
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2852
|
-
window.__cpcgInits.push(function () {
|
|
2853
|
-
if (localStorage.getItem('cpcg_consent') !== 'accepted' && localStorage.getItem('OptanonConsent') !== 'accepted') return;
|
|
2854
|
-
$1
|
|
2855
|
-
});
|
|
2856
|
-
</script>`
|
|
2857
|
-
);
|
|
2858
|
-
if (updatedContent.includes('data-cpcg="deferred-fbq"')) fileModified = true;
|
|
2859
|
-
}
|
|
2860
|
-
}
|
|
2861
|
-
if (violation.trackerName.includes("Google") || violation.trackerName.includes("Tag Manager") || updatedContent.includes("googletagmanager.com/gtm.js")) {
|
|
2862
|
-
const gtmScriptRegex = /<script\s+async\s+src="https:\/\/www\.googletagmanager\.com\/gtm\.js\?id=([^"]+)">\s*<\/script>/gi;
|
|
2863
|
-
if (gtmScriptRegex.test(updatedContent)) {
|
|
2864
|
-
updatedContent = updatedContent.replace(
|
|
2865
|
-
gtmScriptRegex,
|
|
2866
|
-
`<script type="text/plain" data-cookiecategory="analytics" data-src="https://www.googletagmanager.com/gtm.js?id=$1"></script>`
|
|
2867
|
-
);
|
|
2868
|
-
fileModified = true;
|
|
2869
|
-
}
|
|
2870
|
-
const gtmIifeScriptRegex = /<script>\s*\(function\(w,d,s,l,i\)\{[\s\S]*?\}\)\(window,document,'script','dataLayer','([^']+)'\);?\s*<\/script>/i;
|
|
2871
|
-
if (gtmIifeScriptRegex.test(updatedContent) && !updatedContent.includes('data-cpcg="deferred-gtm"')) {
|
|
2872
|
-
updatedContent = updatedContent.replace(
|
|
2873
|
-
gtmIifeScriptRegex,
|
|
2874
|
-
`<script data-cpcg="deferred-gtm">
|
|
2875
|
-
// [CPCG]: Google Consent Mode v2 \u2014 default denied, granted after accept
|
|
2876
|
-
window.dataLayer = window.dataLayer || [];
|
|
2877
|
-
function gtag(){dataLayer.push(arguments);}
|
|
2878
|
-
gtag('consent', 'default', {
|
|
2879
|
-
ad_storage: 'denied',
|
|
2880
|
-
ad_user_data: 'denied',
|
|
2881
|
-
ad_personalization: 'denied',
|
|
2882
|
-
analytics_storage: 'denied',
|
|
2883
|
-
wait_for_update: 500
|
|
2884
|
-
});
|
|
2885
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2886
|
-
window.__cpcgInits.push(function () {
|
|
2887
|
-
if (localStorage.getItem('cpcg_consent') !== 'accepted') return;
|
|
2888
|
-
gtag('consent', 'update', {
|
|
2889
|
-
ad_storage: 'granted',
|
|
2890
|
-
ad_user_data: 'granted',
|
|
2891
|
-
ad_personalization: 'granted',
|
|
2892
|
-
analytics_storage: 'granted'
|
|
2893
|
-
});
|
|
2894
|
-
(function(w,d,s,l,i){var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','$1');
|
|
2895
|
-
});
|
|
2896
|
-
</script>`
|
|
2897
|
-
);
|
|
2898
|
-
if (updatedContent.includes('data-cpcg="deferred-gtm"')) fileModified = true;
|
|
2899
|
-
}
|
|
2900
|
-
}
|
|
2901
|
-
if (/\.(jsx|tsx|js|ts|vue|svelte)$/i.test(filePath) || updatedContent.includes("ReactGA") || updatedContent.includes("react-ga")) {
|
|
2902
|
-
const reactGaInit = /ReactGA\.initialize\s*\(\s*['"`]([^'"`]+)['"`]\s*\)\s*;?/g;
|
|
2903
|
-
if (reactGaInit.test(updatedContent) && !updatedContent.includes("deferred-reactga")) {
|
|
2904
|
-
updatedContent = updatedContent.replace(
|
|
2905
|
-
reactGaInit,
|
|
2906
|
-
`/* data-cpcg="deferred-reactga" */
|
|
2907
|
-
if (typeof window !== 'undefined' && localStorage.getItem('cpcg_consent') === 'accepted') {
|
|
2908
|
-
ReactGA.initialize('$1');
|
|
2909
|
-
} else {
|
|
2910
|
-
window.__cpcgInits = window.__cpcgInits || [];
|
|
2911
|
-
window.__cpcgInits.push(function(){ ReactGA.initialize('$1'); });
|
|
2912
|
-
}`
|
|
2913
|
-
);
|
|
2914
|
-
fileModified = true;
|
|
2915
|
-
}
|
|
2916
|
-
if (/\bgtag\s*\(\s*['"]config['"]/.test(updatedContent) && !updatedContent.includes("cpcgHasConsent") && !updatedContent.includes('data-cpcg="deferred-init"')) {
|
|
2917
|
-
updatedContent = updatedContent.replace(
|
|
2918
|
-
/(\bgtag\s*\(\s*['"]config['"]\s*,\s*['"`][^'"`]+['"`]\s*\)\s*;?)/g,
|
|
2919
|
-
`if (typeof window !== 'undefined' && localStorage.getItem('cpcg_consent') === 'accepted') { $1 }`
|
|
2920
|
-
);
|
|
2921
|
-
fileModified = true;
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
}
|
|
2925
|
-
if (updatedContent.includes('type="text/plain"') && !updatedContent.includes("cpcgConsentRuntime")) {
|
|
2926
|
-
updatedContent = updatedContent.replace(
|
|
2927
|
-
/(<\/head>)/i,
|
|
2928
|
-
`${CONSENT_ACTIVATION_RUNTIME}
|
|
2929
|
-
$1`
|
|
2930
|
-
);
|
|
2931
|
-
if (!updatedContent.includes("cpcgConsentRuntime")) {
|
|
2932
|
-
updatedContent = CONSENT_ACTIVATION_RUNTIME + "\n" + updatedContent;
|
|
2933
|
-
}
|
|
2934
|
-
fileModified = true;
|
|
2935
|
-
}
|
|
2936
|
-
if (fileModified && updatedContent !== content) {
|
|
2937
|
-
patches.push({
|
|
2938
|
-
filePath,
|
|
2939
|
-
originalContent: content,
|
|
2940
|
-
patchedContent: updatedContent,
|
|
2941
|
-
explanation: `Applied privacy remediation for: ${report.violations.map((v) => v.trackerName).join(", ")}. Deferred tracker execution (incl. GTM/React) until explicit consent with post-accept re-activation.`
|
|
2942
|
-
});
|
|
2943
|
-
}
|
|
2944
|
-
}
|
|
2945
|
-
return patches;
|
|
2946
|
-
}
|
|
2947
|
-
/**
|
|
2948
|
-
* LLM API Remediation: Queries OpenAI/Anthropic/Gemini to generate surgical code patches
|
|
2949
|
-
*/
|
|
2950
|
-
async remediateWithLlm(report, candidateFiles, config) {
|
|
2951
|
-
const filesData = [];
|
|
2952
|
-
for (const f of candidateFiles.slice(0, 3)) {
|
|
2953
|
-
const content = await fs2.readFile(f, "utf-8");
|
|
2954
|
-
filesData.push({ path: f, content });
|
|
2955
|
-
}
|
|
2956
|
-
const systemPrompt = `You are the Cuonztech Privacy Auto-Remediation Agent.
|
|
2957
|
-
Your job is to fix privacy leaks detected by the Privacy Chaos Scanner.
|
|
2958
|
-
Return ONLY valid JSON matching this structure:
|
|
2959
|
-
{
|
|
2960
|
-
"patches": [
|
|
2961
|
-
{
|
|
2962
|
-
"filePath": "string",
|
|
2963
|
-
"patchedContent": "string",
|
|
2964
|
-
"explanation": "string"
|
|
2965
|
-
}
|
|
2966
|
-
]
|
|
2967
|
-
}
|
|
2968
|
-
Make minimal, surgical changes (e.g. wrap tracking code in consent checks, add data-cookiecategory attributes). Do NOT remove functional UI code.`;
|
|
2969
|
-
const userPrompt = `A privacy leak was detected:
|
|
2970
|
-
Violations: ${JSON.stringify(report.violations, null, 2)}
|
|
2971
|
-
|
|
2972
|
-
Source Files:
|
|
2973
|
-
${filesData.map((f) => `--- File: ${f.path} ---
|
|
2974
|
-
${f.content}
|
|
2975
|
-
`).join("\n")}
|
|
2976
|
-
|
|
2977
|
-
Generate the patched content to eliminate all unconsented tracker executions.`;
|
|
2978
|
-
try {
|
|
2979
|
-
if (config.llmProvider && config.llmProvider !== "openai") {
|
|
2980
|
-
throw new Error(
|
|
2981
|
-
`LLM-Provider '${config.llmProvider}' ist noch nicht implementiert (nur openai). Deterministische Regel-Engine greift stattdessen.`
|
|
2982
|
-
);
|
|
2983
|
-
}
|
|
2984
|
-
if (config.llmProvider === "openai" || !config.llmProvider) {
|
|
2985
|
-
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
2986
|
-
method: "POST",
|
|
2987
|
-
headers: {
|
|
2988
|
-
"Content-Type": "application/json",
|
|
2989
|
-
Authorization: `Bearer ${config.apiKey}`
|
|
2990
|
-
},
|
|
2991
|
-
signal: AbortSignal.timeout(6e4),
|
|
2992
|
-
body: JSON.stringify({
|
|
2993
|
-
model: config.llmModel || "gpt-4o",
|
|
2994
|
-
response_format: { type: "json_object" },
|
|
2995
|
-
messages: [
|
|
2996
|
-
{ role: "system", content: systemPrompt },
|
|
2997
|
-
{ role: "user", content: userPrompt }
|
|
2998
|
-
],
|
|
2999
|
-
temperature: 0.1
|
|
3000
|
-
})
|
|
3001
|
-
});
|
|
3002
|
-
if (response.ok) {
|
|
3003
|
-
const json = await response.json();
|
|
3004
|
-
const reply = json.choices?.[0]?.message?.content;
|
|
3005
|
-
if (reply) {
|
|
3006
|
-
const parsed = JSON.parse(reply);
|
|
3007
|
-
if (Array.isArray(parsed.patches)) {
|
|
3008
|
-
return await this.sanitizeLlmPatches(parsed.patches, candidateFiles);
|
|
3009
|
-
}
|
|
3010
|
-
}
|
|
3011
|
-
}
|
|
3012
|
-
}
|
|
3013
|
-
} catch (llmErr) {
|
|
3014
|
-
console.warn("\u26A0 LLM request failed, falling back to rule engine: " + String(llmErr));
|
|
3015
|
-
}
|
|
3016
|
-
return [];
|
|
3017
|
-
}
|
|
3018
|
-
/**
|
|
3019
|
-
* LLM output is untrusted: validate against the schema, restrict to known candidate files
|
|
3020
|
-
* (no path traversal / arbitrary writes) and fill originalContent from disk for the Review Agent.
|
|
3021
|
-
*/
|
|
3022
|
-
async sanitizeLlmPatches(raw, candidateFiles) {
|
|
3023
|
-
const allowed = new Map(candidateFiles.map((f) => [path2.resolve(f), f]));
|
|
3024
|
-
const result = [];
|
|
3025
|
-
for (const item of raw) {
|
|
3026
|
-
const parsed = FilePatchSchema.safeParse(item);
|
|
3027
|
-
if (!parsed.success) continue;
|
|
3028
|
-
const resolved = path2.resolve(parsed.data.filePath);
|
|
3029
|
-
const known = allowed.get(resolved);
|
|
3030
|
-
if (!known) {
|
|
3031
|
-
console.warn(`\u26A0 Ignoring LLM patch for unknown/out-of-scope file: ${parsed.data.filePath}`);
|
|
3032
|
-
continue;
|
|
3033
|
-
}
|
|
3034
|
-
const originalContent = await fs2.readFile(known, "utf-8");
|
|
3035
|
-
result.push({ ...parsed.data, filePath: known, originalContent });
|
|
3036
|
-
}
|
|
3037
|
-
return result;
|
|
3038
|
-
}
|
|
3039
|
-
};
|
|
3040
|
-
|
|
3041
|
-
// src/core/legal/jurisdictions.ts
|
|
3042
|
-
var JURISDICTIONS = {
|
|
3043
|
-
CH: {
|
|
3044
|
-
code: "CH",
|
|
3045
|
-
label: "Schweiz",
|
|
3046
|
-
frameworks: ["revDSG (nDSG)", "FDPIC-Richtlinien", "UWG (irref\xFChrende Praktiken)"],
|
|
3047
|
-
priorOptInRequired: true,
|
|
3048
|
-
consentModeV2: "recommended",
|
|
3049
|
-
summary: "Schweizer Datenschutz (revDSG) ist prinzipienbasiert. Der ED\xD6B beschreibt insbesondere Konstellationen personalisierter Werbung (Profiling, Marketing-Tracker), in denen eine Einwilligung erforderlich ist. F\xFCr \xFCbriges Tracking gelten Transparenz- und Informationspflichten; die Grenzen sind einzelfallabh\xE4ngig. Da viele CH-Seiten auch EU-Nutzer erreichen, empfiehlt sich h\xE4ufig die strengere EU-Linie.",
|
|
3050
|
-
note: "Einordnung nach revDSG und ED\xD6B-Cookie-Guidance. Ob Einwilligung erforderlich ist, h\xE4ngt vom Zweck der Verarbeitung ab (personalisierte Werbung: ja). Keine Rechtsberatung \u2014 bei EU-Zielgruppe zus\xE4tzlich EU-Profil nutzen."
|
|
3051
|
-
},
|
|
3052
|
-
EU: {
|
|
3053
|
-
code: "EU",
|
|
3054
|
-
label: "Europ\xE4ische Union",
|
|
3055
|
-
frameworks: ["GDPR/DSGVO", "ePrivacy-Richtlinie", "TTDSG (DE)", "Google Consent Mode v2 (EEA)"],
|
|
3056
|
-
priorOptInRequired: true,
|
|
3057
|
-
consentModeV2: "required",
|
|
3058
|
-
summary: "In der EU gilt f\xFCr nicht-essenzielle Tracker (Analytics, Marketing, Hotjar, TikTok \u2026) grunds\xE4tzlich vorheriges Opt-In (GDPR, ePrivacy, TTDSG). Ein vor der Einwilligung beobachteter Request ist ein starker Hinweis \u2014 ob ein Rechtsverstoss vorliegt, h\xE4ngt vom Zweck, der Technologie und dem dokumentierten Consent-Zustand ab und ist im Einzelfall zu pr\xFCfen. Google Consent Mode v2 ist f\xFCr Google-Produkte im EEA vorgeschrieben; er ist eine technische Implementierung, keine Rechtsgrundlage.",
|
|
3059
|
-
note: "Technischer Befund mit regulatorischem Kontext, keine Rechtsberatung. Verbindliche Einstufung durch Datenschutzbeauftragte/n oder Anwalt/Anw\xE4ltin."
|
|
3060
|
-
},
|
|
3061
|
-
UK: {
|
|
3062
|
-
code: "UK",
|
|
3063
|
-
label: "Vereinigtes K\xF6nigreich",
|
|
3064
|
-
frameworks: ["UK GDPR", "PECR"],
|
|
3065
|
-
priorOptInRequired: true,
|
|
3066
|
-
consentModeV2: "required",
|
|
3067
|
-
summary: "UK GDPR + PECR entsprechen der EU-ePrivacy-Linie: Opt-In vor nicht-essenziellem Tracking. Auch hier ist ein beobachteter Request ein Hinweis \u2014 die rechtliche Einstufung erfolgt im Einzelfall.",
|
|
3068
|
-
note: "Bewertung nach UK GDPR/PECR. Keine Rechtsberatung."
|
|
3069
|
-
},
|
|
3070
|
-
"US-CA": {
|
|
3071
|
-
code: "US-CA",
|
|
3072
|
-
label: "USA \u2014 Kalifornien (CCPA/CPRA)",
|
|
3073
|
-
frameworks: ["CCPA/CPRA", "Do Not Sell / Share", "Global Privacy Control (GPC)", "Opt-out model"],
|
|
3074
|
-
priorOptInRequired: false,
|
|
3075
|
-
consentModeV2: "recommended",
|
|
3076
|
-
summary: "CCPA/CPRA sind prim\xE4r opt-out-basiert (Do Not Sell or Share). Pre-Consent-Tracking ist nicht per se verboten, verlangt aber klare Opt-out-Mechanismen und Offenlegung. Diese Bewertung gilt als unvollst\xE4ndig, solange Sale/Sharing-Opt-out und das Global Privacy Control (GPC) nicht gepr\xFCft sind \u2014 erfasste Unternehmen m\xFCssen GPC als wirksamen Opt-out ber\xFCcksichtigen.",
|
|
3077
|
-
note: "Technische Beobachtung, keine Rechtsberatung. Ohne GPC- und Sale/Sharing-Pr\xFCfung keine abschliessende CCPA-Bewertung. F\xFCr gemischte CA/EU-Traffic EU-Profil anwenden."
|
|
3078
|
-
},
|
|
3079
|
-
BR: {
|
|
3080
|
-
code: "BR",
|
|
3081
|
-
label: "Brasilien (LGPD)",
|
|
3082
|
-
frameworks: ["LGPD"],
|
|
3083
|
-
priorOptInRequired: true,
|
|
3084
|
-
consentModeV2: "recommended",
|
|
3085
|
-
summary: "LGPD verlangt eine Rechtsgrundlage; Consent ist f\xFCr Marketing-Tracking \xFCblich und sicher. Die konkrete Einstufung eines Pre-Consent-Requests erfordert die Pr\xFCfung von Zweck und Technologie.",
|
|
3086
|
-
note: "Bewertung nach LGPD-Praxis. Keine Rechtsberatung."
|
|
3087
|
-
},
|
|
3088
|
-
ROW: {
|
|
3089
|
-
code: "ROW",
|
|
3090
|
-
label: "Rest der Welt",
|
|
3091
|
-
frameworks: ["Landesspezifisch (z. B. PIPEDA, PDPA, APPs)"],
|
|
3092
|
-
priorOptInRequired: false,
|
|
3093
|
-
consentModeV2: "recommended",
|
|
3094
|
-
summary: "Nicht bewertet \u2014 l\xE4nderspezifische Pr\xFCfung erforderlich. CPCG dokumentiert Pre-Consent-Tracking hier neutral als technischen Befund. F\xFCr konkrete Ziell\xE4nder gelten unterschiedliche Regeln (PIPEDA, PDPA, APPs, u. a.), die lokal zu pr\xFCfen sind.",
|
|
3095
|
-
note: "Keine globale Opt-in-/Opt-out-Regel unterstellt. F\xFCr konkrete M\xE4rkte Juristin/Jurist beiziehen."
|
|
3096
|
-
}
|
|
3097
|
-
};
|
|
3098
|
-
function resolveJurisdiction(code) {
|
|
3099
|
-
const key = (code ?? "CH").toUpperCase().replace("_", "-");
|
|
3100
|
-
if (key in JURISDICTIONS) return JURISDICTIONS[key];
|
|
3101
|
-
return JURISDICTIONS.ROW;
|
|
3102
|
-
}
|
|
3103
|
-
function severityForPreConsentLeak(profile) {
|
|
3104
|
-
if (profile.code === "ROW") {
|
|
3105
|
-
return {
|
|
3106
|
-
severity: "P2",
|
|
3107
|
-
framing: "Nicht bewertet \u2014 l\xE4nderspezifische Pr\xFCfung erforderlich. Reiner technischer Befund."
|
|
3108
|
-
};
|
|
3109
|
-
}
|
|
3110
|
-
if (profile.priorOptInRequired && profile.consentModeV2 === "required") {
|
|
3111
|
-
return {
|
|
3112
|
-
severity: "P0",
|
|
3113
|
-
framing: "Opt-In-Pflicht f\xFCr nicht-essenzielle Tracker. Ein vor der Einwilligung beobachteter Request ist ein schwerwiegender Compliance-Hinweis \u2014 die rechtliche Einstufung als Versto\xDF erfordert die Pr\xFCfung von Zweck, Technologie und Consent-Zustand."
|
|
3114
|
-
};
|
|
3115
|
-
}
|
|
3116
|
-
if (profile.priorOptInRequired) {
|
|
3117
|
-
return {
|
|
3118
|
-
severity: "P1",
|
|
3119
|
-
framing: "In diesem Rechtsraum gilt f\xFCr personalisierte Werbung in der Praxis h\xE4ufig eine Einwilligungsl\xF6sung (vergleichbare Aufsichtspraxis); Pre-Consent-Tracking ist ein Risiko, nicht zwingend ein klarer Rechtsbruch. Zweck und Technologie mit einbeziehen."
|
|
3120
|
-
};
|
|
3121
|
-
}
|
|
3122
|
-
return {
|
|
3123
|
-
severity: "P2",
|
|
3124
|
-
framing: "Opt-out-Modell: technischer Befund ohne per-se-Versto\xDF. Offenlegung, Opt-out-Wege und GPC-Unterst\xFCtzung pr\xFCfen."
|
|
3125
|
-
};
|
|
3126
|
-
}
|
|
3127
|
-
|
|
3128
|
-
// src/core/report/evidenceReport.ts
|
|
3129
|
-
import fs3 from "fs";
|
|
3130
|
-
import path3 from "path";
|
|
3131
|
-
function statusLabelOf(s) {
|
|
3132
|
-
return s === "passed" ? "bestanden" : s === "suspicious" ? "auff\xE4llig" : s === "not-applicable" ? "nicht anwendbar" : "nicht pr\xFCfbar";
|
|
3133
|
-
}
|
|
3134
|
-
var CODE_SOLUTIONS = {
|
|
3135
|
-
"pre-consent-tracking": {
|
|
3136
|
-
title: "Tracker erst nach Einwilligung laden (Defer-Muster)",
|
|
3137
|
-
lang: "html",
|
|
3138
|
-
code: `<!-- Vorher: feuert sofort im <head> -->
|
|
3139
|
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
|
|
3140
|
-
<script>
|
|
3141
|
-
window.dataLayer = window.dataLayer || [];
|
|
3142
|
-
function gtag(){ dataLayer.push(arguments); }
|
|
3143
|
-
gtag('js', new Date());
|
|
3144
|
-
gtag('config', 'G-XXXX');
|
|
3145
|
-
</script>
|
|
3146
|
-
|
|
3147
|
-
<!-- Nachher: Blockiert bis die CMP die Kategorie freigibt -->
|
|
3148
|
-
<script type="text/plain" data-cookiecategory="analytics"
|
|
3149
|
-
data-src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
|
|
3150
|
-
<script type="text/plain" data-cookiecategory="analytics">
|
|
3151
|
-
window.dataLayer = window.dataLayer || [];
|
|
3152
|
-
function gtag(){ dataLayer.push(arguments); }
|
|
3153
|
-
gtag('js', new Date());
|
|
3154
|
-
gtag('config', 'G-XXXX');
|
|
3155
|
-
</script>
|
|
3156
|
-
|
|
3157
|
-
<!-- CMP-Callback: bei Akzeptieren Typ umschreiben und ausf\xFChren -->
|
|
3158
|
-
<script>
|
|
3159
|
-
window.addEventListener('consent-given', (e) => {
|
|
3160
|
-
if (!e.detail?.analytics) return;
|
|
3161
|
-
document.querySelectorAll('script[data-cookiecategory="analytics"]').forEach((s) => {
|
|
3162
|
-
const t = document.createElement('script');
|
|
3163
|
-
t.type = 'text/javascript';
|
|
3164
|
-
if (s.dataset.src) t.src = s.dataset.src;
|
|
3165
|
-
else t.text = s.text;
|
|
3166
|
-
s.replaceWith(t);
|
|
3167
|
-
});
|
|
3168
|
-
});
|
|
3169
|
-
</script>`
|
|
3170
|
-
},
|
|
3171
|
-
"tracking-without-consent": {
|
|
3172
|
-
title: "Auch nach Widerruf-Reset keine Tracker ohne erneute Zustimmung",
|
|
3173
|
-
lang: "html",
|
|
3174
|
-
code: `<!-- Consent-Status ist die einzige Eintrittskontrolle -->
|
|
3175
|
-
<script>
|
|
3176
|
-
function trackerAllowed(category) {
|
|
3177
|
-
const c = window.__consent; // von der CMP gesetzt
|
|
3178
|
-
return !!(c && c[category] === true);
|
|
3179
|
-
}
|
|
3180
|
-
|
|
3181
|
-
// Kein direkter Aufruf von gtag/pixel/fbq ohne diesen Check.
|
|
3182
|
-
if (trackerAllowed('analytics')) {
|
|
3183
|
-
gtag('config', 'G-XXXX');
|
|
3184
|
-
}
|
|
3185
|
-
</script>`
|
|
3186
|
-
},
|
|
3187
|
-
"pre-consent-embed": {
|
|
3188
|
-
title: "Externe Embeds hinter eine Consent-Fassade legen",
|
|
3189
|
-
lang: "html",
|
|
3190
|
-
code: `<!-- Vorher: iframe l\xE4dt Drittinhalte ohne Einwilligung -->
|
|
3191
|
-
<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"></iframe>
|
|
3192
|
-
|
|
3193
|
-
<!-- Nachher: Fassade, iFrame erst nach Klick -->
|
|
3194
|
-
<div class="embed-gate" data-embed="https://www.youtube-nocookie.com/embed/VIDEO_ID">
|
|
3195
|
-
<button type="button" class="embed-gate__load">Video laden (setzt Cookies von YouTube)</button>
|
|
3196
|
-
</div>
|
|
3197
|
-
<script>
|
|
3198
|
-
document.querySelectorAll('.embed-gate').forEach((gate) => {
|
|
3199
|
-
gate.querySelector('button').addEventListener('click', () => {
|
|
3200
|
-
const src = gate.dataset.embed;
|
|
3201
|
-
gate.innerHTML = '';
|
|
3202
|
-
const iframe = document.createElement('iframe');
|
|
3203
|
-
iframe.src = src;
|
|
3204
|
-
iframe.allow = 'accelerometer; autoplay; encrypted-media; picture-in-picture';
|
|
3205
|
-
iframe.allowFullscreen = true;
|
|
3206
|
-
gate.appendChild(iframe);
|
|
3207
|
-
});
|
|
3208
|
-
});
|
|
3209
|
-
</script>`
|
|
3210
|
-
},
|
|
3211
|
-
"tracking-after-revoke": {
|
|
3212
|
-
title: "Nach Widerruf: Tracker stoppen und Rest-IDs bereinigen",
|
|
3213
|
-
lang: "js",
|
|
3214
|
-
code: `// Schematisches Muster \u2014 an die eigene CMP/Tracker anpassen.
|
|
3215
|
-
// In den Revoke-Handler der CMP einh\xE4ngen.
|
|
3216
|
-
function onConsentRevoked() {
|
|
3217
|
-
// 1. Laufende Tracker abschalten
|
|
3218
|
-
window['ga-disable-G-XXXX'] = true;
|
|
3219
|
-
if (window.gtag) {
|
|
3220
|
-
gtag('consent', 'update', {
|
|
3221
|
-
ad_storage: 'denied',
|
|
3222
|
-
analytics_storage: 'denied',
|
|
3223
|
-
ad_user_data: 'denied',
|
|
3224
|
-
ad_personalization: 'denied',
|
|
3225
|
-
});
|
|
3226
|
-
}
|
|
3227
|
-
|
|
3228
|
-
// 2. Tracker-Cookies l\xF6schen (nur IDs, die der Scan als Residual gemeldet hat)
|
|
3229
|
-
const names = ['_ga', '_gid', '_gat', '_fbp', '_fbc', 'fr'];
|
|
3230
|
-
for (const name of names) {
|
|
3231
|
-
for (const path of ['/', '/blog', '/shop']) {
|
|
3232
|
-
document.cookie = name + '=; Max-Age=0; path=' + path + '; SameSite=Lax';
|
|
3233
|
-
document.cookie = name + '=; Max-Age=0; path=' + path +
|
|
3234
|
-
'; domain=.' + location.hostname.replace(/^www\\./, '') + '; SameSite=Lax';
|
|
3235
|
-
}
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
// 3. Tracker-Residual aus localStorage/sessionStorage entfernen.
|
|
3239
|
-
// WICHTIG: CMP-Entscheidungsspeicher (OptanonConsent, CookieConsent,
|
|
3240
|
-
// usercentrics-consent) NICHT l\xF6schen \u2014 dort steht die Ablehnung.
|
|
3241
|
-
const residualPrefixes = ['_ga', '_gid', '_gat', '_fbp', '_fbc'];
|
|
3242
|
-
for (const store of [localStorage, sessionStorage]) {
|
|
3243
|
-
for (const key of Object.keys(store)) {
|
|
3244
|
-
if (residualPrefixes.some((p) => key === p || key.startsWith(p + '.'))) {
|
|
3245
|
-
store.removeItem(key);
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
}
|
|
3249
|
-
}`
|
|
3250
|
-
},
|
|
3251
|
-
"tracking-after-deny": {
|
|
3252
|
-
title: "Ablehnung respektieren: kein Tracking ohne Opt-In",
|
|
3253
|
-
lang: "js",
|
|
3254
|
-
code: `// CMP: Choice speichern und Tracker erst bei explizitem Accept aktivieren
|
|
3255
|
-
function onConsentChoice(choice) {
|
|
3256
|
-
// choice = { analytics: boolean, marketing: boolean }
|
|
3257
|
-
if (!choice.analytics) {
|
|
3258
|
-
window['ga-disable-G-XXXX'] = true;
|
|
3259
|
-
window.gtag?.('consent', 'update', { analytics_storage: 'denied' });
|
|
3260
|
-
return; // keine Analytics-Requests
|
|
3261
|
-
}
|
|
3262
|
-
window['ga-disable-G-XXXX'] = false;
|
|
3263
|
-
window.gtag?.('consent', 'update', { analytics_storage: 'granted' });
|
|
3264
|
-
gtag('config', 'G-XXXX');
|
|
3265
|
-
}`
|
|
3266
|
-
},
|
|
3267
|
-
"tracking-declined-category": {
|
|
3268
|
-
title: "Kategorie-Selektivit\xE4t: abgelehnte Kategorien bleiben blockiert",
|
|
3269
|
-
lang: "js",
|
|
3270
|
-
code: `// CMP gibt Kategorie-Flags zur\xFCck \u2014 nur freigegebene Kategorien starten Tracker
|
|
3271
|
-
function activateFor(consent) {
|
|
3272
|
-
if (consent.analytics) {
|
|
3273
|
-
loadScript('https://www.googletagmanager.com/gtag/js?id=G-XXXX');
|
|
3274
|
-
gtag('config', 'G-XXXX');
|
|
3275
|
-
}
|
|
3276
|
-
if (consent.marketing) {
|
|
3277
|
-
// Meta Pixel, LinkedIn Insight etc. erst hier
|
|
3278
|
-
!function(f,b,e,v,n,t,s){/* fbq init */}(window,document,'script');
|
|
3279
|
-
}
|
|
3280
|
-
// Kategorie "functional" l\xE4dt keine Tracker
|
|
3281
|
-
}`
|
|
3282
|
-
},
|
|
3283
|
-
"duplicate-events": {
|
|
3284
|
-
title: "Doppelte Analytics-Eintr\xE4ge vermeiden (ein Tag-Manager)",
|
|
3285
|
-
lang: "html",
|
|
3286
|
-
code: `<!-- Vorher: Plugin UND GTM senden beide page_view -->
|
|
3287
|
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
|
|
3288
|
-
<script>gtag('config', 'G-XXXX');</script>
|
|
3289
|
-
<!-- PLUS GTM-Container, der dasselbe Event noch einmal sendet -->
|
|
3290
|
-
|
|
3291
|
-
<!-- Nachher: genau eine Quelle. Entweder gtag ODER GTM \u2014 nicht beide. -->
|
|
3292
|
-
<!-- Variante A: nur gtag -->
|
|
3293
|
-
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
|
|
3294
|
-
<script>
|
|
3295
|
-
gtag('js', new Date());
|
|
3296
|
-
gtag('config', 'G-XXXX'); // sendet page_view genau einmal
|
|
3297
|
-
</script>
|
|
3298
|
-
|
|
3299
|
-
<!-- Variante B: nur GTM-Container (dataLayer statt zus\xE4tzlichem gtag-config) -->
|
|
3300
|
-
<script>
|
|
3301
|
-
window.dataLayer = window.dataLayer || [];
|
|
3302
|
-
window.dataLayer.push({ event: 'page_view' });
|
|
3303
|
-
</script>`
|
|
3304
|
-
},
|
|
3305
|
-
"consent-mode-mismatch": {
|
|
3306
|
-
title: "Google Consent Mode v2 konsistent setzen",
|
|
3307
|
-
lang: "js",
|
|
3308
|
-
code: `// Schematisches Muster \u2014 vor jedem Google-Tag ausf\xFChren.
|
|
3309
|
-
// Default: alles denied \u2014 BEVOR irgendein Google-Tag l\xE4dt.
|
|
3310
|
-
window.dataLayer = window.dataLayer || [];
|
|
3311
|
-
function gtag(){ dataLayer.push(arguments); }
|
|
3312
|
-
gtag('js', new Date());
|
|
3313
|
-
|
|
3314
|
-
gtag('consent', 'default', {
|
|
3315
|
-
ad_storage: 'denied',
|
|
3316
|
-
ad_user_data: 'denied',
|
|
3317
|
-
ad_personalization: 'denied',
|
|
3318
|
-
analytics_storage: 'denied',
|
|
3319
|
-
wait_for_update: 500,
|
|
3320
|
-
});
|
|
3321
|
-
|
|
3322
|
-
// CMP-Update: nur die tats\xE4chlich erteilten Kategorien auf granted
|
|
3323
|
-
function onConsentUpdate(consent) {
|
|
3324
|
-
gtag('consent', 'update', {
|
|
3325
|
-
analytics_storage: consent.analytics ? 'granted' : 'denied',
|
|
3326
|
-
ad_storage: consent.marketing ? 'granted' : 'denied',
|
|
3327
|
-
ad_user_data: consent.marketing ? 'granted' : 'denied',
|
|
3328
|
-
ad_personalization: consent.marketing ? 'granted' : 'denied',
|
|
3329
|
-
});
|
|
3330
|
-
}`
|
|
3331
|
-
}
|
|
3332
|
-
};
|
|
3333
|
-
function codeSolutionsFor(report) {
|
|
3334
|
-
const ruleIds = [...new Set(report.violations.map((v) => v.ruleId ?? "pre-consent-tracking"))];
|
|
3335
|
-
const out = [];
|
|
3336
|
-
for (const id of ruleIds) {
|
|
3337
|
-
const sol = CODE_SOLUTIONS[id];
|
|
3338
|
-
if (sol) out.push({ ruleId: id, ...sol });
|
|
3339
|
-
}
|
|
3340
|
-
return out;
|
|
3341
|
-
}
|
|
3342
|
-
function deriveReportFacts(report) {
|
|
3343
|
-
const evidence = report.evidence ?? [];
|
|
3344
|
-
const passed = evidence.filter((e) => e.status === "passed").length;
|
|
3345
|
-
const flagged = evidence.filter((e) => e.status === "suspicious").length;
|
|
3346
|
-
const uncheckable = evidence.filter((e) => e.status === "not-checkable").length;
|
|
3347
|
-
const notApplicable = evidence.filter((e) => e.status === "not-applicable").length;
|
|
3348
|
-
let observedCount = 0;
|
|
3349
|
-
for (const e of evidence) {
|
|
3350
|
-
for (const r of e.requestTimeline) {
|
|
3351
|
-
if (r.trackerName) observedCount++;
|
|
3352
|
-
}
|
|
3353
|
-
}
|
|
3354
|
-
const findings = report.totalViolations;
|
|
3355
|
-
return {
|
|
3356
|
-
evidenceCount: evidence.length,
|
|
3357
|
-
passed,
|
|
3358
|
-
flagged,
|
|
3359
|
-
uncheckable,
|
|
3360
|
-
notApplicable,
|
|
3361
|
-
incomplete: uncheckable + notApplicable,
|
|
3362
|
-
findings,
|
|
3363
|
-
observedCount,
|
|
3364
|
-
gate: findings === 0 ? "PASS" : "BLOCKED",
|
|
3365
|
-
findingsText: findingsCountText(findings),
|
|
3366
|
-
gateDisclaimer: "Eine rechtliche Gesamtbewertung erfolgt nicht.",
|
|
3367
|
-
problemCount: groupFindings(report.violations ?? []).length,
|
|
3368
|
-
problemsText: problemsTextOf(groupFindings(report.violations ?? []).length)
|
|
3369
|
-
};
|
|
3370
|
-
}
|
|
3371
|
-
function findingsCountText(n) {
|
|
3372
|
-
return n === 1 ? "1 dokumentierte Befund-Zeile" : `${n} dokumentierte Befund-Zeilen`;
|
|
3373
|
-
}
|
|
3374
|
-
function problemsTextOf(n) {
|
|
3375
|
-
return n === 0 ? "Keine offenen Befundgruppen" : n === 1 ? "1 Befundgruppe" : `${n} Befundgruppen`;
|
|
3376
|
-
}
|
|
3377
|
-
function groupFindings(violations) {
|
|
3378
|
-
const map = /* @__PURE__ */ new Map();
|
|
3379
|
-
for (const v of violations) {
|
|
3380
|
-
const ruleId = v.ruleId ?? "pre-consent-tracking";
|
|
3381
|
-
const key = `${ruleId}|${v.trackerName}`;
|
|
3382
|
-
const cur = map.get(key);
|
|
3383
|
-
if (cur) cur.count++;
|
|
3384
|
-
else map.set(key, { key, ruleId, trackerName: v.trackerName, count: 1 });
|
|
3385
|
-
}
|
|
3386
|
-
return [...map.values()].sort((a, b) => b.count - a.count);
|
|
3387
|
-
}
|
|
3388
|
-
function reproductionText(count) {
|
|
3389
|
-
return count === 1 ? "1 Request" : `${count} Requests`;
|
|
3390
|
-
}
|
|
3391
|
-
function buildEvidenceMarkdown(report, title = "Evidence Report", options = {}) {
|
|
3392
|
-
const evidence = report.evidence ?? [];
|
|
3393
|
-
const demoLine = options.demo ? "> **Demo-Report** \xB7 synthetische Testdaten \u2014 keine realen Kundendaten.\n\n" : "";
|
|
3394
|
-
const facts = deriveReportFacts(report);
|
|
3395
|
-
const icon = statusLabelOf;
|
|
3396
|
-
const lines = [
|
|
3397
|
-
`# CuonzTech Privacy Chaos Gate \u2014 ${title}`,
|
|
3398
|
-
"",
|
|
3399
|
-
`**Ziel:** \`${report.targetUrl}\` `,
|
|
3400
|
-
`**Scan:** ${report.scannedAt} `,
|
|
3401
|
-
`**Technisches Gate:** ${facts.gate} \xB7 ${facts.findingsText} \xB7 ${facts.gateDisclaimer}`,
|
|
3402
|
-
"",
|
|
3403
|
-
"> **Hinweis:** Technisches Hilfsmittel, keine Rechtsberatung. / Technical aid, not legal advice.",
|
|
3404
|
-
"",
|
|
3405
|
-
"## Szenarien",
|
|
3406
|
-
"",
|
|
3407
|
-
"| Szenario | Status | Bemerkung |",
|
|
3408
|
-
"|----------|--------|-----------|"
|
|
3409
|
-
];
|
|
3410
|
-
for (const e of evidence) {
|
|
3411
|
-
const note = e.note.replace(/\|/g, "\\|").replace(/`/g, "'").replace(/\s*\n\s*/g, " ");
|
|
3412
|
-
lines.push(`| \`${e.scenario}\` | **${icon(e.status)}** | ${note} |`);
|
|
3413
|
-
}
|
|
3414
|
-
if (report.violations.length > 0) {
|
|
3415
|
-
lines.push("", "## Befunde", "", "| Szenario | Tracker | Request |", "|----------|---------|---------|");
|
|
3416
|
-
for (const v of report.violations) {
|
|
3417
|
-
const url = v.requestUrl.slice(0, 80).replace(/\|/g, "\\|").replace(/`/g, "'");
|
|
3418
|
-
lines.push(
|
|
3419
|
-
`| \`${v.scenario}\` | ${v.trackerName} | \`${url}\` |`
|
|
3420
|
-
);
|
|
3421
|
-
}
|
|
3422
|
-
const solutions = codeSolutionsFor(report);
|
|
3423
|
-
if (solutions.length > 0) {
|
|
3424
|
-
lines.push("", "## Code-L\xF6sungen", "");
|
|
3425
|
-
for (const s of solutions) {
|
|
3426
|
-
lines.push(`### ${s.title}`, "", `\`${s.ruleId}\``, "", "```" + s.lang, s.code, "```", "");
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
3429
|
-
}
|
|
3430
|
-
lines.push("", "## Beweis", "");
|
|
3431
|
-
for (const e of evidence) {
|
|
3432
|
-
if (!e.screenshots?.length && !e.screenshotPath) continue;
|
|
3433
|
-
lines.push(`### ${e.scenario}`, "");
|
|
3434
|
-
for (const s of e.screenshots ?? []) {
|
|
3435
|
-
lines.push(`- **${s.label}** (${s.at}): \`evidence/${path3.basename(s.path)}\``);
|
|
3436
|
-
}
|
|
3437
|
-
if (e.storageAfter?.cookies?.length) {
|
|
3438
|
-
lines.push(`- Cookies nachher: ${e.storageAfter.cookies.slice(0, 8).join(", ")}`);
|
|
3439
|
-
}
|
|
3440
|
-
if (e.residualAfterRevoke?.length) {
|
|
3441
|
-
lines.push(`- Residual nach Widerruf: ${e.residualAfterRevoke.join(", ")}`);
|
|
3442
|
-
}
|
|
3443
|
-
lines.push("");
|
|
3444
|
-
}
|
|
3445
|
-
lines.push("---", "*\xA9 CuonzTech \xB7 Privacy Chaos Gate*");
|
|
3446
|
-
return lines.join("\n");
|
|
3447
|
-
}
|
|
3448
|
-
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3449
|
-
var statusLabel = statusLabelOf;
|
|
3450
|
-
var statusClass = (s) => s === "passed" ? "ok" : s === "suspicious" ? "warn" : "skip";
|
|
3451
|
-
var fmtTime = (iso) => {
|
|
3452
|
-
const d = new Date(iso);
|
|
3453
|
-
return isNaN(d.getTime()) ? iso : d.toLocaleString("de-CH", { dateStyle: "medium", timeStyle: "medium" });
|
|
3454
|
-
};
|
|
3455
|
-
var fmtDuration = (ms) => ms >= 6e4 ? `${Math.round(ms / 6e4)} min` : `${(ms / 1e3).toFixed(1)} s`;
|
|
3456
|
-
function shotSrc(p) {
|
|
3457
|
-
try {
|
|
3458
|
-
const buf = fs3.readFileSync(p);
|
|
3459
|
-
const ext = path3.extname(p).toLowerCase().replace(".", "");
|
|
3460
|
-
const mime = ext === "jpg" ? "jpeg" : ext === "jpeg" ? "jpeg" : "png";
|
|
3461
|
-
return `data:image/${mime};base64,${buf.toString("base64")}`;
|
|
3462
|
-
} catch {
|
|
3463
|
-
return "";
|
|
3464
|
-
}
|
|
3465
|
-
}
|
|
3466
|
-
function buildEvidenceHtml(report, title = "Evidence Report", options = {}) {
|
|
3467
|
-
const evidence = report.evidence ?? [];
|
|
3468
|
-
const facts = deriveReportFacts(report);
|
|
3469
|
-
const passed = facts.passed;
|
|
3470
|
-
const flagged = facts.flagged;
|
|
3471
|
-
const uncheckable = facts.uncheckable;
|
|
3472
|
-
const notApplicable = facts.notApplicable;
|
|
3473
|
-
const observed = [];
|
|
3474
|
-
for (const e of evidence) {
|
|
3475
|
-
for (const r of e.requestTimeline) {
|
|
3476
|
-
if (r.trackerName) {
|
|
3477
|
-
observed.push({
|
|
3478
|
-
scenario: e.scenario,
|
|
3479
|
-
at: r.at,
|
|
3480
|
-
url: r.url,
|
|
3481
|
-
trackerName: r.trackerName,
|
|
3482
|
-
phase: r.phase
|
|
3483
|
-
});
|
|
3484
|
-
}
|
|
3485
|
-
}
|
|
3486
|
-
}
|
|
3487
|
-
const kpis = [
|
|
3488
|
-
{ label: "Szenarien gepr\xFCft", value: String(evidence.length), tone: "gold" },
|
|
3489
|
-
{ label: "bestanden", value: String(passed), tone: "ok" },
|
|
3490
|
-
{ label: "auff\xE4llig", value: String(flagged), tone: flagged > 0 ? "warn" : "ok" },
|
|
3491
|
-
{
|
|
3492
|
-
label: "nicht pr\xFCfbar",
|
|
3493
|
-
value: `${uncheckable + notApplicable}`,
|
|
3494
|
-
tone: "skip"
|
|
3495
|
-
},
|
|
3496
|
-
{ label: "Befunde (Leaks)", value: String(report.totalViolations), tone: report.totalViolations > 0 ? "warn" : "ok" },
|
|
3497
|
-
{ label: "beobachtete Requests", value: String(observed.length), tone: "gold" }
|
|
3498
|
-
];
|
|
3499
|
-
const incomplete = uncheckable + notApplicable;
|
|
3500
|
-
const RULE_LABELS = {
|
|
3501
|
-
"pre-consent-tracking": "Keine Analytics-Requests vor Zustimmung",
|
|
3502
|
-
"pre-consent-embed": "Keine Drittanbieter-Embeds vor Zustimmung",
|
|
3503
|
-
"tracking-without-consent": "Kein Tracking ohne g\xFCltige Einwilligung",
|
|
3504
|
-
"tracking-after-revoke": "Kein Tracking nach Widerruf",
|
|
3505
|
-
"tracking-after-deny": "Kein Tracking nach gespeicherter Ablehnung",
|
|
3506
|
-
"tracking-declined-category": "Kein Tracking abgelehnter Kategorien",
|
|
3507
|
-
"duplicate-events": "Keine doppelten Analytics-Events",
|
|
3508
|
-
"consent-mode-mismatch": "Consent-Mode-Zustand deckt sich mit Verhalten"
|
|
3509
|
-
};
|
|
3510
|
-
const violatedRules = [...new Set(report.violations.map((v) => v.ruleId ?? "pre-consent-tracking"))];
|
|
3511
|
-
const ruleText = violatedRules.length === 0 ? "Die konfigurierten Regeln wurden nicht verletzt." : `Verletzt: ${violatedRules.map((r) => `\xAB${RULE_LABELS[r] ?? r}\xBB`).join(", ")}.`;
|
|
3512
|
-
const gateClass = report.totalViolations === 0 ? "" : "blocked";
|
|
3513
|
-
const gateTitle = report.totalViolations === 0 ? "Technisches Gate: PASS" : "Technisches Gate: BLOCKED";
|
|
3514
|
-
const gateDetail = `${ruleText} ` + (incomplete > 0 ? `${incomplete === 1 ? "1 Szenario" : `${incomplete} Szenarien`} konnten nicht abschliessend bewertet werden. ` : "") + `Eine rechtliche Gesamtbewertung erfolgt nicht.`;
|
|
3515
|
-
const ranList = evidence.map((e) => `<code>${esc(e.scenario)}</code>`).join(", ") || "\u2014";
|
|
3516
|
-
const summaryHtml = `
|
|
3517
|
-
<p>Der Scan f\xFChrte <strong>${evidence.length === 1 ? "1 Szenario" : `${evidence.length} Szenarien`}</strong> aus: ${ranList}.
|
|
3518
|
-
Ergebnis: ${passed} bestanden \xB7 ${flagged} auff\xE4llig \xB7 ${uncheckable} nicht pr\xFCfbar \xB7 ${notApplicable} nicht anwendbar.</p>
|
|
3519
|
-
<p>Gef\xFChrt werden <strong>${esc(findingsCountText(report.totalViolations))}</strong> und
|
|
3520
|
-
<strong>${observed.length === 1 ? "1 beobachteter Request" : `${observed.length} beobachtete Requests`}</strong> mit erkanntem Tracker
|
|
3521
|
-
(Timelines aller Szenarien \u2014 auch dort, wo keine Violation-Row entstand).</p>`;
|
|
3522
|
-
const jCodes = ["CH", "EU", "UK", "US-CA", "BR", "ROW"];
|
|
3523
|
-
const hasPreConsentFinding = report.violations.some(
|
|
3524
|
-
(v) => (v.ruleId ?? "pre-consent-tracking").startsWith("pre-consent") || (v.ruleId ?? "") === "tracking-without-consent"
|
|
3525
|
-
);
|
|
3526
|
-
const jCards = jCodes.map((code) => {
|
|
3527
|
-
const profile = JURISDICTIONS[code];
|
|
3528
|
-
const { severity, framing } = severityForPreConsentLeak(profile);
|
|
3529
|
-
const tone = severity === "P0" ? "warn" : severity === "P1" ? "gold" : "skip";
|
|
3530
|
-
const framingText = hasPreConsentFinding ? framing : `Falls ein Pre-Consent-Request beobachtet wird: ${framing} Derzeit kein solcher Befund.`;
|
|
3531
|
-
return `<article class="jcard">
|
|
3532
|
-
<header><span class="sev sev-${tone}">${severity}</span><h3>${esc(profile.label)}</h3></header>
|
|
3533
|
-
<p class="jframe">${esc(framingText)}</p>
|
|
3534
|
-
<p class="jfw">${profile.frameworks.map(esc).join(" \xB7 ")}</p>
|
|
3535
|
-
</article>`;
|
|
3536
|
-
}).join("\n");
|
|
3537
|
-
const cards = evidence.map((e) => {
|
|
3538
|
-
const chips = e.actionSequence.map((a) => `<span class="chip">${esc(a)}</span>`).join("");
|
|
3539
|
-
const actionRows = (e.actionTimeline ?? []).slice(0, 12).map(
|
|
3540
|
-
(a) => `<tr><td class="ts">${esc(fmtTime(a.at))}</td><td><code>${esc(a.action)}</code></td></tr>`
|
|
3541
|
-
).join("");
|
|
3542
|
-
const reqRows = e.requestTimeline.filter((r) => r.trackerName).slice(0, 10).map(
|
|
3543
|
-
(r) => `<tr>
|
|
3544
|
-
<td class="ts">${esc(fmtTime(r.at))}</td>
|
|
3545
|
-
<td>${esc(r.trackerName ?? "")}</td>
|
|
3546
|
-
<td><span class="phase phase-${r.phase.replace(/[^a-z]/g, "")}">${esc(r.phase)}</span></td>
|
|
3547
|
-
<td><code>${esc(r.url.slice(0, 90))}</code></td>
|
|
3548
|
-
</tr>`
|
|
3549
|
-
).join("");
|
|
3550
|
-
const diff = (before, after, label) => {
|
|
3551
|
-
const added = after.filter((x) => !before.includes(x));
|
|
3552
|
-
const removed = before.filter((x) => !after.includes(x));
|
|
3553
|
-
if (!added.length && !removed.length) return "";
|
|
3554
|
-
return `<p class="diff"><strong>${label}:</strong>
|
|
3555
|
-
${added.map((a) => `<span class="add">+ ${esc(a)}</span>`).join(" ")}
|
|
3556
|
-
${removed.map((r) => `<span class="del">\u2212 ${esc(r)}</span>`).join(" ")}
|
|
3557
|
-
</p>`;
|
|
3558
|
-
};
|
|
3559
|
-
const entriesDiff = (before, after, label) => {
|
|
3560
|
-
const b = new Map((before ?? []).map((e2) => [e2.key, e2.value]));
|
|
3561
|
-
const a = new Map((after ?? []).map((e2) => [e2.key, e2.value]));
|
|
3562
|
-
const rows = [];
|
|
3563
|
-
for (const [key, val] of a) {
|
|
3564
|
-
const prev = b.get(key);
|
|
3565
|
-
if (prev === void 0) rows.push(`<span class="add">+ ${esc(key)} = ${esc(val)}</span>`);
|
|
3566
|
-
else if (prev !== val) rows.push(`<span class="add">~ ${esc(key)} = ${esc(val)}</span> <span class="del">vorher: ${esc(prev)}</span>`);
|
|
3567
|
-
}
|
|
3568
|
-
for (const [key, val] of b) {
|
|
3569
|
-
if (!a.has(key)) rows.push(`<span class="del">\u2212 ${esc(key)} = ${esc(val)}</span>`);
|
|
3570
|
-
}
|
|
3571
|
-
if (!rows.length) return "";
|
|
3572
|
-
return `<p class="diff"><strong>${label}:</strong> ${rows.join(" ")}</p>`;
|
|
3573
|
-
};
|
|
3574
|
-
const storageDiff = [
|
|
3575
|
-
diff(e.storageBefore.cookies, e.storageAfter.cookies, "Cookies"),
|
|
3576
|
-
diff(e.storageBefore.localStorageKeys, e.storageAfter.localStorageKeys, "localStorage"),
|
|
3577
|
-
diff(e.storageBefore.sessionStorageKeys, e.storageAfter.sessionStorageKeys, "sessionStorage"),
|
|
3578
|
-
entriesDiff(e.storageBefore.localStorageEntries, e.storageAfter.localStorageEntries, "Consent-Werte")
|
|
3579
|
-
].join("");
|
|
3580
|
-
const peakDiff = e.storageAfterAccept ? [
|
|
3581
|
-
diff(e.storageAfterAccept.cookies, e.storageAfter.cookies, "Widerruf-Diff Cookies"),
|
|
3582
|
-
diff(e.storageAfterAccept.localStorageKeys, e.storageAfter.localStorageKeys, "Widerruf-Diff localStorage"),
|
|
3583
|
-
entriesDiff(e.storageAfterAccept.localStorageEntries, e.storageAfter.localStorageEntries, "Widerruf-Diff Consent-Werte")
|
|
3584
|
-
].join("") : "";
|
|
3585
|
-
const residual = e.residualAfterRevoke?.length ? `<p class="residual"><strong>Residual nach Widerruf:</strong>
|
|
3586
|
-
${e.residualAfterRevoke.map((r) => `<code>${esc(r)}</code>`).join(" ")}
|
|
3587
|
-
<span class="warn-text">\u2014 diese IDs bleiben zur\xFCck</span></p>` : e.residualAfterRevoke ? `<p class="muted-line">Keine Residual-IDs nach Widerruf.</p>` : "";
|
|
3588
|
-
const shots = e.screenshots ?? (e.screenshotPath ? [{ label: "Screenshot", path: e.screenshotPath, at: e.checkedAt }] : []);
|
|
3589
|
-
const gallery = shots.length ? `<div class="shots">${shots.map((s) => {
|
|
3590
|
-
const src = shotSrc(s.path);
|
|
3591
|
-
const media = src ? `<img class="shot" src="${src}" alt="${esc(s.label)}" loading="lazy">` : `<code class="shot-path">evidence/${esc(path3.basename(s.path))}</code>`;
|
|
3592
|
-
return `<figure>
|
|
3593
|
-
<figcaption><span class="shot-label">${esc(s.label)}</span>
|
|
3594
|
-
<span class="ts">${esc(fmtTime(s.at))}</span></figcaption>
|
|
3595
|
-
${media}
|
|
3596
|
-
</figure>`;
|
|
3597
|
-
}).join("")}</div>` : "";
|
|
3598
|
-
return `<article class="scard s-${statusClass(e.status)}">
|
|
3599
|
-
<header class="shead">
|
|
3600
|
-
<div class="stitle"><code class="sname">${esc(e.scenario)}</code>
|
|
3601
|
-
<span class="badge ${statusClass(e.status)}">${statusLabel(e.status)}</span>
|
|
3602
|
-
</div>
|
|
3603
|
-
<p class="snote">${esc(e.note)}</p>
|
|
3604
|
-
</header>
|
|
3605
|
-
|
|
3606
|
-
<div class="sbody">
|
|
3607
|
-
<h4>Ablauf</h4>
|
|
3608
|
-
<div class="chips">${chips || '<span class="chip muted">keine Aktionen protokolliert</span>'}</div>
|
|
3609
|
-
${actionRows ? `<details open><summary>Aktions-Zeitlinie (${(e.actionTimeline ?? []).length})</summary>
|
|
3610
|
-
<table class="mini"><tbody>${actionRows}</tbody></table></details>` : ""}
|
|
3611
|
-
|
|
3612
|
-
${reqRows ? `<h4>Tracker-Requests</h4>
|
|
3613
|
-
<table class="mini">
|
|
3614
|
-
<thead><tr><th>Zeit</th><th>Tracker</th><th>Phase</th><th>Request</th></tr></thead>
|
|
3615
|
-
<tbody>${reqRows}</tbody>
|
|
3616
|
-
</table>` : `<p class="muted-line">Keine Tracker-Requests in diesem Szenario erfasst.</p>`}
|
|
3617
|
-
|
|
3618
|
-
${peakDiff ? `<h4>Storage-Diff Widerruf (Peak nach Accept \u2192 Endzustand)</h4>${peakDiff}` : ""}
|
|
3619
|
-
${storageDiff ? `<h4>Storage-Diff</h4>${storageDiff}` : ""}
|
|
3620
|
-
${residual}
|
|
3621
|
-
${gallery}
|
|
3622
|
-
</div>
|
|
3623
|
-
<footer class="sfoot">gepr\xFCft ${esc(fmtTime(e.checkedAt))} \xB7 <code>${esc(e.targetUrl)}</code></footer>
|
|
3624
|
-
</article>`;
|
|
3625
|
-
}).join("\n");
|
|
3626
|
-
const recs = [];
|
|
3627
|
-
const ruleSet = new Set(report.violations.map((v) => v.ruleId ?? "pre-consent-tracking"));
|
|
3628
|
-
if (ruleSet.has("pre-consent-tracking") || ruleSet.has("pre-consent-embed") || ruleSet.has("tracking-without-consent")) {
|
|
3629
|
-
recs.push(
|
|
3630
|
-
"Alle Pre-Consent-Tracker und -Embeds bis zur Einwilligung abschalten (Defer-Muster) oder in die CMP-Steuerung h\xE4ngen \u2014 belegt durch die Befunde im Register."
|
|
3631
|
-
);
|
|
3632
|
-
}
|
|
3633
|
-
if (ruleSet.has("tracking-after-revoke") || ruleSet.has("tracking-after-deny")) {
|
|
3634
|
-
recs.push(
|
|
3635
|
-
"Tracking nach Widerruf/Ablehnung stoppen: Consent-Status server- und clientseitig respektieren \u2014 belegt durch die Befunde im Register."
|
|
3636
|
-
);
|
|
3637
|
-
}
|
|
3638
|
-
if (ruleSet.has("tracking-declined-category")) {
|
|
3639
|
-
recs.push(
|
|
3640
|
-
"Kategorie-Selektivit\xE4t der CMP umsetzen: abgelehnte Kategorien (Marketing) d\xFCrfen keine Tracker starten \u2014 belegt durch die Befunde im Register."
|
|
3641
|
-
);
|
|
3642
|
-
}
|
|
3643
|
-
if (ruleSet.has("duplicate-events")) {
|
|
3644
|
-
recs.push(
|
|
3645
|
-
"Doppelte Analytics-Eintr\xE4ge vermeiden: genau eine Tracking-Quelle betreiben (z. B. nur gtag oder nur Tag-Manager) \u2014 belegt durch die Befunde im Register."
|
|
3646
|
-
);
|
|
3647
|
-
}
|
|
3648
|
-
if (ruleSet.has("consent-mode-mismatch")) {
|
|
3649
|
-
recs.push(
|
|
3650
|
-
"Consent Mode v2 konsistent implementieren: Zust\xE4nde und tats\xE4chliche Google-Requests/Cookies m\xFCssen zusammenpassen \u2014 belegt durch die Befunde im Register."
|
|
3651
|
-
);
|
|
3652
|
-
}
|
|
3653
|
-
const residualEvidence = evidence.some((e) => (e.residualAfterRevoke?.length ?? 0) > 0);
|
|
3654
|
-
if (residualEvidence) {
|
|
3655
|
-
recs.push(
|
|
3656
|
-
"Widerruf muss die gefundenen Tracker-Cookies und -IDs bereinigen \u2014 im Bericht sind Residual-IDs nach Widerruf dokumentiert. CMP-Entscheidungsspeicher (z. B. OptanonConsent) davon unterscheiden und nicht l\xF6schen."
|
|
3657
|
-
);
|
|
3658
|
-
}
|
|
3659
|
-
const darkPattern = evidence.some(
|
|
3660
|
-
(e) => e.scenario === "mobile-usability" && e.status === "suspicious"
|
|
3661
|
-
);
|
|
3662
|
-
if (darkPattern) {
|
|
3663
|
-
recs.push(
|
|
3664
|
-
"Mobile Banner-Bedienung anpassen: \xABAblehnen\xBB ist im Test nicht gleichwertig bedienbar wie \xABAkzeptieren\xBB (Dark-Pattern-Verdacht, siehe Szenario mobile-usability)."
|
|
3665
|
-
);
|
|
3666
|
-
}
|
|
3667
|
-
if (flagged > 0 && recs.length === 0) {
|
|
3668
|
-
recs.push(
|
|
3669
|
-
"Auff\xE4llige Szenarien pr\xFCfen und die genannten Beobachtungen im Detail bewerten (siehe Szenario-Karten)."
|
|
3670
|
-
);
|
|
3671
|
-
}
|
|
3672
|
-
if (uncheckable > 0 || notApplicable > 0) {
|
|
3673
|
-
recs.push(
|
|
3674
|
-
"Nicht abschliessend bewertete Szenarien manuell pr\xFCfen, bevor das Ergebnis gewertet wird \u2014 ein fehlendes Pr\xFCfmerkmal ist kein gr\xFCnes H\xE4kchen."
|
|
3675
|
-
);
|
|
3676
|
-
}
|
|
3677
|
-
if (recs.length === 0) {
|
|
3678
|
-
recs.push(
|
|
3679
|
-
"Keine offenen technischen Befunde. Empfehlung: Scan als Gate in die CI aufnehmen, um Regressionen zu verhindern."
|
|
3680
|
-
);
|
|
3681
|
-
}
|
|
3682
|
-
const recItems = recs.map((r) => `<li>${esc(r)}</li>`).join("");
|
|
3683
|
-
const solutions = codeSolutionsFor(report);
|
|
3684
|
-
const problemGroups = groupFindings(report.violations ?? []);
|
|
3685
|
-
const problemRows = problemGroups.map(
|
|
3686
|
-
(g, i) => `<tr>
|
|
3687
|
-
<td class="nr">${i + 1}</td>
|
|
3688
|
-
<td><strong>${esc(g.trackerName)}</strong></td>
|
|
3689
|
-
<td><code>${esc(g.ruleId)}</code></td>
|
|
3690
|
-
<td>${esc(reproductionText(g.count))}</td>
|
|
3691
|
-
</tr>`
|
|
3692
|
-
).join("");
|
|
3693
|
-
const solutionCards = solutions.map(
|
|
3694
|
-
(s) => `<article class="solution">
|
|
3695
|
-
<h3>${esc(s.title)}</h3>
|
|
3696
|
-
<span class="rule">${esc(s.ruleId)}</span>
|
|
3697
|
-
<pre><code>${esc(s.code)}</code></pre>
|
|
3698
|
-
</article>`
|
|
3699
|
-
).join("\n");
|
|
3700
|
-
const leakRows = report.violations.map(
|
|
3701
|
-
(v) => `<tr>
|
|
3702
|
-
<td><code>${esc(v.scenario)}</code></td>
|
|
3703
|
-
<td><strong>${esc(v.trackerName)}</strong><div class="muted-line">${esc(v.trackerCategory)}</div></td>
|
|
3704
|
-
<td><code title="${esc(v.requestUrl)}">${esc(v.requestUrl.slice(0, 120))}${v.requestUrl.length > 120 ? "\u2026" : ""}</code><div class="muted-line full-url">${esc(v.requestUrl)}</div></td>
|
|
3705
|
-
<td class="ts">${esc(fmtTime(v.timestamp))}</td>
|
|
3706
|
-
</tr>`
|
|
3707
|
-
).join("\n");
|
|
3708
|
-
const observedRows = observed.map(
|
|
3709
|
-
(o) => `<tr>
|
|
3710
|
-
<td><code>${esc(o.scenario)}</code></td>
|
|
3711
|
-
<td class="ts">${esc(fmtTime(o.at))}</td>
|
|
3712
|
-
<td>${esc(o.trackerName)}</td>
|
|
3713
|
-
<td><span class="phase phase-${o.phase.replace(/[^a-z]/g, "")}">${esc(o.phase)}</span></td>
|
|
3714
|
-
<td><code>${esc(o.url.slice(0, 100))}</code></td>
|
|
3715
|
-
</tr>`
|
|
3716
|
-
).join("\n");
|
|
3717
|
-
const demoBanner = options.demo ? `<div class="demo-banner"><strong>Demo-Report</strong> \xB7 synthetische Testdaten \u2014 keine realen Kundendaten.</div>` : "";
|
|
3718
|
-
return `<!DOCTYPE html>
|
|
3719
|
-
<html lang="de-CH">
|
|
3720
|
-
<head>
|
|
3721
|
-
<meta charset="utf-8">
|
|
3722
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
3723
|
-
<title>CuonzTech \xB7 ${esc(title)}</title>
|
|
3724
|
-
<style>
|
|
3725
|
-
:root {
|
|
3726
|
-
--bg: #05070D; --bg2: #070B13; --panel: #0C111D;
|
|
3727
|
-
--gold: #D6B56D; --gold-hell: #F4DDA0; --bronze: #A8966A;
|
|
3728
|
-
--text: #F5F2EA; --soft: #D9D2BF; --muted: #9A9384;
|
|
3729
|
-
--ok: #7EE7B6; --warn: #FF7777; --skip: #9A9384;
|
|
3730
|
-
--cyan: #67E8F9; --violet: #8B5CF6;
|
|
3731
|
-
--serif: "Cormorant Garamond", Georgia, serif;
|
|
3732
|
-
--sans: Inter, "Segoe UI", system-ui, sans-serif;
|
|
3733
|
-
--mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
|
3734
|
-
}
|
|
3735
|
-
* { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
3736
|
-
html { background: var(--bg); }
|
|
3737
|
-
body {
|
|
3738
|
-
margin: 0; background: var(--bg); color: var(--text); font: 16px/1.65 var(--sans);
|
|
3739
|
-
background-image: radial-gradient(ellipse 90% 42% at 50% -12%, rgba(214,181,109,0.16), transparent);
|
|
3740
|
-
}
|
|
3741
|
-
code { font: 13px/1.5 var(--mono); color: var(--gold-hell); background: rgba(214,181,109,0.09); padding: 2px 6px; border-radius: 4px; word-break: break-all; }
|
|
3742
|
-
|
|
3743
|
-
.demo-banner {
|
|
3744
|
-
background: #2A2416; border-bottom: 1px solid var(--gold);
|
|
3745
|
-
color: var(--gold-hell); text-align: center; padding: 10px 16px;
|
|
3746
|
-
font: 600 12px/1.5 var(--sans); letter-spacing: 0.12em; text-transform: uppercase;
|
|
3747
|
-
}
|
|
3748
|
-
|
|
3749
|
-
.hero {
|
|
3750
|
-
padding: 64px 24px 40px; text-align: center;
|
|
3751
|
-
border-bottom: 1px solid rgba(214,181,109,0.28);
|
|
3752
|
-
background-color: var(--bg);
|
|
3753
|
-
background-image: linear-gradient(180deg, rgba(12,17,29,0.95), transparent);
|
|
3754
|
-
}
|
|
3755
|
-
.marke { font: 600 12px/1 var(--sans); letter-spacing: 0.34em; color: var(--gold); text-transform: uppercase; }
|
|
3756
|
-
.hero h1 {
|
|
3757
|
-
font: 500 clamp(32px, 6vw, 52px)/1.1 var(--serif); color: var(--gold-hell);
|
|
3758
|
-
margin: 18px auto 10px; max-width: 20ch;
|
|
3759
|
-
}
|
|
3760
|
-
.hero .lede { max-width: 62ch; margin: 0 auto; color: var(--soft); font-size: 17px; }
|
|
3761
|
-
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; justify-content: center; margin: 22px 0 6px; color: var(--muted); font-size: 13px; }
|
|
3762
|
-
.hero-meta code { font-size: 12px; }
|
|
3763
|
-
.gate {
|
|
3764
|
-
display: inline-block; margin-top: 18px; padding: 14px 22px; border-radius: 12px;
|
|
3765
|
-
border: 1px solid var(--gold); color: var(--gold); max-width: 62ch; text-align: left;
|
|
3766
|
-
background: rgba(214,181,109,0.06);
|
|
3767
|
-
}
|
|
3768
|
-
.gate.blocked { border-color: var(--warn); color: var(--warn); box-shadow: 0 0 24px rgba(255,119,119,0.18); }
|
|
3769
|
-
.gate .gtitle { font: 600 13px/1.4 var(--sans); letter-spacing: 0.14em; text-transform: uppercase; }
|
|
3770
|
-
.gate .gdetail { display: block; margin-top: 8px; font-size: 13px; color: var(--soft); letter-spacing: 0; text-transform: none; }
|
|
3771
|
-
|
|
3772
|
-
main { max-width: 1080px; margin: 0 auto; padding: 36px 22px 80px; background: var(--bg); }
|
|
3773
|
-
h2 {
|
|
3774
|
-
font: 500 30px/1.2 var(--serif); color: var(--gold); margin: 52px 0 8px;
|
|
3775
|
-
padding-bottom: 10px; border-bottom: 1px solid rgba(214,181,109,0.22);
|
|
3776
|
-
}
|
|
3777
|
-
h2 .num { color: var(--bronze); font-size: 0.7em; letter-spacing: 0.12em; margin-right: 12px; vertical-align: middle; }
|
|
3778
|
-
.sub { color: var(--muted); font-size: 14px; margin: 0 0 22px; max-width: 70ch; }
|
|
3779
|
-
|
|
3780
|
-
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin: 26px 0 8px; }
|
|
3781
|
-
.kpi {
|
|
3782
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.18); border-radius: 12px;
|
|
3783
|
-
padding: 18px 16px; text-align: center;
|
|
3784
|
-
}
|
|
3785
|
-
.kpi .val { font: 600 34px/1.1 var(--serif); display: block; }
|
|
3786
|
-
.kpi .lab { font: 600 10px/1.4 var(--sans); letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
|
|
3787
|
-
.kpi.gold .val { color: var(--gold-hell); }
|
|
3788
|
-
.kpi.ok .val { color: var(--ok); }
|
|
3789
|
-
.kpi.warn .val { color: var(--warn); }
|
|
3790
|
-
.kpi.skip .val { color: var(--skip); }
|
|
3791
|
-
|
|
3792
|
-
.note {
|
|
3793
|
-
margin: 26px 0; padding: 16px 20px; border-left: 3px solid var(--gold);
|
|
3794
|
-
background: rgba(214,181,109,0.09); color: var(--soft); font-size: 14px; border-radius: 0 10px 10px 0;
|
|
3795
|
-
}
|
|
3796
|
-
.jgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; }
|
|
3797
|
-
.jcard {
|
|
3798
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.16); border-radius: 12px; padding: 18px;
|
|
3799
|
-
}
|
|
3800
|
-
.jcard h3 { margin: 0; font: 600 18px/1.3 var(--serif); color: var(--text); display: inline; margin-left: 8px; }
|
|
3801
|
-
.jcard header { display: flex; align-items: center; margin-bottom: 10px; }
|
|
3802
|
-
.jframe { color: var(--soft); font-size: 13px; margin: 0 0 10px; }
|
|
3803
|
-
.jfw { color: var(--muted); font-size: 11px; margin: 0; letter-spacing: 0.04em; }
|
|
3804
|
-
.sev {
|
|
3805
|
-
font: 700 11px/1 var(--sans); letter-spacing: 0.1em; padding: 5px 9px; border-radius: 6px;
|
|
3806
|
-
}
|
|
3807
|
-
.sev-gold { background: rgba(214,181,109,0.16); color: var(--gold); border: 1px solid rgba(214,181,109,0.45); }
|
|
3808
|
-
.sev-warn { background: rgba(255,119,119,0.16); color: var(--warn); border: 1px solid rgba(255,119,119,0.45); }
|
|
3809
|
-
.sev-skip { background: rgba(154,147,132,0.16); color: var(--skip); border: 1px solid rgba(154,147,132,0.45); }
|
|
3810
|
-
|
|
3811
|
-
.scard {
|
|
3812
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.16); border-radius: 14px;
|
|
3813
|
-
margin: 18px 0; overflow: hidden;
|
|
3814
|
-
}
|
|
3815
|
-
.scard.s-ok { border-left: 4px solid var(--ok); }
|
|
3816
|
-
.scard.s-warn { border-left: 4px solid var(--warn); }
|
|
3817
|
-
.scard.s-skip { border-left: 4px solid var(--skip); }
|
|
3818
|
-
.shead { padding: 20px 22px 14px; border-bottom: 1px solid rgba(214,181,109,0.12); background: rgba(214,181,109,0.04); }
|
|
3819
|
-
.stitle { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
|
3820
|
-
.sname { font-size: 15px; padding: 5px 10px; }
|
|
3821
|
-
.snote { margin: 10px 0 0; color: var(--soft); font-size: 15px; }
|
|
3822
|
-
.sbody { padding: 18px 22px; }
|
|
3823
|
-
.sbody h4 {
|
|
3824
|
-
font: 600 11px/1 var(--sans); letter-spacing: 0.18em; text-transform: uppercase;
|
|
3825
|
-
color: var(--muted); margin: 20px 0 10px;
|
|
3826
|
-
}
|
|
3827
|
-
.sbody h4:first-child { margin-top: 0; }
|
|
3828
|
-
.sfoot { padding: 12px 22px; border-top: 1px solid rgba(214,181,109,0.12); color: var(--muted); font-size: 11px; }
|
|
3829
|
-
.sfoot code { font-size: 11px; }
|
|
3830
|
-
|
|
3831
|
-
.badge {
|
|
3832
|
-
display: inline-block; padding: 5px 14px; border-radius: 999px;
|
|
3833
|
-
font: 600 11px/1.3 var(--sans); letter-spacing: 0.1em; text-transform: uppercase;
|
|
3834
|
-
}
|
|
3835
|
-
.badge.ok { background: rgba(126,231,182,0.15); color: var(--ok); border: 1px solid rgba(126,231,182,0.42); }
|
|
3836
|
-
.badge.warn { background: rgba(255,119,119,0.15); color: var(--warn); border: 1px solid rgba(255,119,119,0.42); }
|
|
3837
|
-
.badge.skip { background: rgba(154,147,132,0.15); color: var(--skip); border: 1px solid rgba(154,147,132,0.42); }
|
|
3838
|
-
|
|
3839
|
-
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
3840
|
-
.chip {
|
|
3841
|
-
font: 500 12px/1 var(--mono); color: var(--cyan); background: rgba(103,232,249,0.1);
|
|
3842
|
-
border: 1px solid rgba(103,232,249,0.28); padding: 6px 10px; border-radius: 999px;
|
|
3843
|
-
}
|
|
3844
|
-
.chip.muted { color: var(--muted); background: rgba(154,147,132,0.1); border-color: rgba(154,147,132,0.28); }
|
|
3845
|
-
|
|
3846
|
-
table.mini { width: 100%; border-collapse: collapse; font-size: 13px; margin: 4px 0 8px; }
|
|
3847
|
-
table.mini th {
|
|
3848
|
-
text-align: left; font: 600 10px/1 var(--sans); letter-spacing: 0.14em; text-transform: uppercase;
|
|
3849
|
-
color: var(--muted); padding: 8px 10px; border-bottom: 1px solid rgba(214,181,109,0.28);
|
|
3850
|
-
}
|
|
3851
|
-
table.mini td { padding: 8px 10px; border-bottom: 1px solid rgba(214,181,109,0.1); color: var(--soft); vertical-align: top; }
|
|
3852
|
-
.ts { white-space: nowrap; color: var(--muted); font-size: 11px; }
|
|
3853
|
-
.phase { font: 600 10px/1 var(--sans); letter-spacing: 0.08em; text-transform: uppercase; padding: 4px 8px; border-radius: 5px; }
|
|
3854
|
-
.phase-preconsent { background: rgba(255,119,119,0.14); color: var(--warn); }
|
|
3855
|
-
.phase-postaccept { background: rgba(126,231,182,0.14); color: var(--ok); }
|
|
3856
|
-
.phase-postreject, .phase-postrejectrestart { background: rgba(255,119,119,0.14); color: var(--warn); }
|
|
3857
|
-
.phase-postselection { background: rgba(126,231,182,0.14); color: var(--ok); }
|
|
3858
|
-
.phase-postrevoke { background: rgba(255,119,119,0.14); color: var(--warn); }
|
|
3859
|
-
.phase-postwipe { background: rgba(255,119,119,0.14); color: var(--warn); }
|
|
3860
|
-
.phase-other { background: rgba(154,147,132,0.14); color: var(--skip); }
|
|
3861
|
-
|
|
3862
|
-
.diff { font-size: 13px; color: var(--soft); }
|
|
3863
|
-
.add { color: var(--ok); font-family: var(--mono); margin-right: 8px; }
|
|
3864
|
-
.del { color: var(--warn); font-family: var(--mono); margin-right: 8px; }
|
|
3865
|
-
.residual { font-size: 13px; color: var(--soft); }
|
|
3866
|
-
.warn-text { color: var(--warn); }
|
|
3867
|
-
.muted-line { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
3868
|
-
.full-url { display: none; font-family: var(--mono); font-size: 10px; word-break: break-all; }
|
|
3869
|
-
@media print {
|
|
3870
|
-
.full-url { display: block; }
|
|
3871
|
-
}
|
|
3872
|
-
|
|
3873
|
-
.sname {
|
|
3874
|
-
overflow-wrap: normal;
|
|
3875
|
-
word-break: keep-all;
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
|
-
.shots { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; }
|
|
3879
|
-
.shots figure {
|
|
3880
|
-
margin: 0; background: var(--bg2); border: 1px solid rgba(214,181,109,0.18); border-radius: 10px; padding: 12px;
|
|
3881
|
-
break-inside: avoid; page-break-inside: avoid;
|
|
3882
|
-
}
|
|
3883
|
-
.shots figcaption { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
|
3884
|
-
.shot-label { font: 600 11px/1.3 var(--sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold); }
|
|
3885
|
-
.shot-path { display: block; font-size: 11px; color: var(--muted); background: none; padding: 0; }
|
|
3886
|
-
.shot { width: 100%; border-radius: 6px; border: 1px solid rgba(214,181,109,0.2); }
|
|
3887
|
-
|
|
3888
|
-
details { margin: 8px 0; }
|
|
3889
|
-
summary { cursor: pointer; color: var(--gold); font-size: 12px; letter-spacing: 0.06em; }
|
|
3890
|
-
|
|
3891
|
-
.recs { counter-reset: rec; list-style: none; padding: 0; margin: 18px 0; }
|
|
3892
|
-
.recs li {
|
|
3893
|
-
counter-increment: rec; position: relative; padding: 16px 18px 16px 62px;
|
|
3894
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.16); border-radius: 12px;
|
|
3895
|
-
margin: 10px 0; color: var(--soft); font-size: 15px;
|
|
3896
|
-
}
|
|
3897
|
-
.recs li::before {
|
|
3898
|
-
content: counter(rec, decimal-leading-zero); position: absolute; left: 18px; top: 16px;
|
|
3899
|
-
font: 600 18px/1 var(--serif); color: var(--gold);
|
|
3900
|
-
}
|
|
3901
|
-
|
|
3902
|
-
.solutions { display: grid; gap: 14px; margin: 18px 0; }
|
|
3903
|
-
.solution {
|
|
3904
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.18); border-radius: 12px; padding: 16px 18px;
|
|
3905
|
-
}
|
|
3906
|
-
.solution h3 { margin: 0 0 4px; font: 600 17px/1.3 var(--serif); color: var(--gold-hell); }
|
|
3907
|
-
.solution .rule { font: 11px/1 var(--mono); color: var(--gold); letter-spacing: 0.04em; margin-bottom: 10px; display: block; }
|
|
3908
|
-
.solution pre {
|
|
3909
|
-
margin: 10px 0 0; padding: 14px 16px; overflow-x: auto;
|
|
3910
|
-
background: var(--bg2); border: 1px solid rgba(214,181,109,0.12); border-radius: 8px;
|
|
3911
|
-
font: 12px/1.55 var(--mono); color: var(--soft); white-space: pre;
|
|
3912
|
-
}
|
|
3913
|
-
|
|
3914
|
-
.fuss {
|
|
3915
|
-
text-align: center; color: var(--muted); font-size: 12px; padding: 28px;
|
|
3916
|
-
border-top: 1px solid rgba(214,181,109,0.2); margin-top: 40px;
|
|
3917
|
-
}
|
|
3918
|
-
.fuss .claim { font: 500 16px/1.4 var(--serif); color: var(--gold); display: block; margin-bottom: 6px; }
|
|
3919
|
-
|
|
3920
|
-
@media print {
|
|
3921
|
-
html, body { background: var(--bg); color: var(--text); }
|
|
3922
|
-
.scard, .kpi, .jcard, .recs li, .solution, .shots figure { break-inside: avoid; page-break-inside: avoid; }
|
|
3923
|
-
.hero, main, .fuss, .demo-banner { break-inside: avoid; }
|
|
3924
|
-
/* \xDCberschrift mit Folgeinhalt halten (Jurisdictions \u2192 Karten) */
|
|
3925
|
-
h2, h3, h4, .stitle { break-after: avoid; page-break-after: avoid; }
|
|
3926
|
-
.shead { break-inside: avoid; }
|
|
3927
|
-
.jgrid { break-inside: avoid; }
|
|
3928
|
-
/* Fusszeile (gepr\xFCft \xB7 URL) beim Szenario halten */
|
|
3929
|
-
.sfoot { break-before: avoid; page-break-before: avoid; break-inside: avoid; }
|
|
3930
|
-
.sbody { break-inside: auto; }
|
|
3931
|
-
/* Technische Szenarionamen nicht mitten im Wort trennen */
|
|
3932
|
-
.sname, code { overflow-wrap: normal; word-break: keep-all; }
|
|
3933
|
-
details[open] > summary, details:not([open]) > summary { list-style: none; display: none; }
|
|
3934
|
-
details { display: block; }
|
|
3935
|
-
details > *:not(summary) { display: block !important; }
|
|
3936
|
-
}
|
|
3937
|
-
</style>
|
|
3938
|
-
</head>
|
|
3939
|
-
<body>
|
|
3940
|
-
${demoBanner}
|
|
3941
|
-
<header class="hero">
|
|
3942
|
-
<div class="marke">CuonzTech \xB7 Privacy Chaos Gate</div>
|
|
3943
|
-
<h1>${esc(title)}</h1>
|
|
3944
|
-
<p class="lede">Aggressiver Consent-Stresstest: ${evidence.length} ausgef\xFChrte Chaos-Szenarien, ${findingsCountText(report.totalViolations)}, ${observed.length} beobachtete Tracker-Requests. Beweise mit Zeitlinie und Storage-Vergleich.</p>
|
|
3945
|
-
<p class="lede problems" id="problem-summary"><strong>${facts.problemsText}</strong> \u2014 aus ${findingsCountText(report.totalViolations)} destilliert.${solutions.length > 0 ? " Was zuerst zu beheben ist, steht unter Code-L\xF6sungen." : ""}</p>
|
|
3946
|
-
<div class="hero-meta">
|
|
3947
|
-
<span>Ziel: <code>${esc(report.targetUrl)}</code></span>
|
|
3948
|
-
<span>Scan: ${esc(fmtTime(report.scannedAt))}</span>
|
|
3949
|
-
<span>Dauer: ${esc(fmtDuration(report.durationMs))}</span>
|
|
3950
|
-
</div>
|
|
3951
|
-
<div class="gate ${gateClass}">
|
|
3952
|
-
<span class="gtitle">${esc(gateTitle)}</span>
|
|
3953
|
-
<span class="gdetail">${esc(gateDetail)}</span>
|
|
3954
|
-
</div>
|
|
3955
|
-
</header>
|
|
3956
|
-
<main>
|
|
3957
|
-
<h2><span class="num">01</span>Executive Summary</h2>
|
|
3958
|
-
<p class="sub">Ausschliesslich abgeleitet aus den tats\xE4chlich ausgef\xFChrten Szenarien dieses Scans.</p>
|
|
3959
|
-
<div id="summary">${summaryHtml}</div>
|
|
3960
|
-
<div class="kpis">
|
|
3961
|
-
${kpis.map((k) => `<div class="kpi ${k.tone}"><span class="val">${esc(k.value)}</span><span class="lab">${esc(k.label)}</span></div>`).join("\n ")}
|
|
3962
|
-
</div>
|
|
3963
|
-
|
|
3964
|
-
<div class="note"><strong>Hinweis:</strong> Technisches Hilfsmittel, keine Rechtsberatung. / Technical aid, not legal advice. Die Schweregrad-Einstufung je Rechtsraum ist eine technisch-juristische Einordnungshilfe und ersetzt keine Beratung.</div>
|
|
3965
|
-
|
|
3966
|
-
<h2><span class="num">02</span>Jurisdictions</h2>
|
|
3967
|
-
<p class="sub">Dieselben technischen Fakten, unterschiedliche Konsequenz. Die Einstufung erfolgt je Rechtsraum \u2014 eine globale Compliance-Aussage w\xE4re unseri\xF6s.</p>
|
|
3968
|
-
<div class="jgrid">
|
|
3969
|
-
${jCards}
|
|
3970
|
-
</div>
|
|
3971
|
-
|
|
3972
|
-
<h2><span class="num">03</span>Szenarien & Beweise</h2>
|
|
3973
|
-
<p class="sub">Jedes ausgef\xFChrte Szenario mit Status, Ablauf, Tracker-Requests, Storage-Diff und Screenshots. Ein nicht gefundenes Merkmal ergibt kein gr\xFCnes H\xE4kchen \u2014 sondern \xABnicht pr\xFCfbar\xBB.</p>
|
|
3974
|
-
${cards}
|
|
3975
|
-
|
|
3976
|
-
${report.violations.length > 0 ? `<h2><span class="num">04</span>Befunde (Regelverst\xF6sse)</h2>
|
|
3977
|
-
<p class="sub">Vollst\xE4ndige Liste der dokumentierten Tracker-Requests ohne g\xFCltige Einwilligung \u2014 verletzt die konfigurierte Regel \xABKeine Analytics-Requests vor Zustimmung\xBB.</p>
|
|
3978
|
-
<table class="mini">
|
|
3979
|
-
<thead><tr><th>Szenario</th><th>Tracker</th><th>Request</th><th>Zeit</th></tr></thead>
|
|
3980
|
-
<tbody>${leakRows}</tbody>
|
|
3981
|
-
</table>` : ""}
|
|
3982
|
-
|
|
3983
|
-
${problemGroups.length > 0 ? `<h2><span class="num">05</span>Was zuerst beheben</h2>
|
|
3984
|
-
<p class="sub">${esc(facts.problemsText)} \u2014 gruppiert aus ${esc(findingsCountText(report.totalViolations))}. \xABN Requests\xBB z\xE4hlt die dokumentierten Zeilen derselben Ursache, nicht unabh\xE4ngige Testl\xE4ufe.</p>
|
|
3985
|
-
<table class="mini">
|
|
3986
|
-
<thead><tr><th>#</th><th>Tracker</th><th>Regel</th><th>Reproduktion</th></tr></thead>
|
|
3987
|
-
<tbody>${problemRows}</tbody>
|
|
3988
|
-
</table>` : ""}
|
|
3989
|
-
|
|
3990
|
-
${observed.length > 0 ? `<h2><span class="num">06</span>Beobachtete Tracker-Requests</h2>
|
|
3991
|
-
<p class="sub">Alle erfassten Tracker-Requests aus den Szenario-Timelines \u2014 auch solche, die keine eigene Violation-Row erzeugt haben. ${esc(findingsCountText(report.totalViolations))} gegen\xFCber ${observed.length === 1 ? "1 Beobachtung" : `${observed.length} Beobachtungen`}.</p>
|
|
3992
|
-
<table class="mini">
|
|
3993
|
-
<thead><tr><th>Szenario</th><th>Zeit</th><th>Tracker</th><th>Phase</th><th>Request</th></tr></thead>
|
|
3994
|
-
<tbody>${observedRows}</tbody>
|
|
3995
|
-
</table>` : ""}
|
|
3996
|
-
|
|
3997
|
-
<h2><span class="num">07</span>Empfehlungen</h2>
|
|
3998
|
-
<p class="sub">Ausschliesslich aus belegten Befunden dieses Scans abgeleitet \u2014 Beobachtung und Massnahme getrennt.</p>
|
|
3999
|
-
<ol class="recs" id="recommendations">
|
|
4000
|
-
${recItems}
|
|
4001
|
-
</ol>
|
|
4002
|
-
${solutions.length > 0 ? `<h2><span class="num">08</span>Code-L\xF6sungen</h2>
|
|
4003
|
-
<p class="sub">Schematische Umsetzungsmuster zu den verletzten Regeln dieses Scans \u2014 an die eigene CMP/Tracker-Stack anpassen, kein Copy-Paste-Fix.</p>
|
|
4004
|
-
<div class="solutions" id="code-solutions">
|
|
4005
|
-
${solutionCards}
|
|
4006
|
-
</div>` : ""}
|
|
4007
|
-
</main>
|
|
4008
|
-
<footer class="fuss">
|
|
4009
|
-
<span class="claim">KI-Systeme, die bleiben.</span>
|
|
4010
|
-
\xA9 CuonzTech \xB7 Privacy Chaos Gate \xB7 Technisches Hilfsmittel, keine Rechtsberatung.
|
|
4011
|
-
</footer>
|
|
4012
|
-
</body>
|
|
4013
|
-
</html>`;
|
|
4014
|
-
}
|
|
4015
|
-
|
|
4016
|
-
// src/core/report/deluxePdf.ts
|
|
4017
|
-
import path4 from "path";
|
|
4018
|
-
import fs4 from "fs/promises";
|
|
4019
|
-
var esc2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4020
|
-
var FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
4021
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
4022
|
-
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">`;
|
|
4023
|
-
function buildCoverHtml(report, options = {}) {
|
|
4024
|
-
const client = options.clientName ?? "pilot-kunde.ch";
|
|
4025
|
-
const issued = options.issuedAt ?? report.scannedAt;
|
|
4026
|
-
const gate = report.totalViolations === 0 ? "PASS" : "BLOCKED";
|
|
4027
|
-
const gateTone = report.totalViolations === 0 ? "ok" : "blocked";
|
|
4028
|
-
const scenarios = report.evidence?.length ?? 0;
|
|
4029
|
-
const demoBadge = options.demo ? `<div class="demo-badge">Demo-Report \xB7 synthetische Testdaten \u2014 keine realen Kundendaten</div>` : "";
|
|
4030
|
-
return `<!DOCTYPE html>
|
|
4031
|
-
<html lang="de-CH">
|
|
4032
|
-
<head>
|
|
4033
|
-
<meta charset="utf-8">
|
|
4034
|
-
<title>CuonzTech \xB7 Pr\xFCfbericht</title>
|
|
4035
|
-
${FONT_LINKS}
|
|
4036
|
-
<style>
|
|
4037
|
-
:root {
|
|
4038
|
-
--bg: #05070D; --panel: #0C111D; --gold: #D6B56D; --gold-hell: #F4DDA0;
|
|
4039
|
-
--text: #F5F2EA; --soft: #D9D2BF; --muted: #9A9384;
|
|
4040
|
-
--ok: #7EE7B6; --warn: #FF7777;
|
|
4041
|
-
--serif: "Cormorant Garamond", "Times New Roman", Georgia, serif;
|
|
4042
|
-
--sans: Inter, "Segoe UI", system-ui, sans-serif;
|
|
4043
|
-
--mono: ui-monospace, Consolas, monospace;
|
|
4044
|
-
}
|
|
4045
|
-
* { box-sizing: border-box; }
|
|
4046
|
-
html, body { margin: 0; padding: 0; background: var(--bg); }
|
|
4047
|
-
.cover {
|
|
4048
|
-
width: 100%; min-height: 297mm; padding: 26mm 24mm 22mm;
|
|
4049
|
-
background: var(--bg); color: var(--text);
|
|
4050
|
-
background-image:
|
|
4051
|
-
radial-gradient(ellipse 90% 45% at 50% -12%, rgba(214,181,109,0.18), transparent),
|
|
4052
|
-
linear-gradient(180deg, rgba(214,181,109,0.06) 0, transparent 28%);
|
|
4053
|
-
font-family: var(--sans);
|
|
4054
|
-
page-break-after: always;
|
|
4055
|
-
display: flex; flex-direction: column;
|
|
4056
|
-
}
|
|
4057
|
-
.cover::before {
|
|
4058
|
-
content: ""; display: block; height: 3px; margin: -26mm -24mm 18mm;
|
|
4059
|
-
background: linear-gradient(90deg, var(--gold) 0%, rgba(214,181,109,0.15) 70%, transparent);
|
|
4060
|
-
}
|
|
4061
|
-
.marke {
|
|
4062
|
-
font: 600 11px/1 var(--sans); letter-spacing: 0.34em;
|
|
4063
|
-
color: var(--gold); text-transform: uppercase;
|
|
4064
|
-
display: flex; align-items: center; gap: 12px;
|
|
4065
|
-
}
|
|
4066
|
-
.marke .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--gold); flex: 0 0 auto; }
|
|
4067
|
-
h1 {
|
|
4068
|
-
font: 600 54px/1.05 var(--serif); color: var(--gold-hell);
|
|
4069
|
-
margin: 14mm 0 6mm; max-width: 14ch;
|
|
4070
|
-
}
|
|
4071
|
-
.lede { color: var(--soft); font-size: 15px; max-width: 54ch; line-height: 1.75; margin: 0; }
|
|
4072
|
-
.rule {
|
|
4073
|
-
height: 1px; margin: 12mm 0 10mm; border: 0;
|
|
4074
|
-
background: linear-gradient(90deg, rgba(214,181,109,0.55), rgba(214,181,109,0.08));
|
|
4075
|
-
}
|
|
4076
|
-
.fakten { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
4077
|
-
.fakt {
|
|
4078
|
-
background: var(--panel); border: 1px solid rgba(214,181,109,0.22);
|
|
4079
|
-
border-radius: 10px; padding: 16px 18px;
|
|
4080
|
-
}
|
|
4081
|
-
.fakt .lab {
|
|
4082
|
-
font: 600 9px/1.4 var(--sans); letter-spacing: 0.16em;
|
|
4083
|
-
text-transform: uppercase; color: var(--muted);
|
|
4084
|
-
}
|
|
4085
|
-
.fakt .val { font: 600 22px/1.25 var(--serif); color: var(--gold-hell); margin-top: 6px; word-break: break-word; }
|
|
4086
|
-
.fakt .val.sm { font-size: 15px; font-family: var(--sans); font-weight: 500; color: var(--text); }
|
|
4087
|
-
.fakt.wide { grid-column: 1 / -1; }
|
|
4088
|
-
.gate {
|
|
4089
|
-
margin-top: 12mm; padding: 18px 22px; border-radius: 12px;
|
|
4090
|
-
border: 1px solid var(--gold); background: rgba(214,181,109,0.07);
|
|
4091
|
-
}
|
|
4092
|
-
.gate .gtitle {
|
|
4093
|
-
font: 600 13px/1 var(--sans); letter-spacing: 0.16em; text-transform: uppercase;
|
|
4094
|
-
color: var(--gold);
|
|
4095
|
-
}
|
|
4096
|
-
.gate.blocked { border-color: var(--warn); background: rgba(255,119,119,0.08); }
|
|
4097
|
-
.gate.blocked .gtitle { color: var(--warn); }
|
|
4098
|
-
.gate .gdetail { margin: 10px 0 0; font-size: 13px; line-height: 1.65; color: var(--soft); }
|
|
4099
|
-
.stats {
|
|
4100
|
-
display: flex; gap: 28px; margin-top: 10mm; color: var(--muted);
|
|
4101
|
-
font: 500 12px/1.4 var(--sans); letter-spacing: 0.06em;
|
|
4102
|
-
}
|
|
4103
|
-
.stats b { display: block; font: 600 28px/1.1 var(--serif); color: var(--gold-hell); letter-spacing: 0; }
|
|
4104
|
-
.sign {
|
|
4105
|
-
margin-top: auto; padding-top: 8mm;
|
|
4106
|
-
display: flex; justify-content: space-between; align-items: flex-end; gap: 24px;
|
|
4107
|
-
}
|
|
4108
|
-
.sign .line {
|
|
4109
|
-
width: 62mm; border-top: 1px solid rgba(214,181,109,0.45); padding-top: 8px;
|
|
4110
|
-
color: var(--muted); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
|
4111
|
-
}
|
|
4112
|
-
.legal {
|
|
4113
|
-
margin-top: 8mm; padding-top: 10px;
|
|
4114
|
-
color: var(--muted); font-size: 10px; line-height: 1.65;
|
|
4115
|
-
border-top: 1px solid rgba(214,181,109,0.25);
|
|
4116
|
-
max-width: 100%;
|
|
4117
|
-
}
|
|
4118
|
-
.demo-badge {
|
|
4119
|
-
display: inline-block; margin: 0 0 8mm; padding: 10px 14px;
|
|
4120
|
-
border: 1px solid var(--gold); border-radius: 4px;
|
|
4121
|
-
color: var(--gold-hell); background: rgba(214,181,109,0.12);
|
|
4122
|
-
font: 600 12px/1.4 var(--sans); letter-spacing: 0.08em; text-transform: uppercase;
|
|
4123
|
-
}
|
|
4124
|
-
</style>
|
|
4125
|
-
</head>
|
|
4126
|
-
<body>
|
|
4127
|
-
<div class="cover">
|
|
4128
|
-
<div class="marke"><span class="dot"></span>CuonzTech \xB7 Privacy Chaos Gate</div>
|
|
4129
|
-
${demoBadge}
|
|
4130
|
-
<h1>Consent-Pr\xFCfbericht</h1>
|
|
4131
|
-
<p class="lede">Dieser Bericht fasst zusammen, ob Tracking vor der Einwilligung ausbleibt,
|
|
4132
|
-
ob erlaubtes Tracking danach korrekt startet und ob Widerruf sowie Kategorie-Auswahl
|
|
4133
|
-
zuverl\xE4ssig funktionieren. Grundlage ist ein automatisierter Stresstest mit
|
|
4134
|
-
${scenarios} Chaos-Szenarien.</p>
|
|
4135
|
-
|
|
4136
|
-
<hr class="rule">
|
|
4137
|
-
|
|
4138
|
-
<div class="fakten">
|
|
4139
|
-
<div class="fakt"><div class="lab">Kunde</div><div class="val">${esc2(client)}</div></div>
|
|
4140
|
-
<div class="fakt"><div class="lab">Scan-Datum</div><div class="val sm">${esc2(new Date(issued).toLocaleDateString("de-CH", { dateStyle: "long" }))}</div></div>
|
|
4141
|
-
<div class="fakt wide"><div class="lab">Gepr\xFCftes System</div><div class="val sm">${esc2(report.targetUrl)}</div></div>
|
|
4142
|
-
</div>
|
|
4143
|
-
|
|
4144
|
-
<div class="gate ${gateTone}">
|
|
4145
|
-
<div class="gtitle">Technisches Gate: ${gate}</div>
|
|
4146
|
-
<p class="gdetail">${report.totalViolations === 0 ? "Keine dokumentierten Tracker-Funde in den ausgef\xFChrten Szenarien. Eine rechtliche Gesamtbewertung erfolgt nicht." : `${report.totalViolations === 1 ? "1 dokumentierte Befund-Zeile" : `${report.totalViolations} dokumentierte Befund-Zeilen`} in den ausgef\xFChrten Szenarien. Eine rechtliche Gesamtbewertung erfolgt nicht.`}</p>
|
|
4147
|
-
</div>
|
|
4148
|
-
|
|
4149
|
-
<div class="stats">
|
|
4150
|
-
<div><b>${scenarios}</b>Szenarien</div>
|
|
4151
|
-
<div><b>${report.totalViolations}</b>Befunde</div>
|
|
4152
|
-
<div><b>${(report.evidence ?? []).reduce((n, e) => n + e.requestTimeline.filter((r) => r.trackerName).length, 0)}</b>Tracker-Requests</div>
|
|
4153
|
-
</div>
|
|
4154
|
-
|
|
4155
|
-
<div class="sign">
|
|
4156
|
-
<div class="line">Ort, Datum \xB7 Empfangsbest\xE4tigung Kunde</div>
|
|
4157
|
-
<div class="line">Ausgestellt von CuonzTech</div>
|
|
4158
|
-
</div>
|
|
4159
|
-
|
|
4160
|
-
<div class="legal">
|
|
4161
|
-
Technisches Hilfsmittel, keine Rechtsberatung. / Technical aid, not legal advice.
|
|
4162
|
-
Die Rechtsraum-Einordnung im Bericht ist eine technisch-juristische Hilfestellung
|
|
4163
|
-
und ersetzt keine Beratung durch eine Fachperson. Automatisierte Pr\xFCfung \u2014
|
|
4164
|
-
ein nicht gefundenes Merkmal ergibt kein gr\xFCnes H\xE4kchen.
|
|
4165
|
-
</div>
|
|
4166
|
-
</div>
|
|
4167
|
-
</body>
|
|
4168
|
-
</html>`;
|
|
4169
|
-
}
|
|
4170
|
-
function extractStyles(html) {
|
|
4171
|
-
return [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n");
|
|
4172
|
-
}
|
|
4173
|
-
function extractBody(html) {
|
|
4174
|
-
return html.match(/<body>([\s\S]*)<\/body>/)?.[1] ?? html;
|
|
4175
|
-
}
|
|
4176
|
-
async function renderDeluxeCombined(coverHtml, evidenceHtmlPath, outPath, previewDir) {
|
|
4177
|
-
const { chromium: chromium2 } = await import("playwright");
|
|
4178
|
-
const evidenceHtml = await fs4.readFile(evidenceHtmlPath, "utf-8");
|
|
4179
|
-
const styles = [extractStyles(coverHtml), extractStyles(evidenceHtml)].join("\n");
|
|
4180
|
-
const evidenceBody = extractBody(evidenceHtml).replace(/<div class="demo-banner">[\s\S]*?<\/div>\s*/, "").replace(
|
|
4181
|
-
/<header class="hero">[\s\S]*?<\/header>/,
|
|
4182
|
-
""
|
|
4183
|
-
);
|
|
4184
|
-
const combined = `<!DOCTYPE html>
|
|
4185
|
-
<html lang="de-CH">
|
|
4186
|
-
<head>
|
|
4187
|
-
<meta charset="utf-8">
|
|
4188
|
-
<title>CuonzTech \xB7 Consent-Pr\xFCfbericht</title>
|
|
4189
|
-
${FONT_LINKS}
|
|
4190
|
-
<style>
|
|
4191
|
-
html, body { margin: 0; padding: 0; background: #05070D; }
|
|
4192
|
-
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
|
4193
|
-
${styles}
|
|
4194
|
-
</style>
|
|
4195
|
-
</head>
|
|
4196
|
-
<body>
|
|
4197
|
-
${extractBody(coverHtml)}
|
|
4198
|
-
${evidenceBody}
|
|
4199
|
-
</body>
|
|
4200
|
-
</html>`;
|
|
4201
|
-
const browser = await chromium2.launch({ headless: true });
|
|
4202
|
-
try {
|
|
4203
|
-
const page = await browser.newPage();
|
|
4204
|
-
await page.setContent(combined, { waitUntil: "networkidle" });
|
|
4205
|
-
await page.evaluate(() => document.fonts?.ready ?? Promise.resolve());
|
|
4206
|
-
await page.emulateMedia({ media: "print" });
|
|
4207
|
-
await page.waitForTimeout(400);
|
|
4208
|
-
if (previewDir) {
|
|
4209
|
-
await fs4.mkdir(previewDir, { recursive: true });
|
|
4210
|
-
const size = { width: 794, height: 1123 };
|
|
4211
|
-
await page.setViewportSize(size);
|
|
4212
|
-
const pages = await page.evaluate(() => {
|
|
4213
|
-
const el = document.querySelector(".cover") ?? document.body;
|
|
4214
|
-
const h = Math.max(
|
|
4215
|
-
document.documentElement.scrollHeight,
|
|
4216
|
-
el.scrollHeight
|
|
4217
|
-
);
|
|
4218
|
-
return Math.ceil(h / 1123);
|
|
4219
|
-
});
|
|
4220
|
-
for (let i = 0; i < Math.min(pages, 4); i++) {
|
|
4221
|
-
await page.evaluate((y) => window.scrollTo(0, y), i * 1123);
|
|
4222
|
-
await page.waitForTimeout(150);
|
|
4223
|
-
await page.screenshot({
|
|
4224
|
-
path: path4.join(previewDir, `p${String(i + 1).padStart(2, "0")}.png`)
|
|
4225
|
-
});
|
|
4226
|
-
}
|
|
4227
|
-
}
|
|
4228
|
-
const pdf = await page.pdf({ format: "A4", printBackground: true });
|
|
4229
|
-
await fs4.mkdir(path4.dirname(outPath), { recursive: true });
|
|
4230
|
-
await fs4.writeFile(outPath, pdf);
|
|
4231
|
-
return outPath;
|
|
4232
|
-
} finally {
|
|
4233
|
-
await browser.close().catch(() => {
|
|
4234
|
-
});
|
|
4235
|
-
}
|
|
4236
|
-
}
|
|
4237
|
-
async function renderDeluxePdf(coverHtml, evidenceHtmlPath, outPath) {
|
|
4238
|
-
return renderDeluxeCombined(coverHtml, evidenceHtmlPath, outPath);
|
|
4239
|
-
}
|
|
4240
|
-
|
|
4241
|
-
// src/core/orchestrator.ts
|
|
4242
|
-
import fs5 from "fs/promises";
|
|
4243
|
-
import path5 from "path";
|
|
4244
|
-
import pc from "picocolors";
|
|
4245
|
-
var PrivacyChaosGate = class {
|
|
4246
|
-
scanner;
|
|
4247
|
-
remediationEngine;
|
|
4248
|
-
constructor(scanner, remediationEngine) {
|
|
4249
|
-
this.scanner = scanner ?? new ChaosScanner();
|
|
4250
|
-
this.remediationEngine = remediationEngine ?? new RemediationEngine();
|
|
4251
|
-
}
|
|
4252
|
-
async run(config) {
|
|
4253
|
-
console.log(pc.bold(pc.cyan("\n=======================================================")));
|
|
4254
|
-
console.log(pc.bold(pc.cyan(" CUONZTECH PRIVACY CHAOS GATE (CPCG) ")));
|
|
4255
|
-
console.log(pc.bold(pc.cyan("=======================================================\n")));
|
|
4256
|
-
console.log(pc.blue("\xB9 Target URL: "), pc.white(config.targetUrl));
|
|
4257
|
-
console.log(pc.blue("\xB9 Auto-Fix Enabled: "), pc.white(String(config.autoFix)));
|
|
4258
|
-
console.log(pc.blue("\xB9 Fail on Leak: "), pc.white(String(config.failOnLeak)));
|
|
4259
|
-
console.log(pc.blue("\xB9 Report Output: "), pc.white(config.reportOutput));
|
|
4260
|
-
console.log("");
|
|
4261
|
-
console.log(pc.bold(pc.yellow("\u2013\xB6 Phase 1: Scanning with Privacy Chaos Routines...")));
|
|
4262
|
-
const report = await this.scanner.scan(config);
|
|
4263
|
-
console.log(
|
|
4264
|
-
pc.green(`\u0153 Scan complete in ${report.durationMs}ms. Found ${report.totalViolations} leak(s).`)
|
|
4265
|
-
);
|
|
4266
|
-
console.log(pc.bold(pc.yellow("\n\u2013\xB6 Phase 2: Analyzing Privacy Chaos Results...")));
|
|
4267
|
-
if (report.totalViolations > 0) {
|
|
4268
|
-
console.log(pc.red(`\u0153\u2013 Detected ${report.totalViolations} unauthorized tracker leak(s)!`));
|
|
4269
|
-
for (const violation of report.violations) {
|
|
4270
|
-
console.log(
|
|
4271
|
-
pc.red(` [${violation.scenario}] ${violation.trackerName} (${violation.trackerCategory}): ${violation.requestUrl}`)
|
|
4272
|
-
);
|
|
4273
|
-
}
|
|
4274
|
-
} else {
|
|
4275
|
-
console.log(pc.green("\u0153 All privacy chaos tests passed! Zero tracker leaks detected."));
|
|
4276
|
-
}
|
|
4277
|
-
let remediation;
|
|
4278
|
-
if (report.totalViolations > 0 && config.autoFix) {
|
|
4279
|
-
console.log(pc.bold(pc.yellow("\n\u2013\xB6 Phase 3: Triggering Agentic Auto-Remediation & Review Agent...")));
|
|
4280
|
-
remediation = await this.remediationEngine.remediate(report, config);
|
|
4281
|
-
if (remediation.reviewVerdict) {
|
|
4282
|
-
console.log(pc.bold(pc.magenta("\u2013\xB6 Review Agent Evaluation:")));
|
|
4283
|
-
console.log(pc.cyan(` Score: ${remediation.reviewVerdict.score}/100 | Passed: ${remediation.reviewVerdict.passed}`));
|
|
4284
|
-
for (const comment of remediation.reviewVerdict.comments) {
|
|
4285
|
-
console.log(pc.dim(` ${comment}`));
|
|
4286
|
-
}
|
|
4287
|
-
}
|
|
4288
|
-
if (remediation.success) {
|
|
4289
|
-
console.log(pc.green("\u0153 Auto-remediation approved and applied successfully!"));
|
|
4290
|
-
if (remediation.commitHash) {
|
|
4291
|
-
console.log(pc.green(`\u0153 Git commit created: ${remediation.commitHash}`));
|
|
4292
|
-
}
|
|
4293
|
-
if (remediation.pullRequest) {
|
|
4294
|
-
if (remediation.pullRequest.prUrl) {
|
|
4295
|
-
console.log(pc.bold(pc.green(`\u0153 GitHub Pull Request: ${remediation.pullRequest.prUrl}`)));
|
|
4296
|
-
} else {
|
|
4297
|
-
console.log(pc.bold(pc.cyan(`\u0153 Fix committed locally on branch '${remediation.pullRequest.branchName}' (no remote/token: no PR opened)`)));
|
|
4298
|
-
}
|
|
4299
|
-
}
|
|
4300
|
-
} else {
|
|
4301
|
-
console.log(pc.red(`\u0153\u2013 Auto-remediation failed: ${remediation.error ?? "Unknown error"}`));
|
|
4302
|
-
}
|
|
4303
|
-
}
|
|
4304
|
-
await this.saveReport(report, config.reportOutput, config.demo === true, config.reportPdf === true, config.clientName);
|
|
4305
|
-
let status = "clean";
|
|
4306
|
-
let exitCode = 0;
|
|
4307
|
-
if (report.totalViolations > 0) {
|
|
4308
|
-
if (remediation?.success) {
|
|
4309
|
-
status = "remediated";
|
|
4310
|
-
exitCode = 0;
|
|
4311
|
-
} else {
|
|
4312
|
-
status = "blocked";
|
|
4313
|
-
exitCode = config.failOnLeak ? 1 : 0;
|
|
4314
|
-
}
|
|
4315
|
-
}
|
|
4316
|
-
await this.writeGitHubOutputs({
|
|
4317
|
-
leaksFound: report.totalViolations > 0,
|
|
4318
|
-
leaksCount: report.totalViolations,
|
|
4319
|
-
patchApplied: remediation?.success ?? false,
|
|
4320
|
-
reportPath: path5.resolve(config.reportOutput)
|
|
4321
|
-
});
|
|
4322
|
-
await this.writeGitHubSummary(config, report, status, remediation);
|
|
4323
|
-
console.log(pc.bold(pc.cyan("\n-------------------------------------------------------")));
|
|
4324
|
-
if (status === "clean") {
|
|
4325
|
-
console.log(pc.bold(pc.green("\u0153 GATE RESULT: PASS (No privacy leaks)")));
|
|
4326
|
-
} else if (status === "remediated") {
|
|
4327
|
-
console.log(pc.bold(pc.green("\u0153 GATE RESULT: PASS (Leaks automatically remediated)")));
|
|
4328
|
-
} else {
|
|
4329
|
-
console.log(pc.bold(pc.red(`\u0153\u2013 GATE RESULT: BLOCKED (${report.totalViolations} unpatched leaks)`)));
|
|
4330
|
-
}
|
|
4331
|
-
console.log(pc.bold(pc.cyan("-------------------------------------------------------\n")));
|
|
4332
|
-
console.log(pc.dim(LEGAL_DISCLAIMER));
|
|
4333
|
-
console.log("");
|
|
4334
|
-
return {
|
|
4335
|
-
report,
|
|
4336
|
-
remediation,
|
|
4337
|
-
status,
|
|
4338
|
-
exitCode
|
|
4339
|
-
};
|
|
4340
|
-
}
|
|
4341
|
-
async saveReport(report, outputPath, demo = false, reportPdf = false, clientName) {
|
|
4342
|
-
const resolvedPath = path5.resolve(outputPath);
|
|
4343
|
-
const dir = path5.dirname(resolvedPath);
|
|
4344
|
-
await fs5.mkdir(dir, { recursive: true });
|
|
4345
|
-
const portable = {
|
|
4346
|
-
...report,
|
|
4347
|
-
evidence: (report.evidence ?? []).map((e) => ({
|
|
4348
|
-
...e,
|
|
4349
|
-
screenshots: e.screenshots?.map((s) => ({ ...s, path: `evidence/${path5.basename(s.path)}` })),
|
|
4350
|
-
screenshotPath: e.screenshotPath ? `evidence/${path5.basename(e.screenshotPath)}` : e.screenshotPath
|
|
4351
|
-
}))
|
|
4352
|
-
};
|
|
4353
|
-
await fs5.writeFile(resolvedPath, JSON.stringify(portable, null, 2), "utf-8");
|
|
4354
|
-
console.log(pc.dim("Leak report saved to: " + resolvedPath));
|
|
4355
|
-
const evidenceDir = path5.join(dir, "evidence");
|
|
4356
|
-
const shots = [];
|
|
4357
|
-
for (const e of report.evidence ?? []) {
|
|
4358
|
-
for (const s of e.screenshots ?? []) shots.push(s);
|
|
4359
|
-
if (e.screenshotPath && !(e.screenshots ?? []).length) {
|
|
4360
|
-
shots.push({ label: "screenshot", path: e.screenshotPath, at: e.checkedAt });
|
|
4361
|
-
}
|
|
4362
|
-
}
|
|
4363
|
-
if (shots.length) {
|
|
4364
|
-
await fs5.mkdir(evidenceDir, { recursive: true });
|
|
4365
|
-
for (const s of shots) {
|
|
4366
|
-
try {
|
|
4367
|
-
const dest = path5.join(evidenceDir, path5.basename(s.path));
|
|
4368
|
-
await fs5.copyFile(s.path, dest).catch(() => {
|
|
4369
|
-
});
|
|
4370
|
-
} catch {
|
|
4371
|
-
}
|
|
4372
|
-
}
|
|
4373
|
-
console.log(pc.dim("Evidence screenshots copied to: " + evidenceDir));
|
|
4374
|
-
}
|
|
4375
|
-
const base = resolvedPath.replace(/\.json$/i, "");
|
|
4376
|
-
const mdPath = base + "-evidence.md";
|
|
4377
|
-
await fs5.writeFile(mdPath, buildEvidenceMarkdown(report, "Evidence Report", { demo }), "utf-8");
|
|
4378
|
-
console.log(pc.dim("Evidence report saved to: " + mdPath));
|
|
4379
|
-
const htmlPath = base + "-evidence.html";
|
|
4380
|
-
await fs5.writeFile(
|
|
4381
|
-
htmlPath,
|
|
4382
|
-
buildEvidenceHtml(report, "Evidence Report", { demo }),
|
|
4383
|
-
"utf-8"
|
|
4384
|
-
);
|
|
4385
|
-
console.log(pc.dim("Evidence HTML saved to: " + htmlPath));
|
|
4386
|
-
if (reportPdf) {
|
|
4387
|
-
try {
|
|
4388
|
-
const cover = buildCoverHtml(report, {
|
|
4389
|
-
clientName: clientName ?? "pilot-kunde.ch",
|
|
4390
|
-
issuedAt: report.scannedAt,
|
|
4391
|
-
demo
|
|
4392
|
-
});
|
|
4393
|
-
const pdfPath = base + "-deluxe.pdf";
|
|
4394
|
-
await renderDeluxeCombined(cover, htmlPath, pdfPath);
|
|
4395
|
-
console.log(pc.dim("Deluxe PDF saved to: " + pdfPath));
|
|
4396
|
-
} catch (err) {
|
|
4397
|
-
console.log(pc.dim("PDF generation skipped: " + String(err).slice(0, 100)));
|
|
4398
|
-
}
|
|
4399
|
-
}
|
|
4400
|
-
}
|
|
4401
|
-
async writeGitHubOutputs(outputs) {
|
|
4402
|
-
const githubOutputPath = process.env.GITHUB_OUTPUT;
|
|
4403
|
-
if (!githubOutputPath) return;
|
|
4404
|
-
const lines = [
|
|
4405
|
-
`leaks_found=${outputs.leaksFound}`,
|
|
4406
|
-
`leaks_count=${outputs.leaksCount}`,
|
|
4407
|
-
`patch_applied=${outputs.patchApplied}`,
|
|
4408
|
-
`report_path=${outputs.reportPath}`
|
|
4409
|
-
].join("\n");
|
|
4410
|
-
try {
|
|
4411
|
-
await fs5.appendFile(githubOutputPath, `${lines}
|
|
4412
|
-
`, "utf-8");
|
|
4413
|
-
} catch (err) {
|
|
4414
|
-
console.warn(pc.yellow("\u0161 Could not write to GITHUB_OUTPUT: " + String(err)));
|
|
4415
|
-
}
|
|
4416
|
-
}
|
|
4417
|
-
async writeGitHubSummary(config, report, status, remediation) {
|
|
4418
|
-
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
4419
|
-
if (!summaryPath) return;
|
|
4420
|
-
const statusBadge = status === "clean" ? "### \xF0\u0178\u203A\xA1\xEF\xB8 Cuonztech Privacy Chaos Gate: **PASSED (Clean)**" : status === "remediated" ? "### \xF0\u0178\u203A\xA1\xEF\xB8 Cuonztech Privacy Chaos Gate: **PASSED (Auto-Remediated)**" : "### \xF0\u0178\u203A\u2018 Cuonztech Privacy Chaos Gate: **BLOCKED (Privacy Leaks Found)**";
|
|
4421
|
-
const lines = [
|
|
4422
|
-
statusBadge,
|
|
4423
|
-
"",
|
|
4424
|
-
"| Metric | Value |",
|
|
4425
|
-
"| :--- | :--- |",
|
|
4426
|
-
`| **Target URL** | \`${config.targetUrl}\` |`,
|
|
4427
|
-
`| **Duration** | \`${report.durationMs}ms\` |`,
|
|
4428
|
-
`| **Total Violations** | \`${report.totalViolations}\` |`,
|
|
4429
|
-
`| **Passed Scenarios** | ${report.passedScenarios.map((s) => `\`${s}\``).join(", ") || "None"} |`,
|
|
4430
|
-
`| **Failed Scenarios** | ${report.failedScenarios.map((s) => `\`${s}\``).join(", ") || "None"} |`,
|
|
4431
|
-
""
|
|
4432
|
-
];
|
|
4433
|
-
if (report.violations.length > 0) {
|
|
4434
|
-
lines.push("#### \xF0\u0178\u0161\xA8 Detected Privacy Leaks");
|
|
4435
|
-
lines.push("| Scenario | Tracker | Category | Intercepted Request |");
|
|
4436
|
-
lines.push("| :--- | :--- | :--- | :--- |");
|
|
4437
|
-
for (const v of report.violations) {
|
|
4438
|
-
lines.push(
|
|
4439
|
-
`| \`${escapeCell(v.scenario)}\` | **${escapeCell(v.trackerName)}** | \`${escapeCell(v.trackerCategory)}\` | \`${escapeCell(v.requestUrl)}\` |`
|
|
4440
|
-
);
|
|
4441
|
-
}
|
|
4442
|
-
lines.push("");
|
|
4443
|
-
}
|
|
4444
|
-
if (remediation) {
|
|
4445
|
-
lines.push("#### \xF0\u0178\xA7 Agentic Auto-Remediation");
|
|
4446
|
-
if (remediation.success) {
|
|
4447
|
-
lines.push(`> **Status:** Patches successfully generated and applied.`);
|
|
4448
|
-
if (remediation.commitHash) {
|
|
4449
|
-
lines.push(`> **Git Commit:** \`${remediation.commitHash}\``);
|
|
4450
|
-
}
|
|
4451
|
-
for (const patch of remediation.patches) {
|
|
4452
|
-
lines.push(`* **Patched:** \`${patch.filePath}\` - ${patch.explanation}`);
|
|
4453
|
-
}
|
|
4454
|
-
if (remediation.pullRequest) {
|
|
4455
|
-
lines.push(
|
|
4456
|
-
remediation.pullRequest.prUrl ? `> **Pull Request:** ${remediation.pullRequest.prUrl}` : `> **Fix committed locally on branch (no remote/token, no PR opened):** \`${remediation.pullRequest.branchName}\``
|
|
4457
|
-
);
|
|
4458
|
-
}
|
|
4459
|
-
} else {
|
|
4460
|
-
lines.push(`> \u0161\xEF\xB8 **Status:** Auto-remediation failed: ${remediation.error}`);
|
|
4461
|
-
}
|
|
4462
|
-
if (remediation.reviewVerdict) {
|
|
4463
|
-
lines.push("", "#### \xF0\u0178\u017D Review Agent Verdict");
|
|
4464
|
-
lines.push(`> **Score:** \`${remediation.reviewVerdict.score}/100\` | **Passed:** \`${remediation.reviewVerdict.passed}\``);
|
|
4465
|
-
for (const c of remediation.reviewVerdict.comments) {
|
|
4466
|
-
lines.push(`* ${c.replace(/\n/g, " ")}`);
|
|
4467
|
-
}
|
|
4468
|
-
}
|
|
4469
|
-
lines.push("");
|
|
4470
|
-
}
|
|
4471
|
-
try {
|
|
4472
|
-
await fs5.appendFile(summaryPath, lines.join("\n") + "\n", "utf-8");
|
|
4473
|
-
} catch (err) {
|
|
4474
|
-
console.warn(pc.yellow("\u0161 Could not write to GITHUB_STEP_SUMMARY: " + String(err)));
|
|
4475
|
-
}
|
|
4476
|
-
}
|
|
4477
|
-
};
|
|
4478
|
-
|
|
4479
|
-
export {
|
|
4480
|
-
ChaosScenarioSchema,
|
|
4481
|
-
EvidenceStatusSchema,
|
|
4482
|
-
StorageSnapshotSchema,
|
|
4483
|
-
RequestEventSchema,
|
|
4484
|
-
EvidenceRecordSchema,
|
|
4485
|
-
TrackerCategorySchema,
|
|
4486
|
-
TrackerViolationSchema,
|
|
4487
|
-
LeakReportSchema,
|
|
4488
|
-
FilePatchSchema,
|
|
4489
|
-
ReviewVerdictSchema,
|
|
4490
|
-
PullRequestResultSchema,
|
|
4491
|
-
RemediationResultSchema,
|
|
4492
|
-
GateConfigSchema,
|
|
4493
|
-
resolveGateConfig,
|
|
4494
|
-
isOnTargetOrigin,
|
|
4495
|
-
clickFirstSameOriginLink,
|
|
4496
|
-
ChaosScanner,
|
|
4497
|
-
CONSENT_ACTIVATION_RUNTIME,
|
|
4498
|
-
RemediationEngine,
|
|
4499
|
-
resolveJurisdiction,
|
|
4500
|
-
statusLabelOf,
|
|
4501
|
-
codeSolutionsFor,
|
|
4502
|
-
deriveReportFacts,
|
|
4503
|
-
findingsCountText,
|
|
4504
|
-
problemsTextOf,
|
|
4505
|
-
groupFindings,
|
|
4506
|
-
reproductionText,
|
|
4507
|
-
buildEvidenceMarkdown,
|
|
4508
|
-
buildEvidenceHtml,
|
|
4509
|
-
buildCoverHtml,
|
|
4510
|
-
renderDeluxeCombined,
|
|
4511
|
-
renderDeluxePdf,
|
|
4512
|
-
PrivacyChaosGate
|
|
4513
|
-
};
|
|
4514
|
-
//# sourceMappingURL=chunk-T5D6BUAC.js.map
|