Hub tree re-sanitized across all ConnectWise skills + references; history rebuilt so the unsanitized security-report blob (007dc40) and the wholesale hub expansion (b42a8e3) are no longer reachable (scrub decision in issue #31 comment 139, option b).
15 KiB
name, title, author, version, description, trigger
| name | title | author | version | description | trigger |
|---|---|---|---|---|---|
| security-report | One-Command Security Report Generator | TinMan | 1.1.0 | Generate combined Nexpose + firewall + endpoint + alerts report. | User says 'security report for <company>' or asks to generate combined vulnerability + firewall + endpoint health + active alerts assessment where only the customer name is provided. |
One-Command Security Report
When to use
- User says:
security report <company>,run security report for <company>,generate combined report for <company> - Any request to produce a combined vulnerability + firewall assessment where only the customer name is provided.
Shortcut invocation
This skill is a thin orchestrator. It does not replace sophos-xg-config-audit; it delegates to it after doing name resolution.
Step 1: Resolve company name
- Query ConnectWise PSA
/company/companieswithnameLike(case-insensitive). - Also query PostgreSQL
vistanet_registry.customer_system_map(canonical table — notmaster_customer_registry) forcanonical_name ILIKE '%<input>%'. - Return the best match. If ambiguous (multiple matches), ask user to clarify.
- Capture:
connectwise_company_id,canonical_name,sophos_tenant_id,nexpose_site_id,nextcloud_slug.
Step 2: Create kanban ticket
hermes kanban --board <ops-board> create "<Company>: Combined Security Report" \
--body "Nexpose + Sophos XG firewall correlation per sophos-xg-config-audit skill." --json
hermes kanban claim <task_id>
CLI notes: the title is POSITIONAL — --title is rejected (unrecognized arguments); there is no --tag or --idempotency-key flag. Verified 2026-10-28.
Step-by-step workflow
Step 1: Resolve company name
- Query ConnectWise PSA
/company/companieswithnameLike(case-insensitive). - Also query PostgreSQL
vistanet_registry.customer_system_map(canonical table — notmaster_customer_registry) forcanonical_name ILIKE '%<input>%'. - Return the best match. If ambiguous (multiple matches), ask user to clarify.
- Capture:
connectwise_company_id,canonical_name,sophos_tenant_id,nexpose_site_id,nextcloud_slug.
Step 2: Create kanban ticket
hermes kanban --board <ops-board> create "<Company>: Combined Security Report" \
--body "Nexpose + Sophos XG firewall correlation per sophos-xg-config-audit skill." --json
hermes kanban claim <task_id>
CLI notes: the title is POSITIONAL — --title is rejected (unrecognized arguments); there is no --tag or --idempotency-key flag. Verified 2026-10-28.
Step 3: Delegate to sophos-xg-config-audit
Follow the full pipeline in sophos-xg-config-audit skill, now including three data sources:
- Nexpose vulnerability scan — latest scan + asset enumeration + CVE details
- Sophos XG firewall config — Entities.xml parsed for rules, NAT, zones, VPN
- Sophos Central endpoints + alerts — all managed endpoints with health status, active alerts
Pipeline steps:
- Registry check (
export_ready) - Fetch/download
Entities.xml - Nexpose collection (enumerate assets, pull vuln details)
- Sophos Central endpoint collection (query
/endpoint/v1/endpointsfor all devices, health status, active alerts) - Parse firewall config
- DNAT ↔ vuln correlation
- North/South + East/West analysis
- Endpoint security section (total endpoints, servers vs workstations, threat status, active alerts)
- Risk matrix + attack scenarios
- Generate HTML report using
sophos-xg-config-audit/scripts/vistanet_report_template.py(canonical VistaNet-branded generator) - Upload to Nextcloud
Customer Folders/<Company>/Security/Reports/ - Email from VistaNet Security Ops to
sbrown@vninc.com - Complete kanban ticket with
complete <id> --summary "<what shipped>"— the CLI refuses bare completion ("no result or summary evidence")
Step 4: Report back
Summarize:
- Company resolved
- Nexpose scan ID, asset count, vuln counts (critical/severe/moderate)
- Firewall model, firmware, rule/NAT counts, finding counts
- Endpoint count (total, servers, workstations) and threat status
- Active alert count and severity
- Report delivery location
- Email sent confirmation
Error handling
-
No company match: Ask user for exact name or ConnectWise ID.
-
No Sophos tenant: Report that firewall telemetry is unavailable; generate Nexpose-only report.
-
No Nexpose site: Report that vuln scan data is unavailable; generate firewall-only report.
-
Registry unreachable: Fall back to existing Nextcloud
Entities.xmlif present; note in report. -
Alerts/Endpoints API failure: Log the error and continue. Include a note in the report that Sophos Central telemetry was unavailable for this run.
-
Licensed-but-empty tenant is a true zero, not a wrong-tenant zero: after resolving the tenant, cross-check
GET /endpoint/v1/settings->tenantIdecho. Echo matches +endpoints/alerts/firewallsall zero = no enrolled devices; write the report with honest gaps (no firewall config audit, no E/W visibility) instead of inventing firewall sections. Verified 2026-10-19: a licensed-but-empty tenant settings echo returned 200 with zero devices. -
Similarly-named tenants: a sibling tenant (e.g. "X LLC" vs canonical "X Inc") may 403 on the service principal — 403 = no scope, skip it and note it in the report-back; match the registry
canonical_nameto pick the tenant. -
Registry blank link rows:
customer_system_mapis a VIEW overcustomers+link_*tables . When the view shows empty sophos_tenant_id/nexpose_site_id but infra exists, INSERT the discovered values intolink_sophos (customer_id, tenant_id, tenant_name)/link_nexpose (customer_id, site_id, site_name)in the same run. -
WebDAV PUT via urllib drops auth silently: a
Request(data=...)built inline for PUT never receives the Authorization headers added to the outer request — PUT 401 with PROPFIND 207 means the code, not the token. Prefercurl -u user:token --data-binary @filefor DAV writes; verify by GET-back + md5. -
himalaya message send panics on this build: himalaya 1.2.0 crashes (mail-parser index-out-of-bounds) on any raw .eml, even minimal RFC-compliant ones. Send via raw SMTP instead: resolve the password by running the
auth.cmdataccounts.<name>.backend.auth.cmd(SMTP host/port/login live ataccounts.<name>.message.send.backend) from~/.config/himalaya/config.tomlin a subprocess (never print it) — the account root has noauthkey and indexing it KeyErrors,smtplib.SMTP_SSL(host, 465), keep the body ASCII-only, then hand-append the full message toINBOX.Sentwith imaplib (SMTP does not auto-append) and verify by subject search on the Sent folder. Verified 2026-10-19.imaplib.appenddate arg:Time2Internaldateaccepts a datetime / time tuple /time.time()/ None — an RFC-2822 header string (e.g.msg['Date']) raisesdate_time not of a known type. -
"Check for config files again" flow (config exports may land after the report run): PROPFIND both
Customer Folders/<Client>/Security/Firewalls/(plural, where users drop API-export tarballs) andSecurity/Firewall/<Site>/Current/; pace every DAV op >=4.5s. Export tarballs namedAPI-<epochms>_<Site>.tarcontain ONLYEntities.xml+hashFile.json— extract, runsophos-xg-config-audit/scripts/parse_xg_config_multi.py <Entities.xml>(writesEntities_parsed.jsonbeside input; summary to stdout; data under["data"], findings under["findings"]). Appliance model/firmware are NOT in the export: model inferable from the serial in<HostName>of the System block (X230…=XG230, X121…=XG121) — record firmware as "not in export". Verified 2026-10-20 on an XG230+XG121 pair. -
DNAT↔vuln correlation gold: cross-check parsed interface IPs against Nexpose assets — a weak-ISAKMP Critical host turned out to be the XG230's WAN interface (Port2), i.e. the Vulnerability was the appliance itself; remediation becomes SFOS IPsec re-key. Seed
sophos_firewalls (tenant_id, firewall_id=serial, hostname, model, external_ip, export_ready)and normalizeSecurity/Firewalls/<Site>/Current/(Entities.xml+propertyfile+snapversion) for any site missing it, md5-verify uploads. -
himalaya Sent verification race:
ic.searchby Message-ID right after append can return empty (indexer lag) — re-search by SUBJECT after a short wait; append itself is synchronous so the copy exists. Verified v3 2026-10-20. -
Exchange HttpProxy
#Fields:keys have leading spaces (e.g.DateTime): always.strip()every header key after splitting the#Fields:line, else column lookups silently return empty strings and every window aggregate reads as zero. A recurring Exchange log export quirk, not a per-customer thing. Verified 2026-10-19 in production. -
Nexpose
tinmanauth status is a moving target (re-verified 2026-10-20): the 2026-10-05 'tinman 401s' note was stale — with the password in supermemory,tinmanworks onsites/{id}/assets+assets/{id}/vulnerabilities+/vulnerabilities/{id}. Test empirically before assuming superadmin is needed. Asset-vuln rows from/assets/{id}/vulnerabilitieshave NO flat severity/CVSS — those live only on the global/vulnerabilities/{id}object; aggregate by joining unique vuln ids to instance rows (id, instances, ports fromresults). -
Nexpose
included_targetsGET returnsaddresses, notresources:GET /api/3/sites/{id}/included_targetsanswer shapes differ per endpoint — sites/assets useresources, targets useaddresses. POST to add targets returns 400 with the documented body shape on the current console; don't loop retries, surface as a manual step. -
Stuck DAV node blocks PUT with Sabre NotFound (verified 2026-10-22): after a failed chunked upload + MOVE, the 0-byte target can 404 on every plain PUT (
File with name ... could not be located) even though PROPFIND lists it, and even unrelated folders under the same mount may PUT-404 during the anomaly. DELETE the node (DELETE returns 204, not 404) then PUT fresh — clean PUT 201'd and GET-back md5 matched. Also: verify GET-backs withcurl -o file— text-decoding curl stdout corrupts UTF-8 bytes and produces false md5 mismatches. -
Central config export 400 comes two ways: sub-MR2 firmware can return an immediate
400 {"error":"unsupportedFirewall"}or a sub-MR1 firmware POST 202 → transactionfinishedresult: error. Either way: PROPFINDSecurity/Firewalls/for a dropped appliance tarball (API-<epochms>[_<Site>].tar, may omit the _ suffix and carry propertyfile + cert files, not just Entities.xml), extract, and serial-verify<HostName>in the System block matches the Central firewall serial before parsing. -
Registry PG is reachable from the assistant host (password via supermemory).
customers.idis the registry PK;link_*tables key on the registry id (not CW company IDs). -
Gate-source severity, not NAT-source severity: a
NATRule/OriginalSourceof ANY (or blank) is wizard default, not any-source exposure — rate the DNAT's exposure from the pairedFirewallRulesource list +intrusion_preventioninstead. AUnited Statesgeo whitelist carrying aWAN TO LANIPS policy rates HIGH (broad-geo + NDR/AV/HTTPS-decryption disabled), not CRITICAL any-source; only a gate-layer ANY earns the CRITICAL badge. -
sophos_firewallshas nofirmware_versioncolumn: columns areid, tenant_id, firewall_id (serial), hostname, model, firmware, external_ip, connected, export_ready, mr_level, updated_at. Sync model/firmware/external_ip/firewall_id/connected/mr_level in ONE UPDATE — piecemeal retries leave the row half-synced (an early failed UPDATE can strandexternal_ip). -
export_ready=falsecan be stale in the false direction: firmware may have been upgraded to MR-2 after the flag was set — attempt the config export anyway before declaring unsupported; on success flipexport_readyand the model/firmware columns in the same run. -
Nexpose console curls need
-sk: the console serves self-signed TLS; plain curl fails with an empty body and looks like a network outage —-skand the JSON flows. -
Nextcloud DAV URLs with path spaces must be %20-encoded: a quoted curl URL containing literal spaces (
.../Customer Folders/...) fails silently (HTTP 000, no output file). Encode every space (Customer%20Folders/<Company>/...); 207/201/204 = worked. Verified 2026-10-28. -
himalaya send: auth.cmd lives under the backend blocks, not the account root:
accounts.<name>.message.send.backend.auth.cmd(SMTP) andaccounts.<name>.backend.auth.cmd(IMAP). Parse config.toml with tomllib, pick the account byemailfield (singledefaultaccount), run each cmd in a subprocess, never print the output. Verified 2026-10-28. -
Central fullExport tar layout differs from the earlier export generation: one large
./Entities.xmlplus aFiles/tree (CA store, appliance certs, CA private keys) — extract by member-name suffix match, not a hardcoded'Entities.xml'. The transaction'smd5_checksum/file_sizedescribe the TAR, not the XML — verify the tar download against them, then md5 the extracted XML separately (no expected-XML hash exists; compare against the cached Current/ copy instead). -
Verify zone appliance access by enumerating every
<Zone transactionid="">block: the first<ApplianceAccess>in the file belongs to LAN (full admin + portal services), so naive first-match parsing mislabels WAN admin exposure. Verified 2026-10-28: WAN = SSLVPN only, webadmin/SSH/SNMP NOT WAN-exposed; OTP enforced on SSL VPN/VPN portal/webadmin (per-surfaceOTPSettingsflags) — checkLocalServiceACL+OTPSettingsbefore rating SSLVPN-on-WAN; with MFA enforced it is MEDIUM, not CRITICAL. -
Nexpose site target edits are API-refused on the current console: PUT/POST
/api/3/sites/{id}/included_targets→ 400"JSON input is invalid at line 1, column 1"on every body shape tried (range string, expanded IPs), and ad-hocPOST /api/3/scans→ 405 METHOD_NOT_ALLOWED. Don't loop retries. A running scan snapshots its target list — if targets must change, stop the scan (POST/api/3/scans/{id}/stop), add targets via console UI manually, then restart the site scan. A site hostname target (e.g. smtp.) covers its A-record IP; WAN IPs not matching a rule or hostname stay unscanned and become a documented coverage gap. Verified 2026-10-31 (hostname-target coverage gap in production). -
himalaya
auth.cmdcan be a bareecho <pw>string: parse withshlex.splitand evaluate (parts[0]=='echo' → parts[1]); subprocess.run on the raw string raises FileNotFoundError. Never echo the command or its output to stdout. Verified 2026-10-31. -
Prior-report finding-row extraction: rows are
<td>cells per<tr>; filter rows whose first cell matchesFW-\d+and re-strip all tags per cell — the regex must run row-wise(.*?)</tr>withre.S, not per-cell. Verified 2026-10-31 on a prior report.
Reference
- Delegates to:
sophos-xg-config-auditskill (full pipeline) - Board: the ops task board
- Output path:
Customer Folders/<Company>/Security/Reports/ - Sender: the security-ops sender address