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;)
```