stryx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stryx/__init__.py +35 -0
- stryx/ai/__init__.py +5 -0
- stryx/ai/attack_planner.py +199 -0
- stryx/ai/payload_generator.py +212 -0
- stryx/ai/prompts.py +85 -0
- stryx/ai/providers.py +249 -0
- stryx/attacks/__init__.py +6 -0
- stryx/attacks/attack_chain.py +111 -0
- stryx/attacks/replay.py +186 -0
- stryx/auth/__init__.py +1 -0
- stryx/auth/session_manager.py +335 -0
- stryx/cli.py +675 -0
- stryx/comparison/__init__.py +1 -0
- stryx/comparison/differ.py +255 -0
- stryx/config/__init__.py +6 -0
- stryx/config/default_config.yaml +12 -0
- stryx/config/loader.py +111 -0
- stryx/config/schema.py +80 -0
- stryx/crawler/__init__.py +5 -0
- stryx/crawler/discovery.py +178 -0
- stryx/crawler/graphql_discovery.py +86 -0
- stryx/crawler/html_crawler.py +294 -0
- stryx/crawler/js_endpoints.py +165 -0
- stryx/crawler/openapi.py +76 -0
- stryx/crawler/sitemap.py +94 -0
- stryx/orchestrator.py +347 -0
- stryx/payloads/cmdi.txt +31 -0
- stryx/payloads/header_injection.txt +15 -0
- stryx/payloads/ldap.txt +19 -0
- stryx/payloads/nosqli.txt +21 -0
- stryx/payloads/open_redirect.txt +23 -0
- stryx/payloads/path_traversal.txt +26 -0
- stryx/payloads/sqli.txt +31 -0
- stryx/payloads/ssrf.txt +30 -0
- stryx/payloads/ssti.txt +21 -0
- stryx/payloads/xss.txt +48 -0
- stryx/payloads/xxe.txt +35 -0
- stryx/plugins/__init__.py +5 -0
- stryx/plugins/base.py +85 -0
- stryx/policy/__init__.py +1 -0
- stryx/policy/engine.py +201 -0
- stryx/py.typed +0 -0
- stryx/reports/__init__.py +5 -0
- stryx/reports/generator.py +122 -0
- stryx/reports/json_report.py +72 -0
- stryx/reports/markdown_report.py +101 -0
- stryx/reports/sarif_report.py +200 -0
- stryx/reports/templates/report.html.j2 +163 -0
- stryx/reports/terminal_report.py +138 -0
- stryx/scanners/__init__.py +17 -0
- stryx/scanners/auth.py +444 -0
- stryx/scanners/authorization.py +220 -0
- stryx/scanners/blind.py +328 -0
- stryx/scanners/cloud_ssrf.py +208 -0
- stryx/scanners/cors.py +158 -0
- stryx/scanners/dependencies.py +296 -0
- stryx/scanners/disclosure.py +339 -0
- stryx/scanners/fuzz.py +391 -0
- stryx/scanners/graphql.py +309 -0
- stryx/scanners/injection.py +511 -0
- stryx/scanners/race.py +257 -0
- stryx/signatures/framework_fingerprints.yaml +122 -0
- stryx/signatures/known_vulns.yaml +210 -0
- stryx/utils/__init__.py +7 -0
- stryx/utils/evidence.py +109 -0
- stryx/utils/http_client.py +159 -0
- stryx/utils/logging.py +51 -0
- stryx/utils/rate_limiter.py +37 -0
- stryx_cli-0.1.0.dist-info/METADATA +236 -0
- stryx_cli-0.1.0.dist-info/RECORD +74 -0
- stryx_cli-0.1.0.dist-info/WHEEL +5 -0
- stryx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stryx_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- stryx_cli-0.1.0.dist-info/top_level.txt +1 -0
stryx/scanners/cors.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CORS misconfiguration scanner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from stryx.utils.evidence import Finding, Severity
|
|
6
|
+
from stryx.utils.http_client import HttpClient
|
|
7
|
+
from stryx.utils.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger("scanner.cors")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CorsScanner:
|
|
13
|
+
"""Scanner for CORS misconfiguration vulnerabilities."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: HttpClient):
|
|
16
|
+
self.client = client
|
|
17
|
+
|
|
18
|
+
async def scan(self, base_url: str) -> list[Finding]:
|
|
19
|
+
"""Run CORS tests against the target."""
|
|
20
|
+
findings: list[Finding] = []
|
|
21
|
+
logger.info("Running CORS scanner")
|
|
22
|
+
|
|
23
|
+
# Extract domain from base URL
|
|
24
|
+
domain = base_url.split("//")[-1].split("/")[0].split(":")[0]
|
|
25
|
+
|
|
26
|
+
# Test various origin patterns
|
|
27
|
+
test_origins = [
|
|
28
|
+
# Evil domains
|
|
29
|
+
("https://evil.com", "external evil domain"),
|
|
30
|
+
("https://attacker.com", "external attacker domain"),
|
|
31
|
+
# Null origin (sandboxed iframes, file:// protocol)
|
|
32
|
+
("null", "null origin"),
|
|
33
|
+
# Subdomain attacks
|
|
34
|
+
(f"https://{domain}.evil.com", "subdomain takeover"),
|
|
35
|
+
(f"https://evil{domain}", "domain prefix"),
|
|
36
|
+
(f"https://{domain}evil.com", "domain suffix"),
|
|
37
|
+
# HTTP downgrade
|
|
38
|
+
(f"http://{domain}", "HTTP downgrade"),
|
|
39
|
+
# Unicode/Punycode
|
|
40
|
+
(f"https://evil{domain}.com", "homograph domain"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
seen_origins: set[str] = set()
|
|
44
|
+
|
|
45
|
+
for origin, desc in test_origins:
|
|
46
|
+
if origin in seen_origins:
|
|
47
|
+
continue
|
|
48
|
+
seen_origins.add(origin)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response, evidence = await self.client.get(
|
|
52
|
+
base_url,
|
|
53
|
+
headers={"Origin": origin},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
acao = response.headers.get("access-control-allow-origin", "")
|
|
57
|
+
acac = response.headers.get("access-control-allow-credentials", "")
|
|
58
|
+
|
|
59
|
+
if not acao:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Check for dangerous patterns
|
|
63
|
+
finding = self._check_origin_reflection(
|
|
64
|
+
origin, acao, acac, desc, evidence
|
|
65
|
+
)
|
|
66
|
+
if finding:
|
|
67
|
+
findings.append(finding)
|
|
68
|
+
|
|
69
|
+
# Check for wildcard with credentials (very dangerous)
|
|
70
|
+
finding = self._check_wildcard_credentials(acao, acac, evidence)
|
|
71
|
+
if finding:
|
|
72
|
+
findings.append(finding)
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.debug(f"CORS test error: {e}")
|
|
76
|
+
|
|
77
|
+
logger.info(f"CORS scanner found {len(findings)} findings")
|
|
78
|
+
return findings
|
|
79
|
+
|
|
80
|
+
def _check_origin_reflection(
|
|
81
|
+
self,
|
|
82
|
+
origin: str,
|
|
83
|
+
acao: str,
|
|
84
|
+
acac: str,
|
|
85
|
+
desc: str,
|
|
86
|
+
evidence,
|
|
87
|
+
) -> Finding | None:
|
|
88
|
+
"""Check if origin is reflected in ACAO header."""
|
|
89
|
+
if acao == origin or acao == "null":
|
|
90
|
+
severity = Severity.MEDIUM
|
|
91
|
+
if acac.lower() == "true":
|
|
92
|
+
severity = Severity.HIGH
|
|
93
|
+
|
|
94
|
+
evidence.confidence = 0.8
|
|
95
|
+
evidence.payload = f"Origin: {origin}"
|
|
96
|
+
return Finding(
|
|
97
|
+
title=f"CORS reflects {desc} origin: {origin}",
|
|
98
|
+
severity=severity,
|
|
99
|
+
evidence=evidence,
|
|
100
|
+
description=(
|
|
101
|
+
f"The server reflects the Origin header '{origin}' in "
|
|
102
|
+
f"Access-Control-Allow-Origin. "
|
|
103
|
+
f"Credentials allowed: {acac}. "
|
|
104
|
+
f"This allows cross-origin requests from {desc}."
|
|
105
|
+
),
|
|
106
|
+
remediation=(
|
|
107
|
+
"Whitelist specific trusted origins. "
|
|
108
|
+
"Never reflect arbitrary origins when credentials are allowed."
|
|
109
|
+
),
|
|
110
|
+
cwe="CWE-942",
|
|
111
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
112
|
+
scanner="cors",
|
|
113
|
+
tags=["cors", "misconfiguration", desc.replace(" ", "-")],
|
|
114
|
+
)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def _check_wildcard_credentials(
|
|
118
|
+
self,
|
|
119
|
+
acao: str,
|
|
120
|
+
acac: str,
|
|
121
|
+
evidence,
|
|
122
|
+
) -> Finding | None:
|
|
123
|
+
"""Check for wildcard ACAO with credentials (very dangerous)."""
|
|
124
|
+
if acao == "*" and acac.lower() == "true":
|
|
125
|
+
evidence.confidence = 0.9
|
|
126
|
+
evidence.payload = "Access-Control-Allow-Origin: *, Credentials: true"
|
|
127
|
+
return Finding(
|
|
128
|
+
title="CORS: wildcard with credentials",
|
|
129
|
+
severity=Severity.CRITICAL,
|
|
130
|
+
evidence=evidence,
|
|
131
|
+
description=(
|
|
132
|
+
"The server returns Access-Control-Allow-Origin: * with "
|
|
133
|
+
"Access-Control-Allow-Credentials: true. This is a critical "
|
|
134
|
+
"misconfiguration that allows any origin to make credentialed requests."
|
|
135
|
+
),
|
|
136
|
+
remediation=(
|
|
137
|
+
"Remove wildcard CORS or disable credentials. "
|
|
138
|
+
"Whitelist specific trusted origins instead."
|
|
139
|
+
),
|
|
140
|
+
cwe="CWE-942",
|
|
141
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
142
|
+
scanner="cors",
|
|
143
|
+
tags=["cors", "wildcard", "credentials"],
|
|
144
|
+
)
|
|
145
|
+
elif acao == "*":
|
|
146
|
+
evidence.confidence = 0.5
|
|
147
|
+
return Finding(
|
|
148
|
+
title="CORS: wildcard Access-Control-Allow-Origin",
|
|
149
|
+
severity=Severity.LOW,
|
|
150
|
+
evidence=evidence,
|
|
151
|
+
description="The server returns Access-Control-Allow-Origin: * for all requests.",
|
|
152
|
+
remediation="Restrict CORS to specific trusted origins.",
|
|
153
|
+
cwe="CWE-942",
|
|
154
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
155
|
+
scanner="cors",
|
|
156
|
+
tags=["cors", "wildcard"],
|
|
157
|
+
)
|
|
158
|
+
return None
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""JavaScript dependency scanner.
|
|
2
|
+
|
|
3
|
+
Analyzes frontend JavaScript files to detect known vulnerable libraries
|
|
4
|
+
and versions. Identifies supply chain vulnerabilities in frontend dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from stryx.utils.evidence import Evidence, Finding, Severity
|
|
16
|
+
from stryx.utils.http_client import HttpClient
|
|
17
|
+
from stryx.utils.logging import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger("scanner.dependencies")
|
|
20
|
+
|
|
21
|
+
# Path to known vulnerabilities database
|
|
22
|
+
VULN_DB_PATH = Path(__file__).parent.parent / "signatures" / "known_vulns.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_vuln_db() -> dict[str, Any]:
|
|
26
|
+
"""Load the known vulnerabilities database."""
|
|
27
|
+
if VULN_DB_PATH.exists():
|
|
28
|
+
try:
|
|
29
|
+
with open(VULN_DB_PATH, encoding="utf-8") as f:
|
|
30
|
+
data = yaml.safe_load(f)
|
|
31
|
+
return data if isinstance(data, dict) else {}
|
|
32
|
+
except Exception:
|
|
33
|
+
logger.warning("Failed to load vulnerability database")
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_tuple(version_str: str) -> tuple[int, ...]:
|
|
38
|
+
"""Parse a version string into a comparable tuple."""
|
|
39
|
+
# Remove pre-release suffixes
|
|
40
|
+
clean = re.split(r'[-+]', version_str)[0]
|
|
41
|
+
parts = []
|
|
42
|
+
for part in clean.split('.'):
|
|
43
|
+
try:
|
|
44
|
+
parts.append(int(part))
|
|
45
|
+
except ValueError:
|
|
46
|
+
break
|
|
47
|
+
return tuple(parts) if parts else (0,)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Patterns to extract library names and versions from JS files
|
|
51
|
+
VERSION_PATTERNS = [
|
|
52
|
+
# jQuery
|
|
53
|
+
(re.compile(r'jquery[/-](\d+\.\d+\.\d+)', re.I), "jQuery"),
|
|
54
|
+
(re.compile(r'jQuery v(\d+\.\d+\.\d+)', re.I), "jQuery"),
|
|
55
|
+
(re.compile(r'@version (\d+\.\d+\.\d+).*jquery', re.I), "jQuery"),
|
|
56
|
+
# Angular
|
|
57
|
+
(re.compile(r'angular[/-](\d+\.\d+\.\d+)', re.I), "Angular"),
|
|
58
|
+
(re.compile(r'angular\.js v(\d+\.\d+\.\d+)', re.I), "Angular"),
|
|
59
|
+
(re.compile(r'@version (\d+\.\d+\.\d+).*angular', re.I), "Angular"),
|
|
60
|
+
# React
|
|
61
|
+
(re.compile(r'react[/-](\d+\.\d+\.\d+)', re.I), "React"),
|
|
62
|
+
(re.compile(r'React v(\d+\.\d+\.\d+)', re.I), "React"),
|
|
63
|
+
# Vue
|
|
64
|
+
(re.compile(r'vue[/-](\d+\.\d+\.\d+)', re.I), "Vue.js"),
|
|
65
|
+
(re.compile(r'Vue\.js v(\d+\.\d+\.\d+)', re.I), "Vue.js"),
|
|
66
|
+
# Lodash
|
|
67
|
+
(re.compile(r'lodash[/-](\d+\.\d+\.\d+)', re.I), "Lodash"),
|
|
68
|
+
(re.compile(r'@version (\d+\.\d+\.\d+).*lodash', re.I), "Lodash"),
|
|
69
|
+
# Bootstrap
|
|
70
|
+
(re.compile(r'bootstrap[/-](\d+\.\d+\.\d+)', re.I), "Bootstrap"),
|
|
71
|
+
(re.compile(r'Bootstrap v(\d+\.\d+\.\d+)', re.I), "Bootstrap"),
|
|
72
|
+
# Moment.js
|
|
73
|
+
(re.compile(r'moment[/-](\d+\.\d+\.\d+)', re.I), "Moment.js"),
|
|
74
|
+
(re.compile(r'@version (\d+\.\d+\.\d+).*moment', re.I), "Moment.js"),
|
|
75
|
+
# Underscore
|
|
76
|
+
(re.compile(r'underscore[/-](\d+\.\d+\.\d+)', re.I), "Underscore.js"),
|
|
77
|
+
(re.compile(r'@version (\d+\.\d+\.\d+).*underscore', re.I), "Underscore.js"),
|
|
78
|
+
# Backbone
|
|
79
|
+
(re.compile(r'backbone[/-](\d+\.\d+\.\d+)', re.I), "Backbone.js"),
|
|
80
|
+
# Ember
|
|
81
|
+
(re.compile(r'ember[/-](\d+\.\d+\.\d+)', re.I), "Ember.js"),
|
|
82
|
+
# Mootools
|
|
83
|
+
(re.compile(r'mootools[/-](\d+\.\d+\.\d+)', re.I), "MooTools"),
|
|
84
|
+
# Dojo
|
|
85
|
+
(re.compile(r'dojo[/-](\d+\.\d+\.\d+)', re.I), "Dojo"),
|
|
86
|
+
# ExtJS
|
|
87
|
+
(re.compile(r'extjs[/-](\d+\.\d+\.\d+)', re.I), "ExtJS"),
|
|
88
|
+
# Prototype
|
|
89
|
+
(re.compile(r'prototype[/-](\d+\.\d+\.\d+)', re.I), "Prototype.js"),
|
|
90
|
+
# Three.js
|
|
91
|
+
(re.compile(r'three[/-]js[/-](\d+\.\d+\.\d+)', re.I), "Three.js"),
|
|
92
|
+
(re.compile(r'three\.js v(\d+\.\d+\.\d+)', re.I), "Three.js"),
|
|
93
|
+
# D3
|
|
94
|
+
(re.compile(r'd3[/-](\d+\.\d+\.\d+)', re.I), "D3.js"),
|
|
95
|
+
(re.compile(r'd3\.js v(\d+\.\d+\.\d+)', re.I), "D3.js"),
|
|
96
|
+
# Chart.js
|
|
97
|
+
(re.compile(r'chart[/-]js[/-](\d+\.\d+\.\d+)', re.I), "Chart.js"),
|
|
98
|
+
# Lodash (already covered above, but CDN pattern)
|
|
99
|
+
(re.compile(r'cdn.*lodash.*?/(\d+\.\d+\.\d+)/lodash', re.I), "Lodash"),
|
|
100
|
+
# jQuery CDN
|
|
101
|
+
(re.compile(r'cdn.*jquery.*?/(\d+\.\d+\.\d+)/jquery', re.I), "jQuery"),
|
|
102
|
+
# Generic version comment pattern
|
|
103
|
+
(re.compile(r'@version\s+(\d+\.\d+\.\d+)'), "Unknown"),
|
|
104
|
+
(re.compile(r'v(\d+\.\d+\.\d+)\s'), "Unknown"),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# Patterns to detect library name from file path
|
|
108
|
+
LIBRARY_PATH_PATTERNS = [
|
|
109
|
+
(re.compile(r'/jquery[-.]?(\d+\.\d+\.\d+)', re.I), "jQuery"),
|
|
110
|
+
(re.compile(r'/angular[-.]?(\d+\.\d+\.\d+)', re.I), "Angular"),
|
|
111
|
+
(re.compile(r'/react[-.]?(\d+\.\d+\.\d+)', re.I), "React"),
|
|
112
|
+
(re.compile(r'/vue[-.]?(\d+\.\d+\.\d+)', re.I), "Vue.js"),
|
|
113
|
+
(re.compile(r'/lodash[-.]?(\d+\.\d+\.\d+)', re.I), "Lodash"),
|
|
114
|
+
(re.compile(r'/bootstrap[-.]?(\d+\.\d+\.\d+)', re.I), "Bootstrap"),
|
|
115
|
+
(re.compile(r'/moment[-.]?(\d+\.\d+\.\d+)', re.I), "Moment.js"),
|
|
116
|
+
(re.compile(r'/underscore[-.]?(\d+\.\d+\.\d+)', re.I), "Underscore.js"),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DependencyScanner:
|
|
121
|
+
"""Scanner for vulnerable JavaScript dependencies."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, client: HttpClient):
|
|
124
|
+
self.client = client
|
|
125
|
+
self._vuln_db = _load_vuln_db()
|
|
126
|
+
|
|
127
|
+
async def scan(
|
|
128
|
+
self,
|
|
129
|
+
endpoints: list[str],
|
|
130
|
+
base_url: str,
|
|
131
|
+
) -> list[Finding]:
|
|
132
|
+
"""Scan for vulnerable JavaScript dependencies."""
|
|
133
|
+
findings: list[Finding] = []
|
|
134
|
+
logger.info("Running dependency scanner")
|
|
135
|
+
|
|
136
|
+
seen_libraries: set[str] = set()
|
|
137
|
+
|
|
138
|
+
# Scan each endpoint for JS files
|
|
139
|
+
for endpoint in endpoints[:5]: # Limit to 5 pages
|
|
140
|
+
try:
|
|
141
|
+
response, _ = await self.client.get(endpoint)
|
|
142
|
+
if response.status_code != 200:
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Extract script sources
|
|
146
|
+
script_srcs = self._extract_script_sources(response.text, endpoint)
|
|
147
|
+
|
|
148
|
+
for js_url, js_content in script_srcs:
|
|
149
|
+
# Detect libraries
|
|
150
|
+
libraries = self._detect_libraries(js_content, js_url)
|
|
151
|
+
|
|
152
|
+
for lib_name, version in libraries:
|
|
153
|
+
lib_key = f"{lib_name}@{version}"
|
|
154
|
+
if lib_key in seen_libraries:
|
|
155
|
+
continue
|
|
156
|
+
seen_libraries.add(lib_key)
|
|
157
|
+
|
|
158
|
+
# Check against vulnerability database
|
|
159
|
+
vulns = self._check_vulnerabilities(lib_name, version)
|
|
160
|
+
for vuln in vulns:
|
|
161
|
+
findings.append(Finding(
|
|
162
|
+
title=f"Vulnerable library: {lib_name} {version} - {vuln['cve']}",
|
|
163
|
+
severity=Severity(vuln.get("severity", "high")),
|
|
164
|
+
evidence=Evidence(
|
|
165
|
+
request_method="GET",
|
|
166
|
+
request_url=js_url,
|
|
167
|
+
response_status=200,
|
|
168
|
+
response_body=f"Library: {lib_name}, Version: {version}",
|
|
169
|
+
response_snippet=f"CVE: {vuln['cve']}, Description: {vuln.get('description', 'N/A')}",
|
|
170
|
+
confidence=0.9,
|
|
171
|
+
),
|
|
172
|
+
description=(
|
|
173
|
+
f"The JavaScript library {lib_name} version {version} is installed. "
|
|
174
|
+
f"This version has known vulnerabilities: {vuln['cve']}. "
|
|
175
|
+
f"{vuln.get('description', '')}"
|
|
176
|
+
),
|
|
177
|
+
remediation=(
|
|
178
|
+
f"Update {lib_name} to version {vuln.get('fixed_version', 'latest')} "
|
|
179
|
+
f"or later. See {vuln.get('reference', 'N/A')} for details."
|
|
180
|
+
),
|
|
181
|
+
cwe=vuln.get("cwe", "CWE-1395"),
|
|
182
|
+
owasp="A06:2021 - Vulnerable and Outdated Components",
|
|
183
|
+
endpoint=endpoint,
|
|
184
|
+
scanner="dependencies",
|
|
185
|
+
tags=["supply-chain", "vulnerable-dependency", lib_name.lower()],
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.debug(f"Dependency scan error on {endpoint}: {e}")
|
|
190
|
+
|
|
191
|
+
logger.info(f"Dependency scanner found {len(findings)} findings")
|
|
192
|
+
return findings
|
|
193
|
+
|
|
194
|
+
def _extract_script_sources(
|
|
195
|
+
self, html: str, base_url: str
|
|
196
|
+
) -> list[tuple[str, str]]:
|
|
197
|
+
"""Extract script tag sources from HTML."""
|
|
198
|
+
from urllib.parse import urljoin
|
|
199
|
+
|
|
200
|
+
results = []
|
|
201
|
+
pattern = re.compile(
|
|
202
|
+
r'<script[^>]+src=["\']([^"\']+)["\']',
|
|
203
|
+
re.IGNORECASE,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
for match in pattern.finditer(html):
|
|
207
|
+
src = match.group(1)
|
|
208
|
+
if src.startswith(("data:", "javascript:", "blob:")):
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
full_url = urljoin(base_url, src)
|
|
212
|
+
# Only fetch same-origin scripts
|
|
213
|
+
if full_url.startswith(base_url):
|
|
214
|
+
results.append((full_url, "")) # Content fetched lazily
|
|
215
|
+
|
|
216
|
+
return results[:10] # Limit to 10 scripts
|
|
217
|
+
|
|
218
|
+
def _detect_libraries(self, js_content: str, js_url: str) -> list[tuple[str, str]]:
|
|
219
|
+
"""Detect library names and versions from JS content or URL."""
|
|
220
|
+
libraries: list[tuple[str, str]] = []
|
|
221
|
+
|
|
222
|
+
# Try version patterns on content
|
|
223
|
+
for pattern, lib_name in VERSION_PATTERNS:
|
|
224
|
+
match = pattern.search(js_content)
|
|
225
|
+
if match:
|
|
226
|
+
version = match.group(1)
|
|
227
|
+
if lib_name != "Unknown" or js_url:
|
|
228
|
+
libraries.append((lib_name if lib_name != "Unknown" else js_url, version))
|
|
229
|
+
|
|
230
|
+
# Try path patterns on URL
|
|
231
|
+
for pattern, lib_name in LIBRARY_PATH_PATTERNS:
|
|
232
|
+
match = pattern.search(js_url)
|
|
233
|
+
if match:
|
|
234
|
+
version = match.group(1)
|
|
235
|
+
libraries.append((lib_name, version))
|
|
236
|
+
|
|
237
|
+
return libraries
|
|
238
|
+
|
|
239
|
+
def _check_vulnerabilities(
|
|
240
|
+
self, library_name: str, version_str: str
|
|
241
|
+
) -> list[dict[str, Any]]:
|
|
242
|
+
"""Check a library version against the vulnerability database."""
|
|
243
|
+
vulns: list[dict[str, Any]] = []
|
|
244
|
+
version = _version_tuple(version_str)
|
|
245
|
+
|
|
246
|
+
# Check built-in vulnerability database
|
|
247
|
+
lib_vulns = self._vuln_db.get(library_name.lower(), {})
|
|
248
|
+
for cve, vuln_data in lib_vulns.items():
|
|
249
|
+
affected_range = vuln_data.get("affected", "")
|
|
250
|
+
fixed_version = vuln_data.get("fixed_version", "")
|
|
251
|
+
|
|
252
|
+
if self._version_in_range(version, affected_range, fixed_version):
|
|
253
|
+
vulns.append({
|
|
254
|
+
"cve": cve,
|
|
255
|
+
"severity": vuln_data.get("severity", "high"),
|
|
256
|
+
"description": vuln_data.get("description", ""),
|
|
257
|
+
"fixed_version": fixed_version,
|
|
258
|
+
"reference": vuln_data.get("reference", ""),
|
|
259
|
+
"cwe": vuln_data.get("cwe", "CWE-1395"),
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
return vulns
|
|
263
|
+
|
|
264
|
+
def _version_in_range(
|
|
265
|
+
self, version: tuple[int, ...], affected: str, fixed: str
|
|
266
|
+
) -> bool:
|
|
267
|
+
"""Check if a version falls within an affected range."""
|
|
268
|
+
try:
|
|
269
|
+
if fixed:
|
|
270
|
+
fixed_tuple = _version_tuple(fixed)
|
|
271
|
+
return version < fixed_tuple
|
|
272
|
+
if affected:
|
|
273
|
+
# Parse range like "<4.17.21" or ">=1.0.0,<2.0.0"
|
|
274
|
+
parts = affected.split(",")
|
|
275
|
+
for part in parts:
|
|
276
|
+
part = part.strip()
|
|
277
|
+
if part.startswith("<"):
|
|
278
|
+
upper = _version_tuple(part[1:])
|
|
279
|
+
if version >= upper:
|
|
280
|
+
return False
|
|
281
|
+
elif part.startswith(">="):
|
|
282
|
+
lower = _version_tuple(part[2:])
|
|
283
|
+
if version < lower:
|
|
284
|
+
return False
|
|
285
|
+
elif part.startswith(">"):
|
|
286
|
+
lower = _version_tuple(part[1:])
|
|
287
|
+
if version <= lower:
|
|
288
|
+
return False
|
|
289
|
+
elif part.startswith("<="):
|
|
290
|
+
upper = _version_tuple(part[1:])
|
|
291
|
+
if version > upper:
|
|
292
|
+
return False
|
|
293
|
+
return True
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return False
|