muaddib-scanner 2.11.76 → 2.11.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,256 +0,0 @@
1
- """
2
- Label generation engine.
3
-
4
- Correlates signals from OSSF, GHSA, and npm status to produce labels.
5
-
6
- Label tiers (by confidence):
7
- - confirmed_malicious: authoritative source (ossf/ghsa) OR npm takedown pattern
8
- - likely_malicious: npm_removed + high muaddib score, but no authoritative confirmation
9
- - unconfirmed: suspect in muaddib, still on npm, no external signal, >7 days old
10
- - pending: suspect in muaddib, still on npm, no external signal, <7 days old
11
- - missed: clean in muaddib BUT flagged by ossf/ghsa (false negative)
12
- """
13
-
14
- import json
15
- import logging
16
- from datetime import datetime, timezone
17
- from pathlib import Path
18
-
19
- from ossf_index import lookup as ossf_lookup
20
- from ghsa_checker import lookup as ghsa_lookup
21
- from npm_checker import is_quick_takedown
22
-
23
- log = logging.getLogger("auto-labeler.labeler")
24
-
25
- # Thresholds
26
- SCORE_THRESHOLD_CONFIRMED = 50 # Minimum muaddib score for npm_removed → confirmed
27
- PENDING_DAYS = 7 # Days before pending → unconfirmed
28
- MAX_CONFIRMATION_AGE_DAYS = 30 # npm_removed only confirms if detection is recent
29
-
30
-
31
- def _parse_iso(s):
32
- """Parse ISO 8601 date string to datetime."""
33
- if not s:
34
- return None
35
- try:
36
- return datetime.fromisoformat(s.replace("Z", "+00:00"))
37
- except (ValueError, TypeError):
38
- return None
39
-
40
-
41
- def _days_since(iso_str):
42
- """Days elapsed since the given ISO date string."""
43
- dt = _parse_iso(iso_str)
44
- if not dt:
45
- return None
46
- delta = datetime.now(timezone.utc) - dt
47
- return delta.total_seconds() / 86400
48
-
49
-
50
- def _severity_to_score_estimate(severity):
51
- """Rough score estimate from severity when exact score is unavailable."""
52
- return {"CRITICAL": 70, "HIGH": 40, "MEDIUM": 15, "LOW": 5}.get(severity, 0)
53
-
54
-
55
- def label_suspects(detections, ossf_index, ghsa_index, npm_status, alert_scores):
56
- """Generate labels for all suspect detections.
57
-
58
- Args:
59
- detections: list of detection dicts from detections.json
60
- ossf_index: dict from ossf_index.build_index()
61
- ghsa_index: dict from ghsa_checker.build_index()
62
- npm_status: dict from npm_checker.check_suspects()
63
- alert_scores: dict keyed by "name@version" with {"score": N, "tier": "T1a"} from alerts
64
-
65
- Returns:
66
- dict keyed by "name@version" with label info
67
- """
68
- labels = {}
69
- stats = {"confirmed_malicious": 0, "likely_malicious": 0,
70
- "unconfirmed": 0, "pending": 0}
71
-
72
- for det in detections:
73
- name = det["package"]
74
- version = det["version"]
75
- ecosystem = det.get("ecosystem", "npm")
76
- key = f"{name}@{version}"
77
- detection_date = det.get("first_seen_at", "")
78
- severity = det.get("severity", "UNKNOWN")
79
- findings = det.get("findings", [])
80
-
81
- # Skip non-npm for now (OSSF/GHSA npm-focused)
82
- if ecosystem != "npm":
83
- continue
84
-
85
- # Gather signals
86
- signals = []
87
-
88
- # Signal 1: OSSF
89
- ossf_hit = ossf_lookup(ossf_index, name, version)
90
- if ossf_hit:
91
- signals.append("ossf")
92
-
93
- # Signal 2: GHSA
94
- ghsa_hit = ghsa_lookup(ghsa_index, name)
95
- if ghsa_hit:
96
- signals.append("ghsa")
97
-
98
- # Signal 3: npm status
99
- npm_result = npm_status.get(key, {})
100
- npm_removed = npm_result.get("status") == "npm_removed"
101
- if npm_removed:
102
- signals.append("npm_removed")
103
-
104
- # Get score from alerts or estimate from severity
105
- score_info = alert_scores.get(key, {})
106
- score = score_info.get("score", _severity_to_score_estimate(severity))
107
- tier = score_info.get("tier", "")
108
-
109
- # Determine label
110
- label = _classify(signals, npm_result, detection_date, score)
111
- stats[label] += 1
112
-
113
- labels[key] = {
114
- "muaddib_label": "suspect",
115
- "auto_label": label,
116
- "signals": signals,
117
- "muaddib_score": score,
118
- "muaddib_tier": tier,
119
- "muaddib_severity": severity,
120
- "muaddib_findings": findings,
121
- "detection_date": detection_date,
122
- "label_date": datetime.now(timezone.utc).isoformat(),
123
- "npm_status": npm_result.get("status", "unknown"),
124
- "npm_publish_date": npm_result.get("publish_date"),
125
- }
126
-
127
- if ossf_hit:
128
- labels[key]["ossf_id"] = ossf_hit.get("osv_id")
129
- if ghsa_hit:
130
- labels[key]["ghsa_id"] = ghsa_hit[0].get("ghsa_id")
131
-
132
- log.debug("LABEL %s → %s (signals=%s, score=%d)", key, label, signals, score)
133
-
134
- log.info("Suspect labels: %s", stats)
135
- return labels
136
-
137
-
138
- def _classify(signals, npm_result, detection_date, score):
139
- """Core classification logic."""
140
- has_authoritative = "ossf" in signals or "ghsa" in signals
141
- npm_removed = "npm_removed" in signals
142
-
143
- # Tier 1: Authoritative source confirms malicious
144
- if has_authoritative:
145
- return "confirmed_malicious"
146
-
147
- # Tier 2: npm takedown pattern (removed + high score + quick removal)
148
- # Temporal leakage guard (ANSSI audit M5): only confirm via npm_removed if
149
- # the detection is recent. Old detections where the package has since been
150
- # removed cannot be reliably confirmed — the removal may be unrelated.
151
- # Also, is_quick_takedown already validates detection_date > publish_date.
152
- if npm_removed and score >= SCORE_THRESHOLD_CONFIRMED:
153
- detection_age = _days_since(detection_date)
154
- if detection_age is not None and detection_age <= MAX_CONFIRMATION_AGE_DAYS:
155
- if is_quick_takedown(npm_result, detection_date, threshold_hours=72):
156
- return "confirmed_malicious"
157
-
158
- # Tier 3: npm removed but doesn't meet confirmation criteria
159
- if npm_removed:
160
- return "likely_malicious"
161
-
162
- # Tier 4: Still on npm, no external signal
163
- days = _days_since(detection_date)
164
- if days is not None and days > PENDING_DAYS:
165
- return "unconfirmed"
166
-
167
- return "pending"
168
-
169
-
170
- def find_missed(ossf_index, ghsa_index, detections):
171
- """Find packages in OSSF/GHSA that muaddib did NOT detect (false negatives).
172
-
173
- Returns dict keyed by package name with miss details.
174
- """
175
- # Build set of all detected package names
176
- detected_names = set()
177
- for det in detections:
178
- if det.get("ecosystem") == "npm":
179
- detected_names.add(det["package"])
180
-
181
- missed = {}
182
-
183
- # Check OSSF index
184
- ossf_packages = set()
185
- for key in ossf_index:
186
- name = key.rsplit("@", 1)[0]
187
- ossf_packages.add(name)
188
-
189
- for name in ossf_packages:
190
- if name not in detected_names:
191
- missed[name] = {
192
- "auto_label": "missed",
193
- "muaddib_label": "clean",
194
- "signals": ["ossf"],
195
- "source_detail": "In ossf/malicious-packages but not in muaddib detections",
196
- "label_date": datetime.now(timezone.utc).isoformat(),
197
- }
198
-
199
- # Check GHSA index
200
- for name, entries in ghsa_index.items():
201
- if name not in detected_names:
202
- existing = missed.get(name)
203
- if existing:
204
- existing["signals"].append("ghsa")
205
- else:
206
- missed[name] = {
207
- "auto_label": "missed",
208
- "muaddib_label": "clean",
209
- "signals": ["ghsa"],
210
- "ghsa_id": entries[0].get("ghsa_id") if entries else None,
211
- "source_detail": "In GHSA malware advisories but not in muaddib detections",
212
- "label_date": datetime.now(timezone.utc).isoformat(),
213
- }
214
-
215
- log.info("Missed packages (false negatives): %d", len(missed))
216
- if missed:
217
- # Log the first 20 as these are critical for improving the scanner
218
- for name in list(missed.keys())[:20]:
219
- m = missed[name]
220
- log.warning("MISSED: %s (signals=%s)", name, m["signals"])
221
-
222
- return missed
223
-
224
-
225
- def export_labels(labels, missed, output_path):
226
- """Export all labels to auto-labels.json."""
227
- output_path = Path(output_path)
228
- output_path.parent.mkdir(parents=True, exist_ok=True)
229
-
230
- # Merge suspects and missed into one output
231
- all_labels = dict(labels)
232
- for name, info in missed.items():
233
- all_labels[f"{name}@*"] = info
234
-
235
- # Generate summary
236
- summary = {"confirmed_malicious": 0, "likely_malicious": 0,
237
- "unconfirmed": 0, "pending": 0, "missed": 0}
238
- for entry in all_labels.values():
239
- lbl = entry.get("auto_label", "unknown")
240
- if lbl in summary:
241
- summary[lbl] += 1
242
-
243
- output = {
244
- "generated_at": datetime.now(timezone.utc).isoformat(),
245
- "summary": summary,
246
- "total": len(all_labels),
247
- "labels": all_labels,
248
- }
249
-
250
- with open(output_path, "w", encoding="utf-8") as f:
251
- json.dump(output, f, indent=2)
252
-
253
- log.info("Exported %d labels to %s", len(all_labels), output_path)
254
- log.info("Summary: %s", summary)
255
-
256
- return summary
@@ -1,228 +0,0 @@
1
- """
2
- npm registry status checker.
3
-
4
- For each suspect package, checks if the package/version still exists on npm.
5
- Extracts publish timing for temporal correlation (quick takedown = strong signal).
6
- Rate-limited to 50 requests/minute with exponential backoff.
7
- Resumable: saves progress to npm-status-cache.json.
8
- """
9
-
10
- import json
11
- import logging
12
- import time
13
- from datetime import datetime
14
- from pathlib import Path
15
-
16
- import requests
17
-
18
- log = logging.getLogger("auto-labeler.npm")
19
-
20
- NPM_REGISTRY = "https://registry.npmjs.org"
21
- RATE_LIMIT = 50 # requests per minute
22
- RATE_WINDOW = 60 # seconds
23
- CACHE_FILENAME = "npm-status-cache.json"
24
- # Don't re-check packages checked within this window
25
- RECHECK_INTERVAL_SECONDS = 24 * 3600 # 24h
26
-
27
-
28
- def _rate_limiter():
29
- """Generator-based rate limiter. Call next() before each request."""
30
- timestamps = []
31
- while True:
32
- now = time.time()
33
- # Purge timestamps older than the window
34
- timestamps = [t for t in timestamps if now - t < RATE_WINDOW]
35
- if len(timestamps) >= RATE_LIMIT:
36
- sleep_time = timestamps[0] + RATE_WINDOW - now + 0.1
37
- log.debug("Rate limit reached, sleeping %.1fs", sleep_time)
38
- time.sleep(sleep_time)
39
- now = time.time()
40
- timestamps = [t for t in timestamps if now - t < RATE_WINDOW]
41
- timestamps.append(now)
42
- yield
43
-
44
-
45
- def _fetch_package_info(session, name, limiter):
46
- """Fetch package metadata from npm. Returns (status, info) tuple."""
47
- next(limiter)
48
-
49
- url = f"{NPM_REGISTRY}/{name}"
50
- for attempt in range(3):
51
- try:
52
- resp = session.get(url, timeout=15)
53
-
54
- if resp.status_code == 404:
55
- return "npm_removed", {"reason": "package_404"}
56
-
57
- if resp.status_code == 429:
58
- retry_after = int(resp.headers.get("Retry-After", 30))
59
- log.warning("npm 429 for %s, waiting %ds", name, retry_after)
60
- time.sleep(retry_after)
61
- continue
62
-
63
- resp.raise_for_status()
64
- return "npm_available", resp.json()
65
-
66
- except requests.RequestException as e:
67
- wait = 2 ** attempt * 3
68
- log.warning("npm fetch failed for %s (attempt %d): %s",
69
- name, attempt + 1, e)
70
- time.sleep(wait)
71
-
72
- return "npm_error", {"reason": "fetch_failed_after_retries"}
73
-
74
-
75
- def check_suspects(suspects, cache_dir):
76
- """Check npm status for each suspect. Returns dict of results.
77
-
78
- Args:
79
- suspects: list of dicts with 'package', 'version', 'ecosystem' keys
80
- cache_dir: path to cache directory
81
-
82
- Returns:
83
- dict keyed by "name@version" with status info
84
- """
85
- cache_dir = Path(cache_dir)
86
- cache_dir.mkdir(parents=True, exist_ok=True)
87
- cache_path = cache_dir / CACHE_FILENAME
88
-
89
- # Load existing cache for resumability
90
- cache = _load_cache(cache_path)
91
-
92
- # Deduplicate suspects by name@version, npm only
93
- unique = {}
94
- for s in suspects:
95
- if s.get("ecosystem") != "npm":
96
- continue
97
- key = f"{s['package']}@{s['version']}"
98
- if key not in unique:
99
- unique[key] = s
100
-
101
- # Filter out recently checked
102
- now = time.time()
103
- to_check = {}
104
- for key, s in unique.items():
105
- cached = cache.get(key)
106
- if cached and (now - cached.get("checked_at", 0)) < RECHECK_INTERVAL_SECONDS:
107
- continue
108
- to_check[key] = s
109
-
110
- log.info("npm check: %d unique suspects, %d already cached, %d to check",
111
- len(unique), len(unique) - len(to_check), len(to_check))
112
-
113
- if not to_check:
114
- return cache
115
-
116
- session = requests.Session()
117
- session.headers.update({"Accept": "application/json"})
118
- limiter = _rate_limiter()
119
-
120
- checked = 0
121
- # Group by package name to avoid redundant fetches
122
- by_name = {}
123
- for key, s in to_check.items():
124
- name = s["package"]
125
- if name not in by_name:
126
- by_name[name] = []
127
- by_name[name].append((key, s))
128
-
129
- total_packages = len(by_name)
130
-
131
- for i, (name, entries) in enumerate(by_name.items()):
132
- status, info = _fetch_package_info(session, name, limiter)
133
-
134
- if i > 0 and i % 100 == 0:
135
- log.info("npm check progress: %d/%d packages (%.0f%%)",
136
- i, total_packages, i / total_packages * 100)
137
- _save_cache(cache, cache_path)
138
-
139
- for key, s in entries:
140
- version = s["version"]
141
- result = {
142
- "status": status,
143
- "checked_at": now,
144
- }
145
-
146
- if status == "npm_available" and isinstance(info, dict):
147
- versions = info.get("versions", {})
148
- time_info = info.get("time", {})
149
-
150
- if version not in versions:
151
- result["status"] = "npm_removed"
152
- result["reason"] = "version_removed"
153
- else:
154
- result["reason"] = "available"
155
-
156
- # Extract timing for temporal correlation
157
- publish_time = time_info.get(version)
158
- if publish_time:
159
- result["publish_date"] = publish_time
160
-
161
- # Extract latest version publish time
162
- modified = time_info.get("modified")
163
- if modified:
164
- result["last_modified"] = modified
165
-
166
- elif status == "npm_removed":
167
- result["reason"] = "package_404"
168
-
169
- cache[key] = result
170
- checked += 1
171
-
172
- _save_cache(cache, cache_path)
173
- log.info("npm check complete: %d packages checked, %d total cached",
174
- checked, len(cache))
175
-
176
- return cache
177
-
178
-
179
- def _load_cache(cache_path):
180
- """Load npm status cache from disk."""
181
- if not cache_path.is_file():
182
- return {}
183
- try:
184
- with open(cache_path, "r", encoding="utf-8") as f:
185
- data = json.load(f)
186
- if isinstance(data, dict) and "results" in data:
187
- return data["results"]
188
- return {}
189
- except (json.JSONDecodeError, OSError):
190
- return {}
191
-
192
-
193
- def _save_cache(cache, cache_path):
194
- """Save npm status cache to disk."""
195
- try:
196
- with open(cache_path, "w", encoding="utf-8") as f:
197
- json.dump({
198
- "saved_at": datetime.utcnow().isoformat() + "Z",
199
- "count": len(cache),
200
- "results": cache,
201
- }, f)
202
- except OSError as e:
203
- log.error("Failed to save npm cache: %s", e)
204
-
205
-
206
- def is_quick_takedown(result, detection_date_str, threshold_hours=72):
207
- """Check if a package was removed quickly after publish (npm security takedown pattern).
208
-
209
- Returns True if the package was removed AND was published recently
210
- relative to the detection date (within threshold_hours).
211
- """
212
- if result.get("status") != "npm_removed":
213
- return False
214
-
215
- publish_date = result.get("publish_date")
216
- if not publish_date:
217
- return False
218
-
219
- try:
220
- publish_dt = datetime.fromisoformat(publish_date.replace("Z", "+00:00"))
221
- detection_dt = datetime.fromisoformat(detection_date_str.replace("Z", "+00:00"))
222
- delta_hours = (detection_dt - publish_dt).total_seconds() / 3600
223
-
224
- # Package was detected within threshold_hours of publish
225
- # AND has since been removed → strong takedown signal
226
- return 0 <= delta_hours <= threshold_hours
227
- except (ValueError, TypeError):
228
- return False
@@ -1,178 +0,0 @@
1
- """
2
- OSSF malicious-packages indexer.
3
-
4
- Clones (or updates) the ossf/malicious-packages repo with sparse checkout
5
- limited to osv/malicious/npm/, then parses all OSV JSON files into an index.
6
- Skips osv/withdrawn/ (retracted false positives).
7
- """
8
-
9
- import json
10
- import logging
11
- import os
12
- import subprocess
13
- from datetime import datetime
14
- from pathlib import Path
15
-
16
- log = logging.getLogger("auto-labeler.ossf")
17
-
18
- OSSF_REPO_URL = "https://github.com/ossf/malicious-packages.git"
19
- OSSF_SPARSE_PATH = "osv/malicious/npm"
20
-
21
- INDEX_FILENAME = "ossf-index.json"
22
-
23
-
24
- def _run_git(args, cwd=None):
25
- """Run a git command, raise on failure."""
26
- result = subprocess.run(
27
- ["git"] + args,
28
- cwd=cwd,
29
- capture_output=True,
30
- text=True,
31
- timeout=300,
32
- )
33
- if result.returncode != 0:
34
- raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
35
- return result.stdout.strip()
36
-
37
-
38
- def clone_or_update(repo_dir):
39
- """Clone with sparse checkout or git pull if already present."""
40
- repo_dir = Path(repo_dir)
41
-
42
- if (repo_dir / ".git").is_dir():
43
- log.info("OSSF repo exists at %s — pulling latest", repo_dir)
44
- _run_git(["pull", "--ff-only"], cwd=repo_dir)
45
- return
46
-
47
- log.info("Cloning OSSF repo (sparse, depth=1) to %s", repo_dir)
48
- repo_dir.mkdir(parents=True, exist_ok=True)
49
-
50
- _run_git(["clone", "--depth", "1", "--filter=blob:none",
51
- "--sparse", OSSF_REPO_URL, str(repo_dir)])
52
- _run_git(["sparse-checkout", "set", OSSF_SPARSE_PATH], cwd=repo_dir)
53
- log.info("OSSF clone complete (sparse: %s)", OSSF_SPARSE_PATH)
54
-
55
-
56
- def _parse_osv_file(filepath):
57
- """Parse a single OSV JSON file and yield (key, entry) tuples."""
58
- try:
59
- with open(filepath, "r", encoding="utf-8") as f:
60
- data = json.load(f)
61
- except (json.JSONDecodeError, OSError) as e:
62
- log.warning("Skipping invalid OSV file %s: %s", filepath, e)
63
- return
64
-
65
- osv_id = data.get("id", "")
66
- published = data.get("published", "")
67
- summary = data.get("summary", "")
68
-
69
- # Extract attack type from database_specific if available
70
- attack_type = None
71
- db_specific = data.get("database_specific", {})
72
- origins = db_specific.get("malicious-packages-origins", [])
73
- if origins:
74
- attack_type = origins[0].get("reason", None)
75
-
76
- for affected in data.get("affected", []):
77
- pkg = affected.get("package", {})
78
- ecosystem = pkg.get("ecosystem", "").lower()
79
- name = pkg.get("name", "")
80
-
81
- if ecosystem != "npm" or not name:
82
- continue
83
-
84
- # Collect explicit versions
85
- versions = affected.get("versions", [])
86
-
87
- # Also extract versions from ranges
88
- for rng in affected.get("ranges", []):
89
- events = rng.get("events", [])
90
- for event in events:
91
- if "introduced" in event and event["introduced"] != "0":
92
- versions.append(event["introduced"])
93
-
94
- entry = {
95
- "source": "ossf",
96
- "osv_id": osv_id,
97
- "date": published,
98
- "summary": summary[:200],
99
- "attack_type": attack_type,
100
- }
101
-
102
- if versions:
103
- for ver in set(versions):
104
- yield f"{name}@{ver}", entry
105
- else:
106
- # No specific versions — all versions affected
107
- yield f"{name}@*", entry
108
-
109
-
110
- def build_index(repo_dir, cache_dir):
111
- """Build OSSF index from the cloned repo. Returns the index dict."""
112
- repo_dir = Path(repo_dir)
113
- cache_dir = Path(cache_dir)
114
- osv_dir = repo_dir / "osv" / "malicious" / "npm"
115
-
116
- if not osv_dir.is_dir():
117
- log.error("OSSF osv/malicious/npm/ not found at %s", osv_dir)
118
- return {}
119
-
120
- index = {}
121
- file_count = 0
122
- entry_count = 0
123
-
124
- for root, _dirs, files in os.walk(osv_dir):
125
- # Skip withdrawn reports
126
- if "withdrawn" in Path(root).parts:
127
- continue
128
-
129
- for fname in files:
130
- if not fname.endswith(".json"):
131
- continue
132
-
133
- filepath = os.path.join(root, fname)
134
- file_count += 1
135
-
136
- for key, entry in _parse_osv_file(filepath):
137
- index[key] = entry
138
- entry_count += 1
139
-
140
- log.info("OSSF index: %d entries from %d files", entry_count, file_count)
141
-
142
- # Cache to disk
143
- cache_dir.mkdir(parents=True, exist_ok=True)
144
- cache_path = cache_dir / INDEX_FILENAME
145
- with open(cache_path, "w", encoding="utf-8") as f:
146
- json.dump({"built_at": datetime.utcnow().isoformat() + "Z",
147
- "count": len(index),
148
- "index": index}, f)
149
- log.info("OSSF index cached to %s", cache_path)
150
-
151
- return index
152
-
153
-
154
- def load_cached_index(cache_dir):
155
- """Load index from cache if available."""
156
- cache_path = Path(cache_dir) / INDEX_FILENAME
157
- if not cache_path.is_file():
158
- return None
159
- try:
160
- with open(cache_path, "r", encoding="utf-8") as f:
161
- data = json.load(f)
162
- log.info("Loaded cached OSSF index (%d entries, built %s)",
163
- data.get("count", 0), data.get("built_at", "?"))
164
- return data.get("index", {})
165
- except (json.JSONDecodeError, OSError) as e:
166
- log.warning("Failed to load OSSF cache: %s", e)
167
- return None
168
-
169
-
170
- def lookup(index, name, version):
171
- """Check if a package@version is in the OSSF index.
172
-
173
- Returns the entry dict or None. Checks both exact version and wildcard.
174
- """
175
- exact = index.get(f"{name}@{version}")
176
- if exact:
177
- return exact
178
- return index.get(f"{name}@*")
@@ -1 +0,0 @@
1
- requests>=2.28.0
Binary file