headerhawk-auditor 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.
- headerhawk_auditor-0.1.0/.github/workflows/ci.yml +16 -0
- headerhawk_auditor-0.1.0/.github/workflows/publish.yml +43 -0
- headerhawk_auditor-0.1.0/.gitignore +7 -0
- headerhawk_auditor-0.1.0/LICENSE +38 -0
- headerhawk_auditor-0.1.0/PKG-INFO +73 -0
- headerhawk_auditor-0.1.0/README.md +55 -0
- headerhawk_auditor-0.1.0/pyproject.toml +27 -0
- headerhawk_auditor-0.1.0/src/headerhawk/__init__.py +2 -0
- headerhawk_auditor-0.1.0/src/headerhawk/__main__.py +3 -0
- headerhawk_auditor-0.1.0/src/headerhawk/audit.py +56 -0
- headerhawk_auditor-0.1.0/src/headerhawk/cli.py +105 -0
- headerhawk_auditor-0.1.0/tests/test_audit.py +56 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
pull_request:
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-python@v5
|
|
11
|
+
with:
|
|
12
|
+
python-version: '3.12'
|
|
13
|
+
- run: python -m pip install -e '.[dev]'
|
|
14
|
+
- run: pytest -q
|
|
15
|
+
- run: ruff check .
|
|
16
|
+
- run: python -m build
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
name: Build and publish
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- name: Install release tools
|
|
22
|
+
run: python -m pip install --upgrade build twine
|
|
23
|
+
- name: Confirm the tag matches the package version
|
|
24
|
+
env:
|
|
25
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
26
|
+
run: |
|
|
27
|
+
python - <<'PY'
|
|
28
|
+
import os
|
|
29
|
+
import tomllib
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
|
|
33
|
+
if os.environ["RELEASE_TAG"] != f"v{version}":
|
|
34
|
+
raise SystemExit("Release tag does not match the package version.")
|
|
35
|
+
PY
|
|
36
|
+
- name: Build distributions
|
|
37
|
+
run: python -m build
|
|
38
|
+
- name: Validate distributions
|
|
39
|
+
run: twine check dist/*
|
|
40
|
+
- name: Publish to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
42
|
+
with:
|
|
43
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Proprietary Software License — Free Use, No Modification
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noir Stack LLC. All rights reserved.
|
|
4
|
+
|
|
5
|
+
## 1. Grant of License
|
|
6
|
+
|
|
7
|
+
The copyright holder grants users a non-exclusive, worldwide, royalty-free license to download, install, and use this software for personal, educational, research, and commercial purposes, subject to the conditions below.
|
|
8
|
+
|
|
9
|
+
## 2. Restrictions
|
|
10
|
+
|
|
11
|
+
Users may not, without prior written permission from the copyright holder:
|
|
12
|
+
|
|
13
|
+
- Modify, adapt, translate, or create derivative works based on the software.
|
|
14
|
+
- Redistribute, publish, sell, sublicense, or repackage the software.
|
|
15
|
+
- Remove or alter copyright, attribution, or license notices.
|
|
16
|
+
- Claim ownership or authorship of the software.
|
|
17
|
+
|
|
18
|
+
## 3. Ownership
|
|
19
|
+
|
|
20
|
+
All rights, title, and interest in the software, including its source code and associated intellectual property, remain with the copyright holder.
|
|
21
|
+
|
|
22
|
+
No ownership rights are transferred under this license.
|
|
23
|
+
|
|
24
|
+
## 4. No Warranty
|
|
25
|
+
|
|
26
|
+
The software is provided "AS IS", without warranty of any kind, express or implied, to the extent permitted by applicable law.
|
|
27
|
+
|
|
28
|
+
## 5. Limitation of Liability
|
|
29
|
+
|
|
30
|
+
To the extent permitted by applicable law, the copyright holder shall not be liable for damages arising from the use or inability to use the software.
|
|
31
|
+
|
|
32
|
+
## 6. Termination
|
|
33
|
+
|
|
34
|
+
Permission to use the software terminates upon a material violation of this license, subject to applicable law.
|
|
35
|
+
|
|
36
|
+
## 7. Additional Permissions
|
|
37
|
+
|
|
38
|
+
Any permissions not expressly granted must be obtained in writing from the copyright holder.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: headerhawk-auditor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, read-only HTTP security header and cookie auditor
|
|
5
|
+
Author: Noir Stack LLC
|
|
6
|
+
License: Proprietary Software License — Free Use, No Modification
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: audit,cli,headers,http,security
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx<1,>=0.27
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# HeaderHawk
|
|
20
|
+
|
|
21
|
+
A small, read-only HTTP security-header and cookie configuration auditor, designed as a portfolio-ready Python package.
|
|
22
|
+
|
|
23
|
+
> HeaderHawk checks response configuration. It is **not** a penetration test, does not prove exploitability, and its score is not a security certification.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
python -m pip install -e '.[dev]'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Once published to PyPI, install using `pip install headerhawk-auditor` (subject to package name availability).
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
headerhawk scan https://example.com
|
|
37
|
+
headerhawk scan https://example.com --format json --output report.json
|
|
38
|
+
headerhawk scan https://example.com --format html --output report.html
|
|
39
|
+
python -m headerhawk scan https://example.com
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Checks: HTTPS, HSTS, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, framing protection, Cache-Control presence, and Secure/HttpOnly/SameSite cookie attributes. Header checks are intentionally conservative: for example, a CSP being present does not mean it is effective. Cookies may legitimately need different flags based on application purpose. Cache-Control is informational.
|
|
43
|
+
|
|
44
|
+
Reports do not include cookie values or redirect destinations. When a response is a redirect, HeaderHawk records only that a `Location` header was present.
|
|
45
|
+
|
|
46
|
+
No redirects are followed; HTTP redirects must be audited by explicitly scanning the destination URL. No response body is downloaded beyond response headers. Private and loopback destinations are blocked unless `--allow-private` is provided. The DNS check is a best-effort guard, not a complete defense against DNS rebinding; do not run this tool as a privileged or public-facing URL-fetching service.
|
|
47
|
+
|
|
48
|
+
Only audit websites you own or have permission to assess.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m pip install -e '.[dev]'
|
|
54
|
+
pytest -q
|
|
55
|
+
ruff check .
|
|
56
|
+
python -m build
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Publishing
|
|
60
|
+
|
|
61
|
+
1. Choose a unique PyPI distribution name and update `pyproject.toml`.
|
|
62
|
+
2. Create a GitHub repository and configure PyPI Trusted Publishing for the repository's `publish.yml` workflow and `pypi` environment.
|
|
63
|
+
3. Tag a release such as `v0.1.0`, then run the publish workflow after verifying the build.
|
|
64
|
+
4. Never put API tokens or credentials in repository files.
|
|
65
|
+
|
|
66
|
+
## Limitations
|
|
67
|
+
|
|
68
|
+
This MVP audits one response at a time. It does not crawl, brute-force, exploit, verify TLS certificate chains independently, or evaluate the semantics of every header directive. Findings are heuristic and can contain false positives or negatives.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
Copyright © 2026 Noir Stack LLC. Licensed under the
|
|
73
|
+
[Proprietary Software License — Free Use, No Modification](LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# HeaderHawk
|
|
2
|
+
|
|
3
|
+
A small, read-only HTTP security-header and cookie configuration auditor, designed as a portfolio-ready Python package.
|
|
4
|
+
|
|
5
|
+
> HeaderHawk checks response configuration. It is **not** a penetration test, does not prove exploitability, and its score is not a security certification.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install -e '.[dev]'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Once published to PyPI, install using `pip install headerhawk-auditor` (subject to package name availability).
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
headerhawk scan https://example.com
|
|
19
|
+
headerhawk scan https://example.com --format json --output report.json
|
|
20
|
+
headerhawk scan https://example.com --format html --output report.html
|
|
21
|
+
python -m headerhawk scan https://example.com
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Checks: HTTPS, HSTS, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, framing protection, Cache-Control presence, and Secure/HttpOnly/SameSite cookie attributes. Header checks are intentionally conservative: for example, a CSP being present does not mean it is effective. Cookies may legitimately need different flags based on application purpose. Cache-Control is informational.
|
|
25
|
+
|
|
26
|
+
Reports do not include cookie values or redirect destinations. When a response is a redirect, HeaderHawk records only that a `Location` header was present.
|
|
27
|
+
|
|
28
|
+
No redirects are followed; HTTP redirects must be audited by explicitly scanning the destination URL. No response body is downloaded beyond response headers. Private and loopback destinations are blocked unless `--allow-private` is provided. The DNS check is a best-effort guard, not a complete defense against DNS rebinding; do not run this tool as a privileged or public-facing URL-fetching service.
|
|
29
|
+
|
|
30
|
+
Only audit websites you own or have permission to assess.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install -e '.[dev]'
|
|
36
|
+
pytest -q
|
|
37
|
+
ruff check .
|
|
38
|
+
python -m build
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Publishing
|
|
42
|
+
|
|
43
|
+
1. Choose a unique PyPI distribution name and update `pyproject.toml`.
|
|
44
|
+
2. Create a GitHub repository and configure PyPI Trusted Publishing for the repository's `publish.yml` workflow and `pypi` environment.
|
|
45
|
+
3. Tag a release such as `v0.1.0`, then run the publish workflow after verifying the build.
|
|
46
|
+
4. Never put API tokens or credentials in repository files.
|
|
47
|
+
|
|
48
|
+
## Limitations
|
|
49
|
+
|
|
50
|
+
This MVP audits one response at a time. It does not crawl, brute-force, exploit, verify TLS certificate chains independently, or evaluate the semantics of every header directive. Findings are heuristic and can contain false positives or negatives.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
Copyright © 2026 Noir Stack LLC. Licensed under the
|
|
55
|
+
[Proprietary Software License — Free Use, No Modification](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "headerhawk-auditor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A lightweight, read-only HTTP security header and cookie auditor"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary Software License — Free Use, No Modification" }
|
|
12
|
+
authors = [{name = "Noir Stack LLC"}]
|
|
13
|
+
keywords = ["security", "headers", "audit", "cli", "http"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "Topic :: Security"]
|
|
15
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "build>=1.2", "ruff>=0.6"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
headerhawk = "headerhawk.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/headerhawk"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pure response analysis; no networking in this module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Finding:
|
|
10
|
+
id: str
|
|
11
|
+
severity: str
|
|
12
|
+
title: str
|
|
13
|
+
recommendation: str
|
|
14
|
+
passed: bool
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict[str, Any]:
|
|
17
|
+
return asdict(self)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def audit_response(url: str, headers: dict[str, str], cookies: list[str] | None = None) -> dict[str, Any]:
|
|
21
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
22
|
+
findings: list[Finding] = []
|
|
23
|
+
|
|
24
|
+
def check(id: str, severity: str, title: str, ok: bool, recommendation: str) -> None:
|
|
25
|
+
findings.append(Finding(id, severity, title, recommendation, ok))
|
|
26
|
+
|
|
27
|
+
https = url.lower().startswith("https://")
|
|
28
|
+
check("https", "high", "HTTPS in use", https, "Serve the site over HTTPS.")
|
|
29
|
+
check("hsts", "medium", "HTTP Strict Transport Security", not https or bool(h.get("strict-transport-security")), "Set Strict-Transport-Security on HTTPS responses after validating rollout readiness.")
|
|
30
|
+
check("csp", "medium", "Content Security Policy", bool(h.get("content-security-policy")), "Deploy a restrictive Content-Security-Policy appropriate for the application.")
|
|
31
|
+
check("nosniff", "medium", "MIME sniffing protection", h.get("x-content-type-options", "").lower().strip() == "nosniff", "Set X-Content-Type-Options: nosniff.")
|
|
32
|
+
check("referrer", "low", "Referrer policy", bool(h.get("referrer-policy")), "Set a privacy-conscious Referrer-Policy such as strict-origin-when-cross-origin.")
|
|
33
|
+
check("permissions", "low", "Permissions policy", bool(h.get("permissions-policy")), "Define Permissions-Policy to limit unused browser features.")
|
|
34
|
+
check("frame", "medium", "Framing protection", bool(h.get("x-frame-options")) or "frame-ancestors" in h.get("content-security-policy", "").lower(), "Use CSP frame-ancestors (or X-Frame-Options for older browsers).")
|
|
35
|
+
check("cache", "info", "Cache-control header", bool(h.get("cache-control")), "Review Cache-Control based on whether the response contains sensitive data.")
|
|
36
|
+
|
|
37
|
+
for i, raw in enumerate(cookies or [], start=1):
|
|
38
|
+
# Cookie values are never included in findings or reports.
|
|
39
|
+
# Attribute matching is case-insensitive and works with nonstandard cookie values.
|
|
40
|
+
attributes = raw.split(";", 1)[1].lower() if ";" in raw else ""
|
|
41
|
+
parts = [part.strip() for part in attributes.split(";")]
|
|
42
|
+
has_secure = any(part == "secure" for part in parts)
|
|
43
|
+
has_httponly = any(part == "httponly" for part in parts)
|
|
44
|
+
has_samesite = any(part.startswith("samesite=") for part in parts)
|
|
45
|
+
prefix = f"cookie-{i}"
|
|
46
|
+
check(f"{prefix}-secure", "medium", f"Cookie {i}: Secure flag", has_secure, "Set Secure for cookies sent over HTTPS.")
|
|
47
|
+
check(f"{prefix}-httponly", "medium", f"Cookie {i}: HttpOnly flag", has_httponly, "Set HttpOnly for cookies not needed by JavaScript.")
|
|
48
|
+
check(f"{prefix}-samesite", "low", f"Cookie {i}: SameSite attribute", has_samesite, "Set SameSite=Lax or Strict where appropriate.")
|
|
49
|
+
|
|
50
|
+
# Score is a configuration heuristic, not a security guarantee.
|
|
51
|
+
weighted = {"high": 15, "medium": 10, "low": 5, "info": 0}
|
|
52
|
+
scored = [f for f in findings if weighted[f.severity] > 0 and not (f.id == "hsts" and not https)]
|
|
53
|
+
possible = sum(weighted[f.severity] for f in scored)
|
|
54
|
+
earned = sum(weighted[f.severity] for f in scored if f.passed)
|
|
55
|
+
score = round(100 * earned / possible) if possible else 100
|
|
56
|
+
return {"url": url, "score": score, "passed": sum(f.passed for f in findings), "total": len(findings), "findings": [f.to_dict() for f in findings]}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Command line entrypoint for HeaderHawk."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import html
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .audit import audit_response
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_url(url: str, allow_private: bool = False) -> None:
|
|
20
|
+
parts = urlsplit(url)
|
|
21
|
+
if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password:
|
|
22
|
+
raise ValueError("Supply an http(s) URL without embedded credentials.")
|
|
23
|
+
if not allow_private:
|
|
24
|
+
try:
|
|
25
|
+
addresses = socket.getaddrinfo(parts.hostname, parts.port or (443 if parts.scheme == "https" else 80), type=socket.SOCK_STREAM)
|
|
26
|
+
except socket.gaierror as exc:
|
|
27
|
+
raise ValueError(f"DNS resolution failed: {exc}") from exc
|
|
28
|
+
if not addresses or any(not ipaddress.ip_address(entry[4][0]).is_global for entry in addresses):
|
|
29
|
+
raise ValueError("Private, loopback, link-local, and non-global destinations are blocked by default. Use --allow-private for trusted local targets.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def fetch(url: str, timeout: float, allow_private: bool) -> dict:
|
|
33
|
+
validate_url(url, allow_private)
|
|
34
|
+
# No redirects: prevents silently following to an unexpected destination.
|
|
35
|
+
# No proxy environment variables: avoids unexpected routing.
|
|
36
|
+
with httpx.Client(
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
follow_redirects=False,
|
|
39
|
+
trust_env=False,
|
|
40
|
+
headers={"User-Agent": f"HeaderHawk/{__version__}"},
|
|
41
|
+
) as client, client.stream("GET", url) as response:
|
|
42
|
+
headers = dict(response.headers)
|
|
43
|
+
cookies = response.headers.get_list("set-cookie")
|
|
44
|
+
result = audit_response(str(response.url), headers, cookies)
|
|
45
|
+
result["status_code"] = response.status_code
|
|
46
|
+
result["redirect_location_present"] = "location" in response.headers
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_html(result: dict) -> str:
|
|
51
|
+
rows = "\n".join(
|
|
52
|
+
f"<tr><td>{html.escape(item['title'])}</td><td>{html.escape(item['severity'])}</td><td>{'PASS' if item['passed'] else 'REVIEW'}</td><td>{html.escape(item['recommendation'])}</td></tr>"
|
|
53
|
+
for item in result["findings"]
|
|
54
|
+
)
|
|
55
|
+
return ("<!doctype html><html lang='en'><head><meta charset='utf-8'><title>HeaderHawk report</title>"
|
|
56
|
+
"<style>body{font:16px system-ui;max-width:1100px;margin:3rem auto;padding:0 1rem;color:#17212d}table{border-collapse:collapse;width:100%}td,th{border:1px solid #ccc;padding:.6rem;text-align:left}tr:nth-child(even){background:#f4f6f8}</style></head><body>"
|
|
57
|
+
f"<h1>HeaderHawk report</h1><p>URL: {html.escape(result['url'])}</p><p>Configuration score: {result['score']}/100</p>"
|
|
58
|
+
f"<p>HTTP status: {result.get('status_code', 'N/A')}</p><table><thead><tr><th>Check</th><th>Severity</th><th>Result</th><th>Recommendation</th></tr></thead><tbody>{rows}</tbody></table>"
|
|
59
|
+
"<p>Heuristic checks only; a passing score does not establish application security.</p></body></html>")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
parser = argparse.ArgumentParser(prog="headerhawk", description="Read-only HTTP security header and cookie auditor")
|
|
64
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
65
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
66
|
+
scan = sub.add_parser("scan", help="Audit one HTTP(S) URL")
|
|
67
|
+
scan.add_argument("url")
|
|
68
|
+
scan.add_argument("--format", choices=["text", "json", "html"], default="text")
|
|
69
|
+
scan.add_argument("--output", type=Path, help="Write report to a file")
|
|
70
|
+
scan.add_argument("--timeout", type=float, default=10.0)
|
|
71
|
+
scan.add_argument("--allow-private", action="store_true", help="Permit localhost and private network targets")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
if args.timeout <= 0 or args.timeout > 60:
|
|
74
|
+
parser.error("--timeout must be greater than 0 and at most 60")
|
|
75
|
+
try:
|
|
76
|
+
result = fetch(args.url, args.timeout, args.allow_private)
|
|
77
|
+
except (ValueError, httpx.HTTPError, OSError) as exc:
|
|
78
|
+
print(f"headerhawk: {exc}", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
if args.format == "json":
|
|
81
|
+
content = json.dumps(result, indent=2)
|
|
82
|
+
elif args.format == "html":
|
|
83
|
+
content = render_html(result)
|
|
84
|
+
else:
|
|
85
|
+
lines = [f"HeaderHawk | {result['url']}", f"HTTP {result['status_code']} | Configuration score: {result['score']}/100"]
|
|
86
|
+
for item in result["findings"]:
|
|
87
|
+
lines.append(f"[{'PASS' if item['passed'] else 'REVIEW'}] {item['title']} ({item['severity']})")
|
|
88
|
+
if not item["passed"]:
|
|
89
|
+
lines.append(f" {item['recommendation']}")
|
|
90
|
+
lines.append("Note: configuration heuristic only; not a vulnerability assessment.")
|
|
91
|
+
content = "\n".join(lines)
|
|
92
|
+
if args.output:
|
|
93
|
+
try:
|
|
94
|
+
args.output.write_text(content + "\n", encoding="utf-8")
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
print(f"headerhawk: cannot write report: {exc}", file=sys.stderr)
|
|
97
|
+
return 2
|
|
98
|
+
print(f"Report saved to {args.output}")
|
|
99
|
+
else:
|
|
100
|
+
print(content)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from headerhawk import cli
|
|
5
|
+
from headerhawk.audit import audit_response
|
|
6
|
+
from headerhawk.cli import render_html, validate_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_missing_headers_reduce_score():
|
|
10
|
+
result = audit_response("https://example.com", {})
|
|
11
|
+
assert result["score"] < 50
|
|
12
|
+
assert any(f["id"] == "hsts" and not f["passed"] for f in result["findings"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_good_headers():
|
|
16
|
+
headers = {"Strict-Transport-Security": "max-age=31536000", "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Permissions-Policy": "camera=()"}
|
|
17
|
+
result = audit_response("https://example.com", headers, ["session=redacted; Secure; HttpOnly; SameSite=Lax"])
|
|
18
|
+
assert result["score"] == 100
|
|
19
|
+
assert "redacted" not in str(result)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_html_escapes_url():
|
|
23
|
+
result = audit_response("https://example.com/?x=<script>", {})
|
|
24
|
+
assert "<script>" not in render_html(result)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_url_rejects_credentials():
|
|
28
|
+
with pytest.raises(ValueError):
|
|
29
|
+
validate_url("https://user:pass@example.com")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_url_rejects_local_by_default():
|
|
33
|
+
with pytest.raises(ValueError):
|
|
34
|
+
validate_url("http://127.0.0.1")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_allow_private():
|
|
38
|
+
validate_url("http://127.0.0.1", allow_private=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_redirect_location_is_not_reported_verbatim(monkeypatch):
|
|
42
|
+
secret_location = "https://example.com/next?token=secret"
|
|
43
|
+
transport = httpx.MockTransport(
|
|
44
|
+
lambda request: httpx.Response(302, headers={"location": secret_location}, request=request)
|
|
45
|
+
)
|
|
46
|
+
real_client = httpx.Client
|
|
47
|
+
|
|
48
|
+
def mock_client(*args, **kwargs):
|
|
49
|
+
kwargs["transport"] = transport
|
|
50
|
+
return real_client(*args, **kwargs)
|
|
51
|
+
|
|
52
|
+
monkeypatch.setattr(cli, "validate_url", lambda *_args, **_kwargs: None)
|
|
53
|
+
monkeypatch.setattr(cli.httpx, "Client", mock_client)
|
|
54
|
+
result = cli.fetch("https://example.com", timeout=1, allow_private=False)
|
|
55
|
+
assert result["redirect_location_present"] is True
|
|
56
|
+
assert secret_location not in str(result)
|