2026-01-02 09:59:52 +11:00
from __future__ import annotations
import argparse
import asyncio
import base64
import hashlib
import json
import re
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from urllib . parse import urljoin , urldefrag , urlparse
2026-02-04 12:52:53 +11:00
from playwright . async_api import (
async_playwright ,
TimeoutError as PlaywrightTimeoutError ,
)
2026-01-02 09:59:52 +11:00
from . ensure_playwright import ensure_chromium_installed
RESOURCE_TO_DIRECTIVE = {
" script " : " script-src " ,
" stylesheet " : " style-src " ,
" image " : " img-src " ,
" font " : " font-src " ,
" media " : " media-src " ,
" xhr " : " connect-src " ,
" fetch " : " connect-src " ,
" websocket " : " connect-src " ,
" eventsource " : " connect-src " ,
}
BASELINE_DIRECTIVES = {
" default-src " : { " ' self ' " } ,
" base-uri " : { " ' self ' " } ,
" object-src " : { " ' none ' " } ,
" frame-ancestors " : { " ' self ' " } ,
" form-action " : { " ' self ' " } ,
}
def origin_of ( url : str ) - > str :
p = urlparse ( url )
if not p . scheme or not p . netloc :
return " "
return f " { p . scheme } :// { p . netloc } "
def sha256_base64 ( s : str ) - > str :
h = hashlib . sha256 ( s . encode ( " utf-8 " ) ) . digest ( )
return base64 . b64encode ( h ) . decode ( " ascii " )
2026-01-02 14:09:56 +11:00
def normalize_csp_string ( csp : str ) - > str :
s = ( csp or " " ) . strip ( )
if not s :
return s
return s if s . endswith ( " ; " ) else s + " ; "
2026-01-02 09:59:52 +11:00
async def collect_inline ( page , * , max_attr_hashes : int = 2000 ) :
"""
Collect inline < script > ( no src ) , < style > blocks , plus :
- style = " ... " attributes ( CSP3 style - src - attr / unsafe - hashes )
- inline event handler attributes ( onclick = " ... " , onload = " ... " , etc ) ( CSP3 script - src - attr / unsafe - hashes )
IMPORTANT : Hashes must be computed over the EXACT string bytes . Do NOT strip .
"""
data = await page . evaluate (
""" (maxAttr) => {
const inlineScripts = [ . . . document . querySelectorAll ( ' script:not([src]) ' ) ]
. map ( s = > ( {
nonce : s . nonce | | s . getAttribute ( ' nonce ' ) | | null ,
text : s . textContent ? ? ' '
} ) ) ;
const inlineStyles = [ . . . document . querySelectorAll ( ' style ' ) ]
. map ( st = > ( {
nonce : st . nonce | | st . getAttribute ( ' nonce ' ) | | null ,
text : st . textContent ? ? ' '
} ) ) ;
const styleAttrs = [ ] ;
const handlerAttrs = [ ] ;
/ / style = " ... "
for ( const el of document . querySelectorAll ( ' [style] ' ) ) {
if ( styleAttrs . length > = maxAttr ) break ;
const v = el . getAttribute ( ' style ' ) ;
if ( v != = null ) styleAttrs . push ( v ) ;
}
/ / inline event handlers : on *
/ / Iterate elements and look for attributes starting with " on "
const all = document . querySelectorAll ( ' * ' ) ;
for ( let i = 0 ; i < all . length ; i + + ) {
if ( handlerAttrs . length > = maxAttr ) break ;
const el = all [ i ] ;
const names = el . getAttributeNames ? el . getAttributeNames ( ) : [ ] ;
for ( const name of names ) {
if ( handlerAttrs . length > = maxAttr ) break ;
if ( name & & name . toLowerCase ( ) . startsWith ( ' on ' ) ) {
const v = el . getAttribute ( name ) ;
if ( v != = null ) handlerAttrs . push ( v ) ;
}
}
}
const dataImgs = [ . . . document . querySelectorAll ( ' img[src^= " data: " ] ' ) ] . length > 0 ;
const dataFonts = [ . . . document . querySelectorAll ( ' link[rel= " preload " ][as= " font " ][href^= " data: " ] ' ) ] . length > 0 ;
return { inlineScripts , inlineStyles , styleAttrs , handlerAttrs , dataImgs , dataFonts } ;
} """ ,
max_attr_hashes ,
)
script_nonces = { x [ " nonce " ] for x in data [ " inlineScripts " ] if x . get ( " nonce " ) }
style_nonces = { x [ " nonce " ] for x in data [ " inlineStyles " ] if x . get ( " nonce " ) }
script_hashes = set ( )
for x in data [ " inlineScripts " ] :
raw = x . get ( " text " ) or " "
if raw . strip ( ) : # skip pure-whitespace blocks, but DO NOT strip for hashing
script_hashes . add ( f " ' sha256- { sha256_base64 ( raw ) } ' " )
style_hashes = set ( )
for x in data [ " inlineStyles " ] :
raw = x . get ( " text " ) or " "
if raw . strip ( ) :
style_hashes . add ( f " ' sha256- { sha256_base64 ( raw ) } ' " )
# style="..." attribute hashes
style_attr_hashes = set ( )
for v in data . get ( " styleAttrs " ) or [ ] :
if isinstance ( v , str ) and v . strip ( ) :
style_attr_hashes . add ( f " ' sha256- { sha256_base64 ( v ) } ' " )
# on*="..." handler hashes
handler_attr_hashes = set ( )
for v in data . get ( " handlerAttrs " ) or [ ] :
if isinstance ( v , str ) and v . strip ( ) :
handler_attr_hashes . add ( f " ' sha256- { sha256_base64 ( v ) } ' " )
return (
script_nonces ,
style_nonces ,
script_hashes ,
style_hashes ,
style_attr_hashes ,
handler_attr_hashes ,
bool ( data . get ( " dataImgs " ) ) ,
bool ( data . get ( " dataFonts " ) ) ,
)
async def extract_links ( page , base_origin : str ) - > list [ str ] :
hrefs = await page . evaluate (
""" () => [...document.querySelectorAll( ' a[href] ' )].map(a => a.getAttribute( ' href ' )) """
)
out : list [ str ] = [ ]
for href in hrefs or [ ] :
if not href :
continue
abs_url = urljoin ( base_origin + " / " , href )
abs_url , _frag = urldefrag ( abs_url )
p = urlparse ( abs_url )
if p . scheme in ( " http " , " https " ) and origin_of ( abs_url ) == base_origin :
out . append ( abs_url )
return out
def build_csp (
directives : dict [ str , set [ str ] ] ,
* ,
base_origin : str ,
nonce_detected : bool ,
script_hashes : set [ str ] ,
style_hashes : set [ str ] ,
style_attr_hashes : set [ str ] ,
handler_attr_hashes : set [ str ] ,
allow_data_img : bool ,
allow_data_font : bool ,
allow_blob : bool ,
allow_unsafe_eval : bool ,
upgrade_insecure_requests : bool ,
) - > str :
csp : dict [ str , set [ str ] ] = { k : set ( v ) for k , v in BASELINE_DIRECTIVES . items ( ) }
# Merge observed origins into directives.
for d , vals in directives . items ( ) :
if vals :
csp . setdefault ( d , set ( ) ) . update ( vals )
# Always keep 'self' on these directives if present.
for d in (
" script-src " ,
" style-src " ,
" img-src " ,
" connect-src " ,
" font-src " ,
" media-src " ,
" frame-src " ,
) :
if d in csp :
csp [ d ] . add ( " ' self ' " )
# Inline handling:
# - If we detected nonce attributes, emit nonce *template*. You must replace {NONCE} per response.
if nonce_detected :
csp . setdefault ( " script-src " , { " ' self ' " } ) . add ( " ' nonce- {NONCE} ' " )
csp . setdefault ( " style-src " , { " ' self ' " } ) . add ( " ' nonce- {NONCE} ' " )
# Hashes for inline <script>/<style> blocks
if script_hashes :
csp . setdefault ( " script-src " , { " ' self ' " } ) . update ( script_hashes )
if style_hashes :
csp . setdefault ( " style-src " , { " ' self ' " } ) . update ( style_hashes )
# unsafe-hashes: needed for style="" and on*="" attribute hashes (CSP3 behavior)
# We include hashes BOTH in the base directives and the CSP3 *-attr directives for best compatibility.
if handler_attr_hashes :
csp . setdefault ( " script-src " , { " ' self ' " } ) . add ( " ' unsafe-hashes ' " )
csp [ " script-src " ] . update ( handler_attr_hashes )
csp . setdefault ( " script-src-attr " , set ( ) ) . update ( { " ' unsafe-hashes ' " } )
csp [ " script-src-attr " ] . update ( handler_attr_hashes )
if style_attr_hashes :
csp . setdefault ( " style-src " , { " ' self ' " } ) . add ( " ' unsafe-hashes ' " )
csp [ " style-src " ] . update ( style_attr_hashes )
csp . setdefault ( " style-src-attr " , set ( ) ) . update ( { " ' unsafe-hashes ' " } )
csp [ " style-src-attr " ] . update ( style_attr_hashes )
if allow_unsafe_eval :
csp . setdefault ( " script-src " , { " ' self ' " } ) . add ( " ' unsafe-eval ' " )
if allow_data_img :
csp . setdefault ( " img-src " , { " ' self ' " } ) . add ( " data: " )
if allow_data_font :
csp . setdefault ( " font-src " , { " ' self ' " } ) . add ( " data: " )
if allow_blob :
for d in ( " img-src " , " media-src " , " worker-src " , " connect-src " ) :
csp . setdefault ( d , { " ' self ' " } ) . add ( " blob: " )
if upgrade_insecure_requests :
csp [ " upgrade-insecure-requests " ] = set ( )
# Serialize
parts : list [ str ] = [ ]
for k in sorted ( csp . keys ( ) ) :
vals = csp [ k ]
if vals :
parts . append ( f " { k } { ' ' . join ( sorted ( vals ) ) } " )
else :
parts . append ( f " { k } " )
return " ; " . join ( parts ) + " ; "
_SOURCEMAP_RE = re . compile ( r " sourceMappingURL \ s*= \ s*([^ \ s*]+) " , re . IGNORECASE )
def _looks_like_js_or_css ( url : str ) - > bool :
p = urlparse ( url )
path = ( p . path or " " ) . lower ( )
return path . endswith ( " .js " ) or path . endswith ( " .css " )
def _extract_sourcemap_origin (
asset_url : str , body_bytes : bytes , headers : dict
) - > set [ str ] :
out : set [ str ] = set ( )
# Header-based pointers
sm = headers . get ( " sourcemap " ) or headers . get ( " x-sourcemap " )
if sm :
map_url = urljoin ( asset_url , sm )
out . add ( origin_of ( map_url ) )
# Body-based pointer: map comment is usually near end, so just scan the tail
tail = body_bytes [
- 200_000 :
] # big enough to survive minification/compression quirks
text = tail . decode ( " utf-8 " , errors = " ignore " )
m = _SOURCEMAP_RE . search ( text )
if not m :
return { o for o in out if o }
ref = m . group ( 1 ) . strip ( ) . strip ( ' " ' ) . strip ( " ' " )
if ref and not ref . startswith ( " data: " ) :
map_url = urljoin ( asset_url , ref )
out . add ( origin_of ( map_url ) )
return { o for o in out if o }
@dataclass
class CrawlResult :
visited : list [ str ]
csp : str
nonce_detected : bool
directives : dict [ str , list [ str ] ]
notes : list [ str ]
2026-01-02 14:09:56 +11:00
violations : list [ dict ]
2026-01-02 09:59:52 +11:00
async def crawl_and_generate_csp (
start_url : str ,
* ,
max_pages : int = 10 ,
timeout_ms : int = 20000 ,
settle_ms : int = 1500 ,
headless : bool = True ,
browsers_path : Path | None = None ,
auto_install : bool = True ,
with_deps : bool = False ,
allow_blob : bool = False ,
allow_unsafe_eval : bool = False ,
upgrade_insecure_requests : bool = False ,
include_sourcemaps : bool = False ,
2026-01-02 10:41:57 +11:00
ignore_non_html : bool = False ,
2026-01-02 14:09:56 +11:00
bypass_csp : bool = False ,
evaluate : str | None = None , # CSP string to inject as Report-Only and evaluate
2026-01-02 09:59:52 +11:00
) - > CrawlResult :
start_url , _ = urldefrag ( start_url )
base_origin = origin_of ( start_url )
if not base_origin :
raise ValueError ( f " Invalid start URL: { start_url } " )
if auto_install :
await ensure_chromium_installed (
browsers_path = browsers_path , with_deps = with_deps
)
visited : set [ str ] = set ( )
q : deque [ str ] = deque ( [ start_url ] )
# Collect CSP ingredients
directives : dict [ str , set [ str ] ] = {
d : set ( ) for d in set ( RESOURCE_TO_DIRECTIVE . values ( ) ) | { " frame-src " }
}
script_hashes : set [ str ] = set ( )
style_hashes : set [ str ] = set ( )
style_attr_hashes : set [ str ] = set ( )
handler_attr_hashes : set [ str ] = set ( )
nonce_detected = False
allow_data_img = False
allow_data_font = False
notes : list [ str ] = [ ]
2026-01-02 14:09:56 +11:00
evaluate_policy = normalize_csp_string ( evaluate ) if evaluate else None
# Captured CSP violations (Report-Only) when --evaluate is used.
violations : list [ dict ] = [ ]
2026-01-02 09:59:52 +11:00
async with async_playwright ( ) as p :
browser = await p . chromium . launch ( headless = headless )
context = await browser . new_context ( )
2026-01-02 14:09:56 +11:00
# Optionally strip any existing CSP headers, and/or inject a Report-Only CSP for evaluation.
# NOTE: This operates on *document response headers* only.
if bypass_csp or evaluate_policy :
async def _route_handler ( route , request ) :
try :
if request . resource_type != " document " :
return await route . continue_ ( )
2026-01-06 09:07:21 +11:00
# IMPORTANT: Don't rewrite CSP on third-party iframe/object documents.
# Otherwise --evaluate / --bypass-csp will mutate embedded origins
# (e.g. asciinema.org) and produce bogus frame-ancestors violations.
req_origin = origin_of ( request . url )
if not req_origin or req_origin != base_origin :
return await route . continue_ ( )
2026-01-02 14:09:56 +11:00
resp = await route . fetch ( )
hdrs = { k . lower ( ) : v for k , v in ( resp . headers or { } ) . items ( ) }
2026-01-06 09:07:21 +11:00
# Only treat actual HTML documents as candidates for CSP header rewriting.
# (Playwright classifies iframe navigations as "document" even when non-HTML.)
ct = ( hdrs . get ( " content-type " ) or " " ) . lower ( )
is_html = ( " text/html " in ct ) or ( " application/xhtml+xml " in ct )
if not is_html :
return await route . fulfill ( response = resp )
2026-01-02 14:09:56 +11:00
if bypass_csp :
hdrs . pop ( " content-security-policy " , None )
hdrs . pop ( " content-security-policy-report-only " , None )
if evaluate_policy :
hdrs [ " content-security-policy-report-only " ] = evaluate_policy
try :
return await route . fulfill ( response = resp , headers = hdrs )
except TypeError :
body = await resp . body ( )
return await route . fulfill (
status = resp . status , headers = hdrs , body = body
)
except Exception :
try :
return await route . continue_ ( )
except Exception :
return
await context . route ( " **/* " , _route_handler )
2026-01-02 09:59:52 +11:00
def on_request ( req ) :
"""
Playwright sometimes classifies " connect-like " activity as resource_type == " other " .
Heuristic : treat resource_type == " other " with sec - fetch - dest == " empty " as connect - src .
"""
try :
url = req . url
parsed = urlparse ( url )
if parsed . scheme not in ( " http " , " https " , " ws " , " wss " ) :
return
rtype = req . resource_type
directive = RESOURCE_TO_DIRECTIVE . get ( rtype )
if directive is None and rtype == " other " :
hdrs = { k . lower ( ) : v for k , v in ( req . headers or { } ) . items ( ) }
# For fetch/xhr/beacon/pings, browsers typically send: sec-fetch-dest: empty
if ( hdrs . get ( " sec-fetch-dest " ) or " " ) . lower ( ) == " empty " :
directive = " connect-src "
if directive is None :
return
req_origin = origin_of ( url )
if req_origin and req_origin != base_origin :
directives . setdefault ( directive , set ( ) ) . add ( req_origin )
except Exception :
return
context . on ( " request " , on_request )
max_queue = max_pages * 20
while q and len ( visited ) < max_pages :
url = q . popleft ( )
if url in visited :
continue
visited . add ( url )
page = await context . new_page ( )
2026-01-02 14:09:56 +11:00
# If evaluating a candidate CSP, capture Report-Only violations.
if evaluate_policy :
def _record_violation ( _source , payload ) :
try :
if (
isinstance ( payload , dict )
and payload . get ( " disposition " ) == " report "
) :
violations . append ( payload )
except Exception :
return
try :
await page . expose_binding ( " __cspresso_violation " , _record_violation )
await page . add_init_script (
" () => { try { window.addEventListener( ' securitypolicyviolation ' , (e) => { "
" const payload = { documentURI:e.documentURI, referrer:e.referrer, blockedURI:e.blockedURI, "
" violatedDirective:e.violatedDirective, effectiveDirective:e.effectiveDirective, originalPolicy:e.originalPolicy, "
" disposition:e.disposition, sourceFile:e.sourceFile, lineNumber:e.lineNumber, columnNumber:e.columnNumber, "
" statusCode:e.statusCode, sample:e.sample}; "
" if (typeof window.__cspresso_violation === ' function ' ) { window.__cspresso_violation(payload); } "
" }, true); } catch(_) {} } "
)
except Exception :
2026-01-02 14:18:24 +11:00
pass # nosec
2026-01-02 14:09:56 +11:00
def _on_console ( msg ) :
try :
t = msg . text or " "
tl = t . lower ( )
if (
" content security policy " in tl
or " content-security-policy " in tl
) and (
" would violate " in tl
or " report-only " in tl
or " report only " in tl
) :
violations . append (
{
" console " : True ,
" type " : msg . type ,
" text " : t ,
" documentURI " : page . url ,
" disposition " : " report " ,
}
)
except Exception :
return
page . on ( " console " , _on_console )
2026-01-02 09:59:52 +11:00
pending : set [ asyncio . Task ] = set ( )
if include_sourcemaps :
async def handle_response ( resp ) :
try :
url = resp . url
if not _looks_like_js_or_css ( url ) :
return
headers = {
k . lower ( ) : v for k , v in ( resp . headers or { } ) . items ( )
}
# Read the *actual* bytes the browser received
body = await resp . body ( )
origins = _extract_sourcemap_origin ( url , body , headers )
for o in origins :
if o and o != base_origin :
directives . setdefault ( " connect-src " , set ( ) ) . add ( o )
except Exception :
return
def on_response ( resp ) :
t = asyncio . create_task ( handle_response ( resp ) )
pending . add ( t )
t . add_done_callback ( lambda _t : pending . discard ( _t ) )
page . on ( " response " , on_response )
try :
2026-02-04 12:52:53 +11:00
resp = await page . goto ( url , wait_until = " load " , timeout = timeout_ms )
try :
await page . wait_for_load_state (
" networkidle " , timeout = min ( 5000 , timeout_ms )
)
except PlaywrightTimeoutError :
notes . append (
f " Timed out waiting for networkidle on { url } ; continuing after load anyway. "
)
2026-01-02 10:41:57 +11:00
ct = " "
if resp is not None :
ct = ( await resp . header_value ( " content-type " ) or " " ) . lower ( )
is_html = ( " text/html " in ct ) or ( " application/xhtml+xml " in ct )
if not is_html and ignore_non_html :
# Still count as visited, but don't hash inline attrs / don't extract links.
continue
2026-01-02 09:59:52 +11:00
# Give the page a moment to run hydration / delayed fetches.
if settle_ms > 0 :
await page . wait_for_timeout ( settle_ms )
(
s_nonces ,
st_nonces ,
s_hashes ,
st_hashes ,
st_attr_hashes ,
h_attr_hashes ,
has_data_img ,
has_data_font ,
) = await collect_inline ( page )
if include_sourcemaps and pending :
# Give the handler a moment to finish reading bodies
await asyncio . wait ( pending , timeout = 5.0 )
if s_nonces or st_nonces :
nonce_detected = True
script_hashes . update ( s_hashes )
style_hashes . update ( st_hashes )
style_attr_hashes . update ( st_attr_hashes )
handler_attr_hashes . update ( h_attr_hashes )
allow_data_img = allow_data_img or has_data_img
allow_data_font = allow_data_font or has_data_font
# Frame destinations
for fr in page . frames :
if fr . url and fr . url != " about:blank " :
fr_origin = origin_of ( fr . url )
if fr_origin and fr_origin != base_origin :
directives [ " frame-src " ] . add ( fr_origin )
# Enqueue same-origin links
links = await extract_links ( page , base_origin )
for link in links :
if link not in visited and link not in q and len ( q ) < max_queue :
q . append ( link )
finally :
await page . close ( )
await browser . close ( )
csp = build_csp (
directives = directives ,
base_origin = base_origin ,
nonce_detected = nonce_detected ,
script_hashes = script_hashes ,
style_hashes = style_hashes ,
style_attr_hashes = style_attr_hashes ,
handler_attr_hashes = handler_attr_hashes ,
allow_data_img = allow_data_img ,
allow_data_font = allow_data_font ,
allow_blob = allow_blob ,
allow_unsafe_eval = allow_unsafe_eval ,
upgrade_insecure_requests = upgrade_insecure_requests ,
)
if style_attr_hashes or handler_attr_hashes :
notes . append (
' Detected inline attribute code (style= " ... " and/or on*= " ... " ). '
" Hashes for these require ' unsafe-hashes ' (and modern browsers may use style-src-attr/script-src-attr). "
)
if nonce_detected :
notes . append (
" Nonce detected: replace {NONCE} per HTML response (server must generate and inject nonce). "
)
directives_out = { k : sorted ( v ) for k , v in directives . items ( ) if v }
2026-01-02 14:09:56 +11:00
# De-duplicate violations (same doc+directive+blocked URI) to keep output stable.
if violations :
seen = set ( )
uniq : list [ dict ] = [ ]
for v in violations :
if not isinstance ( v , dict ) :
continue
key = (
v . get ( " documentURI " ) ,
v . get ( " effectiveDirective " ) or v . get ( " violatedDirective " ) ,
v . get ( " blockedURI " ) ,
v . get ( " sourceFile " ) ,
v . get ( " lineNumber " ) ,
v . get ( " columnNumber " ) ,
)
if key in seen :
continue
seen . add ( key )
uniq . append ( v )
violations = uniq
2026-01-02 09:59:52 +11:00
return CrawlResult (
visited = sorted ( visited ) ,
csp = csp ,
nonce_detected = nonce_detected ,
directives = directives_out ,
notes = notes ,
2026-01-02 14:09:56 +11:00
violations = violations ,
2026-01-02 09:59:52 +11:00
)
def _parse_args ( argv : list [ str ] | None = None ) - > argparse . Namespace :
ap = argparse . ArgumentParser (
2026-01-02 10:28:46 +11:00
prog = " cspresso " ,
2026-01-02 09:59:52 +11:00
description = " Crawl up to N pages (same-origin) with Playwright and generate a draft CSP. " ,
)
ap . add_argument ( " url " , help = " Start URL (e.g. https://example.com) " )
ap . add_argument (
" --max-pages " ,
type = int ,
default = 10 ,
help = " Maximum number of pages to visit (default: 10) " ,
)
ap . add_argument (
" --timeout-ms " ,
type = int ,
default = 20000 ,
help = " Navigation timeout in ms (default: 20000) " ,
)
ap . add_argument (
" --settle-ms " ,
type = int ,
default = 1500 ,
help = " Extra time after networkidle to allow hydration/delayed requests (default: 1500) " ,
)
ap . add_argument (
" --headed " ,
action = " store_true " ,
help = " Run with a visible browser window (not headless) " ,
)
ap . add_argument (
" --no-install " ,
action = " store_true " ,
help = " Do not auto-install Chromium if missing " ,
)
ap . add_argument (
" --with-deps " ,
action = " store_true " ,
help = " When installing, include Playwright OS deps (Linux). May require elevated privileges. " ,
)
ap . add_argument (
" --browsers-path " ,
default = None ,
help = " Directory to install/playwright browsers (default: ./.pw-browsers). " ,
)
ap . add_argument (
" --allow-blob " ,
action = " store_true " ,
help = " Include blob: in common directives (drafty) " ,
)
ap . add_argument (
" --unsafe-eval " ,
action = " store_true " ,
help = " Include ' unsafe-eval ' in script-src (not recommended) " ,
)
ap . add_argument (
" --upgrade-insecure-requests " ,
action = " store_true " ,
help = " Add upgrade-insecure-requests directive " ,
)
ap . add_argument (
" --include-sourcemaps " ,
action = " store_true " ,
default = False ,
help = " Analyze JS/CSS for sourceMappingURL and add map origins to connect-src " ,
)
2026-01-02 14:09:56 +11:00
ap . add_argument (
" --bypass-csp " ,
action = " store_true " ,
help = " Strip any existing CSP/CSP-Report-Only response headers from HTML documents (useful for discovery or evaluation). " ,
)
ap . add_argument (
" --evaluate " ,
metavar = " CSP " ,
default = None ,
help = " Inject the provided CSP string as Content-Security-Policy-Report-Only on HTML documents and exit 1 if any Report-Only violations are detected. Quote the value. " ,
)
2026-01-02 10:41:57 +11:00
ap . add_argument (
" --ignore-non-html " ,
action = " store_true " ,
default = False ,
help = " Ignore non-HTML pages that get crawled (which might trigger Chromium ' s word-wrap hash: https://stackoverflow.com/a/69838710) " ,
)
2026-01-02 09:59:52 +11:00
ap . add_argument (
" --json " , action = " store_true " , help = " Output JSON instead of a header line "
)
return ap . parse_args ( argv )
2026-01-02 14:09:56 +11:00
def main ( argv : list [ str ] | None = None ) - > int :
2026-01-02 09:59:52 +11:00
args = _parse_args ( argv )
browsers_path = Path ( args . browsers_path ) . resolve ( ) if args . browsers_path else None
result = asyncio . run (
crawl_and_generate_csp (
args . url ,
max_pages = args . max_pages ,
timeout_ms = args . timeout_ms ,
settle_ms = args . settle_ms ,
headless = not args . headed ,
browsers_path = browsers_path ,
auto_install = not args . no_install ,
with_deps = args . with_deps ,
allow_blob = args . allow_blob ,
allow_unsafe_eval = args . unsafe_eval ,
upgrade_insecure_requests = args . upgrade_insecure_requests ,
include_sourcemaps = args . include_sourcemaps ,
2026-01-02 14:09:56 +11:00
bypass_csp = args . bypass_csp ,
evaluate = args . evaluate ,
2026-01-02 10:41:57 +11:00
ignore_non_html = args . ignore_non_html ,
2026-01-02 09:59:52 +11:00
)
)
if args . json :
print (
json . dumps (
{
" visited " : result . visited ,
" nonce_detected " : result . nonce_detected ,
" csp " : result . csp ,
" directives " : result . directives ,
" notes " : result . notes ,
2026-01-02 14:09:56 +11:00
" violations " : result . violations ,
" evaluated_policy " : args . evaluate ,
2026-01-02 09:59:52 +11:00
} ,
indent = 2 ,
sort_keys = True ,
)
)
2026-01-02 14:09:56 +11:00
return 1 if ( args . evaluate and result . violations ) else 0
2026-01-02 09:59:52 +11:00
# Default: print header + visited pages as comments.
for u in result . visited :
print ( f " # visited: { u } " )
for n in result . notes :
print ( f " # NOTE: { n } " )
print ( " Content-Security-Policy: " , result . csp )
2026-01-02 14:09:56 +11:00
if args . evaluate :
if result . violations :
print ( " # CSP Report-Only violations detected: " )
for v in result . violations :
try :
blocked = v . get ( " blockedURI " )
eff = v . get ( " effectiveDirective " ) or v . get ( " violatedDirective " )
doc = v . get ( " documentURI " )
print ( f " # - { eff } blocked= { blocked } on { doc } " )
except Exception :
print ( f " # - { v } " )
return 1
return 0
return 0
2026-01-02 09:59:52 +11:00
if __name__ == " __main__ " :
2026-01-02 14:09:56 +11:00
import sys
sys . exit ( main ( ) )