From dfc95564c03028e334c521a99669f73681a7112f Mon Sep 17 00:00:00 2001 From: Nathan Byrd Date: Sat, 4 Jul 2026 13:13:13 -0500 Subject: [PATCH] Configuration and cleanup --- docs/configuration.md | 28 ++ .../modsecurity3/ConfigurationManager.java | 118 -------- .../modsecurity3/ModelProcessor.java | 236 --------------- .../modsecurity3/Modsecurity3Generator.java | 94 ++++++ .../modsecurity3/OperationProcessor.java | 285 ------------------ .../modsecurity3/TemplateManager.java | 85 ------ .../resources/modsecurity3/config.mustache | 40 +-- .../modsecurity3/mainconfig.mustache | 8 +- .../modsecurity3/tests/DenyConfigTest.java | 128 ++++++++ .../tests/TemplateManagerTest.java | 103 ------- 10 files changed, 275 insertions(+), 850 deletions(-) delete mode 100644 src/main/java/com/oashield/openapi/generators/modsecurity3/ConfigurationManager.java delete mode 100644 src/main/java/com/oashield/openapi/generators/modsecurity3/ModelProcessor.java delete mode 100644 src/main/java/com/oashield/openapi/generators/modsecurity3/OperationProcessor.java delete mode 100644 src/main/java/com/oashield/openapi/generators/modsecurity3/TemplateManager.java create mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/DenyConfigTest.java delete mode 100644 src/test/java/com/oashield/openapi/generators/modsecurity3/tests/TemplateManagerTest.java diff --git a/docs/configuration.md b/docs/configuration.md index 579186c..a551cf1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,11 @@ the known limitations of request-body validation. | `generateJsonSchema` | `true` | Emit the JSON Schema file | | `jsonSchemaOutputFile` | `schema.json` | JSON Schema file name | | `schemaRulePath` | same as `jsonSchemaOutputFile` | Schema path written inside the `@validateSchema` rule. Coraza resolves it relative to the **server process working directory**, not the rules directory | +| `denyAction` | `deny` | What happens when a rule blocks: `deny`, `drop`, `redirect`, or `pass` (detection-only: violations are logged but requests go through) | +| `denyStatus` | `403` | HTTP status returned on deny (100–599). With `denyAction=redirect`, set a 3xx — non-3xx values make the engine fall back to 302 | +| `denyRedirectUrl` | — | Absolute http(s) URL to redirect blocked requests to; required when `denyAction=redirect` | +| `enableLogging` | `true` | Emit `log,auditlog` on generated rules; `false` emits `nolog` instead | +| `includeEngineConfig` | `true` | Emit `SecRuleEngine On`, `SecRequestBodyAccess On`, and the `SecDefaultAction` in `mainconfig.conf`. Set `false` when your existing WAF configuration already defines these | Pass them comma-separated: @@ -21,6 +26,29 @@ Pass them comma-separated: --additional-properties engineFlavor=coraza,schemaRulePath=rules/schema.json ``` +## Deny behavior and logging + +Every generated rule uses the `block` action, so the actual disruptive +behavior is decided in one place: the `SecDefaultAction` emitted at the top of +`mainconfig.conf`. `denyAction`/`denyStatus`/`denyRedirectUrl` control that +line: + +```bash +# Return 429 instead of 403 +--additional-properties denyStatus=429 + +# Detection-only: log violations, let requests through +--additional-properties denyAction=pass + +# Redirect blocked requests +--additional-properties denyAction=redirect,denyRedirectUrl=https://example.com/blocked,denyStatus=302 +``` + +If your ModSecurity/Coraza deployment already configures the engine (rule +engine mode, body access, default action), generate only the rules with +`includeEngineConfig=false` — your existing `SecDefaultAction` then decides +what blocking means. + ## Engine flavors Most generated rules are identical across both engines — the flavor only diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/ConfigurationManager.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/ConfigurationManager.java deleted file mode 100644 index f7a12be..0000000 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/ConfigurationManager.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.oashield.openapi.generators.modsecurity3; - -import org.openapitools.codegen.CliOption; -import org.openapitools.codegen.CodegenConfig; - -import java.util.Map; - -import lombok.Getter; -import lombok.Setter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -/** - * Configuration manager for Modsecurity3Generator. - * Handles all configuration-related functionality. - */ -@RequiredArgsConstructor -@Slf4j -public class ConfigurationManager { - - // Configuration fields - @Getter @Setter private String apiVersion = "0.0.2"; - @Getter @Setter private String outputFolder; - @Getter @Setter private boolean generateJsonSchema = true; - @Getter @Setter private String jsonSchemaOutputFile = "schema.json"; - @Getter @Setter private boolean validateBodySchema = true; - - // Reference to the parent generator - private final CodegenConfig generator; - - /** - * Constructor for ConfigurationManager. - * Created by Lombok @RequiredArgsConstructor which creates a constructor - * with required parameters for final fields. - */ - - /** - * Initialize the configuration. - */ - public void initialize() { - // Configure output folder - configureOutputFolder(); - - // Configure additional properties - configureAdditionalProperties(); - - // Configure CLI options - configureCliOptions(); - - log.debug("ConfigurationManager initialized with output folder: {}", outputFolder); - } - - /** - * Configure the output folder for generated files. - */ - public void configureOutputFolder() { - // Only set default if not already set by CLI/config/test - if (generator.getOutputDir() == null || generator.getOutputDir().isEmpty()) { - generator.setOutputDir("generated-code/modsecurity3"); - } - this.outputFolder = generator.getOutputDir(); - } - - /** - * Configure additional properties for the generator. - */ - public void configureAdditionalProperties() { - // Add API version to additional properties - generator.additionalProperties().put("apiVersion", apiVersion); - - // JSON Schema generation configuration - generator.additionalProperties().put("generateJsonSchema", generateJsonSchema); - generator.additionalProperties().put("jsonSchemaOutputFile", jsonSchemaOutputFile); - generator.additionalProperties().put("validateBodySchema", Boolean.toString(validateBodySchema)); - } - - /** - * Configure CLI options for the generator. - */ - public void configureCliOptions() { - // CLI options for JSON Schema generation - generator.cliOptions().add(new CliOption("generateJsonSchema", "Generate JSON Schema from models") - .defaultValue(Boolean.toString(generateJsonSchema))); - generator.cliOptions().add(new CliOption("jsonSchemaOutputFile", "JSON Schema output file name") - .defaultValue(jsonSchemaOutputFile)); - generator.cliOptions().add(new CliOption("validateBodySchema", "Generate rules for validating JSON body schema") - .defaultValue(Boolean.toString(validateBodySchema))); - } - - /** - * Process the CLI options passed to the generator. - */ - public void processOpts() { - Map additionalProperties = generator.additionalProperties(); - - // Synchronize outputFolder with generator's outputDir to respect CLI/test config - this.outputFolder = generator.getOutputDir(); - - // Process JSON Schema generation options - if (additionalProperties.containsKey("generateJsonSchema")) { - String generateJsonSchemaStr = additionalProperties.get("generateJsonSchema").toString(); - generateJsonSchema = Boolean.parseBoolean(generateJsonSchemaStr); - log.info("generateJsonSchema set to: {}", generateJsonSchema); - } - - // Process validateBodySchema option - if (additionalProperties.containsKey("validateBodySchema")) { - String validateBodySchemaStr = additionalProperties.get("validateBodySchema").toString(); - validateBodySchema = Boolean.parseBoolean(validateBodySchemaStr); - log.info("validateBodySchema set to: {}", validateBodySchema); - } - - if (additionalProperties.containsKey("jsonSchemaOutputFile")) { - jsonSchemaOutputFile = additionalProperties.get("jsonSchemaOutputFile").toString(); - log.info("jsonSchemaOutputFile set to: {}", jsonSchemaOutputFile); - } - } -} diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/ModelProcessor.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/ModelProcessor.java deleted file mode 100644 index c2280d0..0000000 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/ModelProcessor.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.oashield.openapi.generators.modsecurity3; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.openapitools.codegen.CodegenModel; -import org.openapitools.codegen.CodegenProperty; -import org.openapitools.codegen.model.ModelMap; -import org.openapitools.codegen.model.ModelsMap; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import lombok.RequiredArgsConstructor; - -/** - * Service responsible for processing models in the Modsecurity3Generator. - * This class encapsulates model processing logic extracted from Modsecurity3Generator. - */ -@RequiredArgsConstructor -public class ModelProcessor { - private static final Logger LOGGER = LoggerFactory.getLogger(ModelProcessor.class); - - // Service components - private final ConfigurationManager configManager; - private final JsonSchemaGenerator jsonSchemaGenerator; - - /** - * Constructor for ModelProcessor. - * Created by Lombok @RequiredArgsConstructor which creates a constructor - * with required parameters for final fields. - */ - public ModelProcessor(ConfigurationManager configManager) { - this(configManager, new JsonSchemaGenerator()); - LOGGER.debug("Initializing ModelProcessor"); - } - - /** - * Process models and generate JSON Schema. - * - * @param objs The models to process - * @return The processed models - */ - public ModelsMap processModels(ModelsMap objs) { - try { - LOGGER.info("Generating JSON Schema from models..."); - String jsonSchema = jsonSchemaGenerator.generateJsonSchema(objs); - - // Save the JSON Schema to a file - String outputFolder = configManager.getOutputFolder(); - String outputPath = outputFolder + File.separator + "schema.json"; - try { - // Ensure output directory exists before writing - Files.createDirectories(Paths.get(outputFolder)); - // Validate that outputPath is within outputFolder - java.nio.file.Path outputDirPath = Paths.get(outputFolder).toAbsolutePath().normalize(); - java.nio.file.Path targetPath = Paths.get(outputPath).toAbsolutePath().normalize(); - if (!targetPath.startsWith(outputDirPath)) { - throw new RuntimeException("Target files must be generated within the output directory"); - } - Files.write(targetPath, jsonSchema.getBytes()); - LOGGER.info("JSON Schema generated successfully: {}", outputPath); - } catch (IOException e) { - LOGGER.error("Error writing JSON Schema to file: {}", e.getMessage()); - } - } catch (Exception e) { - LOGGER.error("Error generating JSON Schema: {}", e.getMessage()); - } - - return objs; - } - - /** - * Process all models and generate JSON Schema. - * - * @param objs The map of all models to process - * @return The processed map of all models - */ - public Map processAllModels(Map objs) { - // Examine ModelMap structure to understand its content - if (LOGGER.isDebugEnabled()) { - for (Map.Entry entry : objs.entrySet()) { - String modelName = entry.getKey(); - ModelsMap modelsMap = entry.getValue(); - LOGGER.debug("Model: {}", modelName); - - for (ModelMap modelMap : modelsMap.getModels()) { - CodegenModel model = modelMap.getModel(); - LOGGER.debug(" Model name: {}", model.name); - LOGGER.debug(" Model description: {}", model.description); - LOGGER.debug(" Model vars count: {}", model.vars.size()); - - // Log specific model properties to understand structure - if (model.vars != null && !model.vars.isEmpty()) { - LOGGER.debug(" Model has vars"); - CodegenProperty firstVar = model.vars.get(0); - LOGGER.debug(" First var name: {}, dataType: {}", firstVar.name, firstVar.dataType); - } - if (model.requiredVars != null && !model.requiredVars.isEmpty()) { - LOGGER.debug(" Model has requiredVars"); - } - } - } - } - - // Process models for JSON Schema generation - if (configManager.isGenerateJsonSchema()) { - generateJsonSchema(objs); - } - - return objs; - } - - /** - * Generate JSON Schema from models. - * - * @param models The models to convert to JSON Schema - */ - private void generateJsonSchema(Map models) { - LOGGER.info("Generating JSON Schema from models..."); - - try { - // Create a combined schema with all models - ObjectMapper objectMapper = new ObjectMapper(); - ObjectNode rootSchema = objectMapper.createObjectNode(); - rootSchema.put("$schema", "http://json-schema.org/draft-07/schema#"); - rootSchema.put("title", "OpenAPI Schema Definitions"); - rootSchema.put("description", "JSON Schema definitions generated from OpenAPI specification"); - rootSchema.put("type", "object"); - ObjectNode definitions = rootSchema.putObject("definitions"); - - // Process each model and add to the combined schema - for (Map.Entry entry : models.entrySet()) { - String modelName = entry.getKey(); - ModelsMap modelsMap = entry.getValue(); - - // Skip models without any model maps - if (modelsMap.getModels() == null || modelsMap.getModels().isEmpty()) { - continue; - } - - // Get the first model from the models map - ModelMap modelMap = modelsMap.getModels().get(0); - CodegenModel model = modelMap.getModel(); - - // Process the model and add it to the definitions - ObjectNode modelSchema = jsonSchemaGenerator.generateModelSchema(model); - if (modelSchema != null) { - definitions.set(modelName, modelSchema); - } - } - - // Convert the schema to a JSON string - String jsonSchema = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootSchema); - - // Write JSON Schema to file - String outputFolder = configManager.getOutputFolder(); - File outputDir = new File(outputFolder); - if (!outputDir.exists()) { - outputDir.mkdirs(); - } - - String jsonSchemaOutputFile = configManager.getJsonSchemaOutputFile(); - File schemaFile = new File(outputDir, jsonSchemaOutputFile); - // Validate that schemaFile is within outputDir - java.nio.file.Path outputDirPath2 = outputDir.getCanonicalFile().toPath(); - java.nio.file.Path schemaFilePath = schemaFile.getCanonicalFile().toPath(); - if (!schemaFilePath.startsWith(outputDirPath2)) { - throw new RuntimeException("Target files must be generated within the output directory"); - } - try (FileWriter writer = new FileWriter(schemaFile)) { - writer.write(jsonSchema); - } - - LOGGER.info("JSON Schema generated successfully: {}", schemaFile.getAbsolutePath()); - } catch (Exception e) { - LOGGER.error("Error generating JSON Schema", e); - } - } - - /** - * Helper method to flatten a model property into a list of properties. - * - * @param currentProperty The property to flatten - * @param baseNamePrefix The prefix to add to the property name - * @return A list of flattened properties - */ - public List flattenModel(CodegenProperty currentProperty, String baseNamePrefix) { - List properties = new ArrayList(); - - // handle array of primitives as single property - if (currentProperty.isArray && currentProperty.vars != null && !currentProperty.vars.isEmpty() && currentProperty.vars.get(0).isPrimitiveType) { - currentProperty.baseName = baseNamePrefix + currentProperty.baseName; - properties.add(currentProperty); - return properties; - } - // 1. The property is a model - if(currentProperty.isModel) { - // Recursively flatten the model - LOGGER.debug("Flattening model property: {}", currentProperty.baseName); - baseNamePrefix += currentProperty.baseName + "."; - for(CodegenProperty prop : currentProperty.vars) { - List flattenedProperties = flattenModel(prop, baseNamePrefix); - properties.addAll(flattenedProperties); - } - } - - // 2. The property is an array of models - else if(currentProperty.isArray) { - LOGGER.debug("Flattening array of model property: {}", currentProperty.baseName); - int i = 0; - for(CodegenProperty prop : currentProperty.vars) { - List flattenedProperties = flattenModel(prop, baseNamePrefix + currentProperty.baseName + "." + i + "."); - i++; - properties.addAll(flattenedProperties); - } - } - - // 3. The property is a primitive type - else { - LOGGER.debug("Adding property: {}", currentProperty.baseName); - // Add the baseNamePrefix to the property - currentProperty.baseName = baseNamePrefix + currentProperty.baseName; - properties.add(currentProperty); - } - - return properties; - } -} diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java index 3ea97e4..ccae54f 100644 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java +++ b/src/main/java/com/oashield/openapi/generators/modsecurity3/Modsecurity3Generator.java @@ -55,6 +55,17 @@ public void setOutputDir(String dir) { // generally differs from the rules directory (e.g. "rules/schema.json"). private String schemaRulePath = null; + // Deny behavior and logging (issue #16). denyAction is the disruptive action + // SecDefaultAction applies when a generated rule blocks: deny (with denyStatus), + // drop, redirect (to denyRedirectUrl), or pass (detection-only). + private String denyAction = "deny"; + private int denyStatus = 403; + private String denyRedirectUrl = null; + private boolean enableLogging = true; + // false = emit no SecRuleEngine/SecRequestBodyAccess/SecDefaultAction, for + // deployments whose existing ModSecurity config already sets them + private boolean includeEngineConfig = true; + /** * Process the CLI options passed to the generator. * @@ -100,6 +111,69 @@ public void processOpts() { schemaRulePath = additionalProperties.get("schemaRulePath").toString(); } additionalProperties.put("schemaRulePath", schemaRulePath != null ? schemaRulePath : jsonSchemaOutputFile); + + if (additionalProperties.containsKey("denyAction")) { + denyAction = additionalProperties.get("denyAction").toString(); + if (!Arrays.asList("deny", "drop", "pass", "redirect").contains(denyAction)) { + throw new IllegalArgumentException( + "Unknown denyAction '" + denyAction + "'; expected 'deny', 'drop', 'pass' or 'redirect'"); + } + LOGGER.info("denyAction set to: {}", denyAction); + } + + if (additionalProperties.containsKey("denyStatus")) { + try { + denyStatus = Integer.parseInt(additionalProperties.get("denyStatus").toString()); + } catch (NumberFormatException e) { + denyStatus = -1; + } + if (denyStatus < 100 || denyStatus > 599) { + throw new IllegalArgumentException( + "Invalid denyStatus '" + additionalProperties.get("denyStatus") + "'; expected an HTTP status code (100-599)"); + } + LOGGER.info("denyStatus set to: {}", denyStatus); + } + + if (additionalProperties.containsKey("denyRedirectUrl")) { + denyRedirectUrl = additionalProperties.get("denyRedirectUrl").toString(); + if (!denyRedirectUrl.matches("^https?://[^\\s\"']+$")) { + throw new IllegalArgumentException( + "Invalid denyRedirectUrl '" + denyRedirectUrl + "'; expected an absolute http(s) URL"); + } + } + if ("redirect".equals(denyAction) && denyRedirectUrl == null) { + throw new IllegalArgumentException("denyAction=redirect requires denyRedirectUrl"); + } + + if (additionalProperties.containsKey("enableLogging")) { + enableLogging = Boolean.parseBoolean(additionalProperties.get("enableLogging").toString()); + LOGGER.info("enableLogging set to: {}", enableLogging); + } + + if (additionalProperties.containsKey("includeEngineConfig")) { + includeEngineConfig = Boolean.parseBoolean(additionalProperties.get("includeEngineConfig").toString()); + LOGGER.info("includeEngineConfig set to: {}", includeEngineConfig); + } + + // Real boolean for the mustache section; derived strings so templates stay flat + additionalProperties.put("includeEngineConfig", includeEngineConfig); + additionalProperties.put("logAction", enableLogging ? "log,auditlog" : "nolog"); + additionalProperties.put("denyActionDirective", buildDenyActionDirective()); + } + + /** + * The disruptive-action fragment of SecDefaultAction: deny/redirect carry a + * status, drop/pass ignore it. + */ + private String buildDenyActionDirective() { + switch (denyAction) { + case "deny": + return "deny,status:" + denyStatus; + case "redirect": + return "redirect:'" + denyRedirectUrl + "',status:" + denyStatus; + default: + return denyAction; + } } private static final Logger LOGGER = LoggerFactory.getLogger(Modsecurity3Generator.class); @@ -919,6 +993,26 @@ public Modsecurity3Generator() { + "resolved by Coraza relative to the server working directory") .defaultValue(jsonSchemaOutputFile)); + // Deny behavior and logging options (issue #16) + additionalProperties.put("includeEngineConfig", includeEngineConfig); + additionalProperties.put("logAction", "log,auditlog"); + additionalProperties.put("denyActionDirective", "deny,status:" + denyStatus); + cliOptions.add(new CliOption("denyAction", + "Disruptive action applied when a rule blocks: 'deny', 'drop', 'redirect' or 'pass' (detection-only)") + .defaultValue(denyAction)); + cliOptions.add(new CliOption("denyStatus", + "HTTP status returned on deny (use a 3xx with denyAction=redirect)") + .defaultValue(Integer.toString(denyStatus))); + cliOptions.add(new CliOption("denyRedirectUrl", + "Absolute URL to redirect to; required when denyAction=redirect")); + cliOptions.add(new CliOption("enableLogging", + "Emit log,auditlog on generated rules; false emits nolog") + .defaultValue(Boolean.toString(enableLogging))); + cliOptions.add(new CliOption("includeEngineConfig", + "Emit SecRuleEngine/SecRequestBodyAccess/SecDefaultAction in mainconfig.conf; " + + "set false when your existing WAF configuration already defines them") + .defaultValue(Boolean.toString(includeEngineConfig))); + /** * Supporting Files. You can write single files for the generator with the * entire object tree available. If the input file has a suffix of `.mustache diff --git a/src/main/java/com/oashield/openapi/generators/modsecurity3/OperationProcessor.java b/src/main/java/com/oashield/openapi/generators/modsecurity3/OperationProcessor.java deleted file mode 100644 index 192e575..0000000 --- a/src/main/java/com/oashield/openapi/generators/modsecurity3/OperationProcessor.java +++ /dev/null @@ -1,285 +0,0 @@ -package com.oashield.openapi.generators.modsecurity3; - -import org.openapitools.codegen.*; -import org.openapitools.codegen.model.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.*; - -import lombok.Getter; -import lombok.Setter; -import lombok.RequiredArgsConstructor; - -/** - * Service responsible for processing operations in the Modsecurity3Generator. - * This class encapsulates operation processing logic extracted from Modsecurity3Generator. - */ -@RequiredArgsConstructor -public class OperationProcessor { - private static final Logger LOGGER = LoggerFactory.getLogger(OperationProcessor.class); - - // Constants - private static final String MODSECURITY_INDEX_KEY = "x-codegen-globalIndex"; - private static final int MODSECURITY_INDEX_MAX = 20; - private static final String MODSECURITY_PATH_REGEX_KEY = "x-codegen-pathRegex"; - private static final String VENDOR_EXTENSIONS_KEY = "vendorExtensions"; - private static final String MODSECURITY_HAS_ARRAY_MIN = "x-codegen-hasArrayMin"; - private static final String MODSECURITY_HAS_ARRAY_MAX = "x-codegen-hasArrayMax"; - private static final String MODSECURITY_HAS_JSON = "x-codegen-isJson"; - private static final String MODSECURITY_HAS_XML = "x-codegen-isXml"; - private static final String MODSECURITY_MODEL_PROPERTIES = "x-codegen-modelProperties"; - - // Service components - private final PatternGenerationService patternGenerationService; - private final ConfigurationManager configManager; - - // Operational fields - @Getter @Setter - protected Long globalIndex = 4200001L; // Default start - @Getter @Setter - protected Long globalParamIndex = 4210001L; // Default start - - /** - * Constructor for OperationProcessor. - * Created by Lombok @RequiredArgsConstructor which creates a constructor - * with all final fields as parameters. - */ - - /** - * Provides an opportunity to inspect and modify operation data before the code - * is generated. - * - * @param objs The operations map to process - * @param allModels The list of all models - * @return The processed operations map - */ - public OperationsMap processOperationsWithModels(OperationsMap objs, List allModels) { - LOGGER.debug("Processing operations with models"); - - OperationMap ops = objs.getOperations(); - List opList = ops.getOperation(); - - // Process each operation - for (CodegenOperation co : opList) { - processOperation(co); - processOperationParameters(co); - } - - // Add global vendor extensions - Map vendorExtensions = new HashMap(); - vendorExtensions.put(MODSECURITY_INDEX_KEY, globalIndex++); - objs.put(VENDOR_EXTENSIONS_KEY, vendorExtensions); - - return objs; - } - - /** - * Process a single operation, adding necessary vendor extensions. - * - * @param co The CodegenOperation to process - */ - private void processOperation(CodegenOperation co) { - // Process path and add path regex - String path = co.path; - String matchPath = path.replaceAll("\\{.*?\\}", "[^/]+"); - co.vendorExtensions.put(MODSECURITY_PATH_REGEX_KEY, matchPath); - - // Add global indices - for (int i=1; i<=MODSECURITY_INDEX_MAX;i++) { - co.vendorExtensions.put(MODSECURITY_INDEX_KEY + "_" + i, globalIndex++); - } - - LOGGER.debug("Processing operation: {}", co.operationId); - - // Add validateBodySchema as a vendor extension to the operation - co.vendorExtensions.put("validateBodySchema", configManager.isValidateBodySchema()); - - // Process content types - processContentTypes(co); - } - - /** - * Process content types for an operation. - * - * @param co The CodegenOperation to process - */ - private void processContentTypes(CodegenOperation co) { - Boolean includeRequestJSON = false; - Boolean includeRequestXML = false; - - if(co.hasConsumes) { - LOGGER.debug("Operation: {} Consumes: {}", co.baseName, co.consumes); - // Check if the operation consumes JSON or XML - for (Map consume : co.consumes) { - if (consume.containsKey("isJson")) { - String isJsonString = consume.get("isJson"); - includeRequestJSON = isJsonString != null && isJsonString.equals("true"); - } - if (consume.containsKey("isXml")) { - String isXmlString = consume.get("isXml"); - includeRequestXML = isXmlString != null && isXmlString.equals("true"); - } - } - } - - // Add vendor extension for JSON and XML - co.vendorExtensions.put(MODSECURITY_HAS_JSON, includeRequestJSON); - co.vendorExtensions.put(MODSECURITY_HAS_XML, includeRequestXML); - } - - /** - * Process parameters for an operation. - * - * @param co The CodegenOperation containing parameters to process - */ - private void processOperationParameters(CodegenOperation co) { - // Loop through parameters and process each one - for (CodegenParameter param : co.allParams) { - processParameter(param); - } - } - - /** - * Process a single parameter, adding necessary vendor extensions and patterns. - * - * @param param The CodegenParameter to process - */ - private void processParameter(CodegenParameter param) { - // Handle required arrays - if (param.required && param.isArray && (param.getMinItems() == null || param.getMinItems() == 0)) { - LOGGER.debug("Required array parameter: {}", param.baseName); - param.setMinItems(1); - } - - // Process model parameters - if (param.isModel) { - processModelParameter(param); - } - - // Add vendor extensions for array constraints - param.vendorExtensions.put(MODSECURITY_HAS_ARRAY_MIN, (param.getMinItems() != null)); - param.vendorExtensions.put(MODSECURITY_HAS_ARRAY_MAX, (param.getMaxItems() != null)); - - // Add global indices - for (int i=1; i<=MODSECURITY_INDEX_MAX;i++) { - param.vendorExtensions.put(MODSECURITY_INDEX_KEY + "_" + i, globalParamIndex++); - } - - // Process pattern - processParameterPattern(param); - } - - /** - * Process a model parameter by flattening its properties. - * - * @param param The model parameter to process - */ - private void processModelParameter(CodegenParameter param) { - LOGGER.debug("Model parameter: {}", param.baseName); - // We need to flatten the model into something that can be used in the template - // This will be a new vendor extension with an array of properties that represent - // the model - List flattenedProperties = new ArrayList(); - for (CodegenProperty prop : param.vars) { - // We need to create a new CodegenParameter for each property - // Unless the property is a model, then we need to flatten that model - // into properties - List properties = flattenModel(prop, param.baseName + "."); - flattenedProperties.addAll(properties); - } - - // Add the flattened properties to the parameter - param.vendorExtensions.put(MODSECURITY_MODEL_PROPERTIES, flattenedProperties); - } - - /** - * Process the pattern for a parameter. - * - * @param param The parameter to process - */ - private void processParameterPattern(CodegenParameter param) { - String patternString = param.pattern; - - if(patternString != null && !patternString.isEmpty()) { - LOGGER.debug("Config pattern string used: {}", patternString); - if(isInvalidPattern(patternString)) { - LOGGER.warn("Invalid pattern string: {}", patternString); - patternString = null; - } - } - - if(patternString == null || patternString.isEmpty()) { - patternString = patternGenerationService.getParamPattern(param); - LOGGER.debug("Calculated pattern string {}", patternString); - param.setPattern(patternString); - } - - LOGGER.debug("param: {}, validation: {}, pattern: {}", param.hasValidation, param.pattern); - LOGGER.debug("Parameter: {}, data type: {}, isString: {}, max length: {}", param.baseName, param.getDataType(), - param.isString, param.getMaxLength()); - } - - /** - * Helper method to flatten a model property into a list of properties. - * - * @param currentProperty The property to flatten - * @param baseNamePrefix The prefix to add to the property name - * @return A list of flattened properties - */ - public List flattenModel(CodegenProperty currentProperty, String baseNamePrefix) { - List properties = new ArrayList(); - - // handle array of primitives as single property - if (currentProperty.isArray && currentProperty.vars != null && !currentProperty.vars.isEmpty() && currentProperty.vars.get(0).isPrimitiveType) { - currentProperty.baseName = baseNamePrefix + currentProperty.baseName; - properties.add(currentProperty); - return properties; - } - // 1. The property is a model - if(currentProperty.isModel) { - // Recursively flatten the model - LOGGER.debug("Flattening model property: {}", currentProperty.baseName); - baseNamePrefix += currentProperty.baseName + "."; - for(CodegenProperty prop : currentProperty.vars) { - List flattenedProperties = flattenModel(prop, baseNamePrefix); - properties.addAll(flattenedProperties); - } - } - - // 2. The property is an array of models - else if(currentProperty.isArray) { - LOGGER.debug("Flattening array of model property: {}", currentProperty.baseName); - int i = 0; - for(CodegenProperty prop : currentProperty.vars) { - List flattenedProperties = flattenModel(prop, baseNamePrefix + currentProperty.baseName + "." + i + "."); - i++; - properties.addAll(flattenedProperties); - } - } - - // 3. The property is a primitive type - else { - LOGGER.debug("Adding property: {}", currentProperty.baseName); - // Add the baseNamePrefix to the property - currentProperty.baseName = baseNamePrefix + currentProperty.baseName; - properties.add(currentProperty); - } - - return properties; - } - - /** - * Checks if a pattern string contains invalid regex constructs. - * - * @param patternString The pattern string to check - * @return true if the pattern is invalid, false otherwise - */ - public boolean isInvalidPattern(String patternString) { - // This is a very basic check, and should be improved. - return patternString.contains("(?!") || - patternString.contains("(?=") || - patternString.contains("(?<=") || - patternString.contains("(? additionalProperties) { + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("modsecurity3") + .setInputSpec("samples/petstore.yaml") + .setOutputDir(tempDir.toString()); + for (Map.Entry entry : additionalProperties.entrySet()) { + configurator.addAdditionalProperty(entry.getKey(), entry.getValue()); + } + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + } + + private String mainConfig() throws IOException { + return Files.readString(tempDir.resolve("mainconfig.conf")); + } + + private String apiConfig() throws IOException { + return Files.readString(tempDir.resolve("PetApi.conf")); + } + + @Test + public void defaultsEmitDeny403WithLogging() throws IOException { + generate(new HashMap<>()); + + String main = mainConfig(); + assertTrue(main.contains("SecRuleEngine On"), "engine config emitted by default"); + assertTrue(main.contains("SecDefaultAction \"phase:2,log,auditlog,deny,status:403\""), + "default deny action is deny with status 403"); + assertTrue(apiConfig().contains("log,auditlog"), "rules log by default"); + } + + @Test + public void denyStatusAndActionAreConfigurable() throws IOException { + Map props = new HashMap<>(); + props.put("denyStatus", "429"); + generate(props); + assertTrue(mainConfig().contains("SecDefaultAction \"phase:2,log,auditlog,deny,status:429\"")); + + props.clear(); + props.put("denyAction", "drop"); + generate(props); + assertTrue(mainConfig().contains("SecDefaultAction \"phase:2,log,auditlog,drop\""), + "drop carries no status"); + + props.clear(); + props.put("denyAction", "pass"); + generate(props); + assertTrue(mainConfig().contains("SecDefaultAction \"phase:2,log,auditlog,pass\""), + "pass = detection-only"); + } + + @Test + public void redirectEmitsUrlAndStatus() throws IOException { + Map props = new HashMap<>(); + props.put("denyAction", "redirect"); + props.put("denyRedirectUrl", "https://example.com/blocked"); + props.put("denyStatus", "302"); + generate(props); + assertTrue(mainConfig().contains( + "SecDefaultAction \"phase:2,log,auditlog,redirect:'https://example.com/blocked',status:302\"")); + } + + @Test + public void enableLoggingFalseEmitsNolog() throws IOException { + Map props = new HashMap<>(); + props.put("enableLogging", "false"); + generate(props); + + assertTrue(mainConfig().contains("SecDefaultAction \"phase:2,nolog,deny,status:403\"")); + assertFalse(apiConfig().contains("log,auditlog"), "no rule logs when logging is disabled"); + } + + @Test + public void includeEngineConfigFalseOmitsEngineDirectives() throws IOException { + Map props = new HashMap<>(); + props.put("includeEngineConfig", "false"); + generate(props); + + String main = mainConfig(); + assertFalse(main.contains("SecRuleEngine"), "no SecRuleEngine when includeEngineConfig=false"); + assertFalse(main.contains("SecDefaultAction"), "no SecDefaultAction when includeEngineConfig=false"); + assertTrue(main.contains("Include "), "operation includes still emitted"); + assertTrue(main.contains("SecMarker FAILED_API_CHECKS"), "catch-all rules still emitted"); + } + + @Test + public void invalidValuesFail() { + assertThrows(IllegalArgumentException.class, () -> processOptsWith("denyAction", "teapot")); + assertThrows(IllegalArgumentException.class, () -> processOptsWith("denyStatus", "999")); + assertThrows(IllegalArgumentException.class, () -> processOptsWith("denyStatus", "abc")); + assertThrows(IllegalArgumentException.class, () -> processOptsWith("denyRedirectUrl", "javascript:alert(1)")); + // redirect without a URL + assertThrows(IllegalArgumentException.class, () -> processOptsWith("denyAction", "redirect")); + } + + private void processOptsWith(String key, String value) { + Modsecurity3Generator generator = new Modsecurity3Generator(); + generator.additionalProperties().put(key, value); + generator.processOpts(); + } +} diff --git a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/TemplateManagerTest.java b/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/TemplateManagerTest.java deleted file mode 100644 index e9bfb0b..0000000 --- a/src/test/java/com/oashield/openapi/generators/modsecurity3/tests/TemplateManagerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.oashield.openapi.generators.modsecurity3.tests; - -import com.oashield.openapi.generators.modsecurity3.ConfigurationManager; -import com.oashield.openapi.generators.modsecurity3.Modsecurity3Generator; -import com.oashield.openapi.generators.modsecurity3.TemplateManager; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.openapitools.codegen.CodegenConfig; -import org.openapitools.codegen.SupportingFile; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Tests for the TemplateManager class. - */ -public class TemplateManagerTest { - - private ConfigurationManager mockConfigManager; - private CodegenConfig mockGenerator; - private TemplateManager templateManager; - private final String testOutputFolder = "/tmp/test-output"; - private final Map apiTemplateFiles = new HashMap<>(); - private final List supportingFiles = new ArrayList<>(); - - @BeforeEach - public void setup() { - // Create a real generator to use in tests - mockGenerator = new Modsecurity3Generator(); - - // Setup the mock configuration manager - mockConfigManager = new ConfigurationManager(mockGenerator); - mockConfigManager.setOutputFolder(testOutputFolder); - - // Create TemplateManager instance to test - // Pass both required arguments to the constructor - templateManager = new TemplateManager(mockGenerator, mockConfigManager); - } - - @Test - public void testGetTemplateDir() { - // Test that the template directory is set correctly - assertEquals("modsecurity3", templateManager.getTemplateDir()); - } - - @Test - public void testModelFileFolder() { - // Test that the model file folder is retrieved correctly - String folder = templateManager.modelFileFolder(); - assertEquals(testOutputFolder, folder); - } - - @Test - public void testApiFileFolder() { - // Test that the API file folder is retrieved correctly - String folder = templateManager.apiFileFolder(); - assertEquals(testOutputFolder, folder); - } - - @Test - public void testConfigureTemplates() { - // Clear any existing templates - mockGenerator.apiTemplateFiles().clear(); - mockGenerator.supportingFiles().clear(); - - // Configure templates - templateManager.configureTemplates(); - - // Verify API template files - assertTrue(mockGenerator.apiTemplateFiles().containsKey("config.mustache")); - assertEquals(".conf", mockGenerator.apiTemplateFiles().get("config.mustache")); - - // Verify supporting files - boolean foundMainConfig = false; - for (SupportingFile file : mockGenerator.supportingFiles()) { - if (file.getTemplateFile().equals("mainconfig.mustache") && - file.getFolder().equals("") && - file.getDestinationFilename().equals("mainconfig.conf")) { - foundMainConfig = true; - break; - } - } - assertTrue(foundMainConfig, "mainconfig.mustache supporting file should be configured"); - } - - @Test - public void testInitialize() { - // Clear any existing templates - mockGenerator.apiTemplateFiles().clear(); - mockGenerator.supportingFiles().clear(); - - // Initialize the template manager - templateManager.initialize(); - - // Verify templates were configured - assertFalse(mockGenerator.apiTemplateFiles().isEmpty()); - assertFalse(mockGenerator.supportingFiles().isEmpty()); - } -}