sfos-rs [--format text|json] <COMMAND> [ARGS]
Binary: sfos-rs (build with cargo build --release → target/release/sfos-rs).
| Flag | Values | Default | Effect |
|---|---|---|---|
--format |
text, json |
text |
Output format for every command. JSON is pretty-printed and stable for scripting. |
| Code | Meaning |
|---|---|
0 |
Success. For check/s2s: no HIGH/CRIT findings. For trace/path/site-path: verdict DELIVERED. For apply --commit: every change applied. |
1 |
Any error (printed as sfos-rs: <message> on stderr), or a HIGH/CRIT finding (check, s2s), or a BLOCKED verdict (trace, path, site-path), or at least one failed change (apply --commit). |
verify, parse, dump, search, graph, report, iac, entities,
fetch, get, export always exit 0 unless an error occurs — they inform,
they don't gate.
Offline commands take a FILE argument: a backup Entities.xml (System →
Backup & firmware on the firewall; extract the .tar) or any XML API
<Response> body (e.g. saved get --raw / export --raw output). Parse
errors are reported as sfos-rs: <path>: <reason> with exit 1.
Parse and print object counts (zones, firewall rules, IP hosts, IP host groups, services). Quick sanity check that a file is readable.
Dump parsed objects. With no selection flags, all four sections are
printed. Flags are additive (--zones --rules prints just those two).
Text format per line:
- zone:
name (type) - rule:
[on|off] name action srcZones→dstZones svc:[services](anywhen a zone list is empty) - host:
name [IP|Network|IPRange] address(ip/mask,start-end, or-when empty) - service:
name (type)
Find firewall rules either:
--referencing <OBJECT>— rules referencing a host / network / service / zone by name (membership in any rule field), or--from <ZONE> --to <ZONE>— rules for a zone pair (both flags required).
Passing neither (or only one of --from/--to) errors:
specify --referencing <object>, or both --from <zone> and --to <zone>.
No matches is not an error (prints no matching rules, exits 0).
Baseline hygiene checks. Exits 1 if any HIGH/CRIT finding exists.
| Check ID | Severity | Trigger |
|---|---|---|
SFOS-UNDEFINED-ZONE |
HIGH | a rule references a zone that is not defined |
SFOS-DISABLED-RULE |
INFO | rule disabled — dead configuration |
SFOS-WAN-INBOUND-NO-IPS |
MEDIUM | WAN-sourced accept rule with no intrusion-prevention policy |
SFOS-RULE-NO-LOG |
LOW | accept rule does not log traffic |
Output line: [SEVERITY] CHECK-ID object — message, then a summary count.
Simulate one packet against the synthesized rule set for a zone pair.
| Flag | Default | Notes |
|---|---|---|
--from / --to |
(required) | source / destination zone names |
--proto |
tcp |
tcp, udp, or icmp |
--dport |
0 |
give a real port to match port-scoped services |
--src / --dst |
0.0.0.0 |
give real IPs to match address-scoped rules |
If no rule covers the zone pair, the SFOS implicit default-drop applies
(rule set <none>, matched: no rule — default action). Verdict line:
verdict: Accept|Drop → DELIVERED|BLOCKED. Exits 1 when BLOCKED.
Detect shadowed rules per zone-pair rule set. Two kinds:
unreachable— an earlier rule with the same action fully covers it (dead weight),overridden— an earlier rule with a different action fully covers it (the later rule's intent never takes effect — investigate).
Output: [SHADOW] rule set <pair>: rule <seq> unreachable|overridden by rule <seq>.
Detection is sound but incomplete: it reports single-rule total shadowing; it
cannot see a rule shadowed only by a combination of earlier rules. Always
exits 0 (informational).
Zone-reachability graph from enabled accept rules. Edge label = the union of
services allowed between the two zones. Empty zone lists render as the
pseudo-node Any.
- default: Graphviz DOT (
dot -Tsvg) --mermaid: Mermaidgraph LR(paste into mermaid.live or a Markdown doc)--format json:[{from, to, services[], from_wan, self_loop}, …]edge list (--mermaidignored)
The rendered views (DOT, Mermaid) are tuned for legibility: self-loops and
the uninformative any label are dropped (a bare arrow already means
"allowed"), zones with no accept rules are parked in a side bucket, and
WAN-sourced edges — the inbound-exposure ones — are coloured red. Mermaid
labels are quoted, so service names containing ( ) , (e.g. SMTP(S))
render instead of tripping the parser, and node IDs are sanitised with the real
zone name carried as a display label. The JSON view is the faithful,
complete export — every edge, self-loops included — for programmatic use.
Differential reachability — answers "why can zone A reach this server but zone B can't?". Evaluates the rule base from multiple source-zone vantages and names the deciding rule for each.
| Flag | Default | Notes |
|---|---|---|
--to |
(required) | destination IP, or an IPHost object name to resolve |
--proto |
tcp |
tcp, udp, icmp |
--dport |
443 |
ignored for icmp |
--from |
every zone in the config | repeatable; evaluate only these zones |
--src |
— | infer the source zone from interface addressing (used when --from is omitted) |
DNAT-aware: if the destination is DNAT'd, the firewall verdict is evaluated
against the internal (translated) host and the NAT rule is reported.
When vantages disagree it prints the divergence and a concrete fix
(X is allowed by rule 'R' (source zones: …); Y is not. Add the zone(s) to that rule's source zones, or add an equivalent rule.), plus all related
rules that touch the destination/service. Always exits 0.
Single-firewall end-to-end packet trace:
ingress → DNAT → route → firewall → SNAT. Defaults: --proto tcp,
--dport 443. --to accepts an IPHost object name.
Stages printed in order, e.g.:
ingress: 10.1.0.20 -> zone LAN
dnat: 203.0.113.10 -> 10.0.10.5 (rule DNAT-web)
route: ... / route: no route to <dst> / route: not modelled (no interfaces/routes in config)
result: DELIVERED | BLOCKED
Exits 1 when BLOCKED. JSON includes ingress_zone, egress_zone,
egress_interface, nat, snat, the firewall verdict, and all stages.
Cross-firewall trace over a site-to-site IPsec tunnel: the flow is evaluated
through site A's firewall, matched against A's tunnels, then checked for a
mirrored tunnel and evaluated through site B's firewall. Defaults:
--proto tcp, --dport 443. --src/--to must be IPs (no object
resolution here — names differ across boxes).
Convention: traffic arriving over IPsec is evaluated as entering zone VPN
(the SFOS default for IPsec). Distinctive stage messages:
<site-a>: no site-to-site tunnel covers <dst> — not routed off-site
tunnel: <site-b> has no matching tunnel for <src> -> <dst> (asymmetric or missing)
Exits 1 when BLOCKED. JSON includes tunnel_a, tunnel_b, paired,
delivered, stages.
Site-to-site IPsec audit across one or more configs. For each config it reports single-firewall VPN posture; for every unique pair of configs it pairs tunnels by IP-space overlap (local subnets of one end overlap the remote subnets of the other) and checks symmetry. Subnets are resolved to CIDRs via each firewall's own host objects, so naming differences between boxes don't cause false mismatches.
| Check ID | Severity | Trigger |
|---|---|---|
S2S-UNPAIRED |
HIGH | a site-to-site tunnel has no overlapping counterpart on the peer |
S2S-SUBNET-ASYMMETRY |
HIGH | paired tunnels' local/remote subnet sets are not exact mirrors |
S2S-AUTH-MISMATCH |
MEDIUM | authentication type differs between the two ends |
S2S-IKE-MISMATCH |
MEDIUM | IKE version differs between the two ends |
VPN-IKEV1 |
MEDIUM | (per-site posture) connection negotiates IKEv1 — migrate to IKEv2 |
Output: [SEVERITY] CHECK-ID object — message where object is
site/tunnel for posture findings and tunnelA↔tunnelB for pair findings.
Exits 1 if any HIGH/CRIT finding.
Granular per-subsystem state report: object summary, IPsec tunnel inventory
(name, ends, subnets, IKE version, auth), and findings. The site name is
taken from the file stem (site-a.xml → site-a). --format json emits the
full structured report.
Emit version-controllable IaC from a parsed config:
- default: normalized declarative JSON (stable field order — diff-friendly; commit it to git and diff between exports),
--ansible: asophos.sophos_firewallAnsible playbook skeleton.
List the built-in XML API entity catalogue (70 entities across 16 SFOS menu
categories — see sdk-guide.md). Text output
groups by category; JSON emits {category, display, tag} objects. Offline —
does not contact any firewall.
All live commands share the connection flags:
| Flag | Default | Notes |
|---|---|---|
--host |
(required) | firewall hostname or IP |
--port |
4444 |
XML API / webadmin port |
--user |
(required) | admin username |
--password |
env SFOS_PASSWORD |
error if neither is provided |
--insecure |
off | skip TLS certificate verification (SFOS ships a self-signed cert) |
Endpoint: https://<host>:<port>/webconsole/APIController, 30-second request
timeout. The API must be enabled on the firewall and your source IP
allowlisted (Backup & firmware → API) — see
playbooks.md.
Pull the typed entity set (Zone, IPHost, IPHostGroup, Services, FirewallRule)
from a live firewall and print the same summary as parse. Connectivity +
credential smoke test.
Fetch one entity type — any XML API tag, including tags not in the
catalogue (get SSLTLSInspectionRule, get DNS, …). Default output is a
generic XML→JSON conversion; --raw prints the firewall's raw XML response.
Pull every catalogued entity (70 Get requests). Resilient: each
entity's result is captured independently, so one failure (or an entity that
doesn't exist on that model/firmware) never aborts the export.
- default: one combined JSON document on stdout (failed entities appear as
{"error": …}values) --raw: concatenated raw XML on stdout with<!-- Tag -->separators--out-dir <DIR>: one<Tag>.json(or.xmlwith--raw) file per entity; creates the directory; overwrites same-named files
Summary on stderr: exported N entities (M unavailable).
Try one or more arbitrary XML API tags and report, per tag, whether the
firewall responded and how many <Tag> elements came back (or the error).
For validating a hypothesized entity name before adding it to the registry —
e.g. probe --host fw --user admin AccessPoint WirelessAP WirelessAccessPointStatus to find the real tag for a section the registry
only has a best-effort guess for (see the Wireless category in entities).
central-events and central-alerts talk to the Sophos Central cloud,
not the firewall's on-box XML API — a separate product surface for tenants
that manage Access Points (or anything else) through Central rather than a
firewall's own Wireless subsystem.
sfos-rs central-events [--client-id <ID>] [--client-secret <SECRET>] [--tenant-id <ID>] \
[--state-file <PATH>] [--since-hours N] [--wireless-only] [--limit N]
sfos-rs central-alerts [...same flags...]
| Flag | Default | Notes |
|---|---|---|
--client-id / --client-secret |
env SFOS_CENTRAL_CLIENT_ID / SFOS_CENTRAL_CLIENT_SECRET |
from Central → Global Settings → API Credentials Management |
--tenant-id |
env SFOS_CENTRAL_TENANT_ID, else resolved via whoami |
required for partner/organization credentials scoped to more than one tenant |
--state-file |
state/central-cursor.json |
cursor is saved here after every run and resumed on the next one |
--since-hours |
12 |
only used on the first run (no saved cursor yet); the API caps this at 24h |
--wireless-only |
off | best-effort client-side filter on the event type field — see caveat below |
--limit |
1000 |
page size |
Each run authenticates (OAuth2 client-credentials grant), resolves the
tenant/region via whoami/v1 if --tenant-id wasn't given, fetches one
page from /siem/v1/events or /siem/v1/alerts, and updates the state
file's cursor — run it again (e.g. from cron) to keep advancing.
--wireless-only is unconfirmed. Central's events/alerts endpoints have
no server-side product/category filter, so this matches the type field
client-side (sfos_sdk::central::is_wireless_event) against a guessed
naming convention. Validate it against a live tenant with an enrolled Access
Point before relying on it — the wireless event type Sophos actually emits
may not match.
sfos-rs apply <DESIRED> (--live <FILE> | --host <H> --user <U> [...]) [--prune] [--commit]
Terraform-style workflow over the writable entity types (Zone, IPHost,
IPHostGroup, Services, FirewallRule). <DESIRED> is the desired-state config
(an Entities.xml). The "live" side is either:
--live <FILE>— a saved config: offline plan only (--commitis rejected:--commit requires --host (cannot write changes to a --live file)), or--host …— a live firewall (requires--user, and--passwordorSFOS_PASSWORD; same--port/--insecureflags as above). The live state is fetched first, then diffed.
| Flag | Default | Effect |
|---|---|---|
--prune |
off | also REMOVE live objects absent from the desired config — destructive, see safety.md |
--commit |
off | actually transmit the changes; without it the plan is a pure dry run |
Plan output (text): one line per change + ADD / ~ UPDATE / - REMOVE,
followed by the exact <Set operation="…">…</Set> body that would be sent,
and a summary N change(s): A add, U update, R remove. Matching is by object
name (case-insensitive); an object whose serialized XML differs becomes an
UPDATE. JSON format emits {action, tag, name, operation, xml} objects.
Without --commit it ends with (dry run — re-run with --host … --commit to apply) and exits 0. With --commit, changes are sent one at a time in
plan order; failures don't stop the run — each is reported as
! ACTION name failed: <error>, the summary applied N change(s), M failed
goes to stderr, and the exit code is 1 if anything failed. There is no
rollback; see safety.md before using it.