diff --git a/documentation/revision-history.md b/documentation/revision-history.md index dad87d810e..510d28c1f6 100644 --- a/documentation/revision-history.md +++ b/documentation/revision-history.md @@ -734,3 +734,48 @@ Not supported any longer are: - change_history entries carry a module column naming the subsystem that wrote them, currently modelling or workflow. It selects which enum the object_type column uses and limits the modelling roles to modelling entries. Existing entries are migrated as modelling. - change history entries written through the REST workflow endpoints name the authenticated caller instead of the middleware server, and carry that caller's user id in changer_id. changer_id therefore stays empty only for changes made by automation - background jobs and unauthenticated internal callers - so an audit can tell an automated change from a human one and resolve the human one to a user record even after a directory rename. - new REST endpoint workflow/getAuditProofCriticalChanges returns the audit proof critical changes of a workflow ticket: the change history entries of that ticket which are marked as audit proof critical, meaning a content change made in a user session by someone other than the requester. It is available to admins and auditors and reports change time, change user name, change user id and the recorded change text, newest first. The change user id is the trustworthy attribution, as the change user name is free text supplied by the writer of the change; it stays empty for changes made by automation. Change times carry the wall clock of the installation and no offset, because the underlying column is timezone naive. A ticketId that names no workflow ticket is answered with 404 and the error message "Workflow ticket with 'ticketId' does not exist." instead of an empty changes list, so a mistyped or already deleted ticket id cannot be read as a ticket without audit proof critical changes; an existing ticket without such changes, or one whose changes the supplied filter excludes, still answers 200 with an empty list. + +## 9.5.3 - 21.09.2026 +- security fix (SEC-01): the auditor role could update the columns of its own uiuser row that define + who the account is - uuid, uiuser_username, tenant_id, ldap_connection_id and the password flags. + A login and a token refresh derive the roles of a user by resolving uiuser.uuid against LDAP, so + rewriting that column let an auditor have its authorization rebuilt as a different, more + privileged subject. Self-service updates of uiuser are now limited to uiuser_language for every + role except the middleware, and each of those permissions constrains the row after the update as + well as before it, so an update cannot move a row to another subject +- security fix (SEC-04): the LDAP connection test is a POST instead of a GET carrying a body, so + the credentials entered for a test are no longer part of a request that proxies and http clients + handle inconsistently and may cache or log +- security fix (SEC-06): the workflow action endpoint could be asked to execute the side effects of + a state change more than once. The state of a ticket or task is persisted before its actions are + requested, so the endpoint could only check that the object already stands in the requested state, + which stays true after the transition happened and therefore let the same request be replayed to + send mails, raise external requests or create flows again. The new table + request.state_change_execution records which transition the actions of an object were last + executed for; the middleware claims it in a single statement and a repeated request now returns + without executing anything +- security fix (SEC-09): the flow catalog tables were readable without restriction by every workflow + role, and a request element could be pointed at any flow entry by id. A requester could therefore + enumerate flow objects an administrator had hidden or retired, attach them to a task, and reference + the canonical any-IP-protocol service, which is an internal representation the platform writes for + itself. The same eligibility predicate is now enforced at all three layers: the Hasura select + permissions of the workflow roles on flow.nwobject, flow.svcobject and flow.timeobject return only + entries that are offered in the request module, not retired and in a live state, and exclude + negative protocol ids; the Hasura insert and update permissions on request.reqelement refuse a flow + object, flow group or protocol id that does not meet it; and the flow creation refuses an element + whose stored flow id names an entry that has since been hidden or retired instead of following it. + The request module reports a refused element instead of failing the save with a permission error. + Negative protocol ids stay reserved for the middleware, which continues to attach the canonical any + service when it turns a protocol-agnostic request into a flow +- security fix (SEC-10): report and notification html is assembled from stored values - object, service, + device, management and owner names and section headers - and several of those were written into the + generated document without being encoded for the place they land in. The headless browser that renders + an export to pdf loaded subresources, so markup smuggled into such a value made the server itself issue + outbound requests. The renderer now runs with scripting off and aborts every request except the document + it starts from, its host name resolution is disabled, and exported documents carry a content security + policy that denies everything but their own inline styling. The link a report builds around an object + encodes each part for its own context and refuses a target that does not stay on the document, the table + of contents no longer turns encoded markup from the body back into live markup, and the headings and + the object, service and user tables of a rules report encode every imported field they show - name, uid, + comment and group members. Report output changes in two visible ways: object anchor names are now quoted, + and exported documents carry the extra policy element \ No newline at end of file diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index b19d09c23b..a603778be4 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -1,5 +1,5 @@ ### general settings -product_version: "9.5.2" +product_version: "9.5.3" # The oldest product version an upgrade may start from, enforced by # roles/common/tasks/validate-upgrade-source-version.yml before anything touches the # database. It exists because 9.5.0 removed the migration files below 8.0: diff --git a/roles/api/files/replace_metadata.json b/roles/api/files/replace_metadata.json index 8db52cec9c..1757193cfe 100644 --- a/roles/api/files/replace_metadata.json +++ b/roles/api/files/replace_metadata.json @@ -2196,7 +2196,28 @@ "ip_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2213,7 +2234,28 @@ "ip_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2247,7 +2289,28 @@ "ip_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2264,7 +2327,28 @@ "ip_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2281,7 +2365,28 @@ "ip_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" } @@ -2551,7 +2656,33 @@ "port_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } }, "comment": "" }, @@ -2569,7 +2700,33 @@ "port_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } }, "comment": "" }, @@ -2605,7 +2762,33 @@ "port_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } }, "comment": "" }, @@ -2623,7 +2806,33 @@ "port_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } }, "comment": "" }, @@ -2641,7 +2850,33 @@ "port_start", "removed_date" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } }, "comment": "" } @@ -2726,7 +2961,28 @@ "removed_date", "start_time" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2743,7 +2999,28 @@ "removed_date", "start_time" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2777,7 +3054,28 @@ "removed_date", "start_time" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2794,7 +3092,28 @@ "removed_date", "start_time" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" }, @@ -2811,7 +3130,28 @@ "removed_date", "start_time" ], - "filter": {} + "filter": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } }, "comment": "" } @@ -29095,35 +29435,29 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { "role": "auditor", "permission": { "columns": [ - "ldap_connection_id", - "tenant_id", - "uiuser_email", - "uiuser_end_date", - "uiuser_first_name", - "uiuser_id", - "uiuser_language", - "uiuser_last_login", - "uiuser_last_name", - "uiuser_last_password_change", - "uiuser_password_must_be_changed", - "uiuser_pwd_history", - "uiuser_start_date", - "uiuser_username", - "uuid" + "uiuser_language" ], "filter": { "uuid": { - "_eq": "X-Hasura-uuid" + "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29137,7 +29471,11 @@ "_eq": "x-hasura-uuid" } }, - "check": {} + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29151,7 +29489,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29189,7 +29531,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29203,7 +29549,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29217,7 +29567,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29231,7 +29585,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29245,7 +29603,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29259,7 +29621,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } }, { @@ -29273,7 +29639,11 @@ "_eq": "x-hasura-uuid" } }, - "check": null + "check": { + "uuid": { + "_eq": "x-hasura-uuid" + } + } } } ], @@ -33770,7 +34140,161 @@ { "role": "approver", "permission": { - "check": {}, + "check": { + "_and": [ + { + "_or": [ + { + "ip_proto_id": { + "_is_null": true + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + }, + { + "_or": [ + { + "flow_nwobj_id": { + "_is_null": true + } + }, + { + "flow_nwobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_nwgrp_id": { + "_is_null": true + } + }, + { + "flow_nwgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcobj_id": { + "_is_null": true + } + }, + { + "flow_svcobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcgrp_id": { + "_is_null": true + } + }, + { + "flow_svcgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + } + ] + }, "columns": [ "id", "network_object_id", @@ -33830,7 +34354,161 @@ { "role": "modeller", "permission": { - "check": {}, + "check": { + "_and": [ + { + "_or": [ + { + "ip_proto_id": { + "_is_null": true + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + }, + { + "_or": [ + { + "flow_nwobj_id": { + "_is_null": true + } + }, + { + "flow_nwobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_nwgrp_id": { + "_is_null": true + } + }, + { + "flow_nwgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcobj_id": { + "_is_null": true + } + }, + { + "flow_svcobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcgrp_id": { + "_is_null": true + } + }, + { + "flow_svcgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + } + ] + }, "columns": [ "id", "network_object_id", @@ -33860,7 +34538,161 @@ { "role": "requester", "permission": { - "check": {}, + "check": { + "_and": [ + { + "_or": [ + { + "ip_proto_id": { + "_is_null": true + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + }, + { + "_or": [ + { + "flow_nwobj_id": { + "_is_null": true + } + }, + { + "flow_nwobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_nwgrp_id": { + "_is_null": true + } + }, + { + "flow_nwgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcobj_id": { + "_is_null": true + } + }, + { + "flow_svcobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcgrp_id": { + "_is_null": true + } + }, + { + "flow_svcgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + } + ] + }, "columns": [ "id", "network_object_id", @@ -34240,7 +35072,161 @@ "flow_svcgrp_id" ], "filter": {}, - "check": null + "check": { + "_and": [ + { + "_or": [ + { + "ip_proto_id": { + "_is_null": true + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + }, + { + "_or": [ + { + "flow_nwobj_id": { + "_is_null": true + } + }, + { + "flow_nwobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_nwgrp_id": { + "_is_null": true + } + }, + { + "flow_nwgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcobj_id": { + "_is_null": true + } + }, + { + "flow_svcobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcgrp_id": { + "_is_null": true + } + }, + { + "flow_svcgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + } + ] + } } }, { @@ -34284,7 +35270,161 @@ "flow_svcgrp_id" ], "filter": {}, - "check": null + "check": { + "_and": [ + { + "_or": [ + { + "ip_proto_id": { + "_is_null": true + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + }, + { + "_or": [ + { + "flow_nwobj_id": { + "_is_null": true + } + }, + { + "flow_nwobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_nwgrp_id": { + "_is_null": true + } + }, + { + "flow_nwgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcobj_id": { + "_is_null": true + } + }, + { + "flow_svcobject": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + }, + { + "ip_proto_id": { + "_gte": 0 + } + } + ] + } + } + ] + }, + { + "_or": [ + { + "flow_svcgrp_id": { + "_is_null": true + } + }, + { + "flow_svcgroup": { + "_and": [ + { + "show_in_request_module": { + "_eq": true + } + }, + { + "removed_date": { + "_is_null": true + } + }, + { + "state": { + "_in": [ + "requested", + "implemented" + ] + } + } + ] + } + } + ] + } + ] + } } } ], @@ -35593,6 +36733,62 @@ } ] }, + { + "table": { + "name": "state_change_execution", + "schema": "request" + }, + "insert_permissions": [ + { + "role": "middleware-server", + "permission": { + "check": {}, + "columns": [ + "object_scope", + "object_id", + "from_state_id", + "to_state_id", + "executed_at", + "executed_by" + ] + }, + "comment": "" + } + ], + "select_permissions": [ + { + "role": "middleware-server", + "permission": { + "columns": [ + "object_scope", + "object_id", + "from_state_id", + "to_state_id", + "executed_at", + "executed_by" + ], + "filter": {} + }, + "comment": "" + } + ], + "update_permissions": [ + { + "role": "middleware-server", + "permission": { + "columns": [ + "from_state_id", + "to_state_id", + "executed_at", + "executed_by" + ], + "filter": {}, + "check": {} + }, + "comment": "" + } + ] + }, { "table": { "name": "state_action", diff --git a/roles/common/files/fwo-api-calls/flow/getRequestableFlowNwObjectIds.graphql b/roles/common/files/fwo-api-calls/flow/getRequestableFlowNwObjectIds.graphql new file mode 100644 index 0000000000..71dc125347 --- /dev/null +++ b/roles/common/files/fwo-api-calls/flow/getRequestableFlowNwObjectIds.graphql @@ -0,0 +1,12 @@ +query getRequestableFlowNwObjectIds($nwObjIds: [bigint!]!) { + nwobjects: flow_nwobject( + where: { + nwobj_id: { _in: $nwObjIds } + show_in_request_module: { _eq: true } + removed_date: { _is_null: true } + state: { _in: ["requested", "implemented"] } + } + ) { + nwobj_id + } +} diff --git a/roles/common/files/fwo-api-calls/flow/getRequestableFlowSvcObjectIds.graphql b/roles/common/files/fwo-api-calls/flow/getRequestableFlowSvcObjectIds.graphql new file mode 100644 index 0000000000..7ad56eec26 --- /dev/null +++ b/roles/common/files/fwo-api-calls/flow/getRequestableFlowSvcObjectIds.graphql @@ -0,0 +1,13 @@ +query getRequestableFlowSvcObjectIds($svcObjIds: [bigint!]!) { + svcobjects: flow_svcobject( + where: { + svcobj_id: { _in: $svcObjIds } + show_in_request_module: { _eq: true } + removed_date: { _is_null: true } + state: { _in: ["requested", "implemented"] } + ip_proto_id: { _gte: 0 } + } + ) { + svcobj_id + } +} diff --git a/roles/common/files/fwo-api-calls/request/claimStateChangeExecution.graphql b/roles/common/files/fwo-api-calls/request/claimStateChangeExecution.graphql new file mode 100644 index 0000000000..ceaad0dffe --- /dev/null +++ b/roles/common/files/fwo-api-calls/request/claimStateChangeExecution.graphql @@ -0,0 +1,41 @@ +# Claims the execution of the workflow actions belonging to one state transition. +# +# The insert succeeds for an object whose actions have not run yet. For an object that already has a +# row, the transition is only claimed when it differs from the one recorded there: a replay repeats +# the transition that was executed last and is therefore refused, while entering a state again after +# leaving it carries a different transition and is claimed normally. +# Postgres evaluates the conflict and the guard in one statement, so two requests arriving together +# cannot both claim it. affected_rows is 1 when the caller may execute the actions and 0 when the +# transition was already executed. + +mutation claimStateChangeExecution( + $objectScope: String! + $objectId: bigint! + $fromStateId: Int! + $toStateId: Int! + $executedBy: String + $executedAt: timestamptz! +) { + insert_request_state_change_execution( + objects: { + object_scope: $objectScope + object_id: $objectId + from_state_id: $fromStateId + to_state_id: $toStateId + executed_by: $executedBy + executed_at: $executedAt + } + on_conflict: { + constraint: state_change_execution_pkey + update_columns: [from_state_id, to_state_id, executed_at, executed_by] + where: { + _or: [ + { from_state_id: { _neq: $fromStateId } } + { to_state_id: { _neq: $toStateId } } + ] + } + } + ) { + affected_rows + } +} diff --git a/roles/database/files/sql/creation/fworch-create-tables-request.sql b/roles/database/files/sql/creation/fworch-create-tables-request.sql index 6589fd0f05..8871f5ab5a 100644 --- a/roles/database/files/sql/creation/fworch-create-tables-request.sql +++ b/roles/database/files/sql/creation/fworch-create-tables-request.sql @@ -293,3 +293,22 @@ create table request.impltask target_begin_date Timestamp, target_end_date Timestamp ); + +-- Records which state transition the workflow actions of an object were last executed for, so that +-- re-submitting an already executed transition cannot fire its side effects (mail, external +-- request, flow creation) a second time. The object's state is persisted by the caller before the +-- actions are requested, so the request alone cannot say whether it is the first one for that +-- transition; this row is what makes the execution claimable exactly once. +-- One row per object is enough: a replay repeats the transition that was executed last, while +-- legitimately entering a state again requires leaving it first, which writes a different +-- transition here in between. +create table request.state_change_execution +( + object_scope Varchar NOT NULL, + object_id bigint NOT NULL, + from_state_id int NOT NULL, + to_state_id int NOT NULL, + executed_at Timestamp with time zone NOT NULL DEFAULT now(), + executed_by Varchar, + CONSTRAINT state_change_execution_pkey PRIMARY KEY (object_scope, object_id) +); diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index 76a23b4d42..609d908b00 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -3305,6 +3305,8 @@ INSERT INTO txt VALUES ('flow_creation_mixed_address_families', 'German', 'Adr INSERT INTO txt VALUES ('flow_creation_mixed_address_families', 'English', 'Address range starts and ends in different address families'); INSERT INTO txt VALUES ('flow_creation_unreadable_address', 'German', 'Adresse konnte nicht gelesen werden'); INSERT INTO txt VALUES ('flow_creation_unreadable_address', 'English', 'Address could not be read'); +INSERT INTO txt VALUES ('flow_creation_ineligible_flow_object', 'German', 'Flow-Objekt wird im Antragsmodul nicht mehr angeboten'); +INSERT INTO txt VALUES ('flow_creation_ineligible_flow_object', 'English', 'Flow object is no longer offered in the request module'); INSERT INTO txt VALUES ('modelling_objects_updated', 'German', ' Modellierungsobjekte aktualisiert'); INSERT INTO txt VALUES ('modelling_objects_updated', 'English', ' modelling objects updated'); INSERT INTO txt VALUES ('app_zone_pattern', 'German', 'Muster App Zone'); @@ -4629,6 +4631,8 @@ INSERT INTO txt VALUES ('E8015', 'German', 'Eigentümer konnte nicht zugeor INSERT INTO txt VALUES ('E8015', 'English', 'Owner could not be assigned'); INSERT INTO txt VALUES ('E8016', 'German', 'Eigentümer konnte nicht entfernt werden'); INSERT INTO txt VALUES ('E8016', 'English', 'Owner could not be removed'); +INSERT INTO txt VALUES ('E8017', 'German', 'Die Aufgabe verweist auf ein Flow-Objekt oder ein Protokoll, das im Antragsmodul nicht angefordert werden darf. Bitte die betroffenen Elemente neu auswählen.'); +INSERT INTO txt VALUES ('E8017', 'English', 'The task references a flow object or protocol that may not be requested in the request module. Please select the affected elements again.'); INSERT INTO txt VALUES ('E8101', 'German', 'Email-Versand kann nicht getestet werden, da der aktuell angemeldete Nutzer keine Email-Adresse hinterlegt hat.'); INSERT INTO txt VALUES ('E8101', 'English', 'Sending of emails cannot be tested because the logged-in user does not have an email address.'); diff --git a/roles/database/files/upgrade/9.5.3.sql b/roles/database/files/upgrade/9.5.3.sql new file mode 100644 index 0000000000..e91393480c --- /dev/null +++ b/roles/database/files/upgrade/9.5.3.sql @@ -0,0 +1,26 @@ +-- SEC-06: the workflow action endpoint executes the side effects of a state change (mail, external +-- request, flow creation) for a transition the caller describes. The object's state is persisted by +-- the caller before the actions are requested, so the only check available was that the object +-- already stands in the requested new state - which stays true once the transition happened, and +-- therefore let the same request be submitted again to fire the side effects a second time. +-- This table records which transition the actions of an object were last executed for. The +-- middleware claims it in one statement before it runs anything, so the execution can be claimed +-- exactly once. +-- One row per object is enough: a replay repeats the transition that was executed last, while +-- legitimately entering a state again requires leaving it first, which writes a different +-- transition here in between. +CREATE TABLE IF NOT EXISTS request.state_change_execution +( + object_scope VARCHAR NOT NULL, + object_id BIGINT NOT NULL, + from_state_id INT NOT NULL, + to_state_id INT NOT NULL, + executed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + executed_by VARCHAR, + CONSTRAINT state_change_execution_pkey PRIMARY KEY (object_scope, object_id) +); + +-- Existing objects have no recorded execution, so the first transition after this upgrade is +-- claimable for each of them. That is the safe direction: it can let one already executed +-- transition be re-requested once, exactly as before this upgrade, rather than blocking a +-- legitimate promote of every ticket that is currently in flight. diff --git a/roles/lib/files/FWO.Api.Client/Queries/FlowQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/FlowQueries.cs index 00d467af66..0723b2fbe8 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/FlowQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/FlowQueries.cs @@ -22,6 +22,8 @@ public class FlowQueries : Queries public static readonly string getFlowRequestNwObjectCatalog; public static readonly string getFlowRequestSvcObjectCatalog; public static readonly string getFlowRequestTimeObjectCatalog; + public static readonly string getRequestableFlowNwObjectIds; + public static readonly string getRequestableFlowSvcObjectIds; public static readonly string getFlowSelectableManagements; public static readonly string getFlowServiceGroups; public static readonly string getFlowServiceObjectId; @@ -104,6 +106,12 @@ static FlowQueries() getFlowRequestTimeObjectCatalog = GetQueryText("flow/getFlowRequestTimeObjectCatalog.graphql"); + getRequestableFlowNwObjectIds = + GetQueryText("flow/getRequestableFlowNwObjectIds.graphql"); + + getRequestableFlowSvcObjectIds = + GetQueryText("flow/getRequestableFlowSvcObjectIds.graphql"); + getFlowSelectableManagements = GetQueryText("flow/getFlowSelectableManagements.graphql"); diff --git a/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs index 68ca529be7..6a2ae8ae33 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs @@ -86,6 +86,7 @@ public class RequestQueries : Queries public static readonly string addCommentToApproval; public static readonly string addOwnerToReqTask; public static readonly string removeOwnerFromReqTask; + public static readonly string claimStateChangeExecution; static RequestQueries() @@ -155,6 +156,7 @@ static RequestQueries() replaceStateMatrixConfiguration = GetQueryText("request/replaceStateMatrixConfiguration.graphql"); getWorkflowVisibilityGroups = GetQueryText("request/getWorkflowVisibilityGroups.graphql"); getStateMatrixTransitionGroups = GetQueryText("request/getStateMatrixTransitionGroups.graphql"); + claimStateChangeExecution = GetQueryText("request/claimStateChangeExecution.graphql"); createWorkflowVisibilityGroup = GetQueryText("request/createWorkflowVisibilityGroup.graphql"); updateWorkflowVisibilityGroup = GetQueryText("request/updateWorkflowVisibilityGroup.graphql"); deleteWorkflowVisibilityGroup = GetQueryText("request/deleteWorkflowVisibilityGroup.graphql"); diff --git a/roles/lib/files/FWO.Basics/GlobalConstants.cs b/roles/lib/files/FWO.Basics/GlobalConstants.cs index a5579f39bd..04f88528f4 100644 --- a/roles/lib/files/FWO.Basics/GlobalConstants.cs +++ b/roles/lib/files/FWO.Basics/GlobalConstants.cs @@ -78,6 +78,15 @@ public struct GlobalConst public const string kUndefinedText = "(undefined text)"; public const string kStyleHighlightedRed = "color: red;"; + + /// + /// Content security policy of an exported report or notification document. + /// The export is a self contained document: it carries its styles inline and loads nothing else, + /// so everything but inline styling is denied. This keeps a reference that was smuggled into a + /// stored value from being fetched - by the headless browser that renders the pdf, and by the + /// browser of whoever opens the exported html afterwards. + /// + public const string kExportContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; font-src 'none'; img-src 'none'; script-src 'none'; frame-src 'none'; connect-src 'none'"; public const string kStyleHighlightedGreen = "color: green;"; public const string kStyleDeleted = "color: red; text-decoration: line-through red;"; public const string kStyleAdded = "color: green; text-decoration: bold;"; diff --git a/roles/lib/files/FWO.Basics/HtmlOutputEncoder.cs b/roles/lib/files/FWO.Basics/HtmlOutputEncoder.cs new file mode 100644 index 0000000000..e0d7f1e628 --- /dev/null +++ b/roles/lib/files/FWO.Basics/HtmlOutputEncoder.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; + +namespace FWO.Basics +{ + /// + /// Encodes values that are written into generated html, for the context they are written into. + /// Report and notification html is assembled from stored values, and a value is only inert if it is + /// encoded for the place it lands in: text between tags, the value of a double quoted attribute, and + /// the target of a link each end differently and each need their own encoding. + /// + public static partial class HtmlOutputEncoder + { + /// + /// Target a link falls back to when its address may not be used. It stays on the document and + /// loads nothing. + /// + public const string kBlockedUrlReplacement = "#"; + + private const int kUrlPatternTimeoutMs = 100; + + /// + /// Matches the scheme a url starts with, after the url has been stripped of the whitespace and + /// control characters a browser drops before it reads the scheme. + /// + [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9+.\-]*:", RegexOptions.CultureInvariant, kUrlPatternTimeoutMs)] + private static partial Regex UrlSchemePattern(); + + /// + /// Encodes a value that is written as text between tags. + /// + /// The value to write, null is treated as empty. + /// The value with every character that could start markup encoded. + public static string EncodeText(string? value) + { + return WebUtility.HtmlEncode(value ?? ""); + } + + /// + /// Encodes a value that is written as the value of a double quoted attribute. + /// Every attribute this code generates is double quoted, and the encoding covers the quote that + /// would end it as well as the characters that would start a new tag, so the value cannot leave + /// the attribute it is written into. + /// + /// The value to write, null is treated as empty. + /// The value, safe to place between two double quotes. + public static string EncodeAttribute(string? value) + { + return WebUtility.HtmlEncode(value ?? ""); + } + + /// + /// Encodes a link target, refusing any address that does not stay on the generated document. + /// Reports only ever link to an anchor of their own document or to a page of this application, + /// so an address naming a scheme or a host is refused rather than encoded: it could otherwise + /// make the reader, or the headless browser that renders the export, fetch it. + /// The address is stripped of whitespace and control characters before it is judged, because a + /// browser drops those before it reads the scheme and would otherwise see a scheme this check + /// did not. + /// + /// The link target to write, null is treated as blocked. + /// The address ready to place in a double quoted attribute, or "#" when it was refused. + public static string EncodeLocalUrl(string? url) + { + return EncodeAttribute(ValidateLocalUrl(url)); + } + + /// + /// Decides whether a link target stays on the generated document, without encoding it. + /// + /// The link target to judge, null is treated as blocked. + /// The accepted address, or "#" when it was refused. + public static string ValidateLocalUrl(string? url) + { + string strippedUrl = StripIgnoredUrlCharacters(url); + if (strippedUrl.Length == 0) + { + return kBlockedUrlReplacement; + } + + // "//host/path" is a protocol relative url and reaches the network, "\\host" is read the same + // way by several browsers, so neither may pass as a relative path. + if (strippedUrl.StartsWith("//", StringComparison.Ordinal) + || strippedUrl.StartsWith('\\') + || UrlSchemePattern().IsMatch(strippedUrl)) + { + return kBlockedUrlReplacement; + } + + return strippedUrl; + } + + /// + /// Removes the characters a browser ignores while it reads the scheme of a url, so that a value + /// such as "java\nscript:..." is judged as the scheme the browser will act on. + /// + private static string StripIgnoredUrlCharacters(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return ""; + } + + StringBuilder stripped = new(url.Length); + foreach (char character in url) + { + if (!char.IsControl(character) && !char.IsWhiteSpace(character)) + { + stripped.Append(character); + } + } + + return stripped.ToString(); + } + } +} diff --git a/roles/lib/files/FWO.Data/DisplayBase.cs b/roles/lib/files/FWO.Data/DisplayBase.cs index bfa8d6db8d..8929f2fedc 100644 --- a/roles/lib/files/FWO.Data/DisplayBase.cs +++ b/roles/lib/files/FWO.Data/DisplayBase.cs @@ -289,27 +289,32 @@ public static string MemberNamesAsJson(string MemberNames) return $"[{string.Join(",", quoted)}]"; } + /// + /// Renders the members of a group as the cell of a report table. + /// The member names are imported values, so each one is encoded before the separators this method + /// adds itself are put between them - the break is markup this code means, the name is not. + /// + /// The member names as the importer stored them, separated by '|'. + /// The table cell holding the member names. public static string MemberNamesAsHtml(string MemberNames) { - if (MemberNames != null && MemberNames.Contains("|")) - { - return $"{string.Join("
", MemberNames.Split('|'))}"; - } - else - { - return $"{MemberNames}"; - } + return $"{MemberNamesWithoutHtml(MemberNames)}"; } + + /// + /// Renders the members of a group without the surrounding table cell, for callers that place them + /// in markup of their own. The member names are encoded, only the line breaks between them are markup. + /// + /// The member names as the importer stored them, separated by '|'. + /// The member names, one per line. public static string MemberNamesWithoutHtml(string MemberNames) { - if (MemberNames != null && MemberNames.Contains("|")) + if (MemberNames == null) { - return $"{string.Join("
", MemberNames.Split('|'))}"; - } - else - { - return $"{MemberNames}"; + return ""; } + + return string.Join("
", MemberNames.Split('|').Select(HtmlOutputEncoder.EncodeText)); } public static string MemberNamesAsCSV(string MemberNames) diff --git a/roles/lib/files/FWO.Data/Flow/FlowObjectEligibility.cs b/roles/lib/files/FWO.Data/Flow/FlowObjectEligibility.cs new file mode 100644 index 0000000000..0c5e63f0f8 --- /dev/null +++ b/roles/lib/files/FWO.Data/Flow/FlowObjectEligibility.cs @@ -0,0 +1,142 @@ +namespace FWO.Data.Flow +{ + /// + /// Decides whether a Flow catalog entry may be offered in the request module and attached to a + /// workflow element. + /// The same predicate is expressed as a Hasura select filter on the flow catalog tables and as a + /// Hasura insert/update check on request.reqelement, so that an entry which is hidden, retired or + /// reserved for internal use cannot be reached by a direct API call that bypasses the UI either. + /// Two strengths are distinguished: asks whether an entry is still usable at + /// all, additionally rejects the internal representations + /// that only the platform itself may reference. + /// + public static class FlowObjectEligibility + { + /// + /// States a catalog entry may still be used in. A denied or removed entry is left alone so that a + /// new request does not silently inherit an earlier rejection or a retired definition. + /// + private static readonly List kLiveStates = [FlowState.Requested, FlowState.Implemented]; + + /// + /// Whether the protocol id is an internal representation rather than a real IP protocol. + /// Negative ids - currently the canonical ANY protocol - + /// are written by the platform itself and must never be selectable in a request. + /// + /// The IP protocol id to classify. + /// True when the id is reserved for internal use. + public static bool IsInternalProtocolId(int protoId) + { + return protoId < 0; + } + + /// + /// Whether the protocol id of a request element may be written by a user role. + /// + /// The IP protocol id, or null when the element carries no protocol. + /// True when the id is unset or names a real IP protocol. + public static bool IsRequestableProtocolId(int? protoId) + { + return !protoId.HasValue || !IsInternalProtocolId(protoId.Value); + } + + /// + /// Whether a Flow network object is still offered and live, so that it may be attached to a + /// workflow element. + /// + /// The Flow network object to check. + /// True when the object is visible and live. + public static bool IsLive(FlowNwObject? nwObject) + { + return nwObject != null && HasLiveLifecycle(nwObject.ShowInRequestModule, nwObject.State, nwObject.RemovedDate); + } + + /// + /// Whether a Flow service object is still offered and live, so that it may be attached to a + /// workflow element. The canonical ANY service passes here: the platform attaches it itself when + /// it turns a protocol-agnostic request into a flow, and only user roles are barred from + /// referencing it - by the Hasura permissions rather than by this predicate. + /// + /// The Flow service object to check. + /// True when the object is visible and live. + public static bool IsLive(FlowSvcObject? svcObject) + { + return svcObject != null && HasLiveLifecycle(svcObject.ShowInRequestModule, svcObject.State, svcObject.RemovedDate); + } + + /// + /// Whether a Flow time object is still offered and live. + /// + /// The Flow time object to check. + /// True when the object is visible and live. + public static bool IsLive(FlowTimeObject? timeObject) + { + return timeObject != null && HasLiveLifecycle(timeObject.ShowInRequestModule, timeObject.State, timeObject.RemovedDate); + } + + /// + /// Whether a Flow group is still offered and live. A group without a name cannot be referenced by + /// name and is therefore not offered. + /// + /// The Flow network or service group to check. + /// True when the group is named, visible and live. + public static bool IsLive(FlowGroup? group) + { + return group != null + && !string.IsNullOrWhiteSpace(group.Name) + && HasLiveLifecycle(group.ShowInRequestModule, group.State, group.RemovedDate); + } + + /// + /// Whether a Flow network object may be picked in the request module. + /// + /// The Flow network object to check. + /// True when the object is visible and live. + public static bool IsRequestable(FlowNwObject? nwObject) + { + return IsLive(nwObject); + } + + /// + /// Whether a Flow service object may be picked in the request module. + /// + /// The Flow service object to check. + /// True when the object is visible, live and not an internal protocol representation. + public static bool IsRequestable(FlowSvcObject? svcObject) + { + return IsLive(svcObject) && !IsInternalProtocolId(svcObject!.ProtoId); + } + + /// + /// Whether a Flow time object may be picked in the request module. + /// + /// The Flow time object to check. + /// True when the object is visible and live. + public static bool IsRequestable(FlowTimeObject? timeObject) + { + return IsLive(timeObject); + } + + /// + /// Whether a Flow group may be picked in the request module. + /// + /// The Flow network or service group to check. + /// True when the group is named, visible and live. + public static bool IsRequestable(FlowGroup? group) + { + return IsLive(group); + } + + /// + /// Applies the lifecycle part of the eligibility predicate that every flow catalog table shares. + /// + /// Whether the entry is offered in the request module. + /// The entry state. + /// When the entry was retired, null while it is live. + /// True when the entry is visible and live. + private static bool HasLiveLifecycle(bool showInRequestModule, string state, DateTime? removedDate) + { + return showInRequestModule && removedDate == null && kLiveStates.Contains(state); + } + } +} diff --git a/roles/lib/files/FWO.Data/NetworkUser.cs b/roles/lib/files/FWO.Data/NetworkUser.cs index fc5c8a14c4..9e1c51b2a0 100644 --- a/roles/lib/files/FWO.Data/NetworkUser.cs +++ b/roles/lib/files/FWO.Data/NetworkUser.cs @@ -61,16 +61,14 @@ public override int GetHashCode() return Id.GetHashCode(); } + /// + /// Renders the members of this user group as the cell of a report table, with each imported + /// member name encoded and only the line breaks between them left as markup. + /// + /// The table cell holding the member names. public string MemberNamesAsHtml() { - if (MemberNames != null && MemberNames.Contains("|")) - { - return $"{string.Join("
", MemberNames.Split('|'))}"; - } - else - { - return $"{MemberNames}"; - } + return DisplayBase.MemberNamesAsHtml(MemberNames); } } } diff --git a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs index 863efbce54..6579b45e8e 100644 --- a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs +++ b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs @@ -39,7 +39,7 @@ public async Task> CreateInitialJWT(CancellationToken ca public async Task> TestConnection(LdapGetUpdateParameters parameters) { - RestRequest request = new("AuthenticationServer/TestConnection", Method.Get); + RestRequest request = new("AuthenticationServer/TestConnection", Method.Post); request.AddJsonBody(parameters); return await restClient.ExecuteAsync(request); } diff --git a/roles/lib/files/FWO.Report/Display/RuleDisplayHtml.cs b/roles/lib/files/FWO.Report/Display/RuleDisplayHtml.cs index d98d8c5e3c..b9cf62d62b 100644 --- a/roles/lib/files/FWO.Report/Display/RuleDisplayHtml.cs +++ b/roles/lib/files/FWO.Report/Display/RuleDisplayHtml.cs @@ -54,7 +54,7 @@ public static string DisplayEnforcingGateways(Rule rule, OutputLocation location } public static string DisplaySectionHeader(Rule rule, int ColumnCount) { - return $"{rule.SectionHeader}"; + return $"{HtmlOutputEncoder.EncodeText(rule.SectionHeader)}"; } public static string DisplayNextRecert(RuleMetadata ruleMetadata) diff --git a/roles/lib/files/FWO.Report/ReportBase.cs b/roles/lib/files/FWO.Report/ReportBase.cs index 7fa1a444cc..cf46c8df8a 100644 --- a/roles/lib/files/FWO.Report/ReportBase.cs +++ b/roles/lib/files/FWO.Report/ReportBase.cs @@ -66,6 +66,7 @@ protected sealed class HtmlFrameOptions + ##Title## {NotificationTableBodyBuilder.HtmlTableStyleBlock} @@ -252,9 +253,22 @@ public static string GetLinkAddress(OutputLocation location, string reportId, st return $"{link}{type}{chapterNumber}x{id}"; } + /// + /// Builds the link a report uses to point at an object of its own document. + /// Every part is encoded for the place it is written into: the icon class and the style land in + /// double quoted attributes, the target is refused unless it stays on the document, and the name - + /// which is a stored value and the part an attacker can reach - is written as text. + /// + /// Icon class of the linked object kind. + /// Display name of the linked object. + /// Inline style of the link. + /// Target of the link, an anchor of this document or a page of this application. + /// The html of the link. public static string ConstructLink(string symbol, string name, string style, string linkAddress) { - return $" {name}"; + return $" " + + $"{HtmlOutputEncoder.EncodeText(name)}"; } protected static string OutputCsv(string? input) @@ -418,6 +432,7 @@ protected static string ToUtcString(string? timestring) try { using IPage page = await browser.NewPageAsync(); + await PdfRenderSecurity.HardenPageAsync(page); await page.SetContentAsync(html, new SetContentOptions { Timeout = kPageOperationTimeoutMs }); PuppeteerSharp.Media.PaperFormat? pupformat = GetPuppeteerPaperFormat(format) ?? throw new KeyNotFoundException(); @@ -530,6 +545,7 @@ protected static async Task CloseBrowserSafely(IBrowser browser, SupportedBrowse { ExecutablePath = executablePath, Headless = true, + Args = PdfRenderSecurity.GetHardenedBrowserArgs(), Timeout = kBrowserLaunchTimeoutMs, ProtocolTimeout = kBrowserProtocolTimeoutMs }); @@ -614,7 +630,7 @@ public string BuildHTMLToC(string html) private static void AppendHeader(StringBuilder sb, ToCHeader toCHeader) { - sb.AppendLine($"
  • {toCHeader.Title}
  • "); + sb.AppendLine($"
  • {HtmlOutputEncoder.EncodeText(toCHeader.Title)}
  • "); if (toCHeader.Items.Count > 0) { @@ -630,7 +646,7 @@ private static void AppendHeader(StringBuilder sb, ToCHeader toCHeader) private static void AppendItem(StringBuilder sb, ToCItem tocItem) { - sb.AppendLine($"
  • {tocItem.Title}
  • "); + sb.AppendLine($"
  • {HtmlOutputEncoder.EncodeText(tocItem.Title)}
  • "); if (tocItem.SubItems.Count > 0) { sb.AppendLine("
      "); @@ -644,13 +660,13 @@ private static void AppendItem(StringBuilder sb, ToCItem tocItem) private static void AppendSubItem(StringBuilder sb, ToCItem subItem) { - sb.AppendLine($"
    • {subItem.Title}
    • "); + sb.AppendLine($"
    • {HtmlOutputEncoder.EncodeText(subItem.Title)}
    • "); if (subItem.SubItems.Count > 0) { sb.AppendLine(""); } @@ -658,7 +674,7 @@ private static void AppendSubItem(StringBuilder sb, ToCItem subItem) protected string Headline(string? title, int level) { - return $"{title}"; + return $"{HtmlOutputEncoder.EncodeText(title)}"; } public static bool IsValidHTML(string html) diff --git a/roles/lib/files/FWO.Report/ReportConnections.cs b/roles/lib/files/FWO.Report/ReportConnections.cs index 9cfd5840c7..dae7ade3f3 100644 --- a/roles/lib/files/FWO.Report/ReportConnections.cs +++ b/roles/lib/files/FWO.Report/ReportConnections.cs @@ -223,7 +223,7 @@ protected void AppendNetworkObjectsHtml(List networkObjects, int report.AppendLine(""); report.AppendLine($"{nwObj.Number}"); report.AppendLine($"{nwObj.Id}"); - report.AppendLine($"{nwObj.Name}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(nwObj.Name)}"); report.AppendLine($"{nwObj.IP}"); report.AppendLine(DisplayBase.MemberNamesAsHtml(nwObj.MemberNames)); } @@ -255,7 +255,7 @@ protected void AppendNetworkServicesHtml(List networkServices, i report.AppendLine(""); report.AppendLine($"{svc.Number}"); report.AppendLine($"{svc.Id}"); - report.AppendLine($"{svc.Name}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(svc.Name)}"); report.AppendLine($"{svc.Protocol?.Name}"); report.AppendLine($"{svc.DestinationPort}"); report.AppendLine(DisplayBase.MemberNamesAsHtml(svc.MemberNames)); diff --git a/roles/lib/files/FWO.Report/ReportNatRules.cs b/roles/lib/files/FWO.Report/ReportNatRules.cs index 30e739be87..eb84e597ec 100644 --- a/roles/lib/files/FWO.Report/ReportNatRules.cs +++ b/roles/lib/files/FWO.Report/ReportNatRules.cs @@ -27,7 +27,7 @@ public override string ExportToHtml() foreach (var managementReport in ReportData.ManagementData.Where(mgt => !mgt.Ignore && mgt.ContainsRules())) { chapterNumber++; - report.AppendLine($"

      {managementReport.Name}

      "); + report.AppendLine($"

      {HtmlOutputEncoder.EncodeText(managementReport.Name)}

      "); report.AppendLine("
      "); foreach (var device in managementReport.Devices) @@ -66,7 +66,7 @@ public string ExportSingleRulebaseToHtml(Rule[] rulebase, NatRuleDisplayHtml rul private void AppendNatRuleHeadlineHtml(ref StringBuilder report, string? deviceName) { - report.AppendLine($"

      {deviceName}

      "); + report.AppendLine($"

      {HtmlOutputEncoder.EncodeText(deviceName)}

      "); report.AppendLine(""); report.AppendLine(""); foreach (string headerKey in HeaderKeys) @@ -100,7 +100,7 @@ private void AppendNatRuleForDeviceHtml(ref StringBuilder report, int chapterNum else { report.AppendLine(""); - report.AppendLine($""); + report.AppendLine($""); report.AppendLine(""); } } diff --git a/roles/lib/files/FWO.Report/ReportRules.cs b/roles/lib/files/FWO.Report/ReportRules.cs index 5731a1ee32..486ad43afd 100644 --- a/roles/lib/files/FWO.Report/ReportRules.cs +++ b/roles/lib/files/FWO.Report/ReportRules.cs @@ -850,12 +850,12 @@ protected void AppendNetworkObjectsForManagementHtml(ref StringBuilder report, i { report.AppendLine($""); report.AppendLine($""); - report.AppendLine($""); + report.AppendLine($""); report.AppendLine($""); report.AppendLine($""); report.AppendLine(DisplayBase.MemberNamesAsHtml(nwobj.MemberNames)); - report.AppendLine($""); - report.AppendLine($""); + report.AppendLine($""); + report.AppendLine($""); report.AppendLine(""); } report.AppendLine("
      {rule.SectionHeader}{HtmlOutputEncoder.EncodeText(rule.SectionHeader)}
      {objNumber++}{nwobj.Name}{HtmlOutputEncoder.EncodeText(nwobj.Name)}{(nwobj.Type.Name != "" ? userConfig.GetText(nwobj.Type.Name) : "")}{NwObjDisplay.DisplayIp(nwobj.IP, nwobj.IpEnd, nwobj.Type.Name)}{nwobj.Uid}{nwobj.Comment}{HtmlOutputEncoder.EncodeText(nwobj.Uid)}{HtmlOutputEncoder.EncodeText(nwobj.Comment)}
      "); @@ -893,7 +893,7 @@ private void AppendServiceForManagementHtml(ref StringBuilder report, int chapte { report.AppendLine(""); report.AppendLine($"{objNumber}"); - report.AppendLine($"{svcobj.Name}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(svcobj.Name)}"); report.AppendLine($"{(svcobj.Type.Name != "" ? userConfig.GetText(svcobj.Type.Name) : "")}"); report.AppendLine($"{((svcobj.Type.Name != ServiceType.Group && svcobj.Protocol != null) ? svcobj.Protocol.Name : "")}"); if (svcobj.DestinationPortEnd != null && svcobj.DestinationPortEnd != svcobj.DestinationPort) @@ -905,8 +905,8 @@ private void AppendServiceForManagementHtml(ref StringBuilder report, int chapte report.AppendLine($"{svcobj.DestinationPort}"); } report.AppendLine(DisplayBase.MemberNamesAsHtml(svcobj.MemberNames)); - report.AppendLine($"{svcobj.Uid}"); - report.AppendLine($"{svcobj.Comment}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(svcobj.Uid)}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(svcobj.Comment)}"); report.AppendLine(""); } @@ -929,11 +929,11 @@ protected void AppendUsersForManagementHtml(ref StringBuilder report, int chapte { report.AppendLine(""); report.AppendLine($"{objNumber++}"); - report.AppendLine($"{userobj.Name}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(userobj.Name)}"); report.AppendLine($"{(userobj.Type.Name != "" ? userConfig.GetText(userobj.Type.Name) : "")}"); report.AppendLine(userobj.MemberNamesAsHtml()); - report.AppendLine($"{userobj.Uid}"); - report.AppendLine($"{userobj.Comment}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(userobj.Uid)}"); + report.AppendLine($"{HtmlOutputEncoder.EncodeText(userobj.Comment)}"); report.AppendLine(""); } report.AppendLine(""); diff --git a/roles/lib/files/FWO.Report/ReportStatistics.cs b/roles/lib/files/FWO.Report/ReportStatistics.cs index 4ae205a876..3f1d78dade 100644 --- a/roles/lib/files/FWO.Report/ReportStatistics.cs +++ b/roles/lib/files/FWO.Report/ReportStatistics.cs @@ -91,7 +91,7 @@ public override string ExportToHtml() foreach (ManagementReport managementReport in ReportData.ManagementData.Where(mgt => !mgt.Ignore)) { - report.AppendLine($"

      {userConfig.GetText("no_of_obj")} - {managementReport.Name}

      "); + report.AppendLine($"

      {userConfig.GetText("no_of_obj")} - {HtmlOutputEncoder.EncodeText(managementReport.Name)}

      "); report.AppendLine(""); report.AppendLine(""); report.AppendLine($""); diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs index c45263a043..ab10aa2d5d 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs @@ -121,8 +121,14 @@ private static string ResolveContent(NotificationLayout layout, string? content) } executablePath ??= browsers.OrderBy(browser => browser.BuildId).Last().GetExecutablePath(); - await using IBrowser browser = await Puppeteer.LaunchAsync(new LaunchOptions { ExecutablePath = executablePath, Headless = true }); + await using IBrowser browser = await Puppeteer.LaunchAsync(new LaunchOptions + { + ExecutablePath = executablePath, + Headless = true, + Args = PdfRenderSecurity.GetHardenedBrowserArgs() + }); using IPage page = await browser.NewPageAsync(); + await PdfRenderSecurity.HardenPageAsync(page); await page.SetContentAsync(html); PdfOptions options = new() { DisplayHeaderFooter = false, Landscape = true, PrintBackground = true, Format = PaperFormat.A4 }; return Convert.ToBase64String(await page.PdfDataAsync(options)); diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationTableBodyBuilder.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationTableBodyBuilder.cs index cb17f4fa0f..e5709cdf9a 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationTableBodyBuilder.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationTableBodyBuilder.cs @@ -106,6 +106,7 @@ public static string BuildHtmlDocument(string body) .AppendLine("") .AppendLine("") .AppendLine(" ") + .AppendLine($" ") .AppendLine(HtmlTableStyleBlock) .AppendLine("") .AppendLine("") diff --git a/roles/lib/files/FWO.Services/PdfRenderSecurity.cs b/roles/lib/files/FWO.Services/PdfRenderSecurity.cs new file mode 100644 index 0000000000..dbabcaf30e --- /dev/null +++ b/roles/lib/files/FWO.Services/PdfRenderSecurity.cs @@ -0,0 +1,108 @@ +using FWO.Logging; +using PuppeteerSharp; + +namespace FWO.Services +{ + /// + /// Locks down the headless browser that renders report and notification html to pdf. + /// The html handed to that browser is built from stored values - object, service, device and owner + /// names, reasons, labels - so it has to be assumed to contain markup that was never meant to be + /// there. A page that fetches subresources turns such markup into outbound requests issued by the + /// server itself, which reach hosts the requesting user cannot reach and can carry data out in the + /// request line. This class removes that capability at the renderer rather than at each of the many + /// places that build the html: scripts do not run, and every request except the document the render + /// starts from is aborted before it leaves the process. + /// Each render already runs in a browser of its own that is closed afterwards, so no context, cache + /// or cookie is shared between two renders. + /// + public static class PdfRenderSecurity + { + /// + /// Schemes a render may load. Both are resolved inside the browser and never reach the network: + /// "about" is the blank document the content is written into, "data" is content inlined in the + /// url itself. + /// + private static readonly List kAllowedRenderSchemes = ["about", "data"]; + + /// + /// Command line of the render browser. It turns off every background channel Chrome opens on its + /// own, and makes host name resolution fail for every name, so a request that is somehow issued + /// outside the interception below still cannot reach a host. + /// + private static readonly string[] kHardenedBrowserArgs = + [ + "--host-resolver-rules=MAP * ~NOTFOUND", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-extensions", + "--disable-remote-fonts", + "--disable-sync", + "--no-default-browser-check", + "--no-first-run", + "--no-pings" + ]; + + /// + /// The command line the render browser is launched with. + /// + /// A copy of the hardened argument list, so a caller cannot change the shared one. + public static string[] GetHardenedBrowserArgs() + { + return [.. kHardenedBrowserArgs]; + } + + /// + /// Whether a render may load the given url. Only the schemes the render itself needs are allowed; + /// anything naming a host is refused, including a literal address that needs no name resolution. + /// A url that cannot be read at all is refused as well. + /// + /// The url the page wants to load. + /// True when the request may be continued. + public static bool IsAllowedRenderUrl(string? url) + { + return !string.IsNullOrWhiteSpace(url) + && Uri.TryCreate(url, UriKind.Absolute, out Uri? parsedUrl) + && kAllowedRenderSchemes.Contains(parsedUrl.Scheme.ToLowerInvariant()); + } + + /// + /// Applies the render lockdown to a page before any content is set on it. + /// Must be called before the content is written, because a request the page issues while it is + /// still unprotected is already out of reach. + /// + /// The page the report is about to be rendered on. + public static async Task HardenPageAsync(IPage page) + { + await page.SetJavaScriptEnabledAsync(false); + await page.SetCacheEnabledAsync(false); + await page.SetRequestInterceptionAsync(true); + page.Request += OnPageRequest; + } + + /// + /// Decides a single intercepted request. Puppeteer hands this over as an event, so it cannot be + /// awaited by the caller and must not let an exception escape: an unhandled one here would end + /// the process rather than the render. + /// + private static async void OnPageRequest(object? sender, RequestEventArgs eventArgs) + { + try + { + if (IsAllowedRenderUrl(eventArgs.Request.Url)) + { + await eventArgs.Request.ContinueAsync(); + return; + } + + Log.WriteWarning("Report Export", + $"The report renderer refused to load '{eventArgs.Request.Url}'. The exported content contains a reference that does not belong in a report."); + await eventArgs.Request.AbortAsync(); + } + catch (Exception exception) + { + Log.WriteError("Report Export", "Deciding an intercepted render request failed.", exception); + } + } + } +} diff --git a/roles/lib/files/FWO.Services/Workflow/FlowDbCreatorObjectResolution.cs b/roles/lib/files/FWO.Services/Workflow/FlowDbCreatorObjectResolution.cs index 3c61194ecd..1e6c8a5a18 100644 --- a/roles/lib/files/FWO.Services/Workflow/FlowDbCreatorObjectResolution.cs +++ b/roles/lib/files/FWO.Services/Workflow/FlowDbCreatorObjectResolution.cs @@ -15,6 +15,7 @@ public partial class FlowDbCreator { private const string kMixedAddressFamiliesTextKey = "flow_creation_mixed_address_families"; private const string kUnreadableAddressTextKey = "flow_creation_unreadable_address"; + private const string kIneligibleFlowObjectTextKey = "flow_creation_ineligible_flow_object"; private static readonly List kReusableFlowStates = [FlowState.Requested, FlowState.Implemented]; /// @@ -57,26 +58,49 @@ private async Task> ResolveNetworkReferences(IEnumera ?? await ResolveOrCreateNetworkObject(snapshot, context); } - private static FlowNetworkReference? TryResolveNetworkObjectId(FlowObjectSnapshot snapshot, FlowSyncFlowData context) + /// + /// Resolves the Flow network object a request element names by id. The id is written into + /// request.reqelement by whoever edits the element, so an id which does not name an object that is + /// still offered and live is refused here rather than followed: it is the only point at which an + /// element that was pointed at a hidden or retired object can still be kept out of a flow. + /// + private FlowNetworkReference? TryResolveNetworkObjectId(FlowObjectSnapshot snapshot, FlowSyncFlowData context) { if (!snapshot.FlowNetworkObjectId.HasValue) { return null; } - if (context.NwObjectsById.TryGetValue(snapshot.FlowNetworkObjectId.Value, out FlowNwObject? flowObject)) + if (!context.NwObjectsById.TryGetValue(snapshot.FlowNetworkObjectId.Value, out FlowNwObject? flowObject)) { - return FlowNetworkReference.FromObject(flowObject!); + Log.WriteWarning(LogMessageTitle, $"Could not resolve Flow network object id {snapshot.FlowNetworkObjectId.Value} for workflow element {snapshot.WorkflowElementId}."); + return null; } - Log.WriteWarning(LogMessageTitle, $"Could not resolve Flow network object id {snapshot.FlowNetworkObjectId.Value} for workflow element {snapshot.WorkflowElementId}."); - return null; + if (!FlowObjectEligibility.IsLive(flowObject)) + { + RefuseFlowObject(snapshot.WorkflowElementId, flowObject!.Name ?? snapshot.FlowNetworkObjectId.Value.ToString(), + $"Flow network object id {snapshot.FlowNetworkObjectId.Value} is not offered in the request module or no longer live"); + return null; + } + return FlowNetworkReference.FromObject(flowObject!); } - private static FlowNetworkReference? TryResolveNetworkGroupId(FlowObjectSnapshot snapshot, FlowSyncFlowData context) + /// + /// Resolves the Flow network group a request element names by id, refusing a group that is no + /// longer offered or live for the same reason as an ineligible object. + /// + private FlowNetworkReference? TryResolveNetworkGroupId(FlowObjectSnapshot snapshot, FlowSyncFlowData context) { if (!snapshot.FlowNetworkGroupId.HasValue) { return null; } + if (context.NwGroupsById.TryGetValue(snapshot.FlowNetworkGroupId.Value, out FlowNwGroup? namedGroup) + && !FlowObjectEligibility.IsLive(namedGroup)) + { + RefuseFlowObject(snapshot.WorkflowElementId, namedGroup!.Name, + $"Flow network group id {snapshot.FlowNetworkGroupId.Value} is not offered in the request module or no longer live"); + return null; + } FlowNetworkReference? groupReference = TryBuildNetworkGroupReference(snapshot.FlowNetworkGroupId.Value, context); if (groupReference == null) { @@ -197,6 +221,20 @@ private void RefuseNetworkObject(long workflowElementId, string reasonTextKey, s refusals.Add(new FlowCreationRefusal { ReasonTextKey = reasonTextKey, RefusedValue = refusedValue }); } + /// + /// Logs and reports that a request element named a Flow entry by id which may no longer be used. + /// The element is left out of the flow instead of being followed, so that an entry an + /// administrator hid or retired cannot re-enter a flow through an id that was stored earlier. + /// + /// Id of the refused workflow element. + /// Name of the Flow entry the element pointed at. + /// The same reason spelled out for the log. + private void RefuseFlowObject(long workflowElementId, string refusedValue, string logDetail) + { + Log.WriteWarning(LogMessageTitle, $"Refused workflow element {workflowElementId}: {logDetail}."); + refusals.Add(new FlowCreationRefusal { ReasonTextKey = kIneligibleFlowObjectTextKey, RefusedValue = refusedValue }); + } + /// /// Returns whether both endpoints of a range belong to the same address family. A range from an IPv4 to an /// IPv6 address passes every single-endpoint rule but describes nothing, so it is refused instead of stored. @@ -346,26 +384,49 @@ private async Task> ResolveServiceReferences(IEnumera ?? await ResolveOrCreateServiceObject(snapshot, context); } - private static FlowServiceReference? TryResolveServiceObjectId(FlowServiceSnapshot snapshot, FlowSyncFlowData context) + /// + /// Resolves the Flow service object a request element names by id, refusing an object that is no + /// longer offered or live. The canonical ANY service is accepted: the flow creation attaches it + /// itself for a protocol-agnostic request, and a user role is kept away from it by the Hasura + /// permissions on request.reqelement instead. + /// + private FlowServiceReference? TryResolveServiceObjectId(FlowServiceSnapshot snapshot, FlowSyncFlowData context) { if (!snapshot.FlowServiceObjectId.HasValue) { return null; } - if (context.SvcObjectsById.TryGetValue(snapshot.FlowServiceObjectId.Value, out FlowSvcObject? flowObject)) + if (!context.SvcObjectsById.TryGetValue(snapshot.FlowServiceObjectId.Value, out FlowSvcObject? flowObject)) { - return FlowServiceReference.FromObject(flowObject!); + Log.WriteWarning(LogMessageTitle, $"Could not resolve Flow service object id {snapshot.FlowServiceObjectId.Value} for workflow element {snapshot.WorkflowElementId}."); + return null; } - Log.WriteWarning(LogMessageTitle, $"Could not resolve Flow service object id {snapshot.FlowServiceObjectId.Value} for workflow element {snapshot.WorkflowElementId}."); - return null; + if (!FlowObjectEligibility.IsLive(flowObject)) + { + RefuseFlowObject(snapshot.WorkflowElementId, flowObject!.Name, + $"Flow service object id {snapshot.FlowServiceObjectId.Value} is not offered in the request module or no longer live"); + return null; + } + return FlowServiceReference.FromObject(flowObject!); } - private static FlowServiceReference? TryResolveServiceGroupId(FlowServiceSnapshot snapshot, FlowSyncFlowData context) + /// + /// Resolves the Flow service group a request element names by id, refusing a group that is no + /// longer offered or live for the same reason as an ineligible object. + /// + private FlowServiceReference? TryResolveServiceGroupId(FlowServiceSnapshot snapshot, FlowSyncFlowData context) { if (!snapshot.FlowServiceGroupId.HasValue) { return null; } + if (context.SvcGroupsById.TryGetValue(snapshot.FlowServiceGroupId.Value, out FlowSvcGroup? namedGroup) + && !FlowObjectEligibility.IsLive(namedGroup)) + { + RefuseFlowObject(snapshot.WorkflowElementId, namedGroup!.Name, + $"Flow service group id {snapshot.FlowServiceGroupId.Value} is not offered in the request module or no longer live"); + return null; + } FlowServiceReference? groupReference = TryBuildServiceGroupReference(snapshot.FlowServiceGroupId.Value, context); if (groupReference == null) { diff --git a/roles/lib/files/FWO.Services/Workflow/WfDbAccessFlowElements.cs b/roles/lib/files/FWO.Services/Workflow/WfDbAccessFlowElements.cs new file mode 100644 index 0000000000..9cb0826842 --- /dev/null +++ b/roles/lib/files/FWO.Services/Workflow/WfDbAccessFlowElements.cs @@ -0,0 +1,88 @@ +using FWO.Api.Client.Queries; +using FWO.Data.Flow; +using FWO.Data.Workflow; + +namespace FWO.Services.Workflow +{ + public partial class WfDbAccess + { + private const string kIneligibleFlowElementTextKey = "E8017"; + + /// + /// Checks that every Flow catalog entry the request task attaches may still be requested, before + /// any element of the task is written. + /// The element columns that carry the Flow ids are writable by the requesting user, so the ids can + /// name an entry the request module never offered - one that is hidden, retired, or an internal + /// representation such as the canonical ANY service. The Hasura permissions refuse such a write, + /// but they refuse the whole mutation with a permission error; checking here first turns that into + /// a message that names what has to be corrected, and keeps a stale editor from silently + /// re-attaching an entry that was withdrawn while the task was open. + /// Flow group ids are not checked here: they are written by the flow creation in the middleware, + /// never by the request module, and the requesting user cannot read the Flow group tables at all. + /// + /// The request task whose elements are about to be written. + /// True when every attached Flow entry and protocol may be requested. + private async Task FlowReferencesAreRequestable(WfReqTask reqtask) + { + if (reqtask.Elements.Any(element => !FlowObjectEligibility.IsRequestableProtocolId(element.ProtoId))) + { + DisplayMessageInUi(null, UserConfig.GetText("save_task"), UserConfig.GetText(kIneligibleFlowElementTextKey), true); + return false; + } + + List nwObjectIds = CollectFlowIds(reqtask, element => element.FlowNetworkObjectId); + List svcObjectIds = CollectFlowIds(reqtask, element => element.FlowServiceObjectId); + if (nwObjectIds.Count == 0 && svcObjectIds.Count == 0) + { + return true; + } + + if (!await AllIdsAreRequestable(FlowQueries.getRequestableFlowNwObjectIds, "nwObjIds", nwObjectIds, flowObject => flowObject.Id) + || !await AllIdsAreRequestable(FlowQueries.getRequestableFlowSvcObjectIds, "svcObjIds", svcObjectIds, flowObject => flowObject.Id)) + { + DisplayMessageInUi(null, UserConfig.GetText("save_task"), UserConfig.GetText(kIneligibleFlowElementTextKey), true); + return false; + } + + return true; + } + + /// + /// Collects the distinct Flow ids the task elements carry in one of their Flow id columns. + /// + /// The request task to read the elements from. + /// Selects the Flow id column to collect. + /// The distinct ids that are actually set. + private static List CollectFlowIds(WfReqTask reqtask, Func selectId) + { + return [.. reqtask.Elements + .Select(selectId) + .Where(id => id is > 0) + .Select(id => id!.Value) + .Distinct()]; + } + + /// + /// Asks the API which of the given Flow ids still name a requestable entry and reports whether all + /// of them do. The query carries the eligibility predicate itself, so the answer does not depend on + /// the permissions of the role the query runs under. + /// + /// The Flow catalog type being checked. + /// The API call that returns the requestable entries among the given ids. + /// Name of the id list variable of that API call. + /// The ids to check, never empty when the API is asked. + /// Reads the id from a returned entry. + /// True when every given id was returned as requestable. + private async Task AllIdsAreRequestable(string query, string variableName, List ids, Func selectId) + { + if (ids.Count == 0) + { + return true; + } + + Dictionary variables = new() { [variableName] = ids }; + List requestableObjects = await ApiConnection.SendQueryAsync>(query, variables) ?? []; + return ids.TrueForAll(id => requestableObjects.Exists(flowObject => selectId(flowObject) == id)); + } + } +} diff --git a/roles/lib/files/FWO.Services/Workflow/WfDbAccessReqTasks.cs b/roles/lib/files/FWO.Services/Workflow/WfDbAccessReqTasks.cs index e06c8cc04a..73ac8e1873 100644 --- a/roles/lib/files/FWO.Services/Workflow/WfDbAccessReqTasks.cs +++ b/roles/lib/files/FWO.Services/Workflow/WfDbAccessReqTasks.cs @@ -20,6 +20,11 @@ public partial class WfDbAccess public async Task AddReqTaskToDb(WfReqTask reqtask, WfTicket? previousTicket = null) { long returnId = 0; + if (!await FlowReferencesAreRequestable(reqtask)) + { + return returnId; + } + WfTicket? storedTicket = previousTicket ?? await LoadPreviousTicket(reqtask.TicketId); try { @@ -67,7 +72,7 @@ await LogWorkflowChange(new(reqtask.TicketId, ModellingTypes.ChangeType.Insert, /// public async Task UpdateReqTaskInDb(WfReqTask reqtask) { - if (reqtask.Locked) + if (reqtask.Locked || !await FlowReferencesAreRequestable(reqtask)) { return; } diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationServerController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationServerController.cs index 63fa969db1..6d2c053577 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationServerController.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/AuthenticationServerController.cs @@ -42,10 +42,14 @@ public AuthenticationServerController(ApiConnection apiConnection, List ld /// The passwords are taken as entered (clear text). Stored credentials are never /// used here, so that the connection test cannot be turned into a way of replaying /// them against a freely chosen server. + /// This is a POST although it changes nothing: the parameters, including credentials + /// entered for the test, travel in the body, and a GET carrying a body is handled + /// inconsistently by proxies and http clients, while its url and body risk being + /// cached or logged along the way. /// /// Ldap connection parameters /// - [HttpGet("TestConnection")] + [HttpPost("TestConnection")] [Authorize(Roles = $"{Roles.Admin}")] public async Task> TestConnection([FromBody] LdapGetUpdateParameters parameters) { diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs index e7cdfbe6ff..c1235f2779 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs @@ -1,4 +1,5 @@ using FWO.Api.Client; +using FWO.Api.Client.Queries; using FWO.Basics; using FWO.Compliance; using FWO.Config.Api; @@ -202,6 +203,11 @@ private async Task ExecuteActionsInMiddlewareContext(ApiCo return result; } + if (!await TryClaimStateChangeExecution(actionApiConnection, parameters, scope, ticket, result)) + { + return result; + } + // The bundle is neither flushed nor removed here: the dedicated flush-only request is the // single flush entry point and removes the bundle itself. Removing it on an action request // would discard captured emails whenever the action fails. @@ -371,6 +377,69 @@ private static bool ValidateOfferedAction(WfHandler wfHandler, WorkflowActionPar return true; } + /// + /// Claims the one execution of the state-change actions belonging to a transition. + /// + /// + /// SEC-06: the state of the object is persisted by the caller before its actions are + /// requested, so "the object stands in the requested state" stays true once the transition + /// happened and cannot tell a first request apart from a replay. The claim is what makes the + /// difference: request.state_change_execution holds the transition the actions of this object + /// last ran for, and the mutation records the new one only when it differs, in a single + /// statement. A repeated request therefore executes nothing. + /// An already claimed transition is reported as success rather than as an error: the + /// transition did happen and its actions did run, so the caller has the outcome it asked for, + /// and an accidental double submit must not surface as a failed promote. + /// Only the persisted-transition path is claimed. A request naming an action explicitly + /// carries no transition to key the claim on and is validated against the actions currently + /// offered instead. + /// + /// Api connection running under the middleware role. + /// The requested action, holding the claimed transition. + /// Scope of the stateful object. + /// The resolved ticket, which is the stateful object of the ticket scope. + /// Result of the action request, completed when the claim is refused. + /// True when the caller may execute the actions of this transition. + private async Task TryClaimStateChangeExecution(ApiConnection actionApiConnection, WorkflowActionParameters parameters, + WfObjectScopes scope, WfTicket ticket, WorkflowActionResult result) + { + if (parameters.ActionId > 0) + { + return true; + } + + // Taken from the resolved object rather than from the request: the ticket scope carries + // its id in TicketId or ObjectId depending on the caller, and the key must not depend on + // which of the two was filled in. + long objectId = scope == WfObjectScopes.Ticket ? ticket.Id : parameters.ObjectId; + var claimVariables = new + { + objectScope = scope.ToString(), + objectId = objectId, + fromStateId = parameters.OldStateId, + toStateId = parameters.NewStateId, + executedBy = User.FindFirstValue("x-hasura-uuid") ?? "", + executedAt = DateTime.UtcNow + }; + + ReturnId claim = await actionApiConnection.SendQueryAsync(RequestQueries.claimStateChangeExecution, claimVariables); + if (claim.AffectedRows == 1) + { + return true; + } + + Log.WriteAudit("Workflow Actions", $"State-change actions for {scope} {objectId} were already executed for the transition " + + $"{parameters.OldStateId}->{parameters.NewStateId}, so this request executed nothing."); + result.Success = true; + result.Messages.Add(new() + { + Title = "Workflow Actions", + Message = $"The actions of this state change have already been executed for {scope} {objectId}.", + ErrorFlag = false + }); + return false; + } + private static bool ValidatePersistedStateTransition(WorkflowActionParameters parameters, WfStatefulObject statefulObject, WorkflowActionResult result) { if (parameters.OldStateId == parameters.NewStateId) diff --git a/roles/middleware/files/FWO.Middleware.Server/Services/FlowCatalogService.cs b/roles/middleware/files/FWO.Middleware.Server/Services/FlowCatalogService.cs index 3f84768f4a..3ccb771fc5 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Services/FlowCatalogService.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Services/FlowCatalogService.cs @@ -275,27 +275,7 @@ private static Dictionary BuildGroupResolutionVariables(string i private static bool IsActiveAndVisible(FlowGroup group) { - return !string.IsNullOrWhiteSpace(group.Name) - && group.ShowInRequestModule - && !string.Equals(group.State, FlowState.Removed, StringComparison.OrdinalIgnoreCase) - && !string.Equals(group.State, FlowState.Denied, StringComparison.OrdinalIgnoreCase) - && group.RemovedDate == null; - } - - private static bool IsActiveAndVisible(FlowNwObject flowObject) - { - return flowObject.ShowInRequestModule - && !string.Equals(flowObject.State, FlowState.Removed, StringComparison.OrdinalIgnoreCase) - && !string.Equals(flowObject.State, FlowState.Denied, StringComparison.OrdinalIgnoreCase) - && flowObject.RemovedDate == null; - } - - private static bool IsActiveAndVisible(FlowSvcObject flowObject) - { - return flowObject.ShowInRequestModule - && !string.Equals(flowObject.State, FlowState.Removed, StringComparison.OrdinalIgnoreCase) - && !string.Equals(flowObject.State, FlowState.Denied, StringComparison.OrdinalIgnoreCase) - && flowObject.RemovedDate == null; + return FlowObjectEligibility.IsRequestable(group); } private static FlowNetworkGroupResolution ToNetworkGroupResolution(FlowNwGroup group) @@ -305,7 +285,7 @@ private static FlowNetworkGroupResolution ToNetworkGroupResolution(FlowNwGroup g Id = group.Id, Name = group.Name, Members = group.NwGroupMembers - .Where(member => IsActiveAndVisible(member.NwObject)) + .Where(member => FlowObjectEligibility.IsRequestable(member.NwObject)) .Select(member => new FlowNetworkMemberResolution { Id = member.NwObject.Id, @@ -324,7 +304,7 @@ private static FlowServiceGroupResolution ToServiceGroupResolution(FlowSvcGroup Id = group.Id, Name = group.Name, Members = group.SvcGroupMembers - .Where(member => IsActiveAndVisible(member.SvcObject)) + .Where(member => FlowObjectEligibility.IsRequestable(member.SvcObject)) .Select(member => new FlowServiceMemberResolution { Id = member.SvcObject.Id, diff --git a/roles/tests-unit/files/FWO.Test/AuthenticationServerControllerTest.cs b/roles/tests-unit/files/FWO.Test/AuthenticationServerControllerTest.cs index 3bd8c1a7fc..078d78ee64 100644 --- a/roles/tests-unit/files/FWO.Test/AuthenticationServerControllerTest.cs +++ b/roles/tests-unit/files/FWO.Test/AuthenticationServerControllerTest.cs @@ -1,11 +1,14 @@ using FWO.Api.Client; using FWO.Api.Client.Queries; +using FWO.Basics; using FWO.Data; using FWO.Data.Middleware; using FWO.Middleware.Server.Controllers; using MiddlewareLdap = FWO.Middleware.Server.Ldap; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NUnit.Framework; +using System.Reflection; namespace FWO.Test { @@ -47,6 +50,66 @@ public async Task Get_ReturnsConvertedLdapConnections() Assert.That(apiConnection.QueryCount, Is.EqualTo(1)); } + /// + /// SEC-04: the connection test reaches a server named by the caller and carries the + /// credentials entered for it in the request body. A GET with a body is handled + /// inconsistently by proxies and http clients, and its url and body risk being cached or + /// written to an access log on the way, so the test has to be a POST even though it changes + /// nothing. Asserted on the attributes because nothing but a live proxy would report it. + /// + [Test] + public void TestConnection_IsAPostSoItsBodyIsNotSentOnAGet() + { + MethodInfo testConnection = GetTestConnectionMethod(); + + Assert.Multiple(() => + { + Assert.That(testConnection.GetCustomAttributes(), Is.Empty, + "a body on a GET is dropped by some http clients and logged by some proxies"); + HttpPostAttribute? post = testConnection.GetCustomAttribute(); + Assert.That(post, Is.Not.Null); + Assert.That(post!.Template, Is.EqualTo("TestConnection")); + Assert.That(testConnection.GetParameters()[0].GetCustomAttribute(), Is.Not.Null, + "the credentials entered for the test belong in the body, not in the url"); + }); + } + + /// + /// SEC-04: the test binds to a server and port chosen by the caller, so it must stay with + /// the role that may already configure those. An auditor is read-only and has to be refused. + /// + [Test] + public void TestConnection_IsRestrictedToAdmin() + { + AuthorizeAttribute? authorize = GetTestConnectionMethod().GetCustomAttribute(); + + Assert.That(authorize, Is.Not.Null); + List roles = (authorize!.Roles ?? "") + .Split(',') + .Select(role => role.Trim()) + .Where(role => role.Length > 0) + .ToList(); + + Assert.Multiple(() => + { + Assert.That(roles, Is.EqualTo(new List { Roles.Admin })); + Assert.That(roles, Has.No.Member(Roles.Auditor), + "an auditor may read the connections but must not make the product bind anywhere"); + }); + } + + /// + /// Locates the connection test endpoint, so a rename cannot make the two checks above pass + /// against a method that no longer exists. + /// + private static MethodInfo GetTestConnectionMethod() + { + MethodInfo? testConnection = typeof(AuthenticationServerController) + .GetMethod(nameof(AuthenticationServerController.TestConnection)); + Assert.That(testConnection, Is.Not.Null, "the connection test endpoint was renamed or removed"); + return testConnection!; + } + [Test] public void LdapQueriesForUiDoNotContainAnyPassword() { diff --git a/roles/tests-unit/files/FWO.Test/ExportTest.cs b/roles/tests-unit/files/FWO.Test/ExportTest.cs index bc4efcf6af..3d45f22a48 100644 --- a/roles/tests-unit/files/FWO.Test/ExportTest.cs +++ b/roles/tests-unit/files/FWO.Test/ExportTest.cs @@ -8,6 +8,8 @@ using FWO.Report.Filter; using FWO.Test.Mocks; using FWO.Ui.Pages.Reporting; +using AngleSharp; +using AngleSharp.Dom; using NUnit.Framework; using NUnit.Framework.Legacy; using System.Reflection; @@ -81,7 +83,7 @@ public void RulesGenerateHtml() var reportRules = ConstructReportRules(query, userConfig, ReportType.Rules, ConstructRuleReportRules(false)); - string expectedHtmlResult = "Rules Report

      Rules Report

      Time of configuration: 2026-05-20T09:38:32Z (UTC)

      Generated on: 2026-05-20T09:38:32Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter



      TestMgt


      Mock Device 1

      {userConfig.GetText("network_objects")}
      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05
      1.2TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment22023-04-05

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      2TestIp2Network127.0.0.1/32Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      2TestService2UDP6666-7777Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; + string expectedHtmlResult = "Rules Report

      Rules Report

      Time of configuration: 2026-05-20T09:38:32Z (UTC)

      Generated on: 2026-05-20T09:38:32Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter



      TestMgt


      Mock Device 1

      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05
      1.2TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment22023-04-05

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      2TestIp2Network127.0.0.1/32Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f
      2TestService2UDP6666-7777Member-019e44c0-4816-7c9a-9b94-5417d3cbb15f019e44c0-4816-7c9a-9b94-5417d3cbb15fComment-019e44c0-4816-7c9a-9b94-5417d3cbb15f

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; expectedHtmlResult = GuidRegexPattern().Replace(expectedHtmlResult, StaticUid); string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); @@ -102,7 +104,7 @@ public void ResolvedRulesGenerateHtml() Log.WriteInfo("Test Log", "starting rules report resolved html generation"); ReportRules reportRules = ConstructReportRules(query, userConfig, ReportType.ResolvedRules, ConstructRuleReportRules(true)); - string expectedHtmlResult = "Rules Report (resolved)

      Rules Report (resolved)

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Device 1

      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
      TestIp1 (1.2.3.4/32)
      TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
      TestIpRange (1.2.3.4-1.2.3.5)TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05
      1.2TestRule2not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)
      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)
      not
      TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment22023-04-05

      "; + string expectedHtmlResult = "Rules Report (resolved)

      Rules Report (resolved)

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Device 1

      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
      TestIp1 (1.2.3.4/32)
      TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
      TestIpRange (1.2.3.4-1.2.3.5)TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05
      1.2TestRule2not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)
      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)
      not
      TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment22023-04-05

      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -123,7 +125,7 @@ public void ResolvedRulesTechGenerateHtml() ReportRules reportRules = ConstructReportRules(query, userConfig, ReportType.ResolvedRulesTech, ConstructRuleReportRules(true)); - string expectedHtmlResult = "Rules Report (technical)

      Rules Report (technical)

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Device 1

      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
      1.2.3.4/32
      127.0.0.1/32
      dstzn1
      dstzn2
      dstzn3
      1.2.3.4-1.2.3.5443/TCPacceptnoneYuid1comment12023-04-05
      1.2TestRule2not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32
      not
      TestUser2@1.2.3.4-1.2.3.5
      not
      6666-7777/UDP
      denynoneYuid2:123comment22023-04-05

      "; + string expectedHtmlResult = "Rules Report (technical)

      Rules Report (technical)

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Device 1

      No.NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      1.1TestRule1srczn1
      srczn2
      srczn3
      1.2.3.4/32
      127.0.0.1/32
      dstzn1
      dstzn2
      dstzn3
      1.2.3.4-1.2.3.5443/TCPacceptnoneYuid1comment12023-04-05
      1.2TestRule2not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32
      not
      TestUser2@1.2.3.4-1.2.3.5
      not
      6666-7777/UDP
      denynoneYuid2:123comment22023-04-05

      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -143,7 +145,7 @@ public void UnusedRulesGenerateHtml() Log.WriteInfo("Test Log", "starting unused rules report html generation"); ReportRules reportRules = ConstructReportRules(query, userConfig, ReportType.UnusedRules, ConstructRuleReportRules(false)); - string expectedHtmlResult = "Unused Rules Report

      Unused Rules Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter



      TestMgt


      Mock Device 1

      No.Last HitLastModifiedNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidComment
      1.12022-04-192023-04-05TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment1
      1.22023-04-05TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment2

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      2TestIp2Network127.0.0.1/32Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      2TestService2UDP6666-7777Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; + string expectedHtmlResult = "Unused Rules Report

      Unused Rules Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [Mock Device 1]

      Filter: TestFilter



      TestMgt


      Mock Device 1

      No.Last HitLastModifiedNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidComment
      1.12022-04-192023-04-05TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment1
      1.22023-04-05TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment2

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      2TestIp2Network127.0.0.1/32Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53
      2TestService2UDP6666-7777Member-019df806-887d-72c1-bc1b-1315b3d3ec53019df806-887d-72c1-bc1b-1315b3d3ec53Comment-019df806-887d-72c1-bc1b-1315b3d3ec53

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; expectedHtmlResult = GuidRegexPattern().Replace(expectedHtmlResult, StaticUid); string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); @@ -166,7 +168,7 @@ public void RecertReportGenerateHtml() DateTime referenceDate = DateTime.Today; ReportRules reportRecerts = ConstructReportRules(query, userConfig, ReportType.Recertification, ConstructRecertReportRules(false, referenceDate)); - string expectedHtmlResult = "Recertification Report

      Recertification Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Managements: TestMgt

      Filter: TestFilter



      TestMgt


      Mock Rulebase 1

      Next Recertification DateOwnerIP address matchLast HitLastModifiedNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidComment

      1. __RECERT_DATE_PLUS_5__

      2. __RECERT_DATE_MINUS_5__

      1. TestOwner1

      2. TestOwner2

      1. TestIp1

      2. TestIp2

      2022-04-192023-04-05TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment1

      __RECERT_DATE_TODAY__

      TestOwner1

      TestIpRange

      2023-04-05TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment2

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      2TestIp2Network127.0.0.1/32Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      2TestService2UDP6666-7777Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; + string expectedHtmlResult = "Recertification Report

      Recertification Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Managements: TestMgt

      Filter: TestFilter



      TestMgt


      Mock Rulebase 1

      Next Recertification DateOwnerIP address matchLast HitLastModifiedNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidComment

      1. __RECERT_DATE_PLUS_5__

      2. __RECERT_DATE_MINUS_5__

      1. TestOwner1

      2. TestOwner2

      1. TestIp1

      2. TestIp2

      2022-04-192023-04-05TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment1

      __RECERT_DATE_TODAY__

      TestOwner1

      TestIpRange

      2023-04-05TestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)
      not
       TestService2 (6666-7777/UDP)
      denynoneYuid2:123comment2

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      2TestIp2Network127.0.0.1/32Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3
      2TestService2UDP6666-7777Member-019e2ac6-ff8d-7364-ae0a-e75317498dd3019e2ac6-ff8d-7364-ae0a-e75317498dd3Comment-019e2ac6-ff8d-7364-ae0a-e75317498dd3

      Users

      No.NameTypeMembersUidComment
      1TestUser1
      2TestUser2Group

      "; expectedHtmlResult = expectedHtmlResult .Replace("__RECERT_DATE_PLUS_5__", $"{referenceDate.AddDays(5):yyyy-MM-dd}") @@ -228,7 +230,7 @@ public void NatRulesGenerateHtml() }; reportNatRules.TryBuildMockRuleTree(); - string expectedHtmlResult = "NAT Rules Report

      NAT Rules Report

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter



      TestMgt


      TestDev

      No.NameSource ZoneSourceDestination ZoneDestinationServicesTranslated SourceTranslated DestinationTranslated ServicesEnabledUidComment
      TestNatRulebase
      1.1TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP) TestUser2@ TestIp1Changed (2.3.4.5)not
       TestIp1Changed (2.3.4.5)
       TestIpNew (10.0.6.0/24)
       TestService1 (443/TCP)
       TestService2 (6666-7777/UDP)
      Yuid1comment1

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      2TestIp2Network127.0.0.1/32Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      4TestIpNewNetwork10.0.6.0/24Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      5TestIp1ChangedHost2.3.4.5Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      2TestService2UDP6666-7777Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "

      Users

      No.NameTypeMembersUidComment
      1TestUser2Group

      "; + string expectedHtmlResult = "NAT Rules Report

      NAT Rules Report

      Time of configuration: 2023-04-20T15:50:04Z (UTC)

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter



      TestMgt


      TestDev

      No.NameSource ZoneSourceDestination ZoneDestinationServicesTranslated SourceTranslated DestinationTranslated ServicesEnabledUidComment
      TestNatRulebase
      1.1TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP) TestUser2@ TestIp1Changed (2.3.4.5)not
       TestIp1Changed (2.3.4.5)
       TestIpNew (10.0.6.0/24)
       TestService1 (443/TCP)
       TestService2 (6666-7777/UDP)
      Yuid1comment1

      Network Objects

      No.NameTypeIP AddressMembersUidComment
      1TestIp1Network1.2.3.4/32Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      2TestIp2Network127.0.0.1/32Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      3TestIpRangeIP Range1.2.3.4-1.2.3.5Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      4TestIpNewNetwork10.0.6.0/24Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      5TestIp1ChangedHost2.3.4.5Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "

      Network Services

      No.NameTypeProtocolPortMembersUidComment
      1TestService1TCP443Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "
      2TestService2UDP6666-7777Member-" + StaticUid + "" + StaticUid + "Comment-" + StaticUid + "

      Users

      No.NameTypeMembersUidComment
      1TestUser2Group

      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -251,7 +253,7 @@ public void ChangesGenerateHtml() ReportData = ConstructChangeReport(false) }; - string expectedHtmlResult = "Changes Report

      Changes Report

      Change Time: from: 2026-05-20T09:56:45Z, until: 2026-05-20T09:56:45Z (UTC)

      Generated on: 2026-05-20T09:56:45Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

       TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

       TestIp2 (127.0.0.1/32)

      deleted:

       TestIp1 (1.2.3.4/32)

      added:

       TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestIpNew (10.0.6.0/24)

      deleted:

       TestService1 (443/TCP)

      added:

      not
       TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      deleted:

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
       TestService2 (6666-7777/UDP)

      added:

       TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      not
       TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      "; + string expectedHtmlResult = "Changes Report

      Changes Report

      Change Time: from: 2026-05-20T09:56:45Z, until: 2026-05-20T09:56:45Z (UTC)

      Generated on: 2026-05-20T09:56:45Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

       TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

       TestIp2 (127.0.0.1/32)

      deleted:

       TestIp1 (1.2.3.4/32)

      added:

       TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestIpNew (10.0.6.0/24)

      deleted:

       TestService1 (443/TCP)

      added:

      not
       TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      deleted:

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
       TestService2 (6666-7777/UDP)

      added:

       TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      not
       TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -274,7 +276,7 @@ public void ChangesGenerateHtmlIncludeObjects() ReportData = ConstructChangeReport(false) }; - string expectedHtmlResult = "Changes Report

      Changes Report

      Change Time: from: 2026-05-20T09:53:14Z, until: 2026-05-20T09:53:14Z (UTC)

      Generated on: 2026-05-20T09:53:14Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter



      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

       TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

       TestIp2 (127.0.0.1/32)

      deleted:

       TestIp1 (1.2.3.4/32)

      added:

       TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestIpNew (10.0.6.0/24)

      deleted:

       TestService1 (443/TCP)

      added:

      not
       TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      deleted:

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
       TestService2 (6666-7777/UDP)

      added:

       TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      not
       TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      Network objects

      Change TimeChange TypeNameTypeIP AddressMembersUidComment
      05.04.2023 12:00:00network_object_added

      TestIp1

      network

      (1.2.3.4)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      05.04.2023 12:00:00network_object_modifieddeleted:

      TestIp1

      added:

      TestIp1Changed

      deleted:

      network

      added:

      host

      deleted:

      (1.2.3.4)

      added:

      (2.3.4.5)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63019e44cd-bb95-77c7-ba8c-13d3e9d8aa63Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63
      05.04.2023 12:00:00network_object_deleted

      TestIp2

      network

      (127.0.0.1)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63


      Service objects

      Change TimeChange TypeNameTypeProtocolPortMembersUidComment
      05.04.2023 12:00:00service_added

      TestService1

      TCP

      (443)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      05.04.2023 12:00:00service_modifieddeleted:

      TestService1

      added:

      TestService2

      deleted:

      TCP

      added:

      UDP

      deleted:

      (443)

      added:

      (6666-7777)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63019e44cd-bb95-77c7-ba8c-13d3e9d8aa63Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63
      05.04.2023 12:00:00service_deleted

      TestService1

      TCP

      (443)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63


      "; + string expectedHtmlResult = "Changes Report

      Changes Report

      Change Time: from: 2026-05-20T09:53:14Z, until: 2026-05-20T09:53:14Z (UTC)

      Generated on: 2026-05-20T09:53:14Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter



      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

       TestIp1 (1.2.3.4/32)
       TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

       TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

       TestIp2 (127.0.0.1/32)

      deleted:

       TestIp1 (1.2.3.4/32)

      added:

       TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

       TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestIpNew (10.0.6.0/24)

      deleted:

       TestService1 (443/TCP)

      added:

      not
       TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)
      deleted:

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      added:

       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
       TestService2 (6666-7777/UDP)

      added:

       TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
       TestUser1@ TestIp1 (1.2.3.4/32)
       TestUser1@ TestIp2 (127.0.0.1/32)

      not
       TestUser2@ TestIpRange (1.2.3.4-1.2.3.5)

      not
       TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      Network objects

      Change TimeChange TypeNameTypeIP AddressMembersUidComment
      05.04.2023 12:00:00network_object_added

      TestIp1

      network

      (1.2.3.4)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      05.04.2023 12:00:00network_object_modifieddeleted:

      TestIp1

      added:

      TestIp1Changed

      deleted:

      network

      added:

      host

      deleted:

      (1.2.3.4)

      added:

      (2.3.4.5)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63019e44cd-bb95-77c7-ba8c-13d3e9d8aa63Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63
      05.04.2023 12:00:00network_object_deleted

      TestIp2

      network

      (127.0.0.1)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63


      Service objects

      Change TimeChange TypeNameTypeProtocolPortMembersUidComment
      05.04.2023 12:00:00service_added

      TestService1

      TCP

      (443)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      05.04.2023 12:00:00service_modifieddeleted:

      TestService1

      added:

      TestService2

      deleted:

      TCP

      added:

      UDP

      deleted:

      (443)

      added:

      (6666-7777)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63019e44cd-bb95-77c7-ba8c-13d3e9d8aa63Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63
      05.04.2023 12:00:00service_deleted

      TestService1

      TCP

      (443)

      Member-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      019e44cd-bb95-77c7-ba8c-13d3e9d8aa63

      Comment-019e44cd-bb95-77c7-ba8c-13d3e9d8aa63


      "; expectedHtmlResult = GuidRegexPattern().Replace(expectedHtmlResult, StaticUid); string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); @@ -298,7 +300,7 @@ public void ResolvedChangesGenerateHtml() ReportData = ConstructChangeReport(true) }; - string expectedHtmlResult = "Changes Report (resolved)

      Changes Report (resolved)

      Change Time: from: 2026-05-20T09:47:59Z, until: 2026-05-20T09:47:59Z (UTC)

      Generated on: 2026-05-20T09:47:59Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

      TestIp1 (1.2.3.4/32)
      TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

      TestIpRange (1.2.3.4-1.2.3.5)

      TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

      TestIp2 (127.0.0.1/32)

      deleted:

      TestIp1 (1.2.3.4/32)

      added:

      TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

      TestIpRange (1.2.3.4-1.2.3.5)

      added:

      TestIpNew (10.0.6.0/24)

      deleted:

      TestService1 (443/TCP)

      added:

      not
      TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)
      deleted:

      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      added:

      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
      TestService2 (6666-7777/UDP)

      added:

      TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)

      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      not
      TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      "; + string expectedHtmlResult = "Changes Report (resolved)

      Changes Report (resolved)

      Change Time: from: 2026-05-20T09:47:59Z, until: 2026-05-20T09:47:59Z (UTC)

      Generated on: 2026-05-20T09:47:59Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

      TestIp1 (1.2.3.4/32)
      TestIp2 (127.0.0.1/32)

      dstzn1
      dstzn2
      dstzn3

      TestIpRange (1.2.3.4-1.2.3.5)

      TestService1 (443/TCP)

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

      TestIp2 (127.0.0.1/32)

      deleted:

      TestIp1 (1.2.3.4/32)

      added:

      TestIp1Changed (2.3.4.5)

      dstzn1
      dstzn2
      dstzn3

      TestIpRange (1.2.3.4-1.2.3.5)

      added:

      TestIpNew (10.0.6.0/24)

      deleted:

      TestService1 (443/TCP)

      added:

      not
      TestService1 (443/TCP)

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)
      deleted:

      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      added:

      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      deleted:

      not
      TestService2 (6666-7777/UDP)

      added:

      TestService2 (6666-7777/UDP)

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
      TestUser1@TestIp1 (1.2.3.4/32)
      TestUser1@TestIp2 (127.0.0.1/32)

      not
      TestUser2@TestIpRange (1.2.3.4-1.2.3.5)

      not
      TestService2 (6666-7777/UDP)

      deny

      none

      Y

      uid2:123

      comment2


      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -321,7 +323,7 @@ public void ResolvedChangesTechGenerateHtml() ReportData = ConstructChangeReport(true) }; - string expectedHtmlResult = "Changes Report (technical)

      Changes Report (technical)

      Change Time: from: 2026-05-20T09:46:06Z, until: 2026-05-20T09:46:06Z (UTC)

      Generated on: 2026-05-20T09:46:06Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

      1.2.3.4/32
      127.0.0.1/32

      dstzn1
      dstzn2
      dstzn3

      1.2.3.4-1.2.3.5

      443/TCP

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

      127.0.0.1/32

      deleted:

      1.2.3.4/32

      added:

      2.3.4.5

      dstzn1
      dstzn2
      dstzn3

      1.2.3.4-1.2.3.5

      added:

      10.0.6.0/24

      deleted:

      443/TCP

      added:

      not
      443/TCP

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32
      deleted:

      not
      TestUser2@1.2.3.4-1.2.3.5

      added:

      TestUser2@1.2.3.4-1.2.3.5

      deleted:

      not
      6666-7777/UDP

      added:

      6666-7777/UDP

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32

      not
      TestUser2@1.2.3.4-1.2.3.5

      not
      6666-7777/UDP

      deny

      none

      Y

      uid2:123

      comment2


      "; + string expectedHtmlResult = "Changes Report (technical)

      Changes Report (technical)

      Change Time: from: 2026-05-20T09:46:06Z, until: 2026-05-20T09:46:06Z (UTC)

      Generated on: 2026-05-20T09:46:06Z (UTC)

      Devices: TestMgt [TestDev]

      Filter: TestFilter


      Table of content


      TestMgt


      Rules

      Change TimeChange TypeNameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledenforcing_devicesUidComment
      05.04.2023 12:00:00Rule added

      TestRule1

      srczn1
      srczn2
      srczn3

      1.2.3.4/32
      127.0.0.1/32

      dstzn1
      dstzn2
      dstzn3

      1.2.3.4-1.2.3.5

      443/TCP

      accept

      none

      Y

      uid1

      comment1

      05.04.2023 12:00:00Rule modifiedTestRule1srczn1
      srczn2
      srczn3

      127.0.0.1/32

      deleted:

      1.2.3.4/32

      added:

      2.3.4.5

      dstzn1
      dstzn2
      dstzn3

      1.2.3.4-1.2.3.5

      added:

      10.0.6.0/24

      deleted:

      443/TCP

      added:

      not
      443/TCP

      acceptnoneYdeleted:

      uid1

      deleted:

      comment1

      added:

      new comment

      05.04.2023 12:00:00Rule modifiedTestRule2not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32
      deleted:

      not
      TestUser2@1.2.3.4-1.2.3.5

      added:

      TestUser2@1.2.3.4-1.2.3.5

      deleted:

      not
      6666-7777/UDP

      added:

      6666-7777/UDP

      denynonedeleted:

      Y

      added:

      N

      uid2:123comment2
      05.04.2023 12:00:00Rule deleted

      TestRule2

      not
      TestUser1@1.2.3.4/32
      TestUser1@127.0.0.1/32

      not
      TestUser2@1.2.3.4-1.2.3.5

      not
      6666-7777/UDP

      deny

      none

      Y

      uid2:123

      comment2


      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -341,7 +343,7 @@ public async Task AppRulesGenerateHtml() Log.WriteInfo("Test Log", "starting AppRules report html generation"); var reportRules = await ConstructAppReportRules(query, userConfig, ReportType.AppRules, ConstructRuleReportRules(false)); - string expectedHtmlResult = "App Rules

      App Rules

      Generated on: 2026-05-20T09:42:58Z (UTC)

      Managements: TestMgt

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Rulebase 1

      NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
      ... (1 more)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05

      "; + string expectedHtmlResult = "App Rules

      App Rules

      Generated on: 2026-05-20T09:42:58Z (UTC)

      Managements: TestMgt

      Filter: TestFilter


      Table of content


      TestMgt


      Mock Rulebase 1

      NameSource ZoneSourceDestination ZoneDestinationServicesActionTrackEnabledUidCommentLastModified
      TestRule1srczn1
      srczn2
      srczn3
       TestIp1 (1.2.3.4/32)
      ... (1 more)
      dstzn1
      dstzn2
      dstzn3
       TestIpRange (1.2.3.4-1.2.3.5) TestService1 (443/TCP)acceptnoneYuid1comment12023-04-05

      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -902,7 +904,7 @@ public void ConnectionsGenerateHtml() ReportData = ConstructConnectionReport() }; - string expectedHtmlResult = "Connections Report

      Connections Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Owners: TestOwner

      Filter: TestFilter



      TestOwner (APP-1234)

      Connections

      No.IdNameFunctional ReasonSourceServicesDestination
      1101Conn1 AppServer1 (1.0.0.0) ServiceGroup1
       Service1 (1234/TCP)
       AppRole1 (AR1)

      Interfaces

      No.IdInterface PermissionNameInterface DescriptionSourceServicesDestination
      1102PublicInter2 
       Service2 (2345/UDP)
       noRole ()
       AppServer2 (2.0.0.0)

      Own Common Services

      No.IdNameFunctional ReasonSourceServicesDestination
      1103ComSvc3 AppServer1 (1.0.0.0) 
       Service2 (2345/UDP)
       AppServer2 (2.0.0.0)

      Network Objects

      No.IdNameIpMembers
      121AppRole1 (AR1)AppServer1
      211AppServer11.0.0.0
      30noRole ()
      412AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234
      30
      432Service2UDP2345


      Global Common Services

      No.IdOwnerNameFunctional ReasonSourceServicesDestination
      1103App1ComSvc3 AppServer1 (1.0.0.0) 
       Service2 (2345/UDP)
       AppServer2 (2.0.0.0)

      Network Objects

      No.IdNameIpMembers
      111AppServer11.0.0.0
      212AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      10
      232Service2UDP2345

      "; + string expectedHtmlResult = "Connections Report

      Connections Report

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Owners: TestOwner

      Filter: TestFilter



      TestOwner (APP-1234)

      Connections

      No.IdNameFunctional ReasonSourceServicesDestination
      1101Conn1 AppServer1 (1.0.0.0) ServiceGroup1
       Service1 (1234/TCP)
       AppRole1 (AR1)

      Interfaces

      No.IdInterface PermissionNameInterface DescriptionSourceServicesDestination
      1102PublicInter2 
       Service2 (2345/UDP)
       noRole ()
       AppServer2 (2.0.0.0)

      Own Common Services

      No.IdNameFunctional ReasonSourceServicesDestination
      1103ComSvc3 AppServer1 (1.0.0.0) 
       Service2 (2345/UDP)
       AppServer2 (2.0.0.0)

      Network Objects

      No.IdNameIpMembers
      121AppRole1 (AR1)AppServer1
      211AppServer11.0.0.0
      30noRole ()
      412AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234
      30
      432Service2UDP2345


      Global Common Services

      No.IdOwnerNameFunctional ReasonSourceServicesDestination
      1103App1ComSvc3 AppServer1 (1.0.0.0) 
       Service2 (2345/UDP)
       AppServer2 (2.0.0.0)

      Network Objects

      No.IdNameIpMembers
      111AppServer11.0.0.0
      212AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      10
      232Service2UDP2345

      "; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -926,7 +928,7 @@ public void VariancesGenerateHtml() ReportData = ConstructVarianceReport() }; - string expectedHtmlResult = $$"""VarianceAnalysis""" + $$"""

      VarianceAnalysis

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Owners: TestOwner

      Filter: TestFilter


      Table of content

      """ + @@ -943,11 +945,11 @@ public void VariancesGenerateHtml() $$"""

      Connections with Diffs

      Conn2
      """ + $$"""
      IdNameFunctional ReasonSourceServicesDestination
      102Conn2AppServer1 (1.0.0.0)Service1 (1234/TCP)AppRole2 (AR2)
      """ + $$"""
      ManagementGatewaySourceServicesDestination





      """ + - $$"""

      Network Objects

      """ + - $$"""""" + - $$"""
      No.IdNameIpMembers
      151Area50 (NA50)...
      221AppRole1 (AR1)AppServer1
      311AppServer11.0.0.0
      422AppRole2 (AR2)AppServer2
      512AppServer22.0.0.0

      """ + - $$"""

      Network Services

      """ + - $$"""
      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234


      """; + $$"""

      Network Objects

      """ + + $$"""""" + + $$"""
      No.IdNameIpMembers
      151Area50 (NA50)...
      221AppRole1 (AR1)AppServer1
      311AppServer11.0.0.0
      422AppRole2 (AR2)AppServer2
      512AppServer22.0.0.0

      """ + + $$"""

      Network Services

      """ + + $$"""
      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234


      """; string UTCNOW = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"); expectedHtmlResult = RFC3339DateTimePattern().Replace(expectedHtmlResult, UTCNOW); @@ -966,7 +968,7 @@ public void VariancesGenerateHtml() ReportData = ConstructVarianceReport() }; - string expectedHtmlResult2 = "VarianceAnalysis

      VarianceAnalysis

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Owners: TestOwner

      Filter: TestFilter



      In this report...

      TestOwner

      fullymodelledImplementedNot ImplementedWith Diffs
      AppRoles2011
      Connections2011

      AppRoles Not Implemented

      No.IdNameMembers
      121AppRole1AppServer1 (1.0.0.0)


      AppRoles With Diffs

      No.IdNameMissing App ServersSurplus App Servers
      122AppRole2AppServer2 (2.0.0.0)


      Connections not implemented

      Connections
      No.IdNameFunctional ReasonSourceServicesDestination
      1101Conn1Area50 (NA50)
      AppRole1 (AR1)
      AppServer1 (1.0.0.0)
      ServiceGroup1
      Service1 (1234/TCP)
      AppRole2 (AR2)

      Connections with Diffs

      Conn2
      IdNameFunctional ReasonSourceServicesDestination
      102Conn2AppServer1 (1.0.0.0)Service1 (1234/TCP)AppRole2 (AR2)
      ManagementGatewaySourceServicesDestination





      Network Objects

      No.IdNameIpMembers
      151Area50 (NA50)Net1
      21Net11.0.0.0
      321AppRole1 (AR1)AppServer1
      411AppServer11.0.0.0
      522AppRole2 (AR2)AppServer2
      612AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234


      "; + string expectedHtmlResult2 = "VarianceAnalysis

      VarianceAnalysis

      Generated on: 2026-05-20T10:00:36Z (UTC)

      Owners: TestOwner

      Filter: TestFilter



      In this report...

      TestOwner

      fullymodelledImplementedNot ImplementedWith Diffs
      AppRoles2011
      Connections2011

      AppRoles Not Implemented

      No.IdNameMembers
      121AppRole1AppServer1 (1.0.0.0)


      AppRoles With Diffs

      No.IdNameMissing App ServersSurplus App Servers
      122AppRole2AppServer2 (2.0.0.0)


      Connections not implemented

      Connections
      No.IdNameFunctional ReasonSourceServicesDestination
      1101Conn1Area50 (NA50)
      AppRole1 (AR1)
      AppServer1 (1.0.0.0)
      ServiceGroup1
      Service1 (1234/TCP)
      AppRole2 (AR2)

      Connections with Diffs

      Conn2
      IdNameFunctional ReasonSourceServicesDestination
      102Conn2AppServer1 (1.0.0.0)Service1 (1234/TCP)AppRole2 (AR2)
      ManagementGatewaySourceServicesDestination





      Network Objects

      No.IdNameIpMembers
      151Area50 (NA50)Net1
      21Net11.0.0.0
      321AppRole1 (AR1)AppServer1
      411AppServer11.0.0.0
      522AppRole2 (AR2)AppServer2
      612AppServer22.0.0.0

      Network Services

      No.IdNameProtocolPortMembers
      141ServiceGroup1Service1
      231Service1TCP1234


      "; expectedHtmlResult2 = RFC3339DateTimePattern().Replace(expectedHtmlResult2, UTCNOW); @@ -1582,6 +1584,65 @@ private static ReportData ConstructRuleReportData(bool resolved) }; } + /// + /// Every column of the object, service and user tables of a rules report holds a value the + /// importer stored, so each of them is parameterized with markup here and the export is checked + /// for elements that markup would have created (SEC-10). + /// + [Test] + public void RulesGenerateHtml_KeepsImportedObjectFieldsInert() + { + const string kPayload = ""; + NetworkObject injectedObject = new() + { + Id = 90, + Name = kPayload, + IP = "10.9.9.9/32", + IpEnd = "10.9.9.9/32", + Type = new NetworkObjectType() { Name = ObjectType.Network }, + Uid = kPayload, + Comment = kPayload, + MemberNames = $"{kPayload}|{kPayload}" + }; + NetworkService injectedService = new() + { + Id = 91, + Name = kPayload, + DestinationPort = 443, + DestinationPortEnd = 443, + Type = new NetworkServiceType() { Name = "" }, + Uid = kPayload, + Comment = kPayload, + MemberNames = $"{kPayload}|{kPayload}" + }; + NetworkUser injectedUser = new() + { + Id = 92, + Name = kPayload, + Uid = kPayload, + Comment = kPayload, + MemberNames = $"{kPayload}|{kPayload}" + }; + + MockReportRules reportRules = ConstructReportRules(query, userConfig, ReportType.Rules, ConstructRuleReportRules(false)); + ManagementReport managementData = reportRules.ReportData.ManagementData.First(); + managementData.ReportObjects = [injectedObject]; + managementData.ReportServices = [injectedService]; + managementData.ReportUsers = [injectedUser]; + + string reportHtml = reportRules.ExportToHtml(); + using IDocument document = BrowsingContext.New(Configuration.Default) + .OpenAsync(request => request.Content(reportHtml)).GetAwaiter().GetResult(); + + Assert.Multiple(() => + { + Assert.That(document.QuerySelectorAll("img"), Is.Empty, "an imported value created an element"); + Assert.That(document.QuerySelectorAll("script"), Is.Empty, "an imported value created a ")] + [TestCase("file:///etc/passwd")] + [TestCase("mailto:someone@example.org")] + public void ValidateLocalUrl_RefusesTargetsThatLeaveTheDocument(string url) + { + Assert.That(HtmlOutputEncoder.ValidateLocalUrl(url), Is.EqualTo(HtmlOutputEncoder.kBlockedUrlReplacement)); + } + + /// + /// A browser drops whitespace and control characters before it reads the scheme, so a check that + /// reads the value as written would pass a target the browser then treats as script. + /// + [Test] + [TestCase("java\nscript:alert(1)")] + [TestCase("java\tscript:alert(1)")] + [TestCase(" javascript:alert(1)")] + [TestCase("java\0script:alert(1)")] + public void ValidateLocalUrl_RefusesASchemeHiddenBehindIgnoredCharacters(string url) + { + Assert.That(HtmlOutputEncoder.ValidateLocalUrl(url), Is.EqualTo(HtmlOutputEncoder.kBlockedUrlReplacement)); + } + + [Test] + public void ValidateLocalUrl_TreatsNullAndEmptyAsBlocked() + { + Assert.Multiple(() => + { + Assert.That(HtmlOutputEncoder.ValidateLocalUrl(null), Is.EqualTo(HtmlOutputEncoder.kBlockedUrlReplacement)); + Assert.That(HtmlOutputEncoder.ValidateLocalUrl(" "), Is.EqualTo(HtmlOutputEncoder.kBlockedUrlReplacement)); + }); + } + + [Test] + public void EncodeLocalUrl_RefusesAndEncodesInOneStep() + { + Assert.Multiple(() => + { + Assert.That(HtmlOutputEncoder.EncodeLocalUrl("http://attacker.example/\"x"), Is.EqualTo("#")); + Assert.That(HtmlOutputEncoder.EncodeLocalUrl("#a\"b"), Is.EqualTo("#a"b")); + }); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/PdfRenderSecurityTest.cs b/roles/tests-unit/files/FWO.Test/PdfRenderSecurityTest.cs new file mode 100644 index 0000000000..ffee9386d7 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/PdfRenderSecurityTest.cs @@ -0,0 +1,95 @@ +using FWO.Services; +using NSubstitute; +using NUnit.Framework; +using PuppeteerSharp; + +namespace FWO.Test +{ + /// + /// Covers the lockdown of the headless browser that renders report html to pdf (SEC-10). + /// The report html is built from stored values, so the renderer must not turn a reference hidden in + /// one of them into a request the server issues. + /// + [TestFixture] + [Parallelizable] + internal class PdfRenderSecurityTest + { + private const string kDnsBlockArgument = "--host-resolver-rules=MAP * ~NOTFOUND"; + + [Test] + [TestCase("about:blank")] + [TestCase("data:text/html,

      x

      ")] + public void IsAllowedRenderUrl_AllowsWhatTheRenderItselfNeeds(string url) + { + Assert.That(PdfRenderSecurity.IsAllowedRenderUrl(url), Is.True); + } + + [Test] + [TestCase("http://127.0.0.1:8080/x")] + [TestCase("http://[::1]/x")] + [TestCase("https://attacker.example/collect?token=abc")] + [TestCase("http://169.254.169.254/latest/meta-data/")] + [TestCase("file:///etc/passwd")] + [TestCase("ftp://attacker.example/x")] + [TestCase("ws://attacker.example/x")] + [TestCase("//attacker.example/x")] + [TestCase("not a url")] + [TestCase("")] + [TestCase(null)] + public void IsAllowedRenderUrl_RefusesEverythingThatCouldLeaveTheProcess(string? url) + { + Assert.That(PdfRenderSecurity.IsAllowedRenderUrl(url), Is.False); + } + + [Test] + public void HardenedBrowserArgs_MakeEveryHostNameUnresolvable() + { + Assert.That(PdfRenderSecurity.GetHardenedBrowserArgs(), Does.Contain(kDnsBlockArgument)); + } + + /// + /// The argument list is shared, so a caller that edits what it gets back would weaken every later + /// render in the process. + /// + [Test] + public void HardenedBrowserArgs_AreHandedOutAsACopy() + { + string[] firstCall = PdfRenderSecurity.GetHardenedBrowserArgs(); + firstCall[0] = "--something-else"; + + Assert.That(PdfRenderSecurity.GetHardenedBrowserArgs(), Does.Contain(kDnsBlockArgument)); + } + + [Test] + public async Task HardenPage_TurnsOffScriptsAndInterceptsEveryRequest() + { + IPage page = Substitute.For(); + + await PdfRenderSecurity.HardenPageAsync(page); + + await Task.WhenAll( + page.Received(1).SetJavaScriptEnabledAsync(false), + page.Received(1).SetCacheEnabledAsync(false), + page.Received(1).SetRequestInterceptionAsync(true)); + } + + /// + /// Interception has to be in place before the content is written: a request the page issues while + /// it is still unprotected has already left by the time the handler is attached. + /// + [Test] + public async Task HardenPage_EnablesInterceptionBeforeAnyContentCanBeSet() + { + IPage page = Substitute.For(); + + await PdfRenderSecurity.HardenPageAsync(page); + await page.SetContentAsync(""); + + Received.InOrder(() => + { + page.SetRequestInterceptionAsync(true); + page.SetContentAsync(""); + }); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/ReportBaseTest.cs b/roles/tests-unit/files/FWO.Test/ReportBaseTest.cs index 4cb3939bbf..eb4851461b 100644 --- a/roles/tests-unit/files/FWO.Test/ReportBaseTest.cs +++ b/roles/tests-unit/files/FWO.Test/ReportBaseTest.cs @@ -657,6 +657,27 @@ public async Task RenderInLaunchedBrowserReturnsTheBase64EncodedPdf() await page.Received(1).SetContentAsync(kRenderedHtml, Arg.Any()); } + /// + /// The report html is built from stored values, so the page it is rendered on must not be able to + /// fetch anything. The lockdown has to be applied before the content is written, because a request + /// issued while the page is still unprotected has already left (SEC-10). + /// + [Test] + public async Task RenderInLaunchedBrowserLocksThePageDownBeforeWritingTheContent() + { + IBrowser browser = SubstituteBrowser(out IPage page); + BrowserRenderReportBase report = new(); + + await report.RenderIn(browser, kRenderedHtml, FWO.Report.PaperFormat.A4); + + Received.InOrder(() => + { + page.SetJavaScriptEnabledAsync(false); + page.SetRequestInterceptionAsync(true); + page.SetContentAsync(kRenderedHtml, Arg.Any()); + }); + } + [Test] public async Task RenderInLaunchedBrowserGivesEveryPageOperationAFiniteDeadline() { diff --git a/roles/tests-unit/files/FWO.Test/ReportHtmlInjectionTest.cs b/roles/tests-unit/files/FWO.Test/ReportHtmlInjectionTest.cs new file mode 100644 index 0000000000..bf31840783 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/ReportHtmlInjectionTest.cs @@ -0,0 +1,178 @@ +using AngleSharp; +using AngleSharp.Dom; +using FWO.Data; +using FWO.Report; +using NUnit.Framework; + +namespace FWO.Test +{ + /// + /// Parameterizes the link a report builds around an object with a payload for each context that link + /// has - element text, attribute value, url and quote - and asserts that the payload only ever comes + /// back as inert text (SEC-10). + /// The link is the construction site the audit named, and it is the one every rule report puts around + /// every object, service, user and gateway name it shows. + /// + [TestFixture] + [Parallelizable] + internal class ReportHtmlInjectionTest + { + private static readonly List kPayloads = + [ + "", + "", + "\">", + "' onmouseover='alert(1)", + "", + "", + "<img src=x>", + "javascript:alert(1)" + ]; + + private static readonly List kForbiddenElements = ["img", "script", "iframe", "style", "object", "embed", "link"]; + + private static IDocument Parse(string html) + { + IBrowsingContext context = BrowsingContext.New(Configuration.Default); + return context.OpenAsync(request => request.Content($"{html}")).GetAwaiter().GetResult(); + } + + [Test] + [TestCaseSource(nameof(kPayloads))] + public void ConstructLink_KeepsAPayloadInTheObjectNameAsText(string payload) + { + string link = ReportBase.ConstructLink("bi bi-diagram-3-fill", payload, "", "#nwobj1x1"); + using IDocument document = Parse(link); + + Assert.Multiple(() => + { + foreach (string forbidden in kForbiddenElements) + { + Assert.That(document.QuerySelectorAll(forbidden), Is.Empty, $"payload created a <{forbidden}> element"); + } + Assert.That(document.QuerySelector("a")!.TextContent, Is.EqualTo(payload)); + }); + } + + [Test] + [TestCaseSource(nameof(kPayloads))] + public void ConstructLink_KeepsAPayloadInTheStyleInsideItsAttribute(string payload) + { + string link = ReportBase.ConstructLink("bi bi-diagram-3-fill", "name", payload, "#nwobj1x1"); + using IDocument document = Parse(link); + + IElement anchor = document.QuerySelector("a")!; + Assert.Multiple(() => + { + foreach (string forbidden in kForbiddenElements) + { + Assert.That(document.QuerySelectorAll(forbidden), Is.Empty, $"payload created a <{forbidden}> element"); + } + Assert.That(anchor.GetAttribute("style"), Is.EqualTo(payload)); + Assert.That(anchor.GetAttribute("onerror"), Is.Null); + Assert.That(anchor.GetAttribute("onmouseover"), Is.Null); + }); + } + + [Test] + [TestCaseSource(nameof(kPayloads))] + public void ConstructLink_KeepsAPayloadInTheIconClassInsideItsAttribute(string payload) + { + string link = ReportBase.ConstructLink(payload, "name", "", "#nwobj1x1"); + using IDocument document = Parse(link); + + Assert.Multiple(() => + { + foreach (string forbidden in kForbiddenElements) + { + Assert.That(document.QuerySelectorAll(forbidden), Is.Empty, $"payload created a <{forbidden}> element"); + } + Assert.That(document.QuerySelector("span")!.GetAttribute("class"), Is.EqualTo(payload)); + }); + } + + /// + /// Group members are imported values joined by separators this code adds itself, so each member + /// has to be encoded before the line breaks between them are inserted (SEC-10). + /// + [Test] + [TestCaseSource(nameof(kPayloads))] + public void MemberNames_AreEncodedWhileTheSeparatorsStayMarkup(string payload) + { + string cell = DisplayBase.MemberNamesAsHtml($"{payload}|{payload}"); + using IDocument document = Parse($"{cell}
      "); + + Assert.Multiple(() => + { + foreach (string forbidden in kForbiddenElements) + { + Assert.That(document.QuerySelectorAll(forbidden), Is.Empty, $"payload created a <{forbidden}> element"); + } + Assert.That(document.QuerySelectorAll("td"), Has.Count.EqualTo(1)); + Assert.That(document.QuerySelectorAll("br"), Has.Count.EqualTo(1), "the separator between two members was lost"); + Assert.That(document.QuerySelector("td")!.TextContent, Is.EqualTo(payload + payload)); + }); + } + + [Test] + public void MemberNames_OfAUserGroupAreEncodedTheSameWay() + { + NetworkUser user = new() { MemberNames = "|plain" }; + using IDocument document = Parse($"{user.MemberNamesAsHtml()}
      "); + + Assert.Multiple(() => + { + Assert.That(document.QuerySelectorAll("img"), Is.Empty); + Assert.That(document.QuerySelector("td")!.TextContent, Is.EqualTo("plain")); + }); + } + + [Test] + public void MemberNames_OfAnEmptyGroupStayEmpty() + { + Assert.That(DisplayBase.MemberNamesWithoutHtml(null!), Is.Empty); + } + + /// + /// A link may only ever point at this document, so a payload in the address is replaced rather + /// than encoded - an encoded absolute url would still be fetched. + /// + [Test] + [TestCase("http://attacker.example/collect")] + [TestCase("//attacker.example/collect")] + [TestCase("javascript:alert(1)")] + [TestCase("java\nscript:alert(1)")] + [TestCase("data:text/html,")] + public void ConstructLink_RefusesAnAddressThatLeavesTheDocument(string address) + { + string link = ReportBase.ConstructLink("bi bi-diagram-3-fill", "name", "", address); + using IDocument document = Parse(link); + + Assert.That(document.QuerySelector("a")!.GetAttribute("href"), Is.EqualTo("#")); + } + + [Test] + [TestCase("#nwobj1x1")] + [TestCase("ReportGeneration#goto-report-1-nwobj0x5")] + public void ConstructLink_KeepsAnAddressThatStaysOnTheDocument(string address) + { + string link = ReportBase.ConstructLink("bi bi-diagram-3-fill", "name", "", address); + using IDocument document = Parse(link); + + Assert.That(document.QuerySelector("a")!.GetAttribute("href"), Is.EqualTo(address)); + } + + /// + /// The in-app report view navigates through this handler, so it has to survive the encoding. It is + /// a fixed literal the code writes, never a stored value. + /// + [Test] + public void ConstructLink_KeepsTheNavigationHandlerItNeeds() + { + string link = ReportBase.ConstructLink("bi bi-diagram-3-fill", "name", "", "#nwobj1x1"); + using IDocument document = Parse(link); + + Assert.That(document.QuerySelector("a")!.GetAttribute("onclick"), Is.EqualTo("event.stopPropagation();")); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/UiRequestWorkflowTest.cs b/roles/tests-unit/files/FWO.Test/UiRequestWorkflowTest.cs index caf772cd2a..b6b40bfe5b 100644 --- a/roles/tests-unit/files/FWO.Test/UiRequestWorkflowTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiRequestWorkflowTest.cs @@ -1952,14 +1952,14 @@ public async Task DisplayAccessElements_UsesPortAndProtocolNameForFlowServiceFal } [Test] - public async Task DisplayAccessElements_UsesAnyProtocolNameForPortlessFlowServiceFallbackName() + public async Task DisplayAccessElements_UsesProtocolNameForPortlessFlowServiceFallbackName() { await using BunitContext context = new(); RequestWorkflowApiConn apiConn = new() { FlowSvcObjects = new List { - new FlowSvcObject { Id = 202, Name = "", PortStart = null, PortEnd = null, ProtoId = -1, ShowInRequestModule = true } + new FlowSvcObject { Id = 202, Name = "", PortStart = null, PortEnd = null, ProtoId = 6, ShowInRequestModule = true } } }; context.Services.AddSingleton(apiConn); @@ -1970,12 +1970,47 @@ public async Task DisplayAccessElements_UsesAnyProtocolNameForPortlessFlowServic .Add(p => p.Sources, new List()) .Add(p => p.Destinations, new List()) .Add(p => p.Services, new List()) - .Add(p => p.IpProtos, new List { new() { Id = -1, Name = "ANY" } }) + .Add(p => p.IpProtos, new List { new() { Id = 6, Name = "tcp" } }) + .Add(p => p.EditMode, true)); + + List loadedServices = GetMember>(component.Instance, "nwServices"); + + Assert.That(loadedServices.Single().Name, Is.EqualTo("/tcp")); + } + + private static readonly List kOnlyRequestableServiceId = [203]; + + /// + /// The canonical any-IP-protocol service is an internal representation the platform attaches + /// itself; offering it in the request module would let a requester ask for any protocol by + /// picking a catalog entry (SEC-09). + /// + [Test] + public async Task DisplayAccessElements_OmitsInternalAnyProtocolFlowServiceFromCatalog() + { + await using BunitContext context = new(); + RequestWorkflowApiConn apiConn = new() + { + FlowSvcObjects = new List + { + new FlowSvcObject { Id = 202, Name = "", PortStart = null, PortEnd = null, ProtoId = -1, ShowInRequestModule = true }, + new FlowSvcObject { Id = 203, Name = "https", PortStart = 443, PortEnd = 443, ProtoId = 6, ShowInRequestModule = true } + } + }; + context.Services.AddSingleton(apiConn); + context.Services.AddSingleton(new RequestWorkflowUserConfig { ReqUseFlowDb = true }); + context.Services.AddSingleton(); + + IRenderedComponent component = context.Render(parameters => parameters + .Add(p => p.Sources, new List()) + .Add(p => p.Destinations, new List()) + .Add(p => p.Services, new List()) + .Add(p => p.IpProtos, new List { new() { Id = -1, Name = "ANY" }, new() { Id = 6, Name = "tcp" } }) .Add(p => p.EditMode, true)); List loadedServices = GetMember>(component.Instance, "nwServices"); - Assert.That(loadedServices.Single().Name, Is.EqualTo("/ANY")); + Assert.That(loadedServices.Select(service => service.Id), Is.EquivalentTo(kOnlyRequestableServiceId)); } [Test] diff --git a/roles/tests-unit/files/FWO.Test/UiUserUpdatePermissionTest.cs b/roles/tests-unit/files/FWO.Test/UiUserUpdatePermissionTest.cs new file mode 100644 index 0000000000..2001c73f64 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/UiUserUpdatePermissionTest.cs @@ -0,0 +1,238 @@ +using System.Text.Json; +using NUnit.Framework; + +namespace FWO.Test +{ + /// + /// Guards the Hasura update permissions of public.uiuser. + /// The uiuser row decides who an account is: uuid is the dn a login and a token refresh resolve + /// against LDAP to derive roles, tenant_id is the tenant, and ldap_connection_id is the + /// directory the account belongs to. A role that may write those columns on its own row can + /// rewrite its own identity and then have the next refresh rebuild its authorization from the + /// rewritten values, which is how an auditor reached role admin (SEC-01). + /// Self-service on this table therefore exists for presentation preferences only, and nothing + /// but a live escalation attempt would report a regression, which is why it is checked here. + /// + [TestFixture] + [Parallelizable] + internal class UiUserUpdatePermissionTest + { + private const string kMetadataFile = "replace_metadata.json"; + private const string kMetadataOutOfReach = + "The Hasura metadata is not reachable in this environment, so the uiuser update permissions cannot be checked."; + private const string kUiUserTable = "uiuser"; + private const string kPublicSchema = "public"; + private const string kSubjectColumn = "uuid"; + private const string kSubjectSessionVariable = "x-hasura-uuid"; + + /// + /// The middleware runs the login and refresh flows and legitimately writes the identity of + /// a user, for example when it first creates the row or follows a directory change. It + /// authenticates against the API with its own role and is not reachable by a UI session. + /// + private const string kServiceRole = "middleware-server"; + + /// + /// The only column a role may write on its own uiuser row. A language is a presentation + /// preference: it is displayed back to the user and never consulted to authorize anything. + /// + private const string kPreferenceColumn = "uiuser_language"; + + private static readonly Lazy kMetadataFileInfo = new(LocateMetadata); + + /// + /// No role reachable from a UI session may write a column of uiuser other than the + /// presentation preference, whatever its row filter says. + /// + [Test] + public void UiUserUpdate_GrantsNonServiceRolesThePreferenceColumnOnly() + { + List violations = []; + + foreach (JsonElement permission in ReadUpdatePermissions()) + { + string role = permission.GetProperty("role").GetString() ?? ""; + if (role == kServiceRole) + { + continue; + } + + List columns = ReadColumns(permission); + List forbidden = columns.Where(column => column != kPreferenceColumn).ToList(); + if (forbidden.Count > 0) + { + violations.Add($"{role} may update {string.Join(", ", forbidden)}"); + } + } + + Assert.That(violations, Is.Empty, + "a role reachable from a UI session may only update " + kPreferenceColumn + + " on uiuser, because every other column feeds authorization: " + + string.Join("; ", violations)); + } + + /// + /// A row filter alone proves only that the row belonged to the caller before the update. + /// Without a post-update check Hasura does not constrain the row afterwards, so an update + /// may move the row out from under the caller - which is what makes rewriting the subject + /// possible in the first place. Every self-service permission therefore needs a check + /// equal to its filter. + /// + [Test] + public void UiUserUpdate_ConstrainsTheRowAfterTheUpdateAsWellAsBefore() + { + List violations = new(); + + foreach (JsonElement permission in ReadUpdatePermissions()) + { + string role = permission.GetProperty("role").GetString() ?? ""; + if (role == kServiceRole) + { + continue; + } + + JsonElement entry = permission.GetProperty("permission"); + string filter = ReadCondition(entry, "filter"); + string check = ReadCondition(entry, "check"); + + if (check != filter) + { + violations.Add($"{role} filters on {filter} but checks {check}"); + } + } + + Assert.That(violations, Is.Empty, + "a self-service update of uiuser must constrain the row after the update exactly as " + + "before it, otherwise the update may move the row to another subject: " + + string.Join("; ", violations)); + } + + /// + /// The self-service permissions are scoped to the caller's own row, and the session + /// variable that scopes them has to be the subject of the token. A permission that filtered + /// on nothing would let one user rewrite the preference of another. + /// + [Test] + public void UiUserUpdate_ScopesNonServiceRolesToTheirOwnRow() + { + List violations = new(); + string expected = $"{{\"{kSubjectColumn}\":{{\"_eq\":\"{kSubjectSessionVariable}\"}}}}"; + + foreach (JsonElement permission in ReadUpdatePermissions()) + { + string role = permission.GetProperty("role").GetString() ?? ""; + if (role == kServiceRole) + { + continue; + } + + string filter = ReadCondition(permission.GetProperty("permission"), "filter"); + if (filter != expected) + { + violations.Add($"{role} filters on {filter}"); + } + } + + Assert.That(violations, Is.Empty, + $"a self-service update of uiuser has to be scoped to {expected}: " + + string.Join("; ", violations)); + } + + /// + /// Reads the columns one permission entry grants. + /// + /// One entry of the update_permissions array. + private static List ReadColumns(JsonElement permission) + { + JsonElement columns = permission.GetProperty("permission").GetProperty("columns"); + return columns.EnumerateArray().Select(column => column.GetString() ?? "").ToList(); + } + + /// + /// Reads a row condition of a permission entry as canonical JSON, so that a missing + /// condition, an explicit null and an empty object stay distinguishable from one another. + /// + /// The permission object holding the condition. + /// Name of the condition, "filter" or "check". + private static string ReadCondition(JsonElement permission, string name) + { + if (!permission.TryGetProperty(name, out JsonElement condition) + || condition.ValueKind == JsonValueKind.Null) + { + return ""; + } + return JsonSerializer.Serialize(condition); + } + + /// + /// Reads the update_permissions of public.uiuser from the metadata file. + /// + /// One element per role holding an update permission on the table. + private static List ReadUpdatePermissions() + { + FileInfo metadataFile = kMetadataFileInfo.Value ?? throw new InvalidOperationException(kMetadataOutOfReach); + using JsonDocument metadata = JsonDocument.Parse(File.ReadAllText(metadataFile.FullName)); + + foreach (JsonElement source in metadata.RootElement.GetProperty("args") + .GetProperty("metadata").GetProperty("sources").EnumerateArray()) + { + foreach (JsonElement table in source.GetProperty("tables").EnumerateArray()) + { + JsonElement declaration = table.GetProperty("table"); + if (declaration.GetProperty("name").GetString() != kUiUserTable + || declaration.GetProperty("schema").GetString() != kPublicSchema) + { + continue; + } + // A tracked table without the permission at all grants nothing, which passes + // every check below, so an empty list is the correct answer and not a skip. + if (!table.TryGetProperty("update_permissions", out JsonElement permissions)) + { + return new List(); + } + // Cloned, because the elements outlive the JsonDocument they were read from. + return permissions.EnumerateArray().Select(entry => entry.Clone()).ToList(); + } + } + throw new InvalidOperationException($"{kPublicSchema}.{kUiUserTable} is not tracked in {kMetadataFile}."); + } + + /// + /// Skips the checks where the metadata is not deployed, because passing on no input at all + /// would report the permissions as sound without having read them. + /// + [SetUp] + public void SkipWithoutMetadata() + { + if (kMetadataFileInfo.Value is null) + { + Assert.Ignore(kMetadataOutOfReach); + } + } + + /// + /// Locates the Hasura metadata in the repository or in the directory the installer copies + /// it to next to the tests, walking up from the directory of the test assembly. + /// + /// The metadata file, or null when it is out of reach. + private static FileInfo? LocateMetadata() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + FileInfo repository = new(Path.Combine(directory.FullName, "roles", "api", "files", kMetadataFile)); + FileInfo installed = new(Path.Combine(directory.FullName, kMetadataFile)); + if (repository.Exists) + { + return repository; + } + if (installed.Exists) + { + return installed; + } + directory = directory.Parent; + } + return null; + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs b/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs index 68c0e5384b..7a4187deec 100644 --- a/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs +++ b/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs @@ -4,6 +4,7 @@ using FWO.Config.Api; using FWO.Config.Api.Data; using FWO.Data; +using FWO.Data.Flow; using FWO.Data.Workflow; using FWO.Data.Modelling; using FWO.Services; @@ -48,6 +49,8 @@ private sealed class WfDbAccessTestApiConn : SimulatedApiConnection public long UpdatedReqElementId { get; set; } = 302; public long DeletedReqElementId { get; set; } = 303; public bool ReturnNullNewReqElementIds { get; set; } + public List RequestableFlowNwObjects { get; set; } = []; + public List RequestableFlowSvcObjects { get; set; } = []; public int NewReqTaskCallCount { get; private set; } public int UpdateReqTaskCallCount { get; private set; } public int UpdateReqTaskStateCallCount { get; private set; } @@ -277,6 +280,14 @@ public override Task SendQueryAsync(string query, object? variables = null List rules = FindRuleUidHasMatch ? [new Rule()] : []; return Task.FromResult((T)(object)rules); } + if (query == FlowQueries.getRequestableFlowNwObjectIds) + { + return Task.FromResult((T)(object)RequestableFlowNwObjects); + } + if (query == FlowQueries.getRequestableFlowSvcObjectIds) + { + return Task.FromResult((T)(object)RequestableFlowSvcObjects); + } throw new AssertionException($"Unexpected query: {query}"); } } @@ -1198,6 +1209,7 @@ await actionHandler.Init(new List new() { Id = 1, Name = "requested" } }); WfDbAccess dbAccess = new(DefaultInit.DoNothing, userConfig, apiConn, actionHandler, false, WorkflowPhases.request); + apiConn.RequestableFlowSvcObjects = [new FlowSvcObject { Id = 7 }]; WfReqTask reqTask = new() { @@ -1226,6 +1238,114 @@ await actionHandler.Init(new List Assert.That(reqTask.Elements[1].Id, Is.EqualTo(22)); } + /// + /// The Flow id columns of a request element are writable by the requesting user, so a task naming + /// a Flow object the request module does not offer is refused before anything is written (SEC-09). + /// + [Test] + public async Task UpdateReqTaskInDb_RefusesTaskAttachingAFlowObjectThatIsNotRequestable() + { + WfDbAccessTestApiConn apiConn = new(); + UserConfig userConfig = new(); + await userConfig.InitWithUserId(apiConn, 42, false); + WfHandler wfHandler = new(); + ActionHandler actionHandler = new(apiConn, wfHandler); + WfDbAccess dbAccess = new(DefaultInit.DoNothing, userConfig, apiConn, actionHandler, false, WorkflowPhases.request); + apiConn.RequestableFlowNwObjects = []; + + WfReqTask reqTask = new() + { + Id = 100, + TicketId = 77, + StateId = 1, + Elements = new List + { + new() { Id = 0, Field = ElemFieldType.source.ToString(), RequestAction = RequestAction.create.ToString(), FlowNetworkObjectId = 4711 } + }, + Owners = new List() + }; + + await dbAccess.UpdateReqTaskInDb(reqTask); + + Assert.Multiple(() => + { + Assert.That(apiConn.UpdateReqTaskCallCount, Is.EqualTo(0)); + Assert.That(apiConn.NewReqElementCallCount, Is.EqualTo(0)); + }); + } + + /// + /// A negative protocol id is an internal representation and may not be requested, so a task + /// carrying one is refused without asking the API for the Flow catalog at all (SEC-09). + /// + [Test] + public async Task AddReqTaskToDb_RefusesTaskCarryingAnInternalProtocolId() + { + WfDbAccessTestApiConn apiConn = new(); + UserConfig userConfig = new(); + await userConfig.InitWithUserId(apiConn, 42, false); + WfHandler wfHandler = new(); + ActionHandler actionHandler = new(apiConn, wfHandler); + WfDbAccess dbAccess = new(DefaultInit.DoNothing, userConfig, apiConn, actionHandler, false, WorkflowPhases.request); + + WfReqTask reqTask = new() + { + TicketId = 77, + StateId = 1, + Elements = new List + { + new() { Field = ElemFieldType.service.ToString(), RequestAction = RequestAction.create.ToString(), ProtoId = GlobalConst.kAnyIpProtocolId } + }, + Owners = new List() + }; + + long newId = await dbAccess.AddReqTaskToDb(reqTask); + + Assert.Multiple(() => + { + Assert.That(newId, Is.EqualTo(0)); + Assert.That(apiConn.NewReqTaskCallCount, Is.EqualTo(0)); + }); + } + + /// + /// A task whose Flow ids all name requestable entries passes the check and is written. + /// + [Test] + public async Task AddReqTaskToDb_AcceptsTaskAttachingRequestableFlowObjects() + { + WfDbAccessTestApiConn apiConn = new(); + UserConfig userConfig = new(); + await userConfig.InitWithUserId(apiConn, 42, false); + WfHandler wfHandler = new(); + ActionHandler actionHandler = new(apiConn, wfHandler); + await actionHandler.Init(new List + { + new() { Id = 1, Name = "requested" } + }); + WfDbAccess dbAccess = new(DefaultInit.DoNothing, userConfig, apiConn, actionHandler, false, WorkflowPhases.request); + apiConn.RequestableFlowNwObjects = [new FlowNwObject { Id = 4711 }]; + + WfReqTask reqTask = new() + { + TicketId = 77, + StateId = 1, + Elements = new List + { + new() { Field = ElemFieldType.source.ToString(), RequestAction = RequestAction.create.ToString(), FlowNetworkObjectId = 4711 } + }, + Owners = new List() + }; + + long newId = await dbAccess.AddReqTaskToDb(reqTask); + + Assert.Multiple(() => + { + Assert.That(newId, Is.EqualTo(201)); + Assert.That(apiConn.NewReqElementCallCount, Is.EqualTo(1)); + }); + } + [Test] public async Task UpdateReqTaskInDb_ReportsMismatchWhenReturnedIdDiffers() { diff --git a/roles/tests-unit/files/FWO.Test/WorkflowMiddlewareUnitTest.cs b/roles/tests-unit/files/FWO.Test/WorkflowMiddlewareUnitTest.cs index 0e48406120..9f12a134e6 100644 --- a/roles/tests-unit/files/FWO.Test/WorkflowMiddlewareUnitTest.cs +++ b/roles/tests-unit/files/FWO.Test/WorkflowMiddlewareUnitTest.cs @@ -85,6 +85,18 @@ private sealed class WorkflowExecutionApiConn : ApiConnection public List States { get; set; } = []; public WfTicket Ticket { get; set; } = new(); + /// + /// Rows the state-change execution claim reports as written. One means the transition + /// was claimed by this request, zero that it had already been executed. + /// + public int StateChangeClaimAffectedRows { get; set; } = 1; + + /// + /// Variables of the last state-change execution claim, so a test can check what the + /// claim was keyed on. + /// + public object? LastStateChangeClaimVariables { get; private set; } + public override GraphQlApiSubscription GetSubscription(Action exceptionHandler, GraphQlApiSubscription.SubscriptionUpdate subscriptionUpdateHandler, string subscription, object? variables = null, string? operationName = null) @@ -105,6 +117,12 @@ public override Task SendQueryAsync(string query, object? variables = null return Task.FromResult((T)(object)Ticket); } + if (query == RequestQueries.claimStateChangeExecution) + { + LastStateChangeClaimVariables = variables; + return Task.FromResult((T)(object)new ReturnId { AffectedRows = StateChangeClaimAffectedRows }); + } + if (query.Contains("getConfigItemsByUser", StringComparison.OrdinalIgnoreCase) || query.Contains("getConfigItemByKey", StringComparison.OrdinalIgnoreCase)) { @@ -422,6 +440,136 @@ public async Task WorkflowController_ExecuteActions_ReturnsErrorWhenApiServerUri } } + /// + /// SEC-06: the object's state is persisted before its actions are requested, so a request + /// that only claims "the object stands in the new state" stays valid after the transition + /// happened and could be submitted again to fire the side effects a second time. Once the + /// transition is claimed, the repeated request must execute nothing. + /// + [Test] + public async Task WorkflowController_ExecuteActionsInMiddlewareContext_ExecutesNothingForAnAlreadyClaimedTransition() + { + WorkflowController controller = CreateWorkflowController(PrincipalWithRoles(Roles.Admin)); + WorkflowExecutionApiConn apiConnection = new() + { + States = [], + // what the claim reports once the transition was executed by an earlier request + StateChangeClaimAffectedRows = 0 + }; + apiConnection.Ticket = new WfTicket + { + Id = 42, + StateId = 8, + Requester = new UiUser { Dn = "uid=requester,dc=fworch,dc=internal" } + }; + WorkflowActionParameters parameters = new() + { + Scope = WfObjectScopes.Ticket.ToString(), + Phase = WorkflowPhases.request.ToString(), + ExecutionMode = Roles.Admin, + OldStateId = 5, + NewStateId = 8 + }; + WorkflowActionResult result = new(); + Func previousFactory = GlobalStateMatrix.Factory; + GlobalStateMatrix.Factory = () => new TestGlobalStateMatrix(); + + try + { + WorkflowActionResult executed = await InvokePrivateAsync(controller, "ExecuteActionsInMiddlewareContext", + apiConnection, parameters, WfObjectScopes.Ticket, WorkflowPhases.request, 42L, result); + + Assert.Multiple(() => + { + // reported as success: the transition did happen and its actions did run, so an + // accidental double submit must not surface as a failed promote + Assert.That(executed.Success, Is.True); + Assert.That(executed.ErrorMessage, Is.Null.Or.Empty); + Assert.That(executed.Messages.Select(message => message.Message), + Has.Some.Contains("already been executed")); + Assert.That(executed.Messages.Any(message => message.ErrorFlag), Is.False); + Assert.That(apiConnection.Queries.Count(query => query == RequestQueries.claimStateChangeExecution), Is.EqualTo(1), + "the claim is the single decision point and must be attempted exactly once"); + }); + } + finally + { + GlobalStateMatrix.Factory = previousFactory; + } + } + + /// + /// The claim decides whether the side effects run, so it must be keyed on the object the + /// server resolved rather than on the id the caller happened to send. + /// + [Test] + public async Task WorkflowController_TryClaimStateChangeExecution_KeysTheClaimOnTheResolvedTicket() + { + WorkflowController controller = CreateWorkflowController(PrincipalWithRoles(Roles.Admin)); + WorkflowExecutionApiConn apiConnection = new(); + WfTicket ticket = new() { Id = 42, StateId = 8 }; + WorkflowActionParameters parameters = new() + { + Scope = WfObjectScopes.Ticket.ToString(), + ObjectId = 999, + OldStateId = 5, + NewStateId = 8 + }; + + bool claimed = await InvokePrivateAsync(controller, "TryClaimStateChangeExecution", + apiConnection, parameters, WfObjectScopes.Ticket, ticket, new WorkflowActionResult()); + + object variables = apiConnection.LastStateChangeClaimVariables!; + + Assert.Multiple(() => + { + Assert.That(claimed, Is.True); + Assert.That(ReadVariable(variables, "objectId"), Is.EqualTo(42L), + "the ticket scope must be keyed on the resolved ticket, not on the caller's object id"); + Assert.That(ReadVariable(variables, "objectScope"), Is.EqualTo(WfObjectScopes.Ticket.ToString())); + Assert.That(ReadVariable(variables, "fromStateId"), Is.EqualTo(5)); + Assert.That(ReadVariable(variables, "toStateId"), Is.EqualTo(8)); + }); + } + + /// + /// A request naming an action explicitly carries no transition to key a claim on, and is + /// validated against the actions currently offered instead. It must not consume the claim of + /// the transition the object last went through. + /// + [Test] + public async Task WorkflowController_TryClaimStateChangeExecution_DoesNotClaimForAnExplicitAction() + { + WorkflowController controller = CreateWorkflowController(PrincipalWithRoles(Roles.Admin)); + WorkflowExecutionApiConn apiConnection = new(); + WorkflowActionParameters parameters = new() + { + Scope = WfObjectScopes.Ticket.ToString(), + ActionId = 3, + OldStateId = 5, + NewStateId = 8 + }; + + bool claimed = await InvokePrivateAsync(controller, "TryClaimStateChangeExecution", + apiConnection, parameters, WfObjectScopes.Ticket, new WfTicket { Id = 42 }, new WorkflowActionResult()); + + Assert.Multiple(() => + { + Assert.That(claimed, Is.True); + Assert.That(apiConnection.Queries, Has.None.EqualTo(RequestQueries.claimStateChangeExecution)); + }); + } + + /// + /// Reads one property of the anonymous variables object handed to the api connection. + /// + /// The variables object of a recorded query. + /// Name of the property to read. + private static object? ReadVariable(object variables, string name) + { + return variables.GetType().GetProperty(name)?.GetValue(variables); + } + [Test] public async Task WorkflowController_ExecuteActionsInMiddlewareContext_CompletesTicketStateChange() { diff --git a/roles/ui/files/FWO.UI/Pages/Request/DisplayAccessElements.razor b/roles/ui/files/FWO.UI/Pages/Request/DisplayAccessElements.razor index fd0d9a1060..3ebbc8af7d 100644 --- a/roles/ui/files/FWO.UI/Pages/Request/DisplayAccessElements.razor +++ b/roles/ui/files/FWO.UI/Pages/Request/DisplayAccessElements.razor @@ -522,12 +522,12 @@ else private static bool IsSelectable(FlowNwObject nwObject) { - return nwObject.ShowInRequestModule && nwObject.RemovedDate == null; + return FlowObjectEligibility.IsRequestable(nwObject); } private static bool IsSelectable(FlowSvcObject svcObject) { - return svcObject.ShowInRequestModule && svcObject.RemovedDate == null; + return FlowObjectEligibility.IsRequestable(svcObject); } private static NetworkObject ToNetworkObject(FlowNwObject nwObject) diff --git a/roles/ui/files/FWO.UI/Pages/Request/DisplayTaskTargetDates.razor b/roles/ui/files/FWO.UI/Pages/Request/DisplayTaskTargetDates.razor index ec432aafd0..36351ae28c 100644 --- a/roles/ui/files/FWO.UI/Pages/Request/DisplayTaskTargetDates.razor +++ b/roles/ui/files/FWO.UI/Pages/Request/DisplayTaskTargetDates.razor @@ -406,7 +406,7 @@ private static bool IsSelectable(FlowTimeObject timeObject) { - return timeObject.ShowInRequestModule && timeObject.RemovedDate == null; + return FlowObjectEligibility.IsRequestable(timeObject); } private static bool ShowsFlowDbSelection(string selection)