klyrek-headers 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.
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Run all header checks against a response and collect the resulting Findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from klyrek_core.models import Endpoint, Finding
|
|
8
|
+
from klyrek_headers.checks import ALL_CHECKS
|
|
9
|
+
|
|
10
|
+
MODULE_NAME = "klyrek-headers"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def analyze(response: httpx.Response, endpoint: Endpoint | None = None) -> list[Finding]:
|
|
14
|
+
"""Run every registered header check against a response.
|
|
15
|
+
|
|
16
|
+
``endpoint``, if given, is attached to each resulting Finding so it can be traced
|
|
17
|
+
back to where it was found in a larger ScanResult.
|
|
18
|
+
"""
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
for check in ALL_CHECKS:
|
|
21
|
+
for finding in check(response):
|
|
22
|
+
finding.endpoint = endpoint
|
|
23
|
+
finding.module = MODULE_NAME
|
|
24
|
+
findings.append(finding)
|
|
25
|
+
return findings
|
klyrek_headers/checks.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Individual HTTP security header checks.
|
|
2
|
+
|
|
3
|
+
Each check takes an ``httpx.Response`` and returns zero or more ``Finding`` objects.
|
|
4
|
+
Checks are intentionally passive (header inspection only) — they flag configuration
|
|
5
|
+
patterns worth a human's attention, not confirmed exploits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from http.cookies import SimpleCookie
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from klyrek_core.models import Finding, Severity
|
|
17
|
+
|
|
18
|
+
_VERSION_PATTERN = re.compile(r"\d+\.\d+")
|
|
19
|
+
_MIN_HSTS_MAX_AGE = 15_552_000 # ~6 months, a common baseline recommendation
|
|
20
|
+
|
|
21
|
+
HeaderCheck = Callable[[httpx.Response], list[Finding]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_hsts(response: httpx.Response) -> list[Finding]:
|
|
25
|
+
if response.url.scheme != "https":
|
|
26
|
+
return []
|
|
27
|
+
value = response.headers.get("strict-transport-security")
|
|
28
|
+
if value is None:
|
|
29
|
+
return [
|
|
30
|
+
Finding(
|
|
31
|
+
title="Missing Strict-Transport-Security header",
|
|
32
|
+
severity=Severity.MEDIUM,
|
|
33
|
+
description="HTTPS response has no HSTS header, so a user's first visit (or one "
|
|
34
|
+
"over a stripped connection) can be downgraded to plain HTTP.",
|
|
35
|
+
evidence=["no Strict-Transport-Security header present"],
|
|
36
|
+
)
|
|
37
|
+
]
|
|
38
|
+
match = re.search(r"max-age=(\d+)", value, re.IGNORECASE)
|
|
39
|
+
max_age = int(match.group(1)) if match else 0
|
|
40
|
+
if max_age < _MIN_HSTS_MAX_AGE:
|
|
41
|
+
return [
|
|
42
|
+
Finding(
|
|
43
|
+
title="Weak Strict-Transport-Security max-age",
|
|
44
|
+
severity=Severity.LOW,
|
|
45
|
+
description=f"HSTS max-age is {max_age} seconds, below the "
|
|
46
|
+
f"{_MIN_HSTS_MAX_AGE}-second baseline.",
|
|
47
|
+
evidence=[f"Strict-Transport-Security: {value}"],
|
|
48
|
+
)
|
|
49
|
+
]
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_csp(response: httpx.Response) -> list[Finding]:
|
|
54
|
+
value = response.headers.get("content-security-policy")
|
|
55
|
+
if value is None:
|
|
56
|
+
return [
|
|
57
|
+
Finding(
|
|
58
|
+
title="Missing Content-Security-Policy header",
|
|
59
|
+
severity=Severity.MEDIUM,
|
|
60
|
+
description="No CSP header, so the browser applies no restriction on script, "
|
|
61
|
+
"style, or frame sources.",
|
|
62
|
+
evidence=["no Content-Security-Policy header present"],
|
|
63
|
+
)
|
|
64
|
+
]
|
|
65
|
+
lowered = value.lower()
|
|
66
|
+
permissive_tokens = [t for t in ("unsafe-inline", "unsafe-eval", "*") if t in lowered]
|
|
67
|
+
if permissive_tokens:
|
|
68
|
+
return [
|
|
69
|
+
Finding(
|
|
70
|
+
title="Overly permissive Content-Security-Policy",
|
|
71
|
+
severity=Severity.LOW,
|
|
72
|
+
description="CSP contains directives that significantly weaken its protection.",
|
|
73
|
+
evidence=[f"Content-Security-Policy: {value}"],
|
|
74
|
+
)
|
|
75
|
+
]
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_x_content_type_options(response: httpx.Response) -> list[Finding]:
|
|
80
|
+
value = response.headers.get("x-content-type-options", "")
|
|
81
|
+
if value.strip().lower() != "nosniff":
|
|
82
|
+
return [
|
|
83
|
+
Finding(
|
|
84
|
+
title="Missing X-Content-Type-Options: nosniff",
|
|
85
|
+
severity=Severity.LOW,
|
|
86
|
+
description="Without this header, browsers may MIME-sniff responses, which can "
|
|
87
|
+
"turn a file upload or reflected response into script execution.",
|
|
88
|
+
evidence=[f"X-Content-Type-Options: {value}" if value else "header absent"],
|
|
89
|
+
)
|
|
90
|
+
]
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_frame_options(response: httpx.Response) -> list[Finding]:
|
|
95
|
+
if "x-frame-options" in response.headers:
|
|
96
|
+
return []
|
|
97
|
+
csp = response.headers.get("content-security-policy", "")
|
|
98
|
+
if "frame-ancestors" in csp.lower():
|
|
99
|
+
return []
|
|
100
|
+
return [
|
|
101
|
+
Finding(
|
|
102
|
+
title="Missing clickjacking protection",
|
|
103
|
+
severity=Severity.MEDIUM,
|
|
104
|
+
description="No X-Frame-Options header and no CSP frame-ancestors directive, so the "
|
|
105
|
+
"page can be embedded in a hostile iframe.",
|
|
106
|
+
evidence=["no X-Frame-Options header, no CSP frame-ancestors directive"],
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_referrer_policy(response: httpx.Response) -> list[Finding]:
|
|
112
|
+
if "referrer-policy" not in response.headers:
|
|
113
|
+
return [
|
|
114
|
+
Finding(
|
|
115
|
+
title="Missing Referrer-Policy header",
|
|
116
|
+
severity=Severity.INFO,
|
|
117
|
+
description="Without this header, the full URL (potentially including sensitive "
|
|
118
|
+
"query parameters) may leak to third parties via the Referer header.",
|
|
119
|
+
evidence=["no Referrer-Policy header present"],
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_permissions_policy(response: httpx.Response) -> list[Finding]:
|
|
126
|
+
if "permissions-policy" not in response.headers:
|
|
127
|
+
return [
|
|
128
|
+
Finding(
|
|
129
|
+
title="Missing Permissions-Policy header",
|
|
130
|
+
severity=Severity.INFO,
|
|
131
|
+
description="No Permissions-Policy header, so the browser default is used for "
|
|
132
|
+
"powerful features (camera, geolocation, etc.) instead of an explicit opt-out.",
|
|
133
|
+
evidence=["no Permissions-Policy header present"],
|
|
134
|
+
)
|
|
135
|
+
]
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_information_disclosure(response: httpx.Response) -> list[Finding]:
|
|
140
|
+
findings: list[Finding] = []
|
|
141
|
+
server = response.headers.get("server")
|
|
142
|
+
if server and _VERSION_PATTERN.search(server):
|
|
143
|
+
findings.append(
|
|
144
|
+
Finding(
|
|
145
|
+
title="Server header discloses version information",
|
|
146
|
+
severity=Severity.INFO,
|
|
147
|
+
description="The Server header reveals specific software version(s), which helps "
|
|
148
|
+
"an attacker target known vulnerabilities for that version.",
|
|
149
|
+
evidence=[f"Server: {server}"],
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
powered_by = response.headers.get("x-powered-by")
|
|
153
|
+
if powered_by:
|
|
154
|
+
findings.append(
|
|
155
|
+
Finding(
|
|
156
|
+
title="X-Powered-By header discloses backend technology",
|
|
157
|
+
severity=Severity.INFO,
|
|
158
|
+
description="The X-Powered-By header reveals backend technology unnecessarily.",
|
|
159
|
+
evidence=[f"X-Powered-By: {powered_by}"],
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return findings
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def check_cors(response: httpx.Response) -> list[Finding]:
|
|
166
|
+
origin = response.headers.get("access-control-allow-origin")
|
|
167
|
+
if origin is None:
|
|
168
|
+
return []
|
|
169
|
+
credentials = response.headers.get("access-control-allow-credentials", "").lower() == "true"
|
|
170
|
+
if origin == "*" and credentials:
|
|
171
|
+
return [
|
|
172
|
+
Finding(
|
|
173
|
+
title="CORS wildcard origin combined with allowed credentials",
|
|
174
|
+
severity=Severity.HIGH,
|
|
175
|
+
description="Access-Control-Allow-Origin: * together with "
|
|
176
|
+
"Access-Control-Allow-Credentials: true is a well-known misconfiguration "
|
|
177
|
+
"pattern; depending on how the server actually resolves the origin, this can "
|
|
178
|
+
"let any site make credentialed cross-origin requests.",
|
|
179
|
+
evidence=[
|
|
180
|
+
f"Access-Control-Allow-Origin: {origin}",
|
|
181
|
+
"Access-Control-Allow-Credentials: true",
|
|
182
|
+
],
|
|
183
|
+
)
|
|
184
|
+
]
|
|
185
|
+
if origin == "*":
|
|
186
|
+
return [
|
|
187
|
+
Finding(
|
|
188
|
+
title="CORS wildcard origin",
|
|
189
|
+
severity=Severity.LOW,
|
|
190
|
+
description="Access-Control-Allow-Origin: * allows any site to read responses "
|
|
191
|
+
"from this endpoint (without credentials).",
|
|
192
|
+
evidence=[f"Access-Control-Allow-Origin: {origin}"],
|
|
193
|
+
)
|
|
194
|
+
]
|
|
195
|
+
return []
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_cookies(response: httpx.Response) -> list[Finding]:
|
|
199
|
+
findings: list[Finding] = []
|
|
200
|
+
is_https = response.url.scheme == "https"
|
|
201
|
+
for raw_cookie in response.headers.get_list("set-cookie"):
|
|
202
|
+
jar: SimpleCookie = SimpleCookie()
|
|
203
|
+
jar.load(raw_cookie)
|
|
204
|
+
for name, morsel in jar.items():
|
|
205
|
+
if is_https and not morsel["secure"]:
|
|
206
|
+
findings.append(
|
|
207
|
+
Finding(
|
|
208
|
+
title=f"Cookie '{name}' missing Secure flag",
|
|
209
|
+
severity=Severity.MEDIUM,
|
|
210
|
+
description="Cookie can be sent over an unencrypted HTTP connection.",
|
|
211
|
+
evidence=[raw_cookie],
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
if not morsel["httponly"]:
|
|
215
|
+
findings.append(
|
|
216
|
+
Finding(
|
|
217
|
+
title=f"Cookie '{name}' missing HttpOnly flag",
|
|
218
|
+
severity=Severity.MEDIUM,
|
|
219
|
+
description="Cookie is readable from JavaScript, so it is exposed to "
|
|
220
|
+
"theft via any XSS on the page.",
|
|
221
|
+
evidence=[raw_cookie],
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
samesite = morsel["samesite"]
|
|
225
|
+
if not samesite:
|
|
226
|
+
findings.append(
|
|
227
|
+
Finding(
|
|
228
|
+
title=f"Cookie '{name}' missing SameSite attribute",
|
|
229
|
+
severity=Severity.LOW,
|
|
230
|
+
description="Without SameSite, the cookie is sent on cross-site requests "
|
|
231
|
+
"per browser default, which varies and weakens CSRF defenses.",
|
|
232
|
+
evidence=[raw_cookie],
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
elif samesite.lower() == "none" and not morsel["secure"]:
|
|
236
|
+
findings.append(
|
|
237
|
+
Finding(
|
|
238
|
+
title=f"Cookie '{name}' has SameSite=None without Secure",
|
|
239
|
+
severity=Severity.HIGH,
|
|
240
|
+
description="SameSite=None requires the Secure attribute; without it, "
|
|
241
|
+
"modern browsers reject the cookie or treat it inconsistently.",
|
|
242
|
+
evidence=[raw_cookie],
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return findings
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
ALL_CHECKS: list[HeaderCheck] = [
|
|
249
|
+
check_hsts,
|
|
250
|
+
check_csp,
|
|
251
|
+
check_x_content_type_options,
|
|
252
|
+
check_frame_options,
|
|
253
|
+
check_referrer_policy,
|
|
254
|
+
check_permissions_policy,
|
|
255
|
+
check_information_disclosure,
|
|
256
|
+
check_cors,
|
|
257
|
+
check_cookies,
|
|
258
|
+
]
|
klyrek_headers/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klyrek-headers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP security header analysis for the Klyrek ecosystem
|
|
5
|
+
Author: Klyrek Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: appsec,http-headers,pentesting,reconnaissance,security
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Information Technology
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Requires-Dist: klyrek-core
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# klyrek-headers
|
|
23
|
+
|
|
24
|
+
HTTP security header analysis. Takes a single `httpx.Response` (as returned by
|
|
25
|
+
`klyrek-http`/`klyrek-crawler`) and produces `klyrek_core.models.Finding` objects for missing or
|
|
26
|
+
misconfigured security controls: HSTS, CSP, clickjacking protection, cookie flags
|
|
27
|
+
(`Secure`/`HttpOnly`/`SameSite`), CORS misconfiguration, and information-disclosure headers
|
|
28
|
+
(`Server`, `X-Powered-By`).
|
|
29
|
+
|
|
30
|
+
This is the first Klyrek module whose output is a `Finding` rather than just an `Endpoint` —
|
|
31
|
+
everything upstream (`klyrek-crawler`) maps the application; this is where the tool starts
|
|
32
|
+
telling you something is actually wrong.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from klyrek_headers.analyzer import analyze
|
|
36
|
+
|
|
37
|
+
response = client.get("https://target.com/")
|
|
38
|
+
findings = analyze(response)
|
|
39
|
+
|
|
40
|
+
for finding in findings:
|
|
41
|
+
print(finding.severity, finding.title)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Findings are informational hints for a human reviewer to confirm, not proof of exploitability —
|
|
45
|
+
e.g. a missing `X-Frame-Options` header is flagged even though modern CSP `frame-ancestors` may
|
|
46
|
+
cover it, and a wildcard CORS origin is flagged even when browsers would reject the exact
|
|
47
|
+
combination sent, because both patterns indicate configuration worth a second look.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
klyrek_headers/__init__.py,sha256=vPuvKhgpjznIjh4hV5FZmYoQYuakIIKeb2jbjtqShuA,218
|
|
2
|
+
klyrek_headers/analyzer.py,sha256=Vnwa7PvS5heltYj9JDgA2GFC6mqdonmpIdGCKxJBVw0,803
|
|
3
|
+
klyrek_headers/checks.py,sha256=HOZIK1kWK5nPeZSQILoZ876Zi2NGC928rFLqhmo0hZs,10158
|
|
4
|
+
klyrek_headers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
klyrek_headers-0.1.0.dist-info/METADATA,sha256=4V9aNNEQqc15cwDuVxMF6p69oMmSQl2zj4XW_DhWgMI,1918
|
|
6
|
+
klyrek_headers-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
klyrek_headers-0.1.0.dist-info/RECORD,,
|