diff --git a/rules/001_project_settings/001_0007_hash_algorithm_test.rego b/rules/001_project_settings/001_0007_hash_algorithm_test.rego deleted file mode 100644 index 5c2ed2f..0000000 --- a/rules/001_project_settings/001_0007_hash_algorithm_test.rego +++ /dev/null @@ -1,36 +0,0 @@ -package app.mendix.project_settings.hash_algorithm_test - -import data.app.mendix.project_settings.hash_algorithm -import rego.v1 - -# Test data -bcrypt := {"Settings": { - "$Type": "Settings$ModelSettings", - "HashAlgorithm": "BCrypt", -}} - -ssha256 := {"Settings": { - "$Type": "Settings$ModelSettings", - "HashAlgorithm": "SSHA256", -}} - -sha256 := {"Settings": { - "$Type": "Settings$ModelSettings", - "HashAlgorithm": "SHA256", -}} - -md5 := {"Settings": { - "$Type": "Settings$ModelSettings", - "HashAlgorithm": "MD5", -}} - -# Test cases -test_should_allow_when_build_version_in_allowed_list if { - hash_algorithm.allow with input as bcrypt - hash_algorithm.allow with input as ssha256 -} - -test_should_deny_when_build_version_not_in_allowed_list if { - not hash_algorithm.allow with input as sha256 - not hash_algorithm.allow with input as md5 -} diff --git a/rules/001_project_settings/001_0007_hash_algorithm_test.yaml b/rules/001_project_settings/001_0007_hash_algorithm_test.yaml new file mode 100644 index 0000000..5fcf602 --- /dev/null +++ b/rules/001_project_settings/001_0007_hash_algorithm_test.yaml @@ -0,0 +1,25 @@ +TestCases: + - name: allow BCrypt hash algorithm + allow: true + input: + Settings: + $Type: Settings$ModelSettings + HashAlgorithm: BCrypt + - name: allow SSHA256 hash algorithm + allow: true + input: + Settings: + $Type: Settings$ModelSettings + HashAlgorithm: SSHA256 + - name: do not allow SHA256 hash algorithm + allow: false + input: + Settings: + $Type: Settings$ModelSettings + HashAlgorithm: SHA256 + - name: do not allow MD5 hash algorithm + allow: false + input: + Settings: + $Type: Settings$ModelSettings + HashAlgorithm: MD5 diff --git a/rules/001_project_settings/001_0008_check_security_on_user_roles_test.rego b/rules/001_project_settings/001_0008_check_security_on_user_roles_test.rego deleted file mode 100644 index ca72a4f..0000000 --- a/rules/001_project_settings/001_0008_check_security_on_user_roles_test.rego +++ /dev/null @@ -1,24 +0,0 @@ -package app.mendix.project_settings.check_security_on_user_roles_test - -import data.app.mendix.project_settings.check_security_on_user_roles -import rego.v1 - -# Test data -check_for_security := {"UserRoles": [{ - "CheckSecurity": true, - "Name": "Administrator", -}]} - -not_check_for_security := {"UserRoles": [{ - "CheckSecurity": false, - "Name": "Administrator", -}]} - -# Test cases -test_should_allow_when_checking_user_roles_for_security if { - check_security_on_user_roles.allow with input as check_for_security -} - -test_should_deny_when_not_checking_user_roles_for_security if { - not check_security_on_user_roles.allow with input as not_check_for_security -} diff --git a/rules/001_project_settings/001_0008_check_security_on_user_roles_test.yaml b/rules/001_project_settings/001_0008_check_security_on_user_roles_test.yaml new file mode 100644 index 0000000..cc9f72e --- /dev/null +++ b/rules/001_project_settings/001_0008_check_security_on_user_roles_test.yaml @@ -0,0 +1,15 @@ +TestCases: + - name: allow when user role is checked for security + allow: true + input: + UserRoles: + - $Type: Security$UserRole + Name: Administrator + CheckSecurity: true + - name: do not allow when user role is not checked for security + allow: false + input: + UserRoles: + - $Type: Security$UserRole + Name: Administrator + CheckSecurity: false diff --git a/rules/001_project_settings/001_0009_strict_mode_with_react_client_test.yaml b/rules/001_project_settings/001_0009_strict_mode_with_react_client_test.yaml index 3219eb3..2e2c279 100644 --- a/rules/001_project_settings/001_0009_strict_mode_with_react_client_test.yaml +++ b/rules/001_project_settings/001_0009_strict_mode_with_react_client_test.yaml @@ -1,28 +1,26 @@ +# The rule reads Security$ProjectSecurity.yaml from the working directory. +# During tests the working directory is this rule's folder, which contains a +# fixture Security$ProjectSecurity.yaml with StrictMode disabled. Cases that +# enable the optimized (React) client therefore expect a violation. TestCases: - - name: allow when UseOptimizedClient is false + - name: allow when optimized client is disabled allow: true input: - UseOptimizedClient: false - files: - "Security$ProjectSecurity.yaml": - StrictMode: false - - name: allow when both UseOptimizedClient and StrictMode are true + $Type: Settings$ProjectSettings + Settings: + - $Type: Forms$WebUIProjectSettingsPart + UseOptimizedClient: "No" + - name: allow when optimized client setting is absent allow: true input: - UseOptimizedClient: true - files: - "Security$ProjectSecurity.yaml": - StrictMode: true - - name: do not allow UseOptimizedClient true but StrictMode false + $Type: Settings$ProjectSettings + Settings: + - $Type: Settings$ModelSettings + HashAlgorithm: BCrypt + - name: do not allow optimized client enabled while strict mode is disabled allow: false input: - UseOptimizedClient: true - files: - "Security$ProjectSecurity.yaml": - StrictMode: false - - name: do not allow UseOptimizedClient true but StrictMode missing - allow: false - input: - UseOptimizedClient: true - files: - "Security$ProjectSecurity.yaml": {} + $Type: Settings$ProjectSettings + Settings: + - $Type: Forms$WebUIProjectSettingsPart + UseOptimizedClient: "Yes" diff --git a/rules/001_project_settings/001_0010_security_level_check_everything.rego b/rules/001_project_settings/001_0010_security_level_check_everything.rego new file mode 100644 index 0000000..774ba12 --- /dev/null +++ b/rules/001_project_settings/001_0010_security_level_check_everything.rego @@ -0,0 +1,38 @@ +# METADATA +# scope: package +# title: Security level should be set to production +# description: The project security level should be "CheckEverything" so that both page and microflow access as well as entity access are enforced. Lower levels leave parts of the app unprotected. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/security/securitylevel +# custom: +# category: Security +# rulename: SecurityLevelCheckEverything +# severity: HIGH +# rulenumber: "001_0010" +# remediation: Set the security level to "CheckEverything" in the project security settings. +# input: .*Security\$ProjectSecurity\.yaml +package app.mendix.project_settings.security_level_check_everything + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + input.SecurityLevel != "CheckEverything" + + error := sprintf( + "[%v, %v, %v] Security level is %v, expected CheckEverything", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.SecurityLevel, + ], + ) +} diff --git a/rules/001_project_settings/001_0010_security_level_check_everything_test.yaml b/rules/001_project_settings/001_0010_security_level_check_everything_test.yaml new file mode 100644 index 0000000..4adf380 --- /dev/null +++ b/rules/001_project_settings/001_0010_security_level_check_everything_test.yaml @@ -0,0 +1,16 @@ +TestCases: + - name: allow CheckEverything + allow: true + input: + $Type: Security$ProjectSecurity + SecurityLevel: CheckEverything + - name: do not allow CheckNothing + allow: false + input: + $Type: Security$ProjectSecurity + SecurityLevel: CheckNothing + - name: do not allow CheckSecurity + allow: false + input: + $Type: Security$ProjectSecurity + SecurityLevel: CheckSecurity diff --git a/rules/001_project_settings/001_0011_strict_page_url_check.rego b/rules/001_project_settings/001_0011_strict_page_url_check.rego new file mode 100644 index 0000000..c13685e --- /dev/null +++ b/rules/001_project_settings/001_0011_strict_page_url_check.rego @@ -0,0 +1,37 @@ +# METADATA +# scope: package +# title: Strict page URL check should be enabled +# description: With strict page URL check enabled, pages can only be opened through a valid navigation path, which prevents users from reaching pages they should not have direct access to. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/security/securitystrictpageurl +# custom: +# category: Security +# rulename: StrictPageUrlCheck +# severity: MEDIUM +# rulenumber: "001_0011" +# remediation: Enable "Strict page URL check" in the project security settings. +# input: .*Security\$ProjectSecurity\.yaml +package app.mendix.project_settings.strict_page_url_check + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + input.StrictPageUrlCheck == false + + error := sprintf( + "[%v, %v, %v] Strict page URL check is disabled", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + ], + ) +} diff --git a/rules/001_project_settings/001_0011_strict_page_url_check_test.yaml b/rules/001_project_settings/001_0011_strict_page_url_check_test.yaml new file mode 100644 index 0000000..ce17753 --- /dev/null +++ b/rules/001_project_settings/001_0011_strict_page_url_check_test.yaml @@ -0,0 +1,11 @@ +TestCases: + - name: allow when strict page url check enabled + allow: true + input: + $Type: Security$ProjectSecurity + StrictPageUrlCheck: true + - name: do not allow when strict page url check disabled + allow: false + input: + $Type: Security$ProjectSecurity + StrictPageUrlCheck: false diff --git a/rules/001_project_settings/001_0012_allow_one_session_per_user.rego b/rules/001_project_settings/001_0012_allow_one_session_per_user.rego new file mode 100644 index 0000000..6314dc7 --- /dev/null +++ b/rules/001_project_settings/001_0012_allow_one_session_per_user.rego @@ -0,0 +1,39 @@ +# METADATA +# scope: package +# title: Allow only one session per user +# description: Allowing multiple concurrent sessions per user increases the attack surface and makes it harder to invalidate a session. Restrict users to a single active session unless there is a clear business reason. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/security/securityallowonesessionperuser +# custom: +# category: Security +# rulename: AllowOneSessionPerUser +# severity: MEDIUM +# rulenumber: "001_0012" +# remediation: Disable "Allow multiple sessions per user" in the project runtime settings. +# input: .*Settings\$ProjectSettings\.yaml +package app.mendix.project_settings.allow_one_session_per_user + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some part in input.Settings + part["$Type"] == "Settings$ModelSettings" + part.AllowUserMultipleSessions == true + + error := sprintf( + "[%v, %v, %v] Multiple sessions per user are allowed", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + ], + ) +} diff --git a/rules/001_project_settings/001_0012_allow_one_session_per_user_test.yaml b/rules/001_project_settings/001_0012_allow_one_session_per_user_test.yaml new file mode 100644 index 0000000..acccad8 --- /dev/null +++ b/rules/001_project_settings/001_0012_allow_one_session_per_user_test.yaml @@ -0,0 +1,26 @@ +TestCases: + - name: allow single session per user + allow: true + input: + $Type: Settings$ProjectSettings + Settings: + - $Type: Settings$ModelSettings + AllowUserMultipleSessions: false + HashAlgorithm: BCrypt + - name: do not allow multiple sessions per user + allow: false + input: + $Type: Settings$ProjectSettings + Settings: + - $Type: Settings$ModelSettings + AllowUserMultipleSessions: true + HashAlgorithm: BCrypt + - name: allow when other settings parts present but model settings ok + allow: true + input: + $Type: Settings$ProjectSettings + Settings: + - $Type: Forms$WebUIProjectSettingsPart + UseOptimizedClient: "No" + - $Type: Settings$ModelSettings + AllowUserMultipleSessions: false diff --git a/rules/001_project_settings/001_0013_unassigned_module_role.js b/rules/001_project_settings/001_0013_unassigned_module_role.js new file mode 100644 index 0000000..dd591be --- /dev/null +++ b/rules/001_project_settings/001_0013_unassigned_module_role.js @@ -0,0 +1,67 @@ +const metadata = { + scope: "package", + title: "Module roles should be assigned to a user role", + description: "A module role that is not connected to any user role can never be granted to a user. It is usually a leftover or a modeling mistake and should be assigned or removed.", + authors: ["Xiwen Cheng "], + related_resources: ["https://sdf-docs.clevr.com/?docs=acr-rules/reliability/unassignedmodulerole"], + custom: { + category: "Reliability", + rulename: "UnassignedModuleRole", + severity: "LOW", + rulenumber: "001_0013", + remediation: "Assign the module role to at least one user role, or remove it from the module security.", + input: ".*Security\\$ProjectSecurity\\.yaml" + } +}; + +// Collect every module role defined across all modules by reading each +// module's Security$ModuleSecurity.yaml. Returns a set of "Module.Role". +function definedModuleRoles() { + const defined = new Set(); + let entries; + try { + entries = mxlint.io.listdir("."); + } catch (e) { + return defined; + } + for (const entry of entries) { + try { + if (!mxlint.io.isdir(entry)) { + continue; + } + const moduleSecurity = mxlint.io.readYaml(entry + "/Security$ModuleSecurity.yaml"); + if (!moduleSecurity || !moduleSecurity.ModuleRoles) { + continue; + } + for (const role of moduleSecurity.ModuleRoles) { + defined.add(entry + "." + role.Name); + } + } catch (e) { + // Not a module directory (no module security) - skip. + } + } + return defined; +} + +function rule(input = {}) { + const errors = []; + + // Module roles that are assigned to at least one user role. + const assigned = new Set(); + const userRoles = input.UserRoles || []; + for (const userRole of userRoles) { + for (const moduleRole of (userRole.ModuleRoles || [])) { + assigned.add(moduleRole); + } + } + + const defined = definedModuleRoles(); + for (const moduleRole of defined) { + if (!assigned.has(moduleRole)) { + errors.push(`[${metadata.custom.severity}, ${metadata.custom.category}, ${metadata.custom.rulenumber}] Module role ${moduleRole} is not assigned to any user role.`); + } + } + + const allow = errors.length === 0; + return { allow, errors }; +} diff --git a/rules/001_project_settings/001_0013_unassigned_module_role_test.yaml b/rules/001_project_settings/001_0013_unassigned_module_role_test.yaml new file mode 100644 index 0000000..6fa2289 --- /dev/null +++ b/rules/001_project_settings/001_0013_unassigned_module_role_test.yaml @@ -0,0 +1,26 @@ +TestCases: + - name: allow when all defined module roles are assigned + allow: true + input: + $Type: Security$ProjectSecurity + UserRoles: + - $Type: Security$UserRole + Name: Administrator + ModuleRoles: + - ModuleA.User + - ModuleA.Admin + - ModuleB.Manager + - name: do not allow when a module role is unassigned + allow: false + input: + $Type: Security$ProjectSecurity + UserRoles: + - $Type: Security$UserRole + Name: User + ModuleRoles: + - ModuleA.User + - name: do not allow when there are no user roles at all + allow: false + input: + $Type: Security$ProjectSecurity + UserRoles: [] diff --git a/rules/001_project_settings/ModuleA/Security$ModuleSecurity.yaml b/rules/001_project_settings/ModuleA/Security$ModuleSecurity.yaml new file mode 100644 index 0000000..96da061 --- /dev/null +++ b/rules/001_project_settings/ModuleA/Security$ModuleSecurity.yaml @@ -0,0 +1,7 @@ +# Fixture for 001_0013_unassigned_module_role_test.yaml (ignored by rule scanner). +$Type: Security$ModuleSecurity +ModuleRoles: + - $Type: Security$ModuleRole + Name: User + - $Type: Security$ModuleRole + Name: Admin diff --git a/rules/001_project_settings/ModuleB/Security$ModuleSecurity.yaml b/rules/001_project_settings/ModuleB/Security$ModuleSecurity.yaml new file mode 100644 index 0000000..4b5b361 --- /dev/null +++ b/rules/001_project_settings/ModuleB/Security$ModuleSecurity.yaml @@ -0,0 +1,5 @@ +# Fixture for 001_0013_unassigned_module_role_test.yaml (ignored by rule scanner). +$Type: Security$ModuleSecurity +ModuleRoles: + - $Type: Security$ModuleRole + Name: Manager diff --git a/rules/001_project_settings/Security$ProjectSecurity.yaml b/rules/001_project_settings/Security$ProjectSecurity.yaml new file mode 100644 index 0000000..fd45bd1 --- /dev/null +++ b/rules/001_project_settings/Security$ProjectSecurity.yaml @@ -0,0 +1,3 @@ +# Fixture for 001_0009_strict_mode_with_react_client_test.yaml (ignored by rule scanner). +$Type: Security$ProjectSecurity +StrictMode: false diff --git a/rules/002_domain_model/002_0010_association_owner_both.rego b/rules/002_domain_model/002_0010_association_owner_both.rego new file mode 100644 index 0000000..ed0b02d --- /dev/null +++ b/rules/002_domain_model/002_0010_association_owner_both.rego @@ -0,0 +1,54 @@ +# METADATA +# scope: package +# title: Avoid many-to-many associations owned by Both +# description: Using owner "Both" does not change the ability to navigate an association (that is always possible from both ends), but it does introduce overhead. Many-to-many associations should be owned by the Default entity unless there is a strong business reason. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/performance/associationownerboth +# custom: +# category: Performance +# rulename: AssociationOwnerBoth +# severity: LOW +# rulenumber: "002_0010" +# remediation: Change the association owner to Default unless both-side ownership is genuinely required. +# input: .*/DomainModels\$DomainModel\.yaml +package app.mendix.domain_model.association_owner_both + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some association in input.Associations + association.Owner == "Both" + + error := sprintf( + "[%v, %v, %v] Association %v is owned by Both, which introduces unnecessary overhead", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + association.Name, + ], + ) +} + +errors contains error if { + some association in input.CrossAssociations + association.Owner == "Both" + + error := sprintf( + "[%v, %v, %v] Association %v is owned by Both, which introduces unnecessary overhead", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + association.Name, + ], + ) +} diff --git a/rules/002_domain_model/002_0010_association_owner_both_test.yaml b/rules/002_domain_model/002_0010_association_owner_both_test.yaml new file mode 100644 index 0000000..f8c3bea --- /dev/null +++ b/rules/002_domain_model/002_0010_association_owner_both_test.yaml @@ -0,0 +1,37 @@ +TestCases: + - name: allow domain model without associations + allow: true + input: + Associations: null + CrossAssociations: null + - name: allow association owned by Default + allow: true + input: + Associations: + - $Type: DomainModels$Association + Name: Order_Customer + Owner: Default + - name: do not allow association owned by Both + allow: false + input: + Associations: + - $Type: DomainModels$Association + Name: Order_Product + Owner: Both + - name: do not allow cross association owned by Both + allow: false + input: + CrossAssociations: + - $Type: DomainModels$CrossAssociation + Name: Order_ExternalTag + Owner: Both + - name: allow mix with only Default owners + allow: true + input: + Associations: + - $Type: DomainModels$Association + Name: Order_Customer + Owner: Default + - $Type: DomainModels$Association + Name: Order_Line + Owner: Default diff --git a/rules/002_domain_model/002_0011_entity_documentation.rego b/rules/002_domain_model/002_0011_entity_documentation.rego new file mode 100644 index 0000000..9412c32 --- /dev/null +++ b/rules/002_domain_model/002_0011_entity_documentation.rego @@ -0,0 +1,45 @@ +# METADATA +# scope: package +# title: Large entities should be documented +# description: When an entity has many attributes, documentation (for example the reasoning behind associations or attribute choices) improves maintainability. Another developer can then quickly see what the entity is used for. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/entitydocumentation +# custom: +# category: Maintainability +# rulename: EntityDocumentation +# severity: LOW +# rulenumber: "002_0011" +# remediation: Add documentation to the entity describing its purpose. +# input: .*/DomainModels\$DomainModel\.yaml +package app.mendix.domain_model.entity_documentation + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +# Minimum number of attributes before documentation is required. +min_attributes := 10 + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some entity in input.Entities + count(entity.Attributes) >= min_attributes + documentation := trim_space(object.get(entity, "Documentation", "")) + count(documentation) == 0 + + error := sprintf( + "[%v, %v, %v] Entity %v has %v attributes but no documentation", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + entity.Name, + count(entity.Attributes), + ], + ) +} diff --git a/rules/002_domain_model/002_0011_entity_documentation_test.yaml b/rules/002_domain_model/002_0011_entity_documentation_test.yaml new file mode 100644 index 0000000..9f12142 --- /dev/null +++ b/rules/002_domain_model/002_0011_entity_documentation_test.yaml @@ -0,0 +1,49 @@ +TestCases: + - name: allow small entity without documentation + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: SmallEntity + Documentation: "" + Attributes: + - $Type: DomainModels$Attribute + Name: Attr1 + - $Type: DomainModels$Attribute + Name: Attr2 + - name: allow large entity with documentation + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: BigDocumentedEntity + Documentation: "Holds customer master data." + Attributes: + - {Name: A1} + - {Name: A2} + - {Name: A3} + - {Name: A4} + - {Name: A5} + - {Name: A6} + - {Name: A7} + - {Name: A8} + - {Name: A9} + - {Name: A10} + - name: do not allow large entity without documentation + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: BigUndocumentedEntity + Documentation: "" + Attributes: + - {Name: A1} + - {Name: A2} + - {Name: A3} + - {Name: A4} + - {Name: A5} + - {Name: A6} + - {Name: A7} + - {Name: A8} + - {Name: A9} + - {Name: A10} diff --git a/rules/002_domain_model/002_0012_multilevel_inheritance.js b/rules/002_domain_model/002_0012_multilevel_inheritance.js new file mode 100644 index 0000000..8b0d2b3 --- /dev/null +++ b/rules/002_domain_model/002_0012_multilevel_inheritance.js @@ -0,0 +1,85 @@ +const metadata = { + scope: "package", + title: "Inheritance should be limited to 2 levels", + description: "Deep generalization hierarchies are hard to maintain and have a negative performance impact. Inheritance should be limited to at most two levels.", + authors: ["Xiwen Cheng "], + related_resources: ["https://sdf-docs.clevr.com/?docs=acr-rules/architecture/multilevelinheritance"], + custom: { + category: "Architecture", + rulename: "MultiLevelInheritance", + severity: "MEDIUM", + rulenumber: "002_0012", + remediation: "Flatten the hierarchy. Instead of deep inheritance, associate objects or move attributes up into the super entity with an ObjectType enumeration.", + input: ".*/DomainModels\\$DomainModel\\.yaml" + } +}; + +// Maximum number of ancestors an entity may have through inheritance. +const MAX_LEVELS = 2; + +// Resolve a fully qualified entity name (e.g. "Module.Entity") to its entity +// definition by reading the domain model of the referenced module. +function resolveEntity(qualifiedName) { + const parts = qualifiedName.split("."); + const moduleName = parts[0]; + const entityName = parts.slice(1).join("."); + try { + const domainModel = mxlint.io.readYaml(moduleName + "/DomainModels$DomainModel.yaml"); + if (!domainModel || !domainModel.Entities) { + return undefined; + } + return domainModel.Entities.find(e => e.Name === entityName); + } catch (e) { + // Domain model not available (e.g. platform System module) - stop here. + return undefined; + } +} + +// Extract the generalization reference of an entity, or undefined if it does +// not inherit from another entity. +function generalizationOf(entity) { + if (!entity || !entity.MaybeGeneralization) { + return undefined; + } + return entity.MaybeGeneralization.Generalization; +} + +// Count how many ancestors an entity has by walking up the generalization chain. +function ancestorCount(generalization) { + let count = 0; + let current = generalization; + while (current) { + count++; + if (count > 50) { + // Safety brake against cyclic definitions. + break; + } + const moduleName = current.split(".")[0]; + if (moduleName === "System") { + // The platform System module is the base of the hierarchy. + break; + } + const parent = resolveEntity(current); + current = generalizationOf(parent); + } + return count; +} + +function rule(input = {}) { + const errors = []; + const entities = input.Entities || []; + + for (const entity of entities) { + const generalization = generalizationOf(entity); + if (!generalization) { + continue; + } + const levels = ancestorCount(generalization); + if (levels > MAX_LEVELS) { + errors.push(`[${metadata.custom.severity}, ${metadata.custom.category}, ${metadata.custom.rulenumber}] Entity ${entity.Name} inherits through ${levels} levels, which exceeds the maximum of ${MAX_LEVELS}.`); + } + } + + const allow = errors.length === 0; + return { allow, errors }; +} diff --git a/rules/002_domain_model/002_0012_multilevel_inheritance_test.yaml b/rules/002_domain_model/002_0012_multilevel_inheritance_test.yaml new file mode 100644 index 0000000..f4b6186 --- /dev/null +++ b/rules/002_domain_model/002_0012_multilevel_inheritance_test.yaml @@ -0,0 +1,40 @@ +TestCases: + - name: allow no entities + allow: true + input: + Entities: null + - name: allow entity without generalization + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Standalone + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + - name: allow entity inheriting directly from System + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: MyDocument + MaybeGeneralization: + $Type: DomainModels$Generalization + Generalization: System.FileDocument + - name: allow inheritance of two levels + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: TwoLevels + MaybeGeneralization: + $Type: DomainModels$Generalization + Generalization: Inheritance.Middle + - name: do not allow inheritance of three levels + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: ThreeLevels + MaybeGeneralization: + $Type: DomainModels$Generalization + Generalization: Inheritance.Leaf diff --git a/rules/002_domain_model/002_0013_attribute_naming.rego b/rules/002_domain_model/002_0013_attribute_naming.rego new file mode 100644 index 0000000..951a04e --- /dev/null +++ b/rules/002_domain_model/002_0013_attribute_naming.rego @@ -0,0 +1,43 @@ +# METADATA +# scope: package +# title: Attributes should be named in PascalCase +# description: Consistent PascalCase attribute names (starting with an uppercase letter, letters and digits only) improve readability and maintainability across the domain model. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/attributenameentity +# custom: +# category: ProjectHygiene +# rulename: AttributeNaming +# severity: LOW +# rulenumber: "002_0013" +# remediation: Rename the attribute to PascalCase, e.g. "OrderDate" instead of "order_date". +# input: .*/DomainModels\$DomainModel\.yaml +package app.mendix.domain_model.attribute_naming + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +pascal_case := `^[A-Z][a-zA-Z0-9]*$` + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some entity in input.Entities + some attribute in entity.Attributes + not regex.match(pascal_case, attribute.Name) + + error := sprintf( + "[%v, %v, %v] Attribute %v.%v is not PascalCase", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + entity.Name, + attribute.Name, + ], + ) +} diff --git a/rules/002_domain_model/002_0013_attribute_naming_test.yaml b/rules/002_domain_model/002_0013_attribute_naming_test.yaml new file mode 100644 index 0000000..540acfc --- /dev/null +++ b/rules/002_domain_model/002_0013_attribute_naming_test.yaml @@ -0,0 +1,46 @@ +TestCases: + - name: allow PascalCase attributes + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Order + Attributes: + - $Type: DomainModels$Attribute + Name: OrderDate + - $Type: DomainModels$Attribute + Name: TotalAmount + - name: allow entity without attributes + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Empty + Attributes: [] + - name: do not allow snake_case attribute + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Order + Attributes: + - $Type: DomainModels$Attribute + Name: order_date + - name: do not allow attribute starting with lowercase + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Order + Attributes: + - $Type: DomainModels$Attribute + Name: totalAmount + - name: do not allow attribute with leading underscore + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: Order + Attributes: + - $Type: DomainModels$Attribute + Name: _Internal diff --git a/rules/002_domain_model/002_0014_amount_access_rules.rego b/rules/002_domain_model/002_0014_amount_access_rules.rego new file mode 100644 index 0000000..5b62d52 --- /dev/null +++ b/rules/002_domain_model/002_0014_amount_access_rules.rego @@ -0,0 +1,44 @@ +# METADATA +# scope: package +# title: Limit the number of access rules per entity +# description: An entity with a large number of access rules is hard to reason about and increases the risk of misconfigured security. Keep the number of access rules per entity manageable. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/amountaccessrules +# custom: +# category: Maintainability +# rulename: AmountAccessRules +# severity: LOW +# rulenumber: "002_0014" +# remediation: Consolidate access rules or split the entity so that each entity has a manageable number of access rules. +# input: .*/DomainModels\$DomainModel\.yaml +package app.mendix.domain_model.amount_access_rules + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +max_access_rules := 10 + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some entity in input.Entities + rule_count := count(entity.AccessRules) + rule_count > max_access_rules + + error := sprintf( + "[%v, %v, %v] Entity %v has %v access rules, which exceeds the maximum of %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + entity.Name, + rule_count, + max_access_rules, + ], + ) +} diff --git a/rules/002_domain_model/002_0014_amount_access_rules_test.yaml b/rules/002_domain_model/002_0014_amount_access_rules_test.yaml new file mode 100644 index 0000000..20500d9 --- /dev/null +++ b/rules/002_domain_model/002_0014_amount_access_rules_test.yaml @@ -0,0 +1,50 @@ +TestCases: + - name: allow entity with few access rules + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: SmallEntity + AccessRules: + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - name: allow entity with no access rules + allow: true + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: NoRules + AccessRules: [] + - name: do not allow entity with too many access rules + allow: false + input: + Entities: + - $Type: DomainModels$EntityImpl + Name: BigEntity + AccessRules: + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly + - $Type: DomainModels$AccessRule + DefaultMemberAccessRights: ReadOnly diff --git a/rules/002_domain_model/Inheritance/DomainModels$DomainModel.yaml b/rules/002_domain_model/Inheritance/DomainModels$DomainModel.yaml new file mode 100644 index 0000000..1b7b8a0 --- /dev/null +++ b/rules/002_domain_model/Inheritance/DomainModels$DomainModel.yaml @@ -0,0 +1,23 @@ +# Fixture domain model used by 002_0012_multilevel_inheritance_test.yaml. +# It is intentionally NOT a rule file, so the rule scanner ignores it. +# It defines an inheritance chain: Base <- Middle <- Leaf. +$Type: DomainModels$DomainModel +Associations: [] +CrossAssociations: [] +Documentation: "" +Entities: + - $Type: DomainModels$EntityImpl + Name: Base + MaybeGeneralization: + $Type: DomainModels$NoGeneralization + Persistable: true + - $Type: DomainModels$EntityImpl + Name: Middle + MaybeGeneralization: + $Type: DomainModels$Generalization + Generalization: Inheritance.Base + - $Type: DomainModels$EntityImpl + Name: Leaf + MaybeGeneralization: + $Type: DomainModels$Generalization + Generalization: Inheritance.Middle diff --git a/rules/003_modules/003_0002_minimum_mendix_version.rego b/rules/003_modules/003_0002_minimum_mendix_version.rego new file mode 100644 index 0000000..9b1d93f --- /dev/null +++ b/rules/003_modules/003_0002_minimum_mendix_version.rego @@ -0,0 +1,43 @@ +# METADATA +# scope: package +# title: Use a supported Mendix major version +# description: Older Mendix major versions no longer receive security patches or bug fixes. Running on an unsupported major version (for example 5, 6 or 7) is a maintainability and security risk. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/latestmendixverion +# custom: +# category: Maintainability +# rulename: MinimumMendixVersion +# severity: MEDIUM +# rulenumber: "003_0002" +# remediation: Upgrade the app to a currently supported Mendix major version. +# input: .*Metadata\.yaml +package app.mendix.modules.minimum_mendix_version + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +minimum_major := 9 + +default allow := false + +allow if count(errors) == 0 + +major_version := to_number(split(input.ProductVersion, ".")[0]) + +errors contains error if { + major_version < minimum_major + + error := sprintf( + "[%v, %v, %v] Mendix major version %v is below the minimum supported version %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + major_version, + minimum_major, + ], + ) +} diff --git a/rules/003_modules/003_0002_minimum_mendix_version_test.yaml b/rules/003_modules/003_0002_minimum_mendix_version_test.yaml new file mode 100644 index 0000000..1721754 --- /dev/null +++ b/rules/003_modules/003_0002_minimum_mendix_version_test.yaml @@ -0,0 +1,17 @@ +TestCases: + - name: allow current Mendix version + allow: true + input: + ProductVersion: 10.24.16.96987 + - name: allow minimum supported version + allow: true + input: + ProductVersion: 9.24.2.1234 + - name: do not allow Mendix 7 + allow: false + input: + ProductVersion: 7.23.5.100 + - name: do not allow Mendix 6 + allow: false + input: + ProductVersion: 6.10.0.500 diff --git a/rules/004_pages/004_0005_page_documentation.rego b/rules/004_pages/004_0005_page_documentation.rego new file mode 100644 index 0000000..67f9aa2 --- /dev/null +++ b/rules/004_pages/004_0005_page_documentation.rego @@ -0,0 +1,40 @@ +# METADATA +# scope: package +# title: Pages should be documented +# description: Documentation on a page helps other developers understand its purpose and intended use. Pages without any documentation are harder to maintain. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/pagedocumentation +# custom: +# category: ProjectHygiene +# rulename: PageDocumentation +# severity: LOW +# rulenumber: "004_0005" +# remediation: Add documentation to the page describing its purpose. +# input: .*\.Forms\$Page\.yaml +package app.mendix.pages.page_documentation + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + input["$Type"] == "Forms$Page" + documentation := trim_space(object.get(input, "Documentation", "")) + count(documentation) == 0 + + error := sprintf( + "[%v, %v, %v] Page %v has no documentation", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + object.get(input, "Name", ""), + ], + ) +} diff --git a/rules/004_pages/004_0005_page_documentation_test.yaml b/rules/004_pages/004_0005_page_documentation_test.yaml new file mode 100644 index 0000000..64b5656 --- /dev/null +++ b/rules/004_pages/004_0005_page_documentation_test.yaml @@ -0,0 +1,19 @@ +TestCases: + - name: allow documented page + allow: true + input: + $Type: Forms$Page + Name: Order_Overview + Documentation: "Shows all open orders for the current user." + - name: do not allow page without documentation + allow: false + input: + $Type: Forms$Page + Name: Order_Overview + Documentation: "" + - name: do not allow page with whitespace-only documentation + allow: false + input: + $Type: Forms$Page + Name: Order_Detail + Documentation: " " diff --git a/rules/005_microflows/005_0005_nested_if_statements_test.rego b/rules/005_microflows/005_0005_nested_if_statements_test.rego deleted file mode 100644 index 00362f0..0000000 --- a/rules/005_microflows/005_0005_nested_if_statements_test.rego +++ /dev/null @@ -1,76 +0,0 @@ -package app.mendix.microflows.nested_if_statements_test - -import data.app.mendix.microflows.nested_if_statements -import rego.v1 - -# Test data -one_exclusive_split_with_no_nested_ifs := {"ObjectCollection": { - "$Type": "Microflows$MicroflowObjectCollection", - "Objects": [{ - "$Type": "Microflows$ExpressionSplitCondition", - "Caption": "no nested ifs", - "SplitCondition": {"Expression": "if $Variable then a else b"}, - }], -}} - -multiple_exclusive_splits_with_no_nested_ifs := {"ObjectCollection": { - "$Type": "Microflows$MicroflowObjectCollection", - "Objects": [ - { - "$Type": "Microflows$ExpressionSplitCondition", - "Caption": "ex spit 1 with no nested ifs", - "SplitCondition": {"Expression": "if $Variable then a else b"}, - }, - { - "$Type": "Microflows$ExpressionSplitCondition", - "Caption": "ex split 2 with no nested ifs", - "SplitCondition": {"Expression": "if $Variable then c else d"}, - }, - ], -}} - -one_exclusive_split_with_else_if := {"ObjectCollection": { - "$Type": "Microflows$MicroflowObjectCollection", - "Objects": [{ - "$Type": "Microflows$ExclusiveSplit", - "Caption": "ex split with else-if", - "SplitCondition": {"Expression": "if true then\n\tfalse\nelse if false then\n\ttrue\nelse false"}, - }], -}} - -one_exclusive_split_with_then_if := {"ObjectCollection": { - "$Type": "Microflows$MicroflowObjectCollection", - "Objects": [{ - "$Type": "Microflows$ExclusiveSplit", - "Caption": "ex split with then-if", - "SplitCondition": {"Expression": "if a then if b then c else d else e"}, - }], -}} - -multiple_exclusive_splits_with_one_nested_if := {"ObjectCollection": { - "$Type": "Microflows$MicroflowObjectCollection", - "Objects": [ - { - "$Type": "Microflows$ExclusiveSplit", - "Caption": "no nested ifs", - "SplitCondition": {"Expression": "if $Variable then a else b"}, - }, - { - "$Type": "Microflows$ExclusiveSplit", - "Caption": "ex split with then-if", - "SplitCondition": {"Expression": "if a then b else if c then d else e"}, - }, - ], -}} - -# Test cases -test_should_allow_when_no_exclusive_splits_with_nested_ifs if { - nested_if_statements.allow with input as one_exclusive_split_with_no_nested_ifs - nested_if_statements.allow with input as multiple_exclusive_splits_with_no_nested_ifs -} - -test_should_deny_when_exclusive_splits_with_nested_ifs if { - not nested_if_statements.allow with input as one_exclusive_split_with_else_if - not nested_if_statements.allow with input as one_exclusive_split_with_then_if - not nested_if_statements.allow with input as multiple_exclusive_splits_with_one_nested_if -} diff --git a/rules/005_microflows/005_0005_nested_if_statements_test.yaml b/rules/005_microflows/005_0005_nested_if_statements_test.yaml new file mode 100644 index 0000000..d29c3dd --- /dev/null +++ b/rules/005_microflows/005_0005_nested_if_statements_test.yaml @@ -0,0 +1,59 @@ +TestCases: + - name: allow one exclusive split with no nested ifs + allow: true + input: + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExpressionSplitCondition + Caption: no nested ifs + SplitCondition: + Expression: "if $Variable then a else b" + - name: allow multiple exclusive splits with no nested ifs + allow: true + input: + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExpressionSplitCondition + Caption: ex split 1 with no nested ifs + SplitCondition: + Expression: "if $Variable then a else b" + - $Type: Microflows$ExpressionSplitCondition + Caption: ex split 2 with no nested ifs + SplitCondition: + Expression: "if $Variable then c else d" + - name: do not allow exclusive split with else-if + allow: false + input: + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExclusiveSplit + Caption: ex split with else-if + SplitCondition: + Expression: "if true then\n\tfalse\nelse if false then\n\ttrue\nelse false" + - name: do not allow exclusive split with then-if + allow: false + input: + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExclusiveSplit + Caption: ex split with then-if + SplitCondition: + Expression: "if a then if b then c else d else e" + - name: do not allow multiple exclusive splits with one nested if + allow: false + input: + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExclusiveSplit + Caption: no nested ifs + SplitCondition: + Expression: "if $Variable then a else b" + - $Type: Microflows$ExclusiveSplit + Caption: ex split with then-if + SplitCondition: + Expression: "if a then b else if c then d else e" diff --git a/rules/005_microflows/005_0006_microflow_calls_self.rego b/rules/005_microflows/005_0006_microflow_calls_self.rego new file mode 100644 index 0000000..9e74834 --- /dev/null +++ b/rules/005_microflows/005_0006_microflow_calls_self.rego @@ -0,0 +1,43 @@ +# METADATA +# scope: package +# title: Microflow should not call itself (recursion) +# description: Recursion is a complex structure and Mendix has no built-in fail-safe. A wrong exit condition or bad data can keep the recursive calls going and crash the app. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/architecture/microflowcallsself +# custom: +# category: Architecture +# rulename: MicroflowCallsSelf +# severity: MEDIUM +# rulenumber: "005_0006" +# remediation: Avoid recursion. If it is truly needed, add an emergency brake that stops execution after a maximum number of iterations. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.microflow_calls_self + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + # A microflow call anywhere in the flow (including inside loops) that + # targets a microflow with the same short name is a self-call / recursion. + some obj + walk(input, [_, obj]) + obj["$Type"] == "Microflows$MicroflowCallAction" + endswith(obj.MicroflowCall.Microflow, sprintf(".%v", [input.Name])) + + error := sprintf( + "[%v, %v, %v] Microflow %v calls itself (recursion)", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/005_microflows/005_0006_microflow_calls_self_test.yaml b/rules/005_microflows/005_0006_microflow_calls_self_test.yaml new file mode 100644 index 0000000..e8ccd6f --- /dev/null +++ b/rules/005_microflows/005_0006_microflow_calls_self_test.yaml @@ -0,0 +1,61 @@ +TestCases: + - name: allow microflow calling a different microflow + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$MicroflowCallAction + MicroflowCall: + $Type: Microflows$MicroflowCall + Microflow: MyModule.OtherFlow + - name: allow microflow with a call sharing a suffix but not the full name + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$MicroflowCallAction + MicroflowCall: + $Type: Microflows$MicroflowCall + Microflow: MyModule.DoMyFlow + - name: do not allow microflow that calls itself directly + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$MicroflowCallAction + MicroflowCall: + $Type: Microflows$MicroflowCall + Microflow: MyModule.MyFlow + - name: do not allow microflow that calls itself inside a loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$MicroflowCallAction + MicroflowCall: + $Type: Microflows$MicroflowCall + Microflow: MyModule.MyFlow diff --git a/rules/005_microflows/005_0007_loop_in_loop.rego b/rules/005_microflows/005_0007_loop_in_loop.rego new file mode 100644 index 0000000..c836359 --- /dev/null +++ b/rules/005_microflows/005_0007_loop_in_loop.rego @@ -0,0 +1,45 @@ +# METADATA +# scope: package +# title: Avoid nested loops in a microflow +# description: A loop can potentially be executed hundreds or thousands of times. A loop inside a loop multiplies that cost and quickly leads to performance problems. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/performance/loopinloop +# custom: +# category: Performance +# rulename: LoopInLoop +# severity: MEDIUM +# rulenumber: "005_0007" +# remediation: Restructure the logic to avoid nesting loops, for example by retrieving the required data once before the outer loop. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.loop_in_loop + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + # Find a loop that contains another loop anywhere within its body. + some outer + walk(input, [_, outer]) + outer["$Type"] == "Microflows$LoopedActivity" + + some inner + walk(outer.ObjectCollection, [_, inner]) + inner["$Type"] == "Microflows$LoopedActivity" + + error := sprintf( + "[%v, %v, %v] Microflow %v contains a loop nested inside another loop", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/005_microflows/005_0007_loop_in_loop_test.yaml b/rules/005_microflows/005_0007_loop_in_loop_test.yaml new file mode 100644 index 0000000..827dbe8 --- /dev/null +++ b/rules/005_microflows/005_0007_loop_in_loop_test.yaml @@ -0,0 +1,51 @@ +TestCases: + - name: allow microflow with a single loop + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$ChangeAction + - name: allow microflow with two sequential (non-nested) loops + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - name: do not allow a loop nested inside another loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity diff --git a/rules/005_microflows/005_0008_commit_without_events.rego b/rules/005_microflows/005_0008_commit_without_events.rego new file mode 100644 index 0000000..a95a84e --- /dev/null +++ b/rules/005_microflows/005_0008_commit_without_events.rego @@ -0,0 +1,60 @@ +# METADATA +# scope: package +# title: Avoid committing without events +# description: There is rarely a reason to disable events during a commit. Disabling them silently skips before/after commit logic and should only be done with a very good justification. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/reliability/commitwithoutevents +# custom: +# category: Reliability +# rulename: CommitWithoutEvents +# severity: MEDIUM +# rulenumber: "005_0008" +# remediation: Commit with events enabled, or document why the events must be skipped. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.commit_without_events + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +# Dedicated Commit action with events explicitly disabled. +errors contains error if { + some obj + walk(input, [_, obj]) + obj["$Type"] == "Microflows$CommitAction" + obj.WithEvents == false + + error := sprintf( + "[%v, %v, %v] Commit action in microflow %v is configured without events", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} + +# Change action that commits without events. +errors contains error if { + some obj + walk(input, [_, obj]) + obj["$Type"] == "Microflows$ChangeAction" + obj.Commit == "YesWithoutEvents" + + error := sprintf( + "[%v, %v, %v] Change action in microflow %v commits without events", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/005_microflows/005_0008_commit_without_events_test.yaml b/rules/005_microflows/005_0008_commit_without_events_test.yaml new file mode 100644 index 0000000..15a63b9 --- /dev/null +++ b/rules/005_microflows/005_0008_commit_without_events_test.yaml @@ -0,0 +1,65 @@ +TestCases: + - name: allow commit action with events + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CommitAction + WithEvents: true + - name: allow change action committing with events + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$ChangeAction + Commit: "Yes" + - name: do not allow commit action without events + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CommitAction + WithEvents: false + - name: do not allow change action committing without events + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$ChangeAction + Commit: "YesWithoutEvents" + - name: do not allow commit without events inside a loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CommitAction + WithEvents: false diff --git a/rules/005_microflows/005_0009_microflow_documentation.rego b/rules/005_microflows/005_0009_microflow_documentation.rego new file mode 100644 index 0000000..ff42cc8 --- /dev/null +++ b/rules/005_microflows/005_0009_microflow_documentation.rego @@ -0,0 +1,49 @@ +# METADATA +# scope: package +# title: Complex microflows should be documented +# description: With difficult microflows it is very helpful for other developers to see documentation about decisions or modeling choices. Microflows above a minimum number of actions should carry documentation. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/microflowdocumentation +# custom: +# category: Maintainability +# rulename: MicroflowDocumentation +# severity: LOW +# rulenumber: "005_0009" +# remediation: Add documentation to the microflow describing its purpose and any important modeling choices. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.microflow_documentation + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +# Minimum number of action activities before documentation is required. +min_actions := 10 + +default allow := false + +allow if count(errors) == 0 + +action_count := count([obj | + walk(input, [_, obj]) + obj["$Type"] == "Microflows$ActionActivity" +]) + +errors contains error if { + action_count >= min_actions + documentation := trim_space(object.get(input, "Documentation", "")) + count(documentation) == 0 + + error := sprintf( + "[%v, %v, %v] Microflow %v has %v actions but no documentation", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + action_count, + ], + ) +} diff --git a/rules/005_microflows/005_0009_microflow_documentation_test.yaml b/rules/005_microflows/005_0009_microflow_documentation_test.yaml new file mode 100644 index 0000000..8ba70fd --- /dev/null +++ b/rules/005_microflows/005_0009_microflow_documentation_test.yaml @@ -0,0 +1,69 @@ +TestCases: + - name: allow simple microflow without documentation + allow: true + input: + $Type: Microflows$Microflow + Name: SmallFlow + Documentation: "" + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - name: allow complex microflow with documentation + allow: true + input: + $Type: Microflows$Microflow + Name: BigDocumentedFlow + Documentation: "This flow orchestrates the order processing." + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - name: do not allow complex microflow without documentation + allow: false + input: + $Type: Microflows$Microflow + Name: BigUndocumentedFlow + Documentation: "" + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - name: do not allow complex microflow with whitespace-only documentation + allow: false + input: + $Type: Microflows$Microflow + Name: WhitespaceDocFlow + Documentation: " " + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity + - $Type: Microflows$ActionActivity diff --git a/rules/005_microflows/005_0010_create_system_entity.rego b/rules/005_microflows/005_0010_create_system_entity.rego new file mode 100644 index 0000000..c0479df --- /dev/null +++ b/rules/005_microflows/005_0010_create_system_entity.rego @@ -0,0 +1,43 @@ +# METADATA +# scope: package +# title: Avoid creating System entities directly +# description: System entities such as User or Session should not be instantiated directly in a microflow. Create an Administration.Account instead and let Mendix manage sessions. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/reliability/createsystementity +# custom: +# category: Reliability +# rulename: CreateSystemEntity +# severity: MEDIUM +# rulenumber: "005_0010" +# remediation: Create an Administration.Account (which specializes System.User) instead of creating a System entity directly, and let Mendix handle session creation. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.create_system_entity + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + # Find any create action (anywhere in the flow) that instantiates a System entity. + some obj + walk(input, [_, obj]) + startswith(object.get(obj, "$Type", ""), "Microflows$Create") + startswith(object.get(obj, "Entity", ""), "System.") + + error := sprintf( + "[%v, %v, %v] Microflow %v creates System entity %v directly", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + obj.Entity, + ], + ) +} diff --git a/rules/005_microflows/005_0010_create_system_entity_test.yaml b/rules/005_microflows/005_0010_create_system_entity_test.yaml new file mode 100644 index 0000000..0b85981 --- /dev/null +++ b/rules/005_microflows/005_0010_create_system_entity_test.yaml @@ -0,0 +1,55 @@ +TestCases: + - name: allow creating a non-system entity + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Entity: MyModule.Customer + - name: allow retrieving a system entity + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + RetrieveSource: + $Type: Microflows$DatabaseRetrieveSource + Entity: System.User + - name: do not allow creating System.User + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Entity: System.User + - name: do not allow creating System.Session inside a loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$CreateChangeAction + Entity: System.Session diff --git a/rules/005_microflows/005_0011_todo_annotations.rego b/rules/005_microflows/005_0011_todo_annotations.rego new file mode 100644 index 0000000..6c72f2c --- /dev/null +++ b/rules/005_microflows/005_0011_todo_annotations.rego @@ -0,0 +1,44 @@ +# METADATA +# scope: package +# title: Avoid leftover TODO annotations +# description: TODO or FIXME notes in microflow annotations signal unfinished work. They should be resolved and removed before the microflow is considered complete. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/todoannotations +# custom: +# category: ProjectHygiene +# rulename: TodoAnnotations +# severity: LOW +# rulenumber: "005_0011" +# remediation: Resolve the outstanding work and remove the TODO/FIXME annotation. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.todo_annotations + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +todo_pattern := `(?i)\b(todo|fixme)\b` + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some obj + walk(input, [_, obj]) + obj["$Type"] == "Microflows$Annotation" + regex.match(todo_pattern, object.get(obj, "Caption", "")) + + error := sprintf( + "[%v, %v, %v] Microflow %v contains a TODO/FIXME annotation: %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + obj.Caption, + ], + ) +} diff --git a/rules/005_microflows/005_0011_todo_annotations_test.yaml b/rules/005_microflows/005_0011_todo_annotations_test.yaml new file mode 100644 index 0000000..ef75720 --- /dev/null +++ b/rules/005_microflows/005_0011_todo_annotations_test.yaml @@ -0,0 +1,40 @@ +TestCases: + - name: allow microflow with a normal annotation + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$Annotation + Caption: "This flow processes orders." + - name: allow microflow without annotations + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - name: do not allow TODO annotation + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$Annotation + Caption: "TODO: handle the error case" + - name: do not allow lowercase fixme annotation + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$Annotation + Caption: "fixme before release" diff --git a/rules/005_microflows/005_0012_parameter_amount.rego b/rules/005_microflows/005_0012_parameter_amount.rego new file mode 100644 index 0000000..8cd4cf8 --- /dev/null +++ b/rules/005_microflows/005_0012_parameter_amount.rego @@ -0,0 +1,47 @@ +# METADATA +# scope: package +# title: Limit the number of microflow parameters +# description: A microflow with many parameters is harder to understand, reuse and test. Consider passing an object or splitting the logic when the number of parameters grows too large. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/parameteramount +# custom: +# category: Maintainability +# rulename: ParameterAmount +# severity: LOW +# rulenumber: "005_0012" +# remediation: Reduce the number of parameters, for example by passing a single object that groups related values. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.parameter_amount + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +max_parameters := 5 + +default allow := false + +allow if count(errors) == 0 + +parameter_count := count([obj | + some obj in input.ObjectCollection.Objects + obj["$Type"] == "Microflows$MicroflowParameter" +]) + +errors contains error if { + parameter_count > max_parameters + + error := sprintf( + "[%v, %v, %v] Microflow %v has %v parameters, which exceeds the maximum of %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + parameter_count, + max_parameters, + ], + ) +} diff --git a/rules/005_microflows/005_0012_parameter_amount_test.yaml b/rules/005_microflows/005_0012_parameter_amount_test.yaml new file mode 100644 index 0000000..1e753d0 --- /dev/null +++ b/rules/005_microflows/005_0012_parameter_amount_test.yaml @@ -0,0 +1,46 @@ +TestCases: + - name: allow microflow with few parameters + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$MicroflowParameter + Name: Param0 + - $Type: Microflows$MicroflowParameter + Name: Param1 + - $Type: Microflows$MicroflowParameter + Name: Param2 + - name: allow microflow with no parameters + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - name: do not allow microflow with too many parameters + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$StartEvent + - $Type: Microflows$MicroflowParameter + Name: Param0 + - $Type: Microflows$MicroflowParameter + Name: Param1 + - $Type: Microflows$MicroflowParameter + Name: Param2 + - $Type: Microflows$MicroflowParameter + Name: Param3 + - $Type: Microflows$MicroflowParameter + Name: Param4 + - $Type: Microflows$MicroflowParameter + Name: Param5 diff --git a/rules/005_microflows/005_0013_split_caption.rego b/rules/005_microflows/005_0013_split_caption.rego new file mode 100644 index 0000000..2135129 --- /dev/null +++ b/rules/005_microflows/005_0013_split_caption.rego @@ -0,0 +1,44 @@ +# METADATA +# scope: package +# title: Splits should have a descriptive caption +# description: A caption on an exclusive or inclusive split documents the decision being made and makes the microflow much easier to read. Splits without a caption hide their intent. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/splitcaption +# custom: +# category: Maintainability +# rulename: SplitCaption +# severity: LOW +# rulenumber: "005_0013" +# remediation: Give each split a short caption describing the decision it represents. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.split_caption + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +split_types := {"Microflows$ExclusiveSplit", "Microflows$InclusiveSplit"} + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some obj + walk(input, [_, obj]) + split_types[object.get(obj, "$Type", "")] + caption := trim_space(object.get(obj, "Caption", "")) + count(caption) == 0 + + error := sprintf( + "[%v, %v, %v] Microflow %v has a split without a caption", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/005_microflows/005_0013_split_caption_test.yaml b/rules/005_microflows/005_0013_split_caption_test.yaml new file mode 100644 index 0000000..5c506b6 --- /dev/null +++ b/rules/005_microflows/005_0013_split_caption_test.yaml @@ -0,0 +1,40 @@ +TestCases: + - name: allow split with a caption + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExclusiveSplit + Caption: "Is blocked?" + - name: allow microflow without splits + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + - name: do not allow split without caption + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ExclusiveSplit + Caption: "" + - name: do not allow inclusive split with whitespace caption + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$InclusiveSplit + Caption: " " diff --git a/rules/005_microflows/005_0014_no_continue_error_handling.rego b/rules/005_microflows/005_0014_no_continue_error_handling.rego new file mode 100644 index 0000000..1208212 --- /dev/null +++ b/rules/005_microflows/005_0014_no_continue_error_handling.rego @@ -0,0 +1,41 @@ +# METADATA +# scope: package +# title: Do not silently continue on errors +# description: An activity with "Continue" error handling completely ignores the error. At the very least the error should be logged and handled, otherwise failures pass by unnoticed. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/nocontinues +# - https://docs.mendix.com/howto/logic-business-rules/set-up-error-handling +# custom: +# category: Maintainability +# rulename: NoContinueErrorHandling +# severity: MEDIUM +# rulenumber: "005_0014" +# remediation: Use custom error handling that at least logs the error, instead of continuing silently. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.no_continue_error_handling + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some obj + walk(input, [_, obj]) + object.get(obj, "ErrorHandlingType", "") == "Continue" + + error := sprintf( + "[%v, %v, %v] Microflow %v has an activity that continues on error without handling it", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/005_microflows/005_0014_no_continue_error_handling_test.yaml b/rules/005_microflows/005_0014_no_continue_error_handling_test.yaml new file mode 100644 index 0000000..2f08177 --- /dev/null +++ b/rules/005_microflows/005_0014_no_continue_error_handling_test.yaml @@ -0,0 +1,37 @@ +TestCases: + - name: allow rollback error handling + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + ErrorHandlingType: Rollback + - name: allow custom error handling + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + ErrorHandlingType: Custom + - name: do not allow continue error handling + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + ErrorHandlingType: Continue diff --git a/rules/005_microflows/005_0015_loop_heavy_action.rego b/rules/005_microflows/005_0015_loop_heavy_action.rego new file mode 100644 index 0000000..e47c14d --- /dev/null +++ b/rules/005_microflows/005_0015_loop_heavy_action.rego @@ -0,0 +1,56 @@ +# METADATA +# scope: package +# title: Avoid heavy actions inside a loop +# description: A loop can run hundreds or thousands of times. Performing heavy actions such as database retrieves, microflow calls or web service calls inside a loop multiplies their cost and quickly leads to performance problems. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/performance/loopheavyaction +# custom: +# category: Performance +# rulename: LoopHeavyAction +# severity: MEDIUM +# rulenumber: "005_0015" +# remediation: Move the heavy action out of the loop, for example by retrieving all required data once before the loop. +# input: "**/*$Microflow.yaml" +package app.mendix.microflows.loop_heavy_action + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +heavy_actions := { + "Microflows$RetrieveAction", + "Microflows$MicroflowCallAction", + "Microflows$DeleteAction", + "Microflows$AggregateListAction", + "Microflows$RestCallAction", + "Microflows$WebServiceCallAction", + "Microflows$ImportMappingAction", + "Microflows$ExportMappingAction", +} + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + some loop + walk(input, [_, loop]) + loop["$Type"] == "Microflows$LoopedActivity" + + some obj + walk(loop.ObjectCollection, [_, obj]) + heavy_actions[object.get(obj, "$Type", "")] + + error := sprintf( + "[%v, %v, %v] Microflow %v performs a heavy action (%v) inside a loop", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + obj["$Type"], + ], + ) +} diff --git a/rules/005_microflows/005_0015_loop_heavy_action_test.yaml b/rules/005_microflows/005_0015_loop_heavy_action_test.yaml new file mode 100644 index 0000000..18b3325 --- /dev/null +++ b/rules/005_microflows/005_0015_loop_heavy_action_test.yaml @@ -0,0 +1,58 @@ +TestCases: + - name: allow retrieve outside of a loop + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + - name: allow loop with only lightweight actions + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$ChangeAction + Commit: "No" + - name: do not allow retrieve inside a loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + - name: do not allow microflow call inside a loop + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$LoopedActivity + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$MicroflowCallAction diff --git a/rules/006_security/006_0002_module_roles_amount.rego b/rules/006_security/006_0002_module_roles_amount.rego new file mode 100644 index 0000000..5398a91 --- /dev/null +++ b/rules/006_security/006_0002_module_roles_amount.rego @@ -0,0 +1,43 @@ +# METADATA +# scope: package +# title: Limit the number of module roles per module +# description: A module with many module roles increases the complexity of the security model. Keep the number of module roles per module small and map them to user roles at the project level. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/maintainability/modulerolesamount +# custom: +# category: Maintainability +# rulename: ModuleRolesAmount +# severity: LOW +# rulenumber: "006_0002" +# remediation: Reduce the number of module roles by combining roles that share the same access, or move variation to the user-role level. +# input: .*Security\$ModuleSecurity\.yaml +package app.mendix.security.module_roles_amount + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +max_module_roles := 5 + +default allow := false + +allow if count(errors) == 0 + +module_role_count := count(object.get(input, "ModuleRoles", [])) + +errors contains error if { + module_role_count > max_module_roles + + error := sprintf( + "[%v, %v, %v] Module has %v module roles, which exceeds the maximum of %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + module_role_count, + max_module_roles, + ], + ) +} diff --git a/rules/006_security/006_0002_module_roles_amount_test.yaml b/rules/006_security/006_0002_module_roles_amount_test.yaml new file mode 100644 index 0000000..1077420 --- /dev/null +++ b/rules/006_security/006_0002_module_roles_amount_test.yaml @@ -0,0 +1,26 @@ +TestCases: + - name: allow module with few roles + allow: true + input: + $Type: Security$ModuleSecurity + ModuleRoles: + - $Type: Security$ModuleRole + Name: User + - $Type: Security$ModuleRole + Name: Admin + - name: allow module with no roles + allow: true + input: + $Type: Security$ModuleSecurity + ModuleRoles: [] + - name: do not allow module with too many roles + allow: false + input: + $Type: Security$ModuleSecurity + ModuleRoles: + - {Name: Role1} + - {Name: Role2} + - {Name: Role3} + - {Name: Role4} + - {Name: Role5} + - {Name: Role6} diff --git a/rules/006_security/006_0003_microflow_entity_access.rego b/rules/006_security/006_0003_microflow_entity_access.rego new file mode 100644 index 0000000..f876d67 --- /dev/null +++ b/rules/006_security/006_0003_microflow_entity_access.rego @@ -0,0 +1,61 @@ +# METADATA +# scope: package +# title: Exposed microflows should apply entity access +# description: A microflow that is directly accessible by end users (it has allowed module roles) and performs database operations should apply entity access, otherwise it can bypass the entity security rules. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/security/microflowentityaccess +# custom: +# category: Security +# rulename: MicroflowEntityAccess +# severity: HIGH +# rulenumber: "006_0003" +# remediation: Enable "Apply entity access" on the microflow, or restrict who can call it directly. +# input: "**/*$Microflow.yaml" +package app.mendix.security.microflow_entity_access + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +database_actions := { + "Microflows$RetrieveAction", + "Microflows$CreateChangeAction", + "Microflows$ChangeAction", + "Microflows$DeleteAction", + "Microflows$CommitAction", + "Microflows$RollbackAction", + "Microflows$AggregateListAction", +} + +default allow := false + +allow if count(errors) == 0 + +has_database_action if { + some obj + walk(input, [_, obj]) + database_actions[object.get(obj, "$Type", "")] +} + +errors contains error if { + # Directly callable by end users. + count(object.get(input, "AllowedModuleRoles", [])) > 0 + + # Entity access is not applied. + input.ApplyEntityAccess == false + + # And the microflow touches the database. + has_database_action + + error := sprintf( + "[%v, %v, %v] Microflow %v is exposed to module roles but does not apply entity access", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + ], + ) +} diff --git a/rules/006_security/006_0003_microflow_entity_access_test.yaml b/rules/006_security/006_0003_microflow_entity_access_test.yaml new file mode 100644 index 0000000..013dad1 --- /dev/null +++ b/rules/006_security/006_0003_microflow_entity_access_test.yaml @@ -0,0 +1,56 @@ +TestCases: + - name: allow exposed microflow that applies entity access + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + AllowedModuleRoles: + - MyModule.User + ApplyEntityAccess: true + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + - name: allow non-exposed microflow without entity access + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + AllowedModuleRoles: [] + ApplyEntityAccess: false + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction + - name: allow exposed microflow without database operations + allow: true + input: + $Type: Microflows$Microflow + Name: MyFlow + AllowedModuleRoles: + - MyModule.User + ApplyEntityAccess: false + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$LogMessageAction + - name: do not allow exposed microflow with db action and no entity access + allow: false + input: + $Type: Microflows$Microflow + Name: MyFlow + AllowedModuleRoles: + - MyModule.User + ApplyEntityAccess: false + ObjectCollection: + $Type: Microflows$MicroflowObjectCollection + Objects: + - $Type: Microflows$ActionActivity + Action: + $Type: Microflows$RetrieveAction diff --git a/rules/007_enumerations/007_0001_enumeration_prefix.rego b/rules/007_enumerations/007_0001_enumeration_prefix.rego new file mode 100644 index 0000000..5091458 --- /dev/null +++ b/rules/007_enumerations/007_0001_enumeration_prefix.rego @@ -0,0 +1,42 @@ +# METADATA +# scope: package +# title: Enumerations should have a recognizable prefix +# description: Prefixing enumerations with a short string such as ENU makes it much easier to find all enumerations, for example in microflow expressions. +# authors: +# - Xiwen Cheng +# related_resources: +# - https://sdf-docs.clevr.com/?docs=acr-rules/project-hygiene/enumprefix +# custom: +# category: ProjectHygiene +# rulename: EnumerationPrefix +# severity: LOW +# rulenumber: "007_0001" +# remediation: Rename the enumeration so that it starts with the agreed prefix, for example ENU_Status. +# input: .*\.Enumerations\$Enumeration\.yaml +package app.mendix.enumerations.enumeration_prefix + +import rego.v1 + +annotation := rego.metadata.chain()[1].annotations + +required_prefix := "ENU" + +default allow := false + +allow if count(errors) == 0 + +errors contains error if { + input["$Type"] == "Enumerations$Enumeration" + not startswith(input.Name, required_prefix) + + error := sprintf( + "[%v, %v, %v] Enumeration %v does not start with the required prefix %v", + [ + annotation.custom.severity, + annotation.custom.category, + annotation.custom.rulenumber, + input.Name, + required_prefix, + ], + ) +} diff --git a/rules/007_enumerations/007_0001_enumeration_prefix_test.yaml b/rules/007_enumerations/007_0001_enumeration_prefix_test.yaml new file mode 100644 index 0000000..7b7ab94 --- /dev/null +++ b/rules/007_enumerations/007_0001_enumeration_prefix_test.yaml @@ -0,0 +1,21 @@ +TestCases: + - name: allow enumeration with ENU prefix + allow: true + input: + $Type: Enumerations$Enumeration + Name: ENU_Status + - name: allow enumeration named ENUM prefix + allow: true + input: + $Type: Enumerations$Enumeration + Name: ENUMStatus + - name: do not allow enumeration without prefix + allow: false + input: + $Type: Enumerations$Enumeration + Name: EnumerationStatus + - name: do not allow enumeration with different prefix + allow: false + input: + $Type: Enumerations$Enumeration + Name: E_Status diff --git a/scripts/run-policy-tests.sh b/scripts/run-policy-tests.sh index 0a84b25..bd16d55 100755 --- a/scripts/run-policy-tests.sh +++ b/scripts/run-policy-tests.sh @@ -28,8 +28,18 @@ if [ ! -f "$MXLINT" ]; then fi +# test-rules reads the rules path from the config (the --rules flag was removed +# in newer mxlint-cli releases), so point it at ./rules via a temporary config. +CONFIG_FILE="$(mktemp)" +cat > "$CONFIG_FILE" << 'EOF' +rules: + path: ./rules +EOF + # capture all output to a file with tee -$MXLINT test-rules --rules ./rules 2>&1 | tee /tmp/mxlint-test-rules.log +$MXLINT test-rules --config "$CONFIG_FILE" 2>&1 | tee /tmp/mxlint-test-rules.log + +rm -f "$CONFIG_FILE" # grep for FAIL in the log file if grep -q "FAIL" /tmp/mxlint-test-rules.log; then