| """Pull DataDog malicious-software-packages-dataset → JSONL via parallel raw downloads. |
| |
| No git clone. Uses GitHub tree API + raw.githubusercontent.com + ThreadPoolExecutor. |
| |
| Usage: |
| python scripts/pull_datadog.py --max-samples 400 --ecosystems npm pypi |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import io |
| import json |
| import os |
| import re |
| import sys |
| import time |
| import zipfile |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from pathlib import Path |
|
|
| import requests |
|
|
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) |
|
|
|
|
| REPO = "DataDog/malicious-software-packages-dataset" |
| RAW_BASE = f"https://raw.githubusercontent.com/{REPO}/main" |
| ZIP_PASSWORD = b"infected" |
|
|
| _INTERESTING_FILES = ( |
| "package.json", "setup.py", "setup.cfg", "pyproject.toml", |
| "index.js", "preinstall.js", "postinstall.js", "install.js", |
| "__init__.py", |
| ) |
|
|
| _MALICIOUS_TOKENS = re.compile( |
| r"(eval\(|exec\(|subprocess|os\.system|child_process|" |
| r"base64\.b64decode|atob\(|Buffer\.from\([\"'][A-Za-z0-9+/=]{20,}|" |
| r"urllib\.request\.url(retrieve|open)|requests\.(get|post|put)|fetch\(|" |
| r"\.exec\(|new Function\(|" |
| r"/etc/passwd|\.ssh/|\.aws/|\.npmrc|\.env|" |
| r"hostname|whoami|process\.env|os\.environ|" |
| r"verify_signature|verify=False|InsecureRequestWarning|" |
| r"importlib\.import_module|require\([a-zA-Z_])", |
| re.IGNORECASE, |
| ) |
|
|
|
|
| def _gh_headers() -> dict: |
| h = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} |
| tok = os.environ.get("GITHUB_TOKEN") |
| if tok: |
| h["Authorization"] = f"Bearer {tok}" |
| return h |
|
|
|
|
| def fetch_tree() -> list[str]: |
| """Fetch full repo tree (recursive). Returns list of paths to .zip files.""" |
| paths: list[str] = [] |
| url = f"https://api.github.com/repos/{REPO}/git/trees/main?recursive=1" |
| r = requests.get(url, headers=_gh_headers(), timeout=30) |
| r.raise_for_status() |
| data = r.json() |
| paths.extend(t["path"] for t in data.get("tree", []) if t["path"].endswith(".zip")) |
| if data.get("truncated"): |
| for category in ("malicious_intent", "compromised_lib"): |
| sub_url = f"https://api.github.com/repos/{REPO}/contents/samples/pypi/{category}" |
| sr = requests.get(sub_url, headers=_gh_headers(), timeout=30) |
| if sr.status_code != 200: |
| continue |
| pkgs = sr.json() if isinstance(sr.json(), list) else [] |
| for pkg in pkgs[:80]: |
| if pkg.get("type") != "dir": |
| continue |
| vr = requests.get(pkg["url"], headers=_gh_headers(), timeout=30) |
| if vr.status_code != 200: |
| continue |
| vers = vr.json() if isinstance(vr.json(), list) else [] |
| for ver in vers[:2]: |
| if ver.get("type") != "dir": |
| continue |
| zr = requests.get(ver["url"], headers=_gh_headers(), timeout=30) |
| if zr.status_code != 200: |
| continue |
| items = zr.json() if isinstance(zr.json(), list) else [] |
| for z in items: |
| if z.get("name", "").endswith(".zip"): |
| paths.append(z["path"]) |
| return paths |
|
|
|
|
| def stratify(paths: list[str], ecosystems: list[str], max_samples: int) -> list[str]: |
| buckets: dict[tuple[str, str], list[str]] = {} |
| for p in paths: |
| parts = p.split("/") |
| if len(parts) < 4 or parts[0] != "samples": |
| continue |
| eco = parts[1] |
| cat = parts[2] |
| if eco not in ecosystems: |
| continue |
| buckets.setdefault((eco, cat), []).append(p) |
| n_buckets = max(1, len(buckets)) |
| per_bucket = max(1, max_samples // n_buckets) |
| chosen: list[str] = [] |
| for (eco, cat), items in sorted(buckets.items()): |
| chosen.extend(items[:per_bucket]) |
| return chosen[:max_samples] |
|
|
|
|
| def fetch_one(path: str, attempt_max: int = 3) -> tuple[str, bytes | None]: |
| url = f"{RAW_BASE}/{path}" |
| for attempt in range(attempt_max): |
| try: |
| r = requests.get(url, timeout=20) |
| if r.status_code == 200: |
| return path, r.content |
| time.sleep(0.5 * (attempt + 1)) |
| except requests.RequestException: |
| time.sleep(0.5 * (attempt + 1)) |
| return path, None |
|
|
|
|
| def extract_sample(path: str, blob: bytes) -> dict | None: |
| parts = path.split("/") |
| if len(parts) < 6: |
| return None |
| eco = parts[1] |
| category = parts[2] |
| package = parts[3] |
| version = parts[4] |
| fname = parts[-1] |
| try: |
| with zipfile.ZipFile(io.BytesIO(blob)) as zf: |
| interesting: list[tuple[str, str]] = [] |
| for name in zf.namelist(): |
| base = os.path.basename(name).lower() |
| if base in _INTERESTING_FILES or any(name.endswith(ext) for ext in (".js", ".py")): |
| try: |
| with zf.open(name, pwd=ZIP_PASSWORD) as fh: |
| raw = fh.read() |
| try: |
| text = raw.decode("utf-8", errors="ignore") |
| except Exception: |
| continue |
| interesting.append((name, text)) |
| except (RuntimeError, zipfile.BadZipFile): |
| continue |
| if not interesting: |
| return None |
| interesting.sort(key=lambda t: (-len(t[1]), t[0])) |
| top_name, top_text = interesting[0] |
| blob_for_scan = "\n".join(t[1] for t in interesting[:5])[:8000] |
| n_signals = len(_MALICIOUS_TOKENS.findall(blob_for_scan)) |
| return { |
| "ecosystem": eco, |
| "category": category, |
| "package": package, |
| "version": version, |
| "filename": fname, |
| "primary_file": top_name, |
| "diff_preview": top_text[:1500], |
| "files": [n for n, _ in interesting[:6]], |
| "malicious_signal_count": n_signals, |
| "label": "malicious", |
| "source": "datadog", |
| } |
| except (zipfile.BadZipFile, RuntimeError): |
| return None |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--max-samples", type=int, default=400) |
| ap.add_argument("--ecosystems", nargs="*", default=["npm", "pypi"]) |
| ap.add_argument("--workers", type=int, default=32) |
| ap.add_argument("--out", default="data/datadog_extracted.jsonl") |
| args = ap.parse_args() |
|
|
| print(f"[tree] fetching tree from github...", flush=True) |
| t0 = time.time() |
| paths = fetch_tree() |
| print(f"[tree] {len(paths)} zip paths discovered in {time.time()-t0:.1f}s", flush=True) |
|
|
| chosen = stratify(paths, args.ecosystems, args.max_samples) |
| print(f"[stratify] picked {len(chosen)} samples (target={args.max_samples})", flush=True) |
|
|
| out = Path(args.out) |
| out.parent.mkdir(parents=True, exist_ok=True) |
| n_ok = 0 |
| n_fail = 0 |
| n_skipped = 0 |
|
|
| t1 = time.time() |
| with open(out, "w", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=args.workers) as ex: |
| futures = [ex.submit(fetch_one, p) for p in chosen] |
| for i, fut in enumerate(as_completed(futures), 1): |
| path, blob = fut.result() |
| if blob is None: |
| n_fail += 1 |
| else: |
| rec = extract_sample(path, blob) |
| if rec is None: |
| n_skipped += 1 |
| else: |
| f.write(json.dumps(rec, ensure_ascii=False) + "\n") |
| n_ok += 1 |
| if i % 50 == 0: |
| print(f" progress {i}/{len(chosen)} (ok={n_ok}, fail={n_fail}, skip={n_skipped}) " |
| f"in {time.time()-t1:.1f}s", flush=True) |
|
|
| elapsed = time.time() - t1 |
| print(f"DONE: {n_ok} extracted, {n_fail} fetch-fail, {n_skipped} no-interesting-files " |
| f"in {elapsed:.1f}s -> {out}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|