1665 lines
74 KiB
Python
1665 lines
74 KiB
Python
#!/usr/bin/python
|
||
# coding: utf-8
|
||
# Date 2025/11/04
|
||
# -------------------------------------------------------------------
|
||
# YakPanel
|
||
# -------------------------------------------------------------------
|
||
# Copyright (c) 2015-2099 YakPanel(https://www.yakpanel.com) All rights reserved.
|
||
# -------------------------------------------------------------------
|
||
# Author: wpl <wpl@yakpanel.com>
|
||
# 网站安全基础扫描模块
|
||
# -------------------------------------------------------------------
|
||
import re
|
||
import sys, json, os, public, hashlib, requests, time
|
||
import urllib.parse
|
||
from YakPanel import cache
|
||
import PluginLoader
|
||
|
||
|
||
class main:
|
||
__count = 0
|
||
__shell = "/www/server/panel/data/webbasic_shell_check.txt"
|
||
session = requests.Session()
|
||
send_time = "" # 记录上一次发送ws时间
|
||
web_name = "" # 当前检测的网站名
|
||
scan_type = "basicvulscan"
|
||
web_scan_num = 0
|
||
bar = 0
|
||
# 新增:全局进度属性,按站点×模块综合计算
|
||
_total_units = 0
|
||
_done_units = 0
|
||
_module_count_per_site = 0
|
||
# 新增:标记是否处于全站扫描上下文,避免跨次扫描累加
|
||
_in_all_scan = False
|
||
# 添加计数器
|
||
risk_count = {
|
||
"warning": 0, # 告警(0)
|
||
"low": 0, # 低危 (1)
|
||
"middle": 0, # 中危 (2)
|
||
"high": 0 # 高危 (3)
|
||
}
|
||
web_count_list = []
|
||
|
||
def GetWebInfo(self, get):
|
||
'''
|
||
@name 获取网站信息
|
||
@author wpl<2025-11-4>
|
||
@param name 网站名称
|
||
@return dict 网站信息
|
||
'''
|
||
webinfo = public.M('sites').where('project_type=? and name=?', ('PHP', get.name)).count()
|
||
if not webinfo: return False
|
||
webinfo = public.M('sites').where('project_type=? and name=?', ('PHP', get.name)).select()
|
||
return webinfo[0]
|
||
|
||
def GetAllSite(self, get):
|
||
'''
|
||
@name 获取所有网站信息
|
||
@author wpl<2025-11-4>
|
||
@return list 所有网站信息
|
||
'''
|
||
webinfo = public.M('sites').where('project_type=?', ('PHP',)).select()
|
||
return webinfo
|
||
|
||
def WebConfigSecurity(self, webinfo, get):
|
||
'''
|
||
@name 网站配置安全性检测
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning website configuration security for %s" % get.name,
|
||
"type": "webscan",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
|
||
# Nginx版本泄露检测
|
||
if public.get_webserver() == 'nginx':
|
||
nginx_path = '/www/server/nginx/conf/nginx.conf'
|
||
if os.path.exists(nginx_path):
|
||
nginx_info = public.ReadFile(nginx_path)
|
||
if not 'server_tokens off' in nginx_info:
|
||
result.append({
|
||
"name": public.lang("{} website has Nginx version disclosure",get.name) ,
|
||
"info": public.lang("Nginx version disclosure may expose sensitive server information and pose security risks"),
|
||
"repair": public.lang("Open the nginx.conf file for {} website and add 'server_tokens off;' inside the http { } block;", get.name),
|
||
"dangerous": 1,
|
||
"type": "webscan"
|
||
})
|
||
|
||
# PHP版本泄露检测
|
||
phpversion = public.get_site_php_version(get.name)
|
||
phpini = '/www/server/php/%s/etc/php.ini' % phpversion
|
||
if os.path.exists(phpini):
|
||
php_info = public.ReadFile(phpini)
|
||
if not 'expose_php = Off' in php_info:
|
||
result.append({
|
||
"name": public.lang("{} website has PHP version disclosure",get.name),
|
||
"info": public.lang("PHP version disclosure may expose sensitive server information and pose security risks"),
|
||
"repair": public.lang("Open the php.ini file for {} website and set 'expose_php = Off'",get.name),
|
||
"dangerous": 1, "type": "webscan"
|
||
})
|
||
|
||
# 防火墙检测
|
||
# 跳过ols
|
||
if public.get_webserver() != 'openlitespeed':
|
||
if not os.path.exists("/www/server/btwaf/"):
|
||
result.append({
|
||
"name": public.lang("Firewall not installed for {} website",get.name),
|
||
"info": public.lang("Not having a firewall installed may expose sensitive server information and pose security risks"),
|
||
"repair": public.lang("Install or enable the Nginx WAF"),
|
||
"dangerous": 0, "type": "webscan"
|
||
})
|
||
|
||
# 防跨站攻击检测
|
||
web_infos = public.M('sites').where("name=?", (get.name, )).select()
|
||
for web in web_infos:
|
||
run_path = self.GetSiteRunPath(web["name"], web["path"])
|
||
if not run_path:
|
||
continue
|
||
path = web["path"] + run_path
|
||
user_ini_file = path + '/.user.ini'
|
||
|
||
if not os.path.exists(user_ini_file):
|
||
continue
|
||
user_ini_conf = public.readFile(user_ini_file)
|
||
if "open_basedir" not in user_ini_conf:
|
||
result.append({
|
||
"name": public.lang("Cross-site attack protection not enabled for {} website",get.name),
|
||
"info": public.lang("Without cross-site attack protection, sensitive server data may be exposed via directory traversal"),
|
||
"repair": public.lang("Enable open_basedir protection in the website directory to prevent attackers from reading sensitive files across directories"),
|
||
"dangerous": 0, "type": "webscan"
|
||
})
|
||
|
||
# SSL证书安全性检测
|
||
self.WebSSLSecurity(webinfo, get, result)
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Completed scanning website configuration security for %s" % get.name,
|
||
"type": "webscan",
|
||
"results": result,
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
def _read_recent_logs(self, log_path, lines=10000):
|
||
"""
|
||
读取最近N行日志并进行初步过滤
|
||
@param log_path 日志文件路径
|
||
@param lines 读取行数
|
||
@return 过滤后的日志内容
|
||
"""
|
||
try:
|
||
# 只读取状态码为200,301,302,403,404,500的请求,过滤掉静态资源请求
|
||
cmd = f"tail -n {lines} '{log_path}' | grep -E ' (200|301|302|403|404|500) ' | grep -v -E '\\.(css|js|png|jpg|jpeg|gif|ico|woff|woff2|ttf|svg)( |\\?|$)'"
|
||
result = public.ExecShell(cmd)[0]
|
||
# 过滤掉空行
|
||
result = [line for line in result.split('\n') if line.strip()]
|
||
return result
|
||
except Exception as e:
|
||
# 如果grep失败,直接读取原始日志
|
||
cmd = f"tail -n {lines} '{log_path}'"
|
||
return public.ExecShell(cmd)[0]
|
||
|
||
def _analyze_attack_distribution(self, log_content, security_patterns):
|
||
"""
|
||
分析各类攻击的数量和分布
|
||
@param log_content 日志内容
|
||
@param security_patterns 安全检测规则
|
||
@return 攻击统计信息
|
||
"""
|
||
attack_stats = {}
|
||
|
||
for attack_type, pattern_info in security_patterns.items():
|
||
attack_stats[attack_type] = {
|
||
'count': 0,
|
||
'sample_ips': [],
|
||
'sample_urls': []
|
||
}
|
||
|
||
for line in log_content.split('\n'):
|
||
if line.strip():
|
||
ip = None
|
||
req_url = None
|
||
ref_url = None
|
||
try:
|
||
parts = line.split()
|
||
if parts:
|
||
ip = parts[0]
|
||
except:
|
||
pass
|
||
try:
|
||
m_req = re.search(r"\"?(GET|POST|HEAD|PUT|DELETE|OPTIONS|PATCH)\s+(.*?)\s+HTTP\/[0-9.]+\"?", line)
|
||
if m_req:
|
||
req_url = m_req.group(2)
|
||
except:
|
||
pass
|
||
try:
|
||
qs = re.findall(r'"([^\"]*)"', line)
|
||
if len(qs) >= 2:
|
||
ref_full = qs[1]
|
||
if ref_full and ref_full != '-':
|
||
try:
|
||
parsed = urllib.parse.urlparse(ref_full)
|
||
ref_url = (parsed.path or '/') + (('?' + parsed.query) if parsed.query else '')
|
||
except:
|
||
ref_url = ref_full
|
||
except:
|
||
pass
|
||
def field_matches(s):
|
||
if not s:
|
||
return False
|
||
s_low = s.lower()
|
||
try:
|
||
dec = urllib.parse.unquote(s_low)
|
||
except:
|
||
dec = s_low
|
||
for p in pattern_info['patterns']:
|
||
pl = p.lower()
|
||
if pl in s_low or (dec and pl in dec):
|
||
return True
|
||
return False
|
||
matched = False
|
||
if field_matches(req_url):
|
||
matched = True
|
||
url = req_url
|
||
elif field_matches(ref_url):
|
||
matched = True
|
||
url = ref_url
|
||
if matched:
|
||
attack_stats[attack_type]['count'] += 1
|
||
try:
|
||
if ip and ip not in attack_stats[attack_type]['sample_ips']:
|
||
attack_stats[attack_type]['sample_ips'].append(ip)
|
||
except:
|
||
pass
|
||
try:
|
||
if url and url not in attack_stats[attack_type]['sample_urls']:
|
||
attack_stats[attack_type]['sample_urls'].append(url)
|
||
except:
|
||
pass
|
||
|
||
return attack_stats
|
||
|
||
def _analyze_ip_frequency(self, log_content):
|
||
"""
|
||
分析IP访问频率,返回访问次数统计
|
||
@param log_content 日志内容
|
||
@return IP访问频率统计
|
||
"""
|
||
ip_stats = {}
|
||
|
||
for line in log_content.split('\n'):
|
||
if line.strip():
|
||
try:
|
||
ip = line.split()[0]
|
||
ip_stats[ip] = ip_stats.get(ip, 0) + 1
|
||
except:
|
||
continue
|
||
|
||
# 返回访问次数排序的结果
|
||
return sorted(ip_stats.items(), key=lambda x: x[1], reverse=True)
|
||
|
||
def _analyze_url_attacks(self, log_content, security_patterns):
|
||
"""
|
||
分析被攻击的URL统计
|
||
@param log_content 日志内容
|
||
@param security_patterns 安全检测规则
|
||
@return 被攻击URL统计
|
||
"""
|
||
url_attacks = {}
|
||
|
||
# 收集所有攻击模式
|
||
all_patterns = []
|
||
for pattern_info in security_patterns.values():
|
||
all_patterns.extend(pattern_info['patterns'])
|
||
|
||
for line in log_content.split('\n'):
|
||
if line.strip():
|
||
try:
|
||
parts = line.split()
|
||
if len(parts) >= 7:
|
||
url = parts[6]
|
||
line_lower = line.lower()
|
||
|
||
# 检查是否包含攻击模式
|
||
for pattern in all_patterns:
|
||
if pattern.lower() in line_lower:
|
||
url_attacks[url] = url_attacks.get(url, 0) + 1
|
||
break
|
||
except:
|
||
continue
|
||
|
||
# 返回攻击次数排序的结果
|
||
return sorted(url_attacks.items(), key=lambda x: x[1], reverse=True)
|
||
|
||
def WebSSLSecurity(self, webinfo, get, result):
|
||
'''
|
||
@name SSL证书安全性检测
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@param result 结果列表
|
||
'''
|
||
if public.get_webserver() == 'nginx':
|
||
conf_file = '/www/server/panel/vhost/nginx/{}.conf'.format(webinfo['name'])
|
||
if os.path.exists(conf_file):
|
||
conf_info = public.ReadFile(conf_file)
|
||
keyText = 'ssl_certificate'
|
||
|
||
if conf_info.find(keyText) == -1:
|
||
result.append({
|
||
"name": public.lang("{} website has SSL disabled",webinfo['name']),
|
||
"info": public.lang("SSL is not deployed may expose sensitive server information and pose security risks"),
|
||
"repair": public.lang("Deploy SSL in Website → SSL"),
|
||
"dangerous": 0,
|
||
"type": "webscan"
|
||
})
|
||
# 暂未实现 TLSv1 检测
|
||
# if 'TLSv1 ' in conf_info:
|
||
# result.append({
|
||
# "name": "%s website has insecure TLSv1 protocol enabled" % webinfo['name'],
|
||
# "info": "Enabling the insecure TLSv1 protocol may expose sensitive server information and pose security risks",
|
||
# "repair": "In Website Settings, go to Advanced Settings → TLS Settings and disable TLSv1",
|
||
# "dangerous": 2,
|
||
# "type": "webscan"
|
||
# })
|
||
|
||
def WebFileLeakDetection(self, webinfo, get):
|
||
'''
|
||
@name 文件泄露检测
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning %s for file leaks" % get.name,
|
||
"type": "fileleak",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
site_path = webinfo['path']
|
||
|
||
# 检测敏感文件
|
||
sensitive_files = ['.env', '.git', '.svn', '.DS_Store']
|
||
|
||
for filename in sensitive_files:
|
||
file_path = os.path.join(site_path, filename)
|
||
if os.path.exists(file_path):
|
||
result.append({
|
||
"name": public.lang("Sensitive file detected on {} website",webinfo['name']),
|
||
"info": public.lang("Sensitive file 【{}】exposed — may leak server-sensitive information and pose security risks",filename),
|
||
"repair": public.lang("Delete or move the sensitive file outside the website root directory"),
|
||
"dangerous": 2,
|
||
"type": "fileleak",
|
||
"file_path": file_path
|
||
})
|
||
|
||
# 检测SQL文件 只检测网站根目录下的SQL文件
|
||
# 只检测网站根目录下的SQL文件
|
||
try:
|
||
files = os.listdir(site_path)
|
||
for file in files:
|
||
if file.endswith('.sql'):
|
||
file_path = os.path.join(site_path, file)
|
||
result.append({
|
||
"name": public.lang("SQL database file detected on {} website",webinfo['name']),
|
||
"info": public.lang("SQL database file 【{}】exposed — may leak server-sensitive information and pose security risks",file),
|
||
"repair": public.lang("Delete or move the SQL file outside the website root directory"),
|
||
"dangerous": 3,
|
||
"type": "fileleak",
|
||
"file_path": file_path
|
||
})
|
||
except Exception as e:
|
||
# 如果无法访问目录,记录错误但不中断扫描
|
||
pass
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "File leak scan for %s completed, found %d issues" % (get.name, len(result)),
|
||
"results": result,
|
||
"type": "fileleak",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
def WebRootTrojanDetection(self, webinfo, get):
|
||
'''
|
||
@name 木马检测
|
||
@author wpl<2025-11-5>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning %s root directory for trojan files" % get.name,
|
||
"type": "webshell",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
self.__count = 0
|
||
|
||
# 仅列出网站根目录中的文件
|
||
base_path = webinfo.get('path') if isinstance(webinfo, dict) else None
|
||
if not base_path or not os.path.isdir(base_path):
|
||
return result
|
||
try:
|
||
entries = os.listdir(base_path)
|
||
except Exception:
|
||
entries = []
|
||
|
||
file_list = []
|
||
for name in entries:
|
||
fp = os.path.join(base_path, name)
|
||
# 仅扫描php文件
|
||
if os.path.isfile(fp) and name.lower().endswith('.php'):
|
||
file_list.append(fp)
|
||
|
||
if not file_list:
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "No files to scan found in %s root directory" % get.name,
|
||
"type": "webshell",
|
||
"bar": self.bar
|
||
}))
|
||
return result
|
||
|
||
self.__count = len(file_list)
|
||
# 本地正则匹配检测
|
||
rules = [
|
||
"@\\$\\_=", "eval\\(('|\")\\?>", "php_valueauto_append_file", "eval\\(gzinflate\\(",
|
||
"eval\\(str_rot13\\(",
|
||
"base64\\_decode\\(\\$\\_", "eval\\(gzuncompress\\(", "phpjm\\.net", "assert\\(('|\"|\\s*)\\$",
|
||
"require_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "gzinflate\\(base64_decode\\(",
|
||
"echo\\(file_get_contents\\(('|\")\\$_(POST|GET|REQUEST|COOKIE)", "c99shell", "cmd\\.php",
|
||
"call_user_func\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "str_rot13", "webshell", "EgY_SpIdEr",
|
||
"tools88\\.com", "SECFORCE", "eval\\(base64_decode\\(",
|
||
"include\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"array_map[\\s]{0,20}\\(.{1,5}(eval|assert|ass\\\\x65rt).{1,20}\\$_(GET|POST|REQUEST).{0,15}",
|
||
"call_user_func[\\s]{0,25}\\(.{0,25}\\$_(GET|POST|REQUEST).{0,15}",
|
||
"gzdeflate|gzcompress|gzencode",
|
||
"require_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"include_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"call_user_func\\((\"|')assert(\"|')",
|
||
"php_valueauto_prepend_file", "SetHandlerapplication\\/x-httpd-php",
|
||
"fputs\\(fopen\\((.+),('|'\")w('|'\")\\),('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)\\[",
|
||
"file_put_contents\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)\\[([^\\]]+)\\],('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"\\$_(POST|GET|REQUEST|COOKIE)\\[([^\\]]+)\\]\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)\\[",
|
||
"require\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "assert\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"eval\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "base64_decode\\(gzuncompress\\(",
|
||
"gzuncompress\\(base64_decode\\(", "ies\",gzuncompress\\(\\$", "eval\\(gzdecode\\(",
|
||
"preg_replace\\(\"\\/\\.\\*\\/e\"", "Scanners", "phpspy", "cha88\\.cn",
|
||
"chr\\((\\d)+\\)\\.chr\\((\\d)+\\)",
|
||
"\\$\\_=\\$\\_", "\\$(\\w)+\\(\\${", "\\(array\\)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"\\$(\\w)+\\(\"\\/(\\S)+\\/e",
|
||
"\"e\"\\.\"v\"\\.\"a\"\\.\"l\"", "\"e\"\\.\"v\"\\.\"a\"\\.\"l\"", "'e'\\.'v'\\.'a'\\.'l'",
|
||
"@preg\\_replace\\((\")*\\/(\\S)*\\/e(\")*,\\$_POST\\[\\S*\\]", "\\${'\\_'", "@\\$\\_\\(\\$\\_",
|
||
"\\$\\_=\"\""
|
||
]
|
||
patterns = [re.compile(p, re.IGNORECASE) for p in rules]
|
||
|
||
shell_files = []
|
||
for fp in file_list:
|
||
try:
|
||
with open(fp, 'rb') as f:
|
||
data = f.read()
|
||
try:
|
||
text = data.decode('utf-8', errors='ignore')
|
||
except Exception:
|
||
text = data.decode('latin-1', errors='ignore')
|
||
for pat in patterns:
|
||
if pat.search(text):
|
||
shell_files.append(fp)
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
for shell_file in shell_files:
|
||
result.append({
|
||
"name": public.lang("Trojan file detected in {} website root directory",webinfo.get('name', get.name)),
|
||
"info": public.lang("Trojan file 【{}】detected — may expose sensitive server information and pose security risks",shell_file),
|
||
"repair": public.lang("Delete the trojan file or perform a security audit"),
|
||
"dangerous": 3, "type": "webshell",
|
||
"file_path": shell_file
|
||
})
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Trojan scan for %s root directory completed, found %d trojans" % (get.name, len(result)),
|
||
"results": result,
|
||
"type": "webshell",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
|
||
def WebBackupFileDetection(self, webinfo, get):
|
||
'''
|
||
@name 备份文件检测
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning %s for backup files" % get.name,
|
||
"type": "backup",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
site_path = webinfo['path']
|
||
|
||
# 备份文件扩展名
|
||
backup_extensions = ['.bak', '.backup', '.zip', '.rar', '.tar', '.gz', '.7z']
|
||
|
||
# 只扫描站点根目录是否存在备份文件(不遍历子目录)
|
||
if os.path.exists(site_path):
|
||
try:
|
||
files = os.listdir(site_path)
|
||
for file in files:
|
||
file_path = os.path.join(site_path, file)
|
||
# 只检查文件,跳过目录
|
||
if os.path.isfile(file_path):
|
||
file_lower = file.lower()
|
||
for ext in backup_extensions:
|
||
if file_lower.endswith(ext):
|
||
result.append({
|
||
"name": public.lang("Backup file detected on {} website", webinfo['name']),
|
||
"info": public.lang("Backup file 【{}】exposed — may leak sensitive server information and pose security risks",file),
|
||
"repair": public.lang("Delete the backup file or move it to a secure location"),
|
||
"dangerous": 2, "type": "backup",
|
||
"file_path": file_path
|
||
})
|
||
break
|
||
except Exception as e:
|
||
# 如果无法访问目录,记录错误但不中断扫描
|
||
pass
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Backup file scan for %s completed, found %d backup files" % (get.name, len(result)),
|
||
"results": result,
|
||
"type": "backup",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
def WebWeakPasswordDetection(self, webinfo, get):
|
||
'''
|
||
@name 弱口令检测(数据库与FTP)
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning %s for weak passwords" % get.name,
|
||
"type": "weakpass",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
|
||
# 读取弱口令字典
|
||
weekpassfile = "/www/server/panel/config/weak_pass.txt"
|
||
pass_list = []
|
||
if os.path.exists(weekpassfile):
|
||
try:
|
||
pass_info = public.ReadFile(weekpassfile)
|
||
pass_list = [p.strip() for p in pass_info.split('\n') if p.strip()]
|
||
except:
|
||
pass
|
||
|
||
# 获取站点ID
|
||
web_id = None
|
||
try:
|
||
if isinstance(webinfo, dict):
|
||
web_id = webinfo.get('id')
|
||
|
||
except:
|
||
web_id = None
|
||
|
||
# 数据库弱口令检测
|
||
if pass_list and web_id:
|
||
try:
|
||
database = public.M('databases').where("pid=?", (web_id,)).select()
|
||
if isinstance(database, list):
|
||
for dbinfo in database:
|
||
pwd = dbinfo.get('password')
|
||
if not pwd:
|
||
continue
|
||
if pwd in pass_list:
|
||
dbname = dbinfo.get('name', '')
|
||
# 密码脱敏
|
||
if hasattr(self, 'short_passwd'):
|
||
masked = self.short_passwd(pwd)
|
||
else:
|
||
plen = len(pwd)
|
||
masked = (pwd[:2] + "**" + pwd[-2:]) if plen > 4 else ((pwd[:1] + "****" + pwd[-1]) if 1 < plen <= 4 else "******")
|
||
result.append({
|
||
"name": public.lang("{} website database uses a weak password",webinfo.get('name', '')),
|
||
"info": public.lang("Website %s database 【{}】uses a weak password: {}",webinfo.get('name', ''), dbname, masked),
|
||
"repair": public.lang("It is recommended to change the database user password in the panel to prevent brute-force attacks and data theft"),
|
||
"dangerous": 1, "type": "weakpass"
|
||
})
|
||
except:
|
||
pass
|
||
|
||
# FTP弱口令检测
|
||
if pass_list and web_id:
|
||
try:
|
||
ftps = public.M('ftps').where("pid=?", (web_id,)).select()
|
||
if isinstance(ftps, list):
|
||
for ftpinfo in ftps:
|
||
pwd = ftpinfo.get('password')
|
||
if not pwd:
|
||
continue
|
||
if pwd in pass_list:
|
||
ftpname = ftpinfo.get('name', '')
|
||
if hasattr(self, 'short_passwd'):
|
||
masked = self.short_passwd(pwd)
|
||
else:
|
||
plen = len(pwd)
|
||
masked = (pwd[:2] + "**" + pwd[-2:]) if plen > 4 else ((pwd[:1] + "****" + pwd[-1]) if 1 < plen <= 4 else "******")
|
||
result.append({
|
||
"name": public.lang("{} website FTP account uses a weak password",webinfo.get('name', '')),
|
||
"info": public.lang("Website {} FTP account 【{}】uses a weak password: {}",webinfo.get('name', ''), ftpname, masked),
|
||
"repair": public.lang("Change the weak password immediately to prevent attackers from brute-forcing FTP credentials and tampering with website files"),
|
||
"dangerous": 2, "type": "weakpass"
|
||
})
|
||
except:
|
||
pass
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Weak password scan for %s completed, found %d issues" % (get.name, len(result)),
|
||
"results": result,
|
||
"type": "weakpass",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
def WebLogDetection(self, webinfo, get):
|
||
'''
|
||
@name 网站日志检测
|
||
@author wpl<2025-11-4>
|
||
@param webinfo 网站信息
|
||
@param get 请求参数
|
||
@return list 检测结果
|
||
'''
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning %s website logs" % get.name,
|
||
"type": "weblog",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
result = []
|
||
|
||
# 安全检测规则定义
|
||
security_patterns = {
|
||
'xss': {
|
||
'patterns': ['javascript:', 'data:', 'alert(', 'onerror=', 'onload=', 'onclick=',
|
||
'%3Cscript', '%3Csvg/', '%3Ciframe/', '<script>', '<svg/', '<iframe/',
|
||
'document.cookie', 'document.location', 'window.location', 'eval(',
|
||
'expression(', 'vbscript:', 'livescript:', 'mocha:'],
|
||
'name': public.lang('XSS (Cross-Site Scripting) Attack'),
|
||
'repair': public.lang('To protect your website, it is recommended to enable the [Nginx WAF] to effectively block XSS attacks on this site.'),
|
||
'risk_level': 0
|
||
},
|
||
'sql_injection': {
|
||
'patterns': ['union select', 'or 1=1', 'and 1=1', 'drop table', 'insert into',
|
||
'delete from', 'update set', 'exec(', 'execute(', 'sp_', 'xp_',
|
||
'information_schema', 'mysql.user', 'pg_user', 'sysobjects',
|
||
'waitfor delay', 'benchmark(', 'sleep(', 'pg_sleep'],
|
||
'name': public.lang('SQL Injection Attack'),
|
||
'repair': public.lang('To protect your website, it is recommended to enable the [Nginx WAF] to effectively block SQL injection attacks on this site.'),
|
||
'risk_level': 0
|
||
},
|
||
'file_traversal': {
|
||
'patterns': ['../', '..\\', '/etc/', '/var/', '/usr/', '/root/', '/home/',
|
||
'.git/', '.svn/', '.env', '.htaccess', '.htpasswd', 'web.config',
|
||
'wp-config.php', 'database.php', '/proc/', '/dev/',
|
||
'file://', 'php://filter', 'php://input'],
|
||
'name': public.lang('Directory Traversal / File Inclusion Attack'),
|
||
'repair': public.lang('To protect your website, it is recommended to enable the [Nginx WAF] to effectively block directory traversal and file inclusion attacks on this site.'),
|
||
'risk_level': 0
|
||
},
|
||
'php_execution': {
|
||
'patterns': ['eval(', 'system(', 'exec(', 'shell_exec(', 'passthru(',
|
||
'file_get_contents(', 'file_put_contents(', 'fopen(', 'fwrite(',
|
||
'phpinfo(', 'base64_decode(', 'gzinflate(', 'str_rot13(',
|
||
'php://', 'data://', 'expect://', 'phar://', 'assert(',
|
||
'preg_replace(/.*e', 'create_function('],
|
||
'name': public.lang('PHP Code Execution Attack'),
|
||
'repair': public.lang('To protect your website, it is recommended to enable the [Nginx WAF] to effectively block PHP code execution attacks on this site.'),
|
||
'risk_level': 0
|
||
},
|
||
'sensitive_files': {
|
||
'patterns': ['.env', '.env.local', '.env.production', 'wp-config.php',
|
||
'database.php', 'settings.php', '.htaccess', '.htpasswd', 'web.config',
|
||
'composer.json', 'package.json', '.git/config', 'phpinfo.php',
|
||
'info.php', 'test.php', 'shell.php', 'webshell.php', 'setup.php', '.DS_Store', 'Thumbs.db'],
|
||
'name': public.lang('Attempted Access to Sensitive Files'),
|
||
'repair': public.lang('To protect your website, it is recommended to enable the [Nginx WAF] to effectively block attempts to access sensitive files on this site.'),
|
||
'risk_level': 0
|
||
}
|
||
}
|
||
|
||
# 只检测access.log文件,移除error.log检测
|
||
access_log = '/www/wwwlogs/%s.log' % get.name
|
||
|
||
# 检测日志文件大小
|
||
if os.path.exists(access_log):
|
||
file_size = os.path.getsize(access_log)
|
||
try:
|
||
# 优化的日志读取策略:只读取最近10000行,并进行初步过滤
|
||
log_content = self._read_recent_logs(access_log, 10000)
|
||
|
||
# 归一化日志内容,确保分析函数总是接收到字符串
|
||
if isinstance(log_content, list):
|
||
normalized_lines = []
|
||
for line in log_content:
|
||
if isinstance(line, bytes):
|
||
try:
|
||
normalized_lines.append(line.decode('utf-8', errors='ignore'))
|
||
except Exception:
|
||
normalized_lines.append(line.decode('latin-1', errors='ignore'))
|
||
elif isinstance(line, str):
|
||
normalized_lines.append(line)
|
||
elif line is None:
|
||
continue
|
||
else:
|
||
# 对于其他类型(如数字/对象),转为字符串以避免异常
|
||
normalized_lines.append(str(line))
|
||
log_content = '\n'.join(normalized_lines)
|
||
elif isinstance(log_content, bytes):
|
||
try:
|
||
log_content = log_content.decode('utf-8', errors='ignore')
|
||
except Exception:
|
||
log_content = log_content.decode('latin-1', errors='ignore')
|
||
|
||
if log_content:
|
||
# 统计分析攻击类型分布
|
||
attack_stats = self._analyze_attack_distribution(log_content, security_patterns)
|
||
# 分析IP访问频率
|
||
ip_stats = self._analyze_ip_frequency(log_content)
|
||
|
||
|
||
for attack_type, stats in attack_stats.items():
|
||
if stats['count'] > 0:
|
||
pattern_info = security_patterns[attack_type]
|
||
info_text = None
|
||
agg = {}
|
||
# 聚合攻击记录,按IP、URL和类型分类
|
||
for line in log_content.split('\n'):
|
||
ip = None
|
||
tm = None
|
||
req_url = None
|
||
ref_url = None
|
||
try:
|
||
parts = line.split()
|
||
if parts:
|
||
ip = parts[0]
|
||
except:
|
||
pass
|
||
try:
|
||
m = re.search(r"\[(.*?)\]", line)
|
||
if m:
|
||
tm = m.group(1)
|
||
except:
|
||
pass
|
||
try:
|
||
# 提取请求URL,支持包含参数的情况
|
||
m_req = re.search(r"\"?(GET|POST|HEAD|PUT|DELETE|OPTIONS|PATCH)\s+(.*?)\s+HTTP\/[0-9.]+\"?", line)
|
||
if m_req:
|
||
req_url = m_req.group(2)
|
||
except:
|
||
pass
|
||
try:
|
||
# 提取引用URL,支持包含参数的情况
|
||
qs = re.findall(r'"([^"]*)"', line)
|
||
if len(qs) >= 2:
|
||
ref_full = qs[1]
|
||
if ref_full and ref_full != '-':
|
||
try:
|
||
parsed = urllib.parse.urlparse(ref_full)
|
||
ref_url = (parsed.path or '/') + (('?' + parsed.query) if parsed.query else '')
|
||
except:
|
||
ref_url = ref_full
|
||
except:
|
||
pass
|
||
if not ip:
|
||
continue
|
||
# 检查请求URL或引用URL是否匹配攻击模式
|
||
def field_matches(s):
|
||
if not s:
|
||
return False
|
||
s_low = s.lower()
|
||
try:
|
||
dec = urllib.parse.unquote(s_low)
|
||
except:
|
||
dec = s_low
|
||
for p in pattern_info['patterns']:
|
||
pl = p.lower()
|
||
if pl in s_low or (dec and pl in dec):
|
||
return True
|
||
return False
|
||
req_matched = field_matches(req_url)
|
||
# 如果请求URL匹配攻击模式,将其添加到聚合结果中
|
||
if req_matched:
|
||
key = (ip, req_url, 'request')
|
||
if key not in agg:
|
||
agg[key] = {'count': 0, 'time': tm}
|
||
agg[key]['count'] += 1
|
||
if tm:
|
||
agg[key]['time'] = tm
|
||
else:
|
||
if field_matches(ref_url):
|
||
key = (ip, ref_url, 'referer')
|
||
if key not in agg:
|
||
agg[key] = {'count': 0, 'time': tm}
|
||
agg[key]['count'] += 1
|
||
if tm:
|
||
agg[key]['time'] = tm
|
||
# 对聚合结果进行排序
|
||
items = []
|
||
for (ip, url, src), v in agg.items():
|
||
items.append((v['count'], v.get('time', ''), ip, url))
|
||
items.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||
lines = []
|
||
|
||
# 仅显示前5条记录
|
||
for c, t, ip, url in items[:5]:
|
||
disp_t = t
|
||
if t:
|
||
mt = re.search(r"(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})", t)
|
||
if mt:
|
||
disp_t = f"{mt.group(3)}:{mt.group(4)}:{mt.group(5)}:{mt.group(6)}"
|
||
u = url if isinstance(url, str) else ''
|
||
if len(u) > 200:
|
||
u = u[:200] + '...'
|
||
lines.append(f"At {disp_t}, IP {ip} made {c} attempts of {pattern_info['name']} with URL: {u}")
|
||
info_text = "\r\n".join(lines) if lines else f"Detected {pattern_info['name']} attacks ({stats['count']} attempts)"
|
||
result.append({
|
||
"name": f"{webinfo['name']} – Detected {pattern_info['name']}",
|
||
"info": info_text if info_text else f"An attack of {pattern_info['name']} was detected, with a total of {stats['count']} attempts.",
|
||
"repair": pattern_info['repair'],
|
||
"dangerous": pattern_info['risk_level'],
|
||
"type": "weblog",
|
||
"attack_count": stats['count'],
|
||
"attack_type": attack_type,
|
||
"sample_ips": stats['sample_ips'][:5]
|
||
})
|
||
|
||
# IP Top10
|
||
if ip_stats:
|
||
suspicious_ips = [f"{ip}|{count}" for ip, count in ip_stats][:10]
|
||
if suspicious_ips:
|
||
self._last_weblog_ip_top = suspicious_ips
|
||
|
||
|
||
except Exception as e:
|
||
result.append({
|
||
"name": public.lang("Error occurred during log analysis"),
|
||
"repair": public.lang("Please check log file permissions and format. Error: {}",str(e)),
|
||
"dangerous": 1,
|
||
"type": "weblog"
|
||
})
|
||
else:
|
||
# 未找到日志文件,不添加任何结果项
|
||
pass
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Log scan for %s completed, found %d issues" % (get.name, len(result)),
|
||
"type": "weblog",
|
||
"results": result,
|
||
"bar": self.bar
|
||
}))
|
||
|
||
return result
|
||
|
||
def ScanSingleSite(self, get):
|
||
'''
|
||
@name 单站点扫描
|
||
@author wpl<2025-11-4>
|
||
@param get 请求参数
|
||
@return dict 扫描结果
|
||
'''
|
||
# 记录扫描开始时间用于耗时统计
|
||
self._scan_start_time = time.time()
|
||
# 若不处于全站扫描上下文,重置全局累计风险计数,避免跨次扫描累加
|
||
if not getattr(self, '_in_all_scan', False):
|
||
self.risk_count = {"warning": 0, "low": 0, "middle": 0, "high": 0}
|
||
# 重置当前扫描的风险计数器
|
||
current_risk_count = {"warning": 0, "low": 0, "middle": 0, "high": 0}
|
||
|
||
# 添加当前网站到扫描列表
|
||
if not hasattr(self, 'web_count_list'):
|
||
self.web_count_list = []
|
||
if get.name not in self.web_count_list:
|
||
self.web_count_list.append(get.name)
|
||
|
||
webinfo = self.GetWebInfo(get)
|
||
if not webinfo:
|
||
return {'status': False, 'msg': 'Website does not exist', 'data': None}
|
||
|
||
# 初始化结果结构
|
||
scan_result = {
|
||
'site_name': get.name,
|
||
'scan_time': public.format_date(),
|
||
'results': {
|
||
'webscan': [], # 网站配置安全性
|
||
'fileleak': [], # 文件泄露检测
|
||
'webshell': [], # 木马检测
|
||
'backup': [], # 备份文件
|
||
'weakpass': [], # 弱口令检测
|
||
'weblog': [] # 网站日志
|
||
},
|
||
'risk_count': current_risk_count,
|
||
'meta': {}
|
||
}
|
||
|
||
try:
|
||
# 执行各项检测
|
||
if hasattr(get, 'scan_types'):
|
||
scan_types = get.scan_types if isinstance(get.scan_types, list) else [get.scan_types]
|
||
else:
|
||
scan_types = ['webscan', 'fileleak', 'webshell', 'backup', 'weakpass', 'weblog']
|
||
# 计算当前站点将执行的模块数量(不包含未启用的 webshell)
|
||
base_modules = ['webscan', 'fileleak', 'backup', 'weakpass', 'weblog']
|
||
effective_modules = [m for m in base_modules if m in scan_types]
|
||
_site_total = max(1, len(effective_modules))
|
||
_site_done = 0
|
||
# 网站配置安全性检测
|
||
if 'webscan' in scan_types:
|
||
scan_result['results']['webscan'] = self.WebConfigSecurity(webinfo, get)
|
||
_site_done += 1
|
||
if self._total_units > 0:
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
else:
|
||
self.bar = int((_site_done / _site_total) * 100)
|
||
# 文件泄露检测
|
||
if 'fileleak' in scan_types:
|
||
scan_result['results']['fileleak'] = self.WebFileLeakDetection(webinfo, get)
|
||
_site_done += 1
|
||
if self._total_units > 0:
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
else:
|
||
self.bar = int((_site_done / _site_total) * 100)
|
||
# 木马检测
|
||
# if 'webshell' in scan_types:
|
||
# scan_result['results']['webshell'] = self.WebTrojanDetection(webinfo, get)
|
||
# 备份文件检测
|
||
if 'backup' in scan_types:
|
||
scan_result['results']['backup'] = self.WebBackupFileDetection(webinfo, get)
|
||
_site_done += 1
|
||
if self._total_units > 0:
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
else:
|
||
self.bar = int((_site_done / _site_total) * 100)
|
||
# 弱口令检测
|
||
if 'weakpass' in scan_types:
|
||
scan_result['results']['weakpass'] = self.WebWeakPasswordDetection(webinfo, get)
|
||
_site_done += 1
|
||
if self._total_units > 0:
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
else:
|
||
self.bar = int((_site_done / _site_total) * 100)
|
||
# 网站日志检测
|
||
if 'weblog' in scan_types:
|
||
# 重置当前站点的IP Top10缓存,避免使用旧值
|
||
self._last_weblog_ip_top = []
|
||
scan_result['results']['weblog'] = self.WebLogDetection(webinfo, get)
|
||
_site_done += 1
|
||
if self._total_units > 0:
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
else:
|
||
self.bar = int((_site_done / _site_total) * 100)
|
||
# 将本次站点的IP Top10写入meta,供全站聚合使用
|
||
if hasattr(self, '_last_weblog_ip_top'):
|
||
scan_result['meta']['weblog_ip_top'] = list(getattr(self, '_last_weblog_ip_top', []))
|
||
|
||
for scan_type, results in scan_result['results'].items():
|
||
for result in results:
|
||
dangerous = result.get('dangerous', 0)
|
||
if dangerous == 0:
|
||
current_risk_count['warning'] += 1
|
||
self.risk_count['warning'] += 1
|
||
elif dangerous == 1:
|
||
current_risk_count['low'] += 1
|
||
self.risk_count['low'] += 1
|
||
elif dangerous == 2:
|
||
current_risk_count['middle'] += 1
|
||
self.risk_count['middle'] += 1
|
||
elif dangerous == 3:
|
||
current_risk_count['high'] += 1
|
||
self.risk_count['high'] += 1
|
||
|
||
scan_result['risk_count'] = current_risk_count
|
||
# 评分计算(满分100,最低为0)
|
||
# _warn = min(current_risk_count.get('warning', 0)* 1, 3)
|
||
# _low = min(current_risk_count.get('low', 0)* 1, 15)
|
||
# _mid = min(current_risk_count.get('middle', 0)* 2, 40)
|
||
# _high = min(current_risk_count.get('high', 0)* 5, 80)
|
||
# _total_deduct = _warn + _low + _mid + _high
|
||
# scan_result['score'] = max(0, 100 - _total_deduct)
|
||
# self._last_score = scan_result['score']
|
||
|
||
# 保存扫描结果
|
||
# self.SaveScanResult(scan_result)
|
||
|
||
# 保存统计结果
|
||
self.save_statistics_result(details=scan_result.get('results', {}))
|
||
|
||
# if '_ws' in get:
|
||
# get._ws.send(public.getJson({
|
||
# "end": True,
|
||
# "ws_callback": get.ws_callback,
|
||
# "info": "网站 %s 基础安全扫描完成" % get.name,
|
||
# "type": "complete",
|
||
# "result": scan_result
|
||
# }))
|
||
|
||
return {'status': True, 'msg': 'The scan is complete', 'data': scan_result}
|
||
|
||
except Exception as e:
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": True, "ws_callback": get.ws_callback,
|
||
"info": "扫描过程中发生错误: %s" % str(e),
|
||
"type": "error",
|
||
"bar": self.bar
|
||
}))
|
||
return {'status': False, 'msg': 'The scan failed: %s' % str(e), 'data': None}
|
||
|
||
def ScanAllSite(self, get):
|
||
'''
|
||
@name 全站点扫描(按模块执行)
|
||
@author wpl<2025-11-4>
|
||
@param get 请求参数
|
||
@return dict 扫描结果
|
||
'''
|
||
public.set_module_logs('webbasicscanning', 'ScanAllSite', 1)
|
||
# 记录扫描开始时间用于耗时统计
|
||
self._scan_start_time = time.time()
|
||
# 获取全部站点
|
||
sites = self.GetAllSite(get)
|
||
if not sites:
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": True, "ws_callback": get.ws_callback,
|
||
"info": "No PHP website found",
|
||
"type": "complete",
|
||
"bar": 100
|
||
}))
|
||
return public.return_message(0, 0, 'No PHP website found')
|
||
|
||
total_sites = len(sites)
|
||
# 固定模块顺序
|
||
modules = ['webscan', 'fileleak', 'webshell', 'backup', 'weakpass', 'weblog']
|
||
|
||
# 初始化聚合结构:details 按模块聚合
|
||
aggregated_details = {m: [] for m in modules}
|
||
aggregated_ips = {} # 用于 weblog 模块的全站 IP Top10 聚合
|
||
|
||
# 标记全站扫描上下文,并重置累计风险计数
|
||
self._in_all_scan = True
|
||
self.risk_count = {"warning": 0, "low": 0, "middle": 0, "high": 0}
|
||
# 进度初始化:总任务数 = 站点数 × 模块数
|
||
self._module_count_per_site = len(modules)
|
||
self._total_units = total_sites * self._module_count_per_site
|
||
self._done_units = 0
|
||
self.bar = 0
|
||
|
||
# 全局开始提示
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Start scanning %d websites by module" % total_sites,
|
||
"type": "start",
|
||
"bar": self.bar
|
||
}))
|
||
|
||
# 逐模块遍历所有站点
|
||
for m in modules:
|
||
# 模块开始提示
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Starting %s module for %d websites" % (m, total_sites),
|
||
"type": m,
|
||
"bar": self.bar
|
||
}))
|
||
|
||
for site in sites:
|
||
try:
|
||
# 设置当前扫描的网站名,并记录到网站计数列表
|
||
get.name = site['name']
|
||
if not hasattr(self, 'web_count_list'):
|
||
self.web_count_list = []
|
||
if get.name not in self.web_count_list:
|
||
self.web_count_list.append(get.name)
|
||
|
||
# 获取网站信息
|
||
webinfo = self.GetWebInfo(get)
|
||
|
||
# 调用对应模块方法
|
||
if m == 'webscan':
|
||
result_items = self.WebConfigSecurity(webinfo, get)
|
||
elif m == 'fileleak':
|
||
result_items = self.WebFileLeakDetection(webinfo, get)
|
||
elif m == 'webshell':
|
||
result_items = self.WebRootTrojanDetection(webinfo, get)
|
||
elif m == 'backup':
|
||
result_items = self.WebBackupFileDetection(webinfo, get)
|
||
elif m == 'weakpass':
|
||
result_items = self.WebWeakPasswordDetection(webinfo, get)
|
||
elif m == 'weblog':
|
||
# 重置当前站点的IP Top10缓存,避免使用旧值
|
||
self._last_weblog_ip_top = []
|
||
result_items = self.WebLogDetection(webinfo, get)
|
||
# 聚合 weblog 的 IP Top10
|
||
try:
|
||
for item in getattr(self, '_last_weblog_ip_top', []):
|
||
if isinstance(item, str) and '|' in item:
|
||
ip, cnt = item.split('|', 1)
|
||
try:
|
||
cnt = int(cnt)
|
||
except ValueError:
|
||
continue
|
||
aggregated_ips[ip] = aggregated_ips.get(ip, 0) + cnt
|
||
except Exception:
|
||
pass
|
||
else:
|
||
result_items = []
|
||
|
||
# 累计风险计数(按当前模块的结果)
|
||
for r in result_items:
|
||
dangerous = r.get('dangerous', 0)
|
||
if dangerous == 0:
|
||
self.risk_count['warning'] += 1
|
||
elif dangerous == 1:
|
||
self.risk_count['low'] += 1
|
||
elif dangerous == 2:
|
||
self.risk_count['middle'] += 1
|
||
elif dangerous == 3:
|
||
self.risk_count['high'] += 1
|
||
|
||
# 进度更新(每完成一个站点的一个模块,进度 +1)
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
|
||
# 按模块聚合写入详情
|
||
aggregated_details[m].append({
|
||
'site_name': get.name,
|
||
'items': result_items
|
||
})
|
||
|
||
except Exception as e:
|
||
# 异常也记入详情,避免遗漏
|
||
aggregated_details[m].append({
|
||
'site_name': site.get('name'),
|
||
'error': str(e),
|
||
'items': []
|
||
})
|
||
# 进度也应计入
|
||
self._done_units += 1
|
||
self.bar = int((self._done_units / max(1, self._total_units)) * 100)
|
||
|
||
# 模块结束提示
|
||
if m == 'weblog' and aggregated_ips:
|
||
# 根据聚合的IP统计生成全站Top10
|
||
sorted_items = sorted(aggregated_ips.items(), key=lambda x: x[1], reverse=True)
|
||
self._last_weblog_ip_top = [f"{ip}|{cnt}" for ip, cnt in sorted_items[:10]]
|
||
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "%s module completed" % m,
|
||
"type": m,
|
||
"bar": self.bar
|
||
}))
|
||
|
||
# 计算全站分数(满分100,最低为0)
|
||
_all_warn = min(self.risk_count.get('warning', 0) * 1, 3)
|
||
_all_low = min(self.risk_count.get('low', 0) * 1, 15)
|
||
_all_mid = min(self.risk_count.get('middle', 0) * 2, 40)
|
||
_all_high = min(self.risk_count.get('high', 0) * 5, 10) # 注意:默认是60,测试站为10分
|
||
_all_total_deduct = _all_warn + _all_low + _all_mid + _all_high
|
||
self._last_all_score = max(0, 100 - _all_total_deduct)
|
||
self._last_score = self._last_all_score
|
||
|
||
# 保存统计结果
|
||
self.save_statistics_result(details=aggregated_details)
|
||
|
||
# 全局结束提示
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": True, "ws_callback": get.ws_callback,
|
||
"info": "Full-site scan completed. Scanned %d websites." % total_sites,
|
||
"type": "complete",
|
||
"bar": 100
|
||
}))
|
||
|
||
# 结束全站扫描上下文
|
||
self._in_all_scan = False
|
||
final_result = {
|
||
'scan_type': 'all_sites_by_module',
|
||
'scan_time': public.format_date(),
|
||
'total_sites': total_sites,
|
||
'results': aggregated_details
|
||
}
|
||
return public.return_message(0, 0, final_result)
|
||
|
||
def SaveScanResult(self, result, scan_type='single'):
|
||
'''
|
||
@name 保存扫描结果
|
||
@author wpl<2025-11-4>
|
||
@param result 扫描结果
|
||
@param scan_type 扫描类型
|
||
'''
|
||
try:
|
||
result_dir = '/www/server/panel/data/webbasic_scan_results'
|
||
if not os.path.exists(result_dir):
|
||
os.makedirs(result_dir)
|
||
|
||
if scan_type == 'single':
|
||
filename = 'webbasic_scan_%s.json' % (result['site_name'])
|
||
else:
|
||
filename = 'webbasic_scan_all_%s.json' % time.strftime('%Y%m%d')
|
||
|
||
file_path = os.path.join(result_dir, filename)
|
||
public.writeFile(file_path, json.dumps(result, indent=2, ensure_ascii=False))
|
||
|
||
except Exception as e:
|
||
public.WriteLog('Basic website security scanning', 'Saving the scan result failed: %s' % str(e))
|
||
|
||
def save_statistics_result(self, details=None):
|
||
'''
|
||
@name 保存基础安全扫描统计结果
|
||
@author wpl<2025-11-4>
|
||
@return bool 保存是否成功
|
||
'''
|
||
try:
|
||
# 准备保存目录
|
||
save_path = '/www/server/panel/data/safeCloud'
|
||
if not os.path.exists(save_path):
|
||
os.makedirs(save_path)
|
||
|
||
# 计算耗时(单位:秒
|
||
duration_sec = 0.0 # 改为 float
|
||
try:
|
||
if hasattr(self, '_scan_start_time') and isinstance(self._scan_start_time, (int, float)):
|
||
duration_sec = round(time.time() - self._scan_start_time, 1) # 保留1位小数
|
||
except Exception as e:
|
||
duration_sec = 0.0
|
||
|
||
# 计算全站攻击类型总和(xss、sql_injection、file_traversal、php_execution、sensitive_files)
|
||
allowed_types = ['xss', 'sql_injection', 'file_traversal', 'php_execution', 'sensitive_files']
|
||
total_attack = {t: 0 for t in allowed_types}
|
||
if isinstance(details, dict):
|
||
weblog_sites = details.get('weblog')
|
||
if isinstance(weblog_sites, list):
|
||
for site_entry in weblog_sites:
|
||
if not isinstance(site_entry, dict):
|
||
continue
|
||
items = site_entry.get('items', [])
|
||
for it in items:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
if it.get('type') != 'weblog':
|
||
continue
|
||
atype = it.get('attack_type')
|
||
if atype not in total_attack:
|
||
continue
|
||
count = it.get('attack_count', 1)
|
||
try:
|
||
total_attack[atype] += int(count)
|
||
except Exception:
|
||
total_attack[atype] += 1
|
||
|
||
# 规整 details:按模块扁平化,仅保留非空 items
|
||
flattened_details = {}
|
||
if isinstance(details, dict):
|
||
for mod_key, entries in details.items():
|
||
if not isinstance(entries, list):
|
||
continue
|
||
flat_items = []
|
||
for en in entries:
|
||
if not isinstance(en, dict):
|
||
continue
|
||
items = en.get('items')
|
||
if isinstance(items, list) and items:
|
||
flat_items.extend([it for it in items if isinstance(it, dict)])
|
||
if flat_items:
|
||
flattened_details[mod_key] = flat_items
|
||
elif isinstance(details, list):
|
||
# 当 details 为列表时,按每条 item 的 type 进行归类到模块键
|
||
flat_map = {}
|
||
for en in details:
|
||
if not isinstance(en, dict):
|
||
continue
|
||
items = en.get('items')
|
||
if isinstance(items, list) and items:
|
||
for it in items:
|
||
if not isinstance(it, dict):
|
||
continue
|
||
typ = it.get('type')
|
||
if typ in ['webscan', 'fileleak', 'webshell','backup', 'weakpass', 'weblog']:
|
||
flat_map.setdefault(typ, []).append(it)
|
||
flattened_details = flat_map
|
||
else:
|
||
flattened_details = {}
|
||
|
||
# 固定包含所有模块键,空数据写入空数组
|
||
for m in ['webscan', 'fileleak', 'webshell', 'backup', 'weakpass', 'weblog']:
|
||
if m not in flattened_details:
|
||
flattened_details[m] = []
|
||
|
||
# ip_top 仅保存前5个(字符串列表 ip|count)
|
||
ip_top_all = getattr(self, '_last_weblog_ip_top', [])
|
||
ip_top_top5 = list(ip_top_all)[:5] if isinstance(ip_top_all, list) else []
|
||
|
||
# 准备保存数据
|
||
result_data = {
|
||
'scan_time': public.format_date(),
|
||
'duration': duration_sec,
|
||
'risk_count': {
|
||
'warning': self.risk_count.get('warning', 0), # 告警
|
||
'low': self.risk_count.get('low', 0), # 低危
|
||
'middle': self.risk_count.get('middle', 0), # 中危
|
||
'high': self.risk_count.get('high', 0) # 高危
|
||
},
|
||
'web_count': len(self.web_count_list) if hasattr(self, 'web_count_list') else 0, # 扫描网站总数
|
||
'ip_top': ip_top_top5, # 全局IP排行(Top5,字符串列表 ip|count)
|
||
'score': getattr(self, '_last_score', 100), # 最近一次检测分数
|
||
# 保存本次检测的详细结果(模块内为扁平化的 items 列表,并固定模块键)
|
||
'details': flattened_details,
|
||
# 全站攻击类型总和
|
||
'total_attack': total_attack
|
||
}
|
||
|
||
# 保存到文件
|
||
save_file = os.path.join(save_path, 'webbasic_scan_result.json')
|
||
public.writeFile(save_file, json.dumps(result_data, indent=2, ensure_ascii=False))
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
public.WriteLog('Basic website security scanning', 'Failed to save the statistical results: %s' % str(e))
|
||
return False
|
||
|
||
# 读取当前最近一次扫描结果
|
||
def get_scan_result(self, get):
|
||
'''
|
||
@name 获取最近一次扫描结果
|
||
@author wpl<2025-11-4>
|
||
@return dict 最近一次网站安全扫描结果
|
||
'''
|
||
|
||
try:
|
||
save_path = '/www/server/panel/data/safeCloud/webbasic_scan_result.json'
|
||
if not os.path.exists(save_path):
|
||
public.print_log("Basic website security scanning, The most recent scan result file does not exist")
|
||
return public.return_message(0, 0, None)
|
||
|
||
data = json.loads(public.readFile(save_path))
|
||
|
||
# 仅保留前5,并转换为字典结构,附带封禁状态
|
||
ip_list = data.get('ip_top', [])
|
||
if isinstance(ip_list, list):
|
||
# 读取IP封禁规则,判断是否封禁
|
||
try:
|
||
ip_rules = json.loads(public.readFile('data/ssh_deny_ip_rules.json')) or []
|
||
except Exception:
|
||
ip_rules = []
|
||
|
||
transformed = []
|
||
for item in ip_list[:5]:
|
||
if isinstance(item, dict):
|
||
ip = str(item.get('ip', '')).strip()
|
||
# count可能为字符串,统一转为int
|
||
count_val = item.get('count', 0)
|
||
try:
|
||
count = int(count_val)
|
||
except Exception:
|
||
count = 0
|
||
else:
|
||
# 默认格式 "ip|count"
|
||
parts = str(item).split('|', 1)
|
||
ip = parts[0].strip()
|
||
try:
|
||
count = int(parts[1]) if len(parts) > 1 else 0
|
||
except Exception:
|
||
count = 0
|
||
|
||
transformed.append({
|
||
'ip': ip,
|
||
'count': count,
|
||
'deny_status': 1 if ip in ip_rules else 0
|
||
})
|
||
|
||
data['ip_top'] = transformed
|
||
return public.return_message(0, 0, data)
|
||
# return data
|
||
except Exception as e:
|
||
public.WriteLog('Basic website security scanning', 'Failed to read the most recent scan result: %s' % str(e))
|
||
return public.return_message(0, 0, None)
|
||
# 辅助方法
|
||
def GetSiteRunPath(self, siteName, sitePath):
|
||
"""
|
||
@name 获取网站运行目录
|
||
@author wpl
|
||
@param string siteName 网站名
|
||
@param string sitePath 网站路径
|
||
"""
|
||
if not siteName or os.path.isfile(sitePath):
|
||
return "/"
|
||
path = sitePath
|
||
if public.get_webserver() == 'nginx':
|
||
filename = '/www/server/panel/vhost/nginx/' + siteName + '.conf'
|
||
if os.path.exists(filename):
|
||
conf = public.readFile(filename)
|
||
rep = r'\s*root\s+(.+);'
|
||
tmp1 = re.search(rep, conf)
|
||
if tmp1: path = tmp1.groups()[0]
|
||
elif public.get_webserver() == 'apache':
|
||
filename = '/www/server/panel/vhost/apache/' + siteName + '.conf'
|
||
if os.path.exists(filename):
|
||
conf = public.readFile(filename)
|
||
rep = r'\s*DocumentRoot\s*"(.+)"\s*\n'
|
||
tmp1 = re.search(rep, conf)
|
||
if tmp1: path = tmp1.groups()[0]
|
||
|
||
if sitePath == path:
|
||
return '/'
|
||
else:
|
||
return path.replace(sitePath, '')
|
||
|
||
def GetDirList(self, path_data):
|
||
'''
|
||
@name 获取当前目录下所有PHP文件
|
||
@author wpl<2025-11-4>
|
||
@param path_data 目录路径
|
||
@return list PHP文件列表
|
||
'''
|
||
if os.path.exists(str(path_data)):
|
||
return self.Getdir(path_data)
|
||
else:
|
||
return False
|
||
|
||
def Getdir(self, path):
|
||
'''
|
||
@name 获取目录下的所有php文件
|
||
@author wpl<2025-11-4>
|
||
@param path 文件目录
|
||
@return list PHP文件列表
|
||
'''
|
||
return_data = []
|
||
data2 = []
|
||
[[return_data.append(os.path.join(root, file)) for file in files] for root, dirs, files in os.walk(path)]
|
||
for i in return_data:
|
||
if str(i.lower())[-4:] == '.php':
|
||
data2.append(i)
|
||
return data2
|
||
|
||
def ReadFile(self, filename, mode='r'):
|
||
'''
|
||
@name 读取文件内容
|
||
@author wpl<2025-11-4>
|
||
@param filename 文件路径
|
||
@param mode 读取模式
|
||
@return 文件内容
|
||
'''
|
||
import os
|
||
if not os.path.exists(filename): return False
|
||
try:
|
||
fp = open(filename, mode)
|
||
f_body = fp.read()
|
||
fp.close()
|
||
except Exception as ex:
|
||
if sys.version_info[0] != 2:
|
||
try:
|
||
fp = open(filename, mode, encoding="utf-8")
|
||
f_body = fp.read()
|
||
fp.close()
|
||
except Exception as ex2:
|
||
return False
|
||
else:
|
||
return False
|
||
return f_body
|
||
|
||
def FileMd5(self, filename):
|
||
'''
|
||
@name 获取文件的md5值
|
||
@author wpl<2025-11-4>
|
||
@param filename 文件路径
|
||
@return MD5值
|
||
'''
|
||
if os.path.exists(filename):
|
||
with open(filename, 'rb') as fp:
|
||
data = fp.read()
|
||
file_md5 = hashlib.md5(data).hexdigest()
|
||
return file_md5
|
||
else:
|
||
return False
|
||
|
||
def UploadShell(self, data, get, webinfo):
|
||
'''
|
||
@name 上传文件进行木马检测
|
||
@author wpl<2025-11-4>
|
||
@param data 文件路径集合
|
||
@param get 请求参数
|
||
@param webinfo 网站信息
|
||
@return 返回webshell路径列表
|
||
'''
|
||
if len(data) == 0: return []
|
||
# 本地正则匹配检测,避免上传到云端
|
||
self.__count = len(data)
|
||
count = 0
|
||
wubao = 0
|
||
shell_data = []
|
||
shell_files = []
|
||
|
||
if os.path.exists(self.__shell):
|
||
wubao = 1
|
||
try:
|
||
shell_data = json.loads(public.ReadFile(self.__shell))
|
||
except:
|
||
public.WriteFile(self.__shell, json.dumps([]))
|
||
wubao = 0
|
||
|
||
# 正则规则列表
|
||
rules = [
|
||
"@\\$\\_=", "eval\\(('|\")\\?>", "php_valueauto_append_file", "eval\\(gzinflate\\(",
|
||
"eval\\(str_rot13\\(",
|
||
"base64\\_decode\\(\\$\\_", "eval\\(gzuncompress\\(", "phpjm\\.net", "assert\\(('|\"|\\s*)\\$",
|
||
"require_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "gzinflate\\(base64_decode\\(",
|
||
"echo\\(file_get_contents\\(('|\")\\$_(POST|GET|REQUEST|COOKIE)", "c99shell", "cmd\\.php",
|
||
"call_user_func\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "str_rot13", "webshell", "EgY_SpIdEr",
|
||
"tools88\\.com", "SECFORCE", "eval\\(base64_decode\\(",
|
||
"include\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"array_map[\\s]{0,20}\\(.{1,5}(eval|assert|ass\\x65rt).{1,20}\\$_(GET|POST|REQUEST).{0,15}",
|
||
"call_user_func[\\s]{0,25}\\(.{0,25}\\$_(GET|POST|REQUEST).{0,15}",
|
||
"gzdeflate|gzcompress|gzencode",
|
||
"require_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"include_once\\(('|\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"call_user_func\\((\"|')assert(\"|')",
|
||
"php_valueauto_prepend_file", "SetHandlerapplication\\/x-httpd-php",
|
||
"file_put_contents\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)\\[([^\\]]+)\\],('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"\\$_(POST|GET|REQUEST|COOKIE)\\[([^\\]]+)\\]\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)\\[",
|
||
"require\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "assert\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"eval\\(('|'\"|\\s*)\\$_(POST|GET|REQUEST|COOKIE)", "base64_decode\\(gzuncompress\\(",
|
||
"gzuncompress\\(base64_decode\\(", "ies\",gzuncompress\\(\\$", "eval\\(gzdecode\\(",
|
||
"preg_replace\\(\"\\/\\.\\*\\/e\"", "Scanners", "phpspy", "cha88\\.cn",
|
||
"chr\\((\\d)+\\)\\.chr\\((\\d)+\\)",
|
||
"\\$\\_=\\$\\_", "\\$(\\w)+\\(\\${", "\\(array\\)\\$_(POST|GET|REQUEST|COOKIE)",
|
||
"\\$(\\w)+\\(\"\\/(\\S)+\\/e",
|
||
"\"e\"\\.\"v\"\\.\"a\"\\.\"l\"", "\"e\"\\.\"v\"\\.\"a\"\\.\"l\"", "'e'\\.'v'\\.'a'\\.'l'",
|
||
"@preg\\_replace\\((\")*\\/(\\S)*\\/e(\")*,\\$_POST\\[\\S*\\]", "\\${'\\_'", "@\\$\\_\\(\\$\\_",
|
||
"\\$\\_=\"\""
|
||
]
|
||
patterns = [re.compile(p, re.IGNORECASE) for p in rules]
|
||
|
||
for i in data:
|
||
count += 1
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "Scanning for Trojan %s" % i,
|
||
"type": "webshell", "count": self.__count, "is_count": count,
|
||
"bar": self.bar
|
||
}))
|
||
|
||
# 判断是否是误报的文件
|
||
if wubao and i in shell_data:
|
||
continue
|
||
|
||
# 本地内容匹配
|
||
try:
|
||
with open(i, 'rb') as f:
|
||
data_bytes = f.read()
|
||
try:
|
||
text = data_bytes.decode('utf-8', errors='ignore')
|
||
except Exception:
|
||
text = data_bytes.decode('latin-1', errors='ignore')
|
||
|
||
hit = False
|
||
for pat in patterns:
|
||
if pat.search(text):
|
||
hit = True
|
||
break
|
||
|
||
if hit:
|
||
shell_files.append(i)
|
||
if '_ws' in get:
|
||
get._ws.send(public.getJson({
|
||
"end": False, "ws_callback": get.ws_callback,
|
||
"info": "%s website Trojan scan found that the current file is a Trojan file" % get.name,
|
||
"type": "webshell", "count": self.__count, "is_count": count,
|
||
"is_error": True,
|
||
"bar": self.bar
|
||
}))
|
||
except Exception:
|
||
# 文件读取异常直接跳过
|
||
continue
|
||
|
||
return shell_files
|
||
|
||
def UpdateWubao(self, filename):
|
||
'''
|
||
@name 更新误报文件
|
||
@author wpl<2025-11-4>
|
||
@param filename 误报文件路径
|
||
'''
|
||
if not os.path.exists(self.__shell):
|
||
public.WriteFile(self.__shell, json.dumps([]))
|
||
|
||
try:
|
||
shell_data = json.loads(public.ReadFile(self.__shell))
|
||
if filename not in shell_data:
|
||
shell_data.append(filename)
|
||
public.WriteFile(self.__shell, json.dumps(shell_data))
|
||
return True
|
||
except:
|
||
return False
|