100 Commits
v1.2.3 ... main

Author SHA1 Message Date
OfficeCom Codex
6e10a71820 Correlate Exchange IIS failures with security accounts
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-05 09:39:33 +02:00
OfficeCom Codex
7f4cd291e7 Refine Exchange IIS authentication detection
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s
2026-08-05 09:16:46 +02:00
OfficeCom Codex
705b543e5a Publish beta 1.5.0-beta.7 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-03 01:21:25 +02:00
OfficeCom Codex
2ca50a4ee9 Limit uploaded event details
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-03 01:18:35 +02:00
OfficeCom Codex
d1f78a38fd Publish beta 1.5.0-beta.6 manifest
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-08-02 23:53:25 +02:00
OfficeCom Codex
cc77c45a10 Add Exchange IIS service telemetry
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 52s
2026-08-02 23:50:12 +02:00
OfficeCom Codex
c73bab139b Refine Sentinel security dashboard hierarchy
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-01 02:09:53 +02:00
OfficeCom Codex
7009596efc Add read-only Sentinel MCP server
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-08-01 02:07:17 +02:00
OfficeCom Codex
a494bc4ba3 Add progressive CSS enhancements to Sentinel dashboard
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-01 01:58:20 +02:00
OfficeCom Codex
ddba660b1a Refresh Sentinel dashboard and sensor coverage
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-01 01:56:41 +02:00
OfficeCom Codex
517cfa6773 Publish beta 1.5.0-beta.5 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-08-01 01:45:17 +02:00
OfficeCom Codex
072a4257ed Report ransomware sensor coverage in beta
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-08-01 01:41:57 +02:00
OfficeCom Codex
47fe9448b7 Publish beta 1.5.0-beta.4 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-08-01 01:39:17 +02:00
OfficeCom Codex
fd990b698f Enable passive ransomware detection by default for beta
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-08-01 01:35:58 +02:00
OfficeCom Codex
d1bbd8838f Publish beta 1.5.0-beta.3 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-08-01 01:32:55 +02:00
OfficeCom Codex
feeeeedcf1 Remove redundant Ninja client version field
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-08-01 01:29:21 +02:00
OfficeCom Codex
ceefb7a5dc Publish beta 1.5.0-beta.2 manifest
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-08-01 01:19:58 +02:00
OfficeCom Codex
8407d0c5b2 Add passive file churn sensor beta
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-08-01 01:15:40 +02:00
OfficeCom Codex
22be9689e2 Use cache-safe stable release asset URL
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-08-01 01:00:25 +02:00
OfficeCom Codex
f69f14e0b1 Fail context migration when upload is unavailable
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-08-01 00:42:11 +02:00
OfficeCom Codex
94f5be8953 Persist NinjaOne context for scheduled scans
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-31 01:59:40 +02:00
OfficeCom Codex
b97f8819f6 Add fileserver context and map controls
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-31 00:39:36 +02:00
OfficeCom Codex
0207d84775 Build interactive access topology map
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-30 01:37:50 +02:00
OfficeCom Codex
c65001aa17 Visualize observed access paths
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-30 01:16:20 +02:00
OfficeCom Codex
42b387f3ef Align beta manifest with runner artifact
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-30 01:09:18 +02:00
OfficeCom Codex
c3ca95dfa5 Implement reversible Sentinel beta foundation
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-30 01:05:30 +02:00
OfficeCom Codex
58ad77242f Add modern GUI visualization roadmap
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-07-30 00:56:25 +02:00
OfficeCom Codex
412178055f Define reversible beta rollout model
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-30 00:53:22 +02:00
OfficeCom Codex
c52fea835c Refine Sentinel delivery roadmap
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-30 00:48:34 +02:00
OfficeCom Codex
e711dc7029 Document Sentinel roadmap and code quality standard
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-29 00:55:05 +02:00
OfficeCom Codex
a81d8b9830 Publish stable client version 1.4.0
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 48s
2026-07-29 00:51:45 +02:00
OfficeCom Codex
64841d36e7 Correlate failed login activity before alerting
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-29 00:50:14 +02:00
OfficeCom Codex
66dcfe09b6 Show summarized security events above raw export
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-27 14:42:28 +02:00
OfficeCom Codex
c74d5582b0 Add device raw export summary
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 14:40:25 +02:00
OfficeCom Codex
4b9202b47c Redesign recipient management dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 14:39:10 +02:00
OfficeCom Codex
a5cea5ebad Unify dashboard background and administration styling
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:37:07 +02:00
OfficeCom Codex
db0533a4cf Fix Outlook report header and metrics layout
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:34:52 +02:00
OfficeCom Codex
f054702438 Provide importable Outlook weekly report workflow
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 14:31:37 +02:00
OfficeCom Codex
e357e6329d Add Outlook-compatible weekly report template
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 14:29:05 +02:00
OfficeCom Codex
585b4f91b1 Add robust weekly report templates
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 49s
2026-07-27 12:43:09 +02:00
OfficeCom Codex
1eedac4a76 Send weekly reports per organization
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 50s
2026-07-27 12:18:19 +02:00
OfficeCom Codex
5d5828db49 Publish stable client version 1.3.7
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 9m24s
2026-07-27 11:14:49 +02:00
OfficeCom Codex
2cf3281b4d Retry interrupted client package downloads
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Failing after 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 11:13:48 +02:00
OfficeCom Codex
b2da734c75 Publish stable client version 1.3.6
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 51s
2026-07-27 10:46:41 +02:00
OfficeCom Codex
40c6daada8 Persist Ninja context for scheduled scans
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 56s
2026-07-27 10:45:49 +02:00
OfficeCom Codex
5be4fb6c33 Show event freshness and device coverage
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-07-27 01:50:46 +02:00
OfficeCom Codex
0fe8a96057 Skip unsupported Gitea artifact upload
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Successful in 47s
2026-07-27 01:33:31 +02:00
OfficeCom Codex
eb621ace5a Preserve updater exit code in bootstrap
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-07-27 01:25:26 +02:00
OfficeCom Codex
e997e6b58c Use Roboto for Sentinel dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-27 01:24:43 +02:00
OfficeCom Codex
3f09805999 Initialize TLS for legacy client updates
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 47s
2026-07-27 01:22:46 +02:00
OfficeCom Codex
85f0682769 Simplify internal Sentinel dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 49s
2026-07-27 01:04:49 +02:00
OfficeCom Codex
6722b00ee7 Install PowerShell in Gitea build runner
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 48s
2026-07-27 00:58:51 +02:00
OfficeCom Codex
d3897c8e69 Publish stable client version 1.3.5
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-27 00:35:43 +02:00
OfficeCom Codex
f9941b0807 Harden release downloads for TLS failures
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-27 00:34:48 +02:00
OfficeCom Codex
c768e1be6b Publish stable client version 1.3.4
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 21:15:11 +02:00
OfficeCom Codex
2a780cd52f Add time-bounded burst scans
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 21:13:49 +02:00
OfficeCom Codex
daa494fade Publish stable client version 1.3.3
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-26 20:26:37 +02:00
OfficeCom Codex
e7fd4e7ef5 Add resilient upload queue and client health
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 20:25:25 +02:00
OfficeCom Codex
dfdae7a532 Manage weekly report recipients centrally
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-26 11:09:26 +02:00
OfficeCom Codex
854c99e3b2 Configure weekly report recipients
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 03:49:32 +02:00
OfficeCom Codex
2598de2ecc Polish OCSentinel dashboard interface
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 03:43:55 +02:00
OfficeCom Codex
6c791cc417 Rename dashboard overview heading
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 03:42:46 +02:00
OfficeCom Codex
0adac8a0d9 Email weekly organization reports
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 03:41:46 +02:00
OfficeCom Codex
a45a811040 Refine OCSentinel security dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-26 03:39:57 +02:00
OfficeCom Codex
2f2a553fc2 Add weekly organization security reports
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 03:16:47 +02:00
OfficeCom Codex
6ceb29a07b Add read-only OCSentinel debug dashboard
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 02:59:51 +02:00
OfficeCom Codex
1c2170090e Stagger daily scans across early morning
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 02:24:05 +02:00
OfficeCom Codex
91c5794502 Add daily scans and Ninja burst mode
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-26 02:17:03 +02:00
OfficeCom Codex
f392057535 Document n8n raw body signature validation
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-25 21:26:56 +02:00
OfficeCom Codex
7431c656d3 Use documented NinjaOne custom field commands
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-25 21:17:59 +02:00
OfficeCom Codex
49b0e3025d Fix Ninja client configuration parsing
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-25 21:14:50 +02:00
OfficeCom Codex
aefec51581 Fix NinjaOne environment variable lookup
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-25 21:10:24 +02:00
OfficeCom Codex
7555e92aac Add one-time NinjaOne installation script
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-25 21:01:02 +02:00
OfficeCom Codex
b97b554f84 Add NinjaOne client configuration and upload test
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-25 20:58:54 +02:00
OfficeCom Codex
77d7eaa0b5 Read NinjaOne rollout variables from environment
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-25 18:53:34 +02:00
OfficeCom Codex
f91f45ad92 Prepare automated release validation
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 17s
2026-07-25 10:25:07 +02:00
OfficeCom Codex
c27b53ea0d Use repository secret for Gitea releases
Some checks failed
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
OfficeCom Sentinel Client / validate-client (push) Has been cancelled
2026-07-25 10:24:56 +02:00
OfficeCom Codex
f9d7647046 Detect Gitea release tags by reference name
Some checks failed
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
2026-07-25 10:22:43 +02:00
OfficeCom Codex
d37fba137e Run Gitea release pipeline from tag environment
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 18s
2026-07-25 10:20:55 +02:00
OfficeCom Codex
e73d79b520 Publish stable manifest for v1.2.6
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 22s
OfficeCom Sentinel Client / build-client-windows (push) Has been skipped
2026-07-25 10:16:52 +02:00
OfficeCom Codex
b0e3e7dc74 Preserve upload configuration during scan and upload
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 23s
OfficeCom Sentinel Client / build-client-windows (push) Failing after 19s
2026-07-25 10:15:23 +02:00
OfficeCom Codex
290033680b Publish release assets from tag workflow
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been skipped
2026-07-25 03:08:12 +02:00
OfficeCom Codex
3ea0baa147 Build Windows client packages on Linux runner
All checks were successful
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been skipped
2026-07-25 03:06:31 +02:00
OfficeCom Codex
e01aa3dce3 Target Windows Gitea runner label
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:43:12 +02:00
OfficeCom Codex
8bc1d78fc9 Publish stable manifest for v1.2.5
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:40:40 +02:00
OfficeCom Codex
00778175fd Include NinjaOne identifiers in reports
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:39:39 +02:00
OfficeCom Codex
34eff4e012 Publish stable manifest for v1.2.4
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:35:52 +02:00
OfficeCom Codex
707275f992 Add signed report upload to client runtime
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:34:57 +02:00
OfficeCom Codex
b1ee79ca02 Configure upload during Ninja bootstrap
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:33:02 +02:00
OfficeCom Codex
053d601e93 Add internal development webhook configuration
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 02:13:13 +02:00
OfficeCom Codex
67a14c125e Document Dockge PostgreSQL deployment
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 01:56:49 +02:00
OfficeCom Codex
335410b418 Add PostgreSQL reporting schema and ingest contract
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 01:41:13 +02:00
OfficeCom Codex
f9f4d862bf Add NinjaOne bootstrap deployment script
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 24s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 01:34:52 +02:00
OfficeCom Codex
ea90ddd1f6 Add restricted Windows Gitea runner setup
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 25s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-25 01:31:00 +02:00
OfficeCom Codex
1ad720f919 Simplify runs-on labels for Gitea Actions
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Successful in 1m11s
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-17 02:07:29 +02:00
OfficeCom Codex
61c5e7823a Split Linux validation and Windows release build
Some checks failed
OfficeCom Sentinel Client / validate-client (push) Has been cancelled
OfficeCom Sentinel Client / build-client-windows (push) Has been cancelled
2026-07-17 02:03:43 +02:00
OfficeCom Codex
05029c9fb2 Track internal Postgres target in release process
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
2026-07-17 01:12:38 +02:00
OfficeCom Codex
fde24f2616 Add standalone OCSentinel setup executable builder
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
2026-07-17 01:07:37 +02:00
OfficeCom Codex
38a99f2706 Add stable manifest URL for NinjaOne deployments
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
2026-07-17 01:05:56 +02:00
OfficeCom Codex
e8b7a2831d Document manual NinjaOne rollout for v1.2.3
Some checks failed
OfficeCom Sentinel Client / build-client (push) Has been cancelled
2026-07-17 01:00:34 +02:00
89 changed files with 6115 additions and 124 deletions

View File

@@ -7,12 +7,15 @@ on:
tags: tags:
- "v*" - "v*"
workflow_dispatch: workflow_dispatch:
inputs:
build_windows:
description: "Build the Windows release package"
required: false
default: "false"
jobs: jobs:
build-client: validate-client:
runs-on: runs-on: ubuntu-22.04
- self-hosted
- windows
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -22,25 +25,111 @@ jobs:
with: with:
dotnet-version: "10.0.x" dotnet-version: "10.0.x"
- name: Restore client
run: dotnet restore ./src/OCSentinelCli/OCSentinelCli.csproj
- name: Build client
run: dotnet build ./src/OCSentinelCli/OCSentinelCli.csproj -c Release --no-restore
build-client-windows:
needs: validate-client
# .NET can publish a self-contained Windows x64 client from Linux.
# This keeps releases independent of a Windows Gitea runner.
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Install PowerShell
shell: bash
run: |
set -euo pipefail
if command -v pwsh >/dev/null 2>&1; then
pwsh --version
exit 0
fi
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
echo "PowerShell is missing and this runner cannot install packages."
exit 1
fi
. /etc/os-release
case "$ID" in
ubuntu) MICROSOFT_REPO="https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" ;;
debian) MICROSOFT_REPO="https://packages.microsoft.com/config/debian/${VERSION_ID}/packages-microsoft-prod.deb" ;;
*) echo "Unsupported runner distribution: $ID"; exit 1 ;;
esac
$SUDO apt-get update
$SUDO apt-get install -y ca-certificates curl
curl -fsSL "$MICROSOFT_REPO" -o /tmp/packages-microsoft-prod.deb
$SUDO dpkg -i /tmp/packages-microsoft-prod.deb
$SUDO apt-get update
$SUDO apt-get install -y powershell
pwsh --version
- name: Build client package - name: Build client package
shell: pwsh shell: pwsh
run: | run: |
./build/build-client-package.ps1 ./build/build-client-package.ps1
- name: Build release manifest for tags - name: Build release manifest for tags
if: startsWith(github.ref, 'refs/tags/v')
shell: pwsh shell: pwsh
run: | run: |
$tag = "${{ github.ref_name }}" $ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
$tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
if ($tag -notlike "v*") {
Write-Host "Not a version tag; skipping release manifest."
exit 0
}
$artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip" $artifactUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/$tag/OCSentinelClient-win-x64.zip"
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl $channel = if ($tag -match '-beta(?:\.|$)') { 'beta' } else { 'stable' }
./build/build-release-manifest.ps1 -ArtifactUrl $artifactUrl -Channel $channel
- name: Upload package artifacts - name: Publish Gitea release assets
uses: actions/upload-artifact@v4 shell: pwsh
with: env:
name: ocsentinel-client-${{ github.sha }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
path: | run: |
artifacts/OCSentinelClient-win-x64.zip $ref = if ($env:GITHUB_REF) { $env:GITHUB_REF } else { $env:GITEA_REF }
artifacts/OCSentinelClient-win-x64.zip.sha256 $tag = if ($env:GITHUB_REF_NAME) { $env:GITHUB_REF_NAME } else { Split-Path -Leaf $ref }
artifacts/version.json if ($tag -notlike "v*") {
if-no-files-found: warn Write-Host "Not a version tag; skipping Gitea release publication."
exit 0
}
$headers = @{ Authorization = "token $env:GITEA_TOKEN" }
$repository = if ($env:GITEA_REPOSITORY) { $env:GITEA_REPOSITORY } else { $env:GITHUB_REPOSITORY }
$serverUrl = if ($env:GITEA_SERVER_URL) { $env:GITEA_SERVER_URL } else { $env:GITHUB_SERVER_URL }
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN) -or [string]::IsNullOrWhiteSpace($repository) -or [string]::IsNullOrWhiteSpace($serverUrl)) {
throw "Gitea release environment is incomplete. Expected GITEA_TOKEN, repository, and server URL."
}
$baseUrl = "$serverUrl/api/v1/repos/$repository"
$releaseBody = @{
tag_name = $tag
target_commitish = "${{ github.sha }}"
name = "OfficeCom Sentinel $tag"
body = "Automated OfficeCom Sentinel client release."
} | ConvertTo-Json
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$baseUrl/releases/tags/$tag"
}
catch {
$release = Invoke-RestMethod -Method Post -Headers $headers -ContentType "application/json" -Body $releaseBody -Uri "$baseUrl/releases"
}
foreach ($file in @("artifacts/OCSentinelClient-win-x64.zip", "artifacts/OCSentinelClient-win-x64.zip.sha256", "artifacts/version.json")) {
$assetName = [System.IO.Path]::GetFileName($file)
Invoke-RestMethod -Method Post -Headers $headers -InFile $file -ContentType "application/octet-stream" -Uri "$baseUrl/releases/$($release.id)/assets?name=$assetName" | Out-Null
}

2
.gitignore vendored
View File

@@ -1,6 +1,7 @@
bin/ bin/
obj/ obj/
artifacts/ artifacts/
__pycache__/
reports/ reports/
_extracted/ _extracted/
_tools/ _tools/
@@ -10,6 +11,7 @@ payload/
*.log *.log
*.zip *.zip
*.sha256 *.sha256
payload.zip
SetupAttackTracer.exe SetupAttackTracer.exe
decompiled/ decompiled/
msi-admin/ msi-admin/

View File

@@ -9,7 +9,13 @@ OfficeCom Sentinel is the hardened endpoint client for Windows event correlation
- Ninja monitor wrapper: `scripts/run-ocsentinel-monitor.ps1` - Ninja monitor wrapper: `scripts/run-ocsentinel-monitor.ps1`
- packaged installer runtime: `installer/runtime-run-ocsentinel.ps1` - packaged installer runtime: `installer/runtime-run-ocsentinel.ps1`
- package builder: `build/build-client-package.ps1` - package builder: `build/build-client-package.ps1`
- setup EXE builder: `build/build-client-installer.ps1`
- update manifest builder: `build/build-release-manifest.ps1` - update manifest builder: `build/build-release-manifest.ps1`
- release checklist: `docs/release-checklist.md`
- product roadmap: `docs/roadmap.md`
- code quality standard: `docs/code-quality.md`
- beta deployment: `docs/beta-deployment.md`
- internal server-side target example: `infra/postgres-target.example.json`
## Build ## Build

View File

@@ -0,0 +1,50 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$artifactsRoot = Join-Path $repoRoot "artifacts"
$zipPath = Join-Path $artifactsRoot "OCSentinelClient-win-x64.zip"
$bootstrapperRoot = Join-Path $repoRoot "installer\OCSentinelBootstrapper"
$payloadPath = Join-Path $bootstrapperRoot "payload.zip"
$projectPath = Join-Path $bootstrapperRoot "OCSentinelBootstrapper.csproj"
$publishRoot = Join-Path $artifactsRoot "bootstrapper-publish\win-x64"
$outputExe = Join-Path $artifactsRoot "OCSentinelSetup.exe"
if (-not (Test-Path $zipPath)) {
& powershell.exe -ExecutionPolicy Bypass -File (Join-Path $repoRoot "build\build-client-package.ps1") -Configuration $Configuration
if ($LASTEXITCODE -ne 0) {
throw "build-client-package.ps1 failed"
}
}
Copy-Item -Path $zipPath -Destination $payloadPath -Force
if (Test-Path $publishRoot) {
Remove-Item -LiteralPath $publishRoot -Recurse -Force
}
if (Test-Path $outputExe) {
Remove-Item -LiteralPath $outputExe -Force
}
New-Item -ItemType Directory -Force -Path $publishRoot | Out-Null
& dotnet publish $projectPath `
-c $Configuration `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:EnableCompressionInSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-o $publishRoot
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed for bootstrapper"
}
Copy-Item -Path (Join-Path $publishRoot "OCSentinelBootstrapper.exe") -Destination $outputExe -Force
Write-Host "OfficeCom Sentinel setup executable created at $outputExe"

View File

@@ -54,10 +54,12 @@ Copy-Item -Path (Join-Path $installerRoot "uninstall-ocsentinel.ps1") -Destinati
Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force Copy-Item -Path (Join-Path $installerRoot "update-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\update-ocsentinel.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-monitor.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Force
Copy-Item -Path (Join-Path $installerRoot "runtime-run-ocsentinel-scheduled.ps1") -Destination (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Force
Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force Copy-Item -Path (Join-Path $repoRoot "scripts\protect-ocsentinel-secret.ps1") -Destination (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Force
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-settings.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-settings.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Force Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "config\ocsentinel-client.dev.example.json") -Destination (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "config\update-channel.example.json") -Force Copy-Item -Path (Join-Path $repoRoot "config\update-channel.example.json") -Destination (Join-Path $packageRoot "config\update-channel.example.json") -Force
Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Force Copy-Item -Path (Join-Path $repoRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Force
Copy-Item -Path (Join-Path $repoRoot "samples\webhook-payload.example.json") -Destination (Join-Path $packageRoot "samples\webhook-payload.example.json") -Force Copy-Item -Path (Join-Path $repoRoot "samples\webhook-payload.example.json") -Destination (Join-Path $packageRoot "samples\webhook-payload.example.json") -Force

View File

@@ -0,0 +1,19 @@
{
"schemaVersion": "2.0",
"environment": "development",
"lookbackDays": 7,
"topFindings": 10,
"n8nWebhookUrl": "http://172.16.41.197:5678/webhook/ocsentinel-ingest",
"ninjaOrganizationId": "",
"ninjaOrganizationName": "",
"ninjaMachineId": "",
"ninjaNodeId": "",
"ninjaLocationId": "",
"ninjaLocationName": "",
"deviceIdentifierMode": "machineName",
"uploadTimeoutSeconds": 30,
"uploadQueueMaxReports": 100,
"enableVulnerabilityCorrelation": true,
"vulnerabilityCsvPath": "",
"secretReference": "device-default"
}

View File

@@ -4,8 +4,15 @@
"lookbackDays": 7, "lookbackDays": 7,
"topFindings": 10, "topFindings": 10,
"n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest", "n8nWebhookUrl": "https://n8n.example.com/webhook/ocsentinel-ingest",
"ninjaOrganizationId": "",
"ninjaOrganizationName": "",
"ninjaMachineId": "",
"ninjaNodeId": "",
"ninjaLocationId": "",
"ninjaLocationName": "",
"deviceIdentifierMode": "machineName", "deviceIdentifierMode": "machineName",
"uploadTimeoutSeconds": 30, "uploadTimeoutSeconds": 30,
"uploadQueueMaxReports": 100,
"enableVulnerabilityCorrelation": true, "enableVulnerabilityCorrelation": true,
"vulnerabilityCsvPath": "", "vulnerabilityCsvPath": "",
"secretReference": "device-default" "secretReference": "device-default"

View File

@@ -1,14 +1,38 @@
{ {
"warningEventThreshold": 1, "warningEventThreshold": 10,
"criticalEventThreshold": 20, "maxReportedEvents": 1000,
"warningUniqueIpThreshold": 1, "criticalEventThreshold": 30,
"criticalUniqueIpThreshold": 10, "warningUniqueIpThreshold": 5,
"criticalUniqueIpThreshold": 12,
"loginBurstWindowMinutes": 15,
"warningLoginBurstCount": 5,
"criticalLoginBurstCount": 20,
"warningSprayAccountCount": 5,
"criticalSprayAccountCount": 10,
"correlationWarningCveThreshold": 1, "correlationWarningCveThreshold": 1,
"correlationCriticalCveThreshold": 1, "correlationCriticalCveThreshold": 1,
"ransomwareBetaEnabled": true,
"ransomwareBetaAlertingEnabled": false,
"ransomwareLookbackMinutes": 15,
"ransomwareWarningSignalCount": 2,
"ransomwareCriticalSignalCount": 3,
"ransomwareCaptureSmbSessions": true,
"ransomwareFileChurnEnabled": false,
"ransomwareFileChurnWindowMinutes": 15,
"ransomwareFileChurnWarningDeleteCount": 50,
"ransomwareFileChurnWarningWriteCount": 250,
"ransomwareFileChurnCriticalDeleteCount": 200,
"ransomwareFileChurnCriticalWriteCount": 1000,
"ransomwareFileChurnMaxAuditEvents": 5000,
"ransomwareExcludedProcesses": [],
"ftpRoots": [ "ftpRoots": [
"C:\\inetpub\\logs\\LogFiles", "C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles" "D:\\inetpub\\logs\\LogFiles"
], ],
"iisLogRoots": [
"C:\\inetpub\\logs\\LogFiles",
"D:\\inetpub\\logs\\LogFiles"
],
"fileZillaRoots": [ "fileZillaRoots": [
"C:\\Program Files (x86)\\FileZilla Server\\Logs", "C:\\Program Files (x86)\\FileZilla Server\\Logs",
"D:\\Program Files (x86)\\FileZilla Server\\Logs" "D:\\Program Files (x86)\\FileZilla Server\\Logs"

58
docs/beta-deployment.md Normal file
View File

@@ -0,0 +1,58 @@
# OCSentinel Beta Deployment
## Ziel
Beta-Pakete werden ausschliesslich an benannte Pilotgeraete verteilt. Der
Stable-Kanal und die vorhandene Stable-NinjaOne-Aufgabe bleiben unveraendert.
## Beta-Aufgabe in NinjaOne
1. Die bestehende Aufgabe `OCSentinel - Installieren und aktualisieren`
duplizieren und eindeutig als `OCSentinel - Beta Pilot` benennen.
2. Das Script `scripts/bootstrap-ocsentinel-ninja.ps1` verwenden.
3. Die Script-Variable `releasechannel` als Text mit dem Wert `beta` anlegen.
4. Webhook und Secret bleiben identisch zum Stable-Task.
5. Die Aufgabe nur einer Pilot-Richtlinie oder explizit ausgewaehlten Geraeten
zuweisen.
Die Stable-Aufgabe verwendet keinen Kanalwert oder den Wert `stable`.
## Passive Ransomware-Beta
Die Ransomware-Beta ist im Beta-Kanal standardmaessig aktiviert. Die Auswertung
bleibt passiv, solange `ransomwareBetaAlertingEnabled` auf `false` steht:
Hinweise, Warnungen und kritische Beta-Signale erscheinen im JSON-Report und
Dashboard, veraendern aber keine NinjaOne-Alarmfelder. Fuer eine lokale
Ausnahme kann `ransomwareBetaEnabled` in
`C:\Program Files\OCSentinel\config\ocsentinel-settings.json` auf `false`
gesetzt werden.
Jeder Beta-Report enthaelt zudem eine datensparsame Sensorabdeckung fuer
Security-Ereignis 4688, PowerShell 4104, Sysmon 1 und das optionale
Datei-Auditing 4663. Damit bedeutet ein Status `ok` nicht mehr stillschweigend,
dass alle Datenquellen vorhanden waren. Es werden nur Verfuegbarkeit,
technischer Zustand und gezaehlte Ereignisse uebertragen.
Der optionale Datei-Churn-Sensor wird nur mit
`ransomwareFileChurnEnabled: true` aktiviert. Er wertet ausschliesslich bereits
vorhandene Security-Ereignisse 4663 aus, setzt keine Audit-Richtlinie und
aendert keine SACLs. Es werden nur Zaehler sowie Prozessnamen gespeichert und
uebertragen, niemals Datei- oder Freigabenamen. Eine Auswertung ist auf 5.000
Audit-Ereignisse und ein 15-Minuten-Fenster begrenzt; ein gekappter Lauf erzeugt
kein Churn-Signal.
## Rueckfall
1. Die Beta-Richtlinie entfernen oder die Beta-Aufgabe nicht mehr ausfuehren.
2. Auf den Pilotgeraeten die vorhandene Stable-Aufgabe ausfuehren.
3. Die Ransomware-Beta in der lokalen Konfiguration auf `false` setzen, falls
sie aktiviert wurde.
Der Client prueft weiterhin Paket-Hash und Authenticode-Signaturstatus, bevor
eine Beta installiert wird.
## Pilotprotokoll
Vor dem Start festhalten: Organisation, Geraete, Aktivierungszeit, aktivierte
Feature-Schalter, verantwortliche Person und geplantes Enddatum. Nach dem
Pilot Laufzeit, Upload-Volumen, Hinweise und Fehlalarme bewerten.

32
docs/code-quality.md Normal file
View File

@@ -0,0 +1,32 @@
# Code-Qualitaetsstandard
## Ziel
Der Client soll klein, pruefbar und wartbar bleiben. Kommentare sind keine
zweite Dokumentation und keine Erklaerung fuer selbsterklaerenden Code.
## Kommentarregel
- Kommentare bleiben nur bei Sicherheitsgrenzen, externen API-Eigenheiten,
nicht offensichtlichen Entscheidungen und bewusstem Fehlertoleranz-Verhalten.
- Beschreibende Kommentare direkt neben selbsterklaerenden Anweisungen werden
entfernt.
- Veraltete Kommentare werden im selben Pull Request wie die Codeaenderung
geloescht oder aktualisiert.
- Architektur- und Betriebswissen gehoert in `docs`, nicht in lange
Quellcodekommentare.
## Wiederkehrender Clean-up
Bei jeder Minor-Version wird ein kurzer Wartungsdurchlauf eingeplant:
1. Tote Konfiguration, nicht erreichbare Pfade und doppelte Hilfsfunktionen entfernen.
2. Kommentare gegen den aktuellen Code pruefen und ueberfluessige entfernen.
3. Formatierung und Benennung vereinheitlichen.
4. Release-Build und die relevanten Scan-Szenarien erneut ausfuehren.
## Sicherheitsausnahme
Kommentare, die vor einer unsicheren Aenderung schuetzen, bleiben erhalten.
Beispiele sind TLS-Kompatibilitaet, Secret-Schutz, Upload-Signaturpruefung und
deterministische Lastverteilung.

View File

@@ -19,6 +19,15 @@ The repository now keeps only the client-side architecture:
- optional n8n upload - optional n8n upload
- packaged ZIP release flow for NinjaOne deployment - packaged ZIP release flow for NinjaOne deployment
The client must not depend on a PostgreSQL IP or hostname. PostgreSQL stays a server-side concern behind the ingest or n8n layer.
## PostgreSQL Handling
- PostgreSQL is not contacted directly by endpoint clients.
- The PostgreSQL host or IP should be tracked in the repository only as internal deployment metadata.
- Review that internal target on every release before publishing.
- Keep the actual production value in a private operational copy if it should not be visible in the public repository.
## Removed Model ## Removed Model
The following older pieces are intentionally no longer part of the repo: The following older pieces are intentionally no longer part of the repo:

View File

@@ -17,7 +17,7 @@ Build these locally with:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\build\build-client-package.ps1 powershell -ExecutionPolicy Bypass -File .\build\build-client-package.ps1
powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 ` powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
-ArtifactUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/OCSentinelClient-win-x64.zip" -ArtifactUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.2.3/OCSentinelClient-win-x64.zip"
``` ```
## Installed Layout ## Installed Layout
@@ -30,13 +30,69 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release-manifest.ps1 `
- `config\ocsentinel-settings.json` - `config\ocsentinel-settings.json`
- `config\ocsentinel-client.json` - `config\ocsentinel-client.json`
## Local Schedule And Burst Mode
During a NinjaOne installation or update, OCSentinel stores the device's
NinjaOne organization, location, and device identifiers in its local client
configuration. Scheduled `SYSTEM` scans restore that context before creating a
report, so their uploads remain assigned to the correct organization.
The installer creates two Windows Scheduled Tasks running as `SYSTEM`:
- `OCSentinel Daily Scan`: runs once per day and uploads one signed report.
The installer deterministically assigns each device a stable slot between
`04:00` and `06:59`, derived from its Windows `MachineGuid`. This distributes
a fleet rollout instead of sending all reports at the same time.
- `OCSentinel Burst Check`: runs every five minutes. It performs no scan unless
the NinjaOne device custom field `ocsentinelburst` is enabled. Once enabled,
it scans for two hours and then disables itself automatically.
Create `ocsentinelburst` as a device-level `Checkbox` custom field and allow
automation read and write access. Set it to `true` for a device to begin the
five-minute burst scans; clear it to stop them early. The normal daily scan
continues regardless of the checkbox.
Create these accompanying device custom fields and allow automation write
access:
| Field name | Type | Purpose |
| --- | --- | --- |
| `ocsentinelburstuntilutc` | Date/Time | UTC time at which the active burst ends |
| `ocsentinelburststatus` | Text | `idle`, `active until ...`, or `completed` |
## Upload Reliability And Client Health
If the upload endpoint is temporarily unavailable, the client stores up to 100
signed report payloads locally under `C:\ProgramData\OCSentinel\upload-queue`.
The next scheduled run sends queued payloads before its new report. The local
health state is stored under `C:\ProgramData\OCSentinel\state`.
Create these additional device custom fields in NinjaOne and allow automation
write access:
| Field name | Type | Purpose |
| --- | --- | --- |
| `ocsentineluploadstatus` | Text | `ok`, `queued`, or `unknown` upload state |
| `ocsentinelqueuedreports` | Integer | Reports waiting for delivery |
| `ocsentinellastuploadutc` | Date/Time | Last successful upload time |
| `ocsentinellasterror` | Text | Last upload error, if any |
## NinjaOne Tasks ## NinjaOne Tasks
Initial install/update: Create a PowerShell script in NinjaOne named `OCSentinel - Installieren oder aktualisieren`.
Run it as `SYSTEM` in 64-bit PowerShell and copy the content of
`scripts/bootstrap-ocsentinel-ninja.ps1` into the NinjaOne script editor.
It is idempotent: new devices install the current package, while installed devices
only update when a newer manifest version is published.
Use it for the one-time rollout and, later, as the monthly update task. For an
initial validation scan, add `-RunInitialStatusScan` to the script parameters.
Installed-client update only:
```powershell ```powershell
& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" ` & "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" `
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/version.json" ` -ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json" `
-Force -Force
``` ```
@@ -44,7 +100,7 @@ Routine update:
```powershell ```powershell
& "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" ` & "C:\Program Files\OCSentinel\scripts\update-ocsentinel.ps1" `
-ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v2.0.0/version.json" -ManifestUrl "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json"
``` ```
Runtime: Runtime:
@@ -55,6 +111,29 @@ Runtime:
-OutputPath "..\reports\ocsentinel-summary.json" -OutputPath "..\reports\ocsentinel-summary.json"
``` ```
## Repair Missing NinjaOne Context
NinjaOne exposes `NINJA_ORGANIZATION_ID`, `NINJA_ORGANIZATION_NAME`,
`NINJA_AGENT_MACHINE_ID`, and location values only while an automation runs.
The scheduled OCSentinel task runs later as `SYSTEM`, so those values must be
persisted during an actual NinjaOne automation.
If the console shows `Organisation unbekannt`, create a temporary NinjaOne
PowerShell automation named `OCSentinel - NinjaOne Kontext aktualisieren` and
copy `scripts/refresh-ocsentinel-ninja-context.ps1` into the editor. Run it as
`SYSTEM` in 64-bit PowerShell once against the affected devices or policy.
The script has no script variables and does the following safely:
1. updates the installed client through the stable, version-independent manifest;
2. stores the current NinjaOne organization, location, node, and machine values;
3. starts one signed status scan and upload using that stored context.
Expected output includes `OCSENTINEL_NINJA_CONTEXT=updated`; this is emitted
only after the immediate upload succeeds. Do not run this script from an
interactive PowerShell session, because NinjaOne does not expose the required
environment values there.
## Secret Bootstrap ## Secret Bootstrap
```powershell ```powershell
@@ -65,3 +144,31 @@ Runtime:
This writes: This writes:
- `C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat` - `C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat`
## Development Upload
For the internal development environment, copy
`config/ocsentinel-client.dev.example.json` to the installed client config
path and use its HTTP webhook URL. Production clients must use the HTTPS
configuration with the public Sentinel domain instead.
## Current Manual Release State
As of July 16, 2026, the first manual release is already published:
- tag: `v1.2.3`
- release URL: `https://gitea.officecom.cloud/officecom/oc-sentinel/releases/tag/v1.2.3`
- stable manifest URL: `https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json`
The manifest is intentionally version-independent for NinjaOne. Only the JSON content changes per release; the NinjaOne task URL stays the same.
This means NinjaOne rollout can start immediately without waiting for a Gitea runner.
## Later Automation
When a Gitea runner is added later, the usual next step is:
1. connect to the runner host through SSH or RDP, depending on the server type
2. install and register the Gitea runner
3. let `.gitea/workflows/client-build.yml` publish future release artifacts automatically
4. update `release/stable/version.json` automatically as part of the release flow

View File

@@ -25,3 +25,51 @@ n8n is responsible for:
- storage in the central backend - storage in the central backend
- organization-wide aggregation - organization-wide aggregation
- NinjaOne organization API updates - NinjaOne organization API updates
## Required n8n Workflow
The webhook itself may be reachable only on the internal network. It does not
require public access to the n8n editor or API. Every managed device must be
able to reach the webhook URL over HTTPS.
For the isolated development environment only, HTTP is permitted at
`http://172.16.41.197:5678/webhook/ocsentinel-ingest`. Do not reuse this URL,
the development shared secret, or a disabled-TLS configuration in production.
1. `Webhook`: accept `POST` on the configured private URL and enable **Raw Body**.
2. `Code`: reject a request if `X-ATN-Device`, `X-ATN-Timestamp`,
`X-ATN-Nonce`, `X-ATN-Version`, `X-ATN-Payload-SHA256`, or
`X-ATN-Signature` is missing; reject timestamps outside five minutes.
3. `Code`: calculate SHA-256 over the raw request body and compare it with
`X-ATN-Payload-SHA256`. Calculate HMAC-SHA256 over the following exact
newline-separated string and compare it in constant time with
`X-ATN-Signature`:
```text
<device>\n<timestamp>\n<nonce>\n<version>\n<payload-sha256>
```
In the current n8n Webhook node, the raw bytes are exposed as Base64 at
`$binary.data.data`. Decode this value before calculating the payload hash.
Do not hash `JSON.stringify($json.body)`: parsing and reserializing JSON
changes whitespace and can change the signed byte sequence.
4. `Postgres`: insert the nonce into `ocsentinel.ingest_nonce` with a short
expiry. If it already exists, return `409` and do not process the report.
5. `Postgres`: upsert the device, insert a row in `ocsentinel.scan_report`,
then return `202`.
6. A separate scheduled n8n workflow reads
`ocsentinel.organization_summary` and `ocsentinel.current_device_status`
to update the NinjaOne organization fields through the API.
Use an n8n credential for the shared HMAC secret and a separate n8n credential
for PostgreSQL. Do not store either value in workflow JSON or this repository.
For the current Docker deployment, use the private hostname
`ocsentinel-postgres` and the restricted database role `ocsentinel_n8n`; see
`infra/dockge/README.md` for the remaining credential fields.
## PostgreSQL Scope
- The client only knows its outward upload destination.
- PostgreSQL connection details belong to the internal ingest or n8n side.
- If the PostgreSQL IP changes, update the internal server-side configuration and review it during the next release.

25
docs/release-checklist.md Normal file
View File

@@ -0,0 +1,25 @@
# Release Checklist
## Goal
Use this checklist before publishing every OfficeCom Sentinel release.
## Infrastructure Check
1. Confirm the internal PostgreSQL target is still correct in `infra/postgres-target.example.json` or its private production counterpart.
2. Confirm the n8n internal base URL is still correct.
3. Confirm the public client ingest URL still forwards to the intended internal service.
4. Confirm no internal PostgreSQL host or IP is embedded in client configuration, installer output, or public release artifacts.
## Build Check
1. Build `OCSentinelClient-win-x64.zip`.
2. Build `OCSentinelSetup.exe`.
3. Generate `version.json`.
4. Verify SHA-256 output matches the released ZIP.
## Publish Check
1. Upload the ZIP, SHA256 file, and setup EXE to the release.
2. Update `release/stable/version.json` so NinjaOne keeps a version-independent manifest URL.
3. If infrastructure changed, update the internal Postgres target record in the repo at the same time.

253
docs/roadmap.md Normal file
View File

@@ -0,0 +1,253 @@
# OfficeCom Sentinel Roadmap
## Produktziel
OfficeCom Sentinel erkennt nachvollziehbare Sicherheitsmuster auf Windows-
Endpunkten und Fileservern, ohne den Betrieb zu stoeren. NinjaOne ist fuer
zeitnahe Alerts zustaendig. Die zentrale Plattform sammelt verdichtete
Telemetrie, zeigt die Sicherheitslage je Organisation und erstellt Berichte.
Der Client ersetzt weder G DATA/MXDR noch ein EDR. Er ergaenzt diese Systeme mit
lokaler Korrelation, organisationsuebergreifender Sicht und nachvollziehbaren
Incident-Protokollen.
## Leitplanken
- Wenige aussagekraeftige Signale statt Alarmierung bei Einzelereignissen.
- Die Bewertung muss im JSON-Report und in der Uebersicht nachvollziehbar sein.
- Kein direkter Datenbankzugriff und keine internen Infrastrukturwerte im Client.
- Standardmaessig minimale Last: keine Vollscans, kein globales Dateiauditing,
keine dauerhafte Uebertragung von Rohereignissen.
- Datenminimierung: zentrale Speicherung nur von verdichteten Ereignissen und
Incident-Kontext, nicht von vollstaendigen Dateilisten.
## Beta- und Rollback-Modell
Jede neue Erkennung, Datenart und UI-Aenderung durchlaeuft denselben
reversiblen Lieferweg. Eine Funktion wird nie erstmals auf dem gesamten Bestand
aktiv geschaltet.
### Stufe 0: Spezifikation und lokale Tests
- Zweck, Datenfelder, Bewertung und erwartete Last werden vor dem Coding
dokumentiert.
- Beispielereignisse decken Normalfall, Hinweis, Warnung, kritisch und Fehler
ab.
- Der Client muss bei fehlender neuer Konfiguration das bisherige Verhalten
unveraendert beibehalten.
### Stufe 1: Interne Beta
- Das Paket wird als separater Beta-Release veroeffentlicht; `stable` bleibt
unveraendert.
- Eine neue Funktion ist per Feature-Schalter standardmaessig deaktiviert.
- Die Beta wird nur auf Testgeraeten bzw. einer internen Organisation verteilt.
- Zentrale Auswertung prueft Laufzeit, Upload-Volumen, Fehler und Datenformate.
### Stufe 2: Passiver Kunden-Pilot
- Ausgewaehlte Geraete erhalten die Beta mit aktivierter Funktion im
Beobachtungsmodus.
- Signale erscheinen in Protokoll und Dashboard, loesen aber keine NinjaOne-
Alarmbedingung aus.
- Der Pilot laeuft mindestens eine realistische Arbeitswoche, bei Fileservern
inklusive der normalen Spitzenzeiten.
### Stufe 3: Kontrollierte Alarmierung
- Erst nach Auswertung werden Warnungen fuer eine kleine, benannte Pilotgruppe
an NinjaOne uebergeben.
- Hinweise bleiben weiterhin rein informativ.
- Schwellenwerte, Ausnahmen und Empfaenger werden pro Pilot dokumentiert.
### Stufe 4: Stable-Rollout
- Rollout zuerst je Organisation oder Richtlinie, nicht an alle Kunden zugleich.
- Der Stable-Kanal wird erst nach erfolgreichem Pilot, Review der Datenqualitaet
und Freigabe der Alarmbedingungen aktualisiert.
- Die vorherige Stable-Version bleibt als signiertes Release verfuegbar.
### Rueckfall
- Sofort: Feature-Schalter in der Richtlinie deaktivieren. Der Client bleibt
installiert, sammelt fuer diese Funktion aber nichts mehr.
- Kurzfristig: Pilotgeraete ueber NinjaOne auf die vorherige Stable-Version
zuruecksetzen.
- Zentral: Die Auswertung kann das neue Feld ignorieren; neue JSON-Felder sind
immer optional und muessen abwaertskompatibel bleiben.
- Datenbankaenderungen werden nur additiv eingefuehrt. Loeschende oder nicht
rueckgaengig zu machende Migrationen gehoeren nicht in eine Beta.
### Abbruchkriterien
Ein Pilot wird pausiert und zurueckgesetzt, wenn eines dieser Kriterien eintritt:
- spuerbare Last oder Beeintraechtigung auf einem Kundenserver,
- unkontrolliertes Upload- oder Queue-Wachstum,
- fehlerhafte Organisationszuordnung oder unerwartete personenbezogene Daten,
- mehr als ein unbegruendeter NinjaOne-Alarm im Pilot ohne klare Korrektur,
- fehlende oder nicht nachvollziehbare Incident-Protokolle.
## Ausgangslage: geliefert
- Endpoint-Client mit signiertem Upload und lokaler NinjaOne-Feldaktualisierung.
- N8n- und PostgreSQL-Pipeline mit organisationsbezogener Zuordnung.
- Interne Uebersicht, Empfaengerverwaltung und woechentliche HTML-Berichte.
- Gestaffelte taegliche Uploads sowie Burst-Pruefung.
- Version 1.4.0: Fehlanmeldungen werden in 15-Minuten-Fenstern korreliert.
Einzelne Tippfehler erzeugen keinen Alarm; Anmelde-Bursts und Password
Spraying werden als Warnung oder kritisch bewertet.
## Voraussetzung: 1.4.1 Beta-Auslieferung
- Eigener Beta-Manifest-Pfad neben `release/stable/version.json`.
- Eigene NinjaOne-Aufgabe fuer Pilotgeraete, die ausschliesslich den
Beta-Manifest-Pfad verwendet.
- Stable-Aufgabe bleibt unveraendert und ist zugleich der schnelle Rollback auf
die letzte freigegebene Version.
- Beta-Releases werden in Gitea als Vorabversion markiert und erhalten dieselbe
Paket-Hash-Pruefung wie Stable-Releases.
- Jeder Pilot dokumentiert Geraete, Organisation, aktivierte Feature-Schalter,
Startzeitpunkt und verantwortliche Person.
## Naechster Schwerpunkt: 1.5 Ransomware-Frueherkennung
### 1.5.0: Leichtgewichtiger Fileserver-Sensor
**Lieferumfang**
- Inkrementelle Auswertung statt Dateiscan: Nur neue Prozess- und
Systemereignisse sowie Aenderungszaehler seit dem letzten Pruefpunkt.
- Erkennung hochrelevanter Manipulationen wie Schattenkopie-, Recovery- und
Backup-Loeschbefehle sowie verdaechtiger Verschluesselungswerkzeuge.
- Lokaler Ringpuffer mit aggregierten Datei-Churn-Signalen aus vorhandenen
Datei-Audit-Ereignissen. Die erste Beta wertet Loesch- und Schreibzugriffe
ohne Datei- oder Freigabenamen aus.
- Snapshot der SMB-Sitzungen und des Incident-Kontexts erst bei einer
Auffaelligkeit.
- Verdichtetes Ransomware-Incident-Protokoll fuer n8n und die Uebersicht.
- Strukturierte Sensorabdeckung im Report, damit fehlende Audit- oder Sysmon-
Quellen von einem echten unauffaelligen Zeitraum unterscheidbar sind.
**Bewertung**
- Hinweis: Ein schwaches, isoliertes Signal. Es erscheint in Protokoll,
Uebersicht und Wochenbericht, aber nicht als NinjaOne-Alarm.
- Warnung: Zwei unabhaengige Signale innerhalb eines kurzen Zeitfensters oder
eine veraenderte Canary-Datei.
- Kritisch: Mehrere korrelierte Signale oder eine bestaetigte Schutzmeldung von
G DATA/MXDR zusammen mit auffaelligem Datei-Churn.
**Last- und Datenschutzgrenzen**
- Sensorpruefung hoechstens einmal pro Minute, ausschliesslich inkrementell.
- Kein globales Windows-Dateiauditing und keine globale Sysmon-Dateierstellung.
- Keine Datei-Hashes und keine rekursiven Share-Scans im Normalbetrieb.
- Maximal ein verdichteter Incident-Upload je Fileserver und fuenf Minuten;
gleiche Muster werden lokal zusammengefasst.
- Keine Dateinamen im Standardprotokoll; optionale, begrenzte Detaildaten nur
fuer explizit konfigurierte kritische Freigaben.
**Abnahme**
- Test auf einem produktionsnahen Fileserver mit normaler Benutzerlast.
- Vergleich der Sensorlast vor und nach Aktivierung.
- Nachweis, dass normale Dateiaktivitaet von vielen Benutzern keinen Alert
erzeugt und ein simuliertes Mehrsignal-Szenario korrekt eskaliert.
- Offline-Pufferung, Deduplizierung und Retry des Incident-Protokolls getestet.
- Start als interne Beta gemaess dem Beta- und Rollback-Modell; der Sensor wird
erst nach dem passiven Fileserver-Pilot als NinjaOne-Alarm aktiviert.
### 1.5.1: Tuning und kontrollierter Rollout
- Baseline je Fileserver und Zeitfenster aus mindestens einer Arbeitswoche.
- Konfigurierbare Ausnahmen fuer bekannte Backup-, Scan- und Servicekonten.
- Pilotgruppe mit wenigen Fileservern, Auswertung der Hinweise und Anpassung
der Schwellenwerte vor breiter Verteilung.
- Klare NinjaOne-Conditions fuer Warnung und kritisch; Hinweise bleiben ohne
Ticket- oder Alarmflut.
## Danach: 1.6 Zusaetzliche Sensoren
- Neue lokale Administratoren und auffaellige Gruppenmitgliedschaften.
- RDP- und SMB-Fehlanmeldungen mit Quell- und Konto-Korrelation.
- Sicherheitsrelevante Aenderungen an Diensten, geplanten Aufgaben und
Autostart-Mechanismen.
- Optionaler Import von G DATA-/MXDR-relevanten lokalen Ereignissen, sofern
diese verlaesslich und ohne proprietaere Nebenlast verfuegbar sind.
## Danach: 1.7 Modernes Web GUI und Visualisierung
Das interne Web GUI wird von einer Debug-Ansicht zu einer schnellen,
arbeitsfaehigen Sicherheitsuebersicht weiterentwickelt. Es bleibt intern und
benoetigt keine eigene Anmeldung, solange der Zugriff ueber das bestehende
interne Netz und den Reverse Proxy abgesichert ist.
### Informationsarchitektur
- Startseite mit Sicherheitslage ueber alle Organisationen, aktiven Incidents,
Datenabdeckung und Upload-Gesundheit.
- Organisationsansicht mit Trend, betroffenen Geraeten, offenen Hinweisen und
letzter erfolgreicher Datenerfassung.
- Geraeteansicht mit klarer Risikozusammenfassung, Ereignis-Timeline,
Ransomware-Incident-Protokollen und aufgeklapptem Rohdatenexport fuer die
technische Analyse.
- Berichtsbereich mit Vorschau, Versandstatus, Empfaengerregeln und erneutem
Versand einer Organisation.
### Visualisierung
- Zeitreihe fuer Hinweise, Warnungen und kritische Signale je Organisation.
- Gestapelte Tagesansicht fuer Login-, CVE-, Ransomware- und Sensor-Signale.
- Heatmap fuer auffaellige Zeitfenster statt einer langen, schwer lesbaren
Ereignisliste.
- Abdeckungsansicht: aktive Clients, veraltete Scans, Upload-Fehler und
Geraete ohne Organisationszuordnung.
- Jede Grafik verweist auf die zugrundeliegenden Geraete und Ereignisse; es
gibt keine rein dekorativen Kennzahlen ohne Drill-down.
### Technische Leitplanken
- Responsive fuer Notebook, Tablet und Mobilansicht; barrierearme Kontraste und
klare Statusfarben.
- Datenbankabfragen liefern aggregierte Zeitreihen. Rohdaten werden nur beim
Oeffnen einer Geraete- oder Incident-Ansicht nachgeladen.
- Begrenzte Zeitraeume und serverseitige Pagination verhindern langsame Seiten
bei wachsendem Datenbestand.
- HTML-E-Mails und Weboberflaeche teilen einen konsistenten visuellen Standard,
aber keine fragilen, kopierten CSS-Regeln.
### Beta und Abnahme
- Neue GUI zunaechst unter separatem internen Beta-Pfad neben der bestehenden
Uebersicht bereitstellen.
- Vergleich der neuen Kennzahlen mit den bekannten Rohdaten und Wochenberichten.
- Pilot mit realen Organisationen, insbesondere einer groesseren Fileserver-
Umgebung, vor Umschalten der Standardansicht.
- Zuruecksetzen erfolgt ueber den Reverse Proxy auf die bestehende GUI; Daten
und Empfaengerregeln bleiben dabei unveraendert.
## Danach: 1.8 Betrieb und Auswertung
- Datenqualitaetspruefung fuer unbekannte Organisationen, fehlende Zuordnung
und veraltete Clients.
- Sensor- und Client-Gesundheit in der internen Uebersicht.
- Berichtsvarianten je Empfaengergruppe und nachvollziehbare Versandhistorie.
- Betriebsmetriken fuer Upload-Fehler, Queue-Alter und Incident-Volumen.
## Nicht Bestandteil
- Kein zweiter Antivirus- oder EDR-Agent.
- Keine Blockierung oder automatische Wiederherstellung durch OCSentinel ohne
explizite, separat freigegebene Schutzfunktion.
- Kein zentraler Upload aller Dateioperationen oder kompletter Eventlogs.
## Qualitaet in jedem Release
- Keine neue Erkennung ohne Beispielereignisse, Regressionstest und dokumentierte
Bewertungslogik.
- Jede neue Datenart benoetigt Zweck, Aufbewahrungsregel und Datenschutzpruefung.
- Vor jeder Minor-Version: Code-Clean-up, Abhaengigkeiten pruefen, tote Pfade
entfernen, ueberfluessige Kommentare loeschen und Dokumentation aktualisieren.
- Release erst nach Build, Paket-Hash-Pruefung und einem Test der Update- und
Upload-Strecke.

View File

@@ -0,0 +1,6 @@
DB_HOST=ocsentinel-postgres
DB_PORT=5432
DB_NAME=ocsentinel
DB_USER=ocsentinel_debug
DB_PASSWORD=replace-with-server-generated-password
DASHBOARD_CSRF_SECRET=replace-with-server-generated-secret

View File

@@ -0,0 +1,15 @@
FROM python:3.13-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY templates ./templates
COPY static ./static
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
USER ocsentinel
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--threads", "4", "--timeout", "30", "app:app"]

View File

@@ -0,0 +1,570 @@
import hashlib
import hmac
import json
import os
from datetime import datetime, timezone
import psycopg
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
app = Flask(__name__)
CURRENT_EVENT_HOURS = 24
STALE_REPORT_HOURS = 36
def db_connection():
return psycopg.connect(
host=os.environ["DB_HOST"],
port=os.getenv("DB_PORT", "5432"),
dbname=os.environ["DB_NAME"],
user=os.environ["DB_USER"],
password=os.environ["DB_PASSWORD"],
connect_timeout=5,
)
def csrf_token():
secret = os.environ["DASHBOARD_CSRF_SECRET"].encode("utf-8")
return hmac.new(secret, b"recipient-rules", hashlib.sha256).hexdigest()
def require_csrf():
supplied = request.form.get("csrf_token", "")
if not hmac.compare_digest(supplied, csrf_token()):
abort(400)
def event_metadata(payload):
latest_event = None
for event in (payload or {}).get("Events", []):
value = event.get("Timestamp")
if not value:
continue
try:
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
continue
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
if latest_event is None or timestamp > latest_event:
latest_event = timestamp
if latest_event is None:
return {"is_current": False, "label": "keine Ereignisse", "timestamp": None}
age_seconds = max(0, int((datetime.now(timezone.utc) - latest_event.astimezone(timezone.utc)).total_seconds()))
if age_seconds < 3600:
age_label = f"vor {max(1, age_seconds // 60)} Min."
elif age_seconds < 86400:
age_label = f"vor {age_seconds // 3600} Std."
else:
age_label = f"vor {age_seconds // 86400} Tg."
return {
"is_current": age_seconds <= CURRENT_EVENT_HOURS * 3600,
"label": age_label,
"timestamp": latest_event,
}
def payload_value(payload, *names, default=None):
if not isinstance(payload, dict):
return default
for name in names:
if name in payload:
return payload[name]
return default
@app.get("/")
def overview():
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute("SELECT * FROM ocsentinel.organization_summary")
summary = cursor.fetchone()
cursor.execute(
"""
SELECT machine_name, organization_name, received_at, alert_state,
total_events, unique_ip_count, cve_total, cve_critical, payload
FROM (
SELECT machine_name, received_at, alert_state, total_events,
unique_ip_count, cve_total, cve_critical,
payload #>> '{NinjaOne,OrganizationName}' AS organization_name,
payload
FROM ocsentinel.current_device_status
) AS status
ORDER BY received_at DESC NULLS LAST
LIMIT 100
"""
)
reports = cursor.fetchall()
cursor.execute(
"""
SELECT count(*) AS known_devices,
count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices,
count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices
FROM ocsentinel.current_device_status
"""
)
coverage = cursor.fetchone()
cursor.execute(
"""
SELECT machine_name, alert_state, total_events, unique_ip_count,
received_at, payload
FROM ocsentinel.current_device_status
WHERE alert_state IN ('warning', 'critical')
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC
"""
)
alerts = cursor.fetchall()
cursor.execute(
"""
WITH latest AS (
SELECT DISTINCT ON (d.machine_name_key, date_trunc('day', r.received_at))
date_trunc('day', r.received_at)::date AS day,
r.alert_state,
r.total_events
FROM ocsentinel.scan_report AS r
JOIN ocsentinel.device AS d ON d.id = r.device_id
WHERE r.received_at >= now() - interval '14 days'
ORDER BY d.machine_name_key, date_trunc('day', r.received_at), r.received_at DESC
)
SELECT day,
count(*) FILTER (WHERE alert_state = 'warning') AS warning_count,
count(*) FILTER (WHERE alert_state = 'critical') AS critical_count,
coalesce(sum(total_events), 0) AS event_count
FROM latest
GROUP BY day
ORDER BY day
"""
)
trend = cursor.fetchall()
cursor.execute(
"""
SELECT coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') AS organization_id,
coalesce(nullif(payload #>> '{NinjaOne,OrganizationName}', ''), 'Organisation unbekannt') AS organization_name,
count(*) AS device_count,
count(*) FILTER (WHERE alert_state = 'warning') AS warning_count,
count(*) FILTER (WHERE alert_state = 'critical') AS critical_count,
max(received_at) AS last_received_at
FROM ocsentinel.current_device_status
GROUP BY 1, 2
ORDER BY critical_count DESC, warning_count DESC, organization_name
"""
)
organizations = cursor.fetchall()
report_rows = []
for row in reports:
event = event_metadata(row[8])
report_rows.append(
{
"machine_name": row[0],
"organization_name": row[1],
"received_at": row[2],
"alert_state": row[3],
"total_events": row[4],
"unique_ip_count": row[5],
"event": event,
}
)
alert_rows = []
for row in alerts:
event = event_metadata(row[5])
alert_rows.append(
{
"machine_name": row[0],
"alert_state": row[1],
"total_events": row[2],
"unique_ip_count": row[3],
"received_at": row[4],
"event": event,
}
)
trend_rows = [
{
"day": row[0],
"warning_count": row[1],
"critical_count": row[2],
"event_count": row[3],
}
for row in trend
]
trend_max = max([row["event_count"] for row in trend_rows] or [1])
organization_rows = [
{
"id": row[0],
"name": row[1],
"device_count": row[2],
"warning_count": row[3],
"critical_count": row[4],
"last_received_at": row[5],
}
for row in organizations
]
return render_template(
"overview.html",
summary=summary,
coverage=coverage,
reports=report_rows,
alerts=alert_rows,
current_alert_count=sum(alert["event"]["is_current"] for alert in alert_rows),
trend=trend_rows,
trend_max=trend_max,
organizations=organization_rows,
)
@app.get("/organizations/<organization_id>")
def organization(organization_id):
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT machine_name, received_at, alert_state, total_events, unique_ip_count,
cve_critical, payload
FROM ocsentinel.current_device_status
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s
ORDER BY CASE alert_state WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
machine_name
""",
(organization_id,),
)
devices = cursor.fetchall()
if not devices:
abort(404)
organization_name = (devices[0][6] or {}).get("NinjaOne", {}).get("OrganizationName") or "Organisation unbekannt"
return render_template(
"organization.html",
organization_id=organization_id,
organization_name=organization_name,
devices=devices,
critical_count=sum(row[2] == "critical" for row in devices),
warning_count=sum(row[2] == "warning" for row in devices),
)
def load_network_flows(days=14):
days = max(1, min(days, 90))
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT machine_name, received_at, payload
FROM ocsentinel.current_device_status
WHERE received_at >= now() - (%s * interval '1 day')
""",
(days,),
)
reports = cursor.fetchall()
flows = {}
for machine_name, received_at, payload in reports:
ninja_context = (payload or {}).get("NinjaOne") or {}
organization_id = str(ninja_context.get("OrganizationId") or "unknown")
organization_name = ninja_context.get("OrganizationName") or "Organisation unbekannt"
for event in (payload or {}).get("Events", []):
source_ip = event.get("SourceIp") or ""
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
continue
account = event.get("Username") or "[unbekannt]"
target = event.get("Target") or "Anmeldung"
key = (source_ip, machine_name, account, target, organization_id)
entry = flows.setdefault(
key,
{
"source_ip": source_ip,
"machine_name": machine_name,
"account": account,
"target": target,
"organization_id": organization_id,
"organization_name": organization_name,
"count": 0,
"last_seen": received_at,
},
)
entry["count"] += 1
timestamp = event.get("Timestamp")
if timestamp and (entry["last_seen"] is None or str(timestamp) > str(entry["last_seen"])):
entry["last_seen"] = timestamp
flow_rows = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["last_seen"])), reverse=True)[:60]
max_count = max([entry["count"] for entry in flow_rows] or [1])
source_count = len({entry["source_ip"] for entry in flow_rows})
target_count = len({entry["machine_name"] for entry in flow_rows})
return flow_rows, max_count, source_count, target_count
@app.get("/network")
def network():
days = request.args.get("days", 14, type=int)
flows, _, source_count, target_count = load_network_flows(days)
return render_template(
"network.html",
source_count=source_count,
target_count=target_count,
total_events=sum(entry["count"] for entry in flows),
path_count=len(flows),
days=max(1, min(days, 90)),
organizations=sorted({(entry["organization_id"], entry["organization_name"]) for entry in flows}, key=lambda item: item[1]),
event_types=sorted({entry["target"] for entry in flows}),
)
@app.get("/api/network")
def network_api():
days = request.args.get("days", 14, type=int)
flows, max_count, source_count, target_count = load_network_flows(days)
nodes = {}
edges = []
for index, flow in enumerate(flows):
source_id = f"source:{flow['source_ip']}"
target_id = f"target:{flow['machine_name']}"
nodes[source_id] = {"data": {"id": source_id, "label": flow["source_ip"], "kind": "source"}}
nodes[target_id] = {"data": {"id": target_id, "label": flow["machine_name"], "kind": "target"}}
edges.append(
{
"data": {
"id": f"flow:{index}",
"source": source_id,
"target": target_id,
"count": flow["count"],
"account": flow["account"],
"event_type": flow["target"],
"last_seen": str(flow["last_seen"] or "-"),
"machine_name": flow["machine_name"],
"organization_id": flow["organization_id"],
"organization_name": flow["organization_name"],
}
}
)
return jsonify(
{
"elements": {"nodes": list(nodes.values()), "edges": edges},
"max_count": max_count,
"source_count": source_count,
"target_count": target_count,
}
)
@app.get("/device/<machine_name>")
def device(machine_name):
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT machine_name, first_seen_at, last_seen_at, last_client_version,
generated_at_utc, received_at, alert_state, base_alert_state,
total_events, unique_ip_count, cve_total, cve_critical, payload
FROM ocsentinel.current_device_status
WHERE machine_name = %s
""",
(machine_name,),
)
report = cursor.fetchone()
if report is None:
abort(404)
payload = report[12]
event_groups = {}
for entry in payload.get("Events") or payload.get("events") or []:
event_type = entry.get("Target") or entry.get("target") or "Sicherheitsereignis"
account = entry.get("Username") or entry.get("username") or "-"
source_ip = entry.get("SourceIp") or entry.get("sourceIp") or "-"
key = (event_type, account, source_ip)
group = event_groups.setdefault(
key,
{"type": event_type, "account": account, "source_ip": source_ip, "count": 0, "latest": "-"},
)
group["count"] += 1
timestamp = entry.get("Timestamp") or entry.get("timestamp") or "-"
if timestamp > group["latest"]:
group["latest"] = timestamp
security_events = sorted(
event_groups.values(),
key=lambda entry: (entry["latest"], entry["count"]),
reverse=True,
)[:25]
ransomware_raw = payload.get("RansomwareBeta") or payload.get("ransomwareBeta") or {}
sensor_labels = {
"security-process-4688": "Prozessstarts (Security 4688)",
"powershell-script-block-4104": "PowerShell-Skriptblöcke (4104)",
"sysmon-process-1": "Sysmon-Prozesse (1)",
"security-file-audit-4663": "Datei-Auditing (4663)",
}
ransomware_sensors = [
{
"label": sensor_labels.get(payload_value(sensor, "Name", "name"), payload_value(sensor, "Name", "name", default="Unbekannter Sensor")),
"enabled": payload_value(sensor, "Enabled", "enabled", default=False),
"available": payload_value(sensor, "Available", "available", default=False),
"state": payload_value(sensor, "State", "state", default="unknown"),
"event_count": payload_value(sensor, "EventCount", "eventCount", default=0),
}
for sensor in payload_value(ransomware_raw, "Sensors", "sensors", default=[])
]
ransomware_beta = {
"enabled": payload_value(ransomware_raw, "Enabled", "enabled", default=False),
"state": payload_value(ransomware_raw, "State", "state", default="disabled"),
"reason": payload_value(ransomware_raw, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
"signals": [
{
"timestamp": payload_value(signal, "Timestamp", "timestamp", default="-"),
"category": payload_value(signal, "Category", "category", default="Signal"),
"process": payload_value(signal, "Process", "process", default="-"),
"source": payload_value(signal, "Source", "source", default="-"),
"confidence": payload_value(signal, "Confidence", "confidence", default="low"),
}
for signal in payload_value(ransomware_raw, "Signals", "signals", default=[])
],
"smbSessions": [
{
"clientComputerName": payload_value(session, "ClientComputerName", "clientComputerName", default="-"),
"clientUserName": payload_value(session, "ClientUserName", "clientUserName", default="-"),
"openFileCount": payload_value(session, "OpenFileCount", "openFileCount", default=0),
"sessionId": payload_value(session, "SessionId", "sessionId", default="-"),
}
for session in payload_value(ransomware_raw, "SmbSessions", "smbSessions", default=[])
],
}
return render_template(
"device.html",
report=report,
event=event_metadata(payload),
payload=payload,
security_events=security_events,
ransomware_beta=ransomware_beta,
ransomware_sensors=ransomware_sensors,
payload_pretty=json.dumps(payload, indent=2, ensure_ascii=False),
)
@app.get("/reports")
def reports():
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, organization_name, period_start_utc, period_end_utc,
generated_at, device_count, warning_count, critical_count,
total_events
FROM ocsentinel.weekly_organization_report
ORDER BY period_end_utc DESC, organization_name
"""
)
weekly_reports = cursor.fetchall()
return render_template("reports.html", reports=weekly_reports)
@app.get("/reports/<int:report_id>")
def weekly_report(report_id):
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT organization_name, period_start_utc, period_end_utc,
generated_at, report_html
FROM ocsentinel.weekly_organization_report
WHERE id = %s
""",
(report_id,),
)
report = cursor.fetchone()
if report is None:
abort(404)
return render_template("weekly_report.html", report=report)
@app.get("/recipients")
def recipients():
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, organization_id, organization_name, recipient_email, enabled
FROM ocsentinel.organization_report_recipient
ORDER BY organization_id = '*', organization_name, recipient_email
"""
)
rules = cursor.fetchall()
cursor.execute(
"""
SELECT DISTINCT payload #>> '{NinjaOne,OrganizationId}',
payload #>> '{NinjaOne,OrganizationName}'
FROM ocsentinel.current_device_status
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', '') <> ''
ORDER BY 2
"""
)
organizations = cursor.fetchall()
return render_template("recipients.html", rules=rules, organizations=organizations, csrf_token=csrf_token())
@app.post("/recipients")
def add_recipient():
require_csrf()
organization_id = request.form.get("organization_id", "").strip()
organization_name = request.form.get("organization_name", "").strip()
recipient_email = request.form.get("recipient_email", "").strip().lower()
if not organization_id or not organization_name or "@" not in recipient_email:
abort(400)
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO ocsentinel.organization_report_recipient
(organization_id, organization_name, recipient_email)
VALUES (%s, %s, %s)
ON CONFLICT (organization_id, recipient_email) DO NOTHING
""",
(organization_id, organization_name, recipient_email),
)
connection.commit()
return redirect(url_for("recipients"))
@app.post("/recipients/<int:rule_id>/toggle")
def toggle_recipient(rule_id):
require_csrf()
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(
"UPDATE ocsentinel.organization_report_recipient SET enabled = NOT enabled WHERE id = %s",
(rule_id,),
)
connection.commit()
return redirect(url_for("recipients"))
@app.post("/recipients/<int:rule_id>/delete")
def delete_recipient(rule_id):
require_csrf()
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute("DELETE FROM ocsentinel.organization_report_recipient WHERE id = %s", (rule_id,))
connection.commit()
return redirect(url_for("recipients"))
@app.get("/healthz")
def healthz():
try:
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute("SELECT 1")
return {"status": "ok"}
except Exception:
return {"status": "unavailable"}, 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)

View File

@@ -0,0 +1,22 @@
services:
ocsentinel-debug:
build: .
container_name: ocsentinel-debug
restart: unless-stopped
env_file: .env
ports:
- "172.16.41.197:8090:8080"
networks:
- ocsentinel-network
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
ocsentinel-network:
external: true
name: n8n_n8n-network

View File

@@ -0,0 +1,3 @@
Flask==3.1.1
gunicorn==23.0.0
psycopg[binary]==3.2.9

View File

@@ -0,0 +1,78 @@
:root { --ink:#132a3d; --muted:#5f7180; --paper:#eaf1f7; --panel:#ffffff; --line:#d5e1eb; --green:#14735b; --lime:#b8e36a; --amber:#a55a0a; --red:#a52b31; }
* { box-sizing:border-box; }
body { margin:0; color:var(--ink); background:radial-gradient(circle at 10% -12%, #d9e9f7 0, transparent 30rem),radial-gradient(circle at 95% 8%, #dff2ec 0, transparent 24rem),var(--paper); font-family:'Roboto',sans-serif; }.app-shell:before { content:''; position:fixed; z-index:-1; inset:0; opacity:.34; background-image:linear-gradient(rgba(26,73,111,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(26,73,111,.045) 1px,transparent 1px); background-size:36px 36px; mask-image:linear-gradient(to bottom,black,transparent 68%); }
.masthead { height:70px; padding:0 6vw; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #21445f; background:#102a43; box-shadow:0 5px 24px rgba(16,42,67,.2); }.wordmark { color:#fff; font:700 19px 'Roboto',sans-serif; letter-spacing:-.04em; text-decoration:none; }.wordmark span { display:inline-grid; place-items:center; width:27px; height:27px; margin-right:7px; border-radius:7px; background:#b8e36a; color:#102a43; font-size:10px; letter-spacing:0; }.header-links { display:flex; gap:8px; align-items:center; }.header-links a { padding:8px 10px; border-radius:6px; color:#c8d6e1; font:700 12px 'Roboto',sans-serif; text-decoration:none; transition:background .18s ease,color .18s ease; }.header-links a:hover,.header-links a.active { color:#fff; background:#245a85; }
.brand { color:var(--ink); font:700 20px/1 'Roboto',sans-serif; text-decoration:none; letter-spacing:-.04em; }.brand span { display:inline-grid; place-items:center; margin-right:7px; width:28px; height:28px; background:var(--green); color:#fff; border-radius:50%; font-size:11px; letter-spacing:0; }.badge,.eyebrow { color:var(--muted); font:700 10px/1 'Roboto',sans-serif; text-transform:uppercase; letter-spacing:.12em; }.badge { border:1px solid var(--line); padding:6px 8px; border-radius:20px; }
main { max-width:1280px; margin:auto; padding:32px 6vw 80px; }.hero { max-width:760px; margin-bottom:32px; }.hero h1 { font-size:clamp(34px,5vw,64px); line-height:.98; letter-spacing:-.06em; margin:10px 0; }.hero p { color:var(--muted); font-size:18px; }.hero.compact h1 { font-size:48px; }.hero-note { display:flex; align-items:center; gap:8px; margin-top:20px; color:var(--green); font:700 11px 'Roboto',sans-serif; letter-spacing:.03em; }.hero-note span { width:8px; height:8px; border-radius:50%; background:var(--lime); box-shadow:0 0 0 4px rgba(199,238,107,.25); }
.metrics { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin:25px 0 46px; background:transparent; }.metrics article { min-height:130px; padding:20px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 16px rgba(35,56,42,.035); transition:transform .18s ease,box-shadow .18s ease; }.metrics article:hover { transform:translateY(-3px); box-shadow:0 12px 24px rgba(35,56,42,.09); }.metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.09em; text-transform:uppercase; }.metrics strong { display:block; margin-top:16px; font:700 31px 'Roboto',sans-serif; letter-spacing:-.05em; }.metrics .timestamp { font-size:14px; line-height:1.25; letter-spacing:-.02em; }.warning { color:var(--amber); }.critical { color:var(--red); }
.situation { display:flex; align-items:center; justify-content:space-between; gap:22px; margin:0 0 24px; padding:20px 22px; border:1px solid #b9d8c2; background:#edf8f0; color:#195235; }.situation.warning { border-color:#f2cf99; background:#fff6e8; color:#80450d; }.situation.critical { border-color:#edb4aa; background:#fff0ed; color:#8a2a20; }.situation strong { display:block; margin-top:7px; font:700 19px/1.15 'Roboto',sans-serif; letter-spacing:-.025em; }.situation > span { padding:7px 9px; border:1px solid currentColor; border-radius:20px; font:700 10px 'Roboto',sans-serif; letter-spacing:.1em; }
.panel { margin-top:26px; padding:26px; background:var(--panel); border:1px solid var(--line); border-radius:5px; box-shadow:0 6px 18px rgba(35,56,42,.035); }.panel-heading h2 { margin:8px 0 22px; font-size:28px; letter-spacing:-.04em; }.panel-heading h2 small { color:var(--muted); font-size:12px; font-weight:500; letter-spacing:0; }.alert-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:12px; }.alert-card { padding:17px; border-left:5px solid var(--amber); border-radius:3px; background:#fff7e9; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.alert-card:hover { transform:translateY(-2px); box-shadow:0 9px 18px rgba(88,57,20,.12); }.alert-card.critical { border-color:var(--red); background:#fff0ed; }.alert-card span,.alert-card small { display:block; font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.alert-card strong { display:block; margin:10px 0; font:700 22px 'Roboto',sans-serif; letter-spacing:-.04em; }
.coverage-panel { padding-bottom:22px; }.coverage-metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; }.coverage-metrics article { padding:15px; border:1px solid var(--line); border-radius:4px; background:#faf9f4; }.coverage-metrics span { display:block; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.coverage-metrics strong { display:block; margin-top:8px; font-size:26px; }.ok { color:var(--green); }
table { width:100%; border-collapse:collapse; font-family:'Roboto',sans-serif; font-size:13px; } th { text-align:left; color:var(--muted); font-size:10px; letter-spacing:.1em; text-transform:uppercase; } th,td { padding:13px 8px; border-bottom:1px solid var(--line); } td a { color:var(--green); font-weight:700; text-decoration:none; }.state { display:inline-block; margin:1px 3px 1px 0; padding:4px 7px; border-radius:12px; background:#e2efe6; color:var(--green); font:700 10px 'Roboto',sans-serif; text-transform:uppercase; }.state.warning { background:#fff0d7; color:var(--amber); }.state.critical { background:#ffe0db; color:var(--red); }.state.current { background:#e2efe6; color:var(--green); }.state.historic { background:#ece9e1; color:#68736e; } pre { margin:0; padding:18px; overflow:auto; color:#dce7da; background:#13221b; border-radius:4px; font:12px/1.5 'Cascadia Code',Consolas,monospace; }.table-wrap { overflow:auto; }
.report-frame { background:#fff; border:1px solid var(--line); border-radius:8px; box-shadow:0 18px 44px rgba(31,68,99,.12); overflow:hidden; }
.panel-heading p:last-child { max-width:720px; margin:-13px 0 20px; color:var(--muted); font-size:14px; }.calm-panel { border-color:#b9d8c2; background:#f4fbf5; }
.recipient-form { display:grid; grid-template-columns:minmax(220px,1fr) minmax(260px,1fr) auto; gap:14px; align-items:end; }.recipient-form label { display:grid; gap:6px; color:var(--muted); font:700 10px 'Roboto',sans-serif; letter-spacing:.08em; text-transform:uppercase; }.recipient-form input,.recipient-form select { min-height:40px; padding:9px 10px; border:1px solid var(--line); border-radius:4px; background:#fff; color:var(--ink); font:14px 'Roboto',sans-serif; }.recipient-form button,.rule-actions button { min-height:40px; padding:9px 13px; border:1px solid var(--green); border-radius:4px; background:var(--green); color:#fff; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.rule-actions { display:flex; gap:8px; }.rule-actions form { margin:0; }.rule-actions .button-secondary { border-color:#d8d4c6; background:#fffdf7; color:var(--ink); }.rule-actions .button-danger { border-color:#e3afa7; background:#fff0ed; color:#8a2a20; }
@media (max-width:850px) { .recipient-form { grid-template-columns:1fr; }.rule-actions { min-width:220px; } }
@media (max-width:850px) { .metrics { grid-template-columns:repeat(2,1fr); }.metrics article:last-child { grid-column:span 2; }.masthead { height:auto; min-height:70px; padding:14px 5vw; align-items:flex-start; }.header-links { justify-content:flex-end; flex-wrap:wrap; }.badge { display:none; } main { padding:38px 5vw; }.situation { align-items:flex-start; flex-direction:column; } }
.panel { border-radius:8px; box-shadow:0 14px 34px rgba(31,68,99,.08); }
.panel > .table-wrap { border:1px solid #dce6ee; border-radius:6px; background:#fbfdff; }
.panel > .table-wrap table { margin:0; }
.panel > .table-wrap th { padding:12px 10px; color:#456174; background:#f0f5f9; }
.panel > .table-wrap td { padding:14px 10px; }
.panel > .table-wrap tbody tr:hover { background:#f2f8fb; }
.recipient-form { padding:18px; border:1px solid #dce6ee; border-radius:6px; background:#f8fbfd; }
.recipient-form input,.recipient-form select { border-radius:5px; background:#fff; }
.recipient-form input:focus,.recipient-form select:focus { outline:2px solid rgba(36,90,133,.25); border-color:#245a85; }
.rule-actions .button-secondary { border-color:var(--line); background:#f8fbfd; }
.recipient-intro { max-width:720px; margin:6px 0 28px; }.recipient-intro h1 { margin:9px 0 10px; font-size:46px; line-height:1; letter-spacing:-.055em; }.recipient-intro p { margin:0; color:var(--muted); font-size:16px; line-height:1.55; }.recipient-intro strong { color:var(--ink); }.recipient-create-panel { margin-top:0; border-color:#c8dbe8; }.recipient-create-panel .panel-heading h2,.recipient-rules-panel .panel-heading h2 { margin:7px 0 8px; }.recipient-create-panel .panel-heading p { margin:0 0 20px; }.recipient-form button { white-space:nowrap; }.recipient-rules-panel { padding-bottom:12px; }.recipient-rules-panel .panel-heading { display:flex; align-items:end; justify-content:space-between; gap:16px; }.recipient-rules-panel .panel-heading h2 { margin-bottom:20px; }.recipient-rules-panel .panel-heading small { display:inline-block; margin-left:7px; padding:4px 7px; border-radius:12px; background:#edf4f8; color:#4d687b; font-size:10px; font-weight:700; letter-spacing:.04em; vertical-align:middle; }.recipient-table td { height:64px; }.recipient-table tr:last-child td { border-bottom:0; }.recipient-email { color:#245a85; font-weight:500; }.actions-heading { text-align:right; }.recipient-table .rule-actions { justify-content:flex-end; }.empty-state { padding:30px 10px !important; color:var(--muted); text-align:center; }
.compact-metrics { grid-template-columns:repeat(4,1fr); }.event-summary-panel { margin-top:8px; }.event-summary-panel .panel-heading h2,.raw-export-panel .panel-heading h2 { margin:7px 0 8px; }.event-summary-panel .panel-heading p,.raw-export-panel .panel-heading p { margin:0 0 20px; }.event-count { display:inline-grid; min-width:28px; min-height:28px; place-items:center; border-radius:14px; background:#fff0d7; color:var(--amber); font:700 12px 'Roboto',sans-serif; }.raw-export-panel { margin-top:8px; }.raw-json { margin-top:18px; border-top:1px solid var(--line); }.raw-json summary { padding:14px 0; color:#245a85; cursor:pointer; font:700 12px 'Roboto',sans-serif; }.raw-json pre { margin-bottom:0; } @media (max-width:850px) { .compact-metrics { grid-template-columns:repeat(2,1fr); }.compact-metrics article:last-child { grid-column:span 2; } }
.trend-panel { overflow:hidden; }.trend-chart { display:grid; grid-template-columns:repeat(auto-fit,minmax(48px,1fr)); align-items:end; min-height:210px; gap:10px; padding:18px 4px 0; border-bottom:1px solid var(--line); }.trend-day { display:grid; grid-template-rows:154px auto auto; gap:5px; min-width:0; text-align:center; }.trend-bar { position:relative; align-self:end; height:max(7px,var(--bar)); border-radius:5px 5px 0 0; background:#bfd9eb; transition:height .25s ease; }.trend-critical,.trend-warning { position:absolute; right:0; left:0; bottom:0; display:block; }.trend-critical { height:var(--critical); background:var(--red); }.trend-warning { bottom:var(--critical); height:var(--warning); background:var(--amber); }.trend-day strong { font-size:13px; }.trend-day small { color:var(--muted); font-size:10px; }.chart-note { margin:15px 0 0; color:var(--muted); font-size:11px; }.legend { display:inline-block; width:8px; height:8px; margin:0 4px 0 12px; border-radius:2px; }.legend:first-child { margin-left:0; }.legend.critical { background:var(--red); }.legend.warning { background:var(--amber); }.legend.neutral { background:#bfd9eb; }.organization-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(245px,1fr)); gap:12px; }.organization-card { display:grid; gap:11px; min-height:150px; padding:18px; border:1px solid #d7e3ec; border-radius:8px; background:linear-gradient(145deg,#fff,#f3f8fb); color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease,border-color .18s ease; }.organization-card:hover { border-color:#8fb7d0; box-shadow:0 14px 26px rgba(24,59,89,.12); transform:translateY(-2px); }.organization-card strong { font-size:19px; letter-spacing:-.035em; }.organization-card div { display:flex; flex-wrap:wrap; align-items:center; gap:5px; color:var(--muted); font-size:12px; }.organization-card small { color:var(--muted); font-size:10px; }.ransomware-panel { border-left:5px solid #8aa3b4; }.ransomware-panel.warning { border-left-color:var(--amber); }.ransomware-panel.critical { border-left-color:var(--red); }.ransomware-panel .panel-heading p { margin:0 0 18px; color:var(--muted); }
.network-panel { overflow:hidden; }.network-flows { display:grid; gap:8px; }.network-flow { display:grid; grid-template-columns:minmax(150px,.9fr) minmax(130px,1.25fr) minmax(210px,1.2fr); align-items:center; gap:16px; padding:13px 14px; border:1px solid #dce6ee; border-radius:7px; background:#fbfdff; color:var(--ink); text-decoration:none; transition:transform .18s ease,box-shadow .18s ease; }.network-flow:hover { transform:translateX(3px); box-shadow:0 8px 18px rgba(31,68,99,.1); }.flow-endpoint { display:grid; gap:3px; }.flow-endpoint span { color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.flow-endpoint strong { font-size:14px; }.flow-endpoint small { color:var(--muted); font-size:11px; }.flow-line { position:relative; display:flex; align-items:center; gap:7px; min-height:22px; }.flow-line:before { position:absolute; right:0; left:0; height:3px; background:#d7e4ed; content:''; }.flow-line i { z-index:1; width:max(5%,var(--flow)); height:7px; border-radius:6px; background:linear-gradient(90deg,#245a85,#b8e36a); }.flow-line small { z-index:1; margin-left:auto; padding:2px 5px; border-radius:8px; background:#fff; color:#456174; font:700 10px 'Roboto',sans-serif; } @media (max-width:850px) { .network-flow { grid-template-columns:1fr; gap:9px; }.flow-line { order:3; }.flow-endpoint.target { order:2; } }
.network-map-panel { overflow:hidden; }.map-toolbar { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 16px; }.map-toolbar label { flex:1 1 240px; display:grid; gap:5px; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-toolbar input,.map-toolbar button { min-height:38px; padding:8px 10px; border:1px solid var(--line); border-radius:5px; background:#fff; color:var(--ink); font:700 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#f5f9fc; }.network-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 260px; min-height:560px; overflow:hidden; border:1px solid #d7e4ed; border-radius:9px; background:radial-gradient(circle at 18% 12%,#f5fbff,transparent 28rem),#edf4f8; }.network-map-layout #network-map { min-height:560px; background-image:linear-gradient(rgba(36,90,133,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(36,90,133,.05) 1px,transparent 1px); background-size:32px 32px; }.network-map-layout aside { padding:22px; border-left:1px solid #d7e4ed; background:#fff; }.network-map-layout aside strong { display:block; margin:8px 0 12px; font-size:18px; line-height:1.15; letter-spacing:-.035em; }.network-map-layout aside p { color:var(--muted); font-size:13px; line-height:1.5; }.network-map-layout dl { display:grid; grid-template-columns:1fr; gap:4px; margin:18px 0 0; }.network-map-layout dt { color:var(--muted); font-size:10px; font-weight:700; text-transform:uppercase; }.network-map-layout dd { margin:0 0 10px; font-size:13px; overflow-wrap:anywhere; }.inspector-arrow { color:var(--green); font-size:13px; }.legend.source { background:#245a85; }.legend.target { background:#14735b; } @media (max-width:850px) { .network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid #d7e4ed; border-left:0; }.network-map-layout #network-map { min-height:460px; } }
.map-hero { display:flex; align-items:end; justify-content:space-between; gap:28px; margin:0 -2vw 26px; padding:38px 3vw 30px; border-radius:14px; color:#eaf2f8; background:radial-gradient(circle at 82% 10%,rgba(79,163,223,.25),transparent 20rem),linear-gradient(132deg,#102a43,#0c1c2a 70%); box-shadow:0 18px 50px rgba(13,30,44,.22); }.map-hero h1 { max-width:650px; margin:8px 0 14px; font-size:clamp(42px,6vw,76px); line-height:.87; letter-spacing:-.07em; }.map-hero h1 em { color:#b8e36a; font-style:normal; }.map-hero p { max-width:620px; margin:0; color:#b7cad9; font-size:15px; line-height:1.55; }.map-hero .eyebrow { color:#9cc8e8; }.map-hero-status { display:grid; min-width:145px; gap:4px; padding:16px 18px; border:1px solid rgba(184,227,106,.35); border-radius:10px; background:rgba(11,31,45,.55); }.map-hero-status span { color:#b8e36a; font:700 9px 'Roboto',sans-serif; letter-spacing:.13em; }.map-hero-status strong { font-size:37px; line-height:1; letter-spacing:-.06em; }.map-hero-status small { color:#b7cad9; }.map-stat-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:1px; margin:-10px 2vw 28px; border:1px solid #d8e5ee; border-radius:9px; overflow:hidden; background:#d8e5ee; box-shadow:0 10px 22px rgba(31,68,99,.08); }.map-stat-strip article { padding:15px 18px; background:#fff; }.map-stat-strip span,.map-stat-strip small { display:block; color:var(--muted); font:700 9px 'Roboto',sans-serif; letter-spacing:.1em; text-transform:uppercase; }.map-stat-strip strong { display:block; margin:8px 0 4px; font-size:29px; letter-spacing:-.06em; }.network-map-panel { margin-top:0; padding:0; border:0; border-radius:12px; background:#102a43; box-shadow:0 20px 44px rgba(16,42,67,.2); }.map-header { display:flex; justify-content:space-between; align-items:end; gap:16px; padding:24px 26px 18px; color:#eef6fa; }.map-header .eyebrow { color:#9cc8e8; }.map-header h2 { margin:6px 0 0; font-size:30px; letter-spacing:-.05em; }.map-legend { display:flex; gap:12px; color:#b7cad9; font-size:11px; }.map-legend span { display:flex; align-items:center; gap:5px; }.map-legend i { width:8px; height:8px; border-radius:50%; background:#4fa3df; }.map-legend i.target { background:#3bca99; border-radius:2px; }.map-legend i.hot { background:#ffb454; }.map-toolbar { align-items:end; margin:0; padding:0 26px 18px; border-bottom:1px solid rgba(156,200,232,.16); }.map-toolbar label { flex:0 1 180px; color:#9cc8e8; }.map-toolbar .search-field { flex:1 1 240px; }.map-toolbar input,.map-toolbar select,.map-toolbar button { min-height:40px; border:1px solid rgba(156,200,232,.24); border-radius:6px; background:#17374e; color:#eef6fa; font:600 12px 'Roboto',sans-serif; }.map-toolbar button { cursor:pointer; background:#245a85; }.map-toolbar button:hover { background:#326f9f; }.map-actions { display:flex; gap:7px; }.network-map-layout { grid-template-columns:minmax(0,1fr) 280px; min-height:610px; border:0; border-radius:0; background:#0d1e2c; }.network-map-layout #network-map { min-height:610px; background-image:radial-gradient(circle at 50% 0,rgba(79,163,223,.1),transparent 28rem),linear-gradient(rgba(156,200,232,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(156,200,232,.045) 1px,transparent 1px); background-size:auto,36px 36px,36px 36px; }.network-map-layout aside { padding:24px; border-left:1px solid rgba(156,200,232,.16); background:#112b3d; color:#eef6fa; }.network-map-layout aside .eyebrow { color:#9cc8e8; }.network-map-layout aside p { color:#b7cad9; }.network-map-layout dt { color:#82b7dc; }.network-map-layout dd { color:#eef6fa; }.inspector-arrow { color:#b8e36a; }.legend.source { background:#4fa3df; }.legend.target { background:#3bca99; } @media (max-width:850px) { .map-hero { flex-direction:column; align-items:start; margin:0 0 20px; }.map-stat-strip { grid-template-columns:repeat(2,1fr); margin:0 0 20px; }.map-header { align-items:start; flex-direction:column; }.map-toolbar { padding:0 18px 18px; }.network-map-layout { grid-template-columns:1fr; }.network-map-layout aside { border-top:1px solid rgba(156,200,232,.16); border-left:0; } }
:root { --font-sans:'Manrope','Segoe UI',sans-serif; --font-mono:'IBM Plex Mono','Cascadia Code',monospace; --surface:#f7fafc; --surface-strong:#edf4f8; --navy:#0c2438; --blue:#2e6b9a; }
body,body * { font-family:var(--font-sans); }
pre,code,.raw-json pre { font-family:var(--font-mono); }
body { background:radial-gradient(circle at 8% -10%,rgba(102,176,225,.24),transparent 31rem),radial-gradient(circle at 94% 6%,rgba(85,201,155,.16),transparent 24rem),linear-gradient(180deg,#edf4f8 0,#f8fafc 42%,#eef4f7 100%); }
.masthead { position:sticky; z-index:5; top:0; backdrop-filter:blur(16px); background:rgba(12,36,56,.94); }
.dashboard-hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:28px; min-height:276px; margin:0 0 18px; padding:38px; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 8%,rgba(96,183,227,.28),transparent 19rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.18); overflow:hidden; }
.dashboard-hero .eyebrow { color:#a6d2ec; }.dashboard-hero h1 { max-width:730px; margin:11px 0 15px; font-size:clamp(38px,5.2vw,66px); line-height:.94; letter-spacing:-.067em; }.dashboard-hero p { max-width:620px; margin:0; color:#bdd3e1; font-size:15px; line-height:1.65; }
.dashboard-status { align-self:end; display:grid; gap:5px; padding:19px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); box-shadow:inset 0 1px rgba(255,255,255,.06); }.dashboard-status > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.dashboard-status strong { font-size:30px; letter-spacing:-.055em; }.dashboard-status small { color:#c5d9e5; font-size:11px; }.status-orb { width:10px; height:10px; margin-bottom:4px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 6px rgba(114,211,156,.13); }.dashboard-hero.warning .status-orb { background:#ffbe62; box-shadow:0 0 0 6px rgba(255,190,98,.13); }.dashboard-hero.critical .status-orb { background:#f4796d; box-shadow:0 0 0 6px rgba(244,121,109,.13); }
.quick-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:0 0 35px; }.quick-metrics article { min-height:112px; padding:18px 20px; border:1px solid #d6e3ec; border-radius:12px; background:rgba(255,255,255,.86); box-shadow:0 10px 22px rgba(30,68,94,.055); }.quick-metrics span,.quick-metrics small { display:block; color:#607b8e; font-size:10px; font-weight:800; letter-spacing:.08em; text-transform:uppercase; }.quick-metrics strong { display:block; margin:10px 0 6px; font-size:30px; letter-spacing:-.06em; }.quick-metrics small { color:#7d94a4; font-size:9px; letter-spacing:.045em; text-transform:none; }
.page-intro { max-width:770px; margin:10px 0 30px; }.page-intro h1 { margin:10px 0 13px; color:var(--navy); font-size:clamp(38px,5vw,62px); line-height:.94; letter-spacing:-.07em; }.page-intro h1 em { color:var(--green); font-style:normal; }.page-intro p { max-width:600px; margin:0; color:var(--muted); font-size:15px; line-height:1.65; }.reports-panel { margin-top:0; }
.sensor-coverage-panel { margin-top:8px; }.sensor-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(215px,1fr)); gap:11px; }.sensor-card { min-height:160px; display:grid; align-content:start; gap:10px; padding:17px; border:1px solid #dbe7ee; border-radius:11px; background:linear-gradient(145deg,#fff,#f6fafc); }.sensor-card > div { display:flex; align-items:center; gap:7px; }.sensor-card strong { color:#17354a; font-size:14px; line-height:1.3; }.sensor-card p { margin:0; color:#607b8e; font-size:12px; line-height:1.55; }.sensor-dot { width:8px; height:8px; border-radius:50%; background:#6abf90; box-shadow:0 0 0 4px rgba(106,191,144,.13); }.sensor-state { color:#5d788a; font-size:9px; font-weight:800; letter-spacing:.1em; text-transform:uppercase; }.sensor-card.disabled { background:#f4f6f7; }.sensor-card.disabled .sensor-dot,.sensor-card.not-installed .sensor-dot { background:#9caeba; box-shadow:0 0 0 4px rgba(156,174,186,.13); }.sensor-card.query-failed { border-color:#f2c59c; background:#fff8ef; }.sensor-card.query-failed .sensor-dot,.sensor-card.truncated .sensor-dot { background:#e9a64d; box-shadow:0 0 0 4px rgba(233,166,77,.13); }.sensor-empty { width:100%; padding:24px; border:1px dashed #b8cbd7; border-radius:10px; color:#607b8e; text-align:center; font-size:13px; }.ransomware-panel + .sensor-coverage-panel { margin-top:8px; }
@media (max-width:850px) { .dashboard-hero { grid-template-columns:1fr; min-height:0; padding:28px 24px; }.dashboard-status { align-self:auto; }.quick-metrics { grid-template-columns:repeat(2,1fr); }.quick-metrics article { min-height:102px; }.page-intro { margin-top:4px; }.sensor-grid { grid-template-columns:1fr; } }
@layer enhancements {
:where(a,button,input,select,summary):focus-visible { outline:3px solid var(--lime); outline:3px solid color-mix(in srgb,var(--lime) 72%,white); outline-offset:3px; }
:where(a,button) { -webkit-tap-highlight-color:transparent; }
.quick-metrics,.sensor-grid { container-type:inline-size; }
.dashboard-hero { isolation:isolate; }
.dashboard-hero::after { position:absolute; z-index:-1; inset:auto -8% -54% auto; width:300px; aspect-ratio:1; border-radius:999px; background:radial-gradient(circle,rgba(184,227,106,.15),transparent 68%); content:''; filter:blur(3px); }
.quick-metrics article,.organization-card,.sensor-card { position:relative; overflow:hidden; }
.quick-metrics article::before,.sensor-card::before { position:absolute; inset:0; opacity:0; background:linear-gradient(120deg,transparent 18%,rgba(255,255,255,.65),transparent 82%); content:''; transform:translateX(-110%); transition:transform .55s ease,opacity .2s ease; }
@media (hover:hover) { .quick-metrics article:hover::before,.sensor-card:hover::before { opacity:1; transform:translateX(110%); }.quick-metrics article:hover { border-color:color-mix(in srgb,var(--blue) 42%,var(--line)); transform:translateY(-3px); box-shadow:0 18px 34px rgba(30,68,94,.12); }.sensor-card:hover { border-color:color-mix(in srgb,var(--blue) 34%,var(--line)); transform:translateY(-2px); box-shadow:0 14px 28px rgba(30,68,94,.09); } }
.quick-metrics article,.sensor-card { transition:transform .22s cubic-bezier(.2,.8,.2,1),box-shadow .22s ease,border-color .22s ease; }
.dashboard-hero,.quick-metrics article,.panel { animation:sentinel-rise .55s cubic-bezier(.2,.8,.2,1) both; }
.quick-metrics article:nth-child(2),.panel:nth-of-type(2) { animation-delay:60ms; }.quick-metrics article:nth-child(3),.panel:nth-of-type(3) { animation-delay:120ms; }.quick-metrics article:nth-child(4),.panel:nth-of-type(4) { animation-delay:180ms; }
@container (max-width:520px) { .sensor-card { min-height:0; grid-template-columns:auto 1fr; column-gap:12px; }.sensor-card > div { grid-column:1 / -1; }.sensor-card p { grid-column:1 / -1; } }
@supports (backdrop-filter:blur(1px)) { .panel { background:color-mix(in srgb,var(--panel) 90%,transparent); backdrop-filter:blur(10px); }.quick-metrics article { background:color-mix(in srgb,white 82%,transparent); backdrop-filter:blur(12px); } }
@supports not (backdrop-filter:blur(1px)) { .masthead { background:#0c2438; } }
@supports selector(body:has(.dashboard-hero.critical)) { body:has(.dashboard-hero.critical) .masthead { border-bottom-color:color-mix(in srgb,var(--red) 52%,#21445f); } body:has(.dashboard-hero.warning) .masthead { border-bottom-color:color-mix(in srgb,var(--amber) 55%,#21445f); } }
@media (prefers-contrast:more) { .panel,.quick-metrics article,.sensor-card { border-width:2px; }.state { border:1px solid currentColor; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } }
}
@keyframes sentinel-rise { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
/* Operational hierarchy and responsive chrome. */
.skip-link { position:fixed; z-index:20; top:10px; left:10px; padding:10px 13px; border-radius:8px; background:var(--lime); color:var(--navy); font-weight:800; text-decoration:none; transform:translateY(-160%); transition:transform .2s ease; }
.skip-link:focus { transform:translateY(0); }
.wordmark { display:flex; align-items:center; gap:8px; }.wordmark .wordmark-mark { display:grid; flex:0 0 auto; place-items:center; width:29px; height:29px; margin:0; border-radius:8px; background:var(--lime); color:#102a43; font:800 10px var(--font-sans); letter-spacing:0; }.wordmark .wordmark-name { display:grid; gap:1px; color:#fff; font:800 18px/1 var(--font-sans); letter-spacing:-.045em; }.wordmark .wordmark-name small { color:#9cc8e8; font:700 8px/1 var(--font-sans); letter-spacing:.12em; text-transform:uppercase; }
.app-footer { display:flex; justify-content:space-between; gap:16px; max-width:1280px; margin:0 auto; padding:0 6vw 32px; color:#6c8494; font-size:10px; letter-spacing:.04em; }.app-footer span:first-child { color:#426277; font-weight:800; text-transform:uppercase; }
.priority-board { display:grid; grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); gap:1px; margin:0 0 30px; overflow:hidden; border:1px solid #193a53; border-radius:16px; background:#193a53; box-shadow:0 20px 42px rgba(16,42,67,.15); }.priority-intro { display:grid; align-content:space-between; min-height:272px; padding:27px; color:#eaf3f8; background:radial-gradient(circle at 15% 8%,rgba(79,163,223,.2),transparent 16rem),linear-gradient(145deg,#153b58,#0d2437); }.priority-intro .eyebrow { color:#a4cae4; }.priority-intro h2 { margin:10px 0; font-size:clamp(28px,3.2vw,43px); line-height:.95; letter-spacing:-.065em; }.priority-intro p { max-width:290px; margin:0; color:#b7ccda; font-size:13px; line-height:1.55; }.priority-sync { display:flex; align-items:center; gap:8px; margin-top:20px; color:#9fc0d2; font:700 10px var(--font-sans); }.priority-sync i { width:7px; height:7px; border-radius:50%; background:#72d39c; box-shadow:0 0 0 5px rgba(114,211,156,.12); }.priority-list { display:grid; align-content:center; gap:1px; background:#d7e4ec; }.priority-item { display:grid; grid-template-columns:34px minmax(0,1fr) 24px; align-items:center; gap:15px; min-height:90px; padding:16px 22px; background:rgba(255,255,255,.96); color:var(--ink); text-decoration:none; transition:background .2s ease,transform .2s ease; }.priority-item:hover { background:#f4faff; }.priority-item.critical:hover { background:#fff4f1; }.priority-index { align-self:start; color:#8aa0ae; font:700 11px var(--font-mono); }.priority-item strong,.priority-item small { display:block; }.priority-item strong { margin:6px 0 3px; font-size:18px; letter-spacing:-.035em; }.priority-item small { overflow:hidden; color:#657f90; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }.priority-arrow { color:#3a7196; font-size:20px; transition:transform .2s ease; }.priority-item:hover .priority-arrow { transform:translate(2px,-2px); }.priority-calm { display:grid; align-content:center; justify-items:start; gap:9px; padding:30px; color:#eaf3f8; background:linear-gradient(145deg,#173d4b,#0e2a35); }.priority-calm .status-orb { margin:0 0 8px; }.priority-calm strong { font-size:22px; letter-spacing:-.04em; }.priority-calm p { margin:0; color:#b4cbd4; font-size:13px; }.priority-board.calm { grid-template-columns:minmax(240px,.82fr) minmax(0,1.65fr); }
.device-hero { display:grid; grid-template-columns:minmax(0,1fr) 240px; gap:28px; align-items:end; min-height:225px; margin:0 0 18px; padding:31px 33px; overflow:hidden; border:1px solid rgba(134,192,225,.24); border-radius:18px; color:#ecf5fb; background:radial-gradient(circle at 88% 10%,rgba(96,183,227,.25),transparent 17rem),linear-gradient(135deg,#102f49,#0a1d2c 72%); box-shadow:0 24px 50px rgba(18,51,75,.15); }.device-hero.warning { background:radial-gradient(circle at 88% 10%,rgba(247,181,91,.22),transparent 17rem),linear-gradient(135deg,#40301a,#20180e 72%); }.device-hero.critical { background:radial-gradient(circle at 88% 10%,rgba(240,110,100,.24),transparent 17rem),linear-gradient(135deg,#45252b,#1d1116 72%); }.device-hero .eyebrow { color:#a6d2ec; }.device-hero h1 { margin:9px 0 12px; font-size:clamp(37px,5vw,62px); line-height:.92; letter-spacing:-.07em; }.device-hero p { margin:0; color:#bdd3e1; font-size:13px; line-height:1.55; }.device-hero-state { display:grid; gap:5px; padding:18px; border:1px solid rgba(168,216,241,.26); border-radius:14px; background:rgba(4,22,35,.34); }.device-hero-state > span:not(.status-orb) { color:#a9c7d8; font-size:10px; font-weight:800; letter-spacing:.11em; text-transform:uppercase; }.device-hero-state strong { font-size:27px; letter-spacing:-.055em; text-transform:capitalize; }.device-hero-state small { color:#c5d9e5; font-size:11px; line-height:1.45; }
.header-links a,.state,.event-count,.recipient-form button,.rule-actions button,table,th,td,.map-toolbar input,.map-toolbar select,.map-toolbar button { font-family:var(--font-sans); }
@media (max-width:850px) { .masthead { gap:14px; align-items:center; }.header-links { gap:4px; overflow:auto; max-width:calc(100vw - 155px); flex-wrap:nowrap; }.header-links a { flex:0 0 auto; padding:7px 8px; font-size:11px; }.wordmark .wordmark-name small { display:none; }.app-footer { align-items:flex-start; flex-direction:column; padding-bottom:24px; }.priority-board,.priority-board.calm,.device-hero { grid-template-columns:1fr; }.priority-intro { min-height:215px; }.priority-list { gap:1px; }.priority-item { min-height:84px; padding:15px 17px; }.priority-item small { white-space:normal; }.device-hero { min-height:0; padding:28px 24px; }.device-hero-state { max-width:none; }.device-hero h1 { font-size:42px; } }

View File

@@ -0,0 +1,18 @@
Cytoscape.js 3.34.0
MIT License
Copyright (c) 2016-2026, The Cytoscape Consortium
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}OfficeCom Sentinel{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body class="app-shell">
<a class="skip-link" href="#main-content">Zum Inhalt springen</a>
<header class="masthead">
<a class="wordmark" href="{{ url_for('overview') }}"><span class="wordmark-mark">OC</span><span class="wordmark-name">Sentinel <small>Security console</small></span></a>
<nav class="header-links" aria-label="Hauptnavigation"><a class="{{ 'active' if request.endpoint in ('overview', 'organization') else '' }}" href="{{ url_for('overview') }}">Lagebild</a><a class="{{ 'active' if request.endpoint == 'network' else '' }}" href="{{ url_for('network') }}">Zugriffswege</a><a class="{{ 'active' if request.endpoint in ('reports', 'weekly_report') else '' }}" href="{{ url_for('reports') }}">Berichte</a><a class="{{ 'active' if request.endpoint in ('recipients', 'add_recipient', 'toggle_recipient', 'delete_recipient') else '' }}" href="{{ url_for('recipients') }}">Empfaenger</a></nav>
</header>
<main id="main-content">{% block content %}{% endblock %}</main>
<footer class="app-footer"><span>OfficeCom Sentinel</span><span>Interne Sicherheitskonsole · Verdichtete Endpoint-Signale</span></footer>
</body>
</html>

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}{{ report[0] }} - OC Sentinel{% endblock %}
{% block content %}
<section class="device-hero {{ report[6] }}">
<div><span class="eyebrow">Geraeteanalyse</span><h1>{{ report[0] }}</h1><p>Letzter Scan {{ report[5] or '-' }} · Client zuletzt gesehen {{ report[2] or '-' }}</p></div>
<div class="device-hero-state"><span class="status-orb"></span><span>Aktueller Status</span><strong>{{ report[6] }}</strong>{% if report[8] %}<small>{{ 'Aktuelles Signal' if event.is_current else 'Historisches Signal' }} · {{ event.label }}</small>{% else %}<small>Keine Ereignisse im letzten Scan</small>{% endif %}</div>
</section>
<section class="metrics compact-metrics"><article><span>Ereignisse</span><strong>{{ report[8] }}</strong></article><article><span>Quell-IPs</span><strong>{{ report[9] }}</strong></article><article><span>CVEs</span><strong>{{ report[10] }}</strong></article><article><span>Kritische CVEs</span><strong class="critical">{{ report[11] }}</strong></article></section>
{% if ransomware_beta.enabled %}<section class="panel ransomware-panel {{ ransomware_beta.state }}"><div class="panel-heading"><span class="eyebrow">Passive Beta</span><h2>Ransomware-Frueherkennung <small>{{ ransomware_beta.state }}</small></h2><p>{{ ransomware_beta.reason }}</p></div><div class="table-wrap"><table><thead><tr><th>Zeitpunkt</th><th>Signal</th><th>Prozess</th><th>Quelle</th><th>Bewertung</th></tr></thead><tbody>{% for signal in ransomware_beta.signals %}<tr><td>{{ signal.timestamp }}</td><td>{{ signal.category }}</td><td>{{ signal.process }}</td><td>{{ signal.source }}</td><td><span class="state {{ 'critical' if signal.confidence == 'high' else 'warning' }}">{{ signal.confidence }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine Signale im aktuellen Beta-Zeitfenster.</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
{% if ransomware_beta.enabled %}<section class="panel sensor-coverage-panel"><div class="panel-heading"><span class="eyebrow">Beta-Abdeckung</span><h2>Erkennungsquellen <small>Verfuegbarkeit im letzten Scan</small></h2><p>Ein unauffaelliger Zeitraum ist nur dann belastbar, wenn die benoetigten Datenquellen erreichbar waren.</p></div><div class="sensor-grid">{% for sensor in ransomware_sensors %}<article class="sensor-card {{ sensor.state }}"><div><span class="sensor-dot"></span><span class="sensor-state">{{ sensor.state }}</span></div><strong>{{ sensor.label }}</strong><p>{% if not sensor.enabled %}Nicht fuer diesen Client aktiviert.{% elif sensor.available %}{{ sensor.event_count }} Ereignisse im Zeitfenster verarbeitet.{% elif sensor.state == 'not-installed' %}Protokollquelle ist auf diesem System nicht installiert.{% else %}Quelle konnte im letzten Scan nicht verwendet werden.{% endif %}</p></article>{% else %}<div class="sensor-empty">Dieser Client sendet noch keine Abdeckungsdaten. Nach dem Update auf Beta 1.5.0-beta.5 erscheint die Sensoransicht automatisch.</div>{% endfor %}</div></section>{% endif %}
{% if ransomware_beta.smbSessions %}<section class="panel smb-context-panel"><div class="panel-heading"><span class="eyebrow">Incident-Kontext</span><h2>Aktive SMB-Sitzungen <small>Nur bei Ransomware-Warnung oder kritisch erfasst</small></h2></div><div class="table-wrap"><table><thead><tr><th>Client</th><th>Benutzer</th><th>Offene Dateien</th><th>Sitzung</th></tr></thead><tbody>{% for session in ransomware_beta.smbSessions %}<tr><td>{{ session.clientComputerName }}</td><td>{{ session.clientUserName }}</td><td>{{ session.openFileCount }}</td><td>{{ session.sessionId }}</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
<section class="panel event-summary-panel"><div class="panel-heading"><span class="eyebrow">Schnelluebersicht</span><h2>Erkannte Sicherheitsereignisse</h2><p>Fehlgeschlagene Anmeldungen und weitere Vorfaelle aus dem letzten Scan, nach Konto und Quell-IP zusammengefasst.</p></div><div class="table-wrap"><table><thead><tr><th>Vorfall</th><th>Konto</th><th>Quell-IP</th><th>Letzter Zeitpunkt</th><th>Anzahl</th></tr></thead><tbody>{% for entry in security_events %}<tr><td><strong>{{ entry.type }}</strong></td><td>{{ entry.account }}</td><td>{{ entry.source_ip }}</td><td>{{ entry.latest }}</td><td><span class="event-count">{{ entry.count }}</span></td></tr>{% else %}<tr><td colspan="5" class="empty-state">Keine sicherheitsrelevanten Ereignisse im letzten Scan.</td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel raw-export-panel"><div class="panel-heading"><span class="eyebrow">Technische Daten</span><h2>Roh-Export</h2><p>Vollstaendige, unveraenderte Nutzlast des zuletzt eingegangenen Scans.</p></div><details class="raw-json" open><summary>JSON-Rohdaten</summary><pre>{{ payload_pretty }}</pre></details></section>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Zugriffswege - OfficeCom Sentinel{% endblock %}
{% block content %}
<section class="map-hero">
<div><span class="eyebrow">Security topology</span><h1>Zugriffswege<br><em>sichtbar machen.</em></h1><p>Verdichtete fehlgeschlagene Anmeldungen aus den letzten {{ days }} Tagen. Die Karte zeigt nur beobachtete Pfade, keinen vollstaendigen Netzwerkverkehr.</p></div>
<div class="map-hero-status"><span>LIVE DATASET</span><strong>{{ total_events }}</strong><small>beobachtete Versuche</small></div>
</section>
<section class="map-stat-strip"><article><span>Quellen</span><strong>{{ source_count }}</strong><small>externe IPs</small></article><article><span>Ziele</span><strong>{{ target_count }}</strong><small>Systeme</small></article><article><span>Pfade</span><strong>{{ path_count }}</strong><small>korrelierte Kanten</small></article><article><span>Zeitraum</span><strong>{{ days }}</strong><small>Tage Rueckblick</small></article></section>
<section class="network-map-panel">
<header class="map-header"><div><span class="eyebrow">Interaktive Analyse</span><h2>Access graph</h2></div><div class="map-legend"><span><i class="source"></i>Quell-IP</span><span><i class="target"></i>Zielgeraet</span><span><i class="hot"></i>Hohe Aktivitaet</span></div></header>
<div class="map-toolbar">
<label class="search-field"><span>Suchen</span><input id="map-filter" type="search" placeholder="IP, Geraet oder Konto"></label>
<label><span>Zeitraum</span><select id="map-range"><option value="1" {% if days == 1 %}selected{% endif %}>24 Stunden</option><option value="7" {% if days == 7 %}selected{% endif %}>7 Tage</option><option value="14" {% if days == 14 %}selected{% endif %}>14 Tage</option><option value="30" {% if days == 30 %}selected{% endif %}>30 Tage</option></select></label>
<label><span>Organisation</span><select id="map-organization"><option value="">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
<label><span>Vorfall</span><select id="map-event"><option value="">Alle Vorfaelle</option>{% for event_type in event_types %}<option value="{{ event_type }}">{{ event_type }}</option>{% endfor %}</select></label>
<div class="map-actions"><button type="button" id="map-fit">Gesamtansicht</button><button type="button" id="map-export">JSON</button><button type="button" id="map-print">Drucken</button></div>
</div>
<div class="network-map-layout"><div id="network-map" aria-label="Interaktive Netzwerk- und Zugriffskarte"></div><aside id="network-inspector"><span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p></aside></div>
</section>
<script src="{{ url_for('static', filename='vendor/cytoscape.min.js') }}"></script>
<script>
(() => {
const inspector = document.getElementById('network-inspector');
const filter = document.getElementById('map-filter');
const organization = document.getElementById('map-organization');
const eventType = document.getElementById('map-event');
const apiUrl = '{{ url_for("network_api") }}?days={{ days }}';
fetch(apiUrl).then(response => response.json()).then(graph => {
const cy = cytoscape({ container: document.getElementById('network-map'), elements: graph.elements, minZoom: .3, maxZoom: 2.4,
style: [
{ selector: 'node', style: { 'label': 'data(label)', 'font-family': 'Roboto', 'font-size': 11, 'font-weight': 700, 'color': '#eaf2f8', 'text-valign': 'bottom', 'text-margin-y': 8, 'text-outline-width': 3, 'text-outline-color': '#0d1e2c', 'width': 48, 'height': 48, 'border-width': 2, 'border-color': '#d9f5ed' } },
{ selector: 'node[kind = "source"]', style: { 'background-color': '#4fa3df', 'shape': 'ellipse' } },
{ selector: 'node[kind = "target"]', style: { 'background-color': '#3bca99', 'shape': 'round-rectangle' } },
{ selector: 'edge', style: { 'width': 'mapData(count, 1, ' + graph.max_count + ', 2, 10)', 'line-color': '#406b86', 'target-arrow-color': '#406b86', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'opacity': .75 } },
{ selector: 'edge[count >= 5]', style: { 'line-color': '#ffb454', 'target-arrow-color': '#ffb454' } },
{ selector: '.selected', style: { 'border-color': '#e7ff88', 'border-width': 6, 'line-color': '#e7ff88', 'target-arrow-color': '#e7ff88', 'opacity': 1, 'z-index': 20 } },
{ selector: '.hidden', style: { 'display': 'none' } }
], layout: { name: 'cose', animate: false, padding: 52, nodeRepulsion: 9000, idealEdgeLength: 145, gravity: .2 } });
const resetInspector = () => inspector.innerHTML = '<span class="eyebrow">Inspector</span><strong>Kein Element ausgewaehlt</strong><p>Waehle einen Knoten oder einen Pfad. Zugehoerige Verbindungen werden hervorgehoben.</p>';
const show = element => { const data = element.data(); if (element.isEdge()) { inspector.innerHTML = '<span class="eyebrow">Observed path</span><strong>' + data.source.replace('source:', '') + ' <span class="inspector-arrow">to</span> ' + data.machine_name + '</strong><dl><dt>Versuche</dt><dd>' + data.count + '</dd><dt>Organisation</dt><dd>' + data.organization_name + '</dd><dt>Konto</dt><dd>' + data.account + '</dd><dt>Vorfall</dt><dd>' + data.event_type + '</dd><dt>Letzter Scan</dt><dd>' + data.last_seen + '</dd></dl>'; } else { const connected = element.connectedEdges(':visible'); inspector.innerHTML = '<span class="eyebrow">' + (data.kind === 'source' ? 'Quell-IP' : 'Zielgeraet') + '</span><strong>' + data.label + '</strong><p>' + connected.length + ' sichtbare Zugriffswege im aktuellen Filter.</p>'; } };
const applyFilters = () => { const term = filter.value.trim().toLowerCase(); const org = organization.value; const type = eventType.value; cy.elements().addClass('hidden'); const visible = cy.edges().filter(edge => { const d = edge.data(); return (!term || [d.source, d.machine_name, d.account, d.event_type].join(' ').toLowerCase().includes(term)) && (!org || d.organization_id === org) && (!type || d.event_type === type); }); visible.removeClass('hidden'); visible.connectedNodes().removeClass('hidden'); cy.layout({ name:'cose', animate:false, padding:52, nodeRepulsion:9000, idealEdgeLength:145, gravity:.2 }).run(); resetInspector(); };
cy.on('tap', 'node, edge', event => { cy.elements().removeClass('selected'); event.target.addClass('selected'); if (event.target.isNode()) event.target.connectedEdges(':visible').addClass('selected'); show(event.target); });
cy.on('tap', event => { if (event.target === cy) { cy.elements().removeClass('selected'); resetInspector(); } });
filter.addEventListener('input', applyFilters); organization.addEventListener('change', applyFilters); eventType.addEventListener('change', applyFilters);
document.getElementById('map-range').addEventListener('change', e => { window.location.search = 'days=' + e.target.value; });
document.getElementById('map-fit').addEventListener('click', () => cy.fit(cy.elements(':visible'), 48));
document.getElementById('map-export').addEventListener('click', () => { const data = cy.json().elements; const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'ocsentinel-access-map.json'; link.click(); URL.revokeObjectURL(link.href); });
document.getElementById('map-print').addEventListener('click', () => window.print());
}).catch(() => { document.getElementById('network-map').textContent = 'Die Netzwerkdaten konnten nicht geladen werden.'; });
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ organization_name }} - OfficeCom Sentinel{% endblock %}
{% block content %}
<section class="hero compact"><span class="eyebrow">Organisation {{ organization_id }}</span><h1>{{ organization_name }}</h1><p>{{ critical_count }} kritisch, {{ warning_count }} Warnungen. Waehle ein Geraet fuer die technische Analyse.</p></section>
<section class="metrics compact-metrics"><article><span>Geraete</span><strong>{{ devices|length }}</strong></article><article><span>Kritisch</span><strong class="critical">{{ critical_count }}</strong></article><article><span>Warnungen</span><strong class="warning">{{ warning_count }}</strong></article><article><span>Letzte Meldung</span><strong class="timestamp">{{ devices[0][1] or '-' }}</strong></article></section>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Kritische CVEs</th><th>Empfangen</th></tr></thead><tbody>{% for device in devices %}<tr><td><a href="{{ url_for('device', machine_name=device[0]) }}">{{ device[0] }}</a></td><td><span class="state {{ device[2] }}">{{ device[2] }}</span></td><td>{{ device[3] }}</td><td>{{ device[4] }}</td><td>{{ device[5] }}</td><td>{{ device[1] or '-' }}</td></tr>{% endfor %}</tbody></table></div></section>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block content %}
<section class="dashboard-hero {% if summary[2] %}critical{% elif summary[1] %}warning{% else %}ok{% endif %}">
<div>
<span class="eyebrow">OfficeCom Sentinel Uebersicht</span>
<h1>{% if summary[2] %}Sicherheitslage<br>braucht Aufmerksamkeit.{% elif summary[1] %}Signale im Bestand<br>gezielt pruefen.{% else %}Sicherheitslage<br>unter Kontrolle.{% endif %}</h1>
<p>Verdichtete Endpoint-Signale, Upload-Gesundheit und organisationsweite Einordnung an einem Ort.</p>
</div>
<div class="dashboard-status"><span class="status-orb"></span><span>Aktueller Zustand</span><strong>{% if summary[2] %}Kritisch{% elif summary[1] %}Pruefen{% else %}Stabil{% endif %}</strong><small>{{ current_alert_count }} aktuelle Auffaelligkeit{{ '' if current_alert_count == 1 else 'en' }}</small></div>
</section>
<section class="quick-metrics">
<article><span>Geraete</span><strong>{{ summary[0] }}</strong><small>{{ coverage[1] }} melden aktuell</small></article>
<article><span>Warnungen</span><strong class="warning">{{ summary[1] }}</strong><small>im letzten Status</small></article>
<article><span>Kritisch</span><strong class="critical">{{ summary[2] }}</strong><small>sofort sichtbar</small></article>
<article><span>Abdeckung</span><strong class="{% if coverage[2] %}warning{% else %}ok{% endif %}">{{ coverage[1] }}/{{ coverage[0] }}</strong><small>{{ coverage[2] }} stumm &gt; 36 Std.</small></article>
</section>
{% if alerts %}
<section class="priority-board">
<div class="priority-intro">
<span class="eyebrow">Einsatzfokus</span>
<h2>Was jetzt<br>Aufmerksamkeit braucht.</h2>
<p>Priorisiert nach Schweregrad und zuletzt gemeldetem Signal.</p>
<div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div>
</div>
<div class="priority-list">
{% for alert in alerts[:3] %}
<a class="priority-item {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
<span class="priority-index">0{{ loop.index }}</span>
<div><span class="state {{ alert.alert_state }}">{{ alert.alert_state }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse · {{ alert.unique_ip_count }} Quell-IPs · {{ alert.event.label }}</small></div>
<span class="priority-arrow" aria-hidden="true"></span>
</a>
{% endfor %}
</div>
</section>
{% else %}
<section class="priority-board calm">
<div class="priority-intro"><span class="eyebrow">Einsatzfokus</span><h2>Aktuell keine<br>offenen Signale.</h2><p>Die meldenden Systeme liefern derzeit keine auffaelligen Sicherheitsereignisse.</p><div class="priority-sync"><i></i><span>Letzter Datenstand: {{ summary[7] or '-' }}</span></div></div>
<div class="priority-calm"><span class="status-orb"></span><strong>Keine unmittelbare Aktion notwendig</strong><p>Behalte Abdeckung und Berichtsintervall im Blick.</p></div>
</section>
{% endif %}
<section class="panel trend-panel">
<div class="panel-heading"><span class="eyebrow">Letzte 14 Tage</span><h2>Signalverlauf <small>Verdichtete Scan-Ergebnisse pro Tag</small></h2></div>
<div class="trend-chart" aria-label="Signalverlauf der letzten 14 Tage">{% for day in trend %}<article class="trend-day"><div class="trend-bar" style="--bar: {{ (day.event_count * 100 / trend_max)|round(0, 'floor') }}%"><span class="trend-critical" style="--critical: {{ (day.critical_count * 100 / trend_max)|round(0, 'floor') }}%"></span><span class="trend-warning" style="--warning: {{ (day.warning_count * 100 / trend_max)|round(0, 'floor') }}%"></span></div><strong>{{ day.event_count }}</strong><small>{{ day.day.strftime('%d.%m.') }}</small></article>{% else %}<p class="empty-state">Noch keine Trenddaten vorhanden.</p>{% endfor %}</div>
<p class="chart-note"><span class="legend critical"></span>kritisch <span class="legend warning"></span>Warnungen <span class="legend neutral"></span>Ereignisvolumen</p>
</section>
<section class="panel organization-panel">
<div class="panel-heading"><span class="eyebrow">Mandanten</span><h2>Organisationen <small>Drill-down bis zum einzelnen Geraet</small></h2></div>
<div class="organization-grid">{% for organization in organizations %}<a class="organization-card" href="{{ url_for('organization', organization_id=organization.id) }}"><span class="eyebrow">{{ organization.id }}</span><strong>{{ organization.name }}</strong><div><span>{{ organization.device_count }} Geraete</span><span class="state critical">{{ organization.critical_count }} kritisch</span><span class="state warning">{{ organization.warning_count }} Warnung</span></div><small>Letzte Meldung: {{ organization.last_received_at or '-' }}</small></a>{% endfor %}</div>
</section>
<section class="panel coverage-panel">
<div class="panel-heading"><h2>Geraeteabdeckung</h2></div>
<div class="coverage-metrics"><article><span>Bekannt</span><strong>{{ coverage[0] }}</strong></article><article><span>Meldend &lt; 36 Std.</span><strong class="ok">{{ coverage[1] }}</strong></article><article><span>Stumm &gt; 36 Std.</span><strong class="{% if coverage[2] %}warning{% endif %}">{{ coverage[2] }}</strong></article></div>
</section>
{% if alerts %}
<section class="panel alert-panel">
<div class="panel-heading"><span class="eyebrow">Vollstaendige Liste</span><h2>Auffaellige Geraete <small>{{ current_alert_count }} aktuell, {{ alerts|length - current_alert_count }} historisch</small></h2></div>
<div class="alert-grid">
{% for alert in alerts %}
<a class="alert-card {{ alert.alert_state }}" href="{{ url_for('device', machine_name=alert.machine_name) }}">
<span>{{ alert.alert_state }} | {{ 'aktuell' if alert.event.is_current else 'historisch' }}</span><strong>{{ alert.machine_name }}</strong><small>{{ alert.total_events }} Ereignisse | {{ alert.unique_ip_count }} Quell-IPs | letztes Ereignis {{ alert.event.label }}</small>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<section class="panel">
<div class="panel-heading"><h2>Geraetestatus</h2></div>
<div class="table-wrap"><table><thead><tr><th>Geraet</th><th>Organisation</th><th>Status</th><th>Ereignisse</th><th>Quell-IPs</th><th>Empfangen</th></tr></thead>
<tbody>{% for row in reports %}<tr><td><a href="{{ url_for('device', machine_name=row.machine_name) }}">{{ row.machine_name }}</a></td><td>{{ row.organization_name or '-' }}</td><td><span class="state {{ row.alert_state }}">{{ row.alert_state }}</span>{% if row.total_events %}<span class="state {{ 'current' if row.event.is_current else 'historic' }}">{{ 'aktuell' if row.event.is_current else 'historisch' }}</span>{% endif %}</td><td>{{ row.total_events }}</td><td>{{ row.unique_ip_count }}</td><td>{{ row.received_at or '-' }}</td></tr>{% else %}<tr><td colspan="6">Keine Geraeteberichte.</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Empfaenger - OC Sentinel{% endblock %}
{% block content %}
<section class="recipient-intro">
<span class="eyebrow">Wochenberichte</span>
<h1>Empfaenger verwalten</h1>
<p>Lege fest, welche Personen den Sicherheitsbericht einer Organisation erhalten. Regeln fuer <strong>Alle Organisationen</strong> gelten zusaetzlich zu den einzelnen Organisationen.</p>
</section>
<section class="panel recipient-create-panel">
<div class="panel-heading"><span class="eyebrow">Neue Regel</span><h2>Bericht zustellen</h2><p>Die Adresse wird beim naechsten Wochenbericht automatisch beruecksichtigt.</p></div>
<form class="recipient-form" method="post" action="{{ url_for('add_recipient') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Organisation<select name="organization_id" id="organization_id" required onchange="document.getElementById('organization_name').value=this.options[this.selectedIndex].dataset.name"><option value="*" data-name="Alle Organisationen">Alle Organisationen</option>{% for organization in organizations %}<option value="{{ organization[0] }}" data-name="{{ organization[1] }}">{{ organization[1] }}</option>{% endfor %}</select></label>
<input type="hidden" name="organization_name" id="organization_name" value="Alle Organisationen">
<label>E-Mail-Adresse<input type="email" name="recipient_email" placeholder="name@officecom.it" required></label>
<button type="submit">Empfaenger hinzufuegen</button>
</form>
</section>
<section class="panel recipient-rules-panel">
<div class="panel-heading"><span class="eyebrow">Aktive Konfiguration</span><h2>E-Mail-Verteiler <small>{{ rules|length }} Regel{{ '' if rules|length == 1 else 'n' }}</small></h2></div>
<div class="table-wrap recipient-table"><table><thead><tr><th>Organisation</th><th>E-Mail-Adresse</th><th>Status</th><th class="actions-heading">Verwalten</th></tr></thead><tbody>
{% for rule in rules %}<tr><td><strong>{{ rule[2] }}</strong></td><td><a class="recipient-email" href="mailto:{{ rule[3] }}">{{ rule[3] }}</a></td><td><span class="state {{ 'ok' if rule[4] else 'warning' }}">{{ 'aktiv' if rule[4] else 'pausiert' }}</span></td><td class="rule-actions"><form method="post" action="{{ url_for('toggle_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-secondary" type="submit">{{ 'Pausieren' if rule[4] else 'Aktivieren' }}</button></form><form method="post" action="{{ url_for('delete_recipient', rule_id=rule[0]) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button-danger" type="submit">Loeschen</button></form></td></tr>{% else %}<tr><td colspan="4" class="empty-state">Noch keine Empfaengerregeln angelegt.</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Berichte - OC Sentinel{% endblock %}
{% block content %}
<section class="page-intro"><span class="eyebrow">Wochenberichte</span><h1>Sicherheitsberichte<br><em>auf einen Blick.</em></h1><p>Alle automatisch erzeugten Organisationsberichte mit direktem Zugriff auf die finale HTML-Vorschau.</p></section>
<section class="panel reports-panel"><div class="panel-heading"><span class="eyebrow">Archiv</span><h2>Gesendete Berichte <small>{{ reports|length }} Eintraege</small></h2></div><div class="table-wrap"><table><thead><tr><th>Organisation</th><th>Zeitraum</th><th>Geraete</th><th>Warnung</th><th>Kritisch</th><th>Events</th><th>Erstellt</th></tr></thead><tbody>
{% for row in reports %}<tr><td><a href="{{ url_for('weekly_report', report_id=row[0]) }}">{{ row[1] }}</a></td><td>{{ row[2] }} bis {{ row[3] }}</td><td>{{ row[5] }}</td><td>{{ row[6] }}</td><td>{{ row[7] }}</td><td>{{ row[8] }}</td><td>{{ row[4] }}</td></tr>{% else %}<tr><td colspan="7">Keine Wochenberichte.</td></tr>{% endfor %}
</tbody></table></div></section>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ report[0] }} - Wochenbericht{% endblock %}
{% block content %}
<section class="report-frame">{{ report[4] | safe }}</section>
{% endblock %}

44
infra/dockge/README.md Normal file
View File

@@ -0,0 +1,44 @@
# OCSentinel PostgreSQL Dockge Stack
Production is deployed as the Dockge stack directory:
```text
/dockerstacks/ocsentinel-postgres
```
The stack runs `postgres:16-alpine` as `ocsentinel-postgres` and joins the
existing Docker network `n8n_n8n-network`. It deliberately has no `ports:`
mapping, so PostgreSQL is not exposed on the host network or the Internet.
The stack owns these private files on the server:
```text
/dockerstacks/ocsentinel-postgres/compose.yaml
/dockerstacks/ocsentinel-postgres/.env
/dockerstacks/ocsentinel-postgres/init/
/dockerstacks/ocsentinel-postgres/data/
```
`.env` is root-readable only and contains both the PostgreSQL administrator
password and the restricted `ocsentinel_n8n` password. Never commit it or copy
it to endpoint devices.
## n8n PostgreSQL Credential
Create one credential in n8n with these non-secret values:
| Setting | Value |
| --- | --- |
| Host | `ocsentinel-postgres` |
| Port | `5432` |
| Database | `ocsentinel` |
| User | `ocsentinel_n8n` |
| SSL | disabled (private Docker network) |
Retrieve the password only on the server when entering the n8n credential:
```bash
sudo grep '^OCSENTINEL_N8N_PASSWORD=' /dockerstacks/ocsentinel-postgres/.env
```
The database schema source remains [../postgres/001_ocsentinel.sql](../postgres/001_ocsentinel.sql).

View File

@@ -0,0 +1,13 @@
# Dedicated, read-only PostgreSQL login. Do not reuse the n8n or ingest role.
DB_HOST=ocsentinel-postgres
DB_PORT=5432
DB_NAME=ocsentinel
DB_USER=ocsentinel_mcp
DB_PASSWORD=replace-with-a-long-random-password
# A long random bearer token for trusted MCP clients. Keep this file private.
MCP_AUTH_TOKEN=replace-with-a-second-long-random-token
# Validate browser origins and Host headers when they are present.
MCP_ALLOWED_ORIGINS=http://localhost:6274,http://127.0.0.1:6274
MCP_ALLOWED_HOSTS=localhost:8091,127.0.0.1:8091

2
infra/mcp-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
__pycache__/

View File

@@ -0,0 +1,16 @@
FROM python:3.13-alpine
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
RUN addgroup -S ocsentinel && adduser -S ocsentinel -G ocsentinel
USER ocsentinel
EXPOSE 8080
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--no-access-log"]

View File

@@ -0,0 +1,70 @@
# OfficeCom Sentinel MCP
Dieser Container stellt sichere, **schreibgeschuetzte** Abfragen der OfficeCom-Sentinel-Daten fuer KI-Agenten bereit. Er nutzt das offizielle Python-MCP-SDK mit Streamable HTTP unter `/mcp`.
## Sicherheitsmodell
- Der Dienst wird im ersten Schritt nur auf `127.0.0.1:8091` des n8n-Hosts gebunden. Er wird nicht ueber `sentinel.officecom.biz` veroeffentlicht.
- Jeder MCP-Aufruf verlangt einen eigenen Bearer-Token, prueft `Host` sowie vorhandene `Origin`-Header und wird ohne Aufrufparameter protokolliert.
- Der PostgreSQL-Zugang ist ein dedizierter Login mit `default_transaction_read_only=on`, einem 5-Sekunden-Statement-Timeout und ausschliesslich `SELECT`-Rechten.
- Die Werkzeuge haben feste, parametrisierte Abfragen und feste Ergebnisgrenzen. Es gibt kein Werkzeug fuer SQL, Schreiboperationen, Rohbeweise, Befehlszeilen oder Zugangsdaten.
- Die Antwort auf `get_device_security` und `search_security_events` enthaelt standardmaessig keine Kontonamen. Konten werden nur auf ausdrueckliche Tool-Anforderung ergaenzt.
## Verfuegbare Tools
| Tool | Zweck |
| --- | --- |
| `security_overview` | Gesamtlage, Abdeckung und dringende Systeme |
| `get_organization_status` | Status eines NinjaOne-Organisations-IDs |
| `get_device_security` | Bereinigte Sicherheitslage eines Systems |
| `search_security_events` | Zeitlich und mengenmaessig begrenzte Ereigniszusammenfassungen |
| `get_network_paths` | Beobachtete Quell-IP-zu-System-Pfade |
| `get_weekly_report` | Letzte woechentliche Kennzahlen ohne Bericht-HTML |
Zusaetzlich gibt es die Resource `ocsentinel://read-only-policy` und den Prompt `incident_triage`.
## Einmalig: Datenbankrolle anlegen
Auf dem PostgreSQL-Container als Datenbankadministrator ausfuehren. Das Passwort in diesem Befehl durch ein langes, zufaelliges Kennwort ersetzen und danach nur in der lokalen `.env` hinterlegen.
```sql
CREATE ROLE ocsentinel_mcp LOGIN PASSWORD 'replace-with-a-long-random-password'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;
GRANT CONNECT ON DATABASE ocsentinel TO ocsentinel_mcp;
GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_mcp;
GRANT SELECT ON ocsentinel.device, ocsentinel.scan_report,
ocsentinel.weekly_organization_report TO ocsentinel_mcp;
GRANT SELECT ON ocsentinel.current_device_status,
ocsentinel.organization_summary TO ocsentinel_mcp;
```
Pruefung:
```sql
SET ROLE ocsentinel_mcp;
SELECT * FROM ocsentinel.organization_summary;
INSERT INTO ocsentinel.device (machine_name, machine_name_key) VALUES ('must-fail', 'must-fail');
```
Die letzte Anweisung muss scheitern.
## Dockge-Bereitstellung
1. Den Ordner `infra/mcp-server` als neuen Dockge-Stack auf dem n8n-Host ablegen.
2. `.env.example` nach `.env` kopieren, Datenbankpasswort und einen zweiten langen Zufallstoken setzen.
3. In `MCP_ALLOWED_HOSTS` nur die echten, erlaubten Host-Header lassen. Fuer den SSH-Tunnel sind `localhost:8091` und `127.0.0.1:8091` korrekt.
4. Stack starten. Der Endpunkt ist lokal: `http://127.0.0.1:8091/mcp`.
Der Container hat keinen veroeffentlichten Zugriff auf das Internet. Fuer einen Arbeitsplatz wird ein Tunnel genutzt:
```powershell
ssh -L 8091:127.0.0.1:8091 oc@172.16.41.197 -p 1022
```
Danach ist der lokale MCP-Endpunkt `http://localhost:8091/mcp`. Der MCP-Client muss den Header `Authorization: Bearer <MCP_AUTH_TOKEN>` mitsenden.
## Betrieb
- Logs: `docker logs ocsentinel-mcp --tail 100`.
- Niemals den Bearer-Token in einem Git-Repository, Screenshot oder Prompt speichern.
- Fuer einen spaeteren externen Zugriff wird ein separater OAuth-geschuetzter Reverse Proxy benoetigt. Der aktuelle Token-Modus ist ausschliesslich fuer den privaten Tunnel und vertrauenswuerdige Agenten gedacht.

View File

@@ -0,0 +1,23 @@
services:
ocsentinel-mcp:
build: .
container_name: ocsentinel-mcp
restart: unless-stopped
env_file: .env
# The first version is intentionally only reachable through an SSH tunnel.
ports:
- "127.0.0.1:8091:8080"
networks:
- ocsentinel-network
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
ocsentinel-network:
external: true
name: n8n_n8n-network

View File

@@ -0,0 +1,3 @@
mcp==1.26.0
psycopg[binary]==3.2.9
uvicorn==0.35.0

478
infra/mcp-server/server.py Normal file
View File

@@ -0,0 +1,478 @@
"""Read-only MCP access to curated OfficeCom Sentinel security data."""
import functools
import json
import logging
import os
import re
import time
from datetime import datetime
from typing import Any
import psycopg
from psycopg.rows import dict_row
from mcp.server.fastmcp import FastMCP
LOGGER = logging.getLogger("ocsentinel.mcp")
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(message)s")
MAX_RESULT_LIMIT = 100
MAX_LOOKBACK_HOURS = 24 * 90
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
def required_setting(name: str) -> str:
value = os.getenv(name, "").strip()
if not value or value.startswith("replace-with-"):
raise RuntimeError(f"{name} must be configured before starting OCSentinel MCP.")
return value
AUTH_TOKEN = required_setting("MCP_AUTH_TOKEN")
ALLOWED_ORIGINS = {value.strip() for value in os.getenv("MCP_ALLOWED_ORIGINS", "").split(",") if value.strip()}
ALLOWED_HOSTS = {value.strip().lower() for value in os.getenv("MCP_ALLOWED_HOSTS", "").split(",") if value.strip()}
def db_connection() -> psycopg.Connection:
return psycopg.connect(
host=required_setting("DB_HOST"),
port=os.getenv("DB_PORT", "5432"),
dbname=required_setting("DB_NAME"),
user=required_setting("DB_USER"),
password=required_setting("DB_PASSWORD"),
connect_timeout=5,
row_factory=dict_row,
options="-c default_transaction_read_only=on -c statement_timeout=5000",
)
def rows(sql: str, parameters: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
with db_connection() as connection, connection.cursor() as cursor:
cursor.execute(sql, parameters)
return list(cursor.fetchall())
def row(sql: str, parameters: tuple[Any, ...] = ()) -> dict[str, Any] | None:
results = rows(sql, parameters)
return results[0] if results else None
def value(record: dict[str, Any] | None, *names: str, default: Any = None) -> Any:
if not isinstance(record, dict):
return default
for name in names:
if name in record:
return record[name]
return default
def json_safe(data: Any) -> Any:
return json.loads(json.dumps(data, default=lambda entry: entry.isoformat() if isinstance(entry, datetime) else str(entry)))
def bounded_limit(limit: int) -> int:
if not isinstance(limit, int) or isinstance(limit, bool):
raise ValueError("limit must be a whole number.")
return max(1, min(limit, MAX_RESULT_LIMIT))
def bounded_hours(hours: int) -> int:
if not isinstance(hours, int) or isinstance(hours, bool):
raise ValueError("hours must be a whole number.")
return max(1, min(hours, MAX_LOOKBACK_HOURS))
def checked_identifier(identifier: str, field_name: str) -> str:
value_to_check = (identifier or "").strip()
if value_to_check == "unknown" or IDENTIFIER_PATTERN.fullmatch(value_to_check):
return value_to_check
raise ValueError(f"{field_name} contains unsupported characters.")
def checked_machine_name(machine_name: str) -> str:
machine = (machine_name or "").strip()
if not machine or len(machine) > 255 or any(character in machine for character in "\r\n\x00"):
raise ValueError("machine_name must be a single device name of at most 255 characters.")
return machine
def organization_context(payload: dict[str, Any] | None) -> tuple[str, str]:
ninja = value(payload, "NinjaOne", "ninjaOne", default={}) or {}
return (
str(value(ninja, "OrganizationId", "organizationId", default="unknown") or "unknown"),
str(value(ninja, "OrganizationName", "organizationName", default="Organisation unbekannt") or "Organisation unbekannt"),
)
def sanitized_ransomware(payload: dict[str, Any] | None) -> dict[str, Any]:
ransomware = value(payload, "RansomwareBeta", "ransomwareBeta", default={}) or {}
sensors = []
for sensor in value(ransomware, "Sensors", "sensors", default=[]) or []:
sensors.append(
{
"name": value(sensor, "Name", "name", default="unknown"),
"enabled": bool(value(sensor, "Enabled", "enabled", default=False)),
"available": bool(value(sensor, "Available", "available", default=False)),
"state": value(sensor, "State", "state", default="unknown"),
"eventCount": value(sensor, "EventCount", "eventCount", default=0),
}
)
signals = []
for signal in value(ransomware, "Signals", "signals", default=[]) or []:
# Evidence and raw command lines intentionally never leave the MCP boundary.
signals.append(
{
"timestamp": value(signal, "Timestamp", "timestamp", default=None),
"category": value(signal, "Category", "category", default="signal"),
"process": value(signal, "Process", "process", default="-"),
"source": value(signal, "Source", "source", default="-"),
"confidence": value(signal, "Confidence", "confidence", default="low"),
}
)
return {
"enabled": bool(value(ransomware, "Enabled", "enabled", default=False)),
"state": value(ransomware, "State", "state", default="disabled"),
"reason": value(ransomware, "Reason", "reason", default="Keine Ransomware-Beta-Daten verfuegbar."),
"sensors": sensors,
"signals": signals[:20],
}
def summarized_events(payload: dict[str, Any] | None, include_accounts: bool = False) -> list[dict[str, Any]]:
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
for event in value(payload, "Events", "events", default=[]) or []:
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
source_ip = str(value(event, "SourceIp", "sourceIp", default="-"))
account = str(value(event, "Username", "username", default="-")) if include_accounts else ""
key = (event_type, source_ip, account)
group = groups.setdefault(key, {"type": event_type, "sourceIp": source_ip, "count": 0, "latest": None})
group["count"] += 1
timestamp = value(event, "Timestamp", "timestamp", default=None)
if timestamp and (not group["latest"] or str(timestamp) > str(group["latest"])):
group["latest"] = timestamp
if include_accounts:
group["account"] = account
return sorted(groups.values(), key=lambda entry: (str(entry["latest"]), entry["count"]), reverse=True)[:25]
def audited(tool_name: str):
def decorator(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
started = time.monotonic()
try:
result = function(*args, **kwargs)
LOGGER.info("mcp_tool=%s outcome=ok duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
return result
except Exception:
LOGGER.exception("mcp_tool=%s outcome=error duration_ms=%d", tool_name, (time.monotonic() - started) * 1000)
raise
return wrapped
return decorator
mcp = FastMCP(
"OfficeCom Sentinel",
instructions=(
"Read-only security context from OfficeCom Sentinel. Use this data to investigate, summarize, and prioritize. "
"Do not treat it as authorization to alter devices, NinjaOne, PostgreSQL, or security controls."
),
stateless_http=True,
json_response=True,
streamable_http_path="/mcp",
)
@mcp.resource("ocsentinel://read-only-policy")
def read_only_policy() -> str:
"""Explain the data and safety boundary of this server."""
return (
"OfficeCom Sentinel MCP is read-only. It returns curated status, event summaries, ransomware sensor coverage, "
"network paths, and weekly-report metadata. Raw event evidence, command lines, SMB sessions, credentials, "
"and every write action are deliberately excluded."
)
@mcp.prompt()
def incident_triage() -> str:
"""Provide a safe, evidence-oriented workflow for analyzing Sentinel findings."""
return (
"Start with security_overview or get_organization_status. For a flagged device, use get_device_security and "
"search_security_events. Separate observed facts from hypotheses, identify the next reversible validation step, "
"and recommend escalation to the responsible OfficeCom technician for any containment action."
)
@mcp.tool()
@audited("security_overview")
def security_overview() -> dict[str, Any]:
"""Return the current cross-organization security posture and the most urgent devices."""
summary = row("SELECT * FROM ocsentinel.organization_summary") or {}
coverage = row(
"""
SELECT count(*) AS known_devices,
count(*) FILTER (WHERE received_at >= now() - interval '36 hours') AS reporting_devices,
count(*) FILTER (WHERE received_at IS NULL OR received_at < now() - interval '36 hours') AS stale_devices
FROM ocsentinel.current_device_status
"""
) or {}
urgent = rows(
"""
SELECT machine_name, alert_state, received_at, total_events, unique_ip_count,
payload #>> '{NinjaOne,OrganizationId}' AS organization_id,
payload #>> '{NinjaOne,OrganizationName}' AS organization_name
FROM ocsentinel.current_device_status
WHERE alert_state IN ('warning', 'critical')
ORDER BY CASE alert_state WHEN 'critical' THEN 0 ELSE 1 END, received_at DESC NULLS LAST
LIMIT 20
"""
)
return json_safe({"summary": summary, "coverage": coverage, "urgentDevices": urgent})
@mcp.tool()
@audited("get_organization_status")
def get_organization_status(organization_id: str) -> dict[str, Any]:
"""Return current coverage and alert state for one NinjaOne organization ID."""
organization_id = checked_identifier(organization_id, "organization_id")
devices = rows(
"""
SELECT machine_name, received_at, alert_state, base_alert_state, total_events, unique_ip_count,
cve_total, cve_critical, payload
FROM ocsentinel.current_device_status
WHERE coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s
ORDER BY CASE alert_state WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, machine_name
""",
(organization_id,),
)
if not devices:
return {"organizationId": organization_id, "found": False, "devices": []}
_, organization_name = organization_context(devices[0]["payload"])
status_counts = {state: sum(device["alert_state"] == state for device in devices) for state in ("ok", "warning", "critical", "unknown")}
return json_safe(
{
"organizationId": organization_id,
"organizationName": organization_name,
"found": True,
"deviceCount": len(devices),
"statusCounts": status_counts,
"lastReceivedAt": max((device["received_at"] for device in devices if device["received_at"]), default=None),
"devices": [
{
"machineName": device["machine_name"],
"alertState": device["alert_state"],
"receivedAt": device["received_at"],
"totalEvents": device["total_events"],
"uniqueIpCount": device["unique_ip_count"],
"criticalCves": device["cve_critical"],
}
for device in devices
],
}
)
@mcp.tool()
@audited("get_device_security")
def get_device_security(machine_name: str, include_accounts: bool = False) -> dict[str, Any]:
"""Return the latest sanitized security summary for one device. Accounts are omitted by default."""
machine_name = checked_machine_name(machine_name)
device = row(
"""
SELECT machine_name, first_seen_at, last_seen_at, last_client_version, generated_at_utc, received_at,
alert_state, base_alert_state, total_events, unique_ip_count, cve_total, cve_critical, payload
FROM ocsentinel.current_device_status
WHERE machine_name = %s
""",
(machine_name,),
)
if not device:
return {"machineName": machine_name, "found": False}
organization_id, organization_name = organization_context(device["payload"])
return json_safe(
{
"found": True,
"machineName": device["machine_name"],
"organizationId": organization_id,
"organizationName": organization_name,
"receivedAt": device["received_at"],
"generatedAt": device["generated_at_utc"],
"alertState": device["alert_state"],
"baseAlertState": device["base_alert_state"],
"metrics": {
"totalEvents": device["total_events"],
"uniqueIpCount": device["unique_ip_count"],
"cveTotal": device["cve_total"],
"criticalCves": device["cve_critical"],
},
"events": summarized_events(device["payload"], include_accounts),
"ransomwareBeta": sanitized_ransomware(device["payload"]),
}
)
@mcp.tool()
@audited("search_security_events")
def search_security_events(
hours: int = 168,
organization_id: str | None = None,
alert_state: str | None = None,
limit: int = 25,
include_accounts: bool = False,
) -> dict[str, Any]:
"""Find recent scanned reports with bounded filters. This returns event summaries, not raw evidence."""
hours = bounded_hours(hours)
limit = bounded_limit(limit)
if organization_id is not None:
organization_id = checked_identifier(organization_id, "organization_id")
if alert_state is not None and alert_state not in {"ok", "warning", "critical", "unknown"}:
raise ValueError("alert_state must be ok, warning, critical, or unknown.")
reports = rows(
"""
SELECT d.machine_name, r.received_at, r.generated_at_utc, r.alert_state, r.base_alert_state,
r.total_events, r.unique_ip_count, r.cve_total, r.cve_critical, r.payload
FROM ocsentinel.scan_report AS r
JOIN ocsentinel.device AS d ON d.id = r.device_id
WHERE r.received_at >= now() - (%s * interval '1 hour')
AND (%s::text IS NULL OR coalesce(r.payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
AND (%s::text IS NULL OR r.alert_state = %s)
ORDER BY r.received_at DESC
LIMIT %s
""",
(hours, organization_id, organization_id, alert_state, alert_state, limit),
)
results = []
for report in reports:
org_id, org_name = organization_context(report["payload"])
results.append(
{
"machineName": report["machine_name"],
"organizationId": org_id,
"organizationName": org_name,
"receivedAt": report["received_at"],
"generatedAt": report["generated_at_utc"],
"alertState": report["alert_state"],
"baseAlertState": report["base_alert_state"],
"totalEvents": report["total_events"],
"uniqueIpCount": report["unique_ip_count"],
"criticalCves": report["cve_critical"],
"events": summarized_events(report["payload"], include_accounts),
"ransomwareBeta": sanitized_ransomware(report["payload"]),
}
)
return json_safe({"lookbackHours": hours, "resultCount": len(results), "reports": results})
@mcp.tool()
@audited("get_network_paths")
def get_network_paths(days: int = 14, organization_id: str | None = None, limit: int = 50) -> dict[str, Any]:
"""Return recent observed source-IP to device paths from summarized security events."""
days = max(1, min(bounded_hours(days * 24) // 24, 90))
limit = bounded_limit(limit)
if organization_id is not None:
organization_id = checked_identifier(organization_id, "organization_id")
reports = rows(
"""
SELECT machine_name, received_at, payload
FROM ocsentinel.current_device_status
WHERE received_at >= now() - (%s * interval '1 day')
AND (%s::text IS NULL OR coalesce(payload #>> '{NinjaOne,OrganizationId}', 'unknown') = %s)
""",
(days, organization_id, organization_id),
)
flows: dict[tuple[str, str, str, str], dict[str, Any]] = {}
for report in reports:
org_id, org_name = organization_context(report["payload"])
for event in value(report["payload"], "Events", "events", default=[]) or []:
source_ip = str(value(event, "SourceIp", "sourceIp", default=""))
if not source_ip or source_ip in {"-", "127.0.0.1", "::1"}:
continue
event_type = str(value(event, "Target", "target", default="Sicherheitsereignis"))
key = (source_ip, report["machine_name"], event_type, org_id)
flow = flows.setdefault(
key,
{
"sourceIp": source_ip,
"machineName": report["machine_name"],
"eventType": event_type,
"organizationId": org_id,
"organizationName": org_name,
"count": 0,
"lastSeen": report["received_at"],
},
)
flow["count"] += 1
timestamp = value(event, "Timestamp", "timestamp", default=None)
if timestamp and (not flow["lastSeen"] or str(timestamp) > str(flow["lastSeen"])):
flow["lastSeen"] = timestamp
paths = sorted(flows.values(), key=lambda entry: (entry["count"], str(entry["lastSeen"])), reverse=True)[:limit]
return json_safe({"days": days, "pathCount": len(paths), "paths": paths})
@mcp.tool()
@audited("get_weekly_report")
def get_weekly_report(organization_id: str) -> dict[str, Any]:
"""Return the latest weekly report metadata and structured summary for one organization, without report HTML."""
organization_id = checked_identifier(organization_id, "organization_id")
report = row(
"""
SELECT organization_id, organization_name, period_start_utc, period_end_utc, generated_at,
device_count, warning_count, critical_count, total_events, unique_ips, cve_total,
cve_critical, summary
FROM ocsentinel.weekly_organization_report
WHERE organization_id = %s
ORDER BY period_end_utc DESC, generated_at DESC
LIMIT 1
""",
(organization_id,),
)
if not report:
return {"organizationId": organization_id, "found": False}
report["found"] = True
return json_safe(report)
class GuardedMcpApp:
"""Small ASGI guard without BaseHTTPMiddleware, which can disrupt MCP streaming."""
def __init__(self, wrapped_app):
self.wrapped_app = wrapped_app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.wrapped_app(scope, receive, send)
return
headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
host = headers.get("host", "").lower()
origin = headers.get("origin")
authorization = headers.get("authorization", "")
if ALLOWED_HOSTS and host not in ALLOWED_HOSTS:
await self.reject(send, 421, "Untrusted Host header.")
return
if origin and (not ALLOWED_ORIGINS or origin not in ALLOWED_ORIGINS):
await self.reject(send, 403, "Untrusted Origin header.")
return
if authorization != f"Bearer {AUTH_TOKEN}":
await self.reject(send, 401, "Bearer token required.", {b"www-authenticate": b"Bearer"})
return
await self.wrapped_app(scope, receive, send)
@staticmethod
async def reject(send, status: int, message: str, extra_headers: dict[bytes, bytes] | None = None):
body = json.dumps({"error": message}).encode("utf-8")
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode("ascii"))]
if extra_headers:
headers.extend(extra_headers.items())
await send({"type": "http.response.start", "status": status, "headers": headers})
await send({"type": "http.response.body", "body": body})
app = GuardedMcpApp(mcp.streamable_http_app())

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,76 @@
{
"id": "OCwRpt7eK3mQ2xL9",
"name": "OCSentinel - Weekly Organization Reports",
"nodes": [
{
"parameters": { "rule": { "interval": [{ "field": "weeks", "weeksInterval": 1, "triggerAtDay": [1], "triggerAtHour": 7, "triggerAtMinute": 20 }] } },
"id": "schedule-weekly-reports", "name": "Every Monday 07:20", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.3, "position": [300, 300]
},
{
"parameters": { "operation": "executeQuery", "query": "WITH latest AS (\n SELECT DISTINCT ON (d.id) d.machine_name, r.alert_state, r.payload\n FROM ocsentinel.scan_report AS r\n JOIN ocsentinel.device AS d ON d.id = r.device_id\n WHERE r.received_at >= now() - interval '8 days'\n ORDER BY d.id, r.generated_at_utc DESC, r.received_at DESC\n)\nSELECT machine_name, alert_state, payload\nFROM latest\nORDER BY payload #>> '{NinjaOne,OrganizationName}', machine_name;" },
"id": "load-weekly-data", "name": "Load Latest Device Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [560, 300],
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
},
{
"parameters": { "jsCode": "const esc=v=>String(v??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\\\"/g,'&quot;').replace(/'/g,'&#39;');\nconst now=new Date(),end=new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()));\nend.setUTCDate(end.getUTCDate()-((end.getUTCDay()+6)%7)); const start=new Date(end-7*86400000);\nconst groups=new Map();\nfor(const item of items){const p=item.json.payload||{},n=p.NinjaOne||p.ninjaOne||{},id=String(n.OrganizationId||n.organizationId||'unknown'),name=String(n.OrganizationName||n.organizationName||`Organisation ${id}`);if(!groups.has(id))groups.set(id,{id,name,devices:[]});groups.get(id).devices.push({machine:item.json.machine_name||p.MachineName||'Unbekannt',state:String(item.json.alert_state||p.AlertState||'unknown').toLowerCase(),p});}\nconst result=[];\nfor(const group of groups.values()){let warnings=0,criticals=0,total=0,cveTotal=0,cveCritical=0;const ips=new Set(),alerts=[],clean=[];for(const d of group.devices){if(d.state==='warning')warnings++;if(d.state==='critical')criticals++;const vc=d.p.VulnerabilityCorrelation||{};cveTotal+=Number(vc.TotalCount||0);cveCritical+=Number(vc.CriticalCount||0);const events=(d.p.Events||[]).filter(e=>{const t=new Date(e.Timestamp);return !Number.isNaN(t)&&t>=start&&t<end;});total+=events.length;if(!events.length){clean.push(`<tr class=\"clean\"><td>${esc(d.machine)}</td><td>Keine</td><td>-</td><td>-</td><td>0</td><td>-</td><td>Log sauber / Keine Angriffe</td></tr>`);continue;}const rows=new Map();for(const e of events){const ip=e.SourceIp||'-',account=e.Username||'-',type=e.Target||'Sicherheitsereignis',key=[type,account,ip].join('|'),row=rows.get(key)||{ip,account,type,count:0,last:e.Timestamp};row.count++;if(new Date(e.Timestamp)>new Date(row.last))row.last=e.Timestamp;rows.set(key,row);if(ip!=='-')ips.add(ip);}for(const r of rows.values()){alerts.push(`<tr class=\"alert\"><td>${esc(d.machine)}</td><td>${esc(r.type)}</td><td>${esc(r.account)}</td><td>${esc(new Date(r.last).toLocaleString('de-DE',{timeZone:'Europe/Berlin'}))}</td><td>${r.count}</td><td>${esc(r.ip)}</td><td>${d.state==='critical'?'Problem entdeckt (kritisch)':'Problem entdeckt'}</td></tr>`);}}const summary={deviceCount:group.devices.length,warningCount:warnings,criticalCount:criticals,totalEvents:total,uniqueIps:ips.size,cveTotal,cveCritical};const html=`<div class=\"ocsentinel-report\"><style>.ocsentinel-report{font-family:Arial,sans-serif;font-size:12px;color:#1f2937;max-width:1200px}.ocsentinel-report h2{font-size:16px;color:#183b79;margin:0 0 5px;border-bottom:1px solid #183b79;padding-bottom:5px}.ocsentinel-report .meta{font-size:11px;color:#4b5563;margin-bottom:12px}.ocsentinel-report .summary{margin:10px 0;padding:8px;background:#eef5ff;border:1px solid #bfd4f2;color:#183b79}.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{background:#1f4a99;color:#fff;text-align:left;padding:6px;font-size:11px}.ocsentinel-report td{border:1px solid #e5e7eb;padding:6px;vertical-align:top}.ocsentinel-report tr.alert{background:#fff4ed;color:#8a3d16}.ocsentinel-report tr.clean{background:#effcf4;color:#17643a}.ocsentinel-report .footer{margin-top:10px;font-size:10px;color:#6b7280;text-align:right}</style><h2>OCSentinel - Konsolidierter Sicherheitsbericht</h2><div class=\"meta\"><strong>${esc(group.name)}</strong><br>Berichtszeitraum: ${start.toLocaleDateString('de-DE')} bis ${end.toLocaleDateString('de-DE')}<br>Erstellt am: ${now.toLocaleString('de-DE',{timeZone:'Europe/Berlin'})}</div><div class=\"summary\"><strong>${summary.deviceCount} Geräte</strong> | <strong>${summary.criticalCount} kritisch</strong> | <strong>${summary.warningCount} Warnungen</strong> | <strong>${summary.totalEvents} Ereignisse</strong> | <strong>${summary.uniqueIps} IPs</strong> | <strong>${summary.cveCritical} kritische CVEs</strong></div><table><thead><tr><th>Server</th><th>Vorfall-Typ</th><th>Betroffenes Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Angreifer-IP</th><th>Status / Bemerkung</th></tr></thead><tbody>${alerts.join('')}${clean.join('')}</tbody></table><div class=\"footer\">Automatisch durch OCSentinel und n8n erzeugt.</div></div>`;result.push({json:{organizationId:group.id,organizationName:group.name,periodStartUtc:start.toISOString(),periodEndUtc:end.toISOString(),...summary,reportHtml:html,summaryJson:JSON.stringify(summary)}});}return result;" },
"id": "build-weekly-reports", "name": "Build Organization HTML Reports", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [820, 300]
},
{
"parameters": { "operation": "executeQuery", "query": "INSERT INTO ocsentinel.weekly_organization_report (organization_id, organization_name, period_start_utc, period_end_utc, device_count, warning_count, critical_count, total_events, unique_ips, cve_total, cve_critical, report_html, summary)\nVALUES ($1, $2, $3::timestamptz, $4::timestamptz, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)\nON CONFLICT (organization_id, period_start_utc) DO UPDATE SET organization_name=EXCLUDED.organization_name, period_end_utc=EXCLUDED.period_end_utc, generated_at=now(), device_count=EXCLUDED.device_count, warning_count=EXCLUDED.warning_count, critical_count=EXCLUDED.critical_count, total_events=EXCLUDED.total_events, unique_ips=EXCLUDED.unique_ips, cve_total=EXCLUDED.cve_total, cve_critical=EXCLUDED.cve_critical, report_html=EXCLUDED.report_html, summary=EXCLUDED.summary\nRETURNING id;", "options": { "queryReplacement": "={{ [$json.organizationId, $json.organizationName, $json.periodStartUtc, $json.periodEndUtc, $json.deviceCount, $json.warningCount, $json.criticalCount, $json.totalEvents, $json.uniqueIps, $json.cveTotal, $json.cveCritical, $json.reportHtml, $json.summaryJson] }}" } },
"id": "store-weekly-reports", "name": "Store Weekly Organization Reports", "type": "n8n-nodes-base.postgres", "typeVersion": 2.5, "position": [1080, 300],
"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT coalesce(array_agg(recipient_email ORDER BY recipient_email), ARRAY[]::text[]) AS recipients\nFROM ocsentinel.organization_report_recipient\nWHERE enabled = TRUE AND (organization_id = '*' OR organization_id = $1);",
"options": { "queryReplacement": "={{ [$json.organizationId] }}" }
},
"id": "load-report-recipients",
"name": "Empfaenger aus zentraler Zuordnung laden",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [1560, 300]
,"credentials": { "postgres": { "id": "WkjY0kIF3kHvREys", "name": "OCSentinel PostgreSQL" } }
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const report = $('Build Organization HTML Reports').item.json;\nconst recipients = Array.from(new Set($json.recipients || []));\nif (recipients.length === 0) return [];\nreturn { json: { ...report, recipients } };"
},
"id": "prepare-report-email",
"name": "E-Mail vorbereiten",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1800, 300]
},
{
"parameters": {
"fromEmail": "donotreply@officecom.biz",
"toEmail": "={{ $json.recipients.join(', ') }}",
"subject": "=OCSentinel Wochenbericht - {{ $json.organizationName }}",
"html": "={{ $json.reportHtml }}",
"options": { "appendAttribution": false }
},
"id": "send-weekly-report-email",
"name": "Send Weekly Organization Report",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [2040, 300],
"credentials": { "smtp": { "id": "vafGYgYzM9SbxQbW", "name": "SMTP account" } }
}
],
"connections": {
"Every Monday 07:20": { "main": [[{ "node": "Load Latest Device Reports", "type": "main", "index": 0 }]] },
"Load Latest Device Reports": { "main": [[{ "node": "Build Organization HTML Reports", "type": "main", "index": 0 }]] },
"Build Organization HTML Reports": { "main": [[{ "node": "Store Weekly Organization Reports", "type": "main", "index": 0 }, { "node": "Empfaenger aus zentraler Zuordnung laden", "type": "main", "index": 0 }]] },
"Empfaenger aus zentraler Zuordnung laden": { "main": [[{ "node": "E-Mail vorbereiten", "type": "main", "index": 0 }]] },
"E-Mail vorbereiten": { "main": [[{ "node": "Send Weekly Organization Report", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1", "timezone": "Europe/Berlin" },
"active": true,
"pinData": {},
"versionId": "af98f45b-192e-49c8-9a1e-e7b1fc7e00b2",
"meta": { "templateCredsSetupCompleted": true },
"tags": []
}

View File

@@ -0,0 +1,40 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const fmt = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
const now = new Date();
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
const start = new Date(end.getTime() - 7 * 86400000);
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
}
const output = [];
for (const group of groups.values()) {
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
const ips = new Set(), rows = [];
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
if (device.state === 'warning') warnings++;
if (device.state === 'critical') criticals++;
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => { const date = new Date(event.Timestamp); return !Number.isNaN(date) && date >= start && date < end; });
totalEvents += events.length;
if (!events.length) { rows.push(`<tr class="clean"><td>${esc(device.name)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><b class="ok">Sauber</b></td></tr>`); continue; }
const grouped = new Map();
for (const event of events) {
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
}
for (const event of grouped.values()) rows.push(`<tr class="alert"><td>${esc(device.name)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(fmt(event.latest))}</td><td>${event.count}</td><td>${esc(event.ip)}</td><td><b class="${device.state === 'critical' ? 'critical' : 'warning'}">${device.state === 'critical' ? 'Kritisch' : 'Pruefen'}</b></td></tr>`);
}
const risk = criticals ? ['Kritisch', 'critical'] : warnings ? ['Beobachten', 'warning'] : ['Unauffaellig', 'ok'];
const html = `<div class="ocs"><style>.ocs{font:13px Segoe UI,Tahoma,sans-serif;color:#172033;max-width:1160px}.ocs .head{background:#102a43;color:#fff;padding:18px 20px;border-radius:8px 8px 0 0}.ocs h1{margin:0;font-size:20px}.ocs .meta{color:#d5e2ee;margin-top:5px}.ocs .risk{float:right;padding:4px 8px;border-radius:12px}.ocs .stats{width:100%;border-collapse:separate;border-spacing:8px;margin:8px -8px}.ocs .stats td{width:16%;padding:9px;background:#f4f8fc;border:1px solid #dbe5ef}.ocs .stats b{display:block;font-size:20px;color:#102a43}.ocs table{width:100%;border-collapse:collapse}.ocs th{background:#1d4e89;color:#fff;text-align:left;padding:8px;font-size:11px}.ocs td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocs .alert{background:#fff8f3}.ocs .clean{background:#f3fbf6}.ocs .ok,.ocs .warning,.ocs .critical{padding:3px 6px;border-radius:4px}.ocs .ok{background:#d1fae5;color:#065f46}.ocs .warning{background:#fef3c7;color:#92400e}.ocs .critical{background:#fee2e2;color:#991b1b}.ocs .foot{margin-top:12px;text-align:right;color:#64748b;font-size:10px}</style><div class="head"><b class="risk ${risk[1]}">${risk[0]}</b><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="meta">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</div></div><table class="stats"><tr><td><b>${group.devices.length}</b>Ger&auml;te</td><td><b>${criticals}</b>Kritisch</td><td><b>${warnings}</b>Warnungen</td><td><b>${totalEvents}</b>Ereignisse</td><td><b>${ips.size}</b>Quell-IP-Adressen</td><td><b>${cveCritical}</b>Kritische CVEs</td></tr></table><table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><div class="foot">Automatisch erstellt am ${esc(fmt(now))} durch OfficeCom Sentinel.</div></div>`;
output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });
}
return output;

View File

@@ -0,0 +1,129 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
// Numeric entities keep German text intact in every supported mail client.
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const formatDate = (value) => new Date(value).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium',
timeStyle: 'short'
});
const now = new Date();
const periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
periodEnd.setUTCDate(periodEnd.getUTCDate() - ((periodEnd.getUTCDay() + 6) % 7));
const periodStart = new Date(periodEnd.getTime() - 7 * 86400000);
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {};
const ninja = payload.NinjaOne || payload.ninjaOne || {};
const organizationId = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
const organizationName = String(ninja.OrganizationName || ninja.organizationName || `Organisation ${organizationId}`);
if (!groups.has(organizationId)) {
groups.set(organizationId, { organizationId, organizationName, devices: [] });
}
groups.get(organizationId).devices.push({
machineName: item.json.machine_name || payload.MachineName || 'Unbekannt',
state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(),
payload
});
}
const reportItems = [];
for (const group of groups.values()) {
let warningCount = 0;
let criticalCount = 0;
let totalEvents = 0;
let cveTotal = 0;
let cveCritical = 0;
const uniqueIps = new Set();
const alertRows = [];
const cleanRows = [];
for (const device of group.devices.sort((left, right) => left.machineName.localeCompare(right.machineName))) {
if (device.state === 'warning') warningCount++;
if (device.state === 'critical') criticalCount++;
const vulnerabilities = device.payload.VulnerabilityCorrelation || {};
cveTotal += Number(vulnerabilities.TotalCount || 0);
cveCritical += Number(vulnerabilities.CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => {
const timestamp = new Date(event.Timestamp);
return !Number.isNaN(timestamp) && timestamp >= periodStart && timestamp < periodEnd;
});
totalEvents += events.length;
if (events.length === 0) {
cleanRows.push(`<tr class="clean"><td>${esc(device.machineName)}</td><td colspan="5">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td><span class="badge badge-ok">Sauber</span></td></tr>`);
continue;
}
const groupedEvents = new Map();
for (const event of events) {
const sourceIp = event.SourceIp || '-';
const account = event.Username || '-';
const type = event.Target || 'Sicherheitsereignis';
const key = [type, account, sourceIp].join('|');
const row = groupedEvents.get(key) || { type, account, sourceIp, count: 0, latest: event.Timestamp };
row.count++;
if (new Date(event.Timestamp) > new Date(row.latest)) row.latest = event.Timestamp;
groupedEvents.set(key, row);
if (sourceIp !== '-') uniqueIps.add(sourceIp);
}
for (const event of groupedEvents.values()) {
const severity = device.state === 'critical' ? 'Kritisch' : 'Pruefen';
const badge = device.state === 'critical' ? 'badge-critical' : 'badge-warning';
alertRows.push(`<tr class="alert"><td>${esc(device.machineName)}</td><td>${esc(event.type)}</td><td>${esc(event.account)}</td><td>${esc(formatDate(event.latest))}</td><td>${event.count}</td><td>${esc(event.sourceIp)}</td><td><span class="badge ${badge}">${severity}</span></td></tr>`);
}
}
const summary = {
deviceCount: group.devices.length,
warningCount,
criticalCount,
totalEvents,
uniqueIps: uniqueIps.size,
cveTotal,
cveCritical
};
const riskLabel = criticalCount > 0 ? 'Kritisch' : warningCount > 0 ? 'Beobachten' : 'Unauffaellig';
const riskClass = criticalCount > 0 ? 'risk-critical' : warningCount > 0 ? 'risk-warning' : 'risk-ok';
const rows = alertRows.length > 0 ? `${alertRows.join('')}${cleanRows.join('')}` : cleanRows.join('');
const reportHtml = `<div class="ocsentinel-report">
<style>
.ocsentinel-report{max-width:1180px;margin:0 auto;font-family:Segoe UI,Tahoma,sans-serif;font-size:13px;line-height:1.35;color:#172033;background:#fff}
.ocsentinel-report .header{padding:18px 20px;background:#102a43;color:#fff;border-radius:8px 8px 0 0}
.ocsentinel-report h1{margin:0;font-size:20px;line-height:1.2}.ocsentinel-report .subtitle{margin-top:5px;color:#cbd5e1;font-size:12px}
.ocsentinel-report .risk{float:right;padding:5px 9px;border-radius:999px;font-size:11px;font-weight:700}.ocsentinel-report .risk-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .risk-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .risk-critical{background:#fee2e2;color:#991b1b}
.ocsentinel-report .body{padding:16px 20px 20px;border:1px solid #dbe5ef;border-top:0}.ocsentinel-report .metrics{width:100%;border-collapse:separate;border-spacing:8px 0;margin:0 -8px 15px}.ocsentinel-report .metrics td{width:16.66%;padding:10px;background:#f6f9fc;border:1px solid #dbe5ef;border-radius:5px}.ocsentinel-report .metric-value{display:block;font-size:20px;font-weight:700;color:#102a43}.ocsentinel-report .metric-label{display:block;font-size:10px;color:#526577;text-transform:uppercase;letter-spacing:.04em}
.ocsentinel-report table{width:100%;border-collapse:collapse}.ocsentinel-report th{padding:8px;background:#1d4e89;color:#fff;text-align:left;font-size:11px}.ocsentinel-report td{padding:8px;border-bottom:1px solid #dbe5ef;vertical-align:top}.ocsentinel-report tr.alert{background:#fff8f3}.ocsentinel-report tr.clean{background:#f3fbf6;color:#275b3b}.ocsentinel-report .badge{display:inline-block;padding:3px 6px;border-radius:4px;font-size:10px;font-weight:700}.ocsentinel-report .badge-ok{background:#d1fae5;color:#065f46}.ocsentinel-report .badge-warning{background:#fef3c7;color:#92400e}.ocsentinel-report .badge-critical{background:#fee2e2;color:#991b1b}.ocsentinel-report .footer{margin-top:14px;color:#64748b;font-size:10px;text-align:right}
</style>
<div class="header"><span class="risk ${riskClass}">${riskLabel}</span><h1>OfficeCom Sentinel Sicherheitsbericht</h1><div class="subtitle">${esc(group.organizationName)} | ${esc(periodStart.toLocaleDateString('de-DE'))} bis ${esc(periodEnd.toLocaleDateString('de-DE'))}</div></div>
<div class="body"><table class="metrics"><tr><td><span class="metric-value">${summary.deviceCount}</span><span class="metric-label">Ger&auml;te</span></td><td><span class="metric-value">${summary.criticalCount}</span><span class="metric-label">Kritisch</span></td><td><span class="metric-value">${summary.warningCount}</span><span class="metric-label">Warnungen</span></td><td><span class="metric-value">${summary.totalEvents}</span><span class="metric-label">Ereignisse</span></td><td><span class="metric-value">${summary.uniqueIps}</span><span class="metric-label">Quell-IP-Adressen</span></td><td><span class="metric-value">${summary.cveCritical}</span><span class="metric-label">Kritische CVEs</span></td></tr></table>
<table><thead><tr><th>System</th><th>Vorfall</th><th>Konto</th><th>Letzter Zeitpunkt</th><th>Anzahl</th><th>Quell-IP</th><th>Bewertung</th></tr></thead><tbody>${rows}</tbody></table><div class="footer">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</div></div></div>`;
reportItems.push({
json: {
organizationId: group.organizationId,
organizationName: group.organizationName,
periodStartUtc: periodStart.toISOString(),
periodEndUtc: periodEnd.toISOString(),
...summary,
reportHtml,
summaryJson: JSON.stringify(summary)
}
});
}
return reportItems;

View File

@@ -0,0 +1,41 @@
const esc = (value) => String(value ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
.replace(/[^\x20-\x7e]/g, (character) => `&#${character.codePointAt(0)};`);
const now = new Date();
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
end.setUTCDate(end.getUTCDate() - ((end.getUTCDay() + 6) % 7));
const start = new Date(end.getTime() - 7 * 86400000);
const formatDate = (value) => new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' });
const groups = new Map();
for (const item of items) {
const payload = item.json.payload || {}, ninja = payload.NinjaOne || payload.ninjaOne || {};
const id = String(ninja.OrganizationId || ninja.organizationId || 'unknown');
if (!groups.has(id)) groups.set(id, { id, name: String(ninja.OrganizationName || ninja.organizationName || `Organisation ${id}`), devices: [] });
groups.get(id).devices.push({ name: item.json.machine_name || payload.MachineName || 'Unbekannt', state: String(item.json.alert_state || payload.AlertState || 'unknown').toLowerCase(), payload });
}
const output = [];
for (const group of groups.values()) {
let warnings = 0, criticals = 0, totalEvents = 0, cveCritical = 0;
const ips = new Set(), rows = [];
for (const device of group.devices.sort((a, b) => a.name.localeCompare(b.name))) {
if (device.state === 'warning') warnings++;
if (device.state === 'critical') criticals++;
cveCritical += Number((device.payload.VulnerabilityCorrelation || {}).CriticalCount || 0);
const events = (device.payload.Events || []).filter((event) => { const timestamp = new Date(event.Timestamp); return !Number.isNaN(timestamp) && timestamp >= start && timestamp < end; });
totalEvents += events.length;
if (!events.length) { rows.push(`<tr bgcolor="#f0fdf4"><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td colspan="5" style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534">Keine sicherheitsrelevanten Ereignisse im Berichtszeitraum.</td><td style="padding:8px;border-bottom:1px solid #dbe5ef;font-family:Arial,sans-serif;font-size:12px;color:#166534"><b>Sauber</b></td></tr>`); continue; }
const grouped = new Map();
for (const event of events) {
const ip = event.SourceIp || '-', account = event.Username || '-', type = event.Target || 'Sicherheitsereignis', key = [type, account, ip].join('|');
const entry = grouped.get(key) || { ip, account, type, count: 0, latest: event.Timestamp };
entry.count++; if (new Date(event.Timestamp) > new Date(entry.latest)) entry.latest = event.Timestamp; grouped.set(key, entry); if (ip !== '-') ips.add(ip);
}
for (const event of grouped.values()) { const critical = device.state === 'critical'; rows.push(`<tr bgcolor="#fff7ed"><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(device.name)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.type)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.account)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(formatDate(event.latest))}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${event.count}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px">${esc(event.ip)}</td><td style="padding:8px;border-bottom:1px solid #fed7aa;font-family:Arial,sans-serif;font-size:12px;color:${critical ? '#991b1b' : '#92400e'}"><b>${critical ? 'Kritisch' : 'Pruefen'}</b></td></tr>`); }
}
const risk = criticals ? ['Kritisch', '#991b1b', '#fee2e2'] : warnings ? ['Beobachten', '#92400e', '#fef3c7'] : ['Unauffaellig', '#166534', '#dcfce7'];
const metric = (value, label) => `<td width="16.66%" valign="top" style="padding:10px;background:#f8fafc;border:1px solid #dbe5ef;font-family:Arial,sans-serif"><b style="font-size:20px;color:#102a43">${value}</b><br><span style="font-size:10px;color:#526577">${label}</span></td>`;
const html = `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:1100px;border-collapse:collapse"><tr><td style="padding:18px 20px;background:#102a43"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td valign="top" style="font-family:Arial,sans-serif;color:#ffffff"><h2 style="margin:0;font-size:20px;color:#ffffff">OfficeCom Sentinel Sicherheitsbericht</h2><p style="margin:6px 0 0;color:#d5e2ee;font-size:12px">${esc(group.name)} | ${esc(start.toLocaleDateString('de-DE'))} bis ${esc(end.toLocaleDateString('de-DE'))}</p></td><td width="100" align="right" valign="top" style="font-family:Arial,sans-serif"><span style="display:inline-block;padding:5px 9px;background:${risk[2]};color:${risk[1]};font-size:11px"><b>${risk[0]}</b></span></td></tr></table></td></tr><tr><td style="padding:16px 20px;border:1px solid #dbe5ef"><table role="presentation" width="100%" cellspacing="6" cellpadding="0" border="0"><tr>${metric(group.devices.length, 'Ger&auml;te')}${metric(criticals, 'Kritisch')}${metric(warnings, 'Warnungen')}${metric(totalEvents, 'Ereignisse')}${metric(ips.size, 'Quell-IP-Adressen')}${metric(cveCritical, 'Kritische CVEs')}</tr></table><table width="100%" cellspacing="0" cellpadding="0" border="0" style="border-collapse:collapse"><thead><tr bgcolor="#1d4e89"><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">System</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Vorfall</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Konto</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Letzter Zeitpunkt</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Anzahl</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Quell-IP</th><th align="left" style="padding:8px;color:#fff;font-family:Arial,sans-serif;font-size:11px">Bewertung</th></tr></thead><tbody>${rows.join('')}</tbody></table><p style="margin:14px 0 0;text-align:right;color:#64748b;font-family:Arial,sans-serif;font-size:10px">Automatisch erstellt am ${esc(formatDate(now))} durch OfficeCom Sentinel.</p></td></tr></table>`;
output.push({ json: { organizationId: group.id, organizationName: group.name, periodStartUtc: start.toISOString(), periodEndUtc: end.toISOString(), deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveTotal: 0, cveCritical, reportHtml: html, summaryJson: JSON.stringify({ deviceCount: group.devices.length, warningCount: warnings, criticalCount: criticals, totalEvents, uniqueIps: ips.size, cveCritical }) } });
}
return output;

View File

@@ -0,0 +1,10 @@
{
"role": "server-side-only",
"environment": "production",
"postgresHost": "10.0.0.25",
"postgresPort": 5432,
"postgresDatabase": "ocsentinel",
"postgresSslMode": "require",
"n8nInternalBaseUrl": "http://n8n.internal:5678",
"notes": "This file is for the internal ingest or n8n side only. Do not deploy this file to endpoint clients."
}

View File

@@ -0,0 +1,103 @@
-- OfficeCom Sentinel central reporting store.
-- Apply once as a PostgreSQL administrator to the dedicated ocsentinel database.
BEGIN;
CREATE SCHEMA IF NOT EXISTS ocsentinel;
CREATE TABLE IF NOT EXISTS ocsentinel.device (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
machine_name TEXT NOT NULL,
machine_name_key TEXT NOT NULL UNIQUE,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_client_version TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS ocsentinel.scan_report (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE,
generated_at_utc TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
client_version TEXT NOT NULL,
alert_state TEXT NOT NULL CHECK (alert_state IN ('ok', 'warning', 'critical', 'unknown')),
base_alert_state TEXT NOT NULL CHECK (base_alert_state IN ('ok', 'warning', 'critical', 'unknown')),
total_events INTEGER NOT NULL CHECK (total_events >= 0),
unique_ip_count INTEGER NOT NULL CHECK (unique_ip_count >= 0),
cve_total INTEGER NOT NULL DEFAULT 0 CHECK (cve_total >= 0),
cve_critical INTEGER NOT NULL DEFAULT 0 CHECK (cve_critical >= 0),
payload_sha256 CHAR(64) NOT NULL,
payload JSONB NOT NULL,
UNIQUE (device_id, generated_at_utc, payload_sha256)
);
CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_device_received
ON ocsentinel.scan_report (device_id, received_at DESC);
CREATE INDEX IF NOT EXISTS ix_ocsentinel_scan_report_alert_received
ON ocsentinel.scan_report (alert_state, received_at DESC);
CREATE TABLE IF NOT EXISTS ocsentinel.ingest_nonce (
nonce CHAR(32) PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES ocsentinel.device(id) ON DELETE CASCADE,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS ix_ocsentinel_ingest_nonce_expires
ON ocsentinel.ingest_nonce (expires_at);
CREATE TABLE IF NOT EXISTS ocsentinel.weekly_organization_report (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
organization_id TEXT NOT NULL,
organization_name TEXT NOT NULL,
period_start_utc TIMESTAMPTZ NOT NULL,
period_end_utc TIMESTAMPTZ NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
device_count INTEGER NOT NULL DEFAULT 0,
warning_count INTEGER NOT NULL DEFAULT 0,
critical_count INTEGER NOT NULL DEFAULT 0,
total_events INTEGER NOT NULL DEFAULT 0,
unique_ips INTEGER NOT NULL DEFAULT 0,
cve_total INTEGER NOT NULL DEFAULT 0,
cve_critical INTEGER NOT NULL DEFAULT 0,
report_html TEXT NOT NULL,
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (organization_id, period_start_utc)
);
CREATE INDEX IF NOT EXISTS ix_ocsentinel_weekly_report_organization_generated
ON ocsentinel.weekly_organization_report (organization_id, generated_at DESC);
CREATE OR REPLACE VIEW ocsentinel.current_device_status AS
SELECT DISTINCT ON (d.id)
d.machine_name,
d.first_seen_at,
d.last_seen_at,
d.last_client_version,
r.generated_at_utc,
r.received_at,
r.alert_state,
r.base_alert_state,
r.total_events,
r.unique_ip_count,
r.cve_total,
r.cve_critical,
r.payload
FROM ocsentinel.device AS d
LEFT JOIN ocsentinel.scan_report AS r ON r.device_id = d.id
ORDER BY d.id, r.generated_at_utc DESC NULLS LAST, r.received_at DESC NULLS LAST;
CREATE OR REPLACE VIEW ocsentinel.organization_summary AS
SELECT
count(*) FILTER (WHERE generated_at_utc IS NOT NULL) AS devices_reporting,
count(*) FILTER (WHERE alert_state = 'warning') AS devices_warning,
count(*) FILTER (WHERE alert_state = 'critical') AS devices_critical,
coalesce(sum(total_events), 0) AS total_events,
coalesce(sum(unique_ip_count), 0) AS total_unique_ips,
coalesce(sum(cve_total), 0) AS total_cves,
coalesce(sum(cve_critical), 0) AS critical_cves,
max(received_at) AS last_report_received_at
FROM ocsentinel.current_device_status;
COMMIT;

View File

@@ -0,0 +1,25 @@
-- Central recipient rules for OCSentinel weekly organization reports.
-- The '*' organization ID applies to every organization.
BEGIN;
CREATE TABLE IF NOT EXISTS ocsentinel.organization_report_recipient (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
organization_id TEXT NOT NULL,
organization_name TEXT NOT NULL,
recipient_email TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (organization_id, recipient_email)
);
INSERT INTO ocsentinel.organization_report_recipient
(organization_id, organization_name, recipient_email)
VALUES
('*', 'Alle Organisationen', 'lg@officecom.it'),
('*', 'Alle Organisationen', 'rk@officecom.biz'),
('9', 'Mildenberger Verlag', 'dd@officecom.it'),
('16', 'Kirsch GmbH', 'dd@officecom.it')
ON CONFLICT (organization_id, recipient_email) DO NOTHING;
COMMIT;

36
infra/postgres/README.md Normal file
View File

@@ -0,0 +1,36 @@
# OfficeCom Sentinel PostgreSQL
PostgreSQL is the private central store for endpoint reports. It is never
contacted directly by an endpoint; only n8n uses a database account.
## Provisioning
The production instance is deployed as the private Dockge stack documented in
[../dockge/README.md](../dockge/README.md). The bootstrap has already created
the database, schema, and restricted `ocsentinel_n8n` role.
For a separate future installation:
1. Create a database named `ocsentinel` on the private PostgreSQL server.
2. Apply `001_ocsentinel.sql` as a database administrator.
3. Create a non-superuser n8n login and grant only the necessary permissions:
```sql
GRANT USAGE ON SCHEMA ocsentinel TO ocsentinel_n8n;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ocsentinel TO ocsentinel_n8n;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ocsentinel TO ocsentinel_n8n;
GRANT SELECT ON ocsentinel.current_device_status, ocsentinel.organization_summary TO ocsentinel_n8n;
ALTER DEFAULT PRIVILEGES IN SCHEMA ocsentinel
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ocsentinel_n8n;
```
Keep the database host, password, and TLS settings only in n8n credentials.
They do not belong in Gitea, NinjaOne scripts, or endpoint configuration.
## Maintenance
Run monthly from n8n or an administrator session to remove expired replay tokens:
```sql
DELETE FROM ocsentinel.ingest_nonce WHERE expires_at < now();
```

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWindowsForms>false</UseWindowsForms>
<AssemblyName>OCSentinelBootstrapper</AssemblyName>
<RootNamespace>OCSentinelBootstrapper</RootNamespace>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="payload.zip" LogicalName="payload.zip" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,76 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Reflection;
namespace OCSentinelBootstrapper;
internal static class Program
{
private static int Main()
{
string tempRoot = Path.Combine(Path.GetTempPath(), "OCSentinelSetup", Guid.NewGuid().ToString("N"));
string zipPath = Path.Combine(tempRoot, "payload.zip");
string extractRoot = Path.Combine(tempRoot, "payload");
try
{
Directory.CreateDirectory(tempRoot);
Directory.CreateDirectory(extractRoot);
ExtractEmbeddedPayload(zipPath);
ZipFile.ExtractToDirectory(zipPath, extractRoot, overwriteFiles: true);
string installScript = Path.Combine(extractRoot, "scripts", "install-ocsentinel.ps1");
if (!File.Exists(installScript))
{
throw new FileNotFoundException("Embedded payload did not contain install-ocsentinel.ps1", installScript);
}
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-ExecutionPolicy Bypass -File \"{installScript}\"",
UseShellExecute = false
}
};
process.Start();
process.WaitForExit();
return process.ExitCode;
}
catch (Exception ex)
{
Console.Error.WriteLine($"OCSentinel installer failed: {ex}");
return 1;
}
finally
{
try
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
catch
{
// Best-effort cleanup only.
}
}
}
private static void ExtractEmbeddedPayload(string destinationPath)
{
Assembly assembly = Assembly.GetExecutingAssembly();
using Stream? resourceStream = assembly.GetManifestResourceStream("payload.zip");
if (resourceStream is null)
{
throw new InvalidOperationException("Embedded payload.zip resource was not found.");
}
using FileStream output = File.Create(destinationPath);
resourceStream.CopyTo(output);
}
}

View File

@@ -26,9 +26,13 @@ Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-settings.example.json
if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.example.json")) { if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.example.json")) {
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.example.json") -Force Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.example.json") -Force
} }
if (Test-Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json")) {
Copy-Item -Path (Join-Path $packageRoot "config\ocsentinel-client.dev.example.json") -Destination (Join-Path $configRoot "ocsentinel-client.dev.example.json") -Force
}
Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force Copy-Item -Path (Join-Path $packageRoot "samples\ninja-vulnerability-export.example.csv") -Destination (Join-Path $samplesRoot "ninja-vulnerability-export.example.csv") -Force
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel.ps1") -Destination $scriptRoot -Force
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-monitor.ps1") -Destination $scriptRoot -Force
Copy-Item -Path (Join-Path $packageRoot "scripts\run-ocsentinel-scheduled.ps1") -Destination $scriptRoot -Force
if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) { if (Test-Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1")) {
Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force Copy-Item -Path (Join-Path $packageRoot "scripts\protect-ocsentinel-secret.ps1") -Destination $scriptRoot -Force
} }
@@ -66,8 +70,34 @@ Set-ItemProperty -Path $uninstallKey -Name "QuietUninstallString" -Value $uninst
Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord Set-ItemProperty -Path $uninstallKey -Name "NoModify" -Value 1 -Type DWord
Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord Set-ItemProperty -Path $uninstallKey -Name "NoRepair" -Value 1 -Type DWord
$scheduledScript = Join-Path $scriptRoot "run-ocsentinel-scheduled.ps1"
$taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -MultipleInstances IgnoreNew
# Spread fleet uploads across the early-morning window while keeping each device's slot stable.
$machineGuid = (Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Cryptography" -Name "MachineGuid").MachineGuid
$guidBytes = [Text.Encoding]::UTF8.GetBytes([string]$machineGuid)
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$slotHash = $sha256.ComputeHash($guidBytes)
}
finally {
$sha256.Dispose()
}
$dailySlotMinutes = [BitConverter]::ToUInt32($slotHash, 0) % 180
$dailyRunAt = (Get-Date -Hour 4 -Minute 0 -Second 0).AddMinutes($dailySlotMinutes)
$dailyAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind daily" -WorkingDirectory $scriptRoot
$dailyTrigger = New-ScheduledTaskTrigger -Daily -At $dailyRunAt
Register-ScheduledTask -TaskName "OCSentinel Daily Scan" -Action $dailyAction -Trigger $dailyTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel daily signed scan and upload." -Force | Out-Null
$burstAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scheduledScript`" -Kind burst" -WorkingDirectory $scriptRoot
$burstTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(2) -RepetitionInterval (New-TimeSpan -Minutes 5) -RepetitionDuration (New-TimeSpan -Days 3650)
Register-ScheduledTask -TaskName "OCSentinel Burst Check" -Action $burstAction -Trigger $burstTrigger -Principal $taskPrincipal -Settings $taskSettings -Description "OfficeCom Sentinel burst check; scans only when Ninja field ocsentinelburst is enabled." -Force | Out-Null
Write-Host "Installation complete." Write-Host "Installation complete."
Write-Host "Main path: $installRoot" Write-Host "Main path: $installRoot"
Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')" Write-Host "Runner: $(Join-Path $scriptRoot 'run-ocsentinel.ps1')"
Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')" Write-Host "Monitor: $(Join-Path $scriptRoot 'run-ocsentinel-monitor.ps1')"
Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')" Write-Host "Updater: $(Join-Path $scriptRoot 'update-ocsentinel.ps1')"
Write-Host "Schedule: Daily scan at $($dailyRunAt.ToString('HH:mm')) (deterministic 04:00-06:59 slot); burst check every 5 minutes."

View File

@@ -3,8 +3,13 @@ param(
[int]$TopCount = 10, [int]$TopCount = 10,
[string]$OutputPath = "..\reports\ocsentinel-summary.json", [string]$OutputPath = "..\reports\ocsentinel-summary.json",
[string]$ConfigPath = "..\config\ocsentinel-settings.json", [string]$ConfigPath = "..\config\ocsentinel-settings.json",
[string]$ClientConfigPath = "..\config\ocsentinel-client.json",
[string]$SecretPath = "",
[ValidateSet("disabled", "auto", "required")]
[string]$UploadMode = "auto",
[string]$VulnerabilityCsvPath = "", [string]$VulnerabilityCsvPath = "",
[string]$MirrorRoot = "", [string]$MirrorRoot = "",
[switch]$SuppressTriggerExit,
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")] [ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
[string]$Mode = "status" [string]$Mode = "status"
) )
@@ -42,8 +47,13 @@ function Initialize-NinjaFieldWriter {
return return
} }
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
$script:NinjaFieldBackend = "powershell-modern"
return
}
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) { if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
$script:NinjaFieldBackend = "powershell" $script:NinjaFieldBackend = "powershell-legacy"
return return
} }
@@ -60,14 +70,20 @@ function Set-NinjaCustomFieldValue {
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
[AllowEmptyString()] [AllowEmptyString()]
[string]$Value [object]$Value,
[Parameter(Mandatory)]
[string]$Type
) )
Initialize-NinjaFieldWriter Initialize-NinjaFieldWriter
switch ($script:NinjaFieldBackend) { switch ($script:NinjaFieldBackend) {
"powershell" { "powershell-modern" {
Ninja-Property-Set $Name $Value | Out-Null Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
return $true
}
"powershell-legacy" {
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
return $true return $true
} }
"cli" { "cli" {
@@ -108,28 +124,42 @@ function Publish-NinjaCustomFields {
} }
} }
$fieldValues = [ordered]@{ $uploadStatus = [string]$Report.Runtime.UploadStatus
"ocsentinelstatus" = [string]$Report.AlertState if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
"ocsentinelreason" = $Reason $queuedReports = [int]$Report.Runtime.QueuedReportCount
"ocsentinelbasestatus" = [string]$Report.BaseAlertState $lastUploadUtc = ""
"ocsentinelevents" = [string]([int]$Report.TotalEvents) if ($Report.Runtime.LastSuccessfulUploadUtc) {
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount) try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
"ocsentinelmode" = $Mode
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
"ocsentinellastscanutc" = $generatedAtUtc
} }
$lastUploadError = [string]$Report.Runtime.LastUploadError
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
$fieldValues = @(
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
)
$updated = 0 $updated = 0
foreach ($entry in $fieldValues.GetEnumerator()) { foreach ($entry in $fieldValues) {
try { try {
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) { if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
$updated++ $updated++
} }
} }
catch { catch {
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)" Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
} }
} }
@@ -142,9 +172,15 @@ $runnerArgs = @(
"-LookbackDays", $LookbackDays, "-LookbackDays", $LookbackDays,
"-TopCount", $TopCount, "-TopCount", $TopCount,
"-OutputPath", $OutputPath, "-OutputPath", $OutputPath,
"-ConfigPath", $ConfigPath "-ConfigPath", $ConfigPath,
"-ClientConfigPath", $ClientConfigPath,
"-UploadMode", $UploadMode
) )
if (-not [string]::IsNullOrWhiteSpace($SecretPath)) {
$runnerArgs += @("-SecretPath", $SecretPath)
}
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) { if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath) $runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
} }
@@ -168,6 +204,8 @@ $events = [int]$report.TotalEvents
$uniqueIps = [int]$report.UniqueIpCount $uniqueIps = [int]$report.UniqueIpCount
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount $criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount $totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
$uploadStatus = [string]$report.Runtime.UploadStatus
$queuedReports = [int]$report.Runtime.QueuedReportCount
$monitorTriggered = $false $monitorTriggered = $false
$monitorReason = "" $monitorReason = ""
@@ -203,11 +241,13 @@ Write-Host "Events: $events"
Write-Host "Unique IPs: $uniqueIps" Write-Host "Unique IPs: $uniqueIps"
Write-Host "Critical/High CVEs: $criticalCves" Write-Host "Critical/High CVEs: $criticalCves"
Write-Host "Total CVEs: $totalCves" Write-Host "Total CVEs: $totalCves"
Write-Host "Upload status: $uploadStatus"
Write-Host "Queued reports: $queuedReports"
Write-Host "Report: $outputFullPath" Write-Host "Report: $outputFullPath"
Write-Host "Runner exit code: $runnerExitCode" Write-Host "Runner exit code: $runnerExitCode"
if ($monitorTriggered) { if ($monitorTriggered -and -not $SuppressTriggerExit) {
exit 1 exit 1
} }
exit 0 exit $runnerExitCode

View File

@@ -0,0 +1,116 @@
[CmdletBinding()]
param(
[ValidateSet("daily", "burst")]
[string]$Kind = "daily",
[ValidateRange(15, 480)]
[int]$BurstDurationMinutes = 120
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
$mutexName = "Global\OfficeComSentinelScan"
function Get-NinjaBurstEnabled {
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
}
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
$value = Ninja-Property-Get -Name "ocsentinelburst"
return [string]$value -match "^(1|true|yes)$"
}
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
return $false
}
function Get-NinjaValue {
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
try {
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
return Get-NinjaProperty -Name $Name -Type $Type
}
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
return Ninja-Property-Get -Name $Name
}
}
catch {
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
}
return $null
}
function Set-NinjaValue {
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
try {
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
return $true
}
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
return $true
}
}
catch {
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
}
return $false
}
if ($Kind -eq "burst") {
if (-not (Get-NinjaBurstEnabled)) {
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
Write-Host "OfficeCom Sentinel burst check: disabled."
exit 0
}
$now = [DateTimeOffset]::UtcNow
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
$until = $null
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
}
if ($null -eq $until) {
$until = $now.AddMinutes($BurstDurationMinutes)
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
}
elseif ($until -le $now) {
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
exit 0
}
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
}
$createdNew = $false
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
try {
if (-not $mutex.WaitOne(0)) {
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
exit 0
}
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
if ($Kind -eq "burst") {
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
}
& powershell.exe @monitorArgs
exit $LASTEXITCODE
}
finally {
if ($null -ne $mutex) {
try { $mutex.ReleaseMutex() } catch { }
$mutex.Dispose()
}
}

View File

@@ -39,6 +39,40 @@ function Resolve-PathLike {
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue)) return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
} }
function Restore-NinjaContextFromClientConfiguration {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
try {
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$mappings = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($mapping in $mappings) {
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
continue
}
$value = [string]$clientConfiguration.($mapping.PropertyName)
if (-not [string]::IsNullOrWhiteSpace($value)) {
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
}
}
}
catch {
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
}
}
if (-not (Test-Path $appExe)) { if (-not (Test-Path $appExe)) {
throw "Application executable not found: $appExe" throw "Application executable not found: $appExe"
} }
@@ -54,6 +88,8 @@ else {
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir } $secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $scriptDir }
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath) $canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
if ($UploadMode -eq "required" -and -not $canUpload) { if ($UploadMode -eq "required" -and -not $canUpload) {
throw "UploadMode 'required' was set, but client config or protected secret is missing." throw "UploadMode 'required' was set, but client config or protected secret is missing."
} }

View File

@@ -5,6 +5,12 @@ $ErrorActionPreference = "Stop"
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel" $installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
$uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel" $uninstallKey = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\OCSentinel"
foreach ($taskName in @("OCSentinel Daily Scan", "OCSentinel Burst Check")) {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
}
if (Test-Path $uninstallKey) { if (Test-Path $uninstallKey) {
Remove-Item -Path $uninstallKey -Force -Recurse Remove-Item -Path $uninstallKey -Force -Recurse
} }

View File

@@ -7,6 +7,72 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
function Get-OCSentinelArtifact {
param(
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$DestinationPath
)
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
Invoke-WebRequest @parameters
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
throw "The downloaded artifact is empty."
}
return
}
catch {
if ($attempt -eq 3) {
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
}
Write-Warning "Package download attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
}
Initialize-OCSentinelTls
$installRoot = Join-Path ${env:ProgramFiles} "OCSentinel" $installRoot = Join-Path ${env:ProgramFiles} "OCSentinel"
$appExe = Join-Path $installRoot "app\OCSentinelCli.exe" $appExe = Join-Path $installRoot "app\OCSentinelCli.exe"
$installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1" $installScript = Join-Path $installRoot "scripts\install-ocsentinel.ps1"
@@ -30,15 +96,24 @@ function Compare-Version {
[Parameter(Mandatory)][string]$Right [Parameter(Mandatory)][string]$Right
) )
try { $pattern = '^(?<version>\d+(?:\.\d+){0,3})(?:-(?<prerelease>.+))?$'
$leftVersion = [System.Version]$Left $leftMatch = [regex]::Match($Left, $pattern)
$rightVersion = [System.Version]$Right $rightMatch = [regex]::Match($Right, $pattern)
return $leftVersion.CompareTo($rightVersion) if ($leftMatch.Success -and $rightMatch.Success) {
$numericComparison = ([System.Version]$leftMatch.Groups['version'].Value).CompareTo([System.Version]$rightMatch.Groups['version'].Value)
if ($numericComparison -ne 0) {
return $numericComparison
} }
catch {
$leftPrerelease = $leftMatch.Groups['prerelease'].Value
$rightPrerelease = $rightMatch.Groups['prerelease'].Value
if ([string]::IsNullOrWhiteSpace($leftPrerelease) -and -not [string]::IsNullOrWhiteSpace($rightPrerelease)) { return 1 }
if (-not [string]::IsNullOrWhiteSpace($leftPrerelease) -and [string]::IsNullOrWhiteSpace($rightPrerelease)) { return -1 }
return [string]::Compare($leftPrerelease, $rightPrerelease, $true)
}
return [string]::Compare($Left, $Right, $true) return [string]::Compare($Left, $Right, $true)
} }
}
function Get-Sha256Hex { function Get-Sha256Hex {
param([Parameter(Mandatory)][string]$Path) param([Parameter(Mandatory)][string]$Path)
@@ -66,7 +141,7 @@ if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
$resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel $resolvedManifestUrl = Resolve-ManifestUrl -ManifestUrl $ManifestUrl -Channel $Channel
Write-Host "Checking update manifest: $resolvedManifestUrl" Write-Host "Checking update manifest: $resolvedManifestUrl"
$manifest = Invoke-RestMethod -Method Get -Uri $resolvedManifestUrl -TimeoutSec 60 $manifest = Get-OCSentinelManifest -Uri $resolvedManifestUrl
if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) { if (-not $manifest.version -or -not $manifest.artifactUrl -or -not $manifest.sha256) {
throw "Update manifest is missing required fields: version, artifactUrl, sha256." throw "Update manifest is missing required fields: version, artifactUrl, sha256."
} }
@@ -89,7 +164,7 @@ $extractRoot = Join-Path $downloadRoot "payload"
New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null New-Item -ItemType Directory -Force -Path $downloadRoot, $extractRoot | Out-Null
Write-Host "Downloading artifact: $($manifest.artifactUrl)" Write-Host "Downloading artifact: $($manifest.artifactUrl)"
Invoke-WebRequest -Uri ([string]$manifest.artifactUrl) -OutFile $zipPath -TimeoutSec 300 Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
$actualHash = Get-Sha256Hex -Path $zipPath $actualHash = Get-Sha256Hex -Path $zipPath
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant() $expectedHash = ([string]$manifest.sha256).ToLowerInvariant()

5
release/beta/README.md Normal file
View File

@@ -0,0 +1,5 @@
# OCSentinel Beta Channel
This directory contains the current beta `version.json` only after a tested
pre-release has been published. Pilot devices use this channel; stable devices
continue to use `release/stable/version.json`.

View File

@@ -0,0 +1,8 @@
{
"channel": "beta",
"version": "1.5.0-beta.7",
"publishedAtUtc": "2026-08-02T23:20:59.4454466Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.5.0-beta.7/OCSentinelClient-win-x64.zip",
"sha256": "b40da61499d0d9b5f95922f84aebbb3b6de6bf8a2ba2196873ecf2fab1789c0d",
"minUpdaterVersion": "1.0.0"
}

View File

@@ -0,0 +1,8 @@
{
"channel": "stable",
"version": "1.4.0",
"publishedAtUtc": "2026-07-28T22:50:17.7779552Z",
"artifactUrl": "https://gitea.officecom.cloud/officecom/oc-sentinel/releases/download/v1.4.0/OCSentinelClient-win-x64.zip?asset-revision=ece66505c4146fec",
"sha256": "ece66505c4146fec72ba12cb59d3d0a39bb91a3aee44e338d19c186e56e2fad7",
"minUpdaterVersion": "1.0.0"
}

View File

@@ -0,0 +1,300 @@
[CmdletBinding()]
param(
[string]$ManifestUrl = "",
[ValidateSet("stable", "beta")]
[string]$ReleaseChannel = "stable",
[string]$WebhookUrl = "",
[string]$SecretValue = "",
[switch]$RunInitialStatusScan
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
function Invoke-OCSentinelUpdater {
param(
[Parameter(Mandatory)][string]$UpdaterPath,
[Parameter(Mandatory)][string]$ManifestUri
)
# Existing clients can still contain an older updater without TLS setup.
# Start it in a prepared child process so it can download the current package.
$escapedUpdaterPath = $UpdaterPath.Replace("'", "''")
$escapedManifestUri = $ManifestUri.Replace("'", "''")
$command = @"
`$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') {
`$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = `$protocols
[Net.ServicePointManager]::Expect100Continue = `$false
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUri'
exit `$LASTEXITCODE
"@
for ($attempt = 1; $attempt -le 3; $attempt++) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $command | ForEach-Object { Write-Host $_ }
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
return
}
if ($attempt -lt 3) {
Write-Warning "OCSentinel update attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
throw "OCSentinel updater exited with code $exitCode after 3 attempts."
}
function Get-OCSentinelArtifact {
param(
[Parameter(Mandatory)][string]$Uri,
[Parameter(Mandatory)][string]$DestinationPath
)
$parameters = @{ Uri = $Uri; OutFile = $DestinationPath; TimeoutSec = 300 }
if ((Get-Command Invoke-WebRequest).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $DestinationPath -Force -ErrorAction SilentlyContinue
Invoke-WebRequest @parameters
if (-not (Test-Path -LiteralPath $DestinationPath) -or (Get-Item -LiteralPath $DestinationPath).Length -eq 0) {
throw "The downloaded artifact is empty."
}
return
}
catch {
if ($attempt -eq 3) {
throw "Could not download the OCSentinel package after 3 attempts. Last error: $($_.Exception.Message)"
}
Write-Warning "Package download attempt $attempt failed. Retrying."
Start-Sleep -Seconds (5 * $attempt)
}
}
}
function Enable-OCSentinelBetaDefaults {
param([Parameter(Mandatory)][string]$SettingsPath)
if (-not (Test-Path -LiteralPath $SettingsPath)) {
return
}
$settings = Get-Content -LiteralPath $SettingsPath -Raw | ConvertFrom-Json
if ($null -ne $settings.PSObject.Properties["ransomwareBetaDefaultApplied"]) {
return
}
if ($null -eq $settings.PSObject.Properties["ransomwareBetaEnabled"]) {
$settings | Add-Member -NotePropertyName "ransomwareBetaEnabled" -NotePropertyValue $true
}
else {
$settings.ransomwareBetaEnabled = $true
}
$settings | Add-Member -NotePropertyName "ransomwareBetaDefaultApplied" -NotePropertyValue "1.5.0-beta.4"
$settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsPath -Encoding UTF8
Write-Host "Enabled passive ransomware beta defaults."
}
Initialize-OCSentinelTls
if ($ReleaseChannel -eq "stable" -and -not [string]::IsNullOrWhiteSpace($env:ReleaseChannel)) {
$requestedChannel = $env:ReleaseChannel.Trim().ToLowerInvariant()
if ($requestedChannel -notin @("stable", "beta")) {
throw "ReleaseChannel must be stable or beta."
}
$ReleaseChannel = $requestedChannel
}
if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/$ReleaseChannel/version.json"
}
Write-Host "OCSentinel release channel: $ReleaseChannel"
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
$appPath = Join-Path $installRoot "app\OCSentinelCli.exe"
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
$settingsPath = Join-Path $installRoot "config\ocsentinel-settings.json"
$secretScriptPath = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
# NinjaOne script variables are exposed as process environment variables.
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
$WebhookUrl = $env:WebhookUrl
}
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
$SecretValue = $env:SecretValue
}
$runInitialScan = $RunInitialStatusScan.IsPresent
if (-not $runInitialScan -and -not [string]::IsNullOrWhiteSpace($env:RunInitialStatusScan)) {
$runInitialScan = $env:RunInitialStatusScan -match '^(1|true|yes|on)$'
}
function Assert-ArtifactSignature {
param([Parameter(Mandatory)][string]$ExecutablePath)
$signature = Get-AuthenticodeSignature -FilePath $ExecutablePath
if ($signature.Status -notin @("Valid", "NotSigned")) {
throw "Executable signature validation failed with status: $($signature.Status)"
}
if ($signature.Status -eq "NotSigned") {
Write-Warning "The package hash was verified, but OCSentinelCli.exe is not code-signed yet."
}
}
if (Test-Path -LiteralPath $updaterPath) {
Write-Host "Existing OCSentinel installation found. Checking for updates."
Invoke-OCSentinelUpdater -UpdaterPath $updaterPath -ManifestUri $ManifestUrl
}
else {
Write-Host "Reading OCSentinel release manifest: $ManifestUrl"
$manifest = Get-OCSentinelManifest -Uri $ManifestUrl
if ([string]::IsNullOrWhiteSpace($manifest.version) -or [string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
throw "Release manifest is missing version, artifactUrl, or sha256."
}
$downloadRoot = Join-Path $env:ProgramData ("OCSentinel\\bootstrap\\" + [Guid]::NewGuid().ToString("N"))
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
$extractRoot = Join-Path $downloadRoot "payload"
try {
New-Item -ItemType Directory -Force -Path $extractRoot | Out-Null
Write-Host "Downloading OCSentinel $($manifest.version)"
Get-OCSentinelArtifact -Uri ([string]$manifest.artifactUrl) -DestinationPath $zipPath
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
$expectedHash = ([string]$manifest.sha256).ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw "SHA-256 mismatch for the downloaded OCSentinel package."
}
Write-Host "Package hash verified. Extracting release payload."
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractRoot -Force
$payloadApp = Get-ChildItem -Path $extractRoot -Recurse -Filter "OCSentinelCli.exe" | Select-Object -First 1
$installer = Get-ChildItem -Path $extractRoot -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
if ($null -eq $payloadApp -or $null -eq $installer) {
throw "The downloaded package is incomplete."
}
Assert-ArtifactSignature -ExecutablePath $payloadApp.FullName
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer.FullName
if ($LASTEXITCODE -ne 0) {
throw "OCSentinel installer exited with code $LASTEXITCODE"
}
}
finally {
if (Test-Path -LiteralPath $downloadRoot) {
Remove-Item -LiteralPath $downloadRoot -Recurse -Force
}
}
}
if (-not (Test-Path -LiteralPath $appPath)) {
throw "OCSentinel installation completed, but the client executable was not found."
}
if ($ReleaseChannel -eq "beta") {
Enable-OCSentinelBetaDefaults -SettingsPath $settingsPath
}
if (-not [string]::IsNullOrWhiteSpace($WebhookUrl)) {
if (-not (Test-Path -LiteralPath $clientConfigPath)) {
throw "OCSentinel client configuration was not found: $clientConfigPath"
}
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
$clientConfig.n8nWebhookUrl = $WebhookUrl
$clientConfig.environment = "production"
$ninjaContext = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($entry in $ninjaContext) {
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
if (-not [string]::IsNullOrWhiteSpace($value)) {
$clientConfig | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
}
}
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
Write-Host "Configured OCSentinel upload endpoint and NinjaOne context."
}
if (-not [string]::IsNullOrWhiteSpace($SecretValue)) {
if (-not (Test-Path -LiteralPath $secretScriptPath)) {
throw "OCSentinel secret bootstrap script was not found: $secretScriptPath"
}
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScriptPath -SecretValue $SecretValue
if ($LASTEXITCODE -ne 0) {
throw "OCSentinel secret bootstrap failed with code $LASTEXITCODE"
}
}
if ($runInitialScan) {
if (-not (Test-Path -LiteralPath $monitorPath)) {
throw "OCSentinel was installed, but the monitor script is missing."
}
Write-Host "Running initial OCSentinel status scan."
$scanArguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorPath, "-Mode", "status", "-OutputPath", "..\\reports\\ocsentinel-summary.json")
if ((Test-Path -LiteralPath $clientConfigPath) -and (Test-Path -LiteralPath $secretPath)) {
$scanArguments += @("-ClientConfigPath", $clientConfigPath, "-SecretPath", $secretPath, "-UploadMode", "required")
}
& powershell.exe @scanArguments
if ($LASTEXITCODE -ne 0) {
throw "Initial OCSentinel status scan exited with code $LASTEXITCODE"
}
}
Write-Host "OCSentinel bootstrap completed successfully."

View File

@@ -0,0 +1,76 @@
[CmdletBinding()]
param(
[string]$WebhookUrl = "",
[string]$SecretValue = ""
)
$ErrorActionPreference = "Stop"
function Get-NinjaValue {
param([Parameter(Mandatory)][string]$Name)
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
if ($null -eq $value) {
return ""
}
return $value.Trim()
}
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) {
$WebhookUrl = Get-NinjaValue -Name "webhookurl"
}
if ([string]::IsNullOrWhiteSpace($SecretValue)) {
$SecretValue = Get-NinjaValue -Name "secretvalue"
}
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
throw "WebhookUrl and SecretValue must be supplied as NinjaOne script variables."
}
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
foreach ($path in @($configPath, $secretScript, $monitorScript)) {
if (-not (Test-Path -LiteralPath $path)) {
throw "OCSentinel installation is incomplete. Missing: $path"
}
}
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
$config.n8nWebhookUrl = $WebhookUrl
$config.environment = "production"
$ninjaContext = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($entry in $ninjaContext) {
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
if (-not [string]::IsNullOrWhiteSpace($value)) {
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
}
}
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
Write-Host "OCSentinel upload endpoint configured."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
if ($LASTEXITCODE -ne 0) {
throw "Writing the protected upload secret failed with code $LASTEXITCODE"
}
Write-Host "Running signed OCSentinel test scan and upload."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript `
-Mode status `
-ClientConfigPath $configPath `
-SecretPath $secretPath `
-UploadMode required
exit $LASTEXITCODE

View File

@@ -0,0 +1,120 @@
[CmdletBinding()]
param(
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json",
[string]$WebhookUrl = "",
[string]$SecretValue = ""
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Get-OCSentinelManifest {
param([Parameter(Mandatory)][string]$Uri)
$parameters = @{ Method = "Get"; Uri = $Uri; TimeoutSec = 60 }
if ((Get-Command Invoke-RestMethod).Parameters.ContainsKey("UseBasicParsing")) {
$parameters.UseBasicParsing = $true
}
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
return Invoke-RestMethod @parameters
}
catch {
if ($attempt -eq 3) {
throw "Could not retrieve the OCSentinel release manifest after 3 attempts. Verify that the device can reach gitea.officecom.cloud with TLS 1.2 or newer. Last error: $($_.Exception.Message)"
}
Start-Sleep -Seconds (3 * $attempt)
}
}
}
Initialize-OCSentinelTls
function Get-NinjaValue {
param([Parameter(Mandatory)][string]$Name)
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
if ($null -eq $value) {
return ""
}
return $value.Trim()
}
if ([string]::IsNullOrWhiteSpace($WebhookUrl)) { $WebhookUrl = Get-NinjaValue -Name "webhookurl" }
if ([string]::IsNullOrWhiteSpace($SecretValue)) { $SecretValue = Get-NinjaValue -Name "secretvalue" }
if ([string]::IsNullOrWhiteSpace($WebhookUrl) -or [string]::IsNullOrWhiteSpace($SecretValue)) {
throw "WebhookUrl and SecretValue must be set as NinjaOne script variables."
}
$manifest = Get-OCSentinelManifest -Uri $ManifestUrl
if ([string]::IsNullOrWhiteSpace($manifest.artifactUrl) -or [string]::IsNullOrWhiteSpace($manifest.sha256)) {
throw "The release manifest is incomplete."
}
$downloadRoot = Join-Path $env:ProgramData ("OCSentinel\\install-" + [Guid]::NewGuid().ToString("N"))
$zipPath = Join-Path $downloadRoot "OCSentinelClient.zip"
$extractPath = Join-Path $downloadRoot "payload"
try {
New-Item -ItemType Directory -Force -Path $extractPath | Out-Null
Write-Host "Downloading OCSentinel $($manifest.version)."
Invoke-WebRequest -Uri $manifest.artifactUrl -OutFile $zipPath -TimeoutSec 300
$actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne ([string]$manifest.sha256).ToLowerInvariant()) {
throw "Release package SHA-256 validation failed."
}
Expand-Archive -LiteralPath $zipPath -DestinationPath $extractPath -Force
$installer = Get-ChildItem -Path $extractPath -Recurse -Filter "install-ocsentinel.ps1" | Select-Object -First 1
if ($null -eq $installer) { throw "The release package does not contain the installer." }
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer.FullName
if ($LASTEXITCODE -ne 0) { throw "Installer failed with code $LASTEXITCODE" }
}
finally {
if (Test-Path -LiteralPath $downloadRoot) { Remove-Item -LiteralPath $downloadRoot -Recurse -Force }
}
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
$configPath = Join-Path $installRoot "config\ocsentinel-client.json"
$secretScript = Join-Path $installRoot "scripts\protect-ocsentinel-secret.ps1"
$monitorScript = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
$config.n8nWebhookUrl = $WebhookUrl
$config.environment = "production"
$ninjaContext = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($entry in $ninjaContext) {
$value = [Environment]::GetEnvironmentVariable($entry.EnvironmentName, "Process")
if (-not [string]::IsNullOrWhiteSpace($value)) {
$config | Add-Member -NotePropertyName $entry.PropertyName -NotePropertyValue $value.Trim() -Force
}
}
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $secretScript -SecretValue $SecretValue
if ($LASTEXITCODE -ne 0) { throw "Writing the protected upload secret failed with code $LASTEXITCODE" }
Write-Host "Running initial signed scan and upload."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorScript -Mode status -ClientConfigPath $configPath -SecretPath $secretPath -UploadMode required
exit $LASTEXITCODE

View File

@@ -0,0 +1,110 @@
[CmdletBinding()]
param(
[string]$ManifestUrl = "https://gitea.officecom.cloud/officecom/oc-sentinel/raw/main/release/stable/version.json"
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Initialize-OCSentinelTls {
$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains "Tls13") {
$protocols = $protocols -bor [Net.SecurityProtocolType]::Tls13
}
[Net.ServicePointManager]::SecurityProtocol = $protocols
[Net.ServicePointManager]::Expect100Continue = $false
}
function Read-NinjaEnvironmentValue {
param([Parameter(Mandatory)][string]$Name)
$value = [Environment]::GetEnvironmentVariable($Name, "Process")
if ($null -eq $value) {
return ""
}
return $value.Trim()
}
Initialize-OCSentinelTls
$installRoot = Join-Path $env:ProgramFiles "OCSentinel"
$updaterPath = Join-Path $installRoot "scripts\update-ocsentinel.ps1"
$monitorPath = Join-Path $installRoot "scripts\run-ocsentinel-monitor.ps1"
$clientConfigPath = Join-Path $installRoot "config\ocsentinel-client.json"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
foreach ($path in @($updaterPath, $clientConfigPath, $monitorPath)) {
if (-not (Test-Path -LiteralPath $path)) {
throw "OCSentinel installation is incomplete. Missing: $path"
}
}
# The NinjaOne context exists only during this script execution. Upgrade first so
# future scheduled scans restore the context from the local client configuration.
$escapedUpdaterPath = $updaterPath.Replace("'", "''")
$escapedManifestUrl = $ManifestUrl.Replace("'", "''")
$updateCommand = @"
`$protocols = [Net.SecurityProtocolType]::Tls12
if ([Enum]::GetNames([Net.SecurityProtocolType]) -contains 'Tls13') { `$protocols = `$protocols -bor [Net.SecurityProtocolType]::Tls13 }
[Net.ServicePointManager]::SecurityProtocol = `$protocols
[Net.ServicePointManager]::Expect100Continue = `$false
& '$escapedUpdaterPath' -ManifestUrl '$escapedManifestUrl'
exit `$LASTEXITCODE
"@
& powershell.exe -NoProfile -ExecutionPolicy Bypass -Command $updateCommand | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -ne 0) {
throw "OCSentinel updater exited with code $LASTEXITCODE"
}
$mappings = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId"; Required = $true },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName"; Required = $true },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId"; Required = $true },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId"; Required = $false },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId"; Required = $false },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName"; Required = $false }
)
$clientConfig = Get-Content -LiteralPath $clientConfigPath -Raw | ConvertFrom-Json
$missing = @()
$captured = 0
foreach ($mapping in $mappings) {
$value = Read-NinjaEnvironmentValue -Name $mapping.EnvironmentName
if ([string]::IsNullOrWhiteSpace($value)) {
if ($mapping.Required) { $missing += $mapping.EnvironmentName }
continue
}
$clientConfig | Add-Member -NotePropertyName $mapping.PropertyName -NotePropertyValue $value -Force
$captured++
}
if ($missing.Count -gt 0) {
throw "NinjaOne did not provide required context: $($missing -join ', '). Run this only from a NinjaOne automation, not from an interactive PowerShell session."
}
$clientConfig | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $clientConfigPath -Encoding UTF8
Write-Host "OCSentinel NinjaOne context captured: $captured of $($mappings.Count) values."
if ([string]::IsNullOrWhiteSpace([string]$clientConfig.n8nWebhookUrl)) {
throw "NinjaOne context was stored, but this client has no configured n8n webhook URL. Run the OCSentinel installation/configuration automation with its WebhookUrl variable first."
}
if (-not (Test-Path -LiteralPath $secretPath)) {
throw "NinjaOne context was stored, but the protected upload secret is missing. Run the OCSentinel installation/configuration automation with its SecretValue variable first."
}
Write-Host "Running an immediate status scan and upload with the refreshed NinjaOne context."
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitorPath `
-Mode status `
-ClientConfigPath $clientConfigPath `
-SecretPath $secretPath `
-UploadMode required `
-SuppressTriggerExit
if ($LASTEXITCODE -ne 0) {
throw "OCSentinel context refresh scan exited with code $LASTEXITCODE"
}
Write-Host "OCSENTINEL_NINJA_CONTEXT=updated"

View File

@@ -3,8 +3,13 @@ param(
[int]$TopCount = 10, [int]$TopCount = 10,
[string]$OutputPath = ".\reports\ocsentinel-summary.json", [string]$OutputPath = ".\reports\ocsentinel-summary.json",
[string]$ConfigPath = ".\config\ocsentinel-settings.example.json", [string]$ConfigPath = ".\config\ocsentinel-settings.example.json",
[string]$ClientConfigPath = ".\config\ocsentinel-client.json",
[string]$SecretPath = "",
[ValidateSet("disabled", "auto", "required")]
[string]$UploadMode = "auto",
[string]$VulnerabilityCsvPath = "", [string]$VulnerabilityCsvPath = "",
[string]$MirrorRoot = "", [string]$MirrorRoot = "",
[switch]$SuppressTriggerExit,
[ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")] [ValidateSet("status", "attack-only", "cve-critical", "attack-plus-cve")]
[string]$Mode = "status" [string]$Mode = "status"
) )
@@ -42,8 +47,13 @@ function Initialize-NinjaFieldWriter {
return return
} }
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
$script:NinjaFieldBackend = "powershell-modern"
return
}
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) { if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
$script:NinjaFieldBackend = "powershell" $script:NinjaFieldBackend = "powershell-legacy"
return return
} }
@@ -60,14 +70,20 @@ function Set-NinjaCustomFieldValue {
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
[AllowEmptyString()] [AllowEmptyString()]
[string]$Value [object]$Value,
[Parameter(Mandatory)]
[string]$Type
) )
Initialize-NinjaFieldWriter Initialize-NinjaFieldWriter
switch ($script:NinjaFieldBackend) { switch ($script:NinjaFieldBackend) {
"powershell" { "powershell-modern" {
Ninja-Property-Set $Name $Value | Out-Null Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
return $true
}
"powershell-legacy" {
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
return $true return $true
} }
"cli" { "cli" {
@@ -108,28 +124,42 @@ function Publish-NinjaCustomFields {
} }
} }
$fieldValues = [ordered]@{ $uploadStatus = [string]$Report.Runtime.UploadStatus
"ocsentinelstatus" = [string]$Report.AlertState if ([string]::IsNullOrWhiteSpace($uploadStatus)) { $uploadStatus = "unknown" }
"ocsentinelreason" = $Reason $queuedReports = [int]$Report.Runtime.QueuedReportCount
"ocsentinelbasestatus" = [string]$Report.BaseAlertState $lastUploadUtc = ""
"ocsentinelevents" = [string]([int]$Report.TotalEvents) if ($Report.Runtime.LastSuccessfulUploadUtc) {
"ocsentineluniqueips" = [string]([int]$Report.UniqueIpCount) try { $lastUploadUtc = ([DateTimeOffset]$Report.Runtime.LastSuccessfulUploadUtc).ToUniversalTime().ToString("o") } catch { $lastUploadUtc = [string]$Report.Runtime.LastSuccessfulUploadUtc }
"ocsentinelcvecritical" = [string]([int]$Report.VulnerabilityCorrelation.CriticalCount)
"ocsentinelcvetotal" = [string]([int]$Report.VulnerabilityCorrelation.TotalCount)
"ocsentinelmode" = $Mode
"ocsentineltriggered" = $Triggered.ToString().ToLowerInvariant()
"ocsentinellastscanutc" = $generatedAtUtc
} }
$lastUploadError = [string]$Report.Runtime.LastUploadError
if ($lastUploadError.Length -gt 900) { $lastUploadError = $lastUploadError.Substring(0, 900) }
$fieldValues = @(
[pscustomobject]@{ Name = "ocsentinelstatus"; Type = "Text"; Value = [string]$Report.AlertState }
[pscustomobject]@{ Name = "ocsentinelreason"; Type = "Text"; Value = $Reason }
[pscustomobject]@{ Name = "ocsentinelbasestatus"; Type = "Text"; Value = [string]$Report.BaseAlertState }
[pscustomobject]@{ Name = "ocsentinelevents"; Type = "Integer"; Value = [int]$Report.TotalEvents }
[pscustomobject]@{ Name = "ocsentineluniqueips"; Type = "Integer"; Value = [int]$Report.UniqueIpCount }
[pscustomobject]@{ Name = "ocsentinelcvecritical"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.CriticalCount }
[pscustomobject]@{ Name = "ocsentinelcvetotal"; Type = "Integer"; Value = [int]$Report.VulnerabilityCorrelation.TotalCount }
[pscustomobject]@{ Name = "ocsentinelmode"; Type = "Text"; Value = $Mode }
[pscustomobject]@{ Name = "ocsentineltriggered"; Type = "Checkbox"; Value = $Triggered }
[pscustomobject]@{ Name = "ocsentinellastscanutc"; Type = "DateTime"; Value = $generatedAtUtc }
[pscustomobject]@{ Name = "ocsentineluploadstatus"; Type = "Text"; Value = $uploadStatus }
[pscustomobject]@{ Name = "ocsentinelqueuedreports"; Type = "Integer"; Value = $queuedReports }
[pscustomobject]@{ Name = "ocsentinellastuploadutc"; Type = "DateTime"; Value = $lastUploadUtc }
[pscustomobject]@{ Name = "ocsentinellasterror"; Type = "Text"; Value = $lastUploadError }
)
$updated = 0 $updated = 0
foreach ($entry in $fieldValues.GetEnumerator()) { foreach ($entry in $fieldValues) {
try { try {
if (Set-NinjaCustomFieldValue -Name $entry.Key -Value $entry.Value) { if (Set-NinjaCustomFieldValue -Name $entry.Name -Value $entry.Value -Type $entry.Type) {
$updated++ $updated++
} }
} }
catch { catch {
Write-Warning "Failed to set Ninja custom field '$($entry.Key)': $($_.Exception.Message)" Write-Warning "Failed to set Ninja custom field '$($entry.Name)': $($_.Exception.Message)"
} }
} }
@@ -142,9 +172,15 @@ $runnerArgs = @(
"-LookbackDays", $LookbackDays, "-LookbackDays", $LookbackDays,
"-TopCount", $TopCount, "-TopCount", $TopCount,
"-OutputPath", $OutputPath, "-OutputPath", $OutputPath,
"-ConfigPath", $ConfigPath "-ConfigPath", $ConfigPath,
"-ClientConfigPath", $ClientConfigPath,
"-UploadMode", $UploadMode
) )
if (-not [string]::IsNullOrWhiteSpace($SecretPath)) {
$runnerArgs += @("-SecretPath", $SecretPath)
}
if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) { if (-not [string]::IsNullOrWhiteSpace($VulnerabilityCsvPath)) {
$runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath) $runnerArgs += @("-VulnerabilityCsvPath", $VulnerabilityCsvPath)
} }
@@ -168,6 +204,8 @@ $events = [int]$report.TotalEvents
$uniqueIps = [int]$report.UniqueIpCount $uniqueIps = [int]$report.UniqueIpCount
$criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount $criticalCves = [int]$report.VulnerabilityCorrelation.CriticalCount
$totalCves = [int]$report.VulnerabilityCorrelation.TotalCount $totalCves = [int]$report.VulnerabilityCorrelation.TotalCount
$uploadStatus = [string]$report.Runtime.UploadStatus
$queuedReports = [int]$report.Runtime.QueuedReportCount
$monitorTriggered = $false $monitorTriggered = $false
$monitorReason = "" $monitorReason = ""
@@ -203,11 +241,13 @@ Write-Host "Events: $events"
Write-Host "Unique IPs: $uniqueIps" Write-Host "Unique IPs: $uniqueIps"
Write-Host "Critical/High CVEs: $criticalCves" Write-Host "Critical/High CVEs: $criticalCves"
Write-Host "Total CVEs: $totalCves" Write-Host "Total CVEs: $totalCves"
Write-Host "Upload status: $uploadStatus"
Write-Host "Queued reports: $queuedReports"
Write-Host "Report: $outputFullPath" Write-Host "Report: $outputFullPath"
Write-Host "Runner exit code: $runnerExitCode" Write-Host "Runner exit code: $runnerExitCode"
if ($monitorTriggered) { if ($monitorTriggered -and -not $SuppressTriggerExit) {
exit 1 exit 1
} }
exit 0 exit $runnerExitCode

View File

@@ -0,0 +1,116 @@
[CmdletBinding()]
param(
[ValidateSet("daily", "burst")]
[string]$Kind = "daily",
[ValidateRange(15, 480)]
[int]$BurstDurationMinutes = 120
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$monitorScript = Join-Path $scriptDir "run-ocsentinel-monitor.ps1"
$secretPath = "C:\ProgramData\OCSentinel\secrets\ocsentinel-upload-secret.dat"
$mutexName = "Global\OfficeComSentinelScan"
function Get-NinjaBurstEnabled {
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
return [bool](Get-NinjaProperty -Name "ocsentinelburst" -Type "Checkbox")
}
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
$value = Ninja-Property-Get -Name "ocsentinelburst"
return [string]$value -match "^(1|true|yes)$"
}
Write-Warning "Ninja custom-field reader is unavailable; burst scan skipped."
return $false
}
function Get-NinjaValue {
param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Type)
try {
if (Get-Command -Name "Get-NinjaProperty" -ErrorAction SilentlyContinue) {
return Get-NinjaProperty -Name $Name -Type $Type
}
if (Get-Command -Name "Ninja-Property-Get" -ErrorAction SilentlyContinue) {
return Ninja-Property-Get -Name $Name
}
}
catch {
Write-Warning "Could not read Ninja field '$Name': $($_.Exception.Message)"
}
return $null
}
function Set-NinjaValue {
param([Parameter(Mandatory)][string]$Name, [AllowEmptyString()][string]$Value, [Parameter(Mandatory)][string]$Type)
try {
if (Get-Command -Name "Set-NinjaProperty" -ErrorAction SilentlyContinue) {
Set-NinjaProperty -Name $Name -Value $Value -Type $Type -Force | Out-Null
return $true
}
if (Get-Command -Name "Ninja-Property-Set" -ErrorAction SilentlyContinue) {
Ninja-Property-Set -Name $Name -Value $Value | Out-Null
return $true
}
}
catch {
Write-Warning "Could not update Ninja field '$Name': $($_.Exception.Message)"
}
return $false
}
if ($Kind -eq "burst") {
if (-not (Get-NinjaBurstEnabled)) {
Set-NinjaValue -Name "ocsentinelburststatus" -Value "idle" -Type "Text" | Out-Null
Write-Host "OfficeCom Sentinel burst check: disabled."
exit 0
}
$now = [DateTimeOffset]::UtcNow
$untilValue = Get-NinjaValue -Name "ocsentinelburstuntilutc" -Type "DateTime"
$until = $null
if (-not [string]::IsNullOrWhiteSpace([string]$untilValue)) {
try { $until = [DateTimeOffset]$untilValue } catch { Write-Warning "Burst end time is invalid and will be restarted." }
}
if ($null -eq $until) {
$until = $now.AddMinutes($BurstDurationMinutes)
Set-NinjaValue -Name "ocsentinelburstuntilutc" -Value $until.ToString("o") -Type "DateTime" | Out-Null
Write-Host "OfficeCom Sentinel burst window started until $($until.ToString('u'))."
}
elseif ($until -le $now) {
Set-NinjaValue -Name "ocsentinelburst" -Value "false" -Type "Checkbox" | Out-Null
Set-NinjaValue -Name "ocsentinelburststatus" -Value "completed" -Type "Text" | Out-Null
Write-Host "OfficeCom Sentinel burst window completed and was disabled."
exit 0
}
Set-NinjaValue -Name "ocsentinelburststatus" -Value "active until $($until.ToUniversalTime().ToString('o'))" -Type "Text" | Out-Null
}
$createdNew = $false
$mutex = [Threading.Mutex]::new($false, $mutexName, [ref]$createdNew)
try {
if (-not $mutex.WaitOne(0)) {
Write-Host "OfficeCom Sentinel scan skipped: another scan is already running."
exit 0
}
Write-Host "OfficeCom Sentinel scheduled $Kind scan started."
$monitorArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $monitorScript, "-Mode", "status", "-UploadMode", "required", "-SecretPath", $secretPath, "-SuppressTriggerExit")
if ($Kind -eq "burst") {
$monitorArgs += @("-LookbackDays", "1", "-TopCount", "25")
}
& powershell.exe @monitorArgs
exit $LASTEXITCODE
}
finally {
if ($null -ne $mutex) {
try { $mutex.ReleaseMutex() } catch { }
$mutex.Dispose()
}
}

View File

@@ -40,6 +40,40 @@ function Resolve-PathLike {
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue)) return [System.IO.Path]::GetFullPath((Join-Path $BasePath $PathValue))
} }
function Restore-NinjaContextFromClientConfiguration {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
try {
$clientConfiguration = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$mappings = @(
@{ EnvironmentName = "NINJA_ORGANIZATION_ID"; PropertyName = "ninjaOrganizationId" },
@{ EnvironmentName = "NINJA_ORGANIZATION_NAME"; PropertyName = "ninjaOrganizationName" },
@{ EnvironmentName = "NINJA_AGENT_MACHINE_ID"; PropertyName = "ninjaMachineId" },
@{ EnvironmentName = "NINJA_AGENT_NODE_ID"; PropertyName = "ninjaNodeId" },
@{ EnvironmentName = "NINJA_LOCATION_ID"; PropertyName = "ninjaLocationId" },
@{ EnvironmentName = "NINJA_LOCATION_NAME"; PropertyName = "ninjaLocationName" }
)
foreach ($mapping in $mappings) {
if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($mapping.EnvironmentName, "Process"))) {
continue
}
$value = [string]$clientConfiguration.($mapping.PropertyName)
if (-not [string]::IsNullOrWhiteSpace($value)) {
[Environment]::SetEnvironmentVariable($mapping.EnvironmentName, $value, "Process")
}
}
}
catch {
Write-Warning "Could not restore stored NinjaOne context: $($_.Exception.Message)"
}
}
$arguments = @( $arguments = @(
$dllPath $dllPath
) )
@@ -53,6 +87,8 @@ else {
$secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot } $secretFullPath = if ([string]::IsNullOrWhiteSpace($SecretPath)) { "" } else { Resolve-PathLike -PathValue $SecretPath -BasePath $repoRoot }
$canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath) $canUpload = (Test-Path $clientConfigFullPath) -and (-not [string]::IsNullOrWhiteSpace($secretFullPath)) -and (Test-Path $secretFullPath)
Restore-NinjaContextFromClientConfiguration -Path $clientConfigFullPath
if ($UploadMode -eq "required" -and -not $canUpload) { if ($UploadMode -eq "required" -and -not $canUpload) {
throw "UploadMode 'required' was set, but client config or protected secret is missing." throw "UploadMode 'required' was set, but client config or protected secret is missing."
} }

View File

@@ -0,0 +1,124 @@
[CmdletBinding()]
param(
[string]$RegistrationToken,
[string]$InstanceUrl = "https://gitea.officecom.cloud",
[string]$RunnerName = "officecom-oc-sentinel-windows-01",
[string]$RunnerVersion = "1.0.8",
[string]$RunnerAccount = "OCGiteaRunner",
[string]$InstallRoot = "$env:ProgramData\\OCGiteaRunner"
)
$ErrorActionPreference = "Stop"
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function New-RunnerPassword {
# The account is only used by Task Scheduler; no password is persisted in this script or repository.
$bytes = New-Object byte[] 36
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return [Convert]::ToBase64String($bytes).Replace('+', 'A').Replace('/', 'B').Replace('=', 'C') + "!9z"
}
if (-not (Test-IsAdministrator)) {
throw "Run this script from an elevated PowerShell window (Run as administrator)."
}
if ([string]::IsNullOrWhiteSpace($RegistrationToken)) {
$secureToken = Read-Host "Paste the repository runner registration token" -AsSecureString
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
try {
$RegistrationToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer)
}
}
if ([string]::IsNullOrWhiteSpace($RegistrationToken)) {
throw "A repository runner registration token is required."
}
$taskName = "OCSentinel Gitea Windows Runner"
$runnerPath = Join-Path $InstallRoot "gitea-runner.exe"
$configPath = Join-Path $InstallRoot "config.yaml"
$runnerStatePath = Join-Path $InstallRoot ".runner"
$logDirectory = Join-Path $InstallRoot "logs"
$downloadUrl = "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/gitea-runner-$RunnerVersion-windows-amd64.exe"
$checksumUrl = "$downloadUrl.sha256"
$accountQualifiedName = "$env:COMPUTERNAME\\$RunnerAccount"
if (Test-Path -LiteralPath $runnerStatePath) {
throw "A runner is already registered at $InstallRoot. Remove it in Gitea first, then remove this directory if a new registration is needed."
}
$existingAccount = Get-LocalUser -Name $RunnerAccount -ErrorAction SilentlyContinue
if ($existingAccount) {
throw "The local account '$RunnerAccount' already exists. Stop and remove the existing runner before reinstalling it."
}
New-Item -ItemType Directory -Force -Path $InstallRoot, $logDirectory | Out-Null
try {
Write-Host "Downloading Gitea runner $RunnerVersion..."
Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile $runnerPath
$checksumText = (Invoke-WebRequest -UseBasicParsing -Uri $checksumUrl).Content.Trim()
$expectedHash = ($checksumText -split '\s+')[0].ToLowerInvariant()
$actualHash = (Get-FileHash -LiteralPath $runnerPath -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $expectedHash) {
throw "Runner checksum verification failed."
}
$password = New-RunnerPassword
$securePassword = ConvertTo-SecureString -String $password -AsPlainText -Force
New-LocalUser -Name $RunnerAccount -Password $securePassword -Description "Restricted account for the OCSentinel Gitea Actions runner." -AccountNeverExpires | Out-Null
$config = & $runnerPath generate-config
$config = $config -replace '(?m)^ labels:.*$', ' labels: ["windows:host"]'
Set-Content -LiteralPath $configPath -Value $config -Encoding utf8
# Build jobs run only with this non-administrative account and only for the repository runner token supplied.
$acl = Get-Acl -LiteralPath $InstallRoot
$acl.SetAccessRuleProtection($true, $false)
$systemRule = New-Object Security.AccessControl.FileSystemAccessRule("SYSTEM", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")
$adminRule = New-Object Security.AccessControl.FileSystemAccessRule("BUILTIN\\Administrators", "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")
$runnerRule = New-Object Security.AccessControl.FileSystemAccessRule($accountQualifiedName, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow")
$acl.AddAccessRule($systemRule)
$acl.AddAccessRule($adminRule)
$acl.AddAccessRule($runnerRule)
Set-Acl -LiteralPath $InstallRoot -AclObject $acl
$credential = New-Object Management.Automation.PSCredential($accountQualifiedName, $securePassword)
$registerArgs = @(
"--config", "`"$configPath`"", "register", "--no-interactive",
"--instance", "`"$InstanceUrl`"", "--token", "`"$RegistrationToken`"",
"--name", "`"$RunnerName`"", "--labels", "windows:host"
) -join " "
$registration = Start-Process -FilePath $runnerPath -ArgumentList $registerArgs -WorkingDirectory $InstallRoot -Credential $credential -Wait -PassThru
if ($registration.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $runnerStatePath)) {
throw "Runner registration failed with exit code $($registration.ExitCode)."
}
$action = New-ScheduledTaskAction -Execute $runnerPath -Argument "--config `"$configPath`" daemon" -WorkingDirectory $InstallRoot
$trigger = New-ScheduledTaskTrigger -AtStartup
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -User $accountQualifiedName -Password $password -RunLevel Limited -Description "Runs the repository-scoped OCSentinel Gitea Actions runner." -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 3
$task = Get-ScheduledTask -TaskName $taskName
Write-Host "Gitea runner installed successfully."
Write-Host "Runner: $RunnerName"
Write-Host "Labels: windows:host"
Write-Host "Task: $taskName ($($task.State))"
Write-Host "Install path: $InstallRoot"
}
catch {
if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
throw
}

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using System.Net; using System.Net;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using OCSentinelCli.Configuration;
namespace OCSentinelCli; namespace OCSentinelCli;
@@ -15,6 +16,12 @@ internal sealed class AttackScanner
@"D:\inetpub\logs\LogFiles" @"D:\inetpub\logs\LogFiles"
]; ];
private static readonly string[] DefaultIisLogRoots =
[
@"C:\inetpub\logs\LogFiles",
@"D:\inetpub\logs\LogFiles"
];
private static readonly string[] DefaultFileZillaRoots = private static readonly string[] DefaultFileZillaRoots =
[ [
@"C:\Program Files (x86)\FileZilla Server\Logs", @"C:\Program Files (x86)\FileZilla Server\Logs",
@@ -32,8 +39,11 @@ internal sealed class AttackScanner
ScanWindowsLogons(attacks, errors, since); ScanWindowsLogons(attacks, errors, since);
ScanSqlLogons(attacks, errors, since); ScanSqlLogons(attacks, errors, since);
ScanExchangeLogons(attacks, errors, since); ScanExchangeLogons(attacks, errors, since);
ScanExchangeIisLogons(attacks, errors, since, configuration);
ScanIisFtpLogs(attacks, errors, since, configuration); ScanIisFtpLogs(attacks, errors, since, configuration);
ScanFileZillaLogs(attacks, errors, since, configuration); ScanFileZillaLogs(attacks, errors, since, configuration);
attacks = ExchangeIisAccountCorrelator.Enrich(attacks);
RansomwareBetaSummary ransomwareBeta = RansomwareBetaDetector.Scan(configuration, errors);
if (configuration.ExcludedIps.Count > 0) if (configuration.ExcludedIps.Count > 0)
{ {
@@ -44,6 +54,14 @@ internal sealed class AttackScanner
attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp)); attacks.Sort(static (left, right) => left.Timestamp.CompareTo(right.Timestamp));
int totalEventCount = attacks.Count;
int maxReportedEvents = Math.Clamp(configuration.MaxReportedEvents, 100, 5000);
List<AttackEvent> reportedEvents = attacks
.OrderByDescending(static attack => attack.Timestamp)
.Take(maxReportedEvents)
.OrderBy(static attack => attack.Timestamp)
.ToList();
List<AggregatedAttack> topSources = attacks List<AggregatedAttack> topSources = attacks
.GroupBy(static attack => attack.SourceIp) .GroupBy(static attack => attack.SourceIp)
.Select(group => AggregatedAttack.FromGroup(group)) .Select(group => AggregatedAttack.FromGroup(group))
@@ -53,8 +71,9 @@ internal sealed class AttackScanner
.ToList(); .ToList();
int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count(); int uniqueIpCount = attacks.Select(static attack => attack.SourceIp).Distinct(StringComparer.OrdinalIgnoreCase).Count();
string baseAlertState = GetAlertState(attacks.Count, uniqueIpCount, configuration); AlertAssessment baseAssessment = MergeRansomwareAssessment(AssessAttackActivity(attacks, uniqueIpCount, configuration), ransomwareBeta, configuration.RansomwareBetaAlertingEnabled);
string baseAlertReason = GetAlertReason(attacks.Count, uniqueIpCount, configuration, baseAlertState); string baseAlertState = baseAssessment.State;
string baseAlertReason = baseAssessment.Reason;
VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath) VulnerabilityCorrelationSummary vulnerabilityCorrelation = string.IsNullOrWhiteSpace(options.VulnerabilityCsvPath)
? VulnerabilityCorrelationSummary.Empty() ? VulnerabilityCorrelationSummary.Empty()
: VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors); : VulnerabilityCorrelation.LoadForMachine(Environment.MachineName, options.VulnerabilityCsvPath, errors);
@@ -66,29 +85,70 @@ internal sealed class AttackScanner
{ {
SchemaVersion = "2.0", SchemaVersion = "2.0",
MachineName = Environment.MachineName, MachineName = Environment.MachineName,
NinjaOne = GetNinjaOneContext(options, errors),
GeneratedAtLocal = generatedAtLocal, GeneratedAtLocal = generatedAtLocal,
GeneratedAtUtc = generatedAtUtc, GeneratedAtUtc = generatedAtUtc,
ClientVersion = BuildMetadata.Version, ClientVersion = BuildMetadata.Version,
LookbackDays = options.LookbackDays, LookbackDays = options.LookbackDays,
TotalEvents = attacks.Count, TotalEvents = totalEventCount,
ReportedEventCount = reportedEvents.Count,
EventsTruncated = reportedEvents.Count < totalEventCount,
UniqueIpCount = uniqueIpCount, UniqueIpCount = uniqueIpCount,
AlertState = correlationAssessment.FinalAlertState, AlertState = correlationAssessment.FinalAlertState,
AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason, AlertReason = correlationAssessment.CorrelationReason == "No CVE correlation applied." ? baseAlertReason : correlationAssessment.CorrelationReason,
BaseAlertState = baseAlertState, BaseAlertState = baseAlertState,
BaseAlertReason = baseAlertReason, BaseAlertReason = baseAlertReason,
VulnerabilityCorrelation = vulnerabilityCorrelation, VulnerabilityCorrelation = vulnerabilityCorrelation,
RansomwareBeta = ransomwareBeta,
Runtime = new ScanRuntimeMetadata Runtime = new ScanRuntimeMetadata
{ {
StartedAtUtc = startedAtUtc, StartedAtUtc = startedAtUtc,
FinishedAtUtc = generatedAtUtc, FinishedAtUtc = generatedAtUtc,
UploadAttempted = false UploadAttempted = false
}, },
Events = attacks, Events = reportedEvents,
TopSources = topSources, TopSources = topSources,
Errors = errors Errors = errors
}; };
} }
private static NinjaOneContext GetNinjaOneContext(ScanOptions options, List<string> errors)
{
ClientConfiguration? clientConfiguration = null;
if (!string.IsNullOrWhiteSpace(options.ClientConfigPath))
{
try
{
clientConfiguration = ClientConfiguration.Load(options.ClientConfigPath);
}
catch (Exception exception)
{
errors.Add($"Could not load persisted NinjaOne context: {exception.Message}");
}
}
return new NinjaOneContext
{
OrganizationId = ReadContextValue("NINJA_ORGANIZATION_ID", clientConfiguration?.NinjaOrganizationId),
OrganizationName = ReadContextValue("NINJA_ORGANIZATION_NAME", clientConfiguration?.NinjaOrganizationName),
MachineId = ReadContextValue("NINJA_AGENT_MACHINE_ID", clientConfiguration?.NinjaMachineId),
NodeId = ReadContextValue("NINJA_AGENT_NODE_ID", clientConfiguration?.NinjaNodeId),
LocationId = ReadContextValue("NINJA_LOCATION_ID", clientConfiguration?.NinjaLocationId),
LocationName = ReadContextValue("NINJA_LOCATION_NAME", clientConfiguration?.NinjaLocationName)
};
}
private static string ReadContextValue(string environmentName, string? persistedValue)
{
string currentValue = ReadEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(currentValue) ? persistedValue?.Trim() ?? string.Empty : currentValue;
}
private static string ReadEnvironmentVariable(string name)
{
return Environment.GetEnvironmentVariable(name)?.Trim() ?? string.Empty;
}
private static ScannerConfiguration LoadConfiguration(ScanOptions options) private static ScannerConfiguration LoadConfiguration(ScanOptions options)
{ {
if (string.IsNullOrWhiteSpace(options.ConfigPath)) if (string.IsNullOrWhiteSpace(options.ConfigPath))
@@ -122,6 +182,7 @@ internal sealed class AttackScanner
Target = "Windows login", Target = "Windows login",
Username = ReadProperty(eventRecord, 5, "[unknown]"), Username = ReadProperty(eventRecord, 5, "[unknown]"),
Source = "Security", Source = "Security",
Service = ReadProperty(eventRecord, 10) == "3" ? "Network" : string.Empty,
InstanceId = 4625 InstanceId = 4625
}); });
}); });
@@ -183,6 +244,40 @@ internal sealed class AttackScanner
}); });
} }
private static void ScanExchangeIisLogons(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
IEnumerable<string> roots = configuration.IisLogRoots.Count > 0 ? configuration.IisLogRoots : DefaultIisLogRoots;
foreach (string root in roots)
{
try
{
if (!Directory.Exists(root))
{
continue;
}
foreach (string directory in Directory.GetDirectories(root, "W3SVC*"))
{
foreach (string file in Directory.GetFiles(directory, "*.log").Where(path => File.GetLastWriteTime(path) >= since.LocalDateTime.Date))
{
try
{
attacks.AddRange(ExchangeIisLogParser.ParseLines(File.ReadLines(file), since));
}
catch (Exception exception)
{
errors.Add($"Exchange IIS log parse failed for {file}: {exception.Message}");
}
}
}
}
catch (Exception exception)
{
errors.Add($"Exchange IIS log scan failed for {root}: {exception.Message}");
}
}
}
private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration) private static void ScanIisFtpLogs(List<AttackEvent> attacks, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{ {
IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots; IEnumerable<string> roots = configuration.FtpRoots.Count > 0 ? configuration.FtpRoots : DefaultFtpRoots;
@@ -450,28 +545,111 @@ internal sealed class AttackScanner
return IPAddress.TryParse(input, out _); return IPAddress.TryParse(input, out _);
} }
private static string GetAlertState(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration) private static AlertAssessment AssessAttackActivity(IReadOnlyList<AttackEvent> attacks, int uniqueIpCount, ScannerConfiguration configuration)
{ {
if (totalEvents >= configuration.CriticalEventThreshold || uniqueIpCount >= configuration.CriticalUniqueIpThreshold) if (attacks.Count == 0)
{ {
return "critical"; return new AlertAssessment("ok", "No failed login activity observed.");
} }
if (totalEvents >= configuration.WarningEventThreshold || uniqueIpCount >= configuration.WarningUniqueIpThreshold) TimeSpan window = TimeSpan.FromMinutes(configuration.LoginBurstWindowMinutes);
int largestBurst = attacks
.GroupBy(attack => (attack.SourceIp, attack.Username, attack.Target))
.Select(group => GetPeakEventCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
int largestSpray = attacks
.GroupBy(attack => attack.SourceIp)
.Select(group => GetPeakDistinctAccountCount(group.OrderBy(attack => attack.Timestamp).ToList(), window))
.DefaultIfEmpty(0)
.Max();
if (largestBurst >= configuration.CriticalLoginBurstCount || largestSpray >= configuration.CriticalSprayAccountCount)
{ {
return "warning"; return new AlertAssessment("critical", $"High-confidence login attack pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
return "ok"; if (largestBurst >= configuration.WarningLoginBurstCount || largestSpray >= configuration.WarningSprayAccountCount)
{
return new AlertAssessment("warning", $"Suspicious login pattern: burst={largestBurst}, sprayed accounts={largestSpray}, window={configuration.LoginBurstWindowMinutes}m.");
} }
private static string GetAlertReason(int totalEvents, int uniqueIpCount, ScannerConfiguration configuration, string alertState) int criticalEventThreshold = Math.Max(configuration.CriticalEventThreshold, configuration.CriticalLoginBurstCount);
int criticalIpThreshold = Math.Max(configuration.CriticalUniqueIpThreshold, configuration.CriticalSprayAccountCount);
int warningEventThreshold = Math.Max(configuration.WarningEventThreshold, configuration.WarningLoginBurstCount * 2);
int warningIpThreshold = Math.Max(configuration.WarningUniqueIpThreshold, configuration.WarningSprayAccountCount);
if (attacks.Count >= criticalEventThreshold || uniqueIpCount >= criticalIpThreshold)
{ {
return alertState switch return new AlertAssessment("critical", $"Critical volume threshold reached: events={attacks.Count}, unique IPs={uniqueIpCount}.");
}
if (attacks.Count >= warningEventThreshold || uniqueIpCount >= warningIpThreshold)
{ {
"critical" => $"Critical threshold reached. Events={totalEvents}/{configuration.CriticalEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.CriticalUniqueIpThreshold}.", return new AlertAssessment("warning", $"Elevated failed-login volume: events={attacks.Count}, unique IPs={uniqueIpCount}.");
"warning" => $"Warning threshold reached. Events={totalEvents}/{configuration.WarningEventThreshold}, UniqueIPs={uniqueIpCount}/{configuration.WarningUniqueIpThreshold}.", }
_ => "No thresholds exceeded."
return new AlertAssessment("ok", $"Low-volume login errors observed: events={attacks.Count}, unique IPs={uniqueIpCount}; no burst or password-spraying pattern detected.");
}
private static AlertAssessment MergeRansomwareAssessment(AlertAssessment loginAssessment, RansomwareBetaSummary ransomwareBeta, bool ransomwareAlertingEnabled)
{
if (!RansomwareAlertPolicy.CanElevate(ransomwareBeta, ransomwareAlertingEnabled))
{
return loginAssessment;
}
int loginPriority = AlertPriority(loginAssessment.State);
int ransomwarePriority = AlertPriority(ransomwareBeta.State);
string state = ransomwarePriority > loginPriority ? ransomwareBeta.State : loginAssessment.State;
string reason = $"{loginAssessment.Reason} {ransomwareBeta.Reason}";
return new AlertAssessment(state, reason);
}
private static int AlertPriority(string state) => state switch
{
"critical" => 2,
"warning" => 1,
_ => 0
}; };
private static int GetPeakEventCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
while (events[end].Timestamp - events[start].Timestamp > window)
{
start++;
} }
peak = Math.Max(peak, end - start + 1);
}
return peak;
}
private static int GetPeakDistinctAccountCount(IReadOnlyList<AttackEvent> events, TimeSpan window)
{
var accounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
int start = 0;
int peak = 0;
for (int end = 0; end < events.Count; end++)
{
accounts[events[end].Username] = accounts.GetValueOrDefault(events[end].Username) + 1;
while (events[end].Timestamp - events[start].Timestamp > window)
{
string account = events[start].Username;
accounts[account]--;
if (accounts[account] == 0)
{
accounts.Remove(account);
}
start++;
}
peak = Math.Max(peak, accounts.Count);
}
return peak;
}
private sealed record AlertAssessment(string State, string Reason);
} }

View File

@@ -5,7 +5,10 @@ internal static class ScanAndUploadCommand
public static int Execute(string[] args) public static int Execute(string[] args)
{ {
string outputPath = @"C:\ProgramData\OCSentinel\reports\latest.json"; string outputPath = @"C:\ProgramData\OCSentinel\reports\latest.json";
string? clientConfigPath = null;
string? secretPath = null;
bool hasOutput = false; bool hasOutput = false;
var scanArgs = new List<string>();
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
@@ -13,11 +16,29 @@ internal static class ScanAndUploadCommand
{ {
outputPath = args[i + 1]; outputPath = args[i + 1];
hasOutput = true; hasOutput = true;
break; scanArgs.Add(args[i]);
} scanArgs.Add(args[++i]);
continue;
}
if (string.Equals(args[i], "--client-config", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
string configPathValue = args[++i];
clientConfigPath = configPathValue;
scanArgs.Add("--client-config");
scanArgs.Add(configPathValue);
continue;
}
if (string.Equals(args[i], "--secret-path", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
secretPath = args[++i];
continue;
}
scanArgs.Add(args[i]);
} }
List<string> scanArgs = [.. args];
if (!hasOutput) if (!hasOutput)
{ {
scanArgs.Add("--output"); scanArgs.Add("--output");
@@ -36,6 +57,16 @@ internal static class ScanAndUploadCommand
outputPath outputPath
}; };
if (!string.IsNullOrWhiteSpace(clientConfigPath))
{
uploadArgs.AddRange(["--client-config", clientConfigPath]);
}
if (!string.IsNullOrWhiteSpace(secretPath))
{
uploadArgs.AddRange(["--secret-path", secretPath]);
}
return UploadCommand.Execute([.. uploadArgs]); return UploadCommand.Execute([.. uploadArgs]);
} }
} }

View File

@@ -73,6 +73,8 @@ internal static class ScanCommand
Console.WriteLine($"Status: {result.AlertState}"); Console.WriteLine($"Status: {result.AlertState}");
Console.WriteLine($"Reason: {result.AlertReason}"); Console.WriteLine($"Reason: {result.AlertReason}");
Console.WriteLine($"Base status: {result.BaseAlertState}"); Console.WriteLine($"Base status: {result.BaseAlertState}");
Console.WriteLine($"Ransomware beta: {result.RansomwareBeta.State}");
Console.WriteLine($"Ransomware beta signals: {result.RansomwareBeta.Signals.Count}");
Console.WriteLine($"Scan errors: {result.Errors.Count}"); Console.WriteLine($"Scan errors: {result.Errors.Count}");
Console.WriteLine(); Console.WriteLine();

View File

@@ -1,4 +1,5 @@
using OCSentinelCli.Configuration; using OCSentinelCli.Configuration;
using OCSentinelCli.Models;
using System.Text.Json; using System.Text.Json;
using OCSentinelCli.Security; using OCSentinelCli.Security;
using OCSentinelCli.Transport; using OCSentinelCli.Transport;
@@ -56,30 +57,52 @@ internal static class UploadCommand
string reportFullPath = Path.GetFullPath(reportPath); string reportFullPath = Path.GetFullPath(reportPath);
string json = File.ReadAllText(reportFullPath); string json = File.ReadAllText(reportFullPath);
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath);
var client = new N8nUploadClient();
var queue = new UploadQueue(config.UploadQueueMaxReports);
UploadHealth health = queue.LoadHealth();
DateTimeOffset attemptTime = DateTimeOffset.UtcNow;
ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default); ScanResult? parsedReport = JsonSerializer.Deserialize<ScanResult>(json, JsonOptions.Default);
if (parsedReport is not null) if (parsedReport is not null)
{ {
parsedReport = parsedReport with parsedReport = parsedReport with
{ {
Runtime = parsedReport.Runtime with Runtime = parsedReport.Runtime with
{ {
UploadAttempted = true UploadAttempted = true,
UploadStatus = "attempting",
QueuedReportCount = health.QueuedReportCount,
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc
} }
}; };
json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default); json = JsonSerializer.Serialize(parsedReport, JsonOptions.Default);
File.WriteAllText(reportFullPath, json); File.WriteAllText(reportFullPath, json);
} }
string secret = ProtectedSecretStore.LoadSecret(resolvedSecretPath); UploadResult? deferredFailure = queue.Drain(
var client = new N8nUploadClient(); client,
var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds); config.N8nWebhookUrl,
Environment.MachineName,
BuildMetadata.Version,
secret,
config.UploadTimeoutSeconds);
if (!result.Success) if (deferredFailure is null)
{ {
Console.Error.WriteLine($"Upload failed ({result.StatusCode}): {result.Message}"); var result = client.UploadJson(config.N8nWebhookUrl, Environment.MachineName, BuildMetadata.Version, json, secret, config.UploadTimeoutSeconds);
return 1; if (result.Success)
} {
UploadHealth successHealth = new()
{
LastUploadAttemptUtc = attemptTime,
LastSuccessfulUploadUtc = DateTimeOffset.UtcNow,
LastUploadStatus = "ok",
LastUploadError = string.Empty,
QueuedReportCount = queue.GetQueueDepth()
};
queue.SaveHealth(successHealth);
WriteReportRuntime(reportFullPath, parsedReport, successHealth, true);
Console.WriteLine($"Upload succeeded ({result.StatusCode})"); Console.WriteLine($"Upload succeeded ({result.StatusCode})");
Console.WriteLine($"Nonce: {result.Nonce}"); Console.WriteLine($"Nonce: {result.Nonce}");
@@ -87,6 +110,50 @@ internal static class UploadCommand
return 0; return 0;
} }
deferredFailure = result;
}
UploadResult failure = deferredFailure ?? throw new InvalidOperationException("Upload failed without a result.");
int queuedCount = queue.Enqueue(json);
UploadHealth queuedHealth = new()
{
LastUploadAttemptUtc = attemptTime,
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
LastUploadStatus = "queued",
LastUploadError = failure.Message,
QueuedReportCount = queuedCount
};
queue.SaveHealth(queuedHealth);
WriteReportRuntime(reportFullPath, parsedReport, queuedHealth, true);
Console.WriteLine($"Upload deferred ({failure.StatusCode}): {failure.Message}");
Console.WriteLine($"Queued reports: {queuedCount}");
Console.WriteLine("The report will be retried automatically on the next scheduled run.");
return 0;
}
private static void WriteReportRuntime(string reportPath, ScanResult? report, UploadHealth health, bool attempted)
{
if (report is null)
{
return;
}
ScanResult updated = report with
{
Runtime = report.Runtime with
{
UploadAttempted = attempted,
UploadSucceeded = string.Equals(health.LastUploadStatus, "ok", StringComparison.Ordinal),
UploadStatus = health.LastUploadStatus,
QueuedReportCount = health.QueuedReportCount,
LastSuccessfulUploadUtc = health.LastSuccessfulUploadUtc,
LastUploadError = health.LastUploadError
}
};
File.WriteAllText(reportPath, JsonSerializer.Serialize(updated, JsonOptions.Default));
}
private static string ReadValue(string[] args, ref int index, string argName) private static string ReadValue(string[] args, ref int index, string argName)
{ {
if (index + 1 >= args.Length) if (index + 1 >= args.Length)

View File

@@ -4,20 +4,62 @@ namespace OCSentinelCli;
internal sealed record ScannerConfiguration internal sealed record ScannerConfiguration
{ {
public int WarningEventThreshold { get; init; } = 1; public int WarningEventThreshold { get; init; } = 10;
public int CriticalEventThreshold { get; init; } = 20; public int MaxReportedEvents { get; init; } = 1000;
public int WarningUniqueIpThreshold { get; init; } = 1; public int CriticalEventThreshold { get; init; } = 30;
public int CriticalUniqueIpThreshold { get; init; } = 10; public int WarningUniqueIpThreshold { get; init; } = 5;
public int CriticalUniqueIpThreshold { get; init; } = 12;
public int LoginBurstWindowMinutes { get; init; } = 15;
public int WarningLoginBurstCount { get; init; } = 5;
public int CriticalLoginBurstCount { get; init; } = 20;
public int WarningSprayAccountCount { get; init; } = 5;
public int CriticalSprayAccountCount { get; init; } = 10;
public int CorrelationWarningCveThreshold { get; init; } = 1; public int CorrelationWarningCveThreshold { get; init; } = 1;
public int CorrelationCriticalCveThreshold { get; init; } = 1; public int CorrelationCriticalCveThreshold { get; init; } = 1;
public bool RansomwareBetaEnabled { get; init; } = true;
public bool RansomwareBetaAlertingEnabled { get; init; }
public int RansomwareLookbackMinutes { get; init; } = 15;
public int RansomwareWarningSignalCount { get; init; } = 2;
public int RansomwareCriticalSignalCount { get; init; } = 3;
public bool RansomwareCaptureSmbSessions { get; init; } = true;
public bool RansomwareFileChurnEnabled { get; init; }
public int RansomwareFileChurnWindowMinutes { get; init; } = 15;
public int RansomwareFileChurnWarningDeleteCount { get; init; } = 50;
public int RansomwareFileChurnWarningWriteCount { get; init; } = 250;
public int RansomwareFileChurnCriticalDeleteCount { get; init; } = 200;
public int RansomwareFileChurnCriticalWriteCount { get; init; } = 1000;
public int RansomwareFileChurnMaxAuditEvents { get; init; } = 5000;
public List<string> RansomwareExcludedProcesses { get; init; } = [];
public List<string> FtpRoots { get; init; } = []; public List<string> FtpRoots { get; init; } = [];
public List<string> IisLogRoots { get; init; } = [];
public List<string> FileZillaRoots { get; init; } = []; public List<string> FileZillaRoots { get; init; } = [];
public List<string> ExcludedIps { get; init; } = []; public List<string> ExcludedIps { get; init; } = [];

View File

@@ -14,10 +14,24 @@ internal sealed record ClientConfiguration
public string N8nWebhookUrl { get; init; } = string.Empty; public string N8nWebhookUrl { get; init; } = string.Empty;
public string NinjaOrganizationId { get; init; } = string.Empty;
public string NinjaOrganizationName { get; init; } = string.Empty;
public string NinjaMachineId { get; init; } = string.Empty;
public string NinjaNodeId { get; init; } = string.Empty;
public string NinjaLocationId { get; init; } = string.Empty;
public string NinjaLocationName { get; init; } = string.Empty;
public string DeviceIdentifierMode { get; init; } = "machineName"; public string DeviceIdentifierMode { get; init; } = "machineName";
public int UploadTimeoutSeconds { get; init; } = 30; public int UploadTimeoutSeconds { get; init; } = 30;
public int UploadQueueMaxReports { get; init; } = 100;
public bool EnableVulnerabilityCorrelation { get; init; } = true; public bool EnableVulnerabilityCorrelation { get; init; } = true;
public string VulnerabilityCsvPath { get; init; } = string.Empty; public string VulnerabilityCsvPath { get; init; } = string.Empty;

View File

@@ -0,0 +1,53 @@
namespace OCSentinelCli;
internal static class ExchangeIisAccountCorrelator
{
private static readonly TimeSpan CorrelationWindow = TimeSpan.FromMinutes(2);
internal static List<AttackEvent> Enrich(IReadOnlyList<AttackEvent> attacks)
{
List<AttackEvent> securityFailures = attacks
.Where(IsNetworkSecurityFailure)
.Where(HasRecordedAccount)
.ToList();
return attacks.Select(attack => IsUnresolvedExchangeIisFailure(attack)
? EnrichFromSecurityFailure(attack, securityFailures)
: attack).ToList();
}
private static AttackEvent EnrichFromSecurityFailure(AttackEvent iisFailure, IReadOnlyList<AttackEvent> securityFailures)
{
List<string> accounts = securityFailures
.Where(failure => string.Equals(failure.SourceIp, iisFailure.SourceIp, StringComparison.OrdinalIgnoreCase))
.Where(failure => (failure.Timestamp - iisFailure.Timestamp).Duration() <= CorrelationWindow)
.Select(failure => failure.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// A shared NAT address can produce concurrent failures; do not guess between accounts.
return accounts.Count == 1 ? iisFailure with { Username = accounts[0] } : iisFailure;
}
private static bool IsUnresolvedExchangeIisFailure(AttackEvent attack)
{
return string.Equals(attack.Source, "IIS W3C", StringComparison.OrdinalIgnoreCase)
&& (string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase)
|| string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase));
}
private static bool IsNetworkSecurityFailure(AttackEvent attack)
{
return attack.InstanceId == 4625
&& string.Equals(attack.Source, "Security", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Service, "Network", StringComparison.OrdinalIgnoreCase)
&& string.Equals(attack.Target, "Windows login", StringComparison.OrdinalIgnoreCase);
}
private static bool HasRecordedAccount(AttackEvent attack)
{
return !string.IsNullOrWhiteSpace(attack.Username)
&& !string.Equals(attack.Username, "[unknown]", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(attack.Username, "[not logged]", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,168 @@
using System.Globalization;
namespace OCSentinelCli;
internal static class ExchangeIisLogParser
{
private static readonly TimeSpan AuthenticationCompletionWindow = TimeSpan.FromMinutes(2);
internal static IEnumerable<AttackEvent> ParseLines(IEnumerable<string> lines, DateTimeOffset since)
{
Dictionary<string, int>? fields = null;
var pendingFailures = new List<ExchangeIisObservation>();
var attacks = new List<AttackEvent>();
foreach (string line in lines)
{
if (line.StartsWith("#Fields:", StringComparison.OrdinalIgnoreCase))
{
fields = line[8..].Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select((field, index) => new { Field = field, Index = index })
.ToDictionary(item => item.Field, item => item.Index, StringComparer.OrdinalIgnoreCase);
continue;
}
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || fields is null || !TryParseObservation(fields, line, since, out ExchangeIisObservation? observation))
{
continue;
}
if (observation is null)
{
continue;
}
FlushExpiredCandidates(pendingFailures, attacks, observation.Timestamp);
if (observation.IsSuccessfulAuthentication)
{
pendingFailures.RemoveAll(candidate => candidate.MatchesSuccessfulAuthentication(observation));
}
else if (observation.IsCredentialFailure)
{
pendingFailures.Add(observation);
}
}
attacks.AddRange(pendingFailures.Select(static candidate => candidate.ToAttackEvent()));
return attacks;
}
private static void FlushExpiredCandidates(List<ExchangeIisObservation> pendingFailures, List<AttackEvent> attacks, DateTimeOffset currentTimestamp)
{
DateTimeOffset cutoff = currentTimestamp - AuthenticationCompletionWindow;
foreach (ExchangeIisObservation candidate in pendingFailures.Where(candidate => candidate.Timestamp < cutoff).ToList())
{
attacks.Add(candidate.ToAttackEvent());
pendingFailures.Remove(candidate);
}
}
private static bool TryParseObservation(IReadOnlyDictionary<string, int> fields, string line, DateTimeOffset since, out ExchangeIisObservation? observation)
{
observation = null;
string[] values = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!TryValue(fields, values, "date", out string date) || !TryValue(fields, values, "time", out string time)
|| !TryValue(fields, values, "c-ip", out string sourceIp) || !TryValue(fields, values, "cs-uri-stem", out string path)
|| !TryValue(fields, values, "sc-status", out string statusText) || !int.TryParse(statusText, out int status)
|| !TryClassify(path, out string service))
{
return false;
}
if ((status < 200 || status >= 400) && status is not 401 and not 403)
{
return false;
}
if (!DateTime.TryParse($"{date} {time}", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out DateTime timestampUtc))
{
return false;
}
DateTimeOffset timestamp = new(timestampUtc, TimeSpan.Zero);
if (timestamp < since || string.IsNullOrWhiteSpace(sourceIp) || sourceIp == "-")
{
return false;
}
int? destinationPort = TryValue(fields, values, "s-port", out string portText) && int.TryParse(portText, out int parsedPort) ? parsedPort : null;
string username = TryValue(fields, values, "cs-username", out string loggedUser) && loggedUser != "-" ? loggedUser : "[not logged]";
string userAgent = TryValue(fields, values, "cs(User-Agent)", out string parsedUserAgent) && parsedUserAgent != "-" ? parsedUserAgent : string.Empty;
string substatus = TryValue(fields, values, "sc-substatus", out string parsedSubstatus) ? parsedSubstatus : string.Empty;
observation = new ExchangeIisObservation(timestamp, sourceIp, path, service, destinationPort, username, userAgent, status, substatus);
return true;
}
private static bool TryValue(IReadOnlyDictionary<string, int> fields, IReadOnlyList<string> values, string field, out string value)
{
value = string.Empty;
if (!fields.TryGetValue(field, out int index) || index >= values.Count)
{
return false;
}
value = values[index];
return true;
}
private static bool TryClassify(string path, out string service)
{
string normalized = path.Trim().ToLowerInvariant();
service = normalized switch
{
var value when value.StartsWith("/owa/") => "OWA",
var value when value.StartsWith("/ecp/") => "ECP",
var value when value.StartsWith("/mapi/") => "MAPI/HTTP",
var value when value.StartsWith("/ews/") => "EWS",
var value when value.StartsWith("/microsoft-server-activesync") => "ActiveSync",
var value when value.StartsWith("/autodiscover/") => "Autodiscover",
var value when value.StartsWith("/rpc/") => "Outlook Anywhere",
var value when value.StartsWith("/powershell") => "Exchange PowerShell",
_ => string.Empty
};
return service.Length > 0;
}
private sealed record ExchangeIisObservation(
DateTimeOffset Timestamp,
string SourceIp,
string Endpoint,
string Service,
int? DestinationPort,
string Username,
string UserAgent,
int Status,
string Substatus)
{
public bool IsSuccessfulAuthentication => Status is >= 200 and < 400;
// IIS 401.0 and 401.2 commonly occur during normal authentication negotiation or server configuration checks.
public bool IsCredentialFailure => Status == 403 || (Status == 401 && (string.IsNullOrWhiteSpace(Substatus) || Substatus == "1"));
public bool MatchesSuccessfulAuthentication(ExchangeIisObservation success)
{
return success.IsSuccessfulAuthentication
&& success.Timestamp >= Timestamp
&& success.Timestamp - Timestamp <= AuthenticationCompletionWindow
&& string.Equals(success.SourceIp, SourceIp, StringComparison.OrdinalIgnoreCase)
&& string.Equals(success.Endpoint, Endpoint, StringComparison.OrdinalIgnoreCase)
&& success.DestinationPort == DestinationPort
&& string.Equals(success.UserAgent, UserAgent, StringComparison.OrdinalIgnoreCase);
}
public AttackEvent ToAttackEvent() => new()
{
Timestamp = Timestamp.ToLocalTime(),
SourceIp = SourceIp,
Target = $"Exchange {Service} login",
Username = Username,
Source = "IIS W3C",
Service = Service,
DestinationPort = DestinationPort,
Endpoint = Endpoint,
InstanceId = Status
};
}
}

View File

@@ -8,6 +8,9 @@ internal static class JsonOptions
public static readonly JsonSerializerOptions Default = new() public static readonly JsonSerializerOptions Default = new()
{ {
WriteIndented = true, WriteIndented = true,
// Client configuration is also written by PowerShell/NinjaOne scripts.
// Accept their conventional camelCase names (for example n8nWebhookUrl).
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
}; };
} }

View File

@@ -13,6 +13,12 @@ internal sealed record AttackEvent
public string Username { get; init; } = string.Empty; public string Username { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty; public string Source { get; init; } = string.Empty;
public string Service { get; init; } = string.Empty;
public int? DestinationPort { get; init; }
public string Endpoint { get; init; } = string.Empty;
} }
internal sealed record AggregatedAttack internal sealed record AggregatedAttack
@@ -33,6 +39,12 @@ internal sealed record AggregatedAttack
public List<string> Sources { get; init; } = []; public List<string> Sources { get; init; } = [];
public List<string> Services { get; init; } = [];
public List<int> DestinationPorts { get; init; } = [];
public List<string> Endpoints { get; init; } = [];
public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group) public static AggregatedAttack FromGroup(IGrouping<string, AttackEvent> group)
{ {
List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList(); List<AttackEvent> ordered = group.OrderBy(static attack => attack.Timestamp).ToList();
@@ -48,7 +60,10 @@ internal sealed record AggregatedAttack
RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen), RateLabel = FormatRate(ordered.Count, firstSeen, lastSeen),
Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), Targets = ordered.Select(static attack => attack.Target).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(), Usernames = ordered.Select(static attack => attack.Username).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() Sources = ordered.Select(static attack => attack.Source).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
Services = ordered.Select(static attack => attack.Service).Where(static service => !string.IsNullOrWhiteSpace(service)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(),
DestinationPorts = ordered.Select(static attack => attack.DestinationPort).Where(static port => port.HasValue).Select(static port => port!.Value).Distinct().Order().ToList(),
Endpoints = ordered.Select(static attack => attack.Endpoint).Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint)).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList()
}; };
} }
@@ -80,6 +95,8 @@ internal sealed record ScanResult
public string MachineName { get; init; } = string.Empty; public string MachineName { get; init; } = string.Empty;
public NinjaOneContext NinjaOne { get; init; } = new();
public DateTimeOffset GeneratedAtLocal { get; init; } public DateTimeOffset GeneratedAtLocal { get; init; }
public DateTimeOffset GeneratedAtUtc { get; init; } public DateTimeOffset GeneratedAtUtc { get; init; }
@@ -90,6 +107,10 @@ internal sealed record ScanResult
public int TotalEvents { get; init; } public int TotalEvents { get; init; }
public int ReportedEventCount { get; init; }
public bool EventsTruncated { get; init; }
public int UniqueIpCount { get; init; } public int UniqueIpCount { get; init; }
public string AlertState { get; init; } = "ok"; public string AlertState { get; init; } = "ok";
@@ -102,6 +123,8 @@ internal sealed record ScanResult
public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new(); public VulnerabilityCorrelationSummary VulnerabilityCorrelation { get; init; } = new();
public RansomwareBetaSummary RansomwareBeta { get; init; } = new();
public ScanRuntimeMetadata Runtime { get; init; } = new(); public ScanRuntimeMetadata Runtime { get; init; } = new();
public List<AttackEvent> Events { get; init; } = []; public List<AttackEvent> Events { get; init; } = [];
@@ -111,6 +134,113 @@ internal sealed record ScanResult
public List<string> Errors { get; init; } = []; public List<string> Errors { get; init; } = [];
} }
internal sealed record RansomwareBetaSummary
{
public bool Enabled { get; init; }
public bool AlertingEnabled { get; init; }
public string State { get; init; } = "disabled";
public string Reason { get; init; } = "Ransomware beta is disabled.";
public int LookbackMinutes { get; init; }
public List<RansomwareSignal> Signals { get; init; } = [];
public List<RansomwareSensorStatus> Sensors { get; init; } = [];
public List<RansomwareSmbSession> SmbSessions { get; init; } = [];
public RansomwareFileChurnSummary FileChurn { get; init; } = new();
}
internal sealed record RansomwareSensorStatus
{
public string Name { get; init; } = string.Empty;
public bool Enabled { get; init; }
public bool Available { get; init; }
public string State { get; init; } = "unknown";
public int EventCount { get; init; }
}
internal sealed record RansomwareFileChurnSummary
{
public bool Enabled { get; init; }
public bool DataAvailable { get; init; }
public bool IsTruncated { get; init; }
public int WindowMinutes { get; init; }
public int FileOperationCount { get; init; }
public int DeleteOperationCount { get; init; }
public int WriteOperationCount { get; init; }
public int DistinctProcessCount { get; init; }
public List<RansomwareFileChurnProcess> TopProcesses { get; init; } = [];
}
internal sealed record RansomwareFileChurnProcess
{
public string Process { get; init; } = string.Empty;
public int DeleteOperationCount { get; init; }
public int WriteOperationCount { get; init; }
}
internal sealed record RansomwareSmbSession
{
public string ClientComputerName { get; init; } = string.Empty;
public string ClientUserName { get; init; } = string.Empty;
public long SessionId { get; init; }
public long OpenFileCount { get; init; }
}
internal sealed record RansomwareSignal
{
public DateTimeOffset Timestamp { get; init; }
public string Category { get; init; } = string.Empty;
public string Confidence { get; init; } = string.Empty;
public string Process { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
public long EventId { get; init; }
public string Evidence { get; init; } = string.Empty;
}
internal sealed record NinjaOneContext
{
public string OrganizationId { get; init; } = string.Empty;
public string OrganizationName { get; init; } = string.Empty;
public string MachineId { get; init; } = string.Empty;
public string NodeId { get; init; } = string.Empty;
public string LocationId { get; init; } = string.Empty;
public string LocationName { get; init; } = string.Empty;
}
internal sealed record ScanRuntimeMetadata internal sealed record ScanRuntimeMetadata
{ {
public DateTimeOffset StartedAtUtc { get; init; } public DateTimeOffset StartedAtUtc { get; init; }
@@ -118,6 +248,16 @@ internal sealed record ScanRuntimeMetadata
public DateTimeOffset FinishedAtUtc { get; init; } public DateTimeOffset FinishedAtUtc { get; init; }
public bool UploadAttempted { get; init; } public bool UploadAttempted { get; init; }
public bool UploadSucceeded { get; init; }
public string UploadStatus { get; init; } = "not-attempted";
public int QueuedReportCount { get; init; }
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
public string LastUploadError { get; init; } = string.Empty;
} }
internal sealed record VulnerabilityFinding internal sealed record VulnerabilityFinding

View File

@@ -9,10 +9,10 @@
<RootNamespace>OCSentinelCli</RootNamespace> <RootNamespace>OCSentinelCli</RootNamespace>
<Product>OfficeCom Sentinel</Product> <Product>OfficeCom Sentinel</Product>
<Company>OfficeCom</Company> <Company>OfficeCom</Company>
<Version>1.2.3</Version> <Version>1.5.0-beta.7</Version>
<AssemblyVersion>1.2.3.0</AssemblyVersion> <AssemblyVersion>1.5.0.0</AssemblyVersion>
<FileVersion>1.2.3.0</FileVersion> <FileVersion>1.5.0.0</FileVersion>
<InformationalVersion>1.2.3</InformationalVersion> <InformationalVersion>1.5.0-beta.7</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("OCSentinelCli.Tests")]

View File

@@ -0,0 +1,9 @@
namespace OCSentinelCli;
internal static class RansomwareAlertPolicy
{
public static bool CanElevate(RansomwareBetaSummary summary, bool alertingEnabled)
{
return alertingEnabled && summary.Enabled && (summary.State is "warning" or "critical");
}
}

View File

@@ -0,0 +1,267 @@
using System.Diagnostics;
using System.Diagnostics.Eventing.Reader;
using System.Runtime.Versioning;
using System.Text.Json;
namespace OCSentinelCli;
[SupportedOSPlatform("windows")]
internal static class RansomwareBetaDetector
{
public static RansomwareBetaSummary Scan(ScannerConfiguration configuration, List<string> errors)
{
if (!configuration.RansomwareBetaEnabled)
{
return new RansomwareBetaSummary();
}
int lookbackMinutes = Math.Clamp(configuration.RansomwareLookbackMinutes, 1, 60);
DateTimeOffset since = DateTimeOffset.UtcNow.AddMinutes(-lookbackMinutes);
var signals = new List<RansomwareSignal>();
var sensors = new List<RansomwareSensorStatus>
{
ScanSecurityProcesses(signals, errors, since, configuration),
ScanPowerShellScriptBlocks(signals, errors, since, configuration),
ScanSysmonProcesses(signals, errors, since, configuration)
};
RansomwareFileChurnSummary fileChurn = RansomwareFileChurnDetector.Scan(configuration, errors);
sensors.Add(new RansomwareSensorStatus
{
Name = "security-file-audit-4663",
Enabled = fileChurn.Enabled,
Available = fileChurn.DataAvailable,
State = !fileChurn.Enabled ? "disabled" : fileChurn.IsTruncated ? "truncated" : fileChurn.DataAvailable ? "available" : "unavailable",
EventCount = fileChurn.FileOperationCount
});
RansomwareSignal? fileChurnSignal = RansomwareFileChurnDetector.CreateSignal(fileChurn, configuration);
if (fileChurnSignal is not null)
{
signals.Add(fileChurnSignal);
}
List<RansomwareSignal> distinctSignals = signals
.OrderBy(signal => signal.Timestamp)
.GroupBy(signal => $"{signal.Category}\u001f{signal.Process}", StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.Take(20)
.ToList();
int strongSignals = distinctSignals.Count(signal => signal.Confidence == "high");
string state = distinctSignals.Count >= Math.Max(2, configuration.RansomwareCriticalSignalCount)
? "critical"
: strongSignals > 0 || distinctSignals.Count >= Math.Max(2, configuration.RansomwareWarningSignalCount)
? "warning"
: distinctSignals.Count > 0 ? "hint" : "ok";
string reason = state switch
{
"critical" => $"Ransomware beta detected {distinctSignals.Count} independent high-risk signals within {lookbackMinutes} minutes.",
"warning" => $"Ransomware beta detected {strongSignals} high-confidence and {distinctSignals.Count - strongSignals} low-confidence signals within {lookbackMinutes} minutes.",
"hint" => $"Ransomware beta observed an isolated low-confidence signal within {lookbackMinutes} minutes.",
_ => $"Ransomware beta found no suspicious process activity in the last {lookbackMinutes} minutes."
};
List<RansomwareSmbSession> smbSessions = state is "warning" or "critical" && configuration.RansomwareCaptureSmbSessions
? CaptureSmbSessions(errors)
: [];
return new RansomwareBetaSummary
{
Enabled = true,
AlertingEnabled = configuration.RansomwareBetaAlertingEnabled,
State = state,
Reason = reason,
LookbackMinutes = lookbackMinutes,
Signals = distinctSignals,
Sensors = sensors,
SmbSessions = smbSessions,
FileChurn = fileChurn
};
}
private static RansomwareSensorStatus ScanSecurityProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
return TryScan("security-process-4688", "Security", 4688, since, errors, record => AddSignal(signals, record, ReadProperty(record, 5), ReadProperty(record, 8), "Security", configuration));
}
private static RansomwareSensorStatus ScanPowerShellScriptBlocks(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
return TryScan("powershell-script-block-4104", "Microsoft-Windows-PowerShell/Operational", 4104, since, errors, record => AddSignal(signals, record, "powershell", FormatDescription(record), "PowerShell", configuration));
}
private static RansomwareSensorStatus ScanSysmonProcesses(List<RansomwareSignal> signals, List<string> errors, DateTimeOffset since, ScannerConfiguration configuration)
{
return TryScan("sysmon-process-1", "Microsoft-Windows-Sysmon/Operational", 1, since, errors, record => AddSignal(signals, record, "sysmon-process", FormatDescription(record), "Sysmon", configuration));
}
private static void AddSignal(List<RansomwareSignal> signals, EventRecord record, string process, string commandLine, string source, ScannerConfiguration configuration)
{
if (!record.TimeCreated.HasValue || string.IsNullOrWhiteSpace(commandLine))
{
return;
}
string processName = Path.GetFileName(process.Trim());
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, processName, StringComparison.OrdinalIgnoreCase)))
{
return;
}
RansomwareSignal? signal = Classify(record.TimeCreated.Value, record.Id, processName, commandLine, source);
if (signal is not null)
{
signals.Add(signal);
}
}
private static RansomwareSignal? Classify(DateTime timestamp, int eventId, string process, string commandLine, string source)
{
string value = commandLine.ToLowerInvariant();
string evidence = commandLine.Length > 512 ? commandLine[..512] : commandLine;
if (ContainsAll(value, "vssadmin", "delete", "shadow") || ContainsAll(value, "wmic", "shadowcopy", "delete") || ContainsAll(value, "win32_shadowcopy", "delete"))
{
return CreateSignal(timestamp, eventId, "shadow-copy-deletion", "high", process, source, evidence);
}
if (ContainsAll(value, "wbadmin", "delete") || ContainsAll(value, "catalog", "delete"))
{
return CreateSignal(timestamp, eventId, "backup-catalog-deletion", "high", process, source, evidence);
}
if (ContainsAll(value, "bcdedit", "recoveryenabled", "no") || ContainsAll(value, "bcdedit", "bootstatuspolicy", "ignoreallfailures"))
{
return CreateSignal(timestamp, eventId, "recovery-disable", "high", process, source, evidence);
}
if (ContainsAll(value, "wevtutil", " cl "))
{
return CreateSignal(timestamp, eventId, "event-log-clearing", "high", process, source, evidence);
}
return value.Contains("win32_shadowcopy", StringComparison.Ordinal)
? CreateSignal(timestamp, eventId, "shadow-copy-access", "low", process, source, evidence)
: null;
}
private static RansomwareSignal CreateSignal(DateTime timestamp, int eventId, string category, string confidence, string process, string source, string evidence)
{
return new RansomwareSignal
{
Timestamp = new DateTimeOffset(timestamp).ToLocalTime(),
Category = category,
Confidence = confidence,
Process = string.IsNullOrWhiteSpace(process) ? "[unknown]" : process,
Source = source,
EventId = eventId,
Evidence = evidence
};
}
private static bool ContainsAll(string value, params string[] needles) => needles.All(needle => value.Contains(needle, StringComparison.Ordinal));
private static RansomwareSensorStatus TryScan(string sensorName, string logName, int eventId, DateTimeOffset since, List<string> errors, Action<EventRecord> processRecord)
{
int eventCount = 0;
try
{
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - since).TotalMilliseconds);
string query = $"*[System[(EventID={eventId}) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
using var reader = new EventLogReader(new EventLogQuery(logName, PathType.LogName, query));
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
{
using (record)
{
eventCount++;
processRecord(record);
}
}
return new RansomwareSensorStatus
{
Name = sensorName,
Enabled = true,
Available = true,
State = "available",
EventCount = eventCount
};
}
catch (EventLogNotFoundException)
{
return new RansomwareSensorStatus
{
Name = sensorName,
Enabled = true,
State = "not-installed"
};
}
catch (Exception exception)
{
errors.Add($"Ransomware beta query failed for {logName}: {exception.Message}");
return new RansomwareSensorStatus
{
Name = sensorName,
Enabled = true,
State = "query-failed",
EventCount = eventCount
};
}
}
private static string ReadProperty(EventRecord record, int index) => index >= 0 && index < record.Properties.Count ? record.Properties[index].Value?.ToString()?.Trim() ?? string.Empty : string.Empty;
private static string FormatDescription(EventRecord record)
{
try
{
return record.FormatDescription() ?? string.Empty;
}
catch (EventLogException)
{
return string.Empty;
}
}
private static List<RansomwareSmbSession> CaptureSmbSessions(List<string> errors)
{
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = "-NoProfile -NonInteractive -Command \"Get-SmbSession | Select-Object ClientComputerName,ClientUserName,SessionId,NumOpens | ConvertTo-Json -Compress\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});
if (process is null || !process.WaitForExit(5000) || process.ExitCode != 0)
{
return [];
}
string json = process.StandardOutput.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
JsonElement root = JsonSerializer.Deserialize<JsonElement>(json, JsonOptions.Default);
IEnumerable<JsonElement> rows = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : [root];
return rows.Take(100).Select(row => new RansomwareSmbSession
{
ClientComputerName = GetJsonString(row, "ClientComputerName"),
ClientUserName = GetJsonString(row, "ClientUserName"),
SessionId = GetJsonLong(row, "SessionId"),
OpenFileCount = GetJsonLong(row, "NumOpens")
}).ToList();
}
catch (Exception exception)
{
errors.Add($"Ransomware beta SMB snapshot failed: {exception.Message}");
return [];
}
}
private static string GetJsonString(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) ? property.ToString() : string.Empty;
private static long GetJsonLong(JsonElement value, string name) => value.TryGetProperty(name, out JsonElement property) && property.TryGetInt64(out long result) ? result : 0;
}

View File

@@ -0,0 +1,296 @@
using System.Diagnostics.Eventing.Reader;
using System.Globalization;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Xml.Linq;
namespace OCSentinelCli;
[SupportedOSPlatform("windows")]
internal static class RansomwareFileChurnDetector
{
private const uint DeleteAccessMask = 0x00010000;
private const uint FileWriteAccessMask = 0x00000156;
private const string StateFileName = "ransomware-file-churn.json";
public static RansomwareFileChurnSummary Scan(ScannerConfiguration configuration, List<string> errors)
{
if (!configuration.RansomwareFileChurnEnabled)
{
return new RansomwareFileChurnSummary();
}
int windowMinutes = Math.Clamp(configuration.RansomwareFileChurnWindowMinutes, 1, 60);
int maximumAuditEvents = Math.Clamp(configuration.RansomwareFileChurnMaxAuditEvents, 100, 20000);
DateTimeOffset windowStart = DateTimeOffset.UtcNow.AddMinutes(-windowMinutes);
var observed = new List<FileAuditActivity>();
bool isTruncated = false;
try
{
long milliseconds = Math.Max(1, (long)(DateTimeOffset.UtcNow - windowStart).TotalMilliseconds);
string query = $"*[System[(EventID=4663) and TimeCreated[timediff(@SystemTime) <= {milliseconds}]]]";
using var reader = new EventLogReader(new EventLogQuery("Security", PathType.LogName, query));
int inspected = 0;
for (EventRecord? record = reader.ReadEvent(); record is not null; record = reader.ReadEvent())
{
using (record)
{
if (++inspected > maximumAuditEvents)
{
isTruncated = true;
break;
}
if (TryCreateActivity(record, configuration, out FileAuditActivity? activity) && activity is not null)
{
observed.Add(activity);
}
}
}
}
catch (UnauthorizedAccessException exception)
{
errors.Add($"Ransomware file churn cannot read the Security log: {exception.Message}");
return Unavailable(windowMinutes);
}
catch (EventLogNotFoundException)
{
return Unavailable(windowMinutes);
}
catch (Exception exception)
{
errors.Add($"Ransomware file churn query failed: {exception.Message}");
return Unavailable(windowMinutes);
}
if (isTruncated)
{
return BuildSummary(observed, windowMinutes, isTruncated: true);
}
List<FileAuditActivity> rollingActivities = MergeWithState(observed, windowStart, errors);
return BuildSummary(rollingActivities, windowMinutes, isTruncated: false);
}
public static RansomwareSignal? CreateSignal(RansomwareFileChurnSummary summary, ScannerConfiguration configuration)
{
if (!summary.Enabled || !summary.DataAvailable || summary.IsTruncated)
{
return null;
}
int warningDeletes = Math.Max(1, configuration.RansomwareFileChurnWarningDeleteCount);
int warningWrites = Math.Max(1, configuration.RansomwareFileChurnWarningWriteCount);
int criticalDeletes = Math.Max(warningDeletes, configuration.RansomwareFileChurnCriticalDeleteCount);
int criticalWrites = Math.Max(warningWrites, configuration.RansomwareFileChurnCriticalWriteCount);
bool critical = summary.DeleteOperationCount >= criticalDeletes && summary.WriteOperationCount >= criticalWrites;
bool warning = summary.DeleteOperationCount >= warningDeletes && summary.WriteOperationCount >= warningWrites;
if (!warning)
{
return null;
}
RansomwareFileChurnProcess? topProcess = summary.TopProcesses.FirstOrDefault();
string evidence = $"delete={summary.DeleteOperationCount}; write={summary.WriteOperationCount}; processes={summary.DistinctProcessCount}; window={summary.WindowMinutes}m";
return new RansomwareSignal
{
Timestamp = DateTimeOffset.Now,
Category = critical ? "file-churn-critical" : "file-churn",
Confidence = critical ? "medium" : "low",
Process = topProcess?.Process ?? "[multiple]",
Source = "Security file audit",
EventId = 4663,
Evidence = evidence
};
}
private static RansomwareFileChurnSummary Unavailable(int windowMinutes)
{
return new RansomwareFileChurnSummary
{
Enabled = true,
WindowMinutes = windowMinutes
};
}
private static bool TryCreateActivity(EventRecord record, ScannerConfiguration configuration, out FileAuditActivity? activity)
{
activity = null;
if (!record.TimeCreated.HasValue)
{
return false;
}
IReadOnlyDictionary<string, string> data = ReadEventData(record);
if (!data.TryGetValue("ObjectType", out string? objectType) || !string.Equals(objectType, "File", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!data.TryGetValue("AccessMask", out string? accessMaskText) || !TryParseAccessMask(accessMaskText, out uint accessMask))
{
return false;
}
bool isDelete = (accessMask & DeleteAccessMask) != 0;
bool isWrite = (accessMask & FileWriteAccessMask) != 0;
if (!isDelete && !isWrite)
{
return false;
}
string process = data.TryGetValue("ProcessName", out string? processPath) ? Path.GetFileName(processPath.Trim()) : string.Empty;
if (string.IsNullOrWhiteSpace(process))
{
process = "[unknown]";
}
if (configuration.RansomwareExcludedProcesses.Any(item => string.Equals(item, process, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
activity = new FileAuditActivity
{
Timestamp = new DateTimeOffset(record.TimeCreated.Value).ToUniversalTime(),
RecordId = record.RecordId ?? 0,
Process = process,
IsDelete = isDelete,
IsWrite = isWrite
};
return true;
}
private static IReadOnlyDictionary<string, string> ReadEventData(EventRecord record)
{
XDocument document = XDocument.Parse(record.ToXml());
return document.Descendants().Where(element => element.Name.LocalName == "Data")
.Where(element => element.Attribute("Name") is not null)
.ToDictionary(element => element.Attribute("Name")!.Value, element => element.Value.Trim(), StringComparer.OrdinalIgnoreCase);
}
private static bool TryParseAccessMask(string value, out uint result)
{
string normalized = value.Trim();
NumberStyles style = NumberStyles.Integer;
if (normalized.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[2..];
style = NumberStyles.AllowHexSpecifier;
}
return uint.TryParse(normalized, style, CultureInfo.InvariantCulture, out result);
}
private static List<FileAuditActivity> MergeWithState(List<FileAuditActivity> observed, DateTimeOffset windowStart, List<string> errors)
{
FileChurnState stored = LoadState(errors);
long highestObservedRecordId = observed.Count == 0 ? 0 : observed.Max(activity => activity.RecordId);
bool securityLogReset = stored.LastSecurityRecordId > 0 && highestObservedRecordId > 0 && highestObservedRecordId < stored.LastSecurityRecordId;
IEnumerable<FileAuditActivity> fresh = securityLogReset
? observed
: observed.Where(activity => activity.RecordId == 0 || activity.RecordId > stored.LastSecurityRecordId);
List<FileAuditActivity> rolling = (securityLogReset ? [] : stored.Activities)
.Concat(fresh)
.Where(activity => activity.Timestamp >= windowStart)
.GroupBy(activity => activity.RecordId > 0 ? activity.RecordId.ToString(CultureInfo.InvariantCulture) : $"{activity.Timestamp:O}|{activity.Process}|{activity.IsDelete}|{activity.IsWrite}")
.Select(group => group.First())
.OrderBy(activity => activity.Timestamp)
.ToList();
SaveState(new FileChurnState
{
LastSecurityRecordId = securityLogReset ? highestObservedRecordId : Math.Max(stored.LastSecurityRecordId, highestObservedRecordId),
Activities = rolling
}, errors);
return rolling;
}
private static RansomwareFileChurnSummary BuildSummary(List<FileAuditActivity> activities, int windowMinutes, bool isTruncated)
{
return new RansomwareFileChurnSummary
{
Enabled = true,
DataAvailable = true,
IsTruncated = isTruncated,
WindowMinutes = windowMinutes,
FileOperationCount = activities.Count,
DeleteOperationCount = activities.Count(activity => activity.IsDelete),
WriteOperationCount = activities.Count(activity => activity.IsWrite),
DistinctProcessCount = activities.Select(activity => activity.Process).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
TopProcesses = activities.GroupBy(activity => activity.Process, StringComparer.OrdinalIgnoreCase)
.Select(group => new RansomwareFileChurnProcess
{
Process = group.Key,
DeleteOperationCount = group.Count(activity => activity.IsDelete),
WriteOperationCount = group.Count(activity => activity.IsWrite)
})
.OrderByDescending(process => process.DeleteOperationCount + process.WriteOperationCount)
.ThenBy(process => process.Process, StringComparer.OrdinalIgnoreCase)
.Take(5)
.ToList()
};
}
private static FileChurnState LoadState(List<string> errors)
{
string path = GetStatePath();
if (!File.Exists(path))
{
return new FileChurnState();
}
try
{
return JsonSerializer.Deserialize<FileChurnState>(File.ReadAllText(path), JsonOptions.Default) ?? new FileChurnState();
}
catch (Exception exception)
{
errors.Add($"Ransomware file churn state could not be read: {exception.Message}");
return new FileChurnState();
}
}
private static void SaveState(FileChurnState state, List<string> errors)
{
try
{
string path = GetStatePath();
string directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
string temporaryPath = path + ".tmp";
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(state, JsonOptions.Default));
File.Move(temporaryPath, path, overwrite: true);
}
catch (Exception exception)
{
errors.Add($"Ransomware file churn state could not be saved: {exception.Message}");
}
}
private static string GetStatePath()
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel", "state", StateFileName);
}
private sealed record FileChurnState
{
public long LastSecurityRecordId { get; init; }
public List<FileAuditActivity> Activities { get; init; } = [];
}
private sealed record FileAuditActivity
{
public DateTimeOffset Timestamp { get; init; }
public long RecordId { get; init; }
public string Process { get; init; } = string.Empty;
public bool IsDelete { get; init; }
public bool IsWrite { get; init; }
}
}

View File

@@ -4,13 +4,14 @@ internal sealed record ScanOptions
{ {
public const string Usage = """ public const string Usage = """
Usage: Usage:
OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help] OCSentinelCli [--output <path>] [--lookback-days <n>] [--top <n>] [--config <path>] [--client-config <path>] [--vulnerability-csv <path>] [--json-only] [--ninja-output] [--fail-on-attacks] [--fail-on-threshold] [--help]
Options: Options:
--output <path> Write the JSON report to the given file. --output <path> Write the JSON report to the given file.
--lookback-days <n> Only include events newer than now minus n days. Default: 30 --lookback-days <n> Only include events newer than now minus n days. Default: 30
--top <n> Number of aggregated source IPs to show. Default: 10 --top <n> Number of aggregated source IPs to show. Default: 10
--config <path> Load thresholds, path overrides, and exclusions from JSON. --config <path> Load thresholds, path overrides, and exclusions from JSON.
--client-config <path> Load persisted NinjaOne identity and upload settings from JSON.
--vulnerability-csv <path> --vulnerability-csv <path>
Correlate local attack results with exported CVE data for this host. Correlate local attack results with exported CVE data for this host.
--json-only Print only JSON to stdout. --json-only Print only JSON to stdout.
@@ -36,6 +37,8 @@ Options:
public string? ConfigPath { get; init; } public string? ConfigPath { get; init; }
public string? ClientConfigPath { get; init; }
public string? VulnerabilityCsvPath { get; init; } public string? VulnerabilityCsvPath { get; init; }
public bool ShowHelp { get; init; } public bool ShowHelp { get; init; }
@@ -72,6 +75,9 @@ Options:
case "--config": case "--config":
options = options with { ConfigPath = ReadValue(args, ref i, arg) }; options = options with { ConfigPath = ReadValue(args, ref i, arg) };
break; break;
case "--client-config":
options = options with { ClientConfigPath = ReadValue(args, ref i, arg) };
break;
case "--vulnerability-csv": case "--vulnerability-csv":
options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) }; options = options with { VulnerabilityCsvPath = ReadValue(args, ref i, arg) };
break; break;

View File

@@ -38,6 +38,8 @@ internal sealed class N8nUploadClient
request.Headers.Add("X-ATN-Payload-SHA256", payloadHash); request.Headers.Add("X-ATN-Payload-SHA256", payloadHash);
request.Headers.Add("X-ATN-Signature", signature); request.Headers.Add("X-ATN-Signature", signature);
try
{
using HttpResponseMessage response = httpClient.Send(request); using HttpResponseMessage response = httpClient.Send(request);
string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); string responseText = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
@@ -50,6 +52,18 @@ internal sealed class N8nUploadClient
PayloadSha256 = payloadHash PayloadSha256 = payloadHash
}; };
} }
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException)
{
return new UploadResult
{
Success = false,
StatusCode = 0,
Message = exception.Message,
Nonce = nonce,
PayloadSha256 = payloadHash
};
}
}
private static string ComputeSha256(string value) private static string ComputeSha256(string value)
{ {

View File

@@ -0,0 +1,106 @@
using System.Text.Json;
using OCSentinelCli.Models;
namespace OCSentinelCli.Transport;
internal sealed record UploadHealth
{
public DateTimeOffset? LastUploadAttemptUtc { get; init; }
public DateTimeOffset? LastSuccessfulUploadUtc { get; init; }
public string LastUploadStatus { get; init; } = "not-attempted";
public string LastUploadError { get; init; } = string.Empty;
public int QueuedReportCount { get; init; }
}
internal sealed class UploadQueue
{
private readonly string queueDirectory;
private readonly string healthPath;
private readonly int maxReports;
public UploadQueue(int maxReports)
{
string root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "OCSentinel");
queueDirectory = Path.Combine(root, "upload-queue");
healthPath = Path.Combine(root, "state", "upload-health.json");
this.maxReports = Math.Clamp(maxReports, 10, 500);
}
public UploadHealth LoadHealth()
{
try
{
if (!File.Exists(healthPath))
{
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
}
UploadHealth? health = JsonSerializer.Deserialize<UploadHealth>(File.ReadAllText(healthPath), JsonOptions.Default);
return (health ?? new UploadHealth()) with { QueuedReportCount = GetQueueDepth() };
}
catch (Exception)
{
return new UploadHealth { QueuedReportCount = GetQueueDepth() };
}
}
public void SaveHealth(UploadHealth health)
{
Directory.CreateDirectory(Path.GetDirectoryName(healthPath)!);
WriteAtomically(healthPath, JsonSerializer.Serialize(health, JsonOptions.Default));
}
public UploadResult? Drain(N8nUploadClient client, string webhookUrl, string machineName, string clientVersion, string secret, int timeoutSeconds)
{
foreach (string path in GetQueuedPaths())
{
string payload = File.ReadAllText(path);
UploadResult result = client.UploadJson(webhookUrl, machineName, clientVersion, payload, secret, timeoutSeconds);
if (!result.Success)
{
return result;
}
File.Delete(path);
}
return null;
}
public int Enqueue(string payloadJson)
{
Directory.CreateDirectory(queueDirectory);
string fileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}.json";
WriteAtomically(Path.Combine(queueDirectory, fileName), payloadJson);
foreach (string stalePath in GetQueuedPaths().Take(Math.Max(0, GetQueueDepth() - maxReports)))
{
File.Delete(stalePath);
}
return GetQueueDepth();
}
public int GetQueueDepth()
{
return Directory.Exists(queueDirectory) ? Directory.EnumerateFiles(queueDirectory, "*.json").Count() : 0;
}
private IEnumerable<string> GetQueuedPaths()
{
return Directory.Exists(queueDirectory)
? Directory.EnumerateFiles(queueDirectory, "*.json").OrderBy(static path => path, StringComparer.Ordinal)
: Enumerable.Empty<string>();
}
private static void WriteAtomically(string path, string content)
{
string temporaryPath = path + ".tmp";
File.WriteAllText(temporaryPath, content);
File.Move(temporaryPath, path, true);
}
}

View File

@@ -0,0 +1,58 @@
using OCSentinelCli;
using Xunit;
namespace OCSentinelCli.Tests;
public sealed class ExchangeIisAccountCorrelatorTests
{
[Fact]
public void EnrichesAnIisFailureFromOneMatchingSecurityFailure()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "user@example.test")
];
AttackEvent enriched = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("user@example.test", enriched.Username);
}
[Fact]
public void KeepsIisAccountUnresolvedWhenMultipleAccountsMatch()
{
DateTimeOffset timestamp = new(2026, 8, 5, 4, 0, 0, TimeSpan.Zero);
List<AttackEvent> events =
[
IisFailure(timestamp),
SecurityFailure(timestamp.AddSeconds(20), "first@example.test"),
SecurityFailure(timestamp.AddSeconds(30), "second@example.test")
];
AttackEvent unresolved = Assert.Single(ExchangeIisAccountCorrelator.Enrich(events), attack => attack.Source == "IIS W3C");
Assert.Equal("[not logged]", unresolved.Username);
}
private static AttackEvent IisFailure(DateTimeOffset timestamp) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = "[not logged]",
Source = "IIS W3C",
Target = "Exchange ActiveSync login"
};
private static AttackEvent SecurityFailure(DateTimeOffset timestamp, string username) => new()
{
Timestamp = timestamp,
SourceIp = "198.51.100.8",
Username = username,
Source = "Security",
Service = "Network",
Target = "Windows login",
InstanceId = 4625
};
}

View File

@@ -0,0 +1,67 @@
using OCSentinelCli;
using Xunit;
namespace OCSentinelCli.Tests;
public sealed class ExchangeIisLogParserTests
{
[Fact]
public void ParsesFailedOwaLoginWithActualIisPort()
{
string[] lines =
[
"#Fields: date time s-ip cs-method cs-uri-stem cs-username c-ip s-port sc-status",
"2026-08-02 04:15:00 10.0.0.10 POST /owa/auth.owa - 203.0.113.20 443 401"
];
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
Assert.Equal("Exchange OWA login", attack.Target);
Assert.Equal("OWA", attack.Service);
Assert.Equal(443, attack.DestinationPort);
Assert.Equal("/owa/auth.owa", attack.Endpoint);
Assert.Equal("203.0.113.20", attack.SourceIp);
}
[Fact]
public void ParsesMapiAndIgnoresSuccessfulRequests()
{
string[] lines =
[
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status",
"2026-08-02 04:15:00 /mapi/emsmdb/ user@example.test 198.51.100.8 444 403",
"2026-08-02 04:16:00 /ecp/ user@example.test 198.51.100.9 443 200"
];
AttackEvent attack = Assert.Single(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
Assert.Equal("MAPI/HTTP", attack.Service);
Assert.Equal(444, attack.DestinationPort);
Assert.Equal("user@example.test", attack.Username);
}
[Fact]
public void IgnoresNormalIisAuthenticationHandshake()
{
string[] lines =
[
"#Fields: date time cs-uri-stem cs-username c-ip s-port cs(User-Agent) sc-status sc-substatus",
"2026-08-02 04:15:00 /mapi/emsmdb/ - 198.51.100.8 443 Outlook 401 1",
"2026-08-02 04:15:01 /mapi/emsmdb/ user@example.test 198.51.100.8 443 Outlook 200 0"
];
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void IgnoresNonCredentialIis401Substatus()
{
string[] lines =
[
"#Fields: date time cs-uri-stem cs-username c-ip s-port sc-status sc-substatus",
"2026-08-02 04:15:00 /ews/Exchange.asmx - 198.51.100.8 443 401 0"
];
Assert.Empty(ExchangeIisLogParser.ParseLines(lines, new DateTimeOffset(2026, 8, 2, 4, 0, 0, TimeSpan.Zero)));
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OCSentinelCli\OCSentinelCli.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System.Runtime.Versioning;
using System.Text.Json;
using Xunit;
namespace OCSentinelCli.Tests;
[SupportedOSPlatform("windows")]
public sealed class RansomwareBetaTests
{
[Fact]
public void RansomwareBetaIsEnabledByDefault()
{
Assert.True(new ScannerConfiguration().RansomwareBetaEnabled);
Assert.False(new ScannerConfiguration().RansomwareBetaAlertingEnabled);
}
[Fact]
public void DisabledRansomwareBetaDoesNotReportSensorCoverage()
{
var summary = RansomwareBetaDetector.Scan(new ScannerConfiguration { RansomwareBetaEnabled = false }, []);
Assert.False(summary.Enabled);
Assert.Empty(summary.Sensors);
}
[Fact]
public void SensorCoverageIsIncludedInTheJsonReport()
{
var summary = new RansomwareBetaSummary
{
Enabled = true,
Sensors = [new RansomwareSensorStatus { Name = "security-process-4688", Enabled = true, Available = true, State = "available", EventCount = 4 }]
};
string json = JsonSerializer.Serialize(summary, JsonOptions.Default);
Assert.Contains("\"Sensors\"", json);
Assert.Contains("security-process-4688", json);
}
[Fact]
public void FileChurnBelowBothThresholdsDoesNotCreateSignal()
{
var summary = new RansomwareFileChurnSummary
{
Enabled = true,
DataAvailable = true,
DeleteOperationCount = 49,
WriteOperationCount = 500
};
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
Assert.Null(signal);
}
[Fact]
public void CriticalFileChurnCreatesOnlyMediumConfidenceSignal()
{
var summary = new RansomwareFileChurnSummary
{
Enabled = true,
DataAvailable = true,
WindowMinutes = 15,
DeleteOperationCount = 200,
WriteOperationCount = 1000,
DistinctProcessCount = 1,
TopProcesses = [new RansomwareFileChurnProcess { Process = "encryptor.exe", DeleteOperationCount = 200, WriteOperationCount = 1000 }]
};
RansomwareSignal? signal = RansomwareFileChurnDetector.CreateSignal(summary, new ScannerConfiguration());
Assert.NotNull(signal);
Assert.Equal("file-churn-critical", signal.Category);
Assert.Equal("medium", signal.Confidence);
Assert.Equal("encryptor.exe", signal.Process);
}
[Fact]
public void PassiveBetaCannotElevateNinjaAlertState()
{
var summary = new RansomwareBetaSummary { Enabled = true, State = "critical" };
Assert.False(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: false));
Assert.True(RansomwareAlertPolicy.CanElevate(summary, alertingEnabled: true));
}
}