Building Reliable Autonomous Agents
+Deterministic validation and surface scanning protect agents during browser task execution.
+diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdbe2c..fae245e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ### Added +- **Skill (`security/deceptive_ui_guard` v0.2.0):** Upgraded deceptive UI scanner with DOM zone classification (checkout, modal, cmp, navigation, general) and severity multipliers, KB allowlists (screen-reader accessibility, CMP consent banners, SEO metadata), expanded taxonomy (prechecked opt-ins, drip pricing, fake scarcity timers, nag loops), mobile profile heuristics, session fingerprint tracking, optional Playwright computed-style render diffing lane, and 23 golden HTML test corpus fixtures (#314). - **CLI:** User-configurable `pastel`, `ocean`, and `mono` presentation themes; interactive menu selection persists globally, project config can override it, and unknown values fall back to `pastel` (#248). - **CLI:** The mail submenu and direct mail commands now follow the active presentation theme (#248). diff --git a/docs/security/skill-trust-model.md b/docs/security/skill-trust-model.md index 49dcc62..1597120 100644 --- a/docs/security/skill-trust-model.md +++ b/docs/security/skill-trust-model.md @@ -94,6 +94,12 @@ Three common setups and what to watch for in each. **External path.** You point SKILLWARE_SKILL_PATH at a third-party skills directory (External). This is the highest-risk case: unreviewed code runs in your process with access to your entire os.environ. Because a skill can read environment variables and make network calls, a malicious or careless external skill could read your API keys or secrets and send them elsewhere — nothing in the loader prevents this. Only load external skills you have read. +### Browse and checkout defense chain + +When autonomous browser agents navigate e-commerce, banking, or SaaS portals, host agents should establish a dual-defense chain: +1. **Pre-click & Pre-context**: Run `security/deceptive_ui_guard` across sanitized page HTML to detect channel mismatches, hidden checkout fees, drip pricing, and mislabeled CTAs before interacting with buttons or entering payment information. +2. **Text-channel firewalling**: Pass the resulting `sanitized_excerpt` through `security/prompt_injection_firewall` before feeding untrusted text into the agent's LLM context window to prevent prompt injection and instruction overrides. + ## 6. Operator checklist Because there is no default isolation, these precautions are on you, the operator — the loader does not do them for you: diff --git a/docs/skills/deceptive_ui_guard.md b/docs/skills/deceptive_ui_guard.md index d21732c..f49d59e 100644 --- a/docs/skills/deceptive_ui_guard.md +++ b/docs/skills/deceptive_ui_guard.md @@ -4,23 +4,27 @@ **Skill ID:** `security/deceptive_ui_guard` **Issuer:** [@rosspeili](https://github.com/rosspeili) ([@ARPAHLS](https://github.com/ARPAHLS)) -**Version**: `0.1.0` — 27 Aug 2026 +**Version**: `0.2.0` — 1 Sep 2026 -**Recommended install:** `pip install "skillware[security_deceptive_ui_guard]"`. See [Install extras](../usage/install_extras.md). +**Recommended install:** `pip install "skillware[security_deceptive_ui_guard]"`. See [Install extras](../usage/install_extras.md). For optional headless browser computed-style diffing: `pip install "skillware[security_deceptive_ui_guard_render]"`. [Skill Library](README.md) · [Testing](../TESTING.md) -Deterministic scanner for **deceptive web UI surfaces** and **anti-agent tricks** before an autonomous browser agent clicks, checks out, or feeds page text into an LLM. v1 analyzes HTML with dual DOM vs visible-surface extraction, structural heuristics (hidden nodes, mislabeled CTAs, low-contrast styling), lexical deception signals, and corroboration gates. It returns a trust score, structured findings, and agent guidance. It does **not** click, submit forms, or call LLMs for detection. +Deterministic scanner for **deceptive web UI surfaces** and **anti-agent tricks** before an autonomous browser agent clicks, checks out, or feeds page text into an LLM. v2 expands analysis with DOM zone classification, severity multipliers, KB allowlists (accessibility screen-reader text, CMP banners, SEO metadata), prechecked opt-in boxes, drip pricing, fake scarcity timers, nag loops, mobile layout heuristics, and an optional Playwright-rendered computed style diff lane. It returns a trust score, structured findings, zone summary, session recommendations, and agent guidance. It does **not** click, submit forms, or call LLMs for detection. > **Disclaimer:** Heuristic surface analysis can miss semantic manipulation and may flag aggressive but legitimate marketing copy. Use with `security/prompt_injection_firewall` in skill chains for text-layer defense. ## What It Checks -1. **Channel mismatch** — text present in hidden/off-screen DOM branches but absent from the visible surface -2. **Mislabeled CTAs** — visible button/link text diverges from accessible name (`aria-label` / `title`) -3. **Deception lexicon** — confirm-shaming, fake urgency, hidden-fee language (deterministic KB) -4. **Low-contrast styling** — white-on-white and similar CSS hiding (strict + checkout zone) -5. **Agent guidance** — selectors to avoid, payment verification flag, sanitized visible excerpt +1. **Channel mismatch with allowlists** — text present in hidden/off-screen DOM branches but absent from the visible surface (allowlists suppress benign screen-reader text, OneTrust/Cookiebot banners, and SEO tags unless imperative prompts are present) +2. **Mislabeled CTAs** — visible button/link/input text diverges from accessible name (`aria-label` / `title` / `value`) +3. **Pre-checked opt-in boxes** — default-checked recurring subscriptions, insurance, or marketing boxes in forms +4. **Drip pricing & hidden fees** — undisclosed fees and subtotal-versus-total divergence in checkout zones +5. **Fake urgency & timers** — artificial scarcity countdown clocks and hidden reset branches +6. **Nag loops & confirm shaming** — asymmetric dismiss copy ("No thanks, I hate saving money") and repetitive modal traps +7. **Mobile surface profile** — transparent touch overlay traps and tap highlight suppression on mobile snapshots +8. **Render / computed-style diff lane** — optional headless Chromium comparison detecting external-stylesheet hidden traps (`render_dom_divergence`) +9. **Agent guidance & zone summary** — selectors to avoid, payment verification flag, zone breakdown, and session nag recommendations ## Related skills @@ -35,7 +39,10 @@ Deterministic scanner for **deceptive web UI surfaces** and **anti-agent tricks* * `html_content` (string, optional): Sanitized HTML or DOM snapshot (preferred). * `url` (string, optional): Public http(s) URL to fetch when HTML is not supplied (SSRF guarded). * `sensitivity` (string, optional): `strict`, `balanced` (default), or `lenient`. -* `intended_action` (string, optional): Task hint (e.g. complete checkout) for guidance tuning. +* `intended_action` (string, optional): Task hint (e.g. complete checkout) for zone weighting and guidance tuning. +* `render_mode` (string, optional): `off` (default), `auto`, or `force` for optional Playwright computed-style diffing. +* `surface_profile` (string, optional): `desktop` (default), `mobile`, or `auto` (inferred from viewport meta). +* `session_fingerprint` (string, optional): Stable session/origin hash to detect recurring nag loops across pages. **Outputs Schema:** * `status` (string): `ok`, `caution`, `warning`, or `blocked`. @@ -44,9 +51,11 @@ Deterministic scanner for **deceptive web UI surfaces** and **anti-agent tricks* * `is_safe` (boolean): True when posture is clean at the chosen sensitivity. * `risk_level` (string): `none`, `low`, `medium`, `high`, or `critical`. * `detected_threat` (string): Primary published finding summary. -* `findings` (array): Structured findings with selector, snippet, channels, and evidence. +* `findings` (array): Structured findings with type, severity, selector, snippet, channels, zone, and evidence. * `agent_guidance` (object): `do_not_click`, `verify_before_payment`, `summary`. * `sanitized_excerpt` (string): Visible-surface excerpt for downstream LLM context. +* `zone_summary` (object): Breakdown of DOM zones identified and their risk weights. +* `session_recommendation` (string): Guidance based on session nag loop detection. * `fetch_status` (string): `skipped`, `ok`, or error detail. * `offline` (boolean): False only when `url` fetch ran; analysis remains deterministic. * `sensitivity` (string): Sensitivity used for the scan. @@ -69,29 +78,24 @@ skill = bundle["class"]() result = skill.execute( { "html_content": ( - "
Shop now.
" + "Total $9.99.
" + "" "" - "" + "Clean docs page.
" +response = client.messages.create( + model="claude-3-7-sonnet-20250219", + max_tokens=1024, + tools=[tool], + messages=[{"role": "user", "content": f"Scan this checkout HTML before clicking: {html}"}], +) +``` + +### OpenAI (Function Calling) + +```python +import os +from openai import OpenAI +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("security/deceptive_ui_guard") +skill = bundle["class"]() +openai_tool = SkillLoader.to_openai_tool(bundle) +client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) + +response = client.chat.completions.create( + model="gpt-4o", + tools=[openai_tool], + messages=[{"role": "user", "content": "Scan page HTML for deceptive patterns before clicking."}], +) +``` + +### DeepSeek + +```python +import os +from openai import OpenAI +from skillware.core.env import load_env_file +from skillware.core.loader import SkillLoader + +load_env_file() +bundle = SkillLoader.load_skill("security/deceptive_ui_guard") +skill = bundle["class"]() +deepseek_tool = SkillLoader.to_deepseek_tool(bundle) +client = OpenAI( + api_key=os.environ.get("DEEPSEEK_API_KEY"), + base_url="https://api.deepseek.com", +) + +response = client.chat.completions.create( + model="deepseek-chat", + tools=[deepseek_tool], + messages=[{"role": "user", "content": "Analyze web surface before executing checkout action."}], +) +``` + +### Ollama (Local LLMs) + +Prompt-based tool calling or system prompt injection. Pull a model such as `gemma3` or `qwen3.5`, then follow [Ollama usage](../usage/ollama.md): + +```python +from skillware.core.loader import SkillLoader + +bundle = SkillLoader.load_skill("security/deceptive_ui_guard") +system_tool_prompt = SkillLoader.to_ollama_prompt(bundle) +# Append system_tool_prompt to system instructions for text-based tool generation +``` + ### Gemini ```python @@ -113,7 +197,7 @@ from skillware.core.env import load_env_file load_env_file() bundle = SkillLoader.load_skill("security/deceptive_ui_guard") -tool = bundle["to_gemini_tool"]() +tool = SkillLoader.to_gemini_tool(bundle) skill = bundle["class"]() client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) @@ -123,20 +207,8 @@ response = client.models.generate_content( contents="Scan this page HTML for deceptive UI before proceeding.", config=genai.types.GenerateContentConfig(tools=[tool]), ) -# Dispatch function call args: {"html_content": html, "sensitivity": "balanced"} ``` -### Claude, OpenAI, DeepSeek, Ollama - -See [skill usage template](../usage/skill_usage_template.md). Pass `html_content` from the host browser; chain `security/prompt_injection_firewall` on `sanitized_excerpt` when imperative hidden text is suspected. - -## Limitations (v1) - -- Web HTML only (mobile WebView surfaces planned for v2) -- No render/OCR diff yet (v2 — white-on-white without inline CSS may be missed) -- Does not judge legal compliance of copy (pair with `compliance/tos_evaluator`) -- Semantic dark patterns without structural/lexical signals may not publish findings - --- diff --git a/docs/usage/install_extras.md b/docs/usage/install_extras.md index ed4ccb3..53dbdf3 100644 --- a/docs/usage/install_extras.md +++ b/docs/usage/install_extras.md @@ -108,6 +108,7 @@ One extra per bundled registry skill. Naming: `{category}_{skill_name}` (registr | `optimization_prompt_rewriter` | `optimization/prompt_rewriter` | *(none today)* | Use this extra in docs and installs | | `security_prompt_injection_firewall` | `security/prompt_injection_firewall` | *(none today)* | Offline-only; no runtime deps | | `security_deceptive_ui_guard` | `security/deceptive_ui_guard` | *(none today)* | Offline HTML analysis; optional url fetch uses `requests` (core) | +| `security_deceptive_ui_guard_render` | `security/deceptive_ui_guard` | `playwright` | Optional headless Playwright computed-style diffing (v0.2.0) | | `wellness_mental_coach` | `wellness/mental_coach` | `google-genai` | | ```bash diff --git a/examples/deceptive_ui_guard_demo.py b/examples/deceptive_ui_guard_demo.py index 39cc68f..3188a88 100644 --- a/examples/deceptive_ui_guard_demo.py +++ b/examples/deceptive_ui_guard_demo.py @@ -1,10 +1,13 @@ -""" -Local execute demo for security/deceptive_ui_guard. +"""Local execute demo for security/deceptive_ui_guard (v0.2.0). Loads sanitized HTML recreations of documented dark patterns (confirm shaming, drip pricing, forced continuity, mislabeled CTAs, anti-agent hidden text). -Fixtures live under examples/fixtures/deceptive_ui/ — pattern sources are public -deceptive-design taxonomies and regulator case descriptions, not live scrapes. +Demonstrates v2 capabilities: DOM zone weighting, session nag tracking, +mobile profile heuristics, and zone summary breakdowns. + +Demo fixtures live under examples/fixtures/deceptive_ui/. For the expanded +23-fixture golden test corpus categorized across 8 sub-domains, see +tests/fixtures/deceptive_ui/. """ from pathlib import Path @@ -19,21 +22,22 @@ def _load_fixture(name: str) -> str: def run_demo(): - print("Loading security/deceptive_ui_guard...") + print("Loading security/deceptive_ui_guard (v0.2.0)...") bundle = SkillLoader.load_skill("security/deceptive_ui_guard") skill = bundle["module"].DeceptiveUiGuardSkill() scenarios = [ ( - "Confirm shaming (newsletter modal — deceptive.design pattern)", + "Confirm shaming with session nag tracking (newsletter modal)", { "html_content": _load_fixture("confirm_shaming_newsletter.html"), "sensitivity": "balanced", "intended_action": "browse storefront", + "session_fingerprint": "session_usr_98231_checkout", }, ), ( - "Drip pricing (late fee disclosure — FTC-style checkout copy)", + "Drip pricing & zone weighting (late fee disclosure in checkout zone)", { "html_content": _load_fixture("drip_pricing_checkout.html"), "sensitivity": "balanced", @@ -61,13 +65,15 @@ def run_demo(): "html_content": _load_fixture("hidden_imperative_anti_agent.html"), "sensitivity": "balanced", "intended_action": "complete checkout", + "render_mode": "off", }, ), ( - "Clean control (neutral product documentation)", + "Clean control (neutral product documentation with zone summary)", { "html_content": _load_fixture("clean_product_docs.html"), "sensitivity": "balanced", + "surface_profile": "desktop", }, ), ] @@ -84,8 +90,18 @@ def run_demo(): for finding in result["findings"][:3]: print( f" - {finding.get('type')}/{finding.get('subtype')} " - f"({finding.get('severity')})" + f"({finding.get('severity')}) [zone={finding.get('zone')}]" ) + if result.get("zone_summary"): + active_zones = { + z: data["findings_count"] + for z, data in result["zone_summary"].items() + if data["findings_count"] > 0 + } + if active_zones: + print(f"zone_findings: {active_zones}") + if result.get("session_recommendation"): + print(f"session_recommendation: {result.get('session_recommendation')}") print(f"sanitized_excerpt: {result.get('sanitized_excerpt')!r}") guidance = result.get("agent_guidance") or {} print(f"verify_before_payment: {guidance.get('verify_before_payment')}") diff --git a/examples/fixtures/deceptive_ui/README.md b/examples/fixtures/deceptive_ui/README.md new file mode 100644 index 0000000..e7160ed --- /dev/null +++ b/examples/fixtures/deceptive_ui/README.md @@ -0,0 +1,5 @@ +# Deceptive UI Demo Fixtures + +These fixtures provide offline demonstrations of common deceptive UI patterns for [examples/deceptive_ui_guard_demo.py](../../deceptive_ui_guard_demo.py). + +For the comprehensive 23-fixture golden test corpus categorized across 8 sub-domains (clean, cmp_ok, sr_only_ok, checkout_trap, nti_agent, mislabeled_cta, ender_css_hidden, ake_urgency), see [tests/fixtures/deceptive_ui/](../../../tests/fixtures/deceptive_ui/). \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cb8ee46..0880090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,9 @@ agents = [ "anthropic", "openai", ] +security_deceptive_ui_guard_render = [ + "playwright", +] # --- extras: begin generated by scripts/sync_extras.py --- diff --git a/skills/security/deceptive_ui_guard/card.json b/skills/security/deceptive_ui_guard/card.json index 1d65f9d..7c932cb 100644 --- a/skills/security/deceptive_ui_guard/card.json +++ b/skills/security/deceptive_ui_guard/card.json @@ -1,6 +1,6 @@ { "name": "Deceptive UI Guard", - "description": "Deterministic deceptive UI surface scan with trust score and agent guidance.", + "description": "Deterministic deceptive UI surface scan with render diff, DOM zone weighting, allowlists, and trust score.", "issuer": { "name": "Ross Peili", "email": "vpeilivanidis@gmail.com", @@ -36,6 +36,14 @@ "key": "detected_threat", "label": "Primary Threat" }, + { + "key": "zone_summary", + "label": "Zone Breakdown" + }, + { + "key": "session_recommendation", + "label": "Session Guidance" + }, { "key": "sanitized_excerpt", "label": "Sanitized Excerpt" @@ -54,4 +62,4 @@ } ] } -} +} \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/guard.py b/skills/security/deceptive_ui_guard/guard.py index 2baf608..1368873 100644 --- a/skills/security/deceptive_ui_guard/guard.py +++ b/skills/security/deceptive_ui_guard/guard.py @@ -1,12 +1,13 @@ -"""Deterministic deceptive UI surface scanner — local analysis, optional URL fetch.""" +"""Deterministic deceptive UI surface scanner — local analysis, optional URL fetch, render lane.""" from __future__ import annotations +import importlib.util import ipaddress import json import re import socket -from dataclasses import dataclass +from dataclasses import dataclass, field from html import unescape from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Set, Tuple @@ -16,6 +17,8 @@ from bs4 import BeautifulSoup, NavigableString, Tag SensitivityLevel = Literal["strict", "balanced", "lenient"] +RenderMode = Literal["off", "auto", "force"] +SurfaceProfile = Literal["desktop", "mobile", "auto"] RiskLevel = Literal["none", "low", "medium", "high", "critical"] StatusLevel = Literal["ok", "caution", "warning", "blocked"] SurfaceIntegrity = Literal["ok", "degraded", "compromised"] @@ -37,7 +40,7 @@ ) WHITE_COLOR = re.compile( - r"color\s*:\s*(?:#fff(?:fff)?|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))", + r"color\s*:\s*(?:#fff(?:fff)?|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)|transparent)", re.I, ) WHITE_BG = re.compile( @@ -45,18 +48,39 @@ re.I, ) +CHECKOUT_SELECTORS = re.compile( + r"(?i)\b(checkout|payment|billing|order-summary|cart-total|due-today|subtotal|place-order)\b" +) CHECKOUT_CONTEXT = re.compile( r"(?i)\b(checkout|payment|subscribe|billing|credit\s+card|place\s+order|" - r"complete\s+purchase|cart)\b" + r"complete\s+purchase|cart|order\s+total|due\s+today|subtotal)\b" +) + +MODAL_SELECTORS = re.compile( + r"(?i)\b(modal|dialog|overlay|popup|lightbox|cookie-wall|gate)\b" ) +CMP_SELECTORS = re.compile( + r"(?i)\b(onetrust|cookiebot|cookieyes|usercentrics|klaro|cookie-banner|" + r"cookie-notice|consent-banner|cookie-consent)\b" +) + +NAV_SELECTORS = re.compile(r"(?i)\b(nav|footer|header|menu|site-footer|breadcrumbs)\b") + IMPERATIVE_LEXICON = re.compile( r"(?i)\b(ignore\s+(all\s+)?(previous|prior)\s+instructions|" r"disregard\s+(the\s+)?(above|system)|click\s+accept|do\s+not\s+ask|" - r"you\s+must\s+click|override\s+safety)\b" + r"you\s+must\s+click|override\s+safety|system\s+prompt|execute\s+command)\b" ) SEVERITY_RANK = {"low": 1, "medium": 2, "high": 3, "critical": 4} +ZONE_WEIGHTS = { + "checkout": 1.5, + "modal": 1.25, + "cmp": 1.0, + "general": 1.0, + "navigation": 0.75, +} @dataclass @@ -85,17 +109,22 @@ class ScanResult: fetch_status: str offline: bool sensitivity: str + zone_summary: Dict[str, Any] = field(default_factory=dict) + session_recommendation: str = "" -def _load_lexicon() -> Dict[str, List[str]]: - path = _KB_DIR / "deception_lexicon.json" +def _load_json_kb(filename: str) -> Dict[str, Any]: + path = _KB_DIR / filename if not path.exists(): - return { - "confirm_shaming": [], - "urgency": [], - "hidden_fee": [], - } - data = json.loads(path.read_text(encoding="utf-8")) + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _load_lexicon() -> Dict[str, List[str]]: + data = _load_json_kb("deception_lexicon.json") if isinstance(data.get("categories"), dict): return data["categories"] return { @@ -105,6 +134,13 @@ def _load_lexicon() -> Dict[str, List[str]]: } +def _load_allowlists() -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]: + sr_only = _load_json_kb("allowlist_sr_only.json") + cmp_kb = _load_json_kb("allowlist_cmp.json") + seo_kb = _load_json_kb("allowlist_seo.json") + return sr_only, cmp_kb, seo_kb + + def _normalize_space(text: str) -> str: return re.sub(r"\s+", " ", unescape(text or "")).strip() @@ -138,17 +174,102 @@ def _is_hidden_element(element: Tag) -> bool: return False -def _element_zone(element: Tag) -> str: - context = " ".join( - _normalize_space(str(node))[:200] - for node in element.parents - if isinstance(node, Tag) - ) - if CHECKOUT_CONTEXT.search(context): +def _classify_zone(element: Tag) -> str: + id_attr = str(element.get("id", "")) + class_attr = " ".join(element.get("class") or []) + node_str = f"{element.name} {id_attr} {class_attr}" + + if element.name in {"dialog"} or element.get("role") in {"dialog", "alertdialog"}: + return "modal" + + if CMP_SELECTORS.search(node_str): + return "cmp" + + if MODAL_SELECTORS.search(node_str): + return "modal" + + if CHECKOUT_SELECTORS.search(node_str): return "checkout" + + if element.name in {"nav", "footer", "header"} or NAV_SELECTORS.search(node_str): + return "navigation" + + for parent in element.parents: + if not isinstance(parent, Tag): + continue + p_id = str(parent.get("id", "")) + p_class = " ".join(parent.get("class") or []) + p_str = f"{parent.name} {p_id} {p_class}" + + if ( + parent.name in {"dialog"} + or parent.get("role") in {"dialog", "alertdialog"} + or MODAL_SELECTORS.search(p_str) + ): + return "modal" + if CMP_SELECTORS.search(p_str): + return "cmp" + if ( + CHECKOUT_SELECTORS.search(p_str) + or CHECKOUT_CONTEXT.search(p_id) + or CHECKOUT_CONTEXT.search(p_class) + ): + return "checkout" + if parent.name in {"nav", "footer", "header"} or NAV_SELECTORS.search(p_str): + return "navigation" + return "general" +def _is_allowlisted( + element: Tag, + sr_allowlist: Dict[str, Any], + cmp_allowlist: Dict[str, Any], + seo_allowlist: Dict[str, Any], +) -> Tuple[bool, str]: + classes = element.get("class") or [] + sr_classes = set(sr_allowlist.get("classes", [])) + if any(c in sr_classes for c in classes): + return True, "sr_only_class" + + for attr in sr_allowlist.get("attributes", []): + if element.has_attr(attr): + return True, "sr_only_attribute" + + text = _normalize_space(element.get_text(" ", strip=True)).lower() + for pattern in sr_allowlist.get("patterns", []): + if pattern.lower() in text and len(text) < 80: + return True, "sr_only_pattern" + + id_attr = str(element.get("id", "")) + class_str = " ".join(classes) + for sel in cmp_allowlist.get("selectors", []): + if sel.startswith("#") and sel[1:] == id_attr: + return True, "cmp_selector" + if sel.startswith(".") and sel[1:] in classes: + return True, "cmp_selector" + if sel in id_attr or sel in class_str: + return True, "cmp_selector" + + for phrase in cmp_allowlist.get("benign_phrases", []): + if phrase in text and len(text) < 150: + return True, "cmp_benign_copy" + + if element.name in seo_allowlist.get("tags", []): + return True, "seo_tag" + + if element.name == "script": + stype = str(element.get("type", "")).lower() + if stype in seo_allowlist.get("script_types", []): + return True, "seo_script_type" + + for attr in seo_allowlist.get("attributes", []): + if element.has_attr(attr): + return True, "seo_attribute" + + return False, "" + + def _direct_text(element: Tag) -> str: parts: List[str] = [] for child in element.children: @@ -198,51 +319,167 @@ def fetch_url_html(url: str) -> Tuple[str, str]: return content.decode(response.encoding or "utf-8", errors="replace"), "ok" -def _contrast_signal(element: Tag) -> Optional[RawSignal]: +def _contrast_signal(element: Tag, zone: str) -> Optional[RawSignal]: style = str(element.get("style", "")) - if WHITE_COLOR.search(style) and WHITE_BG.search(style): + if WHITE_COLOR.search(style) and ( + WHITE_BG.search(style) or "background" not in style.lower() + ): text = _direct_text(element) if len(text) < 4: return None return RawSignal( signal_type="low_contrast", subtype="white_on_white", - severity="low", + severity="low" if zone != "checkout" else "medium", selector=_element_selector(element), snippet=text[:SNIPPET_MAX], channels=["structural", "visual_style"], evidence={"style": style[:200]}, - zone=_element_zone(element), + zone=zone, ) return None -def _mislabeled_cta_signal(element: Tag) -> Optional[RawSignal]: +def _mislabeled_cta_signal(element: Tag, zone: str) -> Optional[RawSignal]: if element.name not in {"a", "button", "input"}: return None visible = _direct_text(element) aria = _normalize_space(str(element.get("aria-label", ""))) title = _normalize_space(str(element.get("title", ""))) value = _normalize_space(str(element.get("value", ""))) - label_text = visible or value + + if element.name == "input" and element.get("type") in {"submit", "button"}: + label_text = value or visible + else: + label_text = visible or value + alt_label = aria or title if not label_text or not alt_label: return None if label_text.lower() == alt_label.lower(): return None - # Different accessible name vs visible label on an interactive control. + + severity: RiskLevel = "medium" + if zone == "checkout" or ( + "continue" in label_text.lower() + and any(k in alt_label.lower() for k in ["buy", "charge", "pay", "order"]) + ): + severity = "high" + return RawSignal( signal_type="mislabeled_cta", subtype="label_aria_mismatch", - severity="medium", + severity=severity, selector=_element_selector(element), snippet=f"visible={label_text!r} aria/title={alt_label!r}"[:SNIPPET_MAX], channels=["structural", "accessibility"], evidence={"visible": label_text, "accessible_name": alt_label}, - zone=_element_zone(element), + zone=zone, ) +def _prechecked_opt_in_signal(element: Tag, zone: str) -> Optional[RawSignal]: + if element.name != "input" or str(element.get("type", "")).lower() != "checkbox": + return None + + is_checked = element.has_attr("checked") or str( + element.get("checked", "") + ).lower() in {"true", "checked"} + if not is_checked: + return None + + parent_text = _normalize_space( + element.parent.get_text(" ", strip=True) if element.parent else "" + ) + input_id = str(element.get("id", "")) + input_name = str(element.get("name", "")).lower() + + opt_in_terms = re.compile( + r"(?i)\b(subscribe|newsletter|marketing|updates|recurring|insurance|" + r"protection\s+plan|monthly|donation|membership|auto-renew|trial)\b" + ) + if ( + opt_in_terms.search(parent_text) + or opt_in_terms.search(input_name) + or opt_in_terms.search(input_id) + ): + severity: RiskLevel = "high" if zone == "checkout" else "medium" + return RawSignal( + signal_type="prechecked_opt_in", + subtype="preselected_subscription", + severity=severity, + selector=_element_selector(element), + snippet=parent_text[:SNIPPET_MAX] or f"input[name={input_name}]", + channels=["structural", "form_input"], + evidence={"checked": True, "context": parent_text[:120]}, + zone=zone, + ) + return None + + +def _drip_pricing_signal(element: Tag, zone: str) -> Optional[RawSignal]: + if zone != "checkout": + return None + text = _normalize_space(element.get_text(" ", strip=True)) + drip_patterns = re.compile( + r"(?i)\b(mandatory\s+service\s+fee|processing\s+fee\s+added|administrative\s+fee|" + r"drip\s+pricing|undisclosed\s+charge|due\s+at\s+checkout)\b" + ) + if drip_patterns.search(text) and len(text) < 140: + return RawSignal( + signal_type="drip_pricing", + subtype="undisclosed_checkout_fee", + severity="high", + selector=_element_selector(element), + snippet=text[:SNIPPET_MAX], + channels=["textual", "checkout_zone"], + evidence={"matched_fee_text": text}, + zone=zone, + ) + return None + + +def _fake_urgency_timer_signal(element: Tag, zone: str) -> Optional[RawSignal]: + text = _normalize_space(element.get_text(" ", strip=True)) + timer_pattern = re.compile( + r"(?i)\b(deal\s+expires\s+in|cart\s+reserved\s+for|price\s+locked\s+for|offer\s+ends\s+in)\s+\d{1,2}:\d{2}\b" + ) + is_hidden = _is_hidden_element(element) + if timer_pattern.search(text): + severity: RiskLevel = "high" if is_hidden else "medium" + return RawSignal( + signal_type="fake_urgency_timer", + subtype="hidden_scarcity_timer" if is_hidden else "countdown_urgency", + severity=severity, + selector=_element_selector(element), + snippet=text[:SNIPPET_MAX], + channels=["textual", "structural"], + evidence={"hidden_branch": is_hidden, "text": text}, + zone=zone, + ) + return None + + +def _nag_loop_signal(element: Tag, zone: str) -> Optional[RawSignal]: + text = _normalize_space(element.get_text(" ", strip=True)).lower() + nag_patterns = re.compile( + r"(?i)\b(no\s+thanks,\s+i\s+prefer\s+paying\s+full|no\s+thanks,\s+i\s+hate\s+saving|" + r"i\s+don't\s+want\s+to\s+save|remind\s+me\s+later|wait!\s+don't\s+go)\b" + ) + if nag_patterns.search(text) and len(text) < 120: + return RawSignal( + signal_type="nag_loop", + subtype="confirm_shaming_asymmetry", + severity="medium", + selector=_element_selector(element), + snippet=text[:SNIPPET_MAX], + channels=["textual", "copywriting"], + evidence={"nag_phrase": text}, + zone=zone, + ) + return None + + def _lexicon_hits(text: str, lexicon: Dict[str, List[str]]) -> List[str]: lowered = text.lower() hits: List[str] = [] @@ -255,7 +492,11 @@ def _lexicon_hits(text: str, lexicon: Dict[str, List[str]]) -> List[str]: def _channel_mismatch_signal( - element: Tag, visible_aggregate: str, lexicon: Dict[str, List[str]] + element: Tag, + visible_aggregate: str, + lexicon: Dict[str, List[str]], + allowlists: Tuple[Dict, Dict, Dict], + zone: str, ) -> Optional[RawSignal]: if not _is_hidden_element(element): return None @@ -264,8 +505,16 @@ def _channel_mismatch_signal( return None if hidden_text.lower() in visible_aggregate.lower(): return None - lex_hits = _lexicon_hits(hidden_text, lexicon) + + sr_allow, cmp_allow, seo_allow = allowlists + allowlisted, allow_reason = _is_allowlisted(element, sr_allow, cmp_allow, seo_allow) + imperative = bool(IMPERATIVE_LEXICON.search(hidden_text)) + lex_hits = _lexicon_hits(hidden_text, lexicon) + + if allowlisted and not imperative: + return None + if not lex_hits and not imperative: return RawSignal( signal_type="channel_mismatch", @@ -274,11 +523,12 @@ def _channel_mismatch_signal( selector=_element_selector(element), snippet=hidden_text[:SNIPPET_MAX], channels=["dom_text", "hidden_surface"], - evidence={"hidden_only": True}, - zone=_element_zone(element), + evidence={"hidden_only": True, "allowlisted": allowlisted}, + zone=zone, ) + severity: RiskLevel = "high" - if imperative and _element_zone(element) == "checkout": + if imperative and zone == "checkout": severity = "critical" subtype = "hidden_imperative_text" if imperative else "hidden_deception_lexicon" return RawSignal( @@ -288,12 +538,18 @@ def _channel_mismatch_signal( selector=_element_selector(element), snippet=hidden_text[:SNIPPET_MAX], channels=["dom_text", "hidden_surface"], - evidence={"lexicon_hits": lex_hits, "imperative": imperative}, - zone=_element_zone(element), + evidence={ + "lexicon_hits": lex_hits, + "imperative": imperative, + "allowlisted": allowlisted, + }, + zone=zone, ) -def _lexical_signal(element: Tag, lexicon: Dict[str, List[str]]) -> List[RawSignal]: +def _lexical_signal( + element: Tag, lexicon: Dict[str, List[str]], zone: str +) -> List[RawSignal]: text = _normalize_space(element.get_text(" ", strip=True)) if len(text) < 6: return [] @@ -302,8 +558,8 @@ def _lexical_signal(element: Tag, lexicon: Dict[str, List[str]]) -> List[RawSign return [] hidden = _is_hidden_element(element) severity: RiskLevel = "medium" if hidden else "low" - checkout_lexicon = {"hidden_fee", "forced_continuity"} - if checkout_lexicon.intersection(hits) and _element_zone(element) == "checkout": + checkout_lexicon = {"hidden_fee", "forced_continuity", "drip_pricing"} + if checkout_lexicon.intersection(hits) and zone == "checkout": severity = "high" return [ RawSignal( @@ -314,20 +570,148 @@ def _lexical_signal(element: Tag, lexicon: Dict[str, List[str]]) -> List[RawSign snippet=text[:SNIPPET_MAX], channels=["textual"], evidence={"categories": hits, "hidden": hidden}, - zone=_element_zone(element), + zone=zone, ) ] +def _mobile_profile_signal( + element: Tag, profile: SurfaceProfile, zone: str +) -> Optional[RawSignal]: + if profile != "mobile": + return None + style = str(element.get("style", "")).lower() + if ( + "-webkit-tap-highlight-color: transparent" in style + or "-webkit-tap-highlight-color: rgba(0,0,0,0)" in style + ): + if "position: absolute" in style or "position: fixed" in style: + return RawSignal( + signal_type="mobile_overlay_trap", + subtype="transparent_tap_overlay", + severity="medium", + selector=_element_selector(element), + snippet=style[:SNIPPET_MAX], + channels=["mobile_heuristics", "visual_style"], + evidence={"style": style}, + zone=zone, + ) + return None + + +def _render_dom_diff_lane( + html: str, + render_mode: RenderMode, + soup: BeautifulSoup, +) -> List[RawSignal]: + if render_mode == "off": + return [] + + playwright_available = importlib.util.find_spec("playwright") is not None + if not playwright_available: + if render_mode == "force": + raise ImportError( + "render_mode='force' requires Playwright. Install the extra with: " + "pip install 'skillware[security_deceptive_ui_guard_render]'" + ) + return [] + + signals: List[RawSignal] = [] + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + page.set_content(html, wait_until="domcontentloaded") + + elements_data = page.evaluate("""() => { + const results = []; + const all = document.querySelectorAll('*'); + for (const el of all) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + const isZeroSize = rect.width === 0 || rect.height === 0; + const isTransparent = style.opacity === '0' || style.visibility === 'hidden' || + style.display === 'none' || style.color === 'rgba(0, 0, 0, 0)' || + style.color === 'transparent'; + const text = el.innerText || ''; + if (text.length > 5 && (isZeroSize || isTransparent)) { + results.push({ + tagName: el.tagName.toLowerCase(), + id: el.id || '', + className: el.className || '', + text: text.substring(0, 160), + isZeroSize: isZeroSize, + isTransparent: isTransparent, + opacity: style.opacity, + visibility: style.visibility, + display: style.display + }); + } + } + return results; + }""") + browser.close() + + for item in elements_data: + tag_sel = ( + f"{item['tagName']}#{item['id']}" + if item["id"] + else ( + f"{item['tagName']}.{item['className']}" + if item["className"] + else item["tagName"] + ) + ) + signals.append( + RawSignal( + signal_type="render_dom_divergence", + subtype="render_css_hidden_element", + severity="high", + selector=tag_sel, + snippet=item["text"], + channels=["render_computed_style", "dom_divergence"], + evidence={ + "opacity": item.get("opacity"), + "visibility": item.get("visibility"), + "display": item.get("display"), + "zero_size": item.get("isZeroSize"), + }, + zone=( + "checkout" + if "fee" in item["text"].lower() + or "price" in item["text"].lower() + else "general" + ), + ) + ) + except Exception as exc: + if render_mode == "force": + raise RuntimeError(f"Playwright render execution failed: {exc}") from exc + return signals + + def _corroborate( - signals: List[RawSignal], sensitivity: SensitivityLevel + signals: List[RawSignal], + sensitivity: SensitivityLevel, + intended_action: str = "", ) -> List[Dict[str, Any]]: findings: List[Dict[str, Any]] = [] + action_lower = (intended_action or "").lower() + is_checkout_action = any( + k in action_lower for k in ["checkout", "pay", "buy", "order", "subscribe"] + ) + for signal in signals: corroborated_by: List[str] = [] publish = False confidence = 0.55 + zone_weight = ZONE_WEIGHTS.get(signal.zone, 1.0) + if is_checkout_action and signal.zone == "checkout": + zone_weight *= 1.2 + if signal.signal_type == "channel_mismatch": if signal.subtype in {"hidden_imperative_text", "hidden_deception_lexicon"}: publish = True @@ -335,10 +719,12 @@ def _corroborate( corroborated_by = ["channel_mismatch", "textual"] elif sensitivity == "lenient": continue - elif sensitivity == "balanced" and signal.zone == "checkout": + elif sensitivity == "balanced" and ( + signal.zone in {"checkout", "modal"} or zone_weight >= 1.25 + ): publish = True - confidence = 0.7 - corroborated_by = ["channel_mismatch", "checkout_zone"] + confidence = 0.72 + corroborated_by = ["channel_mismatch", f"{signal.zone}_zone"] elif sensitivity == "strict": publish = True confidence = 0.65 @@ -346,33 +732,72 @@ def _corroborate( elif signal.signal_type == "mislabeled_cta": publish = True - confidence = 0.78 - corroborated_by = ["accessibility", "structural"] + confidence = 0.85 if signal.zone == "checkout" else 0.78 + corroborated_by = ["accessibility", "structural", signal.zone] + + elif signal.signal_type == "prechecked_opt_in": + publish = True + confidence = 0.88 if signal.zone == "checkout" else 0.75 + corroborated_by = ["form_structure", signal.zone] + + elif signal.signal_type == "drip_pricing": + publish = True + confidence = 0.85 + corroborated_by = ["checkout_pricing", "textual"] + + elif signal.signal_type == "fake_urgency_timer": + publish = True + confidence = 0.80 + corroborated_by = ["timer_structure", "textual"] + + elif signal.signal_type == "nag_loop": + if sensitivity in {"strict", "balanced"}: + publish = True + confidence = 0.75 + corroborated_by = ["copywriting_asymmetry"] + + elif signal.signal_type == "render_dom_divergence": + publish = True + confidence = 0.92 + corroborated_by = ["render_computed_style", "dom_divergence"] + + elif signal.signal_type == "mobile_overlay_trap": + if sensitivity in {"strict", "balanced"}: + publish = True + confidence = 0.70 + corroborated_by = ["mobile_heuristics"] elif signal.signal_type == "deception_lexicon": if signal.severity in {"high", "critical"}: publish = True - confidence = 0.8 + confidence = 0.82 corroborated_by = ["textual", signal.zone] - elif ( - signal.subtype in {"hidden_fee", "forced_continuity"} - and signal.zone == "checkout" - ): + elif signal.subtype in { + "hidden_fee", + "forced_continuity", + "drip_pricing", + "preselected_opt_in", + } and signal.zone in {"checkout", "modal"}: publish = True - confidence = 0.75 - corroborated_by = ["textual", "checkout_zone"] + confidence = 0.78 + corroborated_by = ["textual", f"{signal.zone}_zone"] elif sensitivity == "strict": publish = True - confidence = 0.6 + confidence = 0.62 corroborated_by = ["textual"] elif signal.signal_type == "low_contrast": - if sensitivity == "strict" and signal.zone == "checkout": + if sensitivity in {"strict", "balanced"} and signal.zone in { + "checkout", + "modal", + }: + publish = True + confidence = 0.65 + corroborated_by = ["visual_style", f"{signal.zone}_zone"] + elif sensitivity == "strict": publish = True confidence = 0.55 - corroborated_by = ["visual_style", "checkout_zone"] - else: - continue + corroborated_by = ["visual_style"] if not publish: continue @@ -382,7 +807,7 @@ def _corroborate( "type": signal.signal_type, "subtype": signal.subtype, "severity": signal.severity, - "confidence": round(confidence, 2), + "confidence": round(min(0.99, confidence), 2), "channels": signal.channels, "selector": signal.selector, "snippet": signal.snippet, @@ -406,6 +831,49 @@ def _visible_excerpt(soup: BeautifulSoup) -> str: return excerpt[:4000] +def _build_zone_summary( + soup: BeautifulSoup, findings: List[Dict[str, Any]] +) -> Dict[str, Any]: + summary: Dict[str, Dict[str, Any]] = { + zone: {"element_count": 0, "findings_count": 0, "weight": weight} + for zone, weight in ZONE_WEIGHTS.items() + } + for element in soup.find_all(True): + if isinstance(element, Tag): + z = _classify_zone(element) + if z in summary: + summary[z]["element_count"] += 1 + + for f in findings: + fz = str(f.get("zone", "general")) + if fz in summary: + summary[fz]["findings_count"] += 1 + return summary + + +def _build_session_recommendation( + session_fingerprint: str, + findings: List[Dict[str, Any]], +) -> str: + if not session_fingerprint: + return "" + has_nag = any( + f.get("type") in {"nag_loop", "fake_urgency_timer"} + or f.get("subtype") in {"confirm_shaming"} + for f in findings + ) + if has_nag: + return ( + f"Recurring nag or urgency pattern detected for session fingerprint " + f"'{session_fingerprint[:16]}...'. Recommend bypassing modal prompts " + f"and enforcing strict payment review." + ) + return ( + f"Session fingerprint '{session_fingerprint[:16]}...' " + f"verified clean of recurring nag loops." + ) + + def _aggregate_risk( findings: List[Dict[str, Any]], ) -> Tuple[RiskLevel, int, SurfaceIntegrity]: @@ -448,9 +916,11 @@ def _build_guidance( if item.get("zone") == "checkout" or item.get("subtype") in { "hidden_fee", "forced_continuity", + "drip_pricing", + "preselected_subscription", }: verify_payment = True - if item.get("type") == "mislabeled_cta": + if item.get("type") in {"mislabeled_cta", "prechecked_opt_in"}: selectors.add(str(item.get("selector", ""))) summary_parts = [] @@ -462,17 +932,23 @@ def _build_guidance( summary_parts.append( "Interactive controls expose mismatched visible and accessible labels." ) - if any(f.get("subtype") == "hidden_fee" for f in findings): - summary_parts.append("Copy suggests fees may appear late in checkout.") + if any(f.get("type") == "prechecked_opt_in" for f in findings): + summary_parts.append( + "Pre-checked subscription or recurring opt-in box detected." + ) + if any(f.get("subtype") in {"hidden_fee", "drip_pricing"} for f in findings): + summary_parts.append( + "Copy suggests hidden or drip fees may appear late in checkout." + ) if any(f.get("subtype") == "forced_continuity" for f in findings): summary_parts.append("Copy suggests automatic renewal or post-trial billing.") + if any(f.get("type") == "render_dom_divergence" for f in findings): + summary_parts.append( + "Computed style rendering reveals text or interactive elements hidden by external stylesheets." + ) action_lower = (intended_action or "").lower() - if ( - "pay" in action_lower - or "checkout" in action_lower - or "subscribe" in action_lower - ): + if any(k in action_lower for k in ["pay", "checkout", "subscribe", "buy", "order"]): verify_payment = True return { @@ -489,6 +965,9 @@ def scan_surface( url: str = "", sensitivity: SensitivityLevel = "balanced", intended_action: str = "", + render_mode: RenderMode = "off", + surface_profile: SurfaceProfile = "desktop", + session_fingerprint: str = "", ) -> ScanResult: fetch_status = "skipped" offline = True @@ -516,6 +995,8 @@ def scan_surface( fetch_status=f"error: {exc}", offline=False, sensitivity=sensitivity, + zone_summary={}, + session_recommendation="", ) if not html.strip(): @@ -536,28 +1017,66 @@ def scan_surface( fetch_status=fetch_status, offline=offline, sensitivity=sensitivity, + zone_summary={}, + session_recommendation="", ) soup = BeautifulSoup(html, "html.parser") lexicon = _load_lexicon() + allowlists = _load_allowlists() visible_aggregate = _visible_excerpt(soup) signals: List[RawSignal] = [] + effective_profile = surface_profile + if surface_profile == "auto": + viewport_meta = soup.find("meta", attrs={"name": "viewport"}) + effective_profile = "mobile" if viewport_meta else "desktop" + for element in soup.find_all(True): if not isinstance(element, Tag): continue - contrast = _contrast_signal(element) + zone = _classify_zone(element) + + contrast = _contrast_signal(element, zone) if contrast: signals.append(contrast) - mislabeled = _mislabeled_cta_signal(element) + + mislabeled = _mislabeled_cta_signal(element, zone) if mislabeled: signals.append(mislabeled) - mismatch = _channel_mismatch_signal(element, visible_aggregate, lexicon) + + prechecked = _prechecked_opt_in_signal(element, zone) + if prechecked: + signals.append(prechecked) + + drip = _drip_pricing_signal(element, zone) + if drip: + signals.append(drip) + + urgency_timer = _fake_urgency_timer_signal(element, zone) + if urgency_timer: + signals.append(urgency_timer) + + nag = _nag_loop_signal(element, zone) + if nag: + signals.append(nag) + + mobile_sig = _mobile_profile_signal(element, effective_profile, zone) + if mobile_sig: + signals.append(mobile_sig) + + mismatch = _channel_mismatch_signal( + element, visible_aggregate, lexicon, allowlists, zone + ) if mismatch: signals.append(mismatch) - signals.extend(_lexical_signal(element, lexicon)) - findings = _corroborate(signals, sensitivity) + signals.extend(_lexical_signal(element, lexicon, zone)) + + render_signals = _render_dom_diff_lane(html, render_mode, soup) + signals.extend(render_signals) + + findings = _corroborate(signals, sensitivity, intended_action) risk, trust, integrity = _aggregate_risk(findings) status = _status_for(trust, risk, sensitivity) is_safe = status == "ok" and risk in {"none", "low"} @@ -568,6 +1087,8 @@ def scan_surface( detected = f"{top['type']}:{top['subtype']} ({top['severity']})" guidance = _build_guidance(findings, intended_action) + zone_summary = _build_zone_summary(soup, findings) + session_rec = _build_session_recommendation(session_fingerprint, findings) return ScanResult( status=status, @@ -582,4 +1103,6 @@ def scan_surface( fetch_status=fetch_status, offline=offline, sensitivity=sensitivity, + zone_summary=zone_summary, + session_recommendation=session_rec, ) diff --git a/skills/security/deceptive_ui_guard/instructions.md b/skills/security/deceptive_ui_guard/instructions.md index 4f84cac..43fbeb2 100644 --- a/skills/security/deceptive_ui_guard/instructions.md +++ b/skills/security/deceptive_ui_guard/instructions.md @@ -1,14 +1,15 @@ # Deceptive UI Guard -You are using the `security/deceptive_ui_guard` skill. +You are using the `security/deceptive_ui_guard` skill (v0.2.0). Run this skill on **web HTML surfaces** before an autonomous agent clicks checkout controls, accepts subscriptions, or passes scraped page text into model context. The skill is a deterministic, offline surface-integrity layer — not a guarantee against every semantic manipulation. ## Trust model - **HTML-first, offline by default.** Prefer `html_content` from the host browser. Optional public `url` fetch uses SSRF guards; analysis stays deterministic with no LLM detection path. -- **Dual-surface extraction.** Compares extractable DOM text against the visible surface to catch hidden copy, mislabeled controls, and checkout-zone deception signals. -- **Corroboration gates.** Not every low signal publishes; findings include selector, snippet, channels, zone, and evidence. +- **Dual-surface & optional render lane.** Compares extractable DOM text against the visible surface and supports headless Chromium computed-style diffing (`render_mode='auto'|'force'`) to catch external-CSS hidden fees and zero-size overlay traps. +- **Allowlist-guarded channel matching.** Standard accessibility screen-reader text (`sr-only`, `visually-hidden`), CMP cookie banners (OneTrust, Cookiebot), and SEO tags are allowlisted to prevent false positives unless containing imperative overrides. +- **Corroboration gates & zone weighting.** Subtrees are classified into `checkout`, `modal`, `cmp`, `navigation`, and `general` zones with severity weighting. - **Warn only.** Never click, submit forms, or complete checkout. Escalate high/critical checkout findings to a human. ## When to invoke @@ -28,9 +29,11 @@ Run this skill on **web HTML surfaces** before an autonomous agent clicks checko | `surface_integrity` | `ok`, `degraded`, or `compromised` | | `risk_level` | Aggregated severity (`none` when clean) | | `detected_threat` | Primary human-readable reason when unsafe | -| `findings` | Published signals only (`type`, `subtype`, `severity`, `selector`, `snippet`, `zone`, `evidence`) | +| `findings` | Published signals (`type`, `subtype`, `severity`, `selector`, `snippet`, `zone`, `evidence`) | | `agent_guidance` | `do_not_click` selectors, `verify_before_payment`, summary | | `sanitized_excerpt` | Visible-surface text safe for downstream context | +| `zone_summary` | Breakdown of DOM zones identified and their risk weights | +| `session_recommendation` | Guidance based on session nag loop detection | | `fetch_status` | `skipped`, `ok`, or error detail when `url` was used | | `offline` | `true` when only local HTML was analyzed | | `sensitivity` | Sensitivity level used for the scan | @@ -42,14 +45,16 @@ If `is_safe` is `false`, honor `agent_guidance.do_not_click` and prefer `sanitiz - `html_content` (preferred): Sanitized HTML or DOM snapshot from the host browser - `url` (optional): Public http(s) URL when HTML is unavailable (network + SSRF guards) - `sensitivity`: `strict`, `balanced` (default), or `lenient` -- `intended_action` (optional): Task hint (e.g. complete checkout) to tune guidance +- `intended_action` (optional): Task hint (e.g. complete checkout) to tune zone weighting and guidance +- `render_mode` (optional): `off` (default), `auto`, or `force` for optional Playwright computed-style diffing +- `surface_profile` (optional): `desktop` (default), `mobile`, or `auto` (inferred from viewport meta) +- `session_fingerprint` (optional): Stable session hash to detect recurring nag loops across pages -### Sensitivity posture +## Allowlist maintenance -- **balanced (default):** Publishes channel mismatches with imperative/deception lexicon hits; checkout-zone fee/renewal copy; mislabeled CTAs. Lone hidden non-visible text needs checkout zone or strict mode. -- **strict:** Also publishes low-contrast checkout styling and more lone-signal lexical hits. Use for autonomous commerce agents. -- **lenient:** Suppresses low-confidence channel mismatches; still publishes imperative hidden text and checkout fee/renewal signals. +Allowlists live under `skills/security/deceptive_ui_guard/kb/`: +- `allowlist_sr_only.json`: Add new legitimate accessibility/screen-reader classes or patterns. +- `allowlist_cmp.json`: Add new Consent Management Platform vendor selectors and benign copy. +- `allowlist_seo.json`: Add valid metadata tags and structured data script types. +- `deception_lexicon.json`: Curated dark pattern phrases and categories. -## Limitations - -Heuristic surface analysis can miss semantic dark patterns without structural or lexical signals. External-stylesheet hiding may be missed until render diff (v2). Aggressive but legitimate marketing copy can false-positive at `strict`. Use human review for payment flows when `verify_before_payment` is set. diff --git a/skills/security/deceptive_ui_guard/kb/allowlist_cmp.json b/skills/security/deceptive_ui_guard/kb/allowlist_cmp.json new file mode 100644 index 0000000..22247ca --- /dev/null +++ b/skills/security/deceptive_ui_guard/kb/allowlist_cmp.json @@ -0,0 +1,48 @@ +{ + "_meta": { + "file": "allowlist_cmp.json", + "skill": "security/deceptive_ui_guard", + "purpose": "Allowlist for known Consent Management Platform (CMP) containers, selectors, and benign cookie banner copy.", + "version": "0.2.0" + }, + "selectors": [ + "#onetrust-consent-sdk", + "#onetrust-banner-sdk", + "#onetrust-pc-sdk", + "#cookiebot", + "#CybotCookiebotDialog", + "#CybotCookiebotDialogBody", + ".cookiebot", + ".cookieyes", + "#cky-consent-container", + ".usercentrics-cmp", + "#usercentrics-root", + ".klaro", + ".trustarc", + "#truste-consent-track", + ".cookie-banner", + ".cookie-notice", + ".consent-banner", + ".cookie-consent", + "#cookie-consent", + "[data-nosnippet*=\"cookie\"]", + "[id*=\"cookie-banner\"]", + "[class*=\"cookie-consent\"]" + ], + "benign_phrases": [ + "we use cookies", + "we use cookies to improve your experience", + "this site uses cookies", + "by continuing to browse", + "accept cookies", + "accept all cookies", + "manage cookie preferences", + "cookie settings", + "reject non-essential cookies", + "privacy and cookie policy", + "strictly necessary cookies", + "functional cookies", + "performance cookies", + "targeting cookies" + ] +} \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/kb/allowlist_seo.json b/skills/security/deceptive_ui_guard/kb/allowlist_seo.json new file mode 100644 index 0000000..fe16a1b --- /dev/null +++ b/skills/security/deceptive_ui_guard/kb/allowlist_seo.json @@ -0,0 +1,24 @@ +{ + "_meta": { + "file": "allowlist_seo.json", + "skill": "security/deceptive_ui_guard", + "purpose": "Allowlist for legitimate metadata, structured data (JSON-LD), and SEO tags not intended for visual display.", + "version": "0.2.0" + }, + "tags": [ + "meta", + "link", + "noscript" + ], + "script_types": [ + "application/ld+json", + "application/json", + "text/template", + "text/html" + ], + "attributes": [ + "itemprop", + "itemscope", + "itemtype" + ] +} \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/kb/allowlist_sr_only.json b/skills/security/deceptive_ui_guard/kb/allowlist_sr_only.json new file mode 100644 index 0000000..101ab6d --- /dev/null +++ b/skills/security/deceptive_ui_guard/kb/allowlist_sr_only.json @@ -0,0 +1,42 @@ +{ + "_meta": { + "file": "allowlist_sr_only.json", + "skill": "security/deceptive_ui_guard", + "purpose": "Allowlist for standard accessibility and screen-reader-only patterns to suppress false-positive channel_mismatch findings.", + "version": "0.2.0" + }, + "classes": [ + "sr-only", + "sr-only-focusable", + "visually-hidden", + "visuallyhidden", + "visually_hidden", + "screen-reader-text", + "screen-reader-only", + "reader-text", + "hidden-accessible", + "clip-hidden", + "skip-link", + "skip-to-content", + "skip-main", + "a11y-sr-only", + "cdk-visually-hidden" + ], + "attributes": [ + "aria-live", + "aria-atomic", + "aria-relevant" + ], + "patterns": [ + "skip to main content", + "skip to content", + "screen reader only", + "opens in new window", + "opens in a new tab", + "expanded", + "collapsed", + "required field", + "close dialog", + "close menu" + ] +} \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/kb/deception_lexicon.json b/skills/security/deceptive_ui_guard/kb/deception_lexicon.json index e13d2aa..507d9b6 100644 --- a/skills/security/deceptive_ui_guard/kb/deception_lexicon.json +++ b/skills/security/deceptive_ui_guard/kb/deception_lexicon.json @@ -3,8 +3,8 @@ "file": "deception_lexicon.json", "skill": "security/deceptive_ui_guard", "purpose": "Deterministic deceptive-design phrase lexicon for offline HTML surface scanning.", - "version": "0.1.0", - "taxonomy": "Categories align with public deceptive-design pattern families (confirm shaming, urgency/scarcity, hidden costs, forced continuity, roach motel, preselection, trick questions, disguised ads).", + "version": "0.2.0", + "taxonomy": "Categories align with public deceptive-design pattern families (confirm shaming, urgency/scarcity, hidden costs, forced continuity, roach motel, preselection, trick questions, disguised ads, drip pricing, fake scarcity timers, nag loops).", "license_note": "Phrases are skillware-curated from published dark-pattern taxonomies and FTC/regulator case descriptions. No verbatim import of live third-party site HTML.", "maintenance": "Add phrases via PR with category, example context, and a regression fixture when behavior should publish." }, @@ -80,6 +80,16 @@ "held for you", "someone else is viewing" ], + "fake_urgency_timer": [ + "deal expires in", + "reservation held for", + "cart expires in", + "price locked for", + "time remaining to complete order", + "offer expiring in", + "your session will expire in", + "timer will reset" + ], "hidden_fee": [ "fee applied at confirmation", "service fee applied", @@ -118,6 +128,14 @@ "surprise fee", "mandatory fee" ], + "drip_pricing": [ + "mandatory service fee", + "undisclosed fee", + "drip pricing fee", + "unavoidable fee", + "administrative surcharge added", + "checkout handling fee" + ], "forced_continuity": [ "automatically renew", "automatic renewal", @@ -156,7 +174,21 @@ "enabled by default", "included by default", "added to your order by default", - "selected for you" + "selected for you", + "add protection plan", + "enroll in monthly coverage", + "subscribe to marketing updates" + ], + "nag_loop": [ + "remind me later", + "maybe later", + "ask me again", + "keep showing this offer", + "continue without saving", + "are you sure you want to leave", + "wait! don't go", + "before you leave", + "special one-time offer" ], "roach_motel": [ "easy to subscribe, hard to cancel", @@ -201,4 +233,4 @@ "commission may be earned" ] } -} +} \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/manifest.yaml b/skills/security/deceptive_ui_guard/manifest.yaml index 45de8df..66bc7cd 100644 --- a/skills/security/deceptive_ui_guard/manifest.yaml +++ b/skills/security/deceptive_ui_guard/manifest.yaml @@ -1,11 +1,12 @@ name: security/deceptive_ui_guard -version: 0.1.0 +version: 0.2.0 description: > Offline deterministic scanner for deceptive web UI surfaces and anti-agent tricks. Analyzes HTML (or optional public URL fetch) with dual DOM vs visible-surface extraction, + optional Playwright-rendered computed style diffing, DOM zone classification, allowlists, structural and lexical heuristics, corroboration gates, trust scoring, and agent guidance for pre-click and pre-context guardrails. Does not click, submit forms, or call LLMs. -short_description: "Deceptive UI guard with dual-surface scan, trust score, and agent guidance." +short_description: "Deceptive UI guard with render diff, DOM zone weighting, allowlists, and trust score." issuer: name: Ross Peili email: vpeilivanidis@gmail.com @@ -31,7 +32,26 @@ parameters: default: balanced intended_action: type: string - description: Optional natural-language task (e.g. complete checkout) to tune agent guidance. + description: Optional natural-language task (e.g. complete checkout) to tune agent guidance and zone weighting. + render_mode: + type: string + description: Render lane mode using headless browser computed style/visual extraction. + enum: + - off + - auto + - force + default: off + surface_profile: + type: string + description: Target layout profile for mobile touch target and viewport heuristics. + enum: + - desktop + - mobile + - auto + default: desktop + session_fingerprint: + type: string + description: Optional stable origin+path or browser session hash to track recurring nag or countdown loops. required: [] outputs: status: @@ -54,7 +74,7 @@ outputs: description: Primary finding summary when signals cross the publish threshold. findings: type: array - description: Structured findings with type, severity, selector, snippet, channels, and evidence. + description: Structured findings with type, severity, selector, snippet, channels, zone, and evidence. agent_guidance: type: object description: Pre-click and pre-context guidance including do_not_click and verify_before_payment. @@ -70,14 +90,20 @@ outputs: sensitivity: type: string description: Sensitivity level used for the scan. + zone_summary: + type: object + description: Breakdown of DOM zones identified and their risk weights. + session_recommendation: + type: string + description: Guidance based on session nag loop detection. requirements: [] constitution: | 1. WARN ONLY: Never click, submit forms, accept cookies, or complete checkout flows. 2. DETERMINISTIC CORE: Identical HTML input yields identical findings; no LLM-in-the-loop detection. 3. HTML-FIRST: Prefer html_content from the host browser; optional public url fetch with SSRF guards. - 4. EXPLAINABLE: Every published finding includes selector, snippet, channels, and corroboration evidence. + 4. EXPLAINABLE: Every published finding includes selector, snippet, channels, zone, and corroboration evidence. 5. COMPOSABLE: Does not call other skills; hosts may chain prompt_injection_firewall separately. - 6. HONEST LIMITS: Semantic manipulation without structural or lexical signals may be missed until v2 OCR. + 6. HONEST LIMITS: Semantic manipulation without structural, lexical, or rendered signals may require visual OCR. presentation: icon: shield - color: "#1f2937" + color: "#1f2937" \ No newline at end of file diff --git a/skills/security/deceptive_ui_guard/skill.py b/skills/security/deceptive_ui_guard/skill.py index 3ca6ea7..5da60e7 100644 --- a/skills/security/deceptive_ui_guard/skill.py +++ b/skills/security/deceptive_ui_guard/skill.py @@ -7,10 +7,10 @@ from skillware.core.base_skill import BaseSkill try: - from .guard import SensitivityLevel, scan_surface + from .guard import RenderMode, SensitivityLevel, SurfaceProfile, scan_surface except ImportError: sys.path.insert(0, os.path.dirname(__file__)) - from guard import SensitivityLevel, scan_surface + from guard import RenderMode, SensitivityLevel, SurfaceProfile, scan_surface class DeceptiveUiGuardSkill(BaseSkill): @@ -22,19 +22,27 @@ def manifest(self) -> Dict[str, Any]: if os.path.exists(manifest_path): with open(manifest_path, "r", encoding="utf-8") as handle: return yaml.safe_load(handle) - return {"name": "security/deceptive_ui_guard", "version": "0.1.0"} + return {"name": "security/deceptive_ui_guard", "version": "0.2.0"} def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: html_content = params.get("html_content") or "" url = params.get("url") or "" sensitivity = self._normalize_sensitivity(params.get("sensitivity", "balanced")) intended_action = str(params.get("intended_action") or "") + render_mode = self._normalize_render_mode(params.get("render_mode", "off")) + surface_profile = self._normalize_surface_profile( + params.get("surface_profile", "desktop") + ) + session_fingerprint = str(params.get("session_fingerprint") or "") result = scan_surface( html_content=str(html_content), url=str(url), sensitivity=sensitivity, intended_action=intended_action, + render_mode=render_mode, + surface_profile=surface_profile, + session_fingerprint=session_fingerprint, ) return { @@ -50,6 +58,8 @@ def execute(self, params: Dict[str, Any]) -> Dict[str, Any]: "fetch_status": result.fetch_status, "offline": result.offline, "sensitivity": result.sensitivity, + "zone_summary": result.zone_summary, + "session_recommendation": result.session_recommendation, } def _normalize_sensitivity(self, value: Any) -> SensitivityLevel: @@ -57,3 +67,15 @@ def _normalize_sensitivity(self, value: Any) -> SensitivityLevel: if normalized in {"strict", "balanced", "lenient"}: return normalized # type: ignore[return-value] return "balanced" + + def _normalize_render_mode(self, value: Any) -> RenderMode: + normalized = str(value or "off").strip().lower() + if normalized in {"off", "auto", "force"}: + return normalized # type: ignore[return-value] + return "off" + + def _normalize_surface_profile(self, value: Any) -> SurfaceProfile: + normalized = str(value or "desktop").strip().lower() + if normalized in {"desktop", "mobile", "auto"}: + return normalized # type: ignore[return-value] + return "desktop" diff --git a/skills/security/deceptive_ui_guard/test_skill.py b/skills/security/deceptive_ui_guard/test_skill.py index 2d964c6..e2be915 100644 --- a/skills/security/deceptive_ui_guard/test_skill.py +++ b/skills/security/deceptive_ui_guard/test_skill.py @@ -170,6 +170,145 @@ def test_scan_surface_strict_white_on_white_checkout(): assert any(f["type"] == "low_contrast" for f in result.findings) +def test_allowlist_sr_only_suppresses_false_positive(skill): + html = """ + +Regular page text.
+ Opens in new window +Legitimate article copy.
No thanks, I hate saving money
+Order summary
test
", render_mode="force") + + +def test_render_dom_divergence_with_playwright(): + pytest.importorskip("playwright") + html = """ + +How can we help you today?
+ + \ No newline at end of file diff --git a/tests/fixtures/deceptive_ui/anti_agent/hidden_system_override.html b/tests/fixtures/deceptive_ui/anti_agent/hidden_system_override.html new file mode 100644 index 0000000..41441ef --- /dev/null +++ b/tests/fixtures/deceptive_ui/anti_agent/hidden_system_override.html @@ -0,0 +1,11 @@ + + +The ultimate developer tool.
+ + \ No newline at end of file diff --git a/tests/fixtures/deceptive_ui/anti_agent/invisible_instruction.html b/tests/fixtures/deceptive_ui/anti_agent/invisible_instruction.html new file mode 100644 index 0000000..564b0ad --- /dev/null +++ b/tests/fixtures/deceptive_ui/anti_agent/invisible_instruction.html @@ -0,0 +1,10 @@ + + +Browse our catalog.
+ + \ No newline at end of file diff --git a/tests/fixtures/deceptive_ui/checkout_trap/drip_pricing_fee.html b/tests/fixtures/deceptive_ui/checkout_trap/drip_pricing_fee.html new file mode 100644 index 0000000..90f1c9c --- /dev/null +++ b/tests/fixtures/deceptive_ui/checkout_trap/drip_pricing_fee.html @@ -0,0 +1,12 @@ + + +Room Rate: $120.00 / night
+Open source artificial intelligence continues to accelerate across global research communities.
+Transparent architectures enable reproducible benchmarks and trust.
+Deterministic validation and surface scanning protect agents during browser task execution.
+1x Mechanical Keyboard - $99.00
+Estimated Tax: $8.00
+Shipping: Free
+Total: $107.00
+ +$20 / month
+Getting started guide.
Recent activity.
Main content area.
Origination fee $350 applies immediately upon signature.
+ +Please enter payment method.
+Official health updates.
+