Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Install package and test dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -e ".[web]"
pip install pytest
- name: Run tests
run: pytest -q
Expand All @@ -34,6 +34,7 @@ jobs:
domain-analyzer --version
domain-analyzer --help
python -m domain_security_analyzer --version
domain-analyzer-web --help

build:
name: build & metadata check
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **SRI library API**: Subresource Integrity analysis is now importable from the
installed package (`from domain_security_analyzer.sri import scan_url`),
returning a structured report with per-resource integrity/crossorigin values,
machine-readable reason codes, observed CSP headers, and compensating-control
detection. `scripts/sri_parser.py` is now a thin CLI wrapper over it.
- **Local web UI** (optional `web` extra): `pip install domain-security-analyzer[web]`
adds a `domain-analyzer-web` command that serves a localhost Flask app to
upload domain lists, watch analysis progress, download the standard 29-column
CSV, and diff the two most recent runs (highlighting security regressions vs.
improvements). Runs are stored under `~/.domain-security-analyzer/runs/`
(override via `DSA_DATA_DIR`).

### Changed

- `analyzer.py` exposes a reusable `write_results_csv()` helper, a shared
`CSV_COLUMNS` constant, and an optional `progress_callback` on
`analyze_domains_from_file` (used by the web UI). Backward compatible.

## [1.0.0] - 2026-06-21

