#!/usr/bin/env python3
"""Restricted local scanner. JSON lines over a group-restricted Unix socket; no network API."""
import os, json, re, socketserver, subprocess, stat, time, pathlib
QUARANTINE=pathlib.Path(os.environ.get('QUARANTINE_PATH','/var/lib/multiscan/quarantine'))
SOCKET=os.environ.get('SCANNER_SOCKET','/run/multiscan/scanner.sock')
CLAM=os.environ.get('CLAMSCAN_BIN','/usr/bin/clamscan')
YARA=os.environ.get('YARA_BIN','/usr/bin/yara')
CLAM_DB=os.environ.get('CLAM_DATABASE','/var/lib/clamav')
YARA_RULES=os.environ.get('YARA_RULES','/etc/multiscan/rules.yarc')
MAX_BYTES=int(os.environ.get('SCANNER_MAX_BYTES',33554432))
def version(engine):
    cmd=[CLAM,'--version'] if engine=='clamav' else [YARA,'--version']
    p=subprocess.run(cmd,capture_output=True,text=True,timeout=5,check=True)
    return p.stdout.strip()[:180]
def definitions(engine):
    files=[p for p in pathlib.Path(CLAM_DB).glob('*') if p.suffix in ('.cvd','.cld','.ndb','.hdb','.ldb')] if engine=='clamav' else [pathlib.Path(YARA_RULES)]
    files=[p for p in files if p.is_file()]
    if not files: raise ValueError('definitions_missing')
    # Fail closed on stale ClamAV data. YARA update frequency is operator-controlled.
    newest=max(p.stat().st_mtime for p in files)
    if engine=='clamav' and time.time()-newest>259200: raise ValueError('definitions_stale')
    return time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime(newest))
def execute(req):
    engine=req.get('engine')
    if engine not in ('clamav','yara'): raise ValueError('invalid_engine')
    signature_date=definitions(engine);v=version(engine)
    if req.get('action')=='health': return {'ok':True,'version':v,'signature_date':signature_date}
    name=req.get('file','')
    if not isinstance(name,str) or not re.fullmatch(r'[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}\.bin',name): raise ValueError('invalid_file')
    # Open relative to a fixed directory with O_NOFOLLOW. Pass the open FD to the child,
    # avoiding path traversal and replacement/symlink races after validation.
    directory=os.open(str(QUARANTINE),os.O_RDONLY|os.O_DIRECTORY)
    try: fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK,dir_fd=directory)
    finally: os.close(directory)
    try:
        info=os.fstat(fd)
        if not stat.S_ISREG(info.st_mode) or info.st_size>MAX_BYTES or info.st_nlink!=1: raise ValueError('invalid_file')
        path=f'/proc/self/fd/{fd}'
        if engine=='clamav': cmd=[CLAM,'--stdout','--no-summary','--max-filesize=32M','--max-scansize=128M','--max-recursion=12','--max-files=1000','--alert-exceeds-max=yes','--database='+CLAM_DB,path]
        else: cmd=[YARA,'-C','--timeout=40',YARA_RULES,path]
        # A temporary output file bounds RAM use; rlimit + timeout bound scanner work.
        import tempfile, resource
        def limits():
            resource.setrlimit(resource.RLIMIT_CPU,(80,80))
            resource.setrlimit(resource.RLIMIT_FSIZE,(65536,65536))
            resource.setrlimit(resource.RLIMIT_CORE,(0,0))
        with tempfile.TemporaryFile() as output:
            p=subprocess.run(cmd,stdout=output,stderr=subprocess.DEVNULL,timeout=90,pass_fds=(fd,),preexec_fn=limits)
            output.seek(0);text=output.read(65536).decode('utf-8','replace')
        if engine=='clamav':
            if p.returncode not in (0,1): raise ValueError('clamav_failed')
            threats=re.findall(r': (.+) FOUND',text)
            if p.returncode==1 and not threats: raise ValueError('clamav_invalid_output')
            if any('Limits.Exceeded' in t for t in threats): raise ValueError('scan_limit_exceeded')
            verdict='malicious' if threats else 'undetected'
        else:
            if p.returncode!=0: raise ValueError('yara_failed')
            threats=[line.split()[0] for line in text.splitlines() if line.strip()]
            verdict='malicious' if threats else 'undetected'
        return {'verdict':verdict,'threat':', '.join(threats)[:4000] or None,'version':v,'signature_date':signature_date}
    finally: os.close(fd)
class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        self.connection.settimeout(5)
        try:
            raw=self.rfile.readline(4097)
            if len(raw)>4096 or not raw.endswith(b'\n'): raise ValueError('invalid_request')
            req=json.loads(raw)
            if not isinstance(req,dict): raise ValueError('invalid_request')
            result=execute(req)
        except (ValueError,OSError,subprocess.SubprocessError,TypeError): result={'error':'scanner_failed'}
        try: self.wfile.write(json.dumps(result).encode()+b'\n')
        except (BrokenPipeError,TimeoutError): pass
class Server(socketserver.ForkingMixIn,socketserver.UnixStreamServer):
    max_children=2
    block_on_close=False
if __name__=='__main__':
    os.umask(0o007)
    if os.path.lexists(SOCKET): os.unlink(SOCKET)
    with Server(SOCKET,Handler) as server:
        os.chmod(SOCKET,0o660)
        server.serve_forever()
