@dreamdata_io/verify-tracking 0.1.0
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 +21 -0
- package/README.md +756 -0
- package/dist/ai/checker.js +222 -0
- package/dist/ai/checker.js.map +1 -0
- package/dist/ai/checklist.js +190 -0
- package/dist/ai/checklist.js.map +1 -0
- package/dist/ai/cli-presets.js +27 -0
- package/dist/ai/cli-presets.js.map +1 -0
- package/dist/ai/evidence-view.js +143 -0
- package/dist/ai/evidence-view.js.map +1 -0
- package/dist/ai/local-cli.js +58 -0
- package/dist/ai/local-cli.js.map +1 -0
- package/dist/ai/openai-compatible.js +78 -0
- package/dist/ai/openai-compatible.js.map +1 -0
- package/dist/ai/response-parser.js +105 -0
- package/dist/ai/response-parser.js.map +1 -0
- package/dist/ai/types.js +2 -0
- package/dist/ai/types.js.map +1 -0
- package/dist/analysis/consent-mode.js +225 -0
- package/dist/analysis/consent-mode.js.map +1 -0
- package/dist/analysis/deployment.js +2658 -0
- package/dist/analysis/deployment.js.map +1 -0
- package/dist/analysis/diagnosis/copy.en.js +303 -0
- package/dist/analysis/diagnosis/copy.en.js.map +1 -0
- package/dist/analysis/diagnosis/diagnose.js +172 -0
- package/dist/analysis/diagnosis/diagnose.js.map +1 -0
- package/dist/analysis/diagnosis/docs.js +118 -0
- package/dist/analysis/diagnosis/docs.js.map +1 -0
- package/dist/analysis/diagnosis/index.js +5 -0
- package/dist/analysis/diagnosis/index.js.map +1 -0
- package/dist/analysis/diagnosis/rules.js +656 -0
- package/dist/analysis/diagnosis/rules.js.map +1 -0
- package/dist/analysis/diagnosis/types.js +2 -0
- package/dist/analysis/diagnosis/types.js.map +1 -0
- package/dist/cli.js +344 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/env.js +100 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/preflight.js +167 -0
- package/dist/config/preflight.js.map +1 -0
- package/dist/config/user-config.js +79 -0
- package/dist/config/user-config.js.map +1 -0
- package/dist/evidence/collect.js +527 -0
- package/dist/evidence/collect.js.map +1 -0
- package/dist/evidence/consent-accept.js +377 -0
- package/dist/evidence/consent-accept.js.map +1 -0
- package/dist/evidence/consent.js +340 -0
- package/dist/evidence/consent.js.map +1 -0
- package/dist/evidence/constants.js +615 -0
- package/dist/evidence/constants.js.map +1 -0
- package/dist/evidence/globals.js +132 -0
- package/dist/evidence/globals.js.map +1 -0
- package/dist/evidence/gtm-container.js +432 -0
- package/dist/evidence/gtm-container.js.map +1 -0
- package/dist/evidence/heuristic-banner.js +107 -0
- package/dist/evidence/heuristic-banner.js.map +1 -0
- package/dist/evidence/http-probe.js +139 -0
- package/dist/evidence/http-probe.js.map +1 -0
- package/dist/evidence/init-script.js +112 -0
- package/dist/evidence/init-script.js.map +1 -0
- package/dist/evidence/network.js +365 -0
- package/dist/evidence/network.js.map +1 -0
- package/dist/evidence/preference-center.js +368 -0
- package/dist/evidence/preference-center.js.map +1 -0
- package/dist/evidence/script.js +425 -0
- package/dist/evidence/script.js.map +1 -0
- package/dist/evidence/second-page.js +332 -0
- package/dist/evidence/second-page.js.map +1 -0
- package/dist/evidence/selector-probe.js +53 -0
- package/dist/evidence/selector-probe.js.map +1 -0
- package/dist/evidence/stealth.js +21 -0
- package/dist/evidence/stealth.js.map +1 -0
- package/dist/evidence/types.js +14 -0
- package/dist/evidence/types.js.map +1 -0
- package/dist/interactive/prompt.js +31 -0
- package/dist/interactive/prompt.js.map +1 -0
- package/dist/mcp/server.js +308 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/summary.js +41 -0
- package/dist/mcp/summary.js.map +1 -0
- package/dist/report/context.js +184 -0
- package/dist/report/context.js.map +1 -0
- package/dist/report/projection.js +125 -0
- package/dist/report/projection.js.map +1 -0
- package/dist/report/quote-check.js +90 -0
- package/dist/report/quote-check.js.map +1 -0
- package/dist/report/render.js +178 -0
- package/dist/report/render.js.map +1 -0
- package/dist/report/serializers.js +124 -0
- package/dist/report/serializers.js.map +1 -0
- package/dist/report/store.js +139 -0
- package/dist/report/store.js.map +1 -0
- package/dist/run/verify.js +77 -0
- package/dist/run/verify.js.map +1 -0
- package/dist/utils/duration.js +10 -0
- package/dist/utils/duration.js.map +1 -0
- package/dist/utils/errors.js +17 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.js +59 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/runTimingsLog.js +27 -0
- package/dist/utils/runTimingsLog.js.map +1 -0
- package/dist/utils/slug.js +27 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -0
- package/scripts/install.sh +314 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
import { combinedBundleTags, tagsGatedOnWrongSignal } from "../deployment.js";
|
|
2
|
+
const CONSENT_LEAK_CHECKS = [
|
|
3
|
+
"clean-before-consent",
|
|
4
|
+
"reject-blocks-normal",
|
|
5
|
+
"reject-writes-no-identity",
|
|
6
|
+
"second-page-honours-consent",
|
|
7
|
+
];
|
|
8
|
+
/** Whether a phase saw any Dreamdata request at all, on either page view. */
|
|
9
|
+
function silent(phase) {
|
|
10
|
+
const second = phase.secondPage;
|
|
11
|
+
return (phase.normalRequests === 0 &&
|
|
12
|
+
phase.cookielessRequests === 0 &&
|
|
13
|
+
(second === null || (second.normalRequests === 0 && second.cookielessRequests === 0)));
|
|
14
|
+
}
|
|
15
|
+
/** Whether the normal bundle sent anything in a phase, on either page view. */
|
|
16
|
+
function normalFired(phase) {
|
|
17
|
+
return phase.normalRequests > 0 || (phase.secondPage?.normalRequests ?? 0) > 0;
|
|
18
|
+
}
|
|
19
|
+
/** Consent Mode read as permissive before the visitor had answered. */
|
|
20
|
+
function permissiveBeforeAnswer(phase) {
|
|
21
|
+
return phase.analyticsStorageSource === "implicit-default" || phase.analyticsStorage === "granted";
|
|
22
|
+
}
|
|
23
|
+
/** A phase where the banner was driven, so its consent state means something. */
|
|
24
|
+
function drivenPhases(facts) {
|
|
25
|
+
return facts.observed.filter((phase) => phase.phase !== "clean" && phase.consentDriven);
|
|
26
|
+
}
|
|
27
|
+
function hasDuplicateKind(facts, kind) {
|
|
28
|
+
return facts.duplicates.some((duplicate) => duplicate.kind === kind);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every remaining unhealthy check. Used only by the rules that genuinely
|
|
32
|
+
* account for the whole run — nothing is installed, or nothing is delivered —
|
|
33
|
+
* where reporting a second cause would be describing a consequence of the
|
|
34
|
+
* first as though it were separate.
|
|
35
|
+
*/
|
|
36
|
+
function everythingElse(facts) {
|
|
37
|
+
return facts.unhealthy;
|
|
38
|
+
}
|
|
39
|
+
export const CAUSE_RULES = [
|
|
40
|
+
// ── Not a verdict about the site. These pre-empt everything. ─────────────
|
|
41
|
+
{
|
|
42
|
+
id: "page-unreadable",
|
|
43
|
+
kind: "not-a-verdict",
|
|
44
|
+
trigger: [],
|
|
45
|
+
triggerMode: "any",
|
|
46
|
+
// Reached with every check already voided to inconclusive by voidChecks,
|
|
47
|
+
// so there is nothing to claim — the gate is pageReadability itself.
|
|
48
|
+
match: (facts) => (facts.analysis.pageReadability.unreadable ? { claims: everythingElse(facts), preempt: true } : null),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "third-party-segment",
|
|
52
|
+
kind: "not-a-verdict",
|
|
53
|
+
trigger: [],
|
|
54
|
+
triggerMode: "any",
|
|
55
|
+
match: (facts) => facts.analysis.shape === "third-party-segment-undetermined"
|
|
56
|
+
? { claims: everythingElse(facts), preempt: true }
|
|
57
|
+
: null,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "banner-undrivable",
|
|
61
|
+
kind: "not-a-verdict",
|
|
62
|
+
trigger: [],
|
|
63
|
+
triggerMode: "any",
|
|
64
|
+
// Gates on the SHAPE, not on consentGateUndetermined alone: that flag can
|
|
65
|
+
// be true beside a concluded shape (see DeploymentAnalysis), and there
|
|
66
|
+
// the checks resting on the undriven phase are already inconclusive, so
|
|
67
|
+
// they never enter the ledger and no rule can misfire on them.
|
|
68
|
+
match: (facts) => (facts.analysis.shape === "undetermined" ? { claims: everythingElse(facts), preempt: true } : null),
|
|
69
|
+
},
|
|
70
|
+
// ── Blocking: data is being lost right now. ─────────────────────────────
|
|
71
|
+
{
|
|
72
|
+
id: "not-installed",
|
|
73
|
+
kind: "blocking",
|
|
74
|
+
trigger: ["script-installed"],
|
|
75
|
+
triggerMode: "any",
|
|
76
|
+
// Claims everything: with no script on the page, every other failing
|
|
77
|
+
// check is a restatement of the same fact.
|
|
78
|
+
match: (facts) => facts.analysis.shape === "nothing-installed" || facts.analysis.shape === "helper-only"
|
|
79
|
+
? { claims: everythingElse(facts) }
|
|
80
|
+
: null,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "requests-never-reach-dreamdata",
|
|
84
|
+
kind: "blocking",
|
|
85
|
+
trigger: ["grant-enables-normal", "cookieless-covers-unconsented"],
|
|
86
|
+
triggerMode: "any",
|
|
87
|
+
/**
|
|
88
|
+
* The rocketbots_io signature: a bundle demonstrably ran — it is in the
|
|
89
|
+
* DOM and it wrote an identity cookie — and yet not one request left the
|
|
90
|
+
* browser in any phase. Every request-side field looks healthy, which is
|
|
91
|
+
* why this needs its own cause rather than reading as a consent problem.
|
|
92
|
+
*/
|
|
93
|
+
match: (facts) => {
|
|
94
|
+
const everySilent = facts.observed.length > 0 && facts.observed.every(silent);
|
|
95
|
+
const bundlePresent = facts.observed.some((phase) => phase.normalBundleInDom || phase.cookielessBundleInDom);
|
|
96
|
+
const wroteIdentity = facts.observed.some((phase) => phase.identityCookiesSet.length > 0);
|
|
97
|
+
if (!everySilent || !bundlePresent || !wroteIdentity)
|
|
98
|
+
return null;
|
|
99
|
+
return {
|
|
100
|
+
claims: [
|
|
101
|
+
"second-page-keeps-tracking",
|
|
102
|
+
"soft-nav-keeps-tracking",
|
|
103
|
+
"request-delivery-accepted",
|
|
104
|
+
"request-payload-complete",
|
|
105
|
+
"global-handoff",
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: "cookieless-not-installed",
|
|
112
|
+
kind: "blocking",
|
|
113
|
+
trigger: ["cookieless-covers-unconsented"],
|
|
114
|
+
triggerMode: "all",
|
|
115
|
+
match: (facts) => (facts.analysis.cookielessBundleConfigured ? null : { claims: [] }),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: "cookieless-shares-normal-gate",
|
|
119
|
+
kind: "blocking",
|
|
120
|
+
trigger: ["cookieless-covers-unconsented"],
|
|
121
|
+
triggerMode: "all",
|
|
122
|
+
/**
|
|
123
|
+
* The most common cause in the corpus (7 of 25 sites), and it reads
|
|
124
|
+
* straight off the DOM: on gong_io, nteams_com, smartbear_com and
|
|
125
|
+
* team_wrike_com the cookieless bundle is absent from the page in clean
|
|
126
|
+
* and reject but present after a grant. A tag that only appears once
|
|
127
|
+
* consent is given is a tag sharing the normal bundle's consent gate,
|
|
128
|
+
* which defeats the entire point of having a cookieless fallback.
|
|
129
|
+
*/
|
|
130
|
+
match: (facts) => {
|
|
131
|
+
if (!facts.analysis.cookielessBundleConfigured)
|
|
132
|
+
return null;
|
|
133
|
+
const failing = facts.failingPhases("cookieless-covers-unconsented");
|
|
134
|
+
if (failing.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
const absentWhereItFailed = failing.every((name) => {
|
|
137
|
+
const phase = facts.phase(name);
|
|
138
|
+
return phase !== undefined && !phase.cookielessBundleInDom && phase.cookielessRequests === 0;
|
|
139
|
+
});
|
|
140
|
+
const presentSomewhere = facts.observed.some((phase) => phase.cookielessBundleInDom);
|
|
141
|
+
return absentWhereItFailed && presentSomewhere ? { claims: [] } : null;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "consent-not-persisted-across-reload",
|
|
146
|
+
kind: "blocking",
|
|
147
|
+
trigger: ["consent-mode-signal"],
|
|
148
|
+
triggerMode: "all",
|
|
149
|
+
/**
|
|
150
|
+
* paytronix_com: the update DID fire while the banner was being answered
|
|
151
|
+
* (consentSignalConfirmed true) but after the post-consent reload
|
|
152
|
+
* analytics_storage reads its default again. The visitor's decision does
|
|
153
|
+
* not survive a page load, so on the page they actually browse every
|
|
154
|
+
* gated tag is still blocked — which is also why grant-enables-normal
|
|
155
|
+
* fails here and is claimed rather than reported separately.
|
|
156
|
+
*/
|
|
157
|
+
match: (facts) => {
|
|
158
|
+
const stranded = drivenPhases(facts).some((phase) => phase.consentSignalConfirmed === true && phase.analyticsStorageSource !== "update");
|
|
159
|
+
if (!stranded)
|
|
160
|
+
return null;
|
|
161
|
+
return { claims: ["grant-enables-normal", "grant-sets-identity-cookies", "second-page-honours-consent"] };
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: "consent-mode-not-wired",
|
|
166
|
+
kind: "blocking",
|
|
167
|
+
trigger: ["clean-consent-mode-default", "consent-mode-signal"],
|
|
168
|
+
triggerMode: "all",
|
|
169
|
+
/**
|
|
170
|
+
* The composite that collapses nextecgroup_com from six failing checks to
|
|
171
|
+
* one root cause, and cmic_ca from three to two. BOTH halves must be
|
|
172
|
+
* failing: no denied default AND no update ever emitted. That conjunction
|
|
173
|
+
* is what separates it from nice_com, where the same leak checks fail but
|
|
174
|
+
* Consent Mode signals correctly and the bundle simply ignores them —
|
|
175
|
+
* a different cause with a different fix, handled below.
|
|
176
|
+
*/
|
|
177
|
+
match: (facts) => {
|
|
178
|
+
const clean = facts.phase("clean");
|
|
179
|
+
const defaultPermissive = clean !== undefined && permissiveBeforeAnswer(clean);
|
|
180
|
+
const neverUpdated = drivenPhases(facts).some((phase) => phase.analyticsStorageSource !== "update");
|
|
181
|
+
if (!defaultPermissive || !neverUpdated)
|
|
182
|
+
return null;
|
|
183
|
+
return {
|
|
184
|
+
claims: [
|
|
185
|
+
...CONSENT_LEAK_CHECKS,
|
|
186
|
+
"grant-enables-normal",
|
|
187
|
+
"grant-sets-identity-cookies",
|
|
188
|
+
"cookieless-covers-unconsented",
|
|
189
|
+
// Claimed so consent-mode-never-called below does not fire again
|
|
190
|
+
// saying a narrower version of the same thing. This cause is the
|
|
191
|
+
// fuller story: it has both halves of the evidence, not just the
|
|
192
|
+
// ics flag.
|
|
193
|
+
"consent-mode-v2-enabled",
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
/**
|
|
200
|
+
* The half of F1 the rule above cannot see, and the most expensive
|
|
201
|
+
* failure in the catalog because nothing about it is visible from
|
|
202
|
+
* analytics_storage alone.
|
|
203
|
+
*
|
|
204
|
+
* A Google tag loads, creates all seven consent entries, and nothing
|
|
205
|
+
* ever calls gtag('consent', …). analytics_storage therefore reads
|
|
206
|
+
* `denied` off gtag.js's own fallback — which makes
|
|
207
|
+
* clean-consent-mode-default PASS, which is why consent-mode-not-wired
|
|
208
|
+
* (needing a permissive default) never fires. ics.active is the only
|
|
209
|
+
* field that tells this apart from a site that deliberately declared a
|
|
210
|
+
* denied default, and until it was collected this case reached a report
|
|
211
|
+
* as a silence nobody could explain.
|
|
212
|
+
*
|
|
213
|
+
* It matters because a consent-gated bundle in this state never
|
|
214
|
+
* initialises at all: it waits for a signal that no CMP is going to
|
|
215
|
+
* send, with no error and no request.
|
|
216
|
+
*/
|
|
217
|
+
id: "consent-mode-never-called",
|
|
218
|
+
kind: "blocking",
|
|
219
|
+
trigger: ["consent-mode-v2-enabled"],
|
|
220
|
+
triggerMode: "all",
|
|
221
|
+
match: (facts) => facts.analysis.consentModeV2.outcome === "not-installed"
|
|
222
|
+
? {
|
|
223
|
+
claims: ["grant-enables-normal", "cookieless-covers-unconsented"].filter((id) => facts.failingPhases(id).length > 0),
|
|
224
|
+
}
|
|
225
|
+
: null,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
/**
|
|
229
|
+
* F6. The consent platform declares its defaults after tags have already
|
|
230
|
+
* run (ics.wasSetLate), so anything that fired first saw no consent
|
|
231
|
+
* state at all — a load-order fault, and one that changes with the
|
|
232
|
+
* visitor's network speed rather than with anything in the container.
|
|
233
|
+
*
|
|
234
|
+
* Read from either the settled read or the pre-decision one: on a driven
|
|
235
|
+
* phase the post-consent reload carries the CMP's own cookie and can
|
|
236
|
+
* declare earlier than a first visit does, so the fault hides in the
|
|
237
|
+
* settled read alone.
|
|
238
|
+
*/
|
|
239
|
+
id: "consent-mode-set-late",
|
|
240
|
+
kind: "blocking",
|
|
241
|
+
trigger: ["consent-mode-v2-enabled"],
|
|
242
|
+
triggerMode: "all",
|
|
243
|
+
match: (facts) => {
|
|
244
|
+
if (!facts.analysis.consentModeV2.causes.includes("set-late"))
|
|
245
|
+
return null;
|
|
246
|
+
// Only the leak checks a late gate actually accounts for: a phase that
|
|
247
|
+
// leaked for some other reason belongs to another cause.
|
|
248
|
+
return { claims: CONSENT_LEAK_CHECKS.filter((id) => facts.failingPhases(id).length > 0) };
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
/**
|
|
253
|
+
* The tag requires a consent signal that is not analytics_storage, and
|
|
254
|
+
* the signal it requires is never granted here — so the tag is inert and
|
|
255
|
+
* the account records nothing at all, in every phase.
|
|
256
|
+
*
|
|
257
|
+
* telaid_com is the case: its standard-bundle tags require
|
|
258
|
+
* `targeted_advertising`, which is not one of Consent Mode's seven
|
|
259
|
+
* signals and which nothing on that page ever sets, so all three phases
|
|
260
|
+
* are silent and the run produced no cause whatsoever. The container is
|
|
261
|
+
* the only place this is visible — from behaviour alone it is
|
|
262
|
+
* indistinguishable from a trigger that never matched.
|
|
263
|
+
*
|
|
264
|
+
* Trigger-less and gated entirely in `match` (like the not-a-verdict
|
|
265
|
+
* rules) because there is no check whose failure means "gated on the
|
|
266
|
+
* wrong signal": the checks see silence, and silence has many causes.
|
|
267
|
+
* `normalFired` in no phase is what separates this from the hardening
|
|
268
|
+
* variant below, and it is what keeps this rule off any site whose tag
|
|
269
|
+
* actually works.
|
|
270
|
+
*/
|
|
271
|
+
id: "consent-gate-wrong-signal",
|
|
272
|
+
kind: "blocking",
|
|
273
|
+
trigger: [],
|
|
274
|
+
triggerMode: "all",
|
|
275
|
+
match: (facts) => {
|
|
276
|
+
if (tagsGatedOnWrongSignal(facts.analysis.install.tagManagerContainers).length === 0)
|
|
277
|
+
return null;
|
|
278
|
+
if (facts.observed.some(normalFired))
|
|
279
|
+
return null;
|
|
280
|
+
return {
|
|
281
|
+
claims: ["grant-enables-normal", "cookieless-covers-unconsented", "core-tracking"].filter((id) => facts.failingPhases(id).length > 0),
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: "normal-bundle-never-fires",
|
|
287
|
+
kind: "blocking",
|
|
288
|
+
trigger: ["grant-enables-normal"],
|
|
289
|
+
triggerMode: "all",
|
|
290
|
+
// The bundle is on the page after the grant and still sends nothing —
|
|
291
|
+
// reached only when the total-silence signature above did not hold, so
|
|
292
|
+
// this is specifically "the identified bundle alone is broken".
|
|
293
|
+
match: (facts) => {
|
|
294
|
+
const grant = facts.phase("grant");
|
|
295
|
+
if (grant === undefined || !grant.normalBundleInDom || normalFired(grant))
|
|
296
|
+
return null;
|
|
297
|
+
return { claims: ["grant-sets-identity-cookies"] };
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "router-no-page-call",
|
|
302
|
+
kind: "blocking",
|
|
303
|
+
trigger: ["soft-nav-keeps-tracking"],
|
|
304
|
+
triggerMode: "all",
|
|
305
|
+
/**
|
|
306
|
+
* Gated on the run-level reconciliation rather than on a re-scan of the
|
|
307
|
+
* phases — same set (summarizeSecondPage copies kind verbatim), one
|
|
308
|
+
* definition of "a soft navigation happened".
|
|
309
|
+
*
|
|
310
|
+
* softPhases, deliberately, and never mode: "client-routed-suspected" is
|
|
311
|
+
* a regex over served markup, and a regex must not be able to fire a
|
|
312
|
+
* blocking cause that tells a customer their site is a single-page app.
|
|
313
|
+
*/
|
|
314
|
+
match: (facts) => {
|
|
315
|
+
const routing = facts.analysis.routing;
|
|
316
|
+
if (routing.softPhases.length === 0)
|
|
317
|
+
return null;
|
|
318
|
+
return { claims: [], vars: { navKind: "soft", framework: routing.frameworkMarkers[0] } };
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "entry-page-only",
|
|
323
|
+
kind: "blocking",
|
|
324
|
+
trigger: ["second-page-keeps-tracking"],
|
|
325
|
+
triggerMode: "all",
|
|
326
|
+
match: (facts) => {
|
|
327
|
+
if (!facts.observed.some((phase) => phase.secondPage?.kind === "hard"))
|
|
328
|
+
return null;
|
|
329
|
+
const gtm = facts.analysis.install;
|
|
330
|
+
return {
|
|
331
|
+
claims: [],
|
|
332
|
+
vars: {
|
|
333
|
+
navKind: "hard",
|
|
334
|
+
// Read from the phases this check actually failed in, so a page that
|
|
335
|
+
// tracked in some other phase cannot soften the sentence.
|
|
336
|
+
secondPageStillTracked: facts
|
|
337
|
+
.failingPhases("second-page-keeps-tracking")
|
|
338
|
+
.map((name) => facts.phase(name)?.secondPage)
|
|
339
|
+
.some((second) => second !== undefined && second !== null && (second.normalRequests > 0 || second.cookielessRequests > 0)),
|
|
340
|
+
// Only true when the container was read and actually lists
|
|
341
|
+
// exceptions. Everything else — not scanned, parse failed, no
|
|
342
|
+
// exceptions — leaves the fix telling the reader to go and look
|
|
343
|
+
// rather than telling them what they will find.
|
|
344
|
+
tagHasBlockingTriggers: (gtm.tagManagerNormalBlockingTriggers?.length ?? 0) > 0,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
// ── Leaking: visitors tracked who did not agree. ────────────────────────
|
|
350
|
+
{
|
|
351
|
+
id: "consent-default-not-denied",
|
|
352
|
+
kind: "leaking",
|
|
353
|
+
trigger: ["clean-consent-mode-default"],
|
|
354
|
+
triggerMode: "all",
|
|
355
|
+
/**
|
|
356
|
+
* aikido_dev: analytics_storage already reads granted before the banner
|
|
357
|
+
* is answered, so the gate never closes in the first place. Claims only
|
|
358
|
+
* the leak checks whose failing phases are ones where storage actually
|
|
359
|
+
* read permissive — a leak in a phase this cause cannot account for
|
|
360
|
+
* belongs to another cause.
|
|
361
|
+
*/
|
|
362
|
+
match: (facts) => {
|
|
363
|
+
const clean = facts.phase("clean");
|
|
364
|
+
if (clean === undefined || !permissiveBeforeAnswer(clean))
|
|
365
|
+
return null;
|
|
366
|
+
const permissivePhases = new Set(facts.observed.filter(permissiveBeforeAnswer).map((phase) => phase.phase));
|
|
367
|
+
const claims = ["clean-before-consent", "second-page-honours-consent", "grant-sets-identity-cookies"].filter((id) => {
|
|
368
|
+
const failing = facts.failingPhases(id);
|
|
369
|
+
return failing.length > 0 && failing.every((phase) => permissivePhases.has(phase));
|
|
370
|
+
});
|
|
371
|
+
return { claims };
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
/**
|
|
376
|
+
* meteomatics_com. Consent Mode IS wired — the grant phase emitted
|
|
377
|
+
* `update: granted` and consentSignalConfirmed was true — but the refusal,
|
|
378
|
+
* which this run had to make through the preference centre, emitted
|
|
379
|
+
* nothing at all. analytics_storage stayed on its declared default, which
|
|
380
|
+
* happens to be `denied`, so the refusal produced the right end state and
|
|
381
|
+
* nothing leaked.
|
|
382
|
+
*
|
|
383
|
+
* Right by accident is what `hardening` means, and it is why this cannot
|
|
384
|
+
* be cmp-signal-not-emitted: that cause says the banner "never passes the
|
|
385
|
+
* choice on to Consent Mode" and links to how to turn Consent Mode ON,
|
|
386
|
+
* which is plainly untrue here and sends the reader to configure something
|
|
387
|
+
* already configured. What is actually broken is narrower and worse than
|
|
388
|
+
* it looks — a visitor who grants and later revokes leaves
|
|
389
|
+
* analytics_storage on `granted` from the earlier update, because the
|
|
390
|
+
* revocation emits nothing to override it. This run cannot see that: each
|
|
391
|
+
* phase is a fresh context that never granted first.
|
|
392
|
+
*/
|
|
393
|
+
id: "refusal-not-passed-to-consent-mode",
|
|
394
|
+
kind: "hardening",
|
|
395
|
+
trigger: ["consent-mode-signal"],
|
|
396
|
+
triggerMode: "all",
|
|
397
|
+
match: (facts) => {
|
|
398
|
+
const driven = drivenPhases(facts);
|
|
399
|
+
const wired = driven.some((phase) => phase.consentSignalConfirmed === true || phase.analyticsStorageSource === "update");
|
|
400
|
+
const silent = driven.filter((phase) => phase.consentSignalConfirmed !== true && phase.analyticsStorageSource !== "update");
|
|
401
|
+
if (!wired || silent.length === 0)
|
|
402
|
+
return null;
|
|
403
|
+
// Only "held in check" while the default denies AND nothing ran anyway.
|
|
404
|
+
// Either failing makes this a real leak, which is the next rule's.
|
|
405
|
+
const heldByDefault = silent.every((phase) => phase.analyticsStorage === "denied" && !normalFired(phase));
|
|
406
|
+
return heldByDefault ? { claims: [] } : null;
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "cmp-signal-not-emitted",
|
|
411
|
+
kind: "leaking",
|
|
412
|
+
trigger: ["consent-mode-signal"],
|
|
413
|
+
triggerMode: "all",
|
|
414
|
+
/**
|
|
415
|
+
* meteomatics_com's reject phase: the banner recorded the refusal but
|
|
416
|
+
* never emitted the Consent Mode update, so anything gated on it stayed
|
|
417
|
+
* on whatever it was. Distinguished from the reload case above by
|
|
418
|
+
* consentSignalConfirmed being false rather than true.
|
|
419
|
+
*/
|
|
420
|
+
match: (facts) => {
|
|
421
|
+
const neverEmitted = drivenPhases(facts).some((phase) => phase.consentSignalConfirmed !== true && phase.analyticsStorageSource !== "update");
|
|
422
|
+
if (!neverEmitted)
|
|
423
|
+
return null;
|
|
424
|
+
const leaked = new Set(facts.observed.filter(normalFired).map((phase) => phase.phase));
|
|
425
|
+
const claims = ["reject-blocks-normal", "clean-before-consent", "grant-enables-normal"].filter((id) => {
|
|
426
|
+
const failing = facts.failingPhases(id);
|
|
427
|
+
return failing.length > 0 && failing.some((phase) => leaked.has(phase));
|
|
428
|
+
});
|
|
429
|
+
return { claims };
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
/**
|
|
434
|
+
* The snippet's own consent gate let the standard bundle through, on an
|
|
435
|
+
* install where the container has no gate to fall back on.
|
|
436
|
+
*
|
|
437
|
+
* A combined tag — both bundles in one Custom HTML tag — is NOT the
|
|
438
|
+
* defect and must never be reported as one: it is the install the
|
|
439
|
+
* documentation currently prescribes, and it behaves correctly whenever
|
|
440
|
+
* the snippet's gate does. This rule therefore requires an OBSERVED leak
|
|
441
|
+
* (normalFired in a phase that was not the grant), not merely the shape.
|
|
442
|
+
* sovos_com and tumodo_io both have a combined tag and neither fires
|
|
443
|
+
* this, which is the intended behaviour, not a gap.
|
|
444
|
+
*
|
|
445
|
+
* What the combined tag contributes once the gate has failed is the
|
|
446
|
+
* absence of a second line of defence. A GTM tag has one `consent` field
|
|
447
|
+
* and one trigger set, so the two bundles cannot be held to opposite
|
|
448
|
+
* consent states however it is configured — which is why the fix here is
|
|
449
|
+
* "split the tag", a different instruction from
|
|
450
|
+
* no-consent-gate-on-bundle's "add a consent requirement", and one the
|
|
451
|
+
* customer cannot carry out until the tag is split.
|
|
452
|
+
*
|
|
453
|
+
* Placed before no-consent-gate-on-bundle (array order is precedence) so
|
|
454
|
+
* that where the container shows the combined tag, the actionable
|
|
455
|
+
* instruction is the one reported. Where it does not — a hardcoded
|
|
456
|
+
* install, or evidence collected before TagManagerScan.containers
|
|
457
|
+
* existed — this returns null and the existing cause is unaffected.
|
|
458
|
+
*
|
|
459
|
+
* On getworkflex_com the failure is not a race, and the distinction
|
|
460
|
+
* decides what gets fixed: the clean phase leaked while
|
|
461
|
+
* analytics_storage resolved to `denied` from a `default` with no update
|
|
462
|
+
* anywhere in the run, so there was nothing for the bundle to race. The
|
|
463
|
+
* snippet's dataLayer.push wrapper reads neither raw[1] (default vs
|
|
464
|
+
* update) nor raw[2].region, so the CMP's global `granted` default —
|
|
465
|
+
* declared for visitors in regions this one was not in — read as the
|
|
466
|
+
* visitor's own consent.
|
|
467
|
+
*/
|
|
468
|
+
id: "combined-tag-gate-failed",
|
|
469
|
+
kind: "leaking",
|
|
470
|
+
trigger: ["clean-before-consent", "reject-blocks-normal"],
|
|
471
|
+
triggerMode: "any",
|
|
472
|
+
match: (facts) => {
|
|
473
|
+
if (combinedBundleTags(facts.analysis.install.tagManagerContainers).length === 0)
|
|
474
|
+
return null;
|
|
475
|
+
// Same phase-awareness as no-consent-gate-on-bundle, and for the same
|
|
476
|
+
// reason: the mechanism named here is "the normal bundle ran when it
|
|
477
|
+
// should not have", so a leak check that failed in a phase where it
|
|
478
|
+
// sent nothing belongs to another cause.
|
|
479
|
+
const ranWithoutGrant = new Set(facts.observed
|
|
480
|
+
.filter((phase) => phase.phase !== "grant" && normalFired(phase))
|
|
481
|
+
.map((phase) => phase.phase));
|
|
482
|
+
if (ranWithoutGrant.size === 0)
|
|
483
|
+
return null;
|
|
484
|
+
return {
|
|
485
|
+
claims: CONSENT_LEAK_CHECKS.filter((id) => {
|
|
486
|
+
const failing = facts.failingPhases(id);
|
|
487
|
+
return failing.length > 0 && failing.some((phase) => ranWithoutGrant.has(phase));
|
|
488
|
+
}),
|
|
489
|
+
};
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
id: "no-consent-gate-on-bundle",
|
|
494
|
+
kind: "leaking",
|
|
495
|
+
trigger: ["clean-before-consent", "reject-blocks-normal"],
|
|
496
|
+
triggerMode: "any",
|
|
497
|
+
/**
|
|
498
|
+
* nice_com and manatal_com: Consent Mode reports the decision CORRECTLY
|
|
499
|
+
* (source "update", the right value) and the script runs anyway. Nothing
|
|
500
|
+
* is gating it. The gate on the two Consent Mode checks not failing is
|
|
501
|
+
* what keeps this apart from consent-mode-not-wired.
|
|
502
|
+
*
|
|
503
|
+
* The claim predicate is phase-aware on purpose, and this is where
|
|
504
|
+
* over-collapse is prevented: the mechanism named here is "the normal
|
|
505
|
+
* bundle ran", so a leak check that failed in a phase where the normal
|
|
506
|
+
* bundle sent nothing is not this cause's business. That is why
|
|
507
|
+
* getmoss_com's reject-writes-no-identity (reject sent no normal request)
|
|
508
|
+
* falls through to its own cause, and why manatal keeps three causes
|
|
509
|
+
* rather than collapsing to one.
|
|
510
|
+
*/
|
|
511
|
+
match: (facts) => {
|
|
512
|
+
if (facts.statusOf("clean-consent-mode-default") === "fail")
|
|
513
|
+
return null;
|
|
514
|
+
if (facts.statusOf("consent-mode-signal") === "fail")
|
|
515
|
+
return null;
|
|
516
|
+
const ranWithoutGrant = new Set(facts.observed
|
|
517
|
+
.filter((phase) => phase.phase !== "grant" && normalFired(phase))
|
|
518
|
+
.map((phase) => phase.phase));
|
|
519
|
+
if (ranWithoutGrant.size === 0)
|
|
520
|
+
return null;
|
|
521
|
+
const claims = CONSENT_LEAK_CHECKS.filter((id) => {
|
|
522
|
+
const failing = facts.failingPhases(id);
|
|
523
|
+
return failing.length > 0 && failing.some((phase) => ranWithoutGrant.has(phase));
|
|
524
|
+
});
|
|
525
|
+
return { claims };
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: "navigation-treated-as-consent",
|
|
530
|
+
kind: "leaking",
|
|
531
|
+
trigger: ["second-page-honours-consent"],
|
|
532
|
+
triggerMode: "all",
|
|
533
|
+
// moxo_com. Reached only when no broader consent cause claimed it, which
|
|
534
|
+
// is exactly when the entry page behaved and the navigation did not.
|
|
535
|
+
match: () => ({ claims: [] }),
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: "reject-writes-identity",
|
|
539
|
+
kind: "leaking",
|
|
540
|
+
trigger: ["reject-writes-no-identity"],
|
|
541
|
+
triggerMode: "all",
|
|
542
|
+
match: () => ({ claims: [] }),
|
|
543
|
+
},
|
|
544
|
+
// ── Duplicate counting. ────────────────────────────────────────────────
|
|
545
|
+
// All four share one trigger, so the ledger guarantees exactly one fires.
|
|
546
|
+
// Ordered most-structural first; page-view-counted-once's own detail still
|
|
547
|
+
// lists every duplicate found, so nothing is hidden by picking one.
|
|
548
|
+
{
|
|
549
|
+
id: "duplicate-two-builds",
|
|
550
|
+
kind: "duplicate-counting",
|
|
551
|
+
trigger: ["page-view-counted-once"],
|
|
552
|
+
triggerMode: "all",
|
|
553
|
+
match: (facts) => hasDuplicateKind(facts, "two-builds") ? { claims: [], vars: { duplicateKind: "two-builds" } } : null,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: "duplicate-replayed-buffer",
|
|
557
|
+
kind: "duplicate-counting",
|
|
558
|
+
trigger: ["page-view-counted-once"],
|
|
559
|
+
triggerMode: "all",
|
|
560
|
+
match: (facts) => hasDuplicateKind(facts, "replayed-buffer") ? { claims: [], vars: { duplicateKind: "replayed-buffer" } } : null,
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: "duplicate-same-build",
|
|
564
|
+
kind: "duplicate-counting",
|
|
565
|
+
trigger: ["page-view-counted-once"],
|
|
566
|
+
triggerMode: "all",
|
|
567
|
+
match: (facts) => hasDuplicateKind(facts, "same-build") ? { claims: [], vars: { duplicateKind: "same-build" } } : null,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
id: "snippet-order",
|
|
571
|
+
kind: "duplicate-counting",
|
|
572
|
+
trigger: ["page-view-counted-once"],
|
|
573
|
+
triggerMode: "all",
|
|
574
|
+
// The one duplicate kind page-view-counted-once warns about rather than
|
|
575
|
+
// failing, because ingest collapses the pair (see isCollapsedByIngest).
|
|
576
|
+
// The race that produced it is identical either way, so the cause and the
|
|
577
|
+
// fix are the same and must still be given.
|
|
578
|
+
triggerOn: "unhealthy",
|
|
579
|
+
match: (facts) => hasDuplicateKind(facts, "mixed-modes") ? { claims: [], vars: { duplicateKind: "mixed-modes" } } : null,
|
|
580
|
+
},
|
|
581
|
+
// ── Hardening: correct today, but only by accident. ────────────────────
|
|
582
|
+
{
|
|
583
|
+
/**
|
|
584
|
+
* F2. Consent Mode is genuinely in use, but only v1: ad_user_data and
|
|
585
|
+
* ad_personalization are not among the declared signals.
|
|
586
|
+
*
|
|
587
|
+
* `hardening` rather than `blocking` because analytics_storage — the only
|
|
588
|
+
* signal Dreamdata's own gating reads — still works, so nothing about
|
|
589
|
+
* this run's tracking is wrong. What it costs the customer is on the ads
|
|
590
|
+
* side, which is worth telling them once and not failing them over.
|
|
591
|
+
*
|
|
592
|
+
* triggerOn "unhealthy": the check reports v1 as a `fail`, but a run that
|
|
593
|
+
* has v1 AND something the check downgraded lands on `warn`, and it is
|
|
594
|
+
* the same missing signals either way.
|
|
595
|
+
*/
|
|
596
|
+
id: "consent-mode-v1-only",
|
|
597
|
+
kind: "hardening",
|
|
598
|
+
trigger: ["consent-mode-v2-enabled"],
|
|
599
|
+
triggerMode: "all",
|
|
600
|
+
triggerOn: "unhealthy",
|
|
601
|
+
match: (facts) => (facts.analysis.consentModeV2.causes.includes("v1-only") ? { claims: [] } : null),
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
/**
|
|
605
|
+
* F4, and the only cause here that is about how the install can be READ
|
|
606
|
+
* rather than how it behaves.
|
|
607
|
+
*
|
|
608
|
+
* The consent platform declares a permissive global default for
|
|
609
|
+
* analytics_storage and then overrides it for a list of regions. gtag.js
|
|
610
|
+
* resolves that correctly — `entries[k].default` is the region-resolved
|
|
611
|
+
* value — so the visitor's end state is right and nothing leaks, which is
|
|
612
|
+
* what makes it hardening.
|
|
613
|
+
*
|
|
614
|
+
* It is still worth a customer's attention, because everything that reads
|
|
615
|
+
* the dataLayer consent commands directly rather than the resolved state
|
|
616
|
+
* sees the global `granted` and takes it for a decision. Dreamdata's own
|
|
617
|
+
* GTM snippet was one of those readers, which is how this shape was
|
|
618
|
+
* found.
|
|
619
|
+
*/
|
|
620
|
+
id: "consent-mode-region-shadowed",
|
|
621
|
+
kind: "hardening",
|
|
622
|
+
trigger: ["consent-mode-v2-enabled"],
|
|
623
|
+
triggerMode: "all",
|
|
624
|
+
triggerOn: "unhealthy",
|
|
625
|
+
match: (facts) => facts.analysis.consentModeV2.causes.includes("region-shadowed-default") ? { claims: [] } : null,
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
/**
|
|
629
|
+
* The tag is gated on a consent signal other than analytics_storage, and
|
|
630
|
+
* it does fire — so it works today, for a reason that is not the one the
|
|
631
|
+
* customer configured.
|
|
632
|
+
*
|
|
633
|
+
* tumodo_io requires `ad_storage`. That holds only because Cookiebot
|
|
634
|
+
* grants advertising and statistics from the same click; a visitor who
|
|
635
|
+
* accepts statistics and declines marketing gets no identified tracking,
|
|
636
|
+
* and a visitor who accepts marketing and declines statistics gets it
|
|
637
|
+
* against their choice. Neither shows up in a run that answers the banner
|
|
638
|
+
* with all-accept or all-reject, which is what every phase here does —
|
|
639
|
+
* so behaviour can never find this and the container is the only witness.
|
|
640
|
+
*
|
|
641
|
+
* `hardening` and claims nothing: nothing in this run failed because of
|
|
642
|
+
* it. Trigger-less for the same reason as the blocking variant above, and
|
|
643
|
+
* mutually exclusive with it on `normalFired`.
|
|
644
|
+
*/
|
|
645
|
+
id: "consent-gate-broad-signal",
|
|
646
|
+
kind: "hardening",
|
|
647
|
+
trigger: [],
|
|
648
|
+
triggerMode: "all",
|
|
649
|
+
match: (facts) => {
|
|
650
|
+
if (tagsGatedOnWrongSignal(facts.analysis.install.tagManagerContainers).length === 0)
|
|
651
|
+
return null;
|
|
652
|
+
return facts.observed.some(normalFired) ? { claims: [] } : null;
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
];
|
|
656
|
+
//# sourceMappingURL=rules.js.map
|