nessus-export 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.
- nessus_export/__init__.py +4 -0
- nessus_export/__main__.py +6 -0
- nessus_export/cli.py +242 -0
- nessus_export/client.py +178 -0
- nessus_export/exporter.py +110 -0
- nessus_export/reconstruct.py +218 -0
- nessus_export-0.1.0.dist-info/METADATA +254 -0
- nessus_export-0.1.0.dist-info/RECORD +12 -0
- nessus_export-0.1.0.dist-info/WHEEL +5 -0
- nessus_export-0.1.0.dist-info/entry_points.txt +2 -0
- nessus_export-0.1.0.dist-info/licenses/LICENSE +21 -0
- nessus_export-0.1.0.dist-info/top_level.txt +1 -0
nessus_export/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Command-line interface for nessus-export."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .client import NessusClient, NessusError, AuthError, TrialModeError
|
|
12
|
+
from .exporter import (CHAPTERS, EXTENSIONS, NATIVE_FORMATS, export_scan)
|
|
13
|
+
|
|
14
|
+
_STATUS_ORDER = ["running", "completed", "canceled", "aborted", "empty"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _eprint(*a) -> None:
|
|
18
|
+
print(*a, file=sys.stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_env_file(path: str) -> None:
|
|
22
|
+
"""Populate os.environ from a simple KEY=VALUE .env file (no deps)."""
|
|
23
|
+
try:
|
|
24
|
+
with open(path) as fh:
|
|
25
|
+
for line in fh:
|
|
26
|
+
line = line.strip()
|
|
27
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
28
|
+
continue
|
|
29
|
+
key, _, val = line.partition("=")
|
|
30
|
+
key, val = key.strip(), val.strip().strip('"').strip("'")
|
|
31
|
+
os.environ.setdefault(key, val)
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_client(args) -> NessusClient:
|
|
37
|
+
if args.env_file:
|
|
38
|
+
_load_env_file(args.env_file)
|
|
39
|
+
else:
|
|
40
|
+
# Convenience: auto-load ./.env if present and keys not already set.
|
|
41
|
+
if os.path.exists(".env"):
|
|
42
|
+
_load_env_file(".env")
|
|
43
|
+
access = args.access_key or os.environ.get("ACCESS_KEY") \
|
|
44
|
+
or os.environ.get("NESSUS_ACCESS_KEY", "")
|
|
45
|
+
secret = args.secret_key or os.environ.get("SECRET_KEY") \
|
|
46
|
+
or os.environ.get("NESSUS_SECRET_KEY", "")
|
|
47
|
+
url = args.url or os.environ.get("NESSUS_URL") or "https://localhost:8834"
|
|
48
|
+
if not access or not secret:
|
|
49
|
+
raise AuthError(
|
|
50
|
+
"missing API keys; set ACCESS_KEY/SECRET_KEY (env or .env) "
|
|
51
|
+
"or pass --access-key/--secret-key"
|
|
52
|
+
)
|
|
53
|
+
return NessusClient(
|
|
54
|
+
url=url, access_key=access, secret_key=secret,
|
|
55
|
+
verify_ssl=args.verify_ssl, ca_bundle=args.ca_bundle,
|
|
56
|
+
timeout=args.timeout,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sanitize(name: str) -> str:
|
|
61
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "scan"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
# commands
|
|
66
|
+
# --------------------------------------------------------------------------
|
|
67
|
+
def cmd_list(client: NessusClient, args) -> int:
|
|
68
|
+
scans = client.list_scans()
|
|
69
|
+
if args.status:
|
|
70
|
+
scans = [s for s in scans if s.get("status") == args.status]
|
|
71
|
+
scans.sort(key=lambda s: (s.get("name") or "").lower())
|
|
72
|
+
if args.json:
|
|
73
|
+
import json
|
|
74
|
+
print(json.dumps(scans, indent=2))
|
|
75
|
+
return 0
|
|
76
|
+
if not scans:
|
|
77
|
+
_eprint("no scans found")
|
|
78
|
+
return 0
|
|
79
|
+
width = max(len(str(s.get("name", ""))) for s in scans)
|
|
80
|
+
print(f"{'ID':>5} {'NAME':<{width}} {'STATUS':<10} FOLDER")
|
|
81
|
+
for s in scans:
|
|
82
|
+
print(f"{s.get('id'):>5} {str(s.get('name','')):<{width}} "
|
|
83
|
+
f"{str(s.get('status','')):<10} {s.get('folder_id','')}")
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _select_scan_ids(client: NessusClient, args) -> List[int]:
|
|
88
|
+
if args.all:
|
|
89
|
+
scans = client.list_scans()
|
|
90
|
+
if args.status:
|
|
91
|
+
scans = [s for s in scans if s.get("status") == args.status]
|
|
92
|
+
return [s["id"] for s in scans]
|
|
93
|
+
if not args.scans:
|
|
94
|
+
raise NessusError("no scans specified; pass names/ids, or use --all")
|
|
95
|
+
resolved = []
|
|
96
|
+
for ref in args.scans:
|
|
97
|
+
s = client.resolve_scan(ref)
|
|
98
|
+
resolved.append(s["id"])
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_export(client: NessusClient, args) -> int:
|
|
103
|
+
scan_ids = _select_scan_ids(client, args)
|
|
104
|
+
if not scan_ids:
|
|
105
|
+
_eprint("no matching scans to export")
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
chapters = None
|
|
109
|
+
if args.chapters:
|
|
110
|
+
chapters = [c.strip() for c in args.chapters.split(",") if c.strip()]
|
|
111
|
+
bad = [c for c in chapters if c not in CHAPTERS]
|
|
112
|
+
if bad:
|
|
113
|
+
raise NessusError(f"invalid chapter(s): {bad}; choose from {CHAPTERS}")
|
|
114
|
+
|
|
115
|
+
out_dir = args.out_dir
|
|
116
|
+
single_out = args.output if len(scan_ids) == 1 else None
|
|
117
|
+
if out_dir:
|
|
118
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
quiet = args.quiet
|
|
121
|
+
progress = (lambda m: None) if quiet else (lambda m: _eprint(f" … {m}"))
|
|
122
|
+
|
|
123
|
+
rc = 0
|
|
124
|
+
used_paths: set = set()
|
|
125
|
+
for scan_id in scan_ids:
|
|
126
|
+
info = client.get_scan(scan_id).get("info", {})
|
|
127
|
+
name = info.get("name", f"scan-{scan_id}")
|
|
128
|
+
_eprint(f"[{name}] (id {scan_id}) → {args.format}")
|
|
129
|
+
try:
|
|
130
|
+
result = export_scan(
|
|
131
|
+
client, scan_id, fmt=args.format, mode=args.mode,
|
|
132
|
+
chapters=chapters, db_password=args.db_password,
|
|
133
|
+
poll_interval=args.poll_interval, max_wait=args.max_wait,
|
|
134
|
+
progress=progress,
|
|
135
|
+
)
|
|
136
|
+
except (TrialModeError, NessusError, ValueError) as e:
|
|
137
|
+
_eprint(f" ✗ {e}")
|
|
138
|
+
rc = 1
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if single_out:
|
|
142
|
+
path = single_out
|
|
143
|
+
else:
|
|
144
|
+
base = os.path.join(out_dir or ".", _sanitize(name))
|
|
145
|
+
path = base + result.extension
|
|
146
|
+
# Disambiguate when several scans share a name (append the id).
|
|
147
|
+
if path in used_paths:
|
|
148
|
+
path = f"{base}_{scan_id}{result.extension}"
|
|
149
|
+
used_paths.add(path)
|
|
150
|
+
with open(path, "wb") as fh:
|
|
151
|
+
fh.write(result.content)
|
|
152
|
+
tag = "reconstructed (trial mode)" if result.mode == "reconstruct" else "native"
|
|
153
|
+
_eprint(f" ✓ wrote {path} ({len(result.content)} bytes, {tag})")
|
|
154
|
+
return rc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def cmd_status(client: NessusClient, args) -> int:
|
|
158
|
+
st = client.server_status()
|
|
159
|
+
print(f"server: {client.url}")
|
|
160
|
+
print(f"status: {st.get('status')} progress: {st.get('progress')}")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
166
|
+
p = argparse.ArgumentParser(
|
|
167
|
+
prog="nessus-export",
|
|
168
|
+
description="Export Nessus scan results via the API, with an automatic "
|
|
169
|
+
"reconstruction fallback for trial-mode servers.",
|
|
170
|
+
)
|
|
171
|
+
p.add_argument("--version", action="version",
|
|
172
|
+
version=f"nessus-export {__version__}")
|
|
173
|
+
p.add_argument("--url", help="Nessus base URL (default env NESSUS_URL or "
|
|
174
|
+
"https://localhost:8834)")
|
|
175
|
+
p.add_argument("--access-key", help="API access key (default env ACCESS_KEY)")
|
|
176
|
+
p.add_argument("--secret-key", help="API secret key (default env SECRET_KEY)")
|
|
177
|
+
p.add_argument("--env-file", help="path to a .env file with the keys")
|
|
178
|
+
p.add_argument("--verify-ssl", action="store_true",
|
|
179
|
+
help="verify the server TLS certificate (off by default; "
|
|
180
|
+
"Nessus ships a self-signed cert)")
|
|
181
|
+
p.add_argument("--ca-bundle", help="CA bundle to verify against")
|
|
182
|
+
p.add_argument("--timeout", type=int, default=60, help="HTTP timeout seconds")
|
|
183
|
+
|
|
184
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
185
|
+
|
|
186
|
+
lp = sub.add_parser("list", help="list scans on the server")
|
|
187
|
+
lp.add_argument("--status", choices=_STATUS_ORDER, help="filter by status")
|
|
188
|
+
lp.add_argument("--json", action="store_true", help="raw JSON output")
|
|
189
|
+
lp.set_defaults(func=cmd_list)
|
|
190
|
+
|
|
191
|
+
sp = sub.add_parser("status", help="show server status")
|
|
192
|
+
sp.set_defaults(func=cmd_status)
|
|
193
|
+
|
|
194
|
+
ep = sub.add_parser("export", help="export one or more scans")
|
|
195
|
+
ep.add_argument("scans", nargs="*", metavar="SCAN",
|
|
196
|
+
help="scan name(s) or id(s) to export")
|
|
197
|
+
ep.add_argument("--all", action="store_true", help="export every scan")
|
|
198
|
+
ep.add_argument("--status", choices=_STATUS_ORDER,
|
|
199
|
+
help="with --all, only scans in this status")
|
|
200
|
+
ep.add_argument("-f", "--format", default="nessus", choices=NATIVE_FORMATS,
|
|
201
|
+
help="export format (default: nessus)")
|
|
202
|
+
ep.add_argument("-m", "--mode", default="auto",
|
|
203
|
+
choices=("auto", "native", "reconstruct"),
|
|
204
|
+
help="auto: native then fallback; native: API only; "
|
|
205
|
+
"reconstruct: rebuild locally (nessus/csv)")
|
|
206
|
+
ep.add_argument("-o", "--output", help="output file (single scan only)")
|
|
207
|
+
ep.add_argument("-d", "--out-dir", help="output directory (for multiple scans)")
|
|
208
|
+
ep.add_argument("--chapters",
|
|
209
|
+
help="pdf/html sections, comma-separated: " + ",".join(CHAPTERS))
|
|
210
|
+
ep.add_argument("--db-password", help="password for the 'db' format")
|
|
211
|
+
ep.add_argument("--poll-interval", type=float, default=2.0,
|
|
212
|
+
help="seconds between export status polls")
|
|
213
|
+
ep.add_argument("--max-wait", type=float, default=600.0,
|
|
214
|
+
help="max seconds to wait for a native export")
|
|
215
|
+
ep.add_argument("-q", "--quiet", action="store_true",
|
|
216
|
+
help="suppress per-step progress")
|
|
217
|
+
ep.set_defaults(func=cmd_export)
|
|
218
|
+
return p
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
222
|
+
parser = build_parser()
|
|
223
|
+
args = parser.parse_args(argv)
|
|
224
|
+
try:
|
|
225
|
+
client = _make_client(args)
|
|
226
|
+
return args.func(client, args)
|
|
227
|
+
except AuthError as e:
|
|
228
|
+
_eprint(f"auth error: {e}")
|
|
229
|
+
return 2
|
|
230
|
+
except TrialModeError as e:
|
|
231
|
+
_eprint(f"trial mode: {e}")
|
|
232
|
+
return 3
|
|
233
|
+
except NessusError as e:
|
|
234
|
+
_eprint(f"error: {e}")
|
|
235
|
+
return 1
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
_eprint("interrupted")
|
|
238
|
+
return 130
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
sys.exit(main())
|
nessus_export/client.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Minimal Nessus REST API client (standard library only).
|
|
2
|
+
|
|
3
|
+
Covers the endpoints needed for listing scans and exporting results, both via
|
|
4
|
+
the native export API and via the direct scan/host/plugin read endpoints used
|
|
5
|
+
by the reconstruction fallback.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import ssl
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NessusError(Exception):
|
|
18
|
+
"""Generic error returned by the Nessus API."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TrialModeError(NessusError):
|
|
22
|
+
"""Raised when an action is blocked because the server is in trial mode.
|
|
23
|
+
|
|
24
|
+
Nessus Essentials / trial installs return errors such as
|
|
25
|
+
"Export is not allowed in trial mode." for the native export endpoints.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthError(NessusError):
|
|
30
|
+
"""Raised on 401/403 — bad or missing API keys."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _looks_like_trial(message: str) -> bool:
|
|
34
|
+
m = message.lower()
|
|
35
|
+
return "trial mode" in m or "purchase a full nessus license" in m
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NessusClient:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
url: str = "https://localhost:8834",
|
|
42
|
+
access_key: str = "",
|
|
43
|
+
secret_key: str = "",
|
|
44
|
+
verify_ssl: bool = False,
|
|
45
|
+
ca_bundle: Optional[str] = None,
|
|
46
|
+
timeout: int = 60,
|
|
47
|
+
) -> None:
|
|
48
|
+
if not access_key or not secret_key:
|
|
49
|
+
raise AuthError("access_key and secret_key are required")
|
|
50
|
+
self.url = url.rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self._headers = {
|
|
53
|
+
"X-ApiKeys": f"accessKey={access_key}; secretKey={secret_key}",
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
}
|
|
56
|
+
if ca_bundle:
|
|
57
|
+
self._ctx = ssl.create_default_context(cafile=ca_bundle)
|
|
58
|
+
elif verify_ssl:
|
|
59
|
+
self._ctx = ssl.create_default_context()
|
|
60
|
+
else:
|
|
61
|
+
self._ctx = ssl.create_default_context()
|
|
62
|
+
self._ctx.check_hostname = False
|
|
63
|
+
self._ctx.verify_mode = ssl.CERT_NONE
|
|
64
|
+
|
|
65
|
+
# -- low level ---------------------------------------------------------
|
|
66
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None,
|
|
67
|
+
raw: bool = False):
|
|
68
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
69
|
+
req = urllib.request.Request(
|
|
70
|
+
self.url + path, data=data, headers=self._headers, method=method
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, context=self._ctx,
|
|
74
|
+
timeout=self.timeout) as resp:
|
|
75
|
+
payload = resp.read()
|
|
76
|
+
except urllib.error.HTTPError as e:
|
|
77
|
+
payload = e.read()
|
|
78
|
+
message = self._extract_error(payload) or f"HTTP {e.code}"
|
|
79
|
+
# Trial-mode restrictions come back as 403, so check the message
|
|
80
|
+
# text before treating a 403 as an authentication failure.
|
|
81
|
+
if _looks_like_trial(message):
|
|
82
|
+
raise TrialModeError(message) from None
|
|
83
|
+
if e.code in (401, 403):
|
|
84
|
+
raise AuthError(message) from None
|
|
85
|
+
raise NessusError(message) from None
|
|
86
|
+
except urllib.error.URLError as e:
|
|
87
|
+
raise NessusError(f"could not reach {self.url}: {e.reason}") from None
|
|
88
|
+
|
|
89
|
+
if raw:
|
|
90
|
+
# Even on 200, an export download may actually be a JSON error body.
|
|
91
|
+
message = self._extract_error(payload)
|
|
92
|
+
if message and _looks_like_trial(message):
|
|
93
|
+
raise TrialModeError(message)
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
parsed = json.loads(payload) if payload else {}
|
|
97
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
98
|
+
message = parsed["error"]
|
|
99
|
+
if _looks_like_trial(message):
|
|
100
|
+
raise TrialModeError(message)
|
|
101
|
+
raise NessusError(message)
|
|
102
|
+
return parsed
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _extract_error(payload: bytes) -> Optional[str]:
|
|
106
|
+
try:
|
|
107
|
+
obj = json.loads(payload)
|
|
108
|
+
except (ValueError, TypeError):
|
|
109
|
+
return None
|
|
110
|
+
if isinstance(obj, dict) and obj.get("error"):
|
|
111
|
+
return str(obj["error"])
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# -- high level --------------------------------------------------------
|
|
115
|
+
def server_status(self) -> Dict[str, Any]:
|
|
116
|
+
return self._request("GET", "/server/status")
|
|
117
|
+
|
|
118
|
+
def list_scans(self) -> List[Dict[str, Any]]:
|
|
119
|
+
data = self._request("GET", "/scans")
|
|
120
|
+
return data.get("scans") or []
|
|
121
|
+
|
|
122
|
+
def resolve_scan(self, ref: str) -> Dict[str, Any]:
|
|
123
|
+
"""Resolve a scan by numeric id or by (case-insensitive) name."""
|
|
124
|
+
scans = self.list_scans()
|
|
125
|
+
if ref.isdigit():
|
|
126
|
+
for s in scans:
|
|
127
|
+
if str(s.get("id")) == ref:
|
|
128
|
+
return s
|
|
129
|
+
matches = [s for s in scans if (s.get("name") or "").lower() == ref.lower()]
|
|
130
|
+
if len(matches) == 1:
|
|
131
|
+
return matches[0]
|
|
132
|
+
if len(matches) > 1:
|
|
133
|
+
ids = ", ".join(str(m["id"]) for m in matches)
|
|
134
|
+
raise NessusError(
|
|
135
|
+
f"scan name {ref!r} is ambiguous (ids: {ids}); use the id"
|
|
136
|
+
)
|
|
137
|
+
raise NessusError(f"no scan matching {ref!r}")
|
|
138
|
+
|
|
139
|
+
def get_scan(self, scan_id: int) -> Dict[str, Any]:
|
|
140
|
+
return self._request("GET", f"/scans/{scan_id}")
|
|
141
|
+
|
|
142
|
+
def get_host(self, scan_id: int, host_id: int) -> Dict[str, Any]:
|
|
143
|
+
return self._request("GET", f"/scans/{scan_id}/hosts/{host_id}")
|
|
144
|
+
|
|
145
|
+
def get_plugin(self, scan_id: int, host_id: int, plugin_id: int) -> Dict[str, Any]:
|
|
146
|
+
return self._request(
|
|
147
|
+
"GET", f"/scans/{scan_id}/hosts/{host_id}/plugins/{plugin_id}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# -- native export -----------------------------------------------------
|
|
151
|
+
def export_request(self, scan_id: int, fmt: str,
|
|
152
|
+
extra: Optional[dict] = None) -> Any:
|
|
153
|
+
body: Dict[str, Any] = {"format": fmt}
|
|
154
|
+
if extra:
|
|
155
|
+
body.update(extra)
|
|
156
|
+
data = self._request("POST", f"/scans/{scan_id}/export", body=body)
|
|
157
|
+
# Nessus returns {"file": <id>, "token": <token>} (token on newer builds)
|
|
158
|
+
return data.get("file"), data.get("token")
|
|
159
|
+
|
|
160
|
+
def export_status(self, scan_id: int, file_id: Any) -> str:
|
|
161
|
+
data = self._request("GET", f"/scans/{scan_id}/export/{file_id}/status")
|
|
162
|
+
return data.get("status", "")
|
|
163
|
+
|
|
164
|
+
def export_download(self, scan_id: int, file_id: Any) -> bytes:
|
|
165
|
+
return self._request(
|
|
166
|
+
"GET", f"/scans/{scan_id}/export/{file_id}/download", raw=True
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def wait_and_download(self, scan_id: int, file_id: Any,
|
|
170
|
+
poll_interval: float = 2.0,
|
|
171
|
+
max_wait: float = 600.0) -> bytes:
|
|
172
|
+
waited = 0.0
|
|
173
|
+
while waited < max_wait:
|
|
174
|
+
if self.export_status(scan_id, file_id) == "ready":
|
|
175
|
+
return self.export_download(scan_id, file_id)
|
|
176
|
+
time.sleep(poll_interval)
|
|
177
|
+
waited += poll_interval
|
|
178
|
+
raise NessusError(f"export {file_id} not ready after {max_wait:.0f}s")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Export orchestration: native API export with reconstruction fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .client import NessusClient, TrialModeError
|
|
7
|
+
from . import reconstruct
|
|
8
|
+
|
|
9
|
+
ProgressFn = Optional[Callable[[str], None]]
|
|
10
|
+
|
|
11
|
+
# Formats the native API can produce.
|
|
12
|
+
NATIVE_FORMATS = ("nessus", "csv", "pdf", "html", "db")
|
|
13
|
+
# Formats we can rebuild ourselves without a license.
|
|
14
|
+
RECONSTRUCTABLE = ("nessus", "csv")
|
|
15
|
+
|
|
16
|
+
EXTENSIONS = {
|
|
17
|
+
"nessus": ".nessus", "csv": ".csv", "pdf": ".pdf",
|
|
18
|
+
"html": ".html", "db": ".db",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Chapter sections valid for pdf/html exports.
|
|
22
|
+
CHAPTERS = (
|
|
23
|
+
"vuln_hosts_summary", "vuln_by_host", "vuln_by_plugin",
|
|
24
|
+
"remediations", "compliance_exec", "compliance",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _noop(_: str) -> None:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExportResult:
|
|
33
|
+
def __init__(self, content: bytes, fmt: str, mode: str, trial: bool):
|
|
34
|
+
self.content = content # bytes to write
|
|
35
|
+
self.fmt = fmt # requested format
|
|
36
|
+
self.mode = mode # "native" or "reconstruct"
|
|
37
|
+
self.trial = trial # server reported trial-mode restriction
|
|
38
|
+
self.extension = EXTENSIONS.get(fmt, ".dat")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _native_export(client: NessusClient, scan_id: int, fmt: str,
|
|
42
|
+
chapters: Optional[List[str]], db_password: Optional[str],
|
|
43
|
+
poll_interval: float, max_wait: float,
|
|
44
|
+
progress: Callable[[str], None]) -> bytes:
|
|
45
|
+
extra: Dict[str, Any] = {}
|
|
46
|
+
if fmt in ("pdf", "html"):
|
|
47
|
+
# Nessus rejects a pdf/html export request that lacks a chapter list,
|
|
48
|
+
# so default to a per-host vulnerability report when none is given.
|
|
49
|
+
extra["chapters"] = ";".join(chapters or ["vuln_by_host"])
|
|
50
|
+
if fmt == "db":
|
|
51
|
+
if not db_password:
|
|
52
|
+
raise ValueError("the 'db' format requires --db-password")
|
|
53
|
+
extra["password"] = db_password
|
|
54
|
+
progress(f"requesting native {fmt} export")
|
|
55
|
+
file_id, _token = client.export_request(scan_id, fmt, extra)
|
|
56
|
+
progress(f"export queued (file {file_id}); polling")
|
|
57
|
+
content = client.wait_and_download(scan_id, file_id,
|
|
58
|
+
poll_interval=poll_interval,
|
|
59
|
+
max_wait=max_wait)
|
|
60
|
+
progress(f"downloaded {len(content)} bytes")
|
|
61
|
+
return content
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _reconstruct_export(client: NessusClient, scan_id: int, fmt: str,
|
|
65
|
+
progress: Callable[[str], None]) -> bytes:
|
|
66
|
+
progress("reconstructing from read API (this walks every host/plugin)")
|
|
67
|
+
data = reconstruct.collect(client, scan_id, progress=progress)
|
|
68
|
+
if fmt == "nessus":
|
|
69
|
+
return reconstruct.build_nessus_xml(data).encode("utf-8")
|
|
70
|
+
if fmt == "csv":
|
|
71
|
+
return reconstruct.build_csv(data).encode("utf-8")
|
|
72
|
+
raise ValueError(f"format {fmt!r} cannot be reconstructed")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def export_scan(client: NessusClient, scan_id: int, fmt: str = "nessus",
|
|
76
|
+
mode: str = "auto", chapters: Optional[List[str]] = None,
|
|
77
|
+
db_password: Optional[str] = None,
|
|
78
|
+
poll_interval: float = 2.0, max_wait: float = 600.0,
|
|
79
|
+
progress: ProgressFn = None) -> ExportResult:
|
|
80
|
+
"""Export a single scan.
|
|
81
|
+
|
|
82
|
+
mode:
|
|
83
|
+
auto try native; on trial-mode restriction, fall back to
|
|
84
|
+
reconstruction if the format supports it.
|
|
85
|
+
native native API only (errors out under trial mode).
|
|
86
|
+
reconstruct skip the API export and rebuild locally (nessus/csv only).
|
|
87
|
+
"""
|
|
88
|
+
progress = progress or _noop
|
|
89
|
+
if fmt not in NATIVE_FORMATS:
|
|
90
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {NATIVE_FORMATS}")
|
|
91
|
+
|
|
92
|
+
if mode == "reconstruct":
|
|
93
|
+
content = _reconstruct_export(client, scan_id, fmt, progress)
|
|
94
|
+
return ExportResult(content, fmt, "reconstruct", trial=False)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
content = _native_export(client, scan_id, fmt, chapters, db_password,
|
|
98
|
+
poll_interval, max_wait, progress)
|
|
99
|
+
return ExportResult(content, fmt, "native", trial=False)
|
|
100
|
+
except TrialModeError as e:
|
|
101
|
+
if mode == "native":
|
|
102
|
+
raise
|
|
103
|
+
if fmt not in RECONSTRUCTABLE:
|
|
104
|
+
raise TrialModeError(
|
|
105
|
+
f"{e} — reconstruction fallback only supports "
|
|
106
|
+
f"{RECONSTRUCTABLE}, not {fmt!r}."
|
|
107
|
+
) from None
|
|
108
|
+
progress(f"native export blocked (trial mode); falling back: {e}")
|
|
109
|
+
content = _reconstruct_export(client, scan_id, fmt, progress)
|
|
110
|
+
return ExportResult(content, fmt, "reconstruct", trial=True)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Reconstruct .nessus (NessusClientData_v2) and CSV output from the read API.
|
|
2
|
+
|
|
3
|
+
Used as a fallback when the native export endpoint is unavailable (e.g. the
|
|
4
|
+
server is in trial mode). The scan/host/plugin read endpoints are not
|
|
5
|
+
license-gated, so the findings themselves are fully recoverable; the scan
|
|
6
|
+
*policy* metadata is not exposed and is therefore emitted as a stub.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
14
|
+
|
|
15
|
+
from .client import NessusClient
|
|
16
|
+
|
|
17
|
+
# Host-property tags surfaced from the host "info" block, in a stable order.
|
|
18
|
+
_HOST_TAGS = [
|
|
19
|
+
"host-ip", "host-fqdn", "host-rdns", "netbios-name", "mac-address",
|
|
20
|
+
"operating-system", "os", "system-type", "host_start", "host_end",
|
|
21
|
+
"HOST_START", "HOST_END",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# risk_information keys emitted verbatim as ReportItem children.
|
|
25
|
+
_RISK_KEYS = [
|
|
26
|
+
"cvss_base_score", "cvss_vector", "cvss_temporal_score", "cvss_temporal_vector",
|
|
27
|
+
"cvss3_base_score", "cvss3_vector", "cvss3_temporal_score",
|
|
28
|
+
"cvss3_temporal_vector", "cvss_score_source", "stig_severity",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
ProgressFn = Optional[Callable[[str], None]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _noop(_: str) -> None:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect(client: NessusClient, scan_id: int,
|
|
39
|
+
progress: ProgressFn = None) -> Dict[str, Any]:
|
|
40
|
+
"""Fetch scan + per-host + per-plugin data into a normalised structure."""
|
|
41
|
+
progress = progress or _noop
|
|
42
|
+
scan = client.get_scan(scan_id)
|
|
43
|
+
info = scan.get("info", {}) or {}
|
|
44
|
+
hosts_out: List[Dict[str, Any]] = []
|
|
45
|
+
|
|
46
|
+
raw_hosts = scan.get("hosts") or []
|
|
47
|
+
for hi, h in enumerate(raw_hosts, 1):
|
|
48
|
+
hid = h["host_id"]
|
|
49
|
+
hd = client.get_host(scan_id, hid)
|
|
50
|
+
hinfo = hd.get("info", {}) or {}
|
|
51
|
+
vulns = hd.get("vulnerabilities") or []
|
|
52
|
+
progress(f"host {hi}/{len(raw_hosts)} "
|
|
53
|
+
f"({hinfo.get('host-ip') or h.get('hostname')}): "
|
|
54
|
+
f"{len(vulns)} plugins")
|
|
55
|
+
findings: List[Dict[str, Any]] = []
|
|
56
|
+
for v in vulns:
|
|
57
|
+
pid = v["plugin_id"]
|
|
58
|
+
det = client.get_plugin(scan_id, hid, pid)
|
|
59
|
+
pa = (((det.get("info") or {}).get("plugindescription") or {})
|
|
60
|
+
.get("pluginattributes", {}) or {})
|
|
61
|
+
pinfo = pa.get("plugin_information", {}) or {}
|
|
62
|
+
risk = pa.get("risk_information", {}) or {}
|
|
63
|
+
vuln = pa.get("vuln_information", {}) or {}
|
|
64
|
+
|
|
65
|
+
instances = [] # one per (port, output)
|
|
66
|
+
for o in det.get("outputs") or []:
|
|
67
|
+
output = o.get("plugin_output") or ""
|
|
68
|
+
ports = o.get("ports") or {}
|
|
69
|
+
if not ports:
|
|
70
|
+
instances.append(("0", "tcp", "", output))
|
|
71
|
+
for pk in ports:
|
|
72
|
+
parts = [x.strip() for x in pk.split("/")]
|
|
73
|
+
port = parts[0] if parts and parts[0] else "0"
|
|
74
|
+
proto = parts[1] if len(parts) > 1 and parts[1] else "tcp"
|
|
75
|
+
svc = parts[2] if len(parts) > 2 else ""
|
|
76
|
+
instances.append((port, proto, svc, output))
|
|
77
|
+
if not instances:
|
|
78
|
+
instances.append(("0", "tcp", "", ""))
|
|
79
|
+
|
|
80
|
+
cves, bids, xrefs = [], [], []
|
|
81
|
+
for ref in (pa.get("ref_information") or {}).get("ref") or []:
|
|
82
|
+
name = (ref.get("name") or "").lower()
|
|
83
|
+
for val in (ref.get("values") or {}).get("value") or []:
|
|
84
|
+
if name == "cve":
|
|
85
|
+
cves.append(val)
|
|
86
|
+
elif name == "bid":
|
|
87
|
+
bids.append(val)
|
|
88
|
+
else:
|
|
89
|
+
xrefs.append(f"{name.upper()}:{val}")
|
|
90
|
+
|
|
91
|
+
findings.append({
|
|
92
|
+
"plugin_id": pid,
|
|
93
|
+
"plugin_name": pa.get("plugin_name") or v.get("plugin_name") or "",
|
|
94
|
+
"plugin_family": pinfo.get("plugin_family")
|
|
95
|
+
or v.get("plugin_family") or "",
|
|
96
|
+
"severity": v.get("severity", pa.get("severity", 0)),
|
|
97
|
+
"attrs": pa,
|
|
98
|
+
"risk": risk,
|
|
99
|
+
"vuln": vuln,
|
|
100
|
+
"pinfo": pinfo,
|
|
101
|
+
"cves": cves, "bids": bids, "xrefs": xrefs,
|
|
102
|
+
"instances": instances,
|
|
103
|
+
})
|
|
104
|
+
hosts_out.append({
|
|
105
|
+
"host_id": hid,
|
|
106
|
+
"name": hinfo.get("host-ip") or h.get("hostname") or str(hid),
|
|
107
|
+
"info": hinfo,
|
|
108
|
+
"findings": findings,
|
|
109
|
+
})
|
|
110
|
+
return {"info": info, "hosts": hosts_out}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _child(tag: str, text: Any) -> str:
|
|
114
|
+
if text is None or text == "":
|
|
115
|
+
return ""
|
|
116
|
+
return f" <{tag}>{escape(str(text))}</{tag}>\n"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_nessus_xml(data: Dict[str, Any]) -> str:
|
|
120
|
+
info = data["info"]
|
|
121
|
+
name = info.get("name", "scan")
|
|
122
|
+
buf = io.StringIO()
|
|
123
|
+
buf.write('<?xml version="1.0" encoding="UTF-8"?>\n<NessusClientData_v2>\n')
|
|
124
|
+
# Policy stub — trial mode does not expose real policy/preferences data.
|
|
125
|
+
buf.write(" <Policy>\n")
|
|
126
|
+
buf.write(f" <policyName>{escape(name)}</policyName>\n")
|
|
127
|
+
buf.write(" <Preferences>\n"
|
|
128
|
+
" <ServerPreferences></ServerPreferences>\n"
|
|
129
|
+
" <PluginsPreferences></PluginsPreferences>\n"
|
|
130
|
+
" </Preferences>\n")
|
|
131
|
+
buf.write(" <FamilySelection></FamilySelection>\n"
|
|
132
|
+
" <IndividualPluginSelection></IndividualPluginSelection>\n")
|
|
133
|
+
buf.write(" </Policy>\n")
|
|
134
|
+
buf.write(f' <Report name={quoteattr(name)} '
|
|
135
|
+
'xmlns:cm="http://www.nessus.org/cm">\n')
|
|
136
|
+
|
|
137
|
+
for host in data["hosts"]:
|
|
138
|
+
buf.write(f' <ReportHost name={quoteattr(str(host["name"]))}>\n')
|
|
139
|
+
buf.write(" <HostProperties>\n")
|
|
140
|
+
hinfo = host["info"]
|
|
141
|
+
for t in _HOST_TAGS:
|
|
142
|
+
if hinfo.get(t) not in (None, ""):
|
|
143
|
+
buf.write(f' <tag name={quoteattr(t)}>'
|
|
144
|
+
f'{escape(str(hinfo[t]))}</tag>\n')
|
|
145
|
+
buf.write(" </HostProperties>\n")
|
|
146
|
+
|
|
147
|
+
for f in host["findings"]:
|
|
148
|
+
pa, risk, vuln, pinfo = f["attrs"], f["risk"], f["vuln"], f["pinfo"]
|
|
149
|
+
for port, proto, svc, output in f["instances"]:
|
|
150
|
+
attrs = (f'port="{escape(port)}" svc_name={quoteattr(svc)} '
|
|
151
|
+
f'protocol="{escape(proto)}" severity="{f["severity"]}" '
|
|
152
|
+
f'pluginID="{f["plugin_id"]}" '
|
|
153
|
+
f'pluginName={quoteattr(str(f["plugin_name"]))} '
|
|
154
|
+
f'pluginFamily={quoteattr(str(f["plugin_family"]))}')
|
|
155
|
+
buf.write(f" <ReportItem {attrs}>\n")
|
|
156
|
+
buf.write(_child("synopsis", pa.get("synopsis")))
|
|
157
|
+
buf.write(_child("description", pa.get("description")))
|
|
158
|
+
buf.write(_child("solution", pa.get("solution")))
|
|
159
|
+
buf.write(_child("risk_factor", risk.get("risk_factor")))
|
|
160
|
+
for k in _RISK_KEYS:
|
|
161
|
+
buf.write(_child(k, risk.get(k)))
|
|
162
|
+
buf.write(_child("vpr_score", pa.get("vpr_score")))
|
|
163
|
+
buf.write(_child("epss_score", pa.get("epss_score")))
|
|
164
|
+
buf.write(_child("exploit_available", vuln.get("exploit_available")))
|
|
165
|
+
buf.write(_child("exploitability_ease", vuln.get("exploitability_ease")))
|
|
166
|
+
buf.write(_child("cpe", vuln.get("cpe")))
|
|
167
|
+
buf.write(_child("plugin_type", pinfo.get("plugin_type")))
|
|
168
|
+
buf.write(_child("plugin_version", pinfo.get("plugin_version")))
|
|
169
|
+
buf.write(_child("plugin_publication_date",
|
|
170
|
+
pinfo.get("plugin_publication_date")))
|
|
171
|
+
buf.write(_child("plugin_modification_date",
|
|
172
|
+
pinfo.get("plugin_modification_date")))
|
|
173
|
+
buf.write(_child("fname", pa.get("fname")))
|
|
174
|
+
for sa in pa.get("see_also") or []:
|
|
175
|
+
buf.write(_child("see_also", sa))
|
|
176
|
+
for cve in f["cves"]:
|
|
177
|
+
buf.write(_child("cve", cve))
|
|
178
|
+
for bid in f["bids"]:
|
|
179
|
+
buf.write(_child("bid", bid))
|
|
180
|
+
for xref in f["xrefs"]:
|
|
181
|
+
buf.write(_child("xref", xref))
|
|
182
|
+
buf.write(_child("plugin_output", output))
|
|
183
|
+
buf.write(" </ReportItem>\n")
|
|
184
|
+
buf.write(" </ReportHost>\n")
|
|
185
|
+
buf.write(" </Report>\n</NessusClientData_v2>\n")
|
|
186
|
+
return buf.getvalue()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_SEV = {0: "None", 1: "Low", 2: "Medium", 3: "High", 4: "Critical"}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_csv(data: Dict[str, Any]) -> str:
|
|
193
|
+
"""Approximate the native Nessus CSV export column set."""
|
|
194
|
+
buf = io.StringIO()
|
|
195
|
+
w = csv.writer(buf)
|
|
196
|
+
w.writerow(["Plugin ID", "CVE", "CVSS v2.0 Base Score", "Risk", "Host",
|
|
197
|
+
"Protocol", "Port", "Name", "Synopsis", "Description",
|
|
198
|
+
"Solution", "See Also", "Plugin Output"])
|
|
199
|
+
for host in data["hosts"]:
|
|
200
|
+
for f in host["findings"]:
|
|
201
|
+
pa, risk = f["attrs"], f["risk"]
|
|
202
|
+
for port, proto, svc, output in f["instances"]:
|
|
203
|
+
w.writerow([
|
|
204
|
+
f["plugin_id"],
|
|
205
|
+
", ".join(f["cves"]),
|
|
206
|
+
risk.get("cvss_base_score", ""),
|
|
207
|
+
_SEV.get(f["severity"], f["severity"]),
|
|
208
|
+
host["name"],
|
|
209
|
+
proto,
|
|
210
|
+
port,
|
|
211
|
+
f["plugin_name"],
|
|
212
|
+
pa.get("synopsis", ""),
|
|
213
|
+
pa.get("description", ""),
|
|
214
|
+
pa.get("solution", ""),
|
|
215
|
+
"\n".join(pa.get("see_also") or []),
|
|
216
|
+
output,
|
|
217
|
+
])
|
|
218
|
+
return buf.getvalue()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nessus-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export Nessus scan results via the API, with a reconstruction fallback for trial-mode servers.
|
|
5
|
+
Author-email: setuidloot <michael@mccord.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/setuidloot/nessus-export
|
|
8
|
+
Project-URL: Issues, https://github.com/setuidloot/nessus-export/issues
|
|
9
|
+
Keywords: nessus,tenable,security,vulnerability,export,scanner
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Information Technology
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# nessus-export
|
|
22
|
+
|
|
23
|
+
[](https://github.com/setuidloot/nessus-export/actions/workflows/ci.yml)
|
|
24
|
+
[](https://pypi.org/project/nessus-export/)
|
|
25
|
+
[](https://pypi.org/project/nessus-export/)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
|
|
28
|
+
Export [Nessus](https://www.tenable.com/products/nessus) scan results from the
|
|
29
|
+
command line via the REST API — **with an automatic fallback that reconstructs a
|
|
30
|
+
valid `.nessus` file even when the server's native export is locked** (as it is
|
|
31
|
+
on Nessus Essentials / trial installations).
|
|
32
|
+
|
|
33
|
+
Zero dependencies. Pure Python standard library.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Why this exists
|
|
38
|
+
|
|
39
|
+
Nessus has a perfectly good export API (`POST /scans/{id}/export`). But on
|
|
40
|
+
**Nessus Essentials** and **trial** installations, every export format is
|
|
41
|
+
license-gated. Ask for one and the server refuses:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "error": "Export is not allowed in trial mode. Please purchase a full Nessus license to enable exports." }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That's frustrating, because **the scan data itself is not restricted** — the
|
|
48
|
+
per-scan, per-host, and per-plugin *read* endpoints (`GET /scans/{id}`,
|
|
49
|
+
`GET /scans/{id}/hosts/{host}`, `GET /scans/{id}/hosts/{host}/plugins/{plugin}`)
|
|
50
|
+
return everything: findings, ports/services, severities, CVSS scores, CVEs,
|
|
51
|
+
plugin output, remediation text. The export endpoint is gated; the data is not.
|
|
52
|
+
|
|
53
|
+
**nessus-export** does the obvious thing:
|
|
54
|
+
|
|
55
|
+
1. **Try the native export API first.** If your server is licensed, you get the
|
|
56
|
+
real, byte-for-byte Nessus export (`.nessus`, CSV, PDF, HTML, DB) — no
|
|
57
|
+
reconstruction, nothing lost.
|
|
58
|
+
2. **If the server is in trial mode**, it transparently reads the scan data and
|
|
59
|
+
**reconstructs** a spec-compliant `NessusClientData_v2` (`.nessus`) or CSV
|
|
60
|
+
file itself.
|
|
61
|
+
|
|
62
|
+
The reconstructed `.nessus` imports cleanly back into Nessus and into anything
|
|
63
|
+
else that consumes the format (parsers, dashboards, DefectDojo, etc.).
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install nessus-export
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or from source:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
git clone https://github.com/setuidloot/nessus-export
|
|
75
|
+
cd nessus-export
|
|
76
|
+
pip install .
|
|
77
|
+
# or run without installing:
|
|
78
|
+
python -m nessus_export --help
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires Python 3.8+. No third-party packages.
|
|
82
|
+
|
|
83
|
+
## Authentication
|
|
84
|
+
|
|
85
|
+
Generate API keys in Nessus: **Settings → My Account → API Keys → Generate**.
|
|
86
|
+
|
|
87
|
+
Provide them any of these ways (checked in order):
|
|
88
|
+
|
|
89
|
+
- CLI flags: `--access-key` / `--secret-key`
|
|
90
|
+
- Environment: `ACCESS_KEY` / `SECRET_KEY` (or `NESSUS_ACCESS_KEY` / `NESSUS_SECRET_KEY`)
|
|
91
|
+
- A `.env` file in the working directory (auto-loaded), or `--env-file PATH`
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
cp .env.example .env # then edit in your keys
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Nessus ships a self-signed TLS certificate, so certificate verification is
|
|
98
|
+
**off by default**. Turn it on with `--verify-ssl` (optionally `--ca-bundle`).
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# List scans on the server
|
|
104
|
+
nessus-export list
|
|
105
|
+
nessus-export list --status completed
|
|
106
|
+
nessus-export list --json
|
|
107
|
+
|
|
108
|
+
# Export one scan by name or id (defaults to .nessus, into the current dir)
|
|
109
|
+
nessus-export export myscan
|
|
110
|
+
nessus-export export 5 -o results.nessus
|
|
111
|
+
|
|
112
|
+
# Pick a format
|
|
113
|
+
nessus-export export myscan -f csv
|
|
114
|
+
nessus-export export myscan -f pdf --chapters vuln_by_host,remediations
|
|
115
|
+
|
|
116
|
+
# Export several scans into a directory
|
|
117
|
+
nessus-export export web-scan db-scan -d ./exports
|
|
118
|
+
nessus-export export --all --status completed -d ./exports
|
|
119
|
+
|
|
120
|
+
# Force behavior
|
|
121
|
+
nessus-export export myscan -m native # native API only (fails under trial)
|
|
122
|
+
nessus-export export myscan -m reconstruct # skip the API, rebuild locally
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Commands
|
|
126
|
+
|
|
127
|
+
| Command | Purpose |
|
|
128
|
+
|----------|-----------------------------------------------------|
|
|
129
|
+
| `list` | List scans (`--status`, `--json`) |
|
|
130
|
+
| `export` | Export one/several/all scans |
|
|
131
|
+
| `status` | Show server status |
|
|
132
|
+
|
|
133
|
+
### Key `export` options
|
|
134
|
+
|
|
135
|
+
| Option | Description |
|
|
136
|
+
|---------------------|-------------------------------------------------------------------|
|
|
137
|
+
| `SCAN…` | One or more scan names or ids |
|
|
138
|
+
| `--all` | Export every scan (combine with `--status`) |
|
|
139
|
+
| `-f, --format` | `nessus` (default), `csv`, `pdf`, `html`, `db` |
|
|
140
|
+
| `-m, --mode` | `auto` (default), `native`, `reconstruct` |
|
|
141
|
+
| `-o, --output` | Output file (single scan) |
|
|
142
|
+
| `-d, --out-dir` | Output directory (multiple scans; filenames from scan names) |
|
|
143
|
+
| `--chapters` | `pdf`/`html` sections, comma-separated |
|
|
144
|
+
| `--db-password` | Required for `-f db` |
|
|
145
|
+
| `-q, --quiet` | Suppress per-step progress |
|
|
146
|
+
|
|
147
|
+
### Export modes
|
|
148
|
+
|
|
149
|
+
- **`auto`** *(default)* — try the native API; if the server reports a
|
|
150
|
+
trial-mode restriction, fall back to reconstruction (for `nessus`/`csv`).
|
|
151
|
+
- **`native`** — only use the API. Exits non-zero under trial mode. Use this
|
|
152
|
+
when you specifically want the licensed, byte-exact export or nothing.
|
|
153
|
+
- **`reconstruct`** — skip the API export entirely and rebuild locally. Handy
|
|
154
|
+
for consistent output across mixed licensed/trial servers.
|
|
155
|
+
|
|
156
|
+
### Formats and modes at a glance
|
|
157
|
+
|
|
158
|
+
| Format | Native (licensed) | Reconstruction fallback (trial) |
|
|
159
|
+
|----------|:-----------------:|:-------------------------------:|
|
|
160
|
+
| `nessus` | ✅ | ✅ |
|
|
161
|
+
| `csv` | ✅ | ✅ |
|
|
162
|
+
| `pdf` | ✅ | ❌ (needs a licensed server) |
|
|
163
|
+
| `html` | ✅ | ❌ (needs a licensed server) |
|
|
164
|
+
| `db` | ✅ | ❌ (needs a licensed server) |
|
|
165
|
+
|
|
166
|
+
`pdf`, `html`, and `db` are report renderings Nessus builds server-side; there's
|
|
167
|
+
no faithful way to reproduce them from the read API, so they require a licensed
|
|
168
|
+
server. In `auto` mode, requesting one on a trial server yields a clear error
|
|
169
|
+
rather than a degraded file.
|
|
170
|
+
|
|
171
|
+
## Caveats when reconstructing (trial mode)
|
|
172
|
+
|
|
173
|
+
When the tool falls back to reconstruction, the **findings are complete and
|
|
174
|
+
faithful** — hosts, ports/services, severities, CVSS v2/v3 scores and vectors,
|
|
175
|
+
CVEs/BIDs/xrefs, synopsis, description, solution, see-also, and plugin output
|
|
176
|
+
are all carried through. But a reconstructed file is **not byte-identical** to a
|
|
177
|
+
licensed export, in these specific ways:
|
|
178
|
+
|
|
179
|
+
1. **The `<Policy>` block is a stub.** A licensed export embeds the full scan
|
|
180
|
+
policy — every server/plugin preference and the plugin-family selection.
|
|
181
|
+
Trial mode does not expose that data through the API, so the policy elements
|
|
182
|
+
are emitted empty. Your *findings* are intact; the scan *configuration*
|
|
183
|
+
metadata is not.
|
|
184
|
+
2. **`HostProperties` is a subset.** It contains what the host-info endpoint
|
|
185
|
+
returns (IP, FQDN, OS, MAC, start/end time, …) — generally fewer tags than a
|
|
186
|
+
native export writes.
|
|
187
|
+
3. **Plugin output honors the API's truncation.** Very large outputs flagged
|
|
188
|
+
`max_attachments_exceeded` by the API are carried through exactly as the API
|
|
189
|
+
returned them (i.e. possibly truncated).
|
|
190
|
+
4. **No attachments.** Binary attachments some plugins produce are not
|
|
191
|
+
reassembled.
|
|
192
|
+
5. **CSV columns approximate** the native Nessus CSV layout; exact column
|
|
193
|
+
ordering/quoting may differ slightly.
|
|
194
|
+
|
|
195
|
+
If any of that matters for your use case, use a licensed server with
|
|
196
|
+
`-m native`. For the common need — "get my findings out in a portable,
|
|
197
|
+
importable format" — the reconstructed `.nessus` does the job.
|
|
198
|
+
|
|
199
|
+
## Supported Nessus versions
|
|
200
|
+
|
|
201
|
+
Developed and verified against **Nessus 10.12.1** (latest at time of writing);
|
|
202
|
+
expected to work on the Nessus 10.x API generally. It uses only long-standing,
|
|
203
|
+
stable REST endpoints, so older 8.x/9.x servers will likely work too but are
|
|
204
|
+
untested. If you run it against another version, a PR updating this section is
|
|
205
|
+
welcome.
|
|
206
|
+
|
|
207
|
+
| Nessus version | Status |
|
|
208
|
+
|----------------|---------------------|
|
|
209
|
+
| 10.12.1 | ✅ Verified |
|
|
210
|
+
| 10.x (other) | 🟡 Expected to work |
|
|
211
|
+
| 8.x – 9.x | 🟡 Likely, untested |
|
|
212
|
+
|
|
213
|
+
## How it works
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
export ──► native API export ──► ready? ──► download (licensed servers)
|
|
217
|
+
│
|
|
218
|
+
└─ trial-mode 403 ──► read scan + hosts + plugins
|
|
219
|
+
└─► serialize NessusClientData_v2 / CSV
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The reconstruction walks every host and every plugin finding, so it makes more
|
|
223
|
+
API calls than a native export — expect it to take longer on large scans.
|
|
224
|
+
|
|
225
|
+
## Library use
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from nessus_export.client import NessusClient
|
|
229
|
+
from nessus_export.exporter import export_scan
|
|
230
|
+
|
|
231
|
+
client = NessusClient(url="https://localhost:8834",
|
|
232
|
+
access_key="…", secret_key="…")
|
|
233
|
+
result = export_scan(client, scan_id=5, fmt="nessus", mode="auto")
|
|
234
|
+
open("myscan.nessus", "wb").write(result.content)
|
|
235
|
+
print(result.mode) # "native" or "reconstruct"
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Security notes
|
|
239
|
+
|
|
240
|
+
- Never commit your `.env` / API keys. `.gitignore` already excludes `.env` and
|
|
241
|
+
common export artifacts (`*.nessus`, `*.pdf`, `*.db`).
|
|
242
|
+
- Exported scan results contain sensitive vulnerability data — handle and store
|
|
243
|
+
them accordingly.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT — see [LICENSE](LICENSE).
|
|
248
|
+
|
|
249
|
+
## Disclaimer
|
|
250
|
+
|
|
251
|
+
Not affiliated with or endorsed by Tenable, Inc. "Nessus" is a trademark of
|
|
252
|
+
Tenable, Inc. This tool uses only documented REST endpoints and does not
|
|
253
|
+
circumvent licensing: it reads data the API already exposes and formats it
|
|
254
|
+
locally.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
nessus_export/__init__.py,sha256=pYp-aFj3TCJHTE6LuNkhJlwpIdvvuI-IFMe77LTIRy4,139
|
|
2
|
+
nessus_export/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
|
|
3
|
+
nessus_export/cli.py,sha256=_zAECQgbtqWDKzDb-h8cPKi_DdCTJTIYZaLWn2SM19A,9366
|
|
4
|
+
nessus_export/client.py,sha256=Vc5bKyk_6VImc0hHKAzPn36_dvHHEHTPGlvupL_UrIE,6898
|
|
5
|
+
nessus_export/exporter.py,sha256=xnpKAZhBwwejG_5nFz682OQMK5cV9kdtOvnI0LjVpYk,4534
|
|
6
|
+
nessus_export/reconstruct.py,sha256=5lZxDs0F48H6KZZb3OAeHmZSY7fLKTVinLLPZkAAFCg,9585
|
|
7
|
+
nessus_export-0.1.0.dist-info/licenses/LICENSE,sha256=uqLgtzci92MJY4mZZnJtjQzfDJd0jElg04pnm1JIa3U,1067
|
|
8
|
+
nessus_export-0.1.0.dist-info/METADATA,sha256=-gLyUOOLWT3NOtKtpFz9tZ6YFiVUzZTIOVXXJlnIJBw,10623
|
|
9
|
+
nessus_export-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
nessus_export-0.1.0.dist-info/entry_points.txt,sha256=tcv5pGQaycggjeMyvufXmI-rEAvD2FVfFKWOMnX1Jho,57
|
|
11
|
+
nessus_export-0.1.0.dist-info/top_level.txt,sha256=A1yBqJUC5nAUr0sCGmpfhJ8RKGGvlrkj2meJDL0pDr0,14
|
|
12
|
+
nessus_export-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 setuidloot
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nessus_export
|