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 +6 -0
- traceforge/__main__.py +5 -0
- traceforge/case.py +295 -0
- traceforge/catalog.py +202 -0
- traceforge/cli.py +912 -0
- traceforge/config.py +138 -0
- traceforge/data/VERSION +1 -0
- traceforge/data/tools.tsv +153 -0
- traceforge/exporters.py +429 -0
- traceforge/modules/__init__.py +3 -0
- traceforge/modules/documents.py +69 -0
- traceforge/modules/domain.py +65 -0
- traceforge/modules/email.py +66 -0
- traceforge/modules/identity.py +64 -0
- traceforge/modules/image.py +74 -0
- traceforge/modules/network.py +60 -0
- traceforge/modules/opsec.py +56 -0
- traceforge/platform_detect.py +289 -0
- traceforge/runners.py +282 -0
- traceforge/tools.py +760 -0
- traceforge_osint-1.0.0.dist-info/METADATA +309 -0
- traceforge_osint-1.0.0.dist-info/RECORD +27 -0
- traceforge_osint-1.0.0.dist-info/WHEEL +5 -0
- traceforge_osint-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_osint-1.0.0.dist-info/licenses/LICENSE +21 -0
- traceforge_osint-1.0.0.dist-info/licenses/NOTICE +20 -0
- traceforge_osint-1.0.0.dist-info/top_level.txt +1 -0
traceforge/__init__.py
ADDED
traceforge/__main__.py
ADDED
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]
|