diff --git a/guardrails_utilities/python_utils/turbot_error_report/README.md b/guardrails_utilities/python_utils/turbot_error_report/README.md new file mode 100644 index 00000000..925f7339 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/README.md @@ -0,0 +1,385 @@ +# Turbot Error Report + +A Python script to report Turbot Guardrails errors and alarms that are impacting the health of the Turbot platform. + +## Overview + +This script queries the Turbot Guardrails API to identify controls in ERROR or ALARM states within a specified time window. It's designed to help administrators quickly identify compliance violations and system errors that need attention. + +## Features + +- **Flexible Time Windows**: Report errors from the last 24 hours (default) or any custom time period +- **State Filtering**: Filter by control states (error, alarm, ok, skipped, tbd) +- **Resource Type Filtering**: Focus on specific resource types using full URIs +- **Multiple Output Formats**: Text (default), JSON, or CSV output +- **SSL Certificate Verification**: Option to disable SSL verification for self-signed certificates +- **Pagination Support**: Automatically handles large result sets +- **Command-Line Interface**: Easy to use with various options and flags +- **Comprehensive Test Suite**: Full unit test coverage with pytest + +## Prerequisites + +- Python 3.7 or higher +- Turbot CLI installed and configured +- Access to a Turbot Guardrails workspace + +## Installation + +1. **Clone or download the script files:** + ```bash + # Ensure you have the following files: + # - turbot_error_report.py + # - _turbot.py + # - requirements.txt + ``` + +2. **Create a virtual environment:** + ```bash + python3 -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +4. **Make the script executable (optional but recommended):** + ```bash + chmod +x turbot_error_report.py + ``` + **Note**: If you skip this step, you'll need to use `python3 turbot_error_report.py` instead of `./turbot_error_report.py` + +5. **Verify Turbot CLI configuration:** + ```bash + turbot graphql --query 'query { turbot { id } }' + ``` + +## Usage + +### Basic Usage + +```bash +# Default: Show errors and alarms from the last 24 hours +./turbot_error_report.py +# or +python3 turbot_error_report.py +``` + +### Command-Line Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--hours` | | Time window in hours | 24 | +| `--states` | `-s` | Control states to include (error, alarm, ok, skipped, tbd) | error,alarm | +| `--resource-type` | `-r` | Filter by resource type using full URI (e.g., tmod:@turbot/aws-s3#/resource/types/bucket) | None | +| `--output` | `-o` | Output format (text, json, csv) | text | +| `--quiet` | `-q` | Only show count, no details | False | +| `--no-timestamp` | | Don't filter by timestamp | False | +| `--limit` | `-l` | Limit number of results (for testing) | None | +| `--debug` | | Enable debug output | False | +| `--insecure` | `-i` | Disable SSL certificate verification | False | + +### Examples + +#### Basic Reporting +```bash +# Show all errors and alarms from last 24 hours +python3 turbot_error_report.py + +# Show only count +python3 turbot_error_report.py --quiet + +# Last 48 hours +python3 turbot_error_report.py --hours 48 +``` + +#### State Filtering +```bash +# Only ERROR states +python3 turbot_error_report.py --states error + +# Only ALARM states +python3 turbot_error_report.py --states alarm + +# Both ERROR and ALARM states (default) +python3 turbot_error_report.py --states error,alarm + +# All states (no filtering) +python3 turbot_error_report.py --states error,alarm,ok,skipped,tbd +``` + +#### Resource Type Filtering +```bash +# AWS S3 Bucket resources (full URI required) +python3 turbot_error_report.py --resource-type "tmod:@turbot/aws-s3#/resource/types/bucket" + +# AWS EC2 Instance resources (full URI required) +python3 turbot_error_report.py --resource-type "tmod:@turbot/aws-ec2#/resource/types/instance" + +# AWS IAM Role resources (full URI required) +python3 turbot_error_report.py --resource-type "tmod:@turbot/aws-iam#/resource/types/role" + +# Azure Storage Account resources (full URI - if available in your workspace) +python3 turbot_error_report.py --resource-type "tmod:@turbot/azure-storage#/resource/types/storageAccount" +``` + +#### Output Formats +```bash +# JSON output for programmatic use +python3 turbot_error_report.py --output json + +# CSV output for spreadsheet analysis +python3 turbot_error_report.py --output csv > errors.csv + +# Text output (default) +python3 turbot_error_report.py --output text +``` + +#### Testing and Debugging +```bash +# Limit results for testing +python3 turbot_error_report.py --limit 10 + +# Enable debug output +python3 turbot_error_report.py --debug + +# Test with no time filter +python3 turbot_error_report.py --no-timestamp --limit 5 +``` + +#### SSL Certificate Verification +```bash +# Disable SSL certificate verification (for self-signed certificates) +python3 turbot_error_report.py --insecure + +# Use with other options +python3 turbot_error_report.py --insecure --debug --limit 5 +``` + +#### Combined Examples +```bash +# S3 alarms from last 7 days, JSON output +python3 turbot_error_report.py --hours 168 --states alarm --resource-type "tmod:@turbot/aws-s3#/resource/types/bucket" --output json + +# Specific AWS S3 Bucket type, last 48 hours +python3 turbot_error_report.py --hours 48 --resource-type "tmod:@turbot/aws-s3#/resource/types/bucket" + +# All errors (no time limit), CSV output +python3 turbot_error_report.py --states error --no-timestamp --output csv + +# Quick count of recent alarms +python3 turbot_error_report.py --states alarm --quiet +``` + +## Output Formats + +### Text Format (Default) +``` +[2025-09-03T00:40:42.866Z] AWS > S3 > Bucket > Encryption at Rest | Resource: Turbot > Dunder Mifflin > Nashua > 574345774624 > us-east-1 > test-unencrypted-bucket-1756859454 (rid:363344552273846) | State: alarm | Reason: Default Encryption at Rest and Encryption at Rest Policy is not set as per policy + +Total: 76 +``` + +### JSON Format +```json +[ + { + "state": "alarm", + "reason": "Default Encryption at Rest and Encryption at Rest Policy is not set as per policy", + "turbot": { + "id": "363344552760452", + "stateChangeTimestamp": "2025-09-03T00:40:42.866Z" + }, + "type": { + "trunk": { + "title": "AWS > S3 > Bucket > Encryption at Rest" + }, + "uri": "tmod:@turbot/aws-s3#/control/types/bucketEncryption" + }, + "resource": { + "trunk": { + "title": "Turbot > Dunder Mifflin > Nashua > 574345774624 > us-east-1 > test-unencrypted-bucket-1756859454" + }, + "turbot": { + "id": "363344552273846" + } + } + } +] +``` + +### CSV Format +```csv +Timestamp,Control Type,Resource,Resource ID,State,Reason +2025-09-03T00:40:42.866Z,AWS > S3 > Bucket > Encryption at Rest,Turbot > Dunder Mifflin > Nashua > 574345774624 > us-east-1 > test-unencrypted-bucket-1756859454,363344552273846,alarm,Default Encryption at Rest and Encryption at Rest Policy is not set as per policy +``` + +## Finding Resource Type URIs + +### From Turbot Guardrails Hub +1. Visit [hub.guardrails.turbot.com](https://hub.guardrails.turbot.com) +2. Navigate to the specific resource type (e.g., AWS S3 Bucket) +3. Copy the **Resource Type URI** from the page +4. Use it with the `--resource-type` parameter + +### Examples of Resource Type URIs +```bash +# AWS S3 Bucket +tmod:@turbot/aws-s3#/resource/types/bucket + +# AWS EC2 Instance +tmod:@turbot/aws-ec2#/resource/types/instance + +# Azure Storage Account +tmod:@turbot/azure-storage#/resource/types/storageAccount + +# GCP Compute Instance +tmod:@turbot/gcp-compute#/resource/types/instance +``` + +### Finding Resource Type URIs +**Note**: Available resource types depend on your workspace configuration. Use `turbot graphql --query 'query { resourceTypes { items { trunk { title } uri } } }'` to see what's available. + +**Important**: Only full URIs are supported. Short forms like `s3` or `ec2` are no longer accepted. + +## Understanding the Results + +### Control States +- **ERROR**: System or technical errors that prevent proper evaluation +- **ALARM**: Compliance violations or policy violations detected +- **OK**: Compliant with policies +- **SKIPPED**: Policy set to skip evaluation +- **TBD**: To be determined (evaluation pending) + +### Timestamp Filter +The `timestamp:>=T-24h` filter captures controls that **changed state** to ERROR or ALARM within the specified time window. This means: +- ✅ Controls that went from OK → ALARM in the last 24h +- ✅ Controls that went from SKIPPED → ERROR in the last 24h +- ❌ Controls that have been in ALARM state for weeks (unless they changed recently) + +## Integration Examples + +### Monitoring Script +```bash +#!/bin/bash +# Check for critical errors every hour +ERROR_COUNT=$(python3 turbot_error_report.py --states error --quiet) +if [ "$ERROR_COUNT" -gt 0 ]; then + echo "CRITICAL: $ERROR_COUNT system errors detected" + # Send alert, create ticket, etc. +fi +``` + +### Daily Report +```bash +#!/bin/bash +# Generate daily compliance report +DATE=$(date +%Y-%m-%d) +python3 turbot_error_report.py --hours 24 --output csv > "compliance-report-${DATE}.csv" +``` + +### API Integration +```python +import subprocess +import json + +# Get errors as Python dictionary +result = subprocess.run([ + 'python3', 'turbot_error_report.py', + '--output', 'json', '--states', 'error,alarm' +], capture_output=True, text=True) + +errors = json.loads(result.stdout) +for error in errors: + print(f"Control: {error['type']['trunk']['title']}") + print(f"State: {error['state']}") + print(f"Reason: {error['reason']}") +``` + +## Troubleshooting + +### Common Issues + +1. **ImportError: cannot import name 'XDG_CONFIG_HOME' from 'xdg'** + ```bash + pip uninstall xdg + pip install xdg==5.1.1 + ``` + +2. **GraphQL errors about unknown arguments** + - Ensure you're using the correct Turbot CLI version + - Check that your workspace supports the GraphQL schema + +3. **No results returned** + - Try `--no-timestamp` to see if there are any matching controls + - Check with `--debug` to see the actual filters being applied + - Verify your Turbot CLI credentials are working + +4. **Permission errors** + - Ensure your Turbot CLI is properly configured + - Check that you have access to the workspace + +### Debug Mode +Use `--debug` to see detailed information about the query being executed: +```bash +python3 turbot_error_report.py --debug --limit 1 +``` + +## Files + +- `turbot_error_report.py` - Main script +- `_turbot.py` - Turbot CLI integration module (do not modify) +- `requirements.txt` - Python dependencies +- `tests/` - Test suite directory + - `test_turbot.py` - Tests for _turbot.py module + - `test_turbot_error_report.py` - Tests for main script + - `conftest.py` - Test configuration and fixtures +- `README.md` - This documentation + +## Testing + +The script includes a comprehensive test suite with 36 unit tests covering all functionality: + +### Running Tests + +```bash +# Run all tests +pytest tests/ -v + +# Run with coverage report +pytest tests/ --cov=. --cov-report=term-missing -v + +# Run specific test file +pytest tests/test_turbot.py -v +``` + +### Test Coverage + +- **Overall Coverage**: 89% +- **Core Functionality**: 97% coverage +- **SSL Verification Feature**: 100% coverage +- **Test Categories**: SSL verification, argument parsing, filter building, output formatting, error handling, integration tests + +## Dependencies + +### Runtime Dependencies +- `requests>=2.25.0` - HTTP requests +- `PyYAML>=5.4.0` - YAML configuration parsing +- `xdg>=5.1.1` - XDG Base Directory specification support + +### Development Dependencies +- `pytest>=7.0.0` - Testing framework +- `pytest-mock>=3.10.0` - Enhanced mocking capabilities + +## License + +This script is provided as-is for Turbot Guardrails customers. Please ensure compliance with your organization's policies when using this tool. + +## Support + +For issues related to: +- **Script functionality**: Check this README and use `--debug` mode +- **Turbot CLI**: Refer to Turbot CLI documentation +- **API access**: Contact your Turbot administrator +- **Workspace configuration**: Check with your Turbot workspace administrator diff --git a/guardrails_utilities/python_utils/turbot_error_report/_turbot.py b/guardrails_utilities/python_utils/turbot_error_report/_turbot.py new file mode 100755 index 00000000..80919cdd --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/_turbot.py @@ -0,0 +1,215 @@ +import os +from base64 import b64encode +from xdg import XDG_CONFIG_HOME +import yaml +import requests +import traceback + +class Config: + """ Locates the users Turbot credentials, verifies connectivity to + the specified Turbot workspace and instantiates a config object. """ + + def __init__(self, custom_config_file, config_profile, debug=False, custom_credentials_file=None, insecure=False): + + config_dict = {} + config_fail = "" + # Use custom credentials file if provided, otherwise use default + if custom_credentials_file: + turbot_config = custom_credentials_file + else: + turbot_config = "{}/turbot/credentials.yml".format(XDG_CONFIG_HOME) + graphql_path = 'api/latest/graphql' + health_path = 'api/latest/turbot/health' + + # Option 1: Use custom yaml configuration file. + if custom_config_file: + with open(custom_config_file, 'r') as stream: + try: + config_dict = yaml.safe_load(stream) + except yaml.YAMLError as exc: + print(exc) + + # Option 2: Use environment variables + elif (os.getenv("TURBOT_ACCESS_KEY_ID") and os.getenv("TURBOT_SECRET_ACCESS_KEY")): + config_dict[config_profile] = {} + config_dict[config_profile]['accessKey'] = os.getenv( + "TURBOT_ACCESS_KEY_ID") + config_dict[config_profile]['secretKey'] = os.getenv( + "TURBOT_SECRET_ACCESS_KEY") + + # Option 2a: Use TURBOT_WORKSPACE environment variable + if os.getenv("TURBOT_WORKSPACE"): + config_dict[config_profile]['workspace'] = os.getenv( + "TURBOT_WORKSPACE") + + # Option 2b: Use TURBOT_GRAPHQL_ENDPOINT environment variable + elif os.getenv("TURBOT_GRAPHQL_ENDPOINT"): + if os.getenv("TURBOT_GRAPHQL_ENDPOINT").endswith(graphql_path): + config_dict[config_profile]['workspace'] = os.getenv( + "TURBOT_GRAPHQL_ENDPOINT")[:-len(graphql_path)] + else: + config_fail = "Incorrect TURBOT_GRAPHQL_ENDPOINT format, exiting..." + else: + config_fail = "No workspace found in environment vars, exiting..." + + # Option 3: Use Turbot xdg configuration file (e.g. ~/.config/turbot/configuration.yml) + elif os.path.exists(turbot_config): + with open(turbot_config, 'r') as stream: + try: + config_dict = yaml.safe_load(stream) + except yaml.YAMLError as exc: + print(exc) + + if len(config_fail): + print(config_fail) + exit() + + if config_dict: + + if not config_profile in config_dict: + config_fail = "No matching profile, exiting..." + + if not all(k in config_dict[config_profile] for k in ("workspace", "accessKey", "secretKey")): + config_fail = "Incorrect config file format, exiting..." + + if len(config_fail): + print(config_fail) + exit() + + self.workspace = config_dict[config_profile]['workspace'] + self.accessKey = config_dict[config_profile]['accessKey'] + self.secretKey = config_dict[config_profile]['secretKey'] + # Set and Test Endpoints + if self.workspace.endswith("/"): + self.graphql_endpoint = "{}{}".format( + self.workspace, graphql_path) + self.health_endpoint = "{}{}".format( + self.workspace, health_path) + else: + self.graphql_endpoint = "{}/{}".format( + self.workspace, graphql_path) + self.health_endpoint = "{}/{}".format( + self.workspace, health_path) + + # Set up SSL verification before health check + self.verify_ssl = not insecure + if insecure: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + if debug: + print("Testing connection to {} ...".format(self.workspace)) + self.test_health(debug) + auth_bytes = '{}:{}'.format( + self.accessKey, self.secretKey).encode("utf-8") + self.auth_token = b64encode(auth_bytes).decode() + + else: + print("Failed to find suitable configuration, please see README") + exit() + + # def test_health(self): + # try: + # r = requests.get(self.health_endpoint) + # except requests.exceptions.ConnectionError: + # print("Failed to connect, exiting.") + # exit() + + # if r.status_code == requests.codes.ok: + # print("Success! Status Code: {}".format(r.status_code)) + # else: + # print("Failure! Status Code: {}".format(r.status_code)) + # r.raise_for_status() + + def test_health(self, debug=False): + if debug: + print(f"Testing health endpoint: {self.health_endpoint}") + try: + r = requests.get(self.health_endpoint, timeout=5, verify=self.verify_ssl) + except requests.exceptions.ConnectionError as e: + print("Failed to connect to the endpoint.") + if debug: + print(f"Exception: {e}") + traceback.print_exc() + exit(1) + except requests.exceptions.Timeout: + print("Request timed out.") + exit(1) + except Exception as e: + print("An unexpected error occurred.") + if debug: + print(f"Exception: {e}") + traceback.print_exc() + exit(1) + + if debug: + print(f"Response received. Status Code: {r.status_code}") + print(f"Response Headers: {r.headers}") + print(f"Response Body: {r.text}") + + if r.status_code == requests.codes.ok: + if debug: + print("Health check succeeded.") + else: + print("Health check failed.") + r.raise_for_status() + + def graphql_query(self, query, variables=None, timeout=240): + """Execute a GraphQL query against the Turbot workspace + + Args: + query (str): The GraphQL query string + variables (dict): Optional variables for the query + timeout (int): Request timeout in seconds (default: 240) + + Returns: + dict: The JSON response from the GraphQL endpoint + + Raises: + requests.exceptions.RequestException: On network errors + SystemExit: On GraphQL errors + """ + headers = { + 'Authorization': f'Basic {self.auth_token}', + 'Content-Type': 'application/json' + } + + payload = { + 'query': query + } + + if variables: + payload['variables'] = variables + + try: + response = requests.post( + self.graphql_endpoint, + json=payload, + headers=headers, + timeout=timeout, + verify=self.verify_ssl + ) + response.raise_for_status() + + result = response.json() + + # Check for GraphQL errors + if "errors" in result: + print("GraphQL errors:") + for error in result['errors']: + print(f" {error}") + print(f"Query: {query[:200]}...") + print(f"Variables: {variables}") + exit() + + return result + + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}") + if hasattr(e, 'response') and e.response is not None: + try: + error_detail = e.response.json() + print(f"Error details: {error_detail}") + except: + print(f"Response text: {e.response.text[:500]}") + exit() \ No newline at end of file diff --git a/guardrails_utilities/python_utils/turbot_error_report/requirements.txt b/guardrails_utilities/python_utils/turbot_error_report/requirements.txt new file mode 100644 index 00000000..e593c267 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.25.0 +PyYAML>=5.4.0 +xdg>=5.1.1 +pytest>=7.0.0 +pytest-mock>=3.10.0 \ No newline at end of file diff --git a/guardrails_utilities/python_utils/turbot_error_report/tests/__init__.py b/guardrails_utilities/python_utils/turbot_error_report/tests/__init__.py new file mode 100644 index 00000000..ba4002a8 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/tests/__init__.py @@ -0,0 +1 @@ +# Test package for turbot_error_report diff --git a/guardrails_utilities/python_utils/turbot_error_report/tests/conftest.py b/guardrails_utilities/python_utils/turbot_error_report/tests/conftest.py new file mode 100644 index 00000000..3db36022 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/tests/conftest.py @@ -0,0 +1,99 @@ +""" +Test configuration and fixtures for turbot_error_report tests. +""" +import pytest +import sys +import os +from unittest.mock import Mock + +# Add the parent directory to the path so we can import our modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +@pytest.fixture +def mock_config_data(): + """Mock configuration data for testing.""" + return { + 'default': { + 'accessKey': 'test_access_key', + 'secretKey': 'test_secret_key', + 'workspace': 'https://test.turbot.com' + } + } + +@pytest.fixture +def mock_health_response(): + """Mock health check response.""" + response = Mock() + response.status_code = 200 + response.text = "OK" + return response + +@pytest.fixture +def mock_graphql_response(): + """Mock GraphQL response.""" + response = Mock() + response.status_code = 200 + response.json.return_value = { + "data": { + "controls": { + "items": [ + { + "state": "error", + "reason": "Test error", + "turbot": { + "id": "12345", + "stateChangeTimestamp": "2024-01-01T00:00:00Z" + }, + "type": { + "trunk": {"title": "Test Control"}, + "uri": "tmod:@turbot/test#/control/types/test" + }, + "resource": { + "trunk": {"title": "Test Resource"}, + "turbot": {"id": "67890"} + } + } + ], + "paging": {"next": None} + } + } + } + return response + +@pytest.fixture +def sample_controls_data(): + """Sample controls data for testing output formatting.""" + return [ + { + "state": "error", + "reason": "Test error reason", + "turbot": { + "id": "12345", + "stateChangeTimestamp": "2024-01-01T00:00:00Z" + }, + "type": { + "trunk": {"title": "AWS S3 Bucket Encryption"}, + "uri": "tmod:@turbot/aws-s3#/control/types/bucketEncryption" + }, + "resource": { + "trunk": {"title": "my-test-bucket"}, + "turbot": {"id": "67890"} + } + }, + { + "state": "alarm", + "reason": "Test alarm reason", + "turbot": { + "id": "54321", + "stateChangeTimestamp": "2024-01-01T01:00:00Z" + }, + "type": { + "trunk": {"title": "AWS EC2 Instance Public Access"}, + "uri": "tmod:@turbot/aws-ec2#/control/types/instancePublicAccess" + }, + "resource": { + "trunk": {"title": "i-1234567890abcdef0"}, + "turbot": {"id": "09876"} + } + } + ] diff --git a/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot.py b/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot.py new file mode 100644 index 00000000..1d2b1880 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot.py @@ -0,0 +1,230 @@ +""" +Tests for _turbot.py Config class. +""" +import pytest +from unittest.mock import Mock, patch, mock_open +import requests +from _turbot import Config + + +def mock_config_with_env_vars(mock_getenv, mock_get): + """Helper function to mock environment variables and requests.""" + # Mock environment variables for configuration + mock_getenv.side_effect = lambda key: { + 'TURBOT_ACCESS_KEY_ID': 'test_key', + 'TURBOT_SECRET_ACCESS_KEY': 'test_secret', + 'TURBOT_WORKSPACE': 'https://test.turbot.com' + }.get(key) + + # Mock successful health check + mock_get.return_value.status_code = 200 + mock_get.return_value.text = "OK" + + +class TestConfigSSL: + """Test SSL verification functionality in Config class.""" + + @patch('os.getenv') + @patch('requests.get') + def test_ssl_verification_enabled_by_default(self, mock_get, mock_getenv): + """Test that SSL verification is enabled by default.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default") + assert config.verify_ssl == True + + @patch('os.getenv') + @patch('requests.get') + def test_ssl_verification_disabled_with_insecure_flag(self, mock_get, mock_getenv): + """Test that SSL verification is disabled when insecure=True.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default", insecure=True) + assert config.verify_ssl == False + + @patch('os.getenv') + @patch('requests.get') + def test_health_check_uses_ssl_setting_secure(self, mock_get, mock_getenv): + """Test that health check uses verify=True when SSL is enabled.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default", insecure=False) + config.test_health() + + mock_get.assert_called_with( + config.health_endpoint, + timeout=5, + verify=True + ) + + @patch('os.getenv') + @patch('requests.get') + def test_health_check_uses_ssl_setting_insecure(self, mock_get, mock_getenv): + """Test that health check uses verify=False when SSL is disabled.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default", insecure=True) + config.test_health() + + mock_get.assert_called_with( + config.health_endpoint, + timeout=5, + verify=False + ) + + @patch('os.getenv') + @patch('requests.post') + @patch('requests.get') + def test_graphql_query_uses_ssl_setting_secure(self, mock_get, mock_post, mock_getenv): + """Test that GraphQL query uses verify=True when SSL is enabled.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"data": {"test": "value"}} + + config = Config(None, "default", insecure=False) + config.graphql_query("query { test }") + + mock_post.assert_called_with( + config.graphql_endpoint, + json={'query': 'query { test }'}, + headers={ + 'Authorization': f'Basic {config.auth_token}', + 'Content-Type': 'application/json' + }, + timeout=240, + verify=True + ) + + @patch('os.getenv') + @patch('requests.post') + @patch('requests.get') + def test_graphql_query_uses_ssl_setting_insecure(self, mock_get, mock_post, mock_getenv): + """Test that GraphQL query uses verify=False when SSL is disabled.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = {"data": {"test": "value"}} + + config = Config(None, "default", insecure=True) + config.graphql_query("query { test }") + + mock_post.assert_called_with( + config.graphql_endpoint, + json={'query': 'query { test }'}, + headers={ + 'Authorization': f'Basic {config.auth_token}', + 'Content-Type': 'application/json' + }, + timeout=240, + verify=False + ) + + @patch('os.getenv') + @patch('urllib3.disable_warnings') + @patch('requests.get') + def test_urllib3_warnings_disabled_when_insecure(self, mock_get, mock_disable_warnings, mock_getenv): + """Test that urllib3 warnings are disabled when insecure=True.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + Config(None, "default", insecure=True) + + mock_disable_warnings.assert_called_once() + + @patch('os.getenv') + @patch('urllib3.disable_warnings') + @patch('requests.get') + def test_urllib3_warnings_not_disabled_when_secure(self, mock_get, mock_disable_warnings, mock_getenv): + """Test that urllib3 warnings are NOT disabled when insecure=False.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + Config(None, "default", insecure=False) + + mock_disable_warnings.assert_not_called() + + +class TestConfigInitialization: + """Test Config class initialization with different parameters.""" + + @patch('os.getenv') + @patch('requests.get') + def test_config_with_debug_mode(self, mock_get, mock_getenv): + """Test Config initialization with debug mode.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default", debug=True) + assert config is not None + + @patch('os.getenv') + @patch('requests.get') + def test_config_with_custom_credentials_file(self, mock_get, mock_getenv): + """Test Config initialization with custom credentials file.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config(None, "default", custom_credentials_file="/custom/path") + assert config is not None + + @patch('os.getenv') + @patch('requests.get') + def test_config_with_all_parameters(self, mock_get, mock_getenv): + """Test Config initialization with all parameters.""" + mock_config_with_env_vars(mock_getenv, mock_get) + + config = Config( + custom_config_file=None, + config_profile="default", + debug=True, + custom_credentials_file="/custom/path", + insecure=True + ) + assert config.verify_ssl == False + + +class TestConfigErrorHandling: + """Test Config class error handling.""" + + @patch('os.getenv') + @patch('requests.get') + def test_health_check_connection_error(self, mock_get, mock_getenv): + """Test health check with connection error.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_get.side_effect = requests.exceptions.ConnectionError("Connection failed") + + with pytest.raises(SystemExit): + Config(None, "default") + + @patch('os.getenv') + @patch('requests.get') + def test_health_check_timeout(self, mock_get, mock_getenv): + """Test health check with timeout.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_get.side_effect = requests.exceptions.Timeout("Request timed out") + + with pytest.raises(SystemExit): + Config(None, "default") + + @patch('os.getenv') + @patch('requests.get') + def test_health_check_http_error(self, mock_get, mock_getenv): + """Test health check with HTTP error.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_get.return_value.status_code = 500 + mock_get.return_value.text = "Internal Server Error" + + # HTTP errors don't cause exit, just print message + config = Config(None, "default") + assert config is not None + + @patch('os.getenv') + @patch('requests.post') + @patch('requests.get') + def test_graphql_query_error(self, mock_get, mock_post, mock_getenv): + """Test GraphQL query with error response.""" + mock_config_with_env_vars(mock_getenv, mock_get) + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "errors": [{"message": "GraphQL error"}] + } + + config = Config(None, "default") + + with pytest.raises(SystemExit): + config.graphql_query("query { test }") \ No newline at end of file diff --git a/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot_error_report.py b/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot_error_report.py new file mode 100644 index 00000000..15bd9ea9 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/tests/test_turbot_error_report.py @@ -0,0 +1,362 @@ +""" +Tests for turbot_error_report.py main script. +""" +import pytest +import sys +from unittest.mock import patch, Mock +from turbot_error_report import parse_arguments, build_filters, format_output, validate_states + + +class TestStateValidation: + """Test state validation functionality.""" + + def test_validate_states_single_valid(self): + """Test validation with single valid state.""" + result = validate_states("error") + assert result == "error" + + def test_validate_states_multiple_valid(self): + """Test validation with multiple valid states.""" + result = validate_states("error,alarm") + assert result == "error,alarm" + + def test_validate_states_all_valid(self): + """Test validation with all valid states.""" + result = validate_states("error,alarm,ok,skipped,tbd") + assert result == "error,alarm,ok,skipped,tbd" + + def test_validate_states_with_spaces(self): + """Test validation with spaces around states.""" + result = validate_states(" error , alarm ") + assert result == " error , alarm " + + def test_validate_states_invalid_uppercase(self, capsys): + """Test validation rejects uppercase states.""" + with pytest.raises(SystemExit) as exc_info: + validate_states("ALARM") + assert exc_info.value.code == 1 + + captured = capsys.readouterr() + assert "Error: Invalid state(s): ALARM" in captured.out + assert "Valid states are: alarm, error, ok, skipped, tbd" in captured.out + assert "For full help: python3 turbot_error_report.py -h" in captured.out + + def test_validate_states_invalid_value(self, capsys): + """Test validation rejects invalid state values.""" + with pytest.raises(SystemExit) as exc_info: + validate_states("invalid") + assert exc_info.value.code == 1 + + captured = capsys.readouterr() + assert "Error: Invalid state(s): invalid" in captured.out + assert "Valid states are: alarm, error, ok, skipped, tbd" in captured.out + + def test_validate_states_mixed_valid_invalid(self, capsys): + """Test validation with mix of valid and invalid states.""" + with pytest.raises(SystemExit) as exc_info: + validate_states("error,INVALID,alarm") + assert exc_info.value.code == 1 + + captured = capsys.readouterr() + assert "Error: Invalid state(s): INVALID" in captured.out + + +class TestArgumentParsing: + """Test command line argument parsing.""" + + def test_insecure_flag_default_false(self): + """Test that --insecure flag defaults to False.""" + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + assert args.insecure == False + + def test_insecure_flag_short_form(self): + """Test that -i flag sets insecure=True.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '-i']): + args = parse_arguments() + assert args.insecure == True + + def test_insecure_flag_long_form(self): + """Test that --insecure flag sets insecure=True.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--insecure']): + args = parse_arguments() + assert args.insecure == True + + def test_insecure_flag_with_other_options(self): + """Test that --insecure works with other options.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--insecure', '--debug', '--limit', '5']): + args = parse_arguments() + assert args.insecure == True + assert args.debug == True + assert args.limit == 5 + + def test_all_arguments_defaults(self): + """Test all argument defaults.""" + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + assert args.hours == 24 + assert args.states == "error,alarm" + assert args.resource_type is None + assert args.output == "text" + assert args.quiet == False + assert args.no_timestamp == False + assert args.limit is None + assert args.debug == False + assert args.insecure == False + + def test_all_arguments_custom_values(self): + """Test all arguments with custom values.""" + with patch.object(sys, 'argv', [ + 'turbot_error_report.py', + '--hours', '48', + '--states', 'error', + '--resource-type', 's3', + '--output', 'json', + '--quiet', + '--no-timestamp', + '--limit', '10', + '--debug', + '--insecure' + ]): + args = parse_arguments() + assert args.hours == 48 + assert args.states == "error" + assert args.resource_type == "s3" + assert args.output == "json" + assert args.quiet == True + assert args.no_timestamp == True + assert args.limit == 10 + assert args.debug == True + assert args.insecure == True + + +class TestFilterBuilding: + """Test filter building logic.""" + + def test_build_filters_default(self): + """Test building filters with default arguments.""" + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error,alarm", + "stateChangeTimestamp:>=T-24h" + ] + assert filters == expected + + def test_build_filters_custom_states(self): + """Test building filters with custom states.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--states', 'error,ok']): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error,ok", + "stateChangeTimestamp:>=T-24h" + ] + assert filters == expected + + def test_build_filters_custom_hours(self): + """Test building filters with custom hours.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--hours', '48']): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error,alarm", + "stateChangeTimestamp:>=T-48h" + ] + assert filters == expected + + def test_build_filters_no_timestamp(self): + """Test building filters without timestamp.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--no-timestamp']): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error,alarm" + ] + assert filters == expected + + def test_build_filters_resource_type_invalid_short_form(self, capsys): + """Test building filters rejects short form resource type.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--resource-type', 's3']): + args = parse_arguments() + with pytest.raises(SystemExit) as exc_info: + build_filters(args) + assert exc_info.value.code == 1 + + captured = capsys.readouterr() + assert "Error: Resource type must be a full URI starting with 'tmod:'" in captured.out + assert "Example: tmod:@turbot/aws-s3#/resource/types/bucket" in captured.out + assert "For full help: python3 turbot_error_report.py -h" in captured.out + + def test_build_filters_resource_type_full_uri(self): + """Test building filters with full URI resource type.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--resource-type', 'tmod:@turbot/aws-s3#/resource/types/bucket']): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error,alarm", + "stateChangeTimestamp:>=T-24h", + "resourceTypeId:tmod:@turbot/aws-s3#/resource/types/bucket" + ] + assert filters == expected + + def test_build_filters_all_options(self): + """Test building filters with all options.""" + with patch.object(sys, 'argv', [ + 'turbot_error_report.py', + '--states', 'error', + '--hours', '12', + '--resource-type', 'tmod:@turbot/aws-ec2#/resource/types/instance', + '--no-timestamp' + ]): + args = parse_arguments() + filters = build_filters(args) + + expected = [ + "state:error", + "resourceTypeId:tmod:@turbot/aws-ec2#/resource/types/instance" + ] + assert filters == expected + + +class TestOutputFormatting: + """Test output formatting functionality.""" + + def test_format_output_quiet_mode(self, sample_controls_data): + """Test output formatting in quiet mode.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--quiet']): + args = parse_arguments() + result = format_output(sample_controls_data, args) + assert result == "Total: 2" + + def test_format_output_text_mode(self, sample_controls_data): + """Test output formatting in text mode.""" + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + result = format_output(sample_controls_data, args) + + assert "Total: 2" in result + assert "AWS S3 Bucket Encryption" in result + assert "my-test-bucket" in result + assert "error" in result + assert "alarm" in result + + def test_format_output_json_mode(self, sample_controls_data): + """Test output formatting in JSON mode.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--output', 'json']): + args = parse_arguments() + result = format_output(sample_controls_data, args) + + import json + parsed = json.loads(result) + assert len(parsed) == 2 + assert parsed[0]["state"] == "error" + assert parsed[1]["state"] == "alarm" + + def test_format_output_csv_mode(self, sample_controls_data): + """Test output formatting in CSV mode.""" + with patch.object(sys, 'argv', ['turbot_error_report.py', '--output', 'csv']): + args = parse_arguments() + result = format_output(sample_controls_data, args) + + lines = result.strip().split('\n') + assert len(lines) == 3 # Header + 2 data rows + assert "Timestamp,Control Type,Resource,Resource ID,State,Reason" in lines[0] + assert "error" in lines[1] + assert "alarm" in lines[2] + + def test_format_output_empty_data(self): + """Test output formatting with empty data.""" + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + result = format_output([], args) + assert result == "\nTotal: 0" + + def test_format_output_missing_reason(self): + """Test output formatting with missing reason field.""" + data_without_reason = [ + { + "state": "error", + "turbot": { + "id": "12345", + "stateChangeTimestamp": "2024-01-01T00:00:00Z" + }, + "type": { + "trunk": {"title": "Test Control"} + }, + "resource": { + "trunk": {"title": "Test Resource"}, + "turbot": {"id": "67890"} + } + } + ] + + with patch.object(sys, 'argv', ['turbot_error_report.py']): + args = parse_arguments() + result = format_output(data_without_reason, args) + + assert "Test Control" in result + assert "Test Resource" in result + assert "error" in result + + +class TestIntegration: + """Integration tests for the main script.""" + + @patch('turbot_error_report.Config') + def test_main_function_basic_flow(self, mock_config_class, sample_controls_data): + """Test main function with basic flow.""" + # Mock Config class + mock_config = Mock() + mock_config.graphql_query.return_value = { + "data": { + "controls": { + "items": sample_controls_data, + "paging": {"next": None} + } + } + } + mock_config_class.return_value = mock_config + + with patch.object(sys, 'argv', ['turbot_error_report.py', '--limit', '1']): + with patch('turbot_error_report.print') as mock_print: + from turbot_error_report import main + main() + + # Verify Config was initialized with correct parameters + mock_config_class.assert_called_once_with(None, "default", debug=False, insecure=False) + + # Verify GraphQL query was called + mock_config.graphql_query.assert_called_once() + + # Verify output was printed + mock_print.assert_called_once() + + @patch('turbot_error_report.Config') + def test_main_function_with_insecure_flag(self, mock_config_class, sample_controls_data): + """Test main function with insecure flag.""" + # Mock Config class + mock_config = Mock() + mock_config.graphql_query.return_value = { + "data": { + "controls": { + "items": sample_controls_data, + "paging": {"next": None} + } + } + } + mock_config_class.return_value = mock_config + + with patch.object(sys, 'argv', ['turbot_error_report.py', '--insecure', '--debug']): + with patch('turbot_error_report.print') as mock_print: + from turbot_error_report import main + main() + + # Verify Config was initialized with insecure=True + mock_config_class.assert_called_once_with(None, "default", debug=True, insecure=True) diff --git a/guardrails_utilities/python_utils/turbot_error_report/turbot_error_report.py b/guardrails_utilities/python_utils/turbot_error_report/turbot_error_report.py new file mode 100755 index 00000000..9e2379d8 --- /dev/null +++ b/guardrails_utilities/python_utils/turbot_error_report/turbot_error_report.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +import datetime as dt +import argparse +import sys +from _turbot import Config + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Report Turbot Guardrails errors and alarms", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s # Default: errors/alarms in last 24h + %(prog)s --hours 48 # Last 48 hours + %(prog)s --states error # Single state (from error, alarm, ok, skipped, tbd) + %(prog)s --states error,alarm # Multiple states (comma-separated, default) + %(prog)s --resource-type "tmod:@turbot/aws-s3#/resource/types/bucket" # S3 bucket resources + %(prog)s --resource-type "tmod:@turbot/aws-ec2#/resource/types/instance" # EC2 instance resources + %(prog)s --output json # JSON output format + %(prog)s --quiet # Only show count + %(prog)s --no-timestamp # Don't filter by time + %(prog)s --insecure # Disable SSL certificate verification + """ + ) + + # Override the error handler to add help instruction + def error_handler(message): + print(f"Error: {message}") + print("For full help: python3 turbot_error_report.py -h") + sys.exit(2) + + parser.error = error_handler + + parser.add_argument( + "--hours", + type=int, + default=24, + help="Time window in hours (default: 24)" + ) + + parser.add_argument( + "--states", "-s", + default="error,alarm", + help="Control states to include: error, alarm, ok, skipped, tbd (default: error,alarm)" + ) + + parser.add_argument( + "--resource-type", "-r", + help="Filter by resource type. Use full URI format (e.g., tmod:@turbot/aws-s3#/resource/types/bucket)" + ) + + # Note: Cloud provider filtering not available in controls API + # parser.add_argument( + # "--cloud-provider", "-c", + # choices=["aws", "azure", "gcp"], + # help="Filter by cloud provider (aws, azure, gcp)" + # ) + + parser.add_argument( + "--output", "-o", + choices=["text", "json", "csv"], + default="text", + help="Output format (default: text)" + ) + + parser.add_argument( + "--quiet", "-q", + action="store_true", + help="Only show count, no details" + ) + + parser.add_argument( + "--no-timestamp", + action="store_true", + help="Don't filter by timestamp (show all matching states)" + ) + + parser.add_argument( + "--limit", "-l", + type=int, + help="Limit number of results (for testing)" + ) + + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug output" + ) + + parser.add_argument( + "--insecure", "-i", + action="store_true", + help="Disable SSL certificate verification" + ) + + return parser.parse_args() + +def validate_states(states_str): + """Validate state values and provide helpful error message.""" + valid_states = {"error", "alarm", "ok", "skipped", "tbd"} + states = [s.strip() for s in states_str.split(",")] + + # Check for invalid states (case-sensitive) + invalid_states = [s for s in states if s not in valid_states] + if invalid_states: + print(f"Error: Invalid state(s): {', '.join(invalid_states)}") + print(f"Valid states are: {', '.join(sorted(valid_states))}") + print("Use comma-separated values like: error,alarm") + print("For full help: python3 turbot_error_report.py -h") + sys.exit(1) + + return states_str + +def build_filters(args): + """Build filter list based on command line arguments.""" + filters = [] + + # State filter + if args.states: + validated_states = validate_states(args.states) + filters.append(f"state:{validated_states}") + + # Timestamp filter + if not args.no_timestamp: + filters.append(f"stateChangeTimestamp:>=T-{args.hours}h") + + # Resource type filter (full URI only) + if args.resource_type: + if not args.resource_type.startswith("tmod:"): + print(f"Error: Resource type must be a full URI starting with 'tmod:'") + print(f"Example: tmod:@turbot/aws-s3#/resource/types/bucket") + print("For full help: python3 turbot_error_report.py -h") + sys.exit(1) + filters.append(f"resourceTypeId:{args.resource_type}") + + # Note: Cloud provider filtering not available in controls API + # if args.cloud_provider: + # provider_title = args.cloud_provider.upper() + # filters.append(f"typeTitle:{provider_title} > *") + + return filters + +def format_output(controls, args): + """Format output based on requested format.""" + if args.quiet: + return f"Total: {len(controls)}" + + if args.output == "json": + import json + return json.dumps(controls, indent=2) + + elif args.output == "csv": + import csv + import io + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["Timestamp", "Control Type", "Resource", "Resource ID", "State", "Reason"]) + for c in controls: + writer.writerow([ + c['turbot']['stateChangeTimestamp'], + c['type']['trunk']['title'], + c['resource']['trunk']['title'], + c['resource']['turbot']['id'], + c['state'], + c.get('reason', '') + ]) + return output.getvalue() + + else: # text format + lines = [] + for c in controls: + lines.append( + f"[{c['turbot']['stateChangeTimestamp']}] " + f"{c['type']['trunk']['title']} | Resource: {c['resource']['trunk']['title']} " + f"(rid:{c['resource']['turbot']['id']}) | State: {c['state']} | Reason: {c.get('reason')}" + ) + lines.append(f"\nTotal: {len(controls)}") + return "\n".join(lines) + +def main(): + """Main function.""" + args = parse_arguments() + + if args.debug: + print(f"Debug: Arguments: {args}", file=sys.stderr) + + # Build filters + filters = build_filters(args) + if args.debug: + print(f"Debug: Filters: {filters}", file=sys.stderr) + + # GraphQL query + query = """ + query Controls($filter: [String!], $paging: String) { + controls(filter: $filter, paging: $paging) { + items { + state + reason + turbot { + id + stateChangeTimestamp + } + type { + trunk { title } + uri + } + resource { + trunk { title } + turbot { id } + } + } + paging { + next + } + } + } + """ + + variables = { + "filter": filters, + "paging": None + } + + # Initialize Turbot configuration + config = Config(None, "default", debug=args.debug, insecure=args.insecure) + + def run_query(q, v): + result = config.graphql_query(q, v) + return result["data"] + + # Fetch all results + all_controls = [] + count = 0 + + while True: + data = run_query(query, variables) + page = data["controls"] + all_controls.extend(page["items"]) + count += len(page["items"]) + + if args.debug: + print(f"Debug: Fetched {len(page['items'])} controls, total: {count}", file=sys.stderr) + + # Check limit + if args.limit and count >= args.limit: + all_controls = all_controls[:args.limit] + break + + # Check pagination + if not page.get("paging", {}).get("next"): + break + variables["paging"] = page["paging"]["next"] + + # Output results + print(format_output(all_controls, args)) + +if __name__ == "__main__": + main() diff --git a/policy_packs/aws/guardrails/enforce_account_organization_name_display/README.md b/policy_packs/aws/guardrails/enforce_account_organization_name_display/README.md new file mode 100644 index 00000000..5497dd2b --- /dev/null +++ b/policy_packs/aws/guardrails/enforce_account_organization_name_display/README.md @@ -0,0 +1,163 @@ +--- +categories: ["compliance"] +primary_category: "compliance" +--- + +# Enforce AWS Account Organization Name Display + +Automatically set AWS account aliases using organization account names for accounts that are part of an AWS organization. This policy pack enables a more user-friendly navigation experience by setting account aliases that will be displayed in breadcrumbs instead of numerical account IDs. + +This [policy pack](https://turbot.com/guardrails/docs/concepts/policy-packs) can help you configure the following settings for AWS accounts: + +- Automatically set account aliases using AWS organization account names +- Use Stack Native policies to manage account aliases as infrastructure +- Ensure account aliases are displayed in breadcrumbs instead of account numbers + +**[Review policy settings →](https://hub.guardrails.turbot.com/policy-packs/aws_guardrails_enforce_account_organization_name_display/settings)** + +## Getting Started + +### Requirements + +- [Terraform](https://developer.hashicorp.com/terraform/install) +- Guardrails mods: + - [@turbot/aws](https://hub.guardrails.turbot.com/mods/aws/mods/aws) + +### Credentials + +To create a policy pack through Terraform: + +- Ensure you have `Turbot/Admin` permissions (or higher) in Guardrails +- [Create access keys](https://turbot.com/guardrails/docs/guides/iam/access-keys#generate-a-new-guardrails-api-access-key) in Guardrails + +And then set your credentials: + +```sh +export TURBOT_WORKSPACE=myworkspace.acme.com +export TURBOT_ACCESS_KEY=acce6ac5-access-key-here +export TURBOT_SECRET_KEY=a8af61ec-secret-key-here +``` + +Please see [Turbot Guardrails Provider authentication](https://registry.terraform.io/providers/turbot/turbot/latest/docs#authentication) for additional authentication methods. + +## Usage + +### Install Policy Pack + +> [!NOTE] +> By default, installed policy packs are not attached to any resources. +> +> Policy packs must be attached to resources in order for their policy settings to take effect. + +Clone: + +```sh +git clone https://github.com/turbot/guardrails-samples.git +cd guardrails-samples/policy_packs/aws/guardrails/enforce_account_organization_name_display +``` + +Run the Terraform to create the policy pack in your workspace: + +```sh +terraform init +terraform plan +``` + +Then apply the changes: + +```sh +terraform apply +``` + +### Apply Policy Pack + +Log into your Guardrails workspace and [attach the policy pack to a resource](https://turbot.com/guardrails/docs/guides/policy-packs#attach-a-policy-pack-to-a-resource). + +If this policy pack is attached to a Guardrails folder, its policies will be applied to all accounts and resources in that folder. The policy pack can also be attached to multiple resources. + +For more information, please see [Policy Packs](https://turbot.com/guardrails/docs/concepts/policy-packs). + +### Enable Enforcement + +> [!TIP] +> You can also update the policy settings in this policy pack directly in the Guardrails console. +> +> Please note your Terraform state file will then become out of sync and the policy settings should then only be managed in the console. + +By default, the policies are set to `Check: Configured` in the pack's policy settings. To enable automated enforcement, you can switch the policy setting: + +```hcl +resource "turbot_policy_setting" "aws_account_stack_native" { + resource = turbot_policy_pack.main.id + type = "tmod:@turbot/aws#/policy/types/accountStackNative" + # value = "Check: Configured" + value = "Enforce: Configured" +} +``` + +Then re-apply the changes: + +```sh +terraform plan +terraform apply +``` + +## How It Works + +This policy pack uses **Stack Native** policies to automatically manage AWS account aliases with a simple priority system: + +1. **Account has alias**: Do nothing (existing alias takes precedence) +2. **Account has organization name only**: Create alias from sanitized organization name +3. **Account has neither**: Do nothing (account number displays) + +The policy accesses account data via `$.account.Name` (organization name) and `$.account.AccountAlias` (existing alias). + +### Priority Logic + +**Existing account aliases always take precedence** because they were explicitly set by users. The policy will only create new aliases when no alias exists and an organization name is available. + +### Example + +For an account with the following properties: +- Account ID: `199816167099` +- Organization Name: `iog-prod-ict-cloud-shared-services` + +The policy will: +1. **Sanitize** the name: `iog-prod-ict-cloud-shared-services` (already compliant) +2. **Create** an `aws_iam_account_alias` resource in the account +3. **Set** the account alias to `iog-prod-ict-cloud-shared-services` +4. **Display** the alias in breadcrumbs instead of the account number + +**Breadcrumb Display**: +- **Before**: `Turbot > AWS > o-p6f4pvkbm0 > r-bk5e > 199816167099` +- **After**: `Turbot > AWS > o-p6f4pvkbm0 > r-bk5e > iog-prod-ict-cloud-shared-services` + +### Name Sanitization Examples + +The policy uses a robust sanitization algorithm that converts organization names to AWS-compliant aliases: + +| Original Organization Name | Sanitized Account Alias | +|----------------------------|-------------------------| +| `iog-prod-ict-cloud-shared-services` | `iog-prod-ict-cloud-shared-services` | +| `My Company (Production)` | `my-company-production` | +| `Dev_Environment@2024` | `dev-environment-2024` | +| `Test.Account.Name` | `test-account-name` | +| `Very Long Organization Name That Exceeds AWS Limits` | `very-long-organization-name-that-exceeds-aws-limits` | +| `XY` | No alias created (account number displays) | +| `A` | No alias created (account number displays) | + +**Sanitization Rules:** +- Converts to lowercase +- Keeps only alphanumeric characters and hyphens +- Removes consecutive hyphens +- Removes leading/trailing hyphens +- Truncates to 63 characters (AWS limit) +- Only creates alias if 3-63 characters (otherwise account number displays) + +## Prerequisites + +- AWS accounts must be imported into Guardrails +- For organization names to be available, accounts must be part of an AWS organization +- The Guardrails service role must have permissions to: + - Read AWS Organizations data + - Create/update IAM account aliases (`iam:CreateAccountAlias`, `iam:DeleteAccountAlias`, `iam:ListAccountAliases`) diff --git a/policy_packs/aws/guardrails/enforce_account_organization_name_display/main.tf b/policy_packs/aws/guardrails/enforce_account_organization_name_display/main.tf new file mode 100644 index 00000000..84b2c5aa --- /dev/null +++ b/policy_packs/aws/guardrails/enforce_account_organization_name_display/main.tf @@ -0,0 +1,5 @@ +resource "turbot_policy_pack" "main" { + title = "Enforce AWS Account Organization Name Display" + description = "Automatically set AWS account aliases using organization account names for accounts that are part of an AWS organization." + akas = ["aws_guardrails_enforce_account_organization_name_display"] +} diff --git a/policy_packs/aws/guardrails/enforce_account_organization_name_display/policies.tf b/policy_packs/aws/guardrails/enforce_account_organization_name_display/policies.tf new file mode 100644 index 00000000..d0b1f645 --- /dev/null +++ b/policy_packs/aws/guardrails/enforce_account_organization_name_display/policies.tf @@ -0,0 +1,59 @@ +# AWS > Account > Stack Native +resource "turbot_policy_setting" "aws_account_stack_native" { + resource = turbot_policy_pack.main.id + type = "tmod:@turbot/aws#/policy/types/accountStackNative" + value = "Check: Configured" + # value = "Enforce: Configured" +} + + +# AWS > Account > Stack Native > Source +resource "turbot_policy_setting" "aws_account_stack_native_source" { + resource = turbot_policy_pack.main.id + type = "tmod:@turbot/aws#/policy/types/accountStackNativeSource" + template_input = <<-EOT +- | + { + account { + name: get(path: "Name") + accountAlias: get(path: "AccountAlias") + } + } +EOT + template = <<-EOT +{%- set org_name = $.account.name -%} +{%- set existing_alias = $.account.accountAlias -%} + +{%- if existing_alias -%} + {# account already has an alias → do nothing #} + +{%- elif org_name -%} + {# --- sanitize org_name into an AWS-valid alias (lowercase, [a-z0-9-], no edge/dup '-') --- #} + {%- set s = (org_name | lower) -%} + {%- set out = '' -%} + {%- set just_dashed = false -%} + {%- for i in range(0, s.length) -%} + {%- set c = s[i] -%} + {%- if ('a' <= c and c <= 'z') or ('0' <= c and c <= '9') -%} + {%- set out = out ~ c -%} + {%- set just_dashed = false -%} + {%- elif out and not just_dashed -%} + {%- set out = out ~ '-' -%} + {%- set just_dashed = true -%} + {%- endif -%} + {%- endfor -%} + {%- if out and out[out.length - 1] == '-' -%} + {%- set out = out | truncate(out.length - 1, true, '') -%} + {%- endif -%} + {%- set alias = out | truncate(63, true, '') -%} + {# Only create alias if it meets AWS requirements (3-63 chars) #} + {%- if (alias | length) >= 3 -%} + +| +resource "aws_iam_account_alias" "alias" { + account_alias = "{{ alias }}" +} + {%- endif -%} +{%- endif -%} + EOT +} diff --git a/policy_packs/aws/guardrails/enforce_account_organization_name_display/providers.tf b/policy_packs/aws/guardrails/enforce_account_organization_name_display/providers.tf new file mode 100644 index 00000000..3ede1821 --- /dev/null +++ b/policy_packs/aws/guardrails/enforce_account_organization_name_display/providers.tf @@ -0,0 +1,11 @@ +terraform { + required_providers { + turbot = { + source = "turbot/turbot" + version = ">= 1.11.0" + } + } +} + +provider "turbot" { +}