Skip to content

Add report summary and HTML web-UI output#6

Merged
noderaven merged 4 commits into
mainfrom
feature/report-and-web-ui
Jun 25, 2026
Merged

Add report summary and HTML web-UI output#6
noderaven merged 4 commits into
mainfrom
feature/report-and-web-ui

Conversation

@noderaven

Copy link
Copy Markdown
Owner

Stage 3 (final) of the multi-source integration: the report summary and the sortable HTML web UI, stdlib-only.

Summary

  • output.py now provides two more formats: report (text summary with totals + per-notice detail) and html (a self-contained, sortable dashboard with accessible sort buttons; no external assets).
  • Wired report and html into --output.
  • Scrubbed the three non-ASCII sort-arrow glyphs: HTML uses entities (↕ etc.), JS uses String.fromCharCode(...) -- so both the source and the generated HTML are pure ASCII.
  • Re-applied the CSV lineterminator="\n" fix and switched output.py to collections.abc imports; added a per-file E501 ignore for the embedded web-UI template.
  • Refreshed the README for the multi-source CLI and all five output formats.

Test Plan

  • CI green on 3.10-3.13: ruff, ruff format, mypy strict, bandit, unittest (38 tests)
  • breach-scraper --source wa_atg --input-html tests/fixtures/wa_atg_sample.html --output html --start-date 2024-01-01 --end-date 2024-12-31 renders a sortable table; output is ASCII
  • --output report prints a summary with totals

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces new data sources and expands output formatting options for the breach scraper, adding interactive HTML dashboards and text reports, along with corresponding CLI options, documentation, and unit tests. The review feedback highlights a correctness bug in _html_affected_count where a count of 0 is incorrectly treated as falsy, potential XSS vulnerabilities in HTML link generation due to unvalidated URL schemes, and an opportunity to reduce code duplication by reusing the affected count helper in the text report generator.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread breach_scraper/output.py Outdated
Comment on lines +277 to +284
def _html_affected_count(row: Row) -> str:
return str(
row.get("persons_affected", "")
or row.get("total_persons_affected", "")
or row.get("number_affected", "")
or row.get("individuals_affected", "")
or ""
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of _html_affected_count uses an or chain to find the first non-empty value. However, in Python, the integer 0 is falsy. If any of these fields contain the integer 0 (which is a valid count of affected individuals), the or chain will skip it and evaluate the subsequent fields, or default to "". This can lead to incorrect data representation and statistics.

Using a loop to check for explicit non-None and non-empty string values resolves this correctness issue.

def _html_affected_count(row: Row) -> str:
    for key in ("persons_affected", "total_persons_affected", "number_affected", "individuals_affected"):
        val = row.get(key)
        if val is not None and val != "":
            return str(val)
    return ""

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _html_affected_count now iterates the candidate keys and returns the first value that is not None and not empty, so a 0 count is preserved. Covered by test_zero_is_not_skipped.

Comment thread breach_scraper/output.py
Comment on lines +218 to +226
def _html_notice_link(row: Row) -> str:
notice_url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "")
if not notice_url:
return ""
return (
f'<a href="{html.escape(notice_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
"View notice"
"</a>"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The notice_url is retrieved from scraped data and rendered directly into an href attribute. If a scraped source contains a malicious URL (such as a javascript: URI), this can lead to a Cross-Site Scripting (XSS) vulnerability when a user clicks the link in the generated HTML report.

To mitigate this, we should validate that the URL uses a safe scheme (e.g., http or https) using urlparse before rendering it as a link.

def _is_safe_url(url: str) -> bool:
    try:
        parsed = urlparse(url)
        return parsed.scheme in ("http", "https")
    except Exception:
        return False


def _html_notice_link(row: Row) -> str:
    notice_url = str(row.get("notice_url", "") or row.get("organization_name_url", "") or "").strip()
    if not _is_safe_url(notice_url):
        return ""
    return (
        f'<a href="{html.escape(notice_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
        "View notice"
        "</a>"
    )

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added _is_safe_url (http/https only, via urlparse) and a _safe_notice_url helper; _html_notice_link renders no link for unsafe schemes, and the data-sort-notice attribute now uses the sanitized value too. Verified that a javascript: notice_url produces no and that 'javascript:' no longer appears anywhere in the HTML output. Covered by test_notice_link_rejects_javascript_scheme.

Comment thread breach_scraper/output.py
Comment on lines +241 to +251
def _html_organization_link(row: Row) -> str:
organization_name = str(row.get("organization_name", "") or "Unknown organization")
organization_url = str(row.get("organization_name_url", "") or "")
safe_name = html.escape(organization_name)
if not organization_url:
return safe_name
return (
f'<a href="{html.escape(organization_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
f"{safe_name}"
"</a>"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Similar to _html_notice_link, the organization_url is retrieved from scraped data and rendered directly into an href attribute. To prevent potential Cross-Site Scripting (XSS) attacks via malicious javascript: URIs, we should validate the URL scheme using the _is_safe_url helper.

Suggested change
def _html_organization_link(row: Row) -> str:
organization_name = str(row.get("organization_name", "") or "Unknown organization")
organization_url = str(row.get("organization_name_url", "") or "")
safe_name = html.escape(organization_name)
if not organization_url:
return safe_name
return (
f'<a href="{html.escape(organization_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
f"{safe_name}"
"</a>"
)
def _html_organization_link(row: Row) -> str:
organization_name = str(row.get("organization_name", "") or "Unknown organization")
organization_url = str(row.get("organization_name_url", "") or "").strip()
safe_name = html.escape(organization_name)
if not _is_safe_url(organization_url):
return safe_name
return (
f'<a href="{html.escape(organization_url, quote=True)}" target="_blank" rel="noopener noreferrer">'
f"{safe_name}"
"</a>"
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _html_organization_link now uses _is_safe_url and falls back to the escaped name for unsafe schemes. Covered by test_organization_link_rejects_javascript_scheme.

Comment thread breach_scraper/output.py Outdated
Comment on lines +966 to +969
lines.append(
"- Persons affected: "
f"{row.get('persons_affected', '') or row.get('total_persons_affected', '') or row.get('number_affected', '') or row.get('individuals_affected', '') or 'Unknown'}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of duplicating the long or chain to extract the affected count (which also suffers from the falsy 0 bug), we should reuse the _html_affected_count helper function. This improves maintainability and ensures correctness.

        affected = _html_affected_count(row) or "Unknown"
        lines.append(f"- Persons affected: {affected}")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. to_report now reuses _html_affected_count for the persons-affected line, removing the duplicated or-chain (and its falsy-0 bug).

@noderaven
noderaven merged commit 3a2b121 into main Jun 25, 2026
4 checks passed
@noderaven
noderaven deleted the feature/report-and-web-ui branch June 25, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant