""" What an action puts where other people can read it: a pull request, an issue or comment, a gist, a chat post, a page. Two checks (rules.yaml `data_checks:`), both only on those places: customer_names the text names a customer on the team's list (dashboard, Settings → Customers). Agents copy what they read: a ticket's customer ends up in a PR description or a public issue. personal_data the text carries a card number, a US social security number, an IBAN, or a list of people (3 or more email addresses or phone numbers). It reads text only. It never stores or repeats what it found beyond the customer's name (people on the team already know their customers); personal data is reported by kind, not value. """ from __future__ import annotations import re from functools import lru_cache from typing import Any # tools whose text others read; shell commands are checked by what they run (gh / glab below) DEFAULT_WHERE = ["*pull_request*", "*create_pull*", "*update_pull*", "*pr_create*", "*issue*", "*comment*", "*gist*", "*discussion*", "*review*", "*post_message*", "*chat*post*", "*send_message*", "*publish*", "*create_page*", "*update_page*", "*wiki*", "*tweet*", "*release*", "*push_files*", "*create_or_update_file*"] SHELL_WHERE = re.compile(r"\b(gh|glab)\s+(pr|issue|gist|release|discussion)\s+(create|comment|edit|review|new)\b" r"|\bgh\s+api\b.*\b(-f|--field|-F|--raw-field|--input)\b", re.I) EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b") PHONE = re.compile(r"(? bool: total, alt = 0, False for d in reversed(digits): n = int(d) * (2 if alt else 1) total += n - 9 if n > 9 else n alt = not alt return total % 10 == 0 def _iban_ok(raw: str) -> bool: s = raw.replace(" ", "") if not 15 <= len(s) <= 34: return False n = "".join(str(int(c, 36)) for c in s[4:] + s[:4]) return int(n) % 97 == 1 def text_of(input: Any) -> str: """Every string in the call's input, as one text.""" out: list[str] = [] def walk(v): if isinstance(v, dict): for x in v.values(): walk(x) elif isinstance(v, (list, tuple)): for x in v: walk(x) elif isinstance(v, str): out.append(v) walk(input if not isinstance(input, str) else [input]) return "\n".join(out) @lru_cache(maxsize=8) def _names_re(names: tuple[str, ...]) -> re.Pattern | None: terms = sorted({n.strip() for n in names if len(n.strip()) >= 3}, key=len, reverse=True) if not terms: return None return re.compile(r"(? list[str]: """Customers on the list that the text names (as written on the list), in order of first mention.""" rx = _names_re(tuple(names)) if rx is None: return [] by_lower = {n.strip().lower(): n.strip() for n in names} seen: list[str] = [] for m in rx.finditer(text): name = by_lower.get(m.group(1).lower(), m.group(1)) if name not in seen: seen.append(name) return seen def personal_data(text: str, own_domains: list[str] | None = None) -> list[str]: """What kinds of personal data the text carries, e.g. ["a card number", "4 email addresses"].""" found = [] if any(_luhn(re.sub(r"\D", "", m.group(0))) for m in CARD.finditer(text) if 13 <= len(re.sub(r"\D", "", m.group(0))) <= 19): found.append("a card number") if SSN.search(text): found.append("a social security number") if any(_iban_ok(m.group(0)) for m in IBAN.finditer(text)): found.append("a bank account number (IBAN)") own = tuple(d.lower().lstrip("@") for d in own_domains or []) emails = {e.lower() for e in EMAIL.findall(text) if not e.lower().split("@")[1].endswith(own or ("\0",))} if len(emails) >= 3: found.append(f"{len(emails)} email addresses") phones = {re.sub(r"\D", "", p) for p in PHONE.findall(text) if 10 <= len(re.sub(r"\D", "", p)) <= 15} if len(phones) >= 3: found.append(f"{len(phones)} phone numbers") return found READS = re.compile(r"(?:^|[._-])(get|list|search|read|fetch|view|find|describe|show|lookup|count)(?:[._-]|$)") def is_shared_place(name: str, command: str | None, where: list[str]) -> bool: """Does this action put text where other people read it? (Looking things up, like list_issues, doesn't.)""" import fnmatch if command is not None: return bool(SHELL_WHERE.search(command)) tool = (name or "").lower().rsplit(".", 1)[-1] if READS.search(tool): return False return any(fnmatch.fnmatchcase((name or "").lower(), g.lower()) for g in where)