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.
- headerhound-0.1.1/.gitignore +10 -0
- headerhound-0.1.1/CHANGELOG.md +26 -0
- headerhound-0.1.1/LICENSE +21 -0
- headerhound-0.1.1/PKG-INFO +147 -0
- headerhound-0.1.1/README.md +112 -0
- headerhound-0.1.1/pyproject.toml +73 -0
- headerhound-0.1.1/src/headerhound/__init__.py +3 -0
- headerhound-0.1.1/src/headerhound/analyzer.py +413 -0
- headerhound-0.1.1/src/headerhound/cli.py +134 -0
- headerhound-0.1.1/src/headerhound/client.py +83 -0
- headerhound-0.1.1/src/headerhound/models.py +54 -0
- headerhound-0.1.1/src/headerhound/output.py +52 -0
- headerhound-0.1.1/src/headerhound/safety.py +65 -0
|
@@ -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
|
+
[](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml)
|
|
39
|
+
[](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
|
+
[](https://github.com/m-ramadan-sec/headerhound/actions/workflows/ci.yml)
|
|
4
|
+
[](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,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
|
+
)
|