From 392eebc2e7af786656cb9e90758789086b17fe14 Mon Sep 17 00:00:00 2001 From: Statyx Date: Mon, 3 Aug 2026 16:55:35 +0200 Subject: [PATCH] Detect leaks by shape: drop the plaintext client deny-list The tool meant to prevent a client name from leaking had become the leak: .github/scripts/check_client_leak.py listed the whole client portfolio in plaintext, in a public repository. Self-exemption fixed self-detection, not exposure. - scripts/check_no_client_leak.py: byte-identical copy of the frozen canonical scanner (Fab-Analyze-Data-Agent@6e4a65e, 4023 bytes). Never edited; every adaptation is applied from the wrapper. - .gitattributes pins that one file to LF so core.autocrlf cannot silently break the published checksum on a Windows checkout. - scripts/check_repo_leaks.py: CI entry point. Re-allows this repo structural GUID placeholders at runtime, and restores name detection from the CLIENT_DENYLIST secret (or a gitignored .clientdeny). No secret means a warning and success, never a failure. Findings report file:line only, since a public repo Actions log would otherwise re-publish what it is guarding. - Colour tolerance is generalised to any entry and recognised by shape (CSS declaration, hex code, badge URL) instead of hardcoding a name to exempt it. - tests/test_leak_scanner.py: 17 tests. Every fixture is assembled at runtime from opaque sentinels; no real GUID, endpoint or name is ever written down. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 4 + .github/scripts/check_client_leak.py | 118 ---------------- .github/workflows/no-client-leak.yml | 8 +- .gitignore | 5 + README.md | 23 +++- scripts/check_no_client_leak.py | 130 ++++++++++++++++++ scripts/check_repo_leaks.py | 166 ++++++++++++++++++++++ tests/test_leak_scanner.py | 198 +++++++++++++++++++++++++++ 8 files changed, 529 insertions(+), 123 deletions(-) create mode 100644 .gitattributes delete mode 100644 .github/scripts/check_client_leak.py create mode 100644 scripts/check_no_client_leak.py create mode 100644 scripts/check_repo_leaks.py create mode 100644 tests/test_leak_scanner.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e6fa083 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# The canonical scanner is shared byte-for-byte with the sibling repositories. +# Without this rule, a Windows checkout (core.autocrlf=true) would rewrite it to +# CRLF on disk and break the published SHA-256. Scoped to that one file on purpose. +scripts/check_no_client_leak.py text eol=lf diff --git a/.github/scripts/check_client_leak.py b/.github/scripts/check_client_leak.py deleted file mode 100644 index 2979936..0000000 --- a/.github/scripts/check_client_leak.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""Fail the build if a customer name or a hard-coded identity GUID leaks into the repo. - -This repo is public and ships a *synthetic* telco demo. Nothing here may hint at a -real customer engagement, and no real tenant / client / subscription GUID may be -committed. - -Run locally: python .github/scripts/check_client_leak.py -Exit code 0 = clean, 1 = leak found. -""" -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[2] - -# This scanner and its workflow necessarily spell the forbidden words out. -SELF_EXEMPT = { - ".github/scripts/check_client_leak.py", - ".github/workflows/no-client-leak.yml", -} - -BINARY_SUFFIXES = { - ".pptx", ".ppt", ".docx", ".xlsx", ".pdf", ".png", ".jpg", ".jpeg", - ".gif", ".ico", ".zip", ".parquet", ".woff", ".woff2", ".ttf", -} - -# Customer / event names that must never appear. -CLIENT_PATTERNS = [ - r"publicis", - r"vivatech", - r"air[\s._-]?liquide", - r"technip", - r"forvia", - r"veolia", - r"arkema", - r"capgemini", - r"eiffage", - r"opmobility", -] -CLIENT_RE = re.compile(r"\b(?:" + "|".join(CLIENT_PATTERNS) + r")\b", re.IGNORECASE) - -# 'orange' is only tolerated as a colour token (CSS / chart / dashboard palettes). -ORANGE_RE = re.compile(r"\borange\b", re.IGNORECASE) -COLOUR_CONTEXT_RE = re.compile( - r"color|colour|background|fill|stroke|palette|theme|border|" - r"#[0-9a-fA-F]{3,8}|rgb\(|hsl\(", - re.IGNORECASE, -) - -# Identity GUIDs must come from config / env, never from source. -GUID = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" -IDENTITY_GUID_RE = re.compile( - r"(?:tenant|client|application|app|subscription|principal|object|directory)" - r"[\s._-]*id[\s\"':=>)\]]{0,12}[\"']?(" + GUID + r")", - re.IGNORECASE, -) -# Structural placeholders that are deliberately committed. -PLACEHOLDER_GUID_RE = re.compile( - r"^(?:0{8}-0{4}-0{4}-0{4}-0{12}|a1000000-0000-4000-a000-[0-9a-f]{12})$", - re.IGNORECASE, -) - - -def tracked_files() -> list: - out = subprocess.check_output(["git", "ls-files"], cwd=ROOT, text=True) - return [line for line in out.splitlines() if line] - - -def scan() -> list: - findings = [] - for rel in tracked_files(): - if rel in SELF_EXEMPT or Path(rel).suffix.lower() in BINARY_SUFFIXES: - continue - try: - text = (ROOT / rel).read_text(encoding="utf-8") - except (UnicodeDecodeError, FileNotFoundError, OSError): - continue - - for lineno, line in enumerate(text.splitlines(), start=1): - for match in CLIENT_RE.finditer(line): - findings.append(f"{rel}:{lineno}: customer name '{match.group(0)}'") - - if ORANGE_RE.search(line) and not COLOUR_CONTEXT_RE.search(line): - findings.append( - f"{rel}:{lineno}: bare 'orange' outside a colour context" - ) - - for match in IDENTITY_GUID_RE.finditer(line): - guid = match.group(1) - if not PLACEHOLDER_GUID_RE.match(guid): - findings.append( - f"{rel}:{lineno}: hard-coded identity GUID '{guid}' " - f"(read it from config.yaml / an env var instead)" - ) - return findings - - -def main() -> int: - findings = scan() - if findings: - print("Client-leak check FAILED:\n") - for finding in findings: - print(f" {finding}") - print( - "\nRemove the reference, or — for a legitimate colour token — put it on a " - "line with an explicit colour context." - ) - return 1 - print("Client-leak check passed: no customer names, no hard-coded identity GUIDs.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/no-client-leak.yml b/.github/workflows/no-client-leak.yml index ef24f42..0db6671 100644 --- a/.github/workflows/no-client-leak.yml +++ b/.github/workflows/no-client-leak.yml @@ -15,8 +15,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Scan tracked files for customer names / hard-coded identity GUIDs - run: python .github/scripts/check_client_leak.py + - name: Scan tracked files for leaked identifiers / customer names + # The denylist lives in a secret, never in the repo. If the secret is not + # configured the name rule is skipped with a warning — never a failure. + env: + CLIENT_DENYLIST: ${{ secrets.CLIENT_DENYLIST }} + run: python scripts/check_repo_leaks.py - name: Ensure no PowerPoint deck is tracked run: | if git ls-files '*.pptx' '*.ppt' | grep .; then diff --git a/.gitignore b/.gitignore index 1610c1e..7ab7082 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,9 @@ src/config.yaml src/state.json .vscode/mcp.json +# Local client denylist for scripts/check_repo_leaks.py — one entry per line. +# Names are detected, never published: in CI the same list comes from the +# ${{ secrets.CLIENT_DENYLIST }} secret. This file must stay untracked. +.clientdeny + # (The *.example files ARE committed: src/config.example.yaml, src/state.example.json, .vscode/mcp.example.json) diff --git a/README.md b/README.md index b4d47cd..8d5bff6 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ ## Project layout ``` -.github/workflows/ — CI: client-leak guard + pytest -.github/scripts/ — check_client_leak.py (run it locally before pushing) +.github/workflows/ — CI: leak guard + pytest +scripts/ — check_no_client_leak.py (canonical, shared verbatim) + + check_repo_leaks.py (entry point; run before pushing) docs/ARCHITECTURE.md — full design + data model + demo storyboard src/config.yaml — workspace, item names, topology + telemetry config src/generate_data.py — synthetic topology + telemetry (with the scripted PFC storm) @@ -176,10 +177,26 @@ This is a **synthetic** demo: sites are generic French metro areas, and every cu service and hardware vendor is fictional. Two guards enforce it, in CI and locally: ```bash -python .github/scripts/check_client_leak.py # customer names + hard-coded identity GUIDs +python scripts/check_repo_leaks.py # real GUIDs, Fabric endpoints, personal paths python -m pytest tests/ -q ``` +The scan reads `git ls-files` only — never the working tree, because `__pycache__/*.pyc` +and `node_modules` embed absolute build paths and produce false positives. + +**Detection is by shape, never by name.** `scripts/check_no_client_leak.py` is a +byte-identical copy of the canonical scanner shared across the sibling repos; do not +edit it, configure it from `scripts/check_repo_leaks.py` instead. + +An optional customer-name rule is driven by a list that is **never committed**: + +- in CI, the `CLIENT_DENYLIST` repository secret (one entry per line); +- locally, a `.clientdeny` file at the repo root (git-ignored, same format, `#` comments). + +Without it the rule is skipped with a warning — it never fails the build. Matches are +reported as `file:line` plus the entry rank only, so the terms are not echoed back into +public CI logs. + --- ## Key facts diff --git a/scripts/check_no_client_leak.py b/scripts/check_no_client_leak.py new file mode 100644 index 0000000..bf1b825 --- /dev/null +++ b/scripts/check_no_client_leak.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Fail if real customer / tenant identifiers leak into Git-tracked files. + +This repo is public. It must never contain real Microsoft Fabric identifiers, +real workspace SQL endpoints, or personal filesystem paths. + +Only files returned by ``git ls-files`` are scanned. The working tree is +deliberately NOT walked: ``__pycache__/*.pyc`` and ``node_modules`` embed +absolute build paths and would produce false positives. + +Usage: + python scripts/check_no_client_leak.py + +Exit code 0 = clean, 1 = leak detected. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Placeholder GUIDs that are allowed to appear verbatim. +ALLOWED_GUIDS = { + "00000000-0000-0000-0000-000000000000", +} + +BINARY_SUFFIXES = { + ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".pptx", ".xlsx", + ".docx", ".zip", ".gz", ".woff", ".woff2", ".ttf", ".eot", +} + +GUID_RE = re.compile( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" + r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b" +) + +# A real Fabric SQL/warehouse endpoint host: a long opaque token followed by +# ".datawarehouse.fabric.microsoft.com" (or the Synapse / Power BI equivalents). +FABRIC_ENDPOINT_RE = re.compile( + r"\b[a-z0-9]{20,}(?:-[a-z0-9]{20,})?\." + r"(?:datawarehouse|dev\.azuresynapse|pbidedicated)\b", + re.IGNORECASE, +) + +# Personal home directories. Placeholders such as C:\Users\ are fine. +PERSONAL_PATH_RE = re.compile( + r"(?:[A-Za-z]:\\Users\\|/Users/|/home/)(?!<|\{|\$|%|USERNAME\b|username\b|you\b|user\b)" + r"[A-Za-z0-9._-]+", +) + +# Extra literals to forbid, as a comma-separated list. Deliberately empty by +# default: hardcoding an author's username here would re-publish the very thing +# this checker exists to remove. +# NO_LEAK_EXTRA_LITERALS="acme,contoso" python scripts/check_no_client_leak.py +FORBIDDEN_LITERALS = [ + literal.strip().lower() + for literal in os.environ.get("NO_LEAK_EXTRA_LITERALS", "").split(",") + if literal.strip() +] + +CHECKS = [ + ("real GUID", GUID_RE, lambda m: m.group(0).lower() not in ALLOWED_GUIDS), + ("Fabric SQL endpoint", FABRIC_ENDPOINT_RE, lambda m: True), + ("personal filesystem path", PERSONAL_PATH_RE, lambda m: True), +] + + +def tracked_files() -> list[str]: + out = subprocess.run( + ["git", "ls-files", "-z"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + return [p for p in out.split("\0") if p] + + +def scan_file(rel_path: str) -> list[str]: + path = REPO_ROOT / rel_path + if path.suffix.lower() in BINARY_SUFFIXES or not path.is_file(): + return [] + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return [] + + findings = [] + for lineno, line in enumerate(text.splitlines(), start=1): + for label, pattern, keep in CHECKS: + for match in pattern.finditer(line): + if keep(match): + findings.append( + f"{rel_path}:{lineno}: {label}: {match.group(0)}" + ) + lowered = line.lower() + for literal in FORBIDDEN_LITERALS: + if literal in lowered: + findings.append( + f"{rel_path}:{lineno}: author-specific marker: {literal}" + ) + return findings + + +def main() -> int: + findings: list[str] = [] + for rel_path in tracked_files(): + findings.extend(scan_file(rel_path)) + + if findings: + print("Client / tenant data leak detected in Git-tracked files:\n") + for finding in findings: + print(f" {finding}") + print( + "\nReplace real values with visibly fake placeholders " + "(e.g. or 00000000-0000-0000-0000-000000000000)." + ) + return 1 + + print("No client data leak detected in Git-tracked files.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_repo_leaks.py b/scripts/check_repo_leaks.py new file mode 100644 index 0000000..ac33529 --- /dev/null +++ b/scripts/check_repo_leaks.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Point d'entree anti-fuite du depot : scanner canonique + regles locales. + +Ce depot est PUBLIC. La detection se fait par FORME, jamais par NOM : aucune +liste de clients n'est ecrite ici. Deux etages : + +1. ``scripts/check_no_client_leak.py`` - copie **octet pour octet** du scanner + canonique de ``Statyx/Fab-Analyze-Data-Agent`` (GUID reel, endpoint Fabric, + chemin personnel). Ce fichier n'est jamais modifie : ce qui lui manque est + reconfigure ici, depuis l'exterieur. +2. Une regle "nom de client" alimentee par un secret, donc absente du depot. + +Denylist (facultative) : + - variable d'environnement ``CLIENT_DENYLIST`` (une entree par ligne), + alimentee en CI par ``${{ secrets.CLIENT_DENYLIST }}`` ; + - a defaut, un fichier local ``.clientdeny`` a la racine (gitignore). + - Absente : la regle est ignoree avec un avertissement, JAMAIS un echec. + +Les correspondances ne sont jamais reproduites en clair dans la sortie : seuls +le fichier, la ligne et le rang de l'entree sont affiches, sans quoi les logs +publics d'Actions re-publieraient ce que ce dispositif sert a retirer. + +Usage : python scripts/check_repo_leaks.py +Code de sortie 0 = propre, 1 = fuite detectee. +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parent + +sys.path.insert(0, str(HERE)) + +import check_no_client_leak as canonical # noqa: E402 + +DENYLIST_ENV = "CLIENT_DENYLIST" +DENYLIST_FILE = REPO_ROOT / ".clientdeny" + +# Placeholders structurels volontairement commites (ids du taskflow). Le scanner +# canonique n'autorise que le GUID nul ; sa liste blanche est donc etendue ici, +# a l'execution, plutot qu'en editant le fichier canonique. +STRUCTURAL_GUID_RE = re.compile( + r"^a1000000-0000-4000-a000-[0-9a-f]{12}$", re.IGNORECASE +) + +# Un nom cite au milieu d'une palette ou d'un badge est un faux positif. Le +# contexte est reconnu par sa FORME (declaration CSS, code hexadecimal, fonction +# de couleur, URL de badge) : coder un terme de couleur pour l'exempter +# reviendrait a le publier, ce que ce dispositif sert precisement a eviter. +COLOUR_CONTEXT_RE = re.compile( + r"color|colour|background|fill|stroke|palette|theme|border|" + r"#[0-9a-fA-F]{3,8}|rgb\(|hsl\(|hsla\(|rgba\(|" + r"shields\.io|badge|svg|css", + re.IGNORECASE, +) + +# re.escape protege aussi les separateurs : "zz alpha" devient "zz\ alpha". Une +# entree en deux mots doit donc tolerer espace, point, tiret, souligne ou rien. +_SEPARATOR_RE = re.compile(r"(?:\\?[\s._-])+") +_WORD_EDGE_RE = re.compile(r"\w") + + +def allow_structural_placeholders() -> None: + """Tolere les GUID placeholders sans toucher au fichier canonique.""" + label = "real GUID" + for index, (name, pattern, keep) in enumerate(canonical.CHECKS): + if name != label: + continue + canonical.CHECKS[index] = ( + name, + pattern, + lambda match, _keep=keep: ( + _keep(match) and not STRUCTURAL_GUID_RE.match(match.group(0)) + ), + ) + return + raise SystemExit( + f"check_repo_leaks: regle '{label}' introuvable dans le scanner canonique " + "- la copie amont a change, revoir la reconfiguration." + ) + + +def compile_entry(entry: str) -> re.Pattern: + """Compile une entree de denylist en motif tolerant aux separateurs.""" + body = _SEPARATOR_RE.sub(r"[\\s._-]?", re.escape(entry)) + prefix = r"\b" if _WORD_EDGE_RE.match(entry[0]) else "" + suffix = r"\b" if _WORD_EDGE_RE.match(entry[-1]) else "" + return re.compile(prefix + body + suffix, re.IGNORECASE) + + +def parse_denylist(raw: str) -> list[re.Pattern]: + entries = [ + line.strip() + for line in raw.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + return [compile_entry(entry) for entry in entries] + + +def load_denylist() -> tuple[list[re.Pattern], str]: + raw = os.environ.get(DENYLIST_ENV, "") + source = f"${DENYLIST_ENV}" + if not raw.strip() and DENYLIST_FILE.is_file(): + raw = DENYLIST_FILE.read_text(encoding="utf-8") + source = DENYLIST_FILE.name + return parse_denylist(raw), source + + +def scan_denylist(patterns: list[re.Pattern]) -> list[str]: + findings = [] + for rel_path in canonical.tracked_files(): + path = REPO_ROOT / rel_path + if path.suffix.lower() in canonical.BINARY_SUFFIXES or not path.is_file(): + continue + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for lineno, line in enumerate(text.splitlines(), start=1): + if COLOUR_CONTEXT_RE.search(line): + continue + for rank, pattern in enumerate(patterns, start=1): + if pattern.search(line): + findings.append( + f"{rel_path}:{lineno}: entree de denylist no {rank}" + ) + return findings + + +def main() -> int: + allow_structural_placeholders() + status = canonical.main() + + patterns, source = load_denylist() + if not patterns: + print( + f"::warning::denylist absente ({DENYLIST_ENV} puis {DENYLIST_FILE.name}) " + "- regle 'nom de client' ignoree, detection par forme uniquement." + ) + return status + + findings = scan_denylist(patterns) + if findings: + print( + f"\nDenylist ({len(patterns)} entrees, source {source}) - " + "correspondances (termes volontairement non reproduits) :\n" + ) + for finding in findings: + print(f" {finding}") + print( + "\nRetirer la reference, ou - pour un jeton de couleur legitime - la " + "placer sur une ligne portant un contexte de couleur explicite." + ) + return 1 + + print(f"Denylist ({len(patterns)} entrees, source {source}) : aucune correspondance.") + return status + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_leak_scanner.py b/tests/test_leak_scanner.py new file mode 100644 index 0000000..a3ebefe --- /dev/null +++ b/tests/test_leak_scanner.py @@ -0,0 +1,198 @@ +"""Non-regression tests for the leak scanners (offline, no Fabric, no network). + +Every fixture value is assembled at runtime: writing a real-looking GUID, a real +Fabric endpoint or a personal path as a literal here would make this very file +trip the scanner it is testing. + +Run: python -m pytest tests/test_leak_scanner.py -v +""" +import hashlib +import pathlib +import re +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import check_no_client_leak as canonical # noqa: E402 +import check_repo_leaks as wrapper # noqa: E402 + +# Published contract of the canonical scanner (Statyx/Fab-Analyze-Data-Agent, +# 6e4a65e704128894175a822825b6bdaddf345650, scripts/check_no_client_leak.py). +CANONICAL_SHA256 = ( + "33312e7e678c09663a7da51151359f8244ea0cadd10e811fc2a31ccf38328cd3" +) +CANONICAL_SIZE = 4023 + +# Opaque sentinels for the deny-list probes: they collide with nothing that +# exists, so a reviewer can never mistake them for real data. Microsoft's +# fictional companies are kept for the value-independence test alone. +PROBE = "zzsecretname" +PROBE_PAIR = "zzalpha zzbeta" + + +def _guid(seed: str) -> str: + """Build a plausible, non-null GUID without writing one down.""" + digest = hashlib.sha256(seed.encode()).hexdigest() + return "-".join([digest[:8], digest[8:12], digest[12:16], + digest[16:20], digest[20:32]]) + + +def _fabric_endpoint() -> str: + return "a" * 24 + ".data" + "warehouse.fabric.microsoft.com" + + +def _personal_path() -> str: + sep = chr(92) + return f"C:{sep}Users{sep}jdoe{sep}secrets" + + +@pytest.fixture +def probe(): + """A throwaway file inside the repo — scan_file() resolves against REPO_ROOT. + + `_tmp_*` is git-ignored, so an aborted run can never leave a committable file. + """ + rel = "_tmp_leak_probe.txt" + path = ROOT / rel + + def write(text: str) -> list: + path.write_text(text, encoding="utf-8") + return canonical.scan_file(rel) + + yield write + path.unlink(missing_ok=True) + + +@pytest.fixture +def denylist_probe(monkeypatch): + """Write a line into a repo file and run only the denylist rule over it.""" + rel = "_tmp_denylist_probe.txt" + path = ROOT / rel + monkeypatch.setattr(canonical, "tracked_files", lambda: [rel]) + + def run(denylist: str, line: str) -> list: + path.write_text(line, encoding="utf-8") + return wrapper.scan_denylist(wrapper.parse_denylist(denylist)) + + yield run + path.unlink(missing_ok=True) + + +@pytest.fixture(autouse=True) +def structural_guids_allowed(): + """The wrapper reconfigures the canonical module in place — restore after.""" + saved = list(canonical.CHECKS) + wrapper.allow_structural_placeholders() + yield + canonical.CHECKS[:] = saved + + +# ── The canonical file is a verbatim copy ─────────────────────── +def test_canonical_scanner_is_byte_identical(): + """Copied, never merged, never improved. Line endings normalised for Windows.""" + data = (SCRIPTS / "check_no_client_leak.py").read_bytes().replace(b"\r\n", b"\n") + assert len(data) == CANONICAL_SIZE + assert hashlib.sha256(data).hexdigest() == CANONICAL_SHA256 + + +def test_no_hardcoded_customer_list_survives(): + """The whole point: the guard must not spell out what it guards against.""" + assert not (ROOT / ".github" / "scripts" / "check_client_leak.py").exists() + for name in ("check_no_client_leak.py", "check_repo_leaks.py"): + text = (SCRIPTS / name).read_text(encoding="utf-8") + assert "CLIENT_PATTERNS" not in text + + +# ── Detection by shape ────────────────────────────────────────── +def test_real_guid_is_flagged(probe): + findings = probe(f"tenant_id: {_guid('tenant')}\n") + assert any("real GUID" in f for f in findings), findings + + +def test_null_guid_is_allowed(probe): + assert probe("logicalId: 00000000-0000-0000-0000-000000000000\n") == [] + + +def test_structural_placeholder_guid_is_allowed(probe): + """taskflow/noa_taskflow.json ships 8 of these on purpose.""" + assert probe('{"id": "a1000000-0000-4000-a000-000000000001"}\n') == [] + + +def test_structural_tolerance_is_not_a_blanket_pass(probe): + findings = probe(f'{{"id": "{_guid("taskflow")}"}}\n') + assert any("real GUID" in f for f in findings), findings + + +def test_fabric_endpoint_is_flagged(probe): + findings = probe(f"server: {_fabric_endpoint()}\n") + assert any("Fabric SQL endpoint" in f for f in findings), findings + + +def test_personal_path_is_flagged(probe): + findings = probe(f"path = {_personal_path()}\n") + assert any("personal filesystem path" in f for f in findings), findings + + +def test_placeholder_home_path_is_allowed(probe): + sep = chr(92) + assert probe(f"path = C:{sep}Users{sep}{sep}repo\n") == [] + + +def test_reconfiguration_fails_loudly_if_upstream_renames_the_rule(): + canonical.CHECKS[:] = [("renamed", canonical.GUID_RE, lambda m: True)] + with pytest.raises(SystemExit): + wrapper.allow_structural_placeholders() + + +# ── Denylist fed by a secret, never by the repo ───────────────── +def test_denylist_absent_degrades_without_failing(monkeypatch): + monkeypatch.delenv(wrapper.DENYLIST_ENV, raising=False) + monkeypatch.setattr(wrapper, "DENYLIST_FILE", ROOT / "_tmp_absent_denylist") + patterns, _source = wrapper.load_denylist() + assert patterns == [] + + +def test_denylist_reads_one_entry_per_line_and_skips_comments(monkeypatch): + monkeypatch.setenv(wrapper.DENYLIST_ENV, f"# comment\n{PROBE}\n\n zzbeta \n") + patterns, source = wrapper.load_denylist() + assert len(patterns) == 2 + assert source.endswith(wrapper.DENYLIST_ENV) + + +def test_denylist_entry_tolerates_separators(): + pattern = wrapper.compile_entry(PROBE_PAIR) + for variant in ("zzalpha zzbeta", "ZZalpha-ZZbeta", "zzalpha_zzbeta", + "zzalpha.zzbeta", "zzalphazzbeta"): + assert pattern.search(f"see {variant} here"), variant + + +def test_denylist_entry_respects_word_boundaries(): + pattern = wrapper.compile_entry(PROBE) + assert pattern.search(f"the {PROBE} device") + assert not pattern.search(f"supra{PROBE}meter") + + +def test_denylist_match_never_echoes_the_term_or_the_line(denylist_probe): + """Actions logs are public: a chatty scanner recreates the leak it fights.""" + findings = denylist_probe(PROBE, f"the {PROBE} account id is 4471\n") + assert findings, "the denylist rule must still catch a name" + for finding in findings: + assert re.fullmatch(r"[^:]+:\d+: entree de denylist no \d+", finding), finding + + +def test_colour_context_is_tolerated(denylist_probe): + """Recognised by shape — a CSS declaration, a hex code, a badge URL.""" + for line in (f"--accent-color: {PROBE};\n", + f"background: #ff8800; /* {PROBE} */\n", + f"![b](https://img.shields.io/badge/{PROBE}-blue)\n"): + assert denylist_probe(PROBE, line) == [], line + + +def test_denylist_rule_is_morphological_not_value_bound(): + """Any entry must work: the rule is tested on its mechanism, not on real data.""" + for entry in (PROBE, "Contoso", "Fabrikam", "Northwind Traders", "zz-9"): + assert wrapper.compile_entry(entry).search(f"x {entry} y"), entry