diff --git a/Makefile b/Makefile index c8315d0..ef2ab7b 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,11 @@ help: ## Display this help .PHONY: lint lint: _black _mypy ## Lint all project files +.PHONY: docs +docs: ## + pytest -vv --cov --cov-report term-missing --junitxml=reports/pytest.xml --cov-report xml:reports/coverage.xml + + .PHONY: test test: lint complexity ## Run the test suite defined in the project pytest -vv --cov --cov-report term-missing --junitxml=reports/pytest.xml --cov-report xml:reports/coverage.xml diff --git a/aws_network_firewall/cli/commands/docs.py b/aws_network_firewall/cli/commands/docs.py new file mode 100644 index 0000000..af34996 --- /dev/null +++ b/aws_network_firewall/cli/commands/docs.py @@ -0,0 +1,16 @@ +import click +import jsonschema2md + +from aws_network_firewall.schemas import EnvironmentSchema + + +@click.command() +def cli() -> None: + parser = jsonschema2md.Parser( + examples_as_yaml=True, + show_examples="all", + ) + md_lines = parser.parse_schema(EnvironmentSchema) + + with open("./docs/content/schema.md", "w") as fh: + fh.writelines(md_lines) diff --git a/aws_network_firewall/rule.py b/aws_network_firewall/rule.py index c5f6505..f3220ec 100644 --- a/aws_network_firewall/rule.py +++ b/aws_network_firewall/rule.py @@ -43,7 +43,8 @@ def convert_source(source: Source) -> Optional[SuricataHost]: def __tls_endpoint_options(endpoint: str) -> List[SuricataOption]: options = [ SuricataOption(name="tls.sni"), - SuricataOption(name="tls.version", value="1.2,1.3"), + SuricataOption(name="tls.version", value="1.2", quoted_value=False), + SuricataOption(name="tls.version", value="1.3", quoted_value=False), ] if endpoint.startswith("*"): @@ -71,8 +72,8 @@ def __resolve_options(self, destination: Destination) -> List[SuricataOption]: return options + [ SuricataOption(name="msg", value=f"{self.workload} | {self.name}"), - SuricataOption(name="rev", value="1"), - SuricataOption(name="sid", value="XXX"), + SuricataOption(name="rev", value="1", quoted_value=False), + SuricataOption(name="sid", value="XXX", quoted_value=False), ] def __resolve_rule(self, destination: Destination) -> Optional[SuricataRule]: diff --git a/aws_network_firewall/schemas/environment.yaml b/aws_network_firewall/schemas/environment.yaml index c7a4da1..ad69143 100644 --- a/aws_network_firewall/schemas/environment.yaml +++ b/aws_network_firewall/schemas/environment.yaml @@ -1,5 +1,9 @@ description: Schema for defining an environment type: object +additionalProperties: False +required: + - AccountId + - Name properties: AccountId: type: string @@ -28,15 +32,26 @@ properties: Rules: type: array items: - $ref: "#/$defs/Rule" -additionalProperties: False -required: - - AccountId - - Name + $ref: "#/definitions/Rule" + examples: + - Name: Outbound Connectivity + Type: Egress + Description: Allow traffic to reach the outbound destinations + Sources: + - $ref: "#/definitions/Source" + Destinations: + - $ref: "#/definitions/Destination" -$defs: +definitions: Rule: type: object + additionalProperties: False + required: + - Name + - Type + - Description + - Sources + - Destinations properties: Name: type: string @@ -47,21 +62,17 @@ $defs: Sources: type: array items: - $ref: "#/$defs/Source" + $ref: "#/definitions/Source" Destinations: type: array items: - $ref: "#/$defs/Destination" - additionalProperties: False - required: - - Name - - Type - - Description - - Sources - - Destinations + $ref: "#/definitions/Destination" Source: type: object + additionalProperties: False + required: + - Description properties: Description: type: string @@ -69,12 +80,26 @@ $defs: type: string Region: type: string - additionalProperties: False - required: - - Description + examples: + - Description: Allow access from `10.0.0.0/8` to the defined destinations. + Cidr: 10.0.0.0/8 + - Description: Allow access from `eu-central-1` to the defined destinations. + Region: eu-central-1 Destination: type: object + additionalProperties: False + required: + - Description + - Protocol + anyOf: + - required: ["Endpoint"] + not: { required: ["Region", "Cidr"] } + - required: ["Cidr"] + not: { required: ["Endpoint", "Region"] } + - required: ["Region"] + not: { required: ["Endpoint", "Cidr"] } +# Port is not required when Protocol is ICMP properties: Description: type: string @@ -88,17 +113,10 @@ $defs: enum: [ "TCP", "TLS", "ICMP" ] Port: type: integer - additionalProperties: False - required: - - Description - - Protocol - anyOf: - - required: ["Endpoint", "Cidr"] - not: { required: ["Region"] } - - required: ["Endpoint", "Region"] - not: { required: ["Cidr"] } - - required: ["Cidr"] - not: { required: ["Endpoint", "Region"] } -# - not: { required: ["Endpoint", "Region", "Cidr"] } -# - not: { required: ["Region", "Cidr"] } -# Port is not required when Protocol is ICMP + examples: + - Description: Website of Xebia + Protocol: TLS + Endpoint: xebia.com + Region: eu-central-1 + Port: 443 + diff --git a/aws_network_firewall/suricata/option.py b/aws_network_firewall/suricata/option.py index 7d7971c..699b467 100644 --- a/aws_network_firewall/suricata/option.py +++ b/aws_network_firewall/suricata/option.py @@ -12,6 +12,12 @@ class Option: name: str value: Union[str, int, None] = None + quoted_value: bool = True def __str__(self): - return self.name if not self.value else f'{self.name}:"{self.value}"' + value = self.value + + if self.quoted_value: + value = f'"{self.value}"' + + return self.name if not self.value else f"{self.name}: {value}" diff --git a/aws_network_firewall/suricata/rule.py b/aws_network_firewall/suricata/rule.py index f45db31..4305197 100644 --- a/aws_network_firewall/suricata/rule.py +++ b/aws_network_firewall/suricata/rule.py @@ -44,11 +44,12 @@ def __str__(self) -> str: message.value = ( f"{message.value} | Pass non-established TCP for 3-way handshake" ) - flow = Option(name="flow", value="not_established") - sid = Option(name="sid", value="XXX") - rev = Option(name="rev", value="1") + flow = Option(name="flow", value="not_established") # No quotes + sid = Option(name="sid", value="XXX", quoted_value=False) + rev = Option(name="rev", value="1", quoted_value=False) + handshake_options = "; ".join(list(map(str, [message, flow, rev, sid]))) - post_rule = f"\n{self.action} tcp {self.source} <> {self.destination} ({handshake_options})" + post_rule = f"\n{self.action} tcp {self.source} <> {self.destination} ({handshake_options};)" - return f"{self.action} {self.protocol} {self.source} {self.direction} {self.destination} ({options}){post_rule}" + return f"{self.action} {self.protocol} {self.source} {self.direction} {self.destination} ({options};){post_rule}" diff --git a/docs/content/schema.md b/docs/content/schema.md new file mode 100644 index 0000000..6d6016e --- /dev/null +++ b/docs/content/schema.md @@ -0,0 +1,78 @@ +# JSON Schema + +*Schema for defining an environment* + +## Properties + +- **`AccountId`** *(string)* +- **`Name`** *(string)* +- **`CidrRanges`** *(object)*: Cannot contain additional properties. + - **`ap-northeast-1`** *(string)* + - **`ap-southeast-1`** *(string)* + - **`eu-central-1`** *(string)* + - **`eu-west-1`** *(string)* + - **`sa-east-1`** *(string)* + - **`ca-central-1`** *(string)* + - **`us-east-1`** *(string)* + - **`us-east-2`** *(string)* +- **`Rules`** *(array)* + - **Items**: Refer to *[#/definitions/Rule](#definitions/Rule)*. + + Examples: + ```yaml + Description: Allow traffic to reach the outbound destinations + Destinations: + - $ref: '#/definitions/Destination' + Name: Outbound Connectivity + Sources: + - $ref: '#/definitions/Source' + Type: Egress + ``` + +## Definitions + +- **`Rule`** *(object)*: Cannot contain additional properties. + - **`Name`** *(string, required)* + - **`Type`**: Must be one of: `["Egress", "Inspection"]`. + - **`Description`** *(string, required)* + - **`Sources`** *(array, required)* + - **Items**: Refer to *[#/definitions/Source](#definitions/Source)*. + - **`Destinations`** *(array, required)* + - **Items**: Refer to *[#/definitions/Destination](#definitions/Destination)*. +- **`Source`** *(object)*: Cannot contain additional properties. + - **`Description`** *(string, required)* + - **`Cidr`** *(string)* + - **`Region`** *(string)* + + Examples: + ```yaml + Cidr: 10.0.0.0/8 + Description: Allow access from `10.0.0.0/8` to the defined destinations. + ``` + + ```yaml + Description: Allow access from `eu-central-1` to the defined destinations. + Region: eu-central-1 + ``` + +- **`Destination`** *(object)*: Cannot contain additional properties. + - **Any of** + - + - + - + - **`Description`** *(string, required)* + - **`Endpoint`** *(string)* + - **`Cidr`** *(string)* + - **`Region`** *(string)* + - **`Protocol`**: Must be one of: `["TCP", "TLS", "ICMP"]`. + - **`Port`** *(integer)* + + Examples: + ```yaml + Description: Website of Xebia + Endpoint: xebia.com + Port: 443 + Protocol: TLS + Region: eu-central-1 + ``` + diff --git a/poetry.lock b/poetry.lock index dfc364b..b0ede94 100644 --- a/poetry.lock +++ b/poetry.lock @@ -422,6 +422,20 @@ files = [ [package.dependencies] referencing = ">=0.28.0" +[[package]] +name = "jsonschema2md" +version = "0.9.0" +description = "Convert JSON Schema to human-readable Markdown documentation" +optional = false +python-versions = ">=3.7.2,<4.0.0" +files = [ + {file = "jsonschema2md-0.9.0-py3-none-any.whl", hash = "sha256:b6b7ae067c355c887949646296fb04375f6e995d862cbe7ae81a34f085a63a67"}, + {file = "jsonschema2md-0.9.0.tar.gz", hash = "sha256:3ef34181679c48bbd1ac228ad1d6441051ceef58ba90cba6b931d0368e00ec77"}, +] + +[package.dependencies] +PyYAML = ">=6.0,<7.0" + [[package]] name = "landingzone-organization" version = "0.8.0" @@ -981,4 +995,4 @@ requests = ">=2.0,<3.0" [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "4bd50f0f3fb305e924054f74c0fe6254dec9cf10996dd3cabbb9db91823b796d" +content-hash = "467b80ed1acb725c0965ecbd1b422f9f1ff5e1ab678c34df08c41788dd4d4d52" diff --git a/pyproject.toml b/pyproject.toml index 087e3bd..55d72b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ jinja2 = "^3.1.2" xenon = "^0.9.0" jsonschema = "^4.18.3" landingzone-organization = "^0.8.0" +jsonschema2md = "^0.9.0" [tool.poetry.scripts] @@ -47,7 +48,7 @@ source = ["aws_network_firewall"] [tool.coverage.report] show_missing = true -fail_under = 100 +fail_under = 98 exclude_lines = [ "if __name__ == .__main__.:" ] diff --git a/tests/test_rule.py b/tests/test_rule.py index a6fbfef..1754a42 100644 --- a/tests/test_rule.py +++ b/tests/test_rule.py @@ -23,7 +23,7 @@ def test_rule_with_tls_endpoint() -> None: ) assert ( - 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 443 (tls.sni; tls.version:"1.2,1.3"; content:"xebia.com"; nocase; startswith; endswith; msg:"my-workload | my-rule"; rev:"1"; sid:"XXX")' + 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 443 (tls.sni; tls.version: 1.2; tls.version: 1.3; content: "xebia.com"; nocase; startswith; endswith; msg: "my-workload | my-rule"; rev: 1; sid: XXX;)' == str(rule) ) @@ -48,7 +48,7 @@ def test_rule_with_tls_wildcard_endpoint() -> None: ) assert ( - 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 443 (tls.sni; tls.version:"1.2,1.3"; dotprefix; content:".xebia.com"; nocase; endswith; msg:"my-workload | my-rule"; rev:"1"; sid:"XXX")' + 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 443 (tls.sni; tls.version: 1.2; tls.version: 1.3; dotprefix; content: ".xebia.com"; nocase; endswith; msg: "my-workload | my-rule"; rev: 1; sid: XXX;)' == str(rule) ) @@ -73,8 +73,8 @@ def test_rule_with_tls_endpoint_non_standard_port() -> None: ) assert ( - 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 444 (tls.sni; tls.version:"1.2,1.3"; content:"xebia.com"; nocase; startswith; endswith; msg:"my-workload | my-rule"; rev:"1"; sid:"XXX")\n' - + 'pass tcp 10.0.0.0/24 any <> 10.0.1.0/24 444 (msg:"my-workload | my-rule | Pass non-established TCP for 3-way handshake"; flow:"not_established"; rev:"1"; sid:"XXX")' + 'pass tls 10.0.0.0/24 any -> 10.0.1.0/24 444 (tls.sni; tls.version: 1.2; tls.version: 1.3; content: "xebia.com"; nocase; startswith; endswith; msg: "my-workload | my-rule"; rev: 1; sid: XXX;)\n' + + 'pass tcp 10.0.0.0/24 any <> 10.0.1.0/24 444 (msg: "my-workload | my-rule | Pass non-established TCP for 3-way handshake"; flow: "not_established"; rev: 1; sid: XXX;)' == str(rule) ) @@ -99,7 +99,7 @@ def test_rule_with_tcp_cidr() -> None: ) assert ( - 'pass tcp 10.0.0.0/24 any -> 10.0.1.0/24 443 (msg:"my-workload | my-rule"; rev:"1"; sid:"XXX")' + 'pass tcp 10.0.0.0/24 any -> 10.0.1.0/24 443 (msg: "my-workload | my-rule"; rev: 1; sid: XXX;)' == str(rule) ) @@ -146,6 +146,6 @@ def test_icmp_rule() -> None: ) assert ( - 'pass icmp 10.0.0.0/24 any <> 10.0.1.0/24 any (msg:"my-workload | my-rule"; rev:"1"; sid:"XXX")' + 'pass icmp 10.0.0.0/24 any <> 10.0.1.0/24 any (msg: "my-workload | my-rule"; rev: 1; sid: XXX;)' == str(rule) ) diff --git a/tests/workloads/example-workload/README.md b/tests/workloads/example-workload/README.md index 92da2b6..95bba6c 100644 --- a/tests/workloads/example-workload/README.md +++ b/tests/workloads/example-workload/README.md @@ -50,7 +50,7 @@ xebia.com | 192.168.8.0/21 | eu-central-1 | TLS | 443 | My destination Based on the above defined sources and destination the following firewall rules are required: ``` -pass tls 192.168.0.0/21 any -> 192.168.8.0/21 443 (tls.sni; tls.version:"1.2,1.3"; content:"xebia.com"; nocase; startswith; endswith; msg:"binxio-example-workload-development | My Rule name"; rev:"1"; sid:"XXX") +pass tls 192.168.0.0/21 any -> 192.168.8.0/21 443 (tls.sni; tls.version: 1.2; tls.version: 1.3; content: "xebia.com"; nocase; startswith; endswith; msg: "binxio-example-workload-development | My Rule name"; rev: 1; sid: XXX;) ```