Files
VPSMaster/proxydoctor/ipquality.py

258 lines
15 KiB
Python

"""IP ownership, type and reputation with provenance and explicit missing data."""
from datetime import datetime, timezone
import ipaddress
import os
import urllib.parse
from .net import request_json
FLAGS = ('is_datacenter', 'is_proxy', 'is_vpn', 'is_tor', 'is_abuser',
'is_mobile', 'is_satellite', 'is_anycast', 'is_bogon')
FIELDS = ('company', 'asn', 'country', 'region', 'city', 'timezone', 'network_type',
'network_abuser_score') + FLAGS
def valid_ip(value):
if not isinstance(value,str): return None
try:
address = ipaddress.ip_address(value)
return str(address) if address.is_global else None
except (ValueError, TypeError):
return None
def text(value):
return value[:200] if isinstance(value, str) and value.strip() else None
def normalize(data):
values = {field: None for field in FIELDS}
company = data.get('company')
asn = data.get('asn')
location = data.get('location') if isinstance(data.get('location'), dict) else data
if isinstance(company, dict):
values['company'] = text(company.get('name'))
values['network_type'] = text(company.get('type'))
values['network_abuser_score'] = text(company.get('abuser_score'))
else:
values['company'] = text(company)
if isinstance(asn, dict):
number = asn.get('asn')
if isinstance(number, int) and not isinstance(number, bool) and number > 0:
values['asn'] = 'AS' + str(number) + (' ' + text(asn.get('org')) if text(asn.get('org')) else '')
values['network_type'] = values['network_type'] or text(asn.get('type'))
else:
values['asn'] = text(asn)
for name in ('country', 'region', 'city', 'timezone'):
values[name] = text(location.get(name))
for name in FLAGS:
if type(data.get(name)) is bool:
values[name] = data[name]
return values
def normalize_proxycheck(item):
values={field:None for field in FIELDS}
network=item.get('network') if isinstance(item.get('network'),dict) else {}
location=item.get('location') if isinstance(item.get('location'),dict) else {}
detections=item.get('detections') if isinstance(item.get('detections'),dict) else {}
values.update(company=text(network.get('provider')),asn=text(network.get('asn')),
network_type=text(network.get('type')))
for target,source in [('country','country_name'),('region','region_name'),('city','city_name'),('timezone','timezone')]:
values[target]=text(location.get(source))
for target,source in [('is_datacenter','hosting'),('is_proxy','proxy'),('is_vpn','vpn'),('is_tor','tor')]:
if type(detections.get(source)) is bool: values[target]=detections[source]
return values
def normalize_ip2location(item):
values={field:None for field in FIELDS}
values['company']=text(item.get('isp')) or text(item.get('as'))
number=item.get('asn')
if isinstance(number,(int,str)) and not isinstance(number,bool) and str(number).isdigit():
values['asn']='AS'+str(number)
for target,source in [('country','country_name'),('region','region_name'),('city','city_name'),('timezone','time_zone')]:
values[target]=text(item.get(source))
# The free plan's proxy flag excludes some VPN types. Do not merge it into
# the common proxy/VPN flags; keep it as a provider-specific observation.
return values
def reconcile(report):
observations=report['observations']
report['field_sources']={}
report['conflicts']={}
for name in FIELDS:
candidates={source:values[name] for source,values in observations.items() if values.get(name) is not None}
if name in FLAGS and len(set(candidates.values()))>1:
report['values'][name]=None
report['conflicts'][name]=candidates
report['field_sources'][name]=list(candidates)
elif candidates:
first=next(iter(candidates.values()))
report['values'][name]=first
report['field_sources'][name]=[source for source,value in candidates.items() if value==first]
available=sum(value is not None for value in report['values'].values())
report['coverage']={'available':available,'total':len(FIELDS),
'complete':available==len(FIELDS) and report['sources']['AbuseIPDB']['state']=='ok'}
def enrich_report(report,on_result=None):
address=report['ip']
if not address: return
url='https://proxycheck.io/v3/'+urllib.parse.quote(address,safe=':')+'?tag=0&ver=24-June-2026'
data,state=request_json(url)
item=None
if state=='ok':
if data.get('status')!='ok': state='provider_error'
else:
item=next((value for key,value in data.items() if valid_ip(key)==address),None)
if not isinstance(item,dict) or not isinstance(item.get('detections'),dict):
state='invalid_response'
report['sources']['proxycheck.io']={'state':state,'access':'anonymous'}
if state=='ok':
report['observations']['proxycheck.io']=normalize_proxycheck(item)
detections=item['detections']
risk={key:detections.get(key) if type(detections.get(key)) is int and 0<=detections[key]<=100 else None
for key in ('risk','confidence')}
for key in ('anonymous','compromised','scraper'):
risk[key]=detections.get(key) if type(detections.get(key)) is bool else None
history=item.get('attack_history')
risk['attack_count']=sum(history.values()) if isinstance(history,dict) and all(type(n) is int and 0<=n<=2**31 for n in history.values()) else None
report['provider_risk']['proxycheck.io']=risk
if on_result is not None:on_result()
data,state=request_json('https://api.ip2location.io/?'+urllib.parse.urlencode({'ip':address,'format':'json'}))
if state=='ok' and valid_ip(data.get('ip'))!=address: state='invalid_response'
report['sources']['IP2Location']={'state':state,'access':'anonymous'}
if state=='ok':
report['observations']['IP2Location']=normalize_ip2location(data)
report['provider_risk']['IP2Location']={'open_proxy_flag':data.get('is_proxy') if type(data.get('is_proxy')) is bool else None}
def collect(enrich=True, mail=False, destination=None, on_result=None):
"""Keys only via environment; never kept in results, URLs or error messages."""
key = os.environ.get('PROXYDOCTOR_IPAPI_KEY', '').strip()
abuse_key = os.environ.get('PROXYDOCTOR_ABUSEIPDB_KEY', '').strip()
data, state = request_json('https://api.ipapi.is/', payload={'key': key} if key else None)
address = valid_ip(data.get('ip')) if data else None
if state == 'ok' and (not address or data.get('error')):
state = 'invalid_response'
if state != 'ok':
address = None
values = normalize(data) if state == 'ok' else {field: None for field in FIELDS}
missing = [name for name, value in values.items() if value is None]
report = {'checked_at': datetime.now(timezone.utc).isoformat(), 'ip': address,
'ip_family': ipaddress.ip_address(address).version if address else None,
'scope': 'request_egress_only', 'values': values,
'sources': {'ipapi.is': {'state': state, 'access': 'key' if key else 'anonymous',
'missing_fields': missing}},
'reputation': {'score': None, 'total_reports': None, 'distinct_reporters': None,
'last_reported_at': None, 'window_days': 90}}
report['observations']={'ipapi.is':values.copy()}
report['provider_risk']={}
if destination is not None:
destination.update(report)
report=destination
if on_result is not None:on_result()
if not address and data is None:
fallback,fallback_state=request_json('https://api.ipify.org?format=json')
address=valid_ip(fallback.get('ip')) if fallback else None
if fallback_state=='ok' and not address: fallback_state='invalid_response'
report['sources']['ipify']={'state':fallback_state}
report['ip']=address
report['ip_family']=ipaddress.ip_address(address).version if address else None
abuse_state = 'key_required' if not abuse_key else 'ip_unavailable'
if abuse_key and address:
url = 'https://api.abuseipdb.com/api/v2/check?' + urllib.parse.urlencode(
{'ipAddress': address, 'maxAgeInDays': 90})
abuse, abuse_state = request_json(url, headers={'Key': abuse_key})
item = abuse.get('data') if abuse else None
if abuse_state == 'ok':
if not isinstance(item, dict) or valid_ip(item.get('ipAddress')) != address:
abuse_state = 'invalid_response'
else:
for source, target, maximum in [('abuseConfidenceScore', 'score', 100),
('totalReports', 'total_reports', None),
('numDistinctUsers', 'distinct_reporters', None)]:
value = item.get(source)
if type(value) is int and value >= 0 and (maximum is None or value <= maximum):
report['reputation'][target] = value
report['reputation']['last_reported_at'] = text(item.get('lastReportedAt'))
if any(report['reputation'][k] is None for k in ('score', 'total_reports', 'distinct_reporters')):
abuse_state = 'partial_response'
report['sources']['AbuseIPDB'] = {'state': abuse_state}
if on_result is not None:on_result()
if enrich: enrich_report(report,on_result)
reconcile(report)
if mail:
from .mailquality import collect as collect_mail
report['mail_quality']=collect_mail(address)
if on_result is not None:on_result()
return report
def render(report,out,show_ip=False):
tr=out.text;values=report['values'];unknown=tr('未知','Unknown')
address=report['ip']
coverage=report['coverage']
concern=[name[3:] for name in ('is_proxy','is_vpn','is_tor','is_abuser') if values.get(name) is True]
location=' / '.join(str(values[name]) for name in ('country','region','city') if values.get(name)) or unknown
summary=[('IP',address if address and show_ip else tr('已隐藏;--show-ip 显示','Hidden; use --show-ip') if address else unknown,'ok' if address else 'unknown'),
('ASN',values.get('asn') or unknown,'ok' if values.get('asn') else 'unknown'),
(tr('运营商','ISP'),values.get('company') or unknown,'ok' if values.get('company') else 'unknown'),
(tr('估计位置','Estimated location'),location,'unknown'),
(tr('网络类型','Network type'),values.get('network_type') or unknown,'unknown'),
(tr('风险标签','Risk flags'),', '.join(concern) if concern else tr('未取得肯定风险标签;不代表安全','No positive risk flag; safety unknown'),'bad' if concern else 'unknown'),
(tr('来源冲突','Source conflicts'),str(len(report.get('conflicts',{}))),'unknown'),
(tr('数据覆盖','Field coverage'),f"{coverage['available']}/{coverage['total']}",'ok' if coverage['complete'] else 'unknown'),
(tr('查询时间','Observed at'),report.get('checked_at',unknown),'unknown')]
out.panel(tr('IP 质量概览 · 来源证据,不是纯净度评分','IP quality overview · Provider evidence, not a purity score'),summary)
out.row(tr('基础归属与多库证据','Ownership and provider evidence'),tr('分来源记录,不合成纯净度分数','Separate sources; no combined purity score'),'unknown')
out.row(tr('出口地址','Egress IP'),address if address and show_ip else tr('已隐藏(--show-ip 显示)','Hidden (use --show-ip)') if address else unknown,'ok' if address else 'unknown')
for key,zh,en in [('company','运营商','ISP'),('asn','ASN','ASN'),('country','国家','Country'),
('region','地区','Region'),('city','城市','City'),('timezone','时区','Timezone'),
('network_type','网络类型','Network type'),('network_abuser_score','网段滥用比例','Network abuse share')]:
value=values.get(key)
out.row(tr(zh,en),value if value is not None else unknown,'ok' if value is not None else 'unknown')
labels=[('数据中心','Datacenter'),('代理','Proxy'),('VPN','VPN'),('Tor','Tor'),('近期滥用','Recent abuse'),
('移动网络','Mobile'),('卫星','Satellite'),('Anycast','Anycast'),('保留地址','Bogon')]
for key,(zh,en) in zip(FLAGS,labels):
value=values.get(key);risk=key in ('is_proxy','is_vpn','is_tor','is_abuser','is_bogon')
label=tr('来源不一致','Sources disagree') if key in report.get('conflicts',{}) else tr('是','Yes') if value is True else tr('否','No') if value is False else unknown
out.row(tr(zh,en),label,'unknown' if value is None else 'bad' if risk and value else 'ok')
pc=report.get('provider_risk',{}).get('proxycheck.io',{})
risk=pc.get('risk');confidence=pc.get('confidence')
out.row('proxycheck.io',unknown if risk is None else tr(f'风险分 {risk}/100 · 置信度 {confidence if confidence is not None else "?"}/100',
f'Risk {risk}/100 · Confidence {confidence if confidence is not None else "?"}/100'),
'unknown' if risk is None else 'bad' if risk>25 else 'ok')
reputation=report.get('reputation',{}); score=reputation.get('score')
if score is not None:
out.row('AbuseIPDB',tr(f"举报置信分 {score}/100 · 90 天举报 {reputation.get('total_reports')}",
f"Abuse confidence {score}/100 · 90-day reports {reputation.get('total_reports')}"),
'bad' if score>0 else 'unknown')
coverage=report['coverage']
for source,observation in report.get('observations',{}).items():
classification=observation.get('network_type')
# Skip providers that returned no usable type data (e.g. keyless sources).
if classification is None:
continue
flags=' / '.join(key[3:]+'='+('?' if observation.get(key) is None else tr('是','Yes') if observation[key] else tr('否','No'))
for key in ('is_datacenter','is_proxy','is_vpn','is_tor'))
out.row(source,tr('类型:','Type: ')+classification+' · '+flags,'unknown')
if report.get('mail_quality'):
from .mailquality import render as render_mail
render_mail(report['mail_quality'],out)
out.row(tr('IP 数据覆盖','IP data coverage'),f"{coverage['available']}/{coverage['total']} · IPv{report['ip_family'] or '?'}",
'ok' if coverage['complete'] else 'unknown')
labels={'ok':('成功','OK'),'key_required':('需要密钥','Key required'),'rate_limited':('限流','Rate limited'),
'invalid_response':('响应格式异常','Invalid response'),'request_failed':('请求失败','Request failed')}
for source,item in report['sources'].items():
state=item['state']
# Skip sources that need a key we don't have, or that returned no usable
# data (key_required / invalid_response / request_failed). Only show the
# ones that actually produced results.
if state == 'key_required':
continue
name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1]
out.row(source+' API',name,'ok' if state=='ok' else 'unknown')