mirror of
https://github.com/cisagov/ScubaGoggles.git
synced 2026-02-07 00:36:07 +01:00
Add unit tests for md_parser.py and reporter.py (#824)
* add first unit test for md_parser.py * add a few more test cases for MarkdownParserError * add additional mock md snippets; add unit test for invalid baseline versions * complete invalid policy id version unit tests with multiple cases; make md_parser.py and version.py regex more strict * add unit test for checking missing policy descriptions; improve md_parser.py logic for determining how a missing description is determined * improve directory structure; start adding unit tests for create_html_table in test_repoter.py * unit test build_front_page_html method * add unit tests for testing logic if a control is omitted or not * add unit test for get_omission_rationale when justification is not provided for a given policy * add more unit tests * finish remaining unit tests * add docstrings * fix linter * fix linter, add docstrings * remove sanitize policy; resolve remaining linter issues * add pylint exceptions * final commit, missed two pylint exceptions * refactor test_reporter.py to use parametrize parameter more, reducing code size; updated README.md * reformat test cases for test_get_omission_rationale to reduce total # or args * refactor test_create_html_table() with parametrize to reduce test duplication * refactor test_md_parser.md to parametrize the parse_baselines_raises_parser_error tests; added new tests to check for duplicate policy ids * update directory structure; move 'Python' and 'Rego' directories inside of 'scubagoggles/Testing/Unit/'; renamed 'RegoTests' to 'Rego' * update rego directory path in run_opa_tests.yml * Update paths in python readme.md * Add ./scubagoggles/ directory in README.md examples
This commit is contained in:
committed by
GitHub
parent
a6bb38fd69
commit
b1444fd726
6
.github/workflows/run_opa_tests.yml
vendored
6
.github/workflows/run_opa_tests.yml
vendored
@@ -28,10 +28,10 @@ jobs:
|
||||
version: latest
|
||||
|
||||
- name: Run OPA Check
|
||||
run: opa check scubagoggles/rego scubagoggles/Testing/RegoTests --strict
|
||||
run: opa check scubagoggles/rego scubagoggles/Testing/Unit/Rego --strict
|
||||
|
||||
- name: Run OPA Tests
|
||||
run: opa test scubagoggles/rego/*.rego scubagoggles/Testing/RegoTests/**/*.rego -v
|
||||
run: opa test scubagoggles/rego/*.rego scubagoggles/Testing/Unit/Rego/**/*.rego -v
|
||||
|
||||
- name: Setup Regal
|
||||
uses: StyraInc/setup-regal@v1
|
||||
@@ -39,4 +39,4 @@ jobs:
|
||||
version: 0.27.0
|
||||
|
||||
- name: Run Regal Lint
|
||||
run: regal lint --format github scubagoggles/rego scubagoggles/Testing/RegoTests
|
||||
run: regal lint --format github scubagoggles/rego scubagoggles/Testing/Unit/Rego
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[pytest]
|
||||
python_classes = *Test
|
||||
python_classes = *Test*
|
||||
pythonpath = . scubagoggles
|
||||
84
scubagoggles/Testing/Unit/Python/README.md
Normal file
84
scubagoggles/Testing/Unit/Python/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Python Unit Testing
|
||||
|
||||
This document provides background on how to run the Python unit tests for the `scubagoggles` Python package and covers key `pytest` concepts used in the test suite.
|
||||
|
||||
We focus on verifying the behavior of `scubagoggles` public class methods and how they interact together. Private helper methods are generally treated as implementation details and should be left untested. However, the exception to this rule is when private methods mutate shared state or encapsulate complex or high-importance logic. These cases warrant direct tests to test their observable behavior.
|
||||
|
||||
## Running Unit Tests
|
||||
|
||||
To run all Python unit tests:
|
||||
|
||||
```bash
|
||||
pytest ./scubagoggles/Testing/Unit/Python/
|
||||
```
|
||||
|
||||
To run all tests for a specific file:
|
||||
|
||||
```bash
|
||||
pytest ./scubagoggles/Testing/Unit/Python/reporter/test_reporter.py
|
||||
```
|
||||
|
||||
To run an individual test by name:
|
||||
|
||||
```bash
|
||||
pytest ./scubagoggles/Testing/Unit/Python -k test_create_html_table
|
||||
```
|
||||
|
||||
## Test Naming Conventions
|
||||
|
||||
Name each unit test by prefixing `test_` to the corresponding class method's name.
|
||||
For example, when testing the `Reporter.create_html_table` method, create `def test_create_html_table():` to keep tests consistent.
|
||||
|
||||
### Example Structure
|
||||
|
||||
```python
|
||||
class TestReporter:
|
||||
def test_create_html_table(self):
|
||||
...
|
||||
```
|
||||
|
||||
## Pytest Concepts
|
||||
|
||||
### Fixtures
|
||||
|
||||
Fixtures provide reusable setup and teardown logic for tests. They are defined using the `@pytest.fixture` decorator and can be scoped to functions, classes, modules, or sessions.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data():
|
||||
return {"key": "value"}
|
||||
```
|
||||
|
||||
### Parametrize
|
||||
|
||||
`pytest.mark.parametrize` allows running a test function with multiple sets of arguments, improving coverage and reducing duplication.
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize("input,expected", [
|
||||
(1, 2),
|
||||
(2, 3),
|
||||
])
|
||||
def test_increment(input, expected):
|
||||
assert input + 1 == expected
|
||||
```
|
||||
|
||||
### Monkeypatch
|
||||
|
||||
The `monkeypatch` fixture lets you modify or mock objects, functions, or environment variables during tests.
|
||||
|
||||
```python
|
||||
def test_env_var(monkeypatch):
|
||||
monkeypatch.setenv("API_KEY", "test-key")
|
||||
# test code that uses API_KEY
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Pytest Documentation](https://docs.pytest.org/en/stable/)
|
||||
- [Fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
|
||||
- [Parametrize](https://docs.pytest.org/en/stable/how-to/parametrize.html)
|
||||
- [Monkeypatch](https://docs.pytest.org/en/stable/how-to/monkeypatch.html)
|
||||
@@ -0,0 +1,16 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Testing Duplicate Policy IDs
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1 Example Group
|
||||
|
||||
This section determines information for xyz.
|
||||
|
||||
### Policies
|
||||
|
||||
<!-- md_parser.py expects the file name to match the product name -->
|
||||
#### GWS.duplicate_policy_id.1.1v1
|
||||
External sharing options for secondary calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
|
||||
#### GWS.duplicate_policy_id.1.1v1
|
||||
External sharing options for secondary calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,19 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Testing Duplicate Policy IDs
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1 Example Group
|
||||
|
||||
This section determines information for xyz.
|
||||
|
||||
### Policies
|
||||
|
||||
<!-- md_parser.py expects the file name to match the product name -->
|
||||
#### GWS.duplicate_policy_id_nonconsecutive.1.1v1
|
||||
External Sharing Options for Primary Calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
|
||||
#### GWS.duplicate_policy_id_nonconsecutive.1.2v1
|
||||
External sharing options for secondary calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
|
||||
#### GWS.duplicate_policy_id_nonconsecutive.1.2v1
|
||||
External sharing options for secondary calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,19 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Google Calendar
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1. External Sharing Options
|
||||
|
||||
This section determines what information is shared from calendars with external entities.
|
||||
|
||||
### Policies
|
||||
|
||||
<!--
|
||||
md_parser.py expects the file name to match the product name.
|
||||
|
||||
For the group mismatch test, notice how the policy ID below, 2.1,
|
||||
does not match under section "1. External Sharing Options".
|
||||
The parser must raise an error if this occurs.
|
||||
-->
|
||||
#### GWS.group_mismatch.2.1v0.6
|
||||
External Sharing Options for Primary Calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,13 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Testing Invalid Policy ID Versions
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1. External Sharing Options
|
||||
|
||||
This section determines what information is shared from calendars with external entities.
|
||||
|
||||
### Policies
|
||||
|
||||
<!-- md_parser.py expects the file name to match the product name -->
|
||||
#### GWS.invalid_policyid_version.1.1__SUFFIX__
|
||||
External Sharing Options for Primary Calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,27 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Gmail
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1. Mail Delegation
|
||||
|
||||
This section determines whether users can delegate access to their mailbox to others within the same domain. This delegation includes access to read, send, and delete messages on the account owner's behalf. This delegation can be done via a command line tool (GAM) if enabled in the admin console.
|
||||
|
||||
<!-- Intentionally missing the `### Policies` section -->
|
||||
|
||||
### Resources
|
||||
|
||||
- [Google Workspace Admin Help: Turn Gmail delegation on or off](https://support.google.com/a/answer/7223765?hl=en)
|
||||
- [GAM: Example Email Settings - Creating a Gmail delegate](https://github.com/GAM-team/GAM/wiki/ExamplesEmailSettings#creating-a-gmail-delegate)
|
||||
- [CIS Google Workspace Foundations Benchmark](https://www.cisecurity.org/benchmark/google_workspace)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- None
|
||||
|
||||
#### GWS.GMAIL.1.1v0.6 Instructions
|
||||
To configure the settings for Mail Delegation:
|
||||
1. Sign in to the [Google Admin Console](https://admin.google.com).
|
||||
2. Select **Apps -\> Google Workspace -\> Gmail**.
|
||||
3. Select **User Settings -\> Mail delegation**.
|
||||
4. Ensure that the **Let users delegate access to their mailbox to other users in the domain** checkbox is unchecked.
|
||||
5. Select **Save**.
|
||||
@@ -0,0 +1,16 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Testing Missing Baseline Descriptions
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1 Example Group
|
||||
|
||||
This section determines information for xyz.
|
||||
|
||||
### Policies
|
||||
|
||||
<!-- md_parser.py expects the file name to match the product name -->
|
||||
#### GWS.missing_policy_description.1.1v1
|
||||
<!-- Intentionally missing the policy description -->
|
||||
|
||||
#### GWS.missing_policy_description.1.2v1
|
||||
External sharing options for secondary calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,17 @@
|
||||
# CISA Google Workspace Secure Configuration Baseline for Google Calendar
|
||||
|
||||
# Baseline Policies
|
||||
|
||||
## 1. External Sharing Options
|
||||
|
||||
This section determines what information is shared from calendars with external entities.
|
||||
|
||||
### Policies
|
||||
|
||||
<!--
|
||||
md_parser.py expects the file name to match the product name,
|
||||
hence why we test if the parser raises an error when
|
||||
"product_mismatch" != "CALENDAR"
|
||||
-->
|
||||
#### GWS.CALENDAR.1.1v0.6
|
||||
External Sharing Options for Primary Calendars SHALL be configured to "Only free/busy information (hide event details)."
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
test_md_parser.py tests the MarkdownParser class.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import pytest
|
||||
|
||||
import scubagoggles as scubagoggles_pkg
|
||||
from scubagoggles.reporter.md_parser import MarkdownParser, MarkdownParserError
|
||||
|
||||
class TestMarkdownParser:
|
||||
"""Unit tests for the MarkdownParser class."""
|
||||
def _baselines_directory(self) -> Path:
|
||||
return Path(scubagoggles_pkg.__file__).resolve().parent / "baselines"
|
||||
|
||||
def _snippets_directory(self) -> Path:
|
||||
return Path(__file__).parent / "snippets"
|
||||
|
||||
def _parser(self, base_dir: Path) -> MarkdownParser:
|
||||
return MarkdownParser(base_dir)
|
||||
|
||||
def test_parse_baselines_returns_correct_format(self):
|
||||
"""
|
||||
Tests the MarkdownParser.parse_baselines() public method for expected output structure.
|
||||
"""
|
||||
parser = self._parser(self._baselines_directory())
|
||||
result = parser.parse_baselines(["gmail"])
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "gmail" in result
|
||||
|
||||
groups = result["gmail"]
|
||||
assert isinstance(groups, list)
|
||||
assert len(groups) >= 1
|
||||
|
||||
id_pattern = re.compile(r"^GWS\.GMAIL\.\d+\.\d+v\d+(?:\.\d+)*$")
|
||||
|
||||
for group in groups:
|
||||
assert { "GroupNumber", "GroupName", "Controls" }.issubset(group.keys())
|
||||
assert isinstance(group["GroupNumber"], str) and group["GroupNumber"].strip() != ""
|
||||
assert isinstance(group["GroupName"], str)
|
||||
|
||||
controls = group["Controls"]
|
||||
assert isinstance(controls, list)
|
||||
assert len(controls) >= 1
|
||||
|
||||
for control in controls:
|
||||
assert { "Id", "Value"}.issubset(control.keys())
|
||||
assert isinstance(control["Id"], str)
|
||||
assert isinstance(control["Value"], str)
|
||||
|
||||
# Check against empty values
|
||||
assert control["Id"].strip() != ""
|
||||
assert control["Value"].strip() != ""
|
||||
|
||||
# Confirm policy ID format
|
||||
assert id_pattern.match(control["Id"]), f"Invalid Policy ID format: {control['Id']}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("snippet_name", "expected_fragment"),
|
||||
[
|
||||
(
|
||||
"missing_policies_section",
|
||||
'"Policies" section missing for group id 1 (Mail Delegation)'
|
||||
),
|
||||
(
|
||||
"product_mismatch",
|
||||
(
|
||||
"different product encountered calendar != product_mismatch "
|
||||
"for group id 1 (External Sharing Options)"
|
||||
)
|
||||
),
|
||||
(
|
||||
"group_mismatch",
|
||||
"mismatching group number (2) for group id 1 (External Sharing Options)"
|
||||
),
|
||||
(
|
||||
"duplicate_policy_id",
|
||||
"expected baseline item 2, got item 1 for group id 1 (Example Group)"
|
||||
),
|
||||
(
|
||||
"duplicate_policy_id_nonconsecutive",
|
||||
"expected baseline item 3, got item 2 for group id 1 (Example Group)"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_baselines_raises_parser_error(
|
||||
self,
|
||||
snippet_name: str,
|
||||
expected_fragment: str,
|
||||
):
|
||||
"""
|
||||
Tests if MarkdownParser.parse_baselines() handles these cases:
|
||||
- raises a MarkdownParserError for missing policies section
|
||||
- raises a MarkdownParserError for product mismatch
|
||||
- raises a MarkdownParserError for group mismatch
|
||||
- duplicate policy IDs
|
||||
"""
|
||||
parser = self._parser(self._snippets_directory())
|
||||
|
||||
with pytest.raises(MarkdownParserError) as exception_info:
|
||||
parser.parse_baselines([snippet_name])
|
||||
|
||||
msg = str(exception_info.value)
|
||||
assert expected_fragment in msg
|
||||
|
||||
@staticmethod
|
||||
def _render_invalid_suffix(tmp_path: Path, suffix: str) -> Path:
|
||||
md_file = Path(__file__).parent / "snippets" / "invalid_policyid_version.md"
|
||||
md_content = md_file.read_text(encoding = "utf-8").replace("__SUFFIX__", suffix)
|
||||
out = tmp_path / "invalid_policyid_version.md"
|
||||
out.write_text(md_content, encoding = "utf-8")
|
||||
return tmp_path
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("suffix", "expect_error"),
|
||||
[
|
||||
("v1.0", False),
|
||||
("v2", False),
|
||||
("version1", True),
|
||||
("v1.0.0.0", True),
|
||||
("vabc", True),
|
||||
("v", True),
|
||||
("v1.a", True),
|
||||
]
|
||||
)
|
||||
def test_parse_baselines_raises_parser_error_for_invalid_policyid_versions(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
suffix: str,
|
||||
expect_error: bool
|
||||
):
|
||||
"""
|
||||
Tests if the MarkdownParser.parse_baselines() public method
|
||||
raises a MarkdownParserError for invalid policy ID versions.
|
||||
"""
|
||||
base_dir = self._render_invalid_suffix(tmp_path, suffix)
|
||||
parser = MarkdownParser(base_dir)
|
||||
|
||||
if expect_error:
|
||||
with pytest.raises(MarkdownParserError) as exception_info:
|
||||
parser.parse_baselines(["invalid_policyid_version"])
|
||||
|
||||
msg = str(exception_info.value)
|
||||
# md_parser.py will raise:
|
||||
assert f"invalid baseline version ({suffix})" in msg
|
||||
else:
|
||||
result = parser.parse_baselines(["invalid_policyid_version"])
|
||||
assert "invalid_policyid_version" in result
|
||||
|
||||
def test_parse_baselines_raises_parser_error_for_missing_policy_description(self):
|
||||
"""
|
||||
Tests if the MarkdownParser.parse_baselines() public method
|
||||
raises a MarkdownParserError for missing policy description.
|
||||
"""
|
||||
parser = self._parser(self._snippets_directory())
|
||||
|
||||
with pytest.raises(MarkdownParserError) as exception_info:
|
||||
parser.parse_baselines(["missing_policy_description"])
|
||||
|
||||
msg = str(exception_info.value)
|
||||
# md_parser.py will raise:
|
||||
assert "missing description for baseline item 1 for group id 1 (Example Group)" in msg
|
||||
511
scubagoggles/Testing/Unit/Python/reporter/test_reporter.py
Normal file
511
scubagoggles/Testing/Unit/Python/reporter/test_reporter.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
test_reporter tests the Reporter class.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import pytest
|
||||
|
||||
import scubagoggles as scubagoggles_pkg
|
||||
from scubagoggles.reporter.reporter import Reporter
|
||||
from scubagoggles.version import Version
|
||||
|
||||
class TestReporter:
|
||||
"""Unit tests for the Reporter class."""
|
||||
def _reporter(self, **overrides) -> Reporter:
|
||||
defaults = {
|
||||
"product": "gmail",
|
||||
"tenant_id": "ABCDEFG",
|
||||
"tenant_name": "Cool Example Org",
|
||||
"tenant_domain": "example.org",
|
||||
"main_report_name": "Baseline Reports",
|
||||
"prod_to_fullname": {"gmail": "Gmail"},
|
||||
"product_policies": [],
|
||||
"successful_calls": set(),
|
||||
"unsuccessful_calls": set(),
|
||||
"missing_policies": set(),
|
||||
"dns_logs": {},
|
||||
"omissions": {},
|
||||
"annotations": {},
|
||||
"progress_bar": None,
|
||||
}
|
||||
params = {**defaults, **overrides}
|
||||
return Reporter(**params)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_data",
|
||||
[
|
||||
[],
|
||||
[
|
||||
{
|
||||
"Customer Name": "Cool Example Org",
|
||||
"Customer Domain": "example.org",
|
||||
"Customer ID": "ABCDEFG",
|
||||
"Report Date": "10/10/2025 13:08:59 Pacific Daylight Time",
|
||||
"Baseline Version": "0.6",
|
||||
"Tool Version": "v0.6.0",
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"Control ID": "GWS.GMAIL.1.1v0.6",
|
||||
"Requirement": "Mail Delegation SHOULD be disabled.",
|
||||
"Result": "Warning",
|
||||
"Criticality": "Should",
|
||||
"Details": (
|
||||
"The following OUs are non-compliant:\n"
|
||||
"<ul>\n"
|
||||
" <li>Terry Hahn's OU: Mail delegation is enabled</li>\n"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
{
|
||||
"Control ID": "GWS.GMAIL.2.1v0.6",
|
||||
"Requirement": "DKIM SHOULD be enabled for all domains.",
|
||||
"Result": "Warning",
|
||||
"Criticality": "Should",
|
||||
"Details": (
|
||||
"The following OUs are non-compliant:\n"
|
||||
"<ul>\n"
|
||||
" <li>Terry Hahn's OU: DKIM is not enabled</li>\n"
|
||||
"</ul>"
|
||||
),
|
||||
},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_create_html_table(self, table_data):
|
||||
"""
|
||||
Tests Reporter.create_html_table() for these cases:
|
||||
- empty list to indicate no table
|
||||
- tenant info table
|
||||
- control info table
|
||||
"""
|
||||
reporter = self._reporter()
|
||||
html = reporter.create_html_table(table_data)
|
||||
|
||||
if not table_data:
|
||||
assert html == ""
|
||||
return
|
||||
|
||||
assert html.startswith("<table")
|
||||
assert "<thead>" in html and "<tbody>" in html
|
||||
|
||||
expected_headers = list(table_data[0].keys())
|
||||
assert html.count("<th>") == len(expected_headers)
|
||||
assert html.count("<td>") == len(table_data) * len(expected_headers)
|
||||
|
||||
headers = re.findall(r"<th>(.*?)</th>", html, flags=re.S)
|
||||
assert headers == expected_headers
|
||||
|
||||
for table in table_data:
|
||||
assert list(table.keys()) == expected_headers
|
||||
|
||||
for value in table.values():
|
||||
assert str(value) in html
|
||||
|
||||
def test_build_front_page_html(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Tests Reporter._build_front_page_html() with test data for fragments,
|
||||
tenant_info, report_uuid, and darkmode enabled.
|
||||
"""
|
||||
tmp = tmp_path
|
||||
(tmp / "FrontPageReport").mkdir(parents=True)
|
||||
(tmp / "styles").mkdir(parents=True)
|
||||
(tmp / "scripts").mkdir(parents=True)
|
||||
(tmp / "templates").mkdir(parents=True)
|
||||
|
||||
pkg_root = Path(scubagoggles_pkg.__file__).resolve().parent
|
||||
front_page_html = (
|
||||
pkg_root / "reporter" / "FrontPageReport" / "FrontPageReportTemplate.html"
|
||||
).read_text(encoding="utf-8")
|
||||
(tmp / "FrontPageReport" / "FrontPageReportTemplate.html").write_text(
|
||||
front_page_html, encoding="utf-8"
|
||||
)
|
||||
|
||||
dark_mode_toggle_html = (
|
||||
pkg_root / "reporter" / "templates" / "DarkModeToggleTemplate.html"
|
||||
).read_text(encoding="utf-8")
|
||||
(tmp / "templates" / "DarkModeToggleTemplate.html").write_text(
|
||||
dark_mode_toggle_html, encoding="utf-8"
|
||||
)
|
||||
|
||||
(tmp / "styles" / "FrontPageStyle.css").write_text("main {}\n footer {}", encoding="utf-8")
|
||||
(tmp / "styles" / "main.css").write_text(":root {}", encoding="utf-8")
|
||||
(tmp / "scripts" / "main.js").write_text("const testVar = 0;", encoding="utf-8")
|
||||
|
||||
# Patch the actual Reporter class attribute for _reporter_path,
|
||||
# otherwise the local instance returned by _reporter() won't use the temp files.
|
||||
monkeypatch.setattr(Reporter, "_reporter_path", tmp, raising=True)
|
||||
|
||||
fragments = [
|
||||
"<table><tr><td>First</td></tr></table>",
|
||||
"<table><tr><td>Second</td></tr></table>",
|
||||
]
|
||||
tenant_info = {
|
||||
"topLevelOU": "Cool Example Org",
|
||||
"domain": "example.org",
|
||||
"ID": "ABCDEFG"
|
||||
}
|
||||
report_uuid = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
reporter = self._reporter()
|
||||
html = reporter.build_front_page_html(
|
||||
fragments,
|
||||
tenant_info,
|
||||
report_uuid,
|
||||
darkmode="true",
|
||||
)
|
||||
|
||||
assert Version.current in html
|
||||
|
||||
assert "{{" not in html and "}}" not in html
|
||||
assert "<style>" in html and "</style>" in html
|
||||
assert "main {}" in html and "footer {}" in html
|
||||
assert ":root {}" in html
|
||||
assert "<script>" in html and "</script>" in html
|
||||
assert "const testVar = 0;" in html
|
||||
|
||||
assert "sgr_settings" in html and "data-darkmode=\"true\"" in html
|
||||
|
||||
assert "First" in html and "Second" in html
|
||||
assert report_uuid in html
|
||||
|
||||
assert all(th in html for th in ["Customer Name", "Customer Domain", "Customer ID"])
|
||||
assert all(td in html for td in ["Cool Example Org", "example.org", "ABCDEFG"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("omissions", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"rationale": "Accepting risk",
|
||||
"expiration": "2035-12-31"
|
||||
},
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"rationale": "Accepting risk",
|
||||
"expiration": "2020-12-31"
|
||||
},
|
||||
},
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
def test_is_control_omitted(self, omissions, expected):
|
||||
"""
|
||||
Tests if Reporter._is_control_omitted() returns True/false
|
||||
for different expiration dates.
|
||||
"""
|
||||
reporter = self._reporter(omissions=omissions)
|
||||
for policy in omissions:
|
||||
assert reporter._is_control_omitted(policy) is expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cases",
|
||||
[
|
||||
{
|
||||
"omissions": {
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"rationale": "Accepting risk for now, will reevaluate at a later date.",
|
||||
"expiration": "2035-12-31",
|
||||
}
|
||||
},
|
||||
"pattern": r"<(?P<tag>\w+)(?:\s[^>]*)?>User justification</(?P=tag)>",
|
||||
"expects_warning": False,
|
||||
"expected_error": None,
|
||||
},
|
||||
{
|
||||
"omissions": {},
|
||||
"pattern": None,
|
||||
"expects_warning": False,
|
||||
"expected_error": RuntimeError,
|
||||
},
|
||||
{
|
||||
"omissions": {
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"rationale": "",
|
||||
"expiration": "2035-12-31",
|
||||
}
|
||||
},
|
||||
"pattern": r"<(?P<tag>\w+)(?:\s[^>]*)?>User justification not provided</(?P=tag)>",
|
||||
"expects_warning": True,
|
||||
"expected_error": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
def test_get_omission_rationale(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
cases
|
||||
):
|
||||
"""
|
||||
Tests if Reporter._get_omission_rationale() returns the expected HTML tag
|
||||
for a given policy and if runtime errors are thrown correctly.
|
||||
"""
|
||||
omissions = cases["omissions"]
|
||||
pattern = cases["pattern"]
|
||||
expects_warning = cases["expects_warning"]
|
||||
expected_error = cases["expected_error"]
|
||||
|
||||
reporter = self._reporter(omissions=omissions)
|
||||
warnings = []
|
||||
monkeypatch.setattr(reporter, "_warn", warnings.append)
|
||||
|
||||
for policy in omissions:
|
||||
if expected_error:
|
||||
with pytest.raises(expected_error):
|
||||
reporter._get_omission_rationale(policy)
|
||||
return
|
||||
|
||||
html = reporter._get_omission_rationale(policy)
|
||||
assert isinstance(html, str)
|
||||
|
||||
if expects_warning:
|
||||
assert warnings, "Expected a warning to be logged for missing rationale"
|
||||
else:
|
||||
assert not warnings, "Did not expect any warnings to be logged"
|
||||
|
||||
if pattern:
|
||||
assert re.search(
|
||||
pattern, html
|
||||
), f"Expected an HTML tag wrapping the rationale for policy {policy}"
|
||||
|
||||
rationale = omissions[policy]["rationale"]
|
||||
if rationale:
|
||||
assert rationale in html
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("annotations", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"incorrectresult": True,
|
||||
"comment": "This control is incorrectly marked as non-compliant.",
|
||||
}
|
||||
},
|
||||
"This control is incorrectly marked as non-compliant.",
|
||||
),
|
||||
({}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": None}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"incorrectresult": True}}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"comment": None}}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"comment": ""}}, None),
|
||||
],
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
def test_get_annotation_comment(self, annotations, expected):
|
||||
"""
|
||||
Tests if Reporter._get_annotation_comment() handles these cases:
|
||||
- returns the expected comment for a given policy
|
||||
- returns None for cases where the annotated policies are
|
||||
declared incorrectly in the config file.
|
||||
- returns None when no comment is specified.
|
||||
"""
|
||||
reporter = self._reporter(annotations=annotations)
|
||||
|
||||
for policy in annotations:
|
||||
comment = reporter._get_annotation_comment(policy)
|
||||
if expected is None:
|
||||
assert comment is None
|
||||
else:
|
||||
assert isinstance(comment, str)
|
||||
assert expected is comment
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("annotations", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"remediationdate": "2035-12-31"
|
||||
}
|
||||
},
|
||||
"2035-12-31",
|
||||
),
|
||||
({}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": None}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"incorrectresult": True}}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"remediationdate": None}}, None),
|
||||
({"GWS.GMAIL.1.1v0.6": {"remediationdate": ""}}, None),
|
||||
],
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
def test_get_remediation_date(self, annotations, expected):
|
||||
"""
|
||||
Tests if Reporter._get_remediation_date() handles these cases:
|
||||
- returns the expected date for a given policy
|
||||
- returns None for cases where the remediation date is
|
||||
not properly specified.
|
||||
|
||||
This method simply pulls the remediation date from a policy if it exists.
|
||||
Its not handling validation to check invalid date formats like YYYY-MM-DD
|
||||
or things like delimiting by / instead of -, e.g. 12/31/2035 vs. 12-31-2035.
|
||||
"""
|
||||
reporter = self._reporter(annotations=annotations)
|
||||
|
||||
for policy in annotations:
|
||||
remediation_date = reporter._get_remediation_date(policy)
|
||||
if expected is None:
|
||||
assert remediation_date is None
|
||||
else:
|
||||
assert isinstance(remediation_date, str)
|
||||
assert expected is remediation_date
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("annotations", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"GWS.GMAIL.1.1v0.6": {
|
||||
"comment": "This control is incorrectly marked as non-compliant.",
|
||||
"incorrectresult": True,
|
||||
}
|
||||
},
|
||||
True,
|
||||
),
|
||||
({}, False),
|
||||
({"GWS.GMAIL.1.1v0.6": {}}, False),
|
||||
({"GWS.GMAIL.1.1v0.6": {"comment": "Some comment"}}, False),
|
||||
({"GWS.GMAIL.1.1v0.6": {"incorrectresult": False}}, False),
|
||||
],
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
def test_is_control_marked_incorrect(self, annotations, expected):
|
||||
"""
|
||||
Tests if Reporter_is_control_marked_incorrect() handles these cases:
|
||||
- returns True for cases where the control is marked as incorrect
|
||||
- returns False for invalid cases or when incorrectResult
|
||||
is set to false.
|
||||
"""
|
||||
reporter = self._reporter(annotations=annotations)
|
||||
|
||||
for policy in annotations:
|
||||
is_incorrect = reporter._is_control_marked_incorrect(policy)
|
||||
assert is_incorrect is expected
|
||||
|
||||
def test_rego_json_to_ind_reports(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""
|
||||
Tests Reporter.rego_json_to_ind_reports() with sample rego output data.
|
||||
"""
|
||||
tmp = tmp_path
|
||||
(tmp / "IndividualReport").mkdir(parents=True)
|
||||
(tmp / "styles").mkdir(parents=True)
|
||||
(tmp / "scripts").mkdir(parents=True)
|
||||
(tmp / "templates").mkdir(parents=True)
|
||||
|
||||
pkg_root = Path(scubagoggles_pkg.__file__).resolve().parent
|
||||
individual_report_template = (
|
||||
pkg_root / "reporter" / "IndividualReport" / "IndividualReportTemplate.html"
|
||||
).read_text(encoding="utf-8")
|
||||
(tmp / "IndividualReport" / "IndividualReportTemplate.html").write_text(
|
||||
individual_report_template, encoding="utf-8"
|
||||
)
|
||||
|
||||
dark_mode_toggle_template = (
|
||||
pkg_root / "reporter" / "templates" / "DarkModeToggleTemplate.html"
|
||||
).read_text(encoding="utf-8")
|
||||
(tmp / "templates" / "DarkModeToggleTemplate.html").write_text(
|
||||
dark_mode_toggle_template, encoding="utf-8"
|
||||
)
|
||||
|
||||
(tmp / "styles" / "main.css").write_text(":root {}", encoding="utf-8")
|
||||
(tmp / "scripts" / "main.js").write_text("const testVar = 0;", encoding="utf-8")
|
||||
|
||||
# Patch the actual Reporter class attribute for _reporter_path,
|
||||
# otherwise the local instance returned by _reporter() won't use the temp files.
|
||||
monkeypatch.setattr(Reporter, "_reporter_path", tmp, raising=True)
|
||||
|
||||
reporter = self._reporter(
|
||||
product = "gmail",
|
||||
product_policies = [
|
||||
{
|
||||
"GroupName": "Mail Delegation",
|
||||
"GroupNumber": "1",
|
||||
"Controls": [
|
||||
{
|
||||
"Id": "GWS.GMAIL.1.1v0.6",
|
||||
"Value": "Mail Delegation SHOULD be disabled.",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
test_results = [
|
||||
{
|
||||
"PolicyId": "GWS.GMAIL.1.1v0.6",
|
||||
"Prerequisites": [
|
||||
"policy/gmail_mail_delegation.enableMailDelegation",
|
||||
"policy/gmail_service_status.serviceState"
|
||||
],
|
||||
"Criticality": "Should",
|
||||
"ReportDetails": (
|
||||
"The following OUs are non-compliant:\n"
|
||||
"<ul>\n"
|
||||
" <li>Terry Hahn's OU: Mail delegation is enabled</li>\n"
|
||||
"</ul>"
|
||||
),
|
||||
"ActualValue": { "NonCompliantOUs": ["Terry Hahn's OU"] },
|
||||
"RequirementMet": False,
|
||||
"NoSuchEvent": False,
|
||||
}
|
||||
]
|
||||
|
||||
out_dir = tmp_path / "GWSBaselineConformance"
|
||||
(out_dir / "IndividualReports").mkdir(parents=True)
|
||||
|
||||
report_stats, json_data = reporter.rego_json_to_ind_reports(
|
||||
test_results,
|
||||
out_dir,
|
||||
darkmode="true",
|
||||
)
|
||||
|
||||
# Check JSON output
|
||||
expected_stats = {
|
||||
"Manual": 0,
|
||||
"Passes": 0,
|
||||
"Errors": 0,
|
||||
"Failures": 0,
|
||||
"Warnings": 1,
|
||||
"Omit": 0,
|
||||
"IncorrectResults": 0,
|
||||
}
|
||||
assert report_stats == expected_stats
|
||||
|
||||
assert isinstance(json_data, list) and len(json_data) == 1
|
||||
group = json_data[0]
|
||||
assert group["GroupName"] == "Mail Delegation"
|
||||
assert group["GroupNumber"] == "1"
|
||||
assert group["GroupReferenceURL"].startswith("https://github.com/cisagov/")
|
||||
expected_suffix = "scubagoggles/baselines/gmail.md#1-mail-delegation"
|
||||
assert group["GroupReferenceURL"].endswith(expected_suffix)
|
||||
assert group["Controls"] == [
|
||||
{
|
||||
"Control ID": "GWS.GMAIL.1.1v0.6",
|
||||
"Requirement": "Mail Delegation SHOULD be disabled.",
|
||||
"Result": "Warning",
|
||||
"Criticality": "Should",
|
||||
"Details": (
|
||||
"The following OUs are non-compliant:\n"
|
||||
"<ul>\n"
|
||||
" <li>Terry Hahn's OU: Mail delegation is enabled</li>\n"
|
||||
"</ul>"
|
||||
),
|
||||
"OmittedEvaluationResult": "N/A",
|
||||
"OmittedEvaluationDetails": "N/A",
|
||||
"IncorrectResult": "N/A",
|
||||
"IncorrectDetails": "N/A",
|
||||
}
|
||||
]
|
||||
@@ -11,11 +11,11 @@ from sys import platform
|
||||
|
||||
# pylint: skip-file
|
||||
|
||||
# The location of the RegoTests directory is where this script is located.
|
||||
# The location of the Rego directory is where this script is located.
|
||||
# The location of the Rego code is one level up.
|
||||
|
||||
test_dir = Path(__file__).parent
|
||||
rego_dir = test_dir.parent / 'rego'
|
||||
rego_dir = test_dir.parent.parent / 'rego'
|
||||
|
||||
gws_baselines = [
|
||||
"gmail",
|
||||
@@ -47,9 +47,9 @@ parser.add_argument('-c', '--controls', type = str, nargs="+",
|
||||
default=[], help="Space-separated list of control group numbers to test within a specific baseline."
|
||||
"Can only be used when a single baseline is specified. By default all are run.")
|
||||
|
||||
parser.add_argument('-o', '--opapath', type=str, default='../..', metavar='',
|
||||
parser.add_argument('-o', '--opapath', type=str, default='../../..', metavar='',
|
||||
help='The relative path to the directory containing the OPA executable. ' +
|
||||
'Defaults to "../.." the current executing directory.')
|
||||
'Defaults to "../../.." the current executing directory.')
|
||||
|
||||
parser.add_argument('-v', action='store_true',
|
||||
help='Verbose flag, passed to opa, increases output.')
|
||||
@@ -90,11 +90,11 @@ for b in args.baselines:
|
||||
print(f"\n==== Testing {b} control {c} ====")
|
||||
c = c.zfill(2)
|
||||
command = (f'{OPA_EXE} test {rego_dir} '
|
||||
f'{test_dir}/RegoTests/{b}/{b}{c}_test.rego {V_FLAG}')
|
||||
f'{test_dir}/Rego/{b}/{b}{c}_test.rego {V_FLAG}')
|
||||
print(command)
|
||||
subprocess.run(command.split(), check=False)
|
||||
else:
|
||||
print(f"\n==== Testing {b} ====")
|
||||
command = f'{OPA_EXE} test {rego_dir} {test_dir}/RegoTests/{b} {V_FLAG}'
|
||||
command = f'{OPA_EXE} test {rego_dir} {test_dir}/Rego/{b} {V_FLAG}'
|
||||
print(command)
|
||||
subprocess.run(command.split(), check=False)
|
||||
@@ -38,7 +38,7 @@ class MarkdownParser:
|
||||
|
||||
_baseline_re = re.compile(r'####\s*(?P<baseline>GWS\.(?P<product>[^.]+)'
|
||||
r'\.(?P<id>\d+)\.(?P<item>\d+)'
|
||||
r'(?P<version>v\d+\.?\d*))$')
|
||||
r'(?P<version>v[^\s]*))$')
|
||||
|
||||
# This handles the single exception case where the combined drive
|
||||
# and docs product has a product name of "drive", but the baseline
|
||||
@@ -231,6 +231,7 @@ class MarkdownParser:
|
||||
|
||||
return result
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def _parse_baselines(self,
|
||||
baseline_content: list,
|
||||
md_file: Path,
|
||||
@@ -311,7 +312,7 @@ class MarkdownParser:
|
||||
group_id,
|
||||
group_name)
|
||||
|
||||
if not Version.is_valid_suffix(version):
|
||||
if not Version.is_valid_suffix(version):
|
||||
message = f'invalid baseline version ({version})'
|
||||
self._parser_error(md_file,
|
||||
message,
|
||||
@@ -331,8 +332,22 @@ class MarkdownParser:
|
||||
|
||||
for value_line in baseline_content[lines_seen:]:
|
||||
|
||||
if (
|
||||
# Next baseline, e.g. #### GWS.GMAIL.1.2v1
|
||||
self._baseline_re.match(value_line)
|
||||
# New group, e.g., ## 2. Group Name
|
||||
or self._level2_re.match(value_line)
|
||||
# Any header above level 4, e.g., ### Policies
|
||||
or self._above4_re.match(value_line)
|
||||
):
|
||||
break
|
||||
|
||||
lines_seen += 1
|
||||
|
||||
# Skip HTML comments
|
||||
if value_line.startswith("<!--") and value_line.endswith("-->"):
|
||||
continue
|
||||
|
||||
if not value_line:
|
||||
if not value_lines:
|
||||
continue
|
||||
|
||||
@@ -43,7 +43,7 @@ class Version:
|
||||
|
||||
_code_root = Path(__file__).parent
|
||||
|
||||
_suffix_regex = r'v(?P<major>\d+)\.?(?P<minor>\d*)'
|
||||
_suffix_regex = r'v(?P<major>\d+)(?:\.(?P<minor>\d+))?$'
|
||||
|
||||
suffix_re = re.compile(_suffix_regex, re.IGNORECASE)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user