headerhound 0.1.1__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.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .coverage
5
+ coverage.xml
6
+ .ruff_cache/
7
+ .venv/
8
+ dist/
9
+ build/
10
+ *.egg-info/
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.1] - 2026-09-07
8
+
9
+ ### Added
10
+
11
+ - Wheel and source-distribution validation in GitHub Actions.
12
+ - A GitHub Release-to-PyPI workflow using Trusted Publishing and OIDC.
13
+
14
+ ### Changed
15
+
16
+ - Hardened package metadata and limited source-distribution contents to release-relevant files.
17
+ - Clarified PyPI and pipx installation guidance.
18
+
19
+ ## [0.1.0] - 2026-09-07
20
+
21
+ ### Added
22
+
23
+ - Initial defensive HTTP security-header scanner CLI.
24
+ - Explainable terminal and JSON reports with weighted scores.
25
+ - Bounded redirects, TLS verification, timeouts, and private-target protection.
26
+ - Unit and local HTTP integration tests, linting, and GitHub Actions CI.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HeaderHound contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.5
2
+ Name: headerhound
3
+ Version: 0.1.1
4
+ Summary: A defensive, explainable CLI for auditing HTTP security response headers.
5
+ Project-URL: Homepage, https://github.com/m-ramadan-sec/headerhound
6
+ Project-URL: Repository, https://github.com/m-ramadan-sec/headerhound
7
+ Project-URL: Issues, https://github.com/m-ramadan-sec/headerhound/issues
8
+ Project-URL: Changelog, https://github.com/m-ramadan-sec/headerhound/blob/main/CHANGELOG.md
9
+ Project-URL: Security, https://github.com/m-ramadan-sec/headerhound/blob/main/SECURITY.md
10
+ Author: HeaderHound contributors
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: cli,csp,http,http-security,security,security-headers,web-security
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: Information Technology
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Topic :: Internet :: WWW/HTTP
27
+ Classifier: Topic :: Security
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: httpx<1,>=0.27
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
32
+ Requires-Dist: pytest>=8.0; extra == 'dev'
33
+ Requires-Dist: ruff>=0.8; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # HeaderHound
37
+
38
+ [![CI](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml/badge.svg)](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ HeaderHound is a defensive, explainable command-line scanner for HTTP response security headers. It requests one URL, follows a bounded redirect chain, and reports missing or risky configurations in a readable terminal table or stable JSON.
42
+
43
+ It is intended for systems you own or are authorized to assess. It does not crawl, exploit vulnerabilities, fuzz endpoints, or perform destructive actions.
44
+
45
+ ## Install
46
+
47
+ HeaderHound requires Python 3.10 or newer. Once a release is published to PyPI, install it with either `pip` or `pipx`:
48
+
49
+ ```bash
50
+ python -m pip install --upgrade headerhound
51
+ # or, for an isolated command-line application:
52
+ pipx install headerhound
53
+ ```
54
+
55
+ For an unreleased source checkout, use `python -m pip install .` instead.
56
+
57
+ ## Packaging and distribution
58
+
59
+ GitHub releases are the source of versioned distributions. The release workflow builds a wheel and source distribution, validates both, and publishes them to [PyPI](https://pypi.org/project/headerhound/) with PyPI Trusted Publishing. It uses GitHub Actions OIDC and does not store a PyPI API token in this repository.
60
+
61
+ After publication, install the latest release with:
62
+
63
+ ```bash
64
+ python -m pip install --upgrade headerhound
65
+ pipx install headerhound
66
+ ```
67
+
68
+ The published project page lists the exact wheel and source-distribution files for every release.
69
+
70
+ ## Usage
71
+
72
+ ```bash
73
+ headerhound https://example.com
74
+ headerhound https://example.com --format json
75
+ headerhound https://example.com --format json --fail-under 80
76
+ headerhound https://service.internal --allow-private --timeout 15
77
+ ```
78
+
79
+ Example terminal output:
80
+
81
+ ```text
82
+ Target: https://example.com/
83
+ Final URL: https://example.com/
84
+ HTTP status: 200
85
+ Security score: 78/100 (grade C)
86
+
87
+ +----------+-----------------------------------+--------------------------------+
88
+ | Severity | Finding | Details |
89
+ +----------+-----------------------------------+--------------------------------+
90
+ | HIGH | Content-Security-Policy missing | The browser receives no policy |
91
+ +----------+-----------------------------------+--------------------------------+
92
+ ```
93
+
94
+ Exit status is `0` for a completed scan and `2` for invalid input or retrieval failure. JSON errors are written to standard error as `{"error": "..."}`.
95
+ Use `--fail-under SCORE` to return exit status `1` when a completed scan is below a chosen CI threshold.
96
+
97
+ ## Checks
98
+
99
+ HeaderHound assesses the presence and selected high-signal weak configurations of:
100
+
101
+ - `Content-Security-Policy` — including report-only mode, no `default-src`, wildcard sources, `unsafe-inline`, and `unsafe-eval`
102
+ - `Strict-Transport-Security` — HTTPS-only evaluation, invalid/disabled values, and short `max-age`
103
+ - `X-Content-Type-Options` and `X-Frame-Options`
104
+ - `Referrer-Policy` and `Permissions-Policy`
105
+ - `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, and `Cross-Origin-Embedder-Policy`
106
+
107
+ The score begins at 100 and subtracts documented weighted findings. It is a prioritization aid, not a compliance result or a substitute for application-specific review.
108
+
109
+ ## Safety and network behavior
110
+
111
+ - Only `http` and `https` URLs are accepted; credentials embedded in URLs are rejected.
112
+ - By default, targets resolving to loopback, private, link-local, multicast, reserved, or unspecified addresses are rejected. Use `--allow-private` only on systems you are authorized to scan.
113
+ - TLS certificates are verified by default. `--insecure` exists only for authorized diagnostic use.
114
+ - The default timeout is 10 seconds, redirects are limited to 5, and the client disables environment proxy settings.
115
+ - One request is made per invocation. `--min-interval` is available for integrations which reuse the client.
116
+
117
+ Private-address filtering is a useful guardrail, not a complete SSRF defense against DNS rebinding or hostile networks. Run scans from a suitably restricted network when targets may be untrusted.
118
+
119
+ ## Architecture
120
+
121
+ ```text
122
+ CLI → URL validation / target policy → bounded HTTP client → header analyzer → table or JSON renderer
123
+ ```
124
+
125
+ - `safety.py`: URL validation and conservative public-target policy
126
+ - `client.py`: timeouts, redirect bound, TLS policy, and retrieval errors
127
+ - `analyzer.py`: pure, testable header checks and score calculation
128
+ - `output.py`: dependency-free terminal and JSON reports
129
+
130
+ ## Limitations
131
+
132
+ HeaderHound evaluates only the final HTTP response (while reporting the redirect chain). It cannot determine whether a header is consistently set on every route, whether a CSP is compatible with the application, whether TLS configuration is strong, or whether application behavior is secure. Security headers are defense in depth.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ python -m pip install -e '.[dev]'
138
+ ruff check .
139
+ ruff format --check .
140
+ pytest
141
+ ```
142
+
143
+ See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [ROADMAP.md](ROADMAP.md).
144
+
145
+ ## License
146
+
147
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,112 @@
1
+ # HeaderHound
2
+
3
+ [![CI](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml/badge.svg)](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ HeaderHound is a defensive, explainable command-line scanner for HTTP response security headers. It requests one URL, follows a bounded redirect chain, and reports missing or risky configurations in a readable terminal table or stable JSON.
7
+
8
+ It is intended for systems you own or are authorized to assess. It does not crawl, exploit vulnerabilities, fuzz endpoints, or perform destructive actions.
9
+
10
+ ## Install
11
+
12
+ HeaderHound requires Python 3.10 or newer. Once a release is published to PyPI, install it with either `pip` or `pipx`:
13
+
14
+ ```bash
15
+ python -m pip install --upgrade headerhound
16
+ # or, for an isolated command-line application:
17
+ pipx install headerhound
18
+ ```
19
+
20
+ For an unreleased source checkout, use `python -m pip install .` instead.
21
+
22
+ ## Packaging and distribution
23
+
24
+ GitHub releases are the source of versioned distributions. The release workflow builds a wheel and source distribution, validates both, and publishes them to [PyPI](https://pypi.org/project/headerhound/) with PyPI Trusted Publishing. It uses GitHub Actions OIDC and does not store a PyPI API token in this repository.
25
+
26
+ After publication, install the latest release with:
27
+
28
+ ```bash
29
+ python -m pip install --upgrade headerhound
30
+ pipx install headerhound
31
+ ```
32
+
33
+ The published project page lists the exact wheel and source-distribution files for every release.
34
+
35
+ ## Usage
36
+
37
+ ```bash
38
+ headerhound https://example.com
39
+ headerhound https://example.com --format json
40
+ headerhound https://example.com --format json --fail-under 80
41
+ headerhound https://service.internal --allow-private --timeout 15
42
+ ```
43
+
44
+ Example terminal output:
45
+
46
+ ```text
47
+ Target: https://example.com/
48
+ Final URL: https://example.com/
49
+ HTTP status: 200
50
+ Security score: 78/100 (grade C)
51
+
52
+ +----------+-----------------------------------+--------------------------------+
53
+ | Severity | Finding | Details |
54
+ +----------+-----------------------------------+--------------------------------+
55
+ | HIGH | Content-Security-Policy missing | The browser receives no policy |
56
+ +----------+-----------------------------------+--------------------------------+
57
+ ```
58
+
59
+ Exit status is `0` for a completed scan and `2` for invalid input or retrieval failure. JSON errors are written to standard error as `{"error": "..."}`.
60
+ Use `--fail-under SCORE` to return exit status `1` when a completed scan is below a chosen CI threshold.
61
+
62
+ ## Checks
63
+
64
+ HeaderHound assesses the presence and selected high-signal weak configurations of:
65
+
66
+ - `Content-Security-Policy` — including report-only mode, no `default-src`, wildcard sources, `unsafe-inline`, and `unsafe-eval`
67
+ - `Strict-Transport-Security` — HTTPS-only evaluation, invalid/disabled values, and short `max-age`
68
+ - `X-Content-Type-Options` and `X-Frame-Options`
69
+ - `Referrer-Policy` and `Permissions-Policy`
70
+ - `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`, and `Cross-Origin-Embedder-Policy`
71
+
72
+ The score begins at 100 and subtracts documented weighted findings. It is a prioritization aid, not a compliance result or a substitute for application-specific review.
73
+
74
+ ## Safety and network behavior
75
+
76
+ - Only `http` and `https` URLs are accepted; credentials embedded in URLs are rejected.
77
+ - By default, targets resolving to loopback, private, link-local, multicast, reserved, or unspecified addresses are rejected. Use `--allow-private` only on systems you are authorized to scan.
78
+ - TLS certificates are verified by default. `--insecure` exists only for authorized diagnostic use.
79
+ - The default timeout is 10 seconds, redirects are limited to 5, and the client disables environment proxy settings.
80
+ - One request is made per invocation. `--min-interval` is available for integrations which reuse the client.
81
+
82
+ Private-address filtering is a useful guardrail, not a complete SSRF defense against DNS rebinding or hostile networks. Run scans from a suitably restricted network when targets may be untrusted.
83
+
84
+ ## Architecture
85
+
86
+ ```text
87
+ CLI → URL validation / target policy → bounded HTTP client → header analyzer → table or JSON renderer
88
+ ```
89
+
90
+ - `safety.py`: URL validation and conservative public-target policy
91
+ - `client.py`: timeouts, redirect bound, TLS policy, and retrieval errors
92
+ - `analyzer.py`: pure, testable header checks and score calculation
93
+ - `output.py`: dependency-free terminal and JSON reports
94
+
95
+ ## Limitations
96
+
97
+ HeaderHound evaluates only the final HTTP response (while reporting the redirect chain). It cannot determine whether a header is consistently set on every route, whether a CSP is compatible with the application, whether TLS configuration is strong, or whether application behavior is secure. Security headers are defense in depth.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ python -m pip install -e '.[dev]'
103
+ ruff check .
104
+ ruff format --check .
105
+ pytest
106
+ ```
107
+
108
+ See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [ROADMAP.md](ROADMAP.md).
109
+
110
+ ## License
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,73 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "headerhound"
7
+ version = "0.1.1"
8
+ description = "A defensive, explainable CLI for auditing HTTP security response headers."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "HeaderHound contributors" }]
14
+ keywords = ["cli", "csp", "http", "http-security", "security", "security-headers", "web-security"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Information Technology",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ "Topic :: Security",
30
+ ]
31
+ dependencies = ["httpx>=0.27,<1"]
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.8"]
35
+
36
+ [project.scripts]
37
+ headerhound = "headerhound.cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/m-ramadan-sec/headerhound"
41
+ Repository = "https://github.com/m-ramadan-sec/headerhound"
42
+ Issues = "https://github.com/m-ramadan-sec/headerhound/issues"
43
+ Changelog = "https://github.com/m-ramadan-sec/headerhound/blob/main/CHANGELOG.md"
44
+ Security = "https://github.com/m-ramadan-sec/headerhound/blob/main/SECURITY.md"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/headerhound"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = [
51
+ "/src",
52
+ "/CHANGELOG.md",
53
+ "/LICENSE",
54
+ "/README.md",
55
+ "/pyproject.toml",
56
+ ]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = "-q --cov=headerhound --cov-report=term-missing --cov-fail-under=80"
61
+
62
+ [tool.coverage.run]
63
+ branch = true
64
+
65
+ [tool.ruff]
66
+ target-version = "py310"
67
+ line-length = 100
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "I", "UP", "B", "SIM"]
71
+
72
+ [tool.ruff.format]
73
+ quote-style = "double"
@@ -0,0 +1,3 @@
1
+ """HeaderHound: an explainable HTTP security-header scanner."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,413 @@
1
+ """Header-specific checks and score calculation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+
8
+ from .models import Finding, ScanResult, Severity
9
+
10
+ _HSTS_MIN_SECONDS = 15_552_000 # 180 days
11
+
12
+
13
+ def assess(
14
+ *,
15
+ target: str,
16
+ final_url: str,
17
+ status_code: int,
18
+ headers: Mapping[str, str],
19
+ redirects: tuple[str, ...],
20
+ ) -> ScanResult:
21
+ """Analyze a normalized response-header mapping without making network requests."""
22
+ normalized = {key.lower(): value for key, value in headers.items()}
23
+ findings = (
24
+ *_check_csp(normalized),
25
+ *_check_hsts(normalized, final_url),
26
+ *_check_xcto(normalized),
27
+ *_check_xfo(normalized),
28
+ *_check_referrer_policy(normalized),
29
+ *_check_permissions_policy(normalized),
30
+ *_check_coop(normalized),
31
+ *_check_corp(normalized),
32
+ *_check_coep(normalized),
33
+ )
34
+ score = max(0, 100 - sum(finding.deduction for finding in findings))
35
+ return ScanResult(
36
+ target=target,
37
+ final_url=final_url,
38
+ status_code=status_code,
39
+ headers=normalized,
40
+ redirects=redirects,
41
+ findings=tuple(findings),
42
+ score=score,
43
+ grade=_grade(score),
44
+ )
45
+
46
+
47
+ def _finding(
48
+ code: str,
49
+ title: str,
50
+ severity: Severity,
51
+ deduction: int,
52
+ description: str,
53
+ remediation: str,
54
+ evidence: str | None = None,
55
+ ) -> Finding:
56
+ return Finding(code, title, severity, deduction, description, remediation, evidence)
57
+
58
+
59
+ def _check_csp(headers: Mapping[str, str]) -> list[Finding]:
60
+ value = headers.get("content-security-policy")
61
+ if not value:
62
+ if headers.get("content-security-policy-report-only"):
63
+ return [
64
+ _finding(
65
+ "CSP_REPORT_ONLY",
66
+ "CSP is report-only",
67
+ Severity.MEDIUM,
68
+ 12,
69
+ "A report-only policy does not enforce restrictions.",
70
+ "Deploy an enforcing Content-Security-Policy after validating reports.",
71
+ )
72
+ ]
73
+ return [
74
+ _finding(
75
+ "CSP_MISSING",
76
+ "Content-Security-Policy missing",
77
+ Severity.HIGH,
78
+ 20,
79
+ "The browser receives no policy limiting trusted content sources.",
80
+ "Define a restrictive Content-Security-Policy, beginning with default-src.",
81
+ )
82
+ ]
83
+ lower = value.lower()
84
+ findings: list[Finding] = []
85
+ if "default-src" not in lower:
86
+ findings.append(
87
+ _finding(
88
+ "CSP_NO_DEFAULT_SRC",
89
+ "CSP has no default-src",
90
+ Severity.MEDIUM,
91
+ 6,
92
+ "Unspecified fetch directives may fall back to permissive browser defaults.",
93
+ "Set default-src and explicitly allow only required origins.",
94
+ value,
95
+ )
96
+ )
97
+ if "'unsafe-inline'" in lower:
98
+ findings.append(
99
+ _finding(
100
+ "CSP_UNSAFE_INLINE",
101
+ "CSP permits inline code",
102
+ Severity.MEDIUM,
103
+ 7,
104
+ "'unsafe-inline' weakens XSS mitigation for scripts or styles.",
105
+ "Use nonces or hashes for required inline code.",
106
+ value,
107
+ )
108
+ )
109
+ if "'unsafe-eval'" in lower:
110
+ findings.append(
111
+ _finding(
112
+ "CSP_UNSAFE_EVAL",
113
+ "CSP permits eval-like code",
114
+ Severity.MEDIUM,
115
+ 8,
116
+ "'unsafe-eval' permits dynamic code evaluation.",
117
+ "Remove 'unsafe-eval' and refactor code that depends on it.",
118
+ value,
119
+ )
120
+ )
121
+ if re.search(r"(?:default-src|script-src)\s+[^;]*\*", lower):
122
+ findings.append(
123
+ _finding(
124
+ "CSP_WILDCARD",
125
+ "CSP allows wildcard sources",
126
+ Severity.MEDIUM,
127
+ 7,
128
+ "A wildcard source allows content from arbitrary origins.",
129
+ "Replace * with the specific origins the application needs.",
130
+ value,
131
+ )
132
+ )
133
+ return findings or [
134
+ _finding(
135
+ "CSP_OK",
136
+ "Content-Security-Policy present",
137
+ Severity.INFO,
138
+ 0,
139
+ "An enforcing CSP is present; review its directives for application-specific needs.",
140
+ "Keep the policy under review as application resources change.",
141
+ )
142
+ ]
143
+
144
+
145
+ def _check_hsts(headers: Mapping[str, str], final_url: str) -> list[Finding]:
146
+ value = headers.get("strict-transport-security")
147
+ if not final_url.lower().startswith("https://"):
148
+ return [
149
+ _finding(
150
+ "HSTS_NOT_APPLICABLE",
151
+ "HSTS not evaluated over HTTP",
152
+ Severity.INFO,
153
+ 0,
154
+ "Browsers ignore HSTS delivered over HTTP.",
155
+ "Serve the site over HTTPS and evaluate HSTS on its HTTPS response.",
156
+ )
157
+ ]
158
+ if not value:
159
+ return [
160
+ _finding(
161
+ "HSTS_MISSING",
162
+ "Strict-Transport-Security missing",
163
+ Severity.HIGH,
164
+ 15,
165
+ "HTTPS visitors are not instructed to prefer future secure connections.",
166
+ f"Set Strict-Transport-Security with max-age of at least "
167
+ f"{_HSTS_MIN_SECONDS} seconds after testing.",
168
+ )
169
+ ]
170
+ match = re.search(r"max-age\s*=\s*(\d+)", value, re.IGNORECASE)
171
+ if not match or int(match.group(1)) == 0:
172
+ return [
173
+ _finding(
174
+ "HSTS_DISABLED",
175
+ "HSTS is disabled or invalid",
176
+ Severity.HIGH,
177
+ 12,
178
+ "The HSTS max-age is absent, invalid, or zero.",
179
+ "Set a positive max-age after testing HTTPS across the site.",
180
+ value,
181
+ )
182
+ ]
183
+ if int(match.group(1)) < _HSTS_MIN_SECONDS:
184
+ return [
185
+ _finding(
186
+ "HSTS_SHORT_MAX_AGE",
187
+ "HSTS max-age is short",
188
+ Severity.MEDIUM,
189
+ 6,
190
+ f"The max-age is below the recommended {_HSTS_MIN_SECONDS} seconds.",
191
+ "Increase max-age gradually after validating HTTPS operations.",
192
+ value,
193
+ )
194
+ ]
195
+ return [
196
+ _finding(
197
+ "HSTS_OK",
198
+ "Strict-Transport-Security present",
199
+ Severity.INFO,
200
+ 0,
201
+ "HSTS has a substantial max-age.",
202
+ "Consider includeSubDomains only when every subdomain supports HTTPS.",
203
+ value,
204
+ )
205
+ ]
206
+
207
+
208
+ def _check_xcto(headers: Mapping[str, str]) -> list[Finding]:
209
+ value = headers.get("x-content-type-options")
210
+ if value and value.strip().lower() == "nosniff":
211
+ return [
212
+ _finding(
213
+ "XCTO_OK",
214
+ "X-Content-Type-Options present",
215
+ Severity.INFO,
216
+ 0,
217
+ "The response requests strict MIME-type handling.",
218
+ "Keep this header on relevant responses.",
219
+ value,
220
+ )
221
+ ]
222
+ return [
223
+ _finding(
224
+ "XCTO_MISSING_OR_WEAK",
225
+ "X-Content-Type-Options missing or weak",
226
+ Severity.MEDIUM,
227
+ 8,
228
+ "Browsers may MIME-sniff a response in some contexts.",
229
+ "Send X-Content-Type-Options: nosniff.",
230
+ value,
231
+ )
232
+ ]
233
+
234
+
235
+ def _check_xfo(headers: Mapping[str, str]) -> list[Finding]:
236
+ value = headers.get("x-frame-options")
237
+ if value and value.strip().lower() in {"deny", "sameorigin"}:
238
+ return [
239
+ _finding(
240
+ "XFO_OK",
241
+ "X-Frame-Options present",
242
+ Severity.INFO,
243
+ 0,
244
+ "The response sets a recognized frame-embedding restriction.",
245
+ "Also use CSP frame-ancestors for modern, flexible framing control.",
246
+ value,
247
+ )
248
+ ]
249
+ if value:
250
+ return [
251
+ _finding(
252
+ "XFO_WEAK",
253
+ "X-Frame-Options has an unsupported value",
254
+ Severity.MEDIUM,
255
+ 7,
256
+ "Browsers may ignore this framing directive.",
257
+ "Use DENY or SAMEORIGIN, and set CSP frame-ancestors where appropriate.",
258
+ value,
259
+ )
260
+ ]
261
+ return [
262
+ _finding(
263
+ "XFO_MISSING",
264
+ "X-Frame-Options missing",
265
+ Severity.MEDIUM,
266
+ 8,
267
+ "The response has no legacy anti-clickjacking header.",
268
+ "Use X-Frame-Options: DENY or SAMEORIGIN; CSP frame-ancestors is the "
269
+ "modern complement.",
270
+ )
271
+ ]
272
+
273
+
274
+ def _policy_check(
275
+ headers: Mapping[str, str],
276
+ header: str,
277
+ code: str,
278
+ title: str,
279
+ deduction: int,
280
+ remediation: str,
281
+ weak_values: set[str] | None = None,
282
+ ) -> list[Finding]:
283
+ value = headers.get(header)
284
+ if not value:
285
+ return [
286
+ _finding(
287
+ f"{code}_MISSING",
288
+ f"{title} missing",
289
+ Severity.LOW,
290
+ deduction,
291
+ f"The response does not declare {title}.",
292
+ remediation,
293
+ )
294
+ ]
295
+ if weak_values and value.strip().lower() in weak_values:
296
+ return [
297
+ _finding(
298
+ f"{code}_WEAK",
299
+ f"{title} is permissive",
300
+ Severity.LOW,
301
+ deduction,
302
+ f"The configured {title} value is permissive.",
303
+ remediation,
304
+ value,
305
+ )
306
+ ]
307
+ return [
308
+ _finding(
309
+ f"{code}_OK",
310
+ f"{title} present",
311
+ Severity.INFO,
312
+ 0,
313
+ f"The response declares {title}.",
314
+ "Review this policy as application requirements change.",
315
+ value,
316
+ )
317
+ ]
318
+
319
+
320
+ def _check_referrer_policy(headers: Mapping[str, str]) -> list[Finding]:
321
+ return _policy_check(
322
+ headers,
323
+ "referrer-policy",
324
+ "REFERRER_POLICY",
325
+ "Referrer-Policy",
326
+ 5,
327
+ "Use strict-origin-when-cross-origin or a stricter policy.",
328
+ {"unsafe-url", "no-referrer-when-downgrade"},
329
+ )
330
+
331
+
332
+ def _check_permissions_policy(headers: Mapping[str, str]) -> list[Finding]:
333
+ value = headers.get("permissions-policy")
334
+ if not value:
335
+ return _policy_check(
336
+ headers,
337
+ "permissions-policy",
338
+ "PERMISSIONS_POLICY",
339
+ "Permissions-Policy",
340
+ 4,
341
+ "Explicitly disable unneeded browser features, for example geolocation=().",
342
+ )
343
+ if "=*" in value.replace(" ", ""):
344
+ return [
345
+ _finding(
346
+ "PERMISSIONS_POLICY_WILDCARD",
347
+ "Permissions-Policy delegates to all origins",
348
+ Severity.LOW,
349
+ 4,
350
+ "One or more features are allowed for every origin.",
351
+ "Limit each feature to self or named, trusted origins.",
352
+ value,
353
+ )
354
+ ]
355
+ return [
356
+ _finding(
357
+ "PERMISSIONS_POLICY_OK",
358
+ "Permissions-Policy present",
359
+ Severity.INFO,
360
+ 0,
361
+ "The response declares a Permissions-Policy.",
362
+ "Review delegated features as application requirements change.",
363
+ value,
364
+ )
365
+ ]
366
+
367
+
368
+ def _check_coop(headers: Mapping[str, str]) -> list[Finding]:
369
+ return _policy_check(
370
+ headers,
371
+ "cross-origin-opener-policy",
372
+ "COOP",
373
+ "Cross-Origin-Opener-Policy",
374
+ 4,
375
+ "Use same-origin when compatible with the application.",
376
+ {"unsafe-none"},
377
+ )
378
+
379
+
380
+ def _check_corp(headers: Mapping[str, str]) -> list[Finding]:
381
+ return _policy_check(
382
+ headers,
383
+ "cross-origin-resource-policy",
384
+ "CORP",
385
+ "Cross-Origin-Resource-Policy",
386
+ 4,
387
+ "Use same-origin or same-site where resource sharing permits.",
388
+ {"cross-origin"},
389
+ )
390
+
391
+
392
+ def _check_coep(headers: Mapping[str, str]) -> list[Finding]:
393
+ return _policy_check(
394
+ headers,
395
+ "cross-origin-embedder-policy",
396
+ "COEP",
397
+ "Cross-Origin-Embedder-Policy",
398
+ 3,
399
+ "Consider require-corp or credentialless after testing third-party resource compatibility.",
400
+ {"unsafe-none"},
401
+ )
402
+
403
+
404
+ def _grade(score: int) -> str:
405
+ if score >= 90:
406
+ return "A"
407
+ if score >= 80:
408
+ return "B"
409
+ if score >= 70:
410
+ return "C"
411
+ if score >= 55:
412
+ return "D"
413
+ return "F"
@@ -0,0 +1,134 @@
1
+ """Command-line interface for HeaderHound."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from . import __version__
11
+ from .analyzer import assess
12
+ from .client import FetchError, ScanClient
13
+ from .output import render_json, render_table
14
+ from .safety import TargetError, ensure_public_target, normalize_url
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="headerhound",
20
+ description="Safely audit HTTP response security headers for one URL.",
21
+ )
22
+ parser.add_argument("url", help="Absolute http:// or https:// URL to scan")
23
+ parser.add_argument(
24
+ "--format",
25
+ choices=("table", "json"),
26
+ default="table",
27
+ help="Report format (default: table)",
28
+ )
29
+ parser.add_argument(
30
+ "--timeout",
31
+ type=_positive_float,
32
+ default=10.0,
33
+ metavar="SECONDS",
34
+ help="Per-request timeout (default: 10)",
35
+ )
36
+ parser.add_argument(
37
+ "--max-redirects",
38
+ type=_nonnegative_int,
39
+ default=5,
40
+ metavar="COUNT",
41
+ help="Maximum redirects to follow (default: 5)",
42
+ )
43
+ parser.add_argument("--no-redirects", action="store_true", help="Do not follow redirects")
44
+ parser.add_argument(
45
+ "--allow-private",
46
+ action="store_true",
47
+ help="Permit loopback, private, and reserved targets; only use on authorized systems",
48
+ )
49
+ parser.add_argument(
50
+ "--insecure",
51
+ action="store_true",
52
+ help="Disable TLS certificate verification (not recommended)",
53
+ )
54
+ parser.add_argument(
55
+ "--min-interval",
56
+ type=_nonnegative_float,
57
+ default=0.2,
58
+ metavar="SECONDS",
59
+ help="Minimum interval before a request (default: 0.2)",
60
+ )
61
+ parser.add_argument(
62
+ "--fail-under",
63
+ type=_score,
64
+ metavar="SCORE",
65
+ help="Exit with status 1 when the completed scan score is below SCORE (0-100)",
66
+ )
67
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
68
+ return parser
69
+
70
+
71
+ def main(argv: Sequence[str] | None = None) -> int:
72
+ args = build_parser().parse_args(argv)
73
+ try:
74
+ target = normalize_url(args.url)
75
+ ensure_public_target(target, allow_private=args.allow_private)
76
+ response = ScanClient(
77
+ timeout=args.timeout,
78
+ max_redirects=args.max_redirects,
79
+ follow_redirects=not args.no_redirects,
80
+ verify_tls=not args.insecure,
81
+ min_interval=args.min_interval,
82
+ ).fetch(target)
83
+ result = assess(
84
+ target=target,
85
+ final_url=response.final_url,
86
+ status_code=response.status_code,
87
+ headers=response.headers,
88
+ redirects=response.redirects,
89
+ )
90
+ except (TargetError, FetchError, ValueError) as exc:
91
+ _write_error(str(exc), args.format)
92
+ return 2
93
+
94
+ print(render_json(result) if args.format == "json" else render_table(result))
95
+ return 1 if args.fail_under is not None and result.score < args.fail_under else 0
96
+
97
+
98
+ def _write_error(message: str, output_format: str) -> None:
99
+ if output_format == "json":
100
+ print(json.dumps({"error": message}), file=sys.stderr)
101
+ else:
102
+ print(f"headerhound: error: {message}", file=sys.stderr)
103
+
104
+
105
+ def _positive_float(value: str) -> float:
106
+ number = float(value)
107
+ if number <= 0:
108
+ raise argparse.ArgumentTypeError("must be greater than zero")
109
+ return number
110
+
111
+
112
+ def _nonnegative_float(value: str) -> float:
113
+ number = float(value)
114
+ if number < 0:
115
+ raise argparse.ArgumentTypeError("must not be negative")
116
+ return number
117
+
118
+
119
+ def _nonnegative_int(value: str) -> int:
120
+ number = int(value)
121
+ if number < 0:
122
+ raise argparse.ArgumentTypeError("must not be negative")
123
+ return number
124
+
125
+
126
+ def _score(value: str) -> int:
127
+ number = _nonnegative_int(value)
128
+ if number > 100:
129
+ raise argparse.ArgumentTypeError("must be between 0 and 100")
130
+ return number
131
+
132
+
133
+ if __name__ == "__main__": # pragma: no cover
134
+ raise SystemExit(main())
@@ -0,0 +1,83 @@
1
+ """Bounded, defensive HTTP retrieval for HeaderHound."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+ import httpx
9
+
10
+
11
+ class FetchError(RuntimeError):
12
+ """A user-facing failure to retrieve a target."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class FetchedResponse:
17
+ final_url: str
18
+ status_code: int
19
+ headers: dict[str, str]
20
+ redirects: tuple[str, ...]
21
+
22
+
23
+ class ScanClient:
24
+ """Fetch one resource with explicit bounds and a small request interval."""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ timeout: float = 10.0,
30
+ max_redirects: int = 5,
31
+ follow_redirects: bool = True,
32
+ verify_tls: bool = True,
33
+ min_interval: float = 0.2,
34
+ ) -> None:
35
+ if timeout <= 0:
36
+ raise ValueError("timeout must be greater than zero")
37
+ if max_redirects < 0:
38
+ raise ValueError("max_redirects cannot be negative")
39
+ if min_interval < 0:
40
+ raise ValueError("min_interval cannot be negative")
41
+ self.timeout = timeout
42
+ self.max_redirects = max_redirects
43
+ self.follow_redirects = follow_redirects
44
+ self.verify_tls = verify_tls
45
+ self.min_interval = min_interval
46
+ self._last_request = 0.0
47
+
48
+ def fetch(self, url: str) -> FetchedResponse:
49
+ self._wait_for_interval()
50
+ try:
51
+ with httpx.Client(
52
+ follow_redirects=self.follow_redirects,
53
+ max_redirects=self.max_redirects,
54
+ timeout=httpx.Timeout(self.timeout),
55
+ verify=self.verify_tls,
56
+ trust_env=False,
57
+ headers={
58
+ "User-Agent": "HeaderHound/0.1.0 (+https://github.com/m-ramadan-sec/headerhound)",
59
+ "Accept": "*/*",
60
+ },
61
+ ) as client:
62
+ response = client.get(url)
63
+ except httpx.TooManyRedirects as exc:
64
+ raise FetchError(f"Too many redirects (limit: {self.max_redirects}).") from exc
65
+ except httpx.TimeoutException as exc:
66
+ raise FetchError(f"Request timed out after {self.timeout:g} seconds.") from exc
67
+ except httpx.ConnectError as exc:
68
+ raise FetchError(f"Could not connect to target: {exc}") from exc
69
+ except httpx.HTTPError as exc:
70
+ raise FetchError(f"HTTP request failed: {exc}") from exc
71
+
72
+ self._last_request = time.monotonic()
73
+ return FetchedResponse(
74
+ final_url=str(response.url),
75
+ status_code=response.status_code,
76
+ headers={key.lower(): value for key, value in response.headers.items()},
77
+ redirects=tuple(str(item.url) for item in response.history),
78
+ )
79
+
80
+ def _wait_for_interval(self) -> None:
81
+ remaining = self.min_interval - (time.monotonic() - self._last_request)
82
+ if remaining > 0:
83
+ time.sleep(remaining)
@@ -0,0 +1,54 @@
1
+ """Typed data structures shared across the scanner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class Severity(str, Enum):
11
+ INFO = "info"
12
+ LOW = "low"
13
+ MEDIUM = "medium"
14
+ HIGH = "high"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Finding:
19
+ code: str
20
+ title: str
21
+ severity: Severity
22
+ deduction: int
23
+ description: str
24
+ remediation: str
25
+ evidence: str | None = None
26
+
27
+ def to_dict(self) -> dict[str, Any]:
28
+ data = asdict(self)
29
+ data["severity"] = self.severity.value
30
+ return data
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ScanResult:
35
+ target: str
36
+ final_url: str
37
+ status_code: int
38
+ headers: dict[str, str]
39
+ redirects: tuple[str, ...]
40
+ findings: tuple[Finding, ...]
41
+ score: int
42
+ grade: str
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "target": self.target,
47
+ "final_url": self.final_url,
48
+ "status_code": self.status_code,
49
+ "headers": self.headers,
50
+ "redirects": list(self.redirects),
51
+ "findings": [finding.to_dict() for finding in self.findings],
52
+ "score": self.score,
53
+ "grade": self.grade,
54
+ }
@@ -0,0 +1,52 @@
1
+ """Human-readable and JSON report rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .models import ScanResult
8
+
9
+
10
+ def render_json(result: ScanResult) -> str:
11
+ return json.dumps(result.to_dict(), indent=2, sort_keys=True)
12
+
13
+
14
+ def render_table(result: ScanResult) -> str:
15
+ """Render a dependency-free table suitable for CI logs and terminals."""
16
+ rows = [("Severity", "Finding", "Details")]
17
+ for finding in result.findings:
18
+ detail = finding.description
19
+ if finding.evidence:
20
+ detail = f"{detail} Value: {finding.evidence}"
21
+ rows.append((finding.severity.value.upper(), finding.title, detail))
22
+
23
+ widths = [
24
+ max(len(_clip(row[column], 74 if column == 2 else 32)) for row in rows)
25
+ for column in range(3)
26
+ ]
27
+ separator = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
28
+ lines = [
29
+ f"Target: {result.target}",
30
+ f"Final URL: {result.final_url}",
31
+ f"HTTP status: {result.status_code}",
32
+ f"Security score: {result.score}/100 (grade {result.grade})",
33
+ ]
34
+ if result.redirects:
35
+ lines.append(f"Redirects followed: {len(result.redirects)}")
36
+ lines.extend(["", separator, _row(rows[0], widths), separator])
37
+ lines.extend(
38
+ _row(tuple(_clip(cell, 74 if index == 2 else 32) for index, cell in enumerate(row)), widths)
39
+ for row in rows[1:]
40
+ )
41
+ lines.append(separator)
42
+ return "\n".join(lines)
43
+
44
+
45
+ def _row(row: tuple[str, str, str], widths: list[int]) -> str:
46
+ return (
47
+ "|" + "|".join(f" {cell:<{width}} " for cell, width in zip(row, widths, strict=True)) + "|"
48
+ )
49
+
50
+
51
+ def _clip(value: str, limit: int) -> str:
52
+ return value if len(value) <= limit else f"{value[: limit - 1]}…"
@@ -0,0 +1,65 @@
1
+ """Input validation and conservative target-safety checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import socket
7
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
8
+
9
+
10
+ class TargetError(ValueError):
11
+ """Raised when a target is malformed or is not allowed by policy."""
12
+
13
+
14
+ def normalize_url(value: str) -> str:
15
+ """Validate an absolute HTTP(S) URL and remove its non-request fragment."""
16
+ try:
17
+ parsed = urlsplit(value.strip())
18
+ _validate_parts(parsed)
19
+ # Fragments are not sent in HTTP requests and should not affect reports.
20
+ return urlunsplit(
21
+ (parsed.scheme.lower(), parsed.netloc, parsed.path or "/", parsed.query, "")
22
+ )
23
+ except (TypeError, ValueError) as exc:
24
+ raise TargetError(
25
+ f"Invalid URL: {value!r}. Supply an absolute http:// or https:// URL."
26
+ ) from exc
27
+
28
+
29
+ def _validate_parts(parsed: SplitResult) -> None:
30
+ if parsed.scheme.lower() not in {"http", "https"}:
31
+ raise TargetError("Only http:// and https:// targets are supported.")
32
+ if not parsed.netloc or not parsed.hostname:
33
+ raise TargetError("A target URL must include a host.")
34
+ if parsed.username or parsed.password:
35
+ raise TargetError("Credentials in target URLs are not supported.")
36
+ # Accessing .port validates malformed and out-of-range port values.
37
+ _ = parsed.port
38
+
39
+
40
+ def ensure_public_target(url: str, *, allow_private: bool = False) -> None:
41
+ """Block resolved non-global addresses unless the caller explicitly opts in.
42
+
43
+ This reduces accidental requests to loopback, private, link-local, and reserved
44
+ services. DNS is resolved once here, so callers scanning sensitive environments
45
+ should use an egress-controlled network as an additional safeguard.
46
+ """
47
+ if allow_private:
48
+ return
49
+ host = urlsplit(url).hostname
50
+ assert host is not None # normalize_url establishes this invariant.
51
+ try:
52
+ addresses = {
53
+ record[4][0] for record in socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
54
+ }
55
+ except socket.gaierror as exc:
56
+ raise TargetError(f"Could not resolve host {host!r}: {exc}") from exc
57
+ if not addresses:
58
+ raise TargetError(f"Could not resolve host {host!r}.")
59
+ non_public = [address for address in addresses if not ipaddress.ip_address(address).is_global]
60
+ if non_public:
61
+ raise TargetError(
62
+ "Refusing a target that resolves to a non-public address "
63
+ f"({', '.join(non_public)}). Use --allow-private only for systems you are "
64
+ "authorized to scan."
65
+ )