@etalon/core 1.0.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/dist/index.js ADDED
@@ -0,0 +1,3051 @@
1
+ import {
2
+ GDPR_RULE_MAP,
3
+ enrichWithGdpr,
4
+ loadCustomRules,
5
+ scanWithCustomRules
6
+ } from "./chunk-C6WLBWUY.js";
7
+
8
+ // src/vendor-registry.ts
9
+ import { readFileSync } from "fs";
10
+ import { fileURLToPath } from "url";
11
+ import { dirname, join } from "path";
12
+
13
+ // src/domain-utils.ts
14
+ function extractDomain(url) {
15
+ try {
16
+ if (url.startsWith("data:") || url.startsWith("blob:") || url.startsWith("about:")) {
17
+ return null;
18
+ }
19
+ const parsed = new URL(url);
20
+ return parsed.hostname.toLowerCase();
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function getParentDomains(domain) {
26
+ const parts = domain.split(".");
27
+ const parents = [];
28
+ for (let i = 1; i < parts.length - 1; i++) {
29
+ parents.push(parts.slice(i).join("."));
30
+ }
31
+ return parents;
32
+ }
33
+ function isFirstParty(requestDomain, siteDomain) {
34
+ const reqLower = requestDomain.toLowerCase();
35
+ const siteLower = siteDomain.toLowerCase();
36
+ if (reqLower === siteLower) return true;
37
+ if (reqLower.endsWith("." + siteLower)) return true;
38
+ return false;
39
+ }
40
+ function normalizeUrl(url) {
41
+ let normalized = url.trim();
42
+ if (!normalized.match(/^https?:\/\//i)) {
43
+ normalized = "https://" + normalized;
44
+ }
45
+ return normalized;
46
+ }
47
+
48
+ // src/vendor-registry.ts
49
+ var VendorRegistry = class _VendorRegistry {
50
+ vendors = [];
51
+ categories = [];
52
+ domainMap = /* @__PURE__ */ new Map();
53
+ version = "";
54
+ lastUpdated = "";
55
+ /**
56
+ * Load the vendor database from a JSON file.
57
+ * If no path is provided, loads the bundled vendors.json.
58
+ */
59
+ static load(path) {
60
+ const registry = new _VendorRegistry();
61
+ const filePath = path ?? _VendorRegistry.defaultPath();
62
+ const raw = readFileSync(filePath, "utf-8");
63
+ const db = JSON.parse(raw);
64
+ registry.version = db.version;
65
+ registry.lastUpdated = db.last_updated;
66
+ registry.vendors = db.vendors;
67
+ registry.categories = db.categories;
68
+ for (const vendor of db.vendors) {
69
+ for (const domain of vendor.domains) {
70
+ registry.domainMap.set(domain.toLowerCase(), vendor);
71
+ }
72
+ }
73
+ return registry;
74
+ }
75
+ /**
76
+ * Load from an in-memory VendorDatabase object (useful for testing).
77
+ */
78
+ static fromDatabase(db) {
79
+ const registry = new _VendorRegistry();
80
+ registry.version = db.version;
81
+ registry.lastUpdated = db.last_updated;
82
+ registry.vendors = db.vendors;
83
+ registry.categories = db.categories;
84
+ for (const vendor of db.vendors) {
85
+ for (const domain of vendor.domains) {
86
+ registry.domainMap.set(domain.toLowerCase(), vendor);
87
+ }
88
+ }
89
+ return registry;
90
+ }
91
+ /**
92
+ * Look up a vendor by domain.
93
+ * Tries exact match first, then walks up parent domains.
94
+ *
95
+ * @param domainOrUrl - A domain (e.g. "ssl.google-analytics.com") or full URL
96
+ * @returns The matched Vendor, or null if not found
97
+ */
98
+ lookupDomain(domainOrUrl) {
99
+ let domain;
100
+ if (domainOrUrl.includes("://")) {
101
+ const extracted = extractDomain(domainOrUrl);
102
+ if (!extracted) return null;
103
+ domain = extracted;
104
+ } else {
105
+ domain = domainOrUrl.toLowerCase();
106
+ }
107
+ const exact = this.domainMap.get(domain);
108
+ if (exact) return exact;
109
+ const parents = getParentDomains(domain);
110
+ for (const parent of parents) {
111
+ const match = this.domainMap.get(parent);
112
+ if (match) return match;
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * Get all vendors in a specific category.
118
+ */
119
+ getByCategory(category) {
120
+ return this.vendors.filter((v) => v.category === category);
121
+ }
122
+ /**
123
+ * Get all GDPR-compliant vendors.
124
+ */
125
+ getCompliant() {
126
+ return this.vendors.filter((v) => v.gdpr_compliant);
127
+ }
128
+ /**
129
+ * Get all available categories.
130
+ */
131
+ getCategories() {
132
+ return [...this.categories];
133
+ }
134
+ /**
135
+ * Search vendors by name or company (case-insensitive substring match).
136
+ */
137
+ search(query) {
138
+ const q = query.toLowerCase();
139
+ return this.vendors.filter(
140
+ (v) => v.name.toLowerCase().includes(q) || v.company.toLowerCase().includes(q) || v.id.toLowerCase().includes(q)
141
+ );
142
+ }
143
+ /**
144
+ * Get all vendors.
145
+ */
146
+ getAllVendors() {
147
+ return [...this.vendors];
148
+ }
149
+ /**
150
+ * Get a vendor by its ID.
151
+ */
152
+ getById(id) {
153
+ return this.vendors.find((v) => v.id === id) ?? null;
154
+ }
155
+ /**
156
+ * Get registry metadata.
157
+ */
158
+ getMetadata() {
159
+ return {
160
+ version: this.version,
161
+ lastUpdated: this.lastUpdated,
162
+ vendorCount: this.vendors.length,
163
+ categoryCount: this.categories.length,
164
+ domainCount: this.domainMap.size
165
+ };
166
+ }
167
+ /**
168
+ * Default path to the bundled vendors.json file.
169
+ */
170
+ static defaultPath() {
171
+ const currentDir = dirname(fileURLToPath(import.meta.url));
172
+ return join(currentDir, "..", "..", "..", "data", "vendors.json");
173
+ }
174
+ };
175
+
176
+ // src/audit/index.ts
177
+ import { readFileSync as readFileSync10, existsSync as existsSync5 } from "fs";
178
+ import { join as join5, dirname as dirname2 } from "path";
179
+ import { fileURLToPath as fileURLToPath2 } from "url";
180
+
181
+ // src/audit/stack-detector.ts
182
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
183
+ import { join as join2 } from "path";
184
+ function detectStack(directory) {
185
+ const languages = [];
186
+ let framework = "none";
187
+ let orm = "none";
188
+ let packageManager = "unknown";
189
+ const detectedFiles = [];
190
+ const packageJsonPath = join2(directory, "package.json");
191
+ if (existsSync(packageJsonPath)) {
192
+ languages.push("javascript");
193
+ detectedFiles.push("package.json");
194
+ if (existsSync(join2(directory, "pnpm-lock.yaml"))) {
195
+ packageManager = "pnpm";
196
+ } else if (existsSync(join2(directory, "yarn.lock"))) {
197
+ packageManager = "yarn";
198
+ } else {
199
+ packageManager = "npm";
200
+ }
201
+ if (existsSync(join2(directory, "tsconfig.json")) || existsSync(join2(directory, "tsconfig.base.json"))) {
202
+ if (!languages.includes("typescript")) languages.push("typescript");
203
+ detectedFiles.push("tsconfig.json");
204
+ }
205
+ try {
206
+ const pkg = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
207
+ const allDeps = {
208
+ ...pkg.dependencies,
209
+ ...pkg.devDependencies
210
+ };
211
+ if (allDeps["next"]) {
212
+ framework = "nextjs";
213
+ detectedFiles.push("next.config.*");
214
+ } else if (allDeps["nuxt"] || allDeps["nuxt3"]) {
215
+ framework = "nuxt";
216
+ } else if (allDeps["@sveltejs/kit"] || allDeps["svelte"]) {
217
+ framework = "svelte";
218
+ } else if (allDeps["fastify"]) {
219
+ framework = "fastify";
220
+ } else if (allDeps["express"]) {
221
+ framework = "express";
222
+ }
223
+ if (allDeps["prisma"] || allDeps["@prisma/client"]) {
224
+ orm = "prisma";
225
+ } else if (allDeps["typeorm"]) {
226
+ orm = "typeorm";
227
+ } else if (allDeps["drizzle-orm"]) {
228
+ orm = "drizzle";
229
+ } else if (allDeps["sequelize"]) {
230
+ orm = "sequelize";
231
+ }
232
+ } catch {
233
+ }
234
+ }
235
+ const pyprojectPath = join2(directory, "pyproject.toml");
236
+ const requirementsPath = join2(directory, "requirements.txt");
237
+ const pipfilePath = join2(directory, "Pipfile");
238
+ if (existsSync(pyprojectPath) || existsSync(requirementsPath) || existsSync(pipfilePath)) {
239
+ if (!languages.includes("python")) languages.push("python");
240
+ if (existsSync(pyprojectPath)) {
241
+ detectedFiles.push("pyproject.toml");
242
+ packageManager = "poetry";
243
+ } else if (existsSync(requirementsPath)) {
244
+ detectedFiles.push("requirements.txt");
245
+ packageManager = "pip";
246
+ }
247
+ const pyDeps = readPythonDeps(directory);
248
+ if (pyDeps.has("django")) {
249
+ framework = "django";
250
+ orm = "django-orm";
251
+ } else if (pyDeps.has("fastapi")) {
252
+ framework = "fastapi";
253
+ } else if (pyDeps.has("flask")) {
254
+ framework = "flask";
255
+ }
256
+ if (pyDeps.has("sqlalchemy") || pyDeps.has("flask-sqlalchemy")) {
257
+ orm = "sqlalchemy";
258
+ }
259
+ if (existsSync(join2(directory, "manage.py"))) {
260
+ framework = "django";
261
+ orm = "django-orm";
262
+ detectedFiles.push("manage.py");
263
+ }
264
+ }
265
+ const cargoPath = join2(directory, "Cargo.toml");
266
+ if (existsSync(cargoPath)) {
267
+ if (!languages.includes("rust")) languages.push("rust");
268
+ detectedFiles.push("Cargo.toml");
269
+ packageManager = "cargo";
270
+ try {
271
+ const cargoContent = readFileSync2(cargoPath, "utf-8");
272
+ if (cargoContent.includes("actix-web")) {
273
+ framework = "actix";
274
+ } else if (cargoContent.includes("axum")) {
275
+ framework = "axum";
276
+ } else if (cargoContent.includes("rocket")) {
277
+ framework = "rocket";
278
+ }
279
+ if (cargoContent.includes("diesel")) {
280
+ orm = "diesel";
281
+ } else if (cargoContent.includes("sea-orm")) {
282
+ orm = "sea-orm";
283
+ }
284
+ } catch {
285
+ }
286
+ }
287
+ if (languages.length === 0) {
288
+ languages.push("unknown");
289
+ }
290
+ return {
291
+ languages,
292
+ framework,
293
+ orm,
294
+ packageManager,
295
+ detectedFiles
296
+ };
297
+ }
298
+ function readPythonDeps(directory) {
299
+ const deps = /* @__PURE__ */ new Set();
300
+ const reqPath = join2(directory, "requirements.txt");
301
+ if (existsSync(reqPath)) {
302
+ const content = readFileSync2(reqPath, "utf-8");
303
+ for (const line of content.split("\n")) {
304
+ const trimmed = line.trim();
305
+ if (trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-")) {
306
+ const name = trimmed.split(/[=><~![\s]/)[0].toLowerCase();
307
+ if (name) deps.add(name);
308
+ }
309
+ }
310
+ }
311
+ const pyprojectPath = join2(directory, "pyproject.toml");
312
+ if (existsSync(pyprojectPath)) {
313
+ const content = readFileSync2(pyprojectPath, "utf-8");
314
+ const matches = content.matchAll(/["']([a-zA-Z0-9_-]+)["']/g);
315
+ for (const match of matches) {
316
+ deps.add(match[1].toLowerCase());
317
+ }
318
+ }
319
+ return deps;
320
+ }
321
+
322
+ // src/audit/code-scanner.ts
323
+ import { readFileSync as readFileSync3 } from "fs";
324
+ import { basename, relative, extname } from "path";
325
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
326
+ ".js",
327
+ ".jsx",
328
+ ".ts",
329
+ ".tsx",
330
+ ".mjs",
331
+ ".cjs",
332
+ ".py",
333
+ ".pyw",
334
+ ".rs",
335
+ ".html",
336
+ ".htm",
337
+ ".ejs",
338
+ ".hbs",
339
+ ".pug",
340
+ ".jinja",
341
+ ".jinja2",
342
+ ".j2",
343
+ ".vue",
344
+ ".svelte",
345
+ ".astro",
346
+ ".env",
347
+ ".env.local",
348
+ ".env.production",
349
+ ".env.development"
350
+ ]);
351
+ var IGNORE_DIRS = /* @__PURE__ */ new Set([
352
+ "node_modules",
353
+ ".next",
354
+ "__pycache__",
355
+ ".git",
356
+ "dist",
357
+ "build",
358
+ "target",
359
+ ".venv",
360
+ "venv",
361
+ "env",
362
+ ".tox",
363
+ ".mypy_cache",
364
+ "vendor",
365
+ ".cargo",
366
+ "coverage",
367
+ ".turbo"
368
+ ]);
369
+ function scanCode(files, baseDir, stack, patterns) {
370
+ const findings = [];
371
+ findings.push(...scanPackageManifest(baseDir, stack, patterns));
372
+ for (const filePath of files) {
373
+ if (!shouldScanFile(filePath)) continue;
374
+ try {
375
+ const content = readFileSync3(filePath, "utf-8");
376
+ const relPath = relative(baseDir, filePath);
377
+ const ext = extname(filePath).toLowerCase();
378
+ if (basename(filePath).startsWith(".env")) {
379
+ findings.push(...scanEnvFile(content, relPath, patterns));
380
+ continue;
381
+ }
382
+ if ([".html", ".htm", ".ejs", ".hbs", ".pug", ".jinja", ".jinja2", ".j2"].includes(ext)) {
383
+ findings.push(...scanHtmlFile(content, relPath, patterns));
384
+ }
385
+ if ([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro"].includes(ext)) {
386
+ findings.push(...scanJsFile(content, relPath, patterns));
387
+ findings.push(...scanCookieUsage(content, relPath));
388
+ findings.push(...scanLocalStorage(content, relPath));
389
+ }
390
+ if ([".py", ".pyw"].includes(ext)) {
391
+ findings.push(...scanPythonFile(content, relPath, patterns));
392
+ }
393
+ if (ext === ".rs") {
394
+ findings.push(...scanRustFile(content, relPath, patterns));
395
+ }
396
+ findings.push(...scanForHtmlPatterns(content, relPath, patterns));
397
+ } catch {
398
+ }
399
+ }
400
+ return deduplicateFindings(findings);
401
+ }
402
+ function shouldScanFile(filePath) {
403
+ const ext = extname(filePath).toLowerCase();
404
+ const name = basename(filePath);
405
+ if (name.startsWith(".env")) return true;
406
+ if (!CODE_EXTENSIONS.has(ext)) return false;
407
+ const parts = filePath.split("/");
408
+ for (const part of parts) {
409
+ if (IGNORE_DIRS.has(part)) return false;
410
+ }
411
+ return true;
412
+ }
413
+ function scanPackageManifest(baseDir, stack, patterns) {
414
+ const findings = [];
415
+ if (stack.languages.includes("javascript") || stack.languages.includes("typescript")) {
416
+ try {
417
+ const pkg = JSON.parse(readFileSync3(`${baseDir}/package.json`, "utf-8"));
418
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
419
+ for (const dep of Object.keys(allDeps)) {
420
+ const match = patterns.npm[dep];
421
+ if (match) {
422
+ findings.push({
423
+ id: `code-tracker-dep-${dep}`,
424
+ category: "code",
425
+ severity: match.severity,
426
+ title: `Tracker SDK dependency: ${dep}`,
427
+ message: `Package "${dep}" is a known tracker SDK (vendor: ${match.vendorId}). Ensure it is loaded behind a consent mechanism.`,
428
+ file: "package.json",
429
+ vendorId: match.vendorId,
430
+ rule: "tracker-dependency"
431
+ });
432
+ }
433
+ }
434
+ } catch {
435
+ }
436
+ }
437
+ if (stack.languages.includes("python")) {
438
+ for (const reqFile of ["requirements.txt", "pyproject.toml"]) {
439
+ try {
440
+ const content = readFileSync3(`${baseDir}/${reqFile}`, "utf-8");
441
+ for (const [pkg, match] of Object.entries(patterns.pypi)) {
442
+ if (content.toLowerCase().includes(pkg.toLowerCase())) {
443
+ findings.push({
444
+ id: `code-tracker-dep-${pkg}`,
445
+ category: "code",
446
+ severity: match.severity,
447
+ title: `Tracker SDK dependency: ${pkg}`,
448
+ message: `Python package "${pkg}" is a known tracker SDK (vendor: ${match.vendorId}). Ensure it has proper consent handling.`,
449
+ file: reqFile,
450
+ vendorId: match.vendorId,
451
+ rule: "tracker-dependency"
452
+ });
453
+ }
454
+ }
455
+ } catch {
456
+ }
457
+ }
458
+ }
459
+ if (stack.languages.includes("rust")) {
460
+ try {
461
+ const content = readFileSync3(`${baseDir}/Cargo.toml`, "utf-8");
462
+ for (const [crate, match] of Object.entries(patterns.cargo)) {
463
+ if (content.includes(crate)) {
464
+ findings.push({
465
+ id: `code-tracker-dep-${crate}`,
466
+ category: "code",
467
+ severity: match.severity,
468
+ title: `Tracker SDK dependency: ${crate}`,
469
+ message: `Cargo crate "${crate}" is a known tracker SDK (vendor: ${match.vendorId}).`,
470
+ file: "Cargo.toml",
471
+ vendorId: match.vendorId,
472
+ rule: "tracker-dependency"
473
+ });
474
+ }
475
+ }
476
+ } catch {
477
+ }
478
+ }
479
+ return findings;
480
+ }
481
+ function scanEnvFile(content, filePath, patterns) {
482
+ const findings = [];
483
+ const lines = content.split("\n");
484
+ for (let i = 0; i < lines.length; i++) {
485
+ const line = lines[i].trim();
486
+ if (!line || line.startsWith("#")) continue;
487
+ const eqIndex = line.indexOf("=");
488
+ if (eqIndex === -1) continue;
489
+ const key = line.slice(0, eqIndex).trim();
490
+ const match = patterns.envVars[key];
491
+ if (match) {
492
+ findings.push({
493
+ id: `code-env-tracker-${key}`,
494
+ category: "code",
495
+ severity: match.severity,
496
+ title: `Tracker env variable: ${key}`,
497
+ message: `Environment variable "${key}" configures ${match.vendorId}. Document this third-party in your privacy policy.`,
498
+ file: filePath,
499
+ line: i + 1,
500
+ vendorId: match.vendorId,
501
+ rule: "tracker-env-var"
502
+ });
503
+ }
504
+ }
505
+ return findings;
506
+ }
507
+ function scanHtmlFile(content, filePath, patterns) {
508
+ const findings = [];
509
+ const lines = content.split("\n");
510
+ for (let i = 0; i < lines.length; i++) {
511
+ for (const hp of patterns.htmlPatterns) {
512
+ if (lines[i].includes(hp.pattern)) {
513
+ findings.push({
514
+ id: `code-html-tracker-${hp.vendorId}-${filePath}`,
515
+ category: "code",
516
+ severity: hp.severity,
517
+ title: `Hardcoded tracking pixel: ${hp.vendorId}`,
518
+ message: `Found "${hp.pattern}" loaded directly in HTML. This should be gated behind user consent.`,
519
+ file: filePath,
520
+ line: i + 1,
521
+ vendorId: hp.vendorId,
522
+ rule: "hardcoded-tracker"
523
+ });
524
+ }
525
+ }
526
+ }
527
+ return findings;
528
+ }
529
+ function scanForHtmlPatterns(content, filePath, patterns) {
530
+ const findings = [];
531
+ const lines = content.split("\n");
532
+ for (let i = 0; i < lines.length; i++) {
533
+ for (const hp of patterns.htmlPatterns) {
534
+ if (lines[i].includes(hp.pattern)) {
535
+ findings.push({
536
+ id: `code-inline-tracker-${hp.vendorId}-${filePath}-${i}`,
537
+ category: "code",
538
+ severity: hp.severity,
539
+ title: `Inline tracking script: ${hp.vendorId}`,
540
+ message: `Found "${hp.pattern}" in source. Ensure this is loaded conditionally behind consent.`,
541
+ file: filePath,
542
+ line: i + 1,
543
+ vendorId: hp.vendorId,
544
+ rule: "inline-tracker"
545
+ });
546
+ }
547
+ }
548
+ }
549
+ return findings;
550
+ }
551
+ function scanJsFile(content, filePath, patterns) {
552
+ const findings = [];
553
+ const lines = content.split("\n");
554
+ for (let i = 0; i < lines.length; i++) {
555
+ const line = lines[i];
556
+ const importMatch = line.match(/(?:from\s+['"]|require\s*\(\s*['"])([^'"]+)['"]/);
557
+ if (importMatch) {
558
+ const pkg = importMatch[1];
559
+ const match = patterns.npm[pkg];
560
+ if (match) {
561
+ findings.push({
562
+ id: `code-import-${match.vendorId}-${filePath}-${i}`,
563
+ category: "code",
564
+ severity: match.severity,
565
+ title: `Tracker SDK import: ${pkg}`,
566
+ message: `Importing "${pkg}" (${match.vendorId}). Ensure this is initialized only after user consent.`,
567
+ file: filePath,
568
+ line: i + 1,
569
+ vendorId: match.vendorId,
570
+ rule: "tracker-import"
571
+ });
572
+ }
573
+ }
574
+ for (const ip of patterns.importPatterns) {
575
+ if (ip.language === "javascript" && line.includes(ip.pattern)) {
576
+ findings.push({
577
+ id: `code-pattern-${ip.vendorId}-${filePath}-${i}`,
578
+ category: "code",
579
+ severity: ip.severity,
580
+ title: `Tracker API usage: ${ip.vendorId}`,
581
+ message: `Found "${ip.pattern}" usage. Ensure proper consent before tracking.`,
582
+ file: filePath,
583
+ line: i + 1,
584
+ vendorId: ip.vendorId,
585
+ rule: "tracker-api-call"
586
+ });
587
+ }
588
+ }
589
+ const urlMatch = line.match(/(?:fetch|axios\.\w+|http\.get|http\.post)\s*\(\s*['"`]([^'"`]+)['"`]/);
590
+ if (urlMatch) {
591
+ const url = urlMatch[1];
592
+ for (const hp of patterns.htmlPatterns) {
593
+ if (url.includes(hp.pattern)) {
594
+ findings.push({
595
+ id: `code-fetch-${hp.vendorId}-${filePath}-${i}`,
596
+ category: "code",
597
+ severity: hp.severity,
598
+ title: `HTTP call to tracker: ${hp.vendorId}`,
599
+ message: `Direct HTTP request to "${url}" detected.`,
600
+ file: filePath,
601
+ line: i + 1,
602
+ vendorId: hp.vendorId,
603
+ rule: "tracker-http-call"
604
+ });
605
+ }
606
+ }
607
+ }
608
+ }
609
+ return findings;
610
+ }
611
+ function scanCookieUsage(content, filePath) {
612
+ const findings = [];
613
+ const lines = content.split("\n");
614
+ for (let i = 0; i < lines.length; i++) {
615
+ const line = lines[i];
616
+ if (line.includes("document.cookie") && line.includes("=") && !line.includes("===") && !line.includes("!==")) {
617
+ const contextStart = Math.max(0, i - 10);
618
+ const context = lines.slice(contextStart, i).join("\n").toLowerCase();
619
+ const hasConsentGuard = context.includes("consent") || context.includes("cookie_accepted") || context.includes("gdpr");
620
+ if (!hasConsentGuard) {
621
+ findings.push({
622
+ id: `code-cookie-no-consent-${filePath}-${i}`,
623
+ category: "code",
624
+ severity: "high",
625
+ title: "Cookie write without consent check",
626
+ message: "Writing to document.cookie without an apparent consent guard. GDPR requires user consent before setting non-essential cookies.",
627
+ file: filePath,
628
+ line: i + 1,
629
+ rule: "cookie-no-consent",
630
+ fix: 'Gate cookie writes behind a consent check, e.g. `if (hasConsent("analytics")) { document.cookie = ... }`'
631
+ });
632
+ }
633
+ }
634
+ if (line.includes("res.cookie(") || line.includes("response.set_cookie(")) {
635
+ if (!content.includes("httpOnly") && !content.includes("HttpOnly") && !content.includes("httponly")) {
636
+ findings.push({
637
+ id: `code-cookie-insecure-${filePath}-${i}`,
638
+ category: "code",
639
+ severity: "medium",
640
+ title: "Cookie set without HttpOnly flag",
641
+ message: "Setting a cookie without explicit HttpOnly flag. Consider adding httpOnly: true for security.",
642
+ file: filePath,
643
+ line: i + 1,
644
+ rule: "cookie-insecure"
645
+ });
646
+ }
647
+ }
648
+ }
649
+ return findings;
650
+ }
651
+ function scanLocalStorage(content, filePath) {
652
+ const findings = [];
653
+ const lines = content.split("\n");
654
+ const piiKeys = ["email", "phone", "name", "address", "user", "token", "password", "ssn", "dob", "birth"];
655
+ for (let i = 0; i < lines.length; i++) {
656
+ const line = lines[i].toLowerCase();
657
+ if (line.includes("localstorage.setitem") || line.includes("sessionstorage.setitem")) {
658
+ const keyMatch = lines[i].match(/(?:localStorage|sessionStorage)\.setItem\s*\(\s*['"]([^'"]+)['"]/i);
659
+ if (keyMatch) {
660
+ const key = keyMatch[1].toLowerCase();
661
+ for (const piiKey of piiKeys) {
662
+ if (key.includes(piiKey)) {
663
+ findings.push({
664
+ id: `code-storage-pii-${filePath}-${i}`,
665
+ category: "code",
666
+ severity: "medium",
667
+ title: `PII stored in browser storage: "${keyMatch[1]}"`,
668
+ message: `Storing potentially sensitive data ("${keyMatch[1]}") in browser storage. Consider using HttpOnly cookies or server-side sessions instead.`,
669
+ file: filePath,
670
+ line: i + 1,
671
+ rule: "storage-pii"
672
+ });
673
+ break;
674
+ }
675
+ }
676
+ }
677
+ }
678
+ }
679
+ return findings;
680
+ }
681
+ function scanPythonFile(content, filePath, patterns) {
682
+ const findings = [];
683
+ const lines = content.split("\n");
684
+ for (let i = 0; i < lines.length; i++) {
685
+ const line = lines[i];
686
+ const importMatch = line.match(/(?:from\s+(\S+)\s+import|^import\s+(\S+))/);
687
+ if (importMatch) {
688
+ const pkg = (importMatch[1] || importMatch[2]).split(".")[0].toLowerCase();
689
+ for (const [pyPkg, match] of Object.entries(patterns.pypi)) {
690
+ if (pkg === pyPkg.toLowerCase().replace(/-/g, "_") || pkg === pyPkg.toLowerCase().replace(/-/g, "")) {
691
+ findings.push({
692
+ id: `code-python-import-${match.vendorId}-${filePath}-${i}`,
693
+ category: "code",
694
+ severity: match.severity,
695
+ title: `Tracker SDK import: ${pyPkg}`,
696
+ message: `Python import of "${pkg}" (${match.vendorId}). Ensure proper consent handling.`,
697
+ file: filePath,
698
+ line: i + 1,
699
+ vendorId: match.vendorId,
700
+ rule: "tracker-import"
701
+ });
702
+ }
703
+ }
704
+ }
705
+ for (const ip of patterns.importPatterns) {
706
+ if (ip.language === "python" && line.includes(ip.pattern)) {
707
+ findings.push({
708
+ id: `code-python-pattern-${ip.vendorId}-${filePath}-${i}`,
709
+ category: "code",
710
+ severity: ip.severity,
711
+ title: `Tracker usage: ${ip.vendorId}`,
712
+ message: `Found "${ip.pattern}" in Python source.`,
713
+ file: filePath,
714
+ line: i + 1,
715
+ vendorId: ip.vendorId,
716
+ rule: "tracker-api-call"
717
+ });
718
+ }
719
+ }
720
+ if (line.includes("'analytical'") || line.includes("'django_analytical'")) {
721
+ findings.push({
722
+ id: `code-django-analytical-${filePath}-${i}`,
723
+ category: "code",
724
+ severity: "medium",
725
+ title: "Django Analytical middleware",
726
+ message: `django-analytical is installed. Review which analytics services are configured and ensure consent is collected.`,
727
+ file: filePath,
728
+ line: i + 1,
729
+ rule: "tracker-middleware"
730
+ });
731
+ }
732
+ }
733
+ return findings;
734
+ }
735
+ function scanRustFile(content, filePath, patterns) {
736
+ const findings = [];
737
+ const lines = content.split("\n");
738
+ for (let i = 0; i < lines.length; i++) {
739
+ const line = lines[i];
740
+ const useMatch = line.match(/^\s*use\s+(\w+)/);
741
+ if (useMatch) {
742
+ const crate = useMatch[1].toLowerCase();
743
+ for (const [cargo, match] of Object.entries(patterns.cargo)) {
744
+ if (crate === cargo.toLowerCase().replace(/-/g, "_")) {
745
+ findings.push({
746
+ id: `code-rust-use-${match.vendorId}-${filePath}-${i}`,
747
+ category: "code",
748
+ severity: match.severity,
749
+ title: `Tracker crate usage: ${cargo}`,
750
+ message: `Using crate "${cargo}" (${match.vendorId}).`,
751
+ file: filePath,
752
+ line: i + 1,
753
+ vendorId: match.vendorId,
754
+ rule: "tracker-import"
755
+ });
756
+ }
757
+ }
758
+ }
759
+ for (const ip of patterns.importPatterns) {
760
+ if (ip.language === "rust" && line.includes(ip.pattern)) {
761
+ findings.push({
762
+ id: `code-rust-pattern-${ip.vendorId}-${filePath}-${i}`,
763
+ category: "code",
764
+ severity: ip.severity,
765
+ title: `Tracker usage: ${ip.vendorId}`,
766
+ message: `Found "${ip.pattern}" in Rust source.`,
767
+ file: filePath,
768
+ line: i + 1,
769
+ vendorId: ip.vendorId,
770
+ rule: "tracker-api-call"
771
+ });
772
+ }
773
+ }
774
+ }
775
+ return findings;
776
+ }
777
+ function deduplicateFindings(findings) {
778
+ const seen = /* @__PURE__ */ new Set();
779
+ return findings.filter((f) => {
780
+ const key = `${f.rule}:${f.vendorId}:${f.file}:${f.line ?? ""}`;
781
+ if (seen.has(key)) return false;
782
+ seen.add(key);
783
+ return true;
784
+ });
785
+ }
786
+
787
+ // src/audit/schema-scanner.ts
788
+ import { readFileSync as readFileSync4 } from "fs";
789
+ import { relative as relative2 } from "path";
790
+ var PII_PATTERNS = [
791
+ // Direct identifiers
792
+ { pattern: "email", piiType: "email address", severity: "high", recommendation: "Hash or encrypt email addresses at rest" },
793
+ { pattern: "e_mail", piiType: "email address", severity: "high", recommendation: "Hash or encrypt email addresses at rest" },
794
+ { pattern: "phone", piiType: "phone number", severity: "high", recommendation: "Encrypt phone numbers and limit access" },
795
+ { pattern: "telephone", piiType: "phone number", severity: "high", recommendation: "Encrypt phone numbers and limit access" },
796
+ { pattern: "mobile", piiType: "phone number", severity: "high", recommendation: "Encrypt phone numbers and limit access" },
797
+ { pattern: "ssn", piiType: "social security number", severity: "critical", recommendation: "Never store SSN in plaintext \u2014 hash or use tokenization" },
798
+ { pattern: "social_security", piiType: "social security number", severity: "critical", recommendation: "Never store SSN in plaintext \u2014 hash or use tokenization" },
799
+ { pattern: "national_id", piiType: "national ID", severity: "critical", recommendation: "Encrypt national ID numbers" },
800
+ { pattern: "passport", piiType: "passport number", severity: "critical", recommendation: "Encrypt passport numbers" },
801
+ { pattern: "drivers_license", piiType: "drivers license", severity: "critical", recommendation: "Encrypt license numbers" },
802
+ { pattern: "tax_id", piiType: "tax ID", severity: "critical", recommendation: "Encrypt tax identification numbers" },
803
+ // Names
804
+ { pattern: "first_name", piiType: "personal name", severity: "medium", recommendation: "Consider whether full names need to be stored" },
805
+ { pattern: "last_name", piiType: "personal name", severity: "medium", recommendation: "Consider whether full names need to be stored" },
806
+ { pattern: "full_name", piiType: "personal name", severity: "medium", recommendation: "Consider whether full names need to be stored" },
807
+ { pattern: "display_name", piiType: "personal name", severity: "low", recommendation: "Display names are PII \u2014 document in privacy policy" },
808
+ // Location
809
+ { pattern: "address", piiType: "physical address", severity: "high", recommendation: "Encrypt addresses and limit retention" },
810
+ { pattern: "street", piiType: "physical address", severity: "high", recommendation: "Encrypt addresses" },
811
+ { pattern: "zip_code", piiType: "location data", severity: "medium", recommendation: "Truncate zip codes for analytics (first 3 digits)" },
812
+ { pattern: "postal_code", piiType: "location data", severity: "medium", recommendation: "Truncate postal codes" },
813
+ { pattern: "latitude", piiType: "geolocation", severity: "high", recommendation: "Reduce precision for analytics, encrypt for storage" },
814
+ { pattern: "longitude", piiType: "geolocation", severity: "high", recommendation: "Reduce precision for analytics" },
815
+ { pattern: "geolocation", piiType: "geolocation", severity: "high", recommendation: "Reduce precision and encrypt" },
816
+ // Network identifiers
817
+ { pattern: "ip_address", piiType: "IP address", severity: "high", recommendation: "Anonymize IP addresses (zero last octet) or hash them" },
818
+ { pattern: "ip_addr", piiType: "IP address", severity: "high", recommendation: "Anonymize IP addresses" },
819
+ { pattern: "client_ip", piiType: "IP address", severity: "high", recommendation: "Anonymize IP addresses" },
820
+ { pattern: "remote_addr", piiType: "IP address", severity: "high", recommendation: "Anonymize IP addresses" },
821
+ { pattern: "user_agent", piiType: "browser fingerprint", severity: "medium", recommendation: "Consider truncating user agent strings" },
822
+ { pattern: "device_id", piiType: "device identifier", severity: "high", recommendation: "Hash device IDs" },
823
+ { pattern: "fingerprint", piiType: "browser fingerprint", severity: "high", recommendation: "Avoid storing browser fingerprints" },
824
+ { pattern: "mac_address", piiType: "hardware identifier", severity: "high", recommendation: "Hash MAC addresses" },
825
+ // Date of birth / age
826
+ { pattern: "date_of_birth", piiType: "date of birth", severity: "high", recommendation: "Store only age or year of birth if possible" },
827
+ { pattern: "dob", piiType: "date of birth", severity: "high", recommendation: "Store only age or year of birth if possible" },
828
+ { pattern: "birth_date", piiType: "date of birth", severity: "high", recommendation: "Store only age or year of birth" },
829
+ // Financial
830
+ { pattern: "credit_card", piiType: "payment card", severity: "critical", recommendation: "Never store credit card numbers \u2014 use tokenization (Stripe, etc.)" },
831
+ { pattern: "card_number", piiType: "payment card", severity: "critical", recommendation: "Never store card numbers \u2014 use a payment processor" },
832
+ { pattern: "bank_account", piiType: "bank account", severity: "critical", recommendation: "Encrypt bank details, use payment processor tokenization" },
833
+ { pattern: "iban", piiType: "bank identifier", severity: "critical", recommendation: "Encrypt IBAN numbers" },
834
+ // Passwords / secrets
835
+ { pattern: "password", piiType: "credential", severity: "critical", recommendation: "Passwords must be hashed (bcrypt/argon2), never stored in plaintext" },
836
+ { pattern: "secret", piiType: "credential", severity: "high", recommendation: "Secrets should be hashed or encrypted" }
837
+ ];
838
+ function scanSchemas(files, baseDir, stack) {
839
+ const findings = [];
840
+ for (const filePath of files) {
841
+ try {
842
+ const content = readFileSync4(filePath, "utf-8");
843
+ const relPath = relative2(baseDir, filePath);
844
+ if (filePath.endsWith(".prisma")) {
845
+ findings.push(...scanPrismaSchema(content, relPath));
846
+ continue;
847
+ }
848
+ if (filePath.endsWith(".sql")) {
849
+ findings.push(...scanSqlFile(content, relPath));
850
+ continue;
851
+ }
852
+ if (filePath.endsWith("models.py")) {
853
+ findings.push(...scanDjangoModels(content, relPath));
854
+ continue;
855
+ }
856
+ if (stack.orm === "sqlalchemy" && filePath.endsWith(".py")) {
857
+ if (content.includes("Column(") || content.includes("mapped_column(")) {
858
+ findings.push(...scanSqlAlchemyModels(content, relPath));
859
+ }
860
+ continue;
861
+ }
862
+ if (stack.orm === "typeorm" && (filePath.endsWith(".ts") || filePath.endsWith(".js"))) {
863
+ if (content.includes("@Entity(") || content.includes("@Column(")) {
864
+ findings.push(...scanTypeOrmEntities(content, relPath));
865
+ }
866
+ continue;
867
+ }
868
+ if (filePath.endsWith(".rs") && content.includes("table!")) {
869
+ findings.push(...scanDieselSchema(content, relPath));
870
+ continue;
871
+ }
872
+ } catch {
873
+ }
874
+ }
875
+ return findings;
876
+ }
877
+ function scanPrismaSchema(content, filePath) {
878
+ const findings = [];
879
+ const lines = content.split("\n");
880
+ let currentModel = "";
881
+ let modelHasTimestamp = false;
882
+ let modelHasPii = false;
883
+ let modelStartLine = 0;
884
+ for (let i = 0; i < lines.length; i++) {
885
+ const line = lines[i].trim();
886
+ const modelMatch = line.match(/^model\s+(\w+)\s*\{/);
887
+ if (modelMatch) {
888
+ if (currentModel && modelHasPii && !modelHasTimestamp) {
889
+ findings.push(createRetentionFinding(filePath, modelStartLine, currentModel));
890
+ }
891
+ currentModel = modelMatch[1];
892
+ modelStartLine = i + 1;
893
+ modelHasTimestamp = false;
894
+ modelHasPii = false;
895
+ continue;
896
+ }
897
+ if (currentModel && line === "}") {
898
+ if (modelHasPii && !modelHasTimestamp) {
899
+ findings.push(createRetentionFinding(filePath, modelStartLine, currentModel));
900
+ }
901
+ currentModel = "";
902
+ continue;
903
+ }
904
+ if (!currentModel) continue;
905
+ const fieldMatch = line.match(/^\s*(\w+)\s+/);
906
+ if (fieldMatch) {
907
+ const fieldName = fieldMatch[1].toLowerCase();
908
+ checkPiiField(fieldName, filePath, i + 1, currentModel, findings);
909
+ if (isPiiField(fieldName)) modelHasPii = true;
910
+ }
911
+ if (line.includes("deletedAt") || line.includes("deleted_at") || line.includes("expiresAt") || line.includes("expires_at") || line.includes("archivedAt")) {
912
+ modelHasTimestamp = true;
913
+ }
914
+ }
915
+ return findings;
916
+ }
917
+ function scanSqlFile(content, filePath) {
918
+ const findings = [];
919
+ const lines = content.split("\n");
920
+ let currentTable = "";
921
+ for (let i = 0; i < lines.length; i++) {
922
+ const line = lines[i];
923
+ const tableMatch = line.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)["`]?/i);
924
+ if (tableMatch) {
925
+ currentTable = tableMatch[1];
926
+ continue;
927
+ }
928
+ if (currentTable) {
929
+ const colMatch = line.match(/^\s*["`]?(\w+)["`]?\s+(VARCHAR|TEXT|CHAR|INTEGER|INT|BIGINT|INET|CIDR|UUID|JSONB?|TIMESTAMP)/i);
930
+ if (colMatch) {
931
+ const colName = colMatch[1].toLowerCase();
932
+ checkPiiField(colName, filePath, i + 1, currentTable, findings);
933
+ }
934
+ }
935
+ if (line.includes(");")) {
936
+ currentTable = "";
937
+ }
938
+ }
939
+ return findings;
940
+ }
941
+ function scanDjangoModels(content, filePath) {
942
+ const findings = [];
943
+ const lines = content.split("\n");
944
+ let currentModel = "";
945
+ const djangoPiiFields = {
946
+ "EmailField": { piiType: "email address", severity: "high" },
947
+ "GenericIPAddressField": { piiType: "IP address", severity: "high" },
948
+ "IPAddressField": { piiType: "IP address", severity: "high" }
949
+ };
950
+ for (let i = 0; i < lines.length; i++) {
951
+ const line = lines[i];
952
+ const classMatch = line.match(/^class\s+(\w+)\s*\(.*Model.*\)/);
953
+ if (classMatch) {
954
+ currentModel = classMatch[1];
955
+ continue;
956
+ }
957
+ if (!currentModel) continue;
958
+ for (const [fieldType, pii] of Object.entries(djangoPiiFields)) {
959
+ if (line.includes(fieldType)) {
960
+ findings.push({
961
+ id: `schema-django-pii-type-${filePath}-${i}`,
962
+ category: "schema",
963
+ severity: pii.severity,
964
+ title: `PII field type: ${fieldType} in ${currentModel}`,
965
+ message: `Django ${fieldType} stores ${pii.piiType}. Ensure GDPR compliance measures are in place.`,
966
+ file: filePath,
967
+ line: i + 1,
968
+ rule: "pii-field-type"
969
+ });
970
+ }
971
+ }
972
+ const fieldNameMatch = line.match(/^\s+(\w+)\s*=\s*models\.\w+Field/);
973
+ if (fieldNameMatch) {
974
+ const fieldName = fieldNameMatch[1].toLowerCase();
975
+ checkPiiField(fieldName, filePath, i + 1, currentModel, findings);
976
+ }
977
+ if (/^class\s/.test(line) && !line.includes("Model")) {
978
+ currentModel = "";
979
+ }
980
+ }
981
+ return findings;
982
+ }
983
+ function scanSqlAlchemyModels(content, filePath) {
984
+ const findings = [];
985
+ const lines = content.split("\n");
986
+ let currentModel = "";
987
+ for (let i = 0; i < lines.length; i++) {
988
+ const line = lines[i];
989
+ const classMatch = line.match(/^class\s+(\w+)\s*\(/);
990
+ if (classMatch) {
991
+ currentModel = classMatch[1];
992
+ continue;
993
+ }
994
+ if (!currentModel) continue;
995
+ const colMatch = line.match(/(?:Column|mapped_column)\s*\(\s*['"]?(\w+)['"]?/);
996
+ if (colMatch) {
997
+ const colName = colMatch[1].toLowerCase();
998
+ checkPiiField(colName, filePath, i + 1, currentModel, findings);
999
+ }
1000
+ const attrMatch = line.match(/^\s+(\w+)\s*=\s*(?:Column|mapped_column)/);
1001
+ if (attrMatch) {
1002
+ const attrName = attrMatch[1].toLowerCase();
1003
+ checkPiiField(attrName, filePath, i + 1, currentModel, findings);
1004
+ }
1005
+ }
1006
+ return findings;
1007
+ }
1008
+ function scanTypeOrmEntities(content, filePath) {
1009
+ const findings = [];
1010
+ const lines = content.split("\n");
1011
+ let currentEntity = "";
1012
+ for (let i = 0; i < lines.length; i++) {
1013
+ const line = lines[i];
1014
+ const entityMatch = line.match(/class\s+(\w+)/);
1015
+ if (entityMatch && content.slice(0, lines.slice(0, i).join("\n").length).includes("@Entity(")) {
1016
+ currentEntity = entityMatch[1];
1017
+ }
1018
+ if (!currentEntity) continue;
1019
+ const colMatch = line.match(/^\s+(\w+)\s*[?!]?\s*:\s*/);
1020
+ if (colMatch && i > 0 && lines[i - 1].includes("@Column")) {
1021
+ const colName = colMatch[1].toLowerCase();
1022
+ checkPiiField(colName, filePath, i + 1, currentEntity, findings);
1023
+ }
1024
+ }
1025
+ return findings;
1026
+ }
1027
+ function scanDieselSchema(content, filePath) {
1028
+ const findings = [];
1029
+ const lines = content.split("\n");
1030
+ let currentTable = "";
1031
+ for (let i = 0; i < lines.length; i++) {
1032
+ const line = lines[i];
1033
+ const tableMatch = line.match(/^\s*(\w+)\s*\(/);
1034
+ if (tableMatch && content.slice(0, lines.slice(0, i).join("\n").length).includes("table!")) {
1035
+ currentTable = tableMatch[1];
1036
+ continue;
1037
+ }
1038
+ if (currentTable) {
1039
+ const colMatch = line.match(/^\s+(\w+)\s*->/);
1040
+ if (colMatch) {
1041
+ const colName = colMatch[1].toLowerCase();
1042
+ checkPiiField(colName, filePath, i + 1, currentTable, findings);
1043
+ }
1044
+ }
1045
+ if (line.trim() === "}") {
1046
+ currentTable = "";
1047
+ }
1048
+ }
1049
+ return findings;
1050
+ }
1051
+ function checkPiiField(fieldName, filePath, line, context, findings) {
1052
+ const normalized = fieldName.toLowerCase().replace(/[_-]/g, "");
1053
+ for (const pii of PII_PATTERNS) {
1054
+ const piiNorm = pii.pattern.replace(/[_-]/g, "");
1055
+ if (normalized.includes(piiNorm)) {
1056
+ findings.push({
1057
+ id: `schema-pii-${pii.pattern}-${filePath}-${line}`,
1058
+ category: "schema",
1059
+ severity: pii.severity,
1060
+ title: `PII column "${fieldName}" in ${context}`,
1061
+ message: `Column "${fieldName}" likely stores ${pii.piiType}. ${pii.recommendation}`,
1062
+ file: filePath,
1063
+ line,
1064
+ rule: "pii-column",
1065
+ fix: pii.recommendation
1066
+ });
1067
+ return;
1068
+ }
1069
+ }
1070
+ }
1071
+ function isPiiField(name) {
1072
+ const norm = name.toLowerCase().replace(/[_-]/g, "");
1073
+ return PII_PATTERNS.some((p) => norm.includes(p.pattern.replace(/[_-]/g, "")));
1074
+ }
1075
+ function createRetentionFinding(filePath, line, model) {
1076
+ return {
1077
+ id: `schema-no-retention-${model}-${filePath}`,
1078
+ category: "schema",
1079
+ severity: "medium",
1080
+ title: `No retention policy for ${model}`,
1081
+ message: `Model "${model}" contains PII but has no soft-delete (deleted_at) or expiry (expires_at) column. GDPR requires data retention limits.`,
1082
+ file: filePath,
1083
+ line,
1084
+ rule: "no-retention-policy",
1085
+ fix: "Add a deleted_at/expires_at timestamp column and implement a data retention cleanup job."
1086
+ };
1087
+ }
1088
+
1089
+ // src/audit/config-scanner.ts
1090
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
1091
+ import { join as join3, relative as relative3 } from "path";
1092
+ function scanConfigs(files, baseDir, stack) {
1093
+ const findings = [];
1094
+ findings.push(...scanFrameworkConfig(baseDir, stack));
1095
+ for (const filePath of files) {
1096
+ try {
1097
+ const content = readFileSync5(filePath, "utf-8");
1098
+ const relPath = relative3(baseDir, filePath);
1099
+ findings.push(...scanCookieConfig(content, relPath, stack));
1100
+ findings.push(...scanCorsConfig(content, relPath));
1101
+ findings.push(...scanCspConfig(content, relPath));
1102
+ findings.push(...scanLoggingConfig(content, relPath));
1103
+ } catch {
1104
+ }
1105
+ }
1106
+ return findings;
1107
+ }
1108
+ function scanFrameworkConfig(baseDir, stack) {
1109
+ const findings = [];
1110
+ switch (stack.framework) {
1111
+ case "nextjs":
1112
+ findings.push(...scanNextjsConfig(baseDir));
1113
+ break;
1114
+ case "django":
1115
+ findings.push(...scanDjangoConfig(baseDir));
1116
+ break;
1117
+ case "express":
1118
+ case "fastify":
1119
+ findings.push(...scanExpressConfig(baseDir));
1120
+ break;
1121
+ case "flask":
1122
+ findings.push(...scanFlaskConfig(baseDir));
1123
+ break;
1124
+ }
1125
+ return findings;
1126
+ }
1127
+ function scanNextjsConfig(baseDir) {
1128
+ const findings = [];
1129
+ for (const ext of [".js", ".ts", ".mjs"]) {
1130
+ const configPath = join3(baseDir, `next.config${ext}`);
1131
+ if (!existsSync3(configPath)) continue;
1132
+ const content = readFileSync5(configPath, "utf-8");
1133
+ if (!content.includes("headers") || !content.includes("Content-Security-Policy")) {
1134
+ findings.push({
1135
+ id: "config-nextjs-no-csp",
1136
+ category: "config",
1137
+ severity: "medium",
1138
+ title: "No Content-Security-Policy in Next.js config",
1139
+ message: "next.config does not set CSP headers. A CSP helps prevent unauthorized tracker injection via XSS.",
1140
+ file: `next.config${ext}`,
1141
+ rule: "missing-csp",
1142
+ fix: "Add security headers in next.config.js headers() function."
1143
+ });
1144
+ }
1145
+ if (content.includes("google-analytics.com") || content.includes("googletagmanager.com")) {
1146
+ const lines = content.split("\n");
1147
+ for (let i = 0; i < lines.length; i++) {
1148
+ if (lines[i].includes("google-analytics.com") || lines[i].includes("googletagmanager.com")) {
1149
+ findings.push({
1150
+ id: `config-nextjs-proxy-analytics-${i}`,
1151
+ category: "config",
1152
+ severity: "high",
1153
+ title: "Analytics proxy in Next.js rewrites",
1154
+ message: "Proxying analytics through Next.js rewrites can circumvent ad blockers but may violate GDPR transparency requirements.",
1155
+ file: `next.config${ext}`,
1156
+ line: i + 1,
1157
+ vendorId: "google-analytics",
1158
+ rule: "analytics-proxy"
1159
+ });
1160
+ }
1161
+ }
1162
+ }
1163
+ }
1164
+ const appFiles = ["src/pages/_app.tsx", "src/pages/_app.jsx", "src/app/layout.tsx", "src/app/layout.jsx", "pages/_app.tsx", "pages/_app.jsx", "app/layout.tsx", "app/layout.jsx"];
1165
+ for (const appFile of appFiles) {
1166
+ const appPath = join3(baseDir, appFile);
1167
+ if (!existsSync3(appPath)) continue;
1168
+ const content = readFileSync5(appPath, "utf-8");
1169
+ const lines = content.split("\n");
1170
+ for (let i = 0; i < lines.length; i++) {
1171
+ if (lines[i].includes("GoogleAnalytics") || lines[i].includes("GoogleTagManager") || lines[i].includes("next/script") && lines[i].includes("gtag")) {
1172
+ const contextStart = Math.max(0, i - 5);
1173
+ const context = lines.slice(contextStart, i + 3).join("\n").toLowerCase();
1174
+ if (!context.includes("consent") && !context.includes("cookie") && !context.includes("gdpr")) {
1175
+ findings.push({
1176
+ id: `config-nextjs-unconditional-tracker-${appFile}-${i}`,
1177
+ category: "config",
1178
+ severity: "high",
1179
+ title: "Tracker loaded unconditionally in app layout",
1180
+ message: `Analytics/tracking script loaded without consent check in ${appFile}. GDPR requires consent before non-essential tracking.`,
1181
+ file: appFile,
1182
+ line: i + 1,
1183
+ rule: "unconditional-tracker",
1184
+ fix: "Wrap tracker initialization in a consent check."
1185
+ });
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1190
+ return findings;
1191
+ }
1192
+ function scanDjangoConfig(baseDir) {
1193
+ const findings = [];
1194
+ const settingsPaths = ["settings.py", "settings/base.py", "settings/production.py", "config/settings.py"];
1195
+ for (const settingsFile of settingsPaths) {
1196
+ const settingsPath = join3(baseDir, settingsFile);
1197
+ if (!existsSync3(settingsPath)) continue;
1198
+ const content = readFileSync5(settingsPath, "utf-8");
1199
+ const lines = content.split("\n");
1200
+ if (!content.includes("SESSION_COOKIE_SECURE") || content.includes("SESSION_COOKIE_SECURE = False")) {
1201
+ findings.push({
1202
+ id: `config-django-session-insecure-${settingsFile}`,
1203
+ category: "config",
1204
+ severity: "high",
1205
+ title: "Django session cookie not marked Secure",
1206
+ message: "SESSION_COOKIE_SECURE should be True in production to prevent session hijacking over HTTP.",
1207
+ file: settingsFile,
1208
+ rule: "cookie-insecure",
1209
+ fix: "Set SESSION_COOKIE_SECURE = True"
1210
+ });
1211
+ }
1212
+ if (content.includes("CSRF_COOKIE_HTTPONLY = False")) {
1213
+ findings.push({
1214
+ id: `config-django-csrf-httponly-${settingsFile}`,
1215
+ category: "config",
1216
+ severity: "medium",
1217
+ title: "Django CSRF cookie HttpOnly disabled",
1218
+ message: "CSRF_COOKIE_HTTPONLY = False exposes the CSRF token to JavaScript.",
1219
+ file: settingsFile,
1220
+ rule: "cookie-insecure"
1221
+ });
1222
+ }
1223
+ if (!content.includes("SESSION_COOKIE_SAMESITE") || content.includes("SESSION_COOKIE_SAMESITE = 'None'") || content.includes("SESSION_COOKIE_SAMESITE = None")) {
1224
+ findings.push({
1225
+ id: `config-django-samesite-${settingsFile}`,
1226
+ category: "config",
1227
+ severity: "medium",
1228
+ title: "Django session cookie SameSite not set",
1229
+ message: 'SESSION_COOKIE_SAMESITE should be "Lax" or "Strict" to prevent CSRF and limit third-party cookie sharing.',
1230
+ file: settingsFile,
1231
+ rule: "cookie-samesite",
1232
+ fix: "Set SESSION_COOKIE_SAMESITE = 'Lax'"
1233
+ });
1234
+ }
1235
+ for (let i = 0; i < lines.length; i++) {
1236
+ if (lines[i].match(/^\s*DEBUG\s*=\s*True/)) {
1237
+ findings.push({
1238
+ id: `config-django-debug-${settingsFile}`,
1239
+ category: "config",
1240
+ severity: "high",
1241
+ title: "Django DEBUG = True",
1242
+ message: "DEBUG mode enabled \u2014 this leaks detailed error info including PII in stack traces to users.",
1243
+ file: settingsFile,
1244
+ line: i + 1,
1245
+ rule: "debug-mode"
1246
+ });
1247
+ }
1248
+ }
1249
+ if (!content.includes("SECURE_SSL_REDIRECT") || content.includes("SECURE_SSL_REDIRECT = False")) {
1250
+ findings.push({
1251
+ id: `config-django-no-ssl-${settingsFile}`,
1252
+ category: "config",
1253
+ severity: "medium",
1254
+ title: "Django SSL redirect not enabled",
1255
+ message: "SECURE_SSL_REDIRECT should be True to enforce HTTPS.",
1256
+ file: settingsFile,
1257
+ rule: "no-ssl"
1258
+ });
1259
+ }
1260
+ }
1261
+ return findings;
1262
+ }
1263
+ function scanExpressConfig(baseDir) {
1264
+ const findings = [];
1265
+ try {
1266
+ const pkg = JSON.parse(readFileSync5(join3(baseDir, "package.json"), "utf-8"));
1267
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1268
+ if (!allDeps["helmet"]) {
1269
+ findings.push({
1270
+ id: "config-express-no-helmet",
1271
+ category: "config",
1272
+ severity: "medium",
1273
+ title: "Express app missing Helmet",
1274
+ message: "Helmet sets security headers (CSP, X-Frame-Options, etc.) that help prevent tracker injection via XSS.",
1275
+ file: "package.json",
1276
+ rule: "missing-security-headers",
1277
+ fix: "npm install helmet && app.use(helmet())"
1278
+ });
1279
+ }
1280
+ } catch {
1281
+ }
1282
+ return findings;
1283
+ }
1284
+ function scanFlaskConfig(baseDir) {
1285
+ const findings = [];
1286
+ const configFiles = ["config.py", "app.py", "settings.py"];
1287
+ for (const configFile of configFiles) {
1288
+ const configPath = join3(baseDir, configFile);
1289
+ if (!existsSync3(configPath)) continue;
1290
+ const content = readFileSync5(configPath, "utf-8");
1291
+ const lines = content.split("\n");
1292
+ if (content.includes("SESSION_COOKIE_SECURE") && content.includes("False")) {
1293
+ findings.push({
1294
+ id: `config-flask-session-insecure-${configFile}`,
1295
+ category: "config",
1296
+ severity: "high",
1297
+ title: "Flask session cookie not secure",
1298
+ message: "SESSION_COOKIE_SECURE should be True in production.",
1299
+ file: configFile,
1300
+ rule: "cookie-insecure"
1301
+ });
1302
+ }
1303
+ for (let i = 0; i < lines.length; i++) {
1304
+ if (lines[i].match(/DEBUG\s*=\s*True/) || lines[i].includes("app.run(debug=True)")) {
1305
+ findings.push({
1306
+ id: `config-flask-debug-${configFile}-${i}`,
1307
+ category: "config",
1308
+ severity: "high",
1309
+ title: "Flask debug mode enabled",
1310
+ message: "Debug mode leaks stack traces and PII to users.",
1311
+ file: configFile,
1312
+ line: i + 1,
1313
+ rule: "debug-mode"
1314
+ });
1315
+ }
1316
+ }
1317
+ }
1318
+ return findings;
1319
+ }
1320
+ function scanCookieConfig(content, filePath, stack) {
1321
+ const findings = [];
1322
+ const lines = content.split("\n");
1323
+ for (let i = 0; i < lines.length; i++) {
1324
+ const line = lines[i];
1325
+ if (line.includes("cookie:") && i + 5 < lines.length) {
1326
+ const cookieBlock = lines.slice(i, i + 8).join("\n");
1327
+ if (!cookieBlock.includes("secure")) {
1328
+ findings.push({
1329
+ id: `config-cookie-no-secure-${filePath}-${i}`,
1330
+ category: "config",
1331
+ severity: "high",
1332
+ title: "Cookie missing Secure flag",
1333
+ message: "Cookie configuration does not set secure: true. Cookies will be sent over HTTP.",
1334
+ file: filePath,
1335
+ line: i + 1,
1336
+ rule: "cookie-insecure",
1337
+ fix: "Add secure: true to cookie options"
1338
+ });
1339
+ }
1340
+ if (!cookieBlock.includes("sameSite") && !cookieBlock.includes("same_site")) {
1341
+ findings.push({
1342
+ id: `config-cookie-no-samesite-${filePath}-${i}`,
1343
+ category: "config",
1344
+ severity: "medium",
1345
+ title: "Cookie missing SameSite attribute",
1346
+ message: 'Cookie does not set SameSite attribute. Set to "Lax" or "Strict" to prevent CSRF.',
1347
+ file: filePath,
1348
+ line: i + 1,
1349
+ rule: "cookie-samesite",
1350
+ fix: "Add sameSite: 'lax' to cookie options"
1351
+ });
1352
+ }
1353
+ }
1354
+ }
1355
+ return findings;
1356
+ }
1357
+ function scanCorsConfig(content, filePath) {
1358
+ const findings = [];
1359
+ const lines = content.split("\n");
1360
+ for (let i = 0; i < lines.length; i++) {
1361
+ const line = lines[i];
1362
+ if (line.includes("origin: '*'") || line.includes('origin: "*"') || line.includes("origin: true") || line.includes("CORS_ALLOW_ALL_ORIGINS") && line.includes("True") || line.includes("Access-Control-Allow-Origin") && line.includes("*")) {
1363
+ findings.push({
1364
+ id: `config-cors-wildcard-${filePath}-${i}`,
1365
+ category: "config",
1366
+ severity: "medium",
1367
+ title: "CORS wildcard origin",
1368
+ message: "CORS is configured to allow all origins. This can expose user data to any domain.",
1369
+ file: filePath,
1370
+ line: i + 1,
1371
+ rule: "cors-wildcard",
1372
+ fix: "Restrict CORS to specific trusted origins."
1373
+ });
1374
+ }
1375
+ if (line.includes("credentials") && line.includes("true")) {
1376
+ const block = lines.slice(Math.max(0, i - 5), i + 5).join("\n");
1377
+ if (block.includes("'*'") || block.includes('"*"')) {
1378
+ findings.push({
1379
+ id: `config-cors-credentials-wildcard-${filePath}-${i}`,
1380
+ category: "config",
1381
+ severity: "high",
1382
+ title: "CORS credentials with wildcard origin",
1383
+ message: "CORS allows credentials with wildcard origin \u2014 this is a security vulnerability.",
1384
+ file: filePath,
1385
+ line: i + 1,
1386
+ rule: "cors-credentials-wildcard"
1387
+ });
1388
+ }
1389
+ }
1390
+ }
1391
+ return findings;
1392
+ }
1393
+ function scanCspConfig(content, filePath) {
1394
+ const findings = [];
1395
+ const lines = content.split("\n");
1396
+ for (let i = 0; i < lines.length; i++) {
1397
+ const line = lines[i];
1398
+ if (line.includes("Content-Security-Policy") || line.includes("contentSecurityPolicy")) {
1399
+ const cspBlock = lines.slice(i, Math.min(lines.length, i + 10)).join("\n");
1400
+ if (cspBlock.includes("'unsafe-inline'")) {
1401
+ findings.push({
1402
+ id: `config-csp-unsafe-inline-${filePath}-${i}`,
1403
+ category: "config",
1404
+ severity: "medium",
1405
+ title: "CSP allows 'unsafe-inline'",
1406
+ message: "Content-Security-Policy uses 'unsafe-inline' which allows injected tracker scripts to execute.",
1407
+ file: filePath,
1408
+ line: i + 1,
1409
+ rule: "csp-unsafe-inline"
1410
+ });
1411
+ }
1412
+ if (cspBlock.includes("'unsafe-eval'")) {
1413
+ findings.push({
1414
+ id: `config-csp-unsafe-eval-${filePath}-${i}`,
1415
+ category: "config",
1416
+ severity: "high",
1417
+ title: "CSP allows 'unsafe-eval'",
1418
+ message: "Content-Security-Policy uses 'unsafe-eval' which enables arbitrary script execution.",
1419
+ file: filePath,
1420
+ line: i + 1,
1421
+ rule: "csp-unsafe-eval"
1422
+ });
1423
+ }
1424
+ }
1425
+ }
1426
+ return findings;
1427
+ }
1428
+ function scanLoggingConfig(content, filePath) {
1429
+ const findings = [];
1430
+ const lines = content.split("\n");
1431
+ const piiLogPatterns = [
1432
+ /console\.log\(.*(?:email|password|token|ssn|phone|ip_address)/i,
1433
+ /logger\.\w+\(.*(?:email|password|token|ssn|phone)/i,
1434
+ /logging\.\w+\(.*(?:email|password|token|ssn|phone)/i,
1435
+ /print\(.*(?:email|password|token|ssn|phone)/i,
1436
+ /log::\w+!\(.*(?:email|password|token|ssn|phone)/i
1437
+ ];
1438
+ for (let i = 0; i < lines.length; i++) {
1439
+ for (const pattern of piiLogPatterns) {
1440
+ if (pattern.test(lines[i])) {
1441
+ findings.push({
1442
+ id: `config-log-pii-${filePath}-${i}`,
1443
+ category: "config",
1444
+ severity: "medium",
1445
+ title: "Logging potentially sensitive data",
1446
+ message: "Log statement appears to include PII (email, password, token, etc.). Avoid logging sensitive data.",
1447
+ file: filePath,
1448
+ line: i + 1,
1449
+ rule: "logging-pii",
1450
+ fix: "Remove PII from log statements or mask sensitive values."
1451
+ });
1452
+ break;
1453
+ }
1454
+ }
1455
+ }
1456
+ return findings;
1457
+ }
1458
+
1459
+ // src/audit/server-tracker-scanner.ts
1460
+ import { readFileSync as readFileSync6 } from "fs";
1461
+ import { relative as relative4, extname as extname2 } from "path";
1462
+ var TRACKER_ENDPOINTS = [
1463
+ { pattern: /https?:\/\/www\.google-analytics\.com\/collect/g, vendor: "Google Analytics", vendorId: "google-analytics" },
1464
+ { pattern: /https?:\/\/www\.google-analytics\.com\/g\/collect/g, vendor: "Google Analytics 4", vendorId: "google-analytics" },
1465
+ { pattern: /https?:\/\/graph\.facebook\.com\/[^'"`\s]*\/events/g, vendor: "Facebook Conversions API", vendorId: "facebook-pixel" },
1466
+ { pattern: /https?:\/\/api\.segment\.(io|com)/g, vendor: "Segment", vendorId: "segment" },
1467
+ { pattern: /https?:\/\/api\.mixpanel\.com/g, vendor: "Mixpanel", vendorId: "mixpanel" },
1468
+ { pattern: /https?:\/\/api2?\.amplitude\.com/g, vendor: "Amplitude", vendorId: "amplitude" },
1469
+ { pattern: /https?:\/\/analytics\.tiktok\.com/g, vendor: "TikTok Pixel", vendorId: "tiktok" },
1470
+ { pattern: /https?:\/\/tr\.snapchat\.com/g, vendor: "Snapchat", vendorId: "snapchat" },
1471
+ { pattern: /https?:\/\/ct\.pinterest\.com/g, vendor: "Pinterest Tag", vendorId: "pinterest" },
1472
+ { pattern: /https?:\/\/bat\.bing\.com/g, vendor: "Microsoft UET", vendorId: "bing" },
1473
+ { pattern: /https?:\/\/app\.posthog\.com\/capture/g, vendor: "PostHog", vendorId: "posthog" },
1474
+ { pattern: /https?:\/\/events\.launchdarkly\.com/g, vendor: "LaunchDarkly", vendorId: "launchdarkly" }
1475
+ ];
1476
+ var TRACKER_DOMAINS = [
1477
+ "google-analytics.com",
1478
+ "googletagmanager.com",
1479
+ "graph.facebook.com",
1480
+ "api.segment.io",
1481
+ "api.segment.com",
1482
+ "api.mixpanel.com",
1483
+ "api.amplitude.com",
1484
+ "api2.amplitude.com",
1485
+ "analytics.tiktok.com",
1486
+ "tr.snapchat.com",
1487
+ "ct.pinterest.com",
1488
+ "bat.bing.com",
1489
+ "app.posthog.com",
1490
+ "events.launchdarkly.com"
1491
+ ];
1492
+ var SERVER_EXTENSIONS = /* @__PURE__ */ new Set([
1493
+ ".js",
1494
+ ".ts",
1495
+ ".mjs",
1496
+ ".cjs",
1497
+ ".py",
1498
+ ".rs",
1499
+ ".rb",
1500
+ ".go",
1501
+ ".java",
1502
+ ".php"
1503
+ ]);
1504
+ function scanServerTracking(files, baseDir, _stack) {
1505
+ const findings = [];
1506
+ for (const filePath of files) {
1507
+ const ext = extname2(filePath);
1508
+ if (!SERVER_EXTENSIONS.has(ext)) continue;
1509
+ let content;
1510
+ try {
1511
+ content = readFileSync6(filePath, "utf-8");
1512
+ } catch {
1513
+ continue;
1514
+ }
1515
+ const relPath = relative4(baseDir, filePath);
1516
+ const lines = content.split("\n");
1517
+ findings.push(...scanTrackerUrls(lines, relPath));
1518
+ findings.push(...scanHttpCalls(lines, relPath));
1519
+ }
1520
+ return deduplicateByKey(findings);
1521
+ }
1522
+ function scanTrackerUrls(lines, filePath) {
1523
+ const findings = [];
1524
+ for (let i = 0; i < lines.length; i++) {
1525
+ const line = lines[i];
1526
+ for (const endpoint of TRACKER_ENDPOINTS) {
1527
+ endpoint.pattern.lastIndex = 0;
1528
+ if (endpoint.pattern.test(line)) {
1529
+ findings.push({
1530
+ id: `code-server-tracking-${filePath}-${i}`,
1531
+ category: "code",
1532
+ severity: "high",
1533
+ title: `Server-side tracking: ${endpoint.vendor}`,
1534
+ message: `Server-side API call to ${endpoint.vendor} detected. This bypasses browser privacy controls (ad blockers, consent banners).`,
1535
+ file: filePath,
1536
+ line: i + 1,
1537
+ vendorId: endpoint.vendorId,
1538
+ rule: "server-side-tracking",
1539
+ fix: "Ensure explicit user consent before sending data. Consider a server-side consent check or privacy-friendly alternative."
1540
+ });
1541
+ }
1542
+ }
1543
+ }
1544
+ return findings;
1545
+ }
1546
+ var HTTP_CALL_PATTERNS = [
1547
+ // JS/TS: fetch, axios, got, node-fetch
1548
+ /fetch\s*\(\s*['"`]([^'"`]+)['"`]/g,
1549
+ /axios\.(get|post|put|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g,
1550
+ /got\.(get|post|put|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g,
1551
+ // Python: requests, httpx, urllib
1552
+ /requests?\.(get|post|put|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g,
1553
+ /httpx\.(get|post|put|patch)\s*\(\s*['"`]([^'"`]+)['"`]/g,
1554
+ // Rust: reqwest
1555
+ /\.(?:get|post|put|patch)\s*\(\s*"([^"]+)"/g,
1556
+ // Generic URL in string assignment
1557
+ /(?:url|endpoint|api_url|base_url)\s*=\s*['"`]([^'"`]+)['"`]/gi
1558
+ ];
1559
+ function scanHttpCalls(lines, filePath) {
1560
+ const findings = [];
1561
+ const fullContent = lines.join("\n");
1562
+ for (const pattern of HTTP_CALL_PATTERNS) {
1563
+ pattern.lastIndex = 0;
1564
+ let match;
1565
+ while ((match = pattern.exec(fullContent)) !== null) {
1566
+ const url = match[2] ?? match[1];
1567
+ if (!url) continue;
1568
+ const matchedDomain = TRACKER_DOMAINS.find((d) => url.includes(d));
1569
+ if (!matchedDomain) continue;
1570
+ const lineNumber = fullContent.substring(0, match.index).split("\n").length;
1571
+ findings.push({
1572
+ id: `code-server-http-${filePath}-${lineNumber}`,
1573
+ category: "code",
1574
+ severity: "high",
1575
+ title: `HTTP call to tracker domain: ${matchedDomain}`,
1576
+ message: `Server-side HTTP request to ${matchedDomain} detected. This sends tracking data without user visibility.`,
1577
+ file: filePath,
1578
+ line: lineNumber,
1579
+ rule: "server-side-tracking",
1580
+ fix: "Verify user consent before sending data. Use a privacy proxy or first-party endpoint instead."
1581
+ });
1582
+ }
1583
+ }
1584
+ return findings;
1585
+ }
1586
+ function deduplicateByKey(findings) {
1587
+ const seen = /* @__PURE__ */ new Set();
1588
+ return findings.filter((f) => {
1589
+ const key = `${f.file}:${f.line}:${f.rule}`;
1590
+ if (seen.has(key)) return false;
1591
+ seen.add(key);
1592
+ return true;
1593
+ });
1594
+ }
1595
+
1596
+ // src/audit/cname-cloaking-scanner.ts
1597
+ import { readFileSync as readFileSync7 } from "fs";
1598
+ import { basename as basename2, relative as relative5, extname as extname3 } from "path";
1599
+ var CNAME_TRACKER_DOMAINS = [
1600
+ // Adobe / Omniture
1601
+ { domain: "omtrdc.net", vendor: "Adobe Analytics", vendorId: "adobe-analytics" },
1602
+ { domain: "2o7.net", vendor: "Adobe Analytics", vendorId: "adobe-analytics" },
1603
+ { domain: "sc.omtrdc.net", vendor: "Adobe Analytics", vendorId: "adobe-analytics" },
1604
+ // Eulerian
1605
+ { domain: "eulerian.net", vendor: "Eulerian", vendorId: "eulerian" },
1606
+ // Criteo
1607
+ { domain: "dnsdelegation.io", vendor: "Criteo", vendorId: "criteo" },
1608
+ { domain: "criteo.com", vendor: "Criteo", vendorId: "criteo" },
1609
+ // AT Internet / Piano
1610
+ { domain: "xiti.com", vendor: "AT Internet", vendorId: "at-internet" },
1611
+ { domain: "ati-host.net", vendor: "AT Internet", vendorId: "at-internet" },
1612
+ // Segment
1613
+ { domain: "cdn.segment.com", vendor: "Segment", vendorId: "segment" },
1614
+ { domain: "api.segment.io", vendor: "Segment", vendorId: "segment" },
1615
+ // Mixpanel
1616
+ { domain: "cdn.mxpnl.com", vendor: "Mixpanel", vendorId: "mixpanel" },
1617
+ { domain: "api-js.mixpanel.com", vendor: "Mixpanel", vendorId: "mixpanel" },
1618
+ // Google Tag Manager server-side
1619
+ { domain: "googletagmanager.com", vendor: "Google Tag Manager", vendorId: "google-tag-manager" },
1620
+ { domain: "analytics.google.com", vendor: "Google Analytics", vendorId: "google-analytics" },
1621
+ // Amplitude
1622
+ { domain: "cdn.amplitude.com", vendor: "Amplitude", vendorId: "amplitude" },
1623
+ { domain: "api.amplitude.com", vendor: "Amplitude", vendorId: "amplitude" },
1624
+ // PostHog
1625
+ { domain: "app.posthog.com", vendor: "PostHog", vendorId: "posthog" },
1626
+ // Hubspot
1627
+ { domain: "js.hs-scripts.com", vendor: "HubSpot", vendorId: "hubspot" },
1628
+ { domain: "track.hubspot.com", vendor: "HubSpot", vendorId: "hubspot" },
1629
+ // Plausible (self-hosted proxy patterns)
1630
+ { domain: "plausible.io", vendor: "Plausible", vendorId: "plausible" },
1631
+ // Facebook / Meta
1632
+ { domain: "connect.facebook.net", vendor: "Facebook", vendorId: "facebook-pixel" },
1633
+ { domain: "graph.facebook.com", vendor: "Facebook", vendorId: "facebook-pixel" },
1634
+ // TikTok
1635
+ { domain: "analytics.tiktok.com", vendor: "TikTok", vendorId: "tiktok" }
1636
+ ];
1637
+ var DNS_CONFIG_FILES = /* @__PURE__ */ new Set([
1638
+ "cloudflare.json",
1639
+ "dns-records.json",
1640
+ "vercel.json",
1641
+ "netlify.toml",
1642
+ "_redirects",
1643
+ "_headers"
1644
+ ]);
1645
+ var IAC_EXTENSIONS = /* @__PURE__ */ new Set([".tf", ".json", ".yml", ".yaml", ".toml"]);
1646
+ var PROXY_CONFIG_FILES = /* @__PURE__ */ new Set(["nginx.conf", "httpd.conf", ".htaccess"]);
1647
+ var NEXTJS_CONFIG = /* @__PURE__ */ new Set(["next.config.js", "next.config.mjs", "next.config.ts"]);
1648
+ function scanCnameCloaking(files, baseDir, stack) {
1649
+ const findings = [];
1650
+ for (const filePath of files) {
1651
+ const name = basename2(filePath);
1652
+ const ext = extname3(filePath);
1653
+ let content;
1654
+ try {
1655
+ content = readFileSync7(filePath, "utf-8");
1656
+ } catch {
1657
+ continue;
1658
+ }
1659
+ const relPath = relative5(baseDir, filePath);
1660
+ if (DNS_CONFIG_FILES.has(name)) {
1661
+ findings.push(...scanDnsConfig(content, relPath, name));
1662
+ }
1663
+ if (IAC_EXTENSIONS.has(ext) && isInfraFile(filePath, content)) {
1664
+ findings.push(...scanIacFile(content, relPath, name));
1665
+ }
1666
+ if (PROXY_CONFIG_FILES.has(name) || name.endsWith(".conf")) {
1667
+ findings.push(...scanProxyConfig(content, relPath));
1668
+ }
1669
+ if (NEXTJS_CONFIG.has(name) || name === "vercel.json") {
1670
+ findings.push(...scanNextjsRewrites(content, relPath));
1671
+ }
1672
+ if ((ext === ".js" || ext === ".ts" || ext === ".mjs") && isServerFile(name)) {
1673
+ findings.push(...scanExpressProxy(content, relPath));
1674
+ }
1675
+ }
1676
+ return dedup(findings);
1677
+ }
1678
+ function scanDnsConfig(content, filePath, fileName) {
1679
+ const findings = [];
1680
+ const lines = content.split("\n");
1681
+ for (let i = 0; i < lines.length; i++) {
1682
+ const line = lines[i].toLowerCase();
1683
+ if (!line.includes("cname")) continue;
1684
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1685
+ if (line.includes(tracker.domain.toLowerCase())) {
1686
+ findings.push(makeFinding(
1687
+ filePath,
1688
+ i + 1,
1689
+ tracker,
1690
+ `CNAME record pointing to ${tracker.vendor} (${tracker.domain}). This cloaks third-party tracking behind a first-party domain, bypassing ad blockers and consent mechanisms.`
1691
+ ));
1692
+ }
1693
+ }
1694
+ }
1695
+ return findings;
1696
+ }
1697
+ function isInfraFile(filePath, content) {
1698
+ if (filePath.endsWith(".tf")) return true;
1699
+ if (content.includes("AWSTemplateFormatVersion") || content.includes("AWS::")) return true;
1700
+ if (content.includes("pulumi")) return true;
1701
+ if (content.includes("route53") || content.includes("dns_record") || content.includes("cloudflare_record")) return true;
1702
+ return false;
1703
+ }
1704
+ function scanIacFile(content, filePath, fileName) {
1705
+ const findings = [];
1706
+ const lines = content.split("\n");
1707
+ for (let i = 0; i < lines.length; i++) {
1708
+ const line = lines[i].toLowerCase();
1709
+ const isCnameContext = line.includes("cname") || line.includes('"type"') && lines.slice(Math.max(0, i - 3), i + 3).join(" ").toLowerCase().includes("cname");
1710
+ if (!isCnameContext) continue;
1711
+ const contextWindow = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 5)).join("\n").toLowerCase();
1712
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1713
+ if (contextWindow.includes(tracker.domain.toLowerCase())) {
1714
+ findings.push(makeFinding(
1715
+ filePath,
1716
+ i + 1,
1717
+ tracker,
1718
+ `Infrastructure-as-code CNAME record to ${tracker.vendor} (${tracker.domain}). This creates a DNS-level proxy to a third-party tracker.`
1719
+ ));
1720
+ }
1721
+ }
1722
+ }
1723
+ return findings;
1724
+ }
1725
+ function scanProxyConfig(content, filePath) {
1726
+ const findings = [];
1727
+ const lines = content.split("\n");
1728
+ const proxyPatterns = [
1729
+ /proxy_pass\s+https?:\/\/([^\s;]+)/gi,
1730
+ /ProxyPass\s+\S+\s+https?:\/\/([^\s]+)/gi,
1731
+ /upstream\s+\w+\s*{[^}]*server\s+([^\s;]+)/gi
1732
+ ];
1733
+ for (let i = 0; i < lines.length; i++) {
1734
+ const line = lines[i];
1735
+ for (const pattern of proxyPatterns) {
1736
+ pattern.lastIndex = 0;
1737
+ const match = pattern.exec(line);
1738
+ if (!match) continue;
1739
+ const target = match[1].toLowerCase();
1740
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1741
+ if (target.includes(tracker.domain.toLowerCase())) {
1742
+ findings.push(makeFinding(
1743
+ filePath,
1744
+ i + 1,
1745
+ tracker,
1746
+ `Reverse proxy to ${tracker.vendor} (${tracker.domain}). Proxying tracker requests through your server makes them appear first-party.`
1747
+ ));
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ return findings;
1753
+ }
1754
+ function scanNextjsRewrites(content, filePath) {
1755
+ const findings = [];
1756
+ const lines = content.split("\n");
1757
+ const rewritePattern = /(?:destination|dest|to)\s*[:=]\s*['"`](https?:\/\/[^'"`]+)['"`]/gi;
1758
+ for (let i = 0; i < lines.length; i++) {
1759
+ const line = lines[i];
1760
+ rewritePattern.lastIndex = 0;
1761
+ let match;
1762
+ while ((match = rewritePattern.exec(line)) !== null) {
1763
+ const url = match[1].toLowerCase();
1764
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1765
+ if (url.includes(tracker.domain.toLowerCase())) {
1766
+ findings.push(makeFinding(
1767
+ filePath,
1768
+ i + 1,
1769
+ tracker,
1770
+ `Rewrite/redirect rule proxying to ${tracker.vendor} (${tracker.domain}). This makes tracker requests appear first-party to the browser.`
1771
+ ));
1772
+ }
1773
+ }
1774
+ }
1775
+ }
1776
+ const fullContent = content.toLowerCase();
1777
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1778
+ if (fullContent.includes(tracker.domain.toLowerCase()) && (fullContent.includes("rewrites") || fullContent.includes("redirects"))) {
1779
+ for (let i = 0; i < lines.length; i++) {
1780
+ if (lines[i].toLowerCase().includes(tracker.domain.toLowerCase())) {
1781
+ findings.push(makeFinding(
1782
+ filePath,
1783
+ i + 1,
1784
+ tracker,
1785
+ `Next.js/Vercel config references ${tracker.vendor} (${tracker.domain}) in a rewrites/redirects context. This proxies tracker traffic through your domain.`
1786
+ ));
1787
+ }
1788
+ }
1789
+ }
1790
+ }
1791
+ return findings;
1792
+ }
1793
+ function isServerFile(name) {
1794
+ const serverNames = ["server", "app", "proxy", "middleware", "api"];
1795
+ const lower = name.toLowerCase();
1796
+ return serverNames.some((s) => lower.includes(s));
1797
+ }
1798
+ function scanExpressProxy(content, filePath) {
1799
+ const findings = [];
1800
+ const lines = content.split("\n");
1801
+ const proxyPatterns = [
1802
+ /(?:target|changeOrigin|router)\s*[:=]\s*['"`](https?:\/\/[^'"`]+)['"`]/gi,
1803
+ /createProxyMiddleware\s*\(\s*(?:['"`]([^'"`]+)['"`]|{[^}]*target\s*:\s*['"`]([^'"`]+)['"`])/gi,
1804
+ /httpProxy\.createServer\s*\(\s*{[^}]*target\s*:\s*['"`](https?:\/\/[^'"`]+)['"`]/gi
1805
+ ];
1806
+ for (let i = 0; i < lines.length; i++) {
1807
+ const line = lines[i];
1808
+ for (const pattern of proxyPatterns) {
1809
+ pattern.lastIndex = 0;
1810
+ let match;
1811
+ while ((match = pattern.exec(line)) !== null) {
1812
+ const url = (match[2] ?? match[1] ?? "").toLowerCase();
1813
+ if (!url) continue;
1814
+ for (const tracker of CNAME_TRACKER_DOMAINS) {
1815
+ if (url.includes(tracker.domain.toLowerCase())) {
1816
+ findings.push(makeFinding(
1817
+ filePath,
1818
+ i + 1,
1819
+ tracker,
1820
+ `Express/Node proxy middleware forwarding to ${tracker.vendor} (${tracker.domain}). This cloaks third-party tracking as first-party traffic.`
1821
+ ));
1822
+ }
1823
+ }
1824
+ }
1825
+ }
1826
+ }
1827
+ return findings;
1828
+ }
1829
+ function makeFinding(file, line, tracker, message) {
1830
+ return {
1831
+ id: `code-cname-cloaking-${file}-${line}`,
1832
+ category: "code",
1833
+ severity: "critical",
1834
+ title: `CNAME cloaking: ${tracker.vendor}`,
1835
+ message,
1836
+ file,
1837
+ line,
1838
+ vendorId: tracker.vendorId,
1839
+ rule: "cname-cloaking",
1840
+ fix: "Remove the CNAME/proxy that cloaks third-party tracking. Use a consent-gated client-side integration instead, or disclose the proxy in your privacy policy."
1841
+ };
1842
+ }
1843
+ function dedup(findings) {
1844
+ const seen = /* @__PURE__ */ new Set();
1845
+ return findings.filter((f) => {
1846
+ const key = `${f.file}:${f.line}:${f.vendorId}`;
1847
+ if (seen.has(key)) return false;
1848
+ seen.add(key);
1849
+ return true;
1850
+ });
1851
+ }
1852
+
1853
+ // src/audit/git-blame.ts
1854
+ import { execSync } from "child_process";
1855
+ function isGitRepo(cwd) {
1856
+ try {
1857
+ execSync("git rev-parse --git-dir", {
1858
+ stdio: "ignore",
1859
+ cwd
1860
+ });
1861
+ return true;
1862
+ } catch {
1863
+ return false;
1864
+ }
1865
+ }
1866
+ function getBlameForLine(filePath, lineNumber, cwd) {
1867
+ try {
1868
+ const output = execSync(
1869
+ `git blame -L ${lineNumber},${lineNumber} --porcelain "${filePath}"`,
1870
+ { encoding: "utf-8", cwd }
1871
+ );
1872
+ const lines = output.split("\n");
1873
+ const commit = lines[0]?.split(" ")[0] ?? "";
1874
+ const author = lines.find((l) => l.startsWith("author "))?.substring(7) ?? "Unknown";
1875
+ const email = lines.find((l) => l.startsWith("author-mail "))?.substring(12).replace(/[<>]/g, "") ?? "";
1876
+ const timestamp = lines.find((l) => l.startsWith("author-time "))?.substring(12) ?? "";
1877
+ const summary = lines.find((l) => l.startsWith("summary "))?.substring(8) ?? "";
1878
+ const date = timestamp ? new Date(parseInt(timestamp, 10) * 1e3).toISOString() : "";
1879
+ return { author, email, date, commit, commitMessage: summary };
1880
+ } catch {
1881
+ return null;
1882
+ }
1883
+ }
1884
+ function enrichFindings(findings, cwd) {
1885
+ if (!isGitRepo(cwd)) return findings;
1886
+ return findings.map((finding) => {
1887
+ if (!finding.line) return finding;
1888
+ const blame = getBlameForLine(finding.file, finding.line, cwd);
1889
+ if (!blame) return finding;
1890
+ return { ...finding, blame };
1891
+ });
1892
+ }
1893
+ function groupByAuthor(findings) {
1894
+ const grouped = /* @__PURE__ */ new Map();
1895
+ for (const finding of findings) {
1896
+ if (!finding.blame) continue;
1897
+ const { author } = finding.blame;
1898
+ const list = grouped.get(author) ?? [];
1899
+ list.push(finding);
1900
+ grouped.set(author, list);
1901
+ }
1902
+ return grouped;
1903
+ }
1904
+
1905
+ // src/audit/scoring.ts
1906
+ var SEVERITY_WEIGHTS = {
1907
+ critical: 25,
1908
+ high: 10,
1909
+ medium: 3,
1910
+ low: 1,
1911
+ info: 0
1912
+ };
1913
+ function gradeFromScore(score) {
1914
+ if (score >= 90) return "A";
1915
+ if (score >= 75) return "B";
1916
+ if (score >= 60) return "C";
1917
+ if (score >= 40) return "D";
1918
+ return "F";
1919
+ }
1920
+ function calculateScore(report) {
1921
+ const breakdown = {
1922
+ critical: 0,
1923
+ high: 0,
1924
+ medium: 0,
1925
+ low: 0,
1926
+ info: 0
1927
+ };
1928
+ for (const finding of report.findings) {
1929
+ breakdown[finding.severity]++;
1930
+ }
1931
+ let penalty = 0;
1932
+ for (const [severity, count] of Object.entries(breakdown)) {
1933
+ penalty += SEVERITY_WEIGHTS[severity] * count;
1934
+ }
1935
+ const score = Math.max(0, 100 - penalty);
1936
+ const grade = gradeFromScore(score);
1937
+ return { score, grade, breakdown };
1938
+ }
1939
+ function gradeColor(grade) {
1940
+ switch (grade) {
1941
+ case "A":
1942
+ return "#4caf50";
1943
+ // green
1944
+ case "B":
1945
+ return "#8bc34a";
1946
+ // light green
1947
+ case "C":
1948
+ return "#ff9800";
1949
+ // orange
1950
+ case "D":
1951
+ return "#ff5722";
1952
+ // deep orange
1953
+ case "F":
1954
+ return "#f44336";
1955
+ }
1956
+ }
1957
+
1958
+ // src/audit/sarif-formatter.ts
1959
+ function formatAuditSarif(report) {
1960
+ const sarif = {
1961
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
1962
+ version: "2.1.0",
1963
+ runs: [
1964
+ {
1965
+ tool: {
1966
+ driver: {
1967
+ name: "ETALON",
1968
+ version: report.meta.etalonVersion,
1969
+ informationUri: "https://github.com/NMA-vc/etalon",
1970
+ rules: generateRules(report.findings)
1971
+ }
1972
+ },
1973
+ results: generateResults(report.findings)
1974
+ }
1975
+ ]
1976
+ };
1977
+ return JSON.stringify(sarif, null, 2);
1978
+ }
1979
+ function toSarifLevel(severity) {
1980
+ switch (severity) {
1981
+ case "critical":
1982
+ case "high":
1983
+ return "error";
1984
+ case "medium":
1985
+ return "warning";
1986
+ case "low":
1987
+ case "info":
1988
+ default:
1989
+ return "note";
1990
+ }
1991
+ }
1992
+ var RULE_DESCRIPTIONS = {
1993
+ "tracker-dependency": "Third-party tracking SDK detected in dependencies",
1994
+ "tracker-import": "Tracker SDK import found in source code",
1995
+ "tracker-api-call": "Call to a tracker API function detected",
1996
+ "tracker-env-var": "Tracker-related environment variable detected",
1997
+ "hardcoded-tracker": "Hardcoded tracking pixel or script found",
1998
+ "cookie-no-consent": "Cookie written without consent check",
1999
+ "storage-pii": "PII stored in localStorage or sessionStorage",
2000
+ "pii-column": "Potential PII stored in database column",
2001
+ "no-retention-policy": "Database table with PII lacks a retention policy",
2002
+ "pii-no-encryption": "PII column without encryption hint",
2003
+ "cookie-insecure": "Cookie missing Secure flag",
2004
+ "cookie-httponly": "Cookie missing HttpOnly flag",
2005
+ "cookie-samesite": "Cookie missing SameSite attribute",
2006
+ "cors-wildcard": "Overly permissive CORS configuration",
2007
+ "cors-credentials-wildcard": "CORS credentials allowed with wildcard origin",
2008
+ "missing-csp": "Missing Content-Security-Policy header",
2009
+ "csp-unsafe-inline": "CSP allows 'unsafe-inline'",
2010
+ "csp-unsafe-eval": "CSP allows 'unsafe-eval'",
2011
+ "debug-mode": "Debug mode enabled in production",
2012
+ "logging-pii": "PII detected in logging statements",
2013
+ "inline-tracker": "Inline tracking script reference detected",
2014
+ "server-side-tracking": "Server-side call to a tracking service endpoint",
2015
+ "cname-cloaking": "CNAME cloaking detected \u2014 third-party tracker hidden behind first-party domain"
2016
+ };
2017
+ function generateRules(findings) {
2018
+ const uniqueRules = new Set(findings.map((f) => f.rule));
2019
+ return Array.from(uniqueRules).map((rule) => ({
2020
+ id: rule,
2021
+ shortDescription: {
2022
+ text: RULE_DESCRIPTIONS[rule] ?? rule.replace(/[-_]/g, " ")
2023
+ },
2024
+ helpUri: `https://github.com/NMA-vc/etalon/blob/main/docs/rules/${rule}.md`,
2025
+ defaultConfiguration: {
2026
+ level: toSarifLevel(
2027
+ findings.find((f) => f.rule === rule)?.severity ?? "info"
2028
+ )
2029
+ }
2030
+ }));
2031
+ }
2032
+ function generateResults(findings) {
2033
+ return findings.map((finding) => ({
2034
+ ruleId: finding.rule,
2035
+ level: toSarifLevel(finding.severity),
2036
+ message: { text: finding.message },
2037
+ locations: [
2038
+ {
2039
+ physicalLocation: {
2040
+ artifactLocation: { uri: finding.file },
2041
+ region: {
2042
+ startLine: finding.line ?? 1,
2043
+ startColumn: finding.column ?? 1
2044
+ }
2045
+ }
2046
+ }
2047
+ ],
2048
+ ...finding.fix && {
2049
+ fixes: [
2050
+ {
2051
+ description: { text: finding.fix }
2052
+ }
2053
+ ]
2054
+ },
2055
+ properties: {
2056
+ category: finding.category,
2057
+ severity: finding.severity,
2058
+ ...finding.vendorId && { vendorId: finding.vendorId }
2059
+ }
2060
+ }));
2061
+ }
2062
+
2063
+ // src/audit/diff-analyzer.ts
2064
+ function diffReports(current, baseline) {
2065
+ const makeKey = (f) => `${f.rule}::${f.file}::${f.line ?? 0}::${f.message}`;
2066
+ const baselineKeys = new Set(baseline.findings.map(makeKey));
2067
+ const currentKeys = new Set(current.findings.map(makeKey));
2068
+ const added = [];
2069
+ const unchanged = [];
2070
+ for (const finding of current.findings) {
2071
+ if (baselineKeys.has(makeKey(finding))) {
2072
+ unchanged.push(finding);
2073
+ } else {
2074
+ added.push(finding);
2075
+ }
2076
+ }
2077
+ const removed = baseline.findings.filter((f) => !currentKeys.has(makeKey(f)));
2078
+ return { added, removed, unchanged };
2079
+ }
2080
+ function shouldBlock(diff, threshold) {
2081
+ const order = {
2082
+ info: 0,
2083
+ low: 1,
2084
+ medium: 2,
2085
+ high: 3,
2086
+ critical: 4
2087
+ };
2088
+ const minLevel = order[threshold] ?? 0;
2089
+ return diff.added.some((f) => (order[f.severity] ?? 0) >= minLevel);
2090
+ }
2091
+
2092
+ // src/audit/badge.ts
2093
+ function generateBadgeSvg(score) {
2094
+ const color = gradeColor(score.grade);
2095
+ const label = "ETALON";
2096
+ const value = `${score.grade} (${score.score})`;
2097
+ const labelWidth = 50;
2098
+ const valueWidth = 55;
2099
+ const totalWidth = labelWidth + valueWidth;
2100
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
2101
+ <title>${label}: ${value}</title>
2102
+ <linearGradient id="s" x2="0" y2="100%">
2103
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
2104
+ <stop offset="1" stop-opacity=".1"/>
2105
+ </linearGradient>
2106
+ <clipPath id="r"><rect width="${totalWidth}" height="20" rx="3" fill="#fff"/></clipPath>
2107
+ <g clip-path="url(#r)">
2108
+ <rect width="${labelWidth}" height="20" fill="#555"/>
2109
+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
2110
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
2111
+ </g>
2112
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
2113
+ <text x="${labelWidth / 2}" y="14">${label}</text>
2114
+ <text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
2115
+ </g>
2116
+ </svg>`;
2117
+ }
2118
+ function badgeUrl(grade, score) {
2119
+ const color = grade === "A" ? "brightgreen" : grade === "B" ? "green" : grade === "C" ? "orange" : grade === "D" ? "red" : "critical";
2120
+ return `https://img.shields.io/badge/ETALON-${grade}%20(${score}%2F100)-${color}?style=flat-square&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PHJlY3Qgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiByeD0iMyIgZmlsbD0iIzNCODJGNiIvPjx0ZXh0IHg9IjEwIiB5PSIxNSIgZm9udC1zaXplPSIxNCIgZmlsbD0iI2ZmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtd2VpZ2h0PSJib2xkIj5FPC90ZXh0Pjwvc3ZnPg==`;
2121
+ }
2122
+ function badgeMarkdown(grade, score) {
2123
+ return `[![ETALON Privacy Score](${badgeUrl(grade, score)})](https://etalon.nma.vc)`;
2124
+ }
2125
+
2126
+ // src/audit/auto-fixer.ts
2127
+ import { readFileSync as readFileSync8, writeFileSync } from "fs";
2128
+ var FIXERS = {
2129
+ "cookie-samesite": fixCookieSameSite,
2130
+ "cookie-insecure": fixCookieSecure,
2131
+ "csp-unsafe-eval": fixCspUnsafeEval,
2132
+ "cors-credentials-wildcard": fixCorsCredentials
2133
+ };
2134
+ function fixCookieSameSite(finding, content) {
2135
+ const lines = content.split("\n");
2136
+ const line = finding.line ? lines[finding.line - 1] : null;
2137
+ if (!line) return null;
2138
+ if (line.includes("Set-Cookie") || line.toLowerCase().includes("cookie")) {
2139
+ const patched = line.includes(";") ? line.replace(/;([^;]*)$/, "; SameSite=Lax;$1") : line.replace(/(["'`])$/, "; SameSite=Lax$1");
2140
+ if (patched !== line) {
2141
+ return {
2142
+ file: finding.file,
2143
+ line: finding.line,
2144
+ rule: finding.rule,
2145
+ oldContent: line,
2146
+ newContent: patched,
2147
+ description: "Add SameSite=Lax to cookie"
2148
+ };
2149
+ }
2150
+ }
2151
+ return null;
2152
+ }
2153
+ function fixCookieSecure(finding, content) {
2154
+ const lines = content.split("\n");
2155
+ const line = finding.line ? lines[finding.line - 1] : null;
2156
+ if (!line) return null;
2157
+ if ((line.includes("Set-Cookie") || line.toLowerCase().includes("cookie")) && !line.includes("Secure")) {
2158
+ const patched = line.includes(";") ? line.replace(/;([^;]*)$/, "; Secure;$1") : line.replace(/(["'`])$/, "; Secure$1");
2159
+ if (patched !== line) {
2160
+ return {
2161
+ file: finding.file,
2162
+ line: finding.line,
2163
+ rule: finding.rule,
2164
+ oldContent: line,
2165
+ newContent: patched,
2166
+ description: "Add Secure flag to cookie"
2167
+ };
2168
+ }
2169
+ }
2170
+ return null;
2171
+ }
2172
+ function fixCspUnsafeEval(finding, content) {
2173
+ const lines = content.split("\n");
2174
+ const line = finding.line ? lines[finding.line - 1] : null;
2175
+ if (!line) return null;
2176
+ if (line.includes("'unsafe-eval'")) {
2177
+ return {
2178
+ file: finding.file,
2179
+ line: finding.line,
2180
+ rule: finding.rule,
2181
+ oldContent: line,
2182
+ newContent: line.replace(/'unsafe-eval'\s*/g, ""),
2183
+ description: "Remove 'unsafe-eval' from CSP"
2184
+ };
2185
+ }
2186
+ return null;
2187
+ }
2188
+ function fixCorsCredentials(finding, content) {
2189
+ const lines = content.split("\n");
2190
+ const line = finding.line ? lines[finding.line - 1] : null;
2191
+ if (!line) return null;
2192
+ if (line.includes("'*'") || line.includes('"*"')) {
2193
+ return {
2194
+ file: finding.file,
2195
+ line: finding.line,
2196
+ rule: finding.rule,
2197
+ oldContent: line,
2198
+ newContent: line.replace(/(['"])\*\1/, `$1https://yourdomain.com$1 /* TODO: replace with actual origin */`),
2199
+ description: "Replace CORS wildcard with specific origin (needs manual review)"
2200
+ };
2201
+ }
2202
+ return null;
2203
+ }
2204
+ function generatePatches(findings, baseDir) {
2205
+ const patches = [];
2206
+ const fileCache = /* @__PURE__ */ new Map();
2207
+ for (const finding of findings) {
2208
+ const fixer = FIXERS[finding.rule];
2209
+ if (!fixer) continue;
2210
+ const filePath = `${baseDir}/${finding.file}`;
2211
+ let content = fileCache.get(filePath);
2212
+ if (content === void 0) {
2213
+ try {
2214
+ content = readFileSync8(filePath, "utf-8");
2215
+ fileCache.set(filePath, content);
2216
+ } catch {
2217
+ continue;
2218
+ }
2219
+ }
2220
+ const patch = fixer(finding, content);
2221
+ if (patch) patches.push(patch);
2222
+ }
2223
+ return patches;
2224
+ }
2225
+ function applyPatches(patches, baseDir) {
2226
+ const fileEdits = /* @__PURE__ */ new Map();
2227
+ for (const patch of patches) {
2228
+ const file = `${baseDir}/${patch.file}`;
2229
+ if (!fileEdits.has(file)) fileEdits.set(file, /* @__PURE__ */ new Map());
2230
+ fileEdits.get(file).set(patch.line, patch.newContent);
2231
+ }
2232
+ let applied = 0;
2233
+ for (const [file, edits] of fileEdits) {
2234
+ try {
2235
+ const content = readFileSync8(file, "utf-8");
2236
+ const lines = content.split("\n");
2237
+ for (const [lineNum, newContent] of edits) {
2238
+ lines[lineNum - 1] = newContent;
2239
+ applied++;
2240
+ }
2241
+ writeFileSync(file, lines.join("\n"), "utf-8");
2242
+ } catch {
2243
+ }
2244
+ }
2245
+ return applied;
2246
+ }
2247
+ function fixableRules() {
2248
+ return Object.keys(FIXERS);
2249
+ }
2250
+
2251
+ // src/audit/data-flow-analyzer.ts
2252
+ import { readFileSync as readFileSync9 } from "fs";
2253
+ import { join as join4, extname as extname4 } from "path";
2254
+ var PII_PATTERNS2 = [
2255
+ { regex: /\b(email|e[_-]?mail)\b/i, piiType: "email" },
2256
+ { regex: /\b(phone|tel|mobile|phone[_-]?number)\b/i, piiType: "phone" },
2257
+ { regex: /\b(name|first[_-]?name|last[_-]?name|full[_-]?name|user[_-]?name)\b/i, piiType: "name" },
2258
+ { regex: /\b(address|street|city|zip[_-]?code|postal)\b/i, piiType: "address" },
2259
+ { regex: /\b(ssn|social[_-]?security|national[_-]?id)\b/i, piiType: "national-id" },
2260
+ { regex: /\b(credit[_-]?card|card[_-]?number|cvv|ccn)\b/i, piiType: "payment" },
2261
+ { regex: /\b(date[_-]?of[_-]?birth|dob|birth[_-]?date)\b/i, piiType: "date-of-birth" },
2262
+ { regex: /\b(ip[_-]?address|ip[_-]?addr)\b/i, piiType: "ip-address" },
2263
+ { regex: /\b(password|passwd|pwd)\b/i, piiType: "password" }
2264
+ ];
2265
+ var SOURCE_PATTERNS = [
2266
+ // Form fields / input elements
2267
+ { regex: /(?:input|TextField|TextInput).*(?:name|type|id)\s*[=:]\s*['"`]([^'"`]+)/i, type: "form-input" },
2268
+ // Request body / params
2269
+ { regex: /req\.body\.(\w+)/i, type: "request-body" },
2270
+ { regex: /request\.form\[['"](\w+)['"]\]/i, type: "request-form" },
2271
+ { regex: /params\[['"](\w+)['"]\]/i, type: "request-params" },
2272
+ // Destructured body
2273
+ { regex: /const\s*\{[^}]*\}\s*=\s*req\.body/i, type: "destructured-body" }
2274
+ ];
2275
+ var STORAGE_PATTERNS = [
2276
+ // Database columns
2277
+ { regex: /(?:column|field|Column)\s*\(\s*['"](\w+)['"]/i, type: "db-column" },
2278
+ { regex: /(?:CREATE\s+TABLE|ALTER\s+TABLE).*?\b(\w+)\b.*?(?:VARCHAR|TEXT|INT)/i, type: "sql-column" },
2279
+ // Prisma/Drizzle schema
2280
+ { regex: /(\w+)\s+String/i, type: "orm-field" },
2281
+ // localStorage / cookies
2282
+ { regex: /localStorage\.setItem\s*\(\s*['"]([^'"]+)['"]/i, type: "local-storage" },
2283
+ { regex: /document\.cookie\s*=\s*['"`](\w+)/i, type: "cookie" },
2284
+ // Redis / cache
2285
+ { regex: /(?:redis|cache)\.set\s*\(\s*['"]([^'"]+)['"]/i, type: "cache" }
2286
+ ];
2287
+ var SINK_PATTERNS = [
2288
+ // Tracker SDKs
2289
+ { regex: /(?:analytics|gtag|fbq|segment|mixpanel|amplitude)\s*\.\s*(?:track|identify|page|send)\s*\(/i, type: "tracker-sdk" },
2290
+ // HTTP calls to external
2291
+ { regex: /(?:fetch|axios|got|request)\s*\(\s*['"`](https?:\/\/[^'"`]+)/i, type: "http-external" },
2292
+ // Logging
2293
+ { regex: /(?:console\.log|logger\.\w+|winston\.\w+)\s*\(/i, type: "logging" },
2294
+ // Email sending
2295
+ { regex: /(?:sendMail|sendEmail|transport\.send|ses\.send)/i, type: "email-send" },
2296
+ // Webhook / API push
2297
+ { regex: /(?:webhook|callbackUrl|postback)\s*[=:]/i, type: "webhook" }
2298
+ ];
2299
+ var SCANNABLE_EXTS = /* @__PURE__ */ new Set([
2300
+ ".ts",
2301
+ ".tsx",
2302
+ ".js",
2303
+ ".jsx",
2304
+ ".mjs",
2305
+ ".cjs",
2306
+ ".py",
2307
+ ".rb",
2308
+ ".go",
2309
+ ".java",
2310
+ ".rs",
2311
+ ".vue",
2312
+ ".svelte",
2313
+ ".prisma",
2314
+ ".sql"
2315
+ ]);
2316
+ function analyzeDataFlow(files, directory) {
2317
+ const nodes = [];
2318
+ const nodeIndex = /* @__PURE__ */ new Map();
2319
+ for (const file of files) {
2320
+ if (!SCANNABLE_EXTS.has(extname4(file))) continue;
2321
+ let content;
2322
+ try {
2323
+ content = readFileSync9(join4(directory, file), "utf-8");
2324
+ } catch {
2325
+ continue;
2326
+ }
2327
+ const lines = content.split("\n");
2328
+ for (let i = 0; i < lines.length; i++) {
2329
+ const line = lines[i];
2330
+ for (const pii of PII_PATTERNS2) {
2331
+ if (!pii.regex.test(line)) continue;
2332
+ for (const src of SOURCE_PATTERNS) {
2333
+ if (src.regex.test(line)) {
2334
+ const key = `source:${pii.piiType}:${file}:${i + 1}`;
2335
+ if (!nodeIndex.has(key)) {
2336
+ nodeIndex.set(key, nodes.length);
2337
+ nodes.push({
2338
+ type: "source",
2339
+ label: `User Input (${pii.piiType})`,
2340
+ file,
2341
+ line: i + 1,
2342
+ piiType: pii.piiType,
2343
+ detail: src.type
2344
+ });
2345
+ }
2346
+ }
2347
+ }
2348
+ for (const store of STORAGE_PATTERNS) {
2349
+ if (store.regex.test(line)) {
2350
+ const key = `storage:${pii.piiType}:${file}:${i + 1}`;
2351
+ if (!nodeIndex.has(key)) {
2352
+ nodeIndex.set(key, nodes.length);
2353
+ nodes.push({
2354
+ type: "storage",
2355
+ label: `Storage (${pii.piiType})`,
2356
+ file,
2357
+ line: i + 1,
2358
+ piiType: pii.piiType,
2359
+ detail: store.type
2360
+ });
2361
+ }
2362
+ }
2363
+ }
2364
+ for (const sink of SINK_PATTERNS) {
2365
+ if (sink.regex.test(line)) {
2366
+ const key = `sink:${pii.piiType}:${file}:${i + 1}`;
2367
+ if (!nodeIndex.has(key)) {
2368
+ nodeIndex.set(key, nodes.length);
2369
+ nodes.push({
2370
+ type: "sink",
2371
+ label: `Sink (${pii.piiType})`,
2372
+ file,
2373
+ line: i + 1,
2374
+ piiType: pii.piiType,
2375
+ detail: sink.type
2376
+ });
2377
+ }
2378
+ }
2379
+ }
2380
+ }
2381
+ }
2382
+ }
2383
+ const edges = [];
2384
+ const byPii = /* @__PURE__ */ new Map();
2385
+ for (const [key, idx] of nodeIndex) {
2386
+ const [type, piiType] = key.split(":");
2387
+ if (!byPii.has(piiType)) byPii.set(piiType, { sources: [], storage: [], sinks: [] });
2388
+ const group = byPii.get(piiType);
2389
+ if (type === "source") group.sources.push(idx);
2390
+ else if (type === "storage") group.storage.push(idx);
2391
+ else if (type === "sink") group.sinks.push(idx);
2392
+ }
2393
+ for (const [, group] of byPii) {
2394
+ for (const src of group.sources) {
2395
+ for (const sto of group.storage) {
2396
+ edges.push({ from: src, to: sto, label: "stores" });
2397
+ }
2398
+ }
2399
+ for (const sto of group.storage) {
2400
+ for (const sink of group.sinks) {
2401
+ edges.push({ from: sto, to: sink, label: "sends" });
2402
+ }
2403
+ }
2404
+ if (group.storage.length === 0) {
2405
+ for (const src of group.sources) {
2406
+ for (const sink of group.sinks) {
2407
+ edges.push({ from: src, to: sink, label: "direct" });
2408
+ }
2409
+ }
2410
+ }
2411
+ }
2412
+ return { nodes, edges };
2413
+ }
2414
+ var NODE_SHAPES = {
2415
+ source: ["([", "])"],
2416
+ // stadium shape
2417
+ storage: ["[(", ")]"],
2418
+ // cylinder
2419
+ sink: ["[[", "]]"]
2420
+ // rectangle
2421
+ };
2422
+ function toMermaid(flow) {
2423
+ if (flow.nodes.length === 0) return "graph LR\n empty[No PII data flows detected]";
2424
+ const lines = ["graph LR"];
2425
+ for (let i = 0; i < flow.nodes.length; i++) {
2426
+ const node = flow.nodes[i];
2427
+ const [open, close] = NODE_SHAPES[node.type];
2428
+ const label = `${node.label}\\n${node.file}:${node.line}`;
2429
+ lines.push(` n${i}${open}"${label}"${close}`);
2430
+ }
2431
+ for (const edge of flow.edges) {
2432
+ const label = edge.label ? `|${edge.label}|` : "";
2433
+ lines.push(` n${edge.from} -->${label} n${edge.to}`);
2434
+ }
2435
+ lines.push(" classDef source fill:#4caf50,stroke:#2e7d32,color:#fff");
2436
+ lines.push(" classDef storage fill:#2196f3,stroke:#1565c0,color:#fff");
2437
+ lines.push(" classDef sink fill:#f44336,stroke:#c62828,color:#fff");
2438
+ const sources = flow.nodes.map((n, i) => n.type === "source" ? `n${i}` : "").filter(Boolean);
2439
+ const storage = flow.nodes.map((n, i) => n.type === "storage" ? `n${i}` : "").filter(Boolean);
2440
+ const sinks = flow.nodes.map((n, i) => n.type === "sink" ? `n${i}` : "").filter(Boolean);
2441
+ if (sources.length) lines.push(` class ${sources.join(",")} source`);
2442
+ if (storage.length) lines.push(` class ${storage.join(",")} storage`);
2443
+ if (sinks.length) lines.push(` class ${sinks.join(",")} sink`);
2444
+ return lines.join("\n");
2445
+ }
2446
+ function toTextSummary(flow) {
2447
+ if (flow.nodes.length === 0) return "No PII data flows detected.";
2448
+ const lines = [];
2449
+ lines.push("PII Data Flow Analysis");
2450
+ lines.push("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
2451
+ const byPii = /* @__PURE__ */ new Map();
2452
+ for (const node of flow.nodes) {
2453
+ if (!byPii.has(node.piiType)) byPii.set(node.piiType, []);
2454
+ byPii.get(node.piiType).push(node);
2455
+ }
2456
+ for (const [piiType, piiNodes] of byPii) {
2457
+ lines.push(`\u{1F4CC} ${piiType.toUpperCase()}`);
2458
+ lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2459
+ const sources = piiNodes.filter((n) => n.type === "source");
2460
+ const storage = piiNodes.filter((n) => n.type === "storage");
2461
+ const sinks = piiNodes.filter((n) => n.type === "sink");
2462
+ if (sources.length) {
2463
+ lines.push(" Sources (user input):");
2464
+ for (const s of sources) lines.push(` \u2192 ${s.detail} at ${s.file}:${s.line}`);
2465
+ }
2466
+ if (storage.length) {
2467
+ lines.push(" Storage:");
2468
+ for (const s of storage) lines.push(` \u{1F4BE} ${s.detail} at ${s.file}:${s.line}`);
2469
+ }
2470
+ if (sinks.length) {
2471
+ lines.push(" Sinks (data exits):");
2472
+ for (const s of sinks) lines.push(` \u2B06\uFE0F ${s.detail} at ${s.file}:${s.line}`);
2473
+ }
2474
+ lines.push("");
2475
+ }
2476
+ lines.push(`Total: ${flow.nodes.length} nodes, ${flow.edges.length} data flows`);
2477
+ return lines.join("\n");
2478
+ }
2479
+
2480
+ // src/audit/policy-generator.ts
2481
+ var CATEGORY_LABELS = {
2482
+ analytics: "Website Analytics",
2483
+ advertising: "Advertising & Conversion Tracking",
2484
+ social: "Social Media Integration",
2485
+ tag_manager: "Tag Management",
2486
+ heatmaps: "Heatmap & Session Recording",
2487
+ ab_testing: "A/B Testing",
2488
+ error_tracking: "Error Monitoring",
2489
+ chat: "Live Chat & Customer Support",
2490
+ video: "Video Embedding",
2491
+ cdn: "Content Delivery",
2492
+ payments: "Payment Processing",
2493
+ consent: "Cookie Consent Management",
2494
+ security: "Security",
2495
+ fonts: "Web Fonts",
2496
+ other: "Third-Party Services"
2497
+ };
2498
+ var CATEGORY_PURPOSES = {
2499
+ analytics: "to understand how visitors interact with our website, including page views, session duration, and user behavior",
2500
+ advertising: "for advertising, retargeting, and conversion tracking to measure the effectiveness of our marketing campaigns",
2501
+ social: "to enable social media sharing, login, and integration features",
2502
+ tag_manager: "to manage third-party scripts and tags on our website",
2503
+ heatmaps: "to record user interactions such as clicks, scrolls, and mouse movements for improving user experience",
2504
+ ab_testing: "to test different versions of our website and optimize the user experience",
2505
+ error_tracking: "to monitor and diagnose technical errors and improve website reliability",
2506
+ chat: "to provide live chat and customer support functionality",
2507
+ video: "to embed and deliver video content",
2508
+ cdn: "for content delivery and performance optimization",
2509
+ payments: "to process payments securely",
2510
+ consent: "to manage cookie consent preferences",
2511
+ security: "for security monitoring and fraud prevention",
2512
+ fonts: "to deliver custom web fonts",
2513
+ other: "for various operational purposes"
2514
+ };
2515
+ var PII_LABELS = {
2516
+ email: "Email addresses",
2517
+ ip: "IP addresses",
2518
+ name: "Names",
2519
+ phone: "Phone numbers",
2520
+ address: "Physical addresses",
2521
+ ssn: "Government identification numbers",
2522
+ "credit-card": "Payment card information",
2523
+ "date-of-birth": "Date of birth",
2524
+ password: "Passwords (hashed)",
2525
+ geolocation: "Geolocation data",
2526
+ "device-id": "Device identifiers",
2527
+ cookie: "Cookies and tracking identifiers"
2528
+ };
2529
+ function mergeVendors(codeVendorIds, networkVendorIds, registry) {
2530
+ const allIds = /* @__PURE__ */ new Set([...codeVendorIds, ...networkVendorIds]);
2531
+ const entries = [];
2532
+ for (const id of allIds) {
2533
+ const vendor = registry.getById(id);
2534
+ if (!vendor) continue;
2535
+ const skipCategories = ["cdn", "consent", "security", "fonts"];
2536
+ if (skipCategories.includes(vendor.category)) continue;
2537
+ const inCode = codeVendorIds.has(id);
2538
+ const inNetwork = networkVendorIds.has(id);
2539
+ entries.push({
2540
+ vendorId: vendor.id,
2541
+ vendorName: vendor.name,
2542
+ company: vendor.company,
2543
+ category: vendor.category,
2544
+ purpose: vendor.purpose,
2545
+ dataCollected: vendor.data_collected,
2546
+ privacyPolicyUrl: vendor.privacy_policy,
2547
+ dpaUrl: vendor.dpa_url,
2548
+ retentionPeriod: vendor.retention_period,
2549
+ gdprCompliant: vendor.gdpr_compliant,
2550
+ source: inCode && inNetwork ? "both" : inCode ? "code" : "network"
2551
+ });
2552
+ }
2553
+ const categoryOrder = {
2554
+ advertising: 0,
2555
+ social: 1,
2556
+ analytics: 2,
2557
+ heatmaps: 3,
2558
+ ab_testing: 4,
2559
+ tag_manager: 5,
2560
+ error_tracking: 6,
2561
+ chat: 7,
2562
+ video: 8,
2563
+ payments: 9,
2564
+ other: 10
2565
+ };
2566
+ entries.sort((a, b) => (categoryOrder[a.category] ?? 10) - (categoryOrder[b.category] ?? 10));
2567
+ return entries;
2568
+ }
2569
+ function extractPiiTypes(dataFlow) {
2570
+ if (!dataFlow) return [];
2571
+ const types = /* @__PURE__ */ new Set();
2572
+ for (const node of dataFlow.nodes) {
2573
+ if (node.piiType) {
2574
+ types.add(node.piiType);
2575
+ }
2576
+ }
2577
+ return Array.from(types);
2578
+ }
2579
+ function extractCodeVendorIds(audit) {
2580
+ if (!audit) return /* @__PURE__ */ new Set();
2581
+ const ids = /* @__PURE__ */ new Set();
2582
+ for (const finding of audit.findings) {
2583
+ if (finding.vendorId) {
2584
+ ids.add(finding.vendorId);
2585
+ }
2586
+ }
2587
+ return ids;
2588
+ }
2589
+ function generateIntroSection(input) {
2590
+ const date = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", {
2591
+ year: "numeric",
2592
+ month: "long",
2593
+ day: "numeric"
2594
+ });
2595
+ return {
2596
+ id: "introduction",
2597
+ title: "Introduction & Data Controller",
2598
+ content: [
2599
+ `This privacy policy explains how **${input.companyName}** ("we", "us", "our") collects, uses, and protects your personal data when you use our website${input.siteUrl ? ` (${input.siteUrl})` : ""} and services.`,
2600
+ "",
2601
+ `**Data Controller:** ${input.companyName}`,
2602
+ `**Contact:** ${input.companyEmail}`,
2603
+ input.companyCountry ? `**Jurisdiction:** ${input.companyCountry}` : "",
2604
+ "",
2605
+ `*Last updated: ${date}*`
2606
+ ].filter(Boolean).join("\n")
2607
+ };
2608
+ }
2609
+ function generateDataCollectionSection(piiTypes) {
2610
+ if (piiTypes.length === 0) {
2611
+ return {
2612
+ id: "data-collection",
2613
+ title: "Data We Collect",
2614
+ content: "We collect standard usage data such as IP addresses, browser type, and pages visited when you use our website."
2615
+ };
2616
+ }
2617
+ const items = piiTypes.map((t) => {
2618
+ const label = PII_LABELS[t] ?? t;
2619
+ return `- ${label}`;
2620
+ });
2621
+ return {
2622
+ id: "data-collection",
2623
+ title: "Data We Collect",
2624
+ content: [
2625
+ "We collect the following categories of personal data:",
2626
+ "",
2627
+ ...items,
2628
+ "",
2629
+ "Additionally, we automatically collect standard usage data such as IP addresses, browser type, device information, and pages visited."
2630
+ ].join("\n")
2631
+ };
2632
+ }
2633
+ function generateThirdPartiesSection(vendors) {
2634
+ if (vendors.length === 0) {
2635
+ return {
2636
+ id: "third-parties",
2637
+ title: "Third-Party Services",
2638
+ content: "We do not currently use third-party tracking or analytics services on our website."
2639
+ };
2640
+ }
2641
+ const groups = /* @__PURE__ */ new Map();
2642
+ for (const v of vendors) {
2643
+ const existing = groups.get(v.category) ?? [];
2644
+ existing.push(v);
2645
+ groups.set(v.category, existing);
2646
+ }
2647
+ const parts = [
2648
+ "We use the following third-party services:",
2649
+ ""
2650
+ ];
2651
+ for (const [category, categoryVendors] of groups) {
2652
+ const label = CATEGORY_LABELS[category] ?? category;
2653
+ parts.push(`### ${label}`);
2654
+ parts.push("");
2655
+ for (const v of categoryVendors) {
2656
+ let snippet = `**${v.vendorName}**`;
2657
+ if (v.company && v.company !== v.vendorName) {
2658
+ snippet += ` (provided by ${v.company})`;
2659
+ }
2660
+ const purposeText = CATEGORY_PURPOSES[v.category] ?? v.purpose;
2661
+ snippet += ` \u2014 We use this service ${purposeText}.`;
2662
+ if (v.dataCollected.length > 0) {
2663
+ snippet += ` This service may collect: ${v.dataCollected.join(", ")}.`;
2664
+ }
2665
+ if (v.retentionPeriod) {
2666
+ snippet += ` Data retention: ${v.retentionPeriod}.`;
2667
+ }
2668
+ if (v.privacyPolicyUrl) {
2669
+ snippet += ` [Privacy Policy](${v.privacyPolicyUrl})`;
2670
+ }
2671
+ parts.push(snippet);
2672
+ parts.push("");
2673
+ }
2674
+ }
2675
+ return {
2676
+ id: "third-parties",
2677
+ title: "Third-Party Services",
2678
+ content: parts.join("\n")
2679
+ };
2680
+ }
2681
+ function generateCookiesSection(vendors) {
2682
+ const cookieVendors = vendors.filter(
2683
+ (v) => ["analytics", "advertising", "social", "heatmaps", "ab_testing", "tag_manager"].includes(v.category)
2684
+ );
2685
+ if (cookieVendors.length === 0) {
2686
+ return {
2687
+ id: "cookies",
2688
+ title: "Cookies & Tracking Technologies",
2689
+ content: [
2690
+ "We use essential cookies required for the basic functioning of our website. These cookies do not require consent under GDPR as they are strictly necessary.",
2691
+ "",
2692
+ "We do not use analytics or advertising cookies."
2693
+ ].join("\n")
2694
+ };
2695
+ }
2696
+ const rows = cookieVendors.map((v) => {
2697
+ const cat = CATEGORY_LABELS[v.category] ?? v.category;
2698
+ const data = v.dataCollected.length > 0 ? v.dataCollected.slice(0, 3).join(", ") : "Usage data";
2699
+ const retention = v.retentionPeriod ?? "See provider policy";
2700
+ return `| ${v.vendorName} | ${cat} | ${data} | ${retention} |`;
2701
+ });
2702
+ return {
2703
+ id: "cookies",
2704
+ title: "Cookies & Tracking Technologies",
2705
+ content: [
2706
+ "We use cookies and similar tracking technologies on our website. Below is a summary of the cookies set by third-party services:",
2707
+ "",
2708
+ "| Provider | Category | Data Collected | Retention |",
2709
+ "|----------|----------|----------------|-----------|",
2710
+ ...rows,
2711
+ "",
2712
+ "**Essential cookies** required for basic website functionality do not require consent. All other cookies require your explicit consent before being set.",
2713
+ "",
2714
+ "You can manage your cookie preferences at any time through your browser settings or our cookie consent banner."
2715
+ ].join("\n")
2716
+ };
2717
+ }
2718
+ function generateDataTransferSection(vendors) {
2719
+ const nonCompliant = vendors.filter((v) => !v.gdprCompliant);
2720
+ if (nonCompliant.length === 0) {
2721
+ return {
2722
+ id: "data-transfers",
2723
+ title: "International Data Transfers",
2724
+ content: "All third-party services we use are GDPR-compliant and process data within the EU/EEA or under appropriate safeguards (such as Standard Contractual Clauses)."
2725
+ };
2726
+ }
2727
+ const lines = nonCompliant.map((v) => {
2728
+ let line = `- **${v.vendorName}** (${v.company})`;
2729
+ if (v.dpaUrl) {
2730
+ line += ` \u2014 [Data Processing Agreement](${v.dpaUrl})`;
2731
+ }
2732
+ return line;
2733
+ });
2734
+ return {
2735
+ id: "data-transfers",
2736
+ title: "International Data Transfers",
2737
+ content: [
2738
+ "Some of our third-party service providers may process data outside the EU/EEA. We ensure appropriate safeguards are in place, including Standard Contractual Clauses (SCCs) and adequacy decisions.",
2739
+ "",
2740
+ "The following services may transfer data internationally:",
2741
+ "",
2742
+ ...lines
2743
+ ].join("\n")
2744
+ };
2745
+ }
2746
+ function generateRetentionSection(vendors) {
2747
+ const withRetention = vendors.filter((v) => v.retentionPeriod);
2748
+ return {
2749
+ id: "data-retention",
2750
+ title: "Data Retention",
2751
+ content: [
2752
+ "We retain personal data only for as long as necessary to fulfill the purposes for which it was collected, or as required by law.",
2753
+ "",
2754
+ ...withRetention.length > 0 ? [
2755
+ "Retention periods for third-party services:",
2756
+ "",
2757
+ ...withRetention.map((v) => `- **${v.vendorName}**: ${v.retentionPeriod}`),
2758
+ ""
2759
+ ] : [],
2760
+ "When data is no longer needed, it is securely deleted or anonymized."
2761
+ ].join("\n")
2762
+ };
2763
+ }
2764
+ function generateRightsSection() {
2765
+ return {
2766
+ id: "your-rights",
2767
+ title: "Your Rights Under GDPR",
2768
+ content: [
2769
+ "Under the General Data Protection Regulation (GDPR), you have the following rights regarding your personal data:",
2770
+ "",
2771
+ "- **Right of Access** (Art. 15) \u2014 You can request a copy of the personal data we hold about you.",
2772
+ "- **Right to Rectification** (Art. 16) \u2014 You can request correction of inaccurate or incomplete data.",
2773
+ '- **Right to Erasure** (Art. 17) \u2014 You can request deletion of your personal data ("right to be forgotten").',
2774
+ "- **Right to Restrict Processing** (Art. 18) \u2014 You can request that we limit how we use your data.",
2775
+ "- **Right to Data Portability** (Art. 20) \u2014 You can request your data in a structured, machine-readable format.",
2776
+ "- **Right to Object** (Art. 21) \u2014 You can object to processing based on legitimate interests or direct marketing.",
2777
+ "- **Right to Withdraw Consent** (Art. 7(3)) \u2014 You can withdraw consent at any time without affecting prior processing.",
2778
+ "",
2779
+ "To exercise any of these rights, please contact us using the information provided above."
2780
+ ].join("\n")
2781
+ };
2782
+ }
2783
+ function generateContactSection(input) {
2784
+ return {
2785
+ id: "contact",
2786
+ title: "Contact & Data Protection Officer",
2787
+ content: [
2788
+ `For any privacy-related questions or to exercise your rights, please contact:`,
2789
+ "",
2790
+ `**${input.companyName}**`,
2791
+ `Email: ${input.companyEmail}`,
2792
+ "",
2793
+ "If you believe that your data protection rights have been violated, you have the right to lodge a complaint with a supervisory authority in the EU member state of your habitual residence, place of work, or place of the alleged infringement."
2794
+ ].join("\n")
2795
+ };
2796
+ }
2797
+ function generatePolicy(opts) {
2798
+ const registry = VendorRegistry.load();
2799
+ const codeVendorIds = extractCodeVendorIds(opts.audit);
2800
+ const networkIds = opts.networkVendorIds ?? /* @__PURE__ */ new Set();
2801
+ const piiTypes = extractPiiTypes(opts.dataFlow);
2802
+ const vendors = mergeVendors(codeVendorIds, networkIds, registry);
2803
+ const sources = [];
2804
+ if (opts.audit) sources.push("code-audit");
2805
+ if (opts.networkVendorIds) sources.push("network-scan");
2806
+ if (opts.dataFlow) sources.push("data-flow-analysis");
2807
+ const sections = [
2808
+ generateIntroSection(opts.input),
2809
+ generateDataCollectionSection(piiTypes),
2810
+ generateThirdPartiesSection(vendors),
2811
+ generateCookiesSection(vendors),
2812
+ generateDataTransferSection(vendors),
2813
+ generateRetentionSection(vendors),
2814
+ generateRightsSection(),
2815
+ generateContactSection(opts.input)
2816
+ ];
2817
+ const fullText = assemblePolicy(opts.input, sections);
2818
+ return {
2819
+ sections,
2820
+ vendors,
2821
+ piiTypes,
2822
+ fullText,
2823
+ meta: {
2824
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2825
+ etalonVersion: "1.0.0",
2826
+ sources
2827
+ }
2828
+ };
2829
+ }
2830
+ function assemblePolicy(input, sections) {
2831
+ const lines = [
2832
+ `# Privacy Policy \u2014 ${input.companyName}`,
2833
+ ""
2834
+ ];
2835
+ for (let i = 0; i < sections.length; i++) {
2836
+ const section = sections[i];
2837
+ lines.push(`## ${i + 1}. ${section.title}`);
2838
+ lines.push("");
2839
+ lines.push(section.content);
2840
+ lines.push("");
2841
+ lines.push("---");
2842
+ lines.push("");
2843
+ }
2844
+ lines.push("");
2845
+ lines.push("> **Disclaimer:** This privacy policy was auto-generated by [ETALON](https://etalon.dev) based on an automated scan of the website and codebase. It should be reviewed by a qualified legal professional before publication. ETALON does not provide legal advice.");
2846
+ lines.push("");
2847
+ return lines.join("\n");
2848
+ }
2849
+
2850
+ // src/audit/index.ts
2851
+ import { readdirSync } from "fs";
2852
+ function findPatternsFile() {
2853
+ const currentDir = dirname2(fileURLToPath2(import.meta.url));
2854
+ let dir = currentDir;
2855
+ for (let i = 0; i < 6; i++) {
2856
+ const candidate = join5(dir, "data", "tracker-patterns.json");
2857
+ if (existsSync5(candidate)) return candidate;
2858
+ dir = dirname2(dir);
2859
+ }
2860
+ return null;
2861
+ }
2862
+ async function auditProject(directory, options = {}) {
2863
+ const startTime = Date.now();
2864
+ const stack = detectStack(directory);
2865
+ let patterns;
2866
+ const patternsPath = findPatternsFile();
2867
+ if (patternsPath) {
2868
+ patterns = JSON.parse(readFileSync10(patternsPath, "utf-8"));
2869
+ } else {
2870
+ patterns = { npm: {}, pypi: {}, cargo: {}, envVars: {}, htmlPatterns: [], importPatterns: [] };
2871
+ }
2872
+ const allFiles = collectFiles(directory);
2873
+ const codeFindings = scanCode(allFiles, directory, stack, patterns);
2874
+ const schemaFindings = scanSchemas(allFiles, directory, stack);
2875
+ const configFindings = scanConfigs(allFiles, directory, stack);
2876
+ const serverFindings = scanServerTracking(allFiles, directory, stack);
2877
+ const cnameFindings = scanCnameCloaking(allFiles, directory, stack);
2878
+ const { loadCustomRules: loadCustomRules2, scanWithCustomRules: scanWithCustomRules2 } = await import("./plugin-loader-TQ2IIQED.js");
2879
+ const customRules = loadCustomRules2(directory);
2880
+ const customFindings = scanWithCustomRules2(allFiles, directory, customRules);
2881
+ let findings = [...codeFindings, ...schemaFindings, ...configFindings, ...serverFindings, ...cnameFindings, ...customFindings];
2882
+ if (options.severity) {
2883
+ const minLevel = severityLevel(options.severity);
2884
+ findings = findings.filter((f) => severityLevel(f.severity) >= minLevel);
2885
+ }
2886
+ findings.sort((a, b) => severityLevel(b.severity) - severityLevel(a.severity));
2887
+ if (options.includeBlame) {
2888
+ findings = enrichFindings(findings, directory);
2889
+ }
2890
+ findings = enrichWithGdpr(findings);
2891
+ const summary = summarizeFindings(findings);
2892
+ const recommendations = generateRecommendations(findings, stack);
2893
+ const durationMs = Date.now() - startTime;
2894
+ const report = {
2895
+ meta: {
2896
+ etalonVersion: "1.0.0",
2897
+ auditDate: (/* @__PURE__ */ new Date()).toISOString(),
2898
+ auditDurationMs: durationMs,
2899
+ directory,
2900
+ stack
2901
+ },
2902
+ summary,
2903
+ findings,
2904
+ recommendations
2905
+ };
2906
+ report.score = calculateScore(report);
2907
+ return report;
2908
+ }
2909
+ var IGNORE_DIRS2 = /* @__PURE__ */ new Set([
2910
+ "node_modules",
2911
+ ".next",
2912
+ ".nuxt",
2913
+ "__pycache__",
2914
+ ".git",
2915
+ "dist",
2916
+ "build",
2917
+ "target",
2918
+ ".venv",
2919
+ "venv",
2920
+ "env",
2921
+ ".tox",
2922
+ ".mypy_cache",
2923
+ "vendor",
2924
+ ".cargo",
2925
+ "coverage",
2926
+ ".turbo",
2927
+ ".svelte-kit",
2928
+ ".vercel",
2929
+ ".output"
2930
+ ]);
2931
+ function collectFiles(dir, maxDepth = 8) {
2932
+ const files = [];
2933
+ function walk(currentDir, depth) {
2934
+ if (depth > maxDepth) return;
2935
+ let entries;
2936
+ try {
2937
+ entries = readdirSync(currentDir, { withFileTypes: true });
2938
+ } catch {
2939
+ return;
2940
+ }
2941
+ for (const entry of entries) {
2942
+ if (entry.name.startsWith(".") && entry.name !== ".env" && !entry.name.startsWith(".env.")) continue;
2943
+ const fullPath = join5(currentDir, entry.name);
2944
+ if (entry.isDirectory()) {
2945
+ if (!IGNORE_DIRS2.has(entry.name)) {
2946
+ walk(fullPath, depth + 1);
2947
+ }
2948
+ } else if (entry.isFile()) {
2949
+ files.push(fullPath);
2950
+ }
2951
+ }
2952
+ }
2953
+ walk(dir, 0);
2954
+ return files;
2955
+ }
2956
+ function severityLevel(severity) {
2957
+ switch (severity) {
2958
+ case "critical":
2959
+ return 5;
2960
+ case "high":
2961
+ return 4;
2962
+ case "medium":
2963
+ return 3;
2964
+ case "low":
2965
+ return 2;
2966
+ case "info":
2967
+ return 1;
2968
+ default:
2969
+ return 0;
2970
+ }
2971
+ }
2972
+ function summarizeFindings(findings) {
2973
+ return {
2974
+ totalFindings: findings.length,
2975
+ critical: findings.filter((f) => f.severity === "critical").length,
2976
+ high: findings.filter((f) => f.severity === "high").length,
2977
+ medium: findings.filter((f) => f.severity === "medium").length,
2978
+ low: findings.filter((f) => f.severity === "low").length,
2979
+ info: findings.filter((f) => f.severity === "info").length,
2980
+ trackerSdksFound: new Set(findings.filter((f) => f.category === "code" && f.vendorId).map((f) => f.vendorId)).size,
2981
+ piiColumnsFound: findings.filter((f) => f.rule === "pii-column").length,
2982
+ configIssues: findings.filter((f) => f.category === "config").length
2983
+ };
2984
+ }
2985
+ function generateRecommendations(findings, _stack) {
2986
+ const recs = [];
2987
+ const trackerCount = new Set(findings.filter((f) => f.category === "code" && f.vendorId).map((f) => f.vendorId)).size;
2988
+ if (trackerCount > 0) {
2989
+ recs.push(`Found ${trackerCount} third-party tracker SDK(s) in your codebase. Ensure each is documented in your privacy policy and loaded only after user consent.`);
2990
+ }
2991
+ const piiCount = findings.filter((f) => f.rule === "pii-column").length;
2992
+ if (piiCount > 0) {
2993
+ recs.push(`Found ${piiCount} PII column(s) in your database schemas. Review each for encryption, anonymization, and retention policies.`);
2994
+ }
2995
+ const criticalCount = findings.filter((f) => f.severity === "critical").length;
2996
+ if (criticalCount > 0) {
2997
+ recs.push(`${criticalCount} critical finding(s) need immediate attention \u2014 these represent serious privacy or security risks.`);
2998
+ }
2999
+ const cookieIssues = findings.filter((f) => f.rule.startsWith("cookie-"));
3000
+ if (cookieIssues.length > 0) {
3001
+ recs.push(`${cookieIssues.length} cookie configuration issue(s) found. Set Secure, HttpOnly, and SameSite flags on all cookies.`);
3002
+ }
3003
+ const noRetention = findings.filter((f) => f.rule === "no-retention-policy");
3004
+ if (noRetention.length > 0) {
3005
+ recs.push(`${noRetention.length} database table(s) with PII lack retention policies. Add deleted_at/expires_at columns and implement cleanup jobs.`);
3006
+ }
3007
+ if (findings.some((f) => f.rule === "missing-csp")) {
3008
+ recs.push("Add a Content-Security-Policy header to prevent unauthorized tracker injection via XSS.");
3009
+ }
3010
+ if (recs.length === 0) {
3011
+ recs.push("No significant GDPR compliance issues found. Keep it up! \u{1F389}");
3012
+ }
3013
+ return recs;
3014
+ }
3015
+ export {
3016
+ GDPR_RULE_MAP,
3017
+ VendorRegistry,
3018
+ analyzeDataFlow,
3019
+ applyPatches,
3020
+ auditProject,
3021
+ badgeMarkdown,
3022
+ badgeUrl,
3023
+ calculateScore,
3024
+ detectStack,
3025
+ diffReports,
3026
+ enrichFindings,
3027
+ enrichWithGdpr,
3028
+ extractDomain,
3029
+ fixableRules,
3030
+ formatAuditSarif,
3031
+ generateBadgeSvg,
3032
+ generatePatches,
3033
+ generatePolicy,
3034
+ getBlameForLine,
3035
+ getParentDomains,
3036
+ gradeColor,
3037
+ groupByAuthor,
3038
+ isFirstParty,
3039
+ isGitRepo,
3040
+ loadCustomRules,
3041
+ normalizeUrl,
3042
+ scanCnameCloaking,
3043
+ scanCode,
3044
+ scanConfigs,
3045
+ scanSchemas,
3046
+ scanServerTracking,
3047
+ scanWithCustomRules,
3048
+ shouldBlock,
3049
+ toMermaid,
3050
+ toTextSummary
3051
+ };