diff --git a/README.md b/README.md index a9ca9fa..186b5a8 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,30 @@ and short reason codes for each unsafe include. When `--list-sri` is supplied th output also enumerates each external script and stylesheet that defines an `integrity` attribute. +### Use as a library + +The same analysis is available as an importable API from the installed package, +so other tools (e.g. a findings/dispute workflow) can reuse it instead of +reimplementing SRI parsing: + +```python +from domain_security_analyzer.sri import scan_url + +# Inspect a single page (default); pass crawl=True to follow same-origin links. +report = scan_url("https://example.com") + +for resource in report["unsafe_resources"]: + print(resource["resource_url"], resource["reasons"]) + +if report["compensating_control_detected"]: + print("A restrictive Content-Security-Policy is acting as a compensating control") +``` + +Each entry in `unsafe_resources` carries the raw `integrity`/`crossorigin` +values plus machine-readable `reasons`: `missing-integrity`, +`invalid-integrity-hash`, `mixed-invalid-hashes`, `non-https-resource`, and +`missing-crossorigin`. For full control, use the `SRIParser` class directly. + ## Documentation ### **Reference Guides** diff --git a/domain_security_analyzer/__init__.py b/domain_security_analyzer/__init__.py index fda8d34..3630933 100644 --- a/domain_security_analyzer/__init__.py +++ b/domain_security_analyzer/__init__.py @@ -7,5 +7,13 @@ from .__version__ import __version__ from .analyzer import DomainAnalyzer, analyze_domains_from_file +from .sri import SRIParser, UnsafeResource, scan_url -__all__ = ["DomainAnalyzer", "analyze_domains_from_file", "__version__"] +__all__ = [ + "DomainAnalyzer", + "analyze_domains_from_file", + "SRIParser", + "UnsafeResource", + "scan_url", + "__version__", +] diff --git a/domain_security_analyzer/sri.py b/domain_security_analyzer/sri.py new file mode 100644 index 0000000..1bde661 --- /dev/null +++ b/domain_security_analyzer/sri.py @@ -0,0 +1,337 @@ +"""Subresource Integrity (SRI) analysis. + +Importable API for detecting unsafe Subresource Integrity usage on a site, +modelled after SecurityScorecard's "Unsafe Implementation of Subresource +Integrity (SRI)" finding. + +Typical use:: + + from domain_security_analyzer.sri import scan_url + + report = scan_url("https://example.com") + for resource in report["unsafe_resources"]: + print(resource["resource_url"], resource["reasons"]) + +Each unsafe resource carries machine-readable ``reasons`` drawn from: + +* ``missing-integrity`` - external resource has no ``integrity`` attribute +* ``invalid-integrity-hash``- ``integrity`` present but no valid sha256/384/512 hash +* ``mixed-invalid-hashes`` - some hashes valid, some malformed +* ``non-https-resource`` - loaded over plain HTTP +* ``missing-crossorigin`` - cross-origin resource without a ``crossorigin`` attribute + +The report also surfaces any ``Content-Security-Policy`` headers and whether a +restrictive one is present as a compensating control. +""" +from __future__ import annotations + +import collections +import re +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Set, Tuple +from urllib.parse import urljoin, urlparse + +import requests +from bs4 import BeautifulSoup + +__all__ = ["SRIParser", "UnsafeResource", "scan_url", "INTEGRITY_PATTERN"] + +# Regular expression to validate integrity hashes (sha256/sha384/sha512) +INTEGRITY_PATTERN = re.compile(r"^(sha(256|384|512))-[A-Za-z0-9+/=]+$") + +# Tokens in script-src that indicate the policy is not restrictive enough to +# qualify as a compensating control. +PERMISSIVE_SCRIPT_TOKENS = { + "*", + "http:", + "https:", + "data:", + "blob:", + "filesystem:", + "'unsafe-inline'", + "'unsafe-eval'", +} + +DEFAULT_USER_AGENT = ( + "SRI-Parser/1.0 (+https://github.com/CallMarcus/domain-security-analyzer)" +) + + +@dataclass +class UnsafeResource: + page_url: str + resource_url: str + tag_type: str + integrity: Optional[str] + crossorigin: Optional[str] + reasons: List[str] = field(default_factory=list) + + +class SRIParser: + """Crawl a site and report unsafe Subresource Integrity usage.""" + + def __init__( + self, + base_url: str, + max_depth: int = 0, + max_pages: int = 1, + timeout: int = 10, + user_agent: str = DEFAULT_USER_AGENT, + ) -> None: + parsed = urlparse(base_url) + if not parsed.scheme: + base_url = f"https://{base_url}" + parsed = urlparse(base_url) + + if not parsed.netloc: + raise ValueError("A valid domain or URL is required") + + self.base_url = base_url.rstrip('/') or base_url + self.base_netloc = parsed.netloc.lower() + self.max_depth = max_depth + self.max_pages = max_pages + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({"User-Agent": user_agent}) + + self.visited: Set[str] = set() + self.to_visit: collections.deque[Tuple[str, int]] = collections.deque([(self.base_url, 0)]) + self.unsafe_resources: List[UnsafeResource] = [] + self.csp_policies: List[Tuple[str, str, str]] = [] # (page_url, header_name, policy_value) + self.resources_with_integrity: List[Dict[str, object]] = [] + + # ------------------------------------------------------------------ + # Crawling helpers + # ------------------------------------------------------------------ + def _is_same_origin(self, url: str) -> bool: + if not url: + return False + parsed = urlparse(url) + if not parsed.scheme: + return True # relative URL -> same origin + return parsed.netloc.lower() == self.base_netloc + + def _is_external_resource(self, url: str) -> bool: + if not url or not url.startswith(("http://", "https://")): + return False + parsed = urlparse(url) + resource_domain = parsed.netloc.lower().replace("www.", "") + base_domain = self.base_netloc.replace("www.", "") + return resource_domain != base_domain + + def _fetch(self, url: str) -> Optional[requests.Response]: + try: + response = self.session.get(url, timeout=self.timeout, allow_redirects=True) + if "text/html" not in response.headers.get("content-type", ""): + return None + return response + except requests.RequestException: + return None + + def _extract_links(self, html: str, page_url: str) -> Iterable[str]: + soup = BeautifulSoup(html, "html.parser") + for anchor in soup.find_all("a", href=True): + href = urljoin(page_url, anchor.get("href")) + if self._is_same_origin(href): + yield href.split("#", 1)[0] + + # ------------------------------------------------------------------ + # SRI evaluation + # ------------------------------------------------------------------ + def _parse_integrity_tokens(self, integrity: str) -> Tuple[List[str], List[str]]: + valid_tokens: List[str] = [] + invalid_tokens: List[str] = [] + for token in integrity.split(): + if INTEGRITY_PATTERN.match(token.strip()): + valid_tokens.append(token) + else: + invalid_tokens.append(token) + return valid_tokens, invalid_tokens + + def _analyze_resource(self, tag, tag_type: str, page_url: str) -> Optional[UnsafeResource]: + src_attr = "src" if tag_type == "script" else "href" + resource_url = tag.get(src_attr) + if not resource_url: + return None + + resource_url = urljoin(page_url, resource_url) + if not self._is_external_resource(resource_url): + return None + + integrity = tag.get("integrity") + crossorigin = tag.get("crossorigin") + parsed = urlparse(resource_url) + reasons: List[str] = [] + + valid_hashes: List[str] = [] + invalid_hashes: List[str] = [] + + if integrity: + valid_hashes, invalid_hashes = self._parse_integrity_tokens(integrity) + self.resources_with_integrity.append( + { + "page_url": page_url, + "resource_url": resource_url, + "tag_type": tag_type, + "integrity": integrity, + "crossorigin": crossorigin, + "valid_hashes": valid_hashes, + "invalid_hashes": invalid_hashes, + } + ) + else: + reasons.append("missing-integrity") + + if integrity: + if not valid_hashes: + reasons.append("invalid-integrity-hash") + elif invalid_hashes: + reasons.append("mixed-invalid-hashes") + + if parsed.scheme != "https": + reasons.append("non-https-resource") + + if self._is_cross_origin(parsed) and not crossorigin: + reasons.append("missing-crossorigin") + + if reasons: + return UnsafeResource( + page_url=page_url, + resource_url=resource_url, + tag_type=tag_type, + integrity=integrity, + crossorigin=crossorigin, + reasons=reasons, + ) + return None + + def _is_cross_origin(self, parsed_url) -> bool: + return parsed_url.netloc.lower() != self.base_netloc + + # ------------------------------------------------------------------ + # CSP evaluation + # ------------------------------------------------------------------ + def _record_csp(self, page_url: str, response: requests.Response) -> None: + for header_name in ("Content-Security-Policy", "Content-Security-Policy-Report-Only"): + policy = response.headers.get(header_name) + if policy: + self.csp_policies.append((page_url, header_name, policy)) + + def _has_compensating_csp(self) -> bool: + for _, _, policy in self.csp_policies: + directives = self._parse_csp(policy) + script_sources = directives.get("script-src") or directives.get("default-src") + if not script_sources: + continue + + lower_tokens = [token.lower() for token in script_sources] + if any(token in PERMISSIVE_SCRIPT_TOKENS for token in lower_tokens): + continue + + has_specific_allowlist = any( + token.startswith("'self'") + or token.startswith("'nonce-") + or token.startswith("'sha") + or token.startswith("https://") + or token.startswith("http://") + or "." in token + for token in lower_tokens + ) + if has_specific_allowlist: + return True + return False + + def _parse_csp(self, policy: str) -> Dict[str, List[str]]: + directives: Dict[str, List[str]] = {} + for directive in policy.split(";"): + directive = directive.strip() + if not directive: + continue + parts = directive.split() + if not parts: + continue + name = parts[0].lower() + sources = parts[1:] + directives[name] = sources + return directives + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def crawl(self) -> Dict[str, object]: + pages_crawled = 0 + while self.to_visit and pages_crawled < self.max_pages: + url, depth = self.to_visit.popleft() + if url in self.visited or depth > self.max_depth: + continue + + response = self._fetch(url) + self.visited.add(url) + if not response: + continue + + pages_crawled += 1 + html = response.text + self._record_csp(url, response) + + soup = BeautifulSoup(html, "html.parser") + for script in soup.find_all("script", src=True): + unsafe = self._analyze_resource(script, "script", url) + if unsafe: + self.unsafe_resources.append(unsafe) + + for link in soup.find_all("link", href=True): + rel = link.get("rel") or [] + if "stylesheet" in [r.lower() for r in rel]: + unsafe = self._analyze_resource(link, "stylesheet", url) + if unsafe: + self.unsafe_resources.append(unsafe) + + if depth < self.max_depth: + for href in self._extract_links(html, url): + if href not in self.visited: + self.to_visit.append((href, depth + 1)) + + report = { + "base_url": self.base_url, + "pages_crawled": pages_crawled, + "unsafe_resources": [unsafe.__dict__ for unsafe in self.unsafe_resources], + "compensating_control_detected": self._has_compensating_csp(), + "csp_policies": [ + {"page_url": page_url, "header": header, "value": policy} + for page_url, header, policy in self.csp_policies + ], + "resources_with_integrity_count": len(self.resources_with_integrity), + "resources_with_integrity": self.resources_with_integrity, + } + return report + + +def scan_url( + url: str, + crawl: bool = False, + max_depth: int = 1, + max_pages: int = 25, + timeout: int = 10, + user_agent: str = DEFAULT_USER_AGENT, +) -> Dict[str, object]: + """Scan a single URL (or crawl a site) for unsafe SRI usage. + + This is the convenience entry point for library consumers such as + ``tpcrm-findings-scanner``. By default it inspects only the given page; + pass ``crawl=True`` to follow same-origin links up to ``max_depth`` / + ``max_pages``. + + Returns the report dict produced by :meth:`SRIParser.crawl`, whose + ``unsafe_resources`` entries carry machine-readable ``reasons`` plus the + raw ``integrity`` / ``crossorigin`` values, alongside any observed CSP + headers and whether a restrictive one acts as a compensating control. + """ + parser = SRIParser( + base_url=url, + max_depth=max_depth if crawl else 0, + max_pages=max_pages if crawl else 1, + timeout=timeout, + user_agent=user_agent, + ) + return parser.crawl() diff --git a/scripts/sri_parser.py b/scripts/sri_parser.py index dfda37c..2ff2bd3 100644 --- a/scripts/sri_parser.py +++ b/scripts/sri_parser.py @@ -1,288 +1,36 @@ -"""SRI Parser - crawl a site for unsafe Subresource Integrity usage. +"""SRI Parser CLI - crawl a site for unsafe Subresource Integrity usage. -This script inspects pages on a given site, looks for external JavaScript -and stylesheet resources, and reports which ones violate SecurityScorecard's -"unsafe SRI" criteria. It also checks for a compensating control in the form -of a restrictive Content-Security-Policy header. +The reusable analysis logic now lives in the installed package at +``domain_security_analyzer.sri`` (see issue #18 / PyPI packaging). This script +is a thin command-line wrapper kept for backward compatibility, so existing +``python scripts/sri_parser.py `` invocations keep working. For +programmatic use prefer:: + + from domain_security_analyzer.sri import scan_url """ from __future__ import annotations import argparse -import collections import json -import re -from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Set, Tuple -from urllib.parse import urljoin, urlparse - -import requests -from bs4 import BeautifulSoup - -# Regular expression to validate integrity hashes (sha256/sha384/sha512) -INTEGRITY_PATTERN = re.compile(r"^(sha(256|384|512))-[A-Za-z0-9+/=]+$") - -# Tokens in script-src that indicate the policy is not restrictive enough to -# qualify as a compensating control. -PERMISSIVE_SCRIPT_TOKENS = { - "*", - "http:", - "https:", - "data:", - "blob:", - "filesystem:", - "'unsafe-inline'", - "'unsafe-eval'", -} - - -@dataclass -class UnsafeResource: - page_url: str - resource_url: str - tag_type: str - integrity: Optional[str] - crossorigin: Optional[str] - reasons: List[str] = field(default_factory=list) - - -class SRIParser: - """Crawl a site and report unsafe Subresource Integrity usage.""" - - def __init__( - self, - base_url: str, - max_depth: int = 0, - max_pages: int = 1, - timeout: int = 10, - user_agent: str = "SRI-Parser/1.0 (+https://github.com/security-domain/domain-security-analyzer)", - ) -> None: - parsed = urlparse(base_url) - if not parsed.scheme: - base_url = f"https://{base_url}" - parsed = urlparse(base_url) - - if not parsed.netloc: - raise ValueError("A valid domain or URL is required") - - self.base_url = base_url.rstrip('/') or base_url - self.base_netloc = parsed.netloc.lower() - self.max_depth = max_depth - self.max_pages = max_pages - self.timeout = timeout - self.session = requests.Session() - self.session.headers.update({"User-Agent": user_agent}) - - self.visited: Set[str] = set() - self.to_visit: collections.deque[Tuple[str, int]] = collections.deque([(self.base_url, 0)]) - self.unsafe_resources: List[UnsafeResource] = [] - self.csp_policies: List[Tuple[str, str, str]] = [] # (page_url, header_name, policy_value) - self.resources_with_integrity: List[Dict[str, object]] = [] - - # ------------------------------------------------------------------ - # Crawling helpers - # ------------------------------------------------------------------ - def _is_same_origin(self, url: str) -> bool: - if not url: - return False - parsed = urlparse(url) - if not parsed.scheme: - return True # relative URL -> same origin - return parsed.netloc.lower() == self.base_netloc - - def _is_external_resource(self, url: str) -> bool: - if not url or not url.startswith(("http://", "https://")): - return False - parsed = urlparse(url) - resource_domain = parsed.netloc.lower().replace("www.", "") - base_domain = self.base_netloc.replace("www.", "") - return resource_domain != base_domain - - def _fetch(self, url: str) -> Optional[requests.Response]: - try: - response = self.session.get(url, timeout=self.timeout, allow_redirects=True) - if "text/html" not in response.headers.get("content-type", ""): - return None - return response - except requests.RequestException: - return None - - def _extract_links(self, html: str, page_url: str) -> Iterable[str]: - soup = BeautifulSoup(html, "html.parser") - for anchor in soup.find_all("a", href=True): - href = urljoin(page_url, anchor.get("href")) - if self._is_same_origin(href): - yield href.split("#", 1)[0] - - # ------------------------------------------------------------------ - # SRI evaluation - # ------------------------------------------------------------------ - def _parse_integrity_tokens(self, integrity: str) -> Tuple[List[str], List[str]]: - valid_tokens: List[str] = [] - invalid_tokens: List[str] = [] - for token in integrity.split(): - if INTEGRITY_PATTERN.match(token.strip()): - valid_tokens.append(token) - else: - invalid_tokens.append(token) - return valid_tokens, invalid_tokens - - def _analyze_resource(self, tag, tag_type: str, page_url: str) -> Optional[UnsafeResource]: - src_attr = "src" if tag_type == "script" else "href" - resource_url = tag.get(src_attr) - if not resource_url: - return None - - resource_url = urljoin(page_url, resource_url) - if not self._is_external_resource(resource_url): - return None - - integrity = tag.get("integrity") - crossorigin = tag.get("crossorigin") - parsed = urlparse(resource_url) - reasons: List[str] = [] - - valid_hashes: List[str] = [] - invalid_hashes: List[str] = [] - - if integrity: - valid_hashes, invalid_hashes = self._parse_integrity_tokens(integrity) - self.resources_with_integrity.append( - { - "page_url": page_url, - "resource_url": resource_url, - "tag_type": tag_type, - "integrity": integrity, - "crossorigin": crossorigin, - "valid_hashes": valid_hashes, - "invalid_hashes": invalid_hashes, - } - ) - else: - reasons.append("missing-integrity") - - if integrity: - if not valid_hashes: - reasons.append("invalid-integrity-hash") - elif invalid_hashes: - reasons.append("mixed-invalid-hashes") - - if parsed.scheme != "https": - reasons.append("non-https-resource") - - if self._is_cross_origin(parsed) and not crossorigin: - reasons.append("missing-crossorigin") - - if reasons: - return UnsafeResource( - page_url=page_url, - resource_url=resource_url, - tag_type=tag_type, - integrity=integrity, - crossorigin=crossorigin, - reasons=reasons, - ) - return None - - def _is_cross_origin(self, parsed_url) -> bool: - return parsed_url.netloc.lower() != self.base_netloc - - # ------------------------------------------------------------------ - # CSP evaluation - # ------------------------------------------------------------------ - def _record_csp(self, page_url: str, response: requests.Response) -> None: - for header_name in ("Content-Security-Policy", "Content-Security-Policy-Report-Only"): - policy = response.headers.get(header_name) - if policy: - self.csp_policies.append((page_url, header_name, policy)) - - def _has_compensating_csp(self) -> bool: - for _, _, policy in self.csp_policies: - directives = self._parse_csp(policy) - script_sources = directives.get("script-src") or directives.get("default-src") - if not script_sources: - continue - - lower_tokens = [token.lower() for token in script_sources] - if any(token in PERMISSIVE_SCRIPT_TOKENS for token in lower_tokens): - continue - - has_specific_allowlist = any( - token.startswith("'self'") - or token.startswith("'nonce-") - or token.startswith("'sha") - or token.startswith("https://") - or token.startswith("http://") - or "." in token - for token in lower_tokens - ) - if has_specific_allowlist: - return True - return False - - def _parse_csp(self, policy: str) -> Dict[str, List[str]]: - directives: Dict[str, List[str]] = {} - for directive in policy.split(";"): - directive = directive.strip() - if not directive: - continue - parts = directive.split() - if not parts: - continue - name = parts[0].lower() - sources = parts[1:] - directives[name] = sources - return directives - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - def crawl(self) -> Dict[str, object]: - pages_crawled = 0 - while self.to_visit and pages_crawled < self.max_pages: - url, depth = self.to_visit.popleft() - if url in self.visited or depth > self.max_depth: - continue - - response = self._fetch(url) - self.visited.add(url) - if not response: - continue - - pages_crawled += 1 - html = response.text - self._record_csp(url, response) - - soup = BeautifulSoup(html, "html.parser") - for script in soup.find_all("script", src=True): - unsafe = self._analyze_resource(script, "script", url) - if unsafe: - self.unsafe_resources.append(unsafe) - - for link in soup.find_all("link", href=True): - rel = link.get("rel") or [] - if "stylesheet" in [r.lower() for r in rel]: - unsafe = self._analyze_resource(link, "stylesheet", url) - if unsafe: - self.unsafe_resources.append(unsafe) - - if depth < self.max_depth: - for href in self._extract_links(html, url): - if href not in self.visited: - self.to_visit.append((href, depth + 1)) - - report = { - "base_url": self.base_url, - "pages_crawled": pages_crawled, - "unsafe_resources": [unsafe.__dict__ for unsafe in self.unsafe_resources], - "compensating_control_detected": self._has_compensating_csp(), - "csp_policies": [ - {"page_url": page_url, "header": header, "value": policy} - for page_url, header, policy in self.csp_policies - ], - "resources_with_integrity_count": len(self.resources_with_integrity), - "resources_with_integrity": self.resources_with_integrity, - } - return report +import os +import sys +from typing import Dict + +try: + from domain_security_analyzer.sri import ( # noqa: F401 (re-exported) + INTEGRITY_PATTERN, + SRIParser, + UnsafeResource, + scan_url, + ) +except ModuleNotFoundError: # pragma: no cover - running from a source checkout + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from domain_security_analyzer.sri import ( # noqa: F401 (re-exported) + INTEGRITY_PATTERN, + SRIParser, + UnsafeResource, + scan_url, + ) def build_arg_parser() -> argparse.ArgumentParser: @@ -386,13 +134,13 @@ def main() -> None: parser = build_arg_parser() args = parser.parse_args() - sri_parser = SRIParser( - base_url=args.url, - max_depth=args.max_depth if args.crawl else 0, - max_pages=args.max_pages if args.crawl else 1, + report = scan_url( + args.url, + crawl=args.crawl, + max_depth=args.max_depth, + max_pages=args.max_pages, timeout=args.timeout, ) - report = sri_parser.crawl() print_report(report, as_json=args.json, list_all=args.list_sri) diff --git a/tests/test_sri.py b/tests/test_sri.py new file mode 100644 index 0000000..91f1ce3 --- /dev/null +++ b/tests/test_sri.py @@ -0,0 +1,123 @@ +"""Unit tests for SRI analysis pure logic (no network required).""" + +import pytest +from bs4 import BeautifulSoup + +from domain_security_analyzer import SRIParser, UnsafeResource, scan_url +from domain_security_analyzer import sri as sri_mod + + +@pytest.fixture +def parser(): + return SRIParser("https://example.com") + + +def _tag(html): + """Parse a single HTML fragment into its first tag.""" + return BeautifulSoup(html, "html.parser").find(True) + + +def test_public_api_exports(): + # The convenience API named in issue #18 must be importable. + assert callable(scan_url) + assert SRIParser is sri_mod.SRIParser + assert UnsafeResource is sri_mod.UnsafeResource + + +def test_bare_domain_gets_https_scheme(): + p = SRIParser("example.com") + assert p.base_url == "https://example.com" + assert p.base_netloc == "example.com" + + +def test_invalid_base_url_rejected(): + with pytest.raises(ValueError): + SRIParser("") + + +def test_same_origin_resource_is_ignored(parser): + tag = _tag('') + assert parser._analyze_resource(tag, "script", "https://example.com/") is None + + +def test_external_script_missing_integrity(parser): + tag = _tag('') + result = parser._analyze_resource(tag, "script", "https://example.com/") + assert isinstance(result, UnsafeResource) + assert "missing-integrity" in result.reasons + assert result.tag_type == "script" + + +def test_external_resource_with_valid_integrity_is_safe(parser): + valid = "sha384-" + "A" * 64 + tag = _tag( + f'' + ) + assert parser._analyze_resource(tag, "script", "https://example.com/") is None + + +def test_invalid_integrity_hash_flagged(parser): + tag = _tag( + '' + ) + result = parser._analyze_resource(tag, "script", "https://example.com/") + assert "invalid-integrity-hash" in result.reasons + + +def test_mixed_valid_and_invalid_hashes_flagged(parser): + valid = "sha384-" + "A" * 64 + tag = _tag( + f'' + ) + result = parser._analyze_resource(tag, "script", "https://example.com/") + assert "mixed-invalid-hashes" in result.reasons + + +def test_non_https_resource_flagged(parser): + valid = "sha384-" + "A" * 64 + tag = _tag( + f'' + ) + result = parser._analyze_resource(tag, "script", "https://example.com/") + assert "non-https-resource" in result.reasons + + +def test_cross_origin_without_crossorigin_flagged(parser): + valid = "sha384-" + "A" * 64 + tag = _tag( + f'' + ) + result = parser._analyze_resource(tag, "script", "https://example.com/") + assert "missing-crossorigin" in result.reasons + + +def test_external_stylesheet_missing_integrity(parser): + tag = _tag('') + result = parser._analyze_resource(tag, "stylesheet", "https://example.com/") + assert isinstance(result, UnsafeResource) + assert "missing-integrity" in result.reasons + assert result.tag_type == "stylesheet" + + +def test_restrictive_csp_is_compensating_control(parser): + parser.csp_policies = [ + ("https://example.com/", "Content-Security-Policy", "script-src 'self' https://cdn.example.org") + ] + assert parser._has_compensating_csp() is True + + +def test_permissive_csp_is_not_compensating_control(parser): + parser.csp_policies = [ + ("https://example.com/", "Content-Security-Policy", "script-src 'unsafe-inline' *") + ] + assert parser._has_compensating_csp() is False + + +def test_integrity_pattern_accepts_known_algorithms(): + assert sri_mod.INTEGRITY_PATTERN.match("sha256-" + "a" * 44) + assert sri_mod.INTEGRITY_PATTERN.match("sha512-" + "b" * 88) + assert not sri_mod.INTEGRITY_PATTERN.match("sha1-abc")