|
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | """ |
3 | | -AutoPiff Alerter — Karton consumer that sends Telegram alerts for high-scoring findings. |
| 3 | +AutoPiff Alerter — Karton consumer that sends Telegram alerts. |
| 4 | +
|
| 5 | +Consumes: |
| 6 | + - {type: autopiff, kind: semantic_deltas} — high-scoring patch findings |
| 7 | + - {type: autopiff, kind: kernelsense} — confirmed variant vulnerabilities |
4 | 8 |
|
5 | | -Consumes: {type: autopiff, kind: semantic_deltas} |
6 | 9 | Filters: final_score >= 8.0 AND surface_area in [ioctl, irp, filesystem] |
| 10 | + variant_candidates where is_variant=true AND confidence >= threshold |
7 | 11 | Sends: Telegram alerts via HTTP API |
8 | 12 | Stores: Recent alerts in Redis sorted set (30-day TTL) |
9 | 13 | """ |
|
28 | 32 | TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID") |
29 | 33 | REDIS_HOST = os.environ.get("KARTON_REDIS_HOST", "karton-redis") |
30 | 34 | SCORE_THRESHOLD = float(os.environ.get("AUTOPIFF_SCORE_THRESHOLD", "8.0")) |
| 35 | +VARIANT_CONFIDENCE_THRESHOLD = float( |
| 36 | + os.environ.get("AUTOPIFF_VARIANT_CONFIDENCE", "0.65") |
| 37 | +) |
31 | 38 | ALERTABLE_SURFACES = {"ioctl", "irp", "filesystem"} |
32 | 39 |
|
33 | 40 | # Redis keys |
34 | 41 | ALERTS_KEY = "autopiff:alerts:recent" |
35 | 42 | ALERTS_FAILED_KEY = "autopiff:alerts:failed" |
| 43 | +VARIANT_ALERTS_KEY = "autopiff:alerts:variants" |
36 | 44 | ALERTS_TTL_SECONDS = 30 * 24 * 3600 # 30 days |
37 | 45 |
|
38 | 46 |
|
39 | 47 | class AutoPiffAlerter(Karton): |
40 | | - """Karton consumer that filters high-scoring AutoPiff findings and sends Telegram alerts.""" |
| 48 | + """Karton consumer that sends Telegram alerts for high-scoring findings and variants.""" |
41 | 49 |
|
42 | 50 | identity = "karton.autopiff.alerter" |
43 | | - filters = [{"type": "autopiff", "kind": "semantic_deltas"}] |
| 51 | + filters = [ |
| 52 | + {"type": "autopiff", "kind": "semantic_deltas"}, |
| 53 | + {"type": "autopiff", "kind": "kernelsense"}, |
| 54 | + ] |
44 | 55 |
|
45 | 56 | def __init__(self, *args, **kwargs): |
46 | 57 | super().__init__(*args, **kwargs) |
47 | 58 | self.rdb = redis.Redis(host=REDIS_HOST, port=6379, decode_responses=True) |
48 | 59 |
|
49 | 60 | def process(self, task: Task) -> None: |
| 61 | + kind = task.headers.get("kind") |
| 62 | + if kind == "semantic_deltas": |
| 63 | + self._process_semantic_deltas(task) |
| 64 | + elif kind == "kernelsense": |
| 65 | + self._process_kernelsense(task) |
| 66 | + else: |
| 67 | + logger.warning(f"Unknown task kind: {kind}") |
| 68 | + |
| 69 | + # ------------------------------------------------------------------ |
| 70 | + # Semantic Deltas (original patch-based alerting) |
| 71 | + # ------------------------------------------------------------------ |
| 72 | + |
| 73 | + def _process_semantic_deltas(self, task: Task) -> None: |
50 | 74 | semantic_deltas = task.get_payload("semantic_deltas") |
51 | 75 | if not semantic_deltas: |
52 | 76 | logger.warning("No semantic_deltas payload in task") |
@@ -89,15 +113,121 @@ def process(self, task: Task) -> None: |
89 | 113 |
|
90 | 114 | logger.info(f"Found {len(alertable)} alertable findings") |
91 | 115 |
|
92 | | - # Build and send alert |
93 | 116 | msg = self._build_alert_message( |
94 | 117 | alertable, driver_new_sha, driver_new_ver, driver_old_ver, summary |
95 | 118 | ) |
96 | 119 | self._send_telegram_alert(msg) |
97 | | - |
98 | | - # Store in Redis for /findings command |
99 | 120 | self._store_alerts(alertable, driver_new_sha) |
100 | 121 |
|
| 122 | + # ------------------------------------------------------------------ |
| 123 | + # KernelSense (variant alerting) |
| 124 | + # ------------------------------------------------------------------ |
| 125 | + |
| 126 | + def _process_kernelsense(self, task: Task) -> None: |
| 127 | + ks_raw = task.headers.get("kernelsense") |
| 128 | + if isinstance(ks_raw, str): |
| 129 | + ks_data = json.loads(ks_raw) |
| 130 | + else: |
| 131 | + ks_data = ks_raw |
| 132 | + |
| 133 | + if not ks_data: |
| 134 | + logger.warning("No kernelsense data in task") |
| 135 | + return |
| 136 | + |
| 137 | + findings = ks_data.get("findings", []) |
| 138 | + driver_new = ks_data.get("driver_new", {}) |
| 139 | + driver_name = driver_new.get("name", driver_new.get("sha256", "unknown")) |
| 140 | + |
| 141 | + # Collect all confirmed variants across all findings |
| 142 | + all_variants = [] |
| 143 | + for finding in findings: |
| 144 | + candidates = finding.get("variant_candidates", []) |
| 145 | + if not candidates: |
| 146 | + continue |
| 147 | + |
| 148 | + assessment = finding.get("llm_assessment", {}) |
| 149 | + if not assessment.get("is_security_fix"): |
| 150 | + continue |
| 151 | + |
| 152 | + confirmed = [ |
| 153 | + c for c in candidates |
| 154 | + if c.get("is_variant") |
| 155 | + and c.get("confidence", 0) >= VARIANT_CONFIDENCE_THRESHOLD |
| 156 | + ] |
| 157 | + |
| 158 | + for variant in confirmed: |
| 159 | + all_variants.append({ |
| 160 | + "source_function": finding.get("function", "unknown"), |
| 161 | + "source_driver": driver_name, |
| 162 | + "bug_class": assessment.get("bug_class", "unknown"), |
| 163 | + "source_confidence": assessment.get("confidence", 0), |
| 164 | + **variant, |
| 165 | + }) |
| 166 | + |
| 167 | + if not all_variants: |
| 168 | + logger.info("No confirmed variants above threshold") |
| 169 | + return |
| 170 | + |
| 171 | + logger.info(f"Found {len(all_variants)} confirmed variant(s)") |
| 172 | + |
| 173 | + msg = self._build_variant_alert(all_variants) |
| 174 | + self._send_telegram_alert(msg) |
| 175 | + self._store_variant_alerts(all_variants) |
| 176 | + |
| 177 | + def _build_variant_alert(self, variants: list[dict]) -> str: |
| 178 | + count = len(variants) |
| 179 | + source = variants[0] |
| 180 | + |
| 181 | + msg = ( |
| 182 | + f"*AutoPiff Variant Alert* — " |
| 183 | + f"{count} potential variant{'s' if count > 1 else ''} found\n\n" |
| 184 | + ) |
| 185 | + msg += ( |
| 186 | + f"Known vulnerability: {source['bug_class']} in " |
| 187 | + f"{source['source_driver']}/`{source['source_function']}`\n" |
| 188 | + f"Source confidence: *{source['source_confidence']:.2f}*\n\n" |
| 189 | + ) |
| 190 | + |
| 191 | + for i, v in enumerate(variants[:5]): |
| 192 | + msg += ( |
| 193 | + f"*{i+1}.* {v['driver']} / `{v['function']}` — " |
| 194 | + f"similarity *{v['similarity']:.2f}*\n" |
| 195 | + ) |
| 196 | + msg += f" {v.get('match_type', 'unknown')} | confidence: {v['confidence']:.2f}\n" |
| 197 | + reasoning = v.get("reasoning", "") |
| 198 | + if reasoning: |
| 199 | + msg += f" _{reasoning[:100]}_\n" |
| 200 | + msg += "\n" |
| 201 | + |
| 202 | + if count > 5: |
| 203 | + msg += f"_...and {count - 5} more variant(s)_\n" |
| 204 | + |
| 205 | + return msg |
| 206 | + |
| 207 | + def _store_variant_alerts(self, variants: list[dict]) -> None: |
| 208 | + now = time.time() |
| 209 | + pipe = self.rdb.pipeline() |
| 210 | + for v in variants: |
| 211 | + entry = { |
| 212 | + "source_driver": v["source_driver"], |
| 213 | + "source_function": v["source_function"], |
| 214 | + "bug_class": v["bug_class"], |
| 215 | + "variant_driver": v["driver"], |
| 216 | + "variant_function": v["function"], |
| 217 | + "similarity": v["similarity"], |
| 218 | + "confidence": v["confidence"], |
| 219 | + "reasoning": v.get("reasoning", ""), |
| 220 | + } |
| 221 | + pipe.zadd(VARIANT_ALERTS_KEY, {json.dumps(entry): now}) |
| 222 | + |
| 223 | + cutoff = now - ALERTS_TTL_SECONDS |
| 224 | + pipe.zremrangebyscore(VARIANT_ALERTS_KEY, "-inf", cutoff) |
| 225 | + pipe.execute() |
| 226 | + |
| 227 | + # ------------------------------------------------------------------ |
| 228 | + # Shared: message building, Telegram, Redis |
| 229 | + # ------------------------------------------------------------------ |
| 230 | + |
101 | 231 | def _build_alert_message( |
102 | 232 | self, findings, driver_sha, new_ver, old_ver, summary |
103 | 233 | ) -> str: |
|
0 commit comments