diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 780f62ac3..c1e93d3a0 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -119,7 +119,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json
- name: Generate bilingual privacy pages
- run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
+ env:
+ GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }}
+ run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID"
- name: Configure optional client measurement
env:
diff --git a/.github/workflows/site-measurement.yml b/.github/workflows/site-measurement.yml
index 9bbc47748..2eebcd2ac 100644
--- a/.github/workflows/site-measurement.yml
+++ b/.github/workflows/site-measurement.yml
@@ -84,7 +84,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json
- name: Generate bilingual privacy pages
- run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
+ env:
+ GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }}
+ run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID"
- name: Configure optional client measurement
env:
@@ -172,7 +174,9 @@ jobs:
run: python scripts/build-product-site.py --output _site --release-evidence /tmp/arsas-published.json
- name: Generate bilingual privacy pages
- run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json
+ env:
+ GA4_MEASUREMENT_ID: ${{ vars.GA4_MEASUREMENT_ID }}
+ run: python scripts/generate-privacy-pages.py --output _site --release-evidence /tmp/arsas-published.json --measurement-id "$GA4_MEASUREMENT_ID"
- name: Configure current client measurement
env:
diff --git a/docs/website-measurement.md b/docs/website-measurement.md
index 4f63dd93a..c0645caa7 100644
--- a/docs/website-measurement.md
+++ b/docs/website-measurement.md
@@ -24,8 +24,9 @@ The browser client disables Google advertising signals, does not request ad-pers
- Declining analytics does not change downloads, content access or application behavior.
- The local key `arsas_analytics_consent_v1` stores only `granted` or `denied`.
- Do Not Track overrides a stored grant and keeps analytics disabled.
-- Revoking consent reloads the current page to stop the already-loaded client before further interaction.
-- `privacy.html` and `privasi.html` are bilingual `noindex,follow` policy pages and never load the analytics client.
+- Revoking consent on a product page reloads that page to stop the already-loaded client before further interaction.
+- `privacy.html` and `privasi.html` are bilingual `noindex,follow` preference surfaces. They carry only an inert availability configuration, can save the user’s choice, and never load `analytics.js` or Google Tag.
+- A preference saved on a privacy page takes effect when the next product page is opened.
- Project policy requires a two-month GA4 event-data retention window before analytics is enabled.
## Repository configuration
@@ -57,9 +58,9 @@ Every Pages artifact is stamped after build with:
- stable release version;
- privacy and measurement activation state.
-After `actions/deploy-pages`, `scripts/verify-pages-deployment.py` fetches the public `build-info.json` with cache-busting parameters and retries until the public source commit matches the just-deployed commit. It also verifies both privacy routes, denied-by-default consent metadata, the configured measurement state and the shared consent controls on the homepage.
+After `actions/deploy-pages`, `scripts/verify-pages-deployment.py` fetches the public `build-info.json` with cache-busting parameters and retries until the public source commit matches the just-deployed commit. It also verifies both privacy routes, their inert measurement-availability configuration, the absence of any analytics client on those policy pages, denied-by-default consent metadata, the configured measurement state and the shared consent controls on the homepage.
-A stale public build, missing privacy route or measurement-state mismatch fails the workflow. IndexNow notification depends on this attestation and is skipped when production is stale.
+A stale public build, missing privacy route, unexpected analytics client or measurement-state mismatch fails the workflow. IndexNow notification depends on this attestation and is skipped when production is stale.
The `production-pages-attestation` artifact retains Markdown and JSON evidence for 90 days.
diff --git a/landing/consent.js b/landing/consent.js
index 1f9035345..1522388d4 100644
--- a/landing/consent.js
+++ b/landing/consent.js
@@ -2,6 +2,7 @@
const STORAGE_KEY = 'arsas_analytics_consent_v1';
const EVENT_NAME = 'arsas:consent';
const dntEnabled = navigator.doNotTrack === '1' || window.doNotTrack === '1';
+ const privacyPage = document.body.dataset.privacyPage === 'true';
const banner = document.querySelector('[data-consent-banner]');
const status = document.querySelector('[data-consent-status]');
const analyticsConfig = document.getElementById('arsas-analytics');
@@ -48,12 +49,12 @@
document.documentElement.dataset.analyticsConsent = preference;
setStatus(preference);
window.dispatchEvent(new CustomEvent(EVENT_NAME, {
- detail: { analytics: preference, source, doNotTrack: dntEnabled, available: analyticsAvailable }
+ detail: { analytics: preference, source, doNotTrack: dntEnabled, available: analyticsAvailable, privacyPage }
}));
};
const loadAnalytics = () => {
- if (analyticsLoaded || dntEnabled || !analyticsAvailable) return;
+ if (privacyPage || analyticsLoaded || dntEnabled || !analyticsAvailable) return;
analyticsLoaded = true;
window.gtag('consent', 'update', {
analytics_storage: 'granted',
@@ -98,7 +99,7 @@
hideBanner();
dispatch(effective, 'user-choice');
if (effective === 'granted') loadAnalytics();
- else if (analyticsLoaded || previous === 'granted') window.location.reload();
+ else if (!privacyPage && (analyticsLoaded || previous === 'granted')) window.location.reload();
};
acceptButtons.forEach(button => button.addEventListener('click', () => save('granted')));
@@ -120,6 +121,7 @@
window.ARSASConsent = Object.freeze({
storageKey: STORAGE_KEY,
available: analyticsAvailable,
+ privacyPage,
doNotTrack: dntEnabled,
get: readPreference,
manage: () => showBanner(true),
diff --git a/landing/privacy-source/privacy.en.html.tmpl b/landing/privacy-source/privacy.en.html.tmpl
index 1c0bc544a..1d374c295 100644
--- a/landing/privacy-source/privacy.en.html.tmpl
+++ b/landing/privacy-source/privacy.en.html.tmpl
@@ -29,7 +29,7 @@
-
+
{{> header}}
Privacy · optional analytics · user choice Measurement is optional and disabled until you allow it. ARSAS uses a small, consent-controlled measurement layer to understand product-page usage, download intent, language demand, broken routes and Core Web Vitals. The website remains fully usable when analytics is declined.
Loading analytics preference…
@@ -38,7 +38,7 @@
Consent behavior Google Analytics loads only after approval. The default consent state is denied. A local preference named arsas_analytics_consent_v1 stores only granted or denied. Declining analytics does not block downloads, documentation or any product page. Browser Do Not Track keeps analytics disabled even if a previous preference was granted.
Processors and hosting GitHub hosts the site; Google processes optional analytics. GitHub Pages serves the public files and may process normal web-request metadata under GitHub’s own terms. When consent is granted, Google Analytics receives the aggregate event fields described above under Google’s terms. Private weekly reports are stored as GitHub Actions artifacts rather than being published on this website.
- Retention policy Short operational retention The ARSAS project policy is a two-month event-data retention window for GA4 and a 90-day maximum for private aggregate workflow artifacts. Analytics must remain disabled until the GA4 property is configured consistently with this policy.
Your control Change the preference at any time. Use the analytics-preferences control in the footer or the button below. Revoking consent prevents new analytics events from being sent from subsequent interactions and page loads.
Open analytics preferences
+ Retention policy Short operational retention The ARSAS project policy is a two-month event-data retention window for GA4 and a 90-day maximum for private aggregate workflow artifacts. Analytics must remain disabled until the GA4 property is configured consistently with this policy.
Your control Change the preference at any time. Use the analytics-preferences control in the footer or the button below. This policy page saves your choice without loading the analytics client; the choice takes effect on the next product page. Revoking consent prevents new analytics events from being sent from subsequent interactions and page loads.
Open analytics preferences
Questions or concerns Report a privacy or measurement issue with evidence. Describe the page, browser, observed request or consent behavior. Do not attach confidential project, IED or customer information.
diff --git a/landing/privacy-source/privacy.id.html.tmpl b/landing/privacy-source/privacy.id.html.tmpl
index 167f9bb73..ca2e01b2d 100644
--- a/landing/privacy-source/privacy.id.html.tmpl
+++ b/landing/privacy-source/privacy.id.html.tmpl
@@ -29,7 +29,7 @@
-
+
{{> header}}
Privasi · analitik opsional · pilihan pengguna Measurement bersifat opsional dan nonaktif sampai Anda mengizinkannya. ARSAS memakai measurement kecil berbasis persetujuan untuk memahami penggunaan halaman produk, niat download, kebutuhan bahasa, broken route dan Core Web Vitals. Website tetap berfungsi penuh saat analitik ditolak.
Memuat preferensi analitik…
@@ -38,7 +38,7 @@
Perilaku persetujuan Google Analytics hanya dimuat setelah disetujui. Status awal consent adalah denied. Preferensi lokal bernama arsas_analytics_consent_v1 hanya menyimpan nilai granted atau denied. Menolak analitik tidak memblokir download, dokumentasi atau halaman produk. Browser Do Not Track mempertahankan analitik tetap nonaktif walaupun preferensi sebelumnya pernah diberikan.
Processor dan hosting GitHub meng-host website; Google memproses analitik opsional. GitHub Pages melayani file publik dan dapat memproses metadata request web normal sesuai ketentuan GitHub. Setelah consent diberikan, Google Analytics menerima field event agregat yang dijelaskan di atas sesuai ketentuan Google. Laporan mingguan privat disimpan sebagai artifact GitHub Actions dan tidak dipublikasikan di website.
- Kebijakan retensi Retensi operasional singkat Kebijakan proyek ARSAS adalah retensi event-data GA4 selama dua bulan dan maksimal 90 hari untuk artifact workflow agregat privat. Analitik harus tetap nonaktif sampai property GA4 dikonfigurasi konsisten dengan kebijakan ini.
Kontrol Anda Preferensi dapat diubah kapan saja. Gunakan kontrol preferensi analitik di footer atau tombol berikut. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.
Buka preferensi analitik
+ Kebijakan retensi Retensi operasional singkat Kebijakan proyek ARSAS adalah retensi event-data GA4 selama dua bulan dan maksimal 90 hari untuk artifact workflow agregat privat. Analitik harus tetap nonaktif sampai property GA4 dikonfigurasi konsisten dengan kebijakan ini.
Kontrol Anda Preferensi dapat diubah kapan saja. Gunakan kontrol preferensi analitik di footer atau tombol berikut. Halaman kebijakan ini menyimpan pilihan tanpa memuat analytics client; pilihan berlaku ketika membuka halaman produk berikutnya. Mencabut consent mencegah event analitik baru dikirim dari interaksi dan page load berikutnya.
Buka preferensi analitik
Pertanyaan atau keberatan Laporkan masalah privasi atau measurement dengan evidence. Jelaskan halaman, browser, request atau perilaku consent yang terlihat. Jangan melampirkan informasi rahasia proyek, IED atau customer.
diff --git a/scripts/generate-privacy-pages.py b/scripts/generate-privacy-pages.py
index 437e35f0f..8e7afe7dd 100644
--- a/scripts/generate-privacy-pages.py
+++ b/scripts/generate-privacy-pages.py
@@ -6,6 +6,7 @@
import argparse
import importlib.util
import json
+import os
import re
import shutil
from pathlib import Path
@@ -13,10 +14,8 @@
ROOT = Path(__file__).resolve().parents[1]
BUILDER_PATH = ROOT / "scripts" / "build-product-site.py"
SOURCE = ROOT / "landing" / "privacy-source"
-ANALYTICS_SCRIPT = re.compile(
- r'\s*',
- re.IGNORECASE,
-)
+MEASUREMENT_PATTERN = re.compile(r"G-[A-Z0-9]+")
+PLACEHOLDER = "__ARSAS_GA4_MEASUREMENT_ID__"
def load_builder():
@@ -32,8 +31,17 @@ def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output", default=str(ROOT / "_site"))
parser.add_argument("--release-evidence", default=str(ROOT / "landing" / "latest.json"))
+ parser.add_argument(
+ "--measurement-id",
+ default=os.environ.get("GA4_MEASUREMENT_ID", ""),
+ help="Public GA4 web-stream ID. It remains inert on privacy pages.",
+ )
args = parser.parse_args()
+ measurement_id = args.measurement_id.strip().upper()
+ if measurement_id and not MEASUREMENT_PATTERN.fullmatch(measurement_id):
+ raise SystemExit("Measurement ID must use the G-XXXXXXXX format")
+
output = Path(args.output).resolve()
evidence_path = Path(args.release_evidence).resolve()
if not output.is_dir() or not (output / "build-info.json").is_file():
@@ -66,9 +74,12 @@ def main() -> int:
raise SystemExit(f"Missing privacy source: {source}")
rendered = builder.render(source.read_text(encoding="utf-8"), values, icon_size)
rendered = builder.inject_alternate_links(rendered, {"template": source.name, "alternates": alternates}, root)
- rendered = ANALYTICS_SCRIPT.sub("", rendered)
- if "__ARSAS_GA4_MEASUREMENT_ID__" in rendered:
- raise SystemExit(f"Privacy page still contains a measurement placeholder: {target.name}")
+ placeholder_count = rendered.count(PLACEHOLDER)
+ if placeholder_count != 1:
+ raise SystemExit(f"{target.name} must contain exactly one inert analytics configuration")
+ rendered = rendered.replace(PLACEHOLDER, measurement_id)
+ if 'src="analytics.js"' in rendered:
+ raise SystemExit(f"Privacy page must not load analytics.js: {target.name}")
target.write_text(rendered, encoding="utf-8")
build_info_path = output / "build-info.json"
@@ -80,11 +91,14 @@ def main() -> int:
"defaultAnalyticsConsent": "denied",
"preferenceStorage": "localStorage",
"preferenceKey": "arsas_analytics_consent_v1",
+ "measurementAvailable": bool(measurement_id),
+ "analyticsClientLoadedOnPolicyPages": False,
}
build_info_path.write_text(json.dumps(build_info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
shutil.rmtree(output / "privacy-source", ignore_errors=True)
- print("Generated privacy.html and privasi.html with noindex, consent controls and shared ARSAS chrome.")
+ state = "measurement available" if measurement_id else "measurement disabled"
+ print(f"Generated privacy.html and privasi.html with {state}; policy pages never load analytics.js.")
return 0
diff --git a/scripts/validate-site-measurement.py b/scripts/validate-site-measurement.py
index 40a60ad9b..62eecef3c 100644
--- a/scripts/validate-site-measurement.py
+++ b/scripts/validate-site-measurement.py
@@ -20,6 +20,7 @@ def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.scripts: list[dict[str, str | None]] = []
self.body_page: str | None = None
+ self.privacy_page = False
self.language: str | None = None
self.robots: str | None = None
self.consent_banners = 0
@@ -33,6 +34,7 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None
self.language = values.get("lang")
elif tag == "body":
self.body_page = values.get("data-page")
+ self.privacy_page = values.get("data-privacy-page") == "true"
elif tag == "script":
self.scripts.append(values)
elif tag == "meta" and values.get("name", "").lower() == "robots":
@@ -54,6 +56,15 @@ def parse_page(path: Path) -> tuple[str, Parser]:
return text, parsed
+def validate_inert_config(config: dict[str, str | None], expected_id: str, label: str, errors: list[str]) -> None:
+ if config.get("type") != "application/json" or config.get("src") is not None:
+ errors.append(f"{label}: analytics configuration must be inert and local")
+ if (config.get("data-measurement-id") or "") != expected_id:
+ errors.append(f"{label}: measurement ID does not match deployment configuration")
+ if not re.fullmatch(r"\d+\.\d+\.\d+", config.get("data-stable-version") or ""):
+ errors.append(f"{label}: stable release context is missing")
+
+
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("site", nargs="?", default="_site")
@@ -85,7 +96,8 @@ def main() -> int:
for required in (
CONSENT_KEY, "analytics_storage: 'denied'", "ad_storage: 'denied'",
"ad_user_data: 'denied'", "ad_personalization: 'denied'", "navigator.doNotTrack",
- "const loadAnalytics", "client.src = 'analytics.js'", "window.location.reload()",
+ "const privacyPage", "if (privacyPage || analyticsLoaded", "client.src = 'analytics.js'",
+ "!privacyPage && (analyticsLoaded || previous === 'granted')",
):
if required not in consent_text:
errors.append(f"consent.js missing privacy contract: {required}")
@@ -123,18 +135,14 @@ def main() -> int:
if len(configs) != 1:
errors.append(f"{relative}: expected one inert analytics configuration")
else:
- config = configs[0]
- if config.get("type") != "application/json" or config.get("src") is not None:
- errors.append(f"{relative}: analytics configuration must be inert and local")
- if (config.get("data-measurement-id") or "") != expected_id:
- errors.append(f"{relative}: measurement ID does not match deployment configuration")
- if not re.fullmatch(r"\d+\.\d+\.\d+", config.get("data-stable-version") or ""):
- errors.append(f"{relative}: stable release context is missing")
+ validate_inert_config(configs[0], expected_id, relative, errors)
consent_scripts = [item for item in parsed.scripts if item.get("src") == "consent.js"]
if len(consent_scripts) != 1 or "defer" not in consent_scripts[0]:
errors.append(f"{relative}: expected one deferred consent controller")
if any(item.get("src") == "analytics.js" for item in parsed.scripts):
errors.append(f"{relative}: analytics.js must not load before consent")
+ if parsed.privacy_page:
+ errors.append(f"{relative}: registered product page must not be marked as a privacy page")
if parsed.consent_banners != 1 or parsed.consent_manage < 1 or parsed.consent_status < 1:
errors.append(f"{relative}: consent banner or preference controls are incomplete")
if parsed.language not in {"en", "id"}:
@@ -153,18 +161,27 @@ def main() -> int:
errors.append(f"privacy page is missing: {relative}")
continue
text, parsed = parse_page(page)
+ if PLACEHOLDER in text:
+ errors.append(f"{relative}: unresolved measurement placeholder")
if parsed.robots != "noindex,follow":
errors.append(f"{relative}: privacy page must use noindex,follow")
if parsed.alternates != expected_privacy_alternates:
errors.append(f"{relative}: bilingual privacy alternates are incomplete")
- if any(item.get("id") == "arsas-analytics" or item.get("src") == "analytics.js" for item in parsed.scripts):
- errors.append(f"{relative}: privacy policy must not load analytics")
+ if not parsed.privacy_page:
+ errors.append(f"{relative}: privacy page marker is missing")
+ configs = [item for item in parsed.scripts if item.get("id") == "arsas-analytics"]
+ if len(configs) != 1:
+ errors.append(f"{relative}: privacy page needs one inert availability configuration")
+ else:
+ validate_inert_config(configs[0], expected_id, relative, errors)
+ if any(item.get("src") == "analytics.js" for item in parsed.scripts):
+ errors.append(f"{relative}: privacy policy must never load analytics.js")
consent_scripts = [item for item in parsed.scripts if item.get("src") == "consent.js"]
if len(consent_scripts) != 1 or "defer" not in consent_scripts[0]:
errors.append(f"{relative}: privacy page must load the consent controller")
if parsed.consent_banners != 1 or parsed.consent_manage < 2 or parsed.consent_status < 1:
errors.append(f"{relative}: privacy preference controls are incomplete")
- if CONSENT_KEY not in text or "two-month" not in text and "dua bulan" not in text:
+ if CONSENT_KEY not in text or ("two-month" not in text and "dua bulan" not in text):
errors.append(f"{relative}: retention or preference-key disclosure is incomplete")
measurement = build_info.get("measurement")
@@ -186,8 +203,18 @@ def main() -> int:
if measurement.get(key) != value:
errors.append(f"build-info.json measurement.{key} must be {value!r}")
privacy = build_info.get("privacy")
- if not isinstance(privacy, dict) or privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied":
- errors.append("build-info.json privacy contract is incomplete")
+ if not isinstance(privacy, dict):
+ errors.append("build-info.json privacy contract is missing")
+ else:
+ expected_privacy = {
+ "consentRequired": True,
+ "defaultAnalyticsConsent": "denied",
+ "measurementAvailable": bool(expected_id),
+ "analyticsClientLoadedOnPolicyPages": False,
+ }
+ for key, value in expected_privacy.items():
+ if privacy.get(key) != value:
+ errors.append(f"build-info.json privacy.{key} must be {value!r}")
errors = list(dict.fromkeys(errors))
if errors:
@@ -198,7 +225,7 @@ def main() -> int:
state = "configured behind consent" if expected_id else "disabled/no-op"
print(
f"ARSAS consent and measurement validation passed: {len(registered)} product pages, "
- f"{len(privacy_pages)} privacy pages, client {state}, denied by default."
+ f"{len(privacy_pages)} preference-capable non-tracking privacy pages, client {state}."
)
return 0
diff --git a/scripts/verify-pages-deployment.py b/scripts/verify-pages-deployment.py
index 2a6c4f9ae..4b2adaef2 100644
--- a/scripts/verify-pages-deployment.py
+++ b/scripts/verify-pages-deployment.py
@@ -5,6 +5,7 @@
import argparse
import json
+import re
import time
from datetime import datetime, timezone
from pathlib import Path
@@ -12,6 +13,12 @@
from urllib.parse import urlencode, urljoin
from urllib.request import Request, urlopen
+MEASUREMENT_CONFIG_PATTERN = re.compile(
+ r'',
+ re.IGNORECASE,
+)
+MEASUREMENT_ID_PATTERN = re.compile(r"G-[A-Z0-9]+")
+
def parse_bool(value: str) -> bool:
normalized = value.strip().lower()
@@ -41,6 +48,19 @@ def fetch(url: str, timeout: int = 20) -> tuple[int, str, dict[str, str]]:
raise RuntimeError(str(exc.reason)) from exc
+def validate_inert_measurement_config(page: str, measurement_enabled: bool, stable_version: str, label: str) -> list[str]:
+ errors: list[str] = []
+ matches = MEASUREMENT_CONFIG_PATTERN.findall(page)
+ if len(matches) != 1:
+ return [f"{label} must contain exactly one inert analytics availability configuration"]
+ measurement_id, page_stable_version = matches[0]
+ if bool(MEASUREMENT_ID_PATTERN.fullmatch(measurement_id)) is not measurement_enabled:
+ errors.append(f"{label} measurement availability does not match the deployed repository variable")
+ if page_stable_version != stable_version:
+ errors.append(f"{label} stable-version context is {page_stable_version!r}, expected {stable_version}")
+ return errors
+
+
def check_once(base_url: str, source_commit: str, stable_version: str, measurement_enabled: bool, nonce: str) -> tuple[list[str], dict[str, object]]:
errors: list[str] = []
evidence: dict[str, object] = {}
@@ -48,7 +68,11 @@ def check_once(base_url: str, source_commit: str, stable_version: str, measureme
status, body, headers = fetch(info_url)
evidence["buildInfoUrl"] = info_url
evidence["buildInfoStatus"] = status
- evidence["buildInfoHeaders"] = {key: headers[key] for key in headers if key.lower() in {"etag", "last-modified", "cache-control", "content-type"}}
+ evidence["buildInfoHeaders"] = {
+ key: headers[key]
+ for key in headers
+ if key.lower() in {"etag", "last-modified", "cache-control", "content-type"}
+ }
if status != 200:
return [f"build-info.json returned HTTP {status}"], evidence
try:
@@ -75,30 +99,44 @@ def check_once(base_url: str, source_commit: str, stable_version: str, measureme
errors.append(f"public measurement enabled={measurement.get('enabled')!r}, expected {measurement_enabled}")
if measurement.get("consentRequired") is not True or measurement.get("defaultConsent") != "denied":
errors.append("public measurement metadata does not require denied-by-default consent")
- if measurement.get("advertisingSignals") is not False:
- errors.append("public measurement metadata must keep advertising signals disabled")
+ if measurement.get("advertisingSignals") is not False or measurement.get("adPersonalizationSignals") is not False:
+ errors.append("public measurement metadata must keep advertising and personalization signals disabled")
privacy_pages = info.get("privacyPages")
if privacy_pages != ["privacy.html", "privasi.html"]:
errors.append("public build does not declare both bilingual privacy pages")
privacy = info.get("privacy")
- if not isinstance(privacy, dict) or privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied":
- errors.append("public privacy metadata is incomplete")
+ if not isinstance(privacy, dict):
+ errors.append("public privacy metadata is missing")
+ else:
+ if privacy.get("consentRequired") is not True or privacy.get("defaultAnalyticsConsent") != "denied":
+ errors.append("public privacy metadata does not require denied-by-default consent")
+ if privacy.get("measurementAvailable") is not measurement_enabled:
+ errors.append("public privacy measurement availability does not match deployment configuration")
+ if privacy.get("analyticsClientLoadedOnPolicyPages") is not False:
+ errors.append("public privacy metadata must prohibit analytics client loading on policy pages")
for relative in ("privacy.html", "privasi.html"):
- url = urljoin(base_url, relative) + "?" + urlencode({"attest": source_commit})
+ url = urljoin(base_url, relative) + "?" + urlencode({"attest": source_commit, "n": nonce})
page_status, page, _ = fetch(url)
evidence[f"{relative}Status"] = page_status
if page_status != 200:
errors.append(f"{relative} returned HTTP {page_status}")
continue
- for required in ('name="robots" content="noindex,follow"', 'src="consent.js"', 'data-consent-manage', 'data-consent-status'):
+ for required in (
+ 'name="robots" content="noindex,follow"',
+ 'data-privacy-page="true"',
+ 'src="consent.js"',
+ 'data-consent-manage',
+ 'data-consent-status',
+ ):
if required not in page:
errors.append(f"{relative} is missing {required}")
- if 'id="arsas-analytics"' in page:
- errors.append(f"{relative} must not load the analytics client")
+ errors.extend(validate_inert_measurement_config(page, measurement_enabled, stable_version, relative))
+ if 'src="analytics.js"' in page or "googletagmanager.com" in page:
+ errors.append(f"{relative} must never load an analytics client")
- home_url = base_url + "?" + urlencode({"attest": source_commit})
+ home_url = base_url + "?" + urlencode({"attest": source_commit, "n": nonce})
home_status, home, _ = fetch(home_url)
evidence["homeStatus"] = home_status
if home_status != 200:
@@ -107,13 +145,16 @@ def check_once(base_url: str, source_commit: str, stable_version: str, measureme
for required in ('src="consent.js"', 'id="arsas-analytics"', 'data-consent-banner', 'assets/app-icon.png'):
if required not in home:
errors.append(f"homepage is missing {required}")
+ errors.extend(validate_inert_measurement_config(home, measurement_enabled, stable_version, "homepage"))
+ if 'src="analytics.js"' in home or "googletagmanager.com" in home:
+ errors.append("homepage must not load analytics before consent")
return errors, evidence
def write_report(path: Path, success: bool, attempts: int, evidence: dict[str, object], errors: list[str]) -> None:
payload = {
- "schemaVersion": 1,
+ "schemaVersion": 2,
"verifiedAtUtc": datetime.now(timezone.utc).isoformat(),
"success": success,
"attempts": attempts,
@@ -122,7 +163,12 @@ def write_report(path: Path, success: bool, attempts: int, evidence: dict[str, o
}
path.parent.mkdir(parents=True, exist_ok=True)
path.with_suffix(".json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
- lines = ["# ARSAS production deployment attestation", "", f"- Status: **{'PASS' if success else 'FAIL'}**", f"- Attempts: {attempts}"]
+ lines = [
+ "# ARSAS production deployment attestation",
+ "",
+ f"- Status: **{'PASS' if success else 'FAIL'}**",
+ f"- Attempts: {attempts}",
+ ]
if errors:
lines.extend(["", "## Last observed errors", "", *[f"- {error}" for error in errors]])
path.write_text("\n".join(lines) + "\n", encoding="utf-8")