🔒 Repository is read-only – file editing is disabled.

PaganLinux/tmp-http-urlaudit.py main

242 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
#!/usr/bin/env python3
"""Precyzyjny audyt http:// w polach source/sources/url receptur.

Dla kazdego wystapienia:
  1. rozwija placeholdery ${pkgname}/${pkgver}/... z receptury,
  2. testuje https:// (kandydat) ORAZ http:// (oryginal),
  3. klasyfikuje:
       SAFE_HTTPS  - https dziala  -> mozna podmienic
       HTTP_ONLY   - http dziala, https nie -> NIE podmieniac
       BOTH_DEAD   - oba nie dzialaja -> zrodlo martwe (osobny raport)
       UNRESOLVED  - nie udalo sie rozwinac placeholderow

Wyniki zapisywane przyrostowo do /tmp/http-urlaudit.json.
"""
import collections
import concurrent.futures as cf
import json
import os
import re
import ssl
import sys
import threading
import time
import urllib.error
import urllib.request

import yaml

R = "/var/lib/pagan-sync/recipes"
OUT = "/tmp/http-urlaudit.json"
CACHE = "/tmp/http-probe-cache.json"
KEYS = ("source", "sources", "url")
PH = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
UA = {"User-Agent": "curl/8.7.1"}

_lock = threading.Lock()
cache = {}
cache_dirty = 0


def as_list(v):
    if v is None:
        return []
    return v if isinstance(v, list) else [v]


def expand(s, d):
    """Rozwija ${...} z receptury. Zwraca (wynik, czy_zostaly_placeholdery)."""
    missing = []

    def rep(m):
        k = m.group(1)
        if k in d and not isinstance(d[k], (dict, list)):
            return str(d[k])
        missing.append(k)
        return m.group(0)

    out = PH.sub(rep, s)
    return out, bool(missing)


def probe(url, timeout=20):
    """(ok, status). HEAD -> GET z Range (nie sciaga pliku)."""
    ctx = ssl.create_default_context()
    last = None

    def attempt(method, extra=None):
        h = dict(UA)
        if extra:
            h.update(extra)
        req = urllib.request.Request(url, headers=h, method=method)
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
            return r.status

    try:
        st = attempt("HEAD")
        if 200 <= st < 400:
            return True, st
        last = f"HTTP {st}"
    except urllib.error.HTTPError as e:
        last = f"HTTP {e.code}"
        if e.code not in (403, 405, 400, 501):
            return False, last
    except Exception as e:
        last = type(e).__name__

    try:
        st = attempt("GET", {"Range": "bytes=0-1"})
        if 200 <= st < 400:
            return True, st
        return False, f"HTTP {st}"
    except urllib.error.HTTPError as e:
        if e.code in (403, 405):
            # serwer istnieje, tylko nie lubi naszych metod
            return True, f"HTTP {e.code}"
        return False, f"HTTP {e.code}"
    except Exception as e:
        return False, f"{last or ''}/{type(e).__name__}".strip("/")


def cached_probe(url):
    with _lock:
        if url in cache:
            return tuple(cache[url])
    res = probe(url)
    with _lock:
        cache[url] = list(res)
    return res


def save_cache():
    with _lock:
        snap = dict(cache)
    tmp = CACHE + ".tmp"
    json.dump(snap, open(tmp, "w"))
    os.replace(tmp, CACHE)


