diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9aaf8a --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Copy to .env for the live test suite. .env is gitignored - never commit credentials. +# +# set -a && . ./.env && set +a +# APPLIANCE="$SNS_URL" PASSWORD="$SNS_PASSWORD" pytest -m live + +SNS_URL="10.0.0.254" +SNS_USER="admin" +SNS_PASSWORD="changeme" + +# optional, unlock the remaining connection modes +#SERIAL="VMSNSX00000000A" +#FQDN="firewall.example.com" +#CABUNDLE="/path/to/ca.pem" +#CERT="/path/to/user-cert-and-key.pem" +#PROXY="socks5://user:pass@proxy:1080" diff --git a/.gitignore b/.gitignore index d160e55..b99cc2c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,19 @@ dist build .eggs -stormshield.sns.sslclient.egg-info +*.egg-info __pycache__ *.pyc .tox +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov + +# local credentials - never commit +.env +.env.* +!.env.example +.venv +venv diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..633985b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,152 @@ +# Changelog + +All notable changes to this project are documented here. +This project adheres to [Semantic Versioning](https://semver.org/). + +## [2.0.0] - 2026-08-03 + +Modernisation release. See [MIGRATION.md](MIGRATION.md) for the upgrade path. + +### Fixed + +- **`snscli -t` set a TOTP instead of a timeout.** `-t` was declared twice, for + `--timeout` and for `--totp`; argparse's `conflict_handler="resolve"` silently + gave it to `--totp`. `-t` now means `--totp` only, and the parser rejects + duplicated options instead of swallowing them. +- **`upload()` poisoned the whole session.** It mutated `self.headers` in place, + so every request issued after an upload carried the multipart `Content-Type` + of that upload. The headers are copied now. +- **`upload()` leaked a file descriptor** when the POST raised. +- **`download()` left corrupted files on disk.** The payload was written in full, + *then* its size and CRC were checked. Downloads now land in a `.part` file that + is only moved into place after verification, so a failed transfer never + destroys a previously good file. +- **`download()` returned an answer whose XML was truncated.** The `` + element it synthesised was never closed, so `ElementTree.fromstring( + response.xml)` raised `ParseError` and `snscli -o xml` died with an + `ExpatError` after every *successful* download. Present in 1.x. +- **A malformed answer raised `IndexError`, `AttributeError` or `TypeError`** + instead of `ServerError`. The `if serverd is not None` guard was a no-op: an + `Element` is never `None`. The download header (its `section`/`key`, `size` + and `crc` nodes), the serverd session nodes (`sessionid`, `protocol`, + `sessionlevel`) and a `` node with no `ret` now all report + `ServerError`. +- **The library hijacked the root logger** (`logging.getLogger()`), capturing the + host application's logging configuration and emitting full API responses into + it at DEBUG level. It now logs under `stormshield.sns.sslclient`. +- **No timeout by default**, so an appliance that stopped answering hung the + caller forever. `SSLClient.DEFAULT_TIMEOUT` is 30 s; pass `timeout=None` to + restore the old behaviour. `snscli --timeout 0` — or the 1.x `-1` — still + waits forever. +- **`crc.compute_crc32()` raised `TypeError`** when handed a `str`, from a + leftover Python 2 branch calling `bytearray(data)` without an encoding. Both + it and `update_crc32()` now read a `str` as UTF-8. +- **`ConfigParser` crashed on an empty answer** (`IndexError` on `lines[0]`, + `AttributeError` when `output` was `None`). +- **`Response.__repr__` crashed** when `output` was `None`, and otherwise dumped + the entire payload. +- A `raw` payload lost its trailing newline, a side effect of rendering the + answer to text and parsing it back. +- **Paged answers looked complete.** The appliance reports row counts and + truncation flags as attributes of the `` node (`total`, + `too_many_data`, `not_enough_space`, `data_changed`); the client dropped them + entirely. `CONFIG OBJECT LIST type=host start=0` returns at most 100 rows + while announcing `total=134`, so iterating `response.data` silently processed + a quarter of the objects with no way to notice. They are exposed as + `Response.meta` / `.total` / `.count` / `.truncated`, and `send_command()` + logs a warning when rows were held back. + +- **`sslverifyhost=False` silently widened the set of trusted authorities.** + That option (and `ip=`) mounts an adapter that builds its own SSL context. + The context came from `ssl.create_default_context()` with no `cafile`, which + activates the system trust store; urllib3 then loaded `cabundle` on top, so + the caller got *their* CA **plus every publicly trusted authority* — while + host name checking was off. Any certificate signed by any public CA, for any + name, was accepted. The context is now built with `cafile=cabundle`, which + keeps `create_default_context` from reaching for the system store. + Present in 1.x as soon as urllib3 2.x was installed. This is the one fix that + can break a *working* connection — an appliance whose certificate is signed by + a public CA was accepted before and is refused now. Pass the authorities you + trust as `cabundle`; see [MIGRATION.md](MIGRATION.md). + +### Changed + +- **Answers are decoded straight from their XML tree.** 1.x parsed the XML, + re-serialised it to ini text, then parsed that text back with regexes and + `shlex`. `Response.data` is now built from the tree, and `Response.output` is + rendered lazily on first access. Raw XML answer to fully decoded `data`, + measured on the captured answers in `tests/fixtures`: + + | answer | 1.x | 2.0 | + |---|---|---| + | `USER LIST` (88 KB) | 27.7 ms | **3.9 ms** (×7.2) | + | `CONFIG OBJECT LIST` (23.5 KB) | 6.4 ms | **1.3 ms** (×5.1) | + +- **`crc.py` uses `zlib`** instead of a CRC table written in Python. The SNS CRC + is the non-finalised IEEE CRC-32, i.e. `zlib.crc32(data) ^ 0xFFFFFFFF`. + **157× faster** (113 ms/MB → 0.72 ms/MB), and the hand-written 256-entry + table (66 lines) removed. +- `format_output()` builds its result with a list join instead of repeated + string concatenation, which was quadratic in the answer size. +- `section_line` parsing uses one compiled regex instead of instantiating a + `shlex` lexer per line with a hand-maintained `wordchars` allow-list. +- Download chunk size raised from 10 KB to 64 KB. +- Connection failures are retried twice with backoff. Only connection + establishment is retried — a command that reached the appliance is never + replayed, as API commands are not idempotent. +- `pygments`, `colorlog` and `pyreadline3` moved to the `cli` extra: importing + the library no longer pulls in terminal colouring dependencies. + +### Added + +- `SSLClient` is a context manager: `with SSLClient(...) as client:`. +- `disconnect()` is idempotent and survives an already-dead connection. +- `SNSError`, a common base class for every exception the library raises. +- `Response.from_xml()` / `Response.from_tree()`, and `Response.get()` as a + shortcut for `response.parser.get()`. +- `Response.serverd_code`, the code of the first serverd node (the transfer + state), distinct from `Response.code` which reports the final status. +- `Response.meta`, `.total`, `.count`, `.offset` and `.truncated` for paged + answers. `truncated` compares `offset + count` against `total`, not `count` + alone: past the last page the appliance reports the full `total` alongside + zero rows, so the naive comparison never terminates. +- `Response.to_dict()` and `Response.json()`. `data` uses `CaseInsensitiveDict`, + which `json.dumps` refuses; these return plain containers and a JSON string. + Note that every value is a string — the appliance sends XML attributes, so no + type information ever reaches the client. +- Type annotations throughout, plus a `py.typed` marker. +- `SNSCLI_PASSWORD` environment variable, so the password no longer has to + appear in `ps` output via `-p`. +- `--retries` option on `snscli`. +- An offline test suite (156 tests) built on XML answers captured from a real + appliance, and a locale-independent live suite behind the `live` marker. +- `tox` environments for Python 3.10–3.13, plus a `live` environment and a + `lint` environment running ruff and mypy. + +### Removed + +- **Python 2 leftovers**: `from __future__ import unicode_literals`, the + `unicode` branch in `quote()`, the `raw_input`/`FileNotFoundError` shims, and + the `sys.version_info[0] < 3` branch in the parser. +- **urllib3 1.x support** (end of life). `urllib3>=2.0` is now required, which + collapsed six near-identical `if URLLIB3V2` branches in the adapters. +- `HostNameAdapter` and `DNSResolverHTTPSAdapter`, merged into the single + `SNSHTTPSAdapter`. +- `setup.py`, replaced by `pyproject.toml` (PEP 517/621). +- Python 3.7–3.9 support; the minimum is now 3.10. +- `SSLClient.SRV_RET_MSG` and `SSLClient.AUTH_FAILED`, and + `ConfigParser.TOKEN_VALUE_RE`: dead since at least 1.0, referenced nowhere. + The `SRV_RET_*` and `SSL_SERVERD_*` code constants themselves are kept, and + `SSL_SERVERD_MSG` is still used to build error messages. +- `MANIFEST.in`: setuptools builds an identical sdist without it now that + packaging is declared in `pyproject.toml`. + +## [1.1.2] + +- Disable compression to avoid an issue with later versions of urllib3 (#23) +- Update readline lib for Windows + +## [1.1.1] + +- Ignore bad xml response from serverd (#22) +- Update for SNS v5 (#17) diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 404cf76..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include stormshield/sns/bundle.ca -include stormshield/sns/cmd.complete diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..e8035e7 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,123 @@ +# Migrating from 1.x to 2.0 + +Most code needs **no change**: `SSLClient(...)`, `send_command()`, `response.data`, +`response.output`, `response.xml`, `response.ret` and `response.parser.get()` all +behave as before. + +The points below are the ones that can actually break a caller. + +## Requirements + +| | 1.x | 2.0 | +|---|---|---| +| Python | 3.7+ | **3.10+** | +| urllib3 | 1.x or 2.x | **2.0+** | + +Install the CLI dependencies explicitly if you use `snscli`: + +```console +$ pip install 'stormshield.sns.sslclient[cli]' +``` + +## Behaviour changes + +### A default timeout is applied + +1.x waited forever. 2.0 defaults to 30 seconds: + +```python +client = SSLClient(host="fw", password="pass", timeout=None) # restore 1.x behaviour +``` + +### `repr(response)` no longer returns the payload + +`__repr__` returned `self.output`, and raised `TypeError` when it was `None`. +It is now a short summary. Use `str(response)` or `response.output` for the text: + +```python +print(response) # unchanged: __str__ still returns output +print(repr(response)) # +``` + +### `sslverifyhost=False` no longer trusts the system authorities + +This is the one change that breaks *working* connections. An appliance reached +with `sslverifyhost=False` **and** peer verification left on connected in 1.x +whenever its certificate was signed by a publicly trusted CA — a common setup +when the appliance is fronted by a corporate or commercial certificate. That +worked by accident: the adapter's SSL context pulled in the system trust store +on top of `cabundle` (see the CHANGELOG). 2.0 trusts `cabundle` and nothing else, +so the same call now raises `SSLError`. + +Name the authorities you actually trust: + +```python +import certifi + +# appliance certificate signed by a public CA +client = SSLClient(host="fw", password="pass", sslverifyhost=False, + cabundle=certifi.where()) + +# appliance certificate signed by your own CA +client = SSLClient(host="fw", password="pass", sslverifyhost=False, + cabundle="/etc/ssl/private/company-ca.pem") +``` + +`snscli` takes the same file with `-C/--cabundle`. Callers already passing +`sslverifypeer=False` (`snscli -k`) are unaffected, and so is the default +factory-certificate setup, which the shipped bundle covers. + +### `raw` answers keep their trailing newline + +1.x dropped the final `\n` of a `format="raw"` payload. If you compared +`response.data` against a literal, add the newline back or use `.rstrip()`. + +### Logging moved out of the root logger + +1.x called `logging.getLogger()`. If you relied on that to see the library's +DEBUG output, target its namespace explicitly: + +```python +logging.getLogger("stormshield.sns.sslclient").setLevel(logging.DEBUG) +``` + +### `snscli -t` + +`-t` was ambiguous and resolved to `--totp`. It now means `--totp` only, and +`--timeout` has no short form. Scripts passing `-t` for a timeout were already +setting a TOTP, so they were already broken — they now need `--timeout`. + +## Removed API + +| Removed | Replacement | +|---|---| +| `HostNameAdapter` | `SNSHTTPSAdapter(assert_hostname=False, cafile=bundle)` | +| `DNSResolverHTTPSAdapter(cn, host)` | `SNSHTTPSAdapter(cn, cafile=bundle)` | +| `URLLIB3V2` | urllib3 2.x is always assumed | +| `setup.py install` | `pip install .` | + +These were internal plumbing; the public entry point has always been `SSLClient`. + +Always pass `cafile` — the certificate authority bundle you mounted the session +with, or `None` only when peer verification is disabled. The adapter builds its +own SSL context, and leaving `cafile` out makes it fall back to the system trust +store: your bundle would no longer be the only authority trusted, which is the +widening 2.0 fixed. + +## New things worth adopting + +```python +from stormshield.sns.sslclient import SSLClient, SNSError + +# context manager: disconnects even when a command raises +with SSLClient(host="10.0.0.254", user="admin", password="pass", + sslverifyhost=False) as client: + response = client.send_command("SYSTEM PROPERTY") + print(response.get("Result", "Version")) # shortcut for parser.get() + +# one except clause for the whole library +try: + ... +except SNSError as exc: + ... +``` diff --git a/README.md b/README.md index d68e7df..94e989d 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,96 @@ -# python-SNS-API +
+ +Stormshield + +# Stormshield Python Module + +[![PyPI version](https://img.shields.io/pypi/v/stormshield.sns.sslclient.svg)](https://pypi.org/project/stormshield.sns.sslclient/) +[![Python versions](https://img.shields.io/pypi/pyversions/stormshield.sns.sslclient.svg)](https://pypi.org/project/stormshield.sns.sslclient/) +[![License](https://img.shields.io/pypi/l/stormshield.sns.sslclient.svg)](LICENCE) +[![Stormshield](https://img.shields.io/badge/Stormshield-Network%20Security-0057B8)](https://www.stormshield.com) + +
A Python client for the Stormshield Network Security appliance SSL API. -Note: this module requires python3.7 or later. +Requires **Python 3.10 or later** and **urllib3 2.x**. + +* What changed, and why: [CHANGELOG.md](CHANGELOG.md) +* Upgrading from 1.x: [MIGRATION.md](MIGRATION.md) + +## Table of contents + +* [Install](#install) +* [API usage](#api-usage) + * [Which CA bundle is used](#which-ca-bundle-is-used) + * [Command results](#command-results) + * [JSON](#json) + * [Paged answers](#paged-answers) + * [Error handling](#error-handling) + * [File upload/download](#file-uploaddownload) + * [Logging](#logging) +* [snscli](#snscli) +* [Proxy](#proxy) +* [Build](#build) +* [Tests](#tests) +* [Links](#links) + +## Install + +From PyPI: + +```console +$ pip install stormshield.sns.sslclient # library only +$ pip install 'stormshield.sns.sslclient[cli]' # + the snscli command +``` + +From source: + +```console +$ pip install '.[cli]' +``` ## API usage ```python from stormshield.sns.sslclient import SSLClient -client = SSLClient( +with SSLClient( host="10.0.0.254", port=443, user='admin', password='password', - sslverifyhost=False) + sslverifyhost=False, +) as client: -response = client.send_command("SYSTEM PROPERTY") + response = client.send_command("SYSTEM PROPERTY") -if response: - model = response.data['Result']['Model'] - version = response.data['Result']['Version'] + if response: + print(f"Model: {response.data['Result']['Model']}") + print(f"Firmware version: {response.data['Result']['Version']}") + else: + print(f"Command failed: {response.output}") +``` - print("Model: {}".format(model)) - print("Firmware version: {}".format(version)) -else: - print("Command failed: {}".format(response.output)) +The client is a context manager, so it disconnects even if a command raises. +`client.disconnect()` still works if you prefer to manage the session yourself, +and calling it twice is harmless. -client.disconnect() +* **Note:** Starting from the 5.0 firmware, a custom CA is used by default by the SSL API. To continue to connect checking the certificate authority of the appliance, the "SNS-WebServer-default-authority" CA must be retrieved from each appliance, then added to a cabundle.pem file. Alternatively, CA verification can be bypassed using `sslverifypeer=False`. -``` +### Which CA bundle is used -* **Note:** Starting from the 5.0 firmware, a custom CA is used by default by the SSL API. To continue to connect checking the certificate authority of the appliance, the "SNS-WebServer-default-authority" CA must be retrieved from each appliance, then added to a cabundle.pem file. Alternatively, CA verification can be bypassed using sslverifypeer=False argument to SSLClient(). +`cabundle` is the **only** set of authorities trusted - it replaces the system +trust store, it does not add to it. The bundle shipped with the library +(`stormshield/sns/bundle.ca`) holds the two Stormshield roots that sign factory +appliance certificates, and nothing else. + +So an appliance fronted by a commercial certificate (Let's Encrypt, DigiCert…) +is *not* verifiable with the default bundle. Point `cabundle` at the matching +roots instead: + +```python +import certifi +SSLClient(host="firewall.example.com", cabundle=certifi.where(), ...) +``` ### Command results @@ -51,33 +112,103 @@ name=ntp2.stormshieldcs.eu keynum=none type=host >>> print(response.data) {'Result': [{'name': 'ntp1.stormshieldcs.eu', 'keynum': 'none', 'type': 'host'}, {'name': 'ntp2.stormshieldcs.eu', 'keynum': 'none', 'type': 'host'}]} - ``` +`data` is decoded directly from the XML answer; `output` is only rendered if you +read it, so code that works on `data` never pays for the text formatting. + The keys of the `data` property are case insensitive, `response.data['Result'][0]['name']` and `response.data['ReSuLt'][0]['NaMe']` will return the same value. -Results token are also available via `response.parser.get()` method which accepts a default parameter to return if the token is not present. +Result tokens are also available via `response.get()` (or `response.parser.get()`), +which accepts a default to return when the token is absent: ```python ->>> print(response.output) -101 code=00a01000 msg="Begin" format="section" -[Server] -1=dns1.google.com -2=dns2.google.com -100 code=00a00100 msg="Ok" - >>> print(response.data['Server']['3']) Traceback (most recent call last): - File "", line 1, in - File "/usr/local/lib/python3.7/site-packages/requests/structures.py", line 52, in __getitem__ - return self._store[key.lower()][1] + ... KeyError: '3' ->>> print(response.parser.get(section='Server', token='3', default=None)) +>>> print(response.get(section='Server', token='3', default=None)) None +``` + +### JSON +`data` uses `CaseInsensitiveDict`, which `json.dumps` cannot serialise. Use +`to_dict()` for plain containers, or `json()` for a string: + +```python +>>> json.dumps(response.data) +TypeError: Object of type CaseInsensitiveDict is not JSON serializable + +>>> response.to_dict() +{'Result': [{'name': 'ntp1.stormshieldcs.eu', 'keynum': 'none', 'type': 'host'}]} + +>>> print(response.json(indent=2)) +{ + "Result": [ + {"name": "ntp1.stormshieldcs.eu", "keynum": "none", "type": "host"} + ] +} +``` + +**Every value is a string.** The appliance answers in XML, where everything is +a text attribute, so no type information ever reaches the client: `"modify": "1"` +is the text `1`, not the integer, and `"global": "0"` is not a boolean. The +library does not guess - converting is the caller's decision. + +The appliance itself has no JSON mode. `output=json` is rejected by some +commands and silently ignored by others, which answer in their usual format; +only `output=xml` is real. + +### Paged answers + +Commands that page their results announce how many rows exist in total. Those +counters are answer metadata, not rows, so they live outside `data`: + +```python +>>> response = client.send_command("CONFIG OBJECT LIST type=host start=0") +>>> response.count, response.total, response.truncated +(100, 134, True) +>>> response.meta +{'total': '134', 'data_changed': '0', 'too_many_data': '0', 'not_enough_space': '0'} ``` +Iterating `response.data['Object']` alone would process 100 of 134 objects +without any error. Page until `truncated` is False: + +```python +objects, start = [], 0 +while True: + response = client.send_command(f"CONFIG OBJECT LIST type=host start={start}") + objects.extend(response.data['Object']) + if not response.truncated: + break + start += response.count +``` + +`send_command()` also logs a warning whenever rows were held back. `truncated` +compares `offset + count` against `total` - `offset` is read from the command's +`start=` argument - because past the last page the appliance still reports the +full `total` next to zero rows, and comparing `count` alone would never +terminate. + +### Error handling + +Every exception derives from `SNSError`: + +```python +from stormshield.sns.sslclient import ( + SNSError, # base class + MissingHost, MissingAuth, MissingCABundle, + AuthenticationError, TOTPNeededError, + ServerError, FileError, +) +``` + +A command that the appliance rejects is *not* an exception: check the response. +`bool(response)` is true when `ret` is OK or a warning (100–199). + ### File upload/download Files can be downloaded to or uploaded from the client host by adding a redirection to a file with '>' or '<' at the end of the configuration command. @@ -87,18 +218,41 @@ Files can be downloaded to or uploaded from the client host by adding a redirect 100 code=00a00100 msg="Ok" ``` +Downloads are written to a temporary file and only moved into place once their +size and CRC match what the appliance announced, so a failed transfer never +leaves a truncated file behind. + +### Logging + +The library logs under the `stormshield.sns.sslclient` namespace and does not +touch the root logger: + +```python +logging.getLogger("stormshield.sns.sslclient").setLevel(logging.DEBUG) +``` + +Note that DEBUG logs the full body of every API answer. + ## snscli - `snscli` is a python cli for executing configuration commands and scripts on Stormshield Network Security appliances. +`snscli` is a python cli for executing configuration commands and scripts on Stormshield Network Security appliances. * Output format can be chosen between section/ini or xml * File upload and download available with adding `< upload` or `> download` at the end of the command * Client can execute script files using `--script` option. * Comments are allowed with `#` -`$ snscli --host ` +```console +$ snscli --host +$ snscli --host --user admin --script config.script +``` + +Pass the password through the environment rather than `--password`, which is +visible in `ps`: -`$ snscli --host --user admin --password admin --script config.script` +```console +$ SNSCLI_PASSWORD=secret snscli --host --script config.script +``` Concerning the SSL validation: @@ -113,38 +267,42 @@ Concerning the SSL validation: The library and `snscli` tool support HTTP and SOCKS proxies, use `--proxy scheme://user:password@host:port` option. - ## Build -`$ python3 setup.py sdist bdist_wheel` - - -## Install - -## From PyPI: - -`$ pip3 install stormshield.sns.sslclient` - -## From source: +```console +$ python3 -m build +``` -`$ python3 setup.py install` +## Tests +The default suite is offline: it replays XML answers captured from a real +appliance, stored under `tests/fixtures`. -## Tests +```console +$ pytest +``` -Warning: some tests require a remote SNS appliance. +Tests marked `live` need a reachable appliance and skip otherwise: -`$ PASSWORD=password APPLIANCE=10.0.0.254 tox` +```console +$ APPLIANCE=10.0.0.254 PASSWORD=password pytest -m live +``` -To run one test: +Optional variables unlock the remaining connection modes: `SERIAL` (host name +check against the certificate CN), `FQDN` + `CABUNDLE` (custom CA), `CERT` +(client certificate authentication), `PROXY`. -`tox -- tests/test_format_ini` +Across supported interpreters: +```console +$ tox +``` To run `snscli` from the source folder without install: -`$ PYTHONPATH=. python3 stormshield/sns/cli.py --help` - +```console +$ PYTHONPATH=. python3 -m stormshield.sns.cli --help +``` ## Links diff --git a/examples/addvlan.py b/examples/addvlan.py index dfd7c2a..7d5b59a 100755 --- a/examples/addvlan.py +++ b/examples/addvlan.py @@ -4,8 +4,8 @@ Script to create a VLAN interface on a SNS appliance """ -import sys import getpass +import sys from stormshield.sns.sslclient import SSLClient @@ -39,7 +39,7 @@ def error(msg): global client - print("ERROR: {}".format(msg)) + print(f"ERROR: {msg}") client.disconnect() sys.exit(1) @@ -48,7 +48,7 @@ def command(cmd): response = client.send_command(cmd) if not response: - error("command failed:\n{}".format(response.output)) + error(f"command failed:\n{response.output}") return response @@ -60,23 +60,23 @@ def command(cmd): else: vlanid = -1 for i in range(MAXVLAN): - if "vlan{}".format(i) not in response.data: + if f"vlan{i}" not in response.data: vlanid = i break if vlanid == -1: error("all available VLAN already created") -response = command("CONFIG NETWORK INTERFACE CREATE state=1 protected=0 mtu=1500 physical={} name={} tag={} priority=0 keepVlanPriority=1 maxThroughput=0 ifname=vlan{} address={} mask={}".format(vlanphy, vlanname, vlantag, vlanid, vlanaddr, vlanmask)) +response = command(f"CONFIG NETWORK INTERFACE CREATE state=1 protected=0 mtu=1500 physical={vlanphy} name={vlanname} tag={vlantag} priority=0 keepVlanPriority=1 maxThroughput=0 ifname=vlan{vlanid} address={vlanaddr} mask={vlanmask}") if response.code: - print("VLAN vlan{} created".format(vlanid)) + print(f"VLAN vlan{vlanid} created") else: - error("VLAN vlan{} can't be created:\n{}".format(vlanid, response.output)) + error(f"VLAN vlan{vlanid} can't be created:\n{response.output}") response = command("CONFIG NETWORK ACTIVATE") if response.code: print("Configuration activated") else: - error("Can't activate network:\n{}".format(response.output)) + error(f"Can't activate network:\n{response.output}") client.disconnect() diff --git a/examples/getproperty.py b/examples/getproperty.py index f086b4e..4c43533 100755 --- a/examples/getproperty.py +++ b/examples/getproperty.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python3 """ This example show how to connect to a SNS appliance, send a command @@ -8,32 +8,35 @@ import getpass -from stormshield.sns.sslclient import SSLClient +from stormshield.sns.sslclient import SNSError, SSLClient # user input host = input("Appliance ip address: ") user = input("User:") password = getpass.getpass("Password: ") -# connect to the appliance -client = SSLClient( - host=host, port=443, - user=user, password=password, - sslverifyhost=False) - -# request appliance properties -response = client.send_command("SYSTEM PROPERTY") - -if response: - #get value using parser get method - model = response.parser.get(section='Result', token='Model') - # get value with direct access to data - version = response.data['Result']['Version'] - - print("") - print("Model: {}".format(model)) - print("Firmware version: {}".format(version)) -else: - print("Command failed: {}".format(response.output)) - -client.disconnect() +try: + # the context manager disconnects even if a command raises + with SSLClient( + host=host, port=443, + user=user, password=password, + sslverifyhost=False, + ) as client: + + # request appliance properties + response = client.send_command("SYSTEM PROPERTY") + + if response: + # get value using the get() shortcut, which accepts a default + model = response.get(section='Result', token='Model') + # get value with direct access to data + version = response.data['Result']['Version'] + + print("") + print(f"Model: {model}") + print(f"Firmware version: {version}") + else: + print(f"Command failed: {response.output}") + +except SNSError as exception: + raise SystemExit(f"Connection failed: {exception}") from exception diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0db3317 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "stormshield.sns.sslclient" +dynamic = ["version"] +description = "SSL API client for Stormshield Network Security appliances" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENCE"] +authors = [{ name = "Remi Pauchet", email = "remi.pauchet@stormshield.eu" }] +keywords = ["stormshield", "sns", "firewall", "utm", "nsrpc"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Typing :: Typed", + "Topic :: System :: Networking", +] +dependencies = [ + "requests[socks]>=2.31", + "requests_toolbelt>=1.0", + "defusedxml>=0.7", + "urllib3>=2.0", +] + +# pygments/colorlog/readline are only needed by the `snscli` console script, +# not by the library itself. +[project.optional-dependencies] +cli = [ + "pygments>=2.15", + "colorlog>=6.7", + "pyreadline3>=3.4; platform_system == 'Windows'", +] +test = ["pytest>=7.4", "pytest-cov>=4.1"] +dev = ["ruff>=0.6", "mypy>=1.11", "types-defusedxml", "types-requests"] + +[project.urls] +Homepage = "https://github.com/stormshield/python-SNS-API" +Source = "https://github.com/stormshield/python-SNS-API" +Issues = "https://github.com/stormshield/python-SNS-API/issues" + +[project.scripts] +snscli = "stormshield.sns.cli:main" + +[tool.setuptools.dynamic] +version = { attr = "stormshield.sns.sslclient.__version__.__version__" } + +[tool.setuptools.packages.find] +include = ["stormshield*"] + +[tool.setuptools.package-data] +"stormshield.sns" = ["bundle.ca", "cmd.complete", "py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["error::DeprecationWarning:stormshield.*"] +markers = ["live: needs a running SNS appliance (APPLIANCE/PASSWORD env vars)"] + +[tool.ruff] +line-length = 110 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "RUF"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.10" +packages = ["stormshield"] +ignore_missing_imports = true diff --git a/setup.py b/setup.py deleted file mode 100755 index 9c94bf9..0000000 --- a/setup.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/python - -import setuptools -import os - -version = {} -with open(os.path.join('stormshield', 'sns', 'sslclient', '__version__.py'), 'r') as fh: - exec(fh.read(), version) - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - name="stormshield.sns.sslclient", - version=version['__version__'], - author="Remi Pauchet", - author_email="remi.pauchet@stormshield.eu", - description="SSL API client for Stormshield Network Security appliances", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/stormshield/python-SNS-API", - license='Apache License 2.0', - packages=setuptools.find_packages(), - entry_points={ - 'console_scripts': ['snscli=stormshield.sns.cli:main'], - }, - install_requires=[ - 'pygments', - 'requests[socks]', - 'requests_toolbelt', - 'colorlog', - 'defusedxml', - 'packaging', - 'pyreadline3; platform_system == "Windows"' - ], - include_package_data=True, - tests_require=["pytest"], - classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Topic :: System :: Networking", - "Environment :: Console" - ], -) diff --git a/stormshield/sns/cli.py b/stormshield/sns/cli.py index 1fe0947..90fbe65 100644 --- a/stormshield/sns/cli.py +++ b/stormshield/sns/cli.py @@ -1,140 +1,214 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 -""" cli to connect to Stormshield Network Security appliances""" +"""cli to connect to Stormshield Network Security appliances""" -from __future__ import unicode_literals -import sys -import os -import re +from __future__ import annotations + +import argparse +import atexit +import getpass import logging import logging.handlers -import readline -import getpass -import atexit -import defusedxml.minidom -import argparse +import os import platform -from pygments import highlight -from pygments.lexers import XmlLexer -from pygments.formatters import TerminalFormatter -from colorlog import LevelFormatter +import re +import sys +from typing import Any, cast -from stormshield.sns.sslclient import SSLClient, ServerError, TOTPNeededError +import defusedxml.minidom + +from stormshield.sns.sslclient import ServerError, SSLClient, TOTPNeededError from stormshield.sns.sslclient.__version__ import __version__ as libversion -from urllib3 import __version__ as urllib3version -from requests import __version__ as requestsversion - -# define missing exception for python2 -try: - FileNotFoundError -except NameError: - FileNotFoundError = IOError - -OUTPUT_LEVELV_NUM = 60 # log command response -COMMAND_LEVELV_NUM = 59 # log command input -FORMATTER = LevelFormatter( - fmt={ - 'DEBUG': "%(log_color)s%(levelname)-8s%(reset)s %(message)s", - 'INFO': "%(log_color)s%(levelname)-8s%(reset)s %(message)s", - 'WARNING': "%(log_color)s%(levelname)-8s%(reset)s %(message)s", - 'ERROR': "%(log_color)s%(levelname)-8s%(reset)s %(message)s", - 'CRITICAL': "%(log_color)s%(levelname)-8s%(reset)s %(message)s", - 'OUTPUT': "%(message)s", - 'COMMAND': "%(message)s" - }, - datefmt=None, - reset=True, - log_colors={ - 'DEBUG': 'green', - 'INFO': 'cyan', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red,bg_white' - }, - secondary_log_colors={}, - style='%' + +try: # optional, only needed for history and completion + import readline +except ImportError: # pragma: no cover - Windows without pyreadline3 + readline = None # type: ignore[assignment] + +CLI_EXTRA_HINT = ( + "snscli needs its optional dependencies: pip install 'stormshield.sns.sslclient[cli]'" ) +OUTPUT_LEVELV_NUM = 60 # log command response +COMMAND_LEVELV_NUM = 59 # log command input + +EMPTY_RE = re.compile(r"^\s*$") +#: matches the urllib3 error naming the certificate CN we failed to match +CN_MISMATCH_RE = re.compile(r"doesn't match '(.*)'") + +#: environment variable used to pass the password without exposing it in `ps` +PASSWORD_ENV = "SNSCLI_PASSWORD" + + +def _build_formatter() -> Any: + """Build the colored log formatter, failing with a clear message.""" + + try: + from colorlog import LevelFormatter + except ImportError as exc: # pragma: no cover + raise SystemExit(CLI_EXTRA_HINT) from exc + + return LevelFormatter( + fmt={ + "DEBUG": "%(log_color)s%(levelname)-8s%(reset)s %(message)s", + "INFO": "%(log_color)s%(levelname)-8s%(reset)s %(message)s", + "WARNING": "%(log_color)s%(levelname)-8s%(reset)s %(message)s", + "ERROR": "%(log_color)s%(levelname)-8s%(reset)s %(message)s", + "CRITICAL": "%(log_color)s%(levelname)-8s%(reset)s %(message)s", + "OUTPUT": "%(message)s", + "COMMAND": "%(message)s", + }, + datefmt=None, + reset=True, + log_colors={ + "DEBUG": "green", + "INFO": "cyan", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + secondary_log_colors={}, + style="%", + ) + + +def _highlight_xml(xml: str) -> str: + """Pretty print and colorize an XML answer.""" + + try: + from pygments import highlight + from pygments.formatters import TerminalFormatter + from pygments.lexers import XmlLexer + except ImportError as exc: # pragma: no cover + raise SystemExit(CLI_EXTRA_HINT) from exc + + return highlight(defusedxml.minidom.parseString(xml).toprettyxml(), XmlLexer(), TerminalFormatter()) + + class CommandFilter(logging.Filter): - def filter(self, record): - if record.levelname == 'COMMAND': - return False - return True + def filter(self, record: logging.LogRecord) -> bool: + return record.levelname != "COMMAND" + + +class SNSLogger(logging.Logger): + """Logger exposing the CLI's ``OUTPUT`` and ``COMMAND`` levels. + + A subclass rather than methods bolted onto :class:`logging.Logger`, which + would leak the two levels into every logger of the host application. + """ + + def output(self, message: str, *args: Any, **kwargs: Any) -> None: + """Log a command answer.""" + + self._log(OUTPUT_LEVELV_NUM, message, args, **kwargs) + + def command(self, message: str, *args: Any, **kwargs: Any) -> None: + """Log a command input.""" + + self._log(COMMAND_LEVELV_NUM, message, args, **kwargs) -EMPTY_RE = re.compile(r'^\s*$') def make_completer(): - """ load completer for readline """ - vocabulary = [] - with open(SSLClient.get_completer(), "r") as completelist: - for line in completelist: - vocabulary.append(line.replace('.', ' ').strip('\n')) + """Load completer for readline""" + + with open(SSLClient.get_completer()) as completelist: + vocabulary = [line.replace(".", " ").strip("\n") for line in completelist] def custom_complete(text, state): results = [x for x in vocabulary if x.startswith(text)] + [None] return results[state] + return custom_complete -def main(): - # parse command line +def build_parser() -> argparse.ArgumentParser: + """Build the command line parser. + + ``add_help=False`` frees ``-h`` for ``--host``; without it argparse would + need ``conflict_handler="resolve"``, which silently swallows duplicated + short options instead of reporting them. + """ - parser = argparse.ArgumentParser(conflict_handler="resolve") + parser = argparse.ArgumentParser(prog="snscli", add_help=False) + parser.add_argument("--help", action="help", help="Show this help message and exit") group = parser.add_argument_group("Connection parameters") - group.add_argument("-h", "--host", help="Remote UTM", default=None) - group.add_argument("-i", "--ip", help="Remote UTM ip", default=None) - group.add_argument("-P", "--port", help="Remote port", default=443, type=int) - group.add_argument("--proxy", help="Proxy URL (scheme://user:password@host:port)", default=None) - group.add_argument("-t", "--timeout", help="Connection timeout in seconds", default=-1, type=int) + group.add_argument("-h", "--host", help="Remote UTM", default=None) + group.add_argument("-i", "--ip", help="Remote UTM ip", default=None) + group.add_argument("-P", "--port", help="Remote port", default=443, type=int) + group.add_argument("--proxy", help="Proxy URL (scheme://user:password@host:port)", default=None) + group.add_argument( + "--timeout", + help="Connection timeout in seconds (default: %(default)s, 0 or -1 to wait forever)", + default=SSLClient.DEFAULT_TIMEOUT, + type=float, + ) + group.add_argument( + "--retries", help="Retries on connection failure (default: %(default)s)", default=2, type=int + ) group = parser.add_argument_group("Authentication parameters") - group.add_argument("-u", "--user", help="User name", default="admin") - group.add_argument("-p", "--password", help="Password", default=None) - group.add_argument("-t", "--totp", help="Time-based one time password", default=None) - group.add_argument("-U", "--usercert", help="User certificate file", default=None) + group.add_argument("-u", "--user", help="User name", default="admin") + group.add_argument( + "-p", + "--password", + help=f"Password (prefer the {PASSWORD_ENV} environment variable)", + default=None, + ) + group.add_argument("-t", "--totp", help="Time-based one time password", default=None) + group.add_argument("-U", "--usercert", help="User certificate file", default=None) group = parser.add_argument_group("SSL parameters") - group.add_argument("-C", "--cabundle", help="CA bundle file", default=None) - group.add_argument("--sslverifypeer", help="Strict SSL CA check", default=True, action="store_true") - group.add_argument("-k", "--no-sslverifypeer", help="Disable strict SSL CA check", default=True, action="store_false", dest="sslverifypeer") - group.add_argument("--sslverifyhost", help="Strict SSL host name check", default=True, action="store_true") - group.add_argument("-K", "--no-sslverifyhost", help="Disable strict SSL host name check", default=True, action="store_false", dest="sslverifyhost") + group.add_argument("-C", "--cabundle", help="CA bundle file", default=None) + group.add_argument("--sslverifypeer", help="Strict SSL CA check", default=True, action="store_true") + group.add_argument( + "-k", + "--no-sslverifypeer", + help="Disable strict SSL CA check", + action="store_false", + dest="sslverifypeer", + ) + group.add_argument( + "--sslverifyhost", help="Strict SSL host name check", default=True, action="store_true" + ) + group.add_argument( + "-K", + "--no-sslverifyhost", + help="Disable strict SSL host name check", + action="store_false", + dest="sslverifyhost", + ) group = parser.add_argument_group("Protocol parameters") - group.add_argument("-c", "--credentials", help="Privilege list", default=None) - group.add_argument("-s", "--script", help="Command script", default=None) - group.add_argument("-o", "--outputformat", help="Output format (ini|xml)", default="ini") + group.add_argument("-c", "--credentials", help="Privilege list", default=None) + group.add_argument("-s", "--script", help="Command script", default=None) + group.add_argument( + "-o", "--outputformat", help="Output format", default="ini", choices=["ini", "xml"] + ) parser.add_argument("--version", help="Library version", default=False, action="store_true") group = parser.add_argument_group("Logging parameters") - exclusive = group.add_mutually_exclusive_group() - exclusive.add_argument("-v", "--verbose", help="Increase logging output", default=False, action="store_true") - exclusive.add_argument("-q", "--quiet", help="Decrease logging output", default=False, action="store_true") - group.add_argument("--loglvl", help="Set explicit log level", default=None, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']) - group.add_argument("--logfile", help='Output log messages to file', default=None) - - args = parser.parse_args() - - host = args.host - ip = args.ip - usercert = args.usercert - cabundle = args.cabundle - password = args.password - totp = args.totp - port = args.port - proxy = args.proxy - timeout = args.timeout - user = args.user - sslverifypeer = args.sslverifypeer - sslverifyhost = args.sslverifyhost - credentials = args.credentials - script = args.script - outputformat = args.outputformat - version = args.version - - # logging + exclusive = group.add_mutually_exclusive_group() + exclusive.add_argument( + "-v", "--verbose", help="Increase logging output", default=False, action="store_true" + ) + exclusive.add_argument( + "-q", "--quiet", help="Decrease logging output", default=False, action="store_true" + ) + group.add_argument( + "--loglvl", + help="Set explicit log level", + default=None, + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ) + group.add_argument("--logfile", help="Output log messages to file", default=None) + + return parser + + +def setup_logging(args: argparse.Namespace) -> SNSLogger: + """Configure the root logger and the custom OUTPUT/COMMAND levels.""" level = logging.INFO if args.loglvl is not None: @@ -144,171 +218,120 @@ def main(): elif args.quiet: level = logging.WARNING - # add custom level logging.addLevelName(OUTPUT_LEVELV_NUM, "OUTPUT") logging.addLevelName(COMMAND_LEVELV_NUM, "COMMAND") - def logoutput(self, message, *args, **kwargs): - # Yes, logger takes its '*args' as 'args'. - self._log(OUTPUT_LEVELV_NUM, message, args, **kwargs) - def logcommand(self, message, *args, **kwargs): - # Yes, logger takes its '*args' as 'args'. - self._log(COMMAND_LEVELV_NUM, message, args, **kwargs) + root = logging.getLogger() + for handler in list(root.handlers): + root.removeHandler(handler) + root.setLevel(level) - logging.Logger.output = logoutput - logging.Logger.command = logcommand - - # logger - logger = logging.getLogger() - for handler in logger.handlers: - logger.removeHandler(handler) - logger.setLevel(level) - - # attach handlers handler = logging.StreamHandler(sys.stdout) handler.addFilter(CommandFilter()) - logger.addHandler(handler) + handler.setFormatter(_build_formatter()) + root.addHandler(handler) + if args.logfile is not None: - if platform.system() != 'Windows': - handler = logging.handlers.WatchedFileHandler(args.logfile) + filehandler: logging.Handler + if platform.system() != "Windows": + filehandler = logging.handlers.WatchedFileHandler(args.logfile) else: - handler = logging.FileHandler(args.logfile) - logger.addHandler(handler) + filehandler = logging.FileHandler(args.logfile) + root.addHandler(filehandler) - for handler in logger.handlers: - if handler.__class__ == logging.StreamHandler: - handler.setFormatter(FORMATTER) + # the CLI's own logger; its records propagate to the root handlers above + previous_class = logging.getLoggerClass() + logging.setLoggerClass(SNSLogger) + logger = logging.getLogger("snscli") + logging.setLoggerClass(previous_class) - if version: - logging.info("snscli - stormshield.sns.sslclient version {}".format(libversion)) - logging.info(" urllib3 {}".format(urllib3version)) - logging.info(" requests {}".format(requestsversion)) - sys.exit(0) + return cast(SNSLogger, logger) - if script is not None: - try: - script = open(script, 'r') - except Exception as exception: - logging.error("Can't open script file - %s", str(exception)) - sys.exit(1) - - if outputformat not in ['ini', 'xml']: - logging.error("Unknown output format") - sys.exit(1) - if host is None: - logging.error("No host provided") - sys.exit(1) +def print_response(logger: SNSLogger, response, outputformat: str) -> None: + if outputformat == "xml": + logger.output(_highlight_xml(response.xml)) + else: + logger.output(response.output) - if password is None and usercert is None: - password = getpass.getpass() - if timeout == -1: - timeout = None +def run_script(logger: SNSLogger, client: SSLClient, path: str, outputformat: str) -> int: + """Run a command script, returning the process exit code.""" - # first try without totp, if needed ask for totp - for i in range(0, 2): + try: + with open(path) as script: + commands = script.read().splitlines() + except OSError as exception: + logging.error("Can't open script file - %s", exception) + return 1 + + for cmd in commands: + logger.output(cmd) + if cmd.startswith("#") or EMPTY_RE.match(cmd): + continue try: - client = SSLClient( - host=host, ip=ip, port=port, user=user, password=password, totp=totp, - sslverifypeer=sslverifypeer, sslverifyhost=sslverifyhost, - credentials=credentials, proxy=proxy, timeout=timeout, - usercert=usercert, cabundle=cabundle, autoconnect=False) + response = client.send_command(cmd) except Exception as exception: logging.error(str(exception)) - sys.exit(1) + return 1 + print_response(logger, response, outputformat) - try: - client.connect() - except TOTPNeededError as exception: - if i == 0 and totp is None: - logging.warning("Second factor authentication is required.") - totp = getpass.getpass("Totp:") - continue - else: - logging.error(str(exception)) - sys.exit(1) - except Exception as exception: - search = re.search(r'doesn\'t match \'(.*)\'', str(exception)) - if search: - logging.error(("Appliance name can't be verified, to force connection " - "use \"--host %s --ip %s\" or \"--no-sslverifyhost|-K\" " - "options"), search.group(1), ip if ip is not None else host) - else: - logging.error(str(exception)) - sys.exit(1) - else: - break + return 0 - # disconnect gracefuly at exit - atexit.register(client.disconnect) - if script is not None: - for cmd in script.readlines(): - cmd = cmd.strip('\r\n') - logger.output(cmd) - if cmd.startswith('#'): - continue - if EMPTY_RE.match(cmd): - continue - try: - response = client.send_command(cmd) - except Exception as exception: - logging.error(str(exception)) - sys.exit(1) - if outputformat == 'xml': - logger.output(highlight(defusedxml.minidom.parseString(response.xml).toprettyxml(), - XmlLexer(), TerminalFormatter())) - else: - logger.output(response.output) - sys.exit(0) +def setup_readline() -> None: + """Enable history and completion when readline is available.""" - # Start cli + if readline is None: + return - # load history histfile = os.path.join(os.path.expanduser("~"), ".sslclient_history") try: readline.read_history_file(histfile) readline.set_history_length(1000) - except FileNotFoundError: + except (FileNotFoundError, OSError): pass - def save_history(histfile): + def save_history(): try: readline.write_history_file(histfile) - except: + except OSError: logging.warning("Can't write history") - atexit.register(save_history, histfile) + atexit.register(save_history) - # load auto-complete - readline.parse_and_bind('tab: complete') - readline.set_completer_delims('') + readline.parse_and_bind("tab: complete") + readline.set_completer_delims("") readline.set_completer(make_completer()) + +def interactive(logger: SNSLogger, client: SSLClient, outputformat: str) -> int: + """Run the interactive prompt, returning the process exit code.""" + + setup_readline() + while True: try: cmd = input("> ") - logger.command(cmd) except EOFError: - break + return 0 + logger.command(cmd) # skip comments - if cmd.startswith('#'): + if cmd.startswith("#"): continue try: response = client.send_command(cmd) except ServerError as exception: # do not log error on QUIT - if "quit".startswith(cmd.lower()) \ - and str(exception) == "Server disconnected": - sys.exit(0) + if "quit".startswith(cmd.lower()) and str(exception) == "Server disconnected": + return 0 logging.error(str(exception)) - sys.exit(1) + return 1 except Exception as exception: logging.error(str(exception)) - sys.exit(1) + return 1 if response.ret == client.SRV_RET_DOWNLOAD: filename = input("File to save: ") @@ -325,18 +348,102 @@ def save_history(histfile): except Exception as exception: logging.error(str(exception)) else: - if outputformat == 'xml': - logger.output(highlight(defusedxml.minidom.parseString(response.xml).toprettyxml(), - XmlLexer(), TerminalFormatter())) + print_response(logger, response, outputformat) + + +def connect(args: argparse.Namespace, password: str | None) -> SSLClient: + """Connect to the appliance, prompting for a TOTP if the appliance asks for one.""" + + # 0 and the 1.x `-1` sentinel both mean "wait forever"; urllib3 rejects any + # non-positive timeout, so they must not be forwarded as is + timeout = args.timeout if args.timeout > 0 else None + totp = args.totp + + # first try without totp, if needed ask for totp + for attempt in range(2): + try: + client = SSLClient( + host=args.host, + ip=args.ip, + port=args.port, + user=args.user, + password=password, + totp=totp, + sslverifypeer=args.sslverifypeer, + sslverifyhost=args.sslverifyhost, + credentials=args.credentials, + proxy=args.proxy, + timeout=timeout, + retries=args.retries, + usercert=args.usercert, + cabundle=args.cabundle, + autoconnect=False, + ) + except Exception as exception: + logging.error(str(exception)) + raise SystemExit(1) from exception + + try: + client.connect() + except TOTPNeededError as exception: + if attempt == 0 and totp is None: + logging.warning("Second factor authentication is required.") + totp = getpass.getpass("Totp:") + continue + logging.error(str(exception)) + raise SystemExit(1) from exception + except Exception as exception: + search = CN_MISMATCH_RE.search(str(exception)) + if search: + logging.error( + ( + "Appliance name can't be verified, to force connection " + 'use "--host %s --ip %s" or "--no-sslverifyhost|-K" options' + ), + search.group(1), + args.ip if args.ip is not None else args.host, + ) else: - logger.output(response.output) + logging.error(str(exception)) + raise SystemExit(1) from exception + else: + return client + + raise SystemExit(1) # pragma: no cover + + +def main() -> int: + args = build_parser().parse_args() + logger = setup_logging(args) + + if args.version: + from requests import __version__ as requestsversion + from urllib3 import __version__ as urllib3version + + logging.info("snscli - stormshield.sns.sslclient version %s", libversion) + logging.info(" urllib3 %s", urllib3version) + logging.info(" requests %s", requestsversion) + return 0 + + if args.host is None: + logging.error("No host provided") + return 1 + + password = args.password or os.environ.get(PASSWORD_ENV) + if password is None and args.usercert is None: + password = getpass.getpass() + + client = connect(args, password) + + # disconnect gracefully at exit + atexit.register(client.disconnect) + + if args.script is not None: + return run_script(logger, client, args.script, args.outputformat) + + return interactive(logger, client, args.outputformat) -# use correct input function with python2 -try: - input = raw_input -except NameError: - pass if __name__ == "__main__": # execute only if run as a script - main() + sys.exit(main()) diff --git a/stormshield/sns/configparser.py b/stormshield/sns/configparser.py index bd429b5..7ff1d42 100644 --- a/stormshield/sns/configparser.py +++ b/stormshield/sns/configparser.py @@ -1,78 +1,83 @@ -#!/usr/bin/python - """ stormshield.sns.configparser -This module handles SNS API responses and extract section/token/values +This module handles SNS API responses and extracts section/token/values in ini/section format. """ -import sys +from __future__ import annotations + +import logging import re -from shlex import shlex +from typing import Any + from requests.structures import CaseInsensitiveDict -import logging logger = logging.getLogger(__name__) -def unquote(value): - """ remove quotes if needed """ +__all__ = ["ConfigParser", "serialize", "unquote"] + + +def unquote(value: Any) -> Any: + """Remove the surrounding double quotes of ``value`` if present.""" + if isinstance(value, str) and len(value) > 1 and value[0] == '"' and value[-1] == '"': return value[1:-1] return value -def serialize(data): - if type(data) is CaseInsensitiveDict: - res = {} - for (k, v) in data.items(): - res[k] = serialize(v) - return res - elif type(data) is list: - res = [] - for v in data: - res.append(serialize(v)) - return res - else: - return data + +def serialize(data: Any) -> Any: + """Recursively convert :class:`CaseInsensitiveDict` into plain dicts.""" + + if isinstance(data, CaseInsensitiveDict): + return {k: serialize(v) for k, v in data.items()} + if isinstance(data, list): + return [serialize(v) for v in data] + return data class ConfigParser: - """ A class to parse section format from SNS API responses """ + """A class to parse section format from SNS API responses.""" SERVERD_HEAD_RE = re.compile(r'^\d{3} code=.* msg=.* format="(.*?)"') - SERVERD_TAIL_RE = re.compile(r'^\d{3} code=.*? msg=.*?') - SECTION_RE = re.compile(r'^\s*\[\s*(.+?)\s*\]\s*$') - EMPTY_RE = re.compile(r'^\s*$') - TOKEN_VALUE_RE = re.compile(r'^(.*?)=(.*)$') + SERVERD_TAIL_RE = re.compile(r"^\d{3} code=.*? msg=.*?") + SECTION_RE = re.compile(r"^\s*\[\s*(.+?)\s*\]\s*$") + EMPTY_RE = re.compile(r"^\s*$") + + #: ``token=value`` pairs, where a value is either double quoted or + #: runs up to the next whitespace. Replaces the per-line :mod:`shlex` + #: lexer, which needed a hand-maintained ``wordchars`` allow-list. + _PAIR_RE = re.compile(r'(?:^|\s)(?P[^\s="]+)=(?P"[^"]*"|\S*)') - def __init__(self, text): - """ load a section from text """ + def __init__(self, text: str | None) -> None: + """Load a section from text.""" - self.data = CaseInsensitiveDict() - self.format = None + self.data: Any = CaseInsensitiveDict() + self.format: str | None = None - lines = text.splitlines() + lines = (text or "").splitlines() + if not lines: + return # strip serverd headers if needed match = self.SERVERD_HEAD_RE.match(lines[0]) if match: del lines[0] self.format = match.group(1) - if self.SERVERD_TAIL_RE.match(lines[-1]): + if lines and self.SERVERD_TAIL_RE.match(lines[-1]): del lines[-1] text = "\n".join(lines) - if self.format == 'raw' or self.format == 'xml': + if self.format in ("raw", "xml"): # plain data, no parsing self.data = text return - section = "Result" # default section - for line in text.splitlines(): - + section = "Result" # default section + for line in lines: # comment - if line.startswith('#'): + if line.startswith("#"): continue # empty lines @@ -83,72 +88,86 @@ def __init__(self, text): match = self.SECTION_RE.match(line) if match: section = match.group(1) - if self.format == 'section': - self.data[section] = CaseInsensitiveDict() - else: + # anything but list/section_line is parsed as token=value below, + # so the section must be a dict there too + if self.format in ("list", "section_line"): self.data[section] = [] + else: + self.data[section] = CaseInsensitiveDict() continue if self.format == "list": - self.data[section].append(line) + self.data.setdefault(section, []).append(line) elif self.format == "section_line": - # fix encoding for python2 - if sys.version_info[0] < 3: - line = line.encode('utf-8') - # parse token=value token2=value2 - lexer = shlex(line, posix=True) - lexer.wordchars += "=.-*:,/@'()" - lexer.quotes = '"' - parsed = {} - try: - for word in lexer: - # ignore anything else than token=value - if '=' in word: - token, value = word.split("=", 1) - parsed[token] = value - except Exception as e: - logger.warning(f"Can't parse line: `{line}`, error: {str(e)}") - self.data[section].append(parsed) + self.data.setdefault(section, []).append(self._parse_pairs(line)) else: # section - (token, value) = line.split("=", 1) + token, sep, value = line.partition("=") + if not sep: + logger.warning("Can't parse line: `%s`, error: no '=' separator", line) + continue + if section not in self.data: + self.data[section] = CaseInsensitiveDict() self.data[section][token] = unquote(value) - - def get(self, section, token=None, line=None, default=None): - """ get the value of a token or a plain line from the current section """ + @classmethod + def from_data(cls, fmt: str | None, data: Any) -> ConfigParser: + """Build a parser around already decoded data, skipping any text parsing. + + Used by :class:`~stormshield.sns.sslclient.Response`, which decodes the + API answer straight from its XML tree. + """ + + parser = cls.__new__(cls) + parser.format = fmt + parser.data = data + return parser + + @classmethod + def _parse_pairs(cls, line: str) -> dict[str, str]: + """Parse a ``token=value token2="value 2"`` line into a dict.""" + + # An odd number of quotes means the appliance sent a truncated line: + # keep the pairs that end before the unmatched quote rather than + # half-parsing the last value or dropping the whole row. + limit = len(line) + if line.count('"') % 2: + logger.warning("Can't parse line: `%s`, error: unbalanced quotes", line) + limit = line.rfind('"') + + return { + m.group("token"): unquote(m.group("value")) + for m in cls._PAIR_RE.finditer(line) + if m.end() <= limit + } + + def get( + self, + section: str, + token: str | None = None, + line: int | None = None, + default: Any = None, + ) -> Any: + """Get the value of a token or a plain line from the given section.""" if section not in self.data: - value = default + return default - elif token is not None: + if token is not None: # token/value mode - if token not in self.data[section]: - value = default - else: - value = unquote(self.data[section][token]) - elif line is None: - # return all tokens/lines form section - if self.format == "section": - value = self.data[section] - elif section not in self.data: - value = [] - else: - value = self.data[section] - else: - if line < 1: - value = default - elif section not in self.data: - value = default - elif len(self.data[section]) < line: - value = default - else: - value = self.data[section][line-1] + return default + return unquote(self.data[section][token]) + + if line is None: + # return all tokens/lines from the section + return self.data[section] - return value + if line < 1 or len(self.data[section]) < line: + return default + return self.data[section][line - 1] - def serialize_data(self): - """ return serializable output parsed data """ + def serialize_data(self) -> Any: + """Return the parsed data as plain serializable structures.""" return serialize(self.data) diff --git a/stormshield/sns/crc.py b/stormshield/sns/crc.py index 3173f67..6ead0f9 100644 --- a/stormshield/sns/crc.py +++ b/stormshield/sns/crc.py @@ -1,109 +1,43 @@ -#!/usr/bin/python - """ stormshield.sns.crc -This module implements SNS crc32 functions. +SNS CRC32 helpers. + +The appliance announces the CRC of a download as the *non-finalised* CRC-32 +value: the standard IEEE 802.3 algorithm without the final one's complement. +That is exactly ``zlib.crc32(data) ^ 0xFFFFFFFF``, so the C implementation of +zlib is used instead of a table lookup written in Python. """ +from __future__ import annotations + +import zlib + +__all__ = ["CRC32_init", "compute_crc32", "update_crc32"] + +#: Seed value of an empty SNS CRC. +CRC32_init = 0xFFFFFFFF + +_FINAL_XOR = 0xFFFFFFFF + + +def _tobytes(data: bytes | str) -> bytes: + """Encode ``data`` as UTF-8 when it is a ``str``.""" + + return data.encode("utf-8") if isinstance(data, str) else data + + +def compute_crc32(data: bytes | str) -> int: + """Return the SNS CRC32 value of ``data``, a ``str`` being read as UTF-8.""" + + return zlib.crc32(_tobytes(data)) ^ _FINAL_XOR + + +def update_crc32(data: bytes | str, crc: int) -> int: + """Return ``crc`` updated with ``data``, for incremental hashing. + ``crc`` must be a value previously returned by :func:`compute_crc32`, + :func:`update_crc32`, or :data:`CRC32_init` to start a new computation. + """ -CRC32_init = 0xffffffff - -CRCTAB = [ - 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, - 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, - 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, - 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, - 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, - 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, - 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, - 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, - 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, - 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, - 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, - 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, - 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, - 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, - 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, - 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, - 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, - 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, - 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, - 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, - 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, - 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, - 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, - 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, - 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, - 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, - 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, - 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, - 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, - 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, - 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, - 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, - 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, - 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, - 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, - 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, - 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, - 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, - 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, - 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, - 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, - 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, - 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, - 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, - 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, - 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, - 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, - 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, - 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, - 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, - 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, - 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, - 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, - 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, - 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, - 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, - 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, - 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, - 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, - 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, - 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, - 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, - 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, - 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d, -] - - - -def compute_crc32(data): - """ Returns the hex string of CRC value of DATA """ - - # python2 convert str to bytes - if type(data) == str: - data = bytearray(data) - - n = len(data) - crc = CRC32_init - - for i in range(0, n): - crc = (crc >> 8) ^ CRCTAB[(crc ^ data[i]) & 0xff] - return crc - - -def update_crc32(data, crc): - """ Incremental CRC, returns updated CRC. """ - - # python2 convert str to bytes - if type(data) == str: - data = bytearray(data) - - n = len(data) - - for i in range(0, n): - crc = (crc >> 8) ^ CRCTAB[(crc ^ data[i]) & 0xff] - - return crc + return zlib.crc32(_tobytes(data), crc ^ _FINAL_XOR) ^ _FINAL_XOR diff --git a/stormshield/sns/py.typed b/stormshield/sns/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/stormshield/sns/sslclient/__init__.py b/stormshield/sns/sslclient/__init__.py index 9cab307..aeb094a 100644 --- a/stormshield/sns/sslclient/__init__.py +++ b/stormshield/sns/sslclient/__init__.py @@ -1,707 +1,46 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - """ stormshield.sns.sslclient ~~~~~~~~~~~~~~~~~~~~~~~~~ -This module contains SSLClient class to handle SNS API calls -and Response class to handle API answers. -""" +SSL API client for Stormshield Network Security appliances. -from __future__ import unicode_literals -import os -import ipaddress -import base64 -import logging -import re -import platform -import defusedxml.ElementTree as ElementTree -from xml.etree import ElementTree as Et -import ssl -import requests -from requests.adapters import HTTPAdapter, DEFAULT_POOLSIZE, DEFAULT_RETRIES, DEFAULT_POOLBLOCK -from urllib3.poolmanager import PoolManager, proxy_from_url -from requests.utils import get_auth_from_url -from requests.exceptions import InvalidSchema -import requests.compat -from requests_toolbelt.multipart.encoder import MultipartEncoder -import urllib3 -try: - from urllib3.contrib.socks import SOCKSProxyManager -except ImportError: - def SOCKSProxyManager(*args, **kwargs): - raise InvalidSchema("Missing dependencies for SOCKS support.") + >>> from stormshield.sns.sslclient import SSLClient + >>> with SSLClient(host="10.0.0.254", user="admin", password="pass") as client: + ... response = client.send_command("SYSTEM PROPERTY") + ... print(response.data["Result"]["Version"]) +""" -from stormshield.sns.configparser import ConfigParser -import stormshield.sns.crc as snscrc -from packaging import version +from __future__ import annotations from .__version__ import __version__ - -URLLIB3V2 = version.parse(urllib3.__version__) >= version.parse('2.0.0') - -#disable ssl warnings, we have --sslverify* for that -requests.packages.urllib3.disable_warnings( - requests.packages.urllib3.exceptions.InsecureRequestWarning) -try: - requests.packages.urllib3.disable_warnings( - requests.packages.urllib3.exceptions.SubjectAltNameWarning) -except AttributeError: - # urllib3 v2 doesn't have the exception anymore - pass -#disable http warning 'Received response with both Content-Length and Transfer-Encoding set' -logging.getLogger(requests.packages.urllib3.__name__).setLevel(logging.ERROR) - -class HostNameAdapter(HTTPAdapter): - """ HTTP adapter to disable strict ssl host name verification or check hostname against common name """ - - def __init__(self, host=None): - self.host = host - super().__init__() - - def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): - - if URLLIB3V2: - context = ssl.create_default_context() - context.hostname_checks_common_name = True # use CN field for factory Stormshield certificates - context.check_hostname = False # check is done with assert_hostname - - self.poolmanager = PoolManager(num_pools=connections, - maxsize=maxsize, - block=block, - assert_hostname=self.host, - ssl_context=context, - **pool_kwargs) - else: - self.poolmanager = PoolManager(num_pools=connections, - maxsize=maxsize, - block=block, - assert_hostname=False, - **pool_kwargs) - - def proxy_manager_for(self, proxy, **proxy_kwargs): - if proxy in self.proxy_manager: - manager = self.proxy_manager[proxy] - elif proxy.lower().startswith('socks'): - username, password = get_auth_from_url(proxy) - - if URLLIB3V2: - context = ssl.create_default_context() - context.hostname_checks_common_name = True - context.check_hostname = False - - manager = self.proxy_manager[proxy] = SOCKSProxyManager( - proxy, - username=username, - password=password, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - assert_hostname=self.host, - ssl_context=context, - **proxy_kwargs - ) - else: - manager = self.proxy_manager[proxy] = SOCKSProxyManager( - proxy, - username=username, - password=password, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - assert_hostname=False, - **proxy_kwargs - ) - else: - proxy_headers = self.proxy_headers(proxy) - - if URLLIB3V2: - context = ssl.create_default_context() - context.hostname_checks_common_name = True - context.check_hostname = False - - manager = self.proxy_manager[proxy] = proxy_from_url( - proxy, - proxy_headers=proxy_headers, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - assert_hostname=self.host, - ssl_context=context, - **proxy_kwargs) - else: - manager = self.proxy_manager[proxy] = proxy_from_url( - proxy, - proxy_headers=proxy_headers, - num_pools=self._pool_connections, - maxsize=self._pool_maxsize, - block=self._pool_block, - assert_hostname=False, - **proxy_kwargs) - - return manager - -class DNSResolverHTTPSAdapter(HTTPAdapter): - """ HTTP adapter to check peer common_name with provided host name """ - - def __init__(self, common_name, host, pool_connections=DEFAULT_POOLSIZE, - pool_maxsize=DEFAULT_POOLSIZE, max_retries=DEFAULT_RETRIES, - pool_block=DEFAULT_POOLBLOCK): - self.__common_name = common_name - self.__host = host - - self.__is_stormshield_cert = True - if re.search(r"\.", self.__common_name): - self.__is_stormshield_cert = False - - super(DNSResolverHTTPSAdapter, self).__init__(pool_connections=pool_connections, - pool_maxsize=pool_maxsize, - max_retries=max_retries, - pool_block=pool_block) - - def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK, **pool_kwargs): - pool_kwargs['assert_hostname'] = self.__common_name - if URLLIB3V2 and self.__is_stormshield_cert: - context = ssl.create_default_context() - context.hostname_checks_common_name = True # use CN field for factory Stormshield certificates - context.check_hostname = False # check is done with assert_hostname - pool_kwargs["ssl_context"] = context - super(DNSResolverHTTPSAdapter, self).init_poolmanager(connections, - maxsize, - block=block, - **pool_kwargs) - -class Response(): - """ :class:`Response ` object contains the SNS API response to a request """ - - def __init__(self, code=None, ret=0, msg=None, output=None, xml=None): - self.code = code - self.ret = ret - self.msg = msg - self.output = output - self.xml = xml - - self.parser = ConfigParser(output) - self.data = self.parser.data - self.format = self.parser.format - - def __repr__(self): - return self.output - - def __bool__(self): - """ Returns True if :attr:`ret` is OK or WARNING. """ - return self.ret >= 100 and self.ret < 200 - -def quote(value): - """ Quote value if needed """ - try: - if value and (type(value) == str or type(value) == unicode) and ' ' in value: - return '"' + value + '"' - except: - # in python3 unicode class doesn't exists - pass - return value - -def format_output(output): - """ Format command output in ini/section or text format""" - nws_node = ElementTree.fromstring(output) - serverd_node = nws_node[0] - ini = '{} code={} msg="{}"'.format(serverd_node.get('ret'), - serverd_node.get('code'), - serverd_node.get('msg')) - if len(list(nws_node)) > 1: - data_node = serverd_node[0] - node_format = data_node.get('format') - ini += ' format="{}"\n'.format(node_format) - if node_format == 'raw': - if data_node.text: - ini += data_node.text - elif node_format == 'section': - for section_node in data_node: - ini += '[{}]\n'.format(section_node.get('title')) - for key_node in section_node: - ini += '{}={}\n'.format(key_node.get('name'), - quote(key_node.get('value'))) - elif node_format == 'section_line': - for section_node in data_node: - ini += '[{}]\n'.format(section_node.get('title')) - for line_node in section_node: - tokens = [] - for key_node in line_node: - tokens.append('{}={}'.format(key_node.get('name'), - quote(key_node.get('value')))) - ini += " ".join(tokens) + "\n" - elif node_format == 'list': - for section_node in data_node: - ini += '[{}]\n'.format(section_node.get('title')) - for line_node in section_node: - ini += "{}\n".format(line_node.text) - elif node_format == 'xml': - # display xml data node - ini += Et.tostring(data_node).decode() + "\n" - serverd_node = nws_node[1] - ini += '{} code={} msg="{}"'.format(serverd_node.get('ret'), - serverd_node.get('code'), - serverd_node.get('msg')) - return ini - -class MissingHost(ValueError): - """ The remote host is missing """ - -class MissingAuth(ValueError): - """ password or user certificate is missing """ - -class MissingCABundle(ValueError): - """ The certificate authority bundle is missing """ - -class TOTPNeededError(Exception): - """ Time-base one time password needed """ - -class AuthenticationError(Exception): - """ authentication failed """ - -class ServerError(Exception): - """ NWS server error """ - -class FileError(Exception): - """ file access error """ - -class SSLClient: - """SSL client to SNS configuration API """ - - SSL_SERVERD_OK = 100 - SSL_SERVERD_REQUEST_ERROR = 200 - SSL_SERVERD_UNKNOWN_COMMAND = 201 - SSL_SERVERD_ERROR_COMMAND = 202 - SSL_SERVERD_INVALID_SESSION = 203 - SSL_SERVERD_EXPIRED_SESSION = 204 - SSL_SERVERD_AUTH_ERROR = 205 - SSL_SERVERD_PENDING_TRANSFER = 206 - SSL_SERVERD_PENDING_UPLOAD = 207 - SSL_SERVERD_OVERHEAT = 500 - SSL_SERVERD_UNREACHABLE = 501 - SSL_SERVERD_DISCONNECTED = 502 - SSL_SERVERD_INTERNAL_ERROR = 900 - - SSL_SERVERD_MSG = { - SSL_SERVERD_REQUEST_ERROR: "Request error", - SSL_SERVERD_UNKNOWN_COMMAND: "Unknown command", - SSL_SERVERD_ERROR_COMMAND: "Command error", - SSL_SERVERD_INVALID_SESSION: "Invalid session", - SSL_SERVERD_EXPIRED_SESSION: "Expired session", - SSL_SERVERD_AUTH_ERROR: "Authentication error", - SSL_SERVERD_PENDING_TRANSFER: "Pending transfer", - SSL_SERVERD_PENDING_UPLOAD: "Upload pending", - SSL_SERVERD_OVERHEAT: "Server overheat", - SSL_SERVERD_UNREACHABLE: "Server unreachable", - SSL_SERVERD_DISCONNECTED: "Server disconnected", - SSL_SERVERD_INTERNAL_ERROR: "Internal error" - } - - SRV_RET_OK = 100 - SRV_RET_DOWNLOAD = 101 - SRV_RET_UPLOAD = 102 - SRV_RET_LASTCMD = 103 - SRV_RET_MUSTREBOOT = 104 - SRV_RET_WARNING = 110 - SRV_RET_MULTIWARN = 111 - SRV_RET_COMMAND = 200 - SRV_RET_MULTILINE = 201 - SRV_RET_AUTHFAILED = 202 - SRV_RET_IDLE = 203 - SRV_RET_AUTHLIMIT = 204 - SRV_RET_AUTHLEVEL = 205 - SRV_RET_LICENCE = 206 - - SRV_RET_MSG = { - SRV_RET_OK: 'Command successful', - SRV_RET_DOWNLOAD: 'Command successful, download follow', - SRV_RET_UPLOAD: 'Command successful, upload follow', - SRV_RET_LASTCMD: 'Command successful, you will be disconnected', - SRV_RET_MUSTREBOOT: 'Command successful, but reboot needed', - SRV_RET_WARNING: 'Command successful, but warning', - SRV_RET_MULTIWARN: 'Command successful, but multiple warnings', - SRV_RET_COMMAND: 'Command error', - SRV_RET_MULTILINE: 'Return error message on many lines', - SRV_RET_AUTHFAILED: 'Authentication failed', - SRV_RET_IDLE: 'Client is idle, disconnecting', - SRV_RET_AUTHLIMIT: 'Maximum number of authentication user reached for that level', - SRV_RET_AUTHLEVEL: 'Not enough privilege', - SRV_RET_LICENCE: 'Licence restriction' - } - - SERVERD_WAIT_DOWNLOAD = "00a01c00" - SERVERD_WAIT_UPLOAD = "00a00300" - AUTH_SUCCESS = "AUTH_SUCCESS" - AUTH_FAILED = "AUTH_FAILED" - NEED_TOTP_AUTH = "NEED_TOTP_AUTH" - ERR_BRUTEFORCE = "ERR_BRUTEFORCE" - - fileregexp = re.compile(r'^(?P.+?)\s*[<>]\s*(?!.*\")(?P.*?)$') - - CHUNK_SIZE = 10240 # bytes - - def __init__(self, user='admin', password=None, totp=None, host=None, ip=None, port=443, cabundle=None, - sslverifypeer=True, sslverifyhost=True, credentials=None, - usercert=None, autoconnect=True, proxy=None, timeout=None): - """:class:`SSLclient ` constructor. - - :param user: Optional user name. - :param password: Optional password. - :param totp: Optional time-based one time password. - :param host: hostname to connect or certificate common name (appliance serial). - :param ip: Optional ip address to connect. - :param port: Optional port number. - :param cabundle: Optional certificat authorities bundle file in PEM format. - :param sslverifypeer: Optional boolean to verify remote certificate authority. - :param sslverifyhost: Optional boolean to verify remote certificate common name. - :param credentials: Optional list of requested privileges. - :param usercert: Optional user certificate. - :param autoconnect: Connect to the appliance at initialization - :param proxy: https proxy url (socks5://user:pass@host:port http://user:password@host/) - :param timeout: connection and read timeout in seconds - """ - - self.user = user - self.password = password - self.totp = totp - self.host = host - self.ip = ip - self.port = port - self.cabundle = cabundle - self.app = 'sslclient' - self.sslverifypeer = sslverifypeer - self.sslverifyhost = sslverifyhost - self.credentials = credentials - self.usercert = usercert - self.sessionid = "" - self.protocol = "" - self.sessionlevel = "" - self.dl_size = 0 - self.dl_crc = "" - self.autoconnect = autoconnect - self.proxy = proxy - self.conn_options = {} - - if host is None: - raise MissingHost("Host parameter must be provided") - if password is None and usercert is None: - raise MissingAuth("Password parameter must be provided") - if password is None and totp is not None: - raise MissingAuth("Password parameter must be provided when totp parameter is provided") - if usercert is not None and not os.path.isfile(usercert): - raise MissingAuth("User certificate not found") - if cabundle is None: - # use default cabundle - self.cabundle = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", 'bundle.ca')) - if not os.path.isfile(self.cabundle): - raise MissingCABundle("Certificate authority bundle not found") - - #test ipv6 address - try: - ipaddress.IPv6Address(self.host) - urlhost = "[{}]".format(self.host) - except ipaddress.AddressValueError: - urlhost = self.host - - self.baseurl = 'https://' + urlhost + ':' + str(self.port) - - self.headers = { - 'Accept-Encoding': 'identity', - 'user-agent': 'stormshield.sns.sslclient/{} ({})'.format( - __version__, platform.platform()) - } - - self.session = requests.Session() - if self.sslverifypeer: - self.session.verify = self.cabundle - else: - self.session.verify = False - - if not self.sslverifyhost: - if URLLIB3V2: - self.session.mount(self.baseurl, HostNameAdapter(False)) - else: - self.session.mount(self.baseurl, HostNameAdapter()) - - if self.ip is not None: - #test ipv6 address - try: - ipaddress.IPv6Address(self.ip) - urlip = "[{}]".format(self.ip) - except ipaddress.AddressValueError: - urlip = self.ip - self.baseurl = 'https://' + urlip + ':' + str(self.port) - self.session.mount(self.baseurl.lower(), DNSResolverHTTPSAdapter(self.host, self.ip)) - - if self.usercert is not None: - self.session.cert = self.usercert - - if self.proxy: - self.session.proxies = { "https": self.proxy} - - if timeout is not None: - self.conn_options = { "timeout": timeout } - - self.logger = logging.getLogger() - - if self.autoconnect: - self.connect() - - @staticmethod - def get_completer(): - """ Get the path to the installed cmd.complete file """ - return os.path.normpath(os.path.join(os.path.dirname(__file__), "..", 'cmd.complete')) - - def connect(self): - """ Connect to the server """ - - self.logger.log(logging.INFO, 'Connecting to %s on port %d with user %s%s', - self.host, self.port, self.user, " (proxy {})".format(self.proxy) if self.proxy else "") - - # 1. Authentication and get cookie - if self.usercert is not None: - # user cert authentication - self.logger.log(logging.DEBUG, "Authentication with SSL certificate") - request = self.session.get( - self.baseurl + '/auth/admin.html?sslcert=1&app={}'.format(self.app), - headers=self.headers, **self.conn_options) - else: - # password authentication - self.logger.log(logging.DEBUG, "Authentication with user/password") - data = { 'uid':base64.b64encode(self.user.encode('utf-8')), - 'pswd':base64.b64encode(self.password.encode('utf-8')), - 'app':self.app } - - if self.totp is not None: - data['totp']=base64.b64encode(self.totp.encode('utf-8')) - - request = self.session.post( - self.baseurl + '/auth/admin.html', - data, - headers=self.headers, - **self.conn_options) - - self.logger.log(logging.DEBUG, request.text) - - try: - nws_node = ElementTree.fromstring(request.content) - msg = nws_node.attrib['msg'] - except (ElementTree.ParseError, KeyError): - raise ServerError("Can't decode authentication result") - - if msg == self.ERR_BRUTEFORCE: - nws_node = ElementTree.fromstring(request.content) - delay = nws_node.attrib['delay'] - raise AuthenticationError("Brut force detected, try again after " + delay + " seconds.") - if msg == self.NEED_TOTP_AUTH: - raise TOTPNeededError("TOTP is needed") - if msg != self.AUTH_SUCCESS: - raise AuthenticationError("Authentication failed") - - # 2. Serverd session - data = {'app': self.app, 'id': 0} - if self.credentials is not None: - data['reqlevel'] = self.credentials - request = self.session.post( - self.baseurl + '/api/auth/login', - data=data, - headers=self.headers, - **self.conn_options) - - self.logger.log(logging.DEBUG, request.text) - - if request.status_code == requests.codes.OK: - nws_node = ElementTree.fromstring(request.content) - ret = int(nws_node.attrib['code']) - msg = nws_node.attrib['msg'] - - if ret != self.SSL_SERVERD_OK: - raise ServerError("ERROR: {} {}".format(ret, msg)) - - self.sessionid = nws_node.find('sessionid').text - self.protocol = nws_node.find('protocol').text - self.sessionlevel = nws_node.find('sessionlevel').text - - self.logger.log(logging.DEBUG, "Session ID: %s", self.sessionid) - self.logger.log(logging.DEBUG, "Protocol: %s", self.protocol) - self.logger.log(logging.DEBUG, "Session level: %s", self.sessionlevel) - - else: - raise ServerError("can't get serverd session") - - - - def disconnect(self): - """ Disconnect from the server """ - - request = self.session.get( - self.baseurl + '/api/auth/logout?sessionid=' + self.sessionid, - headers=self.headers, **self.conn_options) - - if request.status_code == requests.codes.OK: - self.logger.log(logging.INFO, 'Disconnected from %s', self.host) - else: - self.logger.log(logging.ERROR, 'Disconnect failed') - - self.session.close() - - def nws_parse(self, code): - """ Parse server response """ - - if code == self.SSL_SERVERD_OK: - return - - if code == self.SSL_SERVERD_AUTH_ERROR: - raise AuthenticationError(self.SSL_SERVERD_MSG[code]) - elif code in self.SSL_SERVERD_MSG: - raise ServerError(self.SSL_SERVERD_MSG[code]) - else: - raise ServerError("Unknown error") - - def send_command(self, command, **conn_options): - """Execute a NSRPC command on the remote appliance. - - :param command: SNS API command. Files can be uploaded by adding '< filename' - at the end of the command. Downloads are handled with '> filename'. - :return: :class:`Response ` object - :rtype: stormshield.sns.Response - """ - - # overload connection options - for k in self.conn_options: - if k not in conn_options: - conn_options[k] = self.conn_options[k] - - filename = None - result = self.fileregexp.match(command) - if result: - command = result.group('cmd') - filename = result.group('file') - - request = self.session.get( - self.baseurl + '/api/command?sessionid=' + self.sessionid + - '&cmd=' + requests.compat.quote(command.encode('utf-8')), # manually done since we need %20 encoding - headers=self.headers, **conn_options) - - self.logger.log(logging.DEBUG, request.text) - - if request.status_code == requests.codes.OK: - nws_node = ElementTree.fromstring(request.content) - code = int(nws_node.attrib['code']) - self.nws_parse(code) - serverd = nws_node[0] - - if serverd is not None: - serverd_code = serverd.attrib['code'] - serverd_ret = int(serverd.attrib['ret']) - serverd_msg = serverd.attrib['msg'] - - response = Response(ret=serverd_ret, - code=serverd_code, - msg=serverd_msg, - output=format_output(request.content), - xml=request.text) - - #multiline answer get the final code - if len(list(nws_node)) > 1: - response.code = nws_node[1].get('code') - response.msg = nws_node[1].get('msg') - response.ret = int(nws_node[1].get('ret')) - - if serverd_code == self.SERVERD_WAIT_UPLOAD: - if filename: - return self.upload(filename) - return response - - if serverd_code == self.SERVERD_WAIT_DOWNLOAD: - data = serverd.find('data') - # keep size and crc for further verification - if data.get('format') == 'section': - #
- key = data.find('section').find('key') - values = key.get('value').split(',') - self.dl_size = int(values[2].split('=')[1]) - self.dl_crc = values[1].split('=')[1] - else: - # 439B8525096 - self.dl_size = int(data.find('size').text) - self.dl_crc = data.find('crc').text - if filename: - return self.download(filename) - return response - else: - raise ServerError("HTTP error {}".format(request.status_code)) - - return response - - def download(self, filename): - """ handle file download """ - - request = self.session.get( - self.baseurl + '/api/download/tmp.file?sessionid=' + self.sessionid, - headers=self.headers, - stream=True, - **self.conn_options) - - if request.status_code == requests.codes.OK: - size = 0 - crc = snscrc.CRC32_init - try: - with open(filename, "wb") as savefile: - for chunk in request.iter_content(self.CHUNK_SIZE): - savefile.write(chunk) - size += len(chunk) - crc = snscrc.update_crc32(chunk, crc) - except Exception as exception: - self.logger.log(logging.ERROR, str(exception)) - raise FileError("Can't save file") - - if size != self.dl_size: - raise ServerError("Download error: {} bytes downloaded, expecting {} bytes".format( - size, self.dl_size)) - - crc = "%X" % (crc) - - if crc != self.dl_crc: - raise ServerError("Download error: crc {}, expecting {}".format(crc, self.dl_crc)) - - return Response(ret=100, code='00a00100', msg='OK', - output='100 code=00a00100 msg="Ok"', - xml='' + - '') - - raise ServerError("HTTP error {}".format(request.status_code)) - - def upload(self, filename): - """ handle file upload """ - - uploadh = open(filename, 'rb') - - data = MultipartEncoder( - fields={'upload': uploadh} - ) - headers = self.headers - headers['Content-Type'] = data.content_type - - request = self.session.post( - self.baseurl + '/api/upload?sessionid=' + self.sessionid, - headers=headers, - data=data, - **self.conn_options) - - uploadh.close() - - if request.status_code == requests.codes.OK: - nws_node = ElementTree.fromstring(request.content) - code = int(nws_node.attrib['code']) - self.nws_parse(code) - - return Response(code=nws_node[0].get('code'), - ret=int(nws_node[0].get('ret')), - msg=nws_node[0].get('msg'), - output=format_output(request.content), - xml=request.text) - - raise ServerError("HTTP error {}".format(request.status_code)) +from .adapters import SNSHTTPSAdapter +from .client import SSLClient +from .exceptions import ( + AuthenticationError, + FileError, + MissingAuth, + MissingCABundle, + MissingHost, + ServerError, + SNSError, + TOTPNeededError, +) +from .response import Response, format_output, quote, render_output + +__all__ = [ + "AuthenticationError", + "FileError", + "MissingAuth", + "MissingCABundle", + "MissingHost", + "Response", + "SNSError", + "SNSHTTPSAdapter", + "SSLClient", + "ServerError", + "TOTPNeededError", + "__version__", + "format_output", + "quote", + "render_output", +] diff --git a/stormshield/sns/sslclient/__version__.py b/stormshield/sns/sslclient/__version__.py index ebb06e5..14ce2fc 100644 --- a/stormshield/sns/sslclient/__version__.py +++ b/stormshield/sns/sslclient/__version__.py @@ -2,4 +2,4 @@ # major: breaking API change # minor: new functionality # patch: bugfix -__version__ = "1.1.2" +__version__ = "2.0.0" diff --git a/stormshield/sns/sslclient/adapters.py b/stormshield/sns/sslclient/adapters.py new file mode 100644 index 0000000..ab8da09 --- /dev/null +++ b/stormshield/sns/sslclient/adapters.py @@ -0,0 +1,68 @@ +""" +stormshield.sns.sslclient.adapters + +HTTP adapter used to check the peer certificate against an appliance name +instead of the URL host name. + +Appliances shipped with their factory certificate carry the serial number in +the ``CN`` field only, with no ``subjectAltName``. Python refuses to match +those by default, so hostname verification is delegated to urllib3's +``assert_hostname`` with an SSL context that opts back into CN matching. +""" + +from __future__ import annotations + +import ssl +from typing import Any + +from requests.adapters import HTTPAdapter + +__all__ = ["SNSHTTPSAdapter"] + + +def _common_name_context(cafile: str | None = None) -> ssl.SSLContext: + """SSL context matching the peer against the certificate ``CN`` field. + + ``cafile`` must be the bundle the caller asked to trust. Passing it keeps + :func:`ssl.create_default_context` from falling back to the system trust + store, which urllib3 would then combine with the bundle: the caller's CA + pinning would silently widen to every publicly trusted authority. + """ + + context = ssl.create_default_context(cafile=cafile) + context.hostname_checks_common_name = True # factory Stormshield certificates + context.check_hostname = False # done by urllib3 via assert_hostname + return context + + +class SNSHTTPSAdapter(HTTPAdapter): + """Verify the peer certificate name against ``assert_hostname``. + + :param assert_hostname: name the peer certificate must match, or ``False`` + to disable host name verification entirely. + :param cafile: the only certificate authority bundle to trust, or ``None`` + when peer verification is disabled. + """ + + def __init__(self, assert_hostname: str | bool, cafile: str | None = None, **kwargs: Any) -> None: + self._assert_hostname = assert_hostname + self._cafile = cafile + # A name without a dot is an appliance serial, i.e. a factory + # certificate that only carries it in CN. + self._needs_cn_match = assert_hostname is False or "." not in str(assert_hostname) + super().__init__(**kwargs) + + def _ssl_pool_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = {"assert_hostname": self._assert_hostname} + if self._needs_cn_match: + kwargs["ssl_context"] = _common_name_context(self._cafile) + return kwargs + + def init_poolmanager(self, connections: int, maxsize: int, block: bool = False, **pool_kwargs: Any) -> None: + pool_kwargs.update(self._ssl_pool_kwargs()) + super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs) + + def proxy_manager_for(self, proxy: str, **proxy_kwargs: Any) -> Any: + if proxy not in self.proxy_manager: + proxy_kwargs.update(self._ssl_pool_kwargs()) + return super().proxy_manager_for(proxy, **proxy_kwargs) diff --git a/stormshield/sns/sslclient/client.py b/stormshield/sns/sslclient/client.py new file mode 100644 index 0000000..7002b47 --- /dev/null +++ b/stormshield/sns/sslclient/client.py @@ -0,0 +1,600 @@ +""" +stormshield.sns.sslclient.client + +This module contains the SSLClient class handling SNS API calls. +""" + +from __future__ import annotations + +import base64 +import ipaddress +import logging +import os +import platform +import re +from contextlib import suppress +from types import TracebackType +from typing import Any, ClassVar + +import defusedxml.ElementTree as ElementTree +import requests +import requests.compat +import urllib3 +from requests.adapters import HTTPAdapter +from requests_toolbelt.multipart.encoder import MultipartEncoder +from urllib3.util.retry import Retry + +import stormshield.sns.crc as snscrc + +from .__version__ import __version__ +from .adapters import SNSHTTPSAdapter +from .exceptions import ( + AuthenticationError, + FileError, + MissingAuth, + MissingCABundle, + MissingHost, + ServerError, + TOTPNeededError, +) +from .response import Response + +__all__ = ["SSLClient"] + +logger = logging.getLogger(__name__) + +# disable ssl warnings, we have --sslverify* for that +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# disable http warning 'Received response with both Content-Length and Transfer-Encoding set' +logging.getLogger(urllib3.__name__).setLevel(logging.ERROR) + + +class SSLClient: + """SSL client to the SNS configuration API.""" + + SSL_SERVERD_OK = 100 + SSL_SERVERD_REQUEST_ERROR = 200 + SSL_SERVERD_UNKNOWN_COMMAND = 201 + SSL_SERVERD_ERROR_COMMAND = 202 + SSL_SERVERD_INVALID_SESSION = 203 + SSL_SERVERD_EXPIRED_SESSION = 204 + SSL_SERVERD_AUTH_ERROR = 205 + SSL_SERVERD_PENDING_TRANSFER = 206 + SSL_SERVERD_PENDING_UPLOAD = 207 + SSL_SERVERD_OVERHEAT = 500 + SSL_SERVERD_UNREACHABLE = 501 + SSL_SERVERD_DISCONNECTED = 502 + SSL_SERVERD_INTERNAL_ERROR = 900 + + SSL_SERVERD_MSG: ClassVar[dict[int, str]] = { + SSL_SERVERD_REQUEST_ERROR: "Request error", + SSL_SERVERD_UNKNOWN_COMMAND: "Unknown command", + SSL_SERVERD_ERROR_COMMAND: "Command error", + SSL_SERVERD_INVALID_SESSION: "Invalid session", + SSL_SERVERD_EXPIRED_SESSION: "Expired session", + SSL_SERVERD_AUTH_ERROR: "Authentication error", + SSL_SERVERD_PENDING_TRANSFER: "Pending transfer", + SSL_SERVERD_PENDING_UPLOAD: "Upload pending", + SSL_SERVERD_OVERHEAT: "Server overheat", + SSL_SERVERD_UNREACHABLE: "Server unreachable", + SSL_SERVERD_DISCONNECTED: "Server disconnected", + SSL_SERVERD_INTERNAL_ERROR: "Internal error", + } + + SRV_RET_OK = 100 + SRV_RET_DOWNLOAD = 101 + SRV_RET_UPLOAD = 102 + SRV_RET_LASTCMD = 103 + SRV_RET_MUSTREBOOT = 104 + SRV_RET_WARNING = 110 + SRV_RET_MULTIWARN = 111 + SRV_RET_COMMAND = 200 + SRV_RET_MULTILINE = 201 + SRV_RET_AUTHFAILED = 202 + SRV_RET_IDLE = 203 + SRV_RET_AUTHLIMIT = 204 + SRV_RET_AUTHLEVEL = 205 + SRV_RET_LICENCE = 206 + + SERVERD_WAIT_DOWNLOAD = "00a01c00" + SERVERD_WAIT_UPLOAD = "00a00300" + AUTH_SUCCESS = "AUTH_SUCCESS" + NEED_TOTP_AUTH = "NEED_TOTP_AUTH" + ERR_BRUTEFORCE = "ERR_BRUTEFORCE" + + fileregexp = re.compile(r'^(?P.+?)\s*[<>]\s*(?!.*\")(?P.*?)$') + + #: ``start=`` argument of the paged listing commands, used to know which + #: slice of the rows an answer covers. + startregexp = re.compile(r"(?:^|\s)start=(?P\d+)\b", re.IGNORECASE) + + CHUNK_SIZE = 65536 # bytes + + #: Default connect/read timeout, so a silent appliance cannot hang forever. + DEFAULT_TIMEOUT = 30 + + def __init__( + self, + user: str = "admin", + password: str | None = None, + totp: str | None = None, + host: str | None = None, + ip: str | None = None, + port: int = 443, + cabundle: str | None = None, + sslverifypeer: bool = True, + sslverifyhost: bool = True, + credentials: str | None = None, + usercert: str | None = None, + autoconnect: bool = True, + proxy: str | None = None, + timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT, + retries: int = 2, + ) -> None: + """:class:`SSLClient ` constructor. + + :param user: Optional user name. + :param password: Optional password. + :param totp: Optional time-based one time password. + :param host: hostname to connect or certificate common name (appliance serial). + :param ip: Optional ip address to connect. + :param port: Optional port number. + :param cabundle: Optional certificat authorities bundle file in PEM format. + :param sslverifypeer: Optional boolean to verify remote certificate authority. + :param sslverifyhost: Optional boolean to verify remote certificate common name. + :param credentials: Optional list of requested privileges. + :param usercert: Optional user certificate. + :param autoconnect: Connect to the appliance at initialization + :param proxy: https proxy url (socks5://user:pass@host:port http://user:password@host/) + :param timeout: connection and read timeout in seconds, ``None`` to wait forever + :param retries: number of retries on connection failure. Only connection + establishment is retried; a command that reached the appliance is + never replayed, as API commands are not idempotent. + """ + + self.user = user + self.password = password + self.totp = totp + self.ip = ip + self.port = port + self.app = "sslclient" + self.sslverifypeer = sslverifypeer + self.sslverifyhost = sslverifyhost + self.credentials = credentials + self.usercert = usercert + self.sessionid = "" + self.protocol = "" + self.sessionlevel = "" + self.dl_size = 0 + self.dl_crc = "" + self.autoconnect = autoconnect + self.proxy = proxy + self.conn_options: dict[str, Any] = {} + self._connected = False + + if host is None: + raise MissingHost("Host parameter must be provided") + if password is None and usercert is None: + raise MissingAuth("Password parameter must be provided") + if password is None and totp is not None: + raise MissingAuth("Password parameter must be provided when totp parameter is provided") + if usercert is not None and not os.path.isfile(usercert): + raise MissingAuth("User certificate not found") + if cabundle is None: + # use default cabundle + cabundle = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "bundle.ca")) + if not os.path.isfile(cabundle): + raise MissingCABundle("Certificate authority bundle not found") + + # assigned once validated, so both are known to be set from here on + self.host = host + self.cabundle = cabundle + + self.baseurl = f"https://{self._urlhost(self.host)}:{self.port}" + + self.headers = { + "Accept-Encoding": "identity", + "user-agent": f"stormshield.sns.sslclient/{__version__} ({platform.platform()})", + } + + # Retry connection failures only: replaying a request that already + # reached the appliance could apply a configuration command twice. + retry = Retry( + total=retries, + connect=retries, + read=False, + # `other` covers the errors urllib3 classifies as neither connect + # nor read (TLS record errors, for one). Leaving it to `total` + # would replay a request that already reached the appliance. + other=0, + status=0, + redirect=0, + backoff_factor=0.3, + allowed_methods=None, + ) + + self.session = requests.Session() + self.session.verify = self.cabundle if self.sslverifypeer else False + + # the adapters build their own SSL context, so they need the bundle: + # without it they would fall back to the system trust store on top of + # the caller's, widening the set of accepted authorities + cafile = self.cabundle if self.sslverifypeer else None + + if self.ip is not None: + # connect to the ip, but keep checking the certificate against the + # appliance name the caller asked for + self.baseurl = f"https://{self._urlhost(self.ip)}:{self.port}" + + adapter: HTTPAdapter + if not self.sslverifyhost: + adapter = SNSHTTPSAdapter(False, cafile=cafile, max_retries=retry) + elif self.ip is not None: + adapter = SNSHTTPSAdapter(self.host, cafile=cafile, max_retries=retry) + else: + adapter = HTTPAdapter(max_retries=retry) + + # a single adapter, mounted on the url actually used: mounting one per + # option would let the last one win and silently drop the others, + # retries included + self.session.mount(self.baseurl.lower(), adapter) + + if self.usercert is not None: + self.session.cert = self.usercert + + if self.proxy: + self.session.proxies = {"https": self.proxy} + + if timeout is not None: + self.conn_options = {"timeout": timeout} + + #: Kept for backward compatibility, prefer the module level ``logger``. + self.logger = logger + + if self.autoconnect: + self.connect() + + @staticmethod + def _urlhost(host: str) -> str: + """Bracket ``host`` if it is an IPv6 literal.""" + + try: + ipaddress.IPv6Address(host) + except ipaddress.AddressValueError: + return host + return f"[{host}]" + + @staticmethod + def get_completer() -> str: + """Get the path to the installed cmd.complete file.""" + + return os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "cmd.complete")) + + def __enter__(self) -> SSLClient: + if not self._connected: + self.connect() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.disconnect() + + def connect(self) -> None: + """Connect to the server.""" + + logger.info( + "Connecting to %s on port %d with user %s%s", + self.host, + self.port, + self.user, + f" (proxy {self.proxy})" if self.proxy else "", + ) + + # 1. Authentication and get cookie + if self.usercert is not None: + # user cert authentication + logger.debug("Authentication with SSL certificate") + request = self.session.get( + self.baseurl + f"/auth/admin.html?sslcert=1&app={self.app}", + headers=self.headers, + **self.conn_options, + ) + else: + # password authentication + logger.debug("Authentication with user/password") + if self.password is None: + raise MissingAuth("Password parameter must be provided") + data = { + "uid": base64.b64encode(self.user.encode("utf-8")), + "pswd": base64.b64encode(self.password.encode("utf-8")), + "app": self.app, + } + + if self.totp is not None: + data["totp"] = base64.b64encode(self.totp.encode("utf-8")) + + request = self.session.post( + self.baseurl + "/auth/admin.html", + data, + headers=self.headers, + **self.conn_options, + ) + + logger.debug("%s", request.text) + + try: + nws_node = ElementTree.fromstring(request.content) + msg = nws_node.attrib["msg"] + except (ElementTree.ParseError, KeyError) as exc: + raise ServerError("Can't decode authentication result") from exc + + if msg == self.ERR_BRUTEFORCE: + delay = nws_node.attrib.get("delay", "?") + raise AuthenticationError("Brut force detected, try again after " + delay + " seconds.") + if msg == self.NEED_TOTP_AUTH: + raise TOTPNeededError("TOTP is needed") + if msg != self.AUTH_SUCCESS: + raise AuthenticationError("Authentication failed") + + # 2. Serverd session + login: dict[str, Any] = {"app": self.app, "id": 0} + if self.credentials is not None: + login["reqlevel"] = self.credentials + request = self.session.post( + self.baseurl + "/api/auth/login", + data=login, + headers=self.headers, + **self.conn_options, + ) + + logger.debug("%s", request.text) + + if request.status_code != requests.codes.OK: + raise ServerError("can't get serverd session") + + nws_node = ElementTree.fromstring(request.content) + ret = int(nws_node.attrib["code"]) + msg = nws_node.attrib["msg"] + + if ret != self.SSL_SERVERD_OK: + raise ServerError(f"ERROR: {ret} {msg}") + + try: + self.sessionid = nws_node.find("sessionid").text + self.protocol = nws_node.find("protocol").text + self.sessionlevel = nws_node.find("sessionlevel").text + except AttributeError as exception: + raise ServerError("Malformed answer: incomplete serverd session") from exception + self._connected = True + + logger.debug("Session ID: %s", self.sessionid) + logger.debug("Protocol: %s", self.protocol) + logger.debug("Session level: %s", self.sessionlevel) + + def disconnect(self) -> None: + """Disconnect from the server. Calling it twice is a no-op.""" + + if not self._connected: + # No session to log out of, but a connect() that failed after the + # TCP handshake still left a socket in the pool: release it. + self.session.close() + return + self._connected = False + + try: + request = self.session.get( + self.baseurl + "/api/auth/logout?sessionid=" + self.sessionid, + headers=self.headers, + **self.conn_options, + ) + except requests.RequestException as exception: + logger.error("Disconnect failed: %s", exception) + else: + if request.status_code == requests.codes.OK: + logger.info("Disconnected from %s", self.host) + else: + logger.error("Disconnect failed") + finally: + self.session.close() + + def nws_parse(self, code: int) -> None: + """Parse server response.""" + + if code == self.SSL_SERVERD_OK: + return + + if code == self.SSL_SERVERD_AUTH_ERROR: + raise AuthenticationError(self.SSL_SERVERD_MSG[code]) + if code in self.SSL_SERVERD_MSG: + raise ServerError(self.SSL_SERVERD_MSG[code]) + raise ServerError("Unknown error") + + def send_command(self, command: str, **conn_options: Any) -> Response: + """Execute a NSRPC command on the remote appliance. + + :param command: SNS API command. Files can be uploaded by adding '< filename' + at the end of the command. Downloads are handled with '> filename'. + :return: :class:`Response ` object + :rtype: stormshield.sns.sslclient.Response + """ + + # overload connection options + for key, value in self.conn_options.items(): + conn_options.setdefault(key, value) + + filename = None + result = self.fileregexp.match(command) + if result: + command = result.group("cmd") + filename = result.group("file") + + request = self.session.get( + self.baseurl + + "/api/command?sessionid=" + + self.sessionid + + "&cmd=" + + requests.compat.quote(command.encode("utf-8")), # manually done since we need %20 encoding + headers=self.headers, + **conn_options, + ) + + logger.debug("%s", request.text) + + if request.status_code != requests.codes.OK: + raise ServerError(f"HTTP error {request.status_code}") + + nws_node = ElementTree.fromstring(request.content) + self.nws_parse(int(nws_node.attrib["code"])) + + try: + response = Response.from_tree(nws_node, request.text) + except ValueError as exception: + raise ServerError(str(exception)) from exception + + offset = self.startregexp.search(command) + if offset: + response.offset = int(offset.group("start")) + + if response.truncated: + logger.warning( + "%s: rows %s-%s of %s returned, page with the command's start argument " + "(response.truncated, response.total)", + command, + response.offset or 0, + (response.offset or 0) + (response.count or 0), + response.total if response.total is not None else "?", + ) + + if response.serverd_code == self.SERVERD_WAIT_UPLOAD: + if filename: + return self.upload(filename) + return response + + if response.serverd_code == self.SERVERD_WAIT_DOWNLOAD: + self._read_download_header(nws_node[0]) + if filename: + return self.download(filename) + + return response + + def _read_download_header(self, serverd: Any) -> None: + """Keep the announced size and crc for further verification.""" + + data = serverd.find("data") + if data is None: + raise ServerError("Malformed answer: missing download header") + + try: + if data.get("format") == "section": + #
+ # + key = data.find("section").find("key") + values = key.get("value").split(",") + self.dl_size = int(values[2].split("=")[1]) + self.dl_crc = values[1].split("=")[1] + else: + # 439B8525096 + self.dl_size = int(data.find("size").text) + self.dl_crc = data.find("crc").text + except (AttributeError, IndexError, TypeError, ValueError) as exception: + raise ServerError("Malformed answer: invalid download header") from exception + + def download(self, filename: str) -> Response: + """Handle file download. + + The payload is written to a temporary file next to ``filename`` and + only moved into place once its size and CRC have been verified, so a + failed transfer never leaves a corrupted file behind. + """ + + request = self.session.get( + self.baseurl + "/api/download/tmp.file?sessionid=" + self.sessionid, + headers=self.headers, + stream=True, + **self.conn_options, + ) + + if request.status_code != requests.codes.OK: + raise ServerError(f"HTTP error {request.status_code}") + + partial = filename + ".part" + size = 0 + crc = snscrc.CRC32_init + try: + with open(partial, "wb") as savefile: + for chunk in request.iter_content(self.CHUNK_SIZE): + savefile.write(chunk) + size += len(chunk) + crc = snscrc.update_crc32(chunk, crc) + except requests.RequestException: + # RequestException derives from OSError: without this clause a + # transport failure would be reported as a local file error + self._unlink(partial) + raise + except OSError as exception: + self._unlink(partial) + logger.error("%s", exception) + raise FileError("Can't save file") from exception + except BaseException: + self._unlink(partial) + raise + + try: + if size != self.dl_size: + raise ServerError( + f"Download error: {size} bytes downloaded, expecting {self.dl_size} bytes" + ) + + crc_hex = format(crc, "X") + if crc_hex != self.dl_crc: + raise ServerError(f"Download error: crc {crc_hex}, expecting {self.dl_crc}") + + os.replace(partial, filename) + except BaseException: + self._unlink(partial) + raise + + return Response( + ret=100, + code="00a00100", + msg="OK", + output='100 code=00a00100 msg="Ok"', + xml='' + '', + ) + + @staticmethod + def _unlink(path: str) -> None: + with suppress(OSError): + os.unlink(path) + + def upload(self, filename: str) -> Response: + """Handle file upload.""" + + with open(filename, "rb") as uploadh: + data = MultipartEncoder(fields={"upload": uploadh}) + # copy: mutating self.headers would leak the multipart content type + # into every later request of this session + headers = {**self.headers, "Content-Type": data.content_type} + + request = self.session.post( + self.baseurl + "/api/upload?sessionid=" + self.sessionid, + headers=headers, + data=data, + **self.conn_options, + ) + + if request.status_code != requests.codes.OK: + raise ServerError(f"HTTP error {request.status_code}") + + nws_node = ElementTree.fromstring(request.content) + self.nws_parse(int(nws_node.attrib["code"])) + + try: + return Response.from_tree(nws_node, request.text) + except ValueError as exception: + raise ServerError(str(exception)) from exception diff --git a/stormshield/sns/sslclient/exceptions.py b/stormshield/sns/sslclient/exceptions.py new file mode 100644 index 0000000..f8bb5fa --- /dev/null +++ b/stormshield/sns/sslclient/exceptions.py @@ -0,0 +1,51 @@ +""" +stormshield.sns.sslclient.exceptions + +Exceptions raised by the SNS API client. They all derive from +:class:`SNSError`, so callers can catch the whole family at once. +""" + +from __future__ import annotations + +__all__ = [ + "AuthenticationError", + "FileError", + "MissingAuth", + "MissingCABundle", + "MissingHost", + "SNSError", + "ServerError", + "TOTPNeededError", +] + + +class SNSError(Exception): + """Base class of every error raised by this library.""" + + +class MissingHost(SNSError, ValueError): + """The remote host is missing.""" + + +class MissingAuth(SNSError, ValueError): + """Password or user certificate is missing.""" + + +class MissingCABundle(SNSError, ValueError): + """The certificate authority bundle is missing.""" + + +class TOTPNeededError(SNSError): + """Time-based one time password needed.""" + + +class AuthenticationError(SNSError): + """Authentication failed.""" + + +class ServerError(SNSError): + """NWS server error.""" + + +class FileError(SNSError): + """File access error.""" diff --git a/stormshield/sns/sslclient/response.py b/stormshield/sns/sslclient/response.py new file mode 100644 index 0000000..1d99c44 --- /dev/null +++ b/stormshield/sns/sslclient/response.py @@ -0,0 +1,364 @@ +""" +stormshield.sns.sslclient.response + +Decoding of SNS API answers. + +The appliance answers in XML. Rather than rendering that XML to ini text and +parsing the text back (which is what versions up to 1.x did), the structured +``data`` is built straight from the XML tree, and the ini rendering is only +produced when :attr:`Response.output` is actually read. +""" + +from __future__ import annotations + +import json +from functools import cached_property +from typing import Any +from xml.etree import ElementTree as Et + +import defusedxml.ElementTree as ElementTree +from requests.structures import CaseInsensitiveDict + +from stormshield.sns.configparser import ConfigParser, serialize + +__all__ = ["Response", "format_output", "parse_tree", "quote", "render_output"] + +#: attributes of a ```` node that carry the command status; anything +#: else is answer metadata (row counts, truncation flags, ...). +_STATUS_ATTRS = frozenset({"ret", "code", "msg"}) + + +def quote(value: Any) -> Any: + """Quote ``value`` if it contains a space.""" + + if value and isinstance(value, str) and " " in value: + return '"' + value + '"' + return value + + +def _serverd_line(node: Et.Element) -> str: + return '{} code={} msg="{}"'.format(node.get("ret"), node.get("code"), node.get("msg")) + + +def _render_body(data_node: Et.Element, parts: list[str]) -> None: + """Append the ini rendering of a ```` node to ``parts``.""" + + node_format = data_node.get("format") + parts.append(f' format="{node_format}"\n') + + if node_format == "raw": + if data_node.text: + parts.append(data_node.text) + + elif node_format == "section": + for section_node in data_node: + parts.append("[{}]\n".format(section_node.get("title"))) + for key_node in section_node: + parts.append("{}={}\n".format(key_node.get("name"), quote(key_node.get("value")))) + + elif node_format == "section_line": + for section_node in data_node: + parts.append("[{}]\n".format(section_node.get("title"))) + for line_node in section_node: + parts.append( + " ".join( + "{}={}".format(key_node.get("name"), quote(key_node.get("value"))) + for key_node in line_node + ) + ) + parts.append("\n") + + elif node_format == "list": + for section_node in data_node: + parts.append("[{}]\n".format(section_node.get("title"))) + for line_node in section_node: + parts.append(f"{line_node.text}\n") + + elif node_format == "xml": + # display xml data node + parts.append(Et.tostring(data_node).decode() + "\n") + + +def render_output(nws_node: Et.Element) -> str: + """Render a parsed ```` tree in ini/section or text format.""" + + serverd_nodes = list(nws_node) + if not serverd_nodes: + return "" + + parts = [_serverd_line(serverd_nodes[0])] + + if len(serverd_nodes) > 1: + data_nodes = list(serverd_nodes[0]) + if data_nodes: + _render_body(data_nodes[0], parts) + parts.append(_serverd_line(serverd_nodes[1])) + + return "".join(parts) + + +def format_output(output: str | bytes) -> str: + """Format a raw XML command output in ini/section or text format.""" + + return render_output(ElementTree.fromstring(output)) + + +def parse_tree(nws_node: Et.Element) -> tuple[str | None, Any]: + """Extract ``(format, data)`` straight from a parsed ```` tree.""" + + serverd_nodes = list(nws_node) + if len(serverd_nodes) < 2: + # single serverd node: an error or an answer with no payload + return None, CaseInsensitiveDict() + + data_nodes = list(serverd_nodes[0]) + if not data_nodes: + return None, CaseInsensitiveDict() + + data_node = data_nodes[0] + node_format = data_node.get("format") + + if node_format == "raw": + return node_format, data_node.text or "" + + if node_format == "xml": + return node_format, Et.tostring(data_node).decode() + + data: Any = CaseInsensitiveDict() + + if node_format == "section": + for section_node in data_node: + section = CaseInsensitiveDict() + for key_node in section_node: + section[key_node.get("name")] = key_node.get("value") + data[section_node.get("title")] = section + + elif node_format == "section_line": + for section_node in data_node: + data[section_node.get("title")] = [ + {key_node.get("name"): key_node.get("value") for key_node in line_node} + for line_node in section_node + ] + + elif node_format == "list": + for section_node in data_node: + data[section_node.get("title")] = [line_node.text for line_node in section_node] + + return node_format, data + + +class Response: + """:class:`Response ` object contains the SNS API response to a request. + + :attr:`data` and :attr:`output` are computed on first access, so a caller + that only reads :attr:`data` never pays for the ini rendering. + """ + + def __init__( + self, + code: str | None = None, + ret: int = 0, + msg: str | None = None, + output: str | None = None, + xml: str | None = None, + tree: Et.Element | None = None, + ) -> None: + self.code = code + self.ret = ret + self.msg = msg + self.xml = xml + self._tree = tree + self._output = output + #: Code of the *first* serverd node, which carries the transfer state + #: (``SERVERD_WAIT_UPLOAD`` / ``SERVERD_WAIT_DOWNLOAD``). :attr:`code` + #: holds the code of the last node for multiline answers. + self.serverd_code = code + #: Row offset this answer starts at, read from the command's ``start=`` + #: argument by :meth:`SSLClient.send_command`. ``None`` when unknown, + #: which :attr:`truncated` treats as 0. + self.offset: int | None = None + + @classmethod + def from_tree(cls, nws_node: Et.Element, xml: str | None = None) -> Response: + """Build a response from a parsed ```` tree. + + For a multiline answer the returned :attr:`ret`, :attr:`code` and + :attr:`msg` are those of the *last* serverd node, which carries the + final status of the command. + """ + + serverd_nodes = list(nws_node) + if not serverd_nodes: + raise ValueError("Malformed answer: no serverd node") + + first = serverd_nodes[0] + response = cls( + code=first.get("code"), + ret=cls._ret(first), + msg=first.get("msg"), + xml=xml, + tree=nws_node, + ) + + if len(serverd_nodes) > 1: + last = serverd_nodes[-1] + response.code = last.get("code") + response.msg = last.get("msg") + response.ret = cls._ret(last) + + return response + + @staticmethod + def _ret(node: Et.Element) -> int: + """Read the ``ret`` attribute of a serverd node.""" + + ret = node.get("ret") + if ret is None: + raise ValueError("Malformed answer: serverd node without ret") + return int(ret) + + @classmethod + def from_xml(cls, xml: str | bytes) -> Response: + """Build a response from a raw XML answer.""" + + text = xml.decode("utf-8") if isinstance(xml, bytes) else xml + return cls.from_tree(ElementTree.fromstring(xml), text) + + @cached_property + def output(self) -> str: + """The answer rendered in ini/section format.""" + + if self._output is not None: + return self._output + if self._tree is not None: + return render_output(self._tree) + return "" + + @cached_property + def _decoded(self) -> tuple[str | None, Any]: + if self._tree is not None: + return parse_tree(self._tree) + parser = ConfigParser(self.output) + return parser.format, parser.data + + @property + def format(self) -> str | None: + """The payload format announced by the appliance.""" + + return self._decoded[0] + + @property + def data(self) -> Any: + """The answer decoded into dicts/lists, with case insensitive keys.""" + + return self._decoded[1] + + @cached_property + def parser(self) -> ConfigParser: + """A :class:`ConfigParser` over :attr:`data`, for :meth:`ConfigParser.get`.""" + + return ConfigParser.from_data(*self._decoded) + + def get(self, section: str, token: str | None = None, line: int | None = None, default: Any = None) -> Any: + """Shortcut for ``response.parser.get(...)``.""" + + return self.parser.get(section, token=token, line=line, default=default) + + # --- answer metadata ---------------------------------------------------- + + @cached_property + def meta(self) -> dict[str, str]: + """Extra attributes the appliance put on the answer. + + Commands that page their results announce a row count and truncation + flags here, for instance ``{'total': '134', 'too_many_data': '0', + 'not_enough_space': '0', 'data_changed': '0'}``. They are not part of + :attr:`data`, which only holds the rows themselves. + """ + + if self._tree is None: + return {} + serverd_nodes = list(self._tree) + if not serverd_nodes: + return {} + return {k: v for k, v in serverd_nodes[0].attrib.items() if k not in _STATUS_ATTRS} + + @property + def total(self) -> int | None: + """Number of rows the appliance holds, when it announces one. + + This can be larger than the number of rows actually returned: see + :attr:`truncated`. + """ + + value = self.meta.get("total") + if value is None or not value.lstrip("-").isdigit(): + return None + return int(value) + + @property + def count(self) -> int | None: + """Number of rows returned, for the row-shaped formats.""" + + if self.format not in ("section_line", "list"): + return None + return sum(len(rows) for rows in self.data.values()) + + @property + def truncated(self) -> bool: + """True when rows remain past this answer. + + ``CONFIG OBJECT LIST type=host start=0`` answers at most 100 rows while + announcing ``total=134``; iterating :attr:`data` alone would silently + process a quarter of the objects. Page with the command's ``start`` + argument until this is False. + + The comparison is ``offset + count < total``, not ``count < total``: + past the last page the appliance still reports the full ``total`` + alongside zero rows, so the naive form never terminates. + """ + + if self.meta.get("too_many_data", "0") not in ("0", ""): + return True + if self.meta.get("not_enough_space", "0") not in ("0", ""): + return True + total, count = self.total, self.count + if total is None or count is None: + return False + if count == 0: + # nothing came back, so there is nothing left to page through + return False + return (self.offset or 0) + count < total + + # --- serialisation ------------------------------------------------------ + + def to_dict(self) -> Any: + """:attr:`data` as plain dicts and lists, ready for JSON. + + :attr:`data` itself uses :class:`CaseInsensitiveDict`, which + :func:`json.dumps` cannot serialise. + """ + + return serialize(self.data) + + def json(self, **kwargs: Any) -> str: + """Serialise :attr:`data` to a JSON string. + + Every value is a string: the appliance sends XML attributes, so no type + information ever reaches the client. ``"0"`` is the text zero, not the + integer nor the boolean. + """ + + kwargs.setdefault("ensure_ascii", False) + return json.dumps(self.to_dict(), **kwargs) + + def __repr__(self) -> str: + return f"" + + def __str__(self) -> str: + return self.output + + def __bool__(self) -> bool: + """Returns True if :attr:`ret` is OK or WARNING.""" + + return 100 <= self.ret < 200 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e40c525 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared helpers for the offline test suite. + +The XML fixtures under ``tests/fixtures`` are real answers captured from an +SNS appliance (v4.8.15), with serials, addresses and object names scrubbed. +""" + +from __future__ import annotations + +import pathlib + +import pytest + +FIXTURES = pathlib.Path(__file__).parent / "fixtures" + + +def load(name: str) -> str: + """Return the raw XML of a captured appliance answer.""" + + return (FIXTURES / f"{name}.xml").read_text(encoding="utf-8") + + +@pytest.fixture +def fixture_xml(): + return load + + +@pytest.fixture +def all_fixtures() -> list[str]: + return sorted(p.stem for p in FIXTURES.glob("*.xml")) diff --git a/tests/fixtures/bad_args.xml b/tests/fixtures/bad_args.xml new file mode 100644 index 0000000..bb8120f --- /dev/null +++ b/tests/fixtures/bad_args.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/fixtures/filter_xml.xml b/tests/fixtures/filter_xml.xml new file mode 100644 index 0000000..9eeb59f --- /dev/null +++ b/tests/fixtures/filter_xml.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/help_raw.xml b/tests/fixtures/help_raw.xml new file mode 100644 index 0000000..93b10a3 --- /dev/null +++ b/tests/fixtures/help_raw.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/tests/fixtures/hostrep_show.xml b/tests/fixtures/hostrep_show.xml new file mode 100644 index 0000000..3b0b4c8 --- /dev/null +++ b/tests/fixtures/hostrep_show.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/monitor_stat.xml b/tests/fixtures/monitor_stat.xml new file mode 100644 index 0000000..cf484e3 --- /dev/null +++ b/tests/fixtures/monitor_stat.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/nettoken.xml b/tests/fixtures/nettoken.xml new file mode 100644 index 0000000..28759d2 --- /dev/null +++ b/tests/fixtures/nettoken.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/ntp_server_list.xml b/tests/fixtures/ntp_server_list.xml new file mode 100644 index 0000000..e0ddcb5 --- /dev/null +++ b/tests/fixtures/ntp_server_list.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/object_list_host.xml b/tests/fixtures/object_list_host.xml new file mode 100644 index 0000000..ef36fb0 --- /dev/null +++ b/tests/fixtures/object_list_host.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/system_information.xml b/tests/fixtures/system_information.xml new file mode 100644 index 0000000..3c50ce6 --- /dev/null +++ b/tests/fixtures/system_information.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/fixtures/system_property.xml b/tests/fixtures/system_property.xml new file mode 100644 index 0000000..4fd248b --- /dev/null +++ b/tests/fixtures/system_property.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/unknown_command.xml b/tests/fixtures/unknown_command.xml new file mode 100644 index 0000000..e490963 --- /dev/null +++ b/tests/fixtures/unknown_command.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/tests/fixtures/user_list.xml b/tests/fixtures/user_list.xml new file mode 100644 index 0000000..9e6121f --- /dev/null +++ b/tests/fixtures/user_list.xml @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/tests/fixtures/webadmin_access.xml b/tests/fixtures/webadmin_access.xml new file mode 100644 index 0000000..4db9518 --- /dev/null +++ b/tests/fixtures/webadmin_access.xml @@ -0,0 +1,2 @@ + +
network_internalslabo_networks
\ No newline at end of file diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..252a354 --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,128 @@ +"""SSL context construction. + +`sslverifyhost=False` and the `ip=` option both mount an adapter that builds +its own SSL context. That context must trust the caller's bundle and nothing +else: `ssl.create_default_context()` with no `cafile` activates the system +trust store, which urllib3 then combines with the bundle, silently widening +the caller's CA pinning to every publicly trusted authority. +""" + +from __future__ import annotations + +import ssl + +from stormshield.sns.sslclient import SSLClient +from stormshield.sns.sslclient.adapters import SNSHTTPSAdapter, _common_name_context + +BUNDLE = SSLClient(host="fw", password="x", autoconnect=False).cabundle + + +def subjects(context: ssl.SSLContext) -> set[str]: + names = set() + for cert in context.get_ca_certs(): + fields = {k: v for rdn in cert["subject"] for k, v in rdn} + names.add(fields.get("commonName") or fields.get("organizationalUnitName", "")) + return names + + +# --- the context itself ----------------------------------------------------- + + +def test_context_with_a_bundle_trusts_only_that_bundle(): + context = _common_name_context(BUNDLE) + + assert subjects(context) == { + "NETASQ Firewall Certification Authority", + "Stormshield Products Root CA", + } + + +def test_context_without_a_bundle_loads_no_explicit_ca(): + """Peer verification is off in that case; urllib3 forces CERT_NONE.""" + + assert _common_name_context(None).get_ca_certs() == [] + + +def test_context_matches_the_common_name(): + """Factory certificates carry the serial in CN and have no subjectAltName.""" + + context = _common_name_context(BUNDLE) + + assert context.hostname_checks_common_name is True + assert context.check_hostname is False # urllib3 does it via assert_hostname + + +def test_context_keeps_the_default_hardening(): + context = _common_name_context(BUNDLE) + + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.minimum_version >= ssl.TLSVersion.TLSv1_2 + + +# --- the adapter ------------------------------------------------------------ + + +def test_adapter_passes_the_bundle_to_its_context(): + adapter = SNSHTTPSAdapter(False, cafile=BUNDLE) + + assert subjects(adapter._ssl_pool_kwargs()["ssl_context"]) + + +def test_adapter_disables_host_name_check(): + assert SNSHTTPSAdapter(False, cafile=BUNDLE)._ssl_pool_kwargs()["assert_hostname"] is False + + +def test_adapter_asserts_the_serial_for_a_factory_certificate(): + kwargs = SNSHTTPSAdapter("VMSNSX00000000A", cafile=BUNDLE)._ssl_pool_kwargs() + + assert kwargs["assert_hostname"] == "VMSNSX00000000A" + assert "ssl_context" in kwargs # a serial has no dot -> CN matching needed + + +def test_adapter_leaves_a_fqdn_to_the_standard_check(): + """A dotted name is a real host name, matched the usual way.""" + + kwargs = SNSHTTPSAdapter("firewall.example.com", cafile=BUNDLE)._ssl_pool_kwargs() + + assert kwargs["assert_hostname"] == "firewall.example.com" + assert "ssl_context" not in kwargs + + +# --- wiring from the client ------------------------------------------------- + + +def mounted(client: SSLClient) -> SNSHTTPSAdapter: + return next(a for a in client.session.adapters.values() if isinstance(a, SNSHTTPSAdapter)) + + +def test_client_hands_its_bundle_to_the_adapter(): + client = SSLClient(host="fw", password="x", sslverifyhost=False, autoconnect=False) + + assert mounted(client)._cafile == client.cabundle + + +def test_client_passes_a_custom_bundle_through(): + client = SSLClient( + host="fw", password="x", cabundle=BUNDLE, sslverifyhost=False, autoconnect=False + ) + + assert mounted(client)._cafile == BUNDLE + + +def test_no_bundle_when_peer_verification_is_disabled(): + client = SSLClient( + host="fw", password="x", sslverifypeer=False, sslverifyhost=False, autoconnect=False + ) + + assert mounted(client)._cafile is None + assert client.session.verify is False + + +def test_ip_option_also_pins_the_bundle(): + """`ip=` mounts the adapter too, and used to leak the system store as well.""" + + client = SSLClient(host="VMSNSX00000000A", ip="10.0.0.254", password="x", autoconnect=False) + adapter = mounted(client) + + assert adapter._cafile == client.cabundle + assert adapter._assert_hostname == "VMSNSX00000000A" diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 76ff1b4..0000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/python - -import os -import unittest -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -SERIAL = os.getenv('SERIAL', "") -PASSWORD = os.getenv('PASSWORD', "") -SSLVERIFYPEER = os.getenv('SSLVERIFYPEER', "1") == "1"; - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(SERIAL=="", "SERIAL env var must be set to the firewall serial number") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -class TestAuth(unittest.TestCase): - """ Test authentication options """ - - def test_sslverifyhost(self): - """ Test sslverifyhost option """ - - try: - client = SSLClient(host=SERIAL, ip=APPLIANCE, user='admin', password=PASSWORD, sslverifyhost=True, sslverifypeer=SSLVERIFYPEER) - self.assertTrue(1==1, "SSLClient connects with sslverifyhost=True") - except: - self.fail("SSLClient did not connect") - - response = client.send_command('LIST') - self.assertEqual(response.ret, 100) - - client.disconnect() diff --git a/tests/test_cert.py b/tests/test_cert.py deleted file mode 100644 index 1a294c3..0000000 --- a/tests/test_cert.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/python - -import os -import unittest -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -FQDN = os.getenv('FQDN', "") -PASSWORD = os.getenv('PASSWORD', "") -CABUNDLE = os.getenv('CABUNDLE', "") -CERT = os.getenv('CERT', "") - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(FQDN=="", "FQDN env var must be set to the firewall fqdn") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -@unittest.skipIf(CABUNDLE=="", "CABUNDLE env var must be set to the CA bundle file") -@unittest.skipIf(CERT=="", "CERT env var must be set to the certificate file") -class TestCert(unittest.TestCase): - """ Test cabundle / certificate authentication options """ - - def test_sslverifypeer(self): - """ Test sslverifypeer option """ - - # by default sslverifypeer is True - try: - client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD) - self.fail("SSLClient should have failed (untrusted CA)") - except Exception as exception: - self.assertTrue(True, "SSLClient did not connect (untrusted CA)") - - try: - client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD, sslverifypeer=False) - self.assertTrue(True, "SSLClient connects with sslverifypeer=False") - except Exception as exception: - print(exception) - self.fail("SSLClient did not connect") - - response = client.send_command('LIST') - self.assertEqual(response.ret, 100) - - client.disconnect() - - def test_cabundle(self): - """ Test cabundle option """ - - try: - client = SSLClient(host=FQDN, ip=APPLIANCE, user='admin', password=PASSWORD, sslverifyhost=True, cabundle=CABUNDLE) - self.assertTrue(1==1, "SSLClient connects with cabundle") - except Exception as exception: - print(exception) - self.fail("SSLClient did not connect") - - response = client.send_command('LIST') - self.assertEqual(response.ret, 100) - - client.disconnect() - - def test_cert(self): - """ Test user certificate authentication """ - - try: - client = SSLClient(host=FQDN, ip=APPLIANCE, usercert=CERT, sslverifyhost=True, cabundle=CABUNDLE) - self.assertTrue(1==1, "SSLClient connects with cabundle") - except Exception as exception: - print(exception) - self.fail("SSLClient did not connect") - - response = client.send_command('LIST') - self.assertEqual(response.ret, 100) - - client.disconnect() diff --git a/tests/test_cli_args.py b/tests/test_cli_args.py new file mode 100644 index 0000000..99b807b --- /dev/null +++ b/tests/test_cli_args.py @@ -0,0 +1,83 @@ +"""Command line parsing - no live appliance needed.""" + +import pytest + +from stormshield.sns.cli import build_parser + + +@pytest.fixture +def parser(): + return build_parser() + + +def test_timeout_and_totp_do_not_collide(parser): + """1.x declared -t twice; argparse's `resolve` handler silently gave it + to --totp, so `snscli -t 30` set a TOTP instead of a timeout.""" + + args = parser.parse_args(["-h", "fw", "-t", "123456"]) + assert args.totp == "123456" + assert args.timeout == 30 # untouched default + + args = parser.parse_args(["-h", "fw", "--timeout", "5"]) + assert args.timeout == 5 + assert args.totp is None + + +def test_no_duplicate_short_options(): + """Guard the whole parser against a new silent collision.""" + + parser = build_parser() + seen = {} + for action in parser._actions: + for opt in action.option_strings: + assert opt not in seen, f"{opt} declared twice: {seen.get(opt)} and {action.dest}" + seen[opt] = action.dest + + +def test_host_short_option_is_h(parser): + args = parser.parse_args(["-h", "10.0.0.254"]) + assert args.host == "10.0.0.254" + + +def test_long_help_is_available(parser, capsys): + with pytest.raises(SystemExit) as exc: + parser.parse_args(["--help"]) + assert exc.value.code == 0 + assert "--host" in capsys.readouterr().out + + +def test_sslverify_defaults_and_negation(parser): + args = parser.parse_args(["-h", "fw"]) + assert args.sslverifypeer is True + assert args.sslverifyhost is True + + args = parser.parse_args(["-h", "fw", "-k", "-K"]) + assert args.sslverifypeer is False + assert args.sslverifyhost is False + + args = parser.parse_args(["-h", "fw", "--no-sslverifypeer"]) + assert args.sslverifypeer is False + + +def test_outputformat_is_validated(parser): + assert parser.parse_args(["-h", "fw", "-o", "xml"]).outputformat == "xml" + with pytest.raises(SystemExit): + parser.parse_args(["-h", "fw", "-o", "yaml"]) + + +def test_timeout_zero_means_no_timeout(parser): + assert parser.parse_args(["-h", "fw", "--timeout", "0"]).timeout == 0 + + +def test_verbose_and_quiet_are_exclusive(parser): + with pytest.raises(SystemExit): + parser.parse_args(["-h", "fw", "-v", "-q"]) + + +def test_password_env_var_is_used(monkeypatch): + from stormshield.sns.cli import PASSWORD_ENV + + monkeypatch.setenv(PASSWORD_ENV, "from-env") + args = build_parser().parse_args(["-h", "fw"]) + assert args.password is None + assert (args.password or __import__("os").environ.get(PASSWORD_ENV)) == "from-env" diff --git a/tests/test_client_offline.py b/tests/test_client_offline.py new file mode 100644 index 0000000..745a836 --- /dev/null +++ b/tests/test_client_offline.py @@ -0,0 +1,391 @@ +"""SSLClient behaviour that does not need a live appliance.""" + +from __future__ import annotations + +import os +import zlib +from unittest import mock + +import pytest +import requests + +from stormshield.sns.sslclient import ( + MissingAuth, + MissingCABundle, + MissingHost, + ServerError, + SNSError, + SSLClient, +) + +from .conftest import load + + +def make_client(**kwargs) -> SSLClient: + """A client that never touches the network.""" + + params = { + "host": "appliance.example.com", + "user": "admin", + "password": "secret", + "autoconnect": False, + } + params.update(kwargs) + client = SSLClient(**params) + client.sessionid = "SESSION" + client.session = mock.MagicMock(spec=requests.Session) + return client + + +def fake_answer(content: str, status: int = 200) -> mock.Mock: + response = mock.Mock() + response.status_code = status + response.content = content.encode("utf-8") + response.text = content + return response + + +# --- constructor validation ------------------------------------------------- + + +def test_host_is_required(): + with pytest.raises(MissingHost): + SSLClient(password="x", autoconnect=False) + + +def test_auth_is_required(): + with pytest.raises(MissingAuth): + SSLClient(host="fw", autoconnect=False) + + +def test_totp_needs_a_password(): + with pytest.raises(MissingAuth): + SSLClient(host="fw", totp="123456", autoconnect=False) + + +def test_missing_usercert_is_reported(): + with pytest.raises(MissingAuth): + SSLClient(host="fw", usercert="/nope/absent.pem", autoconnect=False) + + +def test_missing_cabundle_is_reported(): + with pytest.raises(MissingCABundle): + SSLClient(host="fw", password="x", cabundle="/nope/absent.ca", autoconnect=False) + + +def test_errors_share_a_base_class(): + """Callers can catch the whole family with one except clause.""" + + with pytest.raises(SNSError): + SSLClient(password="x", autoconnect=False) + + +def test_default_cabundle_is_shipped(): + client = make_client() + assert os.path.isfile(client.cabundle) + + +def test_ipv6_host_is_bracketed(): + client = make_client(host="2001:db8::1") + assert client.baseurl == "https://[2001:db8::1]:443" + + +def test_ipv4_host_is_not_bracketed(): + client = make_client(host="10.0.0.254", port=8443) + assert client.baseurl == "https://10.0.0.254:8443" + + +def test_ipv6_ip_option_is_bracketed(): + client = make_client(host="SERIAL123", ip="2001:db8::2") + assert client.baseurl == "https://[2001:db8::2]:443" + + +def test_default_timeout_is_applied(): + """1.x waited forever by default when an appliance stopped answering.""" + + assert make_client().conn_options["timeout"] == SSLClient.DEFAULT_TIMEOUT + + +def test_timeout_can_be_disabled(): + assert make_client(timeout=None).conn_options == {} + + +# --- send_command ----------------------------------------------------------- + + +def test_send_command_decodes_answer(): + client = make_client() + client.session.get.return_value = fake_answer(load("system_property")) + + response = client.send_command("SYSTEM PROPERTY") + + assert response.ret == 100 + assert response.data["Result"]["Model"] == "EVA2" + + +def test_send_command_url_encodes_spaces(): + client = make_client() + client.session.get.return_value = fake_answer(load("system_property")) + + client.send_command("CONFIG OBJECT LIST type=host") + + url = client.session.get.call_args[0][0] + assert "cmd=CONFIG%20OBJECT%20LIST%20type%3Dhost" in url + + +def test_send_command_raises_on_http_error(): + client = make_client() + client.session.get.return_value = fake_answer("", status=500) + + with pytest.raises(ServerError, match="HTTP error 500"): + client.send_command("LIST") + + +def test_send_command_rejects_answer_without_serverd_node(): + """1.x raised a bare IndexError here.""" + + client = make_client() + client.session.get.return_value = fake_answer('') + + with pytest.raises(ServerError, match="serverd"): + client.send_command("LIST") + + +def test_send_command_maps_serverd_errors(): + client = make_client() + client.session.get.return_value = fake_answer( + '' + ) + + with pytest.raises(ServerError, match="Server disconnected"): + client.send_command("QUIT") + + +def test_send_command_timeout_can_be_overridden(): + client = make_client() + client.session.get.return_value = fake_answer(load("system_property")) + + client.send_command("LIST", timeout=99) + + assert client.session.get.call_args.kwargs["timeout"] == 99 + + +# --- upload ----------------------------------------------------------------- + + +def test_upload_does_not_leak_content_type_into_the_session(tmp_path): + """1.x mutated self.headers, so every later request carried the + multipart Content-Type of the last upload.""" + + client = make_client() + payload = tmp_path / "conf.txt" + payload.write_text("data") + client.session.post.return_value = fake_answer(load("unknown_command")) + + before = dict(client.headers) + client.upload(str(payload)) + + assert client.headers == before + assert "Content-Type" not in client.headers + # the request itself did carry it + assert client.session.post.call_args.kwargs["headers"]["Content-Type"].startswith("multipart/") + + +def test_upload_closes_the_file_on_error(tmp_path): + client = make_client() + payload = tmp_path / "conf.txt" + payload.write_text("data") + client.session.post.side_effect = requests.ConnectionError("boom") + + with pytest.raises(requests.ConnectionError): + client.upload(str(payload)) + # nothing to assert on the fd directly; the `with` block guarantees closure + + +# --- download --------------------------------------------------------------- + + +def streamed(payload: bytes) -> mock.Mock: + response = mock.Mock() + response.status_code = 200 + response.iter_content = lambda size: iter([payload[i : i + size] for i in range(0, len(payload), size)]) + return response + + +def test_download_writes_the_file(tmp_path): + client = make_client() + payload = b"appliance backup payload" * 100 + client.dl_size = len(payload) + client.dl_crc = "%X" % (zlib.crc32(payload) ^ 0xFFFFFFFF) + client.session.get.return_value = streamed(payload) + + target = tmp_path / "backup.na" + response = client.download(str(target)) + + assert target.read_bytes() == payload + assert response.ret == 100 + assert not list(tmp_path.glob("*.part")) + + +def test_download_leaves_no_file_on_crc_mismatch(tmp_path): + """1.x wrote the payload, then verified, leaving corrupted files behind.""" + + client = make_client() + payload = b"corrupted" + client.dl_size = len(payload) + client.dl_crc = "DEADBEEF" + client.session.get.return_value = streamed(payload) + + target = tmp_path / "backup.na" + with pytest.raises(ServerError, match="crc"): + client.download(str(target)) + + assert not target.exists() + assert not list(tmp_path.glob("*.part")) + + +def test_download_leaves_no_file_on_size_mismatch(tmp_path): + client = make_client() + payload = b"truncated" + client.dl_size = 99999 + client.dl_crc = "%X" % (zlib.crc32(payload) ^ 0xFFFFFFFF) + client.session.get.return_value = streamed(payload) + + target = tmp_path / "backup.na" + with pytest.raises(ServerError, match="bytes downloaded"): + client.download(str(target)) + + assert not target.exists() + assert not list(tmp_path.glob("*.part")) + + +def test_download_does_not_clobber_an_existing_file_on_failure(tmp_path): + client = make_client() + target = tmp_path / "backup.na" + target.write_bytes(b"previous good backup") + + client.dl_size = 5 + client.dl_crc = "DEADBEEF" + client.session.get.return_value = streamed(b"bad") + + with pytest.raises(ServerError): + client.download(str(target)) + + assert target.read_bytes() == b"previous good backup" + + +def test_download_raises_on_http_error(tmp_path): + client = make_client() + response = mock.Mock() + response.status_code = 404 + client.session.get.return_value = response + + with pytest.raises(ServerError, match="HTTP error 404"): + client.download(str(tmp_path / "x")) + + +# --- session lifecycle ------------------------------------------------------ + + +def test_disconnect_is_idempotent(): + client = make_client() + client._connected = True + client.session.get.return_value = fake_answer("", status=200) + + client.disconnect() + client.disconnect() + + assert client.session.get.call_count == 1 + + +def test_disconnect_releases_the_socket_of_a_failed_connect(): + """A connect() that failed after the handshake still holds a socket.""" + + client = make_client() + assert client._connected is False + + client.disconnect() + + client.session.close.assert_called_once() + client.session.get.assert_not_called() # no session to log out of + + +def test_disconnect_survives_a_dead_connection(): + client = make_client() + client._connected = True + client.session.get.side_effect = requests.ConnectionError("gone") + + client.disconnect() # must not raise + + client.session.close.assert_called_once() + + +def test_context_manager_disconnects(): + client = make_client() + client._connected = True + client.session.get.return_value = fake_answer("", status=200) + + with client as c: + assert c is client + + client.session.close.assert_called_once() + + +def test_client_does_not_hijack_the_root_logger(): + """1.x did `logging.getLogger()`, capturing the host application's root.""" + + import logging + + assert make_client().logger is logging.getLogger("stormshield.sns.sslclient.client") + assert make_client().logger.name != "root" + + +# --- paging offset ---------------------------------------------------------- + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("CONFIG OBJECT LIST type=host start=0", 0), + ("CONFIG OBJECT LIST type=host start=100", 100), + ("CONFIG OBJECT LIST start=42 type=host", 42), + ("CONFIG OBJECT LIST type=host START=7", 7), + ("CONFIG OBJECT LIST type=host", None), + ("SYSTEM PROPERTY", None), + # must not match a token that merely ends in "start" + ("CONFIG OBJECT LIST restart=5", None), + ], +) +def test_send_command_reads_the_paging_offset(command, expected): + client = make_client() + client.session.get.return_value = fake_answer(load("object_list_host")) + + assert client.send_command(command).offset == expected + + +def test_truncation_is_logged(caplog): + """The silent case of 1.x: 100 rows of 134 with no error reported.""" + + import logging + + client = make_client() + client.session.get.return_value = fake_answer(load("object_list_host")) + + with caplog.at_level(logging.WARNING, logger="stormshield.sns.sslclient"): + response = client.send_command("CONFIG OBJECT LIST type=host start=0") + + assert response.truncated is True + assert "rows 0-100 of 134" in caplog.text + + +def test_complete_answer_logs_nothing(caplog): + import logging + + client = make_client() + client.session.get.return_value = fake_answer(load("ntp_server_list")) + + with caplog.at_level(logging.WARNING, logger="stormshield.sns.sslclient"): + response = client.send_command("CONFIG NTP SERVER LIST") + + assert response.truncated is False + assert caplog.text == "" diff --git a/tests/test_configparser.py b/tests/test_configparser.py index 24007f1..e29edcd 100644 --- a/tests/test_configparser.py +++ b/tests/test_configparser.py @@ -1,11 +1,11 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- -import unittest import json +import unittest from stormshield.sns.configparser import ConfigParser + class TestConfigParser(unittest.TestCase): def test_section(self): diff --git a/tests/test_crc.py b/tests/test_crc.py new file mode 100644 index 0000000..af99cf9 --- /dev/null +++ b/tests/test_crc.py @@ -0,0 +1,54 @@ +"""CRC32 helpers - the appliance sends the non-finalised IEEE CRC-32.""" + +import os +import zlib + +import pytest + +from stormshield.sns.crc import CRC32_init, compute_crc32, update_crc32 + +VECTORS = [b"", b"a", b"hello", b"x" * 4096, bytes(range(256))] + + +@pytest.mark.parametrize("data", VECTORS) +def test_matches_zlib(data): + """SNS CRC is zlib's CRC without the final one's complement.""" + + assert compute_crc32(data) == zlib.crc32(data) ^ 0xFFFFFFFF + + +def test_known_values(): + """Guard against a silent change of convention.""" + + assert compute_crc32(b"") == CRC32_init + assert compute_crc32(b"hello") == 0xC9EF5979 + + +@pytest.mark.parametrize("data", VECTORS) +def test_incremental_matches_oneshot(data): + """Chunked hashing must equal hashing the whole payload at once.""" + + crc = CRC32_init + for i in range(0, len(data), 7): + crc = update_crc32(data[i : i + 7], crc) + assert crc == compute_crc32(data) + + +def test_incremental_random_chunks(): + data = os.urandom(50_000) + crc = CRC32_init + pos = 0 + for step in (1, 10, 1000, 17, 32768): + crc = update_crc32(data[pos : pos + step], crc) + pos += step + crc = update_crc32(data[pos:], crc) + assert crc == compute_crc32(data) + + +def test_update_from_seed_is_oneshot(): + assert update_crc32(b"hello", CRC32_init) == compute_crc32(b"hello") + + +def test_result_is_uint32(): + for data in VECTORS: + assert 0 <= compute_crc32(data) <= 0xFFFFFFFF diff --git a/tests/test_file.py b/tests/test_file.py deleted file mode 100644 index 4eff608..0000000 --- a/tests/test_file.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals -import random -import string -import os -import sys -import tempfile -import unittest -import shutil -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -PASSWORD = os.getenv('PASSWORD', "") -SSLVERIFYPEER = os.getenv('SSLVERIFYPEER', "1") == "1"; - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -class TestFormatIni(unittest.TestCase): - """ Test file upload & download """ - - def setUp(self): - self.client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD, sslverifyhost=False, sslverifypeer=SSLVERIFYPEER) - - self.tmpdir = tempfile.mkdtemp() - self.upload = os.path.join(self.tmpdir, 'upload') - self.download = os.path.join(self.tmpdir, 'download') - - def tearDown(self): - self.client.disconnect() - shutil.rmtree(self.tmpdir, ignore_errors=True) - - def test_upload_download(self): - """ Test file upload and download """ - - letters = string.ascii_letters + 'éèàÎîô' - - #generate a random file - content = ( "[Filter] \n pass from network_internals to any #ASCII" + - "".join( [random.choice(letters) for i in range(100)] ) - ).encode('utf-8') - with open(self.upload, "wb") as fh: - fh.write(content) - - response = self.client.send_command('CONFIG SLOT UPLOAD slot=1 name=testUpload < ' + self.upload) - self.assertEqual(response.ret, 100) - - response = self.client.send_command('CONFIG SLOT DOWNLOAD slot=1 name=testUpload > ' + self.download) - self.assertEqual(response.ret, 100) - - self.client.send_command('CONFIG SLOT DEFAULT type=filter slot=1') - self.assertEqual(response.ret, 100) - - with open(self.download, "rb") as fh: - downloaded = fh.read() - - self.assertEqual(content, downloaded) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_format_ini.py b/tests/test_format_ini.py deleted file mode 100644 index af5dbad..0000000 --- a/tests/test_format_ini.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/python - -import os -import sys -import unittest -import re - -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -PASSWORD = os.getenv('PASSWORD', "") -SSLVERIFYPEER = os.getenv('SSLVERIFYPEER', "1") == "1"; - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -class TestFormatIni(unittest.TestCase): - """ Test INI format """ - - def setUp(self): - self.client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD, sslverifyhost=False, sslverifypeer=SSLVERIFYPEER) - - self.maxDiff = 5000 - - def tearDown(self): - self.client.disconnect() - - def test_raw(self): - """ raw format """ - - expected_re = """101 code=00a01000 msg="Begin" format="raw" -AUTH.* -CHPWD.* -100 code=00a00100 msg="Ok\"""" - - response = self.client.send_command('HELP') - - self.assertTrue(re.match(expected_re, response.output, re.MULTILINE|re.DOTALL)) - self.assertEqual(response.ret, 100) - - def test_section(self): - """ section format """ - - expected = { - "Global": { - "State": "0", - "RiskHalfLife": "21600", - "RiskTTL": "86400" - }, - "Alarm": { - "Minor": "2", - "Major": "10" - }, - "Sandboxing" : { - "Suspicious": "2", - "Malicious": "50", - "Failed": "0" - }, - "Antivirus": { - "Infected": "100", - "Unknown": "2", - "Failed": "0" - } - } - - response = self.client.send_command('CONFIG HOSTREP SHOW') - - self.assertEqual(response.data, expected) - self.assertEqual(response.ret, 100) - - def test_section_line(self): - """ section_line format """ - - expected = { - 'Object': [ - {'type': 'host', 'global': '0', 'name': '_TestHost1', 'ip': '10.10.5.5', 'modify': '1', 'comment': ''}, - {'type': 'host', 'global': '0', 'name': '_TestHost2', 'ip': '10.10.5.6', 'modify': '1', 'comment': ''}, - {'type': 'host', 'global': '0', 'name': '_TestHost3', 'ip': '10.10.5.7', 'modify': '1', 'comment': ''} - ] - } - - self.client.send_command('CONFIG OBJECT HOST NEW name=_TestHost1 ip=10.10.5.5') - self.client.send_command('CONFIG OBJECT HOST NEW name=_TestHost2 ip=10.10.5.6') - self.client.send_command('CONFIG OBJECT HOST NEW name=_TestHost3 ip=10.10.5.7') - - response = self.client.send_command('CONFIG OBJECT LIST type=host search=_Test* searchfield=name start=0') - - self.client.send_command('CONFIG OBJECT HOST DELETE name=_TestHost1') - self.client.send_command('CONFIG OBJECT HOST DELETE name=_TestHost2') - self.client.send_command('CONFIG OBJECT HOST DELETE name=_TestHost3') - - self.assertEqual(response.data, expected) - self.assertEqual(response.ret, 100) - - def test_list(self): - """ list format """ - - expected = {'Result': ['network_internals', 'labo_networks']} - - response = self.client.send_command('CONFIG WEBADMIN ACCESS SHOW') - - self.assertEqual(response.data, expected) - self.assertEqual(response.ret, 100) - - def test_xml(self): - """ xml text output """ - - expected = """ - - - - - - - -""" - - response = self.client.send_command('CONFIG FILTER EXPLICIT index=1 type=filter output=xml') - - self.assertEqual(response.xml, expected) - self.assertEqual(response.ret, 100) - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..0adaa90 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,281 @@ +"""End-to-end tests against a real appliance. + +Skipped unless the appliance is configured:: + + export APPLIANCE=10.0.0.254 PASSWORD=... [USER=admin] [SERIAL=...] + pytest tests/test_live.py + +These are intentionally locale independent: an appliance answers in the +language of its configuration, so the serverd ``msg`` text is never asserted. +""" + +from __future__ import annotations + +import logging +import os +import random +import string + +import pytest + +from stormshield.sns.sslclient import ServerError, SSLClient + +APPLIANCE = os.getenv("APPLIANCE") or os.getenv("SNS_URL", "") +PASSWORD = os.getenv("PASSWORD") or os.getenv("SNS_PASSWORD", "") +USER = os.getenv("USER_SNS") or os.getenv("SNS_USER", "admin") +SERIAL = os.getenv("SERIAL", "") +SSLVERIFYPEER = os.getenv("SSLVERIFYPEER", "0") == "1" + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(not APPLIANCE, reason="APPLIANCE/SNS_URL must point at a running SNS appliance"), + pytest.mark.skipif(not PASSWORD, reason="PASSWORD/SNS_PASSWORD must be set"), +] + + +@pytest.fixture(scope="module") +def client(): + with SSLClient( + host=APPLIANCE, + user=USER, + password=PASSWORD, + sslverifyhost=False, + sslverifypeer=SSLVERIFYPEER, + timeout=30, + ) as c: + yield c + + +# --- session ---------------------------------------------------------------- + + +def test_connect_opens_a_session(client): + assert client.sessionid + assert client.protocol + assert "admin" in client.sessionlevel or client.sessionlevel + + +def test_context_manager_round_trip(): + with SSLClient( + host=APPLIANCE, user=USER, password=PASSWORD, + sslverifyhost=False, sslverifypeer=SSLVERIFYPEER, + ) as c: + assert c.send_command("SYSTEM PROPERTY").ret == 100 + session = c.session + # __exit__ closed the session; a second disconnect is a no-op + c.disconnect() + assert session is c.session + + +# --- answer formats --------------------------------------------------------- + + +def test_section_format(client): + response = client.send_command("SYSTEM PROPERTY") + + assert response.ret == 100 + assert response.format == "section" + assert response.data["Result"]["Version"] + assert response.data["result"]["version"] == response.data["Result"]["Version"] + assert bool(response) is True + + +def test_section_line_format(client): + response = client.send_command("CONFIG OBJECT LIST type=host start=0") + + assert response.ret == 100 + assert response.format == "section_line" + assert isinstance(response.data["Object"], list) + assert "name" in response.data["Object"][0] + + +def test_list_format(client): + response = client.send_command("CONFIG WEBADMIN ACCESS SHOW") + + assert response.ret == 100 + assert response.format == "list" + assert isinstance(response.data["Result"], list) + + +def test_raw_format(client): + response = client.send_command("HELP") + + assert response.ret == 100 + assert response.format == "raw" + assert "AUTH" in response.data + + +def test_xml_format(client): + response = client.send_command("CONFIG FILTER EXPLICIT index=1 type=filter output=xml") + + assert response.ret == 100 + assert response.format == "xml" + assert response.xml.startswith(" {download}") + assert response.ret == 100 + + assert download.read_bytes() == content + assert not list(tmp_path.glob("*.part")) + + +def test_download_of_a_large_payload(client, tmp_path): + """A backup is big enough to exercise chunked CRC accumulation.""" + + target = tmp_path / "backup.na" + try: + response = client.send_command(f"CONFIG BACKUP list=all > {target}") + except ServerError as exc: + pytest.skip(f"backup not available on this appliance: {exc}") + + assert response.ret == 100 + assert target.stat().st_size > 1024 + assert not list(tmp_path.glob("*.part")) + + +# --- upload must not poison the session ------------------------------------- + + +def test_commands_still_work_after_an_upload(client, tmp_path): + """1.x left the multipart Content-Type on the session after an upload.""" + + payload = tmp_path / "slot" + payload.write_bytes(b"[Filter]\n pass from any to any\n") + + client.send_command(f"CONFIG SLOT UPLOAD slot=1 name=testUpload < {payload}") + + assert "Content-Type" not in client.headers + response = client.send_command("SYSTEM PROPERTY") + assert response.ret == 100 + assert response.format == "section" + + +# --- logging ---------------------------------------------------------------- + + +def test_library_logs_under_its_own_namespace(client, caplog): + with caplog.at_level(logging.DEBUG, logger="stormshield.sns.sslclient"): + client.send_command("SYSTEM PROPERTY") + + assert caplog.records + assert all(r.name.startswith("stormshield.sns.sslclient") for r in caplog.records) + + +# --- answer metadata -------------------------------------------------------- + + +def test_paged_answer_reports_truncation(client, caplog): + """`CONFIG OBJECT LIST` caps its rows while announcing the real total.""" + + with caplog.at_level(logging.WARNING, logger="stormshield.sns.sslclient"): + response = client.send_command("CONFIG OBJECT LIST type=host start=0") + + assert response.ret == 100 + if response.total is None: + pytest.skip("this appliance does not announce a total") + + if response.count < response.total: + assert response.truncated is True + assert any("page with the command's start argument" in r.getMessage() for r in caplog.records) + else: + assert response.truncated is False + + +def test_paging_reaches_every_row(client): + """Following `start` until `truncated` is False must yield `total` rows.""" + + first = client.send_command("CONFIG OBJECT LIST type=host start=0") + if first.total is None or not first.truncated: + pytest.skip("nothing to page on this appliance") + + seen, start = first.count, first.count + for _ in range(20): + page = client.send_command(f"CONFIG OBJECT LIST type=host start={start}") + if page.count == 0: + break + seen += page.count + start += page.count + if not page.truncated: + break + + assert seen == first.total + + +def test_answer_without_metadata_is_not_truncated(client): + response = client.send_command("SYSTEM PROPERTY") + + assert response.meta == {} + assert response.total is None + assert response.truncated is False + + +def test_json_round_trip_on_a_real_answer(client): + import json + + response = client.send_command("CONFIG OBJECT LIST type=host start=0") + + assert json.loads(response.json()) == response.to_dict() + with pytest.raises(TypeError): + json.dumps(response.data) diff --git a/tests/test_live_auth.py b/tests/test_live_auth.py new file mode 100644 index 0000000..9851559 --- /dev/null +++ b/tests/test_live_auth.py @@ -0,0 +1,116 @@ +"""Connection and authentication modes against a real appliance. + +Each test skips on its own unless the matching environment variables are set:: + + APPLIANCE ip/hostname of a running SNS appliance (all tests) + PASSWORD appliance password (all but the cert test) + SERIAL appliance serial, i.e. certificate CN (host name check) + FQDN appliance fqdn (cabundle tests) + CABUNDLE CA bundle file in PEM format (cabundle tests) + CERT user certificate file (certificate auth) + PROXY proxy url (proxy test) +""" + +from __future__ import annotations + +import os + +import pytest + +from stormshield.sns.sslclient import SSLClient + +APPLIANCE = os.getenv("APPLIANCE") or os.getenv("SNS_URL", "") +PASSWORD = os.getenv("PASSWORD") or os.getenv("SNS_PASSWORD", "") +USER = os.getenv("USER_SNS") or os.getenv("SNS_USER", "admin") +SERIAL = os.getenv("SERIAL", "") +FQDN = os.getenv("FQDN", "") +CABUNDLE = os.getenv("CABUNDLE", "") +CERT = os.getenv("CERT", "") +PROXY = os.getenv("PROXY", "") + +pytestmark = [ + pytest.mark.live, + pytest.mark.skipif(not APPLIANCE, reason="APPLIANCE/SNS_URL must point at a running SNS appliance"), +] + +needs_password = pytest.mark.skipif(not PASSWORD, reason="PASSWORD/SNS_PASSWORD must be set") + + +def check(client: SSLClient) -> None: + """A connected client must answer a trivial command.""" + + try: + assert client.send_command("LIST").ret == 100 + finally: + client.disconnect() + + +@needs_password +@pytest.mark.skipif(not SERIAL, reason="SERIAL must be set to the appliance serial number") +def test_host_name_check_against_the_certificate_cn(): + """Factory certificates carry the serial in CN and have no subjectAltName.""" + + check( + SSLClient( + host=SERIAL, ip=APPLIANCE, user=USER, password=PASSWORD, + sslverifyhost=True, sslverifypeer=os.getenv("SSLVERIFYPEER", "0") == "1", + ) + ) + + +@needs_password +def test_host_name_check_can_be_disabled(): + check( + SSLClient( + host=APPLIANCE, user=USER, password=PASSWORD, + sslverifyhost=False, sslverifypeer=False, + ) + ) + + +@needs_password +def test_untrusted_ca_is_rejected_by_default(): + """sslverifypeer defaults to True, so an unknown CA must fail.""" + + import requests + + with pytest.raises((requests.exceptions.SSLError, requests.exceptions.ConnectionError)): + SSLClient(host=APPLIANCE, user=USER, password=PASSWORD) + + +@needs_password +@pytest.mark.skipif(not (FQDN and CABUNDLE), reason="FQDN and CABUNDLE must be set") +def test_cabundle(): + check( + SSLClient( + host=FQDN, ip=APPLIANCE, user=USER, password=PASSWORD, + sslverifyhost=True, cabundle=CABUNDLE, + ) + ) + + +@pytest.mark.skipif(not (FQDN and CABUNDLE and CERT), reason="FQDN, CABUNDLE and CERT must be set") +def test_user_certificate_authentication(): + check(SSLClient(host=FQDN, ip=APPLIANCE, usercert=CERT, sslverifyhost=True, cabundle=CABUNDLE)) + + +@needs_password +@pytest.mark.skipif(not PROXY, reason="PROXY must be set to a proxy url") +def test_proxy(): + check( + SSLClient( + host=APPLIANCE, user=USER, password=PASSWORD, + sslverifypeer=False, sslverifyhost=False, proxy=PROXY, + ) + ) + + +@needs_password +def test_wrong_password_is_rejected(): + from stormshield.sns.sslclient import AuthenticationError + + with pytest.raises(AuthenticationError): + SSLClient( + host=APPLIANCE, user=USER, password=PASSWORD + "-wrong", + sslverifyhost=False, sslverifypeer=False, + ) diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 0000000..c96f37c --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,270 @@ +"""Answer metadata and JSON serialisation. + +The appliance carries row counts and truncation flags as attributes of the +```` node. They are not rows, so they are not in `data`; 1.x dropped +them entirely, which made a paged answer indistinguishable from a complete one. +""" + +from __future__ import annotations + +import json + +import pytest + +from stormshield.sns.sslclient import Response + +from .conftest import load + + +def build(serverd_attrs: str = "", body: str = "", fmt: str = "section_line") -> str: + return ( + '' + f'' + f'
{body}
' + '
' + ) + + +def rows(n: int) -> str: + return "".join(f'' for i in range(n)) + + +# --- meta ------------------------------------------------------------------- + + +def test_meta_exposes_the_serverd_attributes(): + r = Response.from_xml(load("object_list_host")) + + assert r.meta == { + "total": "134", + "data_changed": "0", + "too_many_data": "0", + "not_enough_space": "0", + } + + +def test_meta_excludes_the_status_attributes(): + r = Response.from_xml(load("object_list_host")) + + assert "ret" not in r.meta + assert "code" not in r.meta + assert "msg" not in r.meta + + +def test_meta_is_empty_when_the_appliance_sends_none(): + assert Response.from_xml(load("system_property")).meta == {} + assert Response.from_xml(load("unknown_command")).meta == {} + assert Response().meta == {} + + +# --- total / count / truncated ---------------------------------------------- + + +def test_total_is_an_int(): + assert Response.from_xml(load("object_list_host")).total == 134 + + +def test_total_is_none_without_the_attribute(): + assert Response.from_xml(load("system_property")).total is None + + +def test_total_ignores_a_non_numeric_value(): + assert Response.from_xml(build('total="lots"', rows(3))).total is None + + +def test_count_counts_the_returned_rows(): + r = Response.from_xml(load("object_list_host")) + + assert r.count == 100 + assert r.count == len(r.data["Object"]) + + +def test_count_is_none_for_non_row_formats(): + assert Response.from_xml(load("system_property")).count is None # section + assert Response.from_xml(load("help_raw")).count is None # raw + + +def test_count_sums_every_section(): + xml = ( + '' + '' + f'
{rows(2)}
{rows(3)}
' + "
" + '
' + ) + assert Response.from_xml(xml).count == 5 + + +def test_truncated_when_fewer_rows_than_total(): + """The real case: 100 of 134 objects, with no error reported.""" + + r = Response.from_xml(load("object_list_host")) + + assert r.truncated is True + assert r.count < r.total + + +def test_not_truncated_when_every_row_is_there(): + assert Response.from_xml(build('total="3"', rows(3))).truncated is False + + +def test_not_truncated_without_metadata(): + assert Response.from_xml(load("system_property")).truncated is False + assert Response.from_xml(load("unknown_command")).truncated is False + assert Response().truncated is False + + +def test_truncated_on_too_many_data_flag(): + assert Response.from_xml(build('total="2" too_many_data="1"', rows(2))).truncated is True + + +def test_truncated_on_not_enough_space_flag(): + assert Response.from_xml(build('total="2" not_enough_space="1"', rows(2))).truncated is True + + +def test_truncated_is_false_when_flags_are_zero(): + xml = build('total="2" too_many_data="0" not_enough_space="0"', rows(2)) + assert Response.from_xml(xml).truncated is False + + +# --- JSON ------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name", + ["system_property", "hostrep_show", "ntp_server_list", "webadmin_access", + "object_list_host", "help_raw", "filter_xml", "unknown_command"], +) +def test_json_is_valid_for_every_format(name): + """`data` itself is a CaseInsensitiveDict, which json.dumps refuses.""" + + r = Response.from_xml(load(name)) + + assert json.loads(r.json()) == json.loads(json.dumps(r.to_dict())) + + +def test_raw_data_is_not_json_serialisable(): + """Documents why to_dict()/json() exist.""" + + r = Response.from_xml(load("system_property")) + + with pytest.raises(TypeError, match="CaseInsensitiveDict"): + json.dumps(r.data) + + +def test_to_dict_returns_plain_containers(): + r = Response.from_xml(load("ntp_server_list")) + decoded = r.to_dict() + + assert type(decoded) is dict + assert type(decoded["Result"]) is list + assert type(decoded["Result"][0]) is dict + + +def test_json_keeps_unicode_readable_by_default(): + xml = build("", '') + r = Response.from_xml(xml) + + assert "éèà et ✓" in r.json() + assert "\\u00e9" in r.json(ensure_ascii=True) + + +def test_json_forwards_kwargs(): + r = Response.from_xml(load("ntp_server_list")) + + assert "\n" in r.json(indent=2) + + +def test_every_value_is_a_string(): + """The appliance sends XML attributes, so no typing survives the wire.""" + + r = Response.from_xml(load("system_property")) + + assert all(isinstance(v, str) for v in r.to_dict()["Result"].values()) + + r = Response.from_xml(load("object_list_host")) + assert all(isinstance(v, str) for row in r.to_dict()["Object"] for v in row.values()) + + +def test_json_matches_parser_serialize_data(): + """`to_dict()` is the same thing `parser.serialize_data()` produced.""" + + r = Response.from_xml(load("object_list_host")) + + assert r.to_dict() == r.parser.serialize_data() + + +# --- paging must terminate -------------------------------------------------- + + +def paged(start: int, n: int, total: int) -> Response: + """An answer covering rows [start, start+n) out of `total`.""" + + r = Response.from_xml(build(f'total="{total}"', rows(n))) + r.offset = start + return r + + +def test_last_page_is_not_truncated(): + """offset+count == total, so nothing remains even though count < total.""" + + r = paged(start=100, n=34, total=134) + + assert r.count == 34 + assert r.count < r.total # the naive comparison would say "more to fetch" + assert r.truncated is False + + +def test_page_past_the_end_is_not_truncated(): + """The appliance still reports the full total alongside zero rows. + + Without this, `while response.truncated` never terminates. + """ + + r = paged(start=134, n=0, total=134) + + assert r.count == 0 + assert r.truncated is False + + +def test_first_page_of_several_is_truncated(): + assert paged(start=0, n=100, total=134).truncated is True + + +def test_middle_page_is_truncated(): + assert paged(start=100, n=100, total=300).truncated is True + + +def test_unknown_offset_falls_back_to_zero(): + """Without a `start=` in the command the answer is assumed to be page one.""" + + r = Response.from_xml(build('total="134"', rows(100))) + + assert r.offset is None + assert r.truncated is True + + +def test_paging_loop_terminates(): + """The documented loop must converge, and collect every row exactly once.""" + + total, page_size = 134, 100 + collected, start, guard = 0, 0, 0 + + while True: + guard += 1 + assert guard < 10, "paging loop did not terminate" + n = max(0, min(page_size, total - start)) + response = paged(start=start, n=n, total=total) + collected += response.count + if not response.truncated: + break + start += response.count + + assert collected == total + assert guard == 2 # 100 + 34, no wasted empty request + + +def test_zero_rows_with_a_zero_total_is_not_truncated(): + r = paged(start=0, n=0, total=0) + + assert r.truncated is False diff --git a/tests/test_proxy.py b/tests/test_proxy.py deleted file mode 100644 index 013d289..0000000 --- a/tests/test_proxy.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python - -import os -import unittest -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -PASSWORD = os.getenv('PASSWORD', "") -PROXY = os.getenv('PROXY', "") - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -@unittest.skipIf(PROXY=="", "PROXY env var must be set to proxy url") -class TestProxy(unittest.TestCase): - """ Test proxy option """ - - def test_sslverifyhost(self): - """ Test proxy option """ - - try: - client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD, sslverifypeer=False, proxy=PROXY) - self.assertTrue(1==1, "SSLClient connects with proxy") - except: - self.fail("SSLClient did not connect") - - response = client.send_command('LIST') - self.assertEqual(response.ret, 100) - - client.disconnect() diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 0000000..1e79f36 --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,194 @@ +"""Decoding of appliance answers, against real captured XML.""" + +from __future__ import annotations + +import pytest + +from stormshield.sns.configparser import ConfigParser, serialize +from stormshield.sns.sslclient import Response, format_output + +from .conftest import load + + +def test_section_format(): + r = Response.from_xml(load("system_property")) + + assert r.ret == 100 + assert r.format == "section" + assert r.data["Result"]["Model"] == "EVA2" + assert r.data["Result"]["Version"] == "4.8.15" + # keys are case insensitive + assert r.data["result"]["VERSION"] == "4.8.15" + assert bool(r) is True + + +def test_section_format_multiple_sections(): + r = Response.from_xml(load("hostrep_show")) + + assert r.format == "section" + assert set(serialize(r.data)) == {"Global", "Alarm", "Sandboxing", "Antivirus"} + assert r.data["Alarm"]["Minor"] == "2" + assert r.data["Antivirus"]["Infected"] == "100" + + +def test_section_line_format(): + r = Response.from_xml(load("ntp_server_list")) + + assert r.format == "section_line" + assert r.data["Result"][0]["keynum"] == "none" + assert r.data["Result"][0]["type"] == "host" + assert len(r.data["Result"]) == 2 + + +def test_list_format(): + r = Response.from_xml(load("webadmin_access")) + + assert r.format == "list" + assert r.data["Result"] == ["network_internals", "labo_networks"] + + +def test_raw_format(): + r = Response.from_xml(load("help_raw")) + + assert r.format == "raw" + assert isinstance(r.data, str) + assert r.data.startswith("AUTH") + # the trailing newline of a raw payload is preserved (1.x dropped it) + assert r.data.endswith("\n") + + +def test_xml_format(): + r = Response.from_xml(load("filter_xml")) + + assert r.format == "xml" + assert isinstance(r.data, str) + assert r.data.startswith("") + + +def test_error_answer_has_no_payload(): + r = Response.from_xml(load("unknown_command")) + + assert r.ret == 200 + assert r.format is None + assert bool(r) is False + assert serialize(r.data) == {} + + +def test_privilege_error(): + r = Response.from_xml(load("system_information")) + + assert r.ret == 205 + assert bool(r) is False + + +def test_multiline_answer_reports_final_status(): + """ret/code/msg come from the last serverd node, serverd_code from the first.""" + + r = Response.from_xml(load("system_property")) + + assert r.ret == 100 # last node + assert r.code == "00a00100" + assert r.serverd_code == "00a01000" # first node, carries the transfer state + + +@pytest.mark.parametrize( + "name", + [ + "system_property", "hostrep_show", "monitor_stat", "nettoken", + "ntp_server_list", "object_list_host", "user_list", "webadmin_access", + "unknown_command", "bad_args", "system_information", + ], +) +def test_xml_and_text_paths_agree(name): + """The XML-direct decoding must match parsing the rendered ini text. + + This is the contract that let 2.0 stop round-tripping every answer + through its text rendering. + """ + + xml = load(name) + from_xml = Response.from_xml(xml) + from_text = ConfigParser(format_output(xml)) + + assert from_xml.format == from_text.format + assert serialize(from_xml.data) == serialize(from_text.data) + + +@pytest.mark.parametrize("name", ["system_property", "object_list_host", "webadmin_access", "help_raw"]) +def test_output_rendering_is_stable(name): + """`output` keeps the exact 1.x ini rendering.""" + + xml = load(name) + assert Response.from_xml(xml).output == format_output(xml) + + +def test_output_is_lazy(): + """Reading `data` must not build the ini rendering.""" + + r = Response.from_xml(load("user_list")) + assert "output" not in r.__dict__ + _ = r.data + assert "output" not in r.__dict__ + _ = r.output + assert "output" in r.__dict__ + + +def test_parser_reuses_decoded_data(): + """`response.parser` must not re-parse anything.""" + + r = Response.from_xml(load("system_property")) + assert r.parser.get("Result", "Model") == "EVA2" + assert r.parser.format == "section" + assert "output" not in r.__dict__ + + +def test_get_shortcut(): + r = Response.from_xml(load("system_property")) + + assert r.get("Result", "Model") == "EVA2" + assert r.get("Result", "Nope", default="fallback") == "fallback" + assert r.get("Nope", "Model", default=None) is None + + +def test_serialize_data_is_json_ready(): + import json + + r = Response.from_xml(load("ntp_server_list")) + assert json.loads(json.dumps(r.parser.serialize_data()))["Result"][0]["type"] == "host" + + +def test_repr_does_not_dump_the_payload(): + """1.x __repr__ returned the whole output, and crashed when it was None.""" + + r = Response.from_xml(load("user_list")) + assert len(repr(r)) < 100 + assert "ret=100" in repr(r) + assert repr(Response()) # no output, must not raise + + +def test_str_returns_output(): + xml = load("system_property") + assert str(Response.from_xml(xml)) == format_output(xml) + + +def test_empty_answer_is_rejected(): + with pytest.raises(ValueError): + Response.from_xml('') + + +def test_response_without_payload_is_usable(): + """A hand-built Response (as returned by download()) still decodes.""" + + r = Response(ret=100, code="00a00100", msg="OK", output='100 code=00a00100 msg="Ok"') + + assert r.output == '100 code=00a00100 msg="Ok"' + assert r.format is None + assert bool(r) is True + + +def test_bool_is_true_for_warnings(): + assert bool(Response(ret=110)) is True + assert bool(Response(ret=111)) is True + assert bool(Response(ret=100)) is True + assert bool(Response(ret=200)) is False + assert bool(Response(ret=99)) is False diff --git a/tests/test_utf8.py b/tests/test_utf8.py deleted file mode 100644 index 08c5132..0000000 --- a/tests/test_utf8.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals -import os -import sys -import unittest - -from stormshield.sns.sslclient import SSLClient - -APPLIANCE = os.getenv('APPLIANCE', "") -PASSWORD = os.getenv('PASSWORD', "") -SSLVERIFYPEER = os.getenv('SSLVERIFYPEER', "1") == "1"; - -@unittest.skipIf(APPLIANCE=="", "APPLIANCE env var must be set to the ip/hostname of a running SNS appliance") -@unittest.skipIf(PASSWORD=="", "PASSWORD env var must be set to the firewall password") -class TestUtf8(unittest.TestCase): - """ Test INI format """ - - def setUp(self): - self.client = SSLClient(host=APPLIANCE, user='admin', password=PASSWORD, sslverifyhost=False, sslverifypeer=SSLVERIFYPEER) - self.client.send_command('CONFIG OBJECT HOST NEW type=host name=hostutf8 ip=1.2.3.4 comment="comment with utf8 characters éè\u2713"') - - self.maxDiff = 5000 - - def tearDown(self): - self.client.send_command('CONFIG OBJECT HOST delete name=hostutf8') - self.client.disconnect() - - def test_utf8(self): - """ send and receive utf-8 content """ - - expected = """101 code=00a01000 msg="Begin" format="section_line" -[Object] -type=host global=0 name=hostutf8 ip=1.2.3.4 modify=1 comment="comment with utf8 characters éè\u2713" type=host -100 code=00a00100 msg="Ok\"""" - - response = self.client.send_command('CONFIG OBJECT LIST type=host search=hostutf8 start=0') - - self.assertEqual(response.output, expected) - self.assertEqual(response.ret, 100) - - -if __name__ == '__main__': - unittest.main() diff --git a/tox.ini b/tox.ini index 83ed72a..21acbcf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,27 @@ [tox] -env_list = py3-urllib{1,2} +env_list = py{310,311,312,313}, lint +isolated_build = true [testenv] -description = run unit tests +description = run the offline unit tests +extras = cli, test +commands = pytest {posargs:tests} +passenv = * + +[testenv:live] +description = run the tests against a real appliance (needs APPLIANCE and PASSWORD) +extras = cli, test +commands = pytest -m live {posargs:tests} +passenv = * + +[testenv:lint] +description = ruff + mypy +skip_install = true deps = - pytest - urllib1: urllib3>=1.25.0,<2.0.0 - urllib2: urllib3>=2.0.0 -commands = - pytest {posargs:tests} -passenv = * \ No newline at end of file + ruff + mypy + types-requests + types-defusedxml +commands = + ruff check . + mypy