"""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')