ptopenvas 0.0.1__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.
- ptopenvas/__init__.py +0 -0
- ptopenvas/_version.py +1 -0
- ptopenvas/modules/__init__.py +0 -0
- ptopenvas/modules/gvm_setup.py +237 -0
- ptopenvas/modules/helpers.py +220 -0
- ptopenvas/ptopenvas.py +401 -0
- ptopenvas-0.0.1.dist-info/METADATA +109 -0
- ptopenvas-0.0.1.dist-info/RECORD +12 -0
- ptopenvas-0.0.1.dist-info/WHEEL +5 -0
- ptopenvas-0.0.1.dist-info/entry_points.txt +2 -0
- ptopenvas-0.0.1.dist-info/licenses/LICENSE +674 -0
- ptopenvas-0.0.1.dist-info/top_level.txt +1 -0
ptopenvas/__init__.py
ADDED
|
File without changes
|
ptopenvas/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
File without changes
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import secrets
|
|
3
|
+
import string
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import shutil
|
|
7
|
+
import grp
|
|
8
|
+
|
|
9
|
+
from gvm.transforms import EtreeCheckCommandTransform
|
|
10
|
+
from gvm.connections import UnixSocketConnection
|
|
11
|
+
from gvm.protocols.gmp import Gmp
|
|
12
|
+
from gvm.errors import GvmError
|
|
13
|
+
|
|
14
|
+
from ptlibs.app_dirs import AppDirs
|
|
15
|
+
|
|
16
|
+
class GVMSetup:
|
|
17
|
+
|
|
18
|
+
PASSWORD_FILE = os.path.join(AppDirs("ptopenvas").get_base_dir(), "gvm.pass")
|
|
19
|
+
|
|
20
|
+
def __init__(self, args={}) -> None:
|
|
21
|
+
self.args = args
|
|
22
|
+
|
|
23
|
+
def run(self) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Main function to ensure dependencies and GVM setup.
|
|
26
|
+
Returns True if everything is ready.
|
|
27
|
+
"""
|
|
28
|
+
# 1. Install GVM and ensure initialization
|
|
29
|
+
self.install_gvm() # ensure gvm is installed
|
|
30
|
+
self.ensure_gvmd_initialized() # run gvm-setup
|
|
31
|
+
|
|
32
|
+
# 2. Ensure GVMD service is running
|
|
33
|
+
self.run_gvmd_daemon()
|
|
34
|
+
|
|
35
|
+
# 3. Finalize permissions
|
|
36
|
+
self.finalize_permissions()
|
|
37
|
+
|
|
38
|
+
# 4. Ensure penterep user exists (creates with random password if needed)
|
|
39
|
+
self.ensure_penterep_user()
|
|
40
|
+
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def install_gvm(self) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Installs missing GVM dependencies and runs gvm-setup.
|
|
47
|
+
Outputs all subprocess logs directly to the terminal.
|
|
48
|
+
"""
|
|
49
|
+
required_packages = ["gvmd", "gvm-cli"] # Minimal required packages
|
|
50
|
+
|
|
51
|
+
missing = [pkg for pkg in required_packages if shutil.which(pkg) is None]
|
|
52
|
+
|
|
53
|
+
if missing:
|
|
54
|
+
print(f"Installing missing packages: {', '.join(missing)}")
|
|
55
|
+
try:
|
|
56
|
+
subprocess.run(["sudo", "apt", "update"], check=True)
|
|
57
|
+
subprocess.run(["sudo", "apt", "install", "-y"] + missing, check=True)
|
|
58
|
+
print("Installation complete.")
|
|
59
|
+
except subprocess.CalledProcessError as e:
|
|
60
|
+
print(f"Failed to install packages: {e}")
|
|
61
|
+
return False
|
|
62
|
+
else:
|
|
63
|
+
pass#print("All core GVM packages are already installed.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_gvmd_initialized(self) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Ensure GVMD is initialized by running `gvm-setup` if needed.
|
|
69
|
+
|
|
70
|
+
This checks for the GVMD Unix socket at `/run/gvmd/gvmd.sock` and
|
|
71
|
+
runs `gvm-setup` only when initialization appears required.
|
|
72
|
+
"""
|
|
73
|
+
# 1) Check if GVMD database exists
|
|
74
|
+
try:
|
|
75
|
+
db_check = subprocess.run(
|
|
76
|
+
["sudo", "-u", "postgres", "psql", "-tAc", "SELECT 1 FROM pg_database WHERE datname='gvmd'"],
|
|
77
|
+
#["-u", "postgres", "psql", "-tAc", "SELECT 1 FROM pg_database WHERE datname='gvmd'"],
|
|
78
|
+
capture_output=True, text=True, check=True
|
|
79
|
+
)
|
|
80
|
+
if db_check.stdout.strip() == "1":
|
|
81
|
+
#print("GVM setup was already completed previously.")
|
|
82
|
+
return True
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# If database missing -> setup never ran
|
|
87
|
+
try:
|
|
88
|
+
print("Running gvm-setup...")
|
|
89
|
+
subprocess.run(["sudo", "gvm-setup"], check=True)
|
|
90
|
+
print("GVMD initialization completed successfully.")
|
|
91
|
+
return True
|
|
92
|
+
except subprocess.CalledProcessError as e:
|
|
93
|
+
print(f"GVMD initialization failed: {e}")
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def run_gvmd_daemon(self):
|
|
97
|
+
"""
|
|
98
|
+
Ensure that the GVMD service is running.
|
|
99
|
+
Starts it if it is not active.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
#status = subprocess.run(["systemctl", "is-active", "--quiet", "gvmd"])
|
|
103
|
+
status = subprocess.run(["systemctl", "is-active", "--quiet", "gvmd"])
|
|
104
|
+
if status.returncode != 0:
|
|
105
|
+
print("GVMD service is not running. Starting with gvm-start...")
|
|
106
|
+
subprocess.run(["sudo", "gvm-start"], check=True)
|
|
107
|
+
print("GVM stack started.")
|
|
108
|
+
else:
|
|
109
|
+
pass
|
|
110
|
+
#print("GVMD service is already running.")
|
|
111
|
+
except subprocess.CalledProcessError as e:
|
|
112
|
+
print(f"Failed to start GVMD service: {e}")
|
|
113
|
+
|
|
114
|
+
def ensure_penterep_user(self):
|
|
115
|
+
"""
|
|
116
|
+
Ensure gvmd service is running and the 'penterep' admin user exists.
|
|
117
|
+
If the user does not exist, create it with a random password stored in PASSWORD_FILE.
|
|
118
|
+
If the provided admin password is wrong, it will be reset automatically.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# 1. Define admin username and get password
|
|
122
|
+
USERNAME = "penterep"
|
|
123
|
+
PASSWORD = self.get_password() # "admin"
|
|
124
|
+
|
|
125
|
+
self._create_user_if_needed(username=USERNAME, password=PASSWORD)
|
|
126
|
+
|
|
127
|
+
# 2. Determine password for penterep
|
|
128
|
+
if not PASSWORD:
|
|
129
|
+
alphabet = string.ascii_letters + string.digits
|
|
130
|
+
PASSWORD = ''.join(secrets.choice(alphabet) for _ in range(24))
|
|
131
|
+
with open(self.PASSWORD_FILE, "w") as f:
|
|
132
|
+
f.write(PASSWORD)
|
|
133
|
+
|
|
134
|
+
# 3. Connect to gvmd and create user if missing
|
|
135
|
+
conn = UnixSocketConnection(path="/run/gvmd/gvmd.sock")
|
|
136
|
+
try:
|
|
137
|
+
with Gmp(conn, transform=EtreeCheckCommandTransform()) as gmp:
|
|
138
|
+
try:
|
|
139
|
+
gmp.authenticate(USERNAME, PASSWORD)
|
|
140
|
+
except GvmError as e:
|
|
141
|
+
try:
|
|
142
|
+
subprocess.run([
|
|
143
|
+
"sudo", "-u", "_gvm", "gvmd",
|
|
144
|
+
"--user=penterep",
|
|
145
|
+
f"--new-password={PASSWORD}"
|
|
146
|
+
], check=True, capture_output=True)
|
|
147
|
+
#print(f"Reset penterep password to '{PASSWORD}'")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
print(f"Failed to reset penterep password: {e}")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
except PermissionError:
|
|
153
|
+
print("Please reset your shell (log out and log back in) before running scripts that access GVMD.")
|
|
154
|
+
except GvmError as e:
|
|
155
|
+
print(f"Failed to create user: {e}")
|
|
156
|
+
|
|
157
|
+
def _create_user_if_needed(self, username, password):
|
|
158
|
+
r = subprocess.run([
|
|
159
|
+
"sudo", "-u", "_gvm", "gvmd",
|
|
160
|
+
"--get-users"
|
|
161
|
+
], check=True, capture_output=True)
|
|
162
|
+
|
|
163
|
+
available_users = [u for u in r.stdout.decode().split("\n") if u]
|
|
164
|
+
#print("Available_users:\n ", '\n '.join(available_users))
|
|
165
|
+
|
|
166
|
+
if username not in available_users:
|
|
167
|
+
try:
|
|
168
|
+
# Create admin user
|
|
169
|
+
subprocess.run([
|
|
170
|
+
"sudo", "-u", "_gvm", "gvmd",
|
|
171
|
+
"--create-user="+username,
|
|
172
|
+
"--password="+password,
|
|
173
|
+
], check=True, capture_output=True)
|
|
174
|
+
print(f"Created admin user '{username}' with password '{password}'")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f"Failed to create user '{username}': {e}")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_password(self, generate: bool = False) -> str:
|
|
181
|
+
"""
|
|
182
|
+
Return the stored password from PASSWORD_FILE.
|
|
183
|
+
If the file is missing or empty, or generate=True, create a new password, save it, and return it.
|
|
184
|
+
"""
|
|
185
|
+
def generate_password() -> str:
|
|
186
|
+
charset = string.ascii_letters + string.digits
|
|
187
|
+
new_pass = ''.join(secrets.choice(charset) for _ in range(32))
|
|
188
|
+
with open(self.PASSWORD_FILE, "w") as f:
|
|
189
|
+
f.write(new_pass)
|
|
190
|
+
return new_pass
|
|
191
|
+
|
|
192
|
+
# Generate a new password if requested or if the file is missing/empty
|
|
193
|
+
if generate or not os.path.exists(self.PASSWORD_FILE):
|
|
194
|
+
return generate_password()
|
|
195
|
+
|
|
196
|
+
# Read existing password
|
|
197
|
+
with open(self.PASSWORD_FILE, "r") as f:
|
|
198
|
+
existing_pass = f.read().strip()
|
|
199
|
+
|
|
200
|
+
# If file is empty, generate a new password
|
|
201
|
+
if not existing_pass:
|
|
202
|
+
return generate_password()
|
|
203
|
+
|
|
204
|
+
return existing_pass
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def finalize_permissions(self):
|
|
208
|
+
"""
|
|
209
|
+
Add the current user to the _gvm group so the Python script can access the GVMD socket.
|
|
210
|
+
The user must reset their shell for the group change to take effect.
|
|
211
|
+
"""
|
|
212
|
+
user = os.environ.get("USER")
|
|
213
|
+
if not user:
|
|
214
|
+
print("Could not determine the current user.")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
# Check membership
|
|
218
|
+
try:
|
|
219
|
+
group = grp.getgrnam("_gvm")
|
|
220
|
+
except KeyError:
|
|
221
|
+
print("Group '_gvm' does not exist.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if user in group.gr_mem:
|
|
225
|
+
#print(f"User '{user}' is already a member of the _gvm group. No action taken.")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
subprocess.run(["sudo", "usermod", "-aG", "_gvm", user], check=True)
|
|
230
|
+
print(f"User '{user}' has been added to the _gvm group.")
|
|
231
|
+
except subprocess.CalledProcessError as e:
|
|
232
|
+
print(f"Failed to add user to _gvm group: {e}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
gvm = GVMSetup(args={})
|
|
237
|
+
gvm.run()
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from lxml import etree
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import List, Tuple, Union
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
def _ensure_tree(maybe_xml: Union[str, etree._Element]) -> etree._Element:
|
|
7
|
+
"""
|
|
8
|
+
Convert XML string to ElementTree if necessary.
|
|
9
|
+
"""
|
|
10
|
+
if isinstance(maybe_xml, str):
|
|
11
|
+
return etree.fromstring(maybe_xml.encode())
|
|
12
|
+
return maybe_xml
|
|
13
|
+
|
|
14
|
+
def _list_port_lists(gmp) -> List[Tuple[str, str, bool]]:
|
|
15
|
+
"""
|
|
16
|
+
Return all port lists as a list of tuples: (id, name, predefined_flag)
|
|
17
|
+
"""
|
|
18
|
+
root = _ensure_tree(gmp.get_port_lists())
|
|
19
|
+
items = []
|
|
20
|
+
for pl in root.xpath("//port_list"):
|
|
21
|
+
pid = pl.get("id") or pl.findtext("id")
|
|
22
|
+
name = pl.findtext("name") or ""
|
|
23
|
+
predefined_flag = pl.findtext("predefined") == "1"
|
|
24
|
+
items.append((pid, name, predefined_flag))
|
|
25
|
+
return items
|
|
26
|
+
|
|
27
|
+
def _get_default_portlist_id(gmp, proto: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Return the ID of the default All TCP or All UDP port list.
|
|
30
|
+
Raises RuntimeError if not found.
|
|
31
|
+
"""
|
|
32
|
+
proto = proto.lower()
|
|
33
|
+
defaults = {"tcp": "All IANA assigned TCP", "udp": "All UDP"}
|
|
34
|
+
root = _ensure_tree(gmp.get_port_lists())
|
|
35
|
+
node = root.xpath(f"//port_list[name='{defaults.get(proto, '')}']")
|
|
36
|
+
if not node:
|
|
37
|
+
raise RuntimeError(f"Default port list for {proto} not found")
|
|
38
|
+
return node[0].get("id")
|
|
39
|
+
|
|
40
|
+
def _create_temp_portlist(gmp, ports: str) -> Tuple[str, str]:
|
|
41
|
+
"""
|
|
42
|
+
Create a temporary port list in GVM for a scan.
|
|
43
|
+
ports: string like "22,80,443" or "1000-2000"
|
|
44
|
+
Returns: (portlist_id, name)
|
|
45
|
+
"""
|
|
46
|
+
tmp_name = f"pt-{ports}-{time.strftime('%H:%M:%S')}"
|
|
47
|
+
created_xml = gmp.create_port_list(name=tmp_name, port_range=ports)
|
|
48
|
+
root = _ensure_tree(created_xml)
|
|
49
|
+
pid = root.findtext("id") or (root.xpath("//@id")[0] if root.xpath("//@id") else None)
|
|
50
|
+
if not pid:
|
|
51
|
+
raise RuntimeError("Failed to create temporary port list")
|
|
52
|
+
return pid, tmp_name
|
|
53
|
+
|
|
54
|
+
def _cleanup_portlist(gmp, portlist_id: str) -> None:
|
|
55
|
+
"""
|
|
56
|
+
Delete a temporary port list. Ignore errors.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
gmp.delete_port_list(portlist_id)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def get_default_scanner_id(gmp):
|
|
64
|
+
"""
|
|
65
|
+
Retrieve the ID of the default OpenVAS scanner to assign tasks to.
|
|
66
|
+
|
|
67
|
+
Priority:
|
|
68
|
+
1. Scanner whose name contains "openvas" and status "Alive"
|
|
69
|
+
2. Any scanner whose name contains "openvas" (even if not alive)
|
|
70
|
+
3. Fallback to any alive scanner (if no OpenVAS exists)
|
|
71
|
+
4. Fallback to the first registered scanner
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
gmp: An authenticated GMP connection object.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
str: The ID of the selected scanner.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
RuntimeError: If no scanners are registered in GVMD.
|
|
81
|
+
"""
|
|
82
|
+
root = _ensure_tree(gmp.get_scanners())
|
|
83
|
+
scanners = root.xpath("//scanner")
|
|
84
|
+
if not scanners:
|
|
85
|
+
raise RuntimeError("No scanners registered in gvmd")
|
|
86
|
+
|
|
87
|
+
# 1) Alive OpenVAS scanner
|
|
88
|
+
for s in scanners:
|
|
89
|
+
name = (s.findtext("name") or "").lower()
|
|
90
|
+
status = (s.findtext("status") or s.get("status") or "").lower()
|
|
91
|
+
if "openvas" in name and status == "alive":
|
|
92
|
+
return s.get("id") or s.findtext("id")
|
|
93
|
+
|
|
94
|
+
# 2) Any OpenVAS scanner (not necessarily alive)
|
|
95
|
+
for s in scanners:
|
|
96
|
+
name = (s.findtext("name") or "").lower()
|
|
97
|
+
if "openvas" in name:
|
|
98
|
+
return s.get("id") or s.findtext("id")
|
|
99
|
+
|
|
100
|
+
# 3) Any alive scanner
|
|
101
|
+
for s in scanners:
|
|
102
|
+
status = (s.findtext("status") or s.get("status") or "").lower()
|
|
103
|
+
if status == "alive":
|
|
104
|
+
return s.get("id") or s.findtext("id")
|
|
105
|
+
|
|
106
|
+
# 4) Fallback: first scanner
|
|
107
|
+
s = scanners[0]
|
|
108
|
+
return s.get("id") or s.findtext("id")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def wait_for_report(gmp, task_id, timeout=300, interval=5, verbose=False):
|
|
112
|
+
"""
|
|
113
|
+
Wait until a report is available for the given task.
|
|
114
|
+
|
|
115
|
+
Strategy:
|
|
116
|
+
- Poll gmp.get_task(task_id) and look for report id in common places:
|
|
117
|
+
* .//last_report/id
|
|
118
|
+
* .//report/id
|
|
119
|
+
* .//report (check @id)
|
|
120
|
+
- If task status becomes a finished state (Done/Stopped/Aborted/Failed) but no report id found,
|
|
121
|
+
query gmp.get_reports() and filter for reports whose task/id == task_id and return the newest.
|
|
122
|
+
- After timeout, do a final search in reports and raise RuntimeError if nothing found.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
gmp: Authenticated GMP connection object.
|
|
126
|
+
task_id: ID of the task.
|
|
127
|
+
timeout: Seconds to wait before giving up.
|
|
128
|
+
interval: Poll interval in seconds.
|
|
129
|
+
verbose: If True, prints progress messages.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
report_id (str)
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
RuntimeError: If no report is found within timeout.
|
|
136
|
+
"""
|
|
137
|
+
waited = 0
|
|
138
|
+
finished_states = {"done", "stopped", "aborted", "cancelled", "failed"}
|
|
139
|
+
if verbose:
|
|
140
|
+
print(f"[wait_for_report] waiting for report for task {task_id} (timeout={timeout}s)")
|
|
141
|
+
|
|
142
|
+
while waited < timeout:
|
|
143
|
+
task_xml = gmp.get_task(task_id)
|
|
144
|
+
# check several common locations for report id
|
|
145
|
+
report_id = (
|
|
146
|
+
task_xml.findtext(".//last_report/id")
|
|
147
|
+
or task_xml.findtext(".//report/id")
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# sometimes report is present as a <report id="..."> element
|
|
151
|
+
if not report_id:
|
|
152
|
+
report_nodes = task_xml.xpath(".//report")
|
|
153
|
+
if report_nodes:
|
|
154
|
+
# try attribute 'id' first, fallback to child <id>
|
|
155
|
+
first = report_nodes[0]
|
|
156
|
+
report_id = first.get("id") or first.findtext("id")
|
|
157
|
+
|
|
158
|
+
status = (task_xml.findtext(".//status") or "").strip().lower()
|
|
159
|
+
if verbose:
|
|
160
|
+
print(f"[wait_for_report] waited={waited}s status='{status}' report_id='{report_id}'")
|
|
161
|
+
|
|
162
|
+
if report_id:
|
|
163
|
+
if verbose:
|
|
164
|
+
print(f"[wait_for_report] found report id on task: {report_id}")
|
|
165
|
+
return report_id
|
|
166
|
+
|
|
167
|
+
# If the task is finished but no report id on task, search reports list
|
|
168
|
+
if status in finished_states:
|
|
169
|
+
if verbose:
|
|
170
|
+
print(f"[wait_for_report] task in finished state '{status}' but no report on task; searching reports table...")
|
|
171
|
+
# search reports for this task
|
|
172
|
+
candidate = _find_latest_report_for_task(gmp, task_id, verbose=verbose)
|
|
173
|
+
if candidate:
|
|
174
|
+
return candidate
|
|
175
|
+
# else continue waiting a little in case the report is still being generated/attached
|
|
176
|
+
|
|
177
|
+
time.sleep(interval)
|
|
178
|
+
waited += interval
|
|
179
|
+
|
|
180
|
+
# Final attempt after timeout
|
|
181
|
+
if verbose:
|
|
182
|
+
print(f"[wait_for_report] timeout reached ({timeout}s). final search in reports...")
|
|
183
|
+
candidate = _find_latest_report_for_task(gmp, task_id, verbose=verbose)
|
|
184
|
+
if candidate:
|
|
185
|
+
return candidate
|
|
186
|
+
|
|
187
|
+
raise RuntimeError(f"No report found for task {task_id} after waiting {timeout} seconds")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _find_latest_report_for_task(gmp, task_id, verbose=False):
|
|
191
|
+
"""
|
|
192
|
+
Search all reports and return the newest report id for the given task_id, or None.
|
|
193
|
+
Uses gmp.get_reports() and XPath; returns the first match or the newest by creation_time if available.
|
|
194
|
+
"""
|
|
195
|
+
reports_xml = gmp.get_reports()
|
|
196
|
+
reports = reports_xml.xpath(f"//report[task/id='{task_id}']")
|
|
197
|
+
if not reports:
|
|
198
|
+
if verbose:
|
|
199
|
+
print(f"[_find_latest_report_for_task] no reports found for task {task_id}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# prefer attribute id, else child <id>; prefer newest by creation_time if present
|
|
203
|
+
def extract_info(rnode):
|
|
204
|
+
rid = rnode.get("id") or rnode.findtext("id")
|
|
205
|
+
# try to get creation time if available (many report formats include it)
|
|
206
|
+
ctime = rnode.findtext("creation_time") or rnode.findtext("report/creation_time") or ""
|
|
207
|
+
return (rid, ctime)
|
|
208
|
+
|
|
209
|
+
infos = [extract_info(r) for r in reports]
|
|
210
|
+
# if any creation times present, sort by them (lexicographic ISO-8601 is fine)
|
|
211
|
+
if any(info[1] for info in infos):
|
|
212
|
+
infos = sorted(infos, key=lambda x: x[1] or "", reverse=True)
|
|
213
|
+
else:
|
|
214
|
+
# fallback: return first report node's id
|
|
215
|
+
infos = infos
|
|
216
|
+
|
|
217
|
+
chosen = infos[0][0] if infos else None
|
|
218
|
+
if verbose:
|
|
219
|
+
print(f"[_find_latest_report_for_task] candidate reports: {[i[0] for i in infos]}; chosen={chosen}")
|
|
220
|
+
return chosen
|