stackscan 2.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.
- stackscan/__init__.py +5 -0
- stackscan/__main__.py +4 -0
- stackscan/analyzers/__init__.py +29 -0
- stackscan/analyzers/creds.py +218 -0
- stackscan/analyzers/cve.py +458 -0
- stackscan/analyzers/exposure.py +73 -0
- stackscan/analyzers/infra.py +114 -0
- stackscan/analyzers/security.py +26 -0
- stackscan/analyzers/services.py +285 -0
- stackscan/analyzers/tech.py +178 -0
- stackscan/cli.py +750 -0
- stackscan/config/__init__.py +19 -0
- stackscan/config/sigdb_loader.py +74 -0
- stackscan/config/sources.py +238 -0
- stackscan/core/__init__.py +3 -0
- stackscan/core/core.py +60 -0
- stackscan/data/builtin.sigdb +0 -0
- stackscan/data/cve.json.gz +0 -0
- stackscan/data/reekeer-logo.png +0 -0
- stackscan/data/subdomains.txt +522 -0
- stackscan/export.py +574 -0
- stackscan/net/__init__.py +22 -0
- stackscan/net/dns.py +168 -0
- stackscan/net/fingerprint.py +41 -0
- stackscan/net/geo.py +70 -0
- stackscan/net/ipinfo.py +94 -0
- stackscan/net/ports.py +219 -0
- stackscan/net/resolver.py +54 -0
- stackscan/net/subdomains.py +457 -0
- stackscan/net/tld.py +126 -0
- stackscan/net/tls.py +78 -0
- stackscan/render.py +541 -0
- stackscan/scan.py +545 -0
- stackscan/theme.py +23 -0
- stackscan/types/__init__.py +43 -0
- stackscan/types/models.py +18 -0
- stackscan/types/output.py +367 -0
- stackscan/utils/__init__.py +4 -0
- stackscan/utils/paths.py +10 -0
- stackscan/utils/urls.py +39 -0
- stackscan-2.1.0.dist-info/METADATA +341 -0
- stackscan-2.1.0.dist-info/RECORD +45 -0
- stackscan-2.1.0.dist-info/WHEEL +4 -0
- stackscan-2.1.0.dist-info/entry_points.txt +2 -0
- stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from stackscan.types import ScanReport, ServiceFinding, Technology
|
|
4
|
+
|
|
5
|
+
_DB_PORTS: dict[int, str] = {
|
|
6
|
+
3306: "MySQL",
|
|
7
|
+
5432: "PostgreSQL",
|
|
8
|
+
6379: "Redis",
|
|
9
|
+
27017: "MongoDB",
|
|
10
|
+
9200: "Elasticsearch",
|
|
11
|
+
1433: "Microsoft SQL Server",
|
|
12
|
+
1521: "Oracle Database",
|
|
13
|
+
11211: "Memcached",
|
|
14
|
+
5984: "CouchDB",
|
|
15
|
+
9042: "Cassandra",
|
|
16
|
+
7474: "Neo4j",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_REMOTE_PORTS: dict[int, str] = {
|
|
20
|
+
22: "SSH",
|
|
21
|
+
23: "Telnet",
|
|
22
|
+
3389: "RDP",
|
|
23
|
+
5900: "VNC",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_CAMERA_PORTS: dict[int, str] = {
|
|
27
|
+
554: "RTSP",
|
|
28
|
+
8554: "RTSP",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_MESSAGE_PORTS: dict[int, str] = {
|
|
32
|
+
25: "SMTP",
|
|
33
|
+
465: "SMTPS",
|
|
34
|
+
587: "Submission",
|
|
35
|
+
110: "POP3",
|
|
36
|
+
995: "POP3S",
|
|
37
|
+
143: "IMAP",
|
|
38
|
+
993: "IMAPS",
|
|
39
|
+
1883: "MQTT",
|
|
40
|
+
5672: "AMQP",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
_ADMIN_TECHS: dict[str, str] = {
|
|
44
|
+
"phpmyadmin": "phpMyAdmin",
|
|
45
|
+
"adminer": "Adminer",
|
|
46
|
+
"webmin": "Webmin",
|
|
47
|
+
"cpanel": "cPanel",
|
|
48
|
+
"plesk": "Plesk",
|
|
49
|
+
"directadmin": "DirectAdmin",
|
|
50
|
+
"jenkins": "Jenkins",
|
|
51
|
+
"gitlab": "GitLab",
|
|
52
|
+
"gitea": "Gitea",
|
|
53
|
+
"grafana": "Grafana",
|
|
54
|
+
"zabbix": "Zabbix",
|
|
55
|
+
"kibana": "Kibana",
|
|
56
|
+
"kubernetes-dashboard": "Kubernetes Dashboard",
|
|
57
|
+
"rabbitmq": "RabbitMQ Management",
|
|
58
|
+
"solr": "Apache Solr Admin",
|
|
59
|
+
"hadoop": "Hadoop Admin",
|
|
60
|
+
"swagger": "Swagger UI",
|
|
61
|
+
"postman": "Postman",
|
|
62
|
+
"horde": "Horde Webmail",
|
|
63
|
+
"roundcube": "Roundcube Webmail",
|
|
64
|
+
"squirrelmail": "SquirrelMail",
|
|
65
|
+
"zimbra": "Zimbra Web Client",
|
|
66
|
+
"icewarp": "IceWarp WebMail",
|
|
67
|
+
"mdaemon": "MDaemon Webmail",
|
|
68
|
+
"atmail": "Atmail",
|
|
69
|
+
"open-xchange": "Open-Xchange",
|
|
70
|
+
"postfixadmin": "PostfixAdmin",
|
|
71
|
+
"vestacp": "Vesta CP",
|
|
72
|
+
"hestiacp": "Hestia CP",
|
|
73
|
+
"cyberpanel": "CyberPanel",
|
|
74
|
+
"froxlor": "Froxlor",
|
|
75
|
+
"ispconfig": "ISPConfig",
|
|
76
|
+
"ajenti": "Ajenti",
|
|
77
|
+
"cockpit": "Cockpit",
|
|
78
|
+
"portainer": "Portainer",
|
|
79
|
+
"rancher": "Rancher",
|
|
80
|
+
"traefik": "Traefik Dashboard",
|
|
81
|
+
"nomad": "Nomad",
|
|
82
|
+
"consul": "Consul",
|
|
83
|
+
"vault": "Vault",
|
|
84
|
+
"argocd": "Argo CD",
|
|
85
|
+
"octopus deploy": "Octopus Deploy",
|
|
86
|
+
"teamcity": "TeamCity",
|
|
87
|
+
"bamboo": "Bamboo",
|
|
88
|
+
"youtrack": "YouTrack",
|
|
89
|
+
"redmine": "Redmine",
|
|
90
|
+
"confluence": "Confluence",
|
|
91
|
+
"jira": "Jira",
|
|
92
|
+
"bitbucket": "Bitbucket",
|
|
93
|
+
"nagios": "Nagios",
|
|
94
|
+
"icinga": "Icinga",
|
|
95
|
+
"prometheus": "Prometheus",
|
|
96
|
+
"thanos": "Thanos",
|
|
97
|
+
"elasticsearch": "Elasticsearch",
|
|
98
|
+
"logstash": "Logstash",
|
|
99
|
+
"graylog": "Graylog",
|
|
100
|
+
"splunk": "Splunk",
|
|
101
|
+
"nexus": "Sonatype Nexus",
|
|
102
|
+
"artifactory": "JFrog Artifactory",
|
|
103
|
+
"harbor": "Harbor",
|
|
104
|
+
"gogs": "Gogs",
|
|
105
|
+
"sourcegraph": "Sourcegraph",
|
|
106
|
+
"phabricator": "Phabricator",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_DB_TECHS: dict[str, str] = {
|
|
110
|
+
"mysql": "MySQL",
|
|
111
|
+
"mariadb": "MariaDB",
|
|
112
|
+
"postgresql": "PostgreSQL",
|
|
113
|
+
"redis": "Redis",
|
|
114
|
+
"mongodb": "MongoDB",
|
|
115
|
+
"elasticsearch": "Elasticsearch",
|
|
116
|
+
"microsoft sql server": "Microsoft SQL Server",
|
|
117
|
+
"sqlite": "SQLite",
|
|
118
|
+
"cassandra": "Cassandra",
|
|
119
|
+
"couchdb": "CouchDB",
|
|
120
|
+
"neo4j": "Neo4j",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_SECURITY_TECHS: dict[str, str] = {
|
|
124
|
+
"imperva incapsula": "Imperva Incapsula",
|
|
125
|
+
"sucuri": "Sucuri WAF",
|
|
126
|
+
"cloudflare": "Cloudflare",
|
|
127
|
+
"akamai": "Akamai",
|
|
128
|
+
"fortinet": "Fortinet",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_SEVERITY: dict[str, str] = {
|
|
132
|
+
"database": "CRITICAL",
|
|
133
|
+
"admin-panel": "HIGH",
|
|
134
|
+
"remote-access": "HIGH",
|
|
135
|
+
"camera": "HIGH",
|
|
136
|
+
"messaging": "MEDIUM",
|
|
137
|
+
"service": "INFO",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _tech_name(tech: Technology) -> str:
|
|
142
|
+
return tech.name.lower().strip()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _service_from_tech(tech: Technology) -> ServiceFinding | None:
|
|
146
|
+
name = _tech_name(tech)
|
|
147
|
+
if name in _ADMIN_TECHS:
|
|
148
|
+
return ServiceFinding(
|
|
149
|
+
name=_ADMIN_TECHS[name],
|
|
150
|
+
kind="admin-panel",
|
|
151
|
+
evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
|
|
152
|
+
severity=_SEVERITY["admin-panel"],
|
|
153
|
+
)
|
|
154
|
+
if name in _DB_TECHS:
|
|
155
|
+
return ServiceFinding(
|
|
156
|
+
name=_DB_TECHS[name],
|
|
157
|
+
kind="database",
|
|
158
|
+
evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
|
|
159
|
+
severity=_SEVERITY["database"],
|
|
160
|
+
)
|
|
161
|
+
if name in _SECURITY_TECHS:
|
|
162
|
+
return ServiceFinding(
|
|
163
|
+
name=_SECURITY_TECHS[name],
|
|
164
|
+
kind="service",
|
|
165
|
+
evidence=" ".join(tech.evidence) or f"tech: {tech.name}",
|
|
166
|
+
severity=_SEVERITY["service"],
|
|
167
|
+
)
|
|
168
|
+
for category in tech.categories or ():
|
|
169
|
+
if category in {"database", "service", "infrastructure", "security"}:
|
|
170
|
+
return ServiceFinding(
|
|
171
|
+
name=tech.name,
|
|
172
|
+
kind=category if category != "service" else "service",
|
|
173
|
+
evidence=" ".join(tech.evidence) or f"category: {category}",
|
|
174
|
+
severity=_SEVERITY.get(category, "INFO"),
|
|
175
|
+
)
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _service_from_port(port: int) -> ServiceFinding | None:
|
|
180
|
+
if port in _DB_PORTS:
|
|
181
|
+
return ServiceFinding(
|
|
182
|
+
name=_DB_PORTS[port],
|
|
183
|
+
kind="database",
|
|
184
|
+
evidence=f"port {port}/tcp",
|
|
185
|
+
severity=_SEVERITY["database"],
|
|
186
|
+
)
|
|
187
|
+
if port in _REMOTE_PORTS:
|
|
188
|
+
return ServiceFinding(
|
|
189
|
+
name=_REMOTE_PORTS[port],
|
|
190
|
+
kind="remote-access",
|
|
191
|
+
evidence=f"port {port}/tcp",
|
|
192
|
+
severity=_SEVERITY["remote-access"],
|
|
193
|
+
)
|
|
194
|
+
if port in _CAMERA_PORTS:
|
|
195
|
+
return ServiceFinding(
|
|
196
|
+
name=_CAMERA_PORTS[port],
|
|
197
|
+
kind="camera",
|
|
198
|
+
evidence=f"port {port}/tcp",
|
|
199
|
+
severity=_SEVERITY["camera"],
|
|
200
|
+
)
|
|
201
|
+
if port in _MESSAGE_PORTS:
|
|
202
|
+
return ServiceFinding(
|
|
203
|
+
name=_MESSAGE_PORTS[port],
|
|
204
|
+
kind="messaging",
|
|
205
|
+
evidence=f"port {port}/tcp",
|
|
206
|
+
severity=_SEVERITY["messaging"],
|
|
207
|
+
)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_WEB_PORTS: frozenset[int] = frozenset(
|
|
212
|
+
{80, 443, 3000, 2082, 2083, 631, 7547, 8000, 8080, 8081, 8443, 8888, 9000, 9200}
|
|
213
|
+
)
|
|
214
|
+
_WINDOWS_PORTS: frozenset[int] = frozenset({111, 135, 139, 445, 5985})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def port_category(port: int, service: str | None) -> tuple[str, str]:
|
|
218
|
+
|
|
219
|
+
if port in _DB_PORTS:
|
|
220
|
+
return ("database", _SEVERITY["database"])
|
|
221
|
+
if port in _REMOTE_PORTS or port == 2222:
|
|
222
|
+
return ("remote-access", _SEVERITY["remote-access"])
|
|
223
|
+
if port in _CAMERA_PORTS:
|
|
224
|
+
return ("camera", _SEVERITY["camera"])
|
|
225
|
+
if port in _MESSAGE_PORTS:
|
|
226
|
+
return ("mail", _SEVERITY["messaging"])
|
|
227
|
+
if port in _WEB_PORTS:
|
|
228
|
+
return ("web", "INFO")
|
|
229
|
+
if port == 53:
|
|
230
|
+
return ("dns", "INFO")
|
|
231
|
+
if port == 21:
|
|
232
|
+
return ("file-transfer", "MEDIUM")
|
|
233
|
+
if port in _WINDOWS_PORTS:
|
|
234
|
+
return ("windows/rpc", "MEDIUM")
|
|
235
|
+
svc = (service or "").lower()
|
|
236
|
+
if "http" in svc:
|
|
237
|
+
return ("web", "INFO")
|
|
238
|
+
if "ssh" in svc or "telnet" in svc or "vnc" in svc or "rdp" in svc:
|
|
239
|
+
return ("remote-access", "HIGH")
|
|
240
|
+
if "rtsp" in svc:
|
|
241
|
+
return ("camera", "HIGH")
|
|
242
|
+
if any(k in svc for k in ("smtp", "imap", "pop", "mqtt")):
|
|
243
|
+
return ("mail", "MEDIUM")
|
|
244
|
+
if any(k in svc for k in ("mysql", "postgres", "redis", "mongo", "sql")):
|
|
245
|
+
return ("database", "CRITICAL")
|
|
246
|
+
if "ftp" in svc:
|
|
247
|
+
return ("file-transfer", "MEDIUM")
|
|
248
|
+
return ("other", "INFO")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def classify_services(report: ScanReport) -> list[ServiceFinding]:
|
|
252
|
+
seen: set[tuple[str, str]] = set()
|
|
253
|
+
findings: list[ServiceFinding] = []
|
|
254
|
+
|
|
255
|
+
for tech in report.all_technologies():
|
|
256
|
+
finding = _service_from_tech(tech)
|
|
257
|
+
if finding is None:
|
|
258
|
+
continue
|
|
259
|
+
key = (finding.name.lower(), finding.kind)
|
|
260
|
+
if key in seen:
|
|
261
|
+
continue
|
|
262
|
+
seen.add(key)
|
|
263
|
+
findings.append(finding)
|
|
264
|
+
|
|
265
|
+
scan = report.ports
|
|
266
|
+
if scan is not None:
|
|
267
|
+
for port in scan.ports:
|
|
268
|
+
finding = _service_from_port(port.port)
|
|
269
|
+
if finding is None:
|
|
270
|
+
continue
|
|
271
|
+
key = (finding.name.lower(), finding.kind)
|
|
272
|
+
if key in seen:
|
|
273
|
+
continue
|
|
274
|
+
seen.add(key)
|
|
275
|
+
findings.append(finding)
|
|
276
|
+
|
|
277
|
+
findings.sort(key=lambda f: (_severity_rank(f.severity), f.kind, f.name.lower()))
|
|
278
|
+
return findings
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _severity_rank(severity: str) -> int:
|
|
285
|
+
return _SEVERITY_RANK.get(severity.upper(), 99)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from stackscan.types import FetchResult, Technology
|
|
8
|
+
from stackscan.utils import host_of
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from sigdb.core import SigDBMatcher
|
|
12
|
+
from sigdb.types import SigDBMatchResult
|
|
13
|
+
_META_RE = re.compile("<meta\\b([^>]*)>", re.IGNORECASE)
|
|
14
|
+
_SCRIPT_SRC_RE = re.compile("<script\\b[^>]*\\bsrc\\s*=\\s*[\\\"']?([^\\\"'\\s>]+)", re.IGNORECASE)
|
|
15
|
+
_ATTR_RE = re.compile(
|
|
16
|
+
"([a-zA-Z_:][\\w:.-]*)\\s*=\\s*(?:\\\"([^\\\"]*)\\\"|'([^']*)'|([^\\s\\\"'=<>`]+))"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _meta_pairs(html: str) -> list[tuple[str, str]]:
|
|
21
|
+
pairs: list[tuple[str, str]] = []
|
|
22
|
+
for match in _META_RE.finditer(html):
|
|
23
|
+
attrs: dict[str, str] = {}
|
|
24
|
+
for attr in _ATTR_RE.finditer(match.group(1)):
|
|
25
|
+
name = attr.group(1).lower()
|
|
26
|
+
value = attr.group(2) or attr.group(3) or attr.group(4) or ""
|
|
27
|
+
attrs[name] = value
|
|
28
|
+
key = attrs.get("name") or attrs.get("http-equiv") or attrs.get("property")
|
|
29
|
+
content = attrs.get("content")
|
|
30
|
+
if key and content:
|
|
31
|
+
pairs.append((key.lower(), content))
|
|
32
|
+
return pairs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _script_srcs(html: str) -> list[str]:
|
|
36
|
+
return [match.group(1) for match in _SCRIPT_SRC_RE.finditer(html)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _Hit:
|
|
41
|
+
name: str
|
|
42
|
+
category: str | None = None
|
|
43
|
+
evidence: list[str] = field(default_factory=list[str])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_HEADER_TECH: tuple[tuple[str, str | None, str, str], ...] = (
|
|
47
|
+
("x-powered-by", "php", "PHP", "backend"),
|
|
48
|
+
("x-powered-by", "asp.net", "ASP.NET", "backend"),
|
|
49
|
+
("x-powered-by", "express", "Express", "backend"),
|
|
50
|
+
("x-powered-by", "next.js", "Next.js", "frontend"),
|
|
51
|
+
("x-powered-by", "nuxt", "Nuxt.js", "frontend"),
|
|
52
|
+
("x-powered-by", "servlet", "Java Servlet", "backend"),
|
|
53
|
+
("x-powered-by", "plesk", "Plesk", "infrastructure"),
|
|
54
|
+
("x-aspnet-version", None, "ASP.NET", "backend"),
|
|
55
|
+
("x-aspnetmvc-version", None, "ASP.NET MVC", "backend"),
|
|
56
|
+
("x-drupal-cache", None, "Drupal", "cms"),
|
|
57
|
+
("x-drupal-dynamic-cache", None, "Drupal", "cms"),
|
|
58
|
+
("x-generator", "drupal", "Drupal", "cms"),
|
|
59
|
+
("x-shopify-stage", None, "Shopify", "ecommerce"),
|
|
60
|
+
("x-shopid", None, "Shopify", "ecommerce"),
|
|
61
|
+
("x-magento-cache-debug", None, "Magento", "ecommerce"),
|
|
62
|
+
("x-wix-request-id", None, "Wix", "cms"),
|
|
63
|
+
("x-jenkins", None, "Jenkins", "ci"),
|
|
64
|
+
("x-turbo-charged-by", "litespeed", "LiteSpeed", "proxy"),
|
|
65
|
+
("x-litespeed-cache", None, "LiteSpeed Cache", "cms"),
|
|
66
|
+
("x-varnish", None, "Varnish", "proxy"),
|
|
67
|
+
("x-envoy-upstream-service-time", None, "Envoy", "proxy"),
|
|
68
|
+
("x-vercel-id", None, "Vercel", "infrastructure"),
|
|
69
|
+
("x-nextjs-cache", None, "Next.js", "frontend"),
|
|
70
|
+
("fastly-io-info", None, "Fastly", "cdn"),
|
|
71
|
+
)
|
|
72
|
+
_COOKIE_TECH: tuple[tuple[str, str, str], ...] = (
|
|
73
|
+
("phpsessid", "PHP", "backend"),
|
|
74
|
+
("laravel_session", "Laravel", "backend"),
|
|
75
|
+
("ci_session", "CodeIgniter", "backend"),
|
|
76
|
+
("csrftoken", "Django", "backend"),
|
|
77
|
+
("django_language", "Django", "backend"),
|
|
78
|
+
("jsessionid", "Java", "backend"),
|
|
79
|
+
("asp.net_sessionid", "ASP.NET", "backend"),
|
|
80
|
+
(".aspxauth", "ASP.NET", "backend"),
|
|
81
|
+
("wordpress_", "WordPress", "cms"),
|
|
82
|
+
("wp-settings", "WordPress", "cms"),
|
|
83
|
+
("_shopify", "Shopify", "ecommerce"),
|
|
84
|
+
("prestashop", "PrestaShop", "ecommerce"),
|
|
85
|
+
("connect.sid", "Express", "backend"),
|
|
86
|
+
("incap_ses", "Imperva Incapsula", "security"),
|
|
87
|
+
("visid_incap", "Imperva Incapsula", "security"),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TechAnalyzer:
|
|
92
|
+
def __init__(self, matchers: list[SigDBMatcher]) -> None:
|
|
93
|
+
self._matchers = matchers
|
|
94
|
+
|
|
95
|
+
def _add(self, acc: dict[str, _Hit], match: SigDBMatchResult, evidence: str) -> None:
|
|
96
|
+
if not match.result or match.item is None:
|
|
97
|
+
return
|
|
98
|
+
key = match.item.key
|
|
99
|
+
headers = getattr(match.item, "headers", {}) or {}
|
|
100
|
+
hit = acc.get(key)
|
|
101
|
+
if hit is None:
|
|
102
|
+
hit = _Hit(name=headers.get("_name") or key, category=headers.get("_category"))
|
|
103
|
+
acc[key] = hit
|
|
104
|
+
if evidence not in hit.evidence:
|
|
105
|
+
hit.evidence.append(evidence)
|
|
106
|
+
|
|
107
|
+
def detect(self, result: FetchResult) -> list[Technology]:
|
|
108
|
+
acc: dict[str, _Hit] = {}
|
|
109
|
+
location = host_of(result.url) if result.url else ""
|
|
110
|
+
for matcher in self._matchers:
|
|
111
|
+
for name, value in result.headers.items():
|
|
112
|
+
if name == "_raw":
|
|
113
|
+
continue
|
|
114
|
+
self._add(acc, matcher.match_group("headers", value, name=name), f"header:{name}")
|
|
115
|
+
for raw_cookie in result.cookies:
|
|
116
|
+
segment = raw_cookie.split(";", 1)[0].strip()
|
|
117
|
+
if "=" not in segment:
|
|
118
|
+
continue
|
|
119
|
+
cname, _, cvalue = segment.partition("=")
|
|
120
|
+
cname = cname.strip().lower()
|
|
121
|
+
self._add(
|
|
122
|
+
acc, matcher.match_group("headers", cvalue, name=cname), f"cookie:{cname}"
|
|
123
|
+
)
|
|
124
|
+
for key, content in _meta_pairs(result.body):
|
|
125
|
+
self._add(acc, matcher.match_group("meta", content, name=key), f"meta:{key}")
|
|
126
|
+
for src in _script_srcs(result.body):
|
|
127
|
+
self._add(acc, matcher.match_group("script_src", src), "script_src")
|
|
128
|
+
self._add(acc, matcher.match(src), "script_src")
|
|
129
|
+
self._add(acc, matcher.match_html(result.body), "html")
|
|
130
|
+
self._add(acc, matcher.match(result.body), "body")
|
|
131
|
+
self._add(acc, matcher.match(result.url), "url")
|
|
132
|
+
self._curated(acc, result)
|
|
133
|
+
by_name: dict[str, _Hit] = {}
|
|
134
|
+
for hit in acc.values():
|
|
135
|
+
existing = by_name.get(hit.name.lower())
|
|
136
|
+
if existing is None:
|
|
137
|
+
by_name[hit.name.lower()] = hit
|
|
138
|
+
else:
|
|
139
|
+
for ev in hit.evidence:
|
|
140
|
+
if ev not in existing.evidence:
|
|
141
|
+
existing.evidence.append(ev)
|
|
142
|
+
existing.category = existing.category or hit.category
|
|
143
|
+
technologies = [
|
|
144
|
+
Technology(
|
|
145
|
+
name=hit.name,
|
|
146
|
+
categories=(hit.category,) if hit.category else (),
|
|
147
|
+
evidence=tuple(hit.evidence),
|
|
148
|
+
location=location,
|
|
149
|
+
)
|
|
150
|
+
for hit in by_name.values()
|
|
151
|
+
]
|
|
152
|
+
technologies.sort(key=lambda tech: tech.name.lower())
|
|
153
|
+
return technologies
|
|
154
|
+
|
|
155
|
+
def _curated(self, acc: dict[str, _Hit], result: FetchResult) -> None:
|
|
156
|
+
|
|
157
|
+
def add(name: str, category: str, evidence: str) -> None:
|
|
158
|
+
key = f"curated:{name.lower()}"
|
|
159
|
+
hit = acc.get(key)
|
|
160
|
+
if hit is None:
|
|
161
|
+
hit = _Hit(name=name, category=category)
|
|
162
|
+
acc[key] = hit
|
|
163
|
+
if evidence not in hit.evidence:
|
|
164
|
+
hit.evidence.append(evidence)
|
|
165
|
+
|
|
166
|
+
for header, needle, name, category in _HEADER_TECH:
|
|
167
|
+
value = result.headers.get(header)
|
|
168
|
+
if value is None:
|
|
169
|
+
continue
|
|
170
|
+
if needle is None or needle in value.lower():
|
|
171
|
+
add(name, category, f"header:{header}")
|
|
172
|
+
for raw_cookie in result.cookies:
|
|
173
|
+
cname = raw_cookie.split("=", 1)[0].split(";", 1)[0].strip().lower()
|
|
174
|
+
if not cname:
|
|
175
|
+
continue
|
|
176
|
+
for prefix, name, category in _COOKIE_TECH:
|
|
177
|
+
if cname.startswith(prefix):
|
|
178
|
+
add(name, category, f"cookie:{cname}")
|