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 +0 -0
- unseen/api.py +133 -0
- unseen/cli.py +133 -0
- unseen/detectors/__init__.py +8 -0
- unseen/detectors/base.py +92 -0
- unseen/detectors/css_hiding.py +226 -0
- unseen/detectors/hidden_attrs.py +74 -0
- unseen/detectors/html_comments.py +61 -0
- unseen/detectors/instruction_patterns.py +151 -0
- unseen/detectors/invisible_unicode.py +93 -0
- unseen/detectors/patterns.py +131 -0
- unseen/detectors/render_diff.py +35 -0
- unseen/fetcher.py +117 -0
- unseen/net_guard.py +85 -0
- unseen/ratelimit.py +56 -0
- unseen/render.py +174 -0
- unseen/report.py +47 -0
- unseen/sanitize.py +102 -0
- unseen/scoring.py +65 -0
- unseen-0.1.0.dist-info/METADATA +150 -0
- unseen-0.1.0.dist-info/RECORD +24 -0
- unseen-0.1.0.dist-info/WHEEL +4 -0
- unseen-0.1.0.dist-info/entry_points.txt +2 -0
- unseen-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|
unseen/detectors/base.py
ADDED
|
@@ -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
|