First packaged release, distributable via PyPI.
Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ include requirements.txt
recursive-include docs *.md
recursive-include examples *
include scripts/*.py
recursive-include domain_security_analyzer/web/templates *.html
recursive-include domain_security_analyzer/web/static *.css
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ python -m domain_security_analyzer examples/domains.txt report.csv
python domain_analyzer.py examples/domains.txt report.csv # legacy shim
```

### Web UI (local app)

Prefer a browser? Install the optional `web` extra and launch a small local app
to upload domains, watch progress, download the report, and compare runs — no
command line needed after launch:

```bash
pip install "domain-security-analyzer[web]"
domain-analyzer-web --open # serves http://127.0.0.1:8000 and opens it
```

The app lets you:

- **Upload** domains by pasting them (one per line, `#` comments ignored) or
dropping a `.txt` file.
- **Watch progress** as the analysis runs in the background.
- **Download** the standard 29-column CSV for any run.
- **View changes** — each run is saved locally and the *Changes* page diffs the
two most recent runs, highlighting security **regressions** (e.g. SPF/DMARC
lost, SRI coverage dropped) and **improvements**, plus domains added/removed.

It binds to `127.0.0.1` only and stores runs under
`~/.domain-security-analyzer/runs/` (override with the `DSA_DATA_DIR`
environment variable). This is a single-user local tool — it is not meant to be
exposed to a network.

The generated CSV includes comprehensive security analysis with **29 columns**:

### **Domain & Infrastructure**
Expand Down
167 changes: 96 additions & 71 deletions domain_security_analyzer/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import concurrent.futures
import csv
from datetime import datetime
from typing import Dict, List, Optional
from typing import Callable, Dict, List, Optional
from urllib.parse import urlparse

import dns.resolver
Expand Down Expand Up @@ -401,8 +401,95 @@ def analyze_domain(self, domain: str) -> Dict:
}


def analyze_domains_from_file(input_file: str, output_file: str, max_workers: int = 10, *, include_wildcard_matches: bool = False, filtered_subdomains_file: Optional[str] = None):
"""Analyze multiple domains from a file and save results to CSV."""
# Column order for the analysis report CSV. Kept as a module-level constant so
# consumers (CLI, web UI, diff tooling) share one source of truth.
CSV_COLUMNS = [
'Domain',
'Timestamp',
'Parent Domain',
'SOA Exists',
'SOA Record',
'Primary NS',
'Admin Email',
'SPF Exists',
'SPF Record',
'DKIM Exists',
'DKIM Records',
'DMARC Exists',
'DMARC Record',
'Discovered Subdomains',
'CNAME Records',
'Has Wildcard DNS',
'Hosting Provider',
'HTTP Accessible',
'Redirects to HTTPS',
'Final URL',
'Redirect Chain',
'HTTP Error',
'SRI Enabled',
'Total External Resources',
'Resources With SRI',
'SRI Coverage %',
'Missing SRI Count',
'SRI Algorithms Used',
'SRI Error',
]


def _result_to_row(r: Dict) -> list:
"""Flatten one analysis result dict into a CSV row matching CSV_COLUMNS."""
return [
r['domain'],
r['timestamp'],
r['soa']['parent_domain'],
r['soa']['exists'],
r['soa'].get('record'),
r['soa'].get('primary_ns'),
r['soa'].get('admin_email'),
r['spf']['exists'],
r['spf'].get('record'),
r['dkim']['exists'],
';'.join([f"{rec['selector']}:{rec['record']}" for rec in r['dkim']['records']]) if r['dkim']['records'] else '',
r['dmarc']['exists'],
r['dmarc'].get('record'),
','.join(r['subdomains']['subdomains']),
','.join([f"{k}:{v}" for k, v in r['subdomains']['cname_records'].items()]),
r['subdomains']['has_wildcard_dns'],
r['subdomains']['hosting_provider'],
r['http_redirect']['http_accessible'],
r['http_redirect']['redirects_to_https'],
r['http_redirect']['final_url'],
' -> '.join(r['http_redirect'].get('redirect_chain', [])),
r['http_redirect']['error'],
r['sri']['sri_enabled'],
r['sri']['total_external_resources'],
r['sri']['resources_with_sri'],
r['sri']['sri_coverage_percentage'],
r['sri']['missing_sri_count'],
','.join(r['sri']['sri_algorithms_used']) if r['sri']['sri_algorithms_used'] else '',
r['sri']['error'],
]


def write_results_csv(results: List[Dict], output_file: str) -> None:
"""Write analysis result dicts to ``output_file`` as the standard report CSV.

Shared by the CLI and the web UI so the 29-column layout has a single
definition.
"""
with open(output_file, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(CSV_COLUMNS)
for r in results:
writer.writerow(_result_to_row(r))


def analyze_domains_from_file(input_file: str, output_file: str, max_workers: int = 10, *, include_wildcard_matches: bool = False, filtered_subdomains_file: Optional[str] = None, progress_callback: Optional[Callable[[int, int], None]] = None):
"""Analyze multiple domains from a file and save results to CSV.

``progress_callback``, if given, is invoked as ``callback(completed, total)``
after each domain finishes — used by the web UI to drive a progress bar.
"""

# Read domains from input file
with open(input_file, 'r') as f:
Expand Down Expand Up @@ -434,6 +521,11 @@ def analyze_single_domain(domain: str) -> Dict:

completed += 1
print(f"Progress: {completed}/{total_domains} domains analyzed ({(completed/total_domains)*100:.1f}%)")
if progress_callback is not None:
try:
progress_callback(completed, total_domains)
except Exception:
pass # progress reporting must never break analysis
return result

results = []
Expand Down Expand Up @@ -466,74 +558,7 @@ def analyze_single_domain(domain: str) -> Dict:
results.append(error_result)

# Write results to CSV
with open(output_file, 'w', newline='') as f:
writer = csv.writer(f)
# Write header
writer.writerow([
'Domain',
'Timestamp',
'Parent Domain',
'SOA Exists',
'SOA Record',
'Primary NS',
'Admin Email',
'SPF Exists',
'SPF Record',
'DKIM Exists',
'DKIM Records',
'DMARC Exists',
'DMARC Record',
'Discovered Subdomains',
'CNAME Records',
'Has Wildcard DNS',
'Hosting Provider',
'HTTP Accessible',
'Redirects to HTTPS',
'Final URL',
'Redirect Chain',
'HTTP Error',
'SRI Enabled',
'Total External Resources',
'Resources With SRI',
'SRI Coverage %',
'Missing SRI Count',
'SRI Algorithms Used',
'SRI Error'
])

# Write results
for r in results:
writer.writerow([
r['domain'],
r['timestamp'],
r['soa']['parent_domain'],
r['soa']['exists'],
r['soa'].get('record'),
r['soa'].get('primary_ns'),
r['soa'].get('admin_email'),
r['spf']['exists'],
r['spf'].get('record'),
r['dkim']['exists'],
';'.join([f"{rec['selector']}:{rec['record']}" for rec in r['dkim']['records']]) if r['dkim']['records'] else '',
r['dmarc']['exists'],
r['dmarc'].get('record'),
','.join(r['subdomains']['subdomains']),
','.join([f"{k}:{v}" for k, v in r['subdomains']['cname_records'].items()]),
r['subdomains']['has_wildcard_dns'],
r['subdomains']['hosting_provider'],
r['http_redirect']['http_accessible'],
r['http_redirect']['redirects_to_https'],
r['http_redirect']['final_url'],
' -> '.join(r['http_redirect'].get('redirect_chain', [])),
r['http_redirect']['error'],
r['sri']['sri_enabled'],
r['sri']['total_external_resources'],
r['sri']['resources_with_sri'],
r['sri']['sri_coverage_percentage'],
r['sri']['missing_sri_count'],
','.join(r['sri']['sri_algorithms_used']) if r['sri']['sri_algorithms_used'] else '',
r['sri']['error']
])
write_results_csv(results, output_file)

# Optionally write filtered subdomains to a separate CSV
if filtered_subdomains_file:
Expand Down
17 changes: 17 additions & 0 deletions domain_security_analyzer/web/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Optional local web UI for Domain Security Analyzer.

Install with the ``web`` extra::

pip install domain-security-analyzer[web]

then launch::

domain-analyzer-web

This subpackage imports Flask, which is only present when the ``web`` extra is
installed; importing it without Flask raises a clear ImportError.
"""

from .app import create_app

__all__ = ["create_app"]
5 changes: 5 additions & 0 deletions domain_security_analyzer/web/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Enable ``python -m domain_security_analyzer.web``."""
from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading