unseen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
unseen/__init__.py ADDED
File without changes
unseen/api.py ADDED
@@ -0,0 +1,133 @@
1
+ """HTTP API: the same scan as the CLI, over POST /scan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import math
7
+ import os
8
+ import threading
9
+ from typing import Literal
10
+
11
+ from fastapi import FastAPI, HTTPException, Request
12
+ from fastapi.responses import JSONResponse
13
+ from pydantic import BaseModel, Field, model_validator
14
+
15
+ from unseen.cli import overall, scan_page
16
+ from unseen.detectors.base import Page, Severity
17
+ from unseen.fetcher import FetchError, fetch
18
+ from unseen.net_guard import make_validator
19
+ from unseen.ratelimit import RateLimiter
20
+ from unseen.render import RenderError, render_hidden_text
21
+ from unseen.sanitize import sanitize as sanitize_page
22
+
23
+ MAX_HTML_CHARS = 2_000_000
24
+ MAX_BODY_BYTES = 3_000_000
25
+
26
+ app = FastAPI(title="unseen", version="0.1.0")
27
+ RENDER_LOCK = threading.Lock() # only one browser render at a time
28
+ URL_VALIDATOR = make_validator() # SSRF guard: runs on the first URL and on every redirect
29
+
30
+ SCAN_LIMITER = RateLimiter(limit=10, window=60) # scans per visitor per minute
31
+ RENDER_LIMITER = RateLimiter(limit=3, window=60) # browser renders per visitor per minute
32
+
33
+ # Set UNSEEN_TRUST_PROXY=1 ONLY when a reverse proxy (Caddy) sits in front of the API.
34
+ # Otherwise visitors could fake X-Forwarded-For and dodge the rate limit.
35
+ TRUST_PROXY = os.environ.get("UNSEEN_TRUST_PROXY") == "1"
36
+
37
+
38
+ @app.middleware("http")
39
+ async def limit_body_size(request: Request, call_next):
40
+ length = request.headers.get("content-length", "")
41
+ if length.isdigit() and int(length) > MAX_BODY_BYTES:
42
+ return JSONResponse({"detail": "request body too large"}, status_code=413)
43
+ return await call_next(request)
44
+
45
+
46
+ def client_key(http_request: Request) -> str:
47
+ """The identity used for rate limiting: the visitor's IP (IPv6 grouped by /64)."""
48
+ host = http_request.client.host if http_request.client else "unknown"
49
+ if TRUST_PROXY:
50
+ forwarded = http_request.headers.get("x-forwarded-for", "")
51
+ if forwarded:
52
+ host = forwarded.split(",")[-1].strip() or host # the entry our proxy added
53
+ try:
54
+ ip = ipaddress.ip_address(host)
55
+ except ValueError:
56
+ return host
57
+ if isinstance(ip, ipaddress.IPv6Address):
58
+ return str(ipaddress.ip_network(f"{ip}/64", strict=False))
59
+ return str(ip)
60
+
61
+
62
+ class ScanRequest(BaseModel):
63
+ url: str | None = None
64
+ html: str | None = Field(default=None, max_length=MAX_HTML_CHARS)
65
+ render: bool = False
66
+ sanitize: bool = False
67
+ min_severity: Literal["low", "medium", "high"] = "low"
68
+
69
+ @model_validator(mode="after")
70
+ def check_source(self) -> ScanRequest:
71
+ if (self.url is None) == (self.html is None):
72
+ raise ValueError("provide exactly one of 'url' or 'html'")
73
+ if self.url is not None and not self.url.startswith(("http://", "https://")):
74
+ raise ValueError("url must start with http:// or https://")
75
+ return self
76
+
77
+
78
+ @app.get("/health")
79
+ def health() -> dict[str, str]:
80
+ return {"status": "ok"}
81
+
82
+
83
+ @app.post("/scan")
84
+ def scan(request: ScanRequest, http_request: Request) -> dict:
85
+ key = client_key(http_request)
86
+ wait = SCAN_LIMITER.check(key)
87
+ if wait == 0 and request.render:
88
+ wait = RENDER_LIMITER.check(key)
89
+ if wait > 0:
90
+ raise HTTPException(
91
+ status_code=429,
92
+ detail="too many requests, try again later",
93
+ headers={"Retry-After": str(math.ceil(wait))},
94
+ )
95
+
96
+ if request.render and not RENDER_LOCK.acquire(blocking=False):
97
+ raise HTTPException(status_code=429, detail="another render is running, try again soon")
98
+
99
+ try:
100
+ if request.url is not None:
101
+ page = fetch(request.url, validator=URL_VALIDATOR)
102
+ else:
103
+ page = Page.from_html(request.html or "")
104
+ if request.render:
105
+ page.hidden_text = render_hidden_text(page.html)
106
+ except FetchError as error:
107
+ raise HTTPException(status_code=502, detail=f"could not fetch url: {error}") from error
108
+ except RenderError as error:
109
+ raise HTTPException(status_code=503, detail=f"rendering unavailable: {error}") from error
110
+ finally:
111
+ if request.render:
112
+ RENDER_LOCK.release()
113
+
114
+ merged = scan_page(page)
115
+ top = overall(merged)
116
+ minimum = Severity[request.min_severity.upper()]
117
+
118
+ result: dict = {
119
+ "target": request.url if request.url is not None else "(html)",
120
+ "overall": top.name.lower() if top else None,
121
+ "findings": [m.to_dict() for m in merged if m.severity >= minimum],
122
+ }
123
+
124
+ if request.sanitize:
125
+ cleaned = sanitize_page(page)
126
+ result["sanitized"] = {
127
+ "text": cleaned.text,
128
+ "removed": cleaned.removed,
129
+ "comments_removed": cleaned.comments_removed,
130
+ "invisible_removed": cleaned.invisible_removed,
131
+ }
132
+
133
+ return result
unseen/cli.py ADDED
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import unseen.detectors # noqa: F401 (registers all detectors)
9
+ from unseen.detectors.base import Page, Severity, all_detectors
10
+ from unseen.fetcher import FetchError, fetch
11
+ from unseen.render import RenderError, render_hidden_text
12
+ from unseen.report import render as print_report
13
+ from unseen.sanitize import sanitize
14
+ from unseen.scoring import MergedFinding, merge_findings
15
+
16
+ EXIT_OK = 0
17
+ EXIT_RISK = 1
18
+ EXIT_ERROR = 2
19
+
20
+
21
+ def load_page(target: str) -> Page:
22
+ """Return a Page for a URL (via fetcher.py) or a local file path."""
23
+ if target.startswith(("http://", "https://")):
24
+ return fetch(target)
25
+ path = Path(target)
26
+ html = path.read_text(encoding="utf-8", errors="replace")
27
+ return Page.from_html(html, url=str(path))
28
+
29
+
30
+ def scan_page(page: Page) -> list[MergedFinding]:
31
+ findings = []
32
+ for detector in all_detectors():
33
+ findings.extend(detector.scan(page))
34
+ return merge_findings(findings)
35
+
36
+
37
+ def scan_target(target: str) -> list[MergedFinding]:
38
+ return scan_page(load_page(target))
39
+
40
+
41
+ def overall(merged: list[MergedFinding]) -> Severity | None:
42
+ return max((m.severity for m in merged), default=None)
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ parser = argparse.ArgumentParser(
47
+ prog="unseen",
48
+ description="Find hidden text on a web page that humans cannot see but AI models can.",
49
+ )
50
+ commands = parser.add_subparsers(dest="command", required=True)
51
+
52
+ scan = commands.add_parser("scan", help="scan a URL or an HTML file")
53
+ scan.add_argument("target", help="URL or path to an HTML file")
54
+ scan.add_argument("--json", action="store_true", help="print results as JSON")
55
+ scan.add_argument(
56
+ "--min-severity",
57
+ choices=["low", "medium", "high"],
58
+ default="low",
59
+ help="only show findings at or above this level",
60
+ )
61
+ scan.add_argument(
62
+ "--fail-on",
63
+ choices=["low", "medium", "high"],
64
+ default="high",
65
+ help="exit with code 1 if any finding is at or above this level (default: high)",
66
+ )
67
+ scan.add_argument(
68
+ "--sanitize",
69
+ metavar="FILE",
70
+ help="write a cleaned text version of the page (hidden content removed) to FILE",
71
+ )
72
+ scan.add_argument(
73
+ "--render",
74
+ action="store_true",
75
+ help="also render the page in a headless browser to catch hidden text that "
76
+ "static analysis misses (needs Playwright)",
77
+ )
78
+ return parser
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ args = build_parser().parse_args(argv)
83
+
84
+ try:
85
+ page = load_page(args.target)
86
+ if args.render:
87
+ page.hidden_text = render_hidden_text(page.html)
88
+ merged = scan_page(page)
89
+ cleaned = sanitize(page) if args.sanitize else None
90
+ except FetchError as error:
91
+ print(f"unseen: could not fetch {args.target}: {error}", file=sys.stderr)
92
+ return EXIT_ERROR
93
+ except RenderError as error:
94
+ print(f"unseen: {error}", file=sys.stderr)
95
+ return EXIT_ERROR
96
+ except Exception as error: # file or decoding problems
97
+ print(f"unseen: could not scan {args.target}: {error}", file=sys.stderr)
98
+ return EXIT_ERROR
99
+
100
+ fail_level = Severity[args.fail_on.upper()]
101
+ top = overall(merged)
102
+ failed = top is not None and top >= fail_level
103
+
104
+ shown = [m for m in merged if m.severity >= Severity[args.min_severity.upper()]]
105
+
106
+ if args.json:
107
+ output = {
108
+ "target": args.target,
109
+ "overall": top.name.lower() if top else None,
110
+ "findings": [m.to_dict() for m in shown],
111
+ }
112
+ print(json.dumps(output, indent=2))
113
+ else:
114
+ print_report(args.target, shown)
115
+
116
+ if cleaned is not None:
117
+ try:
118
+ Path(args.sanitize).write_text(cleaned.text + "\n", encoding="utf-8")
119
+ except OSError as error:
120
+ print(f"unseen: could not write {args.sanitize}: {error}", file=sys.stderr)
121
+ return EXIT_ERROR
122
+ print(
123
+ f"unseen: cleaned text written to {args.sanitize} "
124
+ f"({len(cleaned.removed)} hidden elements, {cleaned.comments_removed} comments, "
125
+ f"{cleaned.invisible_removed} invisible characters removed)",
126
+ file=sys.stderr,
127
+ )
128
+
129
+ return EXIT_RISK if failed else EXIT_OK
130
+
131
+
132
+ if __name__ == "__main__":
133
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ from unseen.detectors import ( # noqa: F401
2
+ css_hiding,
3
+ hidden_attrs,
4
+ html_comments,
5
+ instruction_patterns,
6
+ invisible_unicode,
7
+ render_diff,
8
+ )
@@ -0,0 +1,92 @@
1
+ """Shared types and the base class every detector implements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from enum import IntEnum
9
+ from typing import TYPE_CHECKING
10
+
11
+ from bs4 import BeautifulSoup
12
+ from bs4.element import Tag
13
+
14
+ if TYPE_CHECKING:
15
+ from unseen.render import HiddenText
16
+
17
+
18
+ class Severity(IntEnum):
19
+ LOW = 1
20
+ MEDIUM = 2
21
+ HIGH = 3
22
+
23
+
24
+ def clip(text: str, limit: int = 120) -> str:
25
+ """Collapse whitespace and shorten text for display."""
26
+ text = re.sub(r"\s+", " ", text).strip()
27
+ return text if len(text) <= limit else text[: limit - 3] + "..."
28
+
29
+
30
+ def describe(tag: Tag) -> str:
31
+ """Short CSS-like label for an element, e.g. div#note.footer."""
32
+ label = tag.name
33
+ if tag.get("id"):
34
+ label += f"#{tag['id']}"
35
+ classes = tag.get("class")
36
+ if classes:
37
+ label += "." + ".".join(classes)
38
+ return label
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Finding:
43
+ detector: str
44
+ severity: Severity
45
+ message: str
46
+ location: str
47
+ snippet: str = ""
48
+
49
+ def to_dict(self) -> dict[str, str]:
50
+ return {
51
+ "detector": self.detector,
52
+ "severity": self.severity.name.lower(),
53
+ "message": self.message,
54
+ "location": self.location,
55
+ "snippet": self.snippet,
56
+ }
57
+
58
+
59
+ @dataclass
60
+ class Page:
61
+ url: str
62
+ html: str
63
+ soup: BeautifulSoup
64
+ hidden_text: list[HiddenText] | None = None # filled by render.py when --render is used
65
+
66
+ @classmethod
67
+ def from_html(cls, html: str, url: str = "") -> Page:
68
+ return cls(url=url, html=html, soup=BeautifulSoup(html, "lxml"))
69
+
70
+
71
+ class Detector(ABC):
72
+ """One hiding technique. Subclasses set name and description, then implement scan."""
73
+
74
+ name: str
75
+ description: str
76
+
77
+ @abstractmethod
78
+ def scan(self, page: Page) -> list[Finding]:
79
+ """Return every finding for this technique on the page."""
80
+
81
+
82
+ REGISTRY: dict[str, type[Detector]] = {}
83
+
84
+
85
+ def register(cls: type[Detector]) -> type[Detector]:
86
+ """Class decorator that adds a detector to the registry."""
87
+ REGISTRY[cls.name] = cls
88
+ return cls
89
+
90
+
91
+ def all_detectors() -> list[Detector]:
92
+ return [cls() for cls in REGISTRY.values()]
@@ -0,0 +1,226 @@
1
+ """Detector 1: text hidden from humans with CSS or the hidden attribute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from bs4 import BeautifulSoup
8
+ from bs4.element import Tag
9
+
10
+ from .base import Detector, Finding, Page, Severity, clip, describe, register
11
+ from .instruction_patterns import detect_instruction_patterns
12
+
13
+ SKIP_TAGS = {"script", "style", "noscript", "template", "head", "title", "meta", "link"}
14
+
15
+ # Common on legitimate pages (menus, modals), so they score lower than deceptive tricks.
16
+ ORDINARY = {"hidden attribute", "display:none", "visibility:hidden"}
17
+
18
+ # Screen-reader-only text uses these. With very short text they cannot carry an attack.
19
+ SCREEN_READER = {"clipped to nothing", "zero-size container"}
20
+
21
+ # A closed dropdown or accordion: hidden with display/visibility AND collapsed to zero size.
22
+ COLLAPSE_HIDERS = {"display:none", "visibility:hidden", "hidden attribute"}
23
+
24
+ NAMED_COLORS = {
25
+ "white": (255, 255, 255),
26
+ "black": (0, 0, 0),
27
+ "red": (255, 0, 0),
28
+ "green": (0, 128, 0),
29
+ "blue": (0, 0, 255),
30
+ "yellow": (255, 255, 0),
31
+ "gray": (128, 128, 128),
32
+ "grey": (128, 128, 128),
33
+ "silver": (192, 192, 192),
34
+ }
35
+
36
+ COLOR_RE = re.compile(
37
+ r"#[0-9a-f]{6}\b|#[0-9a-f]{3}\b|rgba?\([^)]*\)|\b(?:" + "|".join(NAMED_COLORS) + r")\b"
38
+ )
39
+ RULE_RE = re.compile(r"([^{}@]+)\{([^{}]*)\}")
40
+ CLIP_RECT_RE = re.compile(r"rect\(\s*0(?:px)?[\s,]+0(?:px)?[\s,]+0(?:px)?[\s,]+0(?:px)?\s*\)")
41
+ TRANSPARENT_RE = re.compile(r"rgba\(\d+,\d+,\d+,0(?:\.0+)?\)")
42
+
43
+
44
+ def number(value: str) -> float | None:
45
+ match = re.match(r"\s*(-?\d*\.?\d+)", value)
46
+ return float(match.group(1)) if match else None
47
+
48
+
49
+ def parse_color(value: str) -> tuple[int, ...] | None:
50
+ value = value.strip().lower().replace(" ", "")
51
+ if value in NAMED_COLORS:
52
+ return NAMED_COLORS[value]
53
+ match = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", value)
54
+ if match:
55
+ digits = match.group(1)
56
+ if len(digits) == 3:
57
+ digits = "".join(c * 2 for c in digits)
58
+ return tuple(int(digits[i : i + 2], 16) for i in (0, 2, 4))
59
+ match = re.fullmatch(r"rgba?\((\d+),(\d+),(\d+)(?:,[\d.]+)?\)", value)
60
+ if match:
61
+ return tuple(int(g) for g in match.groups())
62
+ return None
63
+
64
+
65
+ def find_color(value: str) -> tuple[int, ...] | None:
66
+ match = COLOR_RE.search(value.lower())
67
+ return parse_color(match.group(0)) if match else None
68
+
69
+
70
+ def is_transparent(value: str) -> bool:
71
+ value = value.replace(" ", "")
72
+ return value == "transparent" or bool(TRANSPARENT_RE.fullmatch(value))
73
+
74
+
75
+ def parse_declarations(block: str) -> dict[str, str]:
76
+ declarations: dict[str, str] = {}
77
+ for part in block.split(";"):
78
+ if ":" not in part:
79
+ continue
80
+ prop, _, value = part.partition(":")
81
+ value = re.sub(r"\s*!important\s*$", "", value.strip(), flags=re.I)
82
+ declarations[prop.strip().lower()] = value.lower()
83
+ return declarations
84
+
85
+
86
+ def compute_styles(soup: BeautifulSoup) -> dict[int, dict[str, str]]:
87
+ """Map id(tag) to declarations from <style> blocks. Specificity is ignored (last rule wins)."""
88
+ css = "\n".join(style.get_text() for style in soup.find_all("style"))
89
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
90
+ styles: dict[int, dict[str, str]] = {}
91
+ for selector, body in RULE_RE.findall(css):
92
+ try:
93
+ matches = soup.select(selector.strip())
94
+ except Exception:
95
+ continue
96
+ declarations = parse_declarations(body)
97
+ for tag in matches:
98
+ styles.setdefault(id(tag), {}).update(declarations)
99
+ return styles
100
+
101
+
102
+ def hiding_reasons(tag: Tag, style: dict[str, str]) -> list[str]:
103
+ reasons: list[str] = []
104
+
105
+ if tag.has_attr("hidden"):
106
+ reasons.append("hidden attribute")
107
+ if style.get("display") == "none":
108
+ reasons.append("display:none")
109
+ if style.get("visibility") in ("hidden", "collapse"):
110
+ reasons.append("visibility:hidden")
111
+
112
+ opacity = number(style.get("opacity", ""))
113
+ if opacity is not None and opacity <= 0.01:
114
+ reasons.append("opacity:0")
115
+
116
+ raw_size = style.get("font-size", "")
117
+ font_size = number(raw_size)
118
+ if font_size is not None and (font_size == 0 or (font_size <= 1 and "px" in raw_size)):
119
+ reasons.append("font-size:0")
120
+
121
+ indent = number(style.get("text-indent", ""))
122
+ if indent is not None and indent <= -500:
123
+ reasons.append("text-indent off-screen")
124
+
125
+ if style.get("position") in ("absolute", "fixed", "relative"):
126
+ for side in ("left", "top", "right", "bottom"):
127
+ offset = number(style.get(side, ""))
128
+ if offset is not None and offset <= -500:
129
+ reasons.append("off-screen position")
130
+ break
131
+
132
+ clip_path = style.get("clip-path", "").replace(" ", "")
133
+ if CLIP_RECT_RE.fullmatch(style.get("clip", "").strip()):
134
+ reasons.append("clipped to nothing")
135
+ elif clip_path.startswith(("inset(100%", "inset(50%")):
136
+ reasons.append("clipped to nothing")
137
+
138
+ if style.get("overflow") == "hidden":
139
+ width = number(style.get("width", ""))
140
+ height = number(style.get("height", ""))
141
+ if (width is not None and width <= 1) or (height is not None and height <= 1):
142
+ reasons.append("zero-size container")
143
+
144
+ color = style.get("color", "")
145
+ if color and is_transparent(color):
146
+ reasons.append("transparent text")
147
+ else:
148
+ fg = find_color(color) if color else None
149
+ bg_value = style.get("background-color") or style.get("background") or ""
150
+ bg = find_color(bg_value) if bg_value else None
151
+ if fg is not None and fg == bg:
152
+ reasons.append("text color equals background")
153
+
154
+ return reasons
155
+
156
+
157
+ def is_skip_link(tag: Tag, text: str) -> bool:
158
+ """A short in-page anchor such as 'Skip to content' (href starting with #)."""
159
+ return tag.name == "a" and str(tag.get("href", "")).startswith("#") and len(text) < 40
160
+
161
+
162
+ def is_ordinary_reason(reason: str, reasons: list[str], tag: Tag, text: str) -> bool:
163
+ if reason in ORDINARY:
164
+ return True
165
+ short = len(text) < 60
166
+ if short and reason in SCREEN_READER:
167
+ return True
168
+ # A closed dropdown or accordion: collapsed to zero size AND hidden the usual way.
169
+ if reason == "zero-size container" and any(r in COLLAPSE_HIDERS for r in reasons):
170
+ return True
171
+ # Skip links are moved off-screen until they receive focus.
172
+ if reason == "off-screen position" and is_skip_link(tag, text):
173
+ return True
174
+ return False
175
+
176
+
177
+ @register
178
+ class CssHiding(Detector):
179
+ name = "css_hiding"
180
+ description = "Text hidden from humans with CSS or the hidden attribute"
181
+
182
+ def scan(self, page: Page) -> list[Finding]:
183
+ styles = compute_styles(page.soup)
184
+ findings: list[Finding] = []
185
+ flagged: set[int] = set()
186
+
187
+ for tag in page.soup.find_all(True):
188
+ if tag.name in SKIP_TAGS:
189
+ continue
190
+ if any(id(parent) in flagged for parent in tag.parents):
191
+ continue
192
+
193
+ style = dict(styles.get(id(tag), {}))
194
+ style.update(parse_declarations(tag.get("style", "") or ""))
195
+
196
+ reasons = hiding_reasons(tag, style)
197
+ if not reasons:
198
+ continue
199
+
200
+ text = tag.get_text(" ", strip=True)
201
+ if not text:
202
+ continue
203
+
204
+ flagged.add(id(tag))
205
+ deceptive = [r for r in reasons if not is_ordinary_reason(r, reasons, tag, text)]
206
+ if deceptive:
207
+ severity = Severity.HIGH if len(text) >= 20 else Severity.MEDIUM
208
+ else:
209
+ severity = Severity.LOW
210
+
211
+ message = f"Hidden from humans via {', '.join(reasons)}"
212
+ # An instruction anywhere inside hidden content (children included) is HIGH.
213
+ if detect_instruction_patterns(text):
214
+ severity = Severity.HIGH
215
+ message += " (contains instruction-like text)"
216
+
217
+ findings.append(
218
+ Finding(
219
+ detector=self.name,
220
+ severity=severity,
221
+ message=message,
222
+ location=describe(tag),
223
+ snippet=clip(text),
224
+ )
225
+ )
226
+ return findings
@@ -0,0 +1,74 @@
1
+ """Detector 4: text aimed at machines and assistive readers rather than at human eyes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Detector, Finding, Page, Severity, clip, describe, register
6
+ from .patterns import find_instruction
7
+
8
+ SKIP_ATTRS = {"href", "src", "srcset", "action", "style", "class", "id", "rel", "type", "name"}
9
+ LONG_ATTRS = {"alt", "title", "aria-label", "aria-description"}
10
+ MIN_LENGTH = 8
11
+ LONG_TEXT = 200
12
+
13
+
14
+ def attr_text(value: object) -> str:
15
+ return " ".join(value) if isinstance(value, list) else str(value)
16
+
17
+
18
+ @register
19
+ class HiddenAttrs(Detector):
20
+ name = "hidden_attrs"
21
+ description = "Instructions in attributes, meta tags, noscript and aria-hidden content"
22
+
23
+ def scan(self, page: Page) -> list[Finding]:
24
+ findings: list[Finding] = []
25
+ flagged: set[int] = set()
26
+
27
+ def add(severity: Severity, message: str, location: str, snippet: str) -> None:
28
+ findings.append(Finding(self.name, severity, message, location, snippet))
29
+
30
+ for tag in page.soup.find_all(True):
31
+ for attr, value in tag.attrs.items():
32
+ if attr in SKIP_ATTRS:
33
+ continue
34
+ text = attr_text(value)
35
+ if len(text) < MIN_LENGTH:
36
+ continue
37
+ location = f"{describe(tag)}[{attr}]"
38
+ phrase = find_instruction(text)
39
+ if phrase:
40
+ message = (
41
+ f'Attribute "{attr}" contains instruction-like text: '
42
+ f'"{clip(phrase, 60)}"'
43
+ )
44
+ add(Severity.HIGH, message, location, clip(text))
45
+ elif attr in LONG_ATTRS and len(text) >= LONG_TEXT:
46
+ message = f'Unusually long "{attr}" text ({len(text)} characters)'
47
+ add(Severity.LOW, message, location, clip(text))
48
+
49
+ if any(id(parent) in flagged for parent in tag.parents):
50
+ continue
51
+
52
+ if str(tag.get("aria-hidden", "")).lower() == "true":
53
+ text = tag.get_text(" ", strip=True)
54
+ phrase = find_instruction(text)
55
+ if phrase:
56
+ flagged.add(id(tag))
57
+ message = (
58
+ "aria-hidden content contains instruction-like text: "
59
+ f'"{clip(phrase, 60)}"'
60
+ )
61
+ add(Severity.MEDIUM, message, describe(tag), clip(text))
62
+
63
+ if tag.name == "noscript":
64
+ text = tag.get_text(" ", strip=True)
65
+ phrase = find_instruction(text)
66
+ if phrase:
67
+ flagged.add(id(tag))
68
+ message = (
69
+ "noscript content contains instruction-like text: "
70
+ f'"{clip(phrase, 60)}"'
71
+ )
72
+ add(Severity.HIGH, message, describe(tag), clip(text))
73
+
74
+ return findings