def main():
    global cache
    if os.path.exists(CACHE):
        try:
            cache = json.load(open(CACHE))
        except Exception:
            cache = {}

    occ = []  # (rel, key, raw, expanded, unresolved)
    for dp, _d, fs in os.walk(R):
        if "PAGBUILD.yaml" not in fs:
            continue
        p = os.path.join(dp, "PAGBUILD.yaml")
        rel = p[len(R) + 1:]
        try:
            d = yaml.safe_load(open(p, encoding="utf-8")) or {}
        except Exception:
            continue
        for k in KEYS:
            for v in as_list(d.get(k)):
                s = str(v)
                if s.startswith("http://"):
                    ex, unres = expand(s, d)
                    occ.append((rel, k, s, ex, unres))

    uniq = sorted({o[3] for o in occ if not o[4]})
    print(f"wystapien http://: {len(occ)} | unikalnych rozwinietch URL: {len(uniq)}",
          flush=True)

    results = {}
    t0 = time.time()
    done = 0
    to_probe = []
    for ex in uniq:
        if ex in cache:
            continue
        to_probe.append(ex)

    with cf.ThreadPoolExecutor(max_workers=12) as pool:
        futs = {pool.submit(cached_probe, u): u for u in to_probe}
        for fut in cf.as_completed(futs):
            u = futs[fut]
            try:
                res = fut.result()
            except Exception as e:
                res = (False, type(e).__name__)
            results[u] = list(res)
            done += 1
            if done % 50 == 0:
                print(f"  ... {done}/{len(to_probe)} ({time.time()-t0:.0f}s)", flush=True)
                save_cache()

    # dociagnij z cache te, ktore juz byly
    for u in uniq:
        if u not in results and u in cache:
            results[u] = list(cache[u])
    save_cache()

    # dla kazdego URL sprawdz tez oryginalne http
    http_urls = sorted({o[3] for o in occ if not o[4]})
    http_res = {}
    to_p = [u for u in http_urls if u not in cache]
    print(f"testowanie oryginalow http:// (bez zmian): {len(to_p)}", flush=True)
    with cf.ThreadPoolExecutor(max_workers=12) as pool:
        futs = {pool.submit(cached_probe, u): u for u in to_p}
        for fut in cf.as_completed(futs):
            u = futs[fut]
            try:
                http_res[u] = list(fut.result())
            except Exception as e:
                http_res[u] = [False, type(e).__name__]
    for u in http_urls:
        if u not in http_res:
            http_res[u] = list(cache.get(u, [False, "?"]))
    save_cache()

    out = collections.defaultdict(list)
    for rel, k, raw, ex, unres in occ:
        if unres:
            cls = "UNRESOLVED"
            hs = http_res.get(ex, [False, "?"])[1]
        else:
            hs = ex  # placeholder: klucz
            https_ok = results.get(ex, [False, "?"])[0]
            http_ok = http_res.get(ex, [False, "?"])[0]
            if https_ok:
                cls = "SAFE_HTTPS"
            elif http_ok:
                cls = "HTTP_ONLY"
            else:
                cls = "BOTH_DEAD"
            hs = f"https={results.get(ex, [False,'?'])[1]} http={http_res.get(ex, [False,'?'])[1]}"
        out[cls].append({"recipe": rel, "key": k, "raw": raw,
                         "expanded": ex, "detail": hs})

    json.dump(out, open(OUT, "w"), indent=1, ensure_ascii=False)
    print()
    for cls in ("SAFE_HTTPS", "HTTP_ONLY", "BOTH_DEAD", "UNRESOLVED"):
        print(f"{cls:12s}: {len(out.get(cls, []))} wystapien")

    hosts = collections.Counter()
    for item in out.get("HTTP_ONLY", []):
        m = re.match(r"^https?://([^/]+)", item["expanded"])
        if m:
            hosts[m.group(1)] += 1
    if hosts:
        print("\nHTTP_ONLY (NIE podmieniac) wg hosta:")
        for h, n in hosts.most_common():
            print(f"   {n:4d}  {h}")

    hosts = collections.Counter()
    for item in out.get("SAFE_HTTPS", []):
        m = re.match(r"^https?://([^/]+)", item["expanded"])
        if m:
            hosts[m.group(1)] += 1
    if hosts:
        print("\nSAFE_HTTPS (do podmiany) wg hosta:")
        for h, n in hosts.most_common(25):
            print(f"   {n:4d}  {h}")
    print(f"\nzapisano {OUT} (szczegoly) i {CACHE} (cache sond)")


if __name__ == "__main__":
    sys.exit(main())