Mixed Content Audit Claude Skill
This skill audits an HTTPS website for mixed content and insecure resources, and produces an actionable report. It crawls pages and inspects every subresource to find active mixed content (http scripts, stylesheets, iframes, and objects browsers block on https pages), insecure form actions that submit over http, passive mixed content (http images, audio, and video), legacy protocol-relative (//) resource URLs, and pages still served over http. Mixed content breaks pages and strips the padlock; the skill finds every instance and groups it by root cause.
What The Skill Checks
- Active mixed content: http scripts, stylesheets, iframes, and objects that browsers block on https pages, plus insecure form actions that submit over http.
- Passive mixed content: http images, audio, and video that strip the padlock and trigger the browser "Not secure" warning.
- Migration gaps: legacy protocol-relative (//) resource URLs and pages still served over http.
How The Skill Is Packaged
The skill follows the standard Claude Agent Skill structure: a SKILL.md file with YAML frontmatter and workflow instructions, a references/ folder with the full audit check definitions and report template, and a scripts/ folder with Python scripts that crawl the site and extract every subresource URL, then audit each one for insecure http and protocol-relative references. Every report can be exported to a shareable Word .docx or a flat .csv of findings via the bundled md_to_docx.py and findings_to_csv.py scripts. Install it with npx claude-seo-skills install mixed-content-audit, or copy the skill folder into your Claude skills directory; Claude invokes it automatically when a request matches its description.
Skill Files
Every file in the skill is shown below, copied from the Claude-SEO-Skills repository, so you can review exactly what the skill instructs Claude to do before installing it.
SKILL.md
The skill definition: frontmatter, inputs to collect, the workflow, and the resources the skill relies on.
Mixed Content / HTTPS Audit
Audit an HTTPS site for insecure (http://) resources and produce an actionable
report. Mixed content breaks pages and strips the padlock; this skill finds every
instance and groups it by root cause.
When to use this skill
Use this skill when the user asks to:
- Audit or fix mixed content / the browser "Not secure" warning
- Find
http://scripts, styles, iframes, images, or media onhttps://pages - Verify an HTTPS migration is complete
- Find insecure form actions
Inputs to collect
- Site URL or URL list — a live
https://site root to crawl (auto-seeds from/sitemap.xml), or a text file with--url-list. - Scope — pages to crawl (default 500,
--max-pages).
Workflow
Step 1: Crawl and inventory subresources
Use scripts/extract_resources.py:
python3 scripts/extract_resources.py https://example.com --max-pages 500 --output resource_inventory.json
# already crawled once (e.g. in a full SEO audit)? skip the crawl and reuse the shared cache:
# python3 scripts/fetch_pages.py https://example.com --output page_cache.json
# python3 scripts/extract_resources.py --from-cache page_cache.json --output resource_inventory.json
For every page it records each subresource (scripts, stylesheets, images, iframes, media, embeds, form actions) with its tag, attribute, resolved URL, and scheme.
Step 2: Run the audit checks
python3 scripts/audit_resources.py resource_inventory.json --output audit_report.json
Step 3: Evaluate the audit checks
Evaluate each check in references/audit-checks.md. Core checks:
| Check | Severity |
|---|---|
| Active mixed content (script/style/iframe/object over http) | High |
| Insecure form action (http) | High |
| Passive mixed content (img/audio/video over http) | Medium |
Protocol-relative (//) resource | Low |
| Page served over http | Info |
Step 4: Produce the report
Write a report following references/report-template.md: summary by severity,
active mixed content and insecure forms first, then passive, grouped by the host
serving the insecure resource, and a prioritized action list.
Step 5: Recommend fixes
- Active/passive mixed content: load the resource over
https://(most hosts already support it — just change the scheme), self-host it, or remove it. - Insecure form action: change the
actiontohttps://. - Protocol-relative URLs: make them explicit
https://. - Insecure pages: fix the site-wide HTTPS redirect at the source (cross-check
the
redirect-auditskill). - Group fixes by host — one insecure CDN usually explains many rows.
- Base every recommendation on the crawled inventory; never invent URLs.
Optional: export the report
As a Word document (.docx)
If the user wants the report as a .docx (for example, to share with stakeholders or attach to a ticket), save the Markdown report to a file and convert it:
python3 scripts/md_to_docx.py report.md --output report.docx
scripts/md_to_docx.py uses only the Python standard library (no pip install) and renders headings, tables, lists, links, bold/italic, and code blocks. Offer this whenever a user asks for a Word doc, a .docx, or a shareable/downloadable report.
As a CSV of findings (.csv)
If the user wants the raw findings as a spreadsheet (for filtering, sorting, or triage in Sheets or Excel), convert the audit's audit_report.json directly:
python3 scripts/findings_to_csv.py audit_report.json --output findings.csv
scripts/findings_to_csv.py is also standard-library only. It writes one row per finding, with check and severity columns prepended and list fields (e.g. the pages sharing a duplicate value) joined with ; . Unlike the .docx, which reformats the written report, the CSV is a direct dump of the structured findings — offer it whenever a user wants the data itself, a spreadsheet, or to slice findings by check or severity.
Resources
scripts/md_to_docx.py— convert the Markdown report into a Word (.docx) document (standard library only)scripts/findings_to_csv.py— flatten the audit findings JSON into a CSV, one row per finding (standard library only)references/audit-checks.md— full definitions and rationalereferences/report-template.md— report output structurescripts/extract_resources.py— crawl and inventory subresourcesscripts/audit_resources.py— run mixed-content audit checks
Audit Checks Reference (references/audit-checks.md)
Full definitions, severities, and rationale for every check across the high, medium, and low tiers.
Mixed Content / HTTPS Audit Checks
Definitions and rationale. "Mixed content" is an insecure (http://) resource
loaded by a secure (https://) page. Browsers block or warn on it, which breaks
layout and functionality and undermines the padlock — a trust and (indirectly)
ranking signal.
High severity
1. Active mixed content
- Definition: An
https://page loads a<script src>,<link rel=stylesheet>,<iframe>,<object>, or<embed>overhttp://. - Why it matters: Browsers block insecure active content outright. The script doesn't run, the stylesheet doesn't apply, the iframe doesn't load — the page is visibly broken, and the connection is no longer trustworthy.
- Fix: Load the resource over
https://(most hosts support it — just change the scheme), self-host it, or remove it.
2. Insecure form action
- Definition: A
<form>on anhttps://page submits to anhttp://action URL. - Why it matters: Form data (including credentials) is sent in cleartext; browsers show a prominent "Not secure" warning on submit.
- Fix: Change the form
actiontohttps://.
Medium severity
3. Passive mixed content
- Definition: An
https://page loads an<img>,<audio>,<video>,<source>, or<track>overhttp://. - Why it matters: Browsers may load it but downgrade the security indicator (no padlock) and can block it under stricter settings. It still signals an incomplete HTTPS migration.
- Fix: Serve the media over
https://.
Low severity
4. Protocol-relative resource
- Definition: A resource URL beginning with
//(inheriting the page's scheme), e.g.//cdn.example.com/app.js. - Why it matters: Harmless on an HTTPS page today, but a legacy pattern that breaks if the page is ever viewed over HTTP and obscures intent.
- Fix: Use explicit
https://URLs.
Informational (note, not counted as a mixed-content issue)
- Insecure page — the page itself was served over
http://(or didn't redirect to HTTPS). This isn't "mixed" content (there's nothing secure to mix), but it's the bigger problem: the whole page is insecure. Reported so the HTTPS migration / redirect can be fixed at the source. Cross-check with theredirect-auditandsite-architecture-auditskills.
Report Template (references/report-template.md)
The output structure the skill follows, including the sections issues are grouped into.
Mixed Content / HTTPS Audit Report Template
Use this structure for the final report delivered to the user.
# Mixed Content / HTTPS Audit — {site}
**Date:** {date}
**Scope:** {pages crawled}
**Pages analyzed:** {n} | **Pages with mixed content:** {n}
## Summary
| Severity | Issues |
|---|---|
| High | {n} |
| Medium | {n} |
| Low | {n} |
{2–3 sentences: is the HTTPS migration complete? Name the dominant source of
insecure resources (often one CDN/host or one template).}
## High-severity issues
### Active mixed content ({n})
Blocked by browsers — these break the page.
| Page | Tag | Insecure resource | Fix |
|---|---|---|---|
| ... | script/link/iframe | http://... | Load over https:// |
### Insecure form actions ({n})
| Page | Form action | Fix |
|---|---|---|
| ... | http://... | Change action to https:// |
## Medium-severity issues
### Passive mixed content ({n})
| Page | Tag | Insecure resource |
|---|---|---|
| ... | img/video/audio | http://... |
## Low-severity issues
{Protocol-relative resources, as a brief list grouped by host.}
## Insecure pages to fix at the source ({n})
| Page served over http | Note |
|---|---|
| ... | redirect to https / fix HSTS |
## Prioritized action list
1. {Highest-impact fix — usually "switch host/CDN X to https", which clears many
rows at once. Name the host and the affected pages.}
2. ...
Guidance
- Group insecure resources by host — one CDN or analytics provider on
httpusually explains most rows and is a single fix. - Lead with active mixed content and insecure forms; those break functionality.
- Every row names the exact page, the tag/attribute, and the insecure URL; the fix is almost always "use https://".
- If many pages are served over
http, the real fix is the site-wide HTTPS redirect — note it and point to theredirect-auditskill. - Base everything on the crawled resource inventory; never invent URLs.
Resource Extractor Script (scripts/extract_resources.py)
Crawls pages and inventories every subresource: scripts, styles, iframes, images, media, and form actions.
#!/usr/bin/env python3
"""Crawl an HTTPS site and inventory every subresource and its protocol.
Usage:
python3 extract_resources.py https://example.com [--max-pages 500] [--output resource_inventory.json]
python3 extract_resources.py urls.txt --url-list [--output resource_inventory.json]
For each crawled page it records the page URL/status and every subresource it
loads or links to — scripts, stylesheets, images, iframes, media, embeds, and
form actions — with the tag, attribute, and the (resolved) URL's scheme. The
auditor uses this to find mixed content (http resources on an https page).
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import deque
from html.parser import HTMLParser
USER_AGENT = "MixedContentAuditBot/1.0 (+claude-skill)"
TIMEOUT = 15
# tag -> attribute that holds a subresource URL, and whether it is "active"
# (executed/applied; browsers block insecure active content) or "passive".
RESOURCE_ATTRS = {
"script": ("src", "active"),
"link": ("href", "active"), # stylesheets / preloads (rel-checked below)
"iframe": ("src", "active"),
"embed": ("src", "active"),
"object": ("data", "active"),
"img": ("src", "passive"),
"audio": ("src", "passive"),
"video": ("src", "passive"),
"source": ("src", "passive"),
"track": ("src", "passive"),
"form": ("action", "form"),
}
class ResourceParser(HTMLParser):
def __init__(self, base_url):
super().__init__(convert_charrefs=True)
self.base_url = base_url
self.resources = [] # {tag, attr, url, scheme, kind}
self.links = []
def _record(self, tag, attr, value, kind):
if not value:
return
resolved = urllib.parse.urljoin(self.base_url, value.strip())
scheme = urllib.parse.urlparse(resolved).scheme
protocol_relative = value.strip().startswith("//")
self.resources.append({
"tag": tag, "attr": attr, "url": resolved, "scheme": scheme,
"kind": kind, "protocol_relative": protocol_relative})
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag in RESOURCE_ATTRS:
attr, kind = RESOURCE_ATTRS[tag]
if tag == "link":
rel = (a.get("rel") or "").lower()
# Only stylesheets/imports are active; icons/manifests are passive-ish.
kind = "active" if "stylesheet" in rel else "passive"
if not any(r in rel for r in ("stylesheet", "preload", "icon",
"manifest", "prefetch", "preconnect")):
return
self._record(tag, attr, a.get(attr), kind)
if tag == "a" and a.get("href"):
href = a["href"].strip()
if not href.startswith(("mailto:", "tel:", "javascript:", "#")):
self.links.append(
urllib.parse.urldefrag(urllib.parse.urljoin(self.base_url, href))[0])
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
ctype = resp.headers.get("Content-Type", "")
body = resp.read(2_000_000).decode("utf-8", errors="replace") \
if "html" in ctype else None
return resp.status, resp.geturl(), body
except urllib.error.HTTPError as e:
return e.code, url, None
except Exception:
return 0, url, None
def norm_host(url):
h = urllib.parse.urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
def pages_from_sitemap(site):
status, _f, body = fetch(urllib.parse.urljoin(site, "/sitemap.xml"))
if status != 200 or not body:
return []
try:
root = ET.fromstring(body)
return [loc.text.strip() for loc in
root.iter("{http://www.sitemaps.org/schemas/sitemap/0.9}loc") if loc.text]
except ET.ParseError:
return []
def extract_from_cache(cache_path):
"""Pure: turn a shared page cache (from fetch_pages.py) into a resource inventory.
No network — parses the cached HTML for the subresource references this audit needs.
"""
with open(cache_path, encoding="utf-8") as f:
cache = json.load(f)
pages = {}
for url, page in cache.get("pages", {}).items():
html = page.get("html")
status = page.get("status", 0)
final_url = page.get("final_url") or url
if not html:
pages[url] = {"status": status, "final_url": final_url, "resources": []}
continue
parser = ResourceParser(final_url)
try:
parser.feed(html)
except Exception:
pass
pages[url] = {"status": status, "final_url": final_url,
"resources": parser.resources}
return cache.get("site", cache_path), pages
def collect(urls, max_pages, crawl_host=None):
pages, queue, seen = {}, deque(urls), set(urls)
while queue and len(pages) < max_pages:
url = queue.popleft()
status, final_url, html = fetch(url)
print(f"[{status}] {url}", file=sys.stderr)
if html is None:
pages[url] = {"status": status, "final_url": final_url, "resources": []}
continue
parser = ResourceParser(final_url)
try:
parser.feed(html)
except Exception:
pass
pages[url] = {"status": status, "final_url": final_url,
"resources": parser.resources}
if crawl_host:
for link in parser.links:
if norm_host(link) == crawl_host and link not in seen:
seen.add(link)
queue.append(link)
return pages
def main():
ap = argparse.ArgumentParser(description="Inventory subresources and their protocols.")
ap.add_argument("source", nargs="?")
ap.add_argument("--from-cache",
help="Extract from a shared page cache produced by fetch_pages.py (no network)")
ap.add_argument("--max-pages", type=int, default=500)
ap.add_argument("--output", default="resource_inventory.json")
ap.add_argument("--url-list", action="store_true")
args = ap.parse_args()
if args.from_cache:
site, pages = extract_from_cache(args.from_cache)
elif not args.source:
ap.error("provide a source, or --from-cache page_cache.json")
elif args.url_list:
with open(args.source, encoding="utf-8") as f:
urls = [l.strip() for l in f if l.strip() and not l.startswith("#")]
site, pages = args.source, collect(urls[: args.max_pages], args.max_pages)
else:
start = args.source.rstrip("/")
seeds = pages_from_sitemap(start) or [start]
site, pages = args.source, collect(seeds[: args.max_pages], args.max_pages,
crawl_host=norm_host(start))
total = sum(len(p["resources"]) for p in pages.values())
with open(args.output, "w", encoding="utf-8") as f:
json.dump({"site": site, "pages": pages}, f, indent=2, ensure_ascii=False)
print(f"Wrote {len(pages)} pages, {total} resource references to {args.output}",
file=sys.stderr)
if __name__ == "__main__":
main()
Resource Auditor Script (scripts/audit_resources.py)
Audits each subresource for insecure http and protocol-relative URLs, then outputs the structured report data grouped by root cause.
#!/usr/bin/env python3
"""Audit a resource inventory (from extract_resources.py) for mixed content.
Usage:
python3 audit_resources.py resource_inventory.json [--output audit_report.json]
Checks (see references/audit-checks.md):
- active_mixed_content (high): http script/stylesheet/iframe/object on an https page
- insecure_form_action (high): a form on an https page POSTs/GETs to http://
- passive_mixed_content (medium): http img/audio/video/source on an https page
- protocol_relative_resource (low): // resource URLs (legacy; should be https://)
- insecure_page (info): the page itself was served over http://
Only pages served over https are checked for mixed content (http pages have no
"mixed" content — they are wholly insecure, reported separately as insecure_page).
Uses only the Python standard library.
"""
import argparse
import json
import sys
import urllib.parse
def main():
ap = argparse.ArgumentParser(description="Run mixed-content audit checks.")
ap.add_argument("inventory")
ap.add_argument("--output", default="audit_report.json")
args = ap.parse_args()
with open(args.inventory, encoding="utf-8") as f:
data = json.load(f)
pages = data.get("pages", {})
findings = {k: [] for k in (
"active_mixed_content", "insecure_form_action", "passive_mixed_content",
"protocol_relative_resource", "insecure_page")}
for url, page in pages.items():
if page.get("status", 0) == 0:
continue
final_url = page.get("final_url", url)
page_https = final_url.startswith("https://")
if not page_https and final_url.startswith("http://"):
findings["insecure_page"].append({"page": url})
# http pages aren't "mixed"; skip per-resource mixed checks
continue
for r in page.get("resources", []):
scheme = r.get("scheme")
entry = {"page": url, "tag": r["tag"], "attr": r["attr"], "resource": r["url"]}
if r.get("protocol_relative"):
findings["protocol_relative_resource"].append(entry)
continue
if scheme != "http":
continue
if r["kind"] == "form":
findings["insecure_form_action"].append(entry)
elif r["kind"] == "active":
findings["active_mixed_content"].append(entry)
else:
findings["passive_mixed_content"].append(entry)
severity = {
"active_mixed_content": "high", "insecure_form_action": "high",
"passive_mixed_content": "medium",
"protocol_relative_resource": "low", "insecure_page": "info",
}
pages_with_mixed = len({f["page"] for k in
("active_mixed_content", "passive_mixed_content",
"insecure_form_action") for f in findings[k]})
summary = {
"pages_analyzed": sum(1 for p in pages.values() if p.get("status", 0) != 0),
"pages_with_mixed_content": pages_with_mixed,
"issues_by_check": {k: len(v) for k, v in findings.items()},
"issues_by_severity": {
level: sum(len(findings[k]) for k, s in severity.items() if s == level)
for level in ("high", "medium", "low", "info")},
}
report = {"summary": summary, "severity": severity, "findings": findings}
with open(args.output, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(json.dumps(summary, indent=2))
print(f"Full report written to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()