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.
@@ -0,0 +1,4 @@
1
+ """nessus-export: export Nessus scan results via the API, with a
2
+ reconstruction fallback for trial-mode servers."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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())
@@ -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
+ [![CI](https://github.com/setuidloot/nessus-export/actions/workflows/ci.yml/badge.svg)](https://github.com/setuidloot/nessus-export/actions/workflows/ci.yml)
24
+ [![PyPI](https://img.shields.io/pypi/v/nessus-export.svg)](https://pypi.org/project/nessus-export/)
25
+ [![Python](https://img.shields.io/pypi/pyversions/nessus-export.svg)](https://pypi.org/project/nessus-export/)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nessus-export = nessus_export.cli:main
@@ -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