Files
VPSMaster/proxydoctor/ipquality.py
T

220 lines
12 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):
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
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):
"""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 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 enrich: enrich_report(report)
reconcile(report)
return report
def render(report,out,show_ip=False):
tr=out.text;values=report['values'];unknown=tr('未知','Unknown')
address=report['ip']
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['reputation'];score=reputation['score']
out.row('AbuseIPDB',unknown if score is None else tr(
f"评分 {score}/100 · 90 天举报 {reputation['total_reports']} · 举报者 {reputation['distinct_reporters']}",
f"Score {score}/100 · 90-day reports {reputation['total_reports']} · Reporters {reputation['distinct_reporters']}"),
'unknown' if score is None else 'bad' if score>0 else 'ok')
coverage=report['coverage']
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'];name=labels.get(state,(state,state))[0 if out.lang=='zh' else 1]
out.row(source+' API',name,'ok' if state=='ok' else 'unknown')
if not coverage['complete']:
print(tr(' 可选扩展字段:PROXYDOCTOR_IPAPI_KEY;举报数据:PROXYDOCTOR_ABUSEIPDB_KEY。',
' Optional extended fields: PROXYDOCTOR_IPAPI_KEY; reports: PROXYDOCTOR_ABUSEIPDB_KEY.'),flush=True)