klyrek-headers 0.1.0__tar.gz
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.
- klyrek_headers-0.1.0/.gitignore +20 -0
- klyrek_headers-0.1.0/PKG-INFO +47 -0
- klyrek_headers-0.1.0/README.md +26 -0
- klyrek_headers-0.1.0/pyproject.toml +30 -0
- klyrek_headers-0.1.0/src/klyrek_headers/__init__.py +8 -0
- klyrek_headers-0.1.0/src/klyrek_headers/analyzer.py +25 -0
- klyrek_headers-0.1.0/src/klyrek_headers/checks.py +258 -0
- klyrek_headers-0.1.0/src/klyrek_headers/py.typed +0 -0
- klyrek_headers-0.1.0/tests/test_analyzer.py +45 -0
- klyrek_headers-0.1.0/tests/test_checks.py +204 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*.egg-info/
|
|
4
|
+
.eggs/
|
|
5
|
+
.qodo/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.env
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
*.log
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
!.vscode/extensions.json
|
|
20
|
+
klyrek_output/
|
|
@@ -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,26 @@
|
|
|
1
|
+
# klyrek-headers
|
|
2
|
+
|
|
3
|
+
HTTP security header analysis. Takes a single `httpx.Response` (as returned by
|
|
4
|
+
`klyrek-http`/`klyrek-crawler`) and produces `klyrek_core.models.Finding` objects for missing or
|
|
5
|
+
misconfigured security controls: HSTS, CSP, clickjacking protection, cookie flags
|
|
6
|
+
(`Secure`/`HttpOnly`/`SameSite`), CORS misconfiguration, and information-disclosure headers
|
|
7
|
+
(`Server`, `X-Powered-By`).
|
|
8
|
+
|
|
9
|
+
This is the first Klyrek module whose output is a `Finding` rather than just an `Endpoint` —
|
|
10
|
+
everything upstream (`klyrek-crawler`) maps the application; this is where the tool starts
|
|
11
|
+
telling you something is actually wrong.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from klyrek_headers.analyzer import analyze
|
|
15
|
+
|
|
16
|
+
response = client.get("https://target.com/")
|
|
17
|
+
findings = analyze(response)
|
|
18
|
+
|
|
19
|
+
for finding in findings:
|
|
20
|
+
print(finding.severity, finding.title)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Findings are informational hints for a human reviewer to confirm, not proof of exploitability —
|
|
24
|
+
e.g. a missing `X-Frame-Options` header is flagged even though modern CSP `frame-ancestors` may
|
|
25
|
+
cover it, and a wildcard CORS origin is flagged even when browsers would reject the exact
|
|
26
|
+
combination sent, because both patterns indicate configuration worth a second look.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "klyrek-headers"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HTTP security header analysis for the Klyrek ecosystem"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Klyrek Contributors" }]
|
|
13
|
+
keywords = ["security", "reconnaissance", "appsec", "pentesting", "http-headers"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Information Technology",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"klyrek-core",
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/klyrek_headers"]
|
|
@@ -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
|
|
@@ -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
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from klyrek_core.models import Endpoint
|
|
4
|
+
from klyrek_headers.analyzer import analyze
|
|
5
|
+
|
|
6
|
+
_GOOD_HEADERS = {
|
|
7
|
+
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
|
|
8
|
+
"Content-Security-Policy": "default-src 'self'",
|
|
9
|
+
"X-Content-Type-Options": "nosniff",
|
|
10
|
+
"X-Frame-Options": "DENY",
|
|
11
|
+
"Referrer-Policy": "no-referrer",
|
|
12
|
+
"Permissions-Policy": "geolocation=()",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _response(url: str, headers: dict[str, str]) -> httpx.Response:
|
|
17
|
+
return httpx.Response(200, headers=headers, request=httpx.Request("GET", url))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_well_configured_response_has_no_findings():
|
|
21
|
+
response = _response("https://target.com/", _GOOD_HEADERS)
|
|
22
|
+
assert analyze(response) == []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_bare_response_produces_multiple_findings():
|
|
26
|
+
response = _response("https://target.com/", {})
|
|
27
|
+
findings = analyze(response)
|
|
28
|
+
titles = {f.title for f in findings}
|
|
29
|
+
assert len(findings) >= 6
|
|
30
|
+
assert any("Strict-Transport-Security" in t for t in titles)
|
|
31
|
+
assert any("Content-Security-Policy" in t for t in titles)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_findings_are_tagged_with_module_and_endpoint():
|
|
35
|
+
endpoint = Endpoint(url="https://target.com/", method="GET")
|
|
36
|
+
response = _response("https://target.com/", {})
|
|
37
|
+
findings = analyze(response, endpoint=endpoint)
|
|
38
|
+
assert all(f.module == "klyrek-headers" for f in findings)
|
|
39
|
+
assert all(f.endpoint is endpoint for f in findings)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_endpoint_defaults_to_none():
|
|
43
|
+
response = _response("https://target.com/", {})
|
|
44
|
+
findings = analyze(response)
|
|
45
|
+
assert all(f.endpoint is None for f in findings)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from klyrek_headers.checks import (
|
|
4
|
+
check_cookies,
|
|
5
|
+
check_cors,
|
|
6
|
+
check_csp,
|
|
7
|
+
check_frame_options,
|
|
8
|
+
check_hsts,
|
|
9
|
+
check_information_disclosure,
|
|
10
|
+
check_permissions_policy,
|
|
11
|
+
check_referrer_policy,
|
|
12
|
+
check_x_content_type_options,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _response(url: str, headers: dict[str, str] | list[tuple[str, str]]) -> httpx.Response:
|
|
17
|
+
return httpx.Response(200, headers=headers, request=httpx.Request("GET", url))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_hsts_missing_on_https_is_flagged():
|
|
21
|
+
response = _response("https://target.com/", {})
|
|
22
|
+
findings = check_hsts(response)
|
|
23
|
+
assert len(findings) == 1
|
|
24
|
+
assert "Missing" in findings[0].title
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_hsts_not_required_on_plain_http():
|
|
28
|
+
response = _response("http://target.com/", {})
|
|
29
|
+
assert check_hsts(response) == []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_hsts_weak_max_age_is_flagged():
|
|
33
|
+
response = _response("https://target.com/", {"Strict-Transport-Security": "max-age=60"})
|
|
34
|
+
findings = check_hsts(response)
|
|
35
|
+
assert len(findings) == 1
|
|
36
|
+
assert "Weak" in findings[0].title
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_hsts_strong_max_age_passes():
|
|
40
|
+
response = _response(
|
|
41
|
+
"https://target.com/",
|
|
42
|
+
{"Strict-Transport-Security": "max-age=63072000; includeSubDomains"},
|
|
43
|
+
)
|
|
44
|
+
assert check_hsts(response) == []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_csp_missing_is_flagged():
|
|
48
|
+
response = _response("https://target.com/", {})
|
|
49
|
+
findings = check_csp(response)
|
|
50
|
+
assert len(findings) == 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_csp_permissive_directive_is_flagged():
|
|
54
|
+
response = _response(
|
|
55
|
+
"https://target.com/", {"Content-Security-Policy": "default-src 'unsafe-inline'"}
|
|
56
|
+
)
|
|
57
|
+
findings = check_csp(response)
|
|
58
|
+
assert len(findings) == 1
|
|
59
|
+
assert "permissive" in findings[0].title.lower()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_csp_strict_policy_passes():
|
|
63
|
+
response = _response("https://target.com/", {"Content-Security-Policy": "default-src 'self'"})
|
|
64
|
+
assert check_csp(response) == []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_x_content_type_options_missing():
|
|
68
|
+
response = _response("https://target.com/", {})
|
|
69
|
+
assert len(check_x_content_type_options(response)) == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_x_content_type_options_present():
|
|
73
|
+
response = _response("https://target.com/", {"X-Content-Type-Options": "nosniff"})
|
|
74
|
+
assert check_x_content_type_options(response) == []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_frame_options_missing_without_csp():
|
|
78
|
+
response = _response("https://target.com/", {})
|
|
79
|
+
assert len(check_frame_options(response)) == 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_frame_options_covered_by_csp_frame_ancestors():
|
|
83
|
+
response = _response(
|
|
84
|
+
"https://target.com/", {"Content-Security-Policy": "frame-ancestors 'self'"}
|
|
85
|
+
)
|
|
86
|
+
assert check_frame_options(response) == []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_frame_options_header_present():
|
|
90
|
+
response = _response("https://target.com/", {"X-Frame-Options": "DENY"})
|
|
91
|
+
assert check_frame_options(response) == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_referrer_policy_missing():
|
|
95
|
+
response = _response("https://target.com/", {})
|
|
96
|
+
assert len(check_referrer_policy(response)) == 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_permissions_policy_missing():
|
|
100
|
+
response = _response("https://target.com/", {})
|
|
101
|
+
assert len(check_permissions_policy(response)) == 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_server_header_version_disclosure():
|
|
105
|
+
response = _response("https://target.com/", {"Server": "nginx/1.18.0"})
|
|
106
|
+
findings = check_information_disclosure(response)
|
|
107
|
+
assert len(findings) == 1
|
|
108
|
+
assert "Server" in findings[0].title
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_server_header_without_version_not_flagged():
|
|
112
|
+
response = _response("https://target.com/", {"Server": "nginx"})
|
|
113
|
+
assert check_information_disclosure(response) == []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_x_powered_by_always_flagged():
|
|
117
|
+
response = _response("https://target.com/", {"X-Powered-By": "Express"})
|
|
118
|
+
findings = check_information_disclosure(response)
|
|
119
|
+
assert len(findings) == 1
|
|
120
|
+
assert "X-Powered-By" in findings[0].title
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_cors_wildcard_alone():
|
|
124
|
+
response = _response("https://target.com/", {"Access-Control-Allow-Origin": "*"})
|
|
125
|
+
findings = check_cors(response)
|
|
126
|
+
assert len(findings) == 1
|
|
127
|
+
assert findings[0].severity.value == "low"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_cors_wildcard_with_credentials_is_high_severity():
|
|
131
|
+
response = _response(
|
|
132
|
+
"https://target.com/",
|
|
133
|
+
{
|
|
134
|
+
"Access-Control-Allow-Origin": "*",
|
|
135
|
+
"Access-Control-Allow-Credentials": "true",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
findings = check_cors(response)
|
|
139
|
+
assert len(findings) == 1
|
|
140
|
+
assert findings[0].severity.value == "high"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_cors_specific_origin_not_flagged():
|
|
144
|
+
response = _response(
|
|
145
|
+
"https://target.com/", {"Access-Control-Allow-Origin": "https://partner.example.com"}
|
|
146
|
+
)
|
|
147
|
+
assert check_cors(response) == []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_cors_header_absent_not_flagged():
|
|
151
|
+
response = _response("https://target.com/", {})
|
|
152
|
+
assert check_cors(response) == []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_cookie_missing_secure_on_https():
|
|
156
|
+
response = _response(
|
|
157
|
+
"https://target.com/", [("Set-Cookie", "session=abc123; HttpOnly; SameSite=Strict")]
|
|
158
|
+
)
|
|
159
|
+
findings = check_cookies(response)
|
|
160
|
+
assert any("Secure" in f.title for f in findings)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_cookie_missing_httponly():
|
|
164
|
+
response = _response(
|
|
165
|
+
"https://target.com/", [("Set-Cookie", "session=abc123; Secure; SameSite=Strict")]
|
|
166
|
+
)
|
|
167
|
+
findings = check_cookies(response)
|
|
168
|
+
assert any("HttpOnly" in f.title for f in findings)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_cookie_missing_samesite():
|
|
172
|
+
response = _response(
|
|
173
|
+
"https://target.com/", [("Set-Cookie", "session=abc123; Secure; HttpOnly")]
|
|
174
|
+
)
|
|
175
|
+
findings = check_cookies(response)
|
|
176
|
+
assert any("SameSite" in f.title for f in findings)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_cookie_samesite_none_without_secure_is_high():
|
|
180
|
+
response = _response(
|
|
181
|
+
"https://target.com/", [("Set-Cookie", "session=abc123; HttpOnly; SameSite=None")]
|
|
182
|
+
)
|
|
183
|
+
findings = check_cookies(response)
|
|
184
|
+
high_findings = [f for f in findings if f.severity.value == "high"]
|
|
185
|
+
assert any("SameSite=None" in f.title for f in high_findings)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_cookie_with_all_flags_passes():
|
|
189
|
+
response = _response(
|
|
190
|
+
"https://target.com/", [("Set-Cookie", "session=abc123; Secure; HttpOnly; SameSite=Strict")]
|
|
191
|
+
)
|
|
192
|
+
assert check_cookies(response) == []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_multiple_cookies_checked_independently():
|
|
196
|
+
response = _response(
|
|
197
|
+
"https://target.com/",
|
|
198
|
+
[
|
|
199
|
+
("Set-Cookie", "session=abc; Secure; HttpOnly; SameSite=Strict"),
|
|
200
|
+
("Set-Cookie", "tracker=xyz"),
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
findings = check_cookies(response)
|
|
204
|
+
assert all("tracker" in f.title for f in findings)
|