codevanta 0.1.0__py3-none-any.whl

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.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,514 @@
1
+ """SQLite-backed session storage for CODEVANTA analyses.
2
+
3
+ A *session* is a persisted snapshot of one analysis run: the normalized
4
+ fingerprint produced by :mod:`codevanta.core.loader` plus the function and
5
+ string counts gathered by later phases. Sessions are additive — saving the
6
+ same binary twice creates two rows with distinct identifiers, which keeps an
7
+ audit trail of repeated analyses.
8
+
9
+ Sessions can also carry *annotations*: one free-text note per address, kept
10
+ in the ``annotations`` table and keyed by ``(session_id, address)`` so that
11
+ re-annotating an address replaces the previous note instead of appending a
12
+ duplicate row.
13
+
14
+ The module uses the standard library only (``sqlite3``, ``uuid``,
15
+ ``datetime``, ``pathlib``) and issues parameterized SQL exclusively.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sqlite3
21
+ import uuid
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from codevanta.core.loader import BinaryInfo
28
+
29
+ __all__ = [
30
+ "DEFAULT_LIMIT",
31
+ "MAX_LIMIT",
32
+ "MAX_NOTE_LENGTH",
33
+ "SESSION_ID_LENGTH",
34
+ "Annotation",
35
+ "SessionError",
36
+ "SessionRecord",
37
+ "SessionStore",
38
+ ]
39
+
40
+ #: Default number of rows returned by :meth:`SessionStore.list`.
41
+ DEFAULT_LIMIT = 50
42
+
43
+ #: Hard ceiling applied by :meth:`SessionStore.list` so one call cannot
44
+ #: exhaust memory on a very large database.
45
+ MAX_LIMIT = 10_000
46
+
47
+ #: Number of hex characters used for generated session identifiers.
48
+ SESSION_ID_LENGTH = 12
49
+
50
+ #: Maximum accepted length of a single annotation note, in characters.
51
+ MAX_NOTE_LENGTH = 4000
52
+
53
+ _SCHEMA = """
54
+ CREATE TABLE IF NOT EXISTS sessions (
55
+ session_id TEXT PRIMARY KEY,
56
+ created_at TEXT NOT NULL,
57
+ binary_path TEXT NOT NULL,
58
+ sha256 TEXT NOT NULL,
59
+ format TEXT NOT NULL,
60
+ architecture TEXT NOT NULL,
61
+ bitness INTEGER NOT NULL,
62
+ entry_point INTEGER,
63
+ functions INTEGER NOT NULL,
64
+ strings INTEGER NOT NULL,
65
+ notes TEXT
66
+ );
67
+
68
+ CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (created_at);
69
+
70
+ CREATE TABLE IF NOT EXISTS annotations (
71
+ session_id TEXT NOT NULL,
72
+ address INTEGER NOT NULL,
73
+ note TEXT NOT NULL,
74
+ created_at TEXT NOT NULL,
75
+ PRIMARY KEY (session_id, address)
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_annotations_session ON annotations (session_id);
79
+ """
80
+
81
+ _COLUMNS = (
82
+ "session_id, created_at, binary_path, sha256, format, architecture, "
83
+ "bitness, entry_point, functions, strings, notes"
84
+ )
85
+
86
+ _INSERT = (
87
+ "INSERT INTO sessions (session_id, created_at, binary_path, sha256, format, "
88
+ "architecture, bitness, entry_point, functions, strings, notes) "
89
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
90
+ )
91
+
92
+ _ANNOTATION_COLUMNS = "session_id, address, note, created_at"
93
+
94
+ _ANNOTATION_UPSERT = (
95
+ "INSERT INTO annotations (session_id, address, note, created_at) "
96
+ "VALUES (?, ?, ?, ?) "
97
+ "ON CONFLICT (session_id, address) DO UPDATE SET "
98
+ "note = excluded.note, created_at = excluded.created_at"
99
+ )
100
+
101
+
102
+ class SessionError(Exception):
103
+ """Raised when a session store cannot be opened, written or queried."""
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class SessionRecord:
108
+ """A single persisted analysis session."""
109
+
110
+ session_id: str
111
+ created_at: str
112
+ binary_path: str
113
+ sha256: str
114
+ format: str
115
+ architecture: str
116
+ bitness: int
117
+ entry_point: int | None
118
+ functions: int
119
+ strings: int
120
+ notes: str | None = None
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Serialize the record, rendering the entry point as hex."""
124
+ return {
125
+ "session_id": self.session_id,
126
+ "created_at": self.created_at,
127
+ "binary_path": self.binary_path,
128
+ "sha256": self.sha256,
129
+ "format": self.format,
130
+ "architecture": self.architecture,
131
+ "bitness": self.bitness,
132
+ "entry_point": f"0x{self.entry_point:x}" if self.entry_point is not None else None,
133
+ "functions": self.functions,
134
+ "strings": self.strings,
135
+ "notes": self.notes,
136
+ }
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class Annotation:
141
+ """A free-text note attached to one address inside a session."""
142
+
143
+ session_id: str
144
+ address: int
145
+ note: str
146
+ created_at: str
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ """Serialize the annotation, rendering the address as hex."""
150
+ return {
151
+ "session_id": self.session_id,
152
+ "address": f"0x{self.address:x}",
153
+ "note": self.note,
154
+ "created_at": self.created_at,
155
+ }
156
+
157
+
158
+ # ``SessionStore.list`` shadows the ``list`` builtin inside the class body, so the
159
+ # type of an annotation collection is aliased here to keep it spellable there.
160
+ _AnnotationList = list[Annotation]
161
+
162
+
163
+ class SessionStore:
164
+ """Create, read, list and delete analysis sessions in SQLite.
165
+
166
+ The database file (and any missing parent directories) is created on
167
+ construction. Use the store as a context manager to close it on exit.
168
+ """
169
+
170
+ def __init__(self, db_path: str | Path) -> None:
171
+ self._db_path = _resolve_db_path(db_path)
172
+ self._closed = False
173
+ try:
174
+ self._conn = sqlite3.connect(str(self._db_path), timeout=5.0)
175
+ except sqlite3.Error as exc: # pragma: no cover - environment dependent
176
+ raise SessionError(f"cannot open session database {self._db_path}: {exc}") from exc
177
+ self._conn.row_factory = sqlite3.Row
178
+ try:
179
+ self._conn.executescript(_SCHEMA)
180
+ self._conn.commit()
181
+ except sqlite3.Error as exc:
182
+ self._conn.close()
183
+ self._closed = True
184
+ raise SessionError(
185
+ f"cannot initialize session database {self._db_path}: {exc}"
186
+ ) from exc
187
+
188
+ # ---- lifecycle ----------------------------------------------------
189
+
190
+ def __enter__(self) -> SessionStore:
191
+ self._require_open()
192
+ return self
193
+
194
+ def __exit__(
195
+ self,
196
+ exc_type: type[BaseException] | None,
197
+ exc_value: BaseException | None,
198
+ traceback: Any,
199
+ ) -> None:
200
+ self.close()
201
+
202
+ def close(self) -> None:
203
+ """Close the database connection. Calling it twice is harmless."""
204
+ if self._closed:
205
+ return
206
+ self._closed = True
207
+ try:
208
+ self._conn.close()
209
+ except sqlite3.Error as exc: # pragma: no cover - defensive
210
+ raise SessionError(f"cannot close session database: {exc}") from exc
211
+
212
+ # ---- CRUD ---------------------------------------------------------
213
+
214
+ def save(
215
+ self,
216
+ info: BinaryInfo,
217
+ functions_count: int,
218
+ strings_count: int,
219
+ notes: str | None = None,
220
+ session_id: str | None = None,
221
+ ) -> SessionRecord:
222
+ """Persist ``info`` as a new session and return the stored record."""
223
+ self._require_open()
224
+ if info is None:
225
+ raise SessionError("binary info is required")
226
+ functions = _require_count(functions_count, "functions_count")
227
+ strings = _require_count(strings_count, "strings_count")
228
+ if notes is not None and not isinstance(notes, str):
229
+ raise SessionError("notes must be a string or None")
230
+ sid = _require_session_id(session_id)
231
+
232
+ record = SessionRecord(
233
+ session_id=sid,
234
+ created_at=_utc_now(),
235
+ binary_path=str(info.path),
236
+ sha256=str(info.sha256),
237
+ format=str(info.format),
238
+ architecture=str(info.architecture),
239
+ bitness=int(info.bitness),
240
+ entry_point=None if info.entry_point is None else int(info.entry_point),
241
+ functions=functions,
242
+ strings=strings,
243
+ notes=notes,
244
+ )
245
+ try:
246
+ self._conn.execute(
247
+ _INSERT,
248
+ (
249
+ record.session_id,
250
+ record.created_at,
251
+ record.binary_path,
252
+ record.sha256,
253
+ record.format,
254
+ record.architecture,
255
+ record.bitness,
256
+ record.entry_point,
257
+ record.functions,
258
+ record.strings,
259
+ record.notes,
260
+ ),
261
+ )
262
+ self._conn.commit()
263
+ except sqlite3.Error as exc:
264
+ self._conn.rollback()
265
+ raise SessionError(f"cannot save session {sid}: {exc}") from exc
266
+ return record
267
+
268
+ def get(self, session_id: str) -> SessionRecord | None:
269
+ """Return the session with ``session_id``, or ``None`` when absent."""
270
+ self._require_open()
271
+ if not _is_usable_id(session_id):
272
+ return None
273
+ try:
274
+ row = self._conn.execute(
275
+ f"SELECT {_COLUMNS} FROM sessions WHERE session_id = ? LIMIT 1",
276
+ (session_id,),
277
+ ).fetchone()
278
+ except sqlite3.Error as exc: # pragma: no cover - defensive
279
+ raise SessionError(f"cannot read session {session_id}: {exc}") from exc
280
+ return None if row is None else _row_to_record(row)
281
+
282
+ def list(self, limit: int = DEFAULT_LIMIT) -> list[SessionRecord]:
283
+ """Return up to ``limit`` sessions, newest first.
284
+
285
+ Ordering falls back to insertion order (``rowid``) when two sessions
286
+ share a timestamp, which happens on platforms with coarse clocks.
287
+ """
288
+ self._require_open()
289
+ if isinstance(limit, bool) or not isinstance(limit, int):
290
+ raise SessionError("limit must be an integer")
291
+ if limit <= 0:
292
+ raise SessionError("limit must be a positive integer")
293
+ capped = min(limit, MAX_LIMIT)
294
+ try:
295
+ rows = self._conn.execute(
296
+ f"SELECT {_COLUMNS} FROM sessions "
297
+ "ORDER BY created_at DESC, rowid DESC LIMIT ?",
298
+ (capped,),
299
+ ).fetchall()
300
+ except sqlite3.Error as exc: # pragma: no cover - defensive
301
+ raise SessionError(f"cannot list sessions: {exc}") from exc
302
+ return [_row_to_record(row) for row in rows]
303
+
304
+ def delete(self, session_id: str) -> bool:
305
+ """Delete a session and return ``True`` when a row was removed."""
306
+ self._require_open()
307
+ if not _is_usable_id(session_id):
308
+ return False
309
+ try:
310
+ cursor = self._conn.execute(
311
+ "DELETE FROM sessions WHERE session_id = ?",
312
+ (session_id,),
313
+ )
314
+ self._conn.commit()
315
+ except sqlite3.Error as exc:
316
+ self._conn.rollback()
317
+ raise SessionError(f"cannot delete session {session_id}: {exc}") from exc
318
+ return cursor.rowcount > 0
319
+
320
+ # ---- annotations --------------------------------------------------
321
+
322
+ def annotate(self, session_id: str, address: int, note: str) -> Annotation:
323
+ """Attach ``note`` to ``address`` inside ``session_id``.
324
+
325
+ Notes are unique per ``(session_id, address)`` pair: annotating an
326
+ address a second time replaces the stored note (and its timestamp)
327
+ instead of adding another row.
328
+ """
329
+ self._require_open()
330
+ sid = _require_session_id(session_id)
331
+ addr = _require_address(address)
332
+ text = _require_note(note)
333
+ if not self._session_exists(sid):
334
+ raise SessionError(f"unknown session {sid}")
335
+
336
+ annotation = Annotation(
337
+ session_id=sid,
338
+ address=addr,
339
+ note=text,
340
+ created_at=_utc_now(),
341
+ )
342
+ try:
343
+ self._conn.execute(
344
+ _ANNOTATION_UPSERT,
345
+ (
346
+ annotation.session_id,
347
+ annotation.address,
348
+ annotation.note,
349
+ annotation.created_at,
350
+ ),
351
+ )
352
+ self._conn.commit()
353
+ except sqlite3.Error as exc:
354
+ self._conn.rollback()
355
+ raise SessionError(
356
+ f"cannot annotate address 0x{addr:x} in session {sid}: {exc}"
357
+ ) from exc
358
+ return annotation
359
+
360
+ def annotations(self, session_id: str) -> _AnnotationList:
361
+ """Return every annotation of ``session_id``, lowest address first."""
362
+ self._require_open()
363
+ sid = _require_session_id(session_id)
364
+ if not self._session_exists(sid):
365
+ raise SessionError(f"unknown session {sid}")
366
+ try:
367
+ rows = self._conn.execute(
368
+ f"SELECT {_ANNOTATION_COLUMNS} FROM annotations "
369
+ "WHERE session_id = ? ORDER BY address ASC",
370
+ (sid,),
371
+ ).fetchall()
372
+ except sqlite3.Error as exc: # pragma: no cover - defensive
373
+ raise SessionError(f"cannot list annotations for session {sid}: {exc}") from exc
374
+ return [_row_to_annotation(row) for row in rows]
375
+
376
+ def remove_annotation(self, session_id: str, address: int) -> bool:
377
+ """Drop the note at ``address`` and return ``True`` when one existed.
378
+
379
+ Unknown sessions and unknown addresses simply report ``False`` so the
380
+ call stays idempotent.
381
+ """
382
+ self._require_open()
383
+ if not _is_usable_id(session_id):
384
+ return False
385
+ if isinstance(address, bool) or not isinstance(address, int) or address < 0:
386
+ return False
387
+ try:
388
+ cursor = self._conn.execute(
389
+ "DELETE FROM annotations WHERE session_id = ? AND address = ?",
390
+ (session_id, address),
391
+ )
392
+ self._conn.commit()
393
+ except sqlite3.Error as exc:
394
+ self._conn.rollback()
395
+ raise SessionError(
396
+ f"cannot remove annotation at 0x{address:x} in session {session_id}: {exc}"
397
+ ) from exc
398
+ return cursor.rowcount > 0
399
+
400
+ # ---- helpers ------------------------------------------------------
401
+
402
+ def _session_exists(self, session_id: str) -> bool:
403
+ """Return ``True`` when a row with ``session_id`` is stored."""
404
+ try:
405
+ row = self._conn.execute(
406
+ "SELECT 1 FROM sessions WHERE session_id = ? LIMIT 1",
407
+ (session_id,),
408
+ ).fetchone()
409
+ except sqlite3.Error as exc: # pragma: no cover - defensive
410
+ raise SessionError(f"cannot read session {session_id}: {exc}") from exc
411
+ return row is not None
412
+
413
+ def _require_open(self) -> None:
414
+ if self._closed:
415
+ raise SessionError("session store is closed")
416
+
417
+
418
+ def _resolve_db_path(db_path: str | Path) -> str | Path:
419
+ """Validate ``db_path`` and create its missing parent directories."""
420
+ if isinstance(db_path, Path):
421
+ path = db_path
422
+ elif isinstance(db_path, str):
423
+ text = db_path.strip()
424
+ if not text:
425
+ raise SessionError("database path must not be empty")
426
+ path = Path(text)
427
+ else:
428
+ raise SessionError(f"database path must be str or Path, not {type(db_path).__name__}")
429
+
430
+ if str(path) == ":memory:": # pragma: no cover - in-memory store
431
+ return ":memory:"
432
+
433
+ try:
434
+ if path.exists() and path.is_dir():
435
+ raise SessionError(f"database path is a directory: {path}")
436
+ path.parent.mkdir(parents=True, exist_ok=True)
437
+ except SessionError:
438
+ raise
439
+ except OSError as exc:
440
+ raise SessionError(f"cannot create directory for database {path}: {exc}") from exc
441
+ return path
442
+
443
+
444
+ def _is_usable_id(session_id: Any) -> bool:
445
+ return isinstance(session_id, str) and bool(session_id.strip())
446
+
447
+
448
+ def _require_session_id(session_id: str | None) -> str:
449
+ if session_id is None:
450
+ return uuid.uuid4().hex[:SESSION_ID_LENGTH]
451
+ if not _is_usable_id(session_id):
452
+ raise SessionError("session_id must be a non-empty string")
453
+ return session_id
454
+
455
+
456
+ def _require_count(value: int, name: str) -> int:
457
+ if isinstance(value, bool) or not isinstance(value, int):
458
+ raise SessionError(f"{name} must be an integer")
459
+ if value < 0:
460
+ raise SessionError(f"{name} must not be negative")
461
+ return value
462
+
463
+
464
+ def _utc_now() -> str:
465
+ """Current UTC time as an ISO-8601 string with a numeric offset."""
466
+ return datetime.now(timezone.utc).isoformat(timespec="microseconds")
467
+
468
+
469
+ def _require_address(address: Any) -> int:
470
+ """Validate an annotation address, which must be a non-negative integer."""
471
+ if isinstance(address, bool) or not isinstance(address, int):
472
+ raise SessionError("address must be an integer")
473
+ if address < 0:
474
+ raise SessionError("address must not be negative")
475
+ return address
476
+
477
+
478
+ def _require_note(note: Any) -> str:
479
+ """Validate an annotation note: a non-blank string of bounded length."""
480
+ if not isinstance(note, str):
481
+ raise SessionError("note must be a string")
482
+ if not note.strip():
483
+ raise SessionError("note must not be empty")
484
+ if len(note) > MAX_NOTE_LENGTH:
485
+ raise SessionError(
486
+ f"note must be at most {MAX_NOTE_LENGTH} characters long"
487
+ )
488
+ return note
489
+
490
+
491
+ def _row_to_annotation(row: sqlite3.Row) -> Annotation:
492
+ return Annotation(
493
+ session_id=row["session_id"],
494
+ address=int(row["address"]),
495
+ note=row["note"],
496
+ created_at=row["created_at"],
497
+ )
498
+
499
+
500
+ def _row_to_record(row: sqlite3.Row) -> SessionRecord:
501
+ entry_point = row["entry_point"]
502
+ return SessionRecord(
503
+ session_id=row["session_id"],
504
+ created_at=row["created_at"],
505
+ binary_path=row["binary_path"],
506
+ sha256=row["sha256"],
507
+ format=row["format"],
508
+ architecture=row["architecture"],
509
+ bitness=int(row["bitness"]),
510
+ entry_point=None if entry_point is None else int(entry_point),
511
+ functions=int(row["functions"]),
512
+ strings=int(row["strings"]),
513
+ notes=row["notes"],
514
+ )