traceforge-osint 1.0.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.
traceforge/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ TraceForge — Open-Source Intelligence & Digital Forensics Platform.
3
+ """
4
+
5
+ __version__ = "1.0.0"
6
+ __author__ = "Aman Kumar Pandey"
traceforge/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from traceforge.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
traceforge/case.py ADDED
@@ -0,0 +1,295 @@
1
+ import datetime
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ from traceforge.config import get_workspace_dir, load_config, save_config
10
+
11
+ def hash_file(file_path: Path) -> Dict[str, str]:
12
+ """Calculates SHA-256 and MD5 checksums for a given file."""
13
+ sha256 = hashlib.sha256()
14
+ md5 = hashlib.md5()
15
+ with open(file_path, "rb") as f:
16
+ while chunk := f.read(65536):
17
+ sha256.update(chunk)
18
+ md5.update(chunk)
19
+ return {
20
+ "sha256": sha256.hexdigest(),
21
+ "md5": md5.hexdigest(),
22
+ }
23
+
24
+ class Case:
25
+ """Manages an active forensic investigation case workspace."""
26
+
27
+ def __init__(self, case_id: str):
28
+ self.case_id = case_id
29
+ self.case_dir = get_workspace_dir() / case_id
30
+ self.case_json = self.case_dir / "case.json"
31
+ self.evidence_dir = self.case_dir / "evidence"
32
+ self.reports_dir = self.case_dir / "reports"
33
+ self.exports_dir = self.case_dir / "exports"
34
+ self.logs_dir = self.case_dir / "logs"
35
+ self.data: Dict[str, Any] = {}
36
+ self.load()
37
+
38
+ def exists(self) -> bool:
39
+ return self.case_dir.exists() and self.case_json.exists()
40
+
41
+ def load(self) -> None:
42
+ if self.case_json.exists():
43
+ try:
44
+ with open(self.case_json, "r", encoding="utf-8") as f:
45
+ self.data = json.load(f)
46
+ except Exception:
47
+ self.data = {}
48
+ else:
49
+ self.data = {}
50
+
51
+ def save(self) -> None:
52
+ self.case_dir.mkdir(parents=True, exist_ok=True)
53
+ self.evidence_dir.mkdir(parents=True, exist_ok=True)
54
+ self.reports_dir.mkdir(parents=True, exist_ok=True)
55
+ self.exports_dir.mkdir(parents=True, exist_ok=True)
56
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
57
+
58
+ with open(self.case_json, "w", encoding="utf-8") as f:
59
+ json.dump(self.data, f, indent=2)
60
+
61
+ def log_action(self, action: str, details: str, operator: str = "Analyst") -> None:
62
+ """Appends an immutable entry to the chain of custody audit log."""
63
+ ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
64
+ entry = {
65
+ "timestamp": ts,
66
+ "operator": operator,
67
+ "action": action,
68
+ "details": details,
69
+ }
70
+ if "chain_of_custody" not in self.data:
71
+ self.data["chain_of_custody"] = []
72
+ self.data["chain_of_custody"].append(entry)
73
+
74
+ # Also append to custody log file
75
+ log_file = self.logs_dir / "chain_of_custody.log"
76
+ with open(log_file, "a", encoding="utf-8") as f:
77
+ f.write(f"[{ts}] [{operator}] {action} - {details}\n")
78
+ self.save()
79
+
80
+ def add_evidence(
81
+ self,
82
+ source_path: Union[str, Path],
83
+ description: str = "",
84
+ source_device: str = "Target System",
85
+ analyst: str = "Analyst"
86
+ ) -> Dict[str, Any]:
87
+ src = Path(source_path).resolve()
88
+ if not src.exists() or not src.is_file():
89
+ raise FileNotFoundError(f"Evidence file not found: {source_path}")
90
+
91
+ evid_list = self.data.setdefault("evidence", [])
92
+ evid_id = f"EVID-{len(evid_list) + 1:03d}"
93
+ dest_filename = f"{evid_id}_{src.name}"
94
+ dest_path = self.evidence_dir / dest_filename
95
+
96
+ # Copy evidence immutably
97
+ shutil.copy2(src, dest_path)
98
+ os.chmod(dest_path, 0o444) # Make read-only
99
+
100
+ hashes = hash_file(dest_path)
101
+ size_bytes = dest_path.stat().st_size
102
+ ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
103
+
104
+ record = {
105
+ "id": evid_id,
106
+ "filename": src.name,
107
+ "stored_filename": dest_filename,
108
+ "relative_path": f"evidence/{dest_filename}",
109
+ "size_bytes": size_bytes,
110
+ "sha256": hashes["sha256"],
111
+ "md5": hashes["md5"],
112
+ "description": description,
113
+ "source_device": source_device,
114
+ "acquired_at": ts,
115
+ "analyst": analyst,
116
+ }
117
+
118
+ evid_list.append(record)
119
+ self.log_action("INGEST_EVIDENCE", f"Ingested {src.name} as {evid_id} (SHA-256: {hashes['sha256'][:16]}...)", analyst)
120
+ return record
121
+
122
+ ingest_evidence = add_evidence
123
+
124
+ def add_finding(
125
+ self,
126
+ title: str,
127
+ category: str = "General",
128
+ severity: str = "medium",
129
+ status: str = "open",
130
+ description: str = "",
131
+ evidence_refs: Optional[List[str]] = None,
132
+ analyst: str = "Analyst"
133
+ ) -> Dict[str, Any]:
134
+ findings = self.data.setdefault("findings", [])
135
+ find_id = f"FIND-{len(findings) + 1:03d}"
136
+ ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
137
+
138
+ rec = {
139
+ "id": find_id,
140
+ "title": title,
141
+ "category": category,
142
+ "severity": severity.lower(),
143
+ "status": status.lower(),
144
+ "description": description,
145
+ "evidence": evidence_refs or [],
146
+ "created_at": ts,
147
+ "analyst": analyst,
148
+ }
149
+ findings.append(rec)
150
+ self.log_action("RECORD_FINDING", f"Created finding {find_id}: {title} [{severity.upper()}]", analyst)
151
+ return rec
152
+
153
+ def add_ioc(
154
+ self,
155
+ value: str,
156
+ ioc_type: str = "domain",
157
+ context: str = "",
158
+ source: str = "Manual Record",
159
+ confidence: str = "high",
160
+ analyst: str = "Analyst"
161
+ ) -> Dict[str, Any]:
162
+ iocs = self.data.setdefault("iocs", [])
163
+ ioc_id = f"IOC-{len(iocs) + 1:03d}"
164
+ ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
165
+
166
+ rec = {
167
+ "id": ioc_id,
168
+ "type": ioc_type.lower(),
169
+ "value": value.strip(),
170
+ "context": context,
171
+ "source": source,
172
+ "confidence": confidence.lower(),
173
+ "first_seen": ts,
174
+ "last_seen": ts,
175
+ }
176
+ iocs.append(rec)
177
+ self.log_action("ADD_IOC", f"Registered observable {ioc_id} ({ioc_type}: {value})", analyst)
178
+ return rec
179
+
180
+ def add_event(
181
+ self,
182
+ timestamp_str: str,
183
+ title: str,
184
+ description: str = "",
185
+ source: str = "Manual Record",
186
+ severity: str = "info",
187
+ evidence_ref: str = "",
188
+ analyst: str = "Analyst"
189
+ ) -> Dict[str, Any]:
190
+ events = self.data.setdefault("timeline", [])
191
+ evt_id = f"EVT-{len(events) + 1:04d}"
192
+
193
+ rec = {
194
+ "id": evt_id,
195
+ "timestamp": timestamp_str,
196
+ "title": title,
197
+ "description": description,
198
+ "source": source,
199
+ "severity": severity.lower(),
200
+ "evidence_ref": evidence_ref,
201
+ }
202
+ events.append(rec)
203
+ self.log_action("ADD_TIMELINE_EVENT", f"Timeline event {evt_id} recorded ({title})", analyst)
204
+ return rec
205
+
206
+ add_timeline_event = add_event
207
+
208
+ def get_summary(self) -> Dict[str, Any]:
209
+ evidence = self.data.get("evidence", [])
210
+ findings = self.data.get("findings", [])
211
+ iocs = self.data.get("iocs", [])
212
+ timeline = self.data.get("timeline", [])
213
+
214
+ high_sev = sum(1 for f in findings if f.get("severity") in ("high", "critical"))
215
+ unique_ips = len({i["value"] for i in iocs if i.get("type") in ("ipv4", "ipv6", "ip")})
216
+ unique_doms = len({i["value"] for i in iocs if i.get("type") == "domain"})
217
+ unique_emails = len({i["value"] for i in iocs if i.get("type") == "email"})
218
+
219
+ return {
220
+ "case_id": self.case_id,
221
+ "case_name": self.data.get("case_name", "Untitled Case"),
222
+ "analyst": self.data.get("analyst", "Analyst"),
223
+ "status": self.data.get("status", "active"),
224
+ "created_at": self.data.get("created_at", "-"),
225
+ "total_evidence": len(evidence),
226
+ "total_findings": len(findings),
227
+ "high_severity_findings": high_sev,
228
+ "total_iocs": len(iocs),
229
+ "unique_ips": unique_ips,
230
+ "unique_domains": unique_doms,
231
+ "unique_emails": unique_emails,
232
+ "total_timeline_events": len(timeline),
233
+ }
234
+
235
+ def create_case(name: str = "Forensic Investigation", analyst: str = "Analyst", case_id: Optional[str] = None) -> Case:
236
+ """Initializes a new case directory and case.json."""
237
+ if not case_id:
238
+ rand_suffix = hashlib.sha256(f"{name}{datetime.datetime.now()}".encode()).hexdigest()[:6].upper()
239
+ date_str = datetime.datetime.now().strftime("%Y%m%d")
240
+ case_id = f"CASE-{date_str}-{rand_suffix}"
241
+
242
+ case = Case(case_id)
243
+ case.case_dir.mkdir(parents=True, exist_ok=True)
244
+ ts = datetime.datetime.now(datetime.timezone.utc).isoformat()
245
+
246
+ case.data = {
247
+ "case_id": case_id,
248
+ "case_name": name,
249
+ "analyst": analyst,
250
+ "status": "active",
251
+ "created_at": ts,
252
+ "evidence": [],
253
+ "findings": [],
254
+ "iocs": [],
255
+ "timeline": [],
256
+ "chain_of_custody": [],
257
+ }
258
+ case.save()
259
+ case.log_action("CREATE_CASE", f"Initialized new case: {name} ({case_id})", analyst)
260
+
261
+ # Set as active case
262
+ cfg = load_config()
263
+ cfg["active_case"] = case_id
264
+ save_config(cfg)
265
+
266
+ return case
267
+
268
+ def get_active_case() -> Optional[Case]:
269
+ cfg = load_config()
270
+ cid = cfg.get("active_case", "")
271
+ if cid:
272
+ c = Case(cid)
273
+ if c.exists():
274
+ return c
275
+ return None
276
+
277
+ def set_active_case(case_id: str) -> bool:
278
+ c = Case(case_id)
279
+ if c.exists():
280
+ cfg = load_config()
281
+ cfg["active_case"] = case_id
282
+ save_config(cfg)
283
+ return True
284
+ return False
285
+
286
+ def list_all_cases() -> List[Dict[str, Any]]:
287
+ ws = get_workspace_dir()
288
+ results = []
289
+ if not ws.exists():
290
+ return results
291
+ for entry in sorted(ws.iterdir()):
292
+ if entry.is_dir() and (entry / "case.json").exists():
293
+ c = Case(entry.name)
294
+ results.append(c.get_summary())
295
+ return results
traceforge/catalog.py ADDED
@@ -0,0 +1,202 @@
1
+ import csv
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from traceforge.config import get_project_root
6
+ from traceforge.platform_detect import is_tool_installed, which_tool
7
+
8
+ class ToolRecord:
9
+ def __init__(
10
+ self,
11
+ tool_id: int,
12
+ name: str,
13
+ binary: str,
14
+ category: str,
15
+ subcategory: str,
16
+ ecosystem: str,
17
+ mac_install: str,
18
+ linux_install: str,
19
+ description: str,
20
+ status: str,
21
+ requires_root: bool,
22
+ requires_api: bool,
23
+ requires_hardware: bool,
24
+ notes: str,
25
+ source_url: str,
26
+ termux_status: str = "supported",
27
+ termux_package: str = "-",
28
+ termux_install: str = "-",
29
+ termux_notes: str = "",
30
+ termux_root: bool = False,
31
+ termux_api: bool = False,
32
+ termux_hardware: bool = False,
33
+ ):
34
+ self.id = tool_id
35
+ self.name = name
36
+ self.binary = binary
37
+ self.category = category
38
+ self.subcategory = subcategory
39
+ self.ecosystem = ecosystem
40
+ self.mac_install = mac_install
41
+ self.linux_install = linux_install
42
+ self.description = description
43
+ self.status = status
44
+ self.requires_root = requires_root
45
+ self.requires_api = requires_api
46
+ self.requires_hardware = requires_hardware
47
+ self.notes = notes
48
+ self.source_url = source_url
49
+
50
+ # Termux specific fields
51
+ self.termux_status = termux_status
52
+ self.termux_package = termux_package
53
+ self.termux_install = termux_install
54
+ self.termux_notes = termux_notes
55
+ self.termux_root = termux_root
56
+ self.termux_api = termux_api
57
+ self.termux_hardware = termux_hardware
58
+
59
+ @property
60
+ def is_installed(self) -> bool:
61
+ return is_tool_installed(self.binary)
62
+
63
+ @property
64
+ def binary_path(self) -> Optional[str]:
65
+ return which_tool(self.binary)
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ return {
69
+ "id": self.id,
70
+ "name": self.name,
71
+ "binary": self.binary,
72
+ "category": self.category,
73
+ "subcategory": self.subcategory,
74
+ "ecosystem": self.ecosystem,
75
+ "mac_install": self.mac_install,
76
+ "linux_install": self.linux_install,
77
+ "description": self.description,
78
+ "status": self.status,
79
+ "requires_root": self.requires_root,
80
+ "requires_api": self.requires_api,
81
+ "requires_hardware": self.requires_hardware,
82
+ "notes": self.notes,
83
+ "source_url": self.source_url,
84
+ "termux_status": self.termux_status,
85
+ "termux_package": self.termux_package,
86
+ "termux_install": self.termux_install,
87
+ "termux_notes": self.termux_notes,
88
+ "termux_root": self.termux_root,
89
+ "termux_api": self.termux_api,
90
+ "termux_hardware": self.termux_hardware,
91
+ "is_installed": self.is_installed,
92
+ "binary_path": self.binary_path,
93
+ }
94
+
95
+ def get_bundled_catalog_path() -> Path:
96
+ """Resolves the canonical tools.tsv path from package data or repository root."""
97
+ pkg_data = Path(__file__).resolve().parent / "data" / "tools.tsv"
98
+ if pkg_data.exists():
99
+ return pkg_data
100
+ repo_data = get_project_root() / "catalog" / "tools.tsv"
101
+ if repo_data.exists():
102
+ return repo_data
103
+ return pkg_data
104
+
105
+ Tool = ToolRecord
106
+
107
+ class Catalog:
108
+ """Parses and indexes the canonical tool registry in catalog/tools.tsv."""
109
+
110
+ def __init__(self, tsv_path: Optional[Path] = None):
111
+ if tsv_path is None:
112
+ tsv_path = get_bundled_catalog_path()
113
+ self.tsv_path = Path(tsv_path)
114
+ self.tools: List[ToolRecord] = []
115
+ self._by_id: Dict[int, ToolRecord] = {}
116
+ self._by_bin: Dict[str, ToolRecord] = {}
117
+ self.load()
118
+
119
+ def __len__(self) -> int:
120
+ return len(self.tools)
121
+
122
+ def load(self) -> None:
123
+ if not self.tsv_path.exists():
124
+ return
125
+ self.tools.clear()
126
+ self._by_id.clear()
127
+ self._by_bin.clear()
128
+
129
+ with open(self.tsv_path, "r", encoding="utf-8") as f:
130
+ reader = csv.DictReader(f, delimiter="\t")
131
+ for row in reader:
132
+ try:
133
+ tid = int(row.get("id", "0"))
134
+ record = ToolRecord(
135
+ tool_id=tid,
136
+ name=row.get("name", ""),
137
+ binary=row.get("binary", ""),
138
+ category=row.get("category", ""),
139
+ subcategory=row.get("subcategory", ""),
140
+ ecosystem=row.get("ecosystem", "native"),
141
+ mac_install=row.get("mac_install", ""),
142
+ linux_install=row.get("linux_install", ""),
143
+ description=row.get("description", ""),
144
+ status=row.get("status", "verified"),
145
+ requires_root=row.get("requires_root", "no").lower() in ("yes", "true", "1"),
146
+ requires_api=row.get("requires_api", "no").lower() in ("yes", "true", "1"),
147
+ requires_hardware=row.get("requires_hardware", "no").lower() in ("yes", "true", "1"),
148
+ notes=row.get("notes", ""),
149
+ source_url=row.get("source_url", ""),
150
+ termux_status=row.get("termux_status", "supported"),
151
+ termux_package=row.get("termux_package", "-"),
152
+ termux_install=row.get("termux_install", "-"),
153
+ termux_notes=row.get("termux_notes", ""),
154
+ termux_root=row.get("termux_root", "no").lower() in ("yes", "true", "1"),
155
+ termux_api=row.get("termux_api", "no").lower() in ("yes", "true", "1"),
156
+ termux_hardware=row.get("termux_hardware", "no").lower() in ("yes", "true", "1"),
157
+ )
158
+ self.tools.append(record)
159
+ self._by_id[tid] = record
160
+ self._by_bin[record.binary.lower()] = record
161
+ except Exception:
162
+ continue
163
+
164
+ def get_by_id(self, tool_id: int) -> Optional[ToolRecord]:
165
+ return self._by_id.get(tool_id)
166
+
167
+ def get_by_binary(self, binary_name: str) -> Optional[ToolRecord]:
168
+ return self._by_bin.get(binary_name.lower())
169
+
170
+ def search(self, query: str) -> List[ToolRecord]:
171
+ q = query.lower().strip()
172
+ if not q:
173
+ return self.tools.copy()
174
+ results = []
175
+ for t in self.tools:
176
+ if (
177
+ q in t.name.lower()
178
+ or q in t.binary.lower()
179
+ or q in t.category.lower()
180
+ or q in t.subcategory.lower()
181
+ or q in t.description.lower()
182
+ or q in t.notes.lower()
183
+ or q in t.termux_notes.lower()
184
+ or q in t.termux_package.lower()
185
+ ):
186
+ results.append(t)
187
+ return results
188
+
189
+ def get_categories(self) -> List[str]:
190
+ seen = set()
191
+ cats = []
192
+ for t in self.tools:
193
+ if t.category and t.category not in seen:
194
+ seen.add(t.category)
195
+ cats.append(t.category)
196
+ return cats
197
+
198
+ def filter_by_category(self, category: str) -> List[ToolRecord]:
199
+ return [t for t in self.tools if t.category == category]
200
+
201
+ def filter_by_termux_status(self, termux_status: str) -> List[ToolRecord]:
202
+ return [t for t in self.tools if t.termux_status == termux_